OGS
ogs_embedded_python.cpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: Copyright (c) OpenGeoSys Community (opengeosys.org)
2// SPDX-License-Identifier: BSD-3-Clause
3
5
6#include <pybind11/embed.h>
7
8#include <array>
9#include <cstddef>
10#include <cstdio>
11#include <filesystem>
12#include <memory>
13#include <optional>
14#include <string>
15#include <string_view>
16#include <vector>
17
18#include "BaseLib/Error.h"
19#include "BaseLib/Logging.h"
23
32
33namespace ApplicationsLib
34{
35pybind11::scoped_interpreter setupEmbeddedPython()
36{
37 // Allows ogs to be interrupted by SIGINT, which otherwise is handled by
38 // python. See
39 // https://docs.python.org/3/c-api/exceptions.html#c.PyErr_CheckSignals and
40 // https://pybind11.readthedocs.io/en/stable/faq.html#how-can-i-properly-handle-ctrl-c-in-long-running-functions
41 constexpr bool init_signal_handlers = false;
42 return pybind11::scoped_interpreter{init_signal_handlers};
43}
44
45// Rest of the file handles venv compatibility checks and sys.path handling.
46namespace
47{
48#ifdef _WIN32
50struct PipeCloser
51{
52 void operator()(FILE* f) const { _pclose(f); }
53};
54
56std::optional<std::string> executeCommand(std::string_view command)
57{
58 std::array<char, 256> buffer;
59 std::string result;
60 std::unique_ptr<FILE, PipeCloser> pipe(_popen(command.data(), "r"));
61
62 if (!pipe)
63 {
64 DBUG("Failed to execute command: {}", command);
65 return std::nullopt;
66 }
67
68 while (fgets(buffer.data(), buffer.size(), pipe.get()) != nullptr)
69 {
70 result += buffer.data();
71 }
72
73 return result;
74}
75
77std::optional<std::pair<int, int>> getPythonVersionFromVenv(
78 std::filesystem::path const& venv_path)
79{
80 namespace fs = std::filesystem;
81
82 auto const python_exe = venv_path / "Scripts" / "python.exe";
83
84 if (!fs::exists(python_exe))
85 {
86 DBUG("Python executable not found at: {}", python_exe.string());
87 return std::nullopt;
88 }
89
90 std::string const command = "\"" + python_exe.string() + "\" --version";
91 auto const output = executeCommand(command);
92
93 if (!output.has_value())
94 {
95 DBUG("Failed to get Python version from: {}", python_exe.string());
96 return std::nullopt;
97 }
98
99 // Parse output like "Python 3.11.5"
100 std::string_view const out_view(output.value());
101 constexpr std::string_view prefix = "Python ";
102 if (!out_view.starts_with(prefix))
103 {
104 DBUG("Unexpected Python version output: {}", output.value());
105 return std::nullopt;
106 }
107
108 std::string_view const version_part = out_view.substr(prefix.size());
109 int major = 0;
110 int minor = 0;
111 if (std::sscanf(version_part.data(), "%d.%d", &major, &minor) != 2)
112 {
113 DBUG("Failed to parse Python version from: {}", output.value());
114 return std::nullopt;
115 }
116
117 return std::pair{major, minor};
118}
119#endif // _WIN32
120
121std::vector<std::filesystem::path> findAlternativeSitePackagesPaths(
122 std::filesystem::path const& venv_path)
123{
124 namespace fs = std::filesystem;
125
126 std::vector<fs::path> alternatives;
127 fs::path const lib_path = venv_path / "lib";
128
129 if (!fs::exists(lib_path) || !fs::is_directory(lib_path))
130 {
131 // Should not happen, i.e. if venv directory is not valid
132 return {};
133 }
134
135 for (auto const& entry : fs::directory_iterator(lib_path))
136 {
137 if (!entry.is_directory())
138 {
139 continue;
140 }
141
142 std::string const dirname = entry.path().filename().string();
143 if (!dirname.starts_with("python"))
144 {
145 continue;
146 }
147
148 fs::path const candidate = entry.path() / "site-packages";
149 if (fs::exists(candidate) && fs::is_directory(candidate))
150 {
151 alternatives.push_back(candidate);
152 }
153 }
154
155 return alternatives;
156}
157
159std::filesystem::path findSitePackagesPath(
160 std::filesystem::path const& venv_path, int const emb_major,
161 int const emb_minor)
162{
163 namespace fs = std::filesystem;
164
165#ifdef _WIN32
166 // On Windows only: compare embedded python interpreter version with the
167 // version of the python executable in the virtual environment.
168 auto const venv_version = getPythonVersionFromVenv(venv_path);
169 if (!venv_version.has_value())
170 {
171 OGS_FATAL(
172 "Failed to determine Python version from virtual environment at "
173 "'{}'.",
174 venv_path.string());
175 }
176
177 if (venv_version->first != emb_major || venv_version->second != emb_minor)
178 {
179 OGS_FATAL(
180 "Python version mismatch: embedded interpreter is {}.{}, but "
181 "virtual environment at '{}' uses {}.{}.",
182 emb_major, emb_minor, venv_path.string(), venv_version->first,
183 venv_version->second);
184 }
185
186 fs::path const site_packages = venv_path / "Lib" / "site-packages";
187#else
188 // On Linux / macOS: Construct path to site-packages directory where the
189 // Python version is embedded in the path. Then check for compatibility.
190 // E.g.: .venv/lib/python3.14/site-packages.
191 // Executing the virtual environment's Python interpreter may not possible
192 // when executing Python BCs from within a container environment, i.e.
193 // this would execute Python from the host inside the container.
194 fs::path const site_packages = venv_path / "lib" /
195 ("python" + std::to_string(emb_major) + "." +
196 std::to_string(emb_minor)) /
197 "site-packages";
198#endif // _WIN32
199
200 if (!fs::exists(site_packages))
201 {
202#ifndef _WIN32
203 // If correct site-packages directory is not found, check for
204 // directories for other Python versions, indicating a Python version
205 // mismatch. Possible on Linux / macOS only.
206 auto const alternatives = findAlternativeSitePackagesPaths(venv_path);
207 if (!alternatives.empty())
208 {
209 std::string alternative_paths;
210 for (std::size_t i = 0; i < alternatives.size(); ++i)
211 {
212 if (i > 0)
213 {
214 alternative_paths += ", ";
215 }
216 alternative_paths += "'" + alternatives[i].string() + "'";
217 }
218
219 WARN(
220 "Expected site-packages directory '{}' was not found. "
221 "Found other site-packages directory/directories: {}. This "
222 "may indicate a Python version mismatch between the embedded "
223 "interpreter {}.{} and the virtual environment.",
224 site_packages.string(), alternative_paths, emb_major,
225 emb_minor);
226 }
227#endif // _WIN32
228 OGS_FATAL("site-packages directory not found at '{}'",
229 site_packages.string());
230 }
231
232 return site_packages;
233}
234} // anonymous namespace
235
237{
238 namespace py = pybind11;
239 namespace fs = std::filesystem;
240
241 // Get embedded Python version
242 py::object const version_info =
243 py::module_::import("sys").attr("version_info");
244 int const emb_major = version_info.attr("major").cast<int>();
245 int const emb_minor = version_info.attr("minor").cast<int>();
246
247 // Check for virtual environment
248 char const* const venv = std::getenv("VIRTUAL_ENV");
249 if (venv == nullptr)
250 {
251 DBUG("No virtual environment detected (VIRTUAL_ENV not set).");
252 return;
253 }
254
255 fs::path const venv_path(venv);
256 DBUG("Virtual environment detected at: {}", venv_path.string());
257
258 // Find and validate site-packages path for the embedded interpreter
259 // version.
260 fs::path const site_packages =
261 findSitePackagesPath(venv_path, emb_major, emb_minor);
262 INFO("Using virtual environment site-packages: {}", site_packages.string());
263
264 // Add to sys.path via site.addsitedir() and not via a plain
265 // sys.path.insert(): only the former processes the .pth files of the
266 // directory. Ephemeral virtual environments created by `uv run --with ...`
267 // contain no packages at all, but merely a .pth file chaining to the
268 // directories the packages really live in. Without .pth processing not a
269 // single package of such an environment would be importable.
270 auto const sys = py::module_::import("sys");
271 py::list const sys_path = sys.attr("path");
272 std::size_t const num_paths_before = py::len(sys_path);
273
274 py::module_::import("site").attr("addsitedir")(
275 py::str(site_packages.string()));
276
277 // addsitedir() appends, but the virtual environment's packages shall take
278 // precedence over those of the embedded interpreter. Hence move the newly
279 // added paths to the front, keeping their relative order.
280 std::size_t const num_paths_after = py::len(sys_path);
281 py::list reordered_path;
282 for (std::size_t i = num_paths_before; i < num_paths_after; ++i)
283 {
284 reordered_path.append(sys_path[i]);
285 }
286 for (std::size_t i = 0; i < num_paths_before; ++i)
287 {
288 reordered_path.append(sys_path[i]);
289 }
290 sys.attr("path") = reordered_path;
291}
292
293} // namespace ApplicationsLib
#define OGS_FATAL(...)
Definition Error.h:10
void INFO(fmt::format_string< Args... > fmt, Args &&... args)
Definition Logging.h:28
void DBUG(fmt::format_string< Args... > fmt, Args &&... args)
Definition Logging.h:22
void WARN(fmt::format_string< Args... > fmt, Args &&... args)
Definition Logging.h:34
std::vector< std::filesystem::path > findAlternativeSitePackagesPaths(std::filesystem::path const &venv_path)
std::filesystem::path findSitePackagesPath(std::filesystem::path const &venv_path, int const emb_major, int const emb_minor)
Finds site-packages path in the virtual environment.
pybind11::scoped_interpreter setupEmbeddedPython()
void pythonBindSourceTerm(pybind11::module &m)
Creates Python bindings for the Python source term class.
void pythonBindBoundaryCondition(pybind11::module &m)
Creates Python bindings for the Python BC class.
void bheInflowpythonBindBoundaryCondition(pybind11::module &m)
Creates BHE Inflow Python bindings for the Python BC class.
PYBIND11_EMBEDDED_MODULE(OpenGeoSys, m)