Skip to content
Open
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
121 changes: 114 additions & 7 deletions PythonKit/Python.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,14 +276,108 @@ 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)
Comment on lines +286 to +288
}

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])
Comment on lines +312 to +318
}
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<String, PythonConvertible> = [:]) 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.
/// - Parameter args: Positional arguments for the Python callable.
@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.
Expand All @@ -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.
Expand Down Expand Up @@ -322,7 +421,7 @@ public struct ThrowingPythonObject {
public func dynamicallyCall(
withKeywordArguments args:
KeyValuePairs<String, PythonConvertible> = [:]) 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.
Expand All @@ -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<T : Collection>(_ args: T) throws -> PythonObject
private func _callSynchronously<T : Collection>(_ args: T) throws -> PythonObject
where T.Element == (key: String, value: PythonConvertible) {
try throwPythonErrorIfPresent()

Expand Down Expand Up @@ -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`.
Comment on lines +714 to +716
@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.
Expand Down Expand Up @@ -1786,7 +1893,7 @@ fileprivate extension PythonFunction {
let function = Unmanaged<PyFunction>.fromOpaque(funcPointer).takeUnretainedValue()

do {
let argumentsAsTuple = PythonObject(consuming: argumentsPointer)
let argumentsAsTuple = PythonObject(argumentsPointer)
return try function(argumentsAsTuple).ownedPyObject
} catch {
PythonFunction.setPythonError(swiftError: error)
Expand All @@ -1805,10 +1912,10 @@ fileprivate extension PythonFunction {
let function = Unmanaged<PyFunction>.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 = [:]
}
Expand Down
6 changes: 6 additions & 0 deletions PythonKit/PythonLibrary+Symbols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
177 changes: 177 additions & 0 deletions Tests/PythonKitTests/PythonAsyncTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}