Skip to content

Commit cfec650

Browse files
Add --platform option to facilitate installing wheels for platforms other than the host system (#123)
1 parent e02be75 commit cfec650

File tree

12 files changed

+649
-1
lines changed

12 files changed

+649
-1
lines changed

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,62 @@ You can also ensure that the virtual environment is recreated by using the `--cl
6666
```bash
6767
poetry bundle venv /path/to/environment --clear
6868
```
69+
70+
#### --platform option (Experimental)
71+
This option allows you to specify a target platform for binary wheel selection, allowing you to install wheels for
72+
architectures/platforms other than the host system.
73+
74+
The primary use case is in CI/CD operations to produce a deployable asset, such as a ZIP file for AWS Lambda and other
75+
such cloud providers. It is common for the runtimes of these target environments to be different enough from the CI/CD's
76+
runner host such that the binary wheels selected using the host's criteria are not compatible with the target system's.
77+
78+
#### Supported platform values
79+
The `--platform` option requires a value that conforms to the [Python Packaging Platform Tag format](
80+
https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/#platform-tag). Only the following
81+
"families" are supported at this time:
82+
- `manylinux`
83+
- `musllinux`
84+
- `macosx`
85+
86+
#### Examples of valid platform tags
87+
This is not a comprehensive list, but illustrates typical examples.
88+
89+
`manylinux_2_28_x86_64`, `manylinux1_x86_64`, `manylinux2010_x86_64`, `manylinux2014_x86_64`
90+
91+
`musllinux_1_2_x86_64`
92+
93+
`macosx_10_9_x86_64`, `macosx_10_9_intel`, `macosx_11_1_universal2`, `macosx_11_0_arm64`
94+
95+
#### Example use case for AWS Lambda
96+
As an example of one motivating use case for this option, consider the AWS Lambda "serverless" execution environment.
97+
Depending upon which Python version you configure for your runtime, you may get different versions of the Linux system
98+
runtime. When dealing with pre-compiled binary wheels, these runtime differences can matter. If a shared library from
99+
a wheel is packaged in your deployment artifact that is incompatible with the runtime provided environment, then your
100+
Python "function" will error at execution time in the serverless environment.
101+
The issue arises when the build system that is producing the deployment artifact has a materially different platform
102+
from the selected serverless Lambda runtime.
103+
104+
For example, the Python 3.11 Lambda runtime is Amazon Linux 2, which includes the Glibc 2.26 library. If a Python
105+
application is packaged and deployed in this environment that contains wheels built for a more recent version of Glibc,
106+
then a runtime error will result. This is likely to occur even if the build system is the same CPU architecture
107+
(e.g. x86_64) and core platform (e.g. Linux) and there is a package dependency that provides multiple precompiled
108+
wheels for various Glibc (or other system library) versions. The "best" wheel in the context of the build system can
109+
differ from that of the target execution environment.
110+
111+
112+
#### Limitations
113+
**This is not an actual cross-compiler**. Nor is it a containerized compilation/build environment. It simply allows
114+
controlling which **prebuilt** binaries are selected. It is not a replacement for cross-compilation or containerized
115+
builds for use cases requiring that.
116+
117+
If there is not a binary wheel distribution compatible with the specified platform, then the package's source
118+
distribution is selected. If there are compile/build steps for "extensions" that need to run for the source
119+
distribution, then these operations will execute in the context of the host CI/build system.
120+
**This means that the `--platform` option
121+
has no impact on any extension compile/build operations that must occur during package installation.**
122+
This feature is only for
123+
**selecting** prebuilt wheels, and **not for compiling** them from source.
124+
125+
Arguably, in a vast number of use cases, prebuilt wheel binaries are available for your packages and simply selecting
126+
them based on a platform other than the host CI/build system is much faster and simpler than heavier build-from-source
127+
alternatives.

src/poetry_plugin_bundle/bundlers/venv_bundler.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from cleo.io.outputs.section_output import SectionOutput
1313
from poetry.poetry import Poetry
1414
from poetry.repositories.lockfile_repository import LockfileRepository
15+
from poetry.utils.env import Env
1516

1617

1718
class VenvBundler(Bundler):
@@ -23,6 +24,7 @@ def __init__(self) -> None:
2324
self._remove: bool = False
2425
self._activated_groups: set[str] | None = None
2526
self._compile: bool = False
27+
self._platform: str | None = None
2628

2729
def set_path(self, path: Path) -> VenvBundler:
2830
self._path = path
@@ -49,6 +51,11 @@ def set_compile(self, compile: bool = False) -> VenvBundler:
4951

5052
return self
5153

54+
def set_platform(self, platform: str | None) -> VenvBundler:
55+
self._platform = platform
56+
57+
return self
58+
5259
def bundle(self, poetry: Poetry, io: IO) -> bool:
5360
from pathlib import Path
5461
from tempfile import TemporaryDirectory
@@ -60,7 +67,6 @@ def bundle(self, poetry: Poetry, io: IO) -> bool:
6067
from poetry.installation.installer import Installer
6168
from poetry.installation.operations.install import Install
6269
from poetry.packages.locker import Locker
63-
from poetry.utils.env import Env
6470
from poetry.utils.env import EnvManager
6571
from poetry.utils.env.python import Python
6672
from poetry.utils.env.python.exceptions import InvalidCurrentPythonVersionError
@@ -134,6 +140,9 @@ def create_venv_at_path(
134140
)
135141
env = manager.create_venv_at_path(self._path, python=python, force=True)
136142

143+
if self._platform:
144+
self._constrain_env_platform(env, self._platform)
145+
137146
self._write(io, f"{message}: <info>Installing dependencies</info>")
138147

139148
class CustomLocker(Locker):
@@ -239,3 +248,12 @@ def _write(self, io: IO | SectionOutput, message: str) -> None:
239248
return
240249

241250
io.overwrite(message)
251+
252+
def _constrain_env_platform(self, env: Env, platform: str) -> None:
253+
"""
254+
Set the argument environment's supported tags
255+
based on the configured platform override.
256+
"""
257+
from poetry_plugin_bundle.utils.platforms import create_supported_tags
258+
259+
env._supported_tags = create_supported_tags(platform, env)

src/poetry_plugin_bundle/console/commands/bundle/venv.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ class BundleVenvCommand(BundleCommand):
4545
" because the old installer always compiles.)",
4646
flag=True,
4747
),
48+
option(
49+
"platform",
50+
None,
51+
(
52+
"Only use wheels compatible with the specified platform."
53+
" Otherwise the default behavior uses the platform"
54+
" of the running system. (<comment>Experimental</comment>)"
55+
),
56+
flag=False,
57+
value_required=True,
58+
),
4859
]
4960

5061
bundler_name = "venv"
@@ -54,4 +65,5 @@ def configure_bundler(self, bundler: VenvBundler) -> None: # type: ignore[overr
5465
bundler.set_executable(self.option("python"))
5566
bundler.set_remove(self.option("clear"))
5667
bundler.set_compile(self.option("compile"))
68+
bundler.set_platform(self.option("platform"))
5769
bundler.set_activated_groups(self.activated_groups)

src/poetry_plugin_bundle/utils/__init__.py

Whitespace-only changes.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import TYPE_CHECKING
5+
6+
7+
if TYPE_CHECKING:
8+
from packaging.tags import Tag
9+
from poetry.utils.env import Env
10+
11+
12+
@dataclass
13+
class PlatformTagParseResult:
14+
platform: str
15+
version_major: int
16+
version_minor: int
17+
arch: str
18+
19+
@staticmethod
20+
def parse(tag: str) -> PlatformTagParseResult:
21+
import re
22+
23+
match = re.match("([a-z]+)_([0-9]+)_([0-9]+)_(.*)", tag)
24+
if not match:
25+
raise ValueError(f"Invalid platform tag: {tag}")
26+
platform, version_major_str, version_minor_str, arch = match.groups()
27+
return PlatformTagParseResult(
28+
platform=platform,
29+
version_major=int(version_major_str),
30+
version_minor=int(version_minor_str),
31+
arch=arch,
32+
)
33+
34+
def to_tag(self) -> str:
35+
return "_".join(
36+
[self.platform, str(self.version_major), str(self.version_minor), self.arch]
37+
)
38+
39+
40+
def create_supported_tags(platform: str, env: Env) -> list[Tag]:
41+
"""
42+
Given a platform specifier string, generate a list of compatible tags
43+
for the argument environment's interpreter.
44+
45+
Refer to:
46+
https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/#platform-tag
47+
https://pip.pypa.io/en/stable/cli/pip_install/#cmdoption-platform
48+
"""
49+
from packaging.tags import INTERPRETER_SHORT_NAMES
50+
from packaging.tags import compatible_tags
51+
from packaging.tags import cpython_tags
52+
from packaging.tags import generic_tags
53+
54+
if platform.startswith("manylinux"):
55+
supported_platforms = create_supported_manylinux_platforms(platform)
56+
elif platform.startswith("musllinux"):
57+
supported_platforms = create_supported_musllinux_platforms(platform)
58+
elif platform.startswith("macosx"):
59+
supported_platforms = create_supported_macosx_platforms(platform)
60+
else:
61+
raise NotImplementedError(f"Platform {platform} not supported")
62+
63+
python_implementation = env.python_implementation.lower()
64+
python_version = env.version_info[:2]
65+
interpreter_name = INTERPRETER_SHORT_NAMES.get(
66+
python_implementation, python_implementation
67+
)
68+
interpreter = None
69+
70+
if interpreter_name == "cp":
71+
tags = list(
72+
cpython_tags(python_version=python_version, platforms=supported_platforms)
73+
)
74+
interpreter = f"{interpreter_name}{python_version[0]}{python_version[1]}"
75+
else:
76+
tags = list(
77+
generic_tags(
78+
interpreter=interpreter, abis=[], platforms=supported_platforms
79+
)
80+
)
81+
82+
tags.extend(
83+
compatible_tags(
84+
interpreter=interpreter,
85+
python_version=python_version,
86+
platforms=supported_platforms,
87+
)
88+
)
89+
90+
return tags
91+
92+
93+
def create_supported_manylinux_platforms(platform: str) -> list[str]:
94+
"""
95+
https://peps.python.org/pep-0600/
96+
manylinux_${GLIBCMAJOR}_${GLIBCMINOR}_${ARCH}
97+
98+
For now, only GLIBCMAJOR "2" is supported. It is unclear if there will be a need to support a future major
99+
version like "3" and if specified, how generate the compatible 2.x version tags.
100+
"""
101+
# Implementation based on https://peps.python.org/pep-0600/#package-installers
102+
103+
tag = normalize_legacy_manylinux_alias(platform)
104+
105+
parsed = PlatformTagParseResult.parse(tag)
106+
return [
107+
f"{parsed.platform}_{parsed.version_major}_{tag_minor}_{parsed.arch}"
108+
for tag_minor in range(parsed.version_minor, -1, -1)
109+
]
110+
111+
112+
LEGACY_MANYLINUX_ALIASES = {
113+
"manylinux1": "manylinux_2_5",
114+
"manylinux2010": "manylinux_2_12",
115+
"manylinux2014": "manylinux_2_17",
116+
}
117+
118+
119+
def normalize_legacy_manylinux_alias(tag: str) -> str:
120+
tag_os_index_end = tag.index("_")
121+
tag_os = tag[:tag_os_index_end]
122+
tag_arch_suffix = tag[tag_os_index_end:]
123+
os_replacement = LEGACY_MANYLINUX_ALIASES.get(tag_os)
124+
if not os_replacement:
125+
return tag
126+
127+
return os_replacement + tag_arch_suffix
128+
129+
130+
def create_supported_macosx_platforms(platform: str) -> list[str]:
131+
import re
132+
133+
from packaging.tags import mac_platforms
134+
135+
match = re.match("macosx_([0-9]+)_([0-9]+)_(.*)", platform)
136+
if not match:
137+
raise ValueError(f"Invalid macosx tag: {platform}")
138+
tag_major_str, tag_minor_str, tag_arch = match.groups()
139+
tag_major_max = int(tag_major_str)
140+
tag_minor_max = int(tag_minor_str)
141+
142+
return list(mac_platforms(version=(tag_major_max, tag_minor_max), arch=tag_arch))
143+
144+
145+
def create_supported_musllinux_platforms(platform: str) -> list[str]:
146+
import re
147+
148+
match = re.match("musllinux_([0-9]+)_([0-9]+)_(.*)", platform)
149+
if not match:
150+
raise ValueError(f"Invalid musllinux tag: {platform}")
151+
tag_major_str, tag_minor_str, tag_arch = match.groups()
152+
tag_major_max = int(tag_major_str)
153+
tag_minor_max = int(tag_minor_str)
154+
155+
return [
156+
f"musllinux_{tag_major_max}_{minor}_{tag_arch}"
157+
for minor in range(tag_minor_max, -1, -1)
158+
]

tests/bundlers/test_venv_bundler.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from cleo.formatters.style import Style
1212
from cleo.io.buffered_io import BufferedIO
1313
from poetry.core.packages.package import Package
14+
from poetry.core.packages.utils.link import Link
1415
from poetry.factory import Factory
1516
from poetry.installation.operations.install import Install
1617
from poetry.puzzle.exceptions import SolverProblemError
@@ -422,3 +423,79 @@ def test_bundler_non_package_mode(
422423
• Bundled simple-project-non-package-mode (1.2.3) into {path}
423424
"""
424425
assert expected == io.fetch_output()
426+
427+
428+
def test_bundler_platform_override(
429+
io: BufferedIO, tmpdir: str, mocker: MockerFixture, config: Config
430+
) -> None:
431+
poetry = Factory().create_poetry(
432+
Path(__file__).parent.parent / "fixtures" / "project_with_binary_wheel"
433+
)
434+
poetry.set_config(config)
435+
436+
def get_links_fake(package: Package) -> list[Link]:
437+
return [Link(f"https://example.com/{file['file']}") for file in package.files]
438+
439+
mocker.patch(
440+
"poetry.installation.chooser.Chooser._get_links", side_effect=get_links_fake
441+
)
442+
mocker.patch("poetry.installation.executor.Executor._execute_uninstall")
443+
mocker.patch("poetry.installation.executor.Executor._execute_update")
444+
mock_download_link = mocker.patch(
445+
"poetry.installation.executor.Executor._download_link"
446+
)
447+
mocker.patch("poetry.installation.wheel_installer.WheelInstaller.install")
448+
449+
def get_installed_links() -> dict[str, str]:
450+
return {
451+
call[0][0].package.name: call[0][1].filename
452+
for call in mock_download_link.call_args_list
453+
}
454+
455+
bundler = VenvBundler()
456+
bundler.set_path(Path(tmpdir))
457+
bundler.set_remove(True)
458+
459+
bundler.set_platform("manylinux_2_28_x86_64")
460+
bundler.bundle(poetry, io)
461+
installed_link_by_package = get_installed_links()
462+
assert "manylinux_2_28_x86_64" in installed_link_by_package["cryptography"]
463+
assert "manylinux_2_17_x86_64" in installed_link_by_package["cffi"]
464+
assert "py3-none-any.whl" in installed_link_by_package["pycparser"]
465+
466+
bundler.set_platform("manylinux2014_x86_64")
467+
bundler.bundle(poetry, io)
468+
installed_link_by_package = get_installed_links()
469+
assert "manylinux2014_x86_64" in installed_link_by_package["cryptography"]
470+
assert "manylinux_2_17_x86_64" in installed_link_by_package["cffi"]
471+
assert "py3-none-any.whl" in installed_link_by_package["pycparser"]
472+
473+
bundler.set_platform("macosx_10_9_x86_64")
474+
bundler.bundle(poetry, io)
475+
installed_link_by_package = get_installed_links()
476+
assert "macosx_10_9_universal2" in installed_link_by_package["cryptography"]
477+
expected_cffi_platform = (
478+
"macosx_10_9_x86_64" if sys.version_info < (3, 13) else "cffi-1.17.1.tar.gz"
479+
)
480+
assert expected_cffi_platform in installed_link_by_package["cffi"]
481+
assert "py3-none-any.whl" in installed_link_by_package["pycparser"]
482+
483+
bundler.set_platform("macosx_11_0_arm64")
484+
bundler.bundle(poetry, io)
485+
installed_link_by_package = get_installed_links()
486+
assert "macosx_10_9_universal2" in installed_link_by_package["cryptography"]
487+
expected_cffi_platform = (
488+
"macosx_11_0_arm64" if sys.version_info >= (3, 9) else "cffi-1.17.1.tar.gz"
489+
)
490+
assert expected_cffi_platform in installed_link_by_package["cffi"]
491+
assert "py3-none-any.whl" in installed_link_by_package["pycparser"]
492+
493+
bundler.set_platform("musllinux_1_2_aarch64")
494+
bundler.bundle(poetry, io)
495+
installed_link_by_package = get_installed_links()
496+
assert "musllinux_1_2_aarch64" in installed_link_by_package["cryptography"]
497+
expected_cffi_platform = (
498+
"musllinux_1_1_aarch64" if sys.version_info >= (3, 9) else "cffi-1.17.1.tar.gz"
499+
)
500+
assert expected_cffi_platform in installed_link_by_package["cffi"]
501+
assert "py3-none-any.whl" in installed_link_by_package["pycparser"]

tests/fixtures/project_with_binary_wheel/README.md

Whitespace-only changes.

0 commit comments

Comments
 (0)