Skip to content

Commit 250377b

Browse files
committed
Fix runtime conflict between embedded and system Python
The issue was that our embedded Python could pick up extra Python packages installed in the user's home directory. Python adds user paths by default unless instructed otherwise. The `._pth` file puts it into isolated mode. See the docstrings of `_isolate()` for all the details.
1 parent 3b01d0d commit 250377b

File tree

5 files changed

+115
-18
lines changed

5 files changed

+115
-18
lines changed

changelog.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Changelog
22

3-
## v1.8.1 | In development
3+
## v1.8.1 | 2023-10-02
44

5+
- Fixed packaging and runtime errors caused by conflicts with an incompatible system Python installation or `pip` packages installed in the user's home directory. The embedded Python now always run in isolated mode regardless of command line flags.
56
- Fixed packaging error on Windows when the Conan cache path contains spaces.
67
- Fixed Python include dirs being added twice (didn't cause any issues, just noise on the command line).
78
- Fixed `openssl` v3 mistakenly being enabled for Python 3.10. While 3.10 has preliminary support for `openssl` v3, Python 3.11 is the real minimum requirement for full support.

conanfile.py

+24-13
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,10 @@ def _build_bootstrap(self):
128128
bootstrap = pathlib.Path(self.build_folder) / "bootstrap"
129129
files.copy(self, "*", src=self.core_pkg / "embedded_python", dst=bootstrap)
130130

131-
if self.settings.os == "Windows":
132-
# Deleting the ._pth file restores regular (non-embedded) module path rules
131+
# Deleting the ._pth file restores regular (non-embedded) module path rules
132+
if self.settings.os != "Windows":
133+
os.remove(bootstrap / f"python{self.short_pyversion}._pth")
134+
else:
133135
os.remove(bootstrap / f"python{self.int_pyversion}._pth")
134136
# Moving files to the `DLLs` folder restores non-embedded folder structure
135137
dlls = bootstrap / "DLLs"
@@ -138,7 +140,7 @@ def _build_bootstrap(self):
138140
file.rename(dlls / file.name)
139141
# We need pip to install packages
140142
files.download(self, "https://bootstrap.pypa.io/get-pip.py", filename="get-pip.py")
141-
self.run(f"{self.bootstrap_py_exe} get-pip.py")
143+
self._run_bootstrap_py("get-pip.py")
142144

143145
specs = [
144146
f"pip=={self.options.pip_version}",
@@ -147,12 +149,26 @@ def _build_bootstrap(self):
147149
f"pip-licenses=={self.options.pip_licenses_version}",
148150
]
149151
options = "--no-warn-script-location --upgrade"
150-
self.run(f"{self.bootstrap_py_exe} -m pip install {options} {' '.join(specs)}")
152+
self._run_bootstrap_py(f"-m pip install {options} {' '.join(specs)}")
153+
154+
def _run_bootstrap_py(self, command, **kwargs):
155+
"""Run `command` with the Python created by `_build_bootstrap()`
156+
157+
While we do need to mostly restore regular module path rules for the bootstrap, we still
158+
don't want to get conflicts with packages installed in the user's home directory. We can
159+
disable those via env variable. Again, this is only for bootstrapping. The final package
160+
will be fully isolated via the `._pth` file.
161+
162+
Here, we can't use `-I` because that also removes the current script directory from the
163+
path which is a problem for older packages with outdated `setup.py` conventions. `-E -s`
164+
gets us close enough to isolated mode without breaking the installation of old packages.
165+
"""
166+
self.run(f"{self.bootstrap_py_exe} -E -s {command}", **kwargs)
151167

152168
def _gather_licenses(self, license_folder):
153169
"""Gather licenses for all packages using our bootstrap environment"""
154-
self.run(
155-
f"{self.bootstrap_py_exe} -m piplicenses --python={self.package_py_exe}"
170+
self._run_bootstrap_py(
171+
f"-m piplicenses --python={self.package_py_exe}"
156172
" --with-system --from=mixed --format=plain-vertical"
157173
" --with-license-file --no-license-path --output-file=package_licenses.txt",
158174
cwd=license_folder,
@@ -175,12 +191,6 @@ def build(self):
175191
def package(self):
176192
files.copy(self, "embedded_python.cmake", src=self.build_folder, dst=self.package_folder)
177193
files.copy(self, "embedded_python*", src=self.core_pkg, dst=self.package_folder)
178-
prefix = pathlib.Path(self.package_folder, "embedded_python")
179-
if self.settings.os == "Windows":
180-
# Enable site-packages, i.e. additional non-system packages
181-
target = prefix / f"python{self.int_pyversion}._pth"
182-
files.replace_in_file(self, target, "#import site", "import site")
183-
184194
license_folder = pathlib.Path(self.package_folder, "licenses")
185195
files.copy(self, "LICENSE.txt", src=self.core_pkg / "licenses", dst=license_folder)
186196

@@ -191,8 +201,9 @@ def package(self):
191201
requirements = self._make_requirements_file(
192202
extra_packages=[f"setuptools=={self.options.setuptools_version}"]
193203
)
204+
prefix = pathlib.Path(self.package_folder, "embedded_python")
194205
options = f'--no-deps --ignore-installed --no-warn-script-location --prefix "{prefix}"'
195-
self.run(f"{self.bootstrap_py_exe} -m pip install {options} -r {requirements}")
206+
self._run_bootstrap_py(f"-m pip install {options} -r {requirements}")
196207
self._gather_licenses(license_folder)
197208
self._gather_packages(license_folder)
198209

core/conanfile.py

+59-4
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,11 @@ def generate(self):
9696
url = f"https://github.com/python/cpython/archive/v{self.pyversion}.tar.gz"
9797
files.get(self, url, strip_root=True)
9898

99-
tc = AutotoolsToolchain(self, prefix=pathlib.Path(self.package_folder, "embedded_python"))
99+
prefix = pathlib.Path(self.package_folder, "embedded_python")
100+
tc = AutotoolsToolchain(self, prefix=prefix)
100101
openssl_path = self.dependencies["openssl"].package_folder
101102
tc.configure_args += [
103+
f"--bindir={prefix}", # see `_isolate()` for the reason why we override this path
102104
"--enable-shared",
103105
"--without-static-libpython",
104106
"--disable-test-modules",
@@ -114,7 +116,7 @@ def generate(self):
114116
# package. Unlike RUNPATH, RPATH takes precedence over LD_LIBRARY_PATH.
115117
if self.settings.os == "Linux":
116118
deps.environment.append(
117-
"LDFLAGS", [r"-Wl,-rpath='\$\$ORIGIN/../lib'", "-Wl,--disable-new-dtags"]
119+
"LDFLAGS", [r"-Wl,-rpath='\$\$ORIGIN/lib'", "-Wl,--disable-new-dtags"]
118120
)
119121

120122
# Statically linking CPython with OpenSSL requires a bit of extra care. See the discussion
@@ -151,14 +153,14 @@ def _patch_libpython_path(self, dst):
151153
if self.settings.os != "Macos":
152154
return
153155

154-
exe = dst / f"bin/python{self.short_pyversion}"
156+
exe = dst / f"python{self.short_pyversion}"
155157
buffer = io.StringIO()
156158
self.run(f"otool -L {exe}", output=buffer)
157159
lines = buffer.getvalue().strip().split("\n")[1:]
158160
libraries = [line.split()[0] for line in lines]
159161
hardcoded_libraries = [lib for lib in libraries if lib.startswith(str(dst))]
160162
for lib in hardcoded_libraries:
161-
relocatable_library = lib.replace(str(dst), "@executable_path/..")
163+
relocatable_library = lib.replace(str(dst), "@executable_path")
162164
self.output.info(f"Patching {exe}, replace {lib} with {relocatable_library}")
163165
self.run(f"install_name_tool -change {lib} {relocatable_library} {exe}")
164166

@@ -230,6 +232,57 @@ def is_landmark(filepath):
230232
elif path.is_dir() and path.name not in keep_lib_dirs:
231233
shutil.rmtree(path)
232234

235+
def _isolate(self, prefix):
236+
"""Isolate this embedded environment from any other Python installations
237+
238+
Creating a `._pth` file puts Python into isolated mode: it will ignore any `PYTHON*`
239+
env variables or additional packages installed in the users home directory. Only
240+
the paths listed in the `._pth` file will be in `sys.path` on startup.
241+
242+
There's an extra quirk that complicates things on non-Windows systems. The `._pth` file
243+
must be in the same directory as the real (non-symlink) executable, but it also must be
244+
in the home/prefix directory. Usually, the executable is in `prefix/bin`. This forces us
245+
to move the executable to `prefix` (this is done in `generate()`). To avoid issues with
246+
established Unix Python conventions, we put symlinks back into `prefix/bin`. This is not
247+
an issue on Windows since it already has `bin == prefix` by default.
248+
249+
Note that `._pth == isolated_mode` is only the case when running Python via the `python(3)`
250+
executable. When embedding into an application executable, the `._pth` file is not relevant.
251+
Isolated mode is set via the C API: https://docs.python.org/3/c-api/init_config.html While
252+
embedding in the app is the primary use case, running the `python(3)` exe is also useful
253+
for various build and runtime tasks. It's important to maintain isolated mode in all cases
254+
to avoid obscure, hard-to-debug issues.
255+
256+
Finally, both `-core` and regular variants of this recipe will have the `._pth` file in the
257+
package. All installed `pip` packages work correctly at runtime in isolated mode. However,
258+
some older packages cannot be installed in isolated mode (they are using outdated `setup.py`
259+
conventions). For this reason, we temporarily delete the `._pth` file and fall back to
260+
partial isolation while installing `pip` packages. See `_build_bootstrap()` for details.
261+
"""
262+
if self.settings.os == "Windows":
263+
paths = [
264+
f"python{self.int_pyversion}.zip",
265+
".",
266+
"Lib/site-packages",
267+
]
268+
# `.pth` file must be next to the main `.dll` and use the same name.
269+
with open(prefix / f"python{self.int_pyversion}._pth", "w") as f:
270+
f.write("\n".join(paths))
271+
else:
272+
paths = [
273+
f"lib/python{self.int_pyversion}.zip",
274+
f"lib/python{self.short_pyversion}",
275+
f"lib/python{self.short_pyversion}/lib-dynload",
276+
f"lib/python{self.short_pyversion}/site-packages",
277+
]
278+
# `.pth` file must be next to real (non-symlink) executable and use the same name.
279+
with open(prefix / f"python{self.short_pyversion}._pth", "w") as f:
280+
f.write("\n".join(paths))
281+
282+
py_exe = f"python{self.short_pyversion}"
283+
os.symlink(f"../{py_exe}", prefix / f"bin/{py_exe}")
284+
os.symlink(f"../{py_exe}", prefix / f"bin/python3")
285+
233286
def package(self):
234287
src = self.build_folder
235288
dst = pathlib.Path(self.package_folder, "embedded_python")
@@ -249,13 +302,15 @@ def package(self):
249302
files.rmdir(self, "tmp")
250303
files.rm(self, "dev.msi", dst)
251304

305+
self._isolate(dst)
252306
files.copy(self, "LICENSE.txt", src=dst, dst=license_folder)
253307
else:
254308
from conan.tools.gnu import Autotools
255309

256310
autotools = Autotools(self)
257311
autotools.install(args=["DESTDIR=''"]) # already handled by AutotoolsToolchain prefix
258312
self._patch_libpython_path(dst)
313+
self._isolate(dst)
259314

260315
# Give write permissions, otherwise end-user projects won't be able to re-import
261316
# the shared libraries (re-import happens on subsequent `conan install` runs).

core/test_package/test.py

+15
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,18 @@
88
import zlib
99

1010
print("All optional Python features are importable")
11+
12+
import sys
13+
import site
14+
15+
if sys.version_info[:2] >= (3, 11):
16+
assert sys.flags.isolated == 1
17+
assert sys.flags.ignore_environment == 1
18+
assert not site.ENABLE_USER_SITE
19+
20+
print("sys.path:")
21+
for p in sys.path:
22+
print("-", p)
23+
24+
# The environment is isolated so only internal paths should be here
25+
assert all("embedded_python" in p for p in sys.path)

test_package/baseline/test.py

+15
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,18 @@
88
import zlib
99

1010
print("All optional Python features are importable")
11+
12+
import sys
13+
import site
14+
15+
if sys.version_info[:2] >= (3, 11):
16+
assert sys.flags.isolated == 1
17+
assert sys.flags.ignore_environment == 1
18+
assert not site.ENABLE_USER_SITE
19+
20+
print("sys.path:")
21+
for p in sys.path:
22+
print("-", p)
23+
24+
# The environment is isolated so only internal paths should be here
25+
assert all("embedded_python" in p for p in sys.path)

0 commit comments

Comments
 (0)