From 819ea4c35517abd2d477beb9cfb4797577cf4b03 Mon Sep 17 00:00:00 2001 From: Buseong Kim Date: Sat, 6 Dec 2025 18:05:39 +0900 Subject: [PATCH] Add throwing callable wrapper --- PythonKit/Python.swift | 48 +++++++++++++++++++ Tests/PythonKitTests/PythonRuntimeTests.swift | 13 +++++ 2 files changed, 61 insertions(+) diff --git a/PythonKit/Python.swift b/PythonKit/Python.swift index 35d2fcb..cacd6a5 100644 --- a/PythonKit/Python.swift +++ b/PythonKit/Python.swift @@ -204,6 +204,15 @@ public extension PythonObject { var throwing: ThrowingPythonObject { return ThrowingPythonObject(self) } + + /// Returns a dynamic-callable wrapper that surfaces Python exceptions as + /// Swift errors instead of trapping. + /// + /// This keeps the existing `PythonObject` call behavior unchanged while + /// offering an opt-in path to handle errors via `try`/`catch`. + var throwingCallable: ThrowingDynamicCallable { + return ThrowingDynamicCallable(self) + } } /// An error produced by a failable Python operation. @@ -415,6 +424,45 @@ public struct ThrowingPythonObject { } } +/// A dynamic-callable wrapper around `PythonObject` that throws instead of +/// trapping when a Python exception is raised. +@dynamicCallable +public struct ThrowingDynamicCallable { + private var base: PythonObject + + fileprivate init(_ base: PythonObject) { + self.base = base + } + + /// Call `base` with the specified positional arguments. + /// - Precondition: `base` must be a Python callable. + /// - Parameter args: Positional arguments for the Python callable. + @discardableResult + public func dynamicallyCall( + withArguments args: [PythonConvertible] = []) throws -> PythonObject { + return try base.throwing.dynamicallyCall(withArguments: args) + } + + /// Call `base` with the specified arguments. + /// - Precondition: `base` must be a Python callable. + /// - Parameter args: Positional or keyword arguments for the Python callable. + @discardableResult + public func dynamicallyCall( + withKeywordArguments args: + KeyValuePairs = [:]) throws -> PythonObject { + return try base.throwing.dynamicallyCall(withKeywordArguments: args) + } + + /// Alias for the function above that lets the caller dynamically construct the argument list without using a dictionary literal. + /// This must be called explicitly because `@dynamicCallable` does not recognize it on `PythonObject`. + @discardableResult + public func dynamicallyCall( + withKeywordArguments args: + [(key: String, value: PythonConvertible)] = []) throws -> PythonObject { + return try base.throwing.dynamicallyCall(withKeywordArguments: args) + } +} + //===----------------------------------------------------------------------===// // `PythonObject` member access implementation diff --git a/Tests/PythonKitTests/PythonRuntimeTests.swift b/Tests/PythonKitTests/PythonRuntimeTests.swift index 90f1418..3e21464 100644 --- a/Tests/PythonKitTests/PythonRuntimeTests.swift +++ b/Tests/PythonKitTests/PythonRuntimeTests.swift @@ -198,6 +198,19 @@ class PythonRuntimeTests: XCTestCase { } } + func testThrowingCallableWrapper() throws { + let intCtor = Python.int + XCTAssertEqual(try intCtor.throwingCallable("2"), 2) + + XCTAssertThrowsError(try intCtor.throwingCallable("abc")) { error in + guard case let PythonError.exception(exception, _) = error else { + XCTFail("non-Python error: \(error)") + return + } + XCTAssertEqual(exception.__class__.__name__, "ValueError") + } + } + #if !os(Windows) func testTuple() { let element1: PythonObject = 0