Repository: WayScience/nViz
Pull request #36: Add `nviz` CLI
Author: login: d33bs
Created: 2025-04-25T17:17:01Z | Merged: 2025-04-28T15:54:33Z

<!-- _modified from [EmbeddedArtistry](https://embeddedartistry.com/blog/2017/08/04/a-github-pull-request-template-for-your-projects/)_
_referenced with modifications from [pycytominer](https://github.com/cytomining/pycytominer/blob/master/.github/PULL_REQUEST_TEMPLATE.md)_ -->

# Description

This PR adds a CLI for `nviz` with commands for converting from TIFF sets to Zarr, OME-TIFF, and viewing both file formats.

Depends on #35 to succeed with tests.

Note: I've modified the coverage configuration to ignore certain test files which were causing challenges based on how our Linux CI is configured for this project (headless display properties for Napari tests).

Thanks for any feedback!

Closes #21 

## What kind of change(s) are included?

- [ ] Documentation (changes docs or other related content)
- [ ] Bug fix (fixes an issue).
- [x] Enhancement (adds functionality).
- [ ] Breaking change (these changes would cause existing functionality to not work as expected).

# Checklist

Please ensure that all boxes are checked before indicating that this pull request is ready for review.

- [x] I have read and followed the [CONTRIBUTING.md](CONTRIBUTING.md) guidelines.
- [x] I have searched for existing content to ensure this is not a duplicate.
- [x] I have performed a self-review of these additions (including spelling, grammar, and related).
- [x] These changes pass all pre-commit checks.
- [x] I have added comments to my code to help provide understanding
- [x] I have added a test which covers the code changes found within this PR
- [x] I have deleted all non-relevant text in this pull request template.


Linked issue #21: Add command-line interface (CLI)
We should add a CLI to `nViz` in order to make it easier to use outside of the Python API.

Diff:
diff --git a/docs/src/_static/coverage-badge.svg b/docs/src/_static/coverage-badge.svg
index 8beed38..ee269f6 100644
--- a/docs/src/_static/coverage-badge.svg
+++ b/docs/src/_static/coverage-badge.svg
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 91.49%"><title>coverage: 91.49%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#4c1"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="865" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">91.49%</text><text x="865" y="140" transform="scale(.1)" fill="#fff" textLength="430">91.49%</text></g></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" width="114" height="20" role="img" aria-label="coverage: 97.68%"><title>coverage: 97.68%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#4c1"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="865" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">97.68%</text><text x="865" y="140" transform="scale(.1)" fill="#fff" textLength="430">97.68%</text></g></svg>
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index b42425f..378354d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,7 @@ classifiers = [
 ]
 dynamic = [ "version" ]
 dependencies = [
+  "fire>=0.7",
   "napari[all]>=0.5.5",
   "napari-ome-zarr>=0.6.1",
   "numpy<2.3",
@@ -23,6 +24,7 @@ dependencies = [
   "xmltodict>=0.14.2",
   "zarr>=2.18.4",
 ]
+scripts.nviz = "nviz.cli:trigger"
 
 [dependency-groups]
 # dependency groups for uv
@@ -85,6 +87,17 @@ lint.per-file-ignores."tests/*" = [ "ANN201", "PLR0913", "PLR2004" ]
 [tool.pytest.ini_options]
 pythonpath = [ "." ]
 
+[tool.coverage]
+# omit testing utils for code coverage
+run.omit = [
+  "tests/utils.py",
+  "tests/conftest.py",
+]
+report.omit = [
+  "tests/utils.py",
+  "tests/conftest.py",
+]
+
 [tool.jupytext]
 formats = "ipynb,py:light"
 
diff --git a/src/nviz/cli.py b/src/nviz/cli.py
new file mode 100644
index 0000000..fb0de40
--- /dev/null
+++ b/src/nviz/cli.py
@@ -0,0 +1,161 @@
+"""
+CLI for nviz
+"""
+
+from typing import Dict, List, Optional, Tuple, Union
+
+import fire
+
+from nviz.image import tiff_to_ometiff, tiff_to_zarr
+from nviz.view import view_ometiff_with_napari, view_zarr_with_napari
+
+
+class nVizCLI:
+    def tiff_to_zarr(  # noqa: PLR0913
+        self,
+        image_dir: str,
+        output_path: str,
+        channel_map: Dict[str, str],
+        scaling_values: Union[List[int], Tuple[int]],
+        label_dir: Optional[str] = None,
+        ignore: Optional[List[str]] = ["Merge"],
+    ) -> str:
+        """
+        CLI interface for converting TIFF images to Zarr format.
+
+        Args:
+            image_dir (str):
+                Directory containing TIFF image files.
+            output_path (str):
+                Path to save the output OME-Zarr file.
+            channel_map (Dict[str, str]):
+                Mapping from filename codes to channel names.
+            scaling_values (Union[List[int], Tuple[int]]):
+                Scaling values for the images.
+            label_dir (Optional[str]):
+                Directory containing label TIFF files. Defaults to None.
+            ignore (Optional[List[str]]):
+                List of filename codes to ignore.
+                Defaults to ["Merge"], which is a
+                code for merged images.
+
+        Returns:
+            str: Path to the output OME-Zarr file.
+        """
+
+        return tiff_to_zarr(
+            image_dir=image_dir,
+            output_path=output_path,
+            channel_map=channel_map,
+            scaling_values=scaling_values,
+            label_dir=label_dir,
+            ignore=ignore,
+        )
+
+    def tiff_to_ometiff(  # noqa: PLR0913
+        self,
+        image_dir: str,
+        output_path: str,
+        channel_map: Dict[str, str],
+        scaling_values: Union[List[int], Tuple[int]],
+        label_dir: Optional[str] = None,
+        ignore: Optional[List[str]] = ["Merge"],
+    ) -> str:
+        """
+        CLI interface for converting TIFF images to OME-TIFF format.
+
+        Args:
+            image_dir (str):
+                Directory containing TIFF image files.
+            output_path (str):
+                Path to save the output OME-Zarr file.
+            channel_map (Dict[str, str]):
+                Mapping from filename codes to channel names.
+            scaling_values (Union[List[int], Tuple[int]]):
+                Scaling values for the images.
+            label_dir (Optional[str]):
+                Directory containing label TIFF files. Defaults to None.
+            ignore (Optional[List[str]]):
+                List of filename codes to ignore.
+                Defaults to ["Merge"], which is a
+                code for merged images.
+
+        Returns:
+            str: Path to the output OME-TIFF file.
+        """
+
+        return tiff_to_ometiff(
+            image_dir=image_dir,
+            output_path=output_path,
+            channel_map=channel_map,
+            scaling_values=scaling_values,
+            label_dir=label_dir,
+            ignore=ignore,
+        )
+
+    def view_zarr(
+        self,
+        zarr_dir: str,
+        scaling_values: Union[List[int], Tuple[int]],
+        headless: Optional[bool] = False,
+    ) -> Optional[str]:
+        """
+        CLI interface for viewing Zarr files with napari.
+
+        Args:
+            zarr_dir (str):
+                Path to the Zarr file.
+            scaling_values (Union[List[int], Tuple[int]]):
+                Scaling values for the image.
+            headless (Optional[bool]):
+                Whether to run in headless mode
+                (where we don't run napari and hand
+                back the viewer object instead).
+                Defaults to False.
+
+        Returns:
+            Optional[str]:
+                The napari viewer object if not headless,
+                otherwise None.
+        """
+
+        return view_zarr_with_napari(
+            zarr_dir=zarr_dir, scaling_values=scaling_values, headless=headless
+        )
+
+    def view_ometiff(
+        self,
+        ometiff_path: str,
+        scaling_values: Union[List[int], Tuple[int]],
+        headless: Optional[bool] = False,
+    ) -> Optional[str]:
+        """
+        CLI interface for viewing OME-TIFF files with napari.
+
+        Args:
+            ometiff_path (str):
+                Path to the OME-TIFF file.
+            scaling_values (Union[List[int], Tuple[int]]):
+                Scaling values for the image.
+            headless (Optional[bool]):
+                Whether to run in headless mode
+                (where we don't run napari and hand
+                back the viewer object instead).
+                Defaults to False.
+
+        Returns:
+            Optional[str]:
+                The napari viewer object if not headless,
+                otherwise None.
+        """
+
+        return view_ometiff_with_napari(
+            ometiff_path=ometiff_path, scaling_values=scaling_values, headless=headless
+        )
+
+
+def trigger() -> None:
+    """
+    Trigger the CLI to run.
+    """
+    fire.Fire(nVizCLI)
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..741b002
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,130 @@
+"""
+Tests CLI capabilities for nviz
+"""
+
+import pathlib
+from typing import Dict, List, Optional, Tuple, Union
+
+import pytest
+
+from tests.utils import run_cli_command
+
+
+def test_cli_util():
+    """
+    Test the run_cli_command for successful output
+    """
+
+    _, _, returncode = run_cli_command(["echo", "'hello world'"])
+
+    assert returncode == 0
+
+
+@pytest.mark.parametrize(
+    "image_dir, output_path, channel_map, scaling_values, label_dir, ignore",
+    [
+        (
+            "tests/data/random_tiff_z_stacks/Z99",
+            "output.zarr",
+            (
+                '{ "111": "Channel A", "222": "Channel B", "333": "Channel C",'
+                ' "444": "Channel D", "555": "Channel E", }'
+            ),
+            "(1.0, 0.1, 0.1)",
+            "None",
+            "None",
+        )
+    ],
+)
+def test_cli_tiff_to_zarr(
+    tmp_path: pathlib.Path,
+    image_dir: str,
+    output_path: str,
+    channel_map: Dict[str, str],
+    scaling_values: Union[List[int], Tuple[int]],
+    label_dir: Optional[str],
+    ignore: Optional[List[str]],
+):
+    """
+    Test CLI use of tiff to zarr and viewing capabilities
+    """
+    stdout, _, returncode = run_cli_command(
+        [
+            "nviz",
+            "tiff_to_zarr",
+            image_dir,
+            (output_path := f"{tmp_path}/{output_path}"),
+            channel_map,
+            scaling_values,
+            label_dir,
+            ignore,
+        ]
+    )
+
+    assert returncode == 0
+    assert stdout.strip() == output_path
+
+    stdout, stderr, returncode = run_cli_command(
+        ["nviz", "view_zarr", output_path, scaling_values, "True"]
+    )
+
+    # napari has difficulties when used headlessly through the cli
+    # so we check that the output shows we returned a viewer object.
+    # This isn't a typical usecase because we can't use the viewer
+    # object through a CLI interface (we only enable it for testing).
+    assert stdout.strip() == "napari.Viewer: napari"
+
+
+@pytest.mark.parametrize(
+    "image_dir, output_path, channel_map, scaling_values, label_dir, ignore",
+    [
+        (
+            "tests/data/random_tiff_z_stacks/Z99",
+            "output.zarr",
+            (
+                '{ "111": "Channel A", "222": "Channel B", "333": "Channel C",'
+                ' "444": "Channel D", "555": "Channel E", }'
+            ),
+            "(1.0, 0.1, 0.1)",
+            "None",
+            "None",
+        )
+    ],
+)
+def test_cli_tiff_to_ometiff(
+    tmp_path: pathlib.Path,
+    image_dir: str,
+    output_path: str,
+    channel_map: Dict[str, str],
+    scaling_values: Union[List[int], Tuple[int]],
+    label_dir: Optional[str],
+    ignore: Optional[List[str]],
+):
+    """
+    Test CLI use of tiff to ome-tiff and viewing capabilities
+    """
+    stdout, _, returncode = run_cli_command(
+        [
+            "nviz",
+            "tiff_to_ometiff",
+            image_dir,
+            (output_path := f"{tmp_path}/{output_path}"),
+            channel_map,
+            scaling_values,
+            label_dir,
+            ignore,
+        ]
+    )
+
+    assert returncode == 0
+    assert stdout.strip() == output_path
+
+    stdout, _, returncode = run_cli_command(
+        ["nviz", "view_ometiff", output_path, scaling_values, "True"]
+    )
+
+    # napari has difficulties when used headlessly through the cli
+    # so we check that the output shows we returned a viewer object.
+    # This isn't a typical usecase because we can't use the viewer
+    # object through a CLI interface (we only enable it for testing).
+    assert stdout.strip() == "napari.Viewer: napari"
diff --git a/tests/utils.py b/tests/utils.py
index 5e19ebb..11464b5 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -3,8 +3,10 @@
 """
 
 import os
+import subprocess
 from concurrent.futures import ThreadPoolExecutor
 from pathlib import Path
+from typing import Tuple
 
 import pytest
 import synapseclient
@@ -158,3 +160,18 @@ def download_synapse_folder(
         with ThreadPoolExecutor(max_workers=max_workers) as executor:
             for file_name, file_id in files:
                 executor.submit(download_file, syn, file_id, target_dir, file_name)
+
+
+def run_cli_command(command: str) -> Tuple[str, str, int]:
+    """
+    Run a CLI command using subprocess and capture the output and return code.
+
+    Args:
+        command (list): The command to run as a list of strings.
+
+    Returns:
+        tuple: (str: stdout, str: stderr, int: returncode)
+    """
+
+    result = subprocess.run(args=command, capture_output=True, text=True, check=False)
+    return result.stdout, result.stderr, result.returncode
diff --git a/uv.lock b/uv.lock
index dfc29cf..e751eb1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -735,6 +735,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 },
 ]
 
+[[package]]
+name = "fire"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "termcolor" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6b/b6/82c7e601d6d3c3278c40b7bd35e17e82aa227f050aa9f66cb7b7fce29471/fire-0.7.0.tar.gz", hash = "sha256:961550f07936eaf65ad1dc8360f2b2bf8408fad46abbfa4d2a3794f8d2a95cdf", size = 87189 }
+
 [[package]]
 name = "flexcache"
 version = "0.3"
@@ -2078,9 +2087,10 @@ wheels = [
 
 [[package]]
 name = "nviz"
-version = "0.0.post1.dev8"
+version = "0.0.post1.dev9"
 source = { editable = "." }
 dependencies = [
+    { name = "fire" },
     { name = "napari", extra = ["all"] },
     { name = "napari-ome-zarr" },
     { name = "numpy" },
@@ -2111,6 +2121,7 @@ dev = [
 
 [package.metadata]
 requires-dist = [
+    { name = "fire", specifier = ">=0.7.0" },
     { name = "napari", extras = ["all"], specifier = ">=0.5.5" },
     { name = "napari-ome-zarr", specifier = ">=0.6.1" },
     { name = "numpy", specifier = "<2.3" },
@@ -2136,7 +2147,7 @@ dev = [
     { name = "pytest", specifier = ">=8.3.4" },
     { name = "setuptools-scm", specifier = ">=8.1" },
     { name = "sphinx", specifier = ">=8.1.3" },
-    { name = "synapseclient", specifier = ">=4.7.0" },
+    { name = "synapseclient", specifier = ">=4.7" },
 ]
 
 [[package]]
@@ -3595,6 +3606,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/9b/87/ce70db7cae60e67851eb94e1a2127d4abb573d3866d2efd302ceb0d4d2a5/tblib-3.0.0-py3-none-any.whl", hash = "sha256:80a6c77e59b55e83911e1e607c649836a69c103963c5f28a46cbeef44acf8129", size = 12478 },
 ]
 
+[[package]]
+name = "termcolor"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/b6/8e2aaa8aeb570b5cc955cd913b083d96c5447bbe27eaf330dfd7cc8e3329/termcolor-3.0.1.tar.gz", hash = "sha256:a6abd5c6e1284cea2934443ba806e70e5ec8fd2449021be55c280f8a3731b611", size = 12935 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a6/7e/a574ccd49ad07e8b117407bac361f1e096b01f1b620365daf60ff702c936/termcolor-3.0.1-py3-none-any.whl", hash = "sha256:da1ed4ec8a5dc5b2e17476d859febdb3cccb612be1c36e64511a6f2485c10c69", size = 7157 },
+]
+
 [[package]]
 name = "terminado"
 version = "0.18.1"


Reviews:
user: login: MikeLippincott
body: LGTM, mionor comments
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T19:52:46Z
user: login: d33bs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T12:32:27Z
user: login: d33bs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T12:32:59Z
user: login: d33bs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T12:33:50Z
user: login: d33bs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T12:34:32Z
user: login: d33bs
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T12:35:08Z

Review comments:
diff_hunk: @@ -0,0 +1,117 @@
+"""
+Tests CLI capabilities for nviz
+"""
+
+import pytest
+
+from tests.utils import run_cli_command
+
+
+def test_cli_util():
+    """
+    Test the run_cli_command for successful output
+    """
+
+    _, _, returncode = run_cli_command(["echo", "'hello world'"])
+
+    assert returncode == 0
+
+    assert returncode == 0
path: tests/test_cli.py
user: login: MikeLippincott
body: seems to be a duplicated line here?
created_at: 2025-04-25T19:43:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,117 @@
+"""
+Tests CLI capabilities for nviz
+"""
+
+import pytest
+
+from tests.utils import run_cli_command
+
+
+def test_cli_util():
+    """
+    Test the run_cli_command for successful output
+    """
+
+    _, _, returncode = run_cli_command(["echo", "'hello world'"])
+
+    assert returncode == 0
+
+    assert returncode == 0
+
+
+@pytest.mark.parametrize(
+    "image_dir, output_path, channel_map, scaling_values, label_dir, ignore",
+    [
+        (
+            "tests/data/random_tiff_z_stacks/Z99",
+            "output.zarr",
+            (
+                '{ "111": "Channel A", "222": "Channel B", "333": "Channel C",'
+                ' "444": "Channel D", "555": "Channel E", }'
+            ),
+            "(1.0, 0.1, 0.1)",
+            "None",
+            "None",
+        )
+    ],
+)
+def test_cli_tiff_to_zarr(
+    tmp_path, image_dir, output_path, channel_map, scaling_values, label_dir, ignore
+):
path: tests/test_cli.py
user: login: MikeLippincott
body: Is there a convention for adding or not adding type hints for test functions? Consider adjusting here depending on the answer.
created_at: 2025-04-25T19:44:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,117 @@
+"""
+Tests CLI capabilities for nviz
+"""
+
+import pytest
+
+from tests.utils import run_cli_command
+
+
+def test_cli_util():
+    """
+    Test the run_cli_command for successful output
+    """
+
+    _, _, returncode = run_cli_command(["echo", "'hello world'"])
+
+    assert returncode == 0
+
+    assert returncode == 0
+
+
+@pytest.mark.parametrize(
+    "image_dir, output_path, channel_map, scaling_values, label_dir, ignore",
+    [
+        (
+            "tests/data/random_tiff_z_stacks/Z99",
+            "output.zarr",
+            (
+                '{ "111": "Channel A", "222": "Channel B", "333": "Channel C",'
+                ' "444": "Channel D", "555": "Channel E", }'
+            ),
+            "(1.0, 0.1, 0.1)",
+            "None",
+            "None",
+        )
+    ],
+)
+def test_cli_tiff_to_zarr(
+    tmp_path, image_dir, output_path, channel_map, scaling_values, label_dir, ignore
+):
+    """
+    Test CLI use of tiff to zarr and viewing capabilities
+    """
+    stdout, _, returncode = run_cli_command(
+        [
+            "nviz",
+            "tiff_to_zarr",
+            image_dir,
+            (output_path := f"{tmp_path}/{output_path}"),
+            channel_map,
+            scaling_values,
+            label_dir,
+            ignore,
+        ]
+    )
+
+    assert returncode == 0
+    assert stdout.strip() == output_path
+
+    stdout, stderr, returncode = run_cli_command(
+        ["nviz", "view_zarr", output_path, scaling_values, "True"]
+    )
+
+    # napari has difficulties when used headlessly through the cli
+    # so we check that the output shows we returned a viewer object.
+    # This isn't a typical usecase because we can't use the viewer
+    # object through a CLI interface (we only enable it for testing).
+    assert stdout.strip() == "napari.Viewer: napari"
+
+
+@pytest.mark.parametrize(
+    "image_dir, output_path, channel_map, scaling_values, label_dir, ignore",
+    [
+        (
+            "tests/data/random_tiff_z_stacks/Z99",
+            "output.zarr",
+            (
+                '{ "111": "Channel A", "222": "Channel B", "333": "Channel C",'
+                ' "444": "Channel D", "555": "Channel E", }'
+            ),
+            "(1.0, 0.1, 0.1)",
+            "None",
+            "None",
+        )
+    ],
+)
+def test_cli_tiff_to_ometiff(
+    tmp_path, image_dir, output_path, channel_map, scaling_values, label_dir, ignore
+):
+    """
path: tests/test_cli.py
user: login: MikeLippincott
body: there is some repeated code here. This might be minimized by potentially merging the ome_tiff and zarr functions into one and adding a single parameter. Out of scope of this PR though
created_at: 2025-04-25T19:46:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,161 @@
+"""
+CLI for onesentence
path: src/nviz/cli.py
user: login: MikeLippincott
body: what is onesentence in this case?
created_at: 2025-04-25T19:49:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,117 @@
+"""
+Tests CLI capabilities for nviz
+"""
+
+import pytest
+
+from tests.utils import run_cli_command
+
+
+def test_cli_util():
+    """
+    Test the run_cli_command for successful output
+    """
+
+    _, _, returncode = run_cli_command(["echo", "'hello world'"])
+
+    assert returncode == 0
+
+    assert returncode == 0
path: tests/test_cli.py
user: login: d33bs
body: Great point! I'll remove.
created_at: 2025-04-26T12:32:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,117 @@
+"""
+Tests CLI capabilities for nviz
+"""
+
+import pytest
+
+from tests.utils import run_cli_command
+
+
+def test_cli_util():
+    """
+    Test the run_cli_command for successful output
+    """
+
+    _, _, returncode = run_cli_command(["echo", "'hello world'"])
+
+    assert returncode == 0
+
+    assert returncode == 0
+
+
+@pytest.mark.parametrize(
+    "image_dir, output_path, channel_map, scaling_values, label_dir, ignore",
+    [
+        (
+            "tests/data/random_tiff_z_stacks/Z99",
+            "output.zarr",
+            (
+                '{ "111": "Channel A", "222": "Channel B", "333": "Channel C",'
+                ' "444": "Channel D", "555": "Channel E", }'
+            ),
+            "(1.0, 0.1, 0.1)",
+            "None",
+            "None",
+        )
+    ],
+)
+def test_cli_tiff_to_zarr(
+    tmp_path, image_dir, output_path, channel_map, scaling_values, label_dir, ignore
+):
path: tests/test_cli.py
user: login: d33bs
body: I tend to add them out of practice and keeping things clear. I'll add them in. Thanks!
created_at: 2025-04-26T12:32:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,117 @@
+"""
+Tests CLI capabilities for nviz
+"""
+
+import pytest
+
+from tests.utils import run_cli_command
+
+
+def test_cli_util():
+    """
+    Test the run_cli_command for successful output
+    """
+
+    _, _, returncode = run_cli_command(["echo", "'hello world'"])
+
+    assert returncode == 0
+
+    assert returncode == 0
+
+
+@pytest.mark.parametrize(
+    "image_dir, output_path, channel_map, scaling_values, label_dir, ignore",
+    [
+        (
+            "tests/data/random_tiff_z_stacks/Z99",
+            "output.zarr",
+            (
+                '{ "111": "Channel A", "222": "Channel B", "333": "Channel C",'
+                ' "444": "Channel D", "555": "Channel E", }'
+            ),
+            "(1.0, 0.1, 0.1)",
+            "None",
+            "None",
+        )
+    ],
+)
+def test_cli_tiff_to_zarr(
+    tmp_path, image_dir, output_path, channel_map, scaling_values, label_dir, ignore
+):
+    """
+    Test CLI use of tiff to zarr and viewing capabilities
+    """
+    stdout, _, returncode = run_cli_command(
+        [
+            "nviz",
+            "tiff_to_zarr",
+            image_dir,
+            (output_path := f"{tmp_path}/{output_path}"),
+            channel_map,
+            scaling_values,
+            label_dir,
+            ignore,
+        ]
+    )
+
+    assert returncode == 0
+    assert stdout.strip() == output_path
+
+    stdout, stderr, returncode = run_cli_command(
+        ["nviz", "view_zarr", output_path, scaling_values, "True"]
+    )
+
+    # napari has difficulties when used headlessly through the cli
+    # so we check that the output shows we returned a viewer object.
+    # This isn't a typical usecase because we can't use the viewer
+    # object through a CLI interface (we only enable it for testing).
+    assert stdout.strip() == "napari.Viewer: napari"
+
+
+@pytest.mark.parametrize(
+    "image_dir, output_path, channel_map, scaling_values, label_dir, ignore",
+    [
+        (
+            "tests/data/random_tiff_z_stacks/Z99",
+            "output.zarr",
+            (
+                '{ "111": "Channel A", "222": "Channel B", "333": "Channel C",'
+                ' "444": "Channel D", "555": "Channel E", }'
+            ),
+            "(1.0, 0.1, 0.1)",
+            "None",
+            "None",
+        )
+    ],
+)
+def test_cli_tiff_to_ometiff(
+    tmp_path, image_dir, output_path, channel_map, scaling_values, label_dir, ignore
+):
+    """
path: tests/test_cli.py
user: login: d33bs
body: Thanks - I agree, there is an opportunity to abstract the write and read capabilities here so they may be used with the same arguments. I'll add this as a new issue.
created_at: 2025-04-26T12:33:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,117 @@
+"""
+Tests CLI capabilities for nviz
+"""
+
+import pytest
+
+from tests.utils import run_cli_command
+
+
+def test_cli_util():
+    """
+    Test the run_cli_command for successful output
+    """
+
+    _, _, returncode = run_cli_command(["echo", "'hello world'"])
+
+    assert returncode == 0
+
+    assert returncode == 0
+
+
+@pytest.mark.parametrize(
+    "image_dir, output_path, channel_map, scaling_values, label_dir, ignore",
+    [
+        (
+            "tests/data/random_tiff_z_stacks/Z99",
+            "output.zarr",
+            (
+                '{ "111": "Channel A", "222": "Channel B", "333": "Channel C",'
+                ' "444": "Channel D", "555": "Channel E", }'
+            ),
+            "(1.0, 0.1, 0.1)",
+            "None",
+            "None",
+        )
+    ],
+)
+def test_cli_tiff_to_zarr(
+    tmp_path, image_dir, output_path, channel_map, scaling_values, label_dir, ignore
+):
+    """
+    Test CLI use of tiff to zarr and viewing capabilities
+    """
+    stdout, _, returncode = run_cli_command(
+        [
+            "nviz",
+            "tiff_to_zarr",
+            image_dir,
+            (output_path := f"{tmp_path}/{output_path}"),
+            channel_map,
+            scaling_values,
+            label_dir,
+            ignore,
+        ]
+    )
+
+    assert returncode == 0
+    assert stdout.strip() == output_path
+
+    stdout, stderr, returncode = run_cli_command(
+        ["nviz", "view_zarr", output_path, scaling_values, "True"]
+    )
+
+    # napari has difficulties when used headlessly through the cli
+    # so we check that the output shows we returned a viewer object.
+    # This isn't a typical usecase because we can't use the viewer
+    # object through a CLI interface (we only enable it for testing).
+    assert stdout.strip() == "napari.Viewer: napari"
+
+
+@pytest.mark.parametrize(
+    "image_dir, output_path, channel_map, scaling_values, label_dir, ignore",
+    [
+        (
+            "tests/data/random_tiff_z_stacks/Z99",
+            "output.zarr",
+            (
+                '{ "111": "Channel A", "222": "Channel B", "333": "Channel C",'
+                ' "444": "Channel D", "555": "Channel E", }'
+            ),
+            "(1.0, 0.1, 0.1)",
+            "None",
+            "None",
+        )
+    ],
+)
+def test_cli_tiff_to_ometiff(
+    tmp_path, image_dir, output_path, channel_map, scaling_values, label_dir, ignore
+):
+    """
path: tests/test_cli.py
user: login: d33bs
body: Created #37 
created_at: 2025-04-26T12:34:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,161 @@
+"""
+CLI for onesentence
path: src/nviz/cli.py
user: login: d33bs
body: Thanks for checking, this was an error on my part and a holdover from code I wrote which I referenced to build this file. I'll revise here.
created_at: 2025-04-26T12:35:08Z
author_association: MEMBER

PR comments:
user: login: d33bs
created_at: 2025-04-28T15:54:08Z
body: Note: I've modified the coverage configuration to ignore certain test files which were causing challenges based on how our Linux CI is configured for this project (headless display properties for Napari tests).
author_association: MEMBER
user: login: d33bs
created_at: 2025-04-28T15:54:25Z
body: Thanks @MikeLippincott ! After making some changes here I'll now merge this in.
author_association: MEMBER