Repository: meltano/meltano

Issue:
bug: Missing Python executable from plugin venv causes error during `meltano install`
### Meltano Version

2.10.0

### Python Version

3.9

### Bug scope

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

### Operating System

Linux Mint 20.3

### Description

1. Install Python (e.g. using `pyenv`).
2. Use that version of Python to run `meltano install`.
3. Uninstall that version of Python.
4. Install a new version of Python (e.g. going from version 3.9.X to 3.9.Y)
5. Use that version of Python to run `meltano install` again:

```
Installing X plugins...
[...]

Need help fixing this problem? Visit http://melta.no/ for troubleshooting steps, or to
join our friendly Slack community.

[Errno 2] No such file or directory: '<project directory>/.meltano/<installed plugin>/venv/bin/python'
```

The venvs managed by Meltano assume that the underlying Python executable that `venv/bin/python` links to will not be deleted. In reality it may be, and if it is we should detect this and automatically replace the venv (as if the `--clean` flag has been provided).

### Code

_No response_


Discussion:

Resolving patch:
diff --git a/src/meltano/core/venv_service.py b/src/meltano/core/venv_service.py
--- a/src/meltano/core/venv_service.py
+++ b/src/meltano/core/venv_service.py
@@ -154,7 +154,6 @@ def __init__(self, project: Project, namespace: str = "", name: str = ""):
         self.namespace = namespace
         self.name = name
         self.venv = VirtualEnv(self.project.venvs_dir(namespace, name))
-        self.python_path = self.venv.bin_dir / "python"
         self.plugin_fingerprint_path = self.venv.root / ".meltano_plugin_fingerprint"
 
     async def install(self, pip_install_args: list[str], clean: bool = False) -> None:
@@ -184,15 +183,18 @@ def requires_clean_install(self, pip_install_args: list[str]) -> bool:
         Returns:
             Whether virtual environment doesn't exist or can't be reused.
         """
-        if self.venv.site_packages_dir.joinpath("meltano_venv.pth").exists():
-            # clean up deprecated feature
-            return True
-        existing_fingerprint = self.read_fingerprint()
-        return (
-            existing_fingerprint != fingerprint(pip_install_args)
-            if existing_fingerprint
-            else True
-        )
+        # A generator function is used to perform the checks lazily
+        def checks():
+            # The Python installation used to create this venv no longer exists
+            yield not self.exec_path("python").exists()
+            # The deprecated `meltano_venv.pth` feature is used by this venv
+            yield self.venv.site_packages_dir.joinpath("meltano_venv.pth").exists()
+            # The fingerprint of the venv does not match the pip install args
+            existing_fingerprint = self.read_fingerprint()
+            yield existing_fingerprint is None
+            yield existing_fingerprint != fingerprint(pip_install_args)
+
+        return any(checks())
 
     def clean_run_files(self) -> None:
         """Destroy cached configuration files, if they exist."""
@@ -279,7 +281,18 @@ def exec_path(self, executable: str) -> Path:
         Returns:
             The venv bin directory joined to the provided executable.
         """
-        return self.venv.bin_dir / executable
+        absolute_executable = self.venv.bin_dir / executable
+        if platform.system() != "Windows":
+            return absolute_executable
+
+        # On Windows, try using the '.exe' suffixed version if it exists. Use the
+        # regular executable path as a fallback (and for backwards compatibility).
+        absolute_executable_windows = absolute_executable.with_suffix(".exe")
+        return (
+            absolute_executable_windows
+            if absolute_executable_windows.exists()
+            else absolute_executable
+        )
 
     async def _pip_install(
         self, pip_install_args: list[str], clean: bool = False
@@ -313,7 +326,7 @@ async def _pip_install(
 
         try:
             return await exec_async(
-                str(self.python_path), "-m", "pip", "install", *pip_install_args
+                str(self.exec_path("python")), "-m", "pip", "install", *pip_install_args
             )
         except AsyncSubprocessError as err:
             raise AsyncSubprocessError(


Test patch:
diff --git a/tests/meltano/core/test_venv_service.py b/tests/meltano/core/test_venv_service.py
--- a/tests/meltano/core/test_venv_service.py
+++ b/tests/meltano/core/test_venv_service.py
@@ -117,6 +117,20 @@ async def test_requires_clean_install(self, project, subject: VenvService):
         # Make sure the venv exists already
         await subject.install(["example"], clean=True)
 
+        if platform.system() != "Windows":
+            python_path = subject.exec_path("python")
+            original_link_target = os.readlink(python_path)
+            try:
+                # Simulate the deletion of the underlying Python executable by
+                # making its symlink point to a file that does not exist
+                python_path.unlink()
+                python_path.symlink_to("./fake/path/to/python/executable")
+                assert subject.requires_clean_install(["example"])
+            finally:
+                # Restore the symlink to the actual Python executable
+                python_path.unlink()
+                python_path.symlink_to(original_link_target)
+
         assert not subject.requires_clean_install(["example"])
         assert subject.requires_clean_install(["example==0.1.0"])
         assert subject.requires_clean_install(["example", "another-package"])