Repository: pypa/pipenv

Issue:
Support pip 22.2 truststore API
### Is your feature request related to a problem? Please describe.

Using a corporate SSL cert results in a lot of errors like the following:

```
[pipenv.exceptions.InstallError]: Could not fetch URL https://pypi.python.org/simple/cachetools/: There was a problem confirming the ssl certificate: HTTPSConnectionPool(host='pypi.python.org', port=443): Max retries exceeded with url: /simple/cachetools/ (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:997)')))
```

This is because it can't verify the cert against `https://pypi.python.org/simple` as defined in the Pipfile.

I had to set `verify_ssl = false` to get around this, which is obviously not ideal.

### Describe the solution you'd like

Pipenv should use the new Pip 22.2 SSL api for using system cert stores: https://pip.pypa.io/en/latest/topics/https-certificates/#using-system-certificate-stores

### Describe alternatives you've considered

I already tried setting `REQUESTS_CA_BUNDLE` to my corporate cert, but it didn't work for me.

### Additional context

I'm running inside a docker container using the official Python `3.10-alpine` image.

I'm running Pipenv version `2022.8.19`


Discussion:
Just thinking out-loud here that there are at least a couple tickets in the backlog requesting we add the ability to pass pip arguments through the installer.   I am not sure how that would be yet, but either way I agree that we should support this use case.

Resolving patch:
diff --git a/pipenv/cli/command.py b/pipenv/cli/command.py
--- a/pipenv/cli/command.py
+++ b/pipenv/cli/command.py
@@ -250,6 +250,7 @@ def install(state, **kwargs):
         packages=state.installstate.packages,
         editable_packages=state.installstate.editables,
         site_packages=state.site_packages,
+        extra_pip_args=state.installstate.extra_pip_args,
     )
 
 
@@ -561,6 +562,7 @@ def update(ctx, state, bare=False, dry_run=None, outdated=False, **kwargs):
         unused=False,
         sequential=state.installstate.sequential,
         pypi_mirror=state.pypi_mirror,
+        extra_pip_args=state.installstate.extra_pip_args,
     )
 
 
@@ -652,6 +654,7 @@ def sync(ctx, state, bare=False, user=False, unused=False, **kwargs):
         sequential=state.installstate.sequential,
         pypi_mirror=state.pypi_mirror,
         system=state.system,
+        extra_pip_args=state.installstate.extra_pip_args,
     )
     if retcode:
         ctx.abort()
diff --git a/pipenv/cli/options.py b/pipenv/cli/options.py
--- a/pipenv/cli/options.py
+++ b/pipenv/cli/options.py
@@ -87,6 +87,7 @@ def __init__(self):
         self.deploy = False
         self.packages = []
         self.editables = []
+        self.extra_pip_args = []
 
 
 class LockOptions:
@@ -286,6 +287,24 @@ def callback(ctx, param, value):
     )(f)
 
 
+def extra_pip_args(f):
+    def callback(ctx, param, value):
+        state = ctx.ensure_object(State)
+        if value:
+            for opt in value.split(" "):
+                state.installstate.extra_pip_args.append(opt)
+        return value
+
+    return option(
+        "--extra-pip-args",
+        nargs=1,
+        required=False,
+        callback=callback,
+        expose_value=True,
+        type=click_types.STRING,
+    )(f)
+
+
 def three_option(f):
     def callback(ctx, param, value):
         state = ctx.ensure_object(State)
@@ -570,6 +589,7 @@ def install_options(f):
     f = ignore_pipfile_option(f)
     f = editable_option(f)
     f = package_arg(f)
+    f = extra_pip_args(f)
     return f
 
 
diff --git a/pipenv/core.py b/pipenv/core.py
--- a/pipenv/core.py
+++ b/pipenv/core.py
@@ -723,6 +723,7 @@ def batch_install(
     pypi_mirror=None,
     retry=True,
     sequential_deps=None,
+    extra_pip_args=None,
 ):
     from .vendor.requirementslib.models.utils import (
         strip_extras_markers_from_requirement,
@@ -774,6 +775,7 @@ def batch_install(
             trusted_hosts=trusted_hosts,
             use_pep517=use_pep517,
             use_constraint=False,  # no need to use constraints, it's written in lockfile
+            extra_pip_args=extra_pip_args,
         )
 
         for c in cmds:
@@ -792,6 +794,7 @@ def do_install_dependencies(
     concurrent=True,
     requirements_dir=None,
     pypi_mirror=None,
+    extra_pip_args=None,
 ):
     """
     Executes the installation functionality.
@@ -841,6 +844,7 @@ def do_install_dependencies(
         "allow_global": allow_global,
         "pypi_mirror": pypi_mirror,
         "sequential_deps": editable_or_vcs_deps,
+        "extra_pip_args": extra_pip_args,
     }
 
     batch_install(
@@ -1228,6 +1232,7 @@ def do_init(
     keep_outdated=False,
     requirements_dir=None,
     pypi_mirror=None,
+    extra_pip_args=None,
 ):
     """Executes the init functionality."""
 
@@ -1327,6 +1332,7 @@ def do_init(
         concurrent=concurrent,
         requirements_dir=requirements_dir,
         pypi_mirror=pypi_mirror,
+        extra_pip_args=extra_pip_args,
     )
 
     # Hint the user what to do to activate the virtualenv.
@@ -1352,6 +1358,7 @@ def get_pip_args(
     no_deps: bool = False,
     selective_upgrade: bool = False,
     src_dir: Optional[str] = None,
+    extra_pip_args: Optional[List] = None,
 ) -> List[str]:
     arg_map = {
         "pre": ["--pre"],
@@ -1373,6 +1380,8 @@ def get_pip_args(
             arg_set.extend(arg_map.get(key))
         elif key == "selective_upgrade" and not locals().get(key):
             arg_set.append("--exists-action=i")
+    for extra_pip_arg in extra_pip_args:
+        arg_set.append(extra_pip_arg)
     return list(dict.fromkeys(arg_set))
 
 
@@ -1445,6 +1454,7 @@ def pip_install(
     trusted_hosts=None,
     use_pep517=True,
     use_constraint=False,
+    extra_pip_args: Optional[List] = None,
 ):
     piplogger = logging.getLogger("pipenv.patched.pip._internal.commands.install")
     if not trusted_hosts:
@@ -1521,6 +1531,7 @@ def pip_install(
         no_use_pep517=not use_pep517,
         no_deps=no_deps,
         require_hashes=not ignore_hashes,
+        extra_pip_args=extra_pip_args,
     )
     pip_command.extend(pip_args)
     if r:
@@ -1574,6 +1585,7 @@ def pip_install_deps(
     trusted_hosts=None,
     use_pep517=True,
     use_constraint=False,
+    extra_pip_args: Optional[List] = None,
 ):
     if not trusted_hosts:
         trusted_hosts = []
@@ -1662,6 +1674,7 @@ def pip_install_deps(
             selective_upgrade=selective_upgrade,
             no_use_pep517=not use_pep517,
             no_deps=no_deps,
+            extra_pip_args=extra_pip_args,
         )
         sources = get_source_list(
             project,
@@ -2058,6 +2071,7 @@ def do_install(
     keep_outdated=False,
     selective_upgrade=False,
     site_packages=None,
+    extra_pip_args=None,
 ):
     requirements_directory = vistir.path.create_tracked_tempdir(
         suffix="-requirements", prefix="pipenv-"
@@ -2213,6 +2227,7 @@ def do_install(
             requirements_dir=requirements_directory,
             pypi_mirror=pypi_mirror,
             keep_outdated=keep_outdated,
+            extra_pip_args=extra_pip_args,
         )
 
     # This is for if the user passed in dependencies, then we want to make sure we
@@ -2233,6 +2248,7 @@ def do_install(
                 deploy=deploy,
                 pypi_mirror=pypi_mirror,
                 skip_lock=skip_lock,
+                extra_pip_args=extra_pip_args,
             )
         for pkg_line in pkg_list:
             click.secho(
@@ -2279,6 +2295,7 @@ def do_install(
                         index=index_url,
                         pypi_mirror=pypi_mirror,
                         use_constraint=True,
+                        extra_pip_args=extra_pip_args,
                     )
                     if c.returncode:
                         sp.write_err(
@@ -2381,6 +2398,7 @@ def do_install(
             deploy=deploy,
             pypi_mirror=pypi_mirror,
             skip_lock=skip_lock,
+            extra_pip_args=extra_pip_args,
         )
     sys.exit(0)
 
@@ -3056,6 +3074,7 @@ def do_sync(
     pypi_mirror=None,
     system=False,
     deploy=False,
+    extra_pip_args=None,
 ):
     # The lock file needs to exist because sync won't write to it.
     if not project.lockfile_exists:
@@ -3090,6 +3109,7 @@ def do_sync(
         pypi_mirror=pypi_mirror,
         deploy=deploy,
         system=system,
+        extra_pip_args=extra_pip_args,
     )
     if not bare:
         click.echo(click.style("All dependencies are now up-to-date!", fg="green"))


Test patch:
diff --git a/tests/integration/test_install_basic.py b/tests/integration/test_install_basic.py
--- a/tests/integration/test_install_basic.py
+++ b/tests/integration/test_install_basic.py
@@ -1,4 +1,5 @@
 import os
+import mock
 
 from pathlib import Path
 from tempfile import TemporaryDirectory
@@ -534,7 +535,6 @@ def test_install_dev_use_default_constraints(PipenvInstance):
         assert c.returncode != 0
 
 
-@pytest.mark.dev
 @pytest.mark.basic
 @pytest.mark.install
 @pytest.mark.needs_internet
@@ -547,6 +547,19 @@ def test_install_does_not_exclude_packaging(PipenvInstance):
         assert c.returncode == 0
 
 
+@pytest.mark.basic
+@pytest.mark.install
+@pytest.mark.needs_internet
+def test_install_will_supply_extra_pip_args(PipenvInstance):
+    with PipenvInstance(chdir=True) as p:
+        c = p.pipenv("""install dataclasses-json --extra-pip-args=""--use-feature=truststore --proxy=test""")
+        assert c.returncode == 1
+        assert "truststore feature" in c.stderr
+
+
+@pytest.mark.basic
+@pytest.mark.install
+@pytest.mark.needs_internet
 def test_install_tarball_is_actually_installed(PipenvInstance):
     """ Test case for Issue 5326"""
     with PipenvInstance(chdir=True) as p: