Skip to content
Open
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: 12 additions & 0 deletions src/poetry/publishing/publisher.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import logging
import urllib.parse

from typing import TYPE_CHECKING

Expand All @@ -18,6 +19,16 @@
logger = logging.getLogger(__name__)


def _normalize_legacy_repository_url(url: str) -> str:
parsed = urllib.parse.urlsplit(url)

if parsed.path.endswith("/legacy") and not parsed.path.endswith("/legacy/"):
parsed = parsed._replace(path=f"{parsed.path}/")
return urllib.parse.urlunsplit(parsed)

return url


class Publisher:
"""
Registers and publishes packages to remote repositories.
Expand Down Expand Up @@ -52,6 +63,7 @@ def publish(
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 += "/"


if not (username and password):
# Check if we have a token first
Expand Down
25 changes: 25 additions & 0 deletions tests/publishing/test_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,31 @@ def test_publish_can_publish_to_given_repository(
assert f"Publishing {project_name} ({uploader_version}) to foo" in io.fetch_output()


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(
{
Comment on lines +85 to +94
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.

"repositories": {"testpypi": {"url": "https://test.pypi.org/legacy"}},
"http-basic": {"testpypi": {"username": "foo", "password": "bar"}},
}
)

publisher = Publisher(poetry, NullIO())
publisher.publish("testpypi", None, None)

assert uploader_auth.call_args == [("foo", "bar")]
assert uploader_upload.call_args == [
Comment on lines +103 to +104
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.

("https://test.pypi.org/legacy/",),
{"cert": True, "client_cert": None, "dry_run": False, "skip_existing": False},
]


def test_publish_raises_error_for_undefined_repository(
fixture_dir: FixtureDirGetter, config: Config
) -> None:
Expand Down