diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml new file mode 100644 index 0000000..389bf73 --- /dev/null +++ b/.github/workflows/windows-arm64.yml @@ -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 diff --git a/extensions.py b/extensions.py index 0c599c5..9f6c0ae 100644 --- a/extensions.py +++ b/extensions.py @@ -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(), @@ -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, ) diff --git a/test_scripts/native_wheel_smoke.py b/test_scripts/native_wheel_smoke.py new file mode 100644 index 0000000..be48a3c --- /dev/null +++ b/test_scripts/native_wheel_smoke.py @@ -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(" 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() diff --git a/test_scripts/test_build_commands.py b/test_scripts/test_build_commands.py new file mode 100644 index 0000000..b31cf00 --- /dev/null +++ b/test_scripts/test_build_commands.py @@ -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()