Repository: pypa/pip

Issue:
Invalid Configuration File causes a traceback + crash
Coming from #4892

If any configuration file can't be read from, pip crashes with a traceback. While the traceback points at the relevant line, it would be nicer to print a better error message (and no traceback).


Discussion:
I've been fiddling with a fix for this and I would like to work on a pull request for it.
Go ahead! =) 

Resolving patch:
diff --git a/src/pip/_internal/cli/parser.py b/src/pip/_internal/cli/parser.py
--- a/src/pip/_internal/cli/parser.py
+++ b/src/pip/_internal/cli/parser.py
@@ -9,6 +9,7 @@
 
 from pip._vendor.six import string_types
 
+from pip._internal.cli.status_codes import UNKNOWN_ERROR
 from pip._internal.configuration import Configuration, ConfigurationError
 from pip._internal.utils.compat import get_terminal_size
 
@@ -232,7 +233,7 @@ def get_default_values(self):
         try:
             self.config.load()
         except ConfigurationError as err:
-            self.exit(2, err.args[0])
+            self.exit(UNKNOWN_ERROR, str(err))
 
         defaults = self._update_defaults(self.defaults.copy())  # ours
         for option in self._get_all_options():
@@ -244,7 +245,7 @@ def get_default_values(self):
 
     def error(self, msg):
         self.print_usage(sys.stderr)
-        self.exit(2, "%s\n" % msg)
+        self.exit(UNKNOWN_ERROR, "%s\n" % msg)
 
 
 def invalid_config_error_message(action, key, val):
diff --git a/src/pip/_internal/configuration.py b/src/pip/_internal/configuration.py
--- a/src/pip/_internal/configuration.py
+++ b/src/pip/_internal/configuration.py
@@ -18,7 +18,9 @@
 from pip._vendor import six
 from pip._vendor.six.moves import configparser
 
-from pip._internal.exceptions import ConfigurationError
+from pip._internal.exceptions import (
+    ConfigurationError, ConfigurationFileCouldNotBeLoaded,
+)
 from pip._internal.locations import (
     legacy_config_file, new_config_file, running_under_virtualenv,
     site_config_files, venv_config_file,
@@ -289,11 +291,16 @@ def _construct_parser(self, fname):
             try:
                 parser.read(fname)
             except UnicodeDecodeError:
-                raise ConfigurationError((
-                    "ERROR: "
-                    "Configuration file contains invalid %s characters.\n"
-                    "Please fix your configuration, located at %s\n"
-                ) % (locale.getpreferredencoding(False), fname))
+                # See https://github.com/pypa/pip/issues/4963
+                raise ConfigurationFileCouldNotBeLoaded(
+                    reason="contains invalid {} characters".format(
+                        locale.getpreferredencoding(False)
+                    ),
+                    fname=fname,
+                )
+            except configparser.Error as error:
+                # See https://github.com/pypa/pip/issues/4893
+                raise ConfigurationFileCouldNotBeLoaded(error=error)
         return parser
 
     def _load_environment_vars(self):
diff --git a/src/pip/_internal/exceptions.py b/src/pip/_internal/exceptions.py
--- a/src/pip/_internal/exceptions.py
+++ b/src/pip/_internal/exceptions.py
@@ -247,3 +247,22 @@ def hash_then_or(hash_name):
 class UnsupportedPythonVersion(InstallationError):
     """Unsupported python version according to Requires-Python package
     metadata."""
+
+
+class ConfigurationFileCouldNotBeLoaded(ConfigurationError):
+    """When there are errors while loading a configuration file
+    """
+
+    def __init__(self, reason="could not be loaded", fname=None, error=None):
+        super(ConfigurationFileCouldNotBeLoaded, self).__init__(error)
+        self.reason = reason
+        self.fname = fname
+        self.error = error
+
+    def __str__(self):
+        if self.fname is not None:
+            message_part = " in {}.".format(self.fname)
+        else:
+            assert self.error is not None
+            message_part = ".\n{}\n".format(self.error.message)
+        return "Configuration file {}{}".format(self.reason, message_part)


Test patch:
diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py
--- a/tests/unit/test_configuration.py
+++ b/tests/unit/test_configuration.py
@@ -68,6 +68,23 @@ def test_environment_var_does_not_load_version(self):
         with pytest.raises(ConfigurationError):
             self.configuration.get_value(":env:.version")
 
+    def test_environment_config_errors_if_malformed(self):
+        contents = """
+            test]
+            hello = 4
+        """
+        with self.tmpfile(contents) as config_file:
+            os.environ["PIP_CONFIG_FILE"] = config_file
+            with pytest.raises(ConfigurationError) as err:
+                self.configuration.load()
+
+        assert "section header" in str(err.value)  # error kind
+        assert "1" in str(err.value)  # line number
+        assert (  # file name
+            config_file in str(err.value) or
+            repr(config_file) in str(err.value)
+        )
+
 
 class TestConfigurationPrecedence(ConfigurationMixin):
     # Tests for methods to that determine the order of precedence of