From 8204cb4f99036e932f0b5b7b30c04d7bc057f7fa Mon Sep 17 00:00:00 2001 From: Adrian Altenhoff Date: Thu, 29 Oct 2026 22:33:06 +0100 Subject: [PATCH 1/4] Build and test on Windows - align pointers through uintptr_t in aligned_malloc; long is only 32 bits on 64-bit Windows, which truncated the pointers - wheels for Windows x86_64 and arm64; delvewheel bundles the MSVC OpenMP runtime - ci: test on windows-latest, using bash as shell on all platforms --- .github/workflows/ci.yml | 6 +++++- .github/workflows/wheels.yml | 4 ++-- c_source/DynProgr_sse_double.c | 4 +++- pyproject.toml | 4 ++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f9fdb0..b3a0699 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,14 @@ jobs: build: name: ${{ matrix.os }} ${{ matrix.python-version }} runs-on: ${{ matrix.os }} + defaults: + run: + # bash also on windows, e.g. for the dist/*.tar.gz glob + shell: bash strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ae6016f..2e18a40 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -26,8 +26,8 @@ jobs: - ubuntu-24.04-arm # linux aarch64 - macos-15-intel # macos x86_64 - macos-latest # macos arm64 - # tests are failing for now on windows. - # - windows-latest + - windows-latest # windows x86_64 + - windows-11-arm # windows arm64 steps: - uses: actions/checkout@v7 diff --git a/c_source/DynProgr_sse_double.c b/c_source/DynProgr_sse_double.c index c7df9fa..553e1d2 100644 --- a/c_source/DynProgr_sse_double.c +++ b/c_source/DynProgr_sse_double.c @@ -7,12 +7,14 @@ /* According to http://stackoverflow.com/questions/1919183/how-to-allocate-and-free-aligned-memory-in-c */ +#include #include "Python_extension.h" #include "DynProgr_sse_double.h" void *aligned_malloc(int align, int size) { char *mem = malloc(size + align + sizeof(char*)); - char **ptr = (char**) ((long) (mem + align + sizeof(char*)) & ~(align - 1)); + /* uintptr_t, as long is only 32 bits on 64-bit Windows */ + char **ptr = (char**) ((uintptr_t) (mem + align + sizeof(char*)) & ~(uintptr_t) (align - 1)); ptr[-1] = mem; return ptr; } diff --git a/pyproject.toml b/pyproject.toml index df971a9..08fa215 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,3 +64,7 @@ before-all = "bash tools/build_libomp_macos.sh /tmp/libomp" # set explicitly: before-all otherwise gets MACOSX_DEPLOYMENT_TARGET=10.9 from # cibuildwheel, but libomp needs >= 10.15 (and arm64 needs >= 11.0 anyway) environment = { PYOPA_REQUIRE_OPENMP = "1", OPENMP_PREFIX = "/tmp/libomp", MACOSX_DEPLOYMENT_TARGET = "11.0" } + +[tool.cibuildwheel.windows] +# bundle the MSVC OpenMP runtime (vcomp140.dll) +repair-wheel-command = "uvx delvewheel repair -w {dest_dir} {wheel}" From 8c42bad73b559fd1aec60d8ed85dff8f12521237 Mon Sep 17 00:00:00 2001 From: Adrian Altenhoff Date: Thu, 29 Oct 2026 22:46:38 +0100 Subject: [PATCH 2/4] Skip the Windows arm64 wheel for Python 3.10, numpy has none --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 08fa215..1103dbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,8 +51,9 @@ include = ["pyopa*"] [tool.cibuildwheel] build-frontend = "build[uv]" build-verbosity = 1 -# the Cython extension is not declared free-threading compatible yet -skip = ["cp3*t-*"] +# the Cython extension is not declared free-threading compatible yet; +# numpy has no windows arm64 wheels for Python 3.10 +skip = ["cp3*t-*", "cp310-win_arm64"] test-groups = ["test"] test-command = "pytest -v {project}/test" # fail instead of silently building wheels without parallel support From 4d5ca39140bd3c5c941516c46175a57e7edb0329 Mon Sep 17 00:00:00 2001 From: Adrian Altenhoff Date: Thu, 29 Oct 2026 22:58:24 +0100 Subject: [PATCH 3/4] Drop 32-bit Windows wheels, fix MSVC warnings - skip the win32 wheels - explicit casts where doubles are converted to char/short/int and Py_ssize_t lengths are passed as int (sequences are far shorter than 2^31) - print pointers with %p instead of truncating them to unsigned long - tot is a double; do not assign it in the loop initialiser of i --- c_source/DynProgr_sse_byte.c | 6 +++--- c_source/DynProgr_sse_short.c | 6 +++--- c_source/EstimatePam.c | 2 +- c_source/Python_extension.c | 19 ++++++++++--------- pyopa/backend/pyopa.pyx | 30 +++++++++++++++--------------- pyproject.toml | 4 ++-- 6 files changed, 34 insertions(+), 33 deletions(-) diff --git a/c_source/DynProgr_sse_byte.c b/c_source/DynProgr_sse_byte.c index a1ff990..e9e04bf 100644 --- a/c_source/DynProgr_sse_byte.c +++ b/c_source/DynProgr_sse_byte.c @@ -120,7 +120,7 @@ EXPORT double swps3_alignmentByteSSE_lin(ProfileByte * query, const char * db, __m128i vMinimums = _mm_set1_epi32(0); - __m128i vDelFixed = _mm_set1_epi8(-options->gapOpen); + __m128i vDelFixed = _mm_set1_epi8((char) -options->gapOpen); __m128i vBias = _mm_set1_epi8(query->bias); __m128i vMaxScore = vMinimums; /* vMaxScore = [0,0] */ @@ -265,8 +265,8 @@ EXPORT double swps3_alignmentByteSSE(ProfileByte * query, const char * db, __m128i vMinimums = _mm_set1_epi32(0); - __m128i vDelIncr = _mm_set1_epi8(-options->gapExt); - __m128i vDelFixed = _mm_set1_epi8(-options->gapOpen); + __m128i vDelIncr = _mm_set1_epi8((char) -options->gapExt); + __m128i vDelFixed = _mm_set1_epi8((char) -options->gapOpen); __m128i vBias = _mm_set1_epi8(query->bias); __m128i vMaxScore = vMinimums; /* vMaxScore = [0,0] */ diff --git a/c_source/DynProgr_sse_short.c b/c_source/DynProgr_sse_short.c index 288d6f7..3ab5f94 100644 --- a/c_source/DynProgr_sse_short.c +++ b/c_source/DynProgr_sse_short.c @@ -102,7 +102,7 @@ EXPORT double swps3_alignmentShortSSE_lin(ProfileShort * query, const char * db, __m128i vMinimums = _mm_set1_epi16(0x8000); - __m128i vDelFixed = _mm_set1_epi16(options->gapOpen); + __m128i vDelFixed = _mm_set1_epi16((short) options->gapOpen); __m128i vBias = _mm_set1_epi16(query->bias); __m128i vMaxScore = vMinimums; /* vMaxScore = [0,0] */ @@ -254,8 +254,8 @@ EXPORT double swps3_alignmentShortSSE(ProfileShort * query, const char * db, __m128i vMinimums = _mm_set1_epi16(0x8000); - __m128i vDelIncr = _mm_set1_epi16(options->gapExt); - __m128i vDelFixed = _mm_set1_epi16(options->gapOpen); + __m128i vDelIncr = _mm_set1_epi16((short) options->gapExt); + __m128i vDelFixed = _mm_set1_epi16((short) options->gapOpen); __m128i vBias = _mm_set1_epi16(query->bias); __m128i vMaxScore = vMinimums; /* vMaxScore = [0,0] */ diff --git a/c_source/EstimatePam.c b/c_source/EstimatePam.c index ebe1b73..a2c7f1c 100644 --- a/c_source/EstimatePam.c +++ b/c_source/EstimatePam.c @@ -610,7 +610,7 @@ DayMatrix* createDayMatrices(double* gapOpen, double* gapExt, #ifdef PY_DEBUG printf("Creating day matrix:\n"); printf("FixedDel = %f\nIncDel = %f\nPamNumber = %f\n", gapOpen[i], gapExt[i], pamDistances[i]); - printf("Simi at %lx:\n", (long)matrix_pointers[i]); + printf("Simi at %p:\n", (void*) matrix_pointers[i]); for(k = 0; k < MATRIX_DIM; ++k) { printf("\t["); for(l = 0; l < MATRIX_DIM; ++l) { diff --git a/c_source/Python_extension.c b/c_source/Python_extension.c index fbc6b54..5fa183f 100644 --- a/c_source/Python_extension.c +++ b/c_source/Python_extension.c @@ -34,8 +34,8 @@ void debug_alignment(char* name, void* profile, const char* s2, int ls2, Options* options) { char* dns2 = denormalize(s2, ls2); printf( - "Aligning %s: profile = %lu, s2 = %s, len(s2) = %d, gapOpen = %f, gapExt = %f, threshold = %f\n", - name, (unsigned long) profile, dns2, ls2, options->gapOpen, + "Aligning %s: profile = %p, s2 = %s, len(s2) = %d, gapOpen = %f, gapExt = %f, threshold = %f\n", + name, profile, dns2, ls2, options->gapOpen, options->gapExt, options->threshold); free(dns2); } @@ -46,8 +46,8 @@ void debug_profile(char* name, void* pb, const char* q, int queryLen, int i, j; printf( - "Successfully created %s profile at %lu from query = %s, len(query) = %d\n", - name, (unsigned long) pb, query, queryLen); + "Successfully created %s profile at %p from query = %s, len(query) = %d\n", + name, pb, query, queryLen); printf("The matrix used for the %s profile: \n", name); for (i = 0; i < 26; ++i) { @@ -114,13 +114,13 @@ ProfileShort* c_create_profile_short_sse_local(const char* query, int queryLen, void c_free_profile_byte_sse_local(ProfileByte* profile) { #ifdef PY_DEBUG - printf("Freeing BYTE profile at %lu\n", (unsigned long)profile); + printf("Freeing BYTE profile at %p\n", (void*) profile); #endif swps3_freeProfileByteSSE(profile); } void c_free_profile_short_sse_local(ProfileShort* profile) { #ifdef PY_DEBUG - printf("Freeing SHORT profile at %lu\n", (unsigned long)profile); + printf("Freeing SHORT profile at %p\n", (void*) profile); #endif swps3_freeProfileShortSSE(profile); } @@ -189,7 +189,8 @@ int c_align_strings(double* matrix, char *s1, int len1, char *s2, int len2, /* equal length, try to see if there is a match without deletions */ if (len1 == len2 && s1 != s2) { - for (i = tot = 0; i < len1; i++) + tot = 0; + for (i = 0; i < len1; i++) tot += matrix[s1[i] * MATRIX_DIM + s2[i]]; if (tot >= escore) { for (i = 0; i < len1; i++) { @@ -216,14 +217,14 @@ int c_align_strings(double* matrix, char *s1, int len1, char *s2, int len2, /* reverse strings */ if (s1 - s2 >= 0 && s2 + len2 - s1 > 0) { - j = MMAX(s1 - s2 + len1, len2); + j = (int) MMAX(s1 - s2 + len1, len2); tprs1 = (char*) malloc(j * sizeof(char)); for (i = 0; i < j; i++) tprs1[j - 1 - i] = s2[i]; prs1 = tprs1 + (j + s2 - s1 - len1); prs2 = tprs1 + (j - len2); } else if (s2 - s1 >= 0 && s1 + len1 - s2 > 0) { - j = MMAX(s2 - s1 + len2, len1); + j = (int) MMAX(s2 - s1 + len2, len1); tprs1 = (char*) malloc(j * sizeof(char)); for (i = 0; i < j; i++) tprs1[j - 1 - i] = s1[i]; diff --git a/pyopa/backend/pyopa.pyx b/pyopa/backend/pyopa.pyx index 8d3ea8c..f57dde8 100644 --- a/pyopa/backend/pyopa.pyx +++ b/pyopa/backend/pyopa.pyx @@ -313,8 +313,8 @@ cpdef align_strings(s1, s2, env, is_global=False, provided_alignment=None): cdef np.ndarray[np.double_t,ndim=2] matrix = env.float64_matrix - max_len = cpyopa.c_align_strings( matrix.data, s1.encode('utf-8'), len(s1), - s2.encode('utf-8'), len(s2), provided_alignment[0], o1, o2, 0.5e-4, env.gap_open, env.gap_ext) + max_len = cpyopa.c_align_strings( matrix.data, s1.encode('utf-8'), len(s1), + s2.encode('utf-8'), len(s2), provided_alignment[0], o1, o2, 0.5e-4, env.gap_open, env.gap_ext) o1_p = [0] * max_len o2_p = [0] * max_len @@ -343,7 +343,7 @@ def align_double(s1, s2, env, stop_at_threshold=False, is_global=False, calculat profile1.create_profile_double(s1, env.float64_matrix) if is_global: - res = c_align_double_normalized_global(env.float64_matrix, s1.s_norm.encode('utf-8'), len(s1), s2.s_norm.encode('utf-8'), len(s2), + res = c_align_double_normalized_global(env.float64_matrix, s1.s_norm.encode('utf-8'), len(s1), s2.s_norm.encode('utf-8'), len(s2), env.gap_open, env.gap_ext) if calculate_ranges : res.extend([0, 0]) @@ -380,7 +380,7 @@ def align_scalar_reference_local(s1, s2, env): :param env: the AlignmentEnvironment to be used :return: the exact scalar score """ - return c_align_scalar_normalized_reference_local(env.float64_matrix, s1.s_norm.encode('utf-8'), len(s1), s2.s_norm.encode('utf-8'), len(s2), + return c_align_scalar_normalized_reference_local(env.float64_matrix, s1.s_norm.encode('utf-8'), len(s1), s2.s_norm.encode('utf-8'), len(s2), env.gap_open, env.gap_ext, env.threshold) cpdef generate_env(log_pam1_env, new_pam, threshold=85.0): @@ -534,7 +534,7 @@ cdef class AlignmentProfile: if self._c_profileByte is not NULL: cpyopa.c_free_profile_byte_sse_local(self._c_profileByte) - self._c_profileByte = cpyopa.c_create_profile_byte_sse_local(query.encode('utf-8'), len(query), matrix.data) + self._c_profileByte = cpyopa.c_create_profile_byte_sse_local(query.encode('utf-8'), len(query), matrix.data) cpdef create_profile_short(self, query, np.ndarray[np.int16_t,ndim=2] matrix): @@ -554,7 +554,7 @@ cdef class AlignmentProfile: if self._c_profileShort is not NULL: cpyopa.c_free_profile_short_sse_local(self._c_profileShort) - self._c_profileShort = cpyopa.c_create_profile_short_sse_local(query.encode('utf-8'), len(query), + self._c_profileShort = cpyopa.c_create_profile_short_sse_local(query.encode('utf-8'), len(query), matrix.data) @@ -574,7 +574,7 @@ cdef class AlignmentProfile: if self._c_profileDouble is not NULL: cpyopa.free_profile_double_sse(self._c_profileDouble) - self._c_profileDouble = cpyopa.createProfileDoubleSSE(query.encode('utf-8'), len(query), matrix.data) + self._c_profileDouble = cpyopa.createProfileDoubleSSE(query.encode('utf-8'), len(query), matrix.data) cpdef align_byte(self, s2, env): @@ -589,7 +589,7 @@ cdef class AlignmentProfile: s2 = s2.s_norm - ret = cpyopa.c_align_profile_byte_sse_local(self._c_profileByte, s2.encode('utf-8'), len(s2), + ret = cpyopa.c_align_profile_byte_sse_local(self._c_profileByte, s2.encode('utf-8'), len(s2), env.int8_gap_open, env.int8_gap_ext, env.threshold) #do not scale back DBL_MAX if ret >= sys.float_info.max: @@ -610,7 +610,7 @@ cdef class AlignmentProfile: s2 = s2.s_norm ret = cpyopa.c_align_profile_short_sse_local(self._c_profileShort, - s2.encode('utf-8'), len(s2), env.int16_gap_open, env.int16_gap_ext, env.threshold) + s2.encode('utf-8'), len(s2), env.int16_gap_open, env.int16_gap_ext, env.threshold) #do not scale back DBL_MAX if ret >= sys.float_info.max: return ret @@ -641,7 +641,7 @@ cdef class AlignmentProfile: if threshold is None: threshold = env.threshold if stop_at_threshold else sys.float_info.max - res = cpyopa.align_double_local(self._c_profileDouble, s2.encode('utf-8'), len(s2), + res = cpyopa.align_double_local(self._c_profileDouble, s2.encode('utf-8'), len(s2), env.gap_open, env.gap_ext, threshold, max1, max2) ret.append(res) @@ -778,7 +778,7 @@ cdef class MutipleAlEnv: Q2 = Q @ Q Q3 = Q @ Q2 - self._pam_model.nlam = len(active) + self._pam_model.nlam = len(active) for i in range(26): self._pam_model.lam[i] = lam[i] if i < len(active) else 0.0 for j in range(26): @@ -799,7 +799,7 @@ cdef class MutipleAlEnv: def create_day_matrices(self, envs): self.free() - self.dms_len = len(envs) + self.dms_len = len(envs) cdef np.ndarray[np.double_t, ndim=1, mode="c"] gap_open = np.empty(shape=(self.dms_len + 1)) cdef np.ndarray[np.double_t, ndim=1, mode="c"] gap_ext = np.empty(shape=(self.dms_len + 1)) cdef np.ndarray[np.double_t, ndim=1, mode="c"] pam_dist = np.empty(shape=(self.dms_len + 1)) @@ -830,7 +830,7 @@ cdef class MutipleAlEnv: raise RuntimeError('The day matrices have been freed') cdef double result[3] - cpyopa.EstimatePamEigen(s1.s_norm.encode('utf-8'), s2.s_norm.encode('utf-8'), len(s1), + cpyopa.EstimatePamEigen(s1.s_norm.encode('utf-8'), s2.s_norm.encode('utf-8'), len(s1), self._c_dayMatrices, self.dms_len, &self._pam_model, result) return [result[0], result[1], result[2]] @@ -849,7 +849,7 @@ cdef class MutipleAlEnv: cdef double result[3] cdef np.ndarray[np.double_t, ndim=2, mode="c"] logpam = self.log_pam1.float64_matrix - cpyopa.EstimatePam(s1.s_norm.encode('utf-8'), s2.s_norm.encode('utf-8'), len(s1), + cpyopa.EstimatePam(s1.s_norm.encode('utf-8'), s2.s_norm.encode('utf-8'), len(s1), self._c_dayMatrices, self.dms_len, logpam.data, result) return [result[0], result[1], result[2]] @@ -871,7 +871,7 @@ cdef class MutipleAlEnv: if n < 2: raise ValueError('Must pass atleast 2 sequences!') - cdef int seq_len = len(seqs[0]) + cdef int seq_len = len(seqs[0]) for i in range(1, n): if len(seqs[i]) != seq_len: raise ValueError('All lengths must be equal!') diff --git a/pyproject.toml b/pyproject.toml index 1103dbf..c54da43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,8 @@ include = ["pyopa*"] build-frontend = "build[uv]" build-verbosity = 1 # the Cython extension is not declared free-threading compatible yet; -# numpy has no windows arm64 wheels for Python 3.10 -skip = ["cp3*t-*", "cp310-win_arm64"] +# no 32-bit windows wheels; numpy has no windows arm64 wheels for Python 3.10 +skip = ["cp3*t-*", "*-win32", "cp310-win_arm64"] test-groups = ["test"] test-command = "pytest -v {project}/test" # fail instead of silently building wheels without parallel support From 3f6ab029cee86bba31ca74d708ca4686f819b393 Mon Sep 17 00:00:00 2001 From: Adrian Altenhoff Date: Thu, 29 Oct 2026 23:47:33 +0100 Subject: [PATCH 4/4] Add Windows support to the changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42435e4..02c5d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ changes are released with the following version. ([#29](https://github.com/DessimozLab/pyopa/pull/29)). - Wheels for Linux aarch64 and for Python 3.14 and 3.15 ([#27](https://github.com/DessimozLab/pyopa/pull/27)). +- Wheels for Windows x86_64 and arm64 (Python 3.11 or newer on arm64, as + numpy has no Windows arm64 wheels for Python 3.10); Windows is tested in + CI ([#33](https://github.com/DessimozLab/pyopa/pull/33)). ### Changed - Requires Python 3.10 or newer; no more wheels for Python 3.6 to 3.9. @@ -53,6 +56,9 @@ changes are released with the following version. matrices while `estimate_many_pam` is running; using freed matrices raises a `RuntimeError` instead of crashing ([#28](https://github.com/DessimozLab/pyopa/pull/28)). +- `align_double` truncated pointers on 64-bit Windows, where `long` is + 32 bits ([#33](https://github.com/DessimozLab/pyopa/pull/33)). +- Compiler warnings about implicit conversions with MSVC ([#33](https://github.com/DessimozLab/pyopa/pull/33)). ## 0.8.6 - 2025-04-24