Skip to content

Commit 0f25503

Browse files
committed
Zip standard library on macOS and Linux
1 parent 9339029 commit 0f25503

2 files changed

Lines changed: 87 additions & 2 deletions

File tree

changelog.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# Changelog
22

3-
## v1.6.0 | In development
3+
## v1.6.0 | 2023-06-07
44

55
- Added tests for Python 3.11 and set minimum supported Python version to 3.9.8.
66
- Recipe build performance has been improved significantly and Conan cache usage has been reduced:
77
- In cases where only the `packages` option changes, the recipe no longer requires CPython to be re-compiled (Linux, macOS) or re-downloaded (Windows) every single time. Instead, we take advantage of the Conan cache: the new `embedded_python-core` package contains all baseline binaries (without any `pip` packages). `embedded_python` builds on top of `-core` by adding the `pip` packages and can reuse any compatible `-core` package from the cache.
88
- The Python packages are now installed directly into the `package` folder instead of going via the `build` folder. This speeds up the packaging and reduces space usage since there's no more file duplication.
99
- With Python >= 3.11, the recipe now makes use of the new `./configure --disable-test-modules` option to avoid building and packaging CPython's internal tests.
10+
- On macOS and Linux, the Python standard library is now stored in a `.zip` file to reduce package size (as was already the case on Windows). This is controlled by the `embedded_python-core:zip_stdlib` option which can have the values of `no`, `stored`, or `delflated`.
1011
- Updated default recipe options to `pip` v23.1.2, `setuptools` v67.8.0, and `wheel` v0.40.0 to improve compatibility with the latest PyPI packages.
1112
- Updated default `pip_licenses_version` to v4.3.2 for compatibility with Python 3.11.
1213
- Fixed a bug where deleting the recipe `build` folder would make the package unusable because the `package` folder accidentally contained symlinks to files in the `build` folder.

core/conanfile.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import io
2+
import os
3+
import sys
4+
import shutil
25
import pathlib
36
from conan import ConanFile
47
from conan.tools import files, scm
@@ -19,8 +22,12 @@ class EmbeddedPythonCore(ConanFile):
1922
options = {
2023
"version": ["ANY"],
2124
"openssl_variant": ["lowercase", "uppercase"], # see explanation in `build_requirements()`
25+
"zip_stdlib": ["no", "stored", "deflated"],
26+
}
27+
default_options = {
28+
"openssl_variant": "lowercase",
29+
"zip_stdlib": "stored",
2230
}
23-
default_options = {"openssl_variant": "lowercase"}
2431
exports_sources = "embedded_python_tools.py", "embedded_python.cmake"
2532

2633
def validate(self):
@@ -33,6 +40,7 @@ def config_options(self):
3340
if self.settings.os == "Windows":
3441
del self.settings.compiler
3542
del self.settings.build_type
43+
del self.options.zip_stdlib
3644

3745
def configure(self):
3846
"""We only use the C compiler so ensure we don't need to rebuild if C++ settings change"""
@@ -75,6 +83,11 @@ def short_pyversion(self):
7583
"""The first two components of the version number, e.g. 3.11"""
7684
return scm.Version(".".join(str(self.options.version).split(".")[:2]))
7785

86+
@property
87+
def int_pyversion(self):
88+
"""The first two components of the version number in integer form, e.g. 311"""
89+
return scm.Version("".join(str(self.options.version).split(".")[:2]))
90+
7891
def generate(self):
7992
files.replace_in_file(
8093
self, "embedded_python.cmake", "${self.pyversion}", str(self.pyversion)
@@ -137,6 +150,74 @@ def _patch_libpython_path(self, dst):
137150
self.output.info(f"Patching {exe}, replace {lib} with {relocatable_library}")
138151
self.run(f"install_name_tool -change {lib} {relocatable_library} {exe}")
139152

153+
def _zip_stdlib(self, prefix):
154+
"""Precompile and zip the standard library just like the pre-built package for Windows
155+
156+
For reference, see https://github.com/python/cpython/blob/main/PC/layout/main.py, which is
157+
used to create the embedded Python distribution for Windows. We can't re-use it directly
158+
because it's too Windows-specific, but we can follow the same steps.
159+
"""
160+
if self.settings.os == "Windows":
161+
return
162+
163+
import zipfile
164+
165+
# We'll move everything from `lib` to `.zip` except for these folders
166+
keep_lib_dirs = [
167+
"lib-dynload", # contains only binaries (shared libraries)
168+
"site-packages", # not part of the standard library
169+
f"config-{self.short_pyversion}-{sys.platform}", # binaries and config files
170+
]
171+
172+
# Pre compile all the `.py` files into `.pyc` byte code
173+
compileall = f"{prefix}/bin/python3 -m compileall"
174+
options = [
175+
# Force the compilation even if a `.pyc` already exists.
176+
"-f",
177+
# Place `.pyc` next to `.py` instead of in a `__pycache__` dir. We want this because
178+
# we'll delete the `.py` file and have the `.pyc` be the one and only code file.
179+
"-b",
180+
# Since `.pyc` will be the one and only file, it will never be invalidated.
181+
"--invalidation-mode unchecked-hash",
182+
# Set optimization level to 0. Levels 1 removes asserts and level 2 removes docstrings.
183+
# We don't gain much in performance from level 1 and level 2 is harmful (e.g. no more
184+
# docs lookup in Jupyter notebooks). Level 0 is the default anyway.
185+
"-o0",
186+
# Drop the prefix pointing to the Conan package directory from the byte code so that it
187+
# does not appear in exception messages and stack traces.
188+
f"-s {prefix}",
189+
# Skip files that we don't want to compile `keep_lib_dirs` or cannot be compiled because
190+
# they are used as internal tests for CPython itself. This matches other invokations of
191+
# `compileall` in the CPython codebase.
192+
f"-x '{'|'.join(keep_lib_dirs)}|bad_coding|badsyntax|lib2to3/tests/data'",
193+
# Use as many compiler workers as there are CPU threads.
194+
"-j0",
195+
]
196+
lib = prefix / f"lib/python{self.short_pyversion}"
197+
self.run(f"{compileall} {' '.join(options)} {lib}")
198+
199+
# Zip all the `.pyc` files
200+
zip_name = prefix / f"lib/python{self.int_pyversion}.zip"
201+
compression = getattr(zipfile, f"ZIP_{str(self.options.zip_stdlib).upper()}")
202+
with zipfile.ZipFile(zip_name, "w", compression) as zf:
203+
for root, dir_names, file_names in os.walk(lib):
204+
skip = keep_lib_dirs + ["__pycache__"]
205+
dir_names[:] = [d for d in dir_names if d not in skip]
206+
207+
for pyc_file in (pathlib.Path(root, f) for f in file_names if f.endswith(".pyc")):
208+
zf.write(pyc_file, arcname=str(pyc_file.relative_to(lib)))
209+
210+
def is_landmark(filepath):
211+
"""Older Python version require `os.py(c)` to use as a landmark for the stdlib"""
212+
return self.pyversion < "3.11.0" and filepath.name == "os.pyc"
213+
214+
# Delete everything that we can in `lib`: the `.zip` takes over
215+
for path in lib.iterdir():
216+
if path.is_file() and not is_landmark(path):
217+
path.unlink()
218+
elif path.is_dir() and path.name not in keep_lib_dirs:
219+
shutil.rmtree(path)
220+
140221
def package(self):
141222
src = self.build_folder
142223
dst = pathlib.Path(self.package_folder, "embedded_python")
@@ -177,6 +258,9 @@ def package(self):
177258
keep_path=False,
178259
)
179260

261+
if self.options.zip_stdlib != "no":
262+
self._zip_stdlib(dst)
263+
180264
def package_info(self):
181265
self.env_info.PYTHONPATH.append(self.package_folder)
182266
self.cpp_info.set_property("cmake_build_modules", ["embedded_python.cmake"])

0 commit comments

Comments
 (0)