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