Repository: meltano/meltano

Issue:
[Feature]: meltano add to project without install
### Feature scope

CLI (options, error messages, logging, etc.)

### Description

**As a user, I need to add to my meltano project without install**

Adding to a meltano project is currently a very slow operation.  Our requirement is to add all the plugins to the project and then later build (i.e. 'meltano install') the whole project.  This means that the initial 'meltano add' is unnecessarily slow as we don't use any of the pip-installed modules from the initial 'meltano add'

Work arounds considered:
- directly add a meltano.yml format file with our plugin definition to a directory with the root meltano.yml includes referencing that directory path & directly save a lock file to the /plugins directory  (essentially avoiding using 'meltano add' at all)


This proposal is to be able to add to the meltano project rapidly without installing all pip dependencies, and we'd ideally like to be able to freeze those dependencies.

`meltano add --skip-install` should
1. download the lock file
2. add to the meltano.yml
3. add all the lock and meltano.yml files for any `requires` plugins (https://github.com/meltano/hub/pull/505 & https://github.com/meltano/meltano/issues/6107)



Related to:
This feature is related to https://github.com/meltano/meltano/issues/6416
**As a user, I need to add to my meltano project without install and create pip freeze**

`meltano add --skip-install --freeze` consider whether this --skip-install works if --freeze is used (assuming https://github.com/meltano/meltano/issues/6416 might implement --freeze)



Discussion:

Resolving patch:
diff --git a/src/meltano/cli/add.py b/src/meltano/cli/add.py
--- a/src/meltano/cli/add.py
+++ b/src/meltano/cli/add.py
@@ -55,6 +55,11 @@
     is_flag=True,
     help="Add a custom plugin. The command will prompt you for the package's base plugin description metadata.",
 )
+@click.option(
+    "--no-install",
+    is_flag=True,
+    help="Do not install the plugin after adding it to the project.",
+)
 @pass_project()
 @click.pass_context
 def add(
@@ -140,11 +145,12 @@ def add(
     )
     tracker.track_command_event(CliEvent.inflight)
 
-    success = install_plugins(project, plugins, reason=PluginInstallReason.ADD)
+    if not flags.get("no_install"):
+        success = install_plugins(project, plugins, reason=PluginInstallReason.ADD)
 
-    if not success:
-        tracker.track_command_event(CliEvent.failed)
-        raise CliError("Failed to install plugin(s)")
+        if not success:
+            tracker.track_command_event(CliEvent.failed)
+            raise CliError("Failed to install plugin(s)")
 
     _print_plugins(plugins)
     tracker.track_command_event(CliEvent.completed)


Test patch:
diff --git a/tests/meltano/cli/test_add.py b/tests/meltano/cli/test_add.py
--- a/tests/meltano/cli/test_add.py
+++ b/tests/meltano/cli/test_add.py
@@ -16,6 +16,8 @@
 from meltano.core.plugin.error import PluginNotFoundError
 from meltano.core.plugin.project_plugin import ProjectPlugin
 from meltano.core.plugin_install_service import PluginInstallReason
+from meltano.core.project import Project
+from meltano.core.project_init_service import ProjectInitService
 
 
 class TestCliAdd:
@@ -27,6 +29,13 @@ def patch_hub(self, meltano_hub_service: MeltanoHubService):
         ):
             yield
 
+    @pytest.fixture
+    def reset_project_context(
+        self, project: Project, project_init_service: ProjectInitService
+    ):
+        shutil.rmtree(".", ignore_errors=True)
+        project_init_service.create_files(project)
+
     @pytest.mark.parametrize(
         "plugin_type,plugin_name,default_variant,required_plugin_refs",
         [
@@ -521,3 +530,89 @@ def test_add_custom_variant(
             plugin_variant = plugin_def.variants[0]
 
             assert plugin.variant == plugin_variant.name == "personal"
+
+    @pytest.mark.parametrize(
+        "plugin_type,plugin_name,default_variant,required_plugin_refs",
+        [
+            (PluginType.EXTRACTORS, "tap-carbon-intensity", "meltano", []),
+            (PluginType.LOADERS, "target-sqlite", "meltanolabs", []),
+            (PluginType.TRANSFORMS, "tap-carbon-intensity", "meltano", []),
+            (
+                PluginType.ORCHESTRATORS,
+                "airflow",
+                Variant.ORIGINAL_NAME,
+                [PluginRef(PluginType.FILES, "airflow")],
+            ),
+        ],
+        ids=[
+            "single-extractor",
+            "single-loader",
+            "transform-and-related",
+            "orchestrator-and-required",
+        ],
+    )
+    def test_add_no_install(
+        self,
+        plugin_type,
+        plugin_name,
+        default_variant,
+        required_plugin_refs,
+        project,
+        cli_runner,
+        project_plugins_service,
+        reset_project_context,
+    ):
+        # ensure the plugin is not present
+        with pytest.raises(PluginNotFoundError):
+            project_plugins_service.find_plugin(plugin_name, plugin_type=plugin_type)
+
+        with mock.patch("meltano.cli.add.install_plugins") as install_plugin_mock:
+            install_plugin_mock.return_value = True
+            res = cli_runner.invoke(
+                cli, ["add", plugin_type.singular, plugin_name, "--no-install"]
+            )
+
+            if plugin_type is PluginType.TRANSFORMS:
+                assert res.exit_code == 1, res.stdout
+                assert isinstance(res.exception, CliError)
+                assert "Dependencies not met:" in str(res.exception)
+            else:
+                assert res.exit_code == 0, res.stdout
+                assert f"Added {plugin_type.descriptor} '{plugin_name}'" in res.stdout
+
+                plugin = project_plugins_service.find_plugin(plugin_name, plugin_type)
+                assert plugin
+                assert plugin.variant == default_variant
+
+                # check plugin lock file is added
+                plugins_dir = project.root_dir("plugins")
+                assert plugins_dir.joinpath(
+                    f"{plugin_type}/{plugin_name}--{default_variant}.lock"
+                ).exists()
+
+                for required_plugin_ref in required_plugin_refs:
+                    if (required_plugin_ref._type) == PluginType.FILES and (
+                        required_plugin_ref.name == "dbt"
+                    ):
+                        # file bundles with no managed files are added but do not appear in meltano.yml
+                        assert (
+                            f"Adding required file bundle '{required_plugin_ref.name}'"
+                            in res.stdout
+                        )
+                    else:
+                        plugin = project_plugins_service.get_plugin(required_plugin_ref)
+                        assert plugin
+
+                        assert (
+                            f"Added required {plugin.type.descriptor} '{plugin.name}'"
+                            in res.stdout
+                        )
+
+                    # check required plugin lock files are added
+                    assert list(
+                        plugins_dir.glob(
+                            f"{required_plugin_ref._type}/{required_plugin_ref.name}--*.lock"
+                        )
+                    )
+
+                install_plugin_mock.assert_not_called()