Skip to content

Commit 3f901f0

Browse files
committed
Add support for Linux and macOS
This is an MVP implementation of Linux and macOS support. Everything is functional but there are two caveats that should certainly be resolved in future versions: * The standard library is not pre-compiled and zipped so it takes up more space than the Windows variant. * The environment is not as locked down as the Windows variant: `pip` is still accessible in the final package. On Windows, we can simply download a pre-built binary embedded package, but on macOS and Linux, we need to build from source and lock down the environment ourselves. This means that the two points above require a bit more effort. We can tackle this later. For now, this implementation is certainly good enough to get going.
1 parent bb0c3f1 commit 3f901f0

File tree

7 files changed

+142
-25
lines changed

7 files changed

+142
-25
lines changed

.github/workflows/test_package.yml

+4-3
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ on: [push]
44

55
jobs:
66
test_package:
7-
runs-on: windows-latest
7+
runs-on: ${{ matrix.os }}
88
strategy:
99
matrix:
10-
embedded-py: [3.7.7, 3.8.3]
10+
os: [windows-latest, ubuntu-latest, macos-latest]
11+
embedded-py: [3.7.7, 3.8.10]
1112
env:
1213
create_pck: conan create . lumicks/testing -o embedded_python:version=${{ matrix.embedded-py }}
1314
steps:
@@ -18,7 +19,7 @@ jobs:
1819
python-version: 3.7
1920
- name: Install Conan
2021
run: |
21-
python -m pip install conan==1.25.2
22+
python -m pip install conan==1.36.0
2223
conan profile new default --detect
2324
- name: Test baseline
2425
run: ${{ env.create_pck }}

changelog.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# Changelog
22

3-
## v1.3.0 | In development
3+
## v1.3.0 | 2021-06-03
44

5+
- Added support for Linux and macOS with a couple of caveats to be resolved later:
6+
* The standard library is not pre-compiled and zipped so it takes up more space than the Windows variant
7+
* The environment is not as locked down as the Windows variant: `pip` is still accessible in the final package
58
- The `packages` option now accepts the full contents of a `requirements.txt` file.
69
Previously, the contents needed to be converted into a space-separated list (`.replace("\n", " ")`) and stripped of comments and markers.
710
- CMake will now automatically call `find_package(Python)` and ensure that the embedded distribution is found instead of a system-installed Python.

conanfile.py

+106-4
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,42 @@ class EmbeddedPython(ConanFile):
1010
description = "Embedded distribution of Python"
1111
url = "https://www.python.org/"
1212
license = "PSFL"
13-
settings = {"os": ["Windows"]}
14-
options = {"version": "ANY", "packages": "ANY"}
15-
default_options = "packages=None"
13+
settings = "os", "compiler", "build_type", "arch"
14+
options = {
15+
"version": "ANY",
16+
"packages": "ANY",
17+
"openssl_variant": ["lowercase", "uppercase"] # see explanation in `build_requirements()`
18+
}
19+
default_options = {
20+
"packages": None,
21+
"openssl_variant": "lowercase"
22+
}
1623
exports = "embedded_python_tools.py", "embedded_python.cmake"
1724
short_paths = True # some of the pip packages go over the 260 char path limit on Windows
1825

26+
def config_options(self):
27+
"""On Windows, we download a binary so these options have no effect"""
28+
if self.settings.os == "Windows":
29+
del self.settings.compiler
30+
del self.settings.build_type
31+
32+
def build_requirements(self):
33+
"""On Windows, we download a binary so we don't need anything else"""
34+
if self.settings.os == "Windows":
35+
return
36+
37+
self.build_requires("sqlite3/3.35.5")
38+
self.build_requires("bzip2/1.0.8")
39+
40+
# The pre-conan-center-index version of `openssl` was capitalized as `OpenSSL`.
41+
# Both versions can't live in the same Conan cache so we need this compatibility
42+
# option to pick the available version. The cache case-sensitivity issue should
43+
# be solved in Conan 2.0, but we need this for now.
44+
if self.options.openssl_variant == "lowercase":
45+
self.build_requires("openssl/1.1.1k")
46+
else:
47+
self.build_requires("OpenSSL/1.1.1f")
48+
1949
@property
2050
def pyversion(self):
2151
"""Full Python version that we want to package"""
@@ -64,7 +94,10 @@ def _gather_licenses(self, bootstrap):
6494

6595
def build(self):
6696
prefix = pathlib.Path(self.build_folder) / "embedded_python"
67-
build_helper = WindowsBuildHelper(self, prefix)
97+
if self.settings.os == "Windows":
98+
build_helper = WindowsBuildHelper(self, prefix)
99+
else:
100+
build_helper = UnixLikeBuildHelper(self, prefix)
68101
build_helper.build_embedded()
69102

70103
if not self.options.packages:
@@ -142,3 +175,72 @@ def build_bootstrap(self):
142175

143176
return python_exe
144177

178+
179+
class UnixLikeBuildHelper:
180+
def __init__(self, conanfile, prefix_dir):
181+
self.conanfile = conanfile
182+
self.prefix_dir = prefix_dir
183+
184+
def _get_source(self):
185+
url = f"https://github.com/python/cpython/archive/v{self.conanfile.pyversion}.tar.gz"
186+
tools.get(url)
187+
os.rename(f"cpython-{self.conanfile.pyversion}", "src")
188+
189+
@property
190+
def _openssl_path(self):
191+
if self.conanfile.options.openssl_variant == "lowercase":
192+
pck = "openssl"
193+
else:
194+
pck = "OpenSSL"
195+
return self.conanfile.deps_cpp_info[pck].rootpath
196+
197+
def _build(self, dest_dir):
198+
from conans import AutoToolsBuildEnvironment
199+
200+
autotools = AutoToolsBuildEnvironment(self.conanfile)
201+
env_vars = autotools.vars
202+
203+
# On Linux, we need to set RPATH so that `root/bin/python3` can correctly find the `.so`
204+
# file in `root/lib` no matter where `root` is. We need it to be portable. We explicitly
205+
# set `--disable-new-dtags` to use RPATH instead of RUNPATH. RUNPATH can be overridden by
206+
# the LD_LIBRARY_PATH env variable which is not at all what we want for this self-contained
207+
# package. Unlike RUNPATH, RPATH takes precedence over LD_LIBRARY_PATH.
208+
if self.conanfile.settings.os == "Linux":
209+
env_vars["LDFLAGS"] += " -Wl,-rpath,'$$ORIGIN/../lib' -Wl,--disable-new-dtags"
210+
211+
config_args = " ".join([
212+
"--enable-shared",
213+
f"--prefix={dest_dir}",
214+
f"--with-openssl={self._openssl_path}",
215+
])
216+
217+
tools.mkdir("./build")
218+
with tools.chdir("./build"), tools.environment_append(env_vars):
219+
self.conanfile.run(f"../src/configure {config_args}")
220+
self.conanfile.run("make -j8")
221+
self.conanfile.run("make install -j8")
222+
223+
ver = ".".join(self.conanfile.pyversion.split(".")[:2])
224+
exe = str(dest_dir / f"bin/python{ver}")
225+
self.conanfile.run(f"{exe} -m pip install -U pip==21.1.1")
226+
227+
# Move the license file to match the Windows layout
228+
lib_dir = dest_dir / "lib"
229+
os.rename(lib_dir / f"python{ver}/LICENSE.txt", dest_dir / "LICENSE.txt")
230+
231+
# Give write permissions, otherwise end-user projects won't be able to re-import
232+
# the shared libraries (re-import happens on subsequent `conan install` runs).
233+
for file in lib_dir.glob("libpython*"):
234+
self.conanfile.run(f"chmod 777 {file}")
235+
236+
def build_embedded(self):
237+
self._get_source()
238+
self._build(self.prefix_dir)
239+
240+
def enable_site_packages(self):
241+
"""These are enabled by default when building from source"""
242+
pass
243+
244+
def build_bootstrap(self):
245+
"""For now, as a shortcut, we'll let the Unix-like builds bootstrap themselves"""
246+
return self.prefix_dir / "bin/python3"

embedded_python_tools.py

+19-13
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,17 @@
33
import pathlib
44

55

6-
def symlink_import(self, dst="bin/python/interpreter", bin="bin"):
6+
def _symlink_compat(conanfile, src, dst):
7+
"""On Windows, symlinks require admin privileges, so we use a directory junction instead"""
8+
if conanfile.settings.os == "Windows":
9+
import _winapi
10+
11+
_winapi.CreateJunction(str(src), str(dst))
12+
else:
13+
os.symlink(src, dst)
14+
15+
16+
def symlink_import(conanfile, dst="bin/python/interpreter", bin="bin"):
717
"""Copying the entire embedded Python environment is extremely slow, so we just symlink it
818
919
Usage:
@@ -13,8 +23,7 @@ def imports(self):
1323
embedded_python_tools.symlink_import(self, dst="bin/python/interpreter")
1424
```
1525
16-
On Windows, symlinks require admin privileges, so we use a directory junction instead.
17-
It points to the Conan package location. We still want to copy in `python*.dll` and
26+
The symlink points to the Conan package location. We still want to copy in `python*.dll` and
1827
`python*.zip` right next to the executable so that they can be found, but the rest of
1928
the Python environment is in a subfolder:
2029
@@ -27,24 +36,21 @@ def imports(self):
2736
|- python*.zip
2837
\- ...
2938
"""
30-
if self.settings.os != "Windows":
31-
return
32-
33-
import _winapi
34-
3539
dst = pathlib.Path(dst).absolute()
3640
if not dst.parent.exists():
3741
dst.parent.mkdir(parents=True)
3842

3943
if dst.exists():
40-
try: # to remove any existing junction
44+
try: # to remove any existing junction/symlink
4145
os.remove(dst)
4246
except: # this seems to be the only way to find out this is not a junction
4347
shutil.rmtree(dst)
4448

45-
src = pathlib.Path(self.deps_cpp_info["embedded_python"].rootpath) / "embedded_python"
46-
_winapi.CreateJunction(str(src), str(dst))
49+
src = pathlib.Path(conanfile.deps_cpp_info["embedded_python"].rootpath) / "embedded_python"
50+
_symlink_compat(conanfile, src, dst)
4751

4852
bin = pathlib.Path(bin).absolute()
49-
self.copy("python*.dll", dst=bin, src="embedded_python", keep_path=False)
50-
self.copy("python*.zip", dst=bin, src="embedded_python", keep_path=False)
53+
conanfile.copy("python*.dll", dst=bin, src="embedded_python", keep_path=False)
54+
conanfile.copy("libpython*.so*", dst=bin, src="embedded_python/lib", keep_path=False)
55+
conanfile.copy("libpython*.dylib", dst=bin, src="embedded_python/lib", keep_path=False)
56+
conanfile.copy("python*.zip", dst=bin, src="embedded_python", keep_path=False)

test_package/conanfile.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ def _test_env(self):
4141
if self.options.env:
4242
script += f"import {name}; print('Found {name}');"
4343

44-
python_exe = str(pathlib.Path("./bin/python/python").resolve())
44+
if self.settings.os == "Windows":
45+
python_exe = str(pathlib.Path("./bin/python/python").resolve())
46+
else:
47+
python_exe = str(pathlib.Path("./bin/python/bin/python3").resolve())
4548
self.run([python_exe, "-c", script])
4649

4750
if self.options.env:

test_package/envs/baseline_test.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import bz2
2+
import lzma
3+
import ssl
14
import sqlite3
2-
import _ssl
3-
import _decimal
45
import zlib
6+
import _decimal
57

68
print("All optional Python features are importable")

test_package/envs/nbconvert.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ pygments==2.7.4
2222
pyparsing==2.4.7
2323
pyrsistent==0.17.3
2424
python-dateutil==2.8.1
25-
pywin32==300
25+
pywin32==300; sys_platform == "win32"
2626
pyzmq==22.0.2
2727
six==1.15.0
2828
testpath==0.4.4

0 commit comments

Comments
 (0)