Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
test:
strategy:
fail-fast: false
matrix:
include:
- os: macos-15
swift: "6.0"
- os: ubuntu-24.04
swift: "6.0"
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Set up Swift
if: runner.os == 'Linux'
uses: swift-actions/setup-swift@7ca6abe6b3b0e8b5421b88be48feee39cbf52c6a # v2.4.0
with:
swift-version: ${{ matrix.swift }}

- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'

- name: Show Swift version
run: swift --version

- name: Configure pkg-config path (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y python3-dev pkg-config

- name: Resolve dependencies
run: swift package resolve

- name: Build
run: swift build --build-tests

- name: Test
run: swift test
6 changes: 3 additions & 3 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: actions/setup-python@v5
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'

- name: Install dependencies
run: pip install mkdocs-material
run: pip install "mkdocs-material==9.5.49"

- name: Deploy to GitHub Pages
run: mkdocs gh-deploy --force
10 changes: 9 additions & 1 deletion Docs/guide/building.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ applepy publish

## Manual Build with SPM

ApplePy uses Swift Package Manager. The `ApplePyFFI` system library target uses `pkg-config` to find Python headers and libraries automatically.
ApplePy uses Swift Package Manager. The `ApplePyFFI` system library target uses `pkg-config` (module `python3-embed`) to find Python headers and libraries automatically. The `-embed` variant is required rather than plain `python3` because ApplePy embeds the interpreter in a host process. A normal CPython extension module resolves Python symbols dynamically from the host interpreter at import time and must *not* link `libpython` directly, but an embedding host process needs to link against `libpython` explicitly — which is exactly what the `-embed` pkg-config module provides.

### Standard build

Expand All @@ -38,6 +38,14 @@ PKG_CONFIG_PATH=$(python3 -c 'import sysconfig; print(sysconfig.get_config_var("
swift build
```

If your Python installation doesn't expose an unversioned `python3-embed.pc` alias (common on some Homebrew/pyenv installs, which only ship a versioned `python-3.1x-embed.pc`), create a symlink in a directory on `PKG_CONFIG_PATH`, e.g.:

```bash
ln -s "$(python3 -c 'import sysconfig; print(sysconfig.get_config_var("LIBPC"))')/python-3.$(python3 -c 'import sys; print(sys.version_info.minor)')-embed.pc" \
/tmp/pkgconfig/python3-embed.pc
export PKG_CONFIG_PATH=/tmp/pkgconfig:$PKG_CONFIG_PATH
```

### Building for Python import

Python extensions need the `.dylib` renamed to `.so`:
Expand Down
7 changes: 6 additions & 1 deletion Docs/guide/memory-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ protocol PyBridged {
PyBridge.store(myCounter, in: pyObject)

// Method wrapper: access the Swift value
let counter = PyBridge.load(Counter.self, from: pyObject)
guard let counter = PyBridge.load(Counter.self, from: pyObject) else {
// Object was already deallocated or the pairing is otherwise corrupted —
// fail gracefully instead of crashing.
PyErr_SetString(PyExc_SystemError, "Counter: underlying Swift value is missing")
return nil
}

// tp_dealloc: release
PyBridge.release(Counter.self, from: pyObject)
Expand Down
11 changes: 5 additions & 6 deletions Examples/Counter/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ PYTHON_HOME=$(python3 -c 'import sys; print(sys.prefix)')
APPLEPY_FFI="$(cd ../.. && pwd)/Sources/ApplePyFFI"

case "$(uname -s)" in
Darwin) PLATFORM="darwin" ;;
Linux) PLATFORM="linux-$(uname -m)" ;;
*) PLATFORM="unknown" ;;
Darwin) PLATFORM="darwin"; UNDEFINED_SYMBOL_FLAGS="-Xlinker -undefined -Xlinker dynamic_lookup" ;;
Linux) PLATFORM="linux-$(uname -m)"; UNDEFINED_SYMBOL_FLAGS="" ;;
*) PLATFORM="unknown"; UNDEFINED_SYMBOL_FLAGS="" ;;
esac

SO_NAME="counter.${PYTHON_TAG}-${PLATFORM}.so"
Expand All @@ -24,9 +24,8 @@ echo "Building ${SO_NAME}..."
# Create a temporary module map for CPython
TMPDIR_MAP=$(mktemp -d)
cat > "$TMPDIR_MAP/module.modulemap" <<EOF
module CPythonShim [system] {
module ApplePyFFI [system] {
header "${APPLEPY_FFI}/include/applepy_shim.h"
link "python3.13"
export *
}
EOF
Expand All @@ -37,7 +36,7 @@ swiftc \
-I "$PYTHON_INCLUDE" \
-I "$TMPDIR_MAP" \
-L "$PYTHON_LIBDIR" \
-Xlinker -undefined -Xlinker dynamic_lookup \
$UNDEFINED_SYMBOL_FLAGS \
-parse-as-library \
counter.swift

Expand Down
2 changes: 1 addition & 1 deletion Examples/Counter/counter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// c.increment()
// print(c.value()) # 12

import CPythonShim
import ApplePyFFI

// ─── The Swift type we're wrapping ──────────────────────────────────────────

Expand Down
15 changes: 8 additions & 7 deletions Examples/HelloWorld/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ PYTHON_LIBDIR=$(python3 -c 'import sysconfig; print(sysconfig.get_config_var("LI
PYTHON_TAG=$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")')
APPLEPY_FFI="$(cd ../.. && pwd)/Sources/ApplePyFFI"

# Determine platform suffix
# Determine platform suffix and the linker flag needed so the extension can
# resolve CPython symbols from the host interpreter at import time instead of
# linking libpython directly (the standard approach for CPython extensions).
case "$(uname -s)" in
Darwin) PLATFORM="darwin" ;;
Linux) PLATFORM="linux-$(uname -m)" ;;
*) PLATFORM="unknown" ;;
Darwin) PLATFORM="darwin"; UNDEFINED_SYMBOL_FLAGS="-Xlinker -undefined -Xlinker dynamic_lookup" ;;
Linux) PLATFORM="linux-$(uname -m)"; UNDEFINED_SYMBOL_FLAGS="" ;;
*) PLATFORM="unknown"; UNDEFINED_SYMBOL_FLAGS="" ;;
esac

SO_NAME="hello.${PYTHON_TAG}-${PLATFORM}.so"
Expand All @@ -29,9 +31,8 @@ echo " ApplePyFFI: ${APPLEPY_FFI}"
# Create a temporary module map for CPython
TMPDIR=$(mktemp -d)
cat > "$TMPDIR/module.modulemap" <<EOF
module CPythonShim [system] {
module ApplePyFFI [system] {
header "${APPLEPY_FFI}/include/applepy_shim.h"
link "python3.13"
export *
}
EOF
Expand All @@ -42,7 +43,7 @@ swiftc \
-I "$PYTHON_INCLUDE" \
-I "$TMPDIR" \
-L "$PYTHON_LIBDIR" \
-Xlinker -undefined -Xlinker dynamic_lookup \
$UNDEFINED_SYMBOL_FLAGS \
-parse-as-library \
hello.swift

Expand Down
2 changes: 1 addition & 1 deletion Examples/HelloWorld/hello.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// so we parse arguments manually using PyTuple_GetItem + type-specific extractors.
// String literals for PyMethodDef/PyModuleDef must have stable (non-temporary) pointers.

import CPythonShim
import ApplePyFFI

// ─── greet(name: str) -> str ────────────────────────────────────────────────

Expand Down
7 changes: 4 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ let package = Package(
.library(name: "ApplePyClient", targets: ["ApplePyClient"]),
],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0"),
.package(url: "https://github.com/swiftlang/swift-syntax.git", "600.0.0"..<"700.0.0"),
],
targets: [
// ── CPython FFI Bindings ─────────────────────────────────────
.systemLibrary(
name: "ApplePyFFI",
pkgConfig: "python3",
pkgConfig: "python3-embed",
providers: [
.brew(["python3"]),
.apt(["python3-dev"]),
Expand Down Expand Up @@ -55,7 +55,8 @@ let package = Package(
"ApplePy",
"ApplePyClient",
"ApplePyMacros",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacroExpansion", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacrosGenericTestSupport", package: "swift-syntax"),
]
),

Expand Down
36 changes: 34 additions & 2 deletions Plugins/ApplePyBundle/ApplePyBundlePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ struct ApplePyBundlePlugin: CommandPlugin {
let modName = moduleName ?? targetName.lowercased()

// Detect Python tag and platform
let pythonTag = try runShell("python3", "-c", "import sys; print(f'cpython-{sys.version_info.major}{sys.version_info.minor}')")
let pythonExecutable = try detectPythonExecutable()
let pythonTag = try runShell(pythonExecutable, "-c", "import sys; print(f'cpython-{sys.version_info.major}{sys.version_info.minor}')")
let platform = try detectPlatform()

let soName = "\(modName).\(pythonTag)-\(platform).so"
Expand Down Expand Up @@ -76,7 +77,27 @@ struct ApplePyBundlePlugin: CommandPlugin {
try FileManager.default.copyItem(at: sourceFile, to: destFile)

Diagnostics.remark("✅ Bundled: \(destFile.path)")
Diagnostics.remark("Import with: python3 -c 'import \(modName)'")
Diagnostics.remark("Import with: \(pythonExecutable) -c 'import \(modName)'")
}

/// Locate a usable Python 3 interpreter on PATH, trying the generic
/// `python3` first and falling back to specific minor versions in
/// descending order (`python3.15`, `python3.14`, ..., `python3.9`).
/// This avoids hardcoding a single Python version that may not be
/// installed on the host machine, and includes upcoming/pre-release
/// versions so newer interpreters are picked up without code changes.
private func detectPythonExecutable() throws -> String {
let candidates = [
"python3",
"python3.15", "python3.14", "python3.13",
"python3.12", "python3.11", "python3.10", "python3.9",
]
for candidate in candidates {
if !(try runShell("which", candidate)).isEmpty {
return candidate
}
}
throw PluginError.pythonNotFound
}

private func runShell(_ args: String...) throws -> String {
Expand All @@ -103,3 +124,14 @@ struct ApplePyBundlePlugin: CommandPlugin {
#endif
}
}

enum PluginError: Error, CustomStringConvertible {
case pythonNotFound

var description: String {
switch self {
case .pythonNotFound:
return "Could not find a Python 3 interpreter on PATH (tried python3, python3.15, python3.14, python3.13, python3.12, python3.11, python3.10, python3.9)"
}
}
}
12 changes: 10 additions & 2 deletions Sources/ApplePy/BufferBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,17 @@ public enum BufferBridge {

/// Extract bytes from a Python `bytes` or `bytearray` object (copies data).
public static func bytesFromPython(_ obj: UnsafeMutablePointer<PyObject>) -> [UInt8]? {
// Try bytes first
if let ptr = PyBytes_AsString(obj) {
// Type-check before extracting so we don't call PyBytes_Size /
// PyBytes_AsString on an object that isn't actually a `bytes`
// instance (e.g. a `bytearray`, which needs the PyByteArray_* API).
if ApplePy_BytesCheck(obj) != 0, let ptr = PyBytes_AsString(obj) {
let len = PyBytes_Size(obj)
guard len >= 0 else { return nil }
return Array(UnsafeBufferPointer(start: ptr.withMemoryRebound(to: UInt8.self, capacity: len) { $0 }, count: len))
}
if ApplePy_ByteArrayCheck(obj) != 0, let ptr = PyByteArray_AsString(obj) {
let len = PyByteArray_Size(obj)
guard len >= 0 else { return nil }
return Array(UnsafeBufferPointer(start: ptr.withMemoryRebound(to: UInt8.self, capacity: len) { $0 }, count: len))
}
return nil
Expand Down
8 changes: 8 additions & 0 deletions Sources/ApplePy/ErrorBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ public func checkPythonError() throws {
/// // In setPythonException:
/// MyError.raise("something went wrong")
/// ```
///
/// - Warning: `@unchecked Sendable` is justified here only under ApplePy's
/// standing convention that every `PyObject*` (including `pyType`) is only
/// ever read or mutated while the Python GIL is held — the GIL provides the
/// mutual exclusion that `Sendable` would otherwise require the compiler to
/// verify. Do not call `raise(_:)`, `register(in:)`, or access `pyType`
/// without holding the GIL (e.g. from a Swift `Task` that has not
/// round-tripped through Python).
public final class PyExceptionType: @unchecked Sendable {
/// The Python exception type object.
public private(set) var pyType: UnsafeMutablePointer<PyObject>?
Expand Down
26 changes: 22 additions & 4 deletions Sources/ApplePy/MemoryBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ public protocol PyBridged {
// MARK: - Default struct box

/// A generic box for value types. Stores a mutable copy on the heap.
///
/// - Warning: `@unchecked Sendable` here is **not** a general safety
/// guarantee — `T` is not required to be `Sendable`, and `value` is a
/// `var` that can be freely mutated. This type is safe to share across
/// threads *only* because ApplePy's convention is that all access to a
/// `PyObjectBox` happens while the Python GIL is held (see `PythonHandle`),
/// which serializes access the same way a lock would. Do not read or write
/// `value` from Swift code that isn't holding the GIL.
public final class PyObjectBox<T>: @unchecked Sendable {
public var value: T

Expand Down Expand Up @@ -62,18 +70,28 @@ public enum PyBridge {

/// Load a Swift value from a Python object without changing the retain count.
/// Call this from method wrappers to access `self`.
///
/// Returns `nil` if the object has no live Swift value stored (e.g. it was
/// already released via `tp_dealloc`, or the pairing was otherwise
/// corrupted). Callers should treat `nil` as an error condition — set a
/// Python exception and return an error sentinel — rather than crash the
/// whole interpreter.
@inlinable
public static func load<T: PyBridged>(_ type: T.Type, from pyObj: UnsafeMutablePointer<PyObject>) -> T {
public static func load<T: PyBridged>(_ type: T.Type, from pyObj: UnsafeMutablePointer<PyObject>) -> T? {
let typed = UnsafeMutableRawPointer(pyObj).assumingMemoryBound(to: SwiftPyObject.self)
let box = Unmanaged<T.Box>.fromOpaque(typed.pointee.swiftPtr!).takeUnretainedValue()
guard let ptr = typed.pointee.swiftPtr else { return nil }
let box = Unmanaged<T.Box>.fromOpaque(ptr).takeUnretainedValue()
return T.unbox(box)
}

/// Load the mutable box from a Python object (for structs that need mutation).
///
/// Returns `nil` under the same conditions as `load(_:from:)`.
@inlinable
public static func loadBox<T: PyBridged>(_ type: T.Type, from pyObj: UnsafeMutablePointer<PyObject>) -> T.Box {
public static func loadBox<T: PyBridged>(_ type: T.Type, from pyObj: UnsafeMutablePointer<PyObject>) -> T.Box? {
let typed = UnsafeMutableRawPointer(pyObj).assumingMemoryBound(to: SwiftPyObject.self)
return Unmanaged<T.Box>.fromOpaque(typed.pointee.swiftPtr!).takeUnretainedValue()
guard let ptr = typed.pointee.swiftPtr else { return nil }
return Unmanaged<T.Box>.fromOpaque(ptr).takeUnretainedValue()
}

/// Release the Swift value stored in a Python object.
Expand Down
Loading
Loading