diff --git a/news/4475.feature b/news/4475.feature new file mode 100644 index 00000000000..d4a17604c80 --- /dev/null +++ b/news/4475.feature @@ -0,0 +1,16 @@ +Add extra headers option to enhance HTTP requests + +Users can supply --extra-headers='{...}' option to pip commands that enhances the +PipSession object with custom headers. + +This enables use of private PyPI servers that use token-based authentication. + +Example: + +``` +pip install \ + --extra-headers='{"Authorization": "..."}' \ + --index-url https://secure.pypi.example.com/simple \ + --trusted-host secure.pypi.example.com \ + fizz==1.2.3 +``` diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index ff9acfd4644..b6656166fef 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -335,6 +335,17 @@ def exists_action(): ) # type: Callable[..., Option] +def extra_headers(): + # type: () -> Option + return Option( + '--extra-headers', + dest='extra_headers', + metavar='JSON', + default=None, + help='Extra HTTP request headers JSON.', + ) + + def extra_index_url(): # type: () -> Option return Option( @@ -969,5 +980,6 @@ def check_list_path_option(options): extra_index_url, no_index, find_links, + extra_headers, ] } # type: Dict[str, Any] diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 104b033281f..e795b1f8ae8 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -5,6 +5,7 @@ PackageFinder machinery and all its vendored dependencies, etc. """ +import json import logging import os from functools import partial @@ -35,7 +36,7 @@ if MYPY_CHECK_RUNNING: from optparse import Values - from typing import Any, List, Optional, Tuple + from typing import Any, Dict, List, Optional, Tuple from pip._internal.cache import WheelCache from pip._internal.models.target_python import TargetPython @@ -76,6 +77,20 @@ def _get_index_urls(cls, options): # Return None rather than an empty list return index_urls or None + @classmethod + def _get_extra_headers(cls, options): + # type: (Values) -> Optional[Dict[str, str]] + """ + Return a dict of extra HTTP request headers from user-provided options. + """ + if not options.extra_headers: + return None + try: + return json.loads(options.extra_headers) + except (TypeError, ValueError): + logger.critical('Could not parse extra headers as JSON') + return None + def get_default_session(self, options): # type: (Values) -> PipSession """Get a default-managed session.""" @@ -98,6 +113,7 @@ def _build_session(self, options, retries=None, timeout=None): retries=retries if retries is not None else options.retries, trusted_hosts=options.trusted_hosts, index_urls=self._get_index_urls(options), + extra_headers=self._get_extra_headers(options), ) # Handle custom ca-bundles from the user diff --git a/src/pip/_internal/network/session.py b/src/pip/_internal/network/session.py index f5eb15ef2f6..e2d62dea073 100644 --- a/src/pip/_internal/network/session.py +++ b/src/pip/_internal/network/session.py @@ -230,6 +230,7 @@ def __init__(self, *args, **kwargs): cache = kwargs.pop("cache", None) trusted_hosts = kwargs.pop("trusted_hosts", []) # type: List[str] index_urls = kwargs.pop("index_urls", None) + extra_headers = kwargs.pop("extra_headers", None) super(PipSession, self).__init__(*args, **kwargs) @@ -240,6 +241,10 @@ def __init__(self, *args, **kwargs): # Attach our User Agent to the request self.headers["User-Agent"] = user_agent() + # Attach extra headers to the request + if extra_headers: + self.headers.update(extra_headers) + # Attach our Authentication handler to the session self.auth = MultiDomainBasicAuth(index_urls=index_urls) diff --git a/tests/unit/test_network_session.py b/tests/unit/test_network_session.py index 159a4d4dea1..1951975134b 100644 --- a/tests/unit/test_network_session.py +++ b/tests/unit/test_network_session.py @@ -219,3 +219,19 @@ def warning(self, *args, **kwargs): actual_level, actual_message = log_records[0] assert actual_level == 'WARNING' assert 'is not a trusted or secure host' in actual_message + + def test_extra_headers(self): + session = PipSession(extra_headers={ + 'Authorization': + 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0N' + 'TY3ODkwIiwibmFtZSI6IlNwYW0gU3BhbSIsImlhdCI6MTUxNjIzOTAyMn0.h1' + '98Wld6h_ASlfRZN3ZftXLNkGHIdrdNpXwrEOdLO1U'}) + assert 'Authorization' in session.headers + + def test_bogus_extra_headers(self): + bogus_header = \ + 'Authorization=Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdW'\ + 'IiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlNwYW0gU3BhbSIsImlhdCI6MTUxNjIzOT'\ + 'AyMn0.h198Wld6h_ASlfRZN3ZftXLNkGHIdrdNpXwrEOdLO1U' + with pytest.raises(ValueError): + PipSession(extra_headers=bogus_header)