From 52eb7a0c2da2dacf233dc2650dd0678fef914adc Mon Sep 17 00:00:00 2001 From: Xudong Han <42986190+han-xudong@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:37:48 +0800 Subject: [PATCH] Add Windows wheels with netft-cpp 0.3.0 --- .github/workflows/ci.yml | 18 +- .github/workflows/codeql.yml | 2 +- .github/workflows/coverage.yml | 2 +- .github/workflows/release.yml | 31 ++- .github/workflows/wheels.yml | 69 +++++- CHANGELOG.md | 11 + CMakeLists.txt | 14 +- README.md | 10 +- core/CMakeLists.txt | 36 ++- core/SNAPSHOT.sha256 | 15 +- core/UPSTREAM | 4 +- core/src/detail/client_impl.cpp | 4 +- core/src/detail/client_impl.hpp | 8 +- core/src/detail/posix_transport.hpp | 31 --- core/src/detail/udp_transport.hpp | 55 +++++ ..._transport.cpp => udp_transport_posix.cpp} | 65 +++-- core/src/detail/udp_transport_windows.cpp | 233 ++++++++++++++++++ core/src/discovery.cpp | 9 + pixi.toml | 4 +- pyproject.toml | 9 +- tests/artifact/test_wheel.py | 2 +- tests/python/test_import.py | 2 +- tests/quality/test_wheel_configuration.py | 213 ++++++++++++---- tests/release/test_metadata.py | 107 +++++--- tools/build_windows_curl.ps1 | 101 ++++++++ tools/check_release_artifacts.py | 2 + tools/check_wheel.py | 60 ++++- 27 files changed, 931 insertions(+), 186 deletions(-) delete mode 100644 core/src/detail/posix_transport.hpp create mode 100644 core/src/detail/udp_transport.hpp rename core/src/detail/{posix_transport.cpp => udp_transport_posix.cpp} (65%) create mode 100644 core/src/detail/udp_transport_windows.cpp create mode 100644 tools/build_windows_curl.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42d361c..6b9dc0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -39,7 +39,7 @@ jobs: name: Format, static analysis, snapshot, and metadata runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: prefix-dev/setup-pixi@v0.10.0 @@ -66,9 +66,11 @@ jobs: pixi run python - <<'PY' from importlib.metadata import metadata, version + import pynetft + project = metadata("pynetft") assert project["Name"] == "pynetft" - assert version("pynetft") == "2.0.1" + assert version("pynetft") == pynetft.__version__ assert project["Requires-Python"] == ">=3.10" PY @@ -80,7 +82,7 @@ jobs: matrix: build-type: [Debug, Release] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: prefix-dev/setup-pixi@v0.10.0 @@ -108,7 +110,7 @@ jobs: arch: arm64 runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: prefix-dev/setup-pixi@v0.10.0 @@ -140,7 +142,7 @@ jobs: - library: static build-shared: "OFF" steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: prefix-dev/setup-pixi@v0.10.0 @@ -164,7 +166,7 @@ jobs: name: Native ASan and UBSan runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: prefix-dev/setup-pixi@v0.10.0 @@ -200,7 +202,7 @@ jobs: name: ARM64 Release runs-on: ubuntu-24.04-arm steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: prefix-dev/setup-pixi@v0.10.0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0dd4356..33c2546 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,7 +30,7 @@ jobs: build-mode: manual suppression-pack: +codeql/cpp-queries:AlertSuppression.ql steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - name: Initialize Python CodeQL diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index fbdc50c..56341cb 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -17,7 +17,7 @@ jobs: coverage: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: prefix-dev/setup-pixi@v0.10.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c666810..56fe2b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,6 +147,35 @@ jobs: path: wheelhouse/*.whl if-no-files-found: error + windows-wheels: + needs: validate + runs-on: windows-2025 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + - name: Install wheel build and validation tools + run: python -m pip install cibuildwheel==3.4.1 pefile twine + - name: Build and install-test every supported wheel + run: python -m cibuildwheel --platform windows --output-dir wheelhouse + env: + CIBW_ARCHS_WINDOWS: AMD64 + - name: Validate every wheel + shell: pwsh + run: | + $wheels = Get-ChildItem wheelhouse/*.whl + python -m twine check $wheels + python tools/check_wheel.py --self-contained $wheels + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-wheels-windows-x86_64 + path: wheelhouse/*.whl + if-no-files-found: error + sdist: needs: validate runs-on: ubuntu-24.04 @@ -173,7 +202,7 @@ jobs: if-no-files-found: error assemble: - needs: [wheels, macos-wheels, sdist] + needs: [wheels, macos-wheels, windows-wheels, sdist] runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 0cd925d..3bbdecd 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -17,7 +17,7 @@ jobs: curl-build: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -39,7 +39,7 @@ jobs: needs: curl-build runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -78,7 +78,7 @@ jobs: runner: macos-15 runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -104,6 +104,36 @@ jobs: path: wheelhouse/*.whl if-no-files-found: error + windows-smoke: + if: github.event_name == 'pull_request' + needs: curl-build + runs-on: windows-2025 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + - name: Install wheel build frontend + run: python -m pip install cibuildwheel==3.4.1 pefile + - name: Build CPython 3.10 Windows wheel + run: python -m cibuildwheel --platform windows --output-dir wheelhouse + env: + CIBW_BUILD: cp310-* + CIBW_ARCHS_WINDOWS: AMD64 + - name: Validate wheel structure and dependencies + shell: pwsh + run: | + $wheels = Get-ChildItem wheelhouse/*.whl + python tools/check_wheel.py --self-contained $wheels + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-smoke-windows-x86_64 + path: wheelhouse/*.whl + if-no-files-found: error + wheels: if: github.event_name != 'pull_request' needs: curl-build @@ -117,7 +147,7 @@ jobs: runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -155,7 +185,7 @@ jobs: runner: macos-15 runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -179,3 +209,32 @@ jobs: name: wheels-macos-${{ matrix.arch }} path: wheelhouse/*.whl if-no-files-found: error + + windows-wheels: + if: github.event_name != 'pull_request' + needs: curl-build + runs-on: windows-2025 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + - name: Install wheel build frontend + run: python -m pip install cibuildwheel==3.4.1 pefile + - name: Build supported Windows wheels + run: python -m cibuildwheel --platform windows --output-dir wheelhouse + env: + CIBW_ARCHS_WINDOWS: AMD64 + - name: Validate wheel structure and dependencies + shell: pwsh + run: | + $wheels = Get-ChildItem wheelhouse/*.whl + python tools/check_wheel.py --self-contained $wheels + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: wheels-windows-x86_64 + path: wheelhouse/*.whl + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd0544..9fd2bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project are documented in this file. +## 2.1.0 - 2026-07-29 + +### Added + +- Publish self-contained CPython 3.10–3.14 wheels for 64-bit Windows. + +### Changed + +- Update the pinned native core to netft-cpp 0.3.0, including its WinSock transport and cross-platform lifecycle coverage. +- Build a pinned static HTTP-only curl dependency for Windows wheels. + ## 2.0.1 - 2026-07-25 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index ad2a0a0..379d2de 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,16 +1,28 @@ cmake_minimum_required(VERSION 3.16) -project(pynetft VERSION 2.0.1 LANGUAGES CXX) +project(pynetft VERSION 2.1.0 LANGUAGES CXX) option(PYNETFT_BUILD_TESTING "Build pyNetFT native tests" OFF) set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) set(BUILD_TESTING OFF CACHE BOOL "" FORCE) +set(NETFT_BUILD_CLI OFF CACHE BOOL "" FORCE) set(CMAKE_POSITION_INDEPENDENT_CODE ON) find_package(Python COMPONENTS Interpreter Development.Module REQUIRED) find_package(pybind11 CONFIG REQUIRED) add_subdirectory(core EXCLUDE_FROM_ALL) +if(WIN32) + target_compile_definitions(netft PRIVATE CURL_STATICLIB) + target_link_libraries(netft PRIVATE + advapi32 + bcrypt + crypt32 + iphlpapi + secur32 + ws2_32 + ) +endif() pybind11_add_module(_native MODULE bindings/python/bind_types.cpp diff --git a/README.md b/README.md index 5e56646..56f6bae 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,18 @@ pyNetFT is a synchronous, typed Python client for ATI Industrial Automation Net F/T Ethernet force/torque sensors. It discovers the sensor calibration, streams RDT measurements through a native C++ core, and exposes raw counts, physical measurements, health, and recovery information without requiring NumPy. -- **Tested native core:** the protocol and recovery implementation is pinned to [netft-cpp 0.2.2](https://github.com/netft/netft-cpp/releases/tag/v0.2.2), includes its GCC 10/manylinux2014 compatibility fix and corrected libcurl minimum, and is exercised with offline fake sensors and native sanitizers. +- **Tested native core:** the protocol and recovery implementation is pinned to [netft-cpp 0.3.0](https://github.com/netft/netft-cpp/releases/tag/v0.3.0) and is exercised with offline fake sensors and native sanitizers. - **Sensor-aware data:** calibration, force and torque units, configuration revisions, sequence progress, and faults remain visible to the application. -- **Self-contained typed wheels:** Linux and macOS wheels include the native core and a minimal static HTTP-only curl build, plus inline type information for Python 3.10–3.14. +- **Self-contained typed wheels:** Linux, macOS, and Windows wheels include the native core and a minimal static HTTP-only curl build, plus inline type information for Python 3.10–3.14. ## Supported platforms | Install method | Platform | Support | | --- | --- | --- | -| PyPI | Linux and macOS (x86_64 and ARM64), CPython 3.10–3.14 | Supported | -| Source | Linux, macOS, and other POSIX systems, CPython 3.10–3.14 | Best effort | +| PyPI | Linux and macOS (x86_64 and ARM64), Windows (x86_64), CPython 3.10–3.14 | Supported | +| Source | Linux, macOS, Windows, and other supported C++17 platforms, CPython 3.10–3.14 | Best effort | -Windows, PyPy, and asyncio are not supported by the 2.x package. +PyPy and asyncio are not supported by the 2.x package. ## Installation diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index a2017a4..89f873d 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -1,12 +1,13 @@ cmake_minimum_required(VERSION 3.16) -project(netft VERSION 0.2.2 LANGUAGES CXX) +project(netft VERSION 0.3.0 LANGUAGES CXX) include(CTest) include(GNUInstallDirs) include(CMakePackageConfigHelpers) option(NETFT_SANITIZERS "Enable address and undefined behavior sanitizers" OFF) +option(NETFT_BUILD_CLI "Build the netft command-line tool" ON) add_library(netft_sanitizers INTERFACE) if(NETFT_SANITIZERS) @@ -31,11 +32,16 @@ add_library(netft src/client.cpp src/detail/client_impl.cpp src/detail/fault_latch.cpp - src/detail/posix_transport.cpp src/detail/protocol.cpp src/detail/sequence.cpp src/detail/xml_config.cpp ) +if(WIN32) + target_sources(netft PRIVATE src/detail/udp_transport_windows.cpp) + target_link_libraries(netft PRIVATE ws2_32) +else() + target_sources(netft PRIVATE src/detail/udp_transport_posix.cpp) +endif() add_library(netft::netft ALIAS netft) target_compile_definitions(netft PRIVATE NETFT_BUILDING_LIBRARY) @@ -62,14 +68,16 @@ target_link_libraries(netft $ ) -add_library(netft_cli_lib STATIC app/cli.cpp) -target_compile_features(netft_cli_lib PUBLIC cxx_std_17) -target_include_directories(netft_cli_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/app) -target_link_libraries(netft_cli_lib PUBLIC netft::netft) +if(NETFT_BUILD_CLI) + add_library(netft_cli_lib STATIC app/cli.cpp) + target_compile_features(netft_cli_lib PUBLIC cxx_std_17) + target_include_directories(netft_cli_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/app) + target_link_libraries(netft_cli_lib PUBLIC netft::netft) -add_executable(netft_cli app/main.cpp) -set_target_properties(netft_cli PROPERTIES OUTPUT_NAME netft) -target_link_libraries(netft_cli PRIVATE netft_cli_lib) + add_executable(netft_cli app/main.cpp) + set_target_properties(netft_cli PROPERTIES OUTPUT_NAME netft) + target_link_libraries(netft_cli PRIVATE netft_cli_lib) +endif() install( DIRECTORY include/netft @@ -82,10 +90,12 @@ install( LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} ) -install( - TARGETS netft_cli - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} -) +if(NETFT_BUILD_CLI) + install( + TARGETS netft_cli + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + ) +endif() set(netft_cmake_install_dir "${CMAKE_INSTALL_LIBDIR}/cmake/netft") configure_package_config_file( diff --git a/core/SNAPSHOT.sha256 b/core/SNAPSHOT.sha256 index 9fc701d..bbda350 100644 --- a/core/SNAPSHOT.sha256 +++ b/core/SNAPSHOT.sha256 @@ -1,6 +1,6 @@ -80f3a153e43709863fa355ad1e446be9119e52ff438ef6b7c1d90d004aef4215 CMakeLists.txt +d743e29b839b38a36ab6826314e85611cb388d014729eefaf7e664e9de6dfc99 CMakeLists.txt c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 LICENSE -6382ac405f87e6a2a884e20c02c1f6fe66f35306e1b712e4de74099c62980f7c UPSTREAM +c76dd2f126d2e3b9a146e8516de1e0a000671a533e706453b34d9d5926e8789c UPSTREAM 38f545018e46fcf7c5174769021770c3e11791a3accf7e61b7505d18d0f9d2bf app/cli.cpp 91de2a75f3dfd9db88020acec1fdc72b0ddaf2667eee543d4cdfc0e27be91a2b app/cli.hpp 4963748e0ea83bf1a3ff18e8ea1e5b07d726d4aedd86ffdcbee5f1d2c5774d08 app/main.cpp @@ -11,18 +11,19 @@ ebb4c53b7e7e65880580a7649e1c1a142e3a9db1fdec7d108d2d7dde0ad0d36d include/netft/ 912892bcb8bb5f95392c9c74983d91aab103b7adc5735505b0ac97e1012893b1 include/netft/status.hpp a7c899858aa7a0a28780504f14c1ecdaebb375607119a03500903f3782f45a68 include/netft/types.hpp e8a68945aaebfd19a1cb1f5cf71f4666d997fa351a41e9adcd137b50a7eb3ae7 src/client.cpp -5f4e7d2e95b567b5cf53f5e01a6c32c4a677301f39a75ddd7a7a3bd10de13d84 src/detail/client_impl.cpp -d16e127cc0e950da497bf031150d01feff47dd8adb704a8e43e9b8245057cc4a src/detail/client_impl.hpp +7f7b998e4a29c358365235aeaf44531d2bc3a189187272a676c5580ac211bca2 src/detail/client_impl.cpp +5d194b6b6cb8b04e742ee3e1508ac7b8e7972b5bbc7a02c94003d1f402dfccf1 src/detail/client_impl.hpp ad8744385642693bb4666c428a5dbd0d6eff350b0079a6c9ff720512e89b6a53 src/detail/fault_latch.cpp be488c557e17044948cd62e6de07bf54862d389a584b40b78336479cecdaf23c src/detail/fault_latch.hpp -c1bf750153787c5a242d906bc2dfacfb5d5d3956b4aa42395b97372d39a6ca43 src/detail/posix_transport.cpp -2632f9899c0e1c965e404916a41e3a3be8d1885ec988e28b02ef11161e0db860 src/detail/posix_transport.hpp f5953a7fc4fd5eee56f7aa3568fe7417010e3e767cfbb0ee5922ee5f8aa2a9aa src/detail/protocol.cpp 8ef13600d67b2bb77181c5403e9b51b7f48b6f8ef06381fb1f07c5b9b2a90d8a src/detail/protocol.hpp fe9034a4a32138c3469005a44a1fbd61cfe41df25207723635a22e8ed376dbf5 src/detail/sequence.cpp 72daab01251425dd7fd5d26334760f4937f94e674a16df0fb38301b0ee78b9c1 src/detail/sequence.hpp +78098e0c98c84103653b7aa9e2baf689cde656b52e1e13873f7491aed7f35ff5 src/detail/udp_transport.hpp +6c4fb6a8b6bdfc9f1364ac0d76423e9972e1eb30bb987aa6865e191e7d93f9ea src/detail/udp_transport_posix.cpp +9a9e34da6e9d66e0918e85da61ebc60f9ae3806a0471c327f818e8ccd210426e src/detail/udp_transport_windows.cpp 3e908eac916b3cf457387fd878dd77fb46e3b5a9eee37dfc60be788649476475 src/detail/xml_config.cpp f10ff61cd2cc645abca23a7abce37439273569c6fe139a4fb07041532edb908d src/detail/xml_config.hpp -07417ba68a9a23cde2b4434fe1f7cde468b524682c2d97799e65a2b18502f8af src/discovery.cpp +5a25276d0307c6a341aaee525cc9a66f00cda06e30bd345716ff5ee17a57e3e7 src/discovery.cpp 2598c182a2322925c2d99e1d0bba3b57215e451ba1ecc8df6b108b885f089c1f src/status.cpp 6017859e709c6b80fd5dc444554e497410e1c7d19a12ae05502f0c9922c58762 src/types.cpp diff --git a/core/UPSTREAM b/core/UPSTREAM index 4ae6624..3f85795 100644 --- a/core/UPSTREAM +++ b/core/UPSTREAM @@ -1,4 +1,4 @@ repository=https://github.com/netft/netft-cpp -tag=v0.2.2 -commit=e424c401587052f03de9b94f76f1e86b78902105 +tag=v0.3.0 +commit=46ee05639f818a17c1cfe604d0d77b1feb8f9b2b paths=CMakeLists.txt,LICENSE,app,cmake,include,src diff --git a/core/src/detail/client_impl.cpp b/core/src/detail/client_impl.cpp index 2726149..1314d43 100644 --- a/core/src/detail/client_impl.cpp +++ b/core/src/detail/client_impl.cpp @@ -224,8 +224,8 @@ void Client::Impl::start(SampleCallback callback) { } std::thread Client::Impl::create_worker_thread() { - if (thread_factory_ != nullptr) { - return thread_factory_(this); + if (thread_creation_test_hook_ != nullptr) { + thread_creation_test_hook_(); } return std::thread([this] { run(); }); } diff --git a/core/src/detail/client_impl.hpp b/core/src/detail/client_impl.hpp index 9eb871d..512ea21 100644 --- a/core/src/detail/client_impl.hpp +++ b/core/src/detail/client_impl.hpp @@ -10,9 +10,9 @@ #include #include "detail/fault_latch.hpp" -#include "detail/posix_transport.hpp" #include "detail/protocol.hpp" #include "detail/sequence.hpp" +#include "detail/udp_transport.hpp" #include "netft/client.hpp" namespace netft { @@ -64,7 +64,7 @@ class NETFT_LOCAL Client::Impl { void record_callback_error(const char *message) noexcept; void finish_session() noexcept; - using ThreadFactory = std::thread (*)(Impl *); + using ThreadCreationHook = void (*)(); using FaultPublishedHook = void (*)(Impl *) noexcept; using WaitWakeHook = void (*)(Impl *, std::uint64_t) noexcept; @@ -78,7 +78,7 @@ class NETFT_LOCAL Client::Impl { mutable std::mutex data_mutex_; std::condition_variable first_sample_cv_; std::thread worker_; - ThreadFactory thread_factory_{nullptr}; + ThreadCreationHook thread_creation_test_hook_{nullptr}; // Internal synchronization hooks used only by lifecycle tests. Client's // installed API remains opaque and does not expose these seams. FaultPublishedHook fault_published_test_hook_{nullptr}; @@ -86,7 +86,7 @@ class NETFT_LOCAL Client::Impl { std::thread::id active_worker_id_; bool joining_{false}; SampleCallback callback_; - detail::PosixTransport transport_; + detail::UdpTransport transport_; detail::RdtSequenceTracker rdt_sequence_; detail::FtSequenceTracker ft_sequence_; std::atomic stopping_{false}; diff --git a/core/src/detail/posix_transport.hpp b/core/src/detail/posix_transport.hpp deleted file mode 100644 index 68b59ee..0000000 --- a/core/src/detail/posix_transport.hpp +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -namespace netft::detail { - -class PosixTransport { -public: - PosixTransport() = default; - ~PosixTransport(); - PosixTransport(const PosixTransport &) = delete; - PosixTransport &operator=(const PosixTransport &) = delete; - - void connect(const std::string &host, int port); - void send(const std::array &request); - std::size_t receive(std::uint8_t *data, std::size_t capacity, - std::chrono::duration timeout); - void shutdown() noexcept; - void close() noexcept; - -private: - mutable std::mutex mutex_; - int socket_{-1}; -}; - -} // namespace netft::detail diff --git a/core/src/detail/udp_transport.hpp b/core/src/detail/udp_transport.hpp new file mode 100644 index 0000000..8a7bae3 --- /dev/null +++ b/core/src/detail/udp_transport.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace netft::detail { + +#ifdef _WIN32 +class WinSockRuntime { +public: + WinSockRuntime(); + ~WinSockRuntime(); + WinSockRuntime(const WinSockRuntime &) = delete; + WinSockRuntime &operator=(const WinSockRuntime &) = delete; +}; +#endif + +class UdpTransport { +public: + using WaitStartedTestHook = void (*)(void *) noexcept; + + UdpTransport() = default; + ~UdpTransport(); + UdpTransport(const UdpTransport &) = delete; + UdpTransport &operator=(const UdpTransport &) = delete; + + void connect(const std::string &host, int port); + void send(std::array request); + std::size_t receive(std::uint8_t *data, std::size_t capacity, + std::chrono::duration timeout); + void shutdown() noexcept; + void close() noexcept; + + void set_wait_started_test_hook(WaitStartedTestHook hook, void *context) { + std::scoped_lock lock(mutex_); + wait_started_test_hook_ = hook; + wait_started_test_context_ = context; + } + +private: +#ifdef _WIN32 + WinSockRuntime runtime_; +#endif + mutable std::mutex mutex_; + std::uintptr_t socket_{~std::uintptr_t{0}}; + bool shutdown_requested_{false}; + WaitStartedTestHook wait_started_test_hook_{nullptr}; + void *wait_started_test_context_{nullptr}; +}; + +} // namespace netft::detail diff --git a/core/src/detail/posix_transport.cpp b/core/src/detail/udp_transport_posix.cpp similarity index 65% rename from core/src/detail/posix_transport.cpp rename to core/src/detail/udp_transport_posix.cpp index 5943450..5af2b7f 100644 --- a/core/src/detail/posix_transport.cpp +++ b/core/src/detail/udp_transport_posix.cpp @@ -1,4 +1,4 @@ -#include "detail/posix_transport.hpp" +#include "detail/udp_transport.hpp" #include #include @@ -26,11 +26,13 @@ int timeout_milliseconds(const std::chrono::duration timeout) { return static_cast(milliseconds); } +constexpr std::uintptr_t kInvalidSocket = ~std::uintptr_t{0}; + } // namespace -PosixTransport::~PosixTransport() { close(); } +UdpTransport::~UdpTransport() { close(); } -void PosixTransport::connect(const std::string &host, const int port) { +void UdpTransport::connect(const std::string &host, const int port) { addrinfo hints{}; hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_DGRAM; @@ -64,18 +66,19 @@ void PosixTransport::connect(const std::string &host, const int port) { } std::scoped_lock lock(mutex_); - if (socket_ >= 0) { - ::close(socket_); + if (socket_ != kInvalidSocket) { + ::close(static_cast(socket_)); } - socket_ = connected_socket; + socket_ = static_cast(connected_socket); + shutdown_requested_ = false; } -void PosixTransport::send(const std::array &request) { +void UdpTransport::send(const std::array request) { std::scoped_lock lock(mutex_); - if (socket_ < 0) { + if (socket_ == kInvalidSocket) { throw std::runtime_error("UDP socket is not connected"); } - const auto sent = ::send(socket_, request.data(), request.size(), 0); + const auto sent = ::send(static_cast(socket_), request.data(), request.size(), 0); if (sent < 0) { throw socket_error("failed to send UDP request"); } @@ -84,21 +87,31 @@ void PosixTransport::send(const std::array &request) { } } -std::size_t PosixTransport::receive(std::uint8_t *data, const std::size_t capacity, - const std::chrono::duration timeout) { +std::size_t UdpTransport::receive(std::uint8_t *data, const std::size_t capacity, + const std::chrono::duration timeout) { int socket = -1; + WaitStartedTestHook wait_started_hook = nullptr; + void *wait_started_context = nullptr; { std::scoped_lock lock(mutex_); - socket = socket_; - } - if (socket < 0) { - throw std::runtime_error("UDP socket is not connected"); + if (socket_ == kInvalidSocket) { + throw std::runtime_error("UDP socket is not connected"); + } + if (shutdown_requested_) { + return 0; + } + socket = static_cast(socket_); + wait_started_hook = wait_started_test_hook_; + wait_started_context = wait_started_test_context_; } pollfd descriptor{socket, POLLIN, 0}; const auto deadline = std::chrono::steady_clock::now() + timeout; auto remaining = timeout; int poll_result{}; + if (wait_started_hook != nullptr) { + wait_started_hook(wait_started_context); + } while (true) { poll_result = ::poll(&descriptor, 1, timeout_milliseconds(remaining)); if (poll_result >= 0) { @@ -117,6 +130,12 @@ std::size_t PosixTransport::receive(std::uint8_t *data, const std::size_t capaci if (poll_result == 0) { return 0; } + { + std::scoped_lock lock(mutex_); + if (shutdown_requested_) { + return 0; + } + } if ((descriptor.revents & POLLNVAL) != 0) { throw std::runtime_error("UDP socket became invalid"); } @@ -131,18 +150,20 @@ std::size_t PosixTransport::receive(std::uint8_t *data, const std::size_t capaci return static_cast(received); } -void PosixTransport::shutdown() noexcept { +void UdpTransport::shutdown() noexcept { std::scoped_lock lock(mutex_); - if (socket_ >= 0) { - static_cast(::shutdown(socket_, SHUT_RDWR)); + shutdown_requested_ = true; + if (socket_ != kInvalidSocket) { + static_cast(::shutdown(static_cast(socket_), SHUT_RDWR)); } } -void PosixTransport::close() noexcept { +void UdpTransport::close() noexcept { std::scoped_lock lock(mutex_); - if (socket_ >= 0) { - ::close(socket_); - socket_ = -1; + shutdown_requested_ = true; + if (socket_ != kInvalidSocket) { + ::close(static_cast(socket_)); + socket_ = kInvalidSocket; } } diff --git a/core/src/detail/udp_transport_windows.cpp b/core/src/detail/udp_transport_windows.cpp new file mode 100644 index 0000000..1330f54 --- /dev/null +++ b/core/src/detail/udp_transport_windows.cpp @@ -0,0 +1,233 @@ +#include "detail/udp_transport.hpp" + +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0600 +#endif +#include +#include + +#include +#include +#include +#include + +namespace netft::detail { +namespace { + +constexpr std::uintptr_t kInvalidSocket = ~std::uintptr_t{0}; + +std::runtime_error winsock_error(const char *operation, const int error) { + return std::runtime_error(std::string{operation} + " (WinSock error " + std::to_string(error) + + ")"); +} + +std::runtime_error last_winsock_error(const char *operation) { + return winsock_error(operation, ::WSAGetLastError()); +} + +int timeout_milliseconds(const std::chrono::duration timeout) { + const auto milliseconds = std::ceil(timeout.count() * 1000.0); + if (milliseconds >= static_cast(std::numeric_limits::max())) { + return std::numeric_limits::max(); + } + return static_cast(milliseconds); +} + +SOCKET native_socket(const std::uintptr_t socket) { return static_cast(socket); } + +int socket_wait_error(const SOCKET socket, const short events) { + if ((events & POLLNVAL) != 0) { + return WSAENOTSOCK; + } + + int error = 0; + int error_size = sizeof(error); + if ((events & POLLERR) != 0 && + ::getsockopt(socket, SOL_SOCKET, SO_ERROR, reinterpret_cast(&error), &error_size) == + 0 && + error != 0) { + return error; + } + return WSAECONNRESET; +} + +} // namespace + +WinSockRuntime::WinSockRuntime() { + WSADATA data{}; + const int result = ::WSAStartup(MAKEWORD(2, 2), &data); + if (result != 0) { + throw winsock_error("failed to initialize WinSock", result); + } +} + +WinSockRuntime::~WinSockRuntime() { ::WSACleanup(); } + +UdpTransport::~UdpTransport() { close(); } + +void UdpTransport::connect(const std::string &host, const int port) { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_DGRAM; + addrinfo *addresses = nullptr; + const auto service = std::to_string(port); + const int result = ::getaddrinfo(host.c_str(), service.c_str(), &hints, &addresses); + if (result != 0) { + throw winsock_error("failed to resolve sensor", result); + } + + SOCKET connected_socket = INVALID_SOCKET; + int last_error = WSAEHOSTUNREACH; + for (auto *address = addresses; address != nullptr; address = address->ai_next) { + connected_socket = ::socket(address->ai_family, address->ai_socktype, address->ai_protocol); + if (connected_socket == INVALID_SOCKET) { + last_error = ::WSAGetLastError(); + continue; + } + if (::connect(connected_socket, address->ai_addr, static_cast(address->ai_addrlen)) == 0) { + break; + } + last_error = ::WSAGetLastError(); + ::closesocket(connected_socket); + connected_socket = INVALID_SOCKET; + } + ::freeaddrinfo(addresses); + + if (connected_socket == INVALID_SOCKET) { + throw winsock_error("failed to connect UDP socket", last_error); + } + + std::scoped_lock lock(mutex_); + if (socket_ != kInvalidSocket) { + ::closesocket(native_socket(socket_)); + } + socket_ = static_cast(connected_socket); + shutdown_requested_ = false; +} + +void UdpTransport::send(const std::array request) { + std::scoped_lock lock(mutex_); + if (socket_ == kInvalidSocket) { + throw std::runtime_error("UDP socket is not connected"); + } + const auto sent = ::send(native_socket(socket_), reinterpret_cast(request.data()), + static_cast(request.size()), 0); + if (sent == SOCKET_ERROR) { + throw last_winsock_error("failed to send UDP request"); + } + if (static_cast(sent) != request.size()) { + throw std::runtime_error("short UDP request write"); + } +} + +std::size_t UdpTransport::receive(std::uint8_t *data, const std::size_t capacity, + const std::chrono::duration timeout) { + SOCKET socket = INVALID_SOCKET; + WaitStartedTestHook wait_started_hook = nullptr; + void *wait_started_context = nullptr; + { + std::scoped_lock lock(mutex_); + if (socket_ == kInvalidSocket) { + throw std::runtime_error("UDP socket is not connected"); + } + if (shutdown_requested_) { + return 0; + } + socket = native_socket(socket_); + wait_started_hook = wait_started_test_hook_; + wait_started_context = wait_started_test_context_; + } + + WSAPOLLFD descriptor{socket, POLLRDNORM, 0}; + if (wait_started_hook != nullptr) { + wait_started_hook(wait_started_context); + } + const auto bounded_timeout = std::max(timeout, std::chrono::duration::zero()); + const auto deadline = std::chrono::steady_clock::now() + bounded_timeout; + constexpr int kShutdownCheckIntervalMilliseconds = 50; + constexpr auto kShutdownCheckInterval = + std::chrono::milliseconds{kShutdownCheckIntervalMilliseconds}; + for (;;) { + { + std::scoped_lock lock(mutex_); + if (shutdown_requested_) { + return 0; + } + } + + const auto now = std::chrono::steady_clock::now(); + const auto remaining = deadline - now; + const auto poll_timeout = remaining > std::chrono::steady_clock::duration::zero() + ? std::min(std::chrono::duration{remaining}, + std::chrono::duration{kShutdownCheckInterval}) + : std::chrono::duration::zero(); + descriptor.revents = 0; + const int poll_result = + ::WSAPoll(&descriptor, 1, + std::min(kShutdownCheckIntervalMilliseconds, timeout_milliseconds(poll_timeout))); + if (poll_result == SOCKET_ERROR) { + const int error = ::WSAGetLastError(); + if (error == WSAEINTR) { + return 0; + } + throw winsock_error("failed to wait for UDP record", error); + } + if (poll_result > 0) { + break; + } + if (std::chrono::steady_clock::now() >= deadline) { + return 0; + } + } + { + std::scoped_lock lock(mutex_); + if (shutdown_requested_) { + return 0; + } + } + if ((descriptor.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) { + throw winsock_error("failed to wait for UDP record", + socket_wait_error(socket, descriptor.revents)); + } + + const auto bounded_capacity = + std::min(capacity, static_cast(std::numeric_limits::max())); + const auto received = + ::recv(socket, reinterpret_cast(data), static_cast(bounded_capacity), 0); + if (received == SOCKET_ERROR) { + const int error = ::WSAGetLastError(); + { + std::scoped_lock lock(mutex_); + if (shutdown_requested_) { + return 0; + } + } + if (error == WSAEINTR || error == WSAESHUTDOWN) { + return 0; + } + throw winsock_error("failed to receive UDP record", error); + } + return static_cast(received); +} + +void UdpTransport::shutdown() noexcept { + std::scoped_lock lock(mutex_); + shutdown_requested_ = true; + if (socket_ != kInvalidSocket) { + static_cast(::shutdown(native_socket(socket_), SD_BOTH)); + } +} + +void UdpTransport::close() noexcept { + std::scoped_lock lock(mutex_); + shutdown_requested_ = true; + if (socket_ != kInvalidSocket) { + static_cast(::closesocket(native_socket(socket_))); + socket_ = kInvalidSocket; + } +} + +} // namespace netft::detail diff --git a/core/src/discovery.cpp b/core/src/discovery.cpp index 69eb48e..b3eda81 100644 --- a/core/src/discovery.cpp +++ b/core/src/discovery.cpp @@ -1,5 +1,14 @@ #include "netft/discovery.hpp" +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#endif + #include #include diff --git a/pixi.toml b/pixi.toml index 89e4d8e..6424990 100644 --- a/pixi.toml +++ b/pixi.toml @@ -68,6 +68,6 @@ check = { depends-on = [ "native-tidy", ] } wheel = "python -m build --sdist --wheel --no-isolation" -wheel-check = "python tools/check_wheel.py dist/pynetft-2.0.1-*.whl" -sdist-check = "python tools/check_sdist.py dist/pynetft-2.0.1.tar.gz" +wheel-check = "python tools/check_wheel.py dist/pynetft-2.1.0-*.whl" +sdist-check = "python tools/check_sdist.py dist/pynetft-2.1.0.tar.gz" hardware-test = "python tools/hardware_test.py" diff --git a/pyproject.toml b/pyproject.toml index af2016d..06bc009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "scikit_build_core.build" [project] name = "pynetft" -version = "2.0.1" +version = "2.1.0" description = "Python client for ATI Net F/T Ethernet force/torque sensors" readme = "README.md" requires-python = ">=3.10" @@ -16,6 +16,8 @@ license-files = ["LICENSE", "LICENSES/*", "core/LICENSE"] authors = [{ name = "Xudong Han" }] classifiers = [ "Development Status :: 4 - Beta", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS", "Operating System :: POSIX :: Linux", "Programming Language :: C++", "Programming Language :: Python :: 3", @@ -96,3 +98,8 @@ archs = ["x86_64", "arm64"] before-all = "bash {project}/tools/build_macos_curl.sh" environment = { MACOSX_DEPLOYMENT_TARGET = "11.0", PYNETFT_CURL_PREFIX = "$RUNNER_TEMP/pynetft-curl", CMAKE_ARGS = "-DCMAKE_DISABLE_FIND_PACKAGE_PkgConfig=ON -DCURL_NO_CURL_CMAKE=ON -DCURL_USE_STATIC_LIBS=ON -DCURL_LIBRARY:FILEPATH=$RUNNER_TEMP/pynetft-curl/lib/libcurl.a -DCURL_INCLUDE_DIR:PATH=$RUNNER_TEMP/pynetft-curl/include" } repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} {wheel}" + +[tool.cibuildwheel.windows] +archs = ["AMD64"] +before-all = "powershell -NoProfile -ExecutionPolicy Bypass -File {project}/tools/build_windows_curl.ps1" +environment = { CMAKE_ARGS = "-DCURL_NO_CURL_CMAKE=ON -DCURL_USE_STATIC_LIBS=ON -DCURL_LIBRARY:FILEPATH=C:/pynetft-curl/lib/libcurl.lib -DCURL_INCLUDE_DIR:PATH=C:/pynetft-curl/include" } diff --git a/tests/artifact/test_wheel.py b/tests/artifact/test_wheel.py index 7a205d1..f52c1fa 100644 --- a/tests/artifact/test_wheel.py +++ b/tests/artifact/test_wheel.py @@ -24,7 +24,7 @@ def test_native_extension_and_type_marker_are_installed() -> None: import pynetft from pynetft import _native - assert pynetft.__version__ == "2.0.1" + assert pynetft.__version__ == "2.1.0" assert _native.__version__ == pynetft.__version__ assert resources.files("pynetft").joinpath("py.typed").is_file() diff --git a/tests/python/test_import.py b/tests/python/test_import.py index b78badf..48d2229 100644 --- a/tests/python/test_import.py +++ b/tests/python/test_import.py @@ -5,7 +5,7 @@ def test_installed_version_and_native_module_agree() -> None: - assert pynetft.__version__ == "2.0.1" + assert pynetft.__version__ == "2.1.0" assert version("pynetft") == pynetft.__version__ assert _native.__version__ == pynetft.__version__ diff --git a/tests/quality/test_wheel_configuration.py b/tests/quality/test_wheel_configuration.py index 4e37046..af3d5f5 100644 --- a/tests/quality/test_wheel_configuration.py +++ b/tests/quality/test_wheel_configuration.py @@ -44,12 +44,12 @@ def _valid_wheel_members(extension: str) -> tuple[str, ...]: "pynetft/__init__.py", extension, "pynetft/py.typed", - "pynetft-2.0.1.dist-info/METADATA", - "pynetft-2.0.1.dist-info/WHEEL", - "pynetft-2.0.1.dist-info/licenses/LICENSE", - "pynetft-2.0.1.dist-info/licenses/LICENSES/MIT.txt", - "pynetft-2.0.1.dist-info/licenses/LICENSES/curl.txt", - "pynetft-2.0.1.dist-info/licenses/core/LICENSE", + "pynetft-2.1.0.dist-info/METADATA", + "pynetft-2.1.0.dist-info/WHEEL", + "pynetft-2.1.0.dist-info/licenses/LICENSE", + "pynetft-2.1.0.dist-info/licenses/LICENSES/MIT.txt", + "pynetft-2.1.0.dist-info/licenses/LICENSES/curl.txt", + "pynetft-2.1.0.dist-info/licenses/core/LICENSE", ) @@ -92,6 +92,36 @@ def test_cibuildwheel_configures_separate_native_macos_wheels() -> None: assert "delocate-wheel" in macos["repair-wheel-command"] +def test_cibuildwheel_configures_native_windows_wheels_with_static_curl() -> None: + windows = _project_configuration()["tool"]["cibuildwheel"]["windows"] # type: ignore[index] + + assert windows["archs"] == ["AMD64"] + assert windows["before-all"] == ( + "powershell -NoProfile -ExecutionPolicy Bypass -File {project}/tools/build_windows_curl.ps1" + ) + assert "-DCURL_USE_STATIC_LIBS=ON" in windows["environment"]["CMAKE_ARGS"] + assert "C:/pynetft-curl/lib/libcurl.lib" in windows["environment"]["CMAKE_ARGS"] + assert "C:/pynetft-curl/include" in windows["environment"]["CMAKE_ARGS"] + + +def test_windows_curl_builder_is_pinned_and_produces_only_a_static_library() -> None: + script = (ROOT / "tools" / "build_windows_curl.ps1").read_text(encoding="utf-8") + + assert "8.21.0" in script + assert "aa1b66a70eace83dc624508745646c08ae561de512ab403adffb93ac87fc72e6" in script + assert "System.Security.Cryptography.SHA256" in script + assert "Get-FileHash" not in script + assert "vswhere.exe" in script + assert '"Visual Studio 18 2026"' in script + assert '"Visual Studio 17 2022"' in script + assert "--config Release" in script + assert "-DHTTP_ONLY=ON" in script + assert "-DBUILD_SHARED_LIBS=OFF" in script + assert "-DBUILD_STATIC_LIBS=ON" in script + assert "libcurl.lib" in script + assert "libcurl.dll" in script + + @pytest.mark.parametrize( "name", ("build_static_curl.sh", "build_manylinux_curl.sh", "build_macos_curl.sh"), @@ -358,19 +388,64 @@ def test_wheel_workflow_has_smoke_and_full_build_modes() -> None: assert "publish" not in workflow["jobs"] +def test_wheel_workflow_builds_and_validates_windows_wheels() -> None: + with (ROOT / ".github" / "workflows" / "wheels.yml").open(encoding="utf-8") as stream: + workflow = yaml.load(stream, Loader=yaml.BaseLoader) + + for name, event_condition, artifact_name, build_override in ( + ( + "windows-smoke", + "github.event_name == 'pull_request'", + "wheels-smoke-windows-x86_64", + "cp310-*", + ), + ( + "windows-wheels", + "github.event_name != 'pull_request'", + "wheels-windows-x86_64", + None, + ), + ): + windows = workflow["jobs"][name] + assert windows["if"] == event_condition + assert windows["runs-on"] == "windows-2025" + install = next( + step for step in windows["steps"] if "cibuildwheel==3.4.1" in step.get("run", "") + ) + assert "pefile" in install["run"] + build = next( + step + for step in windows["steps"] + if step.get("run", "").startswith("python -m cibuildwheel") + ) + assert build["env"]["CIBW_ARCHS_WINDOWS"] == "AMD64" + assert build["env"].get("CIBW_BUILD") == build_override + assert "--platform windows" in build["run"] + validation = next( + step for step in windows["steps"] if "tools/check_wheel.py" in step.get("run", "") + ) + assert "--self-contained" in validation["run"] + upload = next( + step + for step in windows["steps"] + if step.get("uses", "").startswith("actions/upload-artifact@") + ) + assert upload["with"]["name"] == artifact_name + + def test_wheel_checker_accepts_only_the_private_runtime_payload(tmp_path: Path) -> None: checker = _load_wheel_checker() - wheel = tmp_path / "pynetft-2.0.1-cp314-cp314-manylinux2014_x86_64.whl" + wheel = tmp_path / "pynetft-2.1.0-cp314-cp314-manylinux2014_x86_64.whl" required = ( "pynetft/__init__.py", "pynetft/_native.cpython-314-x86_64-linux-gnu.so", "pynetft/py.typed", - "pynetft-2.0.1.dist-info/METADATA", - "pynetft-2.0.1.dist-info/WHEEL", - "pynetft-2.0.1.dist-info/licenses/LICENSE", - "pynetft-2.0.1.dist-info/licenses/LICENSES/MIT.txt", - "pynetft-2.0.1.dist-info/licenses/LICENSES/curl.txt", - "pynetft-2.0.1.dist-info/licenses/core/LICENSE", + "pynetft-2.1.0.dist-info/METADATA", + "pynetft-2.1.0.dist-info/WHEEL", + "pynetft-2.1.0.dist-info/licenses/LICENSE", + "pynetft-2.1.0.dist-info/licenses/LICENSES/MIT.txt", + "pynetft-2.1.0.dist-info/licenses/LICENSES/curl.txt", + "pynetft-2.1.0.dist-info/licenses/core/LICENSE", ) _write_wheel(wheel, members=required) @@ -380,8 +455,8 @@ def test_wheel_checker_accepts_only_the_private_runtime_payload(tmp_path: Path) "bin/netft", "include/netft/client.hpp", "lib/libnetft.so", - "pynetft-2.0.1.data/scripts/netft", - "pynetft-2.0.1.data/headers/netft/client.hpp", + "pynetft-2.1.0.data/scripts/netft", + "pynetft-2.1.0.data/headers/netft/client.hpp", ): rejected = tmp_path / f"rejected-{forbidden.replace('/', '-')}.whl" _write_wheel(rejected, members=(*required, forbidden)) @@ -393,12 +468,12 @@ def test_wheel_checker_rejects_missing_or_duplicate_native_extensions(tmp_path: checker = _load_wheel_checker() base = ( "pynetft/py.typed", - "pynetft-2.0.1.dist-info/METADATA", - "pynetft-2.0.1.dist-info/WHEEL", - "pynetft-2.0.1.dist-info/licenses/LICENSE", - "pynetft-2.0.1.dist-info/licenses/LICENSES/MIT.txt", - "pynetft-2.0.1.dist-info/licenses/LICENSES/curl.txt", - "pynetft-2.0.1.dist-info/licenses/core/LICENSE", + "pynetft-2.1.0.dist-info/METADATA", + "pynetft-2.1.0.dist-info/WHEEL", + "pynetft-2.1.0.dist-info/licenses/LICENSE", + "pynetft-2.1.0.dist-info/licenses/LICENSES/MIT.txt", + "pynetft-2.1.0.dist-info/licenses/LICENSES/curl.txt", + "pynetft-2.1.0.dist-info/licenses/core/LICENSE", ) no_extension = tmp_path / "no-extension.whl" _write_wheel(no_extension, members=base) @@ -421,15 +496,15 @@ def test_wheel_checker_rejects_missing_or_duplicate_native_extensions(tmp_path: @pytest.mark.parametrize( "license_member", ( - "pynetft-2.0.1.dist-info/licenses/LICENSE", - "pynetft-2.0.1.dist-info/licenses/LICENSES/MIT.txt", - "pynetft-2.0.1.dist-info/licenses/LICENSES/curl.txt", - "pynetft-2.0.1.dist-info/licenses/core/LICENSE", + "pynetft-2.1.0.dist-info/licenses/LICENSE", + "pynetft-2.1.0.dist-info/licenses/LICENSES/MIT.txt", + "pynetft-2.1.0.dist-info/licenses/LICENSES/curl.txt", + "pynetft-2.1.0.dist-info/licenses/core/LICENSE", ), ) def test_wheel_checker_rejects_each_missing_license(tmp_path: Path, license_member: str) -> None: checker = _load_wheel_checker() - wheel = tmp_path / "pynetft-2.0.1-cp314-cp314-linux_x86_64.whl" + wheel = tmp_path / "pynetft-2.1.0-cp314-cp314-linux_x86_64.whl" members = _valid_wheel_members("pynetft/_native.cpython-314-x86_64-linux-gnu.so") _write_wheel(wheel, members=tuple(member for member in members if member != license_member)) @@ -443,12 +518,12 @@ def test_wheel_checker_rejects_duplicate_zip_entries(tmp_path: Path) -> None: members = ( "pynetft/_native.cpython-314-x86_64-linux-gnu.so", "pynetft/py.typed", - "pynetft-2.0.1.dist-info/METADATA", - "pynetft-2.0.1.dist-info/WHEEL", - "pynetft-2.0.1.dist-info/licenses/LICENSE", - "pynetft-2.0.1.dist-info/licenses/LICENSES/MIT.txt", - "pynetft-2.0.1.dist-info/licenses/LICENSES/curl.txt", - "pynetft-2.0.1.dist-info/licenses/core/LICENSE", + "pynetft-2.1.0.dist-info/METADATA", + "pynetft-2.1.0.dist-info/WHEEL", + "pynetft-2.1.0.dist-info/licenses/LICENSE", + "pynetft-2.1.0.dist-info/licenses/LICENSES/MIT.txt", + "pynetft-2.1.0.dist-info/licenses/LICENSES/curl.txt", + "pynetft-2.1.0.dist-info/licenses/core/LICENSE", ) with pytest.warns(UserWarning), zipfile.ZipFile(wheel, "w") as archive: for member in (*members, "pynetft/py.typed"): @@ -466,11 +541,57 @@ def test_wheel_checker_rejects_a_dynamic_libcurl_dependency() -> None: checker.validate_needed_libraries({"libc.so.6", "libcurl.so.4"}) +def test_windows_dependency_validation_rejects_dynamic_libcurl() -> None: + checker = _load_wheel_checker() + + checker.validate_windows_dependencies( + {"KERNEL32.dll", "python314.dll", "VCRUNTIME140.dll", "WS2_32.dll"} + ) + with pytest.raises(checker.WheelValidationError): + checker.validate_windows_dependencies({"KERNEL32.dll", "libcurl.dll"}) + + +def test_windows_dependency_validation_rejects_other_external_dlls() -> None: + checker = _load_wheel_checker() + + with pytest.raises(checker.WheelValidationError, match="zlib1.dll"): + checker.validate_windows_dependencies({"KERNEL32.dll", "zlib1.dll"}) + + +def test_workflows_pin_checkout_to_an_immutable_revision() -> None: + for workflow in (ROOT / ".github/workflows").glob("*.yml"): + contents = workflow.read_text(encoding="utf-8") + assert "actions/checkout@v" not in contents + + +def test_self_containment_dispatches_pe_inspection_for_windows_wheels( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + checker = _load_wheel_checker() + wheel = tmp_path / "pynetft-2.1.0-cp314-cp314-win_amd64.whl" + _write_wheel( + wheel, + members=_valid_wheel_members("pynetft/_native.cp314-win_amd64.pyd"), + ) + inspected: list[bytes] = [] + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr( + checker, + "_pe_imported_libraries", + lambda binary: inspected.append(binary) or {"KERNEL32.dll", "python314.dll"}, + ) + + checker.validate_wheel(wheel, self_contained=True) + + assert inspected == [b"x"] + + def test_self_containment_dispatches_elf_inspection_for_linux_wheels( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: checker = _load_wheel_checker() - wheel = tmp_path / "pynetft-2.0.1-cp314-cp314-manylinux2014_x86_64.whl" + wheel = tmp_path / "pynetft-2.1.0-cp314-cp314-manylinux2014_x86_64.whl" _write_wheel( wheel, members=_valid_wheel_members("pynetft/_native.cpython-314-x86_64-linux-gnu.so"), @@ -493,7 +614,7 @@ def test_self_containment_extracts_and_inspects_macos_extensions_with_otool( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: checker = _load_wheel_checker() - wheel = tmp_path / "pynetft-2.0.1-cp314-cp314-macosx_11_0_arm64.whl" + wheel = tmp_path / "pynetft-2.1.0-cp314-cp314-macosx_11_0_arm64.whl" _write_wheel( wheel, members=_valid_wheel_members("pynetft/_native.cpython-314-darwin.so"), @@ -560,7 +681,7 @@ def test_macos_self_containment_rejects_an_otool_package_manager_dependency( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: checker = _load_wheel_checker() - wheel = tmp_path / "pynetft-2.0.1-cp314-cp314-macosx_11_0_x86_64.whl" + wheel = tmp_path / "pynetft-2.1.0-cp314-cp314-macosx_11_0_x86_64.whl" _write_wheel( wheel, members=_valid_wheel_members("pynetft/_native.cpython-314-darwin.so"), @@ -585,8 +706,8 @@ def run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str @pytest.mark.parametrize( ("host", "wheel_name", "message"), ( - ("darwin", "pynetft-2.0.1-cp314-cp314-manylinux2014_x86_64.whl", "Linux"), - ("linux", "pynetft-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", "macOS"), + ("darwin", "pynetft-2.1.0-cp314-cp314-manylinux2014_x86_64.whl", "Linux"), + ("linux", "pynetft-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", "macOS"), ), ) def test_native_inspection_rejects_the_wrong_host( @@ -615,8 +736,8 @@ def test_auditwheel_inspects_multiple_wheels_individually( ) -> None: checker = _load_wheel_checker() wheels = [ - tmp_path / "pynetft-2.0.1-cp310-cp310-manylinux2014_x86_64.whl", - tmp_path / "pynetft-2.0.1-cp311-cp311-manylinux2014_aarch64.whl", + tmp_path / "pynetft-2.1.0-cp310-cp310-manylinux2014_x86_64.whl", + tmp_path / "pynetft-2.1.0-cp311-cp311-manylinux2014_aarch64.whl", ] for wheel in wheels: wheel.touch() @@ -648,8 +769,8 @@ def test_delocate_inspects_multiple_macos_wheels_individually( ) -> None: checker = _load_wheel_checker() wheels = [ - tmp_path / "pynetft-2.0.1-cp310-cp310-macosx_11_0_x86_64.whl", - tmp_path / "pynetft-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", + tmp_path / "pynetft-2.1.0-cp310-cp310-macosx_11_0_x86_64.whl", + tmp_path / "pynetft-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", ] for wheel in wheels: wheel.touch() @@ -687,7 +808,7 @@ def test_delocate_rejects_an_external_curl_dependency( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: checker = _load_wheel_checker() - wheel = tmp_path / "pynetft-2.0.1-cp310-cp310-macosx_11_0_arm64.whl" + wheel = tmp_path / "pynetft-2.1.0-cp310-cp310-macosx_11_0_arm64.whl" wheel.touch() monkeypatch.setattr(checker, "validate_wheel", lambda *args, **kwargs: None) monkeypatch.setattr(sys, "platform", "darwin") @@ -710,8 +831,8 @@ def run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str @pytest.mark.parametrize( ("wheel_name", "run_auditwheel", "run_delocate"), ( - ("pynetft-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", True, False), - ("pynetft-2.0.1-cp310-cp310-manylinux2014_x86_64.whl", False, True), + ("pynetft-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", True, False), + ("pynetft-2.1.0-cp310-cp310-manylinux2014_x86_64.whl", False, True), ), ) def test_platform_specific_external_inspection_rejects_other_wheel_tags( @@ -739,7 +860,7 @@ def test_auditwheel_rejects_a_linux_wheel_on_a_non_linux_host( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: checker = _load_wheel_checker() - wheel = tmp_path / "pynetft-2.0.1-cp310-cp310-manylinux2014_x86_64.whl" + wheel = tmp_path / "pynetft-2.1.0-cp310-cp310-manylinux2014_x86_64.whl" wheel.touch() monkeypatch.setattr(checker, "validate_wheel", lambda *args, **kwargs: None) monkeypatch.setattr(sys, "platform", "darwin") @@ -762,7 +883,7 @@ def test_delocate_rejects_a_macos_wheel_on_a_non_macos_host( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: checker = _load_wheel_checker() - wheel = tmp_path / "pynetft-2.0.1-cp310-cp310-macosx_11_0_arm64.whl" + wheel = tmp_path / "pynetft-2.1.0-cp310-cp310-macosx_11_0_arm64.whl" wheel.touch() monkeypatch.setattr(checker, "validate_wheel", lambda *args, **kwargs: None) monkeypatch.setattr(sys, "platform", "linux") diff --git a/tests/release/test_metadata.py b/tests/release/test_metadata.py index 7bc5b90..bc956f2 100644 --- a/tests/release/test_metadata.py +++ b/tests/release/test_metadata.py @@ -35,15 +35,16 @@ def _write_complete_inventory(root: Path) -> list[str]: for python in ("cp310", "cp311", "cp312", "cp313", "cp314"): wheel_names.extend( ( - f"pynetft-2.0.1-{python}-{python}-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", - f"pynetft-2.0.1-{python}-{python}-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", - f"pynetft-2.0.1-{python}-{python}-macosx_11_0_x86_64.whl", - f"pynetft-2.0.1-{python}-{python}-macosx_11_0_arm64.whl", + f"pynetft-2.1.0-{python}-{python}-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + f"pynetft-2.1.0-{python}-{python}-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", + f"pynetft-2.1.0-{python}-{python}-macosx_11_0_x86_64.whl", + f"pynetft-2.1.0-{python}-{python}-macosx_11_0_arm64.whl", + f"pynetft-2.1.0-{python}-{python}-win_amd64.whl", ) ) for name in wheel_names: (root / name).touch() - (root / "pynetft-2.0.1.tar.gz").touch() + (root / "pynetft-2.1.0.tar.gz").touch() return wheel_names @@ -54,9 +55,10 @@ def _write_complete_auditwheel_inventory(root: Path) -> None: "manylinux2014_aarch64.manylinux_2_17_aarch64", "macosx_11_0_x86_64", "macosx_11_0_arm64", + "win_amd64", ): - (root / f"pynetft-2.0.1-{python}-{python}-{platform}.whl").touch() - (root / "pynetft-2.0.1.tar.gz").touch() + (root / f"pynetft-2.1.0-{python}-{python}-{platform}.whl").touch() + (root / "pynetft-2.1.0.tar.gz").touch() def _git(repository: Path, *arguments: str, input_text: str | None = None) -> str: @@ -115,7 +117,7 @@ def test_release_versions_and_changelog_heading_agree() -> None: cmake = (ROOT / "CMakeLists.txt").read_text(encoding="utf-8") changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") - assert version == "2.0.1" + assert version == "2.1.0" assert re.search( rf"^project\(pynetft VERSION {re.escape(version)} LANGUAGES CXX\)$", cmake, @@ -135,17 +137,30 @@ def test_core_snapshot_identifies_the_pinned_release() -> None: ) assert metadata["repository"] == "https://github.com/netft/netft-cpp" - assert metadata["tag"] == "v0.2.2" - assert metadata["commit"] == "e424c401587052f03de9b94f76f1e86b78902105" + assert metadata["tag"] == "v0.3.0" + assert metadata["commit"] == "46ee05639f818a17c1cfe604d0d77b1feb8f9b2b" core_cmake = (ROOT / "core" / "CMakeLists.txt").read_text(encoding="utf-8") assert re.search( - r"^project\(netft VERSION 0\.2\.2 LANGUAGES CXX\)$", + r"^project\(netft VERSION 0\.3\.0 LANGUAGES CXX\)$", core_cmake, re.MULTILINE, ) +def test_python_build_disables_the_core_cli_and_marks_static_windows_curl() -> None: + cmake = (ROOT / "CMakeLists.txt").read_text(encoding="utf-8") + + assert 'set(NETFT_BUILD_CLI OFF CACHE BOOL "" FORCE)' in cmake + assert re.search( + r"if\(WIN32\).*target_compile_definitions\(netft PRIVATE CURL_STATICLIB\).*endif\(\)", + cmake, + re.DOTALL, + ) + for library in ("advapi32", "bcrypt", "crypt32", "iphlpapi", "secur32", "ws2_32"): + assert library in cmake + + def test_core_snapshot_contains_only_the_controlled_upstream_paths() -> None: metadata = dict( line.split("=", 1) @@ -192,12 +207,12 @@ def test_source_build_metadata_requires_libcurl_7_63() -> None: def test_release_metadata_tool_validates_tag_and_extracts_current_section() -> None: tool = _load_tool("release_metadata") - metadata = tool.validate_release(ROOT, "v2.0.1") + metadata = tool.validate_release(ROOT, "v2.1.0") notes = tool.changelog_notes(ROOT / "CHANGELOG.md", metadata.version) - assert metadata.version == "2.0.1" - assert metadata.tag == "v2.0.1" - assert metadata.release_date.isoformat() == "2026-07-25" + assert metadata.version == "2.1.0" + assert metadata.tag == "v2.1.0" + assert metadata.release_date.isoformat() == "2026-07-29" assert notes with pytest.raises(tool.ReleaseMetadataError): @@ -276,7 +291,7 @@ def test_release_workflow_builds_and_validates_the_complete_artifact_matrix() -> assert wheels["needs"] == "validate" assemble = jobs["assemble"] - assert set(assemble["needs"]) == {"wheels", "macos-wheels", "sdist"} + assert set(assemble["needs"]) == {"wheels", "macos-wheels", "windows-wheels", "sdist"} download_steps = [ step for step in assemble["steps"] @@ -369,6 +384,40 @@ def test_release_workflow_builds_and_native_validates_macos_wheels() -> None: assert macos["steps"][upload_index]["with"]["if-no-files-found"] == "error" +def test_release_workflow_builds_and_native_validates_windows_wheels() -> None: + workflow = _workflow() + windows = workflow["jobs"]["windows-wheels"] + + assert windows["needs"] == "validate" + assert windows["runs-on"] == "windows-2025" + install_run = next( + step["run"] for step in windows["steps"] if "pip install" in step.get("run", "") + ) + assert "cibuildwheel==3.4.1" in install_run + assert "pefile" in install_run + assert "twine" in install_run + + build_step = next( + step for step in windows["steps"] if "python -m cibuildwheel" in step.get("run", "") + ) + assert "--platform windows" in build_step["run"] + assert build_step["env"]["CIBW_ARCHS_WINDOWS"] == "AMD64" + + validation_run = next( + step["run"] for step in windows["steps"] if "tools/check_wheel.py" in step.get("run", "") + ) + assert "$wheels = Get-ChildItem wheelhouse/*.whl" in validation_run + assert "python -m twine check $wheels" in validation_run + assert "python tools/check_wheel.py --self-contained $wheels" in validation_run + upload = next( + step + for step in windows["steps"] + if step.get("uses", "").startswith("actions/upload-artifact@") + ) + assert upload["with"]["name"] == "release-wheels-windows-x86_64" + assert upload["with"]["path"] == "wheelhouse/*.whl" + + def test_release_workflow_uses_oidc_only_for_pypi_and_separates_github_write() -> None: workflow = _workflow() jobs = workflow["jobs"] @@ -418,19 +467,19 @@ def test_release_artifact_inventory_rejects_missing_or_duplicate_matrix_entries( tool = _load_tool("check_release_artifacts") wheel_names = _write_complete_inventory(tmp_path) - tool.validate_inventory(tmp_path, "2.0.1") + tool.validate_inventory(tmp_path, "2.1.0") missing_macos_wheel = next(name for name in wheel_names if "macosx_11_0_arm64" in name) (tmp_path / missing_macos_wheel).unlink() with pytest.raises(tool.ReleaseArtifactError): - tool.validate_inventory(tmp_path, "2.0.1") + tool.validate_inventory(tmp_path, "2.1.0") (tmp_path / missing_macos_wheel).touch() duplicate = tmp_path / "duplicate" / wheel_names[0] duplicate.parent.mkdir() duplicate.touch() with pytest.raises(tool.ReleaseArtifactError): - tool.validate_inventory(tmp_path, "2.0.1") + tool.validate_inventory(tmp_path, "2.1.0") def test_release_artifact_inventory_accepts_auditwheel_platform_tag_order( @@ -439,7 +488,7 @@ def test_release_artifact_inventory_accepts_auditwheel_platform_tag_order( tool = _load_tool("check_release_artifacts") _write_complete_auditwheel_inventory(tmp_path) - tool.validate_inventory(tmp_path, "2.0.1") + tool.validate_inventory(tmp_path, "2.1.0") @pytest.mark.parametrize( @@ -447,27 +496,27 @@ def test_release_artifact_inventory_accepts_auditwheel_platform_tag_order( ( ( "manylinux_2_17_x86_64.manylinux2014_x86_64", - "pynetft-2.0.1-cp310-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "pynetft-2.1.0-cp310-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", ), ( "manylinux_2_17_x86_64.manylinux2014_x86_64", - "pynetft-2.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", + "pynetft-2.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", ), ( "manylinux_2_17_x86_64.manylinux2014_x86_64", - "pynetft-2.0.1-cp310-cp310-linux_x86_64.whl", + "pynetft-2.1.0-cp310-cp310-linux_x86_64.whl", ), ( "manylinux_2_17_x86_64.manylinux2014_x86_64", - "pynetft-2.0.1-cp310-cp310-manylinux2014_x86_64.whl", + "pynetft-2.1.0-cp310-cp310-manylinux2014_x86_64.whl", ), ( "manylinux_2_17_x86_64.manylinux2014_x86_64", - "pynetft-2.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.linux_x86_64.whl", + "pynetft-2.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.linux_x86_64.whl", ), ( "manylinux_2_17_x86_64.manylinux2014_x86_64", - "pynetft-2.0.1-cp310-cp310-manylinux_2_17_x86_64." + "pynetft-2.1.0-cp310-cp310-manylinux_2_17_x86_64." "manylinux2014_x86_64.manylinux2014_x86_64.whl", ), ( @@ -476,11 +525,11 @@ def test_release_artifact_inventory_accepts_auditwheel_platform_tag_order( ), ( "macosx_11_0_x86_64", - "pynetft-2.0.1-cp310-cp310-macosx_11_0_universal2.whl", + "pynetft-2.1.0-cp310-cp310-macosx_11_0_universal2.whl", ), ( "macosx_11_0_x86_64", - "pynetft-2.0.1-cp310-cp310-macosx_11_1_x86_64.whl", + "pynetft-2.1.0-cp310-cp310-macosx_11_1_x86_64.whl", ), ), ) @@ -494,7 +543,7 @@ def test_release_artifact_inventory_rejects_invalid_python_or_platform_tags( (tmp_path / replacement).touch() with pytest.raises(tool.ReleaseArtifactError): - tool.validate_inventory(tmp_path, "2.0.1") + tool.validate_inventory(tmp_path, "2.1.0") def test_release_tag_verifier_accepts_authorized_main_tag(tmp_path: Path) -> None: diff --git a/tools/build_windows_curl.ps1 b/tools/build_windows_curl.ps1 new file mode 100644 index 0000000..294d9a2 --- /dev/null +++ b/tools/build_windows_curl.ps1 @@ -0,0 +1,101 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$version = "8.21.0" +$archiveSha256 = "aa1b66a70eace83dc624508745646c08ae561de512ab403adffb93ac87fc72e6" +$prefix = if ($env:PYNETFT_CURL_PREFIX) { $env:PYNETFT_CURL_PREFIX } else { "C:\pynetft-curl" } +$buildRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("pynetft-curl-" + [guid]::NewGuid()) +$archive = if ($env:PYNETFT_CURL_ARCHIVE_CACHE) { + $env:PYNETFT_CURL_ARCHIVE_CACHE +} else { + Join-Path $buildRoot "curl-$version.tar.xz" +} +$sourceRoot = Join-Path $buildRoot "source" +$binaryRoot = Join-Path $buildRoot "build" + +function Get-Sha256([string] $path) { + $algorithm = [System.Security.Cryptography.SHA256]::Create() + $stream = [System.IO.File]::OpenRead($path) + try { + return ([System.BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant() + } + finally { + $stream.Dispose() + $algorithm.Dispose() + } +} + +$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path -LiteralPath $vswhere -PathType Leaf)) { + throw "unable to locate vswhere.exe" +} +$visualStudioVersion = ( + & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationVersion +).Trim() +if ($LASTEXITCODE -ne 0 -or -not $visualStudioVersion) { + throw "unable to locate a Visual Studio C++ toolchain" +} +$visualStudioGenerator = if ($visualStudioVersion.StartsWith("18.")) { + "Visual Studio 18 2026" +} elseif ($visualStudioVersion.StartsWith("17.")) { + "Visual Studio 17 2022" +} else { + throw "unsupported Visual Studio version: $visualStudioVersion" +} + +try { + New-Item -ItemType Directory -Force -Path $buildRoot, $sourceRoot | Out-Null + if (-not (Test-Path -LiteralPath $archive -PathType Leaf)) { + Invoke-WebRequest "https://curl.se/download/curl-$version.tar.xz" -OutFile $archive + } + $actualSha256 = Get-Sha256 $archive + if ($actualSha256 -ne $archiveSha256) { + throw "curl source archive checksum mismatch" + } + + tar -xJf $archive --strip-components=1 -C $sourceRoot + if ($LASTEXITCODE -ne 0) { + throw "unable to extract curl source archive" + } + + cmake -S $sourceRoot -B $binaryRoot -G $visualStudioGenerator -A x64 ` + -DCMAKE_INSTALL_PREFIX="$prefix" ` + -DBUILD_CURL_EXE=OFF ` + -DBUILD_SHARED_LIBS=OFF ` + -DBUILD_STATIC_LIBS=ON ` + -DBUILD_TESTING=OFF ` + -DHTTP_ONLY=ON ` + -DCURL_USE_SCHANNEL=OFF ` + -DCURL_USE_LIBPSL=OFF ` + -DCURL_ZLIB=OFF ` + -DCURL_BROTLI=OFF ` + -DCURL_ZSTD=OFF ` + -DUSE_LIBIDN2=OFF ` + -DUSE_NGHTTP2=OFF + if ($LASTEXITCODE -ne 0) { + throw "unable to configure curl" + } + cmake --build $binaryRoot --config Release --parallel 2 + if ($LASTEXITCODE -ne 0) { + throw "unable to build curl" + } + cmake --install $binaryRoot --config Release + if ($LASTEXITCODE -ne 0) { + throw "unable to install curl" + } + + $staticLibrary = Join-Path $prefix "lib\libcurl.lib" + if (-not (Test-Path -LiteralPath $staticLibrary -PathType Leaf)) { + throw "curl static library was not installed" + } + if (Get-ChildItem -LiteralPath $prefix -Recurse -Filter "libcurl.dll") { + throw "curl shared library was unexpectedly installed" + } +} +finally { + if (Test-Path -LiteralPath $buildRoot) { + Remove-Item -LiteralPath $buildRoot -Recurse -Force + } +} diff --git a/tools/check_release_artifacts.py b/tools/check_release_artifacts.py index c5c0acd..848c726 100644 --- a/tools/check_release_artifacts.py +++ b/tools/check_release_artifacts.py @@ -12,6 +12,7 @@ ("manylinux2014", "aarch64"), ("macosx_11_0", "x86_64"), ("macosx_11_0", "arm64"), + ("win", "amd64"), } ) _PLATFORM_COMPONENTS = { @@ -25,6 +26,7 @@ ), ("macosx_11_0_x86_64",): ("macosx_11_0", "x86_64"), ("macosx_11_0_arm64",): ("macosx_11_0", "arm64"), + ("win_amd64",): ("win", "amd64"), } _WHEEL_FILENAME = re.compile( r"^pynetft-(?P[^-]+)-(?Pcp\d+)-(?Pcp\d+)-" diff --git a/tools/check_wheel.py b/tools/check_wheel.py index 76cc66a..acb8358 100755 --- a/tools/check_wheel.py +++ b/tools/check_wheel.py @@ -16,6 +16,22 @@ class WheelValidationError(RuntimeError): pass +WINDOWS_RUNTIME_LIBRARIES = { + "advapi32.dll", + "bcrypt.dll", + "crypt32.dll", + "iphlpapi.dll", + "kernel32.dll", + "msvcp140.dll", + "secur32.dll", + "ucrtbase.dll", + "user32.dll", + "vcruntime140.dll", + "vcruntime140_1.dll", + "ws2_32.dll", +} + + def _require_exactly_one(members: set[str], pattern: str, description: str) -> str: matches = sorted(member for member in members if re.fullmatch(pattern, member)) if len(matches) != 1: @@ -54,6 +70,24 @@ def validate_macos_dependencies(dependencies: set[str]) -> None: ) +def validate_windows_dependencies(dependencies: set[str]) -> None: + external = sorted( + dependency + for dependency in dependencies + if dependency.lower() not in WINDOWS_RUNTIME_LIBRARIES + and not re.fullmatch(r"python3\d+\.dll", dependency, flags=re.IGNORECASE) + and not re.fullmatch( + r"api-ms-win-(?:core|crt)-[a-z0-9-]+\.dll", + dependency, + flags=re.IGNORECASE, + ) + ) + if external: + raise WheelValidationError( + f"native extension has non-system dependencies: {', '.join(external)}" + ) + + def _needed_libraries(binary: bytes) -> set[str]: from elftools.elf.elffile import ELFFile @@ -63,6 +97,16 @@ def _needed_libraries(binary: bytes) -> set[str]: return {str(tag.needed) for tag in dynamic.iter_tags() if tag.entry.d_tag == "DT_NEEDED"} +def _pe_imported_libraries(binary: bytes) -> set[str]: + import pefile + + image = pefile.PE(data=binary, fast_load=True) + image.parse_data_directories( + directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"]] + ) + return {entry.dll.decode("ascii") for entry in getattr(image, "DIRECTORY_ENTRY_IMPORT", ())} + + def _wheel_platform(path: Path) -> str: filename = path.name if not filename.endswith(".whl"): @@ -73,9 +117,12 @@ def _wheel_platform(path: Path) -> str: platform_tags = components[-1].split(".") is_linux = any(tag.startswith(("linux_", "manylinux", "musllinux")) for tag in platform_tags) is_macos = any(tag.startswith("macosx_") for tag in platform_tags) - if is_linux == is_macos: + is_windows = any(tag.startswith("win_") for tag in platform_tags) + if sum((is_linux, is_macos, is_windows)) != 1: raise WheelValidationError(f"unsupported wheel platform tag in {path}") - return "linux" if is_linux else "macos" + if is_linux: + return "linux" + return "macos" if is_macos else "windows" def _dependencies_from_tool_output(output: str) -> set[str]: @@ -112,6 +159,13 @@ def _validate_native_dependencies( ) validate_needed_libraries(_needed_libraries(archive.read(extension))) return + if platform == "windows": + if sys.platform != "win32": + raise WheelValidationError( + f"native inspection for Windows wheel requires a Windows host: {path}" + ) + validate_windows_dependencies(_pe_imported_libraries(archive.read(extension))) + return if sys.platform != "darwin": raise WheelValidationError( f"native inspection for macOS wheel requires a macOS host: {path}" @@ -133,7 +187,7 @@ def validate_wheel(path: Path, *, self_contained: bool = False) -> None: extension = _require_exactly_one( members, - r"pynetft/_native(?:\.[^/]+)?\.so", + r"pynetft/_native(?:\.[^/]+)?\.(?:so|pyd)", "private native extension", ) if self_contained: