From 0a5685138e807c39cdffffbf44c53493e457e06a Mon Sep 17 00:00:00 2001 From: Rick Mark Date: Sat, 12 Sep 2026 21:12:46 -0700 Subject: [PATCH] Async/Await support --- PythonKit/Python.swift | 121 ++++++++++++- PythonKit/PythonLibrary+Symbols.swift | 6 + Tests/PythonKitTests/PythonAsyncTests.swift | 177 ++++++++++++++++++++ 3 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 Tests/PythonKitTests/PythonAsyncTests.swift diff --git a/PythonKit/Python.swift b/PythonKit/Python.swift index 35d2fcb..8be3f15 100644 --- a/PythonKit/Python.swift +++ b/PythonKit/Python.swift @@ -276,6 +276,100 @@ public struct ThrowingPythonObject { self.base = base } + /// Resolves a Python awaitable using a dedicated event loop. + /// + /// Synchronous Python callables are returned unchanged so the async + /// overload can be used with callables whose implementation is selected + /// dynamically. + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + fileprivate func awaitResult(_ result: PythonObject) async throws -> PythonObject { + let gstate = PyGILState_Ensure() + defer { + PyGILState_Release(gstate) + } + + let inspect = Python.import("inspect") + let isAwaitable = try inspect.isawaitable.throwing.callSynchronously( + withArguments: [result]) + guard Bool(isAwaitable) == true else { + return result + } + + let asyncio = Python.import("asyncio") + if let runnerClass = asyncio.checking.Runner { + let runner = try runnerClass.throwing.callSynchronously( + withArguments: [] as [PythonConvertible]) + defer { + _ = try? runner.close.throwing.callSynchronously( + withArguments: []) + } + return try runner.run.throwing.callSynchronously( + withArguments: [result]) + } + + let eventLoop = try asyncio.new_event_loop.throwing.callSynchronously( + withArguments: [] as [PythonConvertible]) + _ = try? asyncio.set_event_loop.throwing.callSynchronously( + withArguments: [eventLoop]) + defer { + _ = try? eventLoop.close.throwing.callSynchronously( + withArguments: []) + _ = try? asyncio.set_event_loop.throwing.callSynchronously( + withArguments: [Python.None]) + } + return try eventLoop.run_until_complete.throwing.callSynchronously( + withArguments: [result]) + } + + /// Asynchronously calls `self` with the specified positional arguments. + /// + /// If the call returns a Python awaitable, it is resolved before the + /// result is returned. Synchronous callables are also supported. + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + @discardableResult + public func dynamicallyCall( + withArguments args: PythonConvertible...) async throws -> PythonObject { + return try await awaitResult( + callSynchronously(withArguments: args)) + } + + /// Asynchronously calls `self` with the specified positional arguments. + /// + /// If the call returns a Python awaitable, it is resolved before the + /// result is returned. Synchronous callables are also supported. + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + @discardableResult + public func dynamicallyCall( + withArguments args: [PythonConvertible]) async throws -> PythonObject { + return try await awaitResult( + callSynchronously(withArguments: args)) + } + + /// Asynchronously calls `self` with the specified positional and keyword + /// arguments. + /// + /// If the call returns a Python awaitable, it is resolved before the + /// result is returned. Synchronous callables are also supported. + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + @discardableResult + public func dynamicallyCall( + withKeywordArguments args: + KeyValuePairs = [:]) async throws -> PythonObject { + return try await awaitResult( + _callSynchronously(args)) + } + + /// Asynchronously calls `self` with dynamically constructed positional + /// and keyword arguments. + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + @discardableResult + public func dynamicallyCall( + withKeywordArguments args: + [(key: String, value: PythonConvertible)]) async throws -> PythonObject { + return try await awaitResult( + _callSynchronously(args)) + } + /// Call `self` with the specified positional arguments. /// If the call fails for some reason, `PythonError.invalidCall` is thrown. /// - Precondition: `self` must be a Python callable. @@ -283,7 +377,7 @@ public struct ThrowingPythonObject { @discardableResult public func dynamicallyCall( withArguments args: PythonConvertible...) throws -> PythonObject { - return try dynamicallyCall(withArguments: args) + return try callSynchronously(withArguments: args) } /// Call `self` with the specified positional arguments. @@ -293,6 +387,11 @@ public struct ThrowingPythonObject { @discardableResult public func dynamicallyCall( withArguments args: [PythonConvertible] = []) throws -> PythonObject { + return try callSynchronously(withArguments: args) + } + + private func callSynchronously( + withArguments args: [PythonConvertible]) throws -> PythonObject { try throwPythonErrorIfPresent() // Positional arguments are passed as a tuple of objects. @@ -322,7 +421,7 @@ public struct ThrowingPythonObject { public func dynamicallyCall( withKeywordArguments args: KeyValuePairs = [:]) throws -> PythonObject { - return try _dynamicallyCall(args) + return try _callSynchronously(args) } /// Alias for the function above that lets the caller dynamically construct the argument list, without using a dictionary literal. @@ -331,11 +430,11 @@ public struct ThrowingPythonObject { public func dynamicallyCall( withKeywordArguments args: [(key: String, value: PythonConvertible)] = []) throws -> PythonObject { - return try _dynamicallyCall(args) + return try _callSynchronously(args) } /// Implementation of `dynamicallyCall(withKeywordArguments)`. - private func _dynamicallyCall(_ args: T) throws -> PythonObject + private func _callSynchronously(_ args: T) throws -> PythonObject where T.Element == (key: String, value: PythonConvertible) { try throwPythonErrorIfPresent() @@ -612,6 +711,14 @@ public extension PythonObject { return result } + /// Resolves `self` if it is a Python awaitable object (such as a coroutine, + /// Task, or Future), returning the completed result. If `self` is not + /// awaitable, returns `self`. + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + func awaitResult() async throws -> PythonObject { + return try await throwing.awaitResult(self) + } + /// Call `self` with the specified positional arguments. /// - Precondition: `self` must be a Python callable. /// - Parameter args: Positional arguments for the Python callable. @@ -1786,7 +1893,7 @@ fileprivate extension PythonFunction { let function = Unmanaged.fromOpaque(funcPointer).takeUnretainedValue() do { - let argumentsAsTuple = PythonObject(consuming: argumentsPointer) + let argumentsAsTuple = PythonObject(argumentsPointer) return try function(argumentsAsTuple).ownedPyObject } catch { PythonFunction.setPythonError(swiftError: error) @@ -1805,10 +1912,10 @@ fileprivate extension PythonFunction { let function = Unmanaged.fromOpaque(funcPointer).takeUnretainedValue() do { - let argumentsAsTuple = PythonObject(consuming: argumentsPointer) + let argumentsAsTuple = PythonObject(argumentsPointer) var keywordArgumentsAsDictionary: PythonObject if let keywordArgumentsPointer = keywordArgumentsPointer { - keywordArgumentsAsDictionary = PythonObject(consuming: keywordArgumentsPointer) + keywordArgumentsAsDictionary = PythonObject(keywordArgumentsPointer) } else { keywordArgumentsAsDictionary = [:] } diff --git a/PythonKit/PythonLibrary+Symbols.swift b/PythonKit/PythonLibrary+Symbols.swift index c4db681..4df8c31 100644 --- a/PythonKit/PythonLibrary+Symbols.swift +++ b/PythonKit/PythonLibrary+Symbols.swift @@ -41,6 +41,12 @@ let Py_GE: Int32 = 5 let Py_Initialize: @convention(c) () -> Void = PythonLibrary.loadSymbol(name: "Py_Initialize") +let PyGILState_Ensure: @convention(c) () -> Int32 = + PythonLibrary.loadSymbol(name: "PyGILState_Ensure") + +let PyGILState_Release: @convention(c) (Int32) -> Void = + PythonLibrary.loadSymbol(name: "PyGILState_Release") + let Py_IncRef: @convention(c) (PyObjectPointer?) -> Void = PythonLibrary.loadSymbol(name: "Py_IncRef") diff --git a/Tests/PythonKitTests/PythonAsyncTests.swift b/Tests/PythonKitTests/PythonAsyncTests.swift new file mode 100644 index 0000000..72022cb --- /dev/null +++ b/Tests/PythonKitTests/PythonAsyncTests.swift @@ -0,0 +1,177 @@ +import XCTest +import PythonKit + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +final class PythonAsyncTests: XCTestCase { + private var canUseAsyncPython: Bool { + let versionMajor = Python.versionInfo.major + let versionMinor = Python.versionInfo.minor + return (versionMajor == 3 && versionMinor >= 13) || versionMajor > 3 + } + + func testAsyncSleep() async throws { + guard canUseAsyncPython else { return } + let sleep = Python.import("asyncio").sleep + let result = try await sleep.throwing.dynamicallyCall(withArguments: 0) + XCTAssertEqual(result, Python.None) + } + + func testAsyncFunctionWithReturn() async throws { + guard canUseAsyncPython else { return } + let builtins = Python.import("builtins") + let globals: PythonObject = [:] + builtins.exec(""" + import asyncio + + async def add_numbers(a, b): + await asyncio.sleep(0.001) + return a + b + """, globals) + + let addNumbers = globals["add_numbers"] + let sumResult = try await addNumbers.throwing.dynamicallyCall(withArguments: 21, 21) + XCTAssertEqual(Int(sumResult), 42) + } + + func testAsyncFunctionWithKeywords() async throws { + guard canUseAsyncPython else { return } + let builtins = Python.import("builtins") + let globals: PythonObject = [:] + builtins.exec(""" + import asyncio + + async def format_greeting(greeting, name="World"): + await asyncio.sleep(0.001) + return f"{greeting}, {name}!" + """, globals) + + let formatGreeting = globals["format_greeting"] + let result = try await formatGreeting.throwing.dynamicallyCall( + withKeywordArguments: ["greeting": "Hello", "name": "Swift"] + ) + XCTAssertEqual(String(result), "Hello, Swift!") + } + + func testAsyncFunctionWithCallback() async throws { + guard canUseAsyncPython else { return } + let builtins = Python.import("builtins") + let globals: PythonObject = [:] + builtins.exec(""" + import asyncio + + async def process_data(data, callback): + await asyncio.sleep(0.001) + result = f"processed: {data}" + if callback is not None: + callback(result) + return result + """, globals) + + var callbackCalled = false + var callbackValue: String? = nil + + let swiftCallback = PythonFunction { args in + callbackCalled = true + callbackValue = String(args[0]) + return Python.None + } + + let processData = globals["process_data"] + let result = try await processData.throwing.dynamicallyCall( + withArguments: "sample_input", swiftCallback + ) + + XCTAssertEqual(String(result), "processed: sample_input") + XCTAssertTrue(callbackCalled) + XCTAssertEqual(callbackValue, "processed: sample_input") + } + + func testAsyncFunctionWithMultipleCallbacks() async throws { + guard canUseAsyncPython else { return } + let builtins = Python.import("builtins") + let globals: PythonObject = [:] + builtins.exec(""" + import asyncio + + async def stream_items(items, on_item, on_complete): + for item in items: + await asyncio.sleep(0.001) + on_item(item) + on_complete(len(items)) + return len(items) + """, globals) + + var receivedItems: [Int] = [] + var totalCount: Int? = nil + + let onItem = PythonFunction { args in + if let val = Int(args[0]) { + receivedItems.append(val) + } + return Python.None + } + + let onComplete = PythonFunction { args in + totalCount = Int(args[0]) + return Python.None + } + + let streamItems = globals["stream_items"] + let count = try await streamItems.throwing.dynamicallyCall( + withArguments: [10, 20, 30], onItem, onComplete + ) + + XCTAssertEqual(Int(count), 3) + XCTAssertEqual(receivedItems, [10, 20, 30]) + XCTAssertEqual(totalCount, 3) + } + + func testAsyncFunctionException() async throws { + guard canUseAsyncPython else { return } + let builtins = Python.import("builtins") + let globals: PythonObject = [:] + builtins.exec(""" + import asyncio + + async def fail_task(): + await asyncio.sleep(0.001) + raise ValueError("Something went wrong in async execution") + """, globals) + + let failTask = globals["fail_task"] + do { + _ = try await failTask.throwing.dynamicallyCall() + XCTFail("Expected async call to throw PythonError.exception") + } catch PythonError.exception(let error, _) { + XCTAssertEqual(String(error.__class__.__name__), "ValueError") + XCTAssertTrue(String(describing: error).contains("Something went wrong in async execution")) + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + + func testAwaitResultDirectlyOnCoroutine() async throws { + guard canUseAsyncPython else { return } + let builtins = Python.import("builtins") + let globals: PythonObject = [:] + builtins.exec(""" + import asyncio + + async def get_value(): + await asyncio.sleep(0.001) + return 99 + """, globals) + + let getValue = globals["get_value"] + let coroutine = getValue() + let result = try await coroutine.awaitResult() + XCTAssertEqual(Int(result), 99) + } + + func testAwaitResultOnNonAwaitable() async throws { + guard canUseAsyncPython else { return } + let number: PythonObject = 42 + let result = try await number.awaitResult() + XCTAssertEqual(Int(result), 42) + } +}