Skip to content

Commit 15d8c54

Browse files
committed
refactor(cli): split out business logic into separate class
Allows easier testing.
1 parent 534bd3b commit 15d8c54

File tree

20 files changed

+326
-83
lines changed

20 files changed

+326
-83
lines changed

src/poetry/console/commands/build.py

Lines changed: 131 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from __future__ import annotations
22

3+
import dataclasses
4+
35
from pathlib import Path
46
from typing import TYPE_CHECKING
57
from typing import Any
68
from typing import ClassVar
9+
from typing import Literal
710

811
from cleo.helpers import option
912
from poetry.core.constraints.version import Version
@@ -18,83 +21,33 @@
1821
if TYPE_CHECKING:
1922
from collections.abc import Callable
2023

21-
from build import DistributionType
2224
from cleo.io.inputs.option import Option
25+
from cleo.io.io import IO
2326

27+
from poetry.poetry import Poetry
28+
from poetry.utils.env import Env
2429

25-
class BuildCommand(EnvCommand):
26-
name = "build"
27-
description = "Builds a package, as a tarball and a wheel by default."
30+
DistributionType = Literal["sdist", "wheel"]
2831

29-
options: ClassVar[list[Option]] = [
30-
option("format", "f", "Limit the format to either sdist or wheel.", flag=False),
31-
option(
32-
"clean",
33-
description="Clean output directory before building.",
34-
flag=True,
35-
),
36-
option(
37-
"local-version",
38-
"l",
39-
"Add or replace a local version label to the build. (<warning>Deprecated</warning>)",
40-
flag=False,
41-
),
42-
option(
43-
"output",
44-
"o",
45-
"Set output directory for build artifacts. Default is `dist`.",
46-
default="dist",
47-
flag=False,
48-
),
49-
option(
50-
"config-settings",
51-
description="Provide config settings that should be passed to backend in <key>=<value> format.",
52-
flag=False,
53-
multiple=True,
54-
),
55-
]
5632

57-
loggers: ClassVar[list[str]] = [
58-
"poetry.core.masonry.builders.builder",
59-
"poetry.core.masonry.builders.sdist",
60-
"poetry.core.masonry.builders.wheel",
61-
]
33+
@dataclasses.dataclass(frozen=True)
34+
class BuildOptions:
35+
clean: bool
36+
formats: list[DistributionType]
37+
output: str
38+
config_settings: dict[str, Any] = dataclasses.field(default_factory=dict)
6239

63-
def _requested_formats(self) -> list[DistributionType]:
64-
fmt = self.option("format") or "all"
65-
formats: list[DistributionType]
40+
def __post_init__(self) -> None:
41+
for fmt in self.formats:
42+
if fmt not in BUILD_FORMATS:
43+
raise ValueError(f"Invalid format: {fmt}")
6644

67-
if fmt in BUILD_FORMATS:
68-
formats = [fmt] # type: ignore[list-item]
69-
elif fmt == "all":
70-
formats = list(BUILD_FORMATS.keys()) # type: ignore[arg-type]
71-
else:
72-
raise ValueError(f"Invalid format: {fmt}")
7345

74-
return formats
75-
76-
def _config_settings(self) -> dict[str, str]:
77-
config_settings = {}
78-
79-
if local_version_label := self.option("local-version"):
80-
self.line_error(
81-
f"<warning>`<fg=yellow;options=bold>--local-version</>` is deprecated."
82-
f" Use `<fg=yellow;options=bold>--config-settings local-version={local_version_label}</>`"
83-
f" instead.</warning>"
84-
)
85-
config_settings["local-version"] = local_version_label
86-
87-
for config_setting in self.option("config-settings"):
88-
if "=" not in config_setting:
89-
raise ValueError(
90-
f"Invalid config setting format: {config_setting}. "
91-
"Config settings must be in the format 'key=value'"
92-
)
93-
94-
key, _, value = config_setting.partition("=")
95-
config_settings[key] = value
96-
97-
return config_settings
46+
class BuildHandler:
47+
def __init__(self, poetry: Poetry, env: Env, io: IO) -> None:
48+
self.poetry = poetry
49+
self.env = env
50+
self.io = io
9851

9952
def _build(
10053
self,
@@ -103,9 +56,6 @@ def _build(
10356
target_dir: Path,
10457
config_settings: dict[str, Any],
10558
) -> None:
106-
if fmt not in BUILD_FORMATS:
107-
raise ValueError(f"Invalid format: {fmt}")
108-
10959
builder = BUILD_FORMATS[fmt]
11060

11161
builder(
@@ -121,9 +71,6 @@ def _isolated_build(
12171
target_dir: Path,
12272
config_settings: dict[str, Any],
12373
) -> None:
124-
if fmt not in BUILD_FORMATS:
125-
raise ValueError(f"Invalid format: {fmt}")
126-
12774
with isolated_builder(
12875
source=self.poetry.file.path.parent,
12976
distribution=fmt,
@@ -167,32 +114,133 @@ def _get_builder(self) -> Callable[..., None]:
167114

168115
return self._build
169116

170-
def handle(self) -> int:
117+
def build(self, options: BuildOptions) -> int:
171118
if not self.poetry.is_package_mode:
172-
self.line_error("Building a package is not possible in non-package mode.")
119+
self.io.write_error_line(
120+
"Building a package is not possible in non-package mode."
121+
)
173122
return 1
174123

175-
dist_dir = Path(self.option("output"))
124+
dist_dir = Path(options.output)
176125
package = self.poetry.package
177-
self.line(
126+
self.io.write_line(
178127
f"Building <c1>{package.pretty_name}</c1> (<c2>{package.version}</c2>)"
179128
)
180129

181130
if not dist_dir.is_absolute():
182131
dist_dir = self.poetry.pyproject_path.parent / dist_dir
183132

184-
if self.option("clean"):
133+
if options.clean:
185134
remove_directory(path=dist_dir, force=True)
186135

187136
build = self._get_builder()
188137

189-
for fmt in self._requested_formats():
190-
self.line(f"Building <info>{fmt}</info>")
138+
for fmt in options.formats:
139+
self.io.write_line(f"Building <info>{fmt}</info>")
191140
build(
192141
fmt,
193142
executable=self.env.python,
194143
target_dir=dist_dir,
195-
config_settings=self._config_settings(),
144+
config_settings=options.config_settings,
196145
)
197146

198147
return 0
148+
149+
150+
class BuildCommand(EnvCommand):
151+
name = "build"
152+
description = "Builds a package, as a tarball and a wheel by default."
153+
154+
options: ClassVar[list[Option]] = [
155+
option("format", "f", "Limit the format to either sdist or wheel.", flag=False),
156+
option(
157+
"clean",
158+
description="Clean output directory before building.",
159+
flag=True,
160+
),
161+
option(
162+
"local-version",
163+
"l",
164+
"Add or replace a local version label to the build. (<warning>Deprecated</warning>)",
165+
flag=False,
166+
),
167+
option(
168+
"output",
169+
"o",
170+
"Set output directory for build artifacts. Default is `dist`.",
171+
default="dist",
172+
flag=False,
173+
),
174+
option(
175+
"config-settings",
176+
description="Provide config settings that should be passed to backend in <key>=<value> format.",
177+
flag=False,
178+
multiple=True,
179+
),
180+
]
181+
182+
loggers: ClassVar[list[str]] = [
183+
"poetry.core.masonry.builders.builder",
184+
"poetry.core.masonry.builders.sdist",
185+
"poetry.core.masonry.builders.wheel",
186+
]
187+
188+
@staticmethod
189+
def _prepare_config_settings(
190+
local_version: str | None, config_settings: list[str] | None, io: IO
191+
) -> dict[str, str]:
192+
config_settings = config_settings or []
193+
result = {}
194+
195+
if local_version:
196+
io.write_error_line(
197+
f"<warning>`<fg=yellow;options=bold>--local-version</>` is deprecated."
198+
f" Use `<fg=yellow;options=bold>--config-settings local-version={local_version}</>`"
199+
f" instead.</warning>"
200+
)
201+
result["local-version"] = local_version
202+
203+
for config_setting in config_settings:
204+
if "=" not in config_setting:
205+
raise ValueError(
206+
f"Invalid config setting format: {config_setting}. "
207+
"Config settings must be in the format 'key=value'"
208+
)
209+
210+
key, _, value = config_setting.partition("=")
211+
result[key] = value
212+
213+
return result
214+
215+
@staticmethod
216+
def _prepare_formats(fmt: str | None) -> list[DistributionType]:
217+
fmt = fmt or "all"
218+
formats: list[DistributionType]
219+
220+
if fmt in BUILD_FORMATS:
221+
formats = [fmt] # type: ignore[list-item]
222+
elif fmt == "all":
223+
formats = list(BUILD_FORMATS.keys()) # type: ignore[arg-type]
224+
else:
225+
raise ValueError(f"Invalid format: {fmt}")
226+
227+
return formats
228+
229+
def handle(self) -> int:
230+
build_handler = BuildHandler(
231+
poetry=self.poetry,
232+
env=self.env,
233+
io=self.io,
234+
)
235+
build_options = BuildOptions(
236+
clean=self.option("clean"),
237+
formats=self._prepare_formats(self.option("format")),
238+
output=self.option("output"),
239+
config_settings=self._prepare_config_settings(
240+
local_version=self.option("local-version"),
241+
config_settings=self.option("config-settings"),
242+
io=self.io,
243+
),
244+
)
245+
246+
return build_handler.build(options=build_options)

tests/console/commands/test_build.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@
77

88
import pytest
99

10+
from cleo.io.null_io import NullIO
1011
from cleo.testers.application_tester import ApplicationTester
1112

1213
from poetry.console.application import Application
14+
from poetry.console.commands.build import BuildCommand
15+
from poetry.console.commands.build import BuildHandler
16+
from poetry.console.commands.build import BuildOptions
1317
from poetry.factory import Factory
1418
from poetry.utils.helpers import remove_directory
1519
from tests.helpers import with_working_directory
@@ -19,6 +23,7 @@
1923
from pathlib import Path
2024

2125
from cleo.testers.command_tester import CommandTester
26+
from pytest_mock import MockerFixture
2227

2328
from poetry.poetry import Poetry
2429
from poetry.utils.env import VirtualEnv
@@ -211,3 +216,83 @@ def test_build_relative_directory_src_layout(
211216
build_dir = tmp_project_path / "dist"
212217

213218
assert len(list(build_dir.iterdir())) == 2
219+
220+
221+
def test_build_options_validate_formats() -> None:
222+
with pytest.raises(ValueError, match="Invalid format: UNKNOWN"):
223+
_ = BuildOptions(clean=True, formats=["sdist", "UNKNOWN"], output="dist") # type: ignore[list-item]
224+
225+
226+
def test_prepare_config_settings() -> None:
227+
config_settings = BuildCommand._prepare_config_settings(
228+
local_version="42",
229+
config_settings=["setting_1=value_1", "setting_2=value_2"],
230+
io=NullIO(),
231+
)
232+
233+
assert config_settings == {
234+
"local-version": "42",
235+
"setting_1": "value_1",
236+
"setting_2": "value_2",
237+
}
238+
239+
240+
@pytest.mark.parametrize(
241+
["fmt", "expected_formats"],
242+
[
243+
("all", ["sdist", "wheel"]),
244+
(None, ["sdist", "wheel"]),
245+
("sdist", ["sdist"]),
246+
("wheel", ["wheel"]),
247+
],
248+
)
249+
def test_prepare_formats(fmt: str | None, expected_formats: list[str]) -> None:
250+
formats = BuildCommand._prepare_formats(fmt)
251+
assert formats == expected_formats
252+
253+
254+
def test_prepare_formats_raise_on_invalid() -> None:
255+
with pytest.raises(ValueError, match="Invalid format: UNKNOWN"):
256+
_ = BuildCommand._prepare_formats("UNKNOWN")
257+
258+
259+
@pytest.mark.parametrize(
260+
["project", "isolated_build"],
261+
[
262+
("core_in_range", False),
263+
("core_not_in_range", True),
264+
("has_build_script", True),
265+
("multiple_build_deps", True),
266+
("no_core", True),
267+
("core_from_git", True),
268+
],
269+
)
270+
def test_requires_isolated_build(
271+
project: str,
272+
isolated_build: bool,
273+
fixture_dir: FixtureDirGetter,
274+
mocker: MockerFixture,
275+
) -> None:
276+
poetry = Factory().create_poetry(fixture_dir(f"build_systems/{project}"))
277+
handler = BuildHandler(poetry=poetry, env=mocker.Mock(), io=NullIO())
278+
279+
assert handler._requires_isolated_build() is isolated_build
280+
281+
282+
def test_build_handler_build_isolated(
283+
fixture_dir: FixtureDirGetter, mocker: MockerFixture
284+
) -> None:
285+
from build import ProjectBuilder
286+
287+
poetry = Factory().create_poetry(fixture_dir("build_systems/has_build_script"))
288+
289+
mock_builder = mocker.MagicMock(spec=ProjectBuilder)
290+
mock_isolated_builder = mocker.patch(
291+
"poetry.console.commands.build.isolated_builder"
292+
)
293+
mock_isolated_builder.return_value.__enter__.return_value = mock_builder
294+
295+
handler = BuildHandler(poetry=poetry, env=mocker.Mock(), io=NullIO())
296+
handler.build(BuildOptions(clean=True, formats=["wheel"], output="dist"))
297+
298+
assert mock_builder.build.call_count == 1
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
My Package
2+
==========

0 commit comments

Comments
 (0)