diff --git a/openhexa/cli/api.py b/openhexa/cli/api.py index 4c46298..7430197 100644 --- a/openhexa/cli/api.py +++ b/openhexa/cli/api.py @@ -102,6 +102,11 @@ def graphql(config, query: str, variables=None, token=None): return data["data"] +def get_skeleton_dir(): + """Get the path to the skeleton directory.""" + return Path(__file__).parent / "skeleton" + + def get_workspace(config, slug: str, token: str): """Get a single workspace.""" return graphql( @@ -230,6 +235,39 @@ def ensure_is_pipeline_dir(pipeline_path: str): return True +# This is easier to mock in the tests than trying to mock click.confirm +def ask_pipeline_config_creation(): + """Mockable function to ask the user if he wants to create a pipeline config file. + + Returns + ------- + bool: True if the user wants to create a pipeline config file, False otherwise. + + """ + return click.confirm( + "No workspace.yaml file found. Do you want to create one?", + default=True, + ) + + +def ensure_pipeline_config_exists(pipeline_path: Path): + """Ensure that there is a workspace.yaml file in the directory. If it does not exist, it asks the user if he wants to create it. + + Args: + pipeline_path (Path): Base directory of the pipeline + """ + if (pipeline_path / "workspace.yaml").exists(): + return True + + if ask_pipeline_config_creation(): + content = open(get_skeleton_dir() / "workspace.yaml").read() + with open(pipeline_path / "workspace.yaml", "w") as f: + f.write(content) + return True + else: + raise Exception("No workspace.yaml file found") + + def upload_pipeline(config, pipeline_directory_path: typing.Union[str, Path]): """Upload the pipeline contained in the provided directory using the GraphQL API. diff --git a/openhexa/cli/cli.py b/openhexa/cli/cli.py index 9464eb3..a253031 100644 --- a/openhexa/cli/cli.py +++ b/openhexa/cli/cli.py @@ -14,7 +14,9 @@ create_pipeline, delete_pipeline, ensure_is_pipeline_dir, + ensure_pipeline_config_exists, get_pipeline, + get_skeleton_dir, get_workspace, is_debug, list_pipelines, @@ -178,7 +180,7 @@ def pipelines_init(name: str): sys.exit(1) # Load samples - sample_directory_path = Path(__file__).parent / Path("skeleton") + sample_directory_path = get_skeleton_dir() with open(sample_directory_path / Path(".gitignore")) as sample_ignore_file: sample_ignore_content = sample_ignore_file.read() with open(sample_directory_path / Path("pipeline.py")) as sample_pipeline_file: @@ -193,12 +195,12 @@ def pipelines_init(name: str): # Create directory new_pipeline_path.mkdir(exist_ok=False) - (new_pipeline_path / Path("workspace")).mkdir(exist_ok=False) - with open(new_pipeline_path / Path(".gitignore"), "w") as ignore_file: + (new_pipeline_path / "workspace").mkdir(exist_ok=False) + with open(new_pipeline_path / ".gitignore", "w") as ignore_file: ignore_file.write(sample_ignore_content) - with open(new_pipeline_path / Path("pipeline.py"), "w") as pipeline_file: + with open(new_pipeline_path / "pipeline.py", "w") as pipeline_file: pipeline_file.write(sample_pipeline_content) - with open(new_pipeline_path / Path("workspace.yaml"), "w") as workspace_file: + with open(new_pipeline_path / "workspace.yaml", "w") as workspace_file: workspace_file.write(sample_workspace_content) # Success @@ -231,6 +233,7 @@ def pipelines_push(path: str): sys.exit(1) ensure_is_pipeline_dir(path) + ensure_pipeline_config_exists(Path(path)) try: pipeline = import_pipeline(path) @@ -342,7 +345,12 @@ def pipelines_delete(code: str): default=None, help="Configuration JSON file", ) -@click.option("--image", type=str, help="Docker image to use", default="blsq/openhexa-base-notebook:latest") +@click.option( + "--image", + type=str, + help="Docker image to use", + default="blsq/openhexa-base-notebook:latest", +) def pipelines_run( path: str, image: str, @@ -352,14 +360,14 @@ def pipelines_run( """Run a pipeline locally.""" from subprocess import Popen + path = Path(path) user_config = open_config() ensure_is_pipeline_dir(path) + ensure_pipeline_config_exists(path) + env_vars = get_local_workspace_config(path) - env_vars = get_local_workspace_config(Path(path)) - - # Prepare the mount for the workspace's files + # # Prepare the mount for the workspace's files mount_files_path = Path(env_vars["WORKSPACE_FILES_PATH"]).absolute() - cmd = [ "docker", "run", @@ -405,7 +413,6 @@ def pipelines_run( cmd, close_fds=True, ) - return proc.wait() diff --git a/openhexa/sdk/pipelines/utils.py b/openhexa/sdk/pipelines/utils.py index 7fae0fc..9024d12 100644 --- a/openhexa/sdk/pipelines/utils.py +++ b/openhexa/sdk/pipelines/utils.py @@ -28,7 +28,7 @@ def get_local_workspace_config(path: Path): # (We will have to find another approach for tests or running the pipeline using the CLI) local_workspace_config_path = path / Path("workspace.yaml") if not local_workspace_config_path.exists(): - raise ValueError( + raise FileNotFoundError( "To work with pipelines locally, you need a workspace.yaml file in the same directory as your pipeline file" ) @@ -57,9 +57,8 @@ def get_local_workspace_config(path: Path): try: files_path = path / Path(local_workspace_config["files"]["path"]) if not files_path.exists(): - raise LocalWorkspaceConfigError( - f"The {files_path} files path does not exist. " f"Did you forget to create it?" - ) + # Let's create the folder if it doesn't exist + files_path.mkdir(parents=True) env_vars["WORKSPACE_FILES_PATH"] = str(files_path.resolve()) except KeyError: exception_message = ( diff --git a/pyproject.toml b/pyproject.toml index c11a9ea..0ba3a20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ openhexa = "openhexa.cli:app" [project.optional-dependencies] -dev = [ "ruff~=0.0.278", "pytest~=7.3.0", "build>=0.10,<1.1", "pytest-cov>=4.0,<4.2" , "pre-commit"] +dev = [ "ruff~=0.1.11", "pytest~=7.3.0", "build>=0.10,<1.1", "pytest-cov>=4.0,<4.2" , "pre-commit"] examples= ["geopandas~=0.12.2", "pandas~=2.0.0", "rasterio~=1.3.6", "rasterstats>=0.18,<0.20", "setuptools>=67.6.1,<69.1.0", "SQLAlchemy~=2.0.9", "psycopg2"] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..58d8e8a --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,88 @@ +"""CLI test module.""" + +from configparser import ConfigParser +from pathlib import Path +from tempfile import mkdtemp +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from openhexa.cli.cli import pipelines_run + + +class CliRunTest(TestCase): + """Test the CLI.""" + + runner = None + user_config = None + + def setUp(self): + """Configure the CLI runner and the user config.""" + self.runner = CliRunner() + self.user_config = ConfigParser() + self.user_config.read_string( + """ + [openhexa] + url=https://test.openhexa.org + current_workspace=test_workspace + + [workspaces] + test_workspace = WORKSPACE_TOKEN + """ + ) + self.patch_user_config = patch("openhexa.cli.cli.open_config", return_value=self.user_config) + self.patch_user_config.start() + return super().setUp() + + def tearDown(self) -> None: + """Tear down the CLI runner and the user config. + + Returns + ------- + None + """ + self.patch_user_config.stop() + return super().tearDown() + + @patch("openhexa.cli.api.ask_pipeline_config_creation", return_value=True) + def test_no_pipeline(self, *args): + """Test running a pipeline without a pipeline.py file.""" + with self.runner.isolated_filesystem(): + result = self.runner.invoke(pipelines_run, [mkdtemp()]) + assert result.exit_code == 1 + self.assertTrue("does not contain a pipeline.py file" in str(result.exception)) + + @patch("openhexa.cli.api.ask_pipeline_config_creation", return_value=True) + def test_no_config_file(self, mock_ask_config): + """Test running a pipeline without a workspace.yaml file. + + It should ask the user if they want to create the config file. + Two tests are performed: + - The user accepts the config creation + - The user refuses the config creation + """ + with self.runner.isolated_filesystem() as tmp: + with open("pipeline.py", "w") as f: + f.write("") + + self.assertFalse((Path(tmp) / "workspace.yaml").exists()) + with patch("subprocess.Popen") as mock_popen: + mock_process = MagicMock() + mock_process.wait.return_value = MagicMock() + + mock_popen.return_value = mock_process + + # First without accepting the config creation + mock_ask_config.return_value = False + result = self.runner.invoke(pipelines_run, [tmp]) + self.assertEqual(result.exit_code, 1) + self.assertFalse((Path(tmp) / "workspace.yaml").exists()) + mock_popen.assert_not_called() + + # This time we accept the config creation + mock_ask_config.return_value = True + result = self.runner.invoke(pipelines_run, [tmp]) + self.assertEqual(result.exit_code, 0) + self.assertTrue((Path(tmp) / "workspace.yaml").exists()) + mock_popen.assert_called_once()