pythonfmu3 0.3.4__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. pythonfmu3/_version.py +1 -1
  2. pythonfmu3/builder.py +96 -24
  3. pythonfmu3/fmi3slave.py +23 -19
  4. pythonfmu3/modelexchange.py +17 -2
  5. pythonfmu3/osutil.py +34 -9
  6. pythonfmu3/pythonfmu-export/CMakeLists.txt +36 -4
  7. pythonfmu3/pythonfmu-export/generate_shim.py +88 -0
  8. pythonfmu3/pythonfmu-export/src/CMakeLists.txt +41 -13
  9. pythonfmu3/pythonfmu-export/src/cppfmu/cppfmu_common.hpp +3 -0
  10. pythonfmu3/pythonfmu-export/src/cppfmu/fmi_functions.cpp +6 -4
  11. pythonfmu3/pythonfmu-export/src/pythonfmu/PySlaveInstance.cpp +560 -571
  12. pythonfmu3/pythonfmu-export/src/pythonfmu/PySlaveInstance.hpp +20 -7
  13. pythonfmu3/pythonfmu-export/src/shim/fmi3_forwarders.cpp +612 -0
  14. pythonfmu3/pythonfmu-export/src/shim/shim_loader.cpp +157 -0
  15. pythonfmu3/pythonfmu-export/src/shim/shim_loader.hpp +29 -0
  16. pythonfmu3/resources/binaries/x86_64-linux/libpythonfmu-backend.so +0 -0
  17. pythonfmu3/resources/binaries/x86_64-linux/libpythonfmu-export.so +0 -0
  18. pythonfmu3/resources/binaries/x86_64-windows/pythonfmu-export.dll +0 -0
  19. pythonfmu3/tests/data/fmi3_host.c +31 -0
  20. pythonfmu3/tests/fmi3_ctypes_driver.py +137 -0
  21. pythonfmu3/tests/test_builder.py +48 -2
  22. pythonfmu3/tests/test_docs.py +70 -61
  23. pythonfmu3/tests/test_non_python_host.py +75 -0
  24. pythonfmu3/tests/test_osutil.py +23 -0
  25. pythonfmu3/tests/test_variables.py +1 -1
  26. pythonfmu3/tests/test_wrapper_ctypes.py +61 -0
  27. pythonfmu3/variables.py +4 -3
  28. {pythonfmu3-0.3.4.dist-info → pythonfmu3-1.0.0.dist-info}/METADATA +61 -25
  29. {pythonfmu3-0.3.4.dist-info → pythonfmu3-1.0.0.dist-info}/RECORD +33 -23
  30. {pythonfmu3-0.3.4.dist-info → pythonfmu3-1.0.0.dist-info}/WHEEL +1 -1
  31. {pythonfmu3-0.3.4.dist-info → pythonfmu3-1.0.0.dist-info}/entry_points.txt +0 -0
  32. {pythonfmu3-0.3.4.dist-info → pythonfmu3-1.0.0.dist-info}/licenses/LICENSE +0 -0
  33. {pythonfmu3-0.3.4.dist-info → pythonfmu3-1.0.0.dist-info}/top_level.txt +0 -0
pythonfmu3/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.3.4"
1
+ __version__ = "1.0.0"
pythonfmu3/builder.py CHANGED
@@ -1,15 +1,16 @@
1
1
  """Python FMU builder"""
2
2
  import argparse
3
- import importlib
3
+ import importlib.util
4
4
  import itertools
5
5
  import logging
6
6
  import re
7
7
  import shutil
8
8
  import sys
9
+ import sysconfig
9
10
  import tempfile
10
11
  import zipfile
11
12
  from pathlib import Path
12
- from typing import Iterable, Optional, Tuple, Union
13
+ from typing import Iterable, List, Optional, Tuple, Union
13
14
  from xml.dom.minidom import parseString
14
15
  from xml.etree.ElementTree import Element, SubElement, tostring
15
16
  from .osutil import get_lib_extension, get_platform
@@ -21,13 +22,49 @@ HERE = Path(__file__).parent
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
24
 
25
+ _SLAVE_CLASS_RE = re.compile(r'^class (\w+)\(([^)]*\bFmi3Slave(?:Base)?\b[^)]*)\)\s*:', re.MULTILINE)
26
+
27
+
24
28
  def get_class_name(file_name: Path) -> str:
25
- with open(str(file_name), 'r') as file:
26
- data = file.read()
27
- return re.search(r'class (\w+)\(([^)]*\bFmi3Slave(?:Base)?\b[^)]*)\)\s*:', data).group(1)
29
+ """Find the name of the first class in *file_name* that textually extends Fmi3Slave/Fmi3SlaveBase."""
30
+ data = Path(file_name).read_text(encoding="utf-8")
31
+ match = _SLAVE_CLASS_RE.search(data)
32
+ if match is None:
33
+ raise ValueError(f"Unable to find a class extending Fmi3Slave or Fmi3SlaveBase in {file_name!s}")
34
+ return match.group(1)
35
+
28
36
 
37
+ def _find_slave_class(module, filepath: Path) -> type:
38
+ """Locate the FMU class in an imported module.
29
39
 
30
- def get_model_description(filepath: Path, module_name: str) -> Tuple[str, Element]:
40
+ Classes defined in the module that inherit from Fmi3SlaveBase are candidates. If the
41
+ regex-based lookup (also used by the native wrapper as a fallback) matches one of them
42
+ it wins, so behaviour stays identical for existing scripts; otherwise a single
43
+ candidate is accepted (this handles multi-line base lists and indirect inheritance).
44
+ """
45
+ candidates = {
46
+ name: obj for name, obj in vars(module).items()
47
+ if isinstance(obj, type)
48
+ and obj.__module__ == module.__name__
49
+ and any(base.__name__ == Fmi3SlaveBase.__name__ for base in obj.__mro__)
50
+ }
51
+ try:
52
+ preferred = get_class_name(filepath)
53
+ except ValueError:
54
+ preferred = None
55
+ if preferred in candidates:
56
+ return candidates[preferred]
57
+ if len(candidates) == 1:
58
+ return next(iter(candidates.values()))
59
+ if not candidates:
60
+ raise ValueError(f"No class inheriting from {Fmi3SlaveBase.__qualname__} found in {filepath!s}")
61
+ raise ValueError(
62
+ f"Several FMU classes found in {filepath!s} ({', '.join(sorted(candidates))}); "
63
+ "keep a single Fmi3Slave subclass per script."
64
+ )
65
+
66
+
67
+ def get_model_description(filepath: Path, module_name: str) -> Tuple[str, Element, str]:
31
68
  """Extract the FMU model description as XML.
32
69
 
33
70
  Args:
@@ -35,7 +72,7 @@ def get_model_description(filepath: Path, module_name: str) -> Tuple[str, Elemen
35
72
  module_name (str) : python module to load
36
73
 
37
74
  Returns:
38
- Tuple[str, xml.etree.TreeElement.Element] : FMU model name, model description
75
+ Tuple[str, xml.etree.TreeElement.Element, str] : FMU model name, model description, class name
39
76
  """
40
77
  # Add current folder to handle local dependencies
41
78
  sys.path.insert(0, str(filepath.parent))
@@ -45,8 +82,9 @@ def get_model_description(filepath: Path, module_name: str) -> Tuple[str, Elemen
45
82
  fmu_interface = importlib.util.module_from_spec(spec)
46
83
  spec.loader.exec_module(fmu_interface)
47
84
  # Instantiate the interface
48
- class_name = get_class_name(filepath)
49
- instance = getattr(fmu_interface, class_name)(instance_name="dummyInstance", resources=str(filepath.parent))
85
+ slave_class = _find_slave_class(fmu_interface, filepath)
86
+ class_name = slave_class.__name__
87
+ instance = slave_class(instance_name="dummyInstance", resources=str(filepath.parent))
50
88
  finally:
51
89
  sys.path.remove(str(filepath.parent)) # remove inserted temporary path
52
90
 
@@ -55,7 +93,30 @@ def get_model_description(filepath: Path, module_name: str) -> Tuple[str, Elemen
55
93
  f"The provided class '{class_name}' does not inherit from {Fmi3SlaveBase.__qualname__}"
56
94
  )
57
95
  # Produce the xml
58
- return instance.modelName, instance.to_xml()
96
+ return instance.modelName, instance.to_xml(), class_name
97
+
98
+
99
+ def _is_fmu_entry_library(path: Path) -> bool:
100
+ """True for the library exporting the FMI functions (renamed to the model identifier)."""
101
+ return path.stem in ("pythonfmu-export", "libpythonfmu-export")
102
+
103
+
104
+ def _libpython_candidates() -> List[str]:
105
+ """Paths of the shared libpython of the running interpreter, if it has one."""
106
+ if sys.platform == "win32":
107
+ return [] # the Windows library links python3.dll directly
108
+ libdir = sysconfig.get_config_var("LIBDIR") or ""
109
+ framework_prefix = sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX") or ""
110
+ candidates = []
111
+ for name in (sysconfig.get_config_var("INSTSONAME"), sysconfig.get_config_var("LDLIBRARY")):
112
+ if not name or name.endswith(".a"):
113
+ continue # statically linked interpreter: nothing to point at
114
+ for base in (libdir, framework_prefix):
115
+ if base:
116
+ candidate = Path(base) / name
117
+ if candidate.is_file() and str(candidate) not in candidates:
118
+ candidates.append(str(candidate))
119
+ return candidates
59
120
 
60
121
 
61
122
  class FmuBuilder:
@@ -64,7 +125,7 @@ class FmuBuilder:
64
125
  def build_FMU(
65
126
  script_file: FilePath,
66
127
  dest: FilePath = ".",
67
- project_files: Iterable[FilePath] = set(),
128
+ project_files: Iterable[FilePath] = (),
68
129
  documentation_folder: Optional[FilePath] = None,
69
130
  terminals : Optional[FilePath] = None,
70
131
  **options,
@@ -130,7 +191,7 @@ class FmuBuilder:
130
191
  else:
131
192
  shutil.copy2(file_, temp_dir)
132
193
 
133
- model_identifier, xml = get_model_description(
194
+ model_identifier, xml, class_name = get_model_description(
134
195
  temp_dir.absolute() / script_file.name, module_name
135
196
  )
136
197
 
@@ -163,8 +224,20 @@ class FmuBuilder:
163
224
  relative_f = f.relative_to(temp_dir)
164
225
  zip_fmu.write(f, arcname=(resource / relative_f))
165
226
 
166
- # Add information for the Python loader
167
- zip_fmu.writestr(str(resource.joinpath("slavemodule.txt")), module_name)
227
+ # Hint for the Linux/macOS shim: where the Python that built this FMU
228
+ # keeps its shared library (used when the importer is not Python itself).
229
+ libpython_hints = _libpython_candidates()
230
+ if libpython_hints:
231
+ zip_fmu.writestr(
232
+ str(resource.joinpath("pythonfmu_libpython.txt")),
233
+ "\n".join(libpython_hints) + "\n",
234
+ )
235
+
236
+ # Add information for the Python loader: line 1 is the module, line 2 the class.
237
+ # Older wrappers only read the first line, so this stays backward compatible.
238
+ zip_fmu.writestr(
239
+ str(resource.joinpath("slavemodule.txt")), f"{module_name}\n{class_name}\n"
240
+ )
168
241
 
169
242
  # Add FMI API wrapping Python class source
170
243
  sources = Path("sources")
@@ -174,7 +247,7 @@ class FmuBuilder:
174
247
  ):
175
248
  relative_f = f.relative_to(src)
176
249
  if "build" in relative_f.parts:
177
- continue;
250
+ continue
178
251
  zip_fmu.write(f, arcname=(sources / relative_f))
179
252
 
180
253
  # Add FMI API wrapping Python class library
@@ -186,12 +259,13 @@ class FmuBuilder:
186
259
  src_binaries.rglob("*.dylib"),
187
260
  ):
188
261
  relative_f = f.relative_to(src_binaries)
189
- arcname = (
190
- binaries
191
- / relative_f.parent
192
- / f"{model_identifier}{relative_f.suffix}"
193
- )
194
- zip_fmu.write(f, arcname=arcname)
262
+ # Only the FMI entry-point library takes the model identifier's name;
263
+ # helper libraries (the Linux/macOS backend) keep their own name.
264
+ if _is_fmu_entry_library(f):
265
+ name = f"{model_identifier}{relative_f.suffix}"
266
+ else:
267
+ name = relative_f.name
268
+ zip_fmu.write(f, arcname=(binaries / relative_f.parent / name))
195
269
 
196
270
  # Add the documentation folder
197
271
  if documentation_folder is not None:
@@ -203,9 +277,7 @@ class FmuBuilder:
203
277
 
204
278
  if terminals is not None:
205
279
  terminalsFolder = Path("terminalsAndIcons")
206
- relative_f = terminals.relative_to(terminals.parent)
207
- zip_fmu.write(f, arcname=(terminalsFolder / relative_f))
208
-
280
+ zip_fmu.write(terminals, arcname=(terminalsFolder / terminals.name))
209
281
 
210
282
  # Add the model description
211
283
  xml_str = parseString(tostring(xml, "UTF-8"))
pythonfmu3/fmi3slave.py CHANGED
@@ -76,7 +76,7 @@ class Fmi3SlaveBase(object):
76
76
  self.type_definitions: Dict[str, VariableType] = {}
77
77
  self.units: Dict[str, Unit] = {}
78
78
 
79
- def to_xml(self, model_options: Dict[str, str] = dict()) -> Element:
79
+ def to_xml(self, model_options: Optional[Dict[str, Any]] = None) -> Element:
80
80
  """Build the XML representation of the model.
81
81
 
82
82
  Args:
@@ -110,20 +110,24 @@ class Fmi3SlaveBase(object):
110
110
 
111
111
  root = Element("fmiModelDescription", attrib)
112
112
 
113
+ model_options = model_options or {}
114
+
115
+ def _resolve(option: ModelOptions) -> str:
116
+ return str(model_options.get(option.name, option.value)).lower()
117
+
113
118
  options = dict()
114
119
  for option in FMI3_MODEL_OPTIONS_COMMON:
115
- value = model_options.get(option.name, option.value)
116
- options[option.name] = str(value).lower()
120
+ options[option.name] = _resolve(option)
117
121
 
118
122
  options["modelIdentifier"] = self.modelName
119
123
 
120
124
  options_cs = options.copy()
121
125
  for option in FMI3_MODEL_OPTIONS_COSIM:
122
- options_cs[option.name] = str(value).lower()
126
+ options_cs[option.name] = _resolve(option)
123
127
 
124
128
  options_me = options.copy()
125
129
  for option in FMI3_MODEL_OPTIONS_MX:
126
- options_me[option.name] = str(value).lower()
130
+ options_me[option.name] = _resolve(option)
127
131
 
128
132
  # check if we have cosim mixin or model exchange mixin
129
133
  if isinstance(self, ModelExchange):
@@ -175,9 +179,7 @@ class Fmi3SlaveBase(object):
175
179
  filter(lambda v: v.causality == Fmi3Causality.output, self.vars.values())
176
180
  )
177
181
 
178
- continuous_state_derivatives = list(
179
- filter(lambda v: v.variability == Fmi3Variability.continuous and (isinstance(v, Float64) and v.derivative is not None), self.vars.values())
180
- )
182
+ continuous_state_derivatives = self._continuous_state_derivatives()
181
183
 
182
184
  allowed_variability = [None, Fmi3Initial.approx, Fmi3Initial.calculated]
183
185
  initial_unknown = list(
@@ -200,6 +202,15 @@ class Fmi3SlaveBase(object):
200
202
 
201
203
  return root
202
204
 
205
+ def _continuous_state_derivatives(self) -> List[Float64]:
206
+ """Return the registered derivative variables (one per continuous state), in registration order."""
207
+ return [
208
+ v for v in self.vars.values()
209
+ if isinstance(v, Float64)
210
+ and v.variability == Fmi3Variability.continuous
211
+ and v.derivative is not None
212
+ ]
213
+
203
214
  def __apply_start_value(self, var: ModelVariable):
204
215
  vrs = [var.value_reference]
205
216
  if isinstance(var, Int32):
@@ -243,7 +254,7 @@ class Fmi3SlaveBase(object):
243
254
  if var.setter is None and hasattr(owner, var.local_name) and var.variability != Fmi3Variability.constant:
244
255
  if hasattr(var, "dimensions") and len(var.dimensions) > 0:
245
256
  import numpy as np
246
- var.setter = lambda v: setattr(owner, var.local_name, np.reshape(v, newshape=getattr(owner, var.local_name).shape))
257
+ var.setter = lambda v: setattr(owner, var.local_name, np.reshape(v, getattr(owner, var.local_name).shape))
247
258
  else:
248
259
  var.setter = lambda v: setattr(owner, var.local_name, v)
249
260
 
@@ -476,9 +487,7 @@ class Fmi3SlaveBase(object):
476
487
 
477
488
  def set_continuous_states(self, values: List[float]):
478
489
  offset = 0
479
- continuous_state_derivatives = list(
480
- filter(lambda v: v.variability == Fmi3Variability.continuous and (isinstance(v, Float64) and v.derivative is not None), self.vars.values())
481
- )
490
+ continuous_state_derivatives = self._continuous_state_derivatives()
482
491
 
483
492
  vrs = [v.derivative for v in continuous_state_derivatives]
484
493
 
@@ -492,10 +501,7 @@ class Fmi3SlaveBase(object):
492
501
  offset += size
493
502
 
494
503
  def get_continuous_states(self) -> List[float]:
495
- offset = 0
496
- continuous_state_derivatives = list(
497
- filter(lambda v: v.variability == Fmi3Variability.continuous and (isinstance(v, Float64) and v.derivative is not None), self.vars.values())
498
- )
504
+ continuous_state_derivatives = self._continuous_state_derivatives()
499
505
 
500
506
  vrs = [v.derivative for v in continuous_state_derivatives]
501
507
 
@@ -510,9 +516,7 @@ class Fmi3SlaveBase(object):
510
516
  return refs
511
517
 
512
518
  def get_number_of_continuous_states(self) -> int:
513
- continuous_state_derivatives = list(
514
- filter(lambda v: v.variability == Fmi3Variability.continuous and (isinstance(v, Float64) and v.derivative is not None), self.vars.values())
515
- )
519
+ continuous_state_derivatives = self._continuous_state_derivatives()
516
520
  return len(continuous_state_derivatives)
517
521
 
518
522
  def set_time(self, time: float):
@@ -3,13 +3,28 @@ from dataclasses import dataclass
3
3
 
4
4
  @dataclass
5
5
  class Fmi3UpdateDiscreteStatesResult:
6
- discreteStateNeedsUpdate: bool = False
6
+ """Return value of `update_discrete_states`, mirroring the outputs of fmi3UpdateDiscreteStates."""
7
+ discreteStatesNeedUpdate: bool = False
7
8
  terminateSimulation: bool = False
8
- nominalsOfContinuousStatesChanged: bool = False
9
+ nominalContinuousStatesChanged: bool = False
9
10
  valuesOfContinuousStatesChanged: bool = False
10
11
  nextEventTimeDefined: bool = False
11
12
  nextEventTime: float = 0.0
12
13
 
14
+
15
+ def _alias(field_name: str) -> property:
16
+ return property(
17
+ lambda self: getattr(self, field_name),
18
+ lambda self, value: setattr(self, field_name, value),
19
+ doc=f"Deprecated alias of `{field_name}`.",
20
+ )
21
+
22
+
23
+ # Earlier releases used these names, which the FMU wrapper never read (so the flags were
24
+ # silently ignored). Kept as aliases so existing models keep working - and now take effect.
25
+ Fmi3UpdateDiscreteStatesResult.discreteStateNeedsUpdate = _alias("discreteStatesNeedUpdate")
26
+ Fmi3UpdateDiscreteStatesResult.nominalsOfContinuousStatesChanged = _alias("nominalContinuousStatesChanged")
27
+
13
28
  class RequireTimeMeta(ABCMeta):
14
29
  def __init__(cls, name, bases, namespace):
15
30
  super().__init__(name, bases, namespace)
pythonfmu3/osutil.py CHANGED
@@ -1,14 +1,40 @@
1
+ import platform
1
2
  import sys
2
3
 
3
- import platform
4
+ # FMI 3.0 platform tuples are "<arch>-<sys>" (FMI 3.0 standard, section 2.5.1.4),
5
+ # e.g. x86_64-linux, aarch64-darwin, x86-windows.
6
+ _SYSTEMS = {"Windows": "windows", "Linux": "linux", "Darwin": "darwin"}
7
+
8
+ _ARCHITECTURES_64 = {
9
+ "x86_64": "x86_64", "amd64": "x86_64", "x64": "x86_64",
10
+ "i386": "x86_64", "i686": "x86_64", "x86": "x86_64", # 64-bit Python on x86 hardware
11
+ "aarch64": "aarch64", "arm64": "aarch64", "armv8l": "aarch64",
12
+ "ppc64": "ppc64", "ppc64le": "ppc64",
13
+ "riscv64": "riscv64",
14
+ }
15
+
16
+ _ARCHITECTURES_32 = {
17
+ "x86_64": "x86", "amd64": "x86", "x64": "x86", "i386": "x86", "i686": "x86", "x86": "x86",
18
+ "aarch64": "aarch32", "arm64": "aarch32", "armv7l": "aarch32", "armv8l": "aarch32", "arm": "aarch32",
19
+ "ppc": "ppc32",
20
+ "riscv32": "riscv32",
21
+ }
22
+
23
+
24
+ def _is_64bit() -> bool:
25
+ # Bitness of the running interpreter, which is what the FMU binary must match
26
+ # (e.g. a 32-bit Python on 64-bit Windows needs x86-windows).
27
+ return sys.maxsize > 2 ** 32
28
+
29
+
30
+ def get_fmu_arch() -> str:
31
+ """FMI 3.0 platform tuple of the running interpreter, e.g. 'aarch64-darwin'."""
32
+ machine = platform.machine().lower()
33
+ table = _ARCHITECTURES_64 if _is_64bit() else _ARCHITECTURES_32
34
+ arch = table.get(machine, machine or "unknown")
35
+ system = _SYSTEMS.get(platform.system(), platform.system().lower() or "unknown")
36
+ return f"{arch}-{system}"
4
37
 
5
- def get_fmu_arch():
6
- #is_64bits = sys.maxsize > 2 ** 32
7
- system = platform.system()
8
- is_x86 = platform.machine() in ["i386", "AMD64", "x86_64"]
9
- platforms = {"Windows": "windows", "Linux": "linux", "Darwin": "darwin"}
10
- arch = "x86_64" if is_x86 else "x86"
11
- return arch + "-" + platforms.get(system, "unknown")
12
38
 
13
39
  def get_platform() -> str:
14
40
  """Get FMU binary platform folder name."""
@@ -19,4 +45,3 @@ def get_lib_extension() -> str:
19
45
  """Get FMU library platform extension."""
20
46
  platforms = {"Darwin": "dylib", "Linux": "so", "Windows": "dll"}
21
47
  return platforms.get(platform.system(), "")
22
-
@@ -1,5 +1,5 @@
1
1
  cmake_minimum_required(VERSION 3.10)
2
- project(pythonfmu-export VERSION 0.2.0)
2
+ project(pythonfmu-export VERSION 1.0.0)
3
3
 
4
4
  # ==============================================================================
5
5
  # Build settings
@@ -48,12 +48,44 @@ else ()
48
48
  set(TARGET_PLATFORM linux)
49
49
  endif ()
50
50
 
51
- if ("${CMAKE_SIZEOF_VOID_P}" STREQUAL "8")
52
- set(TARGET_PLATFORM x86_64-${TARGET_PLATFORM})
51
+ # FMI 3.0 platform tuple <arch>-<sys>; must match pythonfmu3/osutil.py:get_fmu_arch().
52
+ # The architecture is the one being *built for*: the Visual Studio -A option, or a single
53
+ # CMAKE_OSX_ARCHITECTURES entry on macOS, take precedence over the host processor.
54
+ if (CMAKE_GENERATOR_PLATFORM)
55
+ set(_arch "${CMAKE_GENERATOR_PLATFORM}")
56
+ elseif (APPLE AND CMAKE_OSX_ARCHITECTURES)
57
+ list(LENGTH CMAKE_OSX_ARCHITECTURES _n_arch)
58
+ if (NOT _n_arch EQUAL 1)
59
+ message(FATAL_ERROR "Universal builds are not supported: set CMAKE_OSX_ARCHITECTURES to a single architecture")
60
+ endif ()
61
+ set(_arch "${CMAKE_OSX_ARCHITECTURES}")
62
+ else ()
63
+ set(_arch "${CMAKE_SYSTEM_PROCESSOR}")
64
+ endif ()
65
+ string(TOLOWER "${_arch}" _arch)
66
+
67
+ if (_arch MATCHES "^(aarch64|arm64|armv8)")
68
+ set(_fmi_arch aarch64)
69
+ elseif (_arch MATCHES "^arm")
70
+ set(_fmi_arch aarch32)
71
+ elseif (_arch MATCHES "^(x86_64|amd64|x64|i[3-6]86|x86|win32)$")
72
+ set(_fmi_arch x86)
53
73
  else ()
54
- set(TARGET_PLATFORM x86-${TARGET_PLATFORM})
74
+ set(_fmi_arch "${_arch}")
55
75
  endif ()
56
76
 
77
+ if ("${CMAKE_SIZEOF_VOID_P}" STREQUAL "8")
78
+ if (_fmi_arch STREQUAL "x86")
79
+ set(_fmi_arch x86_64)
80
+ elseif (_fmi_arch STREQUAL "aarch32")
81
+ set(_fmi_arch aarch64)
82
+ endif ()
83
+ elseif (_fmi_arch STREQUAL "aarch64")
84
+ set(_fmi_arch aarch32)
85
+ endif ()
86
+
87
+ set(TARGET_PLATFORM ${_fmi_arch}-${TARGET_PLATFORM})
88
+
57
89
  message("Building pythonfmu-export for platform ${TARGET_PLATFORM}")
58
90
 
59
91
  add_subdirectory(src)
@@ -0,0 +1,88 @@
1
+ """Generate src/shim/fmi3_forwarders.cpp from src/fmi/fmi3FunctionTypes.h.
2
+
3
+ The shim is the library the importer loads on Linux/macOS. It has no link-time
4
+ dependency on libpython: on instantiation it loads libpython (unless the host process
5
+ already provides it, e.g. when the importer is itself written in Python) and then the
6
+ real implementation (the "backend" library), and forwards every FMI call to it.
7
+
8
+ Re-run this script after updating the FMI headers:
9
+ python generate_shim.py
10
+ """
11
+ import re
12
+ from pathlib import Path
13
+
14
+ HERE = Path(__file__).parent
15
+ TYPES_HEADER = HERE / "src" / "fmi" / "fmi3FunctionTypes.h"
16
+ OUTPUT = HERE / "src" / "shim" / "fmi3_forwarders.cpp"
17
+
18
+ TYPEDEF_RE = re.compile(r"typedef\s+([\w\s\*]+?)\s*\b(fmi3\w+)TYPE\s*\((.*?)\)\s*;", re.S)
19
+ INSTANTIATE = {
20
+ "fmi3InstantiateModelExchange",
21
+ "fmi3InstantiateCoSimulation",
22
+ "fmi3InstantiateScheduledExecution",
23
+ }
24
+
25
+
26
+ def param_names(params: str):
27
+ params = " ".join(params.split())
28
+ if params in ("", "void"):
29
+ return []
30
+ return [re.findall(r"\w+", p.replace("[]", ""))[-1] for p in params.split(",")]
31
+
32
+
33
+ def main():
34
+ functions = [
35
+ (" ".join(ret.split()), name, " ".join(params.split()))
36
+ for ret, name, params in TYPEDEF_RE.findall(TYPES_HEADER.read_text())
37
+ ]
38
+ forwarded = [f for f in functions if f[1] != "fmi3GetVersion"]
39
+
40
+ out = [
41
+ "// GENERATED by generate_shim.py from fmi/fmi3FunctionTypes.h - do not edit by hand.",
42
+ '#include "shim/shim_loader.hpp"',
43
+ "",
44
+ "namespace pythonfmu_shim",
45
+ "{",
46
+ "const char* const kFunctionNames[] = {",
47
+ *[f' "{name}",' for _, name, _ in forwarded],
48
+ "};",
49
+ f"const std::size_t kFunctionCount = {len(forwarded)};",
50
+ "} // namespace pythonfmu_shim",
51
+ "",
52
+ 'extern "C" {',
53
+ "",
54
+ "FMI3_Export const char* fmi3GetVersion(void)",
55
+ "{",
56
+ " return fmi3Version;",
57
+ "}",
58
+ "",
59
+ ]
60
+ for index, (ret, name, params) in enumerate(forwarded):
61
+ names = param_names(params)
62
+ args = ", ".join(names)
63
+ out.append(f"FMI3_Export {ret} {name}({params})")
64
+ out.append("{")
65
+ if name in INSTANTIATE:
66
+ out.append(
67
+ " if (!pythonfmu_shim::ensureBackendLoaded(resourcePath, instanceEnvironment, logMessage)) return nullptr;"
68
+ )
69
+ out.append(f" const auto fn = reinterpret_cast<{name}TYPE*>(pythonfmu_shim::function({index}));")
70
+ if ret == "void":
71
+ out.append(f" if (fn) fn({args});")
72
+ elif ret == "fmi3Status":
73
+ out.append(" if (!fn) return fmi3Error;")
74
+ out.append(f" return fn({args});")
75
+ else:
76
+ out.append(" if (!fn) return nullptr;")
77
+ out.append(f" return fn({args});")
78
+ out.append("}")
79
+ out.append("")
80
+ out.append('} // extern "C"')
81
+ out.append("")
82
+ OUTPUT.parent.mkdir(exist_ok=True)
83
+ OUTPUT.write_text("\n".join(out))
84
+ print(f"wrote {OUTPUT.relative_to(HERE)} ({len(functions)} functions)")
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
@@ -1,4 +1,3 @@
1
-
2
1
  set(headers
3
2
  cppfmu/cppfmu_cs.hpp
4
3
  cppfmu/cppfmu_common.hpp
@@ -17,29 +16,58 @@ set(sources
17
16
  pythonfmu/PySlaveInstance.cpp
18
17
  )
19
18
 
20
- add_library(pythonfmu-export ${sources} ${headers})
21
- target_compile_features(pythonfmu-export PUBLIC "cxx_std_17")
19
+ set(BINARY_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/../resources/binaries/${TARGET_PLATFORM}")
22
20
 
23
- target_include_directories(pythonfmu-export
21
+ if (WIN32)
22
+ # Windows: a single library, linked against the version-independent python3.dll.
23
+ set(PYTHONFMU_IMPL_TARGET pythonfmu-export)
24
+ else ()
25
+ # Linux/macOS: the Python-dependent implementation lives in a "backend" library
26
+ # (FMI functions renamed with a prefix). The FMU library itself (pythonfmu-export,
27
+ # renamed to <modelIdentifier> by the builder) is a small shim with no link-time
28
+ # dependency on libpython, so non-Python importers can load it. See shim/shim_loader.cpp.
29
+ set(PYTHONFMU_IMPL_TARGET pythonfmu-backend)
30
+ endif ()
31
+
32
+ add_library(${PYTHONFMU_IMPL_TARGET} ${sources} ${headers})
33
+ target_compile_features(${PYTHONFMU_IMPL_TARGET} PUBLIC "cxx_std_17")
34
+ target_include_directories(${PYTHONFMU_IMPL_TARGET}
24
35
  PRIVATE
25
36
  "${Python3_INCLUDE_DIRS}"
26
37
  "${CMAKE_CURRENT_SOURCE_DIR}"
27
38
  )
28
39
 
29
40
  if (WIN32)
30
- target_link_libraries(pythonfmu-export PRIVATE ${Python3_LIBRARIES})
31
- elseif (APPLE)
32
- set_target_properties(pythonfmu-export PROPERTIES LINK_FLAGS "-undefined dynamic_lookup")
33
- endif ()
34
-
35
- if (WIN32)
36
- set_target_properties(pythonfmu-export
41
+ target_link_libraries(${PYTHONFMU_IMPL_TARGET} PRIVATE ${Python3_LIBRARIES})
42
+ set_target_properties(${PYTHONFMU_IMPL_TARGET}
37
43
  PROPERTIES
38
- RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_SOURCE_DIR}/../resources/binaries/${TARGET_PLATFORM}"
44
+ RUNTIME_OUTPUT_DIRECTORY_RELEASE "${BINARY_OUTPUT_DIR}"
39
45
  )
40
46
  else ()
47
+ # Python symbols are resolved at load time against the process (the host Python, or
48
+ # the libpython the shim opened with RTLD_GLOBAL).
49
+ target_compile_definitions(${PYTHONFMU_IMPL_TARGET} PRIVATE FMI3_FUNCTION_PREFIX=pythonfmu_backend_)
50
+ if (APPLE)
51
+ set_target_properties(${PYTHONFMU_IMPL_TARGET} PROPERTIES LINK_FLAGS "-undefined dynamic_lookup")
52
+ endif ()
53
+ set_target_properties(${PYTHONFMU_IMPL_TARGET}
54
+ PROPERTIES
55
+ LIBRARY_OUTPUT_DIRECTORY "${BINARY_OUTPUT_DIR}"
56
+ )
57
+
58
+ add_library(pythonfmu-export
59
+ shim/shim_loader.cpp
60
+ shim/shim_loader.hpp
61
+ shim/fmi3_forwarders.cpp
62
+ )
63
+ target_compile_features(pythonfmu-export PUBLIC "cxx_std_17")
64
+ target_include_directories(pythonfmu-export PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
65
+ target_link_libraries(pythonfmu-export PRIVATE ${CMAKE_DL_LIBS})
41
66
  set_target_properties(pythonfmu-export
42
67
  PROPERTIES
43
- LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/../resources/binaries/${TARGET_PLATFORM}"
68
+ LIBRARY_OUTPUT_DIRECTORY "${BINARY_OUTPUT_DIR}"
69
+ # Only the FMI functions (marked FMI3_Export) are exported
70
+ CXX_VISIBILITY_PRESET hidden
71
+ VISIBILITY_INLINES_HIDDEN ON
44
72
  )
45
73
  endif ()
@@ -121,6 +121,9 @@ public:
121
121
  FMIString message,
122
122
  Args&&... args) CPPFMU_NOEXCEPT
123
123
  {
124
+ if (m_fmiLogger == nullptr) {
125
+ return; // FMI 3.0 allows instantiating without a logger callback
126
+ }
124
127
  if (m_settings->loggedCategories.empty() ||
125
128
  detail::CanFind(m_settings->loggedCategories, category)) {
126
129
  m_fmiLogger(
@@ -95,7 +95,8 @@ fmi3Instance fmi3InstantiateCoSimulation(
95
95
  fmi3IntermediateUpdateCallback intermediateUpdate )
96
96
  {
97
97
  try {
98
- auto component = new Component(
98
+ // Owned until fully constructed, so a failing instantiation does not leak it.
99
+ auto component = std::make_unique<Component>(
99
100
  environment,
100
101
  logMessage,
101
102
  loggingOn);
@@ -108,7 +109,7 @@ fmi3Instance fmi3InstantiateCoSimulation(
108
109
  visible,
109
110
  cppfmu::FMIFalse,
110
111
  component->logger);
111
- return component;
112
+ return component.release();
112
113
  } catch (const cppfmu::FatalError& e) {
113
114
  if (logMessage)
114
115
  {
@@ -134,7 +135,8 @@ fmi3Instance fmi3InstantiateModelExchange(
134
135
  fmi3LogMessageCallback logMessage)
135
136
  {
136
137
  try {
137
- auto component = new Component(
138
+ // Owned until fully constructed, so a failing instantiation does not leak it.
139
+ auto component = std::make_unique<Component>(
138
140
  environment,
139
141
  logMessage,
140
142
  loggingOn);
@@ -147,7 +149,7 @@ fmi3Instance fmi3InstantiateModelExchange(
147
149
  visible,
148
150
  cppfmu::FMIFalse,
149
151
  component->logger);
150
- return component;
152
+ return component.release();
151
153
  } catch (const cppfmu::FatalError& e) {
152
154
  if (logMessage)
153
155
  {