diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4ef6cca0..9469b38e 100755
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,19 +1,31 @@
+name: Smoke build
+
on:
push:
pull_request:
+ workflow_dispatch:
permissions:
contents: read
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
linux:
+ name: Linux smoke (Py3.11 in Docker)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- - uses: actions/checkout@v4
- - name: Install make
- run: sudo apt install make -y
- - name: Build
- run: make build-dev-image && make build
- - name: Test
- run: make test
+ - uses: actions/checkout@v4
+ - name: Install make
+ run: sudo apt install make -y
+ - name: Build dev image
+ run: make build-dev-image
+ - name: Build PyFMI
+ run: make build
+ - name: Run tests
+ run: make test
+# Multi-Python coverage lives in wheels.yml, which exercises every supported
+# CPython against its built wheel.
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
new file mode 100644
index 00000000..5f29f052
--- /dev/null
+++ b/.github/workflows/wheels.yml
@@ -0,0 +1,128 @@
+name: Build wheels
+
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+ inputs:
+ publish:
+ description: "Publish wheels to PyPI after building"
+ type: boolean
+ default: false
+ test_release:
+ description: "Publish under the pyfmi-testing PyPI name instead of PyFMI"
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+jobs:
+ linux:
+ name: Linux wheels
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Rewrite package name to pyfmi-testing
+ if: github.event_name == 'workflow_dispatch' && inputs.publish && inputs.test_release
+ run: |
+ sed -i 's/^name = "PyFMI"$/name = "pyfmi-testing"/' pyproject.toml
+ sed -i "s/^ 'PyFMI',$/ 'pyfmi-testing',/" meson.build
+ grep '^name = ' pyproject.toml
+ grep -n "pyfmi-testing" meson.build
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - run: pip install cibuildwheel
+
+ - name: Build manylinux image (FMIL pre-installed)
+ run: docker build -f Dockerfile.manylinux -t pyfmi-manylinux .
+
+ - name: Build wheels
+ run: cibuildwheel --platform linux --output-dir dist/
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: wheels-linux
+ path: dist/*.whl
+
+ windows:
+ name: Windows wheels
+ runs-on: windows-2022
+ timeout-minutes: 90
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Rewrite package name to pyfmi-testing
+ if: github.event_name == 'workflow_dispatch' && inputs.publish && inputs.test_release
+ shell: bash
+ run: |
+ sed -i 's/^name = "PyFMI"$/name = "pyfmi-testing"/' pyproject.toml
+ sed -i "s/^ 'PyFMI',$/ 'pyfmi-testing',/" meson.build
+ grep '^name = ' pyproject.toml
+ grep -n "pyfmi-testing" meson.build
+
+ - name: Set up MSYS2 UCRT64
+ uses: msys2/setup-msys2@v2
+ with:
+ msystem: UCRT64
+ update: true
+ install: >-
+ git
+ mingw-w64-ucrt-x86_64-gcc
+ mingw-w64-ucrt-x86_64-cmake
+ mingw-w64-ucrt-x86_64-ninja
+ mingw-w64-ucrt-x86_64-pkg-config
+
+ - name: Build FMI Library
+ shell: msys2 {0}
+ run: bash tools/wheels/build_dependencies_windows.sh
+
+ # cibuildwheel spawns a fresh pwsh; the MSYS2 setup-action's PATH and
+ # compiler vars are scoped to its own shell and need re-exporting here.
+ - name: Configure compiler environment
+ shell: pwsh
+ run: |
+ echo "C:\msys64\ucrt64\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
+ echo "C:\deps\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
+ echo "CC=gcc" | Out-File -FilePath $env:GITHUB_ENV -Append
+ echo "CXX=g++" | Out-File -FilePath $env:GITHUB_ENV -Append
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - run: pip install cibuildwheel
+ shell: pwsh
+
+ - name: Build wheels
+ shell: pwsh
+ run: cibuildwheel --platform windows --output-dir dist/
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: wheels-windows
+ path: dist/*.whl
+
+ upload_pypi:
+ name: Upload to PyPI
+ needs: [linux, windows]
+ runs-on: ubuntu-latest
+ if: github.event_name == 'workflow_dispatch' && inputs.publish && startsWith(github.ref, 'refs/tags/v')
+ environment:
+ name: pypi
+ url: https://pypi.org/p/PyFMI
+ permissions:
+ id-token: write # required for OIDC Trusted Publisher
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: wheels-*
+ path: dist/
+ merge-multiple: true
+
+ - uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/Dockerfile b/Dockerfile
index cb3277bf..2ef8c5db 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,49 +2,19 @@ FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
-# Install Python
-RUN apt update && apt install software-properties-common -y && add-apt-repository ppa:deadsnakes/ppa
-RUN apt update && apt install python3.11 python3-pip python3.11-dev python3.11-venv -y
+RUN apt-get update && apt-get install -y software-properties-common && \
+ add-apt-repository ppa:deadsnakes/ppa && \
+ apt-get update && apt-get install -y \
+ python3.11 python3.11-dev python3.11-venv python3-pip \
+ build-essential cmake curl git vim bash-completion \
+ libgfortran5 && \
+ rm -rf /var/lib/apt/lists/*
+
+# Build FMIL from the same script the wheel pipeline uses, so the dev image
+# and the published wheels link against an identical FMIL build.
+COPY tools/wheels/build_dependencies.sh /tmp/build_dependencies.sh
+RUN bash /tmp/build_dependencies.sh && rm /tmp/build_dependencies.sh
-# Install system
-RUN python3.11 -m pip install Cython numpy scipy matplotlib setuptools==69.1.0
-RUN apt-get -y install cmake liblapack-dev libsuitesparse-dev libhypre-dev curl git make vim bash-completion
-RUN cp -v /usr/lib/x86_64-linux-gnu/libblas.so /usr/lib/x86_64-linux-gnu/libblas_OPENMP.so
-
-# Install superlu
-RUN cd /tmp && \
- curl -fSsL https://github.com/xiaoyeli/superlu_mt/archive/refs/tags/v4.0.1.tar.gz | tar xz && \
- cd superlu_mt-4.0.1 && \
- cmake -Denable_examples=OFF -Denable_tests=OFF -DPLAT="_OPENMP" -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_INSTALL_LIBDIR=lib -DSUPERLUMT_INSTALL_INCLUDEDIR=include . && \
- make -j4 && \
- make install
-
-# Install sundials
-RUN git clone --depth 1 -b v2.7.0 https://github.com/LLNL/sundials /tmp/sundials && \
- cd /tmp/sundials && \
- echo "target_link_libraries(sundials_idas_shared lapack blas superlu_mt_OPENMP)" >> src/idas/CMakeLists.txt && \
- echo "target_link_libraries(sundials_kinsol_shared lapack blas superlu_mt_OPENMP)" >> src/kinsol/CMakeLists.txt && \
- cmake -LAH -DSUPERLUMT_BLAS_LIBRARIES=blas -DSUPERLUMT_INCLUDE_DIR=/usr/include -DSUPERLUMT_LIBRARY=/usr/lib/libsuperlu_mt_OPENMP.a -DSUPERLUMT_THREAD_TYPE=OpenMP -DCMAKE_INSTALL_PREFIX=/usr -DSUPERLUMT_ENABLE=ON -DLAPACK_ENABLE=ON -DEXAMPLES_ENABLE=OFF -B build . && \
- cd build && \
- make -j4 && \
- make install
-
-#Install assimulo
-RUN git clone --depth 1 -b Assimulo-3.8.0 https://github.com/modelon-community/Assimulo /tmp/Assimulo && \
- cd /tmp/Assimulo && \
- rm setup.cfg && \
- python3.11 setup.py install --user --sundials-home=/usr --blas-home=/usr/lib/x86_64-linux-gnu/ --lapack-home=/usr/lib/x86_64-linux-gnu/ --superlu-home=/usr
-
-# Install fmilib
-RUN cd /tmp && \
- curl -fSsL https://github.com/modelon-community/fmi-library/archive/3.0.4.tar.gz | tar xz && \
- cd fmi-library-3.0.4 && \
- cmake -DCMAKE_INSTALL_PREFIX=/usr -DFMILIB_BUILD_TESTS=OFF -B build . && \
- cd build && \
- make -j4 && \
- make install
-
-# Setup a venv to put pip and python on path for convinience running commands
ARG PYTHON_VENV=/src/.venv
ENV PATH=${PYTHON_VENV}/bin:$PATH
WORKDIR /src
diff --git a/Dockerfile.manylinux b/Dockerfile.manylinux
new file mode 100644
index 00000000..0e9bd15d
--- /dev/null
+++ b/Dockerfile.manylinux
@@ -0,0 +1,9 @@
+FROM quay.io/pypa/manylinux_2_28_x86_64
+
+# Pre-bake FMIL so cibuildwheel can skip before-all and so local
+# `cibuildwheel --platform linux` runs reproduce the CI environment exactly.
+COPY tools/wheels/build_dependencies.sh /tmp/build_dependencies.sh
+RUN bash /tmp/build_dependencies.sh && rm /tmp/build_dependencies.sh
+
+WORKDIR /src
+CMD ["/bin/bash"]
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index 8aaaf77e..00000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,5 +0,0 @@
-include CHANGELOG
-include LICENSE
-include src/pyfmi/*.pyx
-include src/pyfmi/*.pxd
-exclude src/pyfmi/*.dll
diff --git a/Makefile b/Makefile
index c5c2ef76..0d22abdf 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,10 @@
-.PHONY: build build-dev-image test shell
-DOCKER_IMAGE := pyfmi-dev
-IN_DOCKER_IMG := $(shell test -f /.dockerenv && echo 1 || echo 0)
-SETUPTOOLS_JFLAG=-j$(shell nproc)
+.PHONY: build build-dev-image build-manylinux-image test shell compile-deps
+DOCKER_IMAGE := pyfmi-dev
+MANYLINUX_IMAGE := pyfmi-manylinux
+IN_DOCKER_IMG := $(shell test -f /.dockerenv && echo 1 || echo 0)
+
+MESON_SETUP_ARGS := -Dfmil_prefix=/usr
+PIP_SETUP_ARGS := $(addprefix -Csetup-args=,$(MESON_SETUP_ARGS))
define _run
@if [ $(IN_DOCKER_IMG) -eq 1 ]; then \
@@ -15,18 +18,33 @@ define _run
fi
endef
+define _run_with_venv
+ $(call _run, bash -c '. .venv/bin/activate && $(1)')
+endef
+
build-dev-image:
docker build -t ${DOCKER_IMAGE} .
-.venv:
- $(call _run, python3.11 -m venv .venv --system-site-packages)
- $(call _run, pip install pytest)
+build-manylinux-image:
+ docker build -f Dockerfile.manylinux -t ${MANYLINUX_IMAGE} .
+
+.venv: requirements.lock
+ $(call _run, python3.11 -m venv .venv)
+ $(call _run_with_venv, pip install -r requirements.lock)
+ $(call _run, touch .venv)
build: .venv
- $(call _run, python setup.py build_ext ${SETUPTOOLS_JFLAG} install --fmil-home=/usr)
+ $(call _run_with_venv, pip install . -v $(PIP_SETUP_ARGS))
test: build
- $(call _run, pytest)
+ $(call _run_with_venv, pytest tests/)
shell:
$(call _run, /bin/bash,-it)
+
+# Regenerate requirements.lock from pyproject.toml. Run after changing
+# build-system requires or runtime dependencies; commit the resulting file.
+compile-deps:
+ $(call _run, python3.11 -m venv .venv)
+ $(call _run_with_venv, pip install pip-tools)
+ $(call _run_with_venv, pip-compile --extra=dev --output-file=requirements.lock pyproject.toml)
diff --git a/meson.build b/meson.build
new file mode 100644
index 00000000..02364737
--- /dev/null
+++ b/meson.build
@@ -0,0 +1,156 @@
+project(
+ 'PyFMI',
+ ['c', 'cython'],
+ version: '3.0.0.dev0',
+ meson_version: '>=1.4'
+)
+
+py = import('python').find_installation(pure: false)
+fs = import('fs')
+cc = meson.get_compiler('c')
+
+numpy_inc = run_command(
+ py, ['-c', 'import numpy; print(numpy.get_include())'],
+ check: true
+).stdout().strip()
+
+# Parent of the assimulo package so Cython resolves `cimport assimulo.problem`.
+assimulo_pkg_parent = run_command(
+ py, ['-c', 'import assimulo, os; print(os.path.dirname(os.path.dirname(assimulo.__file__)))'],
+ check: true
+).stdout().strip()
+
+# numpy_inc is passed via -I rather than include_directories() because under
+# pip build isolation it lives outside the source tree, which meson rejects
+# for include_directories().
+if cc.get_argument_syntax() == 'msvc'
+ common_c_args = [
+ '-I' + numpy_inc,
+ '-DNPY_NO_DEPRECATED_API=NPY_1_7_API_VERSION',
+ '/O2',
+ ]
+else
+ common_c_args = [
+ '-I' + numpy_inc,
+ '-DNPY_NO_DEPRECATED_API=NPY_1_7_API_VERSION',
+ '-fno-strict-aliasing',
+ '-O2',
+ ]
+endif
+
+fmil_prefix = get_option('fmil_prefix')
+if fmil_prefix == ''
+ fmil_prefix = run_command(
+ py, ['-c', 'import os, sys; sys.stdout.write(os.environ.get("FMIL_HOME", ""))'],
+ check: true
+ ).stdout().strip()
+endif
+if fmil_prefix == ''
+ error('FMIL not found. Set -Dfmil_prefix=/path/to/fmil or env FMIL_HOME.')
+endif
+
+fmil_incdir = join_paths(fmil_prefix, 'include')
+if not fs.is_dir(fmil_incdir)
+ error('FMIL include directory not found at @0@'.format(fmil_incdir))
+endif
+
+fmil_libdirs = []
+foreach d : ['lib', 'lib64', 'bin']
+ p = join_paths(fmil_prefix, d)
+ if fs.is_dir(p)
+ fmil_libdirs += p
+ endif
+endforeach
+if fmil_libdirs.length() == 0
+ error('No FMIL lib directory found under @0@'.format(fmil_prefix))
+endif
+
+fmil_name = get_option('fmil_name')
+fmil_lib = cc.find_library(fmil_name, dirs: fmil_libdirs, required: true)
+
+cython_include_dirs = [
+ '-I', meson.current_source_dir(),
+ '-I', join_paths(meson.current_source_dir(), 'src'),
+ '-I', join_paths(meson.current_source_dir(), 'src/pyfmi'),
+ '-I', assimulo_pkg_parent,
+]
+cython_common_args = ['-3', '-X', 'language_level=3str'] + cython_include_dirs
+
+# $ORIGIN lets installed wheels find the bundled libfmilib_shared.so that
+# auditwheel/delvewheel place next to the extension. The configured libdir is
+# kept as a fallback so editable/source installs against a system FMIL also
+# resolve at runtime.
+ext_install_rpath = ''
+if host_machine.system() != 'windows'
+ ext_install_rpath = '$ORIGIN:' + fmil_libdirs[0]
+endif
+
+with_openmp = get_option('openmp')
+openmp_c_args = with_openmp ? ['-fopenmp'] : []
+openmp_link_args = with_openmp ? ['-fopenmp'] : []
+
+ext_inc = [
+ include_directories('src', 'src/pyfmi'),
+ include_directories(fmil_incdir),
+]
+
+plain_modules = [
+ { 'name': 'fmi_base', 'subdir': 'pyfmi', 'src': 'src/pyfmi/fmi_base.pyx' },
+ { 'name': 'fmi', 'subdir': 'pyfmi', 'src': 'src/pyfmi/fmi.pyx' },
+ { 'name': 'fmi1', 'subdir': 'pyfmi', 'src': 'src/pyfmi/fmi1.pyx' },
+ { 'name': 'fmi2', 'subdir': 'pyfmi', 'src': 'src/pyfmi/fmi2.pyx' },
+ { 'name': 'fmi3', 'subdir': 'pyfmi', 'src': 'src/pyfmi/fmi3.pyx' },
+ { 'name': 'fmi_util', 'subdir': 'pyfmi', 'src': 'src/pyfmi/fmi_util.pyx' },
+ { 'name': 'fmi_extended', 'subdir': 'pyfmi', 'src': 'src/pyfmi/fmi_extended.pyx' },
+ { 'name': 'fmi_coupled', 'subdir': 'pyfmi', 'src': 'src/pyfmi/fmi_coupled.pyx' },
+ { 'name': 'util', 'subdir': 'pyfmi', 'src': 'src/pyfmi/util.pyx' },
+ { 'name': 'test_util', 'subdir': 'pyfmi', 'src': 'src/pyfmi/test_util.pyx' },
+ { 'name': 'assimulo_interface', 'subdir': 'pyfmi/simulation', 'src': 'src/pyfmi/simulation/assimulo_interface.pyx' },
+ { 'name': 'assimulo_interface_fmi1', 'subdir': 'pyfmi/simulation', 'src': 'src/pyfmi/simulation/assimulo_interface_fmi1.pyx' },
+ { 'name': 'assimulo_interface_fmi2', 'subdir': 'pyfmi/simulation', 'src': 'src/pyfmi/simulation/assimulo_interface_fmi2.pyx' },
+ { 'name': 'assimulo_interface_fmi3', 'subdir': 'pyfmi/simulation', 'src': 'src/pyfmi/simulation/assimulo_interface_fmi3.pyx' },
+]
+
+foreach m : plain_modules
+ py.extension_module(
+ m['name'],
+ sources: [m['src']],
+ subdir: m['subdir'],
+ include_directories: ext_inc,
+ c_args: common_c_args,
+ cython_args: cython_common_args,
+ dependencies: [fmil_lib],
+ install_rpath: ext_install_rpath,
+ build_rpath: ext_install_rpath,
+ install: true,
+ )
+endforeach
+
+# master.pyx branches on WITH_OPENMP at Cython compile time; the C compiler
+# also needs -fopenmp when openmp is enabled (added below via openmp_c_args).
+master_cython_args = cython_common_args + [
+ '-E', 'WITH_OPENMP=' + (with_openmp ? 'True' : 'False'),
+]
+
+py.extension_module(
+ 'master',
+ sources: ['src/pyfmi/master.pyx'],
+ subdir: 'pyfmi',
+ include_directories: ext_inc,
+ c_args: common_c_args + openmp_c_args,
+ cython_args: master_cython_args,
+ dependencies: [fmil_lib],
+ link_args: openmp_link_args,
+ install_rpath: ext_install_rpath,
+ build_rpath: ext_install_rpath,
+ install: true,
+)
+
+install_subdir('src/pyfmi', install_dir: py.get_install_dir())
+
+# LGPL requires the license to ship with the binary; CHANGELOG goes alongside
+# so users can identify the bundled version at runtime.
+install_data(
+ ['LICENSE', 'CHANGELOG'],
+ install_dir: py.get_install_dir() / 'pyfmi',
+)
diff --git a/meson.options b/meson.options
new file mode 100644
index 00000000..e5bb152e
--- /dev/null
+++ b/meson.options
@@ -0,0 +1,20 @@
+option(
+ 'fmil_prefix',
+ type: 'string',
+ value: '',
+ description: 'Path to FMI Library install prefix (containing include/, lib/, bin/). Falls back to env FMIL_HOME.',
+)
+
+option(
+ 'fmil_name',
+ type: 'string',
+ value: 'fmilib_shared',
+ description: 'FMIL library name to link against (default: fmilib_shared; use fmilib for static).',
+)
+
+option(
+ 'openmp',
+ type: 'boolean',
+ value: false,
+ description: 'Enable OpenMP parallelization in master.pyx.',
+)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..0c823002
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,65 @@
+[build-system]
+requires = [
+ "meson-python>=0.16",
+ "meson>=1.4",
+ "ninja",
+ "Cython>=3.0.7",
+ "numpy>=2.1",
+ "assimulo-testing>=3.9.0b1",
+]
+build-backend = "mesonpy"
+
+[project]
+name = "PyFMI"
+version = "3.0.0.dev0"
+description = "A package for working with dynamic models compliant with the Functional Mock-Up Interface standard."
+readme = "README.md"
+license = { text = "LGPL-3.0-only" }
+authors = [{ name = "Modelon AB" }]
+requires-python = ">=3.11"
+classifiers = [
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Operating System :: Microsoft :: Windows",
+ "Operating System :: Unix",
+ "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
+]
+
+dependencies = [
+ "numpy>=2.1",
+ "scipy>=1.14.0",
+ "matplotlib>=3.0",
+ "assimulo-testing>=3.9.0b1",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.4.4",
+ "meson>=1.4",
+ "meson-python>=0.16",
+ "ninja",
+ "Cython>=3.0.7",
+]
+
+[project.urls]
+Homepage = "https://github.com/modelon-community/PyFMI"
+
+[tool.cibuildwheel]
+build = "cp311-* cp312-* cp313-*"
+skip = "*-win32 *-manylinux_i686 *-musllinux*"
+
+[tool.cibuildwheel.linux]
+# FMIL is pre-installed into /usr inside the pyfmi-manylinux image; build it
+# locally with `make build-manylinux-image` before invoking cibuildwheel.
+manylinux-x86_64-image = "pyfmi-manylinux"
+repair-wheel-command = "auditwheel repair -w {dest_dir} {wheel}"
+config-settings = {"setup-args" = ["-Dfmil_prefix=/usr"]}
+
+[tool.cibuildwheel.windows]
+# FMIL is built into C:/deps by the workflow's MSYS2 UCRT64 step before
+# cibuildwheel runs. UCRT64 is on PATH so meson selects gcc over MSVC.
+before-build = "pip install delvewheel"
+repair-wheel-command = "pwsh {project}/tools/wheels/repair_windows.ps1 {wheel} {dest_dir}"
+config-settings = {"setup-args" = ["-Dfmil_prefix=C:/deps"]}
diff --git a/requirements.lock b/requirements.lock
new file mode 100644
index 00000000..c5ca4b73
--- /dev/null
+++ b/requirements.lock
@@ -0,0 +1,65 @@
+#
+# This file is autogenerated by pip-compile with Python 3.11
+# by the following command:
+#
+# pip-compile --extra=dev --output-file=requirements.lock pyproject.toml
+#
+assimulo-testing==3.9.0b1
+ # via PyFMI (pyproject.toml)
+contourpy==1.3.3
+ # via matplotlib
+cycler==0.12.1
+ # via matplotlib
+cython==3.2.4
+ # via PyFMI (pyproject.toml)
+fonttools==4.63.0
+ # via matplotlib
+iniconfig==2.3.0
+ # via pytest
+kiwisolver==1.5.0
+ # via matplotlib
+matplotlib==3.10.9
+ # via
+ # PyFMI (pyproject.toml)
+ # assimulo-testing
+meson==1.11.1
+ # via
+ # PyFMI (pyproject.toml)
+ # meson-python
+meson-python==0.19.0
+ # via PyFMI (pyproject.toml)
+ninja==1.13.0
+ # via PyFMI (pyproject.toml)
+numpy==2.4.5
+ # via
+ # PyFMI (pyproject.toml)
+ # assimulo-testing
+ # contourpy
+ # matplotlib
+ # scipy
+packaging==26.2
+ # via
+ # matplotlib
+ # meson-python
+ # pyproject-metadata
+ # pytest
+pillow==12.2.0
+ # via matplotlib
+pluggy==1.6.0
+ # via pytest
+pygments==2.20.0
+ # via pytest
+pyparsing==3.3.2
+ # via matplotlib
+pyproject-metadata==0.11.0
+ # via meson-python
+pytest==9.0.3
+ # via PyFMI (pyproject.toml)
+python-dateutil==2.9.0.post0
+ # via matplotlib
+scipy==1.17.1
+ # via
+ # PyFMI (pyproject.toml)
+ # assimulo-testing
+six==1.17.0
+ # via python-dateutil
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 932c5463..00000000
--- a/setup.cfg
+++ /dev/null
@@ -1,17 +0,0 @@
-[options]
-setup_requires =
- setuptools
- numpy >= 1.19.5
- cython >= 3.0
-
-python_requires = >=3.11
-
-install_requires =
- numpy >= 1.19.5
- scipy >= 1.10.1
- cython >= 3.0.7
- matplotlib > 3
- assimulo >= 3.5.0
-
-tests_require =
- pytest >= 7.4.4
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 58e6c2c7..00000000
--- a/setup.py
+++ /dev/null
@@ -1,465 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# Copyright (C) 2014-2025 Modelon AB
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Lesser General Public License as published by
-# the Free Software Foundation, version 3 of the License.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public License
-# along with this program. If not, see .
-
-import os
-import shutil
-import sysconfig
-import numpy as np
-import ctypes.util
-import sys
-from itertools import chain
-
-
-try:
- from numpy.distutils.core import setup
- have_nd = True
-except ImportError:
- from setuptools import setup
- have_nd = False
-
-from Cython.Build import cythonize
-
-
-NAME = "PyFMI"
-AUTHOR = "Modelon AB"
-AUTHOR_EMAIL = ""
-VERSION = "3.0-dev"
-LICENSE = "LGPL"
-URL = "https://github.com/modelon-community/PyFMI"
-DOWNLOAD_URL = "https://github.com/modelon-community/PyFMI/releases"
-DESCRIPTION = "A package for working with dynamic models compliant with the Functional Mock-Up Interface standard."
-PLATFORMS = ["Linux", "Windows", "MacOS X"]
-CLASSIFIERS = [ 'Programming Language :: Python',
- 'Operating System :: MacOS :: MacOS X',
- 'Operating System :: Microsoft :: Windows',
- 'Operating System :: Unix']
-
-LONG_DESCRIPTION = """
-PyFMI is a package for loading and interacting with Functional Mock-Up
-Units (FMUs), which are compiled dynamic models compliant with the
-Functional Mock-Up Interface (FMI), see
-https://www.fmi-standard.org/ for more information. PyFMI
-is based on FMI Library, see https://github.com/modelon-community/fmi-library .
-
-FMI is a standard that enables tool independent exchange of dynamic
-models on binary format. Several industrial simulation platforms
-supports export of FMUs, including, Impact, Dymola, OpenModelica
-and SimulationX, see https://www.fmi-standard.org/tools
-for a complete list. PyFMI offers a Python interface for interacting
-with FMUs and enables for example loading of FMU models, setting of
-model parameters and evaluation of model equations.
-
-Using PyFMI together with the Python
-simulation package `Assimulo `_
-adds industrial grade simulation capabilities of FMUs to Python.
-
-Requirements:
--------------
-- `FMI Library (at least 2.0.1) `_
-- `Python-headers (usually included on Windows, python-dev on Ubuntu)`_
-- `Python 3.11 or newer`_
-- Python package dependencies are listed in file setup.cfg.
-
-Optional
----------
-- `wxPython `_ For the Plot GUI.
-- `matplotlib `_ For the Plot GUI.
-
-Source Installation (note that assimulo needs to be installed and on PYTHONPATH in order to install pyfmi):
-----------------------
-
-python setup.py install --fmil-home=/path/to/FMI_Library/
-
-
-Dynamic FMI Library Handling in PyFMI Build Process
-===================================================
-
-PyFMI depends on the FMI Library (FMIL) for core functionality. Users can choose
-between static or dynamic linking via the --fmil-name build argument (default: 'fmilib_shared').
-
-When building with dynamic FMIL, the behavior varies by platform and build type:
-
-Platform-Specific Dynamic Library Handling:
--------------------------------------------
-
-**Windows Builds:**
-- Dynamic FMIL library (.dll) is automatically copied into the PyFMI package directory
-- Creates a self-contained installation with no external library dependencies
-- End users don't need separate FMIL installation or PATH configuration
-
-**Linux Standard Installation:**
-- PyFMI extensions are linked with RPATH pointing to original FMIL location
-- Dynamic library remains in its system installation directory
-- Requires FMIL to remain accessible at the configured path during runtime
-- Suitable for system-wide installations with centralized FMIL management
-- Smaller PyFMI package size due to external library reference
-
-**Linux Wheel Builds:**
-- Dynamic FMIL library is copied into the PyFMI package (similar to Windows)
-- RPATH set to '$ORIGIN' for relative library loading
-- Creates portable, self-contained wheels for distribution
-- No external FMIL dependency required at runtime
-
-**Static Library Alternative:**
-- When using static FMIL (--fmil-name without 'shared' suffix):
-- Library code is embedded directly into PyFMI extensions
-- Larger package size but completely self-contained
-- No runtime library dependencies
-
-Build Process Flow:
-------------------
-1. Locate FMIL library in specified directories (lib/, lib64/, bin/)
-2. For dynamic libraries on Windows or wheel builds: copy to package directory
-3. Configure appropriate RPATH settings for Linux installations
-4. Build Cython extensions with proper library linking
-5. Clean up temporary library copies after successful build
-
-This approach ensures PyFMI works correctly across different deployment scenarios
-while optimizing for each platform's conventions and user expectations.
-"""
-
-copy_args = sys.argv[1:]
-
-fmil_home = os.getenv("FMIL_HOME")
-if fmil_home: #Check for environment variable that specifies FMIL
- incdirs = [os.path.join(fmil_home, 'include')]
- # Specify both lib64 and 64, since can it depend on the platform (rockylinux/ubuntu etc)
- libdirs = [os.path.join(fmil_home, 'lib64'), os.path.join(fmil_home, 'lib')]
- bindirs = [os.path.join(fmil_home, 'bin')]
-else:
- incdirs = []
- libdirs = []
- bindirs = []
-
-static = False
-debug_flag = False
-fmil_name = "fmilib_shared"
-fmilib_shared = ""
-copy_gcc_lib = False
-gcc_lib = None
-force_32bit = False
-no_msvcr = False
-with_openmp = False
-
-static_link_gcc = "-static-libgcc"
-flag_32bit = "-m32"
-extra_c_flags = ""
-
-is_windows = sys.platform.startswith("win")
-is_wheel_build = 'bdist_wheel' in sys.argv
-# Fix path sep
-for x in sys.argv[1:]:
- if not x.find('--prefix'):
- if not have_nd:
- raise Exception("Cannot specify --prefix without numpy.distutils")
- copy_args[copy_args.index(x)] = x.replace('/', os.sep)
- if not x.find('--fmil-home'):
- incdirs = [os.path.join(x[12:],'include')]
- libdirs = [
- os.path.join(x[12:],'lib'),
- os.path.join(x[12:],'lib64'),
- ]
-
- multiarch = sysconfig.get_config_var('MULTIARCH')
- if multiarch is not None:
- libdirs.append(os.path.join(x[12:], 'lib', multiarch))
-
- bindirs = [os.path.join(x[12:],'bin')]
- copy_args.remove(x)
- if not x.find('--fmil-name'):
- fmil_name = x[12:]
- copy_args.remove(x)
- if not x.find('--copy-libgcc'):
- if x[14:].upper() == "TRUE":
- copy_gcc_lib = True
- copy_args.remove(x)
- if not x.find('--static'):
- static = x[9:].upper() == "TRUE"
- copy_args.remove(x)
- if not x.find('--force-32bit'):
- if x[14:].upper() == "TRUE":
- force_32bit = True
- copy_args.remove(x)
- if not x.find('--no-msvcr'):
- if x[11:].upper() == "TRUE":
- if not have_nd:
- raise Exception("Cannot specify --no-msvcr without numpy.distutils")
- no_msvcr = True
- copy_args.remove(x)
- if not x.find('--extra-c-flags'):
- extra_c_flags = x[16:]
- copy_args.remove(x)
- if not x.find('--with-openmp'):
- with_openmp = True
- copy_args.remove(x)
- if not x.find('--version'):
- VERSION = x[10:]
- copy_args.remove(x)
- if not x.find('--debug'):
- if x[8:].upper() == "TRUE":
- debug_flag = True
- else:
- debug_flag = False
- copy_args.remove(x)
-
-if not incdirs:
- raise Exception(
- "FMI Library cannot be found. Please specify its location, " + \
- "either using the flag to the setup script '--fmil-home' or" + \
- " specify it using the environment variable FMIL_HOME."
- )
-
-def find_dynamic_fmil_library(*directories):
- """ Find the dynamic library of FMIL. """
- for path_to_dir in chain(*directories):
- path_to_dir = os.path.abspath(path_to_dir)
-
- if not os.path.exists(path_to_dir):
- continue
-
- for file_name in os.listdir(path_to_dir):
- full_path = os.path.join(path_to_dir, file_name)
- if fmil_name in file_name and not file_name.endswith(".a"):
- return full_path
-
- raise Exception(
- f"Could not find shared library '{fmil_name}' at either locations:" + \
- f"\n\t{', '.join(dirs_to_search)}")
-
-if 0 != sys.argv[1].find("clean"): # Dont check if we are cleaning!
-
- use_dynamic_fmil_library = fmil_name.endswith("shared") # TODO: this should be improved in a future release
-
- remove_copied_fmil = False
- if use_dynamic_fmil_library:
- fmil_shared = find_dynamic_fmil_library(libdirs, bindirs)
-
- if is_windows or is_wheel_build:
- # Copy the fmil library to current directory, point to the location of the copied file
- fmil_shared = shutil.copy2(fmil_shared, os.path.join(".", "src", "pyfmi"))
- remove_copied_fmil = True
-
-
- if is_windows and copy_gcc_lib:
- path_gcc_lib = ctypes.util.find_library("libgcc_s_dw2-1.dll")
- if path_gcc_lib is not None:
- gcc_lib = shutil.copy2(path_gcc_lib,os.path.join(".", "src", "pyfmi"))
-
-if no_msvcr:
- # prevent the MSVCR* being added to the DLLs passed to the linker
- def msvc_runtime_library_mod():
- return None
-
- import numpy.distutils
- numpy.distutils.misc_util.msvc_runtime_library = msvc_runtime_library_mod
-
-def check_extensions():
- ext_list = []
- extra_link_flags = []
-
- if static:
- extra_link_flags.append(static_link_gcc)
-
- if force_32bit:
- extra_link_flags.append(flag_32bit)
-
- #COMMON PYX
- """
- ext_list = cythonize([os.path.join("src", "common", "core.pyx")],
- include_path=[".","src",os.path.join("src", "common")],
- include_dirs=[N.get_include()],pyrex_gdb=debug)
-
- ext_list[-1].include_dirs = [N.get_include(), "src",os.path.join("src", "common"), incdirs]
-
- if debug:
- ext_list[-1].extra_compile_args = ["-g", "-fno-strict-aliasing", "-ggdb"]
- ext_list[-1].extra_link_args = extra_link_flags
- else:
- ext_list[-1].extra_compile_args = ["-O2", "-fno-strict-aliasing"]
- ext_list[-1].extra_link_args = extra_link_flags
- """
- incl_path = [".", "src", os.path.join("src", "pyfmi")]
- # FMI PYX
- ext_list += cythonize([os.path.join("src", "pyfmi", "fmi_base.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
- ext_list += cythonize([os.path.join("src", "pyfmi", "fmi.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
- ext_list += cythonize([os.path.join("src", "pyfmi", "fmi1.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
- ext_list += cythonize([os.path.join("src", "pyfmi", "fmi2.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
- ext_list += cythonize([os.path.join("src", "pyfmi", "fmi3.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
-
- # FMI UTIL
- ext_list += cythonize([os.path.join("src", "pyfmi", "fmi_util.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
-
- # FMI Extended PYX
- ext_list += cythonize([os.path.join("src", "pyfmi", "fmi_extended.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
-
- # FMI Coupled PYX
- ext_list += cythonize([os.path.join("src", "pyfmi", "fmi_coupled.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
-
- # Simulation interface PYX
- ext_list += cythonize([os.path.join("src", "pyfmi", "simulation", "assimulo_interface.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
- ext_list += cythonize([os.path.join("src", "pyfmi", "simulation", "assimulo_interface_fmi1.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
- ext_list += cythonize([os.path.join("src", "pyfmi", "simulation", "assimulo_interface_fmi2.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
- ext_list += cythonize([os.path.join("src", "pyfmi", "simulation", "assimulo_interface_fmi3.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
-
- # MASTER PYX
- compile_time_env = {'WITH_OPENMP': with_openmp}
- ext_list += cythonize([os.path.join("src", "pyfmi", "master.pyx")],
- include_path = incl_path,
- compile_time_env=compile_time_env,
- compiler_directives={'language_level' : "3str"})
-
- # UTILITIES
- ext_list += cythonize([os.path.join("src", "pyfmi", "util.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
-
- # Test utilities
- ext_list += cythonize([os.path.join("src", "pyfmi", "test_util.pyx")],
- include_path = incl_path,
- compiler_directives={'language_level' : "3str"})
-
- if not is_windows and use_dynamic_fmil_library:
- if is_wheel_build:
- extra_link_flags += ['-Wl,-rpath,$ORIGIN']
- else:
- extra_link_flags += [f'-Wl,-rpath,{os.path.dirname(fmil_shared)}']
-
-
- for i in range(len(ext_list)):
-
- ext_list[i].include_dirs = [np.get_include(), "src", os.path.join("src", "pyfmi")] + incdirs
- ext_list[i].library_dirs = libdirs
- ext_list[i].language = "c"
- ext_list[i].libraries = [fmil_name]
-
- if debug_flag:
- ext_list[i].extra_compile_args = ["-g", "-fno-strict-aliasing", "-ggdb"]
- else:
- ext_list[i].extra_compile_args = ["-O2", "-fno-strict-aliasing"]
-
- if force_32bit:
- ext_list[i].extra_compile_args.append(flag_32bit)
-
- if extra_c_flags:
- flags = extra_c_flags.split(' ')
- for f in flags:
- ext_list[i].extra_compile_args.append(f)
-
- ext_list[i].extra_link_args = extra_link_flags
-
- if with_openmp:
- ext_list[i].extra_link_args.append("-fopenmp")
- ext_list[i].extra_compile_args.append("-fopenmp")
-
- ext_list[i].cython_directives = {"language_level": 3}
-
- return ext_list
-
-ext_list = check_extensions()
-
-try:
- from subprocess import Popen, PIPE
- _p = Popen(["svnversion", "."], stdout=PIPE)
- revision = _p.communicate()[0].decode('ascii')
-except Exception:
- revision = "unknown"
-version_txt = os.path.join('src', 'pyfmi', 'version.txt')
-
-# If a revision is found, always write it!
-if revision != "unknown" and revision!="":
- with open(version_txt, 'w') as f:
- f.write(VERSION+'\n')
- f.write("r"+revision)
-else:# If it does not, check if the file exists and if not, create the file!
- if not os.path.isfile(version_txt):
- with open(version_txt, 'w') as f:
- f.write(VERSION+'\n')
- f.write("unknown")
-
-try:
- shutil.copy2('LICENSE', os.path.join('src', 'pyfmi', 'LICENSE'))
- shutil.copy2('CHANGELOG', os.path.join('src', 'pyfmi', 'CHANGELOG'))
-except Exception:
- pass
-extra_package_data = [f'*{fmil_name}*']
-extra_package_data += ['libgcc_s_dw2-1.dll'] if is_windows and copy_gcc_lib else []
-
-setup(name=NAME,
- version=VERSION,
- license=LICENSE,
- description=DESCRIPTION,
- long_description=LONG_DESCRIPTION,
- author=AUTHOR,
- author_email=AUTHOR_EMAIL,
- url=URL,
- download_url=DOWNLOAD_URL,
- platforms=PLATFORMS,
- classifiers=CLASSIFIERS,
- ext_modules = ext_list,
- package_dir = {'pyfmi': os.path.join('src', 'pyfmi'),
- 'pyfmi.common': os.path.join('src', 'common')
- },
- packages=[
- 'pyfmi',
- 'pyfmi.simulation',
- 'pyfmi.examples',
- 'pyfmi.common',
- 'pyfmi.common.plotting',
- 'pyfmi.common.log'
- ],
- package_data = {'pyfmi': [
- 'examples/files/FMUs/ME1.0/*',
- 'examples/files/FMUs/CS1.0/*',
- 'examples/files/FMUs/ME2.0/*',
- 'examples/files/FMUs/CS2.0/*',
- 'version.txt',
- 'LICENSE',
- 'CHANGELOG',
- 'util/*'] + extra_package_data
- },
- script_args=copy_args
- )
-
-if 0 != sys.argv[1].find("clean"): # Dont check if we are cleaning!
- if remove_copied_fmil:
- os.remove(fmil_shared)
- if gcc_lib and os.path.exists(gcc_lib):
- os.remove(gcc_lib)
diff --git a/src/pyfmi/__init__.py b/src/pyfmi/__init__.py
index aafb79f9..6db422f5 100644
--- a/src/pyfmi/__init__.py
+++ b/src/pyfmi/__init__.py
@@ -29,14 +29,14 @@
import time
try:
- curr_dir = os.path.dirname(os.path.abspath(__file__))
- _fpath=os.path.join(curr_dir,'version.txt')
- with open(_fpath, 'r') as f:
- __version__=f.readline().strip()
- __revision__=f.readline().strip()
+ from importlib.metadata import version as _pkg_version, PackageNotFoundError
+ try:
+ __version__ = _pkg_version("PyFMI")
+ except PackageNotFoundError:
+ __version__ = _pkg_version("pyfmi-testing")
except Exception:
__version__ = "unknown"
- __revision__= "unknown"
+__revision__ = "unknown"
def check_packages():
diff --git a/src/common/__init__.py b/src/pyfmi/common/__init__.py
similarity index 100%
rename from src/common/__init__.py
rename to src/pyfmi/common/__init__.py
diff --git a/src/common/algorithm_drivers.py b/src/pyfmi/common/algorithm_drivers.py
similarity index 100%
rename from src/common/algorithm_drivers.py
rename to src/pyfmi/common/algorithm_drivers.py
diff --git a/src/common/core.py b/src/pyfmi/common/core.py
similarity index 100%
rename from src/common/core.py
rename to src/pyfmi/common/core.py
diff --git a/src/common/diagnostics.py b/src/pyfmi/common/diagnostics.py
similarity index 100%
rename from src/common/diagnostics.py
rename to src/pyfmi/common/diagnostics.py
diff --git a/src/common/io.py b/src/pyfmi/common/io.py
similarity index 100%
rename from src/common/io.py
rename to src/pyfmi/common/io.py
diff --git a/src/common/log/__init__.py b/src/pyfmi/common/log/__init__.py
similarity index 100%
rename from src/common/log/__init__.py
rename to src/pyfmi/common/log/__init__.py
diff --git a/src/common/log/parser.py b/src/pyfmi/common/log/parser.py
similarity index 99%
rename from src/common/log/parser.py
rename to src/pyfmi/common/log/parser.py
index 8f2bede0..faceca04 100644
--- a/src/common/log/parser.py
+++ b/src/pyfmi/common/log/parser.py
@@ -21,7 +21,6 @@
from xml import sax
import re
import numpy as np
-from distutils.util import strtobool
from pyfmi.common.log.tree import Node, Comment
from pyfmi.exceptions import FMUException
from pathlib import Path
@@ -52,7 +51,7 @@ def parse_value(text):
elif floatingpoint_pattern.match(text):
return float(text)
elif boolean_pattern.match(text):
- return bool(strtobool(text))
+ return text.lower() == "true"
else:
if quoted_string_pattern.match(text):
text = text[1:-1].replace('""','"')
diff --git a/src/common/log/prettyprinter.py b/src/pyfmi/common/log/prettyprinter.py
similarity index 100%
rename from src/common/log/prettyprinter.py
rename to src/pyfmi/common/log/prettyprinter.py
diff --git a/src/common/log/tree.py b/src/pyfmi/common/log/tree.py
similarity index 100%
rename from src/common/log/tree.py
rename to src/pyfmi/common/log/tree.py
diff --git a/src/common/plotting/__init__.py b/src/pyfmi/common/plotting/__init__.py
similarity index 100%
rename from src/common/plotting/__init__.py
rename to src/pyfmi/common/plotting/__init__.py
diff --git a/src/common/plotting/plot_gui.py b/src/pyfmi/common/plotting/plot_gui.py
similarity index 100%
rename from src/common/plotting/plot_gui.py
rename to src/pyfmi/common/plotting/plot_gui.py
diff --git a/tools/wheels/build_dependencies.sh b/tools/wheels/build_dependencies.sh
new file mode 100755
index 00000000..e4c1b214
--- /dev/null
+++ b/tools/wheels/build_dependencies.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Builds FMI Library into /usr. The single source of truth for the FMIL
+# version used by the dev image, the manylinux image, and cibuildwheel.
+# Requires curl, tar, cmake, and a C compiler on PATH.
+set -eux
+
+# cibuildwheel's before-all environment can ship a minimal PATH; restore the
+# standard system paths so /usr/bin tools resolve.
+export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin${PATH:+:${PATH}}"
+
+FMIL_VERSION="3.0.4"
+NPROC=$(nproc)
+
+curl -fSsL \
+ "https://github.com/modelon-community/fmi-library/archive/${FMIL_VERSION}.tar.gz" \
+ | tar xz -C /tmp
+
+cmake -S "/tmp/fmi-library-${FMIL_VERSION}" \
+ -B "/tmp/fmi-library-${FMIL_VERSION}/build" \
+ -DCMAKE_INSTALL_PREFIX=/usr \
+ -DFMILIB_BUILD_TESTS=OFF
+
+make -C "/tmp/fmi-library-${FMIL_VERSION}/build" -j"${NPROC}"
+make -C "/tmp/fmi-library-${FMIL_VERSION}/build" install
+rm -rf "/tmp/fmi-library-${FMIL_VERSION}"
diff --git a/tools/wheels/build_dependencies_windows.sh b/tools/wheels/build_dependencies_windows.sh
new file mode 100755
index 00000000..d66d8cbf
--- /dev/null
+++ b/tools/wheels/build_dependencies_windows.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+# Windows counterpart to build_dependencies.sh. Runs under MSYS2 UCRT64 so
+# FMIL is built with the same gcc the extension modules link against,
+# avoiding the MSVC/MinGW ABI mismatch that breaks delvewheel bundling.
+set -eux
+
+FMIL_VERSION="3.0.4"
+INSTALL_PREFIX="/c/deps"
+NPROC=$(nproc 2>/dev/null || echo 4)
+
+mkdir -p "${INSTALL_PREFIX}/bin" "${INSTALL_PREFIX}/lib" "${INSTALL_PREFIX}/include"
+
+curl -fSsL \
+ "https://github.com/modelon-community/fmi-library/archive/${FMIL_VERSION}.tar.gz" \
+ | tar xz -C /tmp
+
+cmake -S "/tmp/fmi-library-${FMIL_VERSION}" \
+ -B "/tmp/fmi-library-${FMIL_VERSION}/build" \
+ -G Ninja \
+ -DCMAKE_INSTALL_PREFIX="${INSTALL_PREFIX}" \
+ -DFMILIB_BUILD_TESTS=OFF \
+ -DCMAKE_BUILD_TYPE=Release
+
+cmake --build "/tmp/fmi-library-${FMIL_VERSION}/build" --parallel "${NPROC}"
+cmake --install "/tmp/fmi-library-${FMIL_VERSION}/build"
+rm -rf "/tmp/fmi-library-${FMIL_VERSION}"
diff --git a/tools/wheels/repair_windows.ps1 b/tools/wheels/repair_windows.ps1
new file mode 100644
index 00000000..9e9abc0b
--- /dev/null
+++ b/tools/wheels/repair_windows.ps1
@@ -0,0 +1,7 @@
+# delvewheel needs --add-path because FMIL and the UCRT64 runtime are not on
+# the system DLL search path inside cibuildwheel's worker process.
+param([string]$Wheel, [string]$DestDir)
+delvewheel repair `
+ --add-path "C:\msys64\ucrt64\bin;C:\deps\bin;C:\deps\lib" `
+ -w $DestDir `
+ $Wheel