Repository: python-poetry/poetry (python)

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

<!--
    Hi there! Thank you for submitting a bug report!

    Before you submit your issue, please review and follow the instructions at
    https://python-poetry.org/docs/contributing/#reporting-bugs. Fully fill out the template below; insufficient
    information or bad reproduction instructions will impair the ability of others to help you.
-->

<!-- All the below information must be provided for others to understand and help with your issue. -->
- **Poetry version**: 1.2.1
- **Python version**: 3.8.10
- **OS version and name**: Linux Mint 20.3 Una
- **pyproject.toml**: not applicable

<!-- All the below steps should be completed before submitting your issue. Checked checkbox should look like this: [x] -->
- [x] I am on the [latest](https://github.com/python-poetry/poetry/releases/latest) stable Poetry version, installed using a recommended method.
- [x] I have searched the [issues](https://github.com/python-poetry/poetry/issues) of this repo and believe that this is not a duplicate.
- [x] I have consulted the [FAQ](https://python-poetry.org/docs/faq/) and [blog](https://python-poetry.org/blog/) for any relevant entries or release notes.
- [ ] If an exception occurs when executing a command, I executed it again in debug mode (`-vvv` option) and have included the output below. (not applicable)

## Issue
Apologies for reporting essentially two issues at once, but trying to figure out the first led directly into the second for me.

The first is the same basic issue reported in #3600, now in 1.2.1 - configuring `poetry config repositories.testpypi https://test.pypi.org/legacy` without the trailing slash caused silent failures.

The thing is, I get silent failures this way **even when** using `twine` (or `poetry run twine`, as described in Issue #742) to upload instead would succeed. The only symptom is that the package does not appear on TestPyPI.

Second issue: after adding the trailing slash and trying again, I got noisy failures instead -
```
$ poetry config repositories.testpypi https://test.pypi.org/legacy/
$ poetry config pypi-token.testpypi <api token omitted>
$ poetry publish -r testpypi

Publishing <package name omitted> to testpypi
 - Uploading <file name omitted> FAILED

HTTP Error 403: Invalid or non-existent authentication information. See https://
test.pypi.org/help/#invalid-auth for more information. | b'<html>\n <head>\n  <t
itle>403 Invalid or non-existent authentication information. See https://test.py
pi.org/help/#invalid-auth for more information.\n \n <body>\n  <h1>403 Invalid o
r non-existent authentication information. See https://test.pypi.org/help/#inval
id-auth for more information.\n  Access was denied to this resource.<br/><br/>\n
Invalid or non-existent authentication information. See https://test.pypi.org/he
lp/#invalid-auth for more information.\n\n\n \n'
```
As far as I can tell, `poetry config pypi-token.testpypi <API token>` *doesn't actually do anything*, despite everything I can find in the documentation and third-party guides. There is no update to the content of `~/.config/pypoetry/config.toml`, and I get the error shown above when trying to use `poetry publish`. (If it is supposed to write information somewhere else, I have no idea how to verify that.) It seems I am forced to specify my actual username and password explicitly on the command line. Even setting the username to `__token__` and password to the API token value, as described [in PyPI's help](https://test.pypi.org/help/#apitoken), does not seem to work.


Patch:
diff --git a/docs/repositories.md b/docs/repositories.md
index 07bf2ef14d1..9c3b5c40054 100644
--- a/docs/repositories.md
+++ b/docs/repositories.md
@@ -447,6 +447,8 @@ typically different to the same one provided by the repository for the simple AP
 in the example of [Test PyPI](https://test.pypi.org/), both the host (`test.pypi.org`) as
 well as the path (`/legacy`) are different to its simple API (`https://test.pypi.org/simple`).
 
+While the examples show URLs with a trailing slash (e.g., `https://test.pypi.org/legacy/`), Poetry automatically normalizes legacy repository URLs by adding a trailing slash if one is missing. This means both `https://test.pypi.org/legacy` and `https://test.pypi.org/legacy/` will work correctly when publishing.
+
 {{% /note %}}
 
 ## Configuring Credentials
diff --git a/src/poetry/publishing/publisher.py b/src/poetry/publishing/publisher.py
index f752a0d5ee4..a31464355dc 100644
--- a/src/poetry/publishing/publisher.py
+++ b/src/poetry/publishing/publisher.py
@@ -18,6 +18,12 @@
 logger = logging.getLogger(__name__)
 
 
+def _normalize_legacy_repository_url(url: str) -> str:
+    if url.endswith("/legacy"):
+        url += "/"
+    return url
+
+
 class Publisher:
     """
     Registers and publishes packages to remote repositories.
@@ -52,6 +58,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)
 
         if not (username and password):
             # Check if we have a token first


Test patch:
diff --git a/tests/publishing/test_publisher.py b/tests/publishing/test_publisher.py
index 53f30e75f78..b40c9048d07 100644
--- a/tests/publishing/test_publisher.py
+++ b/tests/publishing/test_publisher.py
@@ -210,3 +210,46 @@ def test_publish_read_from_environment_variable(
         ("https://foo.bar",),
         {"cert": True, "client_cert": None, "dry_run": False, "skip_existing": False},
     ]
+
+
+@pytest.mark.parametrize(
+    ("configured_url", "expected_url"),
+    [
+        # Missing trailing slash — should be added
+        ("https://test.pypi.org/legacy", "https://test.pypi.org/legacy/"),
+        # Already has trailing slash — should not double up
+        ("https://test.pypi.org/legacy/", "https://test.pypi.org/legacy/"),
+        # Non-legacy URL — should be unchanged
+        ("https://test.pypi.org/simple", "https://test.pypi.org/simple"),
+    ],
+)
+def test_publish_normalizes_legacy_repository_url(
+    fixture_dir: FixtureDirGetter,
+    mocker: MockerFixture,
+    config: Config,
+    configured_url: str,
+    expected_url: str,
+) -> 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(
+        {
+            "repositories": {"testpypi": {"url": configured_url}},
+            "http-basic": {"testpypi": {"username": "foo", "password": "bar"}},
+        }
+    )
+
+    publisher = Publisher(poetry, NullIO())
+    publisher.publish("testpypi", None, None)
+
+    uploader_auth.assert_called_once_with("foo", "bar")
+    uploader_upload.assert_called_once_with(
+        expected_url,
+        cert=True,
+        client_cert=None,
+        dry_run=False,
+        skip_existing=False,
+    )