Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support requests.response.raw being a file-like object #1094

Merged
merged 16 commits into from
Jul 6, 2021
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This project adheres to `Semantic Versioning <https://semver.org/>`_.
an alternative to ``stdin``. (`#534`_)
* Fixed ``--continue --download`` with a single byte to be downloaded left. (`#1032`_)
* Fixed ``--verbose`` HTTP 307 redirects with streamed request body. (`#1088`_)
* Add internal support for file-like object responses to improve adapter plugin support. (`#1094`_)


`2.4.0`_ (2021-02-06)
Expand Down
3 changes: 1 addition & 2 deletions httpie/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,8 @@ def collect_messages(
**send_kwargs,
)

# noinspection PyProtectedMember
expired_cookies += get_expired_cookies(
headers=response.raw._original_response.msg._headers
response.headers.get('Set-Cookie', '')
)

response_count += 1
Expand Down
25 changes: 18 additions & 7 deletions httpie/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from typing import Iterable, Optional
from urllib.parse import urlsplit

from httpie.utils import split_cookies
BoboTiG marked this conversation as resolved.
Show resolved Hide resolved


class HTTPMessage:
"""Abstract class for HTTP messages."""
Expand Down Expand Up @@ -52,21 +54,30 @@ def iter_lines(self, chunk_size):
# noinspection PyProtectedMember
@property
def headers(self):
original = self._orig.raw._original_response

try:
raw_version = self._orig.raw._original_response.version
except AttributeError:
# Assume HTTP/1.1
raw_version = 11
version = {
9: '0.9',
10: '1.0',
11: '1.1',
20: '2',
}[original.version]
}[raw_version]

status_line = f'HTTP/{version} {original.status} {original.reason}'
original = self._orig
status_line = f'HTTP/{version} {original.status_code} {original.reason}'
headers = [status_line]
# `original.msg` is a `http.client.HTTPMessage`
# `_headers` is a 2-tuple
headers.extend(
f'{header[0]}: {header[1]}' for header in original.msg._headers)
': '.join(header)
for header in original.headers.items()
if header[0] != 'Set-Cookie'
)
headers.extend(
f'Set-Cookie: {cookie}'
for cookie in split_cookies(original.headers.get('Set-Cookie'))
)
return '\r\n'.join(headers)

@property
Expand Down
22 changes: 19 additions & 3 deletions httpie/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
from http.cookiejar import parse_ns_headers
from pprint import pformat
from typing import List, Optional, Tuple
import re

import requests.auth

RE_COOKIE_SPLIT = re.compile(r', (?=[^ ;]+=)')


def load_json_preserve_order(s):
return json.loads(s, object_pairs_hook=OrderedDict)
Expand Down Expand Up @@ -85,8 +88,21 @@ def get_content_type(filename):
return content_type


def split_cookies(cookies):
"""
When Requests stores cookies in ``response.headers['Set-Cookie']``
it concatenates all of them through ``, ``
BoboTiG marked this conversation as resolved.
Show resolved Hide resolved

This function splits cookies apart being careful to not to
split on ``, `` which may be part of cookie value.
"""
if not cookies:
return []
return RE_COOKIE_SPLIT.split(cookies)


def get_expired_cookies(
headers: List[Tuple[str, str]],
cookies: str,
now: float = None
) -> List[dict]:

Expand All @@ -96,9 +112,9 @@ def is_expired(expires: Optional[float]) -> bool:
return expires is not None and expires <= now

attr_sets: List[Tuple[str, str]] = parse_ns_headers(
value for name, value in headers
if name.lower() == 'set-cookie'
split_cookies(cookies)
)

cookies = [
# The first attr name is the cookie name.
dict(attrs[1:], name=attrs[0][0])
Expand Down
47 changes: 47 additions & 0 deletions tests/test_cookie.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread

from .utils import http


class TestIntegration:

def setup_mock_server(self, handler):
"""Configure mock server."""
# Passing 0 as the port will cause a random free port to be chosen.
self.mock_server = HTTPServer(('localhost', 0), handler)
_, self.mock_server_port = self.mock_server.server_address

# Start running mock server in a separate thread.
# Daemon threads automatically shut down when the main process exits.
self.mock_server_thread = Thread(target=self.mock_server.serve_forever)
self.mock_server_thread.setDaemon(True)
self.mock_server_thread.start()

def test_cookie_parser(self):
"""Not directly testing HTTPie but `requests` to ensure their cookies handling
is still as expected by `get_expired_cookies()`.
"""

class MockServerRequestHandler(BaseHTTPRequestHandler):
""""HTTP request handler."""

def do_GET(self):
"""Handle GET requests."""
# Craft multiple cookies
cookie = SimpleCookie()
cookie['hello'] = 'world'
cookie['hello']['path'] = '/'
cookie['oatmeal_raisin'] = 'is the best'
cookie['oatmeal_raisin']['path'] = '/'
BoboTiG marked this conversation as resolved.
Show resolved Hide resolved

# Send HTTP headers
self.send_response(200)
self.send_header('Set-Cookie', cookie.output())
self.end_headers()

self.setup_mock_server(MockServerRequestHandler)
response = http(f'http://localhost:{self.mock_server_port}/')
assert 'Set-Cookie: hello=world; Path=/' in response
assert 'Set-Cookie: oatmeal_raisin="is the best"; Path=/' in response
35 changes: 12 additions & 23 deletions tests/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,22 +343,17 @@ def test_expired_cookies(self, httpbin):
assert 'cookie2' not in updated_session['cookies']

def test_get_expired_cookies_using_max_age(self):
headers = [
('Set-Cookie', 'one=two; Max-Age=0; path=/; domain=.tumblr.com; HttpOnly')
]
cookies = 'one=two; Max-Age=0; path=/; domain=.tumblr.com; HttpOnly'
expected_expired = [
{'name': 'one', 'path': '/'}
]
assert get_expired_cookies(headers, now=None) == expected_expired
assert get_expired_cookies(cookies, now=None) == expected_expired

@pytest.mark.parametrize(
argnames=['headers', 'now', 'expected_expired'],
argnames=['cookies', 'now', 'expected_expired'],
argvalues=[
(
[
('Set-Cookie', 'hello=world; Path=/; Expires=Thu, 01-Jan-1970 00:00:00 GMT; HttpOnly'),
('Connection', 'keep-alive')
],
'hello=world; Path=/; Expires=Thu, 01-Jan-1970 00:00:00 GMT; HttpOnly',
None,
[
{
Expand All @@ -368,11 +363,10 @@ def test_get_expired_cookies_using_max_age(self):
]
),
(
[
('Set-Cookie', 'hello=world; Path=/; Expires=Thu, 01-Jan-1970 00:00:00 GMT; HttpOnly'),
('Set-Cookie', 'pea=pod; Path=/ab; Expires=Thu, 01-Jan-1970 00:00:00 GMT; HttpOnly'),
('Connection', 'keep-alive')
],
(
'hello=world; Path=/; Expires=Thu, 01-Jan-1970 00:00:00 GMT; HttpOnly, '
'pea=pod; Path=/ab; Expires=Thu, 01-Jan-1970 00:00:00 GMT; HttpOnly'
),
Comment on lines +366 to +369
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should now be a lists.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cookies were stored as a list in response.raw._original_response.msg._headers however with this change cookies are extracted from response.headers.get('Set-Cookie', '') which is a comma separated string.

get_expired_cookies has been changed from accepting list-of-tuple headers to string of comma separated strings.

I don't see of a way to access more-or-less raw cookie data from requests in any way but parsing them out of the comma separated string without resorting to using protected members. Cookie jar has more formatted cookies but they are stored in a lossy way.

With all this in mind, to keep tests as lists the cookie splitting logic can be moved to outside of get_expired_cookies(), but I'm not sure if this is what you had in mind by your comment.

Thanks
--IAS

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@IlyaSukhanov Is it an option to add a test case to ensure response.headers.get('Set-Cookie', '') returns a string with concatenated cookies? It will help us prevent potential regressions for upcoming requests versions and to be more comfortable with those changes too :)

After that, we will be good to merge.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test to ensure that multiple cookies get extracted correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like cicd checks is failing. Is there a tried approach for starting a local http server to test against?

The way it's erroring out it's not clear what the issue is. Few of suspicions I have:

  1. Multiprocess does not start for some reason
  2. Multiprocess does not start fast enough (single threaded machine?)
  3. Flask is unable to open port for some reason (seems unlikely as no error to that effect is presented)

Without knowing what the error is or much experience with GitHub check infrastructure. My only idea is to add a retry functionality in test, to call httpie multiple time until hopefully the background server becomes responsive.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was referring to the incorrect usage of tuples for what by nature is a list.

(  # <= tuple because fixed-length collection where each item is a different thing

    ( # <= list because it’s a simple list of things
        'hello=world; Path=/; Expires=Thu, 01-Jan-1970 00:00:00 GMT; HttpOnly, '
        'pea=pod; Path=/ab; Expires=Thu, 01-Jan-1970 00:00:00 GMT; HttpOnly'
    ),
    None,
    [  # <= list because it’s a simple list of things
        {'name': 'hello', 'path': '/'},
        {'name': 'pea', 'path': '/ab'}
    ]
),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I see what you mean. But the cookie values are not tuples its a string that is line wrapped, and thus grouped using parenthesis.

Tuple:

(
    'foo',
    'bar'
)

String:

(
    'foo'
    'bar'
)

Note how in this example and in the pr the lines are not separated by comma which is required in tuple definition.

I hope this clears up any confusion.

If project has different style requirement for line wrapping long strings, please let me know and I'll make this change conform to that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@IlyaSukhanov right, I see it now! Yeah, this style of string-wrapping is the preferred one, but in this context, next to a bunch of tuples, it’s easy to misread it (🙋‍♂️), so I’d vote for using a different style here.

None,
[
{'name': 'hello', 'path': '/'},
Expand All @@ -382,24 +376,19 @@ def test_get_expired_cookies_using_max_age(self):
(
# Checks we gracefully ignore expires date in invalid format.
# <https://github.com/httpie/httpie/issues/963>
[
('Set-Cookie', 'pfg=; Expires=Sat, 19-Sep-2020 06:58:14 GMT+0000; Max-Age=0; path=/; domain=.tumblr.com; secure; HttpOnly'),
],
'pfg=; Expires=Sat, 19-Sep-2020 06:58:14 GMT+0000; Max-Age=0; path=/; domain=.tumblr.com; secure; HttpOnly',
None,
[]
),
(
[
('Set-Cookie', 'hello=world; Path=/; Expires=Fri, 12 Jun 2020 12:28:55 GMT; HttpOnly'),
('Connection', 'keep-alive')
],
'hello=world; Path=/; Expires=Fri, 12 Jun 2020 12:28:55 GMT; HttpOnly',
datetime(2020, 6, 11).timestamp(),
[]
),
]
)
def test_get_expired_cookies_manages_multiple_cookie_headers(self, headers, now, expected_expired):
assert get_expired_cookies(headers, now=now) == expected_expired
def test_get_expired_cookies_manages_multiple_cookie_headers(self, cookies, now, expected_expired):
assert get_expired_cookies(cookies, now=now) == expected_expired


class TestCookieStorage(CookieTestBase):
Expand Down
45 changes: 45 additions & 0 deletions tests/test_transport_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from io import BytesIO

from requests.adapters import BaseAdapter
from requests.models import Response
from requests.utils import get_encoding_from_headers

from httpie.plugins import TransportPlugin
from httpie.plugins.registry import plugin_manager

from .utils import HTTP_OK, http

SCHEME = 'http+fake'


class FakeAdapter(BaseAdapter):
def send(self, request, **kwargs):
response = Response()
response.status_code = 200
response.reason = 'OK'
response.headers = {
'Content-Type': 'text/html; charset=UTF-8',
}
response.encoding = get_encoding_from_headers(response.headers)
response.raw = BytesIO(b'<!doctype html><html>Hello</html>')
return response


class FakeTransportPlugin(TransportPlugin):
name = 'Fake Transport'

prefix = SCHEME

def get_adapter(self):
return FakeAdapter()


def test_transport_from_requests_response(httpbin):
plugin_manager.register(FakeTransportPlugin)
try:
r = http(f'{SCHEME}://example.com')
assert HTTP_OK in r
assert 'Hello' in r
assert 'Content-Type: text/html; charset=UTF-8' in r
finally:
plugin_manager.unregister(FakeTransportPlugin)