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
63 changes: 63 additions & 0 deletions .github/workflows/windows-arm64.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Windows ARM64 wheels

on:
pull_request:
branches: [master]
workflow_dispatch:
release:
types: [published]

permissions:
contents: read

jobs:
build-and-test:
runs-on: windows-11-arm
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
# Same versions as build.yml, except 3.10: NumPy (needed to build and
# test) publishes no Windows ARM64 wheels for CPython 3.10.
python-version: ['3.11', '3.12', '3.13', '3.14']
env:
CMAKE_GENERATOR: Ninja
CMAKE_BUILD_PARALLEL_LEVEL: '2'
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
architecture: arm64
- uses: ilammy/msvc-dev-cmd@v1
with:
arch: arm64
- name: Install build tools
run: python -m pip install 'cmake<4' ninja swig 'numpy>=2,<3' setuptools wheel
- name: Check build command regression
run: python -m unittest discover -s test_scripts -p test_build_commands.py -v
- name: Build wheel
run: python setup.py bdist_wheel -d dist
- name: Install and test the wheel in a clean environment
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
python -m venv wheel-test
if ($LASTEXITCODE -ne 0) { throw 'venv creation failed' }
$python = Join-Path $PWD 'wheel-test/Scripts/python.exe'
$wheels = @(Get-ChildItem 'dist/nlopt-*-win_arm64.whl')
if ($wheels.Count -ne 1) { throw 'Expected exactly one ARM64 wheel' }
& $python -m pip install $wheels[0].FullName
if ($LASTEXITCODE -ne 0) { throw 'wheel installation failed' }
& $python extern/nlopt/test/t_python.py
if ($LASTEXITCODE -ne 0) { throw 'upstream Python tests failed' }
& $python test_scripts/native_wheel_smoke.py --require-arm64
if ($LASTEXITCODE -ne 0) { throw 'native numerical/PE checks failed' }
# Kept separate from the existing PyPI deployment until maintainers enable it.
- uses: actions/upload-artifact@v4
with:
name: windows-arm64-${{ matrix.python-version }}
path: dist/*.whl
if-no-files-found: error
6 changes: 4 additions & 2 deletions extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ def build_extension(self, ext: Extension):
cmd = [
"cmake",
"-LAH",
f"-DCMAKE_BUILD_TYPE={self.config}",
f"-DCMAKE_LIBRARY_OUTPUT_DIRECTORY={_ed}",
f"-DPython_EXECUTABLE={sys.executable}",
"-DNLOPT_GUILE=OFF",
"-DNLOPT_JAVA=OFF",
"-DNLOPT_MATLAB=OFF",
"-DNLOPT_OCTAVE=OFF",
ext.source_dir.as_posix(),
Expand Down Expand Up @@ -79,8 +81,8 @@ def build_extension(self, ext: Extension):
".",
"--config",
self.config,
"--",
"-m" if platform.system() == "Windows" else "-j2",
"--parallel",
os.environ.get("CMAKE_BUILD_PARALLEL_LEVEL", "2"),
],
cwd=build_dir,
)
Expand Down
75 changes: 75 additions & 0 deletions test_scripts/native_wheel_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Installed-wheel numerical and native Windows ARM64 regression checks."""
import argparse
import json
import struct
import sys
import sysconfig
from pathlib import Path

import nlopt
import nlopt._nlopt
import numpy as np


def pe_machine(path):
data = Path(path).read_bytes()
assert data[:2] == b"MZ", path
offset = struct.unpack_from("<I", data, 0x3C)[0]
assert data[offset:offset + 4] == b"PE\0\0", path
return struct.unpack_from("<H", data, offset + 4)[0]


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--require-arm64", action="store_true")
args = parser.parse_args()
# Convex quadratic: the unique minimizer is known independently of NLopt.
target = np.array([1.25, -2.5, 0.75])
calls = [0]

def objective(x, grad):
assert isinstance(x, np.ndarray)
calls[0] += 1
if grad.size:
grad[:] = 2 * (x - target)
return float(np.dot(x - target, x - target))

opt = nlopt.opt(nlopt.LD_LBFGS, 3)
opt.set_min_objective(objective)
opt.set_xtol_rel(1e-10)
opt.set_maxeval(200)
result = opt.optimize(np.array([8.0, 3.0, -7.0]))
np.testing.assert_allclose(result, target, atol=1e-7, rtol=0)
assert opt.last_optimum_value() < 1e-12
assert opt.last_optimize_result() > 0
assert calls[0] > 1

# A derivative-free bound-constrained problem exercises another algorithm.
bounded = nlopt.opt(nlopt.LN_COBYLA, 3)
bounded.set_lower_bounds([0.0, 0.0, 0.0])
bounded.set_min_objective(objective)
bounded.set_xtol_rel(1e-9)
bounded.set_maxeval(1000)
bounded_result = bounded.optimize([4.0, 3.0, 2.0])
np.testing.assert_allclose(bounded_result, [1.25, 0.0, 0.75], atol=1e-5, rtol=0)
assert abs(bounded.last_optimum_value() - 6.25) < 1e-8
assert bounded.last_optimize_result() > 0

binaries = {}
if args.require_arm64:
assert sysconfig.get_platform() == "win-arm64", sysconfig.get_platform()
paths = [Path(sys.executable), Path(nlopt._nlopt.__file__)]
paths += list(Path(np.__file__).parent.rglob("*.pyd"))
assert len(paths) > 2, "NumPy extensions not found"
for path in paths:
machine = pe_machine(path)
assert machine == 0xAA64, (path, hex(machine))
binaries[path.name] = hex(machine)
print(json.dumps({"python": sys.version, "platform": sysconfig.get_platform(),
"nlopt": nlopt.__version__, "numpy": np.__version__,
"quadratic": result.tolist(), "bounded": bounded_result.tolist(),
"objective_calls": calls[0], "pe_machine": binaries}))


if __name__ == "__main__":
main()
47 changes: 47 additions & 0 deletions test_scripts/test_build_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Check CMake command construction without invoking the native toolchain."""
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from setuptools import Distribution

from extensions import NLOptBuild, NLOptBuildExtension


class BuildCommandsTest(unittest.TestCase):
def test_generator_neutral_parallelism_and_build_type(self):
for system in ('Windows', 'Linux'):
for debug in (False, True):
with self.subTest(system=system, debug=debug), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
command = NLOptBuild(Distribution({'version': '2.11.0'}))
command.build_temp = str(root / 'temp')
command.build_lib = str(root / 'lib')
command.debug = debug
extension = NLOptBuildExtension('nlopt._nlopt', '2.11.0')
extension_path = root / 'lib' / 'nlopt' / '_nlopt.pyd'
extension_path.parent.mkdir(parents=True)
calls = []

def execute(cmd, cwd, calls=calls, **kwargs):
calls.append(cmd)
if '--build' in cmd:
wrapper = cwd / 'extern/nlopt/src/swig/python/nlopt.py'
wrapper.parent.mkdir(parents=True)
wrapper.write_text('# generated test fixture\n')

with patch('extensions.platform.system', return_value=system), \
patch.object(command, 'get_ext_fullpath', return_value=str(extension_path)), \
patch('extensions.execute_command', side_effect=execute), \
patch.dict(os.environ, {'CMAKE_BUILD_PARALLEL_LEVEL': '2'}):
command.build_extension(extension)
config = 'Debug' if debug else 'Release'
self.assertIn('-DNLOPT_JAVA=OFF', calls[0])
self.assertIn(f'-DCMAKE_BUILD_TYPE={config}', calls[0])
self.assertEqual(calls[1], ['cmake', '--build', '.', '--config', config, '--parallel', '2'])


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