Repository: napari/napari

Issue:
napari CLI script-runner
## 🚀 Feature

In the last meeting and in https://github.com/napari/tutorials/issues/70, we discussed the idea of being able to run a napari script from the command line *regardless* of whether it contained `napari.gui_qt()`.  Something like:
```sh
napari path/to/script.py
```

The question here is whether we want to just hijack the positional "images" (paths) argument in the existing CLI, look for something ending with `.py`, and run it as a script if so (as done in https://github.com/napari/tutorials/issues/70#issuecomment-635691269) ... Or if we should consider moving to subparsers like `napari open image.tif` and `napari run script.py`.
While it _is_ possible to arrange it such that `napari image.tif` _still_ opens a file... it gets more complicated to do it in a "clean" way such that we see the same help menu for both `napari open --help` and `napari --help`.  

## Options

1. **Do** use subparsers (`open` and `run`), where `open` is the default... and live with the fact that `napari --help` will look like this:
```sh
$ napari --help
napari command line viewer.

positional arguments:
  {open,run}

optional arguments:
  -h, --help  show this help message and exit
  --version   show program's version number and exit
  --info      show system information and exit
  --citation  show citation information and exit
```
and `napari open --help` has all the useful options, _even though_ they also apply even if you didn't use `napari open`

```sh
$ napari open --help
positional arguments:
  images             image(s) to view.

optional arguments:
  -h, --help         show this help message and exit
  -v, --verbose      increase output verbosity
  --stack            concatenate multiple input files into a single stack.
  --plugin PLUGIN    specify plugin name when opening a file
  --layer-type TYPE  force file to be interpreted as a specific layer type.
                     one of {'shapes', 'points', 'vectors', 'labels',
                     'image', 'surface'}
```
2. **Don't** use subparsers, and simply inspect the list of paths passed, and if any (?) of them is a `.py` file, run it as a script (and ignore any other position args?)... 

3. **Don't** use subparsers, but require that the first positional argument be the string "run". 
 This will work for `napari run path/to/script.py` but not for `napari path/to/script.py` ... and "`run`" merely "looks" like an actual subcommand... but it's not, and doesn't come with any of its own help.

4. don't give up on subparsers (option #1) ... but allow for the fact that `napari.__main__` may need to get more complicated to achieve the desired subcommand & help behavior we're after.

@GenevieveBuckley, @jni?


Discussion:
Is option 0 we just drop support for `napari image.tif` and require `napari open image.tif`?

That doesn't bother me, but I guess I never use this part of the API, otherwise I lean towards option 1. I will use `napari run my_script.py` all the time though :-)
> Is option 0 we just drop support for `napari image.tif` and require `napari open image.tif`

that would be the simplest approach, but anticipating some desire to be able to keep `napari image.tif`, I was able to get it such that `napari ...` without a subcommand is the same as `napari open ...` but it becomes hard to keep the help menus consistent while also keeping the code reasonably simple.
I am in favour of `napari open image.tif` and `napari run script.py`. Dropping `napari image.tif` is fine with me!
Sorry to be the spanner in the works, but I *love* napari /path/to/image.tif and I use it all the friggin time! I will cry if we drop it. I *also* think that it's unnecessary to have subparsers — `napari path/to/script.py` is completely unambiguous as far as I'm concerned. So I would prefer option 2. Also, anything *other* than a single .py file would be an error.
> Don't use subparsers, and simply inspect the list of paths passed, and if any (?) of them is a .py file, run it as a script (and ignore any other position args?)...

I'm open to option 2 with this approach from @jni. Any arguments against it @tlambert03? 
Nope, I think that’s my preference too

Resolving patch:
diff --git a/napari/__main__.py b/napari/__main__.py
--- a/napari/__main__.py
+++ b/napari/__main__.py
@@ -3,6 +3,7 @@
 """
 import argparse
 import logging
+import runpy
 import sys
 from ast import literal_eval
 from textwrap import wrap
@@ -92,7 +93,7 @@ def main():
         epilog="optional layer-type-specific arguments (precede with '--'):\n"
         + "\n".join(kwarg_options),
     )
-    parser.add_argument('images', nargs='*', help='image(s) to view.')
+    parser.add_argument('paths', nargs='*', help='path(s) to view.')
     parser.add_argument(
         '-v',
         '--verbose',
@@ -135,10 +136,10 @@ def main():
 
     args, unknown = parser.parse_known_args()
     # this is a hack to allow using "=" as a key=value separator while also
-    # allowing nargs='*' on the "images" argument...
-    for idx, item in enumerate(reversed(args.images)):
+    # allowing nargs='*' on the "paths" argument...
+    for idx, item in enumerate(reversed(args.paths)):
         if item.startswith("--"):
-            unknown.append(args.images.pop(len(args.images) - idx - 1))
+            unknown.append(args.paths.pop(len(args.paths) - idx - 1))
     kwargs = validate_unknown_args(unknown) if unknown else {}
 
     # parse -v flags and set the appropriate logging level
@@ -152,7 +153,7 @@ def main():
 
     if args.plugin:
         # make sure plugin is only used when files are specified
-        if not args.images:
+        if not args.paths:
             sys.exit(
                 "error: The '--plugin' argument is only valid "
                 "when providing a file name"
@@ -162,14 +163,28 @@ def main():
         # so remove --plugin from sys.argv to prevent that warningz
         sys.argv.remove('--plugin')
 
-    with gui_qt(startup_logo=True):
-        view_path(
-            args.images,
-            stack=args.stack,
-            plugin=args.plugin,
-            layer_type=args.layer_type,
-            **kwargs,
-        )
+    if any(p.endswith('.py') for p in args.paths):
+        if len(args.paths) > 1:
+            sys.exit(
+                'When providing a python script, only a '
+                'single positional argument may be provided'
+            )
+
+        with gui_qt(startup_logo=True) as app:
+            if hasattr(app, '_splash_widget'):
+                app._splash_widget.close()
+            runpy.run_path(args.paths[0])
+            if getattr(app, '_existed', False):
+                sys.exit()
+    else:
+        with gui_qt(startup_logo=True):
+            view_path(
+                args.paths,
+                stack=args.stack,
+                plugin=args.plugin,
+                layer_type=args.layer_type,
+                **kwargs,
+            )
 
 
 if __name__ == '__main__':
diff --git a/napari/_qt/event_loop.py b/napari/_qt/event_loop.py
--- a/napari/_qt/event_loop.py
+++ b/napari/_qt/event_loop.py
@@ -62,6 +62,9 @@ def gui_qt(*, startup_logo=False):
             )
             splash_widget = QSplashScreen(pm)
             splash_widget.show()
+            app._splash_widget = splash_widget
+    else:
+        app._existed = True
     yield app
     # if the application already existed before this function was called,
     # there's no need to start it again.  By avoiding unnecessary calls to


Test patch:
diff --git a/napari/_tests/test_cli.py b/napari/_tests/test_cli.py
--- a/napari/_tests/test_cli.py
+++ b/napari/_tests/test_cli.py
@@ -61,3 +61,21 @@ def test_cli_raises(monkeypatch):
         with pytest.raises(SystemExit) as e:
             __main__.main()
         assert str(e.value) == 'error: argument --gamma expected one argument'
+
+
+def test_cli_runscript(monkeypatch, tmp_path):
+    """Test that running napari script.py runs a script"""
+    script = tmp_path / 'test.py'
+    script.write_text('1/0')  # raise error so we can assert it ran
+
+    with monkeypatch.context() as m:
+        m.setattr(sys, 'argv', ['napari', str(script)])
+        with pytest.raises(ZeroDivisionError):
+            __main__.main()
+
+    script.write_text('import napari; v = napari.Viewer(show=False)')
+
+    with monkeypatch.context() as m:
+        m.setattr(sys, 'argv', ['napari', str(script)])
+        with pytest.raises(SystemExit):
+            __main__.main()