From 5602e75a5081541e98bc583754592c90d2442446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Furga=C5=82a?= <83299832+00200200@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:32:37 +0200 Subject: [PATCH] upload: add trailing slash to /legacy repository URLs Warehouse treats /legacy and /legacy/ as different resources, so a .pypirc entry of https://test.pypi.org/legacy does not accept uploads. --- flit/upload.py | 18 ++++++++++++++++-- tests/test_upload.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/flit/upload.py b/flit/upload.py index 79c117dd..31a5c6ec 100644 --- a/flit/upload.py +++ b/flit/upload.py @@ -14,7 +14,7 @@ import sys from dataclasses import dataclass from typing import Optional -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse from flit_core.common import make_metadata, Metadata, Module from .config import read_flit_config @@ -44,6 +44,19 @@ def is_pypi(self): return self.url.rstrip('/').endswith('/legacy') +def normalize_repository_url(url: str) -> str: + """Ensure Warehouse's ``/legacy`` upload API has a trailing slash. + + ``https://test.pypi.org/legacy`` (no slash) is a different resource from + ``.../legacy/`` and does not accept uploads (issue #179). + """ + parsed = urlparse(url) + path = parsed.path or '' + if path.rstrip('/') == '/legacy' and not path.endswith('/'): + return urlunparse(parsed._replace(path=path + '/')) + return url + + def get_repositories(file="~/.pypirc"): """Get the known repositories from a pypirc file. @@ -278,7 +291,8 @@ def upload_file(file:Path, metadata:Metadata, repo: RepoDetails): log.info('Uploading %s...', file) resp = requests.post( - repo.url, data=data, files=files, auth=(repo.username, repo.password), + normalize_repository_url(repo.url), data=data, files=files, + auth=(repo.username, repo.password), ) resp.raise_for_status() diff --git a/tests/test_upload.py b/tests/test_upload.py index 95b13a01..44eb3fa9 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -143,6 +143,45 @@ def test_upload_pypirc_file(copy_sample): assert repo.password == pypirc3_pass +@responses.activate +def test_upload_legacy_url_without_trailing_slash(copy_sample): + # Warehouse's /legacy upload API requires a trailing slash. Without it the + # request hits a different resource (404 historically, 405 today). See #179. + repo = RepoDetails( + url="https://test.pypi.org/legacy", + username="user", + password="pw", + ) + responses.add(responses.POST, "https://test.pypi.org/legacy/", status=200) + td = copy_sample("module1_toml") + + with temp_pypirc(pypirc1) as pypirc, patch( + "flit.upload.get_repository", return_value=repo + ): + upload.main(td / "pyproject.toml", repo_name="pypi", pypirc_path=pypirc) + + assert len(responses.calls) == 2 + for call in responses.calls: + assert call.request.url == "https://test.pypi.org/legacy/" + + +def test_normalize_repository_url(): + assert ( + upload.normalize_repository_url("https://test.pypi.org/legacy") + == "https://test.pypi.org/legacy/" + ) + assert ( + upload.normalize_repository_url("https://upload.pypi.org/legacy") + == "https://upload.pypi.org/legacy/" + ) + already = "https://upload.pypi.org/legacy/" + assert upload.normalize_repository_url(already) == already + other = "https://pypi.example.com" + assert upload.normalize_repository_url(other) == other + with_query = "https://test.pypi.org/legacy?foo=1" + assert upload.normalize_repository_url(with_query) == "https://test.pypi.org/legacy/?foo=1" + + def test_upload_invalid_pypirc_file(copy_sample): with patch("flit.upload.upload_file"): td = copy_sample("module1_toml")