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
26 changes: 23 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@ env:
CTEST_NO_TESTS_ACTION: error

on:
workflow_dispatch:
inputs:
logLevel:
description: 'Log level'
required: true
default: 'warning'
type: choice
options:
- info
- warning
- debug
push:
paths:
- "**/CMakeLists.txt"
- "**.cmake"
- "**.f90"
- "**.py"
- ".github/workflows/ci.yml"
- "pyproject.toml"


jobs:
Expand All @@ -21,22 +33,28 @@ jobs:
strategy:
matrix:
os: [ubuntu-24.04]
python-version: ["3.9", "3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
include:
- os: macos-latest
python-version: "3.10"

runs-on: ${{ matrix.os }}

env:
FC: gfortran-14
FC: gfortran

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- uses: fortran-lang/setup-fortran@v1
id: setup-fortran
with:
compiler: gcc
version: 14

- run: pip install .[tests,lint]

- run: flake8
Expand All @@ -47,11 +65,12 @@ jobs:

- run: pytest

- run: pip install meson # required for cmake tests
- run: cmake --workflow --preset default

windows:
runs-on: windows-latest
timeout-minutes: 10
timeout-minutes: 60

steps:
- uses: msys2/setup-msys2@v2
Expand Down Expand Up @@ -80,6 +99,7 @@ jobs:

- run: pytest -v

- run: pip install meson # required for cmake tests
- run: cmake -B build
env:
CMAKE_GENERATOR: "MinGW Makefiles"
Expand Down
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,26 @@

dist/

build/
cmake-build-*/
CMakeFiles/
CMakeCache.txt

__pycache__/
*.py[cod]
*.so
*.dylib
*.egg-info/

# Local virtual environments
.venv/
venv/

# Ninja / CMake logs
rules.ninja
.ninja_log
.ninja_deps

# OS specific files
.DS_Store
Thumbs.db
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ See below for how to make these examples.

Lowtran requires a Fortran compiler and CMake.
We use `f2py` (part of `numpy`) to seamlessly use Fortran libraries from Python by special compilation of the Fortran library with auto-generated shim code.
For Python 3.12, make sure to install setuptools using pip, i.e., `pip install setuptools` and make sure meson is available on the system.
Make sure `meson` and the python devel headers & libraries are available on your system.

If a Fortran compiler is not already installed, install Gfortran:

Expand Down
22 changes: 17 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
[build-system]
requires = ["setuptools>=61.0.0", "wheel", "numpy"]
build-backend = "setuptools.build_meta"
requires = [
"scikit-build-core>=0.9.0",
"numpy",
"meson"
]
build-backend = "scikit_build_core.build"

[project]
name = "lowtran"
Expand All @@ -16,16 +20,16 @@ classifiers = [
"Programming Language :: Fortran",
"Topic :: Scientific/Engineering :: Atmospheric Science"
]
dynamic = ["readme"]
requires-python = ">=3.9"
dependencies = ["numpy", "xarray", "python-dateutil"]

[project.optional-dependencies]
tests = ["pytest"]
lint = ["flake8", "flake8-bugbear", "flake8-builtins", "flake8-blind-except", "mypy", "types-python-dateutil"]

[tool.setuptools.dynamic]
readme = {file = ["README.md"], content-type = "text/markdown"}
[project.readme]
file = "README.md"
content-type = "text/markdown"

[tool.black]
line-length = 100
Expand All @@ -34,3 +38,11 @@ line-length = 100
files = ["src", "example"]
allow_redefinition = true
ignore_missing_imports = true

# scikit-build-core configuration: out-of-source build and package discovery
[tool.scikit-build]
cmake.args = ["-GNinja"]
editable.mode = "redirect"
build-dir = "build/{wheel_tag}"
wheel.packages = ["src/lowtran"]
sdist.include = ["src/lowtran", "README.md", "LICENSE.txt", "MANIFEST.in"]
98 changes: 61 additions & 37 deletions src/lowtran/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,52 +3,76 @@
import xarray
import numpy as np
from typing import Any
from pathlib import Path
import importlib.util
import distutils.sysconfig
import importlib
import sysconfig
import os
import sys
from pathlib import Path
from types import ModuleType

from .cmake import build


def check() -> ModuleType:
try:
lowtran7 = import_f2py_mod("lowtran7")
except ImportError:
src = Path(__file__).parent
build(source_dir=src, build_dir=src / "build")
lowtran7 = import_f2py_mod("lowtran7")
"""Ensure the compiled lowtran7 extension is available.

return lowtran7
With scikit-build-core, the extension is built at install time and shipped in the wheel.
If it's missing, prompt the user to install the package (e.g., `pip install .`).
"""

try:
return import_f2py_mod("lowtran7")
except ImportError as e:
raise ImportError(
"lowtran7 extension not found. Please install the package so the Fortran "
"extension is built (e.g., `pip install .` or `pip install lowtran`)."
) from e

def import_f2py_mod(name: str) -> ModuleType:

if os.name == "nt":
# https://github.com/space-physics/lowtran/issues/19
# code inspired by scipy._distributor_init.py for loading DLLs on Window
dll_path = (Path(__file__) / "../build/lowtran7/.libs").resolve()
if dll_path.is_dir():
# add the folder for Python 3.8 and above
logging.info(f"Adding {dll_path} to DLL search path")
os.add_dll_directory(dll_path) # type: ignore
else:
logging.info(f"Could not find {dll_path} to add to DLL search path")

mod_name = name + distutils.sysconfig.get_config_var("EXT_SUFFIX") # type: ignore
mod_file = Path(__file__).parent / mod_name
if not mod_file.is_file():
raise ModuleNotFoundError(mod_file)
spec = importlib.util.spec_from_file_location(name, mod_file)
if spec is None:
raise ModuleNotFoundError(f"{name} not found in {mod_file}")
mod = importlib.util.module_from_spec(spec)
if mod is None:
raise ImportError(f"could not import {name} from {mod_file}")
spec.loader.exec_module(mod) # type: ignore

return mod
def import_f2py_mod(name: str) -> ModuleType:
lib_name = name + sysconfig.get_config_var("EXT_SUFFIX")
lib_path = importlib.resources.files(__package__) / lib_name

if not lib_path.is_file():
# Assume editable install: navigate up to find the build directory
src_dir = Path(__file__).parent
project_root = src_dir.parent.parent # Assuming src/lowtran structure

# scikit-build-core build directory pattern
build_base = project_root / "build"

if build_base.exists():
# Recursively search for the library file
matches = list(build_base.rglob(lib_name))
if matches:
lib_path = matches[0] # Use the first match

if not lib_path.is_file():
raise ModuleNotFoundError(f"Module not found: {lib_path}")

# On Windows, add DLL search directories to fix loading issues
dll_dirs: list[Any] = []
if sys.platform == "win32" and hasattr(os, 'add_dll_directory'):
# Add common locations where your dependencies might be, i.e., system PATH and module dir
search_paths = os.environ.get('PATH', '').split(os.pathsep)
search_paths.append(os.fspath(lib_path.parent))

for path in search_paths:
try:
dll_dirs.append(os.add_dll_directory(path))
except OSError:
pass
try:
# Load the module from file path
spec = importlib.util.spec_from_file_location(name, lib_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load module from {lib_path}")

mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
finally:
# Clean up DLL directories
for dll_dir in dll_dirs:
dll_dir.close()


def nm2lt7(short_nm: float, long_nm: float, step_cminv: float = 20) -> tuple[float, float, float]:
Expand Down
32 changes: 0 additions & 32 deletions src/lowtran/cmake.py

This file was deleted.

24 changes: 7 additions & 17 deletions src/lowtran/cmake/f2py.cmake
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# f2py

find_package(Python COMPONENTS Interpreter NumPy REQUIRED)
set(Python3_FIND_VIRTUALENV FIRST)
find_package(Python3 COMPONENTS Interpreter NumPy REQUIRED)
message(STATUS "${PYTHON3_EXECUTABLE}")
message(STATUS "${Python3_NumPy_VERSION}")

if(CMAKE_Fortran_COMPILER_ID STREQUAL "GNU" AND
CMAKE_Fortran_COMPILER_VERSION VERSION_GREATER_EQUAL 10 AND
Python_NumPy_VERSION VERSION_LESS 1.19)
CMAKE_Fortran_COMPILER_VERSION VERSION_GREATER_EQUAL 10 AND
Python3_NumPy_VERSION VERSION_LESS 1.19)
message(FATAL_ERROR "Numpy >= 1.19 required for GCC >= 10")
endif()

Expand All @@ -15,26 +18,13 @@ if(f2py_suffix)
endif()

execute_process(
COMMAND ${Python_EXECUTABLE} -c "import sysconfig; x=sysconfig.get_config_var('EXT_SUFFIX'); assert x is not None; print(x)"
COMMAND ${Python3_EXECUTABLE} -c "import sysconfig; x=sysconfig.get_config_var('EXT_SUFFIX'); assert x is not None; print(x)"
OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE ret
OUTPUT_VARIABLE out
ERROR_VARIABLE err
)

if(NOT ret EQUAL 0)

message(VERBOSE "${ret}: ${out}: ${err}")

execute_process(
COMMAND ${Python_EXECUTABLE} -c "import distutils.sysconfig; x=distutils.sysconfig.get_config_var('EXT_SUFFIX'); assert x is not None; print(x)"
OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE ret
OUTPUT_VARIABLE out
ERROR_VARIABLE err
)

endif()

if(NOT ret EQUAL 0)
message(FATAL_ERROR "${ret}: ${out}: ${err}: could not determine f2py output file suffix")
Expand Down
Loading