From ab51413e1b764f3dec7f5f26ea575f1887139b4b Mon Sep 17 00:00:00 2001 From: TenSt Date: Mon, 24 Aug 2026 13:50:15 +0200 Subject: [PATCH] feat: serve PyPI and Simple JSON from the content app Add PythonDistribution.content_handler_json so the content app returns the same PyPI JSON and PEP 691 Simple JSON as the /pypi/ API when Accept prefers JSON. Package artifacts stay binary, and Simple JSON uses the official media type so pip can consume it. "Assisted-By: Cursor" --- CHANGES/+content-handler-json.feature | 1 + docs/user/guides/host.md | 16 ++ pulp_python/app/models.py | 112 +++++++++++- pulp_python/app/utils.py | 23 ++- .../api/test_content_handler_json.py | 141 +++++++++++++++ pulp_python/tests/unit/conftest.py | 9 + .../tests/unit/test_content_handler_json.py | 169 ++++++++++++++++++ pulp_python/tests/unit/test_utils.py | 38 ++++ 8 files changed, 500 insertions(+), 9 deletions(-) create mode 100644 CHANGES/+content-handler-json.feature create mode 100644 pulp_python/tests/functional/api/test_content_handler_json.py create mode 100644 pulp_python/tests/unit/conftest.py create mode 100644 pulp_python/tests/unit/test_content_handler_json.py create mode 100644 pulp_python/tests/unit/test_utils.py diff --git a/CHANGES/+content-handler-json.feature b/CHANGES/+content-handler-json.feature new file mode 100644 index 000000000..5c7a86535 --- /dev/null +++ b/CHANGES/+content-handler-json.feature @@ -0,0 +1 @@ +Added `PythonDistribution.content_handler_json` so the content app can serve PyPI JSON and PEP 691 Simple JSON when the client prefers `application/json`. Requires a pulpcore that includes `Distribution.content_handler_json` (issue 7887). diff --git a/docs/user/guides/host.md b/docs/user/guides/host.md index 3cce146a7..a72eca468 100644 --- a/docs/user/guides/host.md +++ b/docs/user/guides/host.md @@ -103,6 +103,22 @@ pip install --trusted-host localhost shelf-reader See the [pip docs](https://pip.pypa.io/en/stable/topics/configuration) for more details. +## JSON from the content app + +When pulpcore's content app is asked for JSON (`Accept: application/json`), Python distributions +serve the same PyPI JSON and PEP 691 Simple JSON already available from the `/pypi/` API. Clients +do not need the `/json` URL suffix or the PyPI Simple media types: + +```bash +http "${CONTENT_ORIGIN}${CONTENT_PATH_PREFIX}foo/simple/" Accept:application/json +http "${CONTENT_ORIGIN}${CONTENT_PATH_PREFIX}foo/simple/shelf-reader/" Accept:application/json +http "${CONTENT_ORIGIN}${CONTENT_PATH_PREFIX}foo/pypi/shelf-reader/" Accept:application/json +``` + +The distribution root still returns pulpcore's generic file listing. Package artifacts remain binary +regardless of `Accept`. This requires a pulpcore that includes `Distribution.content_handler_json` +(pulpcore issue 7887). + ## Migrating off Publications diff --git a/pulp_python/app/models.py b/pulp_python/app/models.py index e8e6c26dc..4585d3f56 100644 --- a/pulp_python/app/models.py +++ b/pulp_python/app/models.py @@ -8,6 +8,7 @@ from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ObjectDoesNotExist from django.db import models +from django.db.models import F, FilteredRelation, Q from django_lifecycle import ( BEFORE_SAVE, hook, @@ -36,10 +37,14 @@ from .utils import ( PYPI_LAST_SERIAL, PYPI_SERIAL_CONSTANT, + PYPI_SIMPLE_V1_JSON, artifact_to_metadata_artifact, artifact_to_python_content_data, + build_content_url, canonicalize_name, python_content_to_json, + write_simple_detail_json, + write_simple_index_json, ) log = getLogger(__name__) @@ -126,7 +131,7 @@ def content_handler(self, path): pk__in=self.publication.repository_version.content, name_normalized=normalized ) # TODO Change this value to the Repo's serial value when implemented - headers = {PYPI_LAST_SERIAL: str(PYPI_SERIAL_CONSTANT)} + headers = {PYPI_LAST_SERIAL: str(PYPI_SERIAL_CONSTANT), "Vary": "Accept"} if not settings.DOMAIN_ENABLED: domain = None json_body = python_content_to_json( @@ -137,6 +142,111 @@ def content_handler(self, path): return None + def content_handler_json(self, path): + """ + Handler to serve a JSON representation of the content at ``path`` for this Distribution. + + Called by pulpcore's content app when a client's ``Accept`` header prefers JSON, + instead of pulpcore's generic recursive file listing. This reuses the same + serialization already used by the ``pypi/*/json`` and ``pypi/simple`` PyPI APIs, so + requests made directly against the content app (e.g. by a UI or ``pip``-compatible + tool) get the same structured data without needing to know those separate URL + conventions. + + Args: + path (str): The path being requested + Returns: + None if there is no JSON representation to serve at path. Otherwise a + JSON-serializable dict, or an aiohttp.web.Response for full header control. + """ + path = PurePath(path) + parts = path.parts + if not parts: + # Distro root: let pulpcore's generic recursive listing handle it. + return None + + _, repo_version, _ = self.get_repository_publication_and_version() + if repo_version is None: + return None + content = PythonPackageContent.objects.filter(pk__in=repo_version.content) + domain = get_domain() if settings.DOMAIN_ENABLED else None + + if parts[0] == "pypi" and 2 <= len(parts) <= 3 and parts[-1] != "json": + # e.g. pypi// or pypi/// (the *-suffixed /json paths are + # already handled by content_handler, unconditionally on Accept). + name = parts[1] + version = parts[2] if len(parts) == 3 else None + normalized = canonicalize_name(name) + package_content = content.filter(name_normalized=normalized) + headers = {PYPI_LAST_SERIAL: str(PYPI_SERIAL_CONSTANT), "Vary": "Accept"} + yank_markers = dict( + PackageYank.objects.filter( + pk__in=repo_version.content, name_normalized=normalized + ).values_list("version", "yanked_reason") + ) + json_body = python_content_to_json( + self.base_path, + package_content, + version=version, + domain=domain, + repository_version=repo_version, + yank_markers=yank_markers, + ) + if json_body: + return json_response(json_body, headers=headers) + return None + + if parts[0] == "simple": + if len(parts) == 1: + names = ( + content.order_by("name_normalized") + .values_list("name", flat=True) + .distinct("name_normalized") + ) + headers = {PYPI_LAST_SERIAL: str(PYPI_SERIAL_CONSTANT), "Vary": "Accept"} + return json_response( + write_simple_index_json(list(names)), + headers=headers, + content_type=PYPI_SIMPLE_V1_JSON, + ) + + if len(parts) != 2: + return None + + normalized = canonicalize_name(parts[1]) + packages = content.filter(name_normalized=normalized).annotate( + active_membership=FilteredRelation( + "version_memberships", + condition=Q( + version_memberships__repository=repo_version.repository, + version_memberships__version_removed=None, + ), + ), + repo_added_time=F("active_membership__pulp_created"), + ) + if not packages.exists(): + return None + releases = [ + { + "filename": p.filename, + "sha256": p.sha256, + "metadata_sha256": p.metadata_sha256, + "requires_python": p.requires_python, + "size": p.size, + "upload_time": p.repo_added_time or p.pulp_created, + "version": p.version, + "url": build_content_url(self.base_path, p.filename, domain=domain), + } + for p in packages + ] + return json_response( + write_simple_detail_json(normalized, releases), + headers={PYPI_LAST_SERIAL: str(PYPI_SERIAL_CONSTANT), "Vary": "Accept"}, + content_type=PYPI_SIMPLE_V1_JSON, + ) + + return None + class Meta: default_related_name = "%(app_label)s_%(model_name)s" permissions = [ diff --git a/pulp_python/app/utils.py b/pulp_python/app/utils.py index 9e08c77c6..02012575d 100644 --- a/pulp_python/app/utils.py +++ b/pulp_python/app/utils.py @@ -506,6 +506,20 @@ def python_content_to_urls(contents, base_path, domain=None): return [python_content_to_download_info(content, base_path, domain) for content in contents] +def build_content_url(base_path, filename, domain=None): + """ + Builds the absolute content-app URL for a filename served under a distribution's base_path. + """ + origin = settings.CONTENT_ORIGIN or settings.PYPI_API_HOSTNAME or "" + origin = origin.strip("/") + prefix = settings.CONTENT_PATH_PREFIX.strip("/") + base_path = base_path.strip("/") + components = [origin, prefix, base_path, filename] + if domain: + components.insert(2, domain.name) + return "/".join(components) + + def python_content_to_download_info(content, base_path, domain=None): """ Takes in a PythonPackageContent and base path of the distribution to create a dictionary of @@ -524,14 +538,7 @@ def find_artifact(): relative_path__endswith=".metadata" ).first() artifact = find_artifact() - origin = settings.CONTENT_ORIGIN or settings.PYPI_API_HOSTNAME or "" - origin = origin.strip("/") - prefix = settings.CONTENT_PATH_PREFIX.strip("/") - base_path = base_path.strip("/") - components = [origin, prefix, base_path, content.filename] - if domain: - components.insert(2, domain.name) - url = "/".join(components) + url = build_content_url(base_path, content.filename, domain=domain) md5 = artifact.md5 if artifact and artifact.md5 else "" return { "comment_text": "", diff --git a/pulp_python/tests/functional/api/test_content_handler_json.py b/pulp_python/tests/functional/api/test_content_handler_json.py new file mode 100644 index 000000000..cd111a873 --- /dev/null +++ b/pulp_python/tests/functional/api/test_content_handler_json.py @@ -0,0 +1,141 @@ +"""Content-app JSON via Accept: application/json (PythonDistribution.content_handler_json). + +These tests hit the content app, not the DRF ``/pypi/`` API. They require pulpcore +Phase 1 (issue 7887): ``Distribution.content_handler_json`` plus Handler Accept +negotiation. When CI pulpcore is unpatched, skip — unit tests still cover the +handler return values. +""" + +from importlib.util import find_spec +from pathlib import Path +from urllib.parse import urljoin + +import pytest +import requests + +from pulp_python.tests.functional.constants import ( + PYPI_SIMPLE_V1_JSON, + PYTHON_WHEEL_FILENAME, + PYTHON_XS_PROJECT_SPECIFIER, +) + +JSON_ACCEPT = {"Accept": "application/json"} + + +def _pulpcore_content_app_json_enabled(): + """True when pulpcore will invoke Distribution.content_handler_json on JSON Accept. + + Inspect source files instead of importing Django models, which are not ready + during pytest collection. Use find_spec so editable installs are resolved. + """ + handler = find_spec("pulpcore.content.handler") + if not handler or not handler.origin: + return False + handler_path = Path(handler.origin) + publication_path = handler_path.resolve().parents[1] / "app" / "models" / "publication.py" + try: + handler_src = handler_path.read_text() + publication_src = publication_path.read_text() + except OSError: + return False + return "def negotiate_json" in handler_src and "def content_handler_json" in publication_src + + +pytestmark = pytest.mark.skipif( + not _pulpcore_content_app_json_enabled(), + reason=( + "Requires pulpcore content-app Accept negotiation (Handler.negotiate_json and " + "Distribution.content_handler_json from pulpcore issue 7887). Unit tests cover " + "PythonDistribution.content_handler_json without that pulpcore change." + ), +) + + +def _content_url(pulp_content_url, distro, rel=""): + return urljoin(pulp_content_url, f"{distro.base_path}/{rel}") + + +@pytest.mark.parallel +def test_content_app_simple_index_and_detail_json( + python_remote_factory, python_repo_with_sync, python_distribution_factory, pulp_content_url +): + """``.../simple/`` and ``.../simple//`` return PEP 691 Simple JSON.""" + remote = python_remote_factory(includes=PYTHON_XS_PROJECT_SPECIFIER) + repo = python_repo_with_sync(remote) + distro = python_distribution_factory(repository=repo) + + index = requests.get(_content_url(pulp_content_url, distro, "simple/"), headers=JSON_ACCEPT) + assert index.status_code == 200 + assert PYPI_SIMPLE_V1_JSON in index.headers["Content-Type"] + index_body = index.json() + assert index_body["meta"]["api-version"] == "1.1" + names = [project["name"] for project in index_body["projects"]] + assert any("shelf" in name.lower() for name in names) + + detail = requests.get( + _content_url(pulp_content_url, distro, "simple/shelf-reader/"), headers=JSON_ACCEPT + ) + assert detail.status_code == 200 + assert PYPI_SIMPLE_V1_JSON in detail.headers["Content-Type"] + detail_body = detail.json() + assert detail_body["name"] == "shelf-reader" + assert detail_body["files"] + assert detail_body["versions"] + filenames = [f["filename"] for f in detail_body["files"]] + assert PYTHON_WHEEL_FILENAME in filenames or any("shelf" in f for f in filenames) + + +@pytest.mark.parallel +def test_content_app_pypi_json_without_json_suffix( + python_remote_factory, python_repo_with_sync, python_distribution_factory, pulp_content_url +): + """``.../pypi//`` returns PyPI JSON without the ``/json`` URL convention.""" + remote = python_remote_factory(includes=PYTHON_XS_PROJECT_SPECIFIER) + repo = python_repo_with_sync(remote) + distro = python_distribution_factory(repository=repo) + + response = requests.get( + _content_url(pulp_content_url, distro, "pypi/shelf-reader/"), headers=JSON_ACCEPT + ) + assert response.status_code == 200 + assert "application/json" in response.headers["Content-Type"] + body = response.json() + assert "info" in body + assert "releases" in body + assert "urls" in body + assert "shelf" in body["info"]["name"].lower() + + +@pytest.mark.parallel +def test_content_app_distro_root_is_generic_listing( + python_remote_factory, python_repo_with_sync, python_distribution_factory, pulp_content_url +): + """Distribution root still uses pulpcore's generic JSON listing, not PyPI/Simple JSON.""" + remote = python_remote_factory(includes=PYTHON_XS_PROJECT_SPECIFIER) + repo = python_repo_with_sync(remote) + distro = python_distribution_factory(repository=repo) + + response = requests.get(_content_url(pulp_content_url, distro), headers=JSON_ACCEPT) + assert response.status_code == 200 + assert "application/json" in response.headers["Content-Type"] + body = response.json() + assert "packages" in body + assert "projects" not in body + assert "info" not in body + + +@pytest.mark.parallel +def test_content_app_artifact_stays_binary( + python_remote_factory, python_repo_with_sync, python_distribution_factory, pulp_content_url +): + """Concrete package artifacts stay binary even when Accept prefers JSON.""" + remote = python_remote_factory(includes=PYTHON_XS_PROJECT_SPECIFIER, policy="immediate") + repo = python_repo_with_sync(remote) + distro = python_distribution_factory(repository=repo) + + response = requests.get( + _content_url(pulp_content_url, distro, PYTHON_WHEEL_FILENAME), headers=JSON_ACCEPT + ) + assert response.status_code == 200 + assert "application/json" not in response.headers.get("Content-Type", "") + assert response.content[:2] == b"PK" # zip/wheel local file header diff --git a/pulp_python/tests/unit/conftest.py b/pulp_python/tests/unit/conftest.py new file mode 100644 index 000000000..b5a7b783e --- /dev/null +++ b/pulp_python/tests/unit/conftest.py @@ -0,0 +1,9 @@ +import os + +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pulpcore.app.settings") + + +def pytest_configure(config): + django.setup() diff --git a/pulp_python/tests/unit/test_content_handler_json.py b/pulp_python/tests/unit/test_content_handler_json.py new file mode 100644 index 000000000..4c2f23d53 --- /dev/null +++ b/pulp_python/tests/unit/test_content_handler_json.py @@ -0,0 +1,169 @@ +from datetime import datetime, timezone +from json import loads +from unittest.mock import MagicMock, patch + +from django.test import SimpleTestCase, override_settings + +from pulp_python.app.models import PythonDistribution +from pulp_python.app.utils import PYPI_LAST_SERIAL, PYPI_SERIAL_CONSTANT, PYPI_SIMPLE_V1_JSON + + +def _json_body(response): + body = response.body + if isinstance(body, bytes): + body = body.decode() + return loads(body) + + +@override_settings(DOMAIN_ENABLED=False) +class TestContentHandlerJson(SimpleTestCase): + def _distro(self, repo_version=None): + distro = MagicMock() + distro.base_path = "demo-python" + distro.get_repository_publication_and_version.return_value = ( + MagicMock(), + repo_version, + None, + ) + return distro + + def test_empty_path_returns_none(self): + distro = self._distro() + self.assertIsNone(PythonDistribution.content_handler_json(distro, "")) + distro.get_repository_publication_and_version.assert_not_called() + + def test_no_repository_version_returns_none(self): + distro = self._distro(repo_version=None) + self.assertIsNone(PythonDistribution.content_handler_json(distro, "simple")) + + def test_unknown_path_returns_none(self): + distro = self._distro(repo_version=MagicMock()) + with patch("pulp_python.app.models.PythonPackageContent.objects") as mock_objects: + mock_objects.filter.return_value = MagicMock() + self.assertIsNone(PythonDistribution.content_handler_json(distro, "not-a-pypi-path")) + + def test_pypi_json_suffix_returns_none(self): + """``pypi/*/json`` stays on content_handler; content_handler_json must not claim it.""" + distro = self._distro(repo_version=MagicMock()) + with patch("pulp_python.app.models.PythonPackageContent.objects") as mock_objects: + mock_objects.filter.return_value = MagicMock() + self.assertIsNone(PythonDistribution.content_handler_json(distro, "pypi/twine/json")) + + def test_simple_extra_path_returns_none(self): + distro = self._distro(repo_version=MagicMock()) + with patch("pulp_python.app.models.PythonPackageContent.objects") as mock_objects: + mock_objects.filter.return_value = MagicMock() + self.assertIsNone(PythonDistribution.content_handler_json(distro, "simple/twine/extra")) + + @patch("pulp_python.app.models.json_response") + @patch("pulp_python.app.models.python_content_to_json") + @patch("pulp_python.app.models.PackageYank.objects") + @patch("pulp_python.app.models.PythonPackageContent.objects") + def test_pypi_package_returns_json_response( + self, mock_objects, mock_yank, mock_to_json, mock_json_response + ): + repo_version = MagicMock() + distro = self._distro(repo_version=repo_version) + package_qs = MagicMock() + mock_objects.filter.return_value.filter.return_value = package_qs + mock_yank.filter.return_value.values_list.return_value = [] + mock_to_json.return_value = {"info": {"name": "twine"}, "last_serial": 0} + mock_json_response.return_value = "RESPONSE" + + result = PythonDistribution.content_handler_json(distro, "pypi/Twine") + + self.assertEqual(result, "RESPONSE") + mock_objects.filter.return_value.filter.assert_called_with(name_normalized="twine") + mock_to_json.assert_called_once() + kwargs = mock_to_json.call_args.kwargs + self.assertEqual(kwargs["version"], None) + self.assertIs(kwargs["repository_version"], repo_version) + mock_json_response.assert_called_once() + headers = mock_json_response.call_args.kwargs["headers"] + self.assertEqual(headers[PYPI_LAST_SERIAL], str(PYPI_SERIAL_CONSTANT)) + + @patch("pulp_python.app.models.json_response") + @patch("pulp_python.app.models.python_content_to_json") + @patch("pulp_python.app.models.PackageYank.objects") + @patch("pulp_python.app.models.PythonPackageContent.objects") + def test_pypi_version_passes_version( + self, mock_objects, mock_yank, mock_to_json, mock_json_response + ): + distro = self._distro(repo_version=MagicMock()) + mock_objects.filter.return_value.filter.return_value = MagicMock() + mock_yank.filter.return_value.values_list.return_value = [] + mock_to_json.return_value = {"info": {"name": "twine", "version": "5.1.0"}} + mock_json_response.return_value = "RESPONSE" + + result = PythonDistribution.content_handler_json(distro, "pypi/twine/5.1.0") + + self.assertEqual(result, "RESPONSE") + self.assertEqual(mock_to_json.call_args.kwargs["version"], "5.1.0") + + @patch("pulp_python.app.models.python_content_to_json") + @patch("pulp_python.app.models.PackageYank.objects") + @patch("pulp_python.app.models.PythonPackageContent.objects") + def test_pypi_missing_package_returns_none(self, mock_objects, mock_yank, mock_to_json): + distro = self._distro(repo_version=MagicMock()) + mock_objects.filter.return_value.filter.return_value = MagicMock() + mock_yank.filter.return_value.values_list.return_value = [] + mock_to_json.return_value = None + + self.assertIsNone(PythonDistribution.content_handler_json(distro, "pypi/missing")) + + @patch("pulp_python.app.models.PythonPackageContent.objects") + def test_simple_index_returns_pep691(self, mock_objects): + distro = self._distro(repo_version=MagicMock()) + names_qs = MagicMock() + names_qs.order_by.return_value.values_list.return_value.distinct.return_value = [ + "shelf-reader", + "twine", + ] + mock_objects.filter.return_value = names_qs + + result = PythonDistribution.content_handler_json(distro, "simple") + + self.assertEqual(result.content_type, PYPI_SIMPLE_V1_JSON) + body = _json_body(result) + self.assertEqual(body["meta"]["api-version"], "1.1") + self.assertEqual([p["name"] for p in body["projects"]], ["shelf-reader", "twine"]) + + @patch("pulp_python.app.models.build_content_url", return_value="http://example/pkg.whl") + @patch("pulp_python.app.models.PythonPackageContent.objects") + def test_simple_detail_returns_pep691(self, mock_objects, mock_build_url): + distro = self._distro(repo_version=MagicMock()) + pkg = MagicMock() + pkg.filename = "twine-5.1.0-py3-none-any.whl" + pkg.sha256 = "abc123" + pkg.metadata_sha256 = "def456" + pkg.requires_python = ">=3.8" + pkg.size = 12 + pkg.repo_added_time = datetime(2026, 1, 2, tzinfo=timezone.utc) + pkg.pulp_created = datetime(2026, 1, 1, tzinfo=timezone.utc) + pkg.version = "5.1.0" + + packages = MagicMock() + packages.exists.return_value = True + packages.__iter__.return_value = iter([pkg]) + mock_objects.filter.return_value.filter.return_value.annotate.return_value = packages + + result = PythonDistribution.content_handler_json(distro, "simple/Twine") + + self.assertEqual(result.content_type, PYPI_SIMPLE_V1_JSON) + body = _json_body(result) + self.assertEqual(body["name"], "twine") + self.assertEqual(body["versions"], ["5.1.0"]) + self.assertEqual(len(body["files"]), 1) + self.assertEqual(body["files"][0]["filename"], pkg.filename) + self.assertEqual(body["files"][0]["hashes"], {"sha256": "abc123"}) + self.assertEqual(body["files"][0]["url"], "http://example/pkg.whl") + mock_build_url.assert_called_once() + + @patch("pulp_python.app.models.PythonPackageContent.objects") + def test_simple_detail_missing_package_returns_none(self, mock_objects): + distro = self._distro(repo_version=MagicMock()) + packages = MagicMock() + packages.exists.return_value = False + mock_objects.filter.return_value.filter.return_value.annotate.return_value = packages + + self.assertIsNone(PythonDistribution.content_handler_json(distro, "simple/missing")) diff --git a/pulp_python/tests/unit/test_utils.py b/pulp_python/tests/unit/test_utils.py new file mode 100644 index 000000000..54d23c1d5 --- /dev/null +++ b/pulp_python/tests/unit/test_utils.py @@ -0,0 +1,38 @@ +from types import SimpleNamespace + +from django.test import SimpleTestCase, override_settings + +from pulp_python.app.utils import build_content_url + + +@override_settings( + CONTENT_ORIGIN="http://pulp.example.com", + CONTENT_PATH_PREFIX="/pulp/content/", + PYPI_API_HOSTNAME="http://unused.example.com", +) +class TestBuildContentUrl(SimpleTestCase): + def test_url_without_domain(self): + url = build_content_url("my-pypi/", "twine-5.1.0-py3-none-any.whl") + self.assertEqual( + url, + "http://pulp.example.com/pulp/content/my-pypi/twine-5.1.0-py3-none-any.whl", + ) + + def test_url_with_domain(self): + domain = SimpleNamespace(name="default") + url = build_content_url("my-pypi", "pkg.whl", domain=domain) + self.assertEqual( + url, + "http://pulp.example.com/pulp/content/default/my-pypi/pkg.whl", + ) + + +@override_settings( + CONTENT_ORIGIN="", + CONTENT_PATH_PREFIX="/pulp/content/", + PYPI_API_HOSTNAME="https://pypi.example.com", +) +class TestBuildContentUrlFallbackOrigin(SimpleTestCase): + def test_falls_back_to_pypi_api_hostname(self): + url = build_content_url("foo", "bar.whl") + self.assertEqual(url, "https://pypi.example.com/pulp/content/foo/bar.whl")