diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dccd01..870b945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-` 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 diff --git a/CMakeLists.txt b/CMakeLists.txt index d82c742..551c910 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7e3c14f..0653a8f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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-`: 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b09e93d --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/python/cabledyn/driver.py b/python/cabledyn/driver.py index 9c0666c..d1e7f6b 100644 --- a/python/cabledyn/driver.py +++ b/python/cabledyn/driver.py @@ -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: @@ -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)}" ) diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py index 8f868f1..d3ee91c 100644 --- a/python/tests/test_cli.py +++ b/python/tests/test_cli.py @@ -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): @@ -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 diff --git a/release/audit_release_tree.py b/release/audit_release_tree.py index 8a00d3a..3c6285c 100644 --- a/release/audit_release_tree.py +++ b/release/audit_release_tree.py @@ -49,6 +49,7 @@ "SECURITY.md", "VALIDATION.md", "doc/index.rst", + "pyproject.toml", "python/pyproject.toml", "src/CableDyn_CAPI.f90", "validation/README.md", diff --git a/release/check_release_metadata.py b/release/check_release_metadata.py index f6b5a95..7a6f1a1 100644 --- a/release/check_release_metadata.py +++ b/release/check_release_metadata.py @@ -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( @@ -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)] diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py new file mode 100644 index 0000000..049ec46 --- /dev/null +++ b/tests/test_packaging_metadata.py @@ -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()