pact-python-ffi 0.5.10.0__tar.gz → 0.5.12.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ src/pact_ffi/__version__.py
1
2
  src/pact_ffi/data
2
3
  src/pact_ffi/*.pyd
3
4
  src/pact_ffi/*.so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pact-python-ffi
3
- Version: 0.5.10.0
3
+ Version: 0.5.12.1
4
4
  Summary: Python bindings for the Pact FFI library
5
5
  Project-URL: Bug Tracker, https://github.com/pact-foundation/pact-python/issues
6
6
  Project-URL: Changelog, https://github.com/pact-foundation/pact-python/blob/main/pact-python-ffi/CHANGELOG.md
@@ -28,6 +28,7 @@ Classifier: Programming Language :: Python :: 3.14
28
28
  Classifier: Programming Language :: Python :: 3.15
29
29
  Classifier: Topic :: Software Development :: Testing
30
30
  Requires-Python: >=3.11
31
+ Requires-Dist: cffi>=2.1.1; python_version >= '3.15'
31
32
  Requires-Dist: cffi~=2.0
32
33
  Description-Content-Type: text/markdown
33
34
 
@@ -7,13 +7,16 @@ CFFI bindings for it.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import gzip
10
+ import hashlib
11
11
  import os
12
12
  import shutil
13
13
  import subprocess
14
14
  import sys
15
+ import sysconfig
16
+ import tarfile
15
17
  import tempfile
16
18
  import urllib.request
19
+ import zipfile
17
20
  from pathlib import Path
18
21
  from typing import TYPE_CHECKING, Any
19
22
 
@@ -25,7 +28,22 @@ if TYPE_CHECKING:
25
28
  from collections.abc import Mapping, MutableMapping, Sequence
26
29
 
27
30
  PKG_DIR = Path(__file__).parent.resolve() / "src" / "pact_ffi"
28
- PACT_LIB_URL = "https://github.com/pact-foundation/pact-reference/releases/download/libpact_ffi-v{version}/{prefix}pact_ffi-{os}-{platform}{suffix}.{ext}"
31
+ PACT_RELEASE_URL = "https://github.com/pact-foundation/pact-reference/releases/download/pact_ffi-v{version}"
32
+
33
+
34
+ def _stable_abi() -> str:
35
+ """
36
+ Determine the stable ABI targeted by the current interpreter.
37
+
38
+ CFFI compiles against `abi3t` on free-threaded CPython 3.15+ and against
39
+ `abi3` otherwise. Free-threaded interpreters do not load `abi3` extensions.
40
+
41
+ Returns:
42
+ Either `abi3` or `abi3t`.
43
+ """
44
+ if sysconfig.get_config_var("Py_GIL_DISABLED"):
45
+ return "abi3t"
46
+ return "abi3"
29
47
 
30
48
 
31
49
  class UnsupportedPlatformError(RuntimeError):
@@ -169,25 +187,40 @@ class PactBuildHook(BuildHookInterface[Any]):
169
187
  `dst` is the corresponding path within the wheel.
170
188
  """
171
189
  # Download the Pact library binary and header file
172
- lib_url = self._lib_url(version)
173
- header = self._download(lib_url.rsplit("/", 1)[0] + "/pact.h")
174
- lib = self._extract_lib(self._download(lib_url))
175
- if lib.suffix == ".dll":
176
- dll_lib = self._extract_lib(
177
- self._download(lib_url.replace(".dll.gz", ".dll.lib.gz"))
178
- )
190
+ release_url = PACT_RELEASE_URL.format(version=version)
191
+ target = self._target()
192
+ header = self._download(f"{release_url}/pact.h")
193
+ if "windows" in target:
194
+ archive_url = f"{release_url}/pact_ffi-{target}.zip"
195
+ else:
196
+ archive_url = f"{release_url}/pact_ffi-{target}.tar.xz"
197
+ archive = self._download(archive_url)
198
+ self._verify(archive, self._download(f"{archive_url}.sha256"))
199
+
200
+ dll_lib: Path | None = None
201
+ if "windows" in target:
202
+ lib = self._extract_lib(archive, "pact_ffi.dll", "pact_ffi.dll")
203
+ # MSVC resolves `pact_ffi` to `pact_ffi.lib`. The archive's own
204
+ # `pact_ffi.lib` is the static library, so the import library
205
+ # takes its name.
206
+ dll_lib = self._extract_lib(archive, "pact_ffi.dll.lib", "pact_ffi.lib")
207
+ elif "darwin" in target:
208
+ lib = self._extract_lib(archive, "libpact_ffi.dylib", "libpact_ffi.dylib")
209
+ elif "musl" in target:
210
+ # MUSL uses static library
211
+ lib = self._extract_lib(archive, "libpact_ffi.a", "libpact_ffi.a")
179
212
  else:
180
- dll_lib = None
213
+ lib = self._extract_lib(archive, "libpact_ffi.so", "libpact_ffi.so")
181
214
 
182
215
  # Compile the FFI extension
183
216
  extension = self._compile(lib, header)
184
217
 
185
- # Copy into the package directory, using the ABI3 marking for broad
186
- # compatibility.
218
+ # Copy into the package directory, using the stable ABI marking for
219
+ # broad compatibility.
187
220
  # NOTE: Windows does _not_ use the version infixation
188
221
  name = extension.name.split(".")[0]
189
222
  suffix = extension.suffix
190
- infix = ".abi3" if os.name != "nt" else ""
223
+ infix = f".{_stable_abi()}" if os.name != "nt" else ""
191
224
  extension_dest = f"{name}{infix}{suffix}"
192
225
  shutil.copy(extension, PKG_DIR / extension_dest)
193
226
 
@@ -204,16 +237,12 @@ class PactBuildHook(BuildHookInterface[Any]):
204
237
 
205
238
  return {str(extension): f"pact_ffi/{extension_dest}"}
206
239
 
207
- def _lib_url(self, version: str) -> str: # noqa: C901, PLR0912
240
+ def _target(self) -> str:
208
241
  """
209
- Generate the download URL for the Pact library.
210
-
211
- Args:
212
- version:
213
- The upstream Pact version.
242
+ Determine the Rust target triple matching the wheel platform.
214
243
 
215
244
  Returns:
216
- The URL to download the Pact library from.
245
+ The Rust target triple of the upstream Pact library archive.
217
246
 
218
247
  Raises:
219
248
  UnsupportedPlatformError:
@@ -221,83 +250,96 @@ class PactBuildHook(BuildHookInterface[Any]):
221
250
  """
222
251
  wheel_platform = self._sys_tag_platform()
223
252
 
224
- aarch64 = ("_arm64", "_aarch64")
225
- x86_64 = ("_x86_64", "_amd64")
253
+ if wheel_platform.endswith(("_arm64", "_aarch64")):
254
+ arch = "aarch64"
255
+ elif wheel_platform.endswith(("_x86_64", "_amd64")):
256
+ arch = "x86_64"
257
+ else:
258
+ raise UnsupportedPlatformError(wheel_platform)
226
259
 
227
- # Simplified platform and architecture detection
228
260
  if wheel_platform.startswith("macosx"):
229
- os, ext = "macos", "dylib.gz"
230
- prefix = "lib"
231
- suffix = ""
232
- if wheel_platform.endswith(aarch64):
233
- platform = "aarch64"
234
- elif wheel_platform.endswith(x86_64):
235
- platform = "x86_64"
236
- else:
237
- raise UnsupportedPlatformError(wheel_platform)
238
-
239
- elif wheel_platform.startswith("musllinux"):
240
- os, ext = "linux", "a.gz" # MUSL uses static library
241
- prefix = "lib"
242
- suffix = "-musl"
243
- if wheel_platform.endswith(aarch64):
244
- platform = "aarch64"
245
- elif wheel_platform.endswith(x86_64):
246
- platform = "x86_64"
247
- else:
248
- raise UnsupportedPlatformError(wheel_platform)
249
-
250
- elif wheel_platform.startswith("manylinux"):
251
- os, ext = "linux", "so.gz"
252
- prefix = "lib"
253
- suffix = ""
254
- if wheel_platform.endswith(aarch64):
255
- platform = "aarch64"
256
- elif wheel_platform.endswith(x86_64):
257
- platform = "x86_64"
258
- else:
259
- raise UnsupportedPlatformError(wheel_platform)
260
-
261
- elif wheel_platform.startswith("win"):
262
- # TODO: Switch to using `dll.gz`
263
- # https://github.com/python-cffi/cffi/issues/182
264
- os, ext = "windows", "dll.gz"
265
- prefix = ""
266
- suffix = ""
267
- if wheel_platform.endswith(aarch64):
268
- platform = "aarch64"
269
- elif wheel_platform.endswith(x86_64):
270
- platform = "x86_64"
271
- else:
272
- raise UnsupportedPlatformError(wheel_platform)
261
+ return f"{arch}-apple-darwin"
262
+ if wheel_platform.startswith("musllinux"):
263
+ return f"{arch}-unknown-linux-musl"
264
+ if wheel_platform.startswith("manylinux"):
265
+ return f"{arch}-unknown-linux-gnu"
266
+ if wheel_platform.startswith("win"):
267
+ return f"{arch}-pc-windows-msvc"
268
+ raise UnsupportedPlatformError(wheel_platform)
269
+
270
+ def _verify(self, artefact: Path, checksum: Path) -> None:
271
+ """
272
+ Verify an artefact against its SHA-256 checksum file.
273
273
 
274
- else:
275
- raise UnsupportedPlatformError(wheel_platform)
274
+ A mismatched artefact is removed so that the next build downloads it
275
+ again.
276
276
 
277
- return PACT_LIB_URL.format(
278
- version=version,
279
- prefix=prefix,
280
- os=os,
281
- platform=platform,
282
- suffix=suffix,
283
- ext=ext,
284
- )
277
+ Args:
278
+ artefact:
279
+ The path to the downloaded artefact.
280
+
281
+ checksum:
282
+ The path to the checksum file, in the `sha256sum` format
283
+ `<hex> *<filename>`.
285
284
 
286
- def _extract_lib(self, artefact: Path) -> Path:
285
+ Raises:
286
+ ValueError:
287
+ If the checksum does not match.
287
288
  """
288
- Extract the Pact library.
289
+ expected = checksum.read_text().split()[0].lower()
290
+ digest = hashlib.sha256()
291
+ with artefact.open("rb") as f:
292
+ for chunk in iter(lambda: f.read(1 << 20), b""):
293
+ digest.update(chunk)
294
+ if digest.hexdigest() != expected:
295
+ artefact.unlink()
296
+ checksum.unlink()
297
+ msg = (
298
+ f"Checksum mismatch for {artefact.name}: "
299
+ f"expected {expected}, got {digest.hexdigest()}"
300
+ )
301
+ self.app.display_error(msg)
302
+ raise ValueError(msg)
303
+ self.app.display_debug(f"Verified checksum of {artefact.name}")
304
+
305
+ def _extract_lib(self, archive: Path, member: str, name: str) -> Path:
306
+ """
307
+ Extract a file from the Pact library archive.
308
+
309
+ The `.tar.xz` archives nest their files under a `pact_ffi-<target>`
310
+ directory, while the `.zip` archives keep them at the root.
289
311
 
290
312
  Args:
291
- artefact:
292
- The URL to download the Pact binaries from.
313
+ archive:
314
+ The path to the downloaded archive.
315
+
316
+ member:
317
+ The file name within the archive.
318
+
319
+ name:
320
+ The file name to extract to within the package directory.
321
+
322
+ Returns:
323
+ The path to the extracted file.
293
324
  """
294
- target = PKG_DIR / (artefact.name.split("-")[0] + artefact.suffixes[-2])
295
- with (
296
- gzip.open(artefact, "rb") as f_in,
297
- target.open("wb") as f_out,
298
- ):
299
- shutil.copyfileobj(f_in, f_out)
300
- self.app.display_debug(f"Extracted Pact library to {target}")
325
+ target = PKG_DIR / name
326
+ if archive.suffix == ".zip":
327
+ with (
328
+ zipfile.ZipFile(archive) as zf,
329
+ zf.open(member) as f_in,
330
+ target.open("wb") as f_out,
331
+ ):
332
+ shutil.copyfileobj(f_in, f_out)
333
+ else:
334
+ prefix = archive.name.removesuffix(".tar.xz")
335
+ with tarfile.open(archive, "r:xz") as tf:
336
+ f_tar = tf.extractfile(f"{prefix}/{member}")
337
+ if f_tar is None:
338
+ msg = f"{member} in {archive.name} is not a regular file"
339
+ raise ValueError(msg)
340
+ with f_tar, target.open("wb") as f_out:
341
+ shutil.copyfileobj(f_tar, f_out)
342
+ self.app.display_debug(f"Extracted {member} from {archive.name} to {target}")
301
343
  return target
302
344
 
303
345
  def _compile(self, lib: Path, header: Path) -> Path:
@@ -386,46 +428,36 @@ class PactBuildHook(BuildHookInterface[Any]):
386
428
  """
387
429
  Patch the upstream header so that CFFI can compile it.
388
430
 
389
- The upstream `pact.h` emits the callback parameters of
390
- `pactffi_register_plugin_log_callback` and
391
- `pactffi_register_log_callback` as opaque structs passed by value, which
392
- cannot be compiled. The Rust signatures are `Option<extern "C" fn(...)>`,
393
- whose ABI is a nullable function pointer. See
394
- https://github.com/pact-foundation/pact-reference/issues/544.
431
+ The upstream `pact.h` emits the callback parameter of
432
+ `pactffi_register_log_callback` as an opaque struct passed by value,
433
+ which cannot be compiled. The Rust signature is
434
+ `Option<extern "C" fn(...)>`, whose ABI is a nullable function pointer.
435
+ See https://github.com/pact-foundation/pact-reference/issues/544.
395
436
 
396
437
  Args:
397
438
  source:
398
439
  The contents of the upstream `pact.h`.
399
440
 
400
441
  Returns:
401
- The header contents with the callbacks declared as function
402
- pointers.
442
+ The header contents with the callback declared as a function
443
+ pointer.
403
444
  """
404
- for callback, register, first_arg in (
405
- (
406
- "PluginLogCallback",
407
- "pactffi_register_plugin_log_callback",
408
- "plugin_instance_id",
409
- ),
410
- ("LogCallback", "pactffi_register_log_callback", "log_id"),
411
- ):
412
- opaque = f"typedef struct Option_{callback} Option_{callback};"
413
- if opaque not in source:
414
- continue
415
- self.app.display_debug(f"Patching Option_{callback} in pact.h")
416
- source = source.replace(
417
- opaque,
418
- f"typedef void (*{callback})("
419
- f"const char *{first_arg}, "
420
- "const char *test_run_id, "
421
- "const char *level, "
422
- "const char *target, "
423
- "const char *message);",
424
- ).replace(
425
- f"void {register}(struct Option_{callback} callback);",
426
- f"void {register}({callback} callback);",
427
- )
428
- return source
445
+ opaque = "typedef struct Option_LogCallback Option_LogCallback;"
446
+ if opaque not in source:
447
+ return source
448
+ self.app.display_debug("Patching Option_LogCallback in pact.h")
449
+ return source.replace(
450
+ opaque,
451
+ "typedef void (*LogCallback)("
452
+ "const char *log_id, "
453
+ "const char *test_run_id, "
454
+ "const char *level, "
455
+ "const char *target, "
456
+ "const char *message);",
457
+ ).replace(
458
+ "void pactffi_register_log_callback(struct Option_LogCallback callback);",
459
+ "void pactffi_register_log_callback(LogCallback callback);",
460
+ )
429
461
 
430
462
  def _download(self, url: str) -> Path:
431
463
  """
@@ -458,13 +490,17 @@ class PactBuildHook(BuildHookInterface[Any]):
458
490
  """
459
491
  Infer the tag for the current build.
460
492
 
461
- The bindings are built to target ABI3, which is compatible with multiple
462
- Python versions. As a result, we generate `py{version}-abi3-{platform}`
463
- tags for the wheels.
493
+ The bindings are built to target the stable ABI, which is compatible
494
+ with multiple Python versions. As a result, we generate
495
+ `cp{version}-abi3-{platform}` tags for the wheels.
464
496
 
465
497
  While the ABI3 interface was introduced in Python 3.2, we target the
466
498
  earliest supported version of Python in the Python wrapper.
467
499
 
500
+ A free-threaded build targets `abi3t`, which requires Python 3.15. Its
501
+ wheel takes the `cp{version}-abi3.abi3t-{platform}` tag, as the
502
+ extension also loads on GIL-enabled builds.
503
+
468
504
  Returns:
469
505
  The tag for the current build.
470
506
  """
@@ -485,4 +521,5 @@ class PactBuildHook(BuildHookInterface[Any]):
485
521
  else:
486
522
  raise UnsupportedPlatformError(platform)
487
523
 
488
- return f"cp{python_version}-abi3-{platform}"
524
+ abi = "abi3.abi3t" if _stable_abi() == "abi3t" else "abi3"
525
+ return f"cp{python_version}-{abi}-{platform}"