Skip to content

Commit 2e205a5

Browse files
committed
Provide an alternative to embedded_python_tools.symlink_import()
Until now, we've primarily been symlinking the Python dir into the build folder. However, that has a couple of issues: 1. Occasionally the symlink goes wrong and needs to be deleted manually. This is especially problematic on Windows. 2. The `conanfile.py` syntax is surprising. Even more so with Conan v2 where it requires a manual `sys.path.append()` to work. `symlink_import()` was essentially creating a symlink from `bin/python` to `<conan_package_path>/embedded_python`. The project executable would point `PyConfig::home` to `bin/python`. This commit provides an alternative that simply writes that directory path to a file called `bin/.embedded_python.home`. The executable can read that file on startup and point `PyConfig::home` there. For now, both methods are valid. If the home file works out, we can deprecate `symlink_import()` and remove it down the line.
1 parent e261343 commit 2e205a5

File tree

7 files changed

+72
-55
lines changed

7 files changed

+72
-55
lines changed

changelog.md

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

3-
## v1.9.1 | In development
3+
## v1.9.1 | 2024-06-17
44

55
- Fixed an issue where calling CMake with `-DPython_EXECUTABLE=<system_python>` created conflicts with the embedded Python (either a loud version error, or silently passing the wrong library paths). Some IDEs would pass this flag implicitly and it would hijack the `find_package(Python)` call used internally by this recipe. Now, we specifically protect against this since there should be no traces of system Python in a project that wishes to embed it.
6+
- Provided an alternative to `embedded_python_tools.symlink_import()`. For dev builds, it's now possible to point `PyConfig::home` to the contents of `bin/.embedded_python(-core).home` to avoid needing to copy the entire Python environment into the build tree every time the project is reconfigured.
67

78
## v1.9.0 | 2024-05-03
89

core/conanfile.py

+7-4
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ class EmbeddedPythonCore(ConanFile):
2727
default_options = {
2828
"zip_stdlib": "stored",
2929
}
30-
exports_sources = "embedded_python_tools.py", "embedded_python-core.cmake"
30+
exports_sources = "embedded_python_tools.py", "embedded_python*.cmake"
31+
package_type = "shared-library"
3132

3233
def validate(self):
3334
minimum_python = "3.11.5"
@@ -277,7 +278,7 @@ def _isolate(self, prefix):
277278
def package(self):
278279
src = self.build_folder
279280
dst = pathlib.Path(self.package_folder, "embedded_python")
280-
files.copy(self, "embedded_python-core.cmake", src, dst=self.package_folder)
281+
files.copy(self, "embedded_python*.cmake", src, dst=self.package_folder)
281282
files.copy(self, "embedded_python_tools.py", src, dst=self.package_folder)
282283
license_folder = pathlib.Path(self.package_folder, "licenses")
283284

@@ -321,8 +322,10 @@ def package(self):
321322

322323
def package_info(self):
323324
self.env_info.PYTHONPATH.append(self.package_folder)
324-
self.cpp_info.set_property("cmake_build_modules", ["embedded_python-core.cmake"])
325-
self.cpp_info.build_modules = ["embedded_python-core.cmake"]
325+
self.cpp_info.set_property(
326+
"cmake_build_modules", ["embedded_python-core.cmake", "embedded_python-tools.cmake"]
327+
)
328+
self.cpp_info.build_modules = ["embedded_python-core.cmake", "embedded_python-tools.cmake"]
326329
prefix = pathlib.Path(self.package_folder) / "embedded_python"
327330
self.cpp_info.includedirs = [str(prefix / "include")]
328331
if self.settings.os == "Windows":

core/embedded_python-tools.cmake

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
include_guard(DIRECTORY)
2+
3+
# For development, we want avoid copying all of Python's `lib` and `site-packages` into our
4+
# build tree every time we re-configure the project. Instead, we can point `PyConfig::home`
5+
# to the contents of this file to gain access to all the Python packages.
6+
# For release/deployment, the entire `Python_ROOT_DIR` should be copied into the app's `bin`
7+
# folder and `PyConfig::home` should point to that.
8+
function(embedded_python_generate_home_file filename content)
9+
if(DEFINED CMAKE_RUNTIME_OUTPUT_DIRECTORY)
10+
set(filename ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/${filename})
11+
endif()
12+
file(GENERATE OUTPUT ${filename} CONTENT "${content}")
13+
endfunction()
14+
15+
embedded_python_generate_home_file(".embedded_python-core.home" "${Python_ROOT_DIR}")

core/test_package/conanfile.py

+8-21
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
import subprocess
44
import conan
55
from conan import ConanFile
6-
from conan.tools.cmake import CMake, CMakeToolchain, cmake_layout
6+
from conan.tools.cmake import CMake, cmake_layout
77

88

99
# noinspection PyUnresolvedReferences
1010
class TestEmbeddedPythonCore(ConanFile):
1111
name = "test_embedded_python"
1212
settings = "os", "compiler", "build_type", "arch"
13-
generators = "CMakeDeps", "VirtualRunEnv"
13+
generators = "CMakeToolchain", "CMakeDeps", "VirtualRunEnv"
1414
test_type = "explicit"
1515

1616
def layout(self):
@@ -19,18 +19,7 @@ def layout(self):
1919
def requirements(self):
2020
self.requires(self.tested_reference_str)
2121

22-
def generate(self):
23-
build_type = self.settings.build_type.value
24-
tc = CMakeToolchain(self)
25-
tc.variables[f"CMAKE_RUNTIME_OUTPUT_DIRECTORY_{build_type.upper()}"] = "bin"
26-
tc.generate()
27-
2822
def build(self):
29-
sys.path.append(str(self._core_package_path))
30-
31-
import embedded_python_tools
32-
33-
embedded_python_tools.symlink_import(self, dst="bin/python")
3423
cmake = CMake(self)
3524
cmake.configure(
3625
variables={
@@ -42,20 +31,18 @@ def build(self):
4231
)
4332
cmake.build()
4433

45-
@property
46-
def _py_exe(self):
47-
if self.settings.os == "Windows":
48-
return pathlib.Path(self.build_folder, "bin/python/python.exe")
49-
else:
50-
return pathlib.Path(self.build_folder, "bin/python/bin/python3")
51-
5234
@property
5335
def _core_package_path(self):
5436
if conan.__version__.startswith("2"):
5537
return pathlib.Path(self.dependencies["embedded_python-core"].package_folder)
5638
else:
5739
return pathlib.Path(self.deps_cpp_info["embedded_python-core"].rootpath)
5840

41+
@property
42+
def _py_exe(self):
43+
exe = "python.exe" if sys.platform == "win32" else "python3"
44+
return self._core_package_path / "embedded_python" / exe
45+
5946
def _test_stdlib(self):
6047
"""Ensure that Python runs and built the optional stdlib modules"""
6148
self.run(f'{self._py_exe} -c "import sys; print(sys.version);"')
@@ -78,7 +65,7 @@ def _test_libpython_path(self):
7865

7966
def _test_embed(self):
8067
"""Ensure that everything is available to compile and link to the embedded Python"""
81-
self.run(pathlib.Path("bin", "test_package"), env="conanrun")
68+
self.run(pathlib.Path(self.cpp.build.bindir, "test_package").absolute(), env="conanrun")
8269

8370
def _test_licenses(self):
8471
"""Ensure that the license file is included"""

core/test_package/src/main.cpp

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
11
#include <Python.h>
22
#include <iostream>
3+
#include <fstream>
34
#include <filesystem>
45

6+
std::string find_python_home(std::filesystem::path bin) {
7+
const auto local_home = bin / "python";
8+
if (std::filesystem::exists(local_home)) {
9+
return local_home.string();
10+
}
11+
12+
auto home_file = bin / ".embedded_python.home";
13+
if (!std::filesystem::exists(home_file)) {
14+
home_file = bin / ".embedded_python-core.home";
15+
}
16+
auto stream = std::ifstream(home_file);
17+
return std::string(std::istreambuf_iterator<char>(stream),
18+
std::istreambuf_iterator<char>());
19+
}
20+
521
int main(int argc, const char* argv[]) {
622
auto config = PyConfig{};
723
PyConfig_InitIsolatedConfig(&config);
824

925
const auto bin = std::filesystem::path(argv[0]).parent_path();
10-
const auto python_home = (bin / "python").string();
26+
const auto python_home = find_python_home(bin);
1127
if (auto status = PyConfig_SetBytesString(&config, &config.home, python_home.c_str());
1228
PyStatus_Exception(status)) {
1329
PyConfig_Clear(&config);

embedded_python.cmake

+9-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
include_guard(DIRECTORY)
2+
13
# There is one important thing we want to achieve with the `embedded_python`/`embedded_python-core`
24
# split: we want to avoid recompiling the world when only the Python environment packages change
35
# but the version/headers/libs stay the same. To do that we must ensure that everything is built
@@ -9,9 +11,13 @@
911
# library. On top of that, `embedded_python.cmake` adds `EmbeddedPython_EXECUTABLE` which is aware
1012
# of the full environment with `pip` packages. Note that we do no provide any include or lib dirs
1113
# since those are already provided by `core`.
12-
14+
set(EmbeddedPython_ROOT_DIR "${CMAKE_CURRENT_LIST_DIR}/embedded_python" CACHE STRING "" FORCE)
1315
if(WIN32)
14-
set(EmbeddedPython_EXECUTABLE "${CMAKE_CURRENT_LIST_DIR}/embedded_python/python.exe" CACHE STRING "" FORCE)
16+
set(EmbeddedPython_EXECUTABLE "${EmbeddedPython_ROOT_DIR}/python.exe" CACHE STRING "" FORCE)
1517
else()
16-
set(EmbeddedPython_EXECUTABLE "${CMAKE_CURRENT_LIST_DIR}/embedded_python/python3" CACHE STRING "" FORCE)
18+
set(EmbeddedPython_EXECUTABLE "${EmbeddedPython_ROOT_DIR}/python3" CACHE STRING "" FORCE)
1719
endif()
20+
21+
# See docstring of `embedded_python_generate_home_file()`. It's up to the user to pick if they
22+
# want to point the `-core` package (no `pip` package) or the full embedded environment.
23+
embedded_python_generate_home_file(".embedded_python.home" "${EmbeddedPython_ROOT_DIR}")

test_package/conanfile.py

+14-25
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import subprocess
44
import conan
55
from conan import ConanFile
6-
from conan.tools.cmake import CMake, CMakeToolchain, cmake_layout
6+
from conan.tools.cmake import CMake, cmake_layout
77

88
project_root = pathlib.Path(__file__).parent
99

@@ -17,12 +17,13 @@ def _read_env(name):
1717
class TestEmbeddedPython(ConanFile):
1818
name = "test_embedded_python"
1919
settings = "os", "compiler", "build_type", "arch"
20-
generators = "CMakeDeps", "VirtualRunEnv"
20+
generators = "CMakeToolchain", "CMakeDeps", "VirtualRunEnv"
2121
options = {"env": [None, "ANY"]}
2222
default_options = {
2323
"env": None,
2424
"embedded_python-core/*:version": "3.11.5",
2525
}
26+
package_type = "shared-library"
2627

2728
@property
2829
def _core_package_path(self):
@@ -38,6 +39,11 @@ def _package_path(self):
3839
else:
3940
return pathlib.Path(self.deps_cpp_info["embedded_python"].rootpath)
4041

42+
@property
43+
def _py_exe(self):
44+
exe = "python.exe" if sys.platform == "win32" else "python3"
45+
return self._package_path / "embedded_python" / exe
46+
4147
def layout(self):
4248
cmake_layout(self)
4349

@@ -48,19 +54,7 @@ def configure(self):
4854
if self.options.env:
4955
self.options["embedded_python"].packages = _read_env(self.options.env)
5056

51-
def generate(self):
52-
build_type = self.settings.build_type.value
53-
tc = CMakeToolchain(self)
54-
tc.variables[f"CMAKE_RUNTIME_OUTPUT_DIRECTORY_{build_type.upper()}"] = "bin"
55-
tc.generate()
56-
5757
def build(self):
58-
sys.path.append(str(self._package_path))
59-
60-
import embedded_python_tools
61-
62-
embedded_python_tools.symlink_import(self, dst="bin/python")
63-
6458
cmake = CMake(self)
6559
cmake.configure(
6660
variables={
@@ -75,22 +69,17 @@ def build(self):
7569

7670
def _test_env(self):
7771
"""Ensure that Python runs and finds the installed environment"""
78-
if self.settings.os == "Windows":
79-
python_exe = str(pathlib.Path("./bin/python/python").resolve())
80-
else:
81-
python_exe = str(pathlib.Path("./bin/python/bin/python3").resolve())
82-
83-
self.run(f'{python_exe} -c "import sys; print(sys.version);"')
84-
72+
self.run(f'{self._py_exe} -c "import sys; print(sys.version);"')
8573
name = str(self.options.env) if self.options.env else "baseline"
86-
self.run(f"{python_exe} {project_root / name / 'test.py'}", env="conanrun")
74+
self.run(f"{self._py_exe} {project_root / name / 'test.py'}", env="conanrun")
8775

8876
def _test_libpython_path(self):
8977
if self.settings.os != "Macos":
9078
return
9179

92-
python_exe = str(pathlib.Path("./bin/python/bin/python3").resolve())
93-
p = subprocess.run(["otool", "-L", python_exe], check=True, text=True, capture_output=True)
80+
p = subprocess.run(
81+
["otool", "-L", self._py_exe], check=True, text=True, capture_output=True
82+
)
9483
lines = str(p.stdout).strip().split("\n")[1:]
9584
libraries = [line.split()[0] for line in lines]
9685
candidates = [lib for lib in libraries if "libpython" in lib]
@@ -101,7 +90,7 @@ def _test_libpython_path(self):
10190

10291
def _test_embed(self):
10392
"""Ensure that everything is available to compile and link to the embedded Python"""
104-
self.run(pathlib.Path("bin", "test_package"), env="conanrun")
93+
self.run(pathlib.Path(self.cpp.build.bindir, "test_package").absolute(), env="conanrun")
10594

10695
def _test_licenses(self):
10796
"""Ensure that the licenses have been gathered"""

0 commit comments

Comments
 (0)