From 865e4d7ef62af945b602292f85088c6164727e9c Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Sat, 16 May 2026 23:46:54 +0000 Subject: [PATCH 1/3] feat: add --merge-with to merge with a remote PEP 503 index (#133) Fetches the remote simple index (read-only) and appends absolute-URL anchors for any wheel filenames not present locally, so pip can fetch remote-only wheels without ever overwriting them. Local wheels win on filename collisions; remote unreachable falls back to local-only. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 16 +++ src/index_503/index.py | 59 +++++++-- src/index_503/main.py | 17 ++- src/index_503/page_generator.py | 13 +- src/index_503/remote_index.py | 156 ++++++++++++++++++++++++ tests/test_remote_index.py | 207 ++++++++++++++++++++++++++++++++ 6 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 src/index_503/remote_index.py create mode 100644 tests/test_remote_index.py diff --git a/README.md b/README.md index 8fe1d6e..2020536 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,22 @@ Running this again will replace the original index and delete the old index in a A lock will be held in the parent directory to prevent concurrent executions. +### Merging with a remote index + +When you only have a partial set of wheels locally (for example after an +incremental rsync) and want the generated index to keep advertising the wheels +already published on the remote host, pass `--merge-with`: + +``` +index-503 musllinux --merge-with https://wheels.example.com/musllinux-index/ +``` + +The remote `index.html` and per-project pages are fetched read-only. For each +remote wheel whose filename is not present locally, an anchor with the +absolute remote URL is appended to the project page so `pip` can fetch it +directly. Local wheels always take precedence on filename collisions. If the +remote index cannot be reached, only the local wheels are indexed. + ## Example For image builds diff --git a/src/index_503/index.py b/src/index_503/index.py index 919dd0f..cb8dc5f 100644 --- a/src/index_503/index.py +++ b/src/index_503/index.py @@ -6,6 +6,7 @@ from pathlib import Path from shutil import rmtree from tempfile import mkdtemp +from typing import Optional from natsort import natsorted from yarl import URL @@ -13,19 +14,24 @@ from .cache import IndexCache from .file import write_utf8_file from .page_generator import generate_index, generate_project_page +from .remote_index import RemoteEntry, fetch_remote_index from .util import exclusive_lock, get_mtime_and_size_from_path from .wheel_file import WheelFile _LOGGER = logging.getLogger(__name__) -def make_index(origin_path: Path) -> Path: +def make_index(origin_path: Path, merge_with: Optional[str] = None) -> Path: """Generate a simple repository of Python wheels. This function will take a directory of wheels at the top level of a webserver and generate a simple repository of wheels. - :param origin: The name of the directory containing the wheels. + :param origin_path: The directory containing the wheels. + :param merge_with: Optional URL of a remote PEP 503 index whose entries + should be merged into the generated index. Local wheels take + precedence when filenames collide; remote-only files are emitted as + absolute-URL anchors so ``pip`` can fetch them directly. Example musllinux @@ -34,19 +40,22 @@ def make_index(origin_path: Path) -> Path: musllinux-index """ with exclusive_lock(origin_path): - return IndexMaker(origin_path).make_index() + return IndexMaker(origin_path, merge_with=merge_with).make_index() class IndexMaker: """Generate a simple repository of Python wheels.""" - def __init__(self, origin_path: Path) -> None: + def __init__( + self, origin_path: Path, merge_with: Optional[str] = None + ) -> None: """Generate a simple repository of Python wheels.""" self.origin_path = origin_path self.origin_name = origin_path.name target_path = origin_path.parent / (origin_path.name + "-index") self.target_path = target_path self.cache = IndexCache(target_path) + self.merge_with = merge_with def make_index(self) -> Path: """Generate a simple repository of Python wheels.""" @@ -120,26 +129,58 @@ def _make_index_at_temp_dir(self, temp_dir_path: Path) -> None: os.link(wheel_path, target_file) self.cache.remove_stale_keys(all_wheel_files) - self.generate_index_pages(temp_dir_path, projects) + remote_extras = self._collect_remote_extras(all_wheel_files) + self.generate_index_pages(temp_dir_path, projects, remote_extras) self.cache.write_to_new(temp_dir_path) + def _collect_remote_extras( + self, local_wheel_filenames: set[str] + ) -> dict[str, list[RemoteEntry]]: + """Fetch the merge-target index and drop any files we already serve.""" + if not self.merge_with: + return {} + remote = fetch_remote_index(self.merge_with) + extras: dict[str, list[RemoteEntry]] = {} + for canonical_name, entries in remote.items(): + filtered = [ + entry for entry in entries + if entry.filename not in local_wheel_filenames + ] + if filtered: + extras[canonical_name] = filtered + return extras + def generate_index_pages( - self, temp_dir_path: Path, projects: dict[str, list[WheelFile]] + self, + temp_dir_path: Path, + projects: dict[str, list[WheelFile]], + remote_extras: Optional[dict[str, list[RemoteEntry]]] = None, ) -> None: """Generate the index pages.""" - index_content = str(generate_index(projects.keys())) + remote_extras = remote_extras or {} + all_project_names = set(projects.keys()) | set(remote_extras.keys()) + index_content = str(generate_index(all_project_names)) write_utf8_file(temp_dir_path.joinpath("index.html"), index_content) project_base_url = URL("../") - for canonical_name, project_files in projects.items(): + for canonical_name in all_project_names: + project_files = projects.get(canonical_name, []) + extras = remote_extras.get(canonical_name, []) project_dir: Path = temp_dir_path.joinpath(canonical_name) project_dir.mkdir(exist_ok=True, mode=0o755) project_index = generate_project_page( canonical_name, natsorted(project_files, key=attrgetter("filename"), reverse=True), project_base_url, + extra_entries=natsorted( + extras, key=attrgetter("filename"), reverse=True + ), ) write_utf8_file(project_dir.joinpath("index.html"), str(project_index)) - _LOGGER.debug("Generated index pages for %s projects.", len(projects)) + _LOGGER.debug( + "Generated index pages for %s projects (%s remote-only).", + len(all_project_names), + len(remote_extras), + ) diff --git a/src/index_503/main.py b/src/index_503/main.py index 04deda4..e724b67 100644 --- a/src/index_503/main.py +++ b/src/index_503/main.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Optional import click from consolekit import click_command @@ -7,10 +8,22 @@ @click.argument("origin", type=click.STRING) +@click.option( + "--merge-with", + "merge_with", + type=click.STRING, + default=None, + help=( + "URL of a remote PEP 503 index to merge with. Wheels present locally " + "take precedence; remote-only wheels are linked via their absolute " + "URL so 'pip' can fetch them directly. Useful when rsync'ing a " + "partial wheel set to a host that already serves additional wheels." + ), +) @click_command() -def main_cli(origin: str) -> None: +def main_cli(origin: str, merge_with: Optional[str] = None) -> None: origin_path = Path(origin) if not origin_path.exists(): raise FileNotFoundError(f"Directory {origin_path} does not exist") - target_path = make_index(origin_path) + target_path = make_index(origin_path, merge_with=merge_with) print(f"Index generated at {target_path}") diff --git a/src/index_503/page_generator.py b/src/index_503/page_generator.py index adf4a7f..92791b3 100644 --- a/src/index_503/page_generator.py +++ b/src/index_503/page_generator.py @@ -5,6 +5,7 @@ from natsort import natsorted from yarl import URL +from .remote_index import RemoteEntry from .util import canonicalize_name from .wheel_file import WheelFile @@ -40,7 +41,10 @@ def generate_index(projects: Iterable[str]) -> Airium: def generate_project_page( - name: str, files: Iterable[WheelFile], base_url: Union[str, URL] = "/" + name: str, + files: Iterable[WheelFile], + base_url: Union[str, URL] = "/", + extra_entries: Iterable[RemoteEntry] = (), ) -> Airium: """ Generate the repository page for a project. @@ -49,6 +53,9 @@ def generate_project_page( :param files: An iterable of files for the project, which will be linked to from the index page. :param base_url: The base URL of the Python package repository. For example, with PyPI's URL, a URL of /foo/ would be https://pypi.org/simple/foo/. + :param extra_entries: Additional anchor entries (e.g. fetched from a remote + index when merging) to append below the local wheel anchors. Their + ``href`` values are emitted verbatim, so they must already be absolute. """ name = canonicalize_name(name) @@ -70,6 +77,10 @@ def generate_project_page( wheel_file.as_anchor(page, base_url) page.br() + for entry in extra_entries: + entry.as_anchor(page) + page.br() + return page diff --git a/src/index_503/remote_index.py b/src/index_503/remote_index.py new file mode 100644 index 0000000..0f5b7e1 --- /dev/null +++ b/src/index_503/remote_index.py @@ -0,0 +1,156 @@ +"""Fetch and parse remote PEP 503 simple indexes for merging.""" +import logging +from dataclasses import dataclass +from html import escape +from html.parser import HTMLParser +from typing import Optional +from urllib.error import URLError +from urllib.parse import urljoin +from urllib.request import Request, urlopen + +from airium import Airium + +from .util import canonicalize_name + +_LOGGER = logging.getLogger(__name__) + +_USER_AGENT = "index-503" +_DEFAULT_TIMEOUT = 30 + + +@dataclass +class RemoteEntry: + """A wheel entry parsed from a remote PEP 503 project page.""" + + filename: str + href: str # absolute URL, may include ``#sha256=...`` fragment + requires_python: Optional[str] = None + dist_info_metadata: Optional[str] = None # raw PEP 658 attribute value + core_metadata: Optional[str] = None # raw PEP 714 attribute value + + def as_anchor(self, page: Airium) -> None: + """Emit an anchor tag preserving the remote attributes.""" + kwargs: dict[str, str] = {"href": self.href} + if self.requires_python is not None: + kwargs["data-requires-python"] = escape(self.requires_python) + if self.dist_info_metadata is not None: + kwargs["data-dist-info-metadata"] = self.dist_info_metadata + if self.core_metadata is not None: + kwargs["data-core-metadata"] = self.core_metadata + with page.a(**kwargs): + page(self.filename) + + +class _AnchorParser(HTMLParser): + """Collect anchor tags (href + attrs + text) from a simple index page.""" + + def __init__(self) -> None: + super().__init__() + self.anchors: list[tuple[dict[str, str], str]] = [] + self._current_attrs: Optional[dict[str, str]] = None + self._current_text: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: + if tag == "a": + self._current_attrs = {k: v for k, v in attrs if v is not None} + self._current_text = [] + + def handle_data(self, data: str) -> None: + if self._current_attrs is not None: + self._current_text.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag == "a" and self._current_attrs is not None: + text = "".join(self._current_text).strip() + self.anchors.append((self._current_attrs, text)) + self._current_attrs = None + self._current_text = [] + + +def _fetch(url: str, timeout: int) -> str: + """Fetch a URL and return the decoded body.""" + request = Request(url, headers={"User-Agent": _USER_AGENT, "Accept": "text/html"}) + with urlopen(request, timeout=timeout) as response: # noqa: S310 + charset = response.headers.get_content_charset() or "utf-8" + return response.read().decode(charset, errors="replace") + + +def _parse_anchors(html: str) -> list[tuple[dict[str, str], str]]: + parser = _AnchorParser() + parser.feed(html) + parser.close() + return parser.anchors + + +def _parse_top_index(html: str) -> list[str]: + """Return the list of project hrefs from a top-level simple index page.""" + return [attrs["href"] for attrs, _ in _parse_anchors(html) if "href" in attrs] + + +def _parse_project_page(html: str, page_url: str) -> list[RemoteEntry]: + """Return the wheel entries from a project page, with absolute hrefs.""" + entries: list[RemoteEntry] = [] + for attrs, text in _parse_anchors(html): + href = attrs.get("href") + if not href or not text: + continue + # Only collect wheel entries — skip sdists and other formats. + if not text.endswith(".whl"): + continue + entries.append( + RemoteEntry( + filename=text, + href=urljoin(page_url, href), + requires_python=attrs.get("data-requires-python"), + dist_info_metadata=attrs.get("data-dist-info-metadata"), + core_metadata=attrs.get("data-core-metadata"), + ) + ) + return entries + + +def fetch_remote_index( + base_url: str, timeout: int = _DEFAULT_TIMEOUT +) -> dict[str, list[RemoteEntry]]: + """Fetch a remote PEP 503 index and return entries grouped by canonical name. + + Returns an empty dict if the index cannot be reached. + """ + if not base_url.endswith("/"): + base_url = base_url + "/" + + try: + index_html = _fetch(base_url, timeout) + except (URLError, OSError) as exc: + _LOGGER.warning("Failed to fetch remote index %s: %s", base_url, exc) + return {} + + result: dict[str, list[RemoteEntry]] = {} + for project_href in _parse_top_index(index_html): + project_url = urljoin(base_url, project_href) + if not project_url.endswith("/"): + project_url = project_url + "/" + try: + page_html = _fetch(project_url, timeout) + except (URLError, OSError) as exc: + _LOGGER.warning( + "Failed to fetch remote project page %s: %s", project_url, exc + ) + continue + entries = _parse_project_page(page_html, project_url) + if not entries: + continue + # Use the filename's canonical project name when possible so we match + # local projects even if the remote uses a non-canonical directory name. + project_name = project_href.strip("/").split("/")[-1] + canonical = canonicalize_name(project_name) + # Deduplicate by filename within the same project. + seen: set[str] = set() + unique: list[RemoteEntry] = [] + for entry in entries: + if entry.filename in seen: + continue + seen.add(entry.filename) + unique.append(entry) + result.setdefault(canonical, []).extend(unique) + return result diff --git a/tests/test_remote_index.py b/tests/test_remote_index.py new file mode 100644 index 0000000..25596c5 --- /dev/null +++ b/tests/test_remote_index.py @@ -0,0 +1,207 @@ +"""Tests for the remote PEP 503 index fetcher and the merge integration.""" +from io import BytesIO +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from index_503.index import make_index +from index_503.remote_index import ( + RemoteEntry, + _parse_project_page, + _parse_top_index, + fetch_remote_index, +) + +from . import FIXTURES +from .test_index import setup_wheels + +TEST_WHEELS = ( + "bleak-0.17.0-py3-none-any.whl", + "typing_extensions-4.2.0-py3-none-any.whl", +) + + +_TOP_INDEX_HTML = """ + +bleak
+other-pkg
+typing-extensions
+ +""" + +_BLEAK_PAGE_HTML = """ + +bleak-0.17.0-py3-none-any.whl
+bleak-0.18.0-py3-none-any.whl
+ +""" + +_OTHER_PAGE_HTML = """ + +other_pkg-1.2.3-py3-none-any.whl
+other_pkg-1.2.3.tar.gz
+ +""" + +_TYPING_EXT_PAGE_HTML = """ + +typing_extensions-4.2.0-py3-none-any.whl
+ +""" + + +class _FakeResponse: + def __init__(self, body: bytes) -> None: + self._body = BytesIO(body) + self.headers = type( + "H", (), {"get_content_charset": staticmethod(lambda: "utf-8")} + )() + + def __enter__(self) -> "_FakeResponse": + return self + + def __exit__(self, *args: Any) -> None: + self._body.close() + + def read(self) -> bytes: + return self._body.read() + + +def _fake_urlopen_factory(pages: dict[str, str]) -> Any: + def _fake_urlopen(request: Any, timeout: int = 30) -> _FakeResponse: + url = request.full_url if hasattr(request, "full_url") else str(request) + body = pages[url].encode("utf-8") + return _FakeResponse(body) + + return _fake_urlopen + + +def test_parse_top_index_returns_project_hrefs() -> None: + hrefs = _parse_top_index(_TOP_INDEX_HTML) + assert hrefs == ["bleak/", "other-pkg/", "typing-extensions/"] + + +def test_parse_project_page_only_keeps_wheels_with_absolute_href() -> None: + entries = _parse_project_page( + _OTHER_PAGE_HTML, "https://example.com/simple/other-pkg/" + ) + assert len(entries) == 1 + entry = entries[0] + assert entry.filename == "other_pkg-1.2.3-py3-none-any.whl" + assert entry.href == ( + "https://example.com/simple/other_pkg-1.2.3-py3-none-any.whl#sha256=other-hash" + ) + + +def test_parse_project_page_preserves_metadata_attrs() -> None: + entries = _parse_project_page(_BLEAK_PAGE_HTML, "https://example.com/simple/bleak/") + assert {e.filename for e in entries} == { + "bleak-0.17.0-py3-none-any.whl", + "bleak-0.18.0-py3-none-any.whl", + } + by_name = {e.filename: e for e in entries} + older = by_name["bleak-0.17.0-py3-none-any.whl"] + assert older.requires_python == ">=3.7,<4.0" + assert older.dist_info_metadata == "sha256=meta-hash-A" + assert older.core_metadata is None + newer = by_name["bleak-0.18.0-py3-none-any.whl"] + assert newer.core_metadata == "sha256=meta-hash-B" + assert newer.dist_info_metadata is None + + +def test_fetch_remote_index_groups_by_canonical_name() -> None: + base = "https://example.com/simple/" + pages = { + base: _TOP_INDEX_HTML, + base + "bleak/": _BLEAK_PAGE_HTML, + base + "other-pkg/": _OTHER_PAGE_HTML, + base + "typing-extensions/": _TYPING_EXT_PAGE_HTML, + } + with patch( + "index_503.remote_index.urlopen", side_effect=_fake_urlopen_factory(pages) + ): + result = fetch_remote_index(base) + + assert set(result) == {"bleak", "other-pkg", "typing-extensions"} + assert {e.filename for e in result["bleak"]} == { + "bleak-0.17.0-py3-none-any.whl", + "bleak-0.18.0-py3-none-any.whl", + } + assert [e.filename for e in result["other-pkg"]] == [ + "other_pkg-1.2.3-py3-none-any.whl" + ] + + +def test_fetch_remote_index_returns_empty_on_error() -> None: + def _boom(*args: Any, **kwargs: Any) -> Any: + raise OSError("nope") + + with patch("index_503.remote_index.urlopen", side_effect=_boom): + assert fetch_remote_index("https://example.invalid/simple/") == {} + + +def test_make_index_merges_remote_only_wheels(tmp_path: Path) -> None: + """Remote-only wheels are added as absolute-URL anchors; local ones win.""" + origin_path, origin_path_index = setup_wheels(tmp_path, TEST_WHEELS) + base = "https://example.com/simple/" + pages = { + base: _TOP_INDEX_HTML, + base + "bleak/": _BLEAK_PAGE_HTML, + base + "other-pkg/": _OTHER_PAGE_HTML, + base + "typing-extensions/": _TYPING_EXT_PAGE_HTML, + } + with patch( + "index_503.remote_index.urlopen", side_effect=_fake_urlopen_factory(pages) + ): + assert make_index(origin_path, merge_with=base) == origin_path_index + + top = origin_path_index.joinpath("index.html").read_text() + assert "/bleak/" in top + assert "/other-pkg/" in top + assert "/typing-extensions/" in top + + bleak_page = origin_path_index.joinpath("bleak", "index.html").read_text() + # Local bleak 0.17.0 keeps its true sha256 from the actual wheel. + assert "remote-hash-A" not in bleak_page + # Remote-only 0.18.0 is appended with its absolute URL. + assert ( + "https://example.com/simple/bleak-0.18.0-py3-none-any.whl#sha256=remote-hash-B" + in bleak_page + ) + assert "data-core-metadata" in bleak_page + + # Remote-only project gets its own page with absolute links. + other_page = origin_path_index.joinpath("other-pkg", "index.html").read_text() + assert ( + "https://example.com/simple/other_pkg-1.2.3-py3-none-any.whl#sha256=other-hash" + in other_page + ) + + # typing-extensions has only a local wheel with the same filename as remote, + # so the project page must not contain the remote hash. + te_page = origin_path_index.joinpath( + "typing-extensions", "index.html" + ).read_text() + assert "remote-te-hash" not in te_page + + +def test_remote_entry_as_anchor_emits_attributes() -> None: + from airium import Airium + + page = Airium() + RemoteEntry( + filename="foo-1.0-py3-none-any.whl", + href="https://example.com/foo-1.0-py3-none-any.whl#sha256=abc", + requires_python=">=3.9", + dist_info_metadata="sha256=def", + ).as_anchor(page) + html = str(page) + assert 'href="https://example.com/foo-1.0-py3-none-any.whl#sha256=abc"' in html + assert 'data-requires-python=">=3.9"' in html + assert 'data-dist-info-metadata="sha256=def"' in html + assert "foo-1.0-py3-none-any.whl" in html + assert "" in html From 65e0f3f144ad58405fea875e5b1809a1df1478c8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 23:47:40 +0000 Subject: [PATCH 2/3] chore(pre-commit.ci): auto fixes --- src/index_503/index.py | 7 +++---- src/index_503/remote_index.py | 1 + tests/test_remote_index.py | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/index_503/index.py b/src/index_503/index.py index cb8dc5f..6beef91 100644 --- a/src/index_503/index.py +++ b/src/index_503/index.py @@ -46,9 +46,7 @@ def make_index(origin_path: Path, merge_with: Optional[str] = None) -> Path: class IndexMaker: """Generate a simple repository of Python wheels.""" - def __init__( - self, origin_path: Path, merge_with: Optional[str] = None - ) -> None: + def __init__(self, origin_path: Path, merge_with: Optional[str] = None) -> None: """Generate a simple repository of Python wheels.""" self.origin_path = origin_path self.origin_name = origin_path.name @@ -143,7 +141,8 @@ def _collect_remote_extras( extras: dict[str, list[RemoteEntry]] = {} for canonical_name, entries in remote.items(): filtered = [ - entry for entry in entries + entry + for entry in entries if entry.filename not in local_wheel_filenames ] if filtered: diff --git a/src/index_503/remote_index.py b/src/index_503/remote_index.py index 0f5b7e1..c63dbec 100644 --- a/src/index_503/remote_index.py +++ b/src/index_503/remote_index.py @@ -1,4 +1,5 @@ """Fetch and parse remote PEP 503 simple indexes for merging.""" + import logging from dataclasses import dataclass from html import escape diff --git a/tests/test_remote_index.py b/tests/test_remote_index.py index 25596c5..379952d 100644 --- a/tests/test_remote_index.py +++ b/tests/test_remote_index.py @@ -1,4 +1,5 @@ """Tests for the remote PEP 503 index fetcher and the merge integration.""" + from io import BytesIO from pathlib import Path from typing import Any @@ -183,9 +184,7 @@ def test_make_index_merges_remote_only_wheels(tmp_path: Path) -> None: # typing-extensions has only a local wheel with the same filename as remote, # so the project page must not contain the remote hash. - te_page = origin_path_index.joinpath( - "typing-extensions", "index.html" - ).read_text() + te_page = origin_path_index.joinpath("typing-extensions", "index.html").read_text() assert "remote-te-hash" not in te_page From 37ee8a8fa65490f9cab26408cc2ed486cdaacbef Mon Sep 17 00:00:00 2001 From: aiolibsbot Date: Sun, 17 May 2026 01:11:23 +0000 Subject: [PATCH 3/3] fix: drop unused FIXTURES import; add bandit nosec for urlopen flake8 flagged F401 for an unused `from . import FIXTURES` in tests/test_remote_index.py, and bandit re-flagged B310 on the urlopen call despite the existing `# noqa: S310` (which is a flake8-bandit marker, not a bandit one). Add `# nosec B310` to silence bandit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index_503/remote_index.py | 2 +- tests/test_remote_index.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/index_503/remote_index.py b/src/index_503/remote_index.py index c63dbec..b2980a9 100644 --- a/src/index_503/remote_index.py +++ b/src/index_503/remote_index.py @@ -71,7 +71,7 @@ def handle_endtag(self, tag: str) -> None: def _fetch(url: str, timeout: int) -> str: """Fetch a URL and return the decoded body.""" request = Request(url, headers={"User-Agent": _USER_AGENT, "Accept": "text/html"}) - with urlopen(request, timeout=timeout) as response: # noqa: S310 + with urlopen(request, timeout=timeout) as response: # noqa: S310 # nosec B310 charset = response.headers.get_content_charset() or "utf-8" return response.read().decode(charset, errors="replace") diff --git a/tests/test_remote_index.py b/tests/test_remote_index.py index 379952d..959d936 100644 --- a/tests/test_remote_index.py +++ b/tests/test_remote_index.py @@ -13,7 +13,6 @@ fetch_remote_index, ) -from . import FIXTURES from .test_index import setup_wheels TEST_WHEELS = (