diff --git a/docs/cli.md b/docs/cli.md index f77d2f851e7..5ea0b16051f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -535,6 +535,7 @@ Note that, at the moment, only pure python wheels are supported. ### Options * `--format (-f)`: Limit the format to either `wheel` or `sdist`. +* `--output (-o)`: Set output directory for build artifacts. Default is `dist`. ## publish @@ -560,6 +561,7 @@ Should match a repository name set by the [`config`](#config) command. * `--password (-p)`: The password to access the repository. * `--cert`: Certificate authority to access the repository. * `--client-cert`: Client certificate to access the repository. +* `--dist-dir`: Dist directory where built artifact are stored. Default is `dist`. * `--build`: Build the package before publishing. * `--dry-run`: Perform all actions except upload the package. * `--skip-existing`: Ignore errors from files already existing in the repository. diff --git a/src/poetry/console/commands/build.py b/src/poetry/console/commands/build.py index 0d1bd15af45..86626fcf64a 100644 --- a/src/poetry/console/commands/build.py +++ b/src/poetry/console/commands/build.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from pathlib import Path from cleo.helpers import option @@ -8,16 +8,19 @@ from poetry.utils.env import build_environment -if TYPE_CHECKING: - from pathlib import Path - - class BuildCommand(EnvCommand): name = "build" description = "Builds a package, as a tarball and a wheel by default." options = [ - option("format", "f", "Limit the format to either sdist or wheel.", flag=False) + option("format", "f", "Limit the format to either sdist or wheel.", flag=False), + option( + "output", + "o", + "Set output directory for build artifacts. Default is `dist`.", + default="dist", + flag=False, + ), ] loggers = [ @@ -48,11 +51,14 @@ def _build( def handle(self) -> int: with build_environment(poetry=self.poetry, env=self.env, io=self.io) as env: fmt = self.option("format") or "all" + dist_dir = Path(self.option("output")) package = self.poetry.package self.line( f"Building {package.pretty_name} ({package.version})" ) - self._build(fmt, executable=env.python) + if not dist_dir.is_absolute(): + dist_dir = self.poetry.pyproject_path.parent / dist_dir + self._build(fmt, executable=env.python, target_dir=dist_dir) return 0 diff --git a/src/poetry/console/commands/publish.py b/src/poetry/console/commands/publish.py index a53df8208dd..18d4cb0f456 100644 --- a/src/poetry/console/commands/publish.py +++ b/src/poetry/console/commands/publish.py @@ -26,6 +26,13 @@ class PublishCommand(Command): "Client certificate to access the repository.", flag=False, ), + option( + "dist-dir", + None, + "Dist directory where built artifact are stored. Default is `dist`.", + default="dist", + flag=False, + ), option("build", None, "Build the package before publishing."), option("dry-run", None, "Perform all actions except upload the package."), option( @@ -49,7 +56,9 @@ class PublishCommand(Command): def handle(self) -> int: from poetry.publishing.publisher import Publisher - publisher = Publisher(self.poetry, self.io) + dist_dir = self.option("dist-dir") + + publisher = Publisher(self.poetry, self.io, Path(dist_dir)) # Building package first, if told if self.option("build"): @@ -61,7 +70,7 @@ def handle(self) -> int: return 1 - self.call("build") + self.call("build", args=f"--output {dist_dir}") files = publisher.files if not files: diff --git a/src/poetry/publishing/publisher.py b/src/poetry/publishing/publisher.py index a5335258041..a009e7ab9af 100644 --- a/src/poetry/publishing/publisher.py +++ b/src/poetry/publishing/publisher.py @@ -23,11 +23,11 @@ class Publisher: Registers and publishes packages to remote repositories. """ - def __init__(self, poetry: Poetry, io: IO) -> None: + def __init__(self, poetry: Poetry, io: IO, dist_dir: Path | None = None) -> None: self._poetry = poetry self._package = poetry.package self._io = io - self._uploader = Uploader(poetry, io) + self._uploader = Uploader(poetry, io, dist_dir) self._authenticator = Authenticator(poetry.config, self._io) @property diff --git a/src/poetry/publishing/uploader.py b/src/poetry/publishing/uploader.py index e9b22561b92..972e376e0bf 100644 --- a/src/poetry/publishing/uploader.py +++ b/src/poetry/publishing/uploader.py @@ -49,10 +49,11 @@ def __init__(self, error: ConnectionError | HTTPError | str) -> None: class Uploader: - def __init__(self, poetry: Poetry, io: IO) -> None: + def __init__(self, poetry: Poetry, io: IO, dist_dir: Path | None = None) -> None: self._poetry = poetry self._package = poetry.package self._io = io + self._dist_dir = dist_dir or self.default_dist_dir self._username: str | None = None self._password: str | None = None @@ -61,9 +62,20 @@ def user_agent(self) -> str: agent: str = user_agent("poetry", __version__) return agent + @property + def default_dist_dir(self) -> Path: + return self._poetry.file.path.parent / "dist" + + @property + def dist_dir(self) -> Path: + if not self._dist_dir.is_absolute(): + return self._poetry.file.path.parent / self._dist_dir + + return self._dist_dir + @property def files(self) -> list[Path]: - dist = self._poetry.file.path.parent / "dist" + dist = self.dist_dir version = self._package.version.to_string() escaped_name = distribution_name(self._package.name) @@ -275,7 +287,7 @@ def _register(self, session: requests.Session, url: str) -> requests.Response: """ Register a package to a repository. """ - dist = self._poetry.file.path.parent / "dist" + dist = self.dist_dir escaped_name = distribution_name(self._package.name) file = dist / f"{escaped_name}-{self._package.version.to_string()}.tar.gz" diff --git a/tests/console/commands/test_build.py b/tests/console/commands/test_build.py index 3b378d1e4e5..023e855ee59 100644 --- a/tests/console/commands/test_build.py +++ b/tests/console/commands/test_build.py @@ -74,7 +74,6 @@ def test_build_with_multiple_readme_files( poetry = Factory().create_poetry(target_dir) tester = command_tester_factory("build", poetry, environment=tmp_venv) - tester.execute() build_dir = target_dir / "dist" @@ -93,3 +92,27 @@ def test_build_with_multiple_readme_files( assert "my_package-0.1/README-1.rst" in sdist_content assert "my_package-0.1/README-2.rst" in sdist_content + + +@pytest.mark.parametrize( + "output_dir", [None, "dist", "test/dir", "../dist", "absolute"] +) +def test_build_output_option( + tmp_tester: CommandTester, + tmp_project_path: Path, + tmp_poetry: Poetry, + output_dir: str, +) -> None: + if output_dir is None: + tmp_tester.execute() + build_dir = tmp_project_path / "dist" + elif output_dir == "absolute": + tmp_tester.execute(f"--output {tmp_project_path / 'tmp/dist'}") + build_dir = tmp_project_path / "tmp/dist" + else: + tmp_tester.execute(f"--output {output_dir}") + build_dir = tmp_project_path / output_dir + + build_artifacts = tuple(build_dir.glob(get_package_glob(tmp_poetry))) + assert len(build_artifacts) > 0 + assert all(archive.exists() for archive in build_artifacts) diff --git a/tests/console/commands/test_publish.py b/tests/console/commands/test_publish.py index 5f230c01ff3..e123a082c9a 100644 --- a/tests/console/commands/test_publish.py +++ b/tests/console/commands/test_publish.py @@ -1,5 +1,7 @@ from __future__ import annotations +import shutil + from pathlib import Path from typing import TYPE_CHECKING from typing import Any @@ -7,6 +9,7 @@ import pytest import requests +from poetry.factory import Factory from poetry.publishing.uploader import UploadError @@ -16,7 +19,10 @@ from cleo.testers.application_tester import ApplicationTester from pytest_mock import MockerFixture + from poetry.utils.env import VirtualEnv from tests.helpers import PoetryTestApplication + from tests.types import CommandTesterFactory + from tests.types import FixtureDirGetter def test_publish_returns_non_zero_code_for_upload_errors( @@ -130,3 +136,80 @@ def test_skip_existing_output( error = app_tester.io.fetch_error() assert "- Uploading simple_project-1.2.3.tar.gz File exists. Skipping" in error + + +@pytest.mark.parametrize("dist_dir", [None, "dist", "other_dist/dist", "absolute"]) +def test_publish_dist_dir_option( + http: type[httpretty.httpretty], + fixture_dir: FixtureDirGetter, + tmp_path: Path, + tmp_venv: VirtualEnv, + command_tester_factory: CommandTesterFactory, + dist_dir: str | None, +) -> None: + source_dir = fixture_dir("with_multiple_dist_dir") + target_dir = tmp_path / "project" + shutil.copytree(str(source_dir), str(target_dir)) + + http.register_uri( + http.POST, "https://upload.pypi.org/legacy/", status=409, body="Conflict" + ) + + poetry = Factory().create_poetry(target_dir) + tester = command_tester_factory("publish", poetry, environment=tmp_venv) + + if dist_dir is None: + exit_code = tester.execute("--dry-run") + elif dist_dir == "absolute": + exit_code = tester.execute(f"--dist-dir {target_dir / 'dist'} --dry-run") + else: + exit_code = tester.execute(f"--dist-dir {dist_dir} --dry-run") + + assert exit_code == 0 + + output = tester.io.fetch_output() + error = tester.io.fetch_error() + + assert "Publishing simple-project (1.2.3) to PyPI" in output + assert "- Uploading simple_project-1.2.3.tar.gz" in error + assert "- Uploading simple_project-1.2.3-py2.py3-none-any.whl" in error + + +@pytest.mark.parametrize("dist_dir", ["../dist", "tmp/dist", "absolute"]) +def test_publish_dist_dir_and_build_options( + http: type[httpretty.httpretty], + fixture_dir: FixtureDirGetter, + tmp_path: Path, + tmp_venv: VirtualEnv, + command_tester_factory: CommandTesterFactory, + dist_dir: str | None, +) -> None: + source_dir = fixture_dir("simple_project") + target_dir = tmp_path / "project" + shutil.copytree(str(source_dir), str(target_dir)) + + # Remove dist dir because as it will be built again + shutil.rmtree(target_dir / "dist") + + http.register_uri( + http.POST, "https://upload.pypi.org/legacy/", status=409, body="Conflict" + ) + + poetry = Factory().create_poetry(target_dir) + tester = command_tester_factory("publish", poetry, environment=tmp_venv) + + if dist_dir == "absolute": + exit_code = tester.execute( + f"--dist-dir {target_dir / 'test/dist'} --dry-run --build" + ) + else: + exit_code = tester.execute(f"--dist-dir {dist_dir} --dry-run --build") + + assert exit_code == 0 + + output = tester.io.fetch_output() + error = tester.io.fetch_error() + + assert "Publishing simple-project (1.2.3) to PyPI" in output + assert "- Uploading simple_project-1.2.3.tar.gz" in error + assert "- Uploading simple_project-1.2.3-py2.py3-none-any.whl" in error diff --git a/tests/fixtures/with_multiple_dist_dir/README.rst b/tests/fixtures/with_multiple_dist_dir/README.rst new file mode 100644 index 00000000000..f7fe15470f9 --- /dev/null +++ b/tests/fixtures/with_multiple_dist_dir/README.rst @@ -0,0 +1,2 @@ +My Package +========== diff --git a/tests/fixtures/with_multiple_dist_dir/dist/simple_project-1.2.3-py2.py3-none-any.whl b/tests/fixtures/with_multiple_dist_dir/dist/simple_project-1.2.3-py2.py3-none-any.whl new file mode 100644 index 00000000000..fcdeda31338 Binary files /dev/null and b/tests/fixtures/with_multiple_dist_dir/dist/simple_project-1.2.3-py2.py3-none-any.whl differ diff --git a/tests/fixtures/with_multiple_dist_dir/dist/simple_project-1.2.3.tar.gz b/tests/fixtures/with_multiple_dist_dir/dist/simple_project-1.2.3.tar.gz new file mode 100644 index 00000000000..149aa9527c5 Binary files /dev/null and b/tests/fixtures/with_multiple_dist_dir/dist/simple_project-1.2.3.tar.gz differ diff --git a/tests/fixtures/with_multiple_dist_dir/other_dist/dist/simple_project-1.2.3-py2.py3-none-any.whl b/tests/fixtures/with_multiple_dist_dir/other_dist/dist/simple_project-1.2.3-py2.py3-none-any.whl new file mode 100644 index 00000000000..fcdeda31338 Binary files /dev/null and b/tests/fixtures/with_multiple_dist_dir/other_dist/dist/simple_project-1.2.3-py2.py3-none-any.whl differ diff --git a/tests/fixtures/with_multiple_dist_dir/other_dist/dist/simple_project-1.2.3.tar.gz b/tests/fixtures/with_multiple_dist_dir/other_dist/dist/simple_project-1.2.3.tar.gz new file mode 100644 index 00000000000..149aa9527c5 Binary files /dev/null and b/tests/fixtures/with_multiple_dist_dir/other_dist/dist/simple_project-1.2.3.tar.gz differ diff --git a/tests/fixtures/with_multiple_dist_dir/pyproject.toml b/tests/fixtures/with_multiple_dist_dir/pyproject.toml new file mode 100644 index 00000000000..45a61d43cad --- /dev/null +++ b/tests/fixtures/with_multiple_dist_dir/pyproject.toml @@ -0,0 +1,35 @@ +[tool.poetry] +name = "simple-project" +version = "1.2.3" +description = "Some description." +authors = [ + "Sébastien Eustace " +] +license = "MIT" + +readme = ["README.rst"] + +homepage = "https://python-poetry.org" +repository = "https://github.com/python-poetry/poetry" +documentation = "https://python-poetry.org/docs" + +keywords = ["packaging", "dependency", "poetry"] + +classifiers = [ + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries :: Python Modules" +] + +# Requirements +[tool.poetry.dependencies] +python = "~2.7 || ^3.4" + +[tool.poetry.scripts] +foo = "foo:bar" +baz = "bar:baz.boom.bim" +fox = "fuz.foo:bar.baz" + + +[build-system] +requires = ["poetry-core>=1.1.0a7"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/fixtures/with_multiple_dist_dir/simple_project/__init__.py b/tests/fixtures/with_multiple_dist_dir/simple_project/__init__.py new file mode 100644 index 00000000000..e69de29bb2d