diff --git a/docs/managing_workflows.md b/docs/managing_workflows.md index 78f0c1d..f809d40 100644 --- a/docs/managing_workflows.md +++ b/docs/managing_workflows.md @@ -163,6 +163,24 @@ Proceed (Y/n)? y !!! Note Use `--force` to force uninstall without asking. +## Editing workflow CLI configuration + +The `snk edit` command is used to edit the CLI configuration of a installed workflow. This will open the configuration file in the default text editor. + +```bash +snk edit workflow +``` + +Use the `--path` flag to print the path to the configuration file instead of opening it. +```bash +snk edit --path workflow +``` + +!!! note + + The configuration file is a YAML file that contains the CLI configuration for the workflow. For more details on the CLI configuration file read the [snk config file docs](https://snk.wytamma.com/snk_config_file). + + ## Ejecting workflows The `cp -r $(workflow-name -p) workflow-name` command is used to eject the workflow from the package. This will copy the workflow files to the current working directory. This will allow you to modify the workflow and run it with the standard `snakemake` command. diff --git a/snk/main.py b/snk/main.py index c20bcb6..01522b4 100644 --- a/snk/main.py +++ b/snk/main.py @@ -9,6 +9,7 @@ from .__about__ import __version__ from .errors import WorkflowExistsError, WorkflowNotFoundError from .nest import Nest +from .utils import open_text_editor app = typer.Typer() @@ -242,6 +243,38 @@ def list( console = Console() console.print(table) +@app.command() +def edit( + ctx: typer.Context, + workflow_name: str = typer.Argument( + ..., help="Name of the workflow to configure." + ), + path: bool = typer.Option( + False, "--path", "-p", help="Show the path to the snk.yaml file." + ), +): + """ + Access the snk.yaml configuration file for a workflow. + """ + nest = Nest(snk_home=ctx.obj.snk_home, bin_dir=ctx.obj.snk_bin) + try: + workflows = nest.workflows + except FileNotFoundError: + workflows = [] + workflow = next((w for w in workflows if w.name == workflow_name), None) + if not workflow: + typer.secho(f"Workflow '{workflow_name}' not found!", fg="red", err=True) + raise typer.Exit(1) + snk_config = SnkConfig.from_workflow_dir(workflow.path, create_if_not_exists=True) + snk_config.save() + if path: + typer.echo(snk_config._snk_config_path) + else: + try: + open_text_editor(snk_config._snk_config_path) + except Exception as e: + typer.secho(str(e), fg="red", err=True) + raise typer.Exit(1) # @app.command() # def run( @@ -259,11 +292,6 @@ def list( # """Generate annotations defaults from config file""" # raise NotImplementedError -# @app.command() -# def create(name: str): -# """Create a default project that can be installed with snk""" -# raise NotImplementedError - # @app.command() # def update(): # """ diff --git a/snk/utils.py b/snk/utils.py new file mode 100644 index 0000000..7bea2d2 --- /dev/null +++ b/snk/utils.py @@ -0,0 +1,33 @@ +def open_text_editor(file_path): + """ + Opens the system's default text editor to edit the specified file. + + Parameters: + file_path (str): The path to the file to be edited. + """ + import os + import platform + import subprocess + + if platform.system() == "Windows": + os.startfile(file_path) + elif platform.system() == "Darwin": # macOS + subprocess.call(("open", file_path)) + else: # Linux and other Unix-like systems + editors = ["nano", "vim", "vi"] + editor = None + for e in editors: + if ( + subprocess.call( + ["which", e], stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + == 0 + ): + editor = e + break + if editor: + subprocess.call([editor, file_path]) + else: + raise Exception( + "No suitable text editor found. Please install nano or vim." + ) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 65a1319..3670dd4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,7 +41,7 @@ def basic_runner(tmp_path_factory): return runner -@pytest.fixture(scope="session") +@pytest.fixture(scope="module") def local_workflow(tmp_path_factory): path = Path(tmp_path_factory.mktemp("snk")) (path / "home").mkdir() diff --git a/tests/test_snk.py b/tests/test_snk.py index 4f6ce62..20fe4d3 100644 --- a/tests/test_snk.py +++ b/tests/test_snk.py @@ -58,6 +58,12 @@ def test_snk_list(local_workflow: Workflow): assert "workflow" in result.stdout assert "editable" in result.stdout +def test_config_path_cli(local_workflow: Workflow): + snk_home = local_workflow.path.parent.parent + bin_dir = local_workflow.path.parent.parent.parent / "bin" + result = runner.invoke(app, ["--home", snk_home, "--bin", bin_dir, "edit", "--path", "workflow"]) + assert result.exit_code == 0 + assert "/workflows/workflow/snk.yaml" in result.stdout def test_snk_uninstall(local_workflow: Workflow): snk_home = local_workflow.path.parent.parent @@ -87,3 +93,4 @@ def test_import_create_cli(capsys): pass captured = capsys.readouterr() assert "Usage" in captured.out + diff --git a/tests/test_snk_edit_config.py b/tests/test_snk_edit_config.py new file mode 100644 index 0000000..6b72042 --- /dev/null +++ b/tests/test_snk_edit_config.py @@ -0,0 +1,105 @@ +import os # noqa: F401 +import platform # noqa: F401 +import subprocess +from pathlib import Path +from unittest.mock import call, patch + +import pytest +import typer # noqa: F401 +from snk_cli.workflow import Workflow +from typer.testing import CliRunner + +from snk.main import app + +runner = CliRunner() + + +@pytest.fixture +def mock_platform_system(): + with patch('platform.system') as mock: + yield mock + + +@pytest.fixture +def mock_os_startfile(): + # Only patch os.startfile if the platform is Windows + if hasattr(os, 'startfile'): + with patch('os.startfile') as mock: + yield mock + else: + yield None + + +@pytest.fixture +def mock_subprocess_call(): + with patch('subprocess.call') as mock: + yield mock + + +@pytest.mark.skipif(platform.system() != 'Windows', reason="Requires Windows") +def test_open_text_editor_windows( + mock_platform_system, mock_os_startfile, local_workflow: Workflow +): + mock_platform_system.return_value = 'Windows' + file_path = local_workflow.path / "snk.yaml" + + snk_home = local_workflow.path.parent.parent + bin_dir = local_workflow.path.parent.parent.parent / "bin" + res = runner.invoke(app, ["--home", snk_home, "--bin", bin_dir, "edit", "workflow"]) + + assert res.exit_code == 0, res.stderr + if mock_os_startfile: + mock_os_startfile.assert_called_once_with(file_path) + + +def test_open_text_editor_mac( + mock_platform_system, mock_subprocess_call, local_workflow: Workflow +): + mock_platform_system.return_value = 'Darwin' + file_path = local_workflow.path / "snk.yaml" + + snk_home = local_workflow.path.parent.parent + bin_dir = local_workflow.path.parent.parent.parent / "bin" + res = runner.invoke(app, ["--home", snk_home, "--bin", bin_dir, "edit", "workflow"]) + + assert res.exit_code == 0, res.stderr + mock_subprocess_call.assert_called_once_with(('open', file_path)) + + +def test_open_text_editor_linux( + mock_platform_system, mock_subprocess_call, local_workflow: Workflow +): + mock_platform_system.return_value = 'Linux' + file_path = local_workflow.path / "snk.yaml" + + with patch('subprocess.call') as mock_call: + mock_call.side_effect = [1, 1, 0, 0] # Mocking 'which' command results: nano not found, vim not found, vi found + + snk_home = local_workflow.path.parent.parent + bin_dir = local_workflow.path.parent.parent.parent / "bin" + res = runner.invoke(app, ["--home", snk_home, "--bin", bin_dir, "edit", "workflow"]) + assert res.exit_code == 0, res.stderr + mock_call.assert_has_calls([ + call(['which', 'nano'], stdout=subprocess.PIPE, stderr=subprocess.PIPE), + call(['which', 'vim'], stdout=subprocess.PIPE, stderr=subprocess.PIPE), + call(['which', 'vi'], stdout=subprocess.PIPE, stderr=subprocess.PIPE), + call(['vi', file_path]) + ]) + + +def test_open_text_editor_no_editor_found( + mock_platform_system, mock_subprocess_call, local_workflow: Workflow +): + mock_platform_system.return_value = 'Linux' + + with patch('subprocess.call') as mock_call: + mock_call.side_effect = [1, 1, 1] # Mocking 'which' command results: none of the editors found + + with patch('typer.secho') as mock_print: + snk_home = local_workflow.path.parent.parent + bin_dir = local_workflow.path.parent.parent.parent / "bin" + res = runner.invoke(app, ["--home", snk_home, "--bin", bin_dir, "edit", "workflow"]) + assert res.exit_code == 1, res.stderr + mock_print.assert_called_once_with( + "No suitable text editor found. Please install nano or vim.", fg='red', err=True + )