diff --git a/README.md b/README.md index f8f481f..cd7aa0d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -81,6 +82,61 @@ optional arguments: Requirements or environment file. ``` +### How do I inspect a generated FMU? + +Use `pythonfmu info` to display information about a generated FMU. It reports the supported platforms, +compiled models (`.pyd` / `.so`) with their Python implementation (e.g. CPython) and version, +and Python package dependencies. + +> **Note:** Compiled models are only present when the FMU was built with the `--cythonize` (`-c`) flag, +> which compiles the Python script into a native binary module using Cython. +> Currently, only the main Python script is compiled — additional Python dependencies +> included as project files are **not** compiled. + +> **Warning:** `--cythonize` **does not guarantee** protection against reverse +> engineering and **should not be** used for that purpose. Furthermore, without proper use of Cython-specific +> constructs (e.g., `cdef`, `cfunc`, `cdef class`), the +> compiled binary +> still exposes all public interfaces. Refer to +> the [Cython Extension Types](https://cython.readthedocs.io/en/latest/src/userguide/extension_types.html) for +> constructs +> that can help restrict visibility of functions and class attributes when importing binary modules. + +``` +pythonfmu info -f my.fmu +``` + +``` +usage: pythonfmu info [-h] -f FMU + +Display information about a generated FMU: supported platforms, compiled +models (.pyd/.so) with Python implementation and version, and package +dependencies. + +optional arguments: + -h, --help show this help message and exit + -f, --file FMU Path to the FMU file to inspect. +``` + +Example output: + +``` +Supported platforms: + macOS (x64) + Linux (x64) + Windows (x64) + +Compiled model(s): + demoslave.cp314-win_amd64.pyd + Python impl : CPython + Python version : cp314 (CPython 3.14) + Platform arch : win_amd64 + +Python package dependencies (requirements.txt): + numpy + scipy +``` + ### Example: #### Write the script diff --git a/pythonfmu/__main__.py b/pythonfmu/__main__.py index da264d4..553f2c2 100644 --- a/pythonfmu/__main__.py +++ b/pythonfmu/__main__.py @@ -1,7 +1,7 @@ import argparse -from pythonfmu import builder, csvbuilder, deploy -from ._version import __version__ +from pythonfmu import builder, csvbuilder, deploy, info +from pythonfmu._version import __version__ def cli_main(): @@ -50,6 +50,13 @@ def default_execution(**kwargs): ) deploy.create_command_parser(deploy_parser) + info_parser = subparsers.add_parser( + "info", + description="Display information about a generated FMU: supported platforms, compiled models (.pyd/.so) with Python implementation and version, and package dependencies.", + help="Display information about a generated FMU." + ) + info.create_command_parser(info_parser) + options = vars(parser.parse_args()) execute = options.pop("execute") execute(**options) diff --git a/pythonfmu/builder.py b/pythonfmu/builder.py index 6227091..3dbaed7 100644 --- a/pythonfmu/builder.py +++ b/pythonfmu/builder.py @@ -12,12 +12,14 @@ from types import FunctionType import zipfile import inspect +import glob from pathlib import Path from typing import Iterable, Literal, 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 @@ -27,14 +29,14 @@ def match_par(txt: str, left: str = "(", right: str = ")") -> tuple[int, Literal[-1]] | tuple[int, int]: """ Finds the position of the matching closing parenthesis for the first opening parenthesis in a given string. - + Args: txt (str): The input string to search within. left (str, optional): The character representing the opening parenthesis. Defaults to "(". right (str, optional): The character representing the closing parenthesis. Defaults to ")". Returns: - tuple[int, Literal[-1]] | tuple[int, int]: A tuple containing the position of the first opening parenthesis - and the position of the matching closing parenthesis. If no matching closing parenthesis is found, + tuple[int, Literal[-1]] | tuple[int, int]: A tuple containing the position of the first opening parenthesis + and the position of the matching closing parenthesis. If no matching closing parenthesis is found, returns the position of the first opening parenthesis and -1. Raises: AssertionError: If the first opening parenthesis is not found in the input string. @@ -60,7 +62,7 @@ def match_par(txt: str, left: str = "(", right: str = ")") -> tuple[int, Literal def get_model_class(src: Path) -> Fmi2Slave: """ - Given a source file path, dynamically import the module and find the class that + Given a source file path, dynamically import the module and find the class that inherits from Fmi2Slave with the longest hierarchy. Args: @@ -99,7 +101,7 @@ def get_model_class(src: Path) -> Fmi2Slave: raise ValueError(f"Non-unique Fmi2Slave-derived class in module {src}. Found {classes}.") from None else: return classes[0] - + def update_model_parameters(src: Path, model: Fmi2Slave, newargs: dict) -> str: """ Update the model parameters in the __init__ function of a given module. @@ -124,7 +126,7 @@ def update_model_parameters(src: Path, model: Fmi2Slave, newargs: dict) -> str: modulename = src.stem importlib.invalidate_caches() module = importlib.import_module(modulename) - module = importlib.reload( module) # to avoid that the finder looks in temporary folders previously created + module = importlib.reload( module) # to avoid that the finder looks in temporary folders previously created # Find the __init__ function in the module for name, obj in inspect.getmembers(model): @@ -218,7 +220,7 @@ def build_FMU( raise ValueError(f"No such file {script_file!s}") if not script_file.suffix.endswith(".py"): raise ValueError(f"File {script_file!s} must have extension '.py'!") - + dest = Path(dest) if ( dest.suffix == '.fmu' and # explicit FMU file name shall always have suffix '.fmu' ( dest.is_file() or # Note that .is_file() returns False if the file does not yet exist @@ -256,6 +258,31 @@ def build_FMU( else: shutil.copy2(script_file, temp_dir) + 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 + + with tempfile.TemporaryDirectory(prefix="pythonfmu_build_") as _build_dir: + build_dir = Path(_build_dir) + setup( + script_args=["build_ext"], + ext_modules=cythonize( + str(temp_dir.absolute() / script_file.name), + language_level="3", + build_dir=str(build_dir.absolute()), + ), + options={ + "build": {"build_lib": str(build_dir.absolute())} + } + ) + for bin_file in glob.glob( + f"{build_dir / script_file.stem}*.{'pyd' if sys.platform == 'win32' else 'so'}"): + shutil.copy2(bin_file, temp_dir) + # Embed pythonfmu in the FMU so it does not need to be included dep_folder = temp_dir / "pythonfmu" dep_folder.mkdir() @@ -287,6 +314,11 @@ def build_FMU( model_identifier, xml = get_model_description( temp_dir.absolute() / script_file.name, module_name, model_class.__name__ ) + + # Remove source file + if has_cythonize: + (temp_dir / script_file.name).unlink() + dest_file = dest / f"{model_identifier}.fmu" if dest_file == "" else dest_file type_node = xml.find("CoSimulation") @@ -308,6 +340,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 library binaries = Path("binaries") src_binaries = HERE / "resources" / "binaries" @@ -389,3 +426,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." + ) diff --git a/pythonfmu/info.py b/pythonfmu/info.py new file mode 100644 index 0000000..c6787ac --- /dev/null +++ b/pythonfmu/info.py @@ -0,0 +1,165 @@ +import argparse +import re +import zipfile +from pathlib import Path, PurePosixPath +from typing import Union + + +def info(fmu: Union[str, Path]) -> None: + """Display information about a generated FMU. + + Inspects the FMU archive and prints: + - Supported platforms derived from FMI binaries in the binaries/ directory + (e.g. Windows (x64), Linux (x64)) + - Compiled model(s) (.pyd / .so) found in the resources/ directory, + including Python implementation (CPython, PyPy, GraalPy), + Python version (e.g. cp314), and platform architecture + - Python package dependencies from resources/requirements.txt + + Args: + fmu (str or pathlib.Path): Path to the FMU file. + """ + fmu = Path(fmu) + if not fmu.exists(): + raise FileNotFoundError(f"FMU file not found: {fmu}") + + with zipfile.ZipFile(fmu) as zf: + names = zf.namelist() + + # --- Supported platforms (FMI binaries in binaries/) --- + platform_folders = { + PurePosixPath(n).parent.name + for n in names + if n.startswith("binaries/") + and not n.endswith("/") + } + + if platform_folders: + print("Supported platforms:") + for folder in sorted(platform_folders): + print(f" {_friendly_platform(folder)}") + else: + print("No supported platforms found (no FMI binaries).") + + # --- Locate .pyd / .so compiled models in resources/ (first level only) --- + bin_files = [ + n for n in names + if n.startswith("resources/") + and "/" not in n[len("resources/"):] + and (n.endswith(".pyd") or n.endswith(".so")) + ] + + if bin_files: + print("\nCompiled model(s):") + for bf in bin_files: + fname = PurePosixPath(bf).name + print(f" {fname}") + _print_extension_info(fname) + else: + print("\nNo compiled model (.pyd / .so) found.") + + # --- Requirements --- + req_path = "resources/requirements.txt" + if req_path in names: + print("\nPython package dependencies (requirements.txt):") + with zf.open(req_path) as rf: + content = rf.read().decode("utf-8") + for line in content.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + print(f" {stripped}") + else: + print("\nNo requirements.txt found in resources/.") + + +def _friendly_platform(folder: str) -> str: + """Map an FMI binary folder name to platform name. + + Examples: + win64 -> Windows (x64) + linux64 -> Linux (x64) + darwin64 -> macOS (x64) + """ + _PLATFORM_LABELS = { + "win64": "Windows (x64)", + "win32": "Windows (x86)", + "linux64": "Linux (x64)", + "linux32": "Linux (x86)", + "darwin64": "macOS (x64)", + "darwin32": "macOS (x86)", + } + return _PLATFORM_LABELS.get(folder, folder) + + +def _print_extension_info(filename: str) -> None: + """Parse a compiled Python extension filename and print platform and Python version. + + Recognised patterns (CPython, PyPy, GraalPy): + modulename.cpython-314-x86_64-linux-gnu.so + modulename.cp314-win_amd64.pyd + modulename.pypy310-pp73-x86_64-linux-gnu.so + modulename.pypy39-pp73-win_amd64.pyd + modulename.graalpy-24_1-native-x86_64-linux-gnu.so + """ + stem = filename.rsplit(".", 1)[0] # drop final extension + + impl_name: str | None = None + version_tag: str | None = None + version_display: str | None = None + + cp_match = re.search(r"[._](cpython-|cp)(\d+)", stem) + pypy_match = re.search(r"[._](pypy)(\d+)", stem) + graalpy_match = re.search(r"[._](graalpy)-([0-9_]+)", stem) + + if cp_match: + raw_ver = cp_match.group(2) + impl_name = "CPython" + version_tag = f"cp{raw_ver}" + version_display = f"{raw_ver[0]}.{raw_ver[1:]}" + elif pypy_match: + raw_ver = pypy_match.group(2) + impl_name = "PyPy" + version_tag = f"pp{raw_ver}" + version_display = f"{raw_ver[0]}.{raw_ver[1:]}" + elif graalpy_match: + raw_ver = graalpy_match.group(2) # e.g. "24_1" + impl_name = "GraalPy" + version_tag = f"graalpy-{raw_ver}" + version_display = raw_ver.replace("_", ".") + + if impl_name is not None: + print(f" Python impl : {impl_name}") + print(f" Python version : {version_tag} ({impl_name} {version_display})") + else: + print(" Python impl : unknown") + print(" Python version : unknown") + + # Platform / architecture – everything after the implementation+version tag. + # Build a combined pattern that matches any of the three known prefixes. + plat_match = re.search( + r"[._](?:cpython-|cp|pypy|graalpy-)[\d_]+(?:-pp\d+)?[.-](.+)", stem + ) + if plat_match: + platform_tag = plat_match.group(1) + print(f" Platform arch : {platform_tag}") + else: + # Fallback: derive from extension + if filename.endswith(".pyd"): + print(" Platform arch : win (exact arch unknown)") + elif filename.endswith(".so"): + print(" Platform arch : linux/macOS (exact arch unknown)") + else: + print(" Platform arch : unknown") + + +def create_command_parser(parser: argparse.ArgumentParser): + parser.add_argument( + "-f", + "--file", + dest="fmu", + help="Path to the FMU file to inspect.", + required=True, + ) + + parser.set_defaults(execute=info) + diff --git a/requirements.txt b/requirements.txt index c6cea94..1be8166 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -fmpy -pytest \ No newline at end of file +pytest~=8.3.3 +fmpy~=0.3.21 +cython~=3.2.4 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 3094b1f..a27c346 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,7 @@ classifiers = tests_require = pytest fmpy + cython [options] include_package_data = True @@ -45,6 +46,7 @@ console_scripts = tests = pytest fmpy + cython [tool:pytest] markers = diff --git a/src/pythonfmu/PySlaveInstance.cpp b/src/pythonfmu/PySlaveInstance.cpp index f05c866..69bbf29 100644 --- a/src/pythonfmu/PySlaveInstance.cpp +++ b/src/pythonfmu/PySlaveInstance.cpp @@ -26,12 +26,70 @@ std::string getline(const std::filesystem::path& 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(" 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); + + 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; @@ -73,38 +131,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 != nullptr && PySequence_Check(pMroAttribute)) { - std::regex pattern(" 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); @@ -153,11 +180,17 @@ class PySlaveInstance : public SlaveInstance } std::string moduleName = getline(resourceLocation() + "/slavemodule.txt"); + std::string fileType = getline(resourceLocation() + "/filetype.txt"); + if (fileType == "bin") { + pClass_ = findClassFromBinary(resourceLocation(), moduleName); + } else { pClass_ = findClass(resourceLocation(), moduleName); - if (pClass_ == nullptr) { - handle_py_exception("[ctor] findClass", gilState); - } + } + if (pClass_ == nullptr) { + handle_py_exception("[ctor] findClass", gilState); + } + initialize(gilState); });