Repository: meltano/meltano

Issue:
bug: Error message only says `selected_environment` when running outside of a project
### Meltano Version

2.12.0, 2.13.0, 2.14.0

### Python Version

NA

### Bug scope

CLI (options, error messages, logging, etc.)

### Operating System

N/A

### Description

Reproduction steps:
- Install Meltano `v2.14.0`, `v2.13.0`, or `v2.12.0`
- Run `meltano invoke example`  outside of a Meltano project directory

Expected output:

```
Need help fixing this problem? Visit http://melta.no/ for troubleshooting steps, or to
join our friendly Slack community.

`meltano invoke` must be run inside a Meltano project.
Use `meltano init <project_directory>` to create one.
```

Actual output:

```
Need help fixing this problem? Visit http://melta.no/ for troubleshooting steps, or to
join our friendly Slack community.

'selected_environment'
```

When using other commands (e.g. `config`) the error message may differ. Another one I saw was just `'is_default_environment'` instead of `'selected_environment'`.

---

It appears the cause is `meltano.cli.utils.activate_environment` and `meltano.cli.utils.activate_explicitly_provided_environment` accessing the `ctx.obj` dictionary without handling the case where the expected keys are missing.

### Code

_No response_


Discussion:

Resolving patch:
diff --git a/src/meltano/cli/params.py b/src/meltano/cli/params.py
--- a/src/meltano/cli/params.py
+++ b/src/meltano/cli/params.py
@@ -5,7 +5,6 @@
 import functools
 
 import click
-from click.globals import get_current_context as get_current_click_context
 
 from meltano.cli.utils import CliError
 from meltano.core.db import project_engine
@@ -52,7 +51,7 @@ def __call__(self, func):
 
         @database_uri_option
         def decorate(*args, **kwargs):
-            ctx = get_current_click_context()
+            ctx = click.get_current_context()
 
             project = ctx.obj["project"]
             if not project:
diff --git a/src/meltano/cli/utils.py b/src/meltano/cli/utils.py
--- a/src/meltano/cli/utils.py
+++ b/src/meltano/cli/utils.py
@@ -542,7 +542,7 @@ def activate_environment(
         ctx: The Click context, used to determine the selected environment.
         project: The project for which the environment will be activated.
     """
-    if ctx.obj["selected_environment"]:
+    if ctx.obj.get("selected_environment"):
         project.activate_environment(ctx.obj["selected_environment"])
         # Update the project context being used for telemetry:
         project_ctx = next(
@@ -573,7 +573,7 @@ def activate_explicitly_provided_environment(
         ctx: The Click context, used to determine the selected environment.
         project: The project for which the environment will be activated.
     """
-    if ctx.obj["is_default_environment"]:
+    if ctx.obj.get("is_default_environment"):
         logger.info(
             f"The default environment {ctx.obj['selected_environment']!r} will "
             f"be ignored for `meltano {ctx.command.name}`. To configure a specific "


Test patch:
diff --git a/tests/meltano/cli/test_cli.py b/tests/meltano/cli/test_cli.py
--- a/tests/meltano/cli/test_cli.py
+++ b/tests/meltano/cli/test_cli.py
@@ -3,6 +3,7 @@
 import platform
 import re
 import shutil
+import subprocess
 from pathlib import Path
 from time import perf_counter_ns
 
@@ -208,6 +209,43 @@ def test_cwd_option(self, cli_runner, project, tmp_path: Path, pushd):
             assert_cli_runner(cli_runner.invoke(cli, ("--cwd", str(dirpath), "dragon")))
             assert Path().resolve() == dirpath
 
+    @pytest.mark.parametrize(
+        "command_args",
+        (
+            ("invoke", "example"),
+            ("config", "example"),
+            ("job", "list"),
+            ("environment", "list"),
+            ("add", "utility", "example"),
+        ),
+    )
+    def test_error_msg_outside_project(
+        self,
+        tmp_path: Path,
+        command_args: tuple[str, ...],
+    ):
+        # Unless this test runs before every test that uses a project, we
+        # cannot use `cli_runner` to test this because the code path taken
+        # differs after any project has been found.
+
+        # I tried working around this by switching to an empty directory,
+        # calling `Project.deactivate`, using `mock.patch` on various relevant
+        # functions, and more, but nothing I did resulted in the proper code
+        # path being taken. Also it seemed like a fragile approach.
+
+        # Using a subprocess should be robust, but requires the version of
+        # Meltano you want to test be the one that is installed in the active
+        # Python environment. This is not the only test that requires this.
+        assert (
+            "must be run inside a Meltano project"
+            in subprocess.run(
+                ("meltano", *command_args),
+                text=True,
+                stderr=subprocess.PIPE,
+                cwd=tmp_path,
+            ).stderr
+        )
+
 
 def _get_dummy_logging_config(colors=True):
     return {
@@ -368,5 +406,5 @@ def test_list_config_performance(self, large_config_project: Project, cli_runner
             == 0
         )
         duration_ns = perf_counter_ns() - start
-        # Ensure the large config can be processed in less than 20 seconds
-        assert duration_ns < 20000000000
+        # Ensure the large config can be processed in less than 25 seconds
+        assert duration_ns < 25000000000