Skip to content
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
56 changes: 56 additions & 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 Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions pythonfmu/__main__.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 48 additions & 7 deletions pythonfmu/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand All @@ -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"
Expand Down Expand Up @@ -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."
)
165 changes: 165 additions & 0 deletions pythonfmu/info.py
Original file line number Diff line number Diff line change
@@ -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)

Loading