Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 2 additions & 10 deletions stub_uploader/get_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,20 @@

from typing import Any

import requests
from packaging.specifiers import Specifier, SpecifierSet
from packaging.version import Version
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

from stub_uploader.const import TYPES_PREFIX
from stub_uploader.metadata import Metadata
from stub_uploader.pypi import pypi_get

URL_TEMPLATE = "https://pypi.org/pypi/{}/json"
RETRIES = 5
RETRY_ON = [429, 500, 502, 503, 504]
TIMEOUT = 3


def fetch_pypi_versions(distribution: str) -> list[Version]:
assert distribution.startswith(TYPES_PREFIX)
url = URL_TEMPLATE.format(distribution)
retry_strategy = Retry(total=RETRIES, status_forcelist=RETRY_ON)
with requests.Session() as session:
session.mount("https://", HTTPAdapter(max_retries=retry_strategy))
resp = session.get(url, timeout=TIMEOUT)
resp = pypi_get(url)
if not resp.ok:
if resp.status_code == 404:
# Looks like this is first time this package is ever uploaded.
Expand Down
7 changes: 4 additions & 3 deletions stub_uploader/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
TYPES_PREFIX,
UPLOADED_PATH,
)
from .pypi import DOWNLOAD_TIMEOUT, pypi_get


class InvalidRequires(Exception):
Expand Down Expand Up @@ -283,7 +284,7 @@ def temp_archive_path(sdist_data: dict[str, str], req: Requirement) -> str:
tmpdir = tempfile.mkdtemp()
archive_path = Path(tmpdir, sdist_data["filename"])

resp = requests.get(sdist_data["url"], stream=True)
resp = pypi_get(sdist_data["url"], stream=True, timeout=DOWNLOAD_TIMEOUT)
validate_pypi_response(resp, req)
with open(archive_path, "wb") as file:
file.write(resp.raw.read())
Expand Down Expand Up @@ -420,7 +421,7 @@ def get_upstream_release_metadata(
"""Fetch metadata for the latest supported, non-yanked upstream release."""

project_url = f"https://pypi.org/pypi/{upstream_distribution}"
resp = requests.get(f"{project_url}/json")
resp = pypi_get(f"{project_url}/json")
validate_pypi_response(resp, req)
project_data = resp.json()

Expand All @@ -442,7 +443,7 @@ def get_upstream_release_metadata(
)

release = urllib.parse.quote(max(candidates)[1], safe="")
resp = requests.get(f"{project_url}/{release}/json")
resp = pypi_get(f"{project_url}/{release}/json")
validate_pypi_response(resp, req)
data: dict[str, Any] = resp.json()
return data
Expand Down
43 changes: 43 additions & 0 deletions stub_uploader/pypi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""HTTP access to PyPI, retried on transient failures."""

from __future__ import annotations

import functools
from typing import Any

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

RETRIES = 5
RETRY_ON = [429, 500, 502, 503, 504]
# Waits 0.4, 0.8, 1.6 and 3.2 s between retries (urllib3 does not wait before the
# first), so a brief outage does not use up every retry at once.
BACKOFF_FACTOR = 0.2
TIMEOUT = 3
# For sdist downloads. A read timeout limits inactivity, not the whole transfer,
# so a large download only fails here if the server stops sending.
DOWNLOAD_TIMEOUT = 60


@functools.cache
def _session() -> requests.Session:
# Connection errors (including TLS EOFs) and the statuses in RETRY_ON are
# retried. After the last retry the final response is returned rather than
# raised, so callers still see the status code and report it themselves.
retry_strategy = Retry(
total=RETRIES,
status_forcelist=RETRY_ON,
backoff_factor=BACKOFF_FACTOR,
raise_on_status=False,
)
session = requests.Session()
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("https://", adapter)
return session


def pypi_get(url: str, **kwargs: Any) -> requests.Response:
"""GET a URL on PyPI, retrying transient failures."""
kwargs.setdefault("timeout", TIMEOUT)
return _session().get(url, **kwargs)
10 changes: 5 additions & 5 deletions tests/test_release_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def test_supported_release_metadata(
) -> None:
metadata = Metadata("example", {"version": version_spec, field: ["numpy"]}, set())
with patch(
"stub_uploader.metadata.requests.get",
"stub_uploader.metadata.pypi_get",
side_effect=[
response(project_data),
response({"info": {"requires_dist": ["numpy"]}, "urls": []}),
Expand All @@ -65,7 +65,7 @@ def test_supported_release_metadata(
@pytest.mark.parametrize("version_spec", ["==3.*", "==1.11", "==1.12"])
def test_no_supported_release(project_data: dict[str, Any], version_spec: str) -> None:
with patch(
"stub_uploader.metadata.requests.get", return_value=response(project_data)
"stub_uploader.metadata.pypi_get", return_value=response(project_data)
) as get:
with pytest.raises(InvalidRequires, match="No published, non-yanked release"):
verify_external_req(
Expand All @@ -77,7 +77,7 @@ def test_no_supported_release(project_data: dict[str, Any], version_spec: str) -
def test_dependency_must_be_in_selected_release(project_data: dict[str, Any]) -> None:
project_data["info"]["requires_dist"] = ["numpy"]
with patch(
"stub_uploader.metadata.requests.get",
"stub_uploader.metadata.pypi_get",
side_effect=[
response(project_data),
response({"info": {"requires_dist": []}, "urls": []}),
Expand All @@ -98,7 +98,7 @@ def test_sdist_metadata_uses_selected_release(
sdist = {"packagetype": "sdist", "filename": "example-1.10.tar.gz"}
with (
patch(
"stub_uploader.metadata.requests.get",
"stub_uploader.metadata.pypi_get",
side_effect=[
response(project_data),
response({"info": {"requires_dist": []}, "urls": [sdist]}),
Expand Down Expand Up @@ -126,6 +126,6 @@ def test_pypi_error(project_data: dict[str, Any], release_error: bool) -> None:
responses = [response({}, status_code=503)]
if release_error:
responses.insert(0, response(project_data))
with patch("stub_uploader.metadata.requests.get", side_effect=responses):
with patch("stub_uploader.metadata.pypi_get", side_effect=responses):
with pytest.raises(InvalidRequires, match="got 503"):
verify_external_req(Requirement("numpy"), "example", Specifier("==1.*"))
Loading