Repository: pypa/pip

Issue:
Confusing error when setup.py, setup.cfg and pyproject.toml are absent
### Description

When installing from a location where there is no setup.py, setup.cfg nor pyproject.toml, the error message is confusing.


### Expected behavior

There should be an easier to understand message saying that no python project was found at the requested location.

### pip version

21.1.1

### Python version

3.8, not relevant

### OS

Linux, probably not relevant

### How to Reproduce

In the example below, notice `#subdirectory=tests` which points to a directory where there is no setup.py, setup.cfg nor pyproject.toml.

In pip <21.1, `pip install "pip-test-package @ git+https://github.com/pypa/pip-test-package#subdirectory=tests"` reports a file not found error. The error message is not pretty (a stack trace) but easily understandable.

In pip 21.1, the same command results in `WARNING: Generating metadata for package pip-test-package produced metadata for project name unknown. Fix your #egg=pip-test-package fragments.`.

This might follow from https://github.com/pypa/pip/pull/9547, I've not investigated yet.
Not sure if this should be classified as a bug (a slight regression) or feature request.

### Output

_No response_

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).


Discussion:
I bisected and indeed #9547 introduced the change. The cause is basically the same as why the error was ugly in the first place; not all code paths check setup.py/cfg existence before trying to invoke it, and when they don’t the error is either ugly or incomprehensible.

Resolving patch:
diff --git a/src/pip/_internal/req/req_install.py b/src/pip/_internal/req/req_install.py
--- a/src/pip/_internal/req/req_install.py
+++ b/src/pip/_internal/req/req_install.py
@@ -509,6 +509,19 @@ def load_pyproject_toml(self):
             self.unpacked_source_directory, backend, backend_path=backend_path,
         )
 
+    def _check_setup_py_or_cfg_exists(self) -> bool:
+        """Check if the requirement actually has a setuptools build file.
+
+        If setup.py does not exist, we also check setup.cfg in the same
+        directory and allow the directory if that exists.
+        """
+        if os.path.exists(self.setup_py_path):
+            return True
+        stem, ext = os.path.splitext(self.setup_py_path)
+        if ext == ".py" and os.path.exists(f"{stem}.cfg"):
+            return True
+        return False
+
     def _generate_metadata(self):
         # type: () -> str
         """Invokes metadata generator functions, with the required arguments.
@@ -516,6 +529,12 @@ def _generate_metadata(self):
         if not self.use_pep517:
             assert self.unpacked_source_directory
 
+            if not self._check_setup_py_or_cfg_exists():
+                raise InstallationError(
+                    f'File "setup.py" or "setup.cfg" not found for legacy '
+                    f'project {self}.'
+                )
+
             return generate_metadata_legacy(
                 build_env=self.build_env,
                 setup_py_path=self.setup_py_path,
diff --git a/src/pip/_internal/utils/misc.py b/src/pip/_internal/utils/misc.py
--- a/src/pip/_internal/utils/misc.py
+++ b/src/pip/_internal/utils/misc.py
@@ -269,18 +269,14 @@ def tabulate(rows):
     return table, sizes
 
 
-def is_installable_dir(path):
-    # type: (str) -> bool
-    """Is path is a directory containing setup.py or pyproject.toml?"""
+def is_installable_dir(path: str) -> bool:
+    """Is path is a directory containing pyproject.toml, setup.cfg or setup.py?"""
     if not os.path.isdir(path):
         return False
-    setup_py = os.path.join(path, "setup.py")
-    if os.path.isfile(setup_py):
-        return True
-    pyproject_toml = os.path.join(path, "pyproject.toml")
-    if os.path.isfile(pyproject_toml):
-        return True
-    return False
+    return any(
+        os.path.isfile(os.path.join(path, signifier))
+        for signifier in ("pyproject.toml", "setup.cfg", "setup.py")
+    )
 
 
 def read_chunks(file, size=io.DEFAULT_BUFFER_SIZE):


Test patch: