Repository: kedro-org/kedro

Issue:
Add `kedro-viz` to add-ons options
## Description
`Kedro Viz` should be added to the add-ons options, allowing users to get setup for viz (see https://github.com/kedro-org/kedro/issues/2506#issuecomment-1717432995). This option should make use of the new `spaceflights-pandas-viz` starter: https://github.com/kedro-org/kedro/issues/2985

When a user selects `viz` they should get the config/requirements needed to run Kedro viz on their project. This shouldn't include the example pipelines. This will happen in a follow up ticket. 

## Possible Implementation
This solution is likely going to be the same as for the other `add-ons` work, leveraging the post-project hooks. Since we'd use the spaceflights-pandas-viz starter and not the regular template, we probably need to copy some of the logic from the post-project hook in the regular template to the starter. 

## Possible Alternatives
N/A


Discussion:
noklam: I think this should be merged first - https://github.com/kedro-org/kedro/pull/3230. The current test suite is still asserting text, we should read the parse the file.
merelcht: One thought I had while looking at the code is whether we can leverage the existing `delete_pipeline` functionality to remove the pipeline and parameter files: https://github.com/kedro-org/kedro/blob/main/kedro/framework/cli/pipeline.py#L143
merelcht: Should https://github.com/kedro-org/kedro-starters/blob/main/spaceflights-pyspark/hooks/post_gen_project.py be updated to also mention "Kedro Viz" not in addons? The opposite of https://github.com/kedro-org/kedro-starters/blob/main/spaceflights-pandas-viz/hooks/post_gen_project.py
SajidAlamQB: > Should https://github.com/kedro-org/kedro-starters/blob/main/spaceflights-pyspark/hooks/post_gen_project.py be updated to also mention "Kedro Viz" not in addons? The opposite of https://github.com/kedro-org/kedro-starters/blob/main/spaceflights-pandas-viz/hooks/post_gen_project.py

Nice spot! I don't think it really matters as the templates are decided in `starters.py` so where the add-ons work is concerned its not a problem. This was more of guard for users using the `--starter` flags as the default add-on in starters is `none` so as long as we have a check for any other add-on it won't run. But for consistency I think it would be nice to make them all aligned - it’s cleaner that way!

noklam: I have fix the tests with `toml` and add some docstring for the new utils question. I leave a question whether these should be all public methods and maybe worth refactor later but not necessary to fix this in this PR.

Documented my observation in https://github.com/kedro-org/kedro/issues/3264



Patch:
diff --git a/kedro/framework/cli/starters.py b/kedro/framework/cli/starters.py
index 1442a862..c38d5540 100644
--- a/kedro/framework/cli/starters.py
+++ b/kedro/framework/cli/starters.py
@@ -108,7 +108,8 @@ Add-Ons\n
 3) Custom Logging: Provides more logging options\n
 4) Documentation: Basic documentation setup with Sphinx\n
 5) Data Structure: Provides a directory structure for storing data\n
-6) Pyspark: Provides a basic PySpark set up\n
+6) Pyspark: Provides set up configuration for working with PySpark\n
+7) Kedro Viz: Provides Kedro's native visualisation tool \n
 
 Example usage:\n
 kedro new --addons=lint,test,log,docs,data,pyspark (or any subset of these options)\n
@@ -123,6 +124,7 @@ ADD_ONS_SHORTNAME_TO_NUMBER = {
     "docs": "4",
     "data": "5",
     "pyspark": "6",
+    "viz": "7",
 }
 NUMBER_TO_ADD_ONS_NAME = {
     "1": "Linting",
@@ -131,6 +133,7 @@ NUMBER_TO_ADD_ONS_NAME = {
     "4": "Documentation",
     "5": "Data Structure",
     "6": "Pyspark",
+    "7": "Kedro Viz",
 }
 
 
@@ -223,7 +226,7 @@ def _parse_add_ons_input(add_ons_str: str):
     def _validate_selection(add_ons: list[str]):
         for add_on in add_ons:
             if add_on not in NUMBER_TO_ADD_ONS_NAME:
-                message = f"'{add_on}' is not a valid selection.\nPlease select from the available add-ons: 1, 2, 3, 4, 5, 6."  # nosec
+                message = f"'{add_on}' is not a valid selection.\nPlease select from the available add-ons: 1, 2, 3, 4, 5, 6, 7."  # nosec
                 click.secho(message, fg="red", err=True)
                 sys.exit(1)
 
@@ -475,7 +478,7 @@ def _select_prompts_to_display(
         for addon in addons:
             if addon not in valid_addons:
                 click.secho(
-                    "Please select from the available add-ons: lint, test, log, docs, data, pyspark, all, none",
+                    "Please select from the available add-ons: lint, test, log, docs, data, pyspark, viz, all, none",
                     fg="red",
                     err=True,
                 )
@@ -581,11 +584,24 @@ def _make_cookiecutter_args(
 def fetch_template_based_on_add_ons(template_path, cookiecutter_args: dict[str, Any]):
     extra_context = cookiecutter_args["extra_context"]
     add_ons = extra_context.get("add_ons")
-    if add_ons and "Pyspark" in add_ons:
-        cookiecutter_args["directory"] = "spaceflights-pyspark"
-        pyspark_path = "git+https://github.com/kedro-org/kedro-starters.git"
-        return pyspark_path
-    return template_path
+    starter_path = "git+https://github.com/kedro-org/kedro-starters.git"
+    if add_ons:
+        if "Pyspark" in add_ons and "Kedro Viz" in add_ons:
+            # Use the spaceflights-pyspark-viz starter if both Pyspark and Kedro Viz are chosen.
+            cookiecutter_args["directory"] = "spaceflights-pyspark-viz"
+        elif "Pyspark" in add_ons:
+            # Use the spaceflights-pyspark starter if only Pyspark is chosen.
+            cookiecutter_args["directory"] = "spaceflights-pyspark"
+        elif "Kedro Viz" in add_ons:
+            # Use the spaceflights-pandas-viz starter if only Kedro Viz is chosen.
+            cookiecutter_args["directory"] = "spaceflights-pandas-viz"
+        else:
+            # Use the default template path for any other combinations or if "none" is chosen.
+            starter_path = template_path
+    else:
+        # Use the default template path if add_ons is None, which can occur if there is no prompts.yml or its empty.
+        starter_path = template_path
+    return starter_path
 
 
 def _create_project(template_path: str, cookiecutter_args: dict[str, Any]):
@@ -619,11 +635,9 @@ def _create_project(template_path: str, cookiecutter_args: dict[str, Any]):
     )
     add_ons = extra_context.get("add_ons")
 
-    # Only core template and spaceflights-pyspark have configurable add-ons
-    if (
-        template_path == str(TEMPLATE_PATH)
-        or add_ons is not None
-        and "Pyspark" in add_ons
+    # Only core template and spaceflight starters have configurable add-ons
+    if template_path == str(TEMPLATE_PATH) or (
+        add_ons and ("Pyspark" in add_ons or "Kedro Viz" in add_ons)
     ):
         if add_ons == "[]":  # TODO: This should be a list
             click.secho("\nYou have selected no add-ons")
diff --git a/kedro/templates/project/hooks/utils.py b/kedro/templates/project/hooks/utils.py
index 00a232bf..77ca9a36 100644
--- a/kedro/templates/project/hooks/utils.py
+++ b/kedro/templates/project/hooks/utils.py
@@ -1,140 +1,189 @@
 from pathlib import Path
 import shutil
-import sys
-import click
+import toml
 
 current_dir = Path.cwd()
 
-lint_requirements = "black~=22.12.0\nruff~=0.0.290\n"
-lint_pyproject_requirements = """
-[tool.ruff]
-select = [
-    "F",  # Pyflakes
-    "E",  # Pycodestyle
-    "W",  # Pycodestyle
-    "UP",  # pyupgrade
-    "I",  # isort
-    "PL", # Pylint
-]
-ignore = ["E501"]  # Black takes care of line-too-long
-"""
-
-test_requirements = "pytest-cov~=3.0\npytest-mock>=1.7.1, <2.0\npytest~=7.2"
-test_pyproject_requirements = """
-[tool.pytest.ini_options]
-addopts = \"\"\"
---cov-report term-missing \\
---cov src/{{ cookiecutter.python_package }} -ra
-\"\"\"
-
-[tool.coverage.report]
-fail_under = 0
-show_missing = true
-exclude_lines = ["pragma: no cover", "raise NotImplementedError"]
-"""
-
-docs_pyproject_requirements = """
-[project.optional-dependencies]
-docs = [
-    "docutils<0.18.0",
-    "sphinx~=3.4.3",
-    "sphinx_rtd_theme==0.5.1",
-    "nbsphinx==0.8.1",
-    "sphinx-autodoc-typehints==1.11.1",
-    "sphinx_copybutton==0.3.1",
-    "ipykernel>=5.3, <7.0",
-    "Jinja2<3.1.0",
-    "myst-parser~=0.17.2",
-]
-"""
-
-
-def setup_template_add_ons(selected_add_ons_list, requirements_file_path, pyproject_file_path, python_package_name):
-    """Removes directories and files related to unwanted addons from
-    a Kedro project template. Adds the necessary requirements for
-    the addons that were selected.
+# Requirements for linting tools
+lint_requirements = "black~=22.0\nruff~=0.0.290\n"  # For requirements.txt
+lint_pyproject_requirements = ["tool.ruff"]  # For pyproject.toml
+
+# Requirements and configurations for testing tools and coverage reporting
+test_requirements = "pytest-cov~=3.0\npytest-mock>=1.7.1, <2.0\npytest~=7.2"  # For requirements.txt
+test_pyproject_requirements = ["tool.pytest.ini_options", "tool.coverage.report"]  # For pyproject.toml
+
+# Configuration key for documentation dependencies
+docs_pyproject_requirements = ["project.optional-dependencies"]  # For pyproject.toml
+
+
+# Helper Functions
+def _remove_from_file(file_path: Path, content_to_remove: str) -> None:
+    """Remove specified content from the file.
 
     Args:
-        selected_add_ons_list: a list containing numbers from 1 to 5,
-            representing specific add-ons.
-        requirements_file_path: the path to the requirements.txt file.
-        pyproject_file_path: the path to the pyproject.toml file
-            located on the the root of the template.
+        file_path (Path): The path of the file from which to remove content.
+        content_to_remove (str): The content to be removed from the file.
     """
-    if "Linting" not in selected_add_ons_list:
-        pass
+    with open(file_path, 'r') as file:
+        lines = file.readlines()
+
+    # Split the content to remove into lines and remove trailing whitespaces/newlines
+    content_to_remove_lines = [line.strip() for line in content_to_remove.split('\n')]
+
+    # Keep lines that are not in content_to_remove
+    lines = [line for line in lines if line.strip() not in content_to_remove_lines]
+
+    with open(file_path, 'w') as file:
+        file.writelines(lines)
+
+
+def _remove_nested_section(data: dict, nested_key: str) -> None:
+    """Remove a nested section from a dictionary representing a TOML file.
+
+    Args:
+        data (dict): The dictionary from which to remove the section.
+        nested_key (str): The dotted path key representing the nested section to remove.
+    """
+    keys = nested_key.split('.')
+    current_data = data
+    # Look for Parent section
+    for key in keys[:-1]:  # Iterate over all but last element
+        if key in current_data:
+            current_data = current_data[key]
+        else:
+            return  # Parent section not found, nothing to remove
+
+    # Remove the nested section and any empty parent sections
+    current_data.pop(keys[-1], None)  # Remove last element otherwise return None
+    for key in reversed(keys[:-1]):
+        parent_section = data
+        for key_part in keys[:keys.index(key)]:
+            parent_section = parent_section[key_part]
+        if not current_data:  # If the section is empty, remove it
+            parent_section.pop(key, None)
+            current_data = parent_section
+        else:
+            break  # If the section is not empty, stop removing
+
+
+def _remove_from_toml(file_path: Path, sections_to_remove: list) -> None:
+    """Remove specified sections from a TOML file.
+
+    Args:
+        file_path (Path): The path to the TOML file.
+        sections_to_remove (list): A list of section keys to remove from the TOML file.
+    """
+    # Load the TOML file
+    with open(file_path, 'r') as file:
+        data = toml.load(file)
+
+    # Remove the specified sections
+    for section in sections_to_remove:
+        _remove_nested_section(data, section)
+
+    with open(file_path, 'w') as file:
+        toml.dump(data, file)
+
+
+def _remove_dir(path: Path) -> None:
+    """Remove a directory if it exists.
+
+    Args:
+        path (Path): The path of the directory to remove.
+    """
+    if path.exists():
+        shutil.rmtree(str(path))
+
+
+def _remove_file(path: Path) -> None:
+    """Remove a file if it exists.
+
+    Args:
+        path (Path): The path of the file to remove.
+    """
+    if path.exists():
+        path.unlink()
+
+
+def _handle_starter_setup(selected_add_ons_list: str, python_package_name: str) -> None:
+    """Clean up the unnecessary files in the starters template.
+
+    Args:
+        selected_add_ons_list (str): A string contains the selected add-ons.
+        python_package_name (str): The name of the python package.
+    """
+    # Remove all .csv and .xlsx files from data/01_raw/
+    raw_data_path = current_dir / "data/01_raw/"
+    for file_path in raw_data_path.glob("*.*"):
+        if file_path.suffix in [".csv", ".xlsx"]:
+            file_path.unlink()
+
+    # Empty the contents of conf/base/catalog.yml
+    catalog_yml_path = current_dir / "conf/base/catalog.yml"
+    if catalog_yml_path.exists():
+        catalog_yml_path.write_text('')
+    # Remove parameter files from conf/base
+    conf_base_path = current_dir / "conf/base/"
+    parameter_file_patterns = ["parameters_*.yml", "parameters/*.yml"]
+    for pattern in parameter_file_patterns:
+        for param_file in conf_base_path.glob(pattern):
+            _remove_file(param_file)
+
+    # Remove the pipelines subdirectories
+    if "Kedro Viz" in selected_add_ons_list: # Remove reporting if Kedro Viz is selected
+        pipelines_to_remove = ["data_science", "data_processing", "reporting"]
     else:
-        with open(requirements_file_path, 'a') as file:
-            file.write(lint_requirements)
-        with open(pyproject_file_path, 'a') as file:
-            file.write(lint_pyproject_requirements)
+        pipelines_to_remove = ["data_science", "data_processing"]
+
+    pipelines_path = current_dir / f"src/{python_package_name}/pipelines/"
+    for pipeline_subdir in pipelines_to_remove:
+        _remove_dir(pipelines_path / pipeline_subdir)
+
+    # Remove all test files from tests/pipelines/
+    test_pipeline_path = current_dir / "tests/pipelines/test_data_science.py"
+    _remove_file(test_pipeline_path)
+
+
+def setup_template_add_ons(selected_add_ons_list: str, requirements_file_path: str, pyproject_file_path: str, python_package_name: str) -> None:
+    """Setup the templates according to the choice of add-ons.
+
+    Args:
+        selected_add_ons_list (str): A string contains the selected add-ons.
+        requirements_file_path (str): The path of the `requiremenets.txt` in the template.
+        pyproject_file_path (str): The path of the `pyproject.toml` in the template
+        python_package_name (str): The name of the python package.
+    """
+    if "Linting" not in selected_add_ons_list:
+        _remove_from_file(requirements_file_path, lint_requirements)
+        _remove_from_toml(pyproject_file_path, lint_pyproject_requirements)
 
     if "Testing" not in selected_add_ons_list:
-        tests_path = current_dir / "tests"
-        if tests_path.exists():
-            shutil.rmtree(str(tests_path))
-    else:
-        with open(requirements_file_path, 'a') as file:
-            file.write(test_requirements)
-        with open(pyproject_file_path, 'a') as file:
-            file.write(test_pyproject_requirements)
+        _remove_from_file(requirements_file_path, test_requirements)
+        _remove_from_toml(pyproject_file_path, test_pyproject_requirements)
+        _remove_dir(current_dir / "tests")
 
     if "Logging" not in selected_add_ons_list:
-        logging_yml_path = current_dir / "conf/logging.yml"
-        if logging_yml_path.exists():
-            logging_yml_path.unlink()
+        _remove_file(current_dir / "conf/logging.yml")
 
     if "Documentation" not in selected_add_ons_list:
-        docs_path = current_dir / "docs"
-        if docs_path.exists():
-            shutil.rmtree(str(docs_path))
-    else:
-        with open(pyproject_file_path, 'a') as file:
-            file.write(docs_pyproject_requirements)
+        _remove_from_toml(pyproject_file_path, docs_pyproject_requirements)
+        _remove_dir(current_dir / "docs")
 
     if "Data Structure" not in selected_add_ons_list:
-        data_path = current_dir / "data"
-        if data_path.exists():
-            shutil.rmtree(str(data_path))
-
-    if "Pyspark" not in selected_add_ons_list:  # If PySpark not selected
-        pass
-    else:  # Use spaceflights-pyspark to create pyspark template
-        # Remove all .csv and .xlsx files from data/01_raw/
-        raw_data_path = current_dir / "data/01_raw/"
-        if raw_data_path.exists() and raw_data_path.is_dir():
-            for file_path in raw_data_path.glob("*.*"):
-                if file_path.suffix in [".csv", ".xlsx"]:
-                    file_path.unlink()
-
-        # Remove parameter files from conf/base/
-        param_files = [
-            "parameters_data_processing.yml",
-            "parameters_data_science.yml",
-        ]
-        conf_base_path = current_dir / "conf/base/"
-        if conf_base_path.exists() and conf_base_path.is_dir():
-            for param_file in param_files:
-                file_path = conf_base_path / param_file
-                if file_path.exists():
-                    file_path.unlink()
-
-        # Remove specific pipeline subdirectories
-        pipelines_path = current_dir / f"src/{python_package_name}/pipelines/"
-        for pipeline_subdir in ["data_science", "data_processing"]:
-            shutil.rmtree(pipelines_path / pipeline_subdir, ignore_errors=True)
-
-        # Remove all test file from tests/pipelines/
-        test_pipeline_path = current_dir / "tests/pipelines/test_data_science.py"
-        if test_pipeline_path.exists():
-            test_pipeline_path.unlink()
-
-
-def sort_requirements(requirements_file_path):
-    """Sort the requirements.txt file in alphabetical order.
+        _remove_dir(current_dir / "data")
+
+    if "Pyspark" in selected_add_ons_list:
+        _handle_starter_setup(selected_add_ons_list, python_package_name)
+
+    if "Kedro Viz" in selected_add_ons_list:
+        _handle_starter_setup(selected_add_ons_list, python_package_name)
+
+
+def sort_requirements(requirements_file_path: Path) -> None:
+    """Sort the requirements.txt file alphabetically and write it back to the file.
 
     Args:
-        requirements_file_path: the path to the requirements.txt file.
+        requirements_file_path (Path): The path to the `requirements.txt` file.
     """
     with open(requirements_file_path, 'r') as requirements:
         lines = requirements.readlines()
diff --git a/kedro/templates/project/prompts.yml b/kedro/templates/project/prompts.yml
index 1cee3def..3889b03c 100644
--- a/kedro/templates/project/prompts.yml
+++ b/kedro/templates/project/prompts.yml
@@ -10,9 +10,10 @@ add_ons:
     3) Custom Logging : Provides more logging options
     4) Documentation: Provides basic documentations setup with Sphinx
     5) Data Structure: Provides a directory structure for storing data
-    6) PySpark : Provides a basic PySpark set up
+    6) Pyspark: Provides set up configuration for working with PySpark
+    7) Kedro Viz: Provides Kedro's native visualisation tool
 
-    Which add-ons would you like to include in your project? [1-6/1,3/all/none]:
+    Which add-ons would you like to include in your project? [1-7/1,3/all/none]:
   regex_validator: "^(all|none|(\\d(,\\d)*|(\\d-\\d)))$"
   error_message: |
     Invalid input. Please select valid options for add-ons using comma-separated values, ranges, or 'all/none'.
diff --git a/kedro/templates/project/{{ cookiecutter.repo_name }}/conf/local/.gitkeep b/kedro/templates/project/{{ cookiecutter.repo_name }}/conf/local/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/kedro/templates/project/{{ cookiecutter.repo_name }}/pyproject.toml b/kedro/templates/project/{{ cookiecutter.repo_name }}/pyproject.toml
index e4c007e3..92810db8 100644
--- a/kedro/templates/project/{{ cookiecutter.repo_name }}/pyproject.toml
+++ b/kedro/templates/project/{{ cookiecutter.repo_name }}/pyproject.toml
@@ -12,6 +12,19 @@ dynamic = ["dependencies", "version"]
 
 [project.entry-points."kedro.hooks"]
 
+[project.optional-dependencies]
+docs = [
+    "docutils<0.18.0",
+    "sphinx~=3.4.3",
+    "sphinx_rtd_theme==0.5.1",
+    "nbsphinx==0.8.1",
+    "sphinx-autodoc-typehints==1.11.1",
+    "sphinx_copybutton==0.3.1",
+    "ipykernel>=5.3, <7.0",
+    "Jinja2<3.1.0",
+    "myst-parser~=0.17.2",
+]
+
 [tool.setuptools.dynamic]
 dependencies = {file = "requirements.txt"}
 version = {attr = "{{ cookiecutter.python_package }}.__version__"}
@@ -24,4 +37,28 @@ namespaces = false
 package_name = "{{ cookiecutter.python_package }}"
 project_name = "{{ cookiecutter.project_name }}"
 kedro_init_version = "{{ cookiecutter.kedro_version }}"
-add_ons = {{ cookiecutter.add_ons | string | replace("\'", "\"") }}
+add_ons = "{{ cookiecutter.add_ons | default('') | string | replace('\"', '\\\"') }}"
+
+[tool.pytest.ini_options]
+addopts = """
+--cov-report term-missing \
+--cov src/{{ cookiecutter.python_package }} -ra"""
+
+[tool.coverage.report]
+fail_under = 0
+show_missing = true
+exclude_lines = ["pragma: no cover", "raise NotImplementedError"]
+
+[tool.ruff]
+line-length = 88
+show-fixes = true
+select = [
+    "F",   # Pyflakes
+    "W",   # pycodestyle
+    "E",   # pycodestyle
+    "I",   # isort
+    "UP",  # pyupgrade
+    "PL",  # Pylint
+    "T201", # Print Statement
+]
+ignore = ["E501"]  # Black takes care of line-too-long
diff --git a/kedro/templates/project/{{ cookiecutter.repo_name }}/requirements.txt b/kedro/templates/project/{{ cookiecutter.repo_name }}/requirements.txt
index e1bf2945..ea1a3454 100644
--- a/kedro/templates/project/{{ cookiecutter.repo_name }}/requirements.txt
+++ b/kedro/templates/project/{{ cookiecutter.repo_name }}/requirements.txt
@@ -1,9 +1,13 @@
-ipython>=7.31.1, <8.0; python_version < '3.8'
+black~=22.0
 ipython~=8.10; python_version >= '3.8'
 jupyter~=1.0
 jupyterlab_server>=2.11.1, <2.16.0
 jupyterlab~=3.0, <3.6.0
 kedro~={{ cookiecutter.kedro_version }}
 kedro-telemetry~=0.2.0
+pytest-cov~=3.0
+pytest-mock>=1.7.1, <2.0
+pytest~=7.2
+ruff~=0.0.290
 # Pin problematic traitlets release -  https://github.com/jupyter/notebook/issues/7048
 traitlets<5.10.0


Test patch:
diff --git a/tests/framework/cli/test_starters.py b/tests/framework/cli/test_starters.py
index b1893d1d..9f2e6ecb 100644
--- a/tests/framework/cli/test_starters.py
+++ b/tests/framework/cli/test_starters.py
@@ -6,6 +6,7 @@ import shutil
 from pathlib import Path
 
 import pytest
+import toml
 import yaml
 from click.testing import CliRunner
 from cookiecutter.exceptions import RepositoryCloneFailed
@@ -19,7 +20,7 @@ from kedro.framework.cli.starters import (
     _parse_add_ons_input,
 )
 
-FILES_IN_TEMPLATE_WITH_NO_ADD_ONS = 14
+FILES_IN_TEMPLATE_WITH_NO_ADD_ONS = 15
 
 
 @pytest.fixture
@@ -71,12 +72,13 @@ def _make_cli_prompt_input_without_name(
 
 def _get_expected_files(add_ons: str):
     add_ons_template_files = {
-        "1": 0,
-        "2": 3,
-        "3": 1,
-        "4": 2,
-        "5": 8,
-        "6": 3,
+        "1": 0,  # Linting does not add any files
+        "2": 3,  # If Testing is selected, we add 2 init.py files and 1 test_run.py
+        "3": 1,  # If Logging is selected, we add logging.py
+        "4": 2,  # If Documentation is selected, we add conf.py and index.rst
+        "5": 8,  # If Data Structure is selected, we add 8 .gitkeep files
+        "6": 2,  # If Pyspark is selected, we add spark.yml and hooks.py
+        "7": 0,  # Kedro Viz does not add any files
     }  # files added to template by each add-on
     add_ons_list = _parse_add_ons_input(add_ons)
 
@@ -94,7 +96,6 @@ def _assert_requirements_ok(
     repo_name="new-kedro-project",
     output_dir=".",
 ):
-
     assert result.exit_code == 0, result.output
     assert "Change directory to the project generated in" in result.output
 
@@ -111,26 +112,18 @@ def _assert_requirements_ok(
         assert "black" in requirements
         assert "ruff" in requirements
 
-        with open(pyproject_file_path) as pyproject_file:
-            requirements = pyproject_file.read()
-
-        assert (
-            (
-                """
-[tool.ruff]
-select = [
-    "F",  # Pyflakes
-    "E",  # Pycodestyle
-    "W",  # Pycodestyle
-    "UP",  # pyupgrade
-    "I",  # isort
-    "PL", # Pylint
-]
-ignore = ["E501"]  # Black takes care of line-too-long
-"""
-            )
-            in requirements
-        )
+        pyproject_config = toml.load(pyproject_file_path)
+        expected = {
+            "tool": {
+                "ruff": {
+                    "line-length": 88,
+                    "show-fixes": True,
+                    "select": ["F", "W", "E", "I", "UP", "PL", "T201"],
+                    "ignore": ["E501"],
+                }
+            }
+        }
+        assert expected["tool"]["ruff"] == pyproject_config["tool"]["ruff"]
 
     if "2" in add_ons_list:
         with open(requirements_file_path) as requirements_file:
@@ -140,48 +133,44 @@ ignore = ["E501"]  # Black takes care of line-too-long
         assert "pytest-mock>=1.7.1, <2.0" in requirements
         assert "pytest~=7.2" in requirements
 
-        with open(pyproject_file_path) as pyproject_file:
-            requirements = pyproject_file.read()
-
-        assert (
-            (
-                """
-[tool.pytest.ini_options]
-addopts = \"\"\"
---cov-report term-missing \\
---cov src/{{ cookiecutter.python_package }} -ra
-\"\"\"
-
-[tool.coverage.report]
-fail_under = 0
-show_missing = true
-exclude_lines = ["pragma: no cover", "raise NotImplementedError"]
-"""
-            )
-            in requirements
-        )
+        pyproject_config = toml.load(pyproject_file_path)
+        expected = {
+            "pytest": {
+                "ini_options": {
+                    "addopts": "--cov-report term-missing --cov src/new_kedro_project -ra"
+                }
+            },
+            "coverage": {
+                "report": {
+                    "fail_under": 0,
+                    "show_missing": True,
+                    "exclude_lines": ["pragma: no cover", "raise NotImplementedError"],
+                }
+            },
+        }
+        assert expected["pytest"] == pyproject_config["tool"]["pytest"]
+        assert expected["coverage"] == pyproject_config["tool"]["coverage"]
 
     if "4" in add_ons_list:
-        with open(pyproject_file_path) as pyproject_file:
-            requirements = pyproject_file.read()
-
+        pyproject_config = toml.load(pyproject_file_path)
+        expected = {
+            "optional-dependencies": {
+                "docs": [
+                    "docutils<0.18.0",
+                    "sphinx~=3.4.3",
+                    "sphinx_rtd_theme==0.5.1",
+                    "nbsphinx==0.8.1",
+                    "sphinx-autodoc-typehints==1.11.1",
+                    "sphinx_copybutton==0.3.1",
+                    "ipykernel>=5.3, <7.0",
+                    "Jinja2<3.1.0",
+                    "myst-parser~=0.17.2",
+                ]
+            }
+        }
         assert (
-            (
-                """
-docs = [
-    "docutils<0.18.0",
-    "sphinx~=3.4.3",
-    "sphinx_rtd_theme==0.5.1",
-    "nbsphinx==0.8.1",
-    "sphinx-autodoc-typehints==1.11.1",
-    "sphinx_copybutton==0.3.1",
-    "ipykernel>=5.3, <7.0",
-    "Jinja2<3.1.0",
-    "myst-parser~=0.17.2",
-]
-"""
-            )
-            in requirements
+            expected["optional-dependencies"]["docs"]
+            == pyproject_config["project"]["optional-dependencies"]["docs"]
         )
 
 
@@ -195,7 +184,6 @@ def _assert_template_ok(
     kedro_version=version,
     output_dir=".",
 ):
-
     assert result.exit_code == 0, result.output
     assert "Change directory to the project generated in" in result.output
 
@@ -276,7 +264,7 @@ def test_starter_list_with_invalid_starter_plugin(
         ("1,2,3", ["1", "2", "3"]),
         ("2-4", ["2", "3", "4"]),
         ("3-3", ["3"]),
-        ("all", ["1", "2", "3", "4", "5", "6"]),
+        ("all", ["1", "2", "3", "4", "5", "6", "7"]),
         ("none", []),
     ],
 )
@@ -298,12 +286,12 @@ def test_parse_add_ons_invalid_range(input, capsys):
 
 @pytest.mark.parametrize(
     "input,first_invalid",
-    [("0,3,5", "0"), ("1,3,7", "7"), ("0-4", "0"), ("3-7", "7")],
+    [("0,3,5", "0"), ("1,3,8", "8"), ("0-4", "0"), ("3-8", "8")],
 )
 def test_parse_add_ons_invalid_selection(input, first_invalid, capsys):
     with pytest.raises(SystemExit):
         _parse_add_ons_input(input)
-    message = f"'{first_invalid}' is not a valid selection.\nPlease select from the available add-ons: 1, 2, 3, 4, 5, 6."
+    message = f"'{first_invalid}' is not a valid selection.\nPlease select from the available add-ons: 1, 2, 3, 4, 5, 6, 7."
     assert message in capsys.readouterr().err
 
 
@@ -888,7 +876,7 @@ class TestFlagsNotAllowed:
 class TestAddOnsFromUserPrompts:
     @pytest.mark.parametrize(
         "add_ons",
-        ["1", "2", "3", "4", "5", "6", "none", "2,3,4", "3-5", "all"],
+        ["1", "2", "3", "4", "5", "6", "7", "none", "2,3,4", "3-5", "all"],
     )
     def test_valid_add_ons(self, fake_kedro_cli, add_ons):
         result = CliRunner().invoke(
@@ -920,15 +908,15 @@ class TestAddOnsFromUserPrompts:
 class TestAddOnsFromConfigFile:
     @pytest.mark.parametrize(
         "add_ons",
-        ["1", "2", "3", "4", "5", "6", "none", "2,3,4", "3-5", "all"],
+        ["1", "2", "3", "4", "5", "6", "7", "none", "2,3,4", "3-5", "all"],
     )
     def test_valid_add_ons(self, fake_kedro_cli, add_ons):
         """Test project created from config."""
         config = {
             "add_ons": add_ons,
-            "project_name": "My Project",
-            "repo_name": "my-project",
-            "python_package": "my_project",
+            "project_name": "New Kedro Project",
+            "repo_name": "new-kedro-project",
+            "python_package": "new_kedro_project",
         }
         _write_yaml(Path("config.yml"), config)
         result = CliRunner().invoke(
@@ -936,8 +924,8 @@ class TestAddOnsFromConfigFile:
         )
 
         _assert_template_ok(result, **config)
-        _assert_requirements_ok(result, add_ons=add_ons, repo_name="my-project")
-        _clean_up_project(Path("./my-project"))
+        _assert_requirements_ok(result, add_ons=add_ons, repo_name="new-kedro-project")
+        _clean_up_project(Path("./new-kedro-project"))
 
     def test_invalid_add_ons(self, fake_kedro_cli):
         """Test project created from config."""
@@ -971,6 +959,7 @@ class TestAddOnsFromCLI:
             "docs",
             "data",
             "pyspark",
+            "viz",
             "none",
             "test,log,docs",
             "test,data,lint",
@@ -1000,7 +989,7 @@ class TestAddOnsFromCLI:
 
         assert result.exit_code != 0
         assert (
-            "Please select from the available add-ons: lint, test, log, docs, data, pyspark, all, none"
+            "Please select from the available add-ons: lint, test, log, docs, data, pyspark, viz, all, none"
             in result.output
         )