Skip to content

fix(publish): normalize legacy repository URL trailing slash#10732

Open
LouisLau-art wants to merge 1 commit intopython-poetry:mainfrom
LouisLau-art:fix/normalize-legacy-upload-url-trailing-slash-6687
Open

fix(publish): normalize legacy repository URL trailing slash#10732
LouisLau-art wants to merge 1 commit intopython-poetry:mainfrom
LouisLau-art:fix/normalize-legacy-upload-url-trailing-slash-6687

Conversation

@LouisLau-art
Copy link
Contributor

@LouisLau-art LouisLau-art commented Feb 13, 2026

Fixes #6687.

When a repository upload URL ends with /legacy (no trailing slash), some servers respond with a redirect that can cause uploads to silently fail. Poetry now normalizes legacy upload URLs to ensure they end with /legacy/.

Test:

  • python -m pytest -o addopts='' tests/publishing/test_publisher.py

Summary by Sourcery

Normalize legacy repository upload URLs used during publishing to avoid issues caused by missing trailing slashes.

Bug Fixes:

  • Ensure legacy repository URLs ending with /legacy are normalized to include a trailing slash before publishing to prevent redirect-related upload failures.

Tests:

  • Add a publishing test verifying that legacy repository URLs without a trailing slash are normalized before invoking the uploader.

@sourcery-ai
Copy link

sourcery-ai bot commented Feb 13, 2026

Reviewer's Guide

Normalizes legacy repository upload URLs to always end with a trailing slash and adds coverage to ensure publishing uses the normalized URL when configured without it.

Class diagram for Publisher and legacy repository URL normalization

classDiagram
    class publisher_module {
        _normalize_legacy_repository_url(url: str) str
    }

    class Publisher {
        +publish(repository_name: str, username: str, password: str, cert: str, client_cert: str, dry_run: bool) None
    }

    publisher_module ..> Publisher : used_by
Loading

File-Level Changes

Change Details Files
Normalize legacy /legacy repository URLs to always include a trailing slash before publishing.
  • Introduce a helper that parses a repository URL, detects a /legacy path without a trailing slash, and appends the slash while preserving other URL components.
  • Apply the normalization to configured repository URLs in Publisher.publish before authenticating and uploading.
src/poetry/publishing/publisher.py
Add a publishing test to verify that /legacy URLs without a trailing slash are normalized and used for upload.
  • Patch Uploader.auth and Uploader.upload to capture how authentication and upload are invoked during publish.
  • Configure a test repository with a /legacy URL lacking a trailing slash and associated basic auth credentials.
  • Assert that publish calls auth with the configured credentials and upload with the normalized /legacy/ URL and expected upload options.
tests/publishing/test_publisher.py

Assessment against linked issues

Issue Objective Addressed Explanation
#6687 Ensure that publishing to PEP 503-style legacy repositories configured without a trailing slash (e.g., https://test.pypi.org/legacy) works correctly by normalizing the upload URL so uploads do not silently fail.
#6687 Fix handling of configured PyPI API tokens (e.g., via poetry config pypi-token.<repo> ...) so that they are properly stored/used for authentication instead of yielding HTTP 403 errors. The PR only introduces _normalize_legacy_repository_url and uses it to append a trailing slash to /legacy URLs, plus a test verifying the normalized URL and basic auth usage. It does not modify any token storage or authentication logic related to pypi-token.<repo> configuration or 403 auth errors.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `tests/publishing/test_publisher.py:103-104` </location>
<code_context>
+    publisher = Publisher(poetry, NullIO())
+    publisher.publish("testpypi", None, None)
+
+    assert uploader_auth.call_args == [("foo", "bar")]
+    assert uploader_upload.call_args == [
+        ("https://test.pypi.org/legacy/",),
+        {"cert": True, "client_cert": None, "dry_run": False, "skip_existing": False},
</code_context>

<issue_to_address>
**issue (testing):** Use mock's assertion helpers instead of comparing `call_args` to raw lists

`call_args` is a `(args, kwargs)` tuple, so comparing it to a list will not work as intended and may cause this test to always fail. Instead, assert on the expected calls via the mock helpers:

```python
uploader_auth.assert_called_once_with("foo", "bar")
uploader_upload.assert_called_once_with(
    "https://test.pypi.org/legacy/",
    cert=True,
    client_cert=None,
    dry_run=False,
    skip_existing=False,
)
```

This makes the expectations explicit and avoids depending on the internal structure of `call_args`.
</issue_to_address>

### Comment 2
<location> `tests/publishing/test_publisher.py:85-94` </location>
<code_context>
+def test_publish_normalizes_legacy_repository_url_without_trailing_slash(
</code_context>

<issue_to_address>
**suggestion (testing):** Add complementary tests to cover non-legacy and already-normalized URLs

This test captures the `/legacy` regression, but it’s worth also checking that:

1. URLs already ending in `/legacy/` are left unchanged (no double slash).
2. URLs not ending in `/legacy` or `/legacy/` are not modified.

You can either add two focused tests or parametrize this one over input/expected URLs to more fully exercise the normalization helper and guard against similar regressions.

Suggested implementation:

```python
@pytest.mark.parametrize(
    ("configured_url", "expected_url"),
    [
        ("https://test.pypi.org/legacy", "https://test.pypi.org/legacy/"),
        ("https://test.pypi.org/legacy/", "https://test.pypi.org/legacy/"),
        ("https://test.pypi.org/simple", "https://test.pypi.org/simple"),
    ],
)
def test_publish_normalizes_legacy_repository_url_without_trailing_slash(
    fixture_dir: FixtureDirGetter,
    mocker: MockerFixture,
    config: Config,
    configured_url: str,
    expected_url: str,
) -> None:

```

```python
    poetry.config.merge(
        {
            "repositories": {"testpypi": {"url": configured_url}},

```

1. Ensure `pytest` is imported at the top of `tests/publishing/test_publisher.py` if it is not already:
   - `import pytest`
2. In the remainder of this test (not visible in the snippet), wherever the test currently asserts against a hard-coded normalized URL like `"https://test.pypi.org/legacy/"`, update those assertions to use `expected_url` instead. For example, if you have:
   - `uploader_auth.assert_called_with("testpypi", "https://test.pypi.org/legacy/")`
   - change it to:
     - `uploader_auth.assert_called_with("testpypi", expected_url)`
3. Similarly, if the URL is passed to any other collaborator (e.g. `Uploader.upload` or a `Publisher` instance) and is asserted on, replace the hard-coded string with `expected_url` to fully exercise the normalization behavior across all three parametrized cases.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +103 to +104
assert uploader_auth.call_args == [("foo", "bar")]
assert uploader_upload.call_args == [
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (testing): Use mock's assertion helpers instead of comparing call_args to raw lists

call_args is a (args, kwargs) tuple, so comparing it to a list will not work as intended and may cause this test to always fail. Instead, assert on the expected calls via the mock helpers:

uploader_auth.assert_called_once_with("foo", "bar")
uploader_upload.assert_called_once_with(
    "https://test.pypi.org/legacy/",
    cert=True,
    client_cert=None,
    dry_run=False,
    skip_existing=False,
)

This makes the expectations explicit and avoids depending on the internal structure of call_args.

Comment on lines +85 to +94
def test_publish_normalizes_legacy_repository_url_without_trailing_slash(
fixture_dir: FixtureDirGetter, mocker: MockerFixture, config: Config
) -> None:
uploader_auth = mocker.patch("poetry.publishing.uploader.Uploader.auth")
uploader_upload = mocker.patch("poetry.publishing.uploader.Uploader.upload")

poetry = Factory().create_poetry(fixture_dir("sample_project"))
poetry._config = config
poetry.config.merge(
{
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add complementary tests to cover non-legacy and already-normalized URLs

This test captures the /legacy regression, but it’s worth also checking that:

  1. URLs already ending in /legacy/ are left unchanged (no double slash).
  2. URLs not ending in /legacy or /legacy/ are not modified.

You can either add two focused tests or parametrize this one over input/expected URLs to more fully exercise the normalization helper and guard against similar regressions.

Suggested implementation:

@pytest.mark.parametrize(
    ("configured_url", "expected_url"),
    [
        ("https://test.pypi.org/legacy", "https://test.pypi.org/legacy/"),
        ("https://test.pypi.org/legacy/", "https://test.pypi.org/legacy/"),
        ("https://test.pypi.org/simple", "https://test.pypi.org/simple"),
    ],
)
def test_publish_normalizes_legacy_repository_url_without_trailing_slash(
    fixture_dir: FixtureDirGetter,
    mocker: MockerFixture,
    config: Config,
    configured_url: str,
    expected_url: str,
) -> None:
    poetry.config.merge(
        {
            "repositories": {"testpypi": {"url": configured_url}},
  1. Ensure pytest is imported at the top of tests/publishing/test_publisher.py if it is not already:
    • import pytest
  2. In the remainder of this test (not visible in the snippet), wherever the test currently asserts against a hard-coded normalized URL like "https://test.pypi.org/legacy/", update those assertions to use expected_url instead. For example, if you have:
    • uploader_auth.assert_called_with("testpypi", "https://test.pypi.org/legacy/")
    • change it to:
      • uploader_auth.assert_called_with("testpypi", expected_url)
  3. Similarly, if the URL is passed to any other collaborator (e.g. Uploader.upload or a Publisher instance) and is asserted on, replace the hard-coded string with expected_url to fully exercise the normalization behavior across all three parametrized cases.

url = self._poetry.config.get(f"repositories.{repository_name}.url")
if url is None:
raise RuntimeError(f"Repository {repository_name} is not defined")
url = _normalize_legacy_repository_url(url)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this be good enough?

Suggested change
url = _normalize_legacy_repository_url(url)
if url.endswith("/legacy"):
url += "/"

@dimbleby
Copy link
Contributor

Is there definitely a problem that needs solving here? Do we have a reproducer?

I see that the issue report was made in 2022. If there is a real problem here, with a trivial fix available, it is surprising that it should have survived all this time.

@LouisLau-art
Copy link
Contributor Author

LouisLau-art commented Mar 3, 2026

@dimbleby Yes, I believe there is a real reproducer here, and it is the first half of #6687 (the trailing-slash part, not the auth confusion that came later in the same report).

The minimal case is:

poetry config repositories.testpypi https://test.pypi.org/legacy
poetry publish -r testpypi

The original reporter notes that this silently fails, while the same flow works once the URL is changed to https://test.pypi.org/legacy/. They also mention that twine succeeds against the same endpoint, which matches the idea that normalizing the configured legacy upload URL is sufficient here.

So this PR is intentionally very narrow: it only normalizes configured repository URLs that end in /legacy to /legacy/ before upload. It does not try to address the token/auth behavior discussed later in the issue.

My guess for why this survived for so long is that it only affects explicitly configured legacy endpoints without the trailing slash; users who rely on the default PyPI/TestPyPI setup, or who already configure /legacy/, would never hit it.

If you'd prefer, I can also add a slightly higher-level regression test, but I started with the smallest change at the point where Poetry consumes the configured repository URL.

@dimbleby
Copy link
Contributor

dimbleby commented Mar 3, 2026

I see what the original report says. Did you verify that it is true today?

@LouisLau-art
Copy link
Contributor Author

Yes, I verified it again on the current code.

I tested on origin/main at commit 029685cf (checked on 2026-03-03), with a minimal local repro that mocks Uploader.upload so no network is involved.

Result:

  • config URL https://test.pypi.org/legacy -> upload URL https://test.pypi.org/legacy
  • config URL https://test.pypi.org/legacy/ -> upload URL https://test.pypi.org/legacy/

So today it still passes the configured URL through verbatim, and the missing trailing slash case is still present.

@dimbleby
Copy link
Contributor

dimbleby commented Mar 3, 2026

Yes, but my question is whether this is actually an issue.

Does pypi - or testpypi - really reject uploads when the user does not provide a trailing slash? This should be answered by experiment.

@dimbleby
Copy link
Contributor

dimbleby commented Mar 4, 2026

Either way I don't know that it is up to poetry to guess that users meant something different than what they said.

The endpoint is documented as https://upload.pypi.org/legacy/. Probably it's normal that if you configure the wrong endpoint, it won't work.

@radoering
Copy link
Member

I just tried with test.pypi.org. If the trailing slash is missing, I get the following error message:

HTTP Error 400: Bad Request | b'<html>\n <head>\n  <title>400 Bad Request\n \n <body>\n  <h1>400 Bad Request\n  The server could not comply with the request since it is either malformed or otherwise incorrect.<br/><br/>\nPOST body may not contain duplicate keys (URL: &#x27;https://test.pypi.org/legacy&#x27;)\n\n\n \n'

From a user's point of view at least a better error message would be nice if you just forget a slash.

Either way I don't know that it is up to poetry to guess that users meant something different than what they said.

Valid point. I think if there always has to be a trailing slash we can as well add it if it is missing. However, I am not sure if this is specific to PyPI or also valid for other indices...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Omitting trailing slash on some PEP 503 repositories prevents inferring the correct upload URL

3 participants