Skip to content

Commit 364c2c5

Browse files
authored
feat: allow publishing artifacts if version is determined dynamically (#10644)
1 parent 073ac98 commit 364c2c5

File tree

6 files changed

+195
-26
lines changed

6 files changed

+195
-26
lines changed

docs/cli.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,9 +711,15 @@ poetry publish
711711
It can also build the package if you pass it the `--build` option.
712712

713713
{{% note %}}
714-
See [Publishable Repositories]({{< relref "repositories/#publishable-repositories" >}}) for more information on how to configure and use publishable repositories.
714+
See [Publishable Repositories]({{< relref "repositories/#publishable-repositories" >}}) for more information
715+
on how to configure and use publishable repositories.
715716
{{% /note %}}
716717

718+
{{% warning %}}
719+
Only artifacts of the latest version of your package in the dist directory will be uploaded.
720+
Older versions from previous builds as well as artifacts of other packages are ignored.
721+
{{% /warning %}}
722+
717723
#### Options
718724

719725
* `--repository (-r)`: The repository to register the package to (default: `pypi`).

src/poetry/console/commands/publish.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,9 @@ def handle(self) -> int:
8282

8383
self.call("build", args=f"--output {dist_dir}")
8484

85-
files = publisher.files
86-
if not files:
85+
publisher = Publisher(self.poetry, self.io, Path(dist_dir))
86+
87+
if not publisher.files:
8788
self.line_error(
8889
"<error>No files to publish. "
8990
"Run poetry build first or use the --build option.</error>"

src/poetry/publishing/publisher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def publish(
7979
repository_name = "PyPI"
8080
self._io.write_line(
8181
f"Publishing <c1>{self._package.pretty_name}</c1>"
82-
f" (<c2>{self._package.pretty_version}</c2>) to"
82+
f" (<c2>{self._uploader.version}</c2>) to"
8383
f" <info>{repository_name}</info>"
8484
)
8585

src/poetry/publishing/uploader.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from __future__ import annotations
22

3+
import itertools
34
import tarfile
45
import zipfile
56

7+
from collections import defaultdict
8+
from functools import cached_property
69
from pathlib import Path
710
from typing import TYPE_CHECKING
811
from typing import Any
@@ -12,6 +15,7 @@
1215

1316
from packaging.metadata import RawMetadata
1417
from packaging.metadata import parse_email
18+
from poetry.core.constraints.version import Version
1519
from poetry.core.masonry.utils.helpers import distribution_name
1620
from requests_toolbelt import user_agent
1721
from requests_toolbelt.multipart import MultipartEncoder
@@ -36,7 +40,7 @@ class UploadError(Exception):
3640
class Uploader:
3741
def __init__(self, poetry: Poetry, io: IO, dist_dir: Path | None = None) -> None:
3842
self._poetry = poetry
39-
self._package = poetry.package
43+
self._dist_name = distribution_name(poetry.package.name)
4044
self._io = io
4145
self._dist_dir = dist_dir or self.default_dist_dir
4246
self._username: str | None = None
@@ -60,14 +64,39 @@ def dist_dir(self) -> Path:
6064

6165
@property
6266
def files(self) -> list[Path]:
63-
dist = self.dist_dir
64-
version = self._package.version.to_string()
65-
escaped_name = distribution_name(self._package.name)
67+
return self._files_and_version[0]
6668

67-
wheels = list(dist.glob(f"{escaped_name}-{version}-*.whl"))
68-
tars = list(dist.glob(f"{escaped_name}-{version}.tar.gz"))
69+
@property
70+
def version(self) -> str:
71+
return self._files_and_version[1]
6972

70-
return sorted(wheels + tars)
73+
@cached_property
74+
def _files_and_version(self) -> tuple[list[Path], str]:
75+
dist = self.dist_dir
76+
77+
wheels = dist.glob(f"{self._dist_name}-*-*.whl")
78+
tars = dist.glob(f"{self._dist_name}-*.tar.gz")
79+
artifacts_by_version = defaultdict(list)
80+
for artifact in itertools.chain(wheels, tars):
81+
version = (
82+
artifact.stem.removesuffix(".tar")
83+
.removeprefix(f"{self._dist_name}-")
84+
.split("-", maxsplit=1)[0]
85+
)
86+
artifacts_by_version[version].append(artifact)
87+
match len(artifacts_by_version):
88+
case 0:
89+
return [], ""
90+
case 1:
91+
latest_version = next(iter(artifacts_by_version))
92+
artifacts = artifacts_by_version[latest_version]
93+
case _:
94+
latest_version = max(
95+
artifacts_by_version, key=lambda v: Version.parse(v)
96+
)
97+
artifacts = artifacts_by_version[latest_version]
98+
99+
return sorted(artifacts, key=lambda a: (a.suffix == ".whl", a)), latest_version
71100

72101
def auth(self, username: str | None, password: str | None) -> None:
73102
self._username = username
@@ -258,14 +287,7 @@ def _register(self, session: requests.Session, url: str) -> requests.Response:
258287
"""
259288
Register a package to a repository.
260289
"""
261-
dist = self.dist_dir
262-
escaped_name = distribution_name(self._package.name)
263-
file = dist / f"{escaped_name}-{self._package.version.to_string()}.tar.gz"
264-
265-
if not file.exists():
266-
raise RuntimeError(f'"{file.name}" does not exist.')
267-
268-
data = self.post_data(file)
290+
data = self.post_data(self.files[0])
269291
data.update({":action": "submit", "protocol_version": "1"})
270292

271293
data_to_send = self._prepare_data(data)

tests/publishing/test_publisher.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ def test_publish_can_publish_to_given_repository(
5050
config: Config,
5151
fixture_name: str,
5252
) -> None:
53+
uploader_version = "1.2.3+test"
54+
mocker.patch("poetry.publishing.uploader.Uploader.version", uploader_version)
5355
uploader_auth = mocker.patch("poetry.publishing.uploader.Uploader.auth")
5456
uploader_upload = mocker.patch("poetry.publishing.uploader.Uploader.upload")
5557

@@ -62,6 +64,9 @@ def test_publish_can_publish_to_given_repository(
6264

6365
mocker.patch("poetry.config.config.Config.create", return_value=config)
6466
poetry = Factory().create_poetry(fixture_dir(fixture_name))
67+
# Normally both versions are equal, but we want to check that the correct version
68+
# is displayed in the output if they are different.
69+
assert poetry.package.version != uploader_version
6570

6671
io = BufferedIO()
6772
publisher = Publisher(poetry, io)
@@ -74,7 +79,7 @@ def test_publish_can_publish_to_given_repository(
7479
{"cert": True, "client_cert": None, "dry_run": False, "skip_existing": False},
7580
]
7681
project_name = canonicalize_name(fixture_name)
77-
assert f"Publishing {project_name} (1.2.3) to foo" in io.fetch_output()
82+
assert f"Publishing {project_name} ({uploader_version}) to foo" in io.fetch_output()
7883

7984

8085
def test_publish_raises_error_for_undefined_repository(

tests/publishing/test_uploader.py

Lines changed: 141 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import shutil
4+
35
from typing import TYPE_CHECKING
46

57
import pytest
@@ -12,16 +14,116 @@
1214

1315

1416
if TYPE_CHECKING:
17+
from pathlib import Path
18+
1519
import responses
1620

1721
from pytest_mock import MockerFixture
1822

23+
from poetry.poetry import Poetry
1924
from tests.types import FixtureDirGetter
2025

2126

2227
@pytest.fixture
23-
def uploader(fixture_dir: FixtureDirGetter) -> Uploader:
24-
return Uploader(Factory().create_poetry(fixture_dir("simple_project")), NullIO())
28+
def poetry(fixture_dir: FixtureDirGetter) -> Poetry:
29+
return Factory().create_poetry(fixture_dir("simple_project"))
30+
31+
32+
@pytest.fixture
33+
def uploader(poetry: Poetry) -> Uploader:
34+
return Uploader(poetry, NullIO())
35+
36+
37+
@pytest.mark.parametrize(
38+
("files", "expected_files", "expected_version"),
39+
[
40+
([], [], ""),
41+
(
42+
["simple_project-1.2.3.tar.gz", "simple_project-1.2.3-py3-none-any.whl"],
43+
["simple_project-1.2.3.tar.gz", "simple_project-1.2.3-py3-none-any.whl"],
44+
"1.2.3",
45+
),
46+
( # other names are ignored
47+
[
48+
"simple_project-1.2.3.tar.gz",
49+
"other_project-1.2.3.tar.gz",
50+
"simple_project-1.2.3-py3-none-any.whl",
51+
"other_project-1.2.3-py3-none-any.whl",
52+
],
53+
["simple_project-1.2.3.tar.gz", "simple_project-1.2.3-py3-none-any.whl"],
54+
"1.2.3",
55+
),
56+
( # older versions are ignored
57+
[
58+
"simple_project-1.2.3.tar.gz",
59+
"simple_project-1.2.4.tar.gz",
60+
"simple_project-1.2.3-py3-none-any.whl",
61+
"simple_project-1.2.4-py3-none-any.whl",
62+
],
63+
["simple_project-1.2.4.tar.gz", "simple_project-1.2.4-py3-none-any.whl"],
64+
"1.2.4",
65+
),
66+
( # older versions are ignored - only new sdist
67+
[
68+
"simple_project-1.2.3.tar.gz",
69+
"simple_project-1.2.4.tar.gz",
70+
"simple_project-1.2.3-py3-none-any.whl",
71+
],
72+
["simple_project-1.2.4.tar.gz"],
73+
"1.2.4",
74+
),
75+
( # older versions are ignored - only new wheel
76+
[
77+
"simple_project-1.2.3.tar.gz",
78+
"simple_project-1.2.3-py3-none-any.whl",
79+
"simple_project-1.2.4-py3-none-any.whl",
80+
],
81+
["simple_project-1.2.4-py3-none-any.whl"],
82+
"1.2.4",
83+
),
84+
( # older versions are ignored - local version
85+
[
86+
"simple_project-1.2.3.tar.gz",
87+
"simple_project-1.2.3+hash1.tar.gz",
88+
"simple_project-1.2.3+hash2.tar.gz",
89+
"simple_project-1.2.3-py3-none-any.whl",
90+
"simple_project-1.2.3+hash1-py3-none-any.whl",
91+
"simple_project-1.2.3+hash2-py3-none-any.whl",
92+
],
93+
[
94+
"simple_project-1.2.3+hash2.tar.gz",
95+
"simple_project-1.2.3+hash2-py3-none-any.whl",
96+
],
97+
"1.2.3+hash2",
98+
),
99+
( # older versions are ignore - pre-release
100+
[
101+
"simple_project-1.2.3rc1.tar.gz",
102+
"simple_project-1.2.3rc1-py3-none-any.whl",
103+
"simple_project-1.2.3.tar.gz",
104+
"simple_project-1.2.3-py3-none-any.whl",
105+
],
106+
[
107+
"simple_project-1.2.3.tar.gz",
108+
"simple_project-1.2.3-py3-none-any.whl",
109+
],
110+
"1.2.3",
111+
),
112+
],
113+
)
114+
def test_uploader_files_only_latest(
115+
poetry: Poetry,
116+
tmp_path: Path,
117+
files: list[str],
118+
expected_files: list[str],
119+
expected_version: str,
120+
) -> None:
121+
for file in files:
122+
(tmp_path / file).touch()
123+
uploader = Uploader(poetry, NullIO(), dist_dir=tmp_path)
124+
125+
assert uploader.files == [tmp_path / f for f in expected_files]
126+
assert uploader.version == expected_version
25127

26128

27129
def test_uploader_properly_handles_400_errors(
@@ -105,16 +207,49 @@ def test_uploader_properly_handles_301_redirects(
105207
)
106208

107209

108-
def test_uploader_registers_for_appropriate_400_errors(
109-
mocker: MockerFixture, http: responses.RequestsMock, uploader: Uploader
210+
def test_uploader_registers_with_sdist_for_appropriate_400_errors(
211+
http: responses.RequestsMock, uploader: Uploader
110212
) -> None:
111-
register = mocker.patch("poetry.publishing.uploader.Uploader._register")
112213
http.post("https://foo.com", status=400, body="No package was ever registered")
113214

114215
with pytest.raises(UploadError):
115216
uploader.upload("https://foo.com")
116217

117-
assert register.call_count == 1
218+
assert len(http.calls) == 2
219+
bodies = [c.request.body or b"" for c in http.calls]
220+
assert b'name=":action"\r\n\r\nfile_upload\r\n' in bodies[0]
221+
assert b'name=":action"\r\n\r\nsubmit\r\n' in bodies[1]
222+
assert b"sdist" in bodies[0]
223+
assert b"sdist" in bodies[1]
224+
assert b"bdist_wheel" not in bodies[0]
225+
assert b"bdist_wheel" not in bodies[1]
226+
227+
228+
def test_uploader_register_uses_wheel_if_no_sdist(
229+
http: responses.RequestsMock, poetry: Poetry, tmp_path: Path
230+
) -> None:
231+
dist_dir = tmp_path / "dist"
232+
dist_dir.mkdir()
233+
shutil.copy(
234+
poetry.file.path.parent / "dist" / "simple_project-1.2.3-py2.py3-none-any.whl",
235+
dist_dir,
236+
)
237+
238+
uploader = Uploader(poetry, NullIO(), dist_dir=dist_dir)
239+
240+
http.post("https://foo.com", status=400, body="No package was ever registered")
241+
242+
with pytest.raises(UploadError):
243+
uploader.upload("https://foo.com")
244+
245+
assert len(http.calls) == 2
246+
bodies = [c.request.body or b"" for c in http.calls]
247+
assert b'name=":action"\r\n\r\nfile_upload\r\n' in bodies[0]
248+
assert b'name=":action"\r\n\r\nsubmit\r\n' in bodies[1]
249+
assert b"sdist" not in bodies[0]
250+
assert b"sdist" not in bodies[1]
251+
assert b"bdist_wheel" in bodies[0]
252+
assert b"bdist_wheel" in bodies[1]
118253

119254

120255
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)