diff --git a/RELEASE.md b/RELEASE.md index 0c83bd303a..a90b753970 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -20,6 +20,9 @@ print(pipelines["__default__"]) # pipeline loading is only triggered here * The setup of a Kedro project, including adding src to path and configuring settings, is now handled via the `bootstrap_project` method. * Invoked `configure_project` if a `package_name` is supplied to `KedroSession.create`. This is added for backward-compatibility purpose to support workflow that creates a `Session` manually. It will only be removed in `0.18.0`. * Stopped swallowing up all `ModuleNotFoundError` if `register_pipelines` not found, which will display a more helpful error when a dependency is missing, e.g. [Issue #722](https://github.com/quantumblacklabs/kedro/issues/722). +* When `kedro new` is invoked using a configuration yaml file, `output_dir` is no longer a required key; by default the current working directory will be used. +* When `kedro new` is invoked using a configuration yaml file, the appropriate `prompts.yml` file is now used for validating the provided configuration. Previously, validation was always performed against the kedro project template `prompts.yml` file. +* When a relative path to a starter template is provided, `kedro new` now generates user prompts to obtain configuration rather than supplying empty configuration. ## Minor breaking changes to the API diff --git a/kedro/framework/cli/starters.py b/kedro/framework/cli/starters.py index da8cb05ab4..3159f46ad8 100644 --- a/kedro/framework/cli/starters.py +++ b/kedro/framework/cli/starters.py @@ -26,6 +26,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + """kedro is a CLI for managing Kedro projects. This module implements commands available from the kedro CLI for creating @@ -33,9 +34,9 @@ """ import re import tempfile -from collections import namedtuple +from collections import OrderedDict from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional import click import git @@ -64,6 +65,20 @@ } _STARTERS_REPO = "git+https://github.com/quantumblacklabs/kedro-starters.git" +CONFIG_ARG_HELP = """Non-interactive mode, using a configuration yaml file. This file +must supply the keys required by the template's prompts.yml. When not using a starter, +these are `project_name`, `repo_name` and `python_package`.""" +STARTER_ARG_HELP = """Specify the starter template to use when creating the project. +This can be the path to a local directory, a URL to a remote VCS repository supported +by `cookiecutter` or one of the aliases listed in ``kedro starter list``. +""" +CHECKOUT_ARG_HELP = ( + "An optional tag, branch or commit to checkout in the starter repository." +) +DIRECTORY_ARG_HELP = ( + "An optional directory inside the repository where the starter resides." +) + # pylint: disable=missing-function-docstring @click.group(context_settings=CONTEXT_SETTINGS, name="Kedro") @@ -73,65 +88,15 @@ def create_cli(): # pragma: no cover @command_with_verbosity(create_cli, short_help="Create a new kedro project.") @click.option( - "--config", - "-c", - "config_path", - type=click.Path(exists=True), - help="Non-interactive mode, using a configuration yaml file.", -) -@click.option( - "--starter", - "-s", - "starter_name", - help="Specify the starter template to use when creating the project.", -) -@click.option( - "--checkout", help="A tag, branch or commit to checkout in the starter repository." -) -@click.option( - "--directory", - help="An optional directory inside the repository where the starter resides.", + "--config", "-c", "config_path", type=click.Path(exists=True), help=CONFIG_ARG_HELP, ) +@click.option("--starter", "-s", "starter_name", help=STARTER_ARG_HELP) +@click.option("--checkout", help=CHECKOUT_ARG_HELP) +@click.option("--directory", help=DIRECTORY_ARG_HELP) def new( config_path, starter_name, checkout, directory, **kwargs ): # pylint: disable=unused-argument - """Create a new kedro project, either interactively or from a - configuration file. - Create projects according to the Kedro default project template. This - template is ideal for analytics projects and comes with a data - architecture, folders for notebooks, configuration, source code, etc. - \b - ``kedro new`` - Create a new project interactively. - \b - You will have to provide four choices: - * ``Project Name`` - name of the project, not to be confused with name of - the project folder. - * ``Repository Name`` - intended name of your project folder. - * ``Package Name`` - intended name of your Python package. - * ``Generate Example Pipeline`` - yes/no to generating an example pipeline - in your project. - \b - ``kedro new --config `` - ``kedro new -c `` - Create a new project from configuration. - * ``config.yml`` - The configuration YAML must contain at the top level - the above parameters (project_name, repo_name, - python_package) and output_dir - the - parent directory for the new project directory. - \b - ``kedro new --starter `` - Create a new project from a starter template. The starter can be either the path to - a local directory, a URL to a remote VCS repository supported by `cookiecutter` or - one of the aliases listed in ``kedro starter list``. - \b - ``kedro new --starter --checkout `` - Create a new project from a starter template and a particular tag, branch or commit - in the starter repository. - \b - ``kedro new --starter --directory `` - Create a new project from a starter repository and a directory within the location. - Useful when you have multiple starters in the same repository. + """Create a new kedro project. """ if checkout and not starter_name: raise KedroCliError("Cannot use the --checkout flag without a --starter value.") @@ -155,11 +120,26 @@ def new( else: template_path = str(TEMPLATE_PATH) - if config_path: + # Get prompts.yml to find what information the user needs to supply as config. + with tempfile.TemporaryDirectory() as tmpdir: + cookiecutter_dir = _get_cookiecutter_dir( + template_path, checkout, directory, tmpdir + ) + prompts_required = _get_prompts_required(cookiecutter_dir) + # We only need to make cookiecutter_context if interactive prompts are needed. + if not config_path: + cookiecutter_context = _make_cookiecutter_context_for_prompts( + cookiecutter_dir + ) + + # Obtain config, either from a file or from interactive user prompts. + if not prompts_required: + config = dict() + elif config_path: config = _fetch_config_from_file(config_path) + _validate_config_file(config, prompts_required) else: - config = _fetch_config_from_prompts(template_path, checkout, directory) - config.setdefault("kedro_version", version) + config = _fetch_config_from_user_prompts(prompts_required, cookiecutter_context) cookiecutter_args = _make_cookiecutter_args(config, checkout, directory) _create_project(template_path, cookiecutter_args) @@ -187,16 +167,28 @@ def _fetch_config_from_file(config_path: str) -> Dict[str, str]: Args: config_path: The path of the config.yml which should contain the data required - by ``prompts.yml`` and also ``output_dir``. + by ``prompts.yml``. Returns: Configuration for starting a new project. This is passed as ``extra_context`` to cookiecutter and will overwrite the cookiecutter.json defaults. + + Raises: + KedroCliError: If the file cannot be parsed. + """ - config = _parse_config_from_file(config_path) - _validate_config_from_file(config_path, config) - config["output_dir"] = Path(config["output_dir"]).expanduser().resolve() # type: ignore - _assert_output_dir_ok(config["output_dir"]) # type: ignore + try: + with open(config_path, "r") as config_file: + config = yaml.safe_load(config_file) + + if KedroCliError.VERBOSE_ERROR: + click.echo(config_path + ":") + click.echo(yaml.dump(config, default_flow_style=False)) + except Exception as exc: + raise KedroCliError( + f"Failed to generate project: could not load config at {config_path}." + ) from exc + return config @@ -220,12 +212,13 @@ def _make_cookiecutter_args( Returns: Arguments to pass to cookiecutter. """ + config.setdefault("kedro_version", version) + cookiecutter_args = { "output_dir": config.get("output_dir", str(Path.cwd().resolve())), "no_input": True, "extra_context": config, } - if checkout: cookiecutter_args["checkout"] = checkout if directory: @@ -249,96 +242,93 @@ def _create_project(template_path: str, cookiecutter_args: Dict[str, str]): """ with _filter_deprecation_warnings(): # pylint: disable=import-outside-toplevel - from cookiecutter.exceptions import RepositoryCloneFailed, RepositoryNotFound from cookiecutter.main import cookiecutter # for performance reasons try: result_path = cookiecutter(template_path, **cookiecutter_args) - except (RepositoryNotFound, RepositoryCloneFailed) as exc: - error_message = ( - f"Kedro project template not found at {template_path}" - f" with tag {cookiecutter_args.get('checkout')}." - ) - tags = _get_available_tags(template_path) - if tags: - error_message += f" The following tags are available: {', '.join(tags)}" - raise KedroCliError(error_message) from exc - # we don't want the user to see a stack trace on the cli except Exception as exc: - raise KedroCliError("Failed to generate project.") from exc + raise KedroCliError( + "Failed to generate project when running cookiecutter." + ) from exc _clean_pycache(Path(result_path)) - _print_kedro_new_success_message(result_path) - - -def _fetch_config_from_prompts( - template_path: str, checkout: str, directory: str -) -> Dict[str, str]: - """Obtains configuration for a new kedro project interactively from user prompts. - - Args: - template_path: The path to the cookiecutter template to create the project. - It could either be a local directory or a remote VCS repository - supported by cookiecutter. For more details, please see: - https://cookiecutter.readthedocs.io/en/latest/usage.html#generate-your-project - checkout: The tag, branch or commit in the starter repository to checkout. - Maps directly to cookiecutter's ``checkout`` argument. Relevant only when - using a starter. - directory: The directory of a specific starter inside a repository containing - multiple starters. Maps directly to cookiecutter's ``directory`` argument. - Relevant only when using a starter. - https://cookiecutter.readthedocs.io/en/1.7.2/advanced/directories.html + click.secho( + f"\nChange directory to the project generated in {result_path}", fg="green", + ) + click.secho( + "\nA best-practice setup includes initialising git and creating " + "a virtual environment before running ``kedro install`` to install " + "project-specific dependencies. Refer to the Kedro documentation: " + "https://kedro.readthedocs.io/" + ) - Returns: - Configuration for starting a new project. This is passed as ``extra_context`` - to cookiecutter and will overwrite the cookiecutter.json defaults. - Raises: - KedroCliError: if Kedro project template could not be found. +def _get_cookiecutter_dir( + template_path: str, checkout: str, directory: str, tmpdir: str +) -> Path: + """Gives a path to the cookiecutter directory. If template_path is a repo then + clones it to ``tmpdir``; if template_path is a file path then directly uses that + path without copying anything. """ - # pylint: disable=import-outside-toplevel, too-many-locals + # pylint: disable=import-outside-toplevel from cookiecutter.exceptions import RepositoryCloneFailed, RepositoryNotFound from cookiecutter.repository import determine_repo_dir # for performance reasons - with tempfile.TemporaryDirectory() as tmpdir: - temp_dir_path = Path(tmpdir).resolve() - try: - cookiecutter_repo, _ = determine_repo_dir( - template=template_path, - abbreviations=dict(), - clone_to_dir=temp_dir_path, - checkout=checkout, - no_input=True, - directory=directory, - ) - except (RepositoryNotFound, RepositoryCloneFailed) as exc: - error_message = ( - f"Kedro project template not found at {template_path}" - f" with tag {checkout}." + try: + cookiecutter_dir, _ = determine_repo_dir( + template=template_path, + abbreviations=dict(), + clone_to_dir=Path(tmpdir).resolve(), + checkout=checkout, + no_input=True, + directory=directory, + ) + except (RepositoryNotFound, RepositoryCloneFailed) as exc: + error_message = f"Kedro project template not found at {template_path}." + + if checkout: + error_message += ( + f" Specified tag {checkout}. The following tags are available: " + + ", ".join(_get_available_tags(template_path)) ) - tags = _get_available_tags(template_path) - if tags: - error_message += f" The following tags are available: {', '.join(tags)}" - raise KedroCliError(error_message) from exc + raise KedroCliError(error_message) from exc - cookiecutter_dir = temp_dir_path / cookiecutter_repo - prompts_yml = cookiecutter_dir / "prompts.yml" + return Path(cookiecutter_dir) - # If there is no prompts.yml, no need to ask user for input. - if not prompts_yml.is_file(): - return dict() - prompts = _parse_prompts_from_file(prompts_yml) - return _run_prompts_for_user_input(prompts, cookiecutter_dir) +def _get_prompts_required(cookiecutter_dir: Path) -> Optional[Dict[str, Any]]: + """Finds the information a user must supply according to prompts.yml. + """ + prompts_yml = cookiecutter_dir / "prompts.yml" + if not prompts_yml.is_file(): + return None + + try: + with prompts_yml.open("r") as prompts_file: + return yaml.safe_load(prompts_file) + except Exception as exc: + raise KedroCliError( + "Failed to generate project: could not load prompts.yml." + ) from exc -def _run_prompts_for_user_input( - prompts: Dict[str, Dict[str, str]], cookiecutter_dir: Path +def _fetch_config_from_user_prompts( + prompts: Dict[str, Any], cookiecutter_context: OrderedDict ) -> Dict[str, str]: + """Interactively obtains information from user prompts. + + Args: + prompts: Prompts from prompts.yml. + cookiecutter_context: Cookiecutter context generated from cookiecutter.json. + + Returns: + Configuration for starting a new project. This is passed as ``extra_context`` + to cookiecutter and will overwrite the cookiecutter.json defaults. + """ # pylint: disable=import-outside-toplevel + from cookiecutter.environment import StrictEnvironment from cookiecutter.prompt import read_user_variable, render_variable - cookiecutter_env = _prepare_cookiecutter_env(cookiecutter_dir) config: Dict[str, str] = dict() for variable_name, prompt_dict in prompts.items(): @@ -346,8 +336,8 @@ def _run_prompts_for_user_input( # render the variable on the command line cookiecutter_variable = render_variable( - env=cookiecutter_env.env, - raw=cookiecutter_env.context[variable_name], + env=StrictEnvironment(context=cookiecutter_context), + raw=cookiecutter_context[variable_name], cookiecutter_dict=config, ) @@ -359,25 +349,12 @@ def _run_prompts_for_user_input( return config -# A cookiecutter env contains the context and environment to render templated -# cookiecutter values on the CLI. -_CookiecutterEnv = namedtuple("CookiecutterEnv", ["env", "context"]) - - -def _prepare_cookiecutter_env(cookiecutter_dir) -> _CookiecutterEnv: - """Prepare the cookiecutter environment to render its default values - when prompting user on the CLI for inputs. - """ +def _make_cookiecutter_context_for_prompts(cookiecutter_dir: Path): # pylint: disable=import-outside-toplevel - from cookiecutter.environment import StrictEnvironment from cookiecutter.generate import generate_context - cookiecutter_json = cookiecutter_dir / "cookiecutter.json" - cookiecutter_context = generate_context(context_file=cookiecutter_json).get( - "cookiecutter", {} - ) - cookiecutter_env = StrictEnvironment(context=cookiecutter_context) - return _CookiecutterEnv(context=cookiecutter_context, env=cookiecutter_env) + cookiecutter_context = generate_context(cookiecutter_dir / "cookiecutter.json") + return cookiecutter_context.get("cookiecutter", {}) class _Prompt: @@ -428,104 +405,27 @@ def _get_available_tags(template_path: str) -> List: return sorted(unique_tags) -def _parse_config_from_file(config_path: str) -> Dict[str, str]: - """Parses the config YAML from its path. - - Args: - config_path: The path of the config.yml file. - - Raises: - KedroCliError: If the file cannot be parsed. - - Returns: - The config as a dictionary. - """ - try: - with open(config_path, "r") as config_file: - config = yaml.safe_load(config_file) - - if KedroCliError.VERBOSE_ERROR: - click.echo(config_path + ":") - click.echo(yaml.dump(config, default_flow_style=False)) - except Exception as exc: - _show_example_config() - raise KedroCliError(f"Failed to parse {config_path}.") from exc - - return config - - -def _validate_config_from_file(config_path: str, config: Dict[str, str]) -> None: +def _validate_config_file(config: Dict[str, str], prompts: Dict[str, Any]): """Checks that the configuration file contains all needed variables. Args: - config_path: The path of the config file. config: The config as a dictionary. + prompts: Prompts from prompts.yml. Raises: - KedroCliError: If the config file is empty or does not contain all - keys from template/cookiecutter.json and output_dir. + KedroCliError: If the config file is empty or does not contain all the keys + required in prompts, or if the output_dir specified does not exist. """ if config is None: - _show_example_config() - raise KedroCliError(config_path + " is empty") - - mandatory_keys = set(_parse_prompts_from_file(TEMPLATE_PATH / "prompts.yml").keys()) - mandatory_keys.add("output_dir") - missing_keys = mandatory_keys - set(config.keys()) + raise KedroCliError("Config file is empty.") + missing_keys = set(prompts) - set(config) if missing_keys: - click.echo(f"\n{config_path}:") click.echo(yaml.dump(config, default_flow_style=False)) - _show_example_config() - - raise KedroCliError(f"{', '.join(missing_keys)} not found in {config_path}") - + raise KedroCliError(f"{', '.join(missing_keys)} not found in config file.") -def _parse_prompts_from_file(prompts_yml: Path) -> Dict[str, Dict[str, str]]: - try: - with prompts_yml.open() as prompts: - return yaml.safe_load(prompts) - # we don't want the user to see a stack trace on the cli - except Exception as exc: # pragma: no cover - raise KedroCliError("Failed to generate project.") from exc - - -def _assert_output_dir_ok(output_dir: Path): - """Checks that output directory exists. - - Args: - output_dir: Output directory path. - - Raises: - KedroCliError: If the output directory does not exist. - """ - if not output_dir.exists(): - message = ( - f"`{output_dir}` is not a valid output directory. " - f"It must be a relative or absolute path " - f"to an existing directory." - ) - raise KedroCliError(message) - - -def _show_example_config(): - click.secho("Example of valid config.yml:") - prompts_config = _parse_prompts_from_file(TEMPLATE_PATH / "prompts.yml") - for key, value in prompts_config.items(): - click.secho( - click.style(key + ": ", bold=True, fg="yellow") - + click.style(str(value), fg="cyan") + if "output_dir" in config and not Path(config["output_dir"]).exists(): + raise KedroCliError( + f"`{config['output_dir']}` is not a valid output directory. " + "It must be a relative or absolute path to an existing directory." ) - click.echo("") - - -def _print_kedro_new_success_message(result_path): - click.secho( - f"\nChange directory to the project generated in {result_path}", fg="green", - ) - click.secho( - "\nA best-practice setup includes initialising git and creating " - "a virtual environment before running ``kedro install`` to install " - "project-specific dependencies. Refer to the Kedro documentation: " - "https://kedro.readthedocs.io/" - ) diff --git a/tests/framework/cli/test_starters.py b/tests/framework/cli/test_starters.py index fdfd56436f..c553675d65 100644 --- a/tests/framework/cli/test_starters.py +++ b/tests/framework/cli/test_starters.py @@ -39,11 +39,7 @@ from cookiecutter.exceptions import RepositoryCloneFailed from kedro import __version__ as version -from kedro.framework.cli.starters import ( - _STARTER_ALIASES, - TEMPLATE_PATH, - _parse_prompts_from_file, -) +from kedro.framework.cli.starters import _STARTER_ALIASES, TEMPLATE_PATH FILES_IN_TEMPLATE = 37 @@ -59,10 +55,10 @@ def _make_cli_prompt_input( def _assert_template_ok( result, files_in_template, - repo_name=None, project_name="New Kedro Project", + repo_name=None, + python_package="python_package", output_dir=".", - package_name="python_package", ): assert result.exit_code == 0, result.output assert "Change directory to the project generated in" in result.output @@ -87,8 +83,8 @@ def _assert_template_ok( with (full_path / "src" / "requirements.txt").open() as file: assert version in file.read() - if package_name: - assert (full_path / "src" / package_name / "__init__.py").is_file() + if python_package: + assert (full_path / "src" / python_package / "__init__.py").is_file() @pytest.fixture(autouse=True) @@ -115,8 +111,8 @@ def test_new(self, fake_kedro_cli): _assert_template_ok( result, FILES_IN_TEMPLATE, - repo_name=self.repo_name, project_name=project_name, + repo_name=self.repo_name, ) def test_new_custom_dir(self, fake_kedro_cli): @@ -127,17 +123,6 @@ def test_new_custom_dir(self, fake_kedro_cli): ["new"], input=_make_cli_prompt_input(repo_name=self.repo_name), ) - _assert_template_ok( - result, FILES_IN_TEMPLATE, repo_name=self.repo_name, - ) - - def test_new_correct_path(self, fake_kedro_cli): - """Test new project creation with the default project name.""" - result = CliRunner().invoke( - fake_kedro_cli, - ["new"], - input=_make_cli_prompt_input(repo_name=self.repo_name), - ) _assert_template_ok(result, FILES_IN_TEMPLATE, repo_name=self.repo_name) def test_fail_if_dir_exists(self, fake_kedro_cli): @@ -235,10 +220,10 @@ def test_new_from_config(self, fake_kedro_cli): _assert_template_ok( result, FILES_IN_TEMPLATE, - self.repo_name, self.project_name, - output_dir, + self.repo_name, self.repo_name.replace("-", "_"), + output_dir, ) def test_wrong_config(self, fake_kedro_cli): @@ -254,7 +239,6 @@ def test_wrong_config(self, fake_kedro_cli): assert result.exit_code != 0 assert "is not a valid output directory." in result.output - @pytest.mark.xfail def test_config_missing_key(self, fake_kedro_cli, tmp_path): """Check the error if keys are missing from config file.""" output_dir = tmp_path / "test_dir" @@ -275,7 +259,7 @@ def test_config_missing_key(self, fake_kedro_cli, tmp_path): ) assert result.exit_code != 0 - assert "extra_key not found" in result.output # pragma: no cover + assert "extra_key not found" in result.output def test_bad_yaml(self, fake_kedro_cli): """Check the error if config YAML is invalid.""" @@ -286,21 +270,7 @@ def test_bad_yaml(self, fake_kedro_cli): fake_kedro_cli, ["new", "-v", "-c", self.config_path] ) assert result.exit_code != 0 - assert "Failed to parse config.yml" in result.output - - def test_missing_output_dir(self, fake_kedro_cli): - """Check the error if config YAML does not contain the output - directory.""" - _create_config_file( - self.config_path, self.project_name, self.repo_name, output_dir=None - ) # output dir missing - result = CliRunner().invoke( - fake_kedro_cli, ["new", "-v", "--config", self.config_path] - ) - - assert result.exit_code != 0 - assert "output_dir not found in" in result.output - assert not Path(self.repo_name).exists() + assert "Failed to generate project: could not load config" in result.output def test_default_config_up_to_date(): @@ -311,7 +281,8 @@ def test_default_config_up_to_date(): cookie_keys = [ key for key in cookie if not key.startswith("_") and key != "kedro_version" ] - default_config_keys = _parse_prompts_from_file(TEMPLATE_PATH / "prompts.yml").keys() + with open(TEMPLATE_PATH / "prompts.yml") as prompts: + default_config_keys = yaml.safe_load(prompts) assert set(cookie_keys) == set(default_config_keys) @@ -322,7 +293,7 @@ class TestNewWithStarter: package_name = "package_test" project_name = "Test" - def test_new_with_valid_starter(self, fake_kedro_cli, tmp_path): + def test_new_with_valid_starter_absolute_path(self, fake_kedro_cli, tmp_path): starter_path = tmp_path / "starter" shutil.copytree(TEMPLATE_PATH, str(starter_path)) @@ -338,9 +309,30 @@ def test_new_with_valid_starter(self, fake_kedro_cli, tmp_path): _assert_template_ok( result, FILES_IN_TEMPLATE, + project_name=self.project_name, repo_name=self.repo_name, + python_package=self.package_name, + ) + + def test_new_with_valid_starter_relative_path(self, fake_kedro_cli, tmp_path): + starter_path = tmp_path / "starter" + shutil.copytree(TEMPLATE_PATH, str(starter_path)) + + result = CliRunner().invoke( + fake_kedro_cli, + ["new", "-v", "--starter", "starter"], + _make_cli_prompt_input( + project_name=self.project_name, + python_package=self.package_name, + repo_name=self.repo_name, + ), + ) + _assert_template_ok( + result, + FILES_IN_TEMPLATE, project_name=self.project_name, - package_name=self.package_name, + repo_name=self.repo_name, + python_package=self.package_name, ) def test_new_with_invalid_starter_path_should_raise(self, fake_kedro_cli): @@ -396,6 +388,7 @@ def test_new_starter_with_checkout(self, fake_kedro_cli, mocker): checkout_version = "some-version" output_dir = str(Path.cwd()) mocker.patch("cookiecutter.repository.repository_has_cookiecutter_json") + mocker.patch("cookiecutter.generate.generate_context") mocked_cookiecutter = mocker.patch( "cookiecutter.main.cookiecutter", return_value=starter_path ) @@ -419,51 +412,12 @@ def test_new_starter_with_checkout(self, fake_kedro_cli, mocker): def test_new_starter_with_checkout_invalid_checkout(self, fake_kedro_cli, mocker): starter_path = "some-starter" - checkout_version = "some-version" - mocker.patch("cookiecutter.repository.repository_has_cookiecutter_json") - mocked_cookiecutter = mocker.patch( - "cookiecutter.main.cookiecutter", side_effect=RepositoryCloneFailed - ) - result = CliRunner().invoke( - fake_kedro_cli, - ["new", "--starter", starter_path, "--checkout", checkout_version], - input=_make_cli_prompt_input( - project_name=self.project_name, - python_package=self.package_name, - repo_name=self.repo_name, - ), - ) - assert result.exit_code - assert ( - f"Kedro project template not found at {starter_path} with tag {checkout_version}" - in result.output - ) - output_dir = str(Path.cwd()) - mocked_cookiecutter.assert_called_once_with( - starter_path, - checkout=checkout_version, - extra_context={"kedro_version": version}, - no_input=True, - output_dir=output_dir, - ) - - # TODO: merge this with test_new_starter_with_checkout_invalid_checkout_alternative_tags - # once _obtain_config_from_prompts/_create_project error handling tidied - @pytest.mark.parametrize("starter_path", ["some-starter", "git+some-starter"]) - def test_new_starter_with_checkout_invalid_checkout_alternative_tags_determine( - self, fake_kedro_cli, mocker, starter_path - ): checkout_version = "some-version" mocker.patch("cookiecutter.repository.repository_has_cookiecutter_json") mocker.patch( "cookiecutter.repository.determine_repo_dir", side_effect=RepositoryCloneFailed, ) - - mocked_git = mocker.patch("kedro.framework.cli.starters.git") - alternative_tags = "version1\nversion2" - mocked_git.cmd.Git.return_value.ls_remote.return_value = alternative_tags - result = CliRunner().invoke( fake_kedro_cli, ["new", "--starter", starter_path, "--checkout", checkout_version], @@ -474,15 +428,11 @@ def test_new_starter_with_checkout_invalid_checkout_alternative_tags_determine( ), ) assert result.exit_code - tags = sorted(set(alternative_tags.split("\n"))) pattern = ( - f"Kedro project template not found at {starter_path} with tag {checkout_version}. " - f"The following tags are available: {', '.join(tags)}" + f"Kedro project template not found at {starter_path}. " + f"Specified tag {checkout_version}" ) assert pattern in result.output - mocked_git.cmd.Git.return_value.ls_remote.assert_called_once_with( - "--tags", starter_path.replace("git+", "") - ) @pytest.mark.parametrize("starter_path", ["some-starter", "git+some-starter"]) def test_new_starter_with_checkout_invalid_checkout_alternative_tags( @@ -491,8 +441,10 @@ def test_new_starter_with_checkout_invalid_checkout_alternative_tags( checkout_version = "some-version" mocker.patch("cookiecutter.repository.repository_has_cookiecutter_json") mocker.patch( - "cookiecutter.main.cookiecutter", side_effect=RepositoryCloneFailed + "cookiecutter.repository.determine_repo_dir", + side_effect=RepositoryCloneFailed, ) + mocked_git = mocker.patch("kedro.framework.cli.starters.git") alternative_tags = "version1\nversion2" mocked_git.cmd.Git.return_value.ls_remote.return_value = alternative_tags @@ -509,8 +461,8 @@ def test_new_starter_with_checkout_invalid_checkout_alternative_tags( assert result.exit_code tags = sorted(set(alternative_tags.split("\n"))) pattern = ( - f"Kedro project template not found at {starter_path} with tag {checkout_version}. " - f"The following tags are available: {', '.join(tags)}" + f"Kedro project template not found at {starter_path}. " + f"Specified tag {checkout_version}. The following tags are available: {', '.join(tags)}" ) assert pattern in result.output mocked_git.cmd.Git.return_value.ls_remote.assert_called_once_with( @@ -594,7 +546,7 @@ def test_prompt_user_for_config(self, fake_kedro_cli, mocker): checkout=version, ) - def test_raise_error_when_prompt_invalid(self, fake_kedro_cli, tmp_path): + def test_raise_error_when_prompt_no_title(self, fake_kedro_cli, tmp_path): starter_path = tmp_path / "starter" shutil.copytree(TEMPLATE_PATH, str(starter_path)) with open(starter_path / "prompts.yml", "w+") as prompts_file: @@ -614,6 +566,25 @@ def test_raise_error_when_prompt_invalid(self, fake_kedro_cli, tmp_path): "Each prompt must have a title field to be valid" in result.output ), result.output + def test_raise_error_when_prompt_invalid_yaml(self, fake_kedro_cli, tmp_path): + starter_path = tmp_path / "starter" + shutil.copytree(TEMPLATE_PATH, str(starter_path)) + (starter_path / "prompts.yml").write_text("invalid\tyaml") + + result = CliRunner().invoke( + fake_kedro_cli, + ["new", "-v", "--starter", str(starter_path)], + input=_make_cli_prompt_input( + project_name=self.project_name, + python_package=self.package_name, + repo_name=self.repo_name, + ), + ) + assert result.exit_code != 0 + assert ( + "Failed to generate project: could not load prompts.yml." in result.output + ), result.output + def test_starter_list(self, fake_kedro_cli): """Check that `kedro starter list` prints out all starter aliases.""" result = CliRunner().invoke(fake_kedro_cli, ["starter", "list"])