Repository: sciencecorp/synapse-python
Pull request #100: [feature] Allow users to search by name or ip
Author: login: gilbert-sci
Created: 2025-04-16T17:52:56Z | Merged: 2025-04-17T19:13:14Z

# Summary
Users have requested that it would be nice to just search by the device name, not only the IP. This removes the uri positional arguments for the CLI and adds the option of --uri with either <ip> or <device name>

# Usage
```
python3 synapse/cli/__main__.py --uri <device_name/device_ip> info
```


Diff:
diff --git a/README.md b/README.md
index 9469d181..7640b072 100644
--- a/README.md
+++ b/README.md
@@ -5,19 +5,19 @@ This repo contains the Python client for the [Synapse API](https://science.xyz/t
 Includes `synapsectl` command line utility:
 
     % synapsectl --help
-    usage: synapsectl [-h] [--version] [--uri -u]
-                    {discover,info,query,start,stop,configure,logs,read,plot} ...
+    usage: synapsectl [-h] [--uri URI] [--version] [--verbose]
+                    {discover,info,query,start,stop,configure,logs,read,plot,file} ...
 
     Synapse Device Manager
 
     options:
     -h, --help            show this help message and exit
+    --uri URI             The device identifier to connect to. Can either be the IP address or name
     --version             show program's version number and exit
     --verbose, -v         Enable verbose output
-    --uri -u              Device control plane URI
 
     Commands:
-    {discover,info,query,start,stop,configure,logs,read,plot}
+    {discover,info,query,start,stop,configure,logs,read,plot,file}
         discover            Discover Synapse devices on the network
         info                Get device information
         query               Execute a query on the device
@@ -27,7 +27,7 @@ Includes `synapsectl` command line utility:
         logs                Get logs from the device
         read                Read from a device's StreamOut node
         plot                Plot recorded synapse data
-
+        file                File commands
 
 As well as the base for a device implementation (`synapse/server`),
 
diff --git a/synapse/cli/__main__.py b/synapse/cli/__main__.py
index 713034cb..61e48703 100755
--- a/synapse/cli/__main__.py
+++ b/synapse/cli/__main__.py
@@ -1,19 +1,50 @@
 #!/usr/bin/env python
 import argparse
 import logging
+import ipaddress
+
 from importlib import metadata
 from synapse.cli import discover, rpc, streaming, offline_plot, files
 from rich.logging import RichHandler
+from rich.console import Console
+from synapse.utils.discover import find_device_by_name
+
+
+def is_valid_ip(input_str):
+    try:
+        ipaddress.ip_address(input_str)
+        return True
+    except ValueError:
+        return False
+
+
+def setup_device_uri(args):
+    if not args.uri:
+        # User doesn't want to use something that needs a uri
+        return args
+    if not is_valid_ip(args.uri):
+        # User passed in a name
+        console = Console()
+        device_ip = find_device_by_name(args.uri, console)
+        if not device_ip:
+            return None
+        args.uri = device_ip
+    return args
 
 
 def main():
     logging.basicConfig(level=logging.INFO, handlers=[RichHandler()])
-
     parser = argparse.ArgumentParser(
         description="Synapse Device Manager",
         formatter_class=lambda prog: argparse.HelpFormatter(prog, width=124),
     )
-
+    parser.add_argument(
+        "--uri",
+        "-u",
+        help="The device identifier to connect to. Can either be the IP address or name",
+        type=str,
+        default=None,
+    )
     parser.add_argument(
         "--version",
         action="version",
@@ -26,12 +57,7 @@ def main():
         default=False,
         help="Enable verbose output",
     )
-    parser.add_argument(
-        "--uri", metavar="-u", type=str, default=None, help="Device control plane URI"
-    )
-
     subparsers = parser.add_subparsers(title="Commands")
-
     discover.add_commands(subparsers)
     rpc.add_commands(subparsers)
     streaming.add_commands(subparsers)
@@ -39,6 +65,11 @@ def main():
     files.add_commands(subparsers)
     args = parser.parse_args()
 
+    # If we need to setup the device URI, do that now
+    args = setup_device_uri(args)
+    if not args:
+        return
+
     if hasattr(args, "func"):
         args.func(args)
     else:
diff --git a/synapse/cli/files.py b/synapse/cli/files.py
index 5781e3e6..ab3844cc 100644
--- a/synapse/cli/files.py
+++ b/synapse/cli/files.py
@@ -48,7 +48,6 @@ def add_commands(subparsers: argparse._SubParsersAction):
     a: argparse.ArgumentParser = file_subparsers.add_parser(
         "ls", help="List files on device"
     )
-    a.add_argument("uri", type=str)
     a.add_argument(
         "path", type=str, nargs="?", default="/", help="Path to list files from"
     )
@@ -58,7 +57,6 @@ def add_commands(subparsers: argparse._SubParsersAction):
     b: argparse.ArgumentParser = file_subparsers.add_parser(
         "get", help="Get a file from device"
     )
-    b.add_argument("uri", type=str)
     b.add_argument("remote_path", type=str, help="Remote path of file to download")
     b.add_argument(
         "--output_path",
@@ -79,7 +77,6 @@ def add_commands(subparsers: argparse._SubParsersAction):
     c: argparse.ArgumentParser = file_subparsers.add_parser(
         "rm", help="Remove a file from device"
     )
-    c.add_argument("uri", type=str)
     c.add_argument("path", type=str, help="Path to file to remove")
     c.add_argument(
         "--recursive", "-r", action="store_true", help="Remove directories recursively"
diff --git a/synapse/cli/rpc.py b/synapse/cli/rpc.py
index 9d91712d..86471b94 100644
--- a/synapse/cli/rpc.py
+++ b/synapse/cli/rpc.py
@@ -19,29 +19,23 @@
 
 def add_commands(subparsers):
     a = subparsers.add_parser("info", help="Get device information")
-    a.add_argument("uri", type=str)
     a.set_defaults(func=info)
 
     b = subparsers.add_parser("query", help="Execute a query on the device")
-    b.add_argument("uri", type=str)
     b.add_argument("query_file", type=str)
     b.set_defaults(func=query)
 
     c = subparsers.add_parser("start", help="Start the device")
-    c.add_argument("uri", type=str)
     c.set_defaults(func=start)
 
     d = subparsers.add_parser("stop", help="Stop the device")
-    d.add_argument("uri", type=str)
     d.set_defaults(func=stop)
 
     e = subparsers.add_parser("configure", help="Write a configuration to the device")
-    e.add_argument("uri", type=str)
     e.add_argument("config_file", type=str)
     e.set_defaults(func=configure)
 
     f = subparsers.add_parser("logs", help="Get logs from the device")
-    f.add_argument("uri", type=str)
     f.add_argument("--output", "-o", type=str, help="Optional file to write logs to")
     f.add_argument(
         "--quiet",
@@ -64,7 +58,8 @@ def add_commands(subparsers):
         help="Follow log output",
     )
     f.add_argument(
-        "--since", "-S",
+        "--since",
+        "-S",
         type=int,
         help="Get logs from the last N milliseconds",
         metavar="N",
@@ -119,6 +114,7 @@ def query(args):
                             f"{measurement.electrode_id},{measurement.magnitude},{measurement.phase},1\n"
                         )
 
+
 def start(args):
     console = Console()
     with console.status("Starting device...", spinner="bouncingBall"):
@@ -194,12 +190,16 @@ def parse_datetime(time_str: Optional[str]) -> Optional[datetime]:
 
         start_time = parse_datetime(args.start_time)
         if args.start_time and not start_time:
-            console.print("[bold red]Invalid start time format. Use ISO format (e.g., '2024-03-14T15:30:00')")
+            console.print(
+                "[bold red]Invalid start time format. Use ISO format (e.g., '2024-03-14T15:30:00')"
+            )
             return
 
         end_time = parse_datetime(args.end_time)
         if args.end_time and not end_time:
-            console.print("[bold red]Invalid end time format. Use ISO format (e.g., '2024-03-14T15:30:00')")
+            console.print(
+                "[bold red]Invalid end time format. Use ISO format (e.g., '2024-03-14T15:30:00')"
+            )
             return
 
         with console.status("Getting logs...", spinner="bouncingBall"):
@@ -207,7 +207,7 @@ def parse_datetime(time_str: Optional[str]) -> Optional[datetime]:
                 log_level=args.log_level,
                 since_ms=args.since,
                 start_time=start_time,
-                end_time=end_time
+                end_time=end_time,
             )
 
             if not res or not res.entries:
diff --git a/synapse/cli/streaming.py b/synapse/cli/streaming.py
index 1ffd4f62..9b5c0ff6 100644
--- a/synapse/cli/streaming.py
+++ b/synapse/cli/streaming.py
@@ -27,7 +27,6 @@
 
 def add_commands(subparsers):
     a = subparsers.add_parser("read", help="Read from a device's StreamOut node")
-    a.add_argument("uri", type=str, help="IP address of Synapse device")
     a.add_argument(
         "--config",
         type=str,
diff --git a/synapse/utils/discover.py b/synapse/utils/discover.py
index bc132b21..f1d9098f 100644
--- a/synapse/utils/discover.py
+++ b/synapse/utils/discover.py
@@ -7,6 +7,7 @@
 BROADCAST_PORT = 6470
 DISCOVERY_TIMEOUT_SEC = 10
 
+
 @dataclass
 class DeviceInfo:
     host: str
@@ -15,10 +16,11 @@ class DeviceInfo:
     name: str
     serial: str
 
+
 def discover_iter(socket_timeout_sec=1, discovery_timeout_sec=DISCOVERY_TIMEOUT_SEC):
     sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
     sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-    if sys.platform != 'win32':
+    if sys.platform != "win32":
         sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
     sock.settimeout(socket_timeout_sec)
     sock.bind(("", BROADCAST_PORT))
@@ -53,3 +55,31 @@ def discover_iter(socket_timeout_sec=1, discovery_timeout_sec=DISCOVERY_TIMEOUT_
 
 def discover(timeout_sec=DISCOVERY_TIMEOUT_SEC):
     return list(discover_iter(timeout_sec))
+
+
+def find_device_by_name(name, console, include_rpc_port=False):
+    """Find a device by name using the discovery process."""
+    with console.status(
+        f"Searching for device with name {name}...", spinner="bouncingBall"
+    ):
+        # We are broadcasting data every 1 second
+        socket_timeout_sec = 1
+        discovery_timeout_sec = 5
+        found_devices = []
+        devices = discover_iter(socket_timeout_sec, discovery_timeout_sec)
+        for device in devices:
+            if device.name.lower() == name.lower():
+                if include_rpc_port:
+                    return f"{device.host}:{device.port}"
+                return f"{device.host}"
+            found_devices.append(device)
+
+    console.print(f"[bold red]Device with name {name} not found")
+    console.print(
+        "[bold red]Either the device is not running or the name is incorrect\n"
+    )
+    if found_devices:
+        console.print("[yellow]We did find some devices:")
+        for device in found_devices:
+            console.print(f"[yellow]{device.name} ({device.host})")
+    return None


Reviews:
user: login: polymerizedsage
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T22:52:54Z
user: login: polymerizedsage
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T22:54:49Z
user: login: polymerizedsage
body: Tested locally and this works for me. Some small comments but otherwise lgtm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T22:55:37Z
user: login: gilbert-sci
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T00:11:55Z
user: login: antoniaelsen
body: tested, lgtm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T18:10:49Z

Review comments:
diff_hunk: @@ -1,19 +1,75 @@
 #!/usr/bin/env python
 import argparse
 import logging
+import ipaddress
+
 from importlib import metadata
 from synapse.cli import discover, rpc, streaming, offline_plot, files
 from rich.logging import RichHandler
+from rich.console import Console
+from synapse.utils.discover import discover_iter
+
+
+def is_valid_ip(input_str):
+    try:
+        ipaddress.ip_address(input_str)
+        return True
+    except ValueError:
+        return False
+
+
+def find_device_by_name(name, console):
+    """Find a device by name using the discovery process."""
+    with console.status(
+        f"Searching for device with name {name}...", spinner="bouncingBall"
+    ):
+        # We are broadcasting data every 1 second
+        socket_timeout_sec = 1
+        discovery_timeout_sec = 5
+        found_devices = []
+        devices = discover_iter(socket_timeout_sec, discovery_timeout_sec)
+        for device in devices:
+            if device.name.lower() == name.lower():
+                return f"{device.host}:{device.port}"
+            found_devices.append(device)
+
+    console.print(f"[bold red]Device with name {name} not found")
+    console.print(
+        "[bold red]Either the device is not running or the name is incorrect\n"
+    )
+    if found_devices:
+        console.print("[yellow]We did find some devices:")
+        for device in found_devices:
+            console.print(f"[yellow]{device.name} ({device.host}:{device.port})")
+    return None
+
+
+def setup_device_uri(args):
+    if not args.uri:
+        # User doesn't want to use something that needs a uri
+        return args
+    if not is_valid_ip(args.uri):
+        # User passed in a name
+        console = Console()
+        device_ip = find_device_by_name(args.uri, console)
+        if not device_ip:
+            return None
+        args.uri = device_ip
+    return args
 
 
 def main():
     logging.basicConfig(level=logging.INFO, handlers=[RichHandler()])
-
     parser = argparse.ArgumentParser(
         description="Synapse Device Manager",
         formatter_class=lambda prog: argparse.HelpFormatter(prog, width=124),
     )
-
+    parser.add_argument(
+        "--uri",
path: synapse/cli/__main__.py
user: login: polymerizedsage
body: can you re-add the short `-u` option?
created_at: 2025-04-16T22:52:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,19 +1,75 @@
 #!/usr/bin/env python
 import argparse
 import logging
+import ipaddress
+
 from importlib import metadata
 from synapse.cli import discover, rpc, streaming, offline_plot, files
 from rich.logging import RichHandler
+from rich.console import Console
+from synapse.utils.discover import discover_iter
+
+
+def is_valid_ip(input_str):
+    try:
+        ipaddress.ip_address(input_str)
+        return True
+    except ValueError:
+        return False
+
+
+def find_device_by_name(name, console):
path: synapse/cli/__main__.py
user: login: polymerizedsage
body: It would be nice if this was a helper function in `synapse/utils/discover.py` since im sure users of the package will want to be able to do a similar thing. 
created_at: 2025-04-16T22:54:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,19 +1,75 @@
 #!/usr/bin/env python
 import argparse
 import logging
+import ipaddress
+
 from importlib import metadata
 from synapse.cli import discover, rpc, streaming, offline_plot, files
 from rich.logging import RichHandler
+from rich.console import Console
+from synapse.utils.discover import discover_iter
+
+
+def is_valid_ip(input_str):
+    try:
+        ipaddress.ip_address(input_str)
+        return True
+    except ValueError:
+        return False
+
+
+def find_device_by_name(name, console):
path: synapse/cli/__main__.py
user: login: gilbert-sci
body: sure
created_at: 2025-04-17T00:11:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: maxhodak
created_at: 2025-04-16T17:55:49Z
body: it's super easy to automatically detect whether the string is an ip or a name (i.e., just check for the presence of a `.`). this shouldn't need to be specified explicitly
author_association: CONTRIBUTOR
user: login: gilbert-sci
created_at: 2025-04-16T18:05:28Z
body: @maxhodak good point I'll update it to be uri

author_association: CONTRIBUTOR
user: login: gilbert-sci
created_at: 2025-04-16T18:05:47Z
body: Funny enough I had that in the previous version lol
author_association: CONTRIBUTOR
user: login: maxhodak
created_at: 2025-04-16T18:14:55Z
body: are there reasons to make it a flag rather than positional? first argument could just always be uri; you always need one, and it changes less often than the method you're calling (which should be the second positional argument)
author_association: CONTRIBUTOR
user: login: gilbert-sci
created_at: 2025-04-16T18:17:24Z
body: @maxhodak I thought that as well, but there are actually commands that don't need a uri, like discover and plot. I mentioned this to @namthor9 but it isn't super clean to support the conditional synapsectl <uri> <subcommand> because we also support commands that don't need a uri (but since they are all the same entry point, anything where we have a positional arguments will be interpreted as a subcommand).

I figure that the extra --uri is a decent compromise. 
author_association: CONTRIBUTOR
user: login: gilbert-sci
created_at: 2025-04-16T18:19:22Z
body: And to be explicit, the reason why people want that ordering is because then you can easily do from the terminal:

```
synapsectl --uri <name> info

# up arrow, backspace
synapsectl --uri <name> start
```
rather than moving back and switching the command. 

author_association: CONTRIBUTOR
user: login: maxhodak
created_at: 2025-04-16T18:26:11Z
body: oh i see, basically if the first argument is not command then none of the arguments can be positional anymore. so we are weighing having to type `--uri` all the time vs needing to seek the cursor further back each invocation. hmm
author_association: CONTRIBUTOR
user: login: gilbert-sci
created_at: 2025-04-16T18:30:44Z
body: Yes. This was a suggestion from a few of our internal users, but if there is more elegant way to solve this then I'm all ears
author_association: CONTRIBUTOR
user: login: gilbert-sci
created_at: 2025-04-16T18:36:14Z
body: We could take the time to make a more proper and persistent TUI. 
author_association: CONTRIBUTOR
user: login: maxhodak
created_at: 2025-04-16T19:38:23Z
body: ok don't let me hold this up
author_association: CONTRIBUTOR
user: login: polymerizedsage
created_at: 2025-04-16T22:52:21Z
body: > And to be explicit, the reason why people want that ordering is because then you can easily do from the terminal:
> 
> ```
> synapsectl --uri <name> info
> 
> # up arrow, backspace
> synapsectl --uri <name> start
> ```
> 
> rather than moving back and switching the command.

AFAIK you can still do 
```
synapsectl <name> read
```
without `--uri` with your changes. The old code still included the option to have a flag its just not strictly necessary. I tested locally and it worked just fine. Maybe im missing something.  
author_association: CONTRIBUTOR
user: login: gilbert-sci
created_at: 2025-04-17T00:15:44Z
body: > > And to be explicit, the reason why people want that ordering is because then you can easily do from the terminal:
> > ```
> > synapsectl --uri <name> info
> > 
> > # up arrow, backspace
> > synapsectl --uri <name> start
> > ```
> > 
> > 
> >     
> >       
> >     
> > 
> >       
> >     
> > 
> >     
> >   
> > rather than moving back and switching the command.
> 
> AFAIK you can still do
> 
> ```
> synapsectl <name> start
> ```
> 
> without `--uri` with your changes. The old code still included the option to have a flag its just not strictly necessary. I tested locally and it worked just fine. Maybe im missing something.

I can't reproduce this with the start command.

`read` did have an errant uri command. I'll look for others.
author_association: CONTRIBUTOR
user: login: gilbert-sci
created_at: 2025-04-17T00:19:34Z
body: @polymerizedsage but also I can't reproduce that with read, it just fails. So I'm not sure how you got that working 
author_association: CONTRIBUTOR