Skip to content

Commit d42426f

Browse files
authored
Merge pull request #2762 from NicholasTanz/switchUrlLib3
replace RequestsFetcher for Urllib3Fetcher
2 parents 8541a39 + 2ac8bdc commit d42426f

File tree

7 files changed

+143
-21
lines changed

7 files changed

+143
-21
lines changed

docs/api/tuf.ngclient.fetcher.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ Fetcher
55
:undoc-members:
66
:private-members: _fetch
77

8-
.. autoclass:: tuf.ngclient.RequestsFetcher
8+
.. autoclass:: tuf.ngclient.Urllib3Fetcher
99
:no-inherited-members:

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ classifiers = [
4747
dependencies = [
4848
"requests>=2.19.1",
4949
"securesystemslib~=1.0",
50+
"urllib3<3,>=1.21.1",
5051
]
5152
dynamic = ["version"]
5253

@@ -156,4 +157,4 @@ exclude_also = [
156157
]
157158
[tool.coverage.run]
158159
branch = true
159-
omit = [ "tests/*" ]
160+
omit = [ "tests/*", "tuf/ngclient/_internal/requests_fetcher.py" ]

tests/test_fetcher_ng.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright 2021, New York University and the TUF contributors
22
# SPDX-License-Identifier: MIT OR Apache-2.0
33

4-
"""Unit test for RequestsFetcher."""
4+
"""Unit test for Urllib3Fetcher."""
55

66
import io
77
import logging
@@ -13,17 +13,17 @@
1313
from typing import ClassVar
1414
from unittest.mock import Mock, patch
1515

16-
import requests
16+
import urllib3
1717

1818
from tests import utils
1919
from tuf.api import exceptions
20-
from tuf.ngclient import RequestsFetcher
20+
from tuf.ngclient import Urllib3Fetcher
2121

2222
logger = logging.getLogger(__name__)
2323

2424

2525
class TestFetcher(unittest.TestCase):
26-
"""Test RequestsFetcher class."""
26+
"""Test Urllib3Fetcher class."""
2727

2828
server_process_handler: ClassVar[utils.TestServerProcess]
2929

@@ -57,7 +57,7 @@ def tearDownClass(cls) -> None:
5757

5858
def setUp(self) -> None:
5959
# Instantiate a concrete instance of FetcherInterface
60-
self.fetcher = RequestsFetcher()
60+
self.fetcher = Urllib3Fetcher()
6161

6262
# Simple fetch.
6363
def test_fetch(self) -> None:
@@ -94,7 +94,7 @@ def test_fetch_in_chunks(self) -> None:
9494
# Incorrect URL parsing
9595
def test_url_parsing(self) -> None:
9696
with self.assertRaises(exceptions.DownloadError):
97-
self.fetcher.fetch("missing-scheme-and-hostname-in-url")
97+
self.fetcher.fetch("http://invalid/")
9898

9999
# File not found error
100100
def test_http_error(self) -> None:
@@ -104,26 +104,33 @@ def test_http_error(self) -> None:
104104
self.assertEqual(cm.exception.status_code, 404)
105105

106106
# Response read timeout error
107-
@patch.object(requests.Session, "get")
107+
@patch.object(urllib3.PoolManager, "request")
108108
def test_response_read_timeout(self, mock_session_get: Mock) -> None:
109109
mock_response = Mock()
110+
mock_response.status = 200
110111
attr = {
111-
"iter_content.side_effect": requests.exceptions.ConnectionError(
112-
"Simulated timeout"
112+
"stream.side_effect": urllib3.exceptions.MaxRetryError(
113+
urllib3.connectionpool.ConnectionPool("localhost"),
114+
"",
115+
urllib3.exceptions.TimeoutError(),
113116
)
114117
}
115118
mock_response.configure_mock(**attr)
116119
mock_session_get.return_value = mock_response
117120

118121
with self.assertRaises(exceptions.SlowRetrievalError):
119122
next(self.fetcher.fetch(self.url))
120-
mock_response.iter_content.assert_called_once()
123+
mock_response.stream.assert_called_once()
121124

122125
# Read/connect session timeout error
123126
@patch.object(
124-
requests.Session,
125-
"get",
126-
side_effect=requests.exceptions.Timeout("Simulated timeout"),
127+
urllib3.PoolManager,
128+
"request",
129+
side_effect=urllib3.exceptions.MaxRetryError(
130+
urllib3.connectionpool.ConnectionPool("localhost"),
131+
"",
132+
urllib3.exceptions.TimeoutError(),
133+
),
127134
)
128135
def test_session_get_timeout(self, mock_session_get: Mock) -> None:
129136
with self.assertRaises(exceptions.SlowRetrievalError):

tests/test_updater_ng.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,9 @@ def test_persist_metadata_fails(
316316
def test_invalid_target_base_url(self) -> None:
317317
info = TargetFile(1, {"sha256": ""}, "targetpath")
318318
with self.assertRaises(exceptions.DownloadError):
319-
self.updater.download_target(info, target_base_url="invalid_url")
319+
self.updater.download_target(
320+
info, target_base_url="http://invalid/"
321+
)
320322

321323
def test_non_existing_target_file(self) -> None:
322324
info = TargetFile(1, {"sha256": ""}, "/non_existing_file.txt")
@@ -328,7 +330,7 @@ def test_non_existing_target_file(self) -> None:
328330
def test_user_agent(self) -> None:
329331
# test default
330332
self.updater.refresh()
331-
session = next(iter(self.updater._fetcher._sessions.values()))
333+
session = self.updater._fetcher._poolManager
332334
ua = session.headers["User-Agent"]
333335
self.assertEqual(ua[:11], "python-tuf/")
334336

@@ -341,7 +343,7 @@ def test_user_agent(self) -> None:
341343
config=UpdaterConfig(app_user_agent="MyApp/1.2.3"),
342344
)
343345
updater.refresh()
344-
session = next(iter(updater._fetcher._sessions.values()))
346+
session = updater._fetcher._poolManager
345347
ua = session.headers["User-Agent"]
346348

347349
self.assertEqual(ua[:23], "MyApp/1.2.3 python-tuf/")

tuf/ngclient/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
# sigstore-python 1.0 still uses the module from there). requests_fetcher
1010
# can be moved out of _internal once sigstore-python 1.0 is not relevant.
1111
from tuf.ngclient._internal.requests_fetcher import RequestsFetcher
12+
from tuf.ngclient._internal.urllib3_fetcher import Urllib3Fetcher
1213
from tuf.ngclient.config import UpdaterConfig
1314
from tuf.ngclient.fetcher import FetcherInterface
1415
from tuf.ngclient.updater import Updater
1516

1617
__all__ = [ # noqa: PLE0604
1718
FetcherInterface.__name__,
1819
RequestsFetcher.__name__,
20+
Urllib3Fetcher.__name__,
1921
TargetFile.__name__,
2022
Updater.__name__,
2123
UpdaterConfig.__name__,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright 2021, New York University and the TUF contributors
2+
# SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+
"""Provides an implementation of ``FetcherInterface`` using the urllib3 HTTP
5+
library.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
from typing import TYPE_CHECKING
12+
13+
# Imports
14+
import urllib3
15+
16+
import tuf
17+
from tuf.api import exceptions
18+
from tuf.ngclient.fetcher import FetcherInterface
19+
20+
if TYPE_CHECKING:
21+
from collections.abc import Iterator
22+
23+
# Globals
24+
logger = logging.getLogger(__name__)
25+
26+
27+
# Classes
28+
class Urllib3Fetcher(FetcherInterface):
29+
"""An implementation of ``FetcherInterface`` based on the urllib3 library.
30+
31+
Attributes:
32+
socket_timeout: Timeout in seconds, used for both initial connection
33+
delay and the maximum delay between bytes received.
34+
chunk_size: Chunk size in bytes used when downloading.
35+
"""
36+
37+
def __init__(
38+
self,
39+
socket_timeout: int = 30,
40+
chunk_size: int = 400000,
41+
app_user_agent: str | None = None,
42+
) -> None:
43+
# Default settings
44+
self.socket_timeout: int = socket_timeout # seconds
45+
self.chunk_size: int = chunk_size # bytes
46+
47+
# Create User-Agent.
48+
ua = f"python-tuf/{tuf.__version__}"
49+
if app_user_agent is not None:
50+
ua = f"{app_user_agent} {ua}"
51+
52+
self._poolManager = urllib3.PoolManager(headers={"User-Agent": ua})
53+
54+
def _fetch(self, url: str) -> Iterator[bytes]:
55+
"""Fetch the contents of HTTP/HTTPS url from a remote server.
56+
57+
Args:
58+
url: URL string that represents a file location.
59+
60+
Raises:
61+
exceptions.SlowRetrievalError: Timeout occurs while receiving
62+
data.
63+
exceptions.DownloadHTTPError: HTTP error code is received.
64+
65+
Returns:
66+
Bytes iterator
67+
"""
68+
69+
# Defer downloading the response body with preload_content=False.
70+
# Always set the timeout. This timeout value is interpreted by
71+
# urllib3 as:
72+
# - connect timeout (max delay before first byte is received)
73+
# - read (gap) timeout (max delay between bytes received)
74+
try:
75+
response = self._poolManager.request(
76+
"GET",
77+
url,
78+
preload_content=False,
79+
timeout=urllib3.Timeout(self.socket_timeout),
80+
)
81+
except urllib3.exceptions.MaxRetryError as e:
82+
if isinstance(e.reason, urllib3.exceptions.TimeoutError):
83+
raise exceptions.SlowRetrievalError from e
84+
85+
if response.status >= 400:
86+
response.close()
87+
raise exceptions.DownloadHTTPError(
88+
f"HTTP error occurred with status {response.status}",
89+
response.status,
90+
)
91+
92+
return self._chunks(response)
93+
94+
def _chunks(
95+
self, response: urllib3.response.BaseHTTPResponse
96+
) -> Iterator[bytes]:
97+
"""A generator function to be returned by fetch.
98+
99+
This way the caller of fetch can differentiate between connection
100+
and actual data download.
101+
"""
102+
103+
try:
104+
yield from response.stream(self.chunk_size)
105+
except urllib3.exceptions.MaxRetryError as e:
106+
if isinstance(e.reason, urllib3.exceptions.TimeoutError):
107+
raise exceptions.SlowRetrievalError from e
108+
109+
finally:
110+
response.release_conn()

tuf/ngclient/updater.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949

5050
from tuf.api import exceptions
5151
from tuf.api.metadata import Root, Snapshot, TargetFile, Targets, Timestamp
52-
from tuf.ngclient._internal import requests_fetcher, trusted_metadata_set
52+
from tuf.ngclient._internal import trusted_metadata_set, urllib3_fetcher
5353
from tuf.ngclient.config import EnvelopeType, UpdaterConfig
5454

5555
if TYPE_CHECKING:
@@ -71,7 +71,7 @@ class Updater:
7171
target_base_url: ``Optional``; Default base URL for all remote target
7272
downloads. Can be individually set in ``download_target()``
7373
fetcher: ``Optional``; ``FetcherInterface`` implementation used to
74-
download both metadata and targets. Default is ``RequestsFetcher``
74+
download both metadata and targets. Default is ``Urllib3Fetcher``
7575
config: ``Optional``; ``UpdaterConfig`` could be used to setup common
7676
configuration options.
7777
@@ -102,7 +102,7 @@ def __init__(
102102
if fetcher is not None:
103103
self._fetcher = fetcher
104104
else:
105-
self._fetcher = requests_fetcher.RequestsFetcher(
105+
self._fetcher = urllib3_fetcher.Urllib3Fetcher(
106106
app_user_agent=self.config.app_user_agent
107107
)
108108

0 commit comments

Comments
 (0)