Skip to content
Open
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
18 changes: 5 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,28 +72,20 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # setuptools_scm needs history/tags for the version

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

- name: Configure
run: cmake -S . -B build

- name: Build
run: cmake --build build --parallel

- name: Install Python package and dev dependencies
run: python -m pip install -e "python[dev]"
- name: Build and install the Python package (nanobind extension)
run: python -m pip install -e ".[dev]"

- name: Run ruff
if: ${{ matrix.python-version == '3.13' }}
working-directory: python
run: python -m ruff check .

- name: Run Python tests
working-directory: python
env:
LIBDEDX_SO: ${{ github.workspace }}/build/src/libdedx.so
run: python -m pytest tests -q
run: python -m pytest python/tests -q
101 changes: 101 additions & 0 deletions .github/workflows/wheels.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
name: Wheels

on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:

concurrency:
group: wheels-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# Least-privilege default for every job; publish jobs opt into id-token below.
permissions:
contents: read

jobs:
build_wheels:
name: Wheels on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# macOS arm64 + x86_64 are both built on the arm64 runner (see the
# [tool.cibuildwheel.macos] archs in pyproject.toml), so the scarce
# Intel macos-13 runner is not needed.
os: [ubuntu-latest, windows-latest, macos-14]
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # needed for setuptools_scm to resolve the version

- name: Build wheels
uses: pypa/cibuildwheel@v3.2.0

- uses: actions/upload-artifact@v4
with:
name: cibw-wheels-${{ matrix.os }}
path: ./wheelhouse/*.whl

build_sdist:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
name: Build sdist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

# `build` with no target builds the sdist and then a wheel *from that
# sdist*, which proves the sdist is self-contained. Only the sdist is
# published; the locally-built (unrepaired) wheel is discarded.
- name: Build sdist and verify it builds a wheel
run: pipx run build

- uses: actions/upload-artifact@v4
with:
name: cibw-sdist
path: dist/*.tar.gz

# Dry-run publish to TestPyPI on every tag before the real PyPI release.
publish_testpypi:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
name: Publish to TestPyPI
needs: [build_wheels, build_sdist]
runs-on: ubuntu-latest
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
environment:
name: testpypi
url: https://test.pypi.org/p/libdedx
permissions:
id-token: write # OIDC trusted publishing
steps:
- uses: actions/download-artifact@v4
with:
pattern: cibw-*
path: dist
merge-multiple: true

- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
skip-existing: true

publish_pypi:
name: Publish to PyPI
needs: [publish_testpypi]
runs-on: ubuntu-latest
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
environment:
name: pypi
url: https://pypi.org/p/libdedx
permissions:
id-token: write # OIDC trusted publishing
steps:
- uses: actions/download-artifact@v4
with:
pattern: cibw-*
path: dist
merge-multiple: true

- uses: pypa/gh-action-pypi-publish@release/v1
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ python/.pytest_cache/
python/*.egg-info/
*.pyc

# Python packaging (scikit-build-core / cibuildwheel) artifacts
dist/
wheelhouse/
*.egg-info/
.pytest_cache/

# Generated by CMake at configure time - do not commit
libdedx/dedx_config.h

Expand Down
23 changes: 23 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ include(CMakePackageConfigHelpers)

option(DEDX_BUILD_EXAMPLES "Build libdedx example programs" ON)
option(DEDX_BUILD_TESTS "Build libdedx test suite" ON)
option(DEDX_BUILD_PYTHON "Build the libdedx._core Python extension module" OFF)

# When invoked by scikit-build-core (pip / cibuildwheel) build only the Python
# extension: skip the examples, tests, and the C-library install/packaging rules
# so the wheel stays a single self-contained module.
if(SKBUILD)
set(DEDX_BUILD_PYTHON ON)
set(DEDX_BUILD_EXAMPLES OFF)
set(DEDX_BUILD_TESTS OFF)
endif()

# ---- Version from git tag ----
find_package(Git QUIET)
Expand All @@ -24,6 +34,12 @@ if(GIT_FOUND)
ERROR_QUIET
)
endif()
# Under scikit-build-core prefer the version resolved by setuptools_scm so the C
# library and the Python package stay in lockstep (and so source builds without a
# .git directory still get a meaningful version).
if(SKBUILD AND DEFINED SKBUILD_PROJECT_VERSION_FULL AND NOT SKBUILD_PROJECT_VERSION_FULL STREQUAL "")
set(GIT_VERSION "${SKBUILD_PROJECT_VERSION_FULL}")
endif()
if(NOT GIT_VERSION)
set(GIT_VERSION "0.0.0-unknown")
endif()
Expand All @@ -50,7 +66,13 @@ endif()
if(DEDX_BUILD_TESTS)
add_subdirectory(tests)
endif()
if(DEDX_BUILD_PYTHON)
add_subdirectory(python)
endif()

# The exported CMake package files and CPack packaging are only relevant for a
# regular C-library install, not for the Python wheel build.
if(NOT SKBUILD)
configure_package_config_file(
"${PROJECT_SOURCE_DIR}/cmake/dedxConfig.cmake.in"
"${PROJECT_BINARY_DIR}/dedxConfig.cmake"
Expand Down Expand Up @@ -126,3 +148,4 @@ configure_file(
"${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake"
IMMEDIATE @ONLY)
add_custom_target(uninstall COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake)
endif() # NOT SKBUILD
30 changes: 30 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,33 @@ locking.
The intended fix is to audit the library for shared mutable state and either
make workspaces single-thread-owned by design or add explicit synchronization
where shared access is required. This is tracked as a known issue.

The Python binding inherits this limitation: do not share a `Workspace` across
threads. (Calls do not release the GIL, so binding calls on a shared workspace
are serialized in practice, but the underlying library is still not thread-safe.)

## Python binding

The Python package lives under `python/` and is a
[nanobind](https://nanobind.readthedocs.io) extension (`libdedx._core`) built by
[scikit-build-core](https://scikit-build-core.readthedocs.io). It statically
links the `dedx` C target, so building it also compiles the C library. The whole
project is configured from the top-level `pyproject.toml`.

```bash
pip install -e ".[dev]" # builds the extension in place
pytest python/tests
ruff check .
```

Notes for contributors:

- The binding source (`python/src/dedx_core.cpp`) is C++17 and follows ordinary
C++ conventions; the C "declare variables at the top of the block" rule above
applies to the C library, not to this file.
- `dedx_config` owns the element arrays it is given and frees them in
`dedx_free_config()`, so any pointer handed to it from the binding must be
`malloc`'d. See the `Config` wrapper for how ownership and array lengths are
kept consistent.
- The package version comes from `setuptools_scm` (git tags) and is fed into the
C library so the two stay in lockstep.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,33 @@ dedx_free_workspace(ws, &err);

See the [examples/](examples/) directory for more usage patterns.

## Python binding

`libdedx` is also available as a Python package with the C library statically
linked in, so there is no separate install or build step and no runtime
dependency on a shared library:

```bash
pip install libdedx
```

```python
import libdedx
from libdedx import _core as dedx

stp = libdedx.simple_stp(dedx.PROTON, dedx.WATER, 100.0) # MeV cm² / g

# Workspace/config object model for repeated evaluations
ws = dedx.Workspace()
cfg = dedx.Config()
cfg.program, cfg.ion, cfg.target = dedx.PSTAR, dedx.PROTON, dedx.WATER
ws.load(cfg)
stp = ws.stp(cfg, 100.0)
```

See [python/README.md](python/README.md) for the full Python API and the
development workflow.

## Building

Requires CMake 3.21+ and a C11 compiler.
Expand Down
32 changes: 32 additions & 0 deletions index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,38 @@ Passing ``-1`` for the program, ion, or target slot lists the available
values for that level.


***************
Python binding
***************

libdedx ships a Python package built as a `nanobind
<https://nanobind.readthedocs.io>`_ extension with `scikit-build-core
<https://scikit-build-core.readthedocs.io>`_. The C library is statically
linked into the extension and the stopping-power tables are embedded, so the
wheels are self-contained — ``pip install libdedx`` needs no local C build or
shared library.

.. code-block:: python

import libdedx
from libdedx import _core as dedx

# one-shot lookup, mass stopping power in MeV cm^2 / g
stp = libdedx.simple_stp(dedx.PROTON, dedx.WATER, 100.0)

# workspace/config object model for repeated evaluations
ws = dedx.Workspace()
cfg = dedx.Config()
cfg.program, cfg.ion, cfg.target = dedx.PSTAR, dedx.PROTON, dedx.WATER
ws.load(cfg)
stp = ws.stp(cfg, 100.0)

The low-level ``libdedx._core`` module mirrors the C API: the workspace/config
object model, custom compounds, CSDA range, inverse stopping power / range,
unit conversion, composition and I-value accessors, and the program/ion/material
lists and names. The package version is kept in lockstep with the C library.


*****
Notes
*****
Expand Down
87 changes: 87 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
[build-system]
requires = [
"scikit-build-core>0.10",
"nanobind>=2",
"setuptools-scm>=8",
]
build-backend = "scikit_build_core.build"

[project]
name = "libdedx"
description = "Python binding for the libdedx charged-particle stopping-power library"
readme = "python/README.md"
requires-python = ">=3.9"
license = "GPL-3.0-or-later"
license-files = ["COPYING"]
authors = [
{ name = "Jakob Toftegaard" },
{ name = "Niels Bassler" },
{ name = "Leszek Grzanka" },
]
dependencies = ["numpy"]
dynamic = ["version"]

[project.urls]
Homepage = "https://github.com/APTG/libdedx"
Issues = "https://github.com/APTG/libdedx/issues"

[project.optional-dependencies]
test = ["pytest>=8", "numpy"]
dev = ["ruff>=0.5.0", "pytest>=8", "numpy"]

[tool.scikit-build]
minimum-version = "build-system.requires"
build-dir = "build/{wheel_tag}"
# The repo root is the CMake source tree; the extension is added when SKBUILD
# turns on DEDX_BUILD_PYTHON. Only the pure-Python package lives under python/.
wheel.packages = ["python/libdedx"]

[tool.scikit-build.cmake]
version = ">=3.21"

[tool.scikit-build.metadata.version]
provider = "scikit_build_core.metadata.setuptools_scm"

[tool.scikit-build.sdist]
# Ship the C sources/headers needed to build the extension from an sdist. The
# stopping-power tables are already embedded in src/, so the raw data/ tree is
# not required to build a wheel.
include = [
"CMakeLists.txt",
"include/**",
"src/**",
"python/**",
]

[tool.setuptools_scm]
version_scheme = "post-release"
local_scheme = "node-and-date"

[tool.pytest.ini_options]
testpaths = ["python/tests"]

[tool.ruff]
line-length = 120
target-version = "py39"
extend-exclude = ["build"]

[tool.ruff.lint]
select = ["E", "F", "W"]

[tool.cibuildwheel]
# CPython 3.9–3.14 on 64-bit platforms; PyPy and musllinux are skipped for now.
build = "cp39-* cp310-* cp311-* cp312-* cp313-* cp314-*"
skip = "pp* *-musllinux*"
build-frontend = "build"
# Install-and-import smoke test plus the full test suite for every wheel.
test-requires = "pytest numpy"
test-command = "pytest {project}/python/tests"

[tool.cibuildwheel.macos]
# Build both architectures on a single (arm64) runner: arm64 natively and
# x86_64 by cross-compiling. This avoids GitHub's scarce Intel `macos-13`
# runners, which queue for a very long time.
archs = ["arm64", "x86_64"]

[tool.cibuildwheel.macos.environment]
MACOSX_DEPLOYMENT_TARGET = "11.0"
Loading
Loading