Skip to content

Commit a95d336

Browse files
authored
Merge pull request #2041 from sarayourfriend/refactor/requests-to-httpx
Replace `requests` with `httpx`
2 parents ee4e1be + bf89902 commit a95d336

File tree

13 files changed

+298
-182
lines changed

13 files changed

+298
-182
lines changed

changes/2039.misc.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Briefcase now uses `httpx <https://www.python-httpx.org/>`_ internally instead of ``requests``.

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ dependencies = [
8585
"platformdirs >= 2.6, < 5.0",
8686
"psutil >= 5.9, < 7.0",
8787
"python-dateutil >= 2.9.0.post0", # transitive dependency (beeware/briefcase#1428)
88-
"requests >= 2.28, < 3.0",
88+
"httpx >= 0.20, < 1.0",
8989
"rich >= 12.6, < 14.0",
9090
"tomli >= 2.0, < 3.0; python_version <= '3.10'",
9191
"tomli_w >= 1.0, < 2.0",

src/briefcase/exceptions.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,12 @@ def __str__(self):
9090

9191

9292
class NetworkFailure(BriefcaseCommandError):
93-
def __init__(self, action):
93+
DEFAULT_HINT = "is your computer offline?"
94+
95+
def __init__(self, action, hint=None):
9496
self.action = action
95-
super().__init__(msg=f"Unable to {action}; is your computer offline?")
97+
self.hint = hint if hint else self.DEFAULT_HINT
98+
super().__init__(msg=f"Unable to {action}; {self.hint}")
9699

97100

98101
class MissingNetworkResourceError(BriefcaseCommandError):

src/briefcase/integrations/base.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pathlib import Path
1313
from typing import TYPE_CHECKING, DefaultDict, TypeVar
1414

15-
import requests
15+
import httpx
1616
from cookiecutter.main import cookiecutter
1717

1818
from briefcase.config import AppConfig
@@ -169,7 +169,7 @@ class ToolCache(Mapping):
169169

170170
# Third party tools
171171
cookiecutter = staticmethod(cookiecutter)
172-
requests = requests
172+
httpx = httpx
173173

174174
def __init__(
175175
self,

src/briefcase/integrations/file.py

+60-42
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,8 @@
99
from contextlib import suppress
1010
from email.message import Message
1111
from pathlib import Path
12-
from urllib.parse import urlparse
1312

14-
import requests.exceptions as requests_exceptions
15-
from requests import Response
13+
import httpx
1614

1715
from briefcase.exceptions import (
1816
BadNetworkResourceError,
@@ -176,57 +174,76 @@ def download(self, url: str, download_path: Path, role: str | None = None) -> Pa
176174
download_path.mkdir(parents=True, exist_ok=True)
177175
filename: Path = None
178176
try:
179-
response = self.tools.requests.get(url, stream=True)
180-
if response.status_code == 404:
181-
raise MissingNetworkResourceError(url=url)
182-
elif response.status_code != 200:
183-
raise BadNetworkResourceError(url=url, status_code=response.status_code)
184-
185-
# The initial URL might (read: will) go through URL redirects, so
186-
# we need the *final* response. We look at either the `Content-Disposition`
187-
# header, or the final URL, to extract the cache filename.
188-
cache_full_name = urlparse(response.url).path
189-
header_value = response.headers.get("Content-Disposition")
190-
if header_value:
191-
# Neither requests nor httplib provides a way to parse RFC6266 headers.
192-
# The cgi module *did* have a way to parse these headers, but
193-
# it was deprecated as part of PEP594. PEP594 recommends
194-
# using the email.message module to parse these headers as they
195-
# are near identical format.
196-
# See also:
197-
# * https://tools.ietf.org/html/rfc6266
198-
# * https://peps.python.org/pep-0594/#cgi
199-
msg = Message()
200-
msg["Content-Disposition"] = header_value
201-
filename = msg.get_filename()
202-
if filename:
203-
cache_full_name = filename
204-
cache_name = cache_full_name.split("/")[-1]
205-
filename = download_path / cache_name
206-
207-
if filename.exists():
208-
self.tools.logger.info(f"{cache_name} already downloaded")
209-
else:
210-
self.tools.logger.info(f"Downloading {cache_name}...")
211-
self._fetch_and_write_content(response, filename)
212-
except requests_exceptions.ConnectionError as e:
177+
with self.tools.httpx.stream("GET", url, follow_redirects=True) as response:
178+
if response.status_code == 404:
179+
raise MissingNetworkResourceError(url=url)
180+
elif response.status_code != 200:
181+
raise BadNetworkResourceError(
182+
url=url, status_code=response.status_code
183+
)
184+
185+
# The initial URL might (read: will) go through URL redirects, so
186+
# we need the *final* response. We look at either the `Content-Disposition`
187+
# header, or the final URL, to extract the cache filename.
188+
cache_full_name = response.url.path
189+
header_value = response.headers.get("Content-Disposition")
190+
if header_value:
191+
# Httpx does not provide a way to parse RFC6266 headers.
192+
# The cgi module *did* have a way to parse these headers, but
193+
# it was deprecated as part of PEP594. PEP594 recommends
194+
# using the email.message module to parse these headers as they
195+
# are near identical format.
196+
# See also:
197+
# * https://tools.ietf.org/html/rfc6266
198+
# * https://peps.python.org/pep-0594/#cgi
199+
msg = Message()
200+
msg["Content-Disposition"] = header_value
201+
filename = msg.get_filename()
202+
if filename:
203+
cache_full_name = filename
204+
cache_name = cache_full_name.split("/")[-1]
205+
filename = download_path / cache_name
206+
207+
if filename.exists():
208+
self.tools.logger.info(f"{cache_name} already downloaded")
209+
else:
210+
self.tools.logger.info(f"Downloading {cache_name}...")
211+
self._fetch_and_write_content(response, filename)
212+
except httpx.RequestError as e:
213213
if role:
214214
description = role
215215
else:
216216
description = filename.name if filename else url
217-
raise NetworkFailure(f"download {description}") from e
217+
218+
if isinstance(e, httpx.TooManyRedirects):
219+
# httpx, unlike requests, will not follow redirects indefinitely, and defaults to
220+
# 20 redirects before calling it quits. If the download attempt exceeds 20 redirects,
221+
# Briefcase probably needs to re-evaluate the URLs it is using for that download
222+
# and ideally find a starting point that won't have so many redirects.
223+
hint = "exceeded redirects when downloading the file.\n\nPlease report this as a bug to Briefcase."
224+
elif isinstance(e, httpx.DecodingError):
225+
hint = "the server sent a malformed response."
226+
else:
227+
# httpx.TransportError
228+
# Use the default hint for generic network communication errors
229+
hint = None
230+
231+
raise NetworkFailure(
232+
f"download {description}",
233+
hint,
234+
) from e
218235

219236
return filename
220237

221-
def _fetch_and_write_content(self, response: Response, filename: Path):
222-
"""Write the content from the requests Response to file.
238+
def _fetch_and_write_content(self, response: httpx.Response, filename: Path):
239+
"""Write the content from the httpx Response to file.
223240
224241
The data is initially written in to a temporary file in the Briefcase
225242
cache. This avoids partially downloaded files masquerading as complete
226243
downloads in later Briefcase runs. The temporary file is only moved
227244
to ``filename`` if the download is successful; otherwise, it is deleted.
228245
229-
:param response: ``requests.Response``
246+
:param response: ``httpx.Response``
230247
:param filename: full filesystem path to save data
231248
"""
232249
temp_file = tempfile.NamedTemporaryFile(
@@ -239,12 +256,13 @@ def _fetch_and_write_content(self, response: Response, filename: Path):
239256
with temp_file:
240257
total = response.headers.get("content-length")
241258
if total is None:
259+
response.read()
242260
temp_file.write(response.content)
243261
else:
244262
progress_bar = self.tools.input.progress_bar()
245263
task_id = progress_bar.add_task("Downloader", total=int(total))
246264
with progress_bar:
247-
for data in response.iter_content(chunk_size=1024 * 1024):
265+
for data in response.iter_bytes(chunk_size=1024 * 1024):
248266
temp_file.write(data)
249267
progress_bar.update(task_id, advance=len(data))
250268

tests/commands/create/test_install_app_support_package.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import sys
44
from unittest import mock
55

6+
import httpx
67
import pytest
7-
from requests import exceptions as requests_exceptions
88

99
from briefcase.exceptions import (
1010
InvalidSupportPackage,
@@ -428,8 +428,9 @@ def test_offline_install(
428428
app_requirements_path_index,
429429
):
430430
"""If the computer is offline, an error is raised."""
431-
create_command.tools.requests.get = mock.MagicMock(
432-
side_effect=requests_exceptions.ConnectionError
431+
stream_mock = create_command.tools.httpx.stream = mock.MagicMock()
432+
stream_mock.return_value.__enter__.side_effect = httpx.TransportError(
433+
"Unstable connection"
433434
)
434435

435436
# Installing while offline raises an error

tests/commands/create/test_install_stub_binary.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import sys
44
from unittest import mock
55

6+
import httpx
67
import pytest
7-
from requests import exceptions as requests_exceptions
88

99
from briefcase.exceptions import (
1010
InvalidStubBinary,
@@ -412,8 +412,9 @@ def test_offline_install(
412412
stub_binary_revision_path_index,
413413
):
414414
"""If the computer is offline, an error is raised."""
415-
create_command.tools.requests.get = mock.MagicMock(
416-
side_effect=requests_exceptions.ConnectionError
415+
stream_mock = create_command.tools.httpx.stream = mock.MagicMock()
416+
stream_mock.return_value.__enter__.side_effect = httpx.TransportError(
417+
"Unstable connection"
417418
)
418419

419420
# Installing while offline raises an error

tests/integrations/base/test_ToolCache.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
from pathlib import Path
88
from unittest.mock import MagicMock
99

10+
import httpx
1011
import pytest
11-
import requests
1212
from cookiecutter.main import cookiecutter
1313

1414
import briefcase.integrations
@@ -83,7 +83,7 @@ def test_third_party_tools_available():
8383
assert ToolCache.sys is sys
8484

8585
assert ToolCache.cookiecutter is cookiecutter
86-
assert ToolCache.requests is requests
86+
assert ToolCache.httpx is httpx
8787

8888

8989
def test_always_true(simple_tools, tmp_path):

0 commit comments

Comments
 (0)