Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ All notable changes to CableDyn are recorded in this file. The format follows
the same model at `dtM = DT` (platform time series, tension statistics, rainflow
damage-equivalent loads); `VALIDATION.md` records the supercycling verification it scores.

- Packaging for PyPI: a root `pyproject.toml` (scikit-build-core) builds an sdist of the sources
and a `py3-none-<platform>` wheel that carries the compiled library and the standalone driver
inside the `cabledyn` package, so the package needs no separate library; the driver search
takes the bundled driver after `CABLEDYN_DRIVER` and before `PATH`. `python/pyproject.toml`
remains the pure package for development against a build tree.

### Changed

- OpenFAST: `CalcOutput` no longer evaluates the reactions of the finite-EI cables when it saves
Expand Down
10 changes: 10 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1800,6 +1800,16 @@ if(CABLEDYN_STATIC_WINDOWS_RELEASE)
target_link_options(cabledyn PRIVATE "LINKER:/STACK:268435456")
endif()
install(TARGETS cabledyn RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
if(SKBUILD)
# A Python wheel (pyproject.toml, scikit-build-core, install component `python`) carries the
# C-ABI library beside the package modules, where cabledyn._discovery looks first, and the
# driver in the package's bin folder, where cabledyn.driver looks after CABLEDYN_DRIVER. The
# wheel repair tools bundle the runtime and LAPACK libraries they need.
install(TARGETS cabledyn_shared
RUNTIME DESTINATION cabledyn COMPONENT python
LIBRARY DESTINATION cabledyn COMPONENT python)
install(TARGETS cabledyn RUNTIME DESTINATION cabledyn/bin COMPONENT python)
endif()
if(WIN32 AND CABLEDYN_RUNTIME_DLLS)
# A conventional GNU/conda Windows install must be usable outside the build
# environment, not only from the separately assembled release bundle.
Expand Down
31 changes: 31 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,37 @@ the shared library. They compare against independent references (the ASTM E1049
cycle table and analytical sinusoids); do not replace these with snapshots of
CableDyn output.

### Distribution: sdist and platform wheels

The root `pyproject.toml` builds the published distribution with scikit-build-core: an
sdist of the sources and a platform wheel that carries the compiled library beside the
package modules and the standalone driver in `cabledyn/bin`. Only the `cabledyn_shared`
and `cabledyn` targets are built and installed (CMake install component `python`, active
under scikit-build only), serially (`CABLEDYN_ENABLE_OPENMP` off). The wheel is tagged
`py3-none-<platform>`: the package reaches the library through `ctypes`, so one wheel
serves every supported Python version. `python/pyproject.toml` stays the pure package for
development against a build tree; `tests/test_packaging_metadata.py` keeps the two
`[project]` tables identical except for the readme, licence files and platform
classifiers.

To build both locally from the activated `cabledyn` environment (Ninja, the conda GNU
toolchain, and forward slashes in the archiver paths):

```powershell
python -m pip install "scikit-build-core>=0.10" build
python -m build --sdist --no-isolation --outdir dist .
$ar = "$env:CONDA_PREFIX/Library/bin/x86_64-w64-mingw32-ar.exe" -replace '\\','/'
$rl = "$env:CONDA_PREFIX/Library/bin/x86_64-w64-mingw32-ranlib.exe" -replace '\\','/'
python -m pip wheel . --no-deps --no-build-isolation -w dist `
"--config-settings=cmake.args=-GNinja" `
--config-settings=cmake.define.CMAKE_C_COMPILER=gcc `
--config-settings=cmake.define.CMAKE_Fortran_COMPILER=gfortran `
--config-settings=cmake.define.CMAKE_AR=$ar --config-settings=cmake.define.CMAKE_RANLIB=$rl
```

A wheel built this way still needs the toolchain's runtime and LAPACK libraries on the
search path; the release wheels bundle them (or link them statically on Windows).

On Windows, run Python from the activated `cabledyn` environment and point
`CABLEDYN_LIBRARY` at the DLL. Do not prepend compiler or OpenBLAS runtime
directories to `PATH` before importing NumPy: mixed runtime DLLs can terminate
Expand Down
101 changes: 101 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# File: pyproject.toml
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University
#
# The published distribution: an sdist of the Fortran sources and platform wheels that carry the
# compiled CableDyn library and the standalone driver inside the `cabledyn` package. The [project]
# table mirrors python/pyproject.toml (the pure-Python package for development against a CMake
# build tree); tests/test_packaging_metadata.py keeps the two identical.
[build-system]
requires = ["scikit-build-core>=0.10"]
build-backend = "scikit_build_core.build"

[project]
name = "cabledyn"
version = "0.2.0"
description = "Python APIs for the CableDyn standalone driver and native coupling library"
readme = "python/README.md"
requires-python = ">=3.10"
license = "Apache-2.0"
license-files = ["LICENSE", "NOTICE"]
authors = [{ name = "Jae Hoon Seo", email = "jaehoon.seo@inha.ac.kr" }]
keywords = [
"cable dynamics",
"dynamic power cable",
"finite element",
"floating offshore wind",
"lazy wave",
"mooring dynamics",
"MoorDyn",
"offshore engineering",
"OpenFAST",
]
dependencies = ["numpy>=1.24"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Science/Research",
"Intended Audience :: Developers",
"Natural Language :: English",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Programming Language :: Fortran",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
"Topic :: Scientific/Engineering :: Physics",
"Typing :: Typed",
]

[project.urls]
Homepage = "https://github.com/SMI-Lab-Inha/CableDyn"
Documentation = "https://cabledyn.readthedocs.io"
Repository = "https://github.com/SMI-Lab-Inha/CableDyn"
Issues = "https://github.com/SMI-Lab-Inha/CableDyn/issues"
Changelog = "https://github.com/SMI-Lab-Inha/CableDyn/blob/main/CHANGELOG.md"
Releases = "https://github.com/SMI-Lab-Inha/CableDyn/releases"
Citation = "https://doi.org/10.1016/j.oceaneng.2026.128332"

[project.scripts]
cabledyn-run = "cabledyn.cli:main"
cabledyn-post = "cabledyn.post_cli:main"
cabledyn-deck = "cabledyn.deck_cli:main"
cabledyn-study = "cabledyn.study_cli:main"

[project.optional-dependencies]
dataframe = ["pandas>=2.0"]
plot = ["matplotlib>=3.7"]
post = ["pandas>=2.0", "matplotlib>=3.7"]
dev = [
"pytest>=7", "pytest-cov>=5", "pandas>=2.0", "matplotlib>=3.7", "ruff>=0.15,<0.16", "mypy>=1.10",
]

[tool.scikit-build]
minimum-version = "build-system.requires"
cmake.version = ">=3.20"
cmake.build-type = "Release"
# Only the products the wheel carries: the C-ABI library and the standalone driver (the test
# programs are configured but not built), installed into the package by the `python` component.
build.targets = ["cabledyn_shared", "cabledyn"]
install.components = ["python"]
wheel.packages = ["python/cabledyn"]
# The package reaches the library through ctypes, with no Python extension module: one wheel per
# platform serves every supported Python version.
wheel.py-api = "py3"
# The source distribution: every file git does not ignore except the validation records, the
# manual, the OpenFAST integration and the release tooling; it keeps the sources, the tests the
# CMake configuration registers, the examples they run and the Python package (gitignore-style
# patterns, anchored at the project root).
sdist.exclude = [
"/validation/", "/doc/", "/integration/", "/release/", "/.github/", "/build*/",
"**/__pycache__/", "*.pyc",
]

[tool.scikit-build.cmake.define]
# Serial like the release executables: no OpenMP runtime to bundle, and the same results on
# every machine.
CABLEDYN_ENABLE_OPENMP = false
21 changes: 20 additions & 1 deletion python/cabledyn/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,16 @@ def read_line_tensions(self, line_id: int) -> OutputTable:
return table


def bundled_driver(package_directory: Path | None = None) -> Path:
"""The driver a wheel carries in the package's ``bin`` folder: ``CableDyn_driver`` (the name
the MSVC-style toolchains give it) or ``cabledyn`` (GNU), each with ``.exe`` on Windows. Returns
the first that exists, else the ``CableDyn_driver`` path (to report)."""
package = Path(__file__).resolve().parent if package_directory is None else package_directory
suffix = ".exe" if os.name == "nt" else ""
candidates = [package / "bin" / f"{stem}{suffix}" for stem in ("CableDyn_driver", "cabledyn")]
return next((c for c in candidates if c.is_file()), candidates[0])


def _resolve_executable(executable: str | os.PathLike[str] | None) -> Path:
direct_candidates: list[str | os.PathLike[str]] = []
if executable is not None:
Expand All @@ -338,12 +348,21 @@ def _resolve_executable(executable: str | os.PathLike[str] | None) -> Path:
if resolved.is_file() and (os.name == "nt" or os.access(resolved, os.X_OK)):
return resolved
if executable is None:
# the driver a wheel carries in the package (matching its bundled library) before PATH
bundled = bundled_driver()
tried.append(str(bundled))
if bundled.is_file() and (os.name == "nt" or os.access(bundled, os.X_OK)):
return bundled
for name in ("CableDyn_driver.exe", "CableDyn_driver", "cabledyn"):
found = shutil.which(name)
tried.append(name)
if found:
return Path(found).resolve()
source = "the explicit path" if executable is not None else "CABLEDYN_DRIVER or PATH"
source = (
"the explicit path"
if executable is not None
else "CABLEDYN_DRIVER, the bundled driver or PATH"
)
raise DriverNotFoundError(
f"could not locate CableDyn_driver using {source}. Tried: {', '.join(tried)}"
)
Expand Down
51 changes: 50 additions & 1 deletion python/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,9 +515,46 @@ def test_driver_discovery_reports_every_candidate(monkeypatch):

monkeypatch.delenv("CABLEDYN_DRIVER", raising=False)
monkeypatch.setattr("shutil.which", lambda name: None)
with pytest.raises(DriverNotFoundError, match="CABLEDYN_DRIVER or PATH") as caught:
with pytest.raises(
DriverNotFoundError, match="CABLEDYN_DRIVER, the bundled driver or PATH"
) as caught:
CableDynDriver()
assert "CableDyn_driver.exe" in str(caught.value) and "cabledyn" in str(caught.value)
assert str(Path("bin") / "CableDyn_driver") in str(caught.value)


def _fake_driver(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
path.chmod(0o755)
return path


def test_bundled_driver_comes_before_path(monkeypatch, tmp_path):
# a wheel's own driver (matching its bundled library) wins over one on PATH
from cabledyn import driver as driver_module

bundled = _fake_driver(tmp_path / "pkg" / "bin" / "CableDyn_driver.exe")
on_path = _fake_driver(tmp_path / "path" / "CableDyn_driver.exe")
monkeypatch.delenv("CABLEDYN_DRIVER", raising=False)
monkeypatch.setattr(driver_module, "bundled_driver", lambda: bundled)
monkeypatch.setattr("shutil.which", lambda name: str(on_path))
assert driver_module._resolve_executable(None) == bundled
# without a bundled driver, PATH is searched as before
monkeypatch.setattr(
driver_module, "bundled_driver", lambda: tmp_path / "missing" / "CableDyn_driver.exe"
)
assert driver_module._resolve_executable(None) == on_path.resolve()


def test_cabledyn_driver_variable_comes_before_the_bundled_driver(monkeypatch, tmp_path):
from cabledyn import driver as driver_module

bundled = _fake_driver(tmp_path / "pkg" / "bin" / "CableDyn_driver.exe")
chosen = _fake_driver(tmp_path / "chosen" / "CableDyn_driver.exe")
monkeypatch.setenv("CABLEDYN_DRIVER", str(chosen))
monkeypatch.setattr(driver_module, "bundled_driver", lambda: bundled)
assert driver_module._resolve_executable(None) == chosen.resolve()


def test_posix_environment_merge_keeps_case_distinct_names(monkeypatch):
Expand Down Expand Up @@ -547,3 +584,15 @@ def slow(command, **kwargs):
CableDynDriver(fake_driver).version(timeout=1.0)
assert caught.value.stdout == "partial �"
assert caught.value.stderr == ""


def test_bundled_driver_accepts_both_toolchain_names(tmp_path):
from cabledyn import driver as driver_module

suffix = ".exe" if os.name == "nt" else ""
package = tmp_path / "cabledyn"
assert driver_module.bundled_driver(package) == package / "bin" / f"CableDyn_driver{suffix}"
gnu = _fake_driver(package / "bin" / f"cabledyn{suffix}")
assert driver_module.bundled_driver(package) == gnu
msvc = _fake_driver(package / "bin" / f"CableDyn_driver{suffix}")
assert driver_module.bundled_driver(package) == msvc
1 change: 1 addition & 0 deletions release/audit_release_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"SECURITY.md",
"VALIDATION.md",
"doc/index.rst",
"pyproject.toml",
"python/pyproject.toml",
"src/CableDyn_CAPI.f90",
"validation/README.md",
Expand Down
4 changes: 4 additions & 0 deletions release/check_release_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ def main() -> int:
checks = {
"CITATION.cff version": citation_version == version,
"Python project version": f'version = "{version}"' in read("python/pyproject.toml"),
"Published distribution version": f'version = "{version}"' in read("pyproject.toml"),
"Python runtime version": f'__version__ = "{version}"' in read("python/cabledyn/__init__.py"),
"Fortran banner version": f"CableDyn v{version}" in read("src/CableDyn_Banner.f90"),
"C ABI version string": f"CableDyn {version} C-ABI {abi_version}" in read(
Expand Down Expand Up @@ -174,6 +175,9 @@ def main() -> int:
"python/pyproject.toml"
),
"Python licence file": (ROOT / "python" / "LICENSE").is_file(),
"Published distribution licence metadata": 'license-files = ["LICENSE", "NOTICE"]' in read(
"pyproject.toml"
),
}
failures = [label for label, passed in checks.items() if not passed]
failures += [f"stale version reference {where}" for where in stale_version_references(version)]
Expand Down
65 changes: 65 additions & 0 deletions tests/test_packaging_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# SPDX-License-Identifier: Apache-2.0
"""The published distribution (pyproject.toml) and the development package (python/pyproject.toml)
describe the same project: their [project] tables agree on everything but the readme and licence
files (paths relative to each file) and the classifiers (the platform wheels name their operating
systems and Fortran; the pure package is OS independent)."""

from __future__ import annotations

import unittest
from pathlib import Path

try:
import tomllib
except ModuleNotFoundError: # Python 3.10
try:
import tomli as tomllib
except ModuleNotFoundError:
tomllib = None

ROOT = Path(__file__).resolve().parents[1]
PER_FILE = {"readme", "license-files", "classifiers"}


def _project(path: Path) -> dict:
with path.open("rb") as fh:
return tomllib.load(fh)["project"]


@unittest.skipIf(tomllib is None, "needs tomllib (Python 3.11+) or tomli")
class PackagingMetadataTest(unittest.TestCase):
def setUp(self):
self.published = _project(ROOT / "pyproject.toml")
self.development = _project(ROOT / "python" / "pyproject.toml")

def test_the_two_projects_agree(self):
keys = (set(self.published) | set(self.development)) - PER_FILE
for key in sorted(keys):
with self.subTest(key=key):
self.assertEqual(self.published.get(key), self.development.get(key))

def test_per_file_fields_point_at_existing_files(self):
self.assertTrue((ROOT / self.published["readme"]).is_file())
self.assertTrue((ROOT / "python" / self.development["readme"]).is_file())
for name in self.published["license-files"]:
self.assertTrue((ROOT / name).is_file(), name)
for name in self.development["license-files"]:
self.assertTrue((ROOT / "python" / name).is_file(), name)

def test_the_wheel_classifiers_differ_only_in_the_platforms(self):
published = set(self.published["classifiers"])
development = set(self.development["classifiers"])
self.assertEqual(development - published, {"Operating System :: OS Independent"})
self.assertEqual(
published - development,
{
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Programming Language :: Fortran",
},
)


if __name__ == "__main__":
unittest.main()
Loading