Skip to content

Commit

Permalink
Add CLI options for enhancing requests with HTTP headers
Browse files Browse the repository at this point in the history
-H, --header <key:val>      HTTP header to include in all requests. This option
                            can be used multiple times. Conflicts with
                            --extra-index-url.

Example:

```
pip install \
  --index-url http://pypi.index/simple/ \
  --trusted-host pypi.index \
  -H 'X-Spam: ~*~ SPAM ~*~' \
  requests
```
  • Loading branch information
Alexander Mancevice committed May 14, 2020
1 parent a879bc4 commit ffc64a6
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 2 deletions.
15 changes: 15 additions & 0 deletions news/8042.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Add CLI options for enhancing requests with HTTP headers

-H, --header <key:val> HTTP header to include in all requests. This option
can be used multiple times. Conflicts with
--extra-index-url.

Example:

```
pip install \
--index-url http://pypi.index/simple/ \
--trusted-host pypi.index \
-H 'X-Spam: ~*~ SPAM ~*~' \
requests
```
14 changes: 14 additions & 0 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,19 @@ def exists_action():
) # type: Callable[..., Option]


def headers():
# type: () -> Option
return Option(
'-H', '--header',
dest='headers',
action='append',
metavar='KEY:VAL',
default=[],
help='HTTP header to include in all requests. This option can be used '
'multiple times. Conflicts with --extra-index-url.',
)


def extra_index_url():
# type: () -> Option
return Option(
Expand Down Expand Up @@ -929,6 +942,7 @@ def check_list_path_option(options):
'options': [
help_,
isolated_mode,
headers,
require_virtualenv,
verbose,
version,
Expand Down
35 changes: 34 additions & 1 deletion src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import logging
import os
import re
from functools import partial

from pip._internal.cli import cmdoptions
Expand Down Expand Up @@ -34,7 +35,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
Expand Down Expand Up @@ -75,6 +76,37 @@ def _get_index_urls(cls, options):
# Return None rather than an empty list
return index_urls or None

@classmethod
def _get_headers(cls, options):
# type: (Values) -> Optional[Dict[str, str]]
"""
Return a dict of extra HTTP request headers from user-provided options.
"""
# Pull header inputs
header_inputs = getattr(options, 'headers', [])
if not header_inputs:
return None

# Refuse to set headers when multiple index URLs are set
index_urls = cls._get_index_urls(options)
if index_urls and len(index_urls) > 1:
logger.warning(
'Refusing to set -H / --header option(s) because multiple '
'index URLs are configured.',
)
return None

# Parse header inputs into dict
headers = {}
for header in header_inputs:
match = re.match(r'^(.+?):\s*(.+)$', header.lstrip())
if match:
key, val = match.groups()
headers[key] = val
else:
logger.critical('Could not parse header {!r}'.format(header))
return headers or None

def get_default_session(self, options):
# type: (Values) -> PipSession
"""Get a default-managed session."""
Expand All @@ -97,6 +129,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),
headers=self._get_headers(options),
)

# Handle custom ca-bundles from the user
Expand Down
7 changes: 6 additions & 1 deletion src/pip/_internal/network/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@

if MYPY_CHECK_RUNNING:
from typing import (
Iterator, List, Optional, Tuple, Union,
Dict, Iterator, List, Optional, Tuple, Union,
)

from pip._internal.models.link import Link
Expand Down Expand Up @@ -238,6 +238,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)
headers = kwargs.pop("headers", {}) # type: Dict(str, str)

super(PipSession, self).__init__(*args, **kwargs)

Expand All @@ -248,6 +249,10 @@ def __init__(self, *args, **kwargs):
# Attach our User Agent to the request
self.headers["User-Agent"] = user_agent()

# Attach provided HTTP headers to the request
if headers:
self.headers.update(**headers)

# Attach our Authentication handler to the session
self.auth = MultiDomainBasicAuth(index_urls=index_urls)

Expand Down
38 changes: 38 additions & 0 deletions tests/unit/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,41 @@ def is_requirement_command(command):
return isinstance(command, RequirementCommand)

check_commands(is_requirement_command, ['download', 'install', 'wheel'])


@pytest.mark.parametrize(
'headers, extra_index_urls, exp',
[
# No headers
(
[],
[],
None,
),
# Valid headers
(
['X-Spam: SPAM', 'X-Parrot: DEAD'],
[],
{'X-Spam': 'SPAM', 'X-Parrot': 'DEAD'},
),
# One invalid header, one valid
(
['X-Spam SPAM', 'X-Parrot: DEAD'],
[],
{'X-Parrot': 'DEAD'},
),
# Valid headers, but multiple index URLs
(
['X-Spam: SPAM', 'X-Parrot: DEAD'],
['https://pypi.extra/simple/'],
None,
)
],
)
def test_session_mixin_get_headers(headers, extra_index_urls, exp):
command = create_command('install')
options = command.parser.get_default_values()
options.headers = headers
options.extra_index_urls = extra_index_urls
ret = SessionCommandMixin._get_headers(options)
assert ret == exp
6 changes: 6 additions & 0 deletions tests/unit/test_network_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,9 @@ 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_headers(self):
headers = {'Authorization': '~*~ SPAM ~*~'}
session = PipSession(headers=headers)
assert 'Authorization' in session.headers
assert session.headers['Authorization'] == '~*~ SPAM ~*~'

0 comments on commit ffc64a6

Please sign in to comment.