Skip to content

Compiling the Python script into native module using Cython #221

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ optional arguments:
-d DEST, --dest DEST Where to save the FMU.
--doc DOCUMENTATION_FOLDER
Documentation folder to include in the FMU.
-c, --cythonize Compile the Python script into a binary module using Cython.
--no-external-tool If given, needsExecutionTool=false
--no-variable-step If given, canHandleVariableCommunicationStepSize=false
--interpolate-inputs If given, canInterpolateInputs=true
Expand Down
30 changes: 28 additions & 2 deletions pythonfmu/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
import tempfile
import zipfile
import inspect
import glob
from pathlib import Path
from typing import Iterable, Optional, Tuple, Union
from xml.dom.minidom import parseString
from xml.etree.ElementTree import Element, SubElement, tostring
from .osutil import get_lib_extension, get_platform
from .fmi2slave import FMI2_MODEL_OPTIONS, Fmi2Slave
from setuptools import setup

FilePath = Union[str, Path]
HERE = Path(__file__).parent
Expand Down Expand Up @@ -78,6 +80,13 @@ def build_FMU(
documentation_folder: Optional[FilePath] = None,
**options,
) -> Path:
has_cythonize: bool = options["cythonize"]
if has_cythonize:
cython_build_module = importlib.util.find_spec("Cython.Build")
cython = importlib.util.module_from_spec(cython_build_module)
cython_build_module.loader.exec_module(cython)
cythonize = cython.cythonize

script_file = Path(script_file)
if not script_file.exists():
raise ValueError(f"No such file {script_file!s}")
Expand All @@ -100,7 +109,15 @@ def build_FMU(

with tempfile.TemporaryDirectory(prefix="pythonfmu_") as tempd:
temp_dir = Path(tempd)
shutil.copy2(script_file, temp_dir)
if has_cythonize:
setup(
script_args=["build_ext", "--inplace"],
ext_modules=cythonize(str(script_file), compiler_directives={"language_level": "3"}),
)
for bin_file in glob.glob(f"{script_file.stem}*.{'pyd' if sys.platform == 'win32' else 'so'}"):
shutil.copy2(bin_file, temp_dir)
else:
shutil.copy2(script_file, temp_dir)

# Embed pythonfmu in the FMU so it does not need to be included
dep_folder = temp_dir / "pythonfmu"
Expand Down Expand Up @@ -129,7 +146,7 @@ def build_FMU(
shutil.copy2(file_, temp_dir)

model_identifier, xml = get_model_description(
temp_dir.absolute() / script_file.name, module_name
script_file if has_cythonize else temp_dir.absolute() / script_file.name, module_name
)

dest_file = dest / f"{model_identifier}.fmu"
Expand All @@ -153,6 +170,11 @@ def build_FMU(
# Add information for the Python loader
zip_fmu.writestr(str(resource.joinpath("slavemodule.txt")), module_name)

if has_cythonize:
zip_fmu.writestr(str(resource.joinpath("filetype.txt")), "bin")
else:
zip_fmu.writestr(str(resource.joinpath("filetype.txt")), "script")

# Add FMI API wrapping Python class source
source_node = SubElement(type_node, "SourceFiles")
sources = Path("sources")
Expand Down Expand Up @@ -245,3 +267,7 @@ def create_command_parser(parser: argparse.ArgumentParser):
)

parser.set_defaults(execute=FmuBuilder.build_FMU)

parser.add_argument(
"-c", "--cythonize", dest="cythonize", action="store_true", help="Cythonize the script."
)
104 changes: 68 additions & 36 deletions pythonfmu/pythonfmu-export/src/pythonfmu/PySlaveInstance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,69 @@ inline std::string getline(const std::string& fileName)
return line;
}


std::string searchLeafClassName(PyObject* pLocals)
{
std::string deepestFile = "";
Py_ssize_t deepestChain = 0;
PyObject* key, * value;
Py_ssize_t pos = 0;

while (PyDict_Next(pLocals, &pos, &key, &value)) {
// Check if element in namespace is a class
if (!PyType_Check(value)) {
continue;
}

PyObject* pMroAttribute = PyObject_GetAttrString(value, "__mro__");

if (pMroAttribute != NULL && PySequence_Check(pMroAttribute)) {
std::regex pattern("<class '[^']+\\.([^']+)'");
PyObject* pMROList = PySequence_List(pMroAttribute);

for (Py_ssize_t i = 0; i < PyList_Size(pMROList); ++i) {
PyObject* pItem = PyList_GetItem(pMROList, i);
std::smatch match;
const char* className = PyBytes_AsString(PyUnicode_AsUTF8String(PyObject_Repr(pItem)));

std::string str(className);
bool isMatch = std::regex_search(str, match, pattern);

// If regex match is successfull, and found Fmi2Slave at the deepest level then update state
if (isMatch && i > deepestChain && match[1] == "Fmi2Slave") {
deepestFile = PyBytes_AsString(PyUnicode_AsUTF8String(key));
deepestChain = i;
}
}
}
Py_DECREF(pMroAttribute);
}
return deepestFile;
}

PyObject* findClassFromBinary(const std::string& resources, const std::string& moduleName) {

PyObject* pyModule = PyImport_ImportModule(moduleName.c_str());
if (pyModule == nullptr) {
return nullptr;
}

PyObject* pGlobals = PyModule_GetDict(pyModule);
auto deepestFile = searchLeafClassName(pGlobals);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jorgelmh passing pGlobals here

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inheritance seems to work fine, just tried using the new binaries in component-model and seems to work as expected. I'd still recommend we wait until Lars reviews this part as I'm not sure if it might affect other functionality.


PyObject* pyClassName = Py_BuildValue("s", deepestFile.c_str());
PyObject* pyClass = PyObject_GetAttr(pyModule, pyClassName);

// Clean up Python objects
Py_DECREF(pyModule);
Py_DECREF(pyClassName);
return pyClass;

}

PyObject* findClass(const std::string& resources, const std::string& moduleName) {
// Initialize the Python interpreter
std::string filename = resources + "/" + moduleName + ".py";
std::string deepestFile = "";
int deepestChain = 0;

// Read and execute the Python file
std::ifstream file;
Expand Down Expand Up @@ -71,38 +129,7 @@ PyObject* findClass(const std::string& resources, const std::string& moduleName)
}

fileContents.clear();
PyObject* key, * value;
Py_ssize_t pos = 0;

while (PyDict_Next(pLocals, &pos, &key, &value)) {
// Check if element in namespace is a class
if (!PyType_Check(value)) {
continue;
}

PyObject* pMroAttribute = PyObject_GetAttrString(value, "__mro__");

if (pMroAttribute != NULL && PySequence_Check(pMroAttribute)) {
std::regex pattern ("<class '[^']+\\.([^']+)'");
PyObject* pMROList = PySequence_List(pMroAttribute);

for (Py_ssize_t i = 0; i < PyList_Size(pMROList); ++i) {
PyObject* pItem = PyList_GetItem(pMROList, i);
std::smatch match;
const char* className = PyBytes_AsString(PyUnicode_AsUTF8String(PyObject_Repr(pItem)));

std::string str (className);
bool isMatch = std::regex_search(str, match, pattern);

// If regex match is successfull, and found Fmi2Slave at the deepest level then update state
if (isMatch && i > deepestChain && match[1] == "Fmi2Slave") {
deepestFile = PyBytes_AsString(PyUnicode_AsUTF8String(key));
deepestChain = i;
}
}
}
Py_DECREF(pMroAttribute);
}
auto deepestFile = searchLeafClassName(pLocals);

PyObject* pyClassName = Py_BuildValue("s", deepestFile.c_str());
PyObject* pyClass = PyObject_GetAttr(pyModule, pyClassName);
Expand Down Expand Up @@ -150,8 +177,13 @@ PySlaveInstance::PySlaveInstance(std::string instanceName, std::string resources
}

std::string moduleName = getline(resources_ + "/slavemodule.txt");

pClass_ = findClass(resources_, moduleName);
std::string fileType = getline(resources_ + "/filetype.txt");

if (fileType == "bin") {
pClass_ = findClassFromBinary(resources_, moduleName);
} else {
pClass_ = findClass(resources_, moduleName);
}
if (pClass_ == nullptr) {
handle_py_exception("[ctor] findClass", gilState);
}
Expand Down
5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
fmpy
pytest
pytest~=8.3.3
fmpy~=0.3.21
cython~=3.0.11
5 changes: 3 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ classifiers =
Programming Language :: Python :: 3.8
Topic :: Scientific/Engineering
tests_require =
pytest
fmpy
pytest~=8.3.3
fmpy~=0.3.21
cython~=3.0.11

[options]
include_package_data = True
Expand Down
Loading