From 66c96f73138c3d59c1b1dd87ae489f6ff0024a0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:57:38 +0000 Subject: [PATCH 1/8] Fix Python version hardcoding, consolidate macros, add CI workflow --- .github/workflows/ci.yml | 54 +++ .github/workflows/docs.yml | 6 +- Docs/guide/building.md | 10 +- Package.swift | 4 +- .../ApplePyBundle/ApplePyBundlePlugin.swift | 30 +- Sources/ApplePyFFI/module.modulemap | 1 - Sources/ApplePyMacroCore/PyClassMacro.swift | 334 ------------------ Sources/ApplePyMacroCore/PyEnumMacro.swift | 318 ----------------- .../ApplePyMacroCore/PyFunctionMacro.swift | 176 --------- Sources/ApplePyMacroCore/PyMethodMacro.swift | 26 -- Sources/ApplePyMacroCore/PyModuleMacro.swift | 104 ------ .../ApplePyMacroCore/PyPropertyMacro.swift | 34 -- .../PyStaticMethodMacro.swift | 33 -- Sources/ApplePyMacros/ApplePyMacros.swift | 2 +- 14 files changed, 97 insertions(+), 1035 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 Sources/ApplePyMacroCore/PyClassMacro.swift delete mode 100644 Sources/ApplePyMacroCore/PyEnumMacro.swift delete mode 100644 Sources/ApplePyMacroCore/PyFunctionMacro.swift delete mode 100644 Sources/ApplePyMacroCore/PyMethodMacro.swift delete mode 100644 Sources/ApplePyMacroCore/PyModuleMacro.swift delete mode 100644 Sources/ApplePyMacroCore/PyPropertyMacro.swift delete mode 100644 Sources/ApplePyMacroCore/PyStaticMethodMacro.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..525749d --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7fa0183..8528028 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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 diff --git a/Docs/guide/building.md b/Docs/guide/building.md index 86f8a01..bf0645f 100644 --- a/Docs/guide/building.md +++ b/Docs/guide/building.md @@ -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 and needs to link against `libpython`, unlike a normal CPython extension module. ### Standard build @@ -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`: diff --git a/Package.swift b/Package.swift index af55bce..4c0af8c 100644 --- a/Package.swift +++ b/Package.swift @@ -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"]), diff --git a/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift b/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift index cd38c3f..2402728 100644 --- a/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift +++ b/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift @@ -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" @@ -76,7 +77,21 @@ 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. + /// This avoids hardcoding a single Python version that may not be + /// installed on the host machine. + private func detectPythonExecutable() throws -> String { + let candidates = ["python3", "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 { @@ -103,3 +118,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.13, python3.12, python3.11, python3.10, python3.9)" + } + } +} diff --git a/Sources/ApplePyFFI/module.modulemap b/Sources/ApplePyFFI/module.modulemap index 0012b23..6cc1ae0 100644 --- a/Sources/ApplePyFFI/module.modulemap +++ b/Sources/ApplePyFFI/module.modulemap @@ -1,5 +1,4 @@ module ApplePyFFI [system] { header "include/applepy_shim.h" - link "python3.13" export * } diff --git a/Sources/ApplePyMacroCore/PyClassMacro.swift b/Sources/ApplePyMacroCore/PyClassMacro.swift deleted file mode 100644 index 3afbbfa..0000000 --- a/Sources/ApplePyMacroCore/PyClassMacro.swift +++ /dev/null @@ -1,334 +0,0 @@ -// ApplePy – @PyClass Macro Implementation -// Generates: PyObject storage struct, tp_new, tp_init, tp_dealloc, PyType_Spec, and registration. - -import SwiftSyntax -import SwiftSyntaxMacros - -public struct PyClassMacro: MemberMacro { - public static func expansion( - of node: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - // Get the type name - let typeName: String - if let structDecl = declaration.as(StructDeclSyntax.self) { - typeName = structDecl.name.text - } else if let classDecl = declaration.as(ClassDeclSyntax.self) { - typeName = classDecl.name.text - } else { - throw MacroError("@PyClass can only be applied to structs or classes") - } - - let isClass = declaration.is(ClassDeclSyntax.self) - let boxName = "_\(typeName)_Box" - let pyObjName = "_\(typeName)_PyObject" - - // Find the init parameters (first non-private init) - let initParams = extractInitParams(from: declaration) - - // Find all @PyMethod-annotated methods - let methods = extractPyMethods(from: declaration) - - // Find __repr__ method - let hasRepr = methods.contains { $0.pythonName == "__repr__" } - - var decls: [DeclSyntax] = [] - - // 1. Generate the Box class (for value types; for classes, box IS the class) - if !isClass { - decls.append(""" - private final class \(raw: boxName) { - var value: \(raw: typeName) - init(_ v: \(raw: typeName)) { self.value = v } - } - """) - } - - // 2. Generate the PyObject storage struct - decls.append(""" - struct \(raw: pyObjName) { - var ob_base: PyObject - var swiftPtr: UnsafeMutableRawPointer? - } - """) - - // 3. Generate box helpers - let boxType = isClass ? typeName : boxName - decls.append(""" - private static func _getSwiftValue(_ pyObj: UnsafeMutablePointer) -> \(raw: typeName) { - let typed = UnsafeMutableRawPointer(pyObj).assumingMemoryBound(to: \(raw: pyObjName).self) - let box = Unmanaged<\(raw: boxType)>.fromOpaque(typed.pointee.swiftPtr!).takeUnretainedValue() - return \(raw: isClass ? "box" : "box.value") - } - """) - - decls.append(""" - private static func _setSwiftValue(_ pyObj: UnsafeMutablePointer, _ value: \(raw: typeName)) { - let typed = UnsafeMutableRawPointer(pyObj).assumingMemoryBound(to: \(raw: pyObjName).self) - let box: \(raw: boxType) = \(raw: isClass ? "value" : "\(boxName)(value)") - typed.pointee.swiftPtr = Unmanaged.passRetained(box).toOpaque() - } - """) - - decls.append(""" - private static func _releaseSwiftValue(_ pyObj: UnsafeMutablePointer) { - let typed = UnsafeMutableRawPointer(pyObj).assumingMemoryBound(to: \(raw: pyObjName).self) - if let ptr = typed.pointee.swiftPtr { - Unmanaged<\(raw: boxType)>.fromOpaque(ptr).release() - typed.pointee.swiftPtr = nil - } - } - """) - - // 4. Generate tp_new - let tpNewName = "_applepy_\(typeName)_tp_new" - decls.append(""" - @_cdecl("\(raw: tpNewName)") - public static func _tp_new( - _ type: UnsafeMutablePointer?, - _ args: UnsafeMutablePointer?, - _ kwargs: UnsafeMutablePointer? - ) -> UnsafeMutablePointer? { - guard let type = type else { return nil } - guard let self_ = type.pointee.tp_alloc?(type, 0) else { return nil } - let typed = UnsafeMutableRawPointer(self_).assumingMemoryBound(to: \(raw: pyObjName).self) - typed.pointee.swiftPtr = nil - return self_ - } - """) - - // 5. Generate tp_init - let tpInitName = "_applepy_\(typeName)_tp_init" - let initBody = generateInitBody(typeName: typeName, params: initParams) - decls.append(""" - @_cdecl("\(raw: tpInitName)") - public static func _tp_init( - _ self_: UnsafeMutablePointer?, - _ args: UnsafeMutablePointer?, - _ kwargs: UnsafeMutablePointer? - ) -> Int32 { - guard let self_ = self_ else { return -1 } - let _py = PythonHandle() - \(raw: initBody) - } - """) - - // 6. Generate tp_dealloc - let tpDeallocName = "_applepy_\(typeName)_tp_dealloc" - decls.append(""" - @_cdecl("\(raw: tpDeallocName)") - public static func _tp_dealloc(_ self_: UnsafeMutablePointer?) { - guard let self_ = self_ else { return } - _releaseSwiftValue(self_) - let type = ApplePy_TYPE(self_) - type?.pointee.tp_free?(UnsafeMutableRawPointer(self_)) - } - """) - - // 7. Generate tp_repr (if declared) - let tpReprName = "_applepy_\(typeName)_tp_repr" - if hasRepr { - decls.append(""" - @_cdecl("\(raw: tpReprName)") - public static func _tp_repr(_ self_: UnsafeMutablePointer?) -> UnsafeMutablePointer? { - guard let self_ = self_ else { return nil } - let value = _getSwiftValue(self_) - let repr = value.__repr__() - return repr.withCString { PyUnicode_FromString($0) } - } - """) - } - - // 8. Generate method table - let regularMethods = methods.filter { !$0.pythonName.hasPrefix("__") } - var methodTableEntries: [String] = [] - for method in regularMethods { - let wrapperName = "_applepy_\(typeName)_\(method.swiftName)" - let flags = method.paramCount == 0 ? "METH_NOARGS" : "METH_VARARGS" - methodTableEntries.append(""" - PyMethodDef( - ml_name: "\(method.pythonName)".withCString { UnsafePointer(strdup($0)!) }, - ml_meth: \(wrapperName), - ml_flags: \(flags), - ml_doc: nil - ) - """) - } - methodTableEntries.append("PyMethodDef(ml_name: nil, ml_meth: nil, ml_flags: 0, ml_doc: nil)") - - decls.append(""" - static var _pyMethods: [PyMethodDef] = [ - \(raw: methodTableEntries.joined(separator: ",\n ")) - ] - """) - - // 9. Generate slot array - var slotEntries: [String] = [] - slotEntries.append(""" - PyType_Slot(slot: Py_tp_new, pfunc: unsafeBitCast(\(tpNewName) as @convention(c) (UnsafeMutablePointer?, UnsafeMutablePointer?, UnsafeMutablePointer?) -> UnsafeMutablePointer?, to: UnsafeMutableRawPointer.self)) - """) - slotEntries.append(""" - PyType_Slot(slot: Py_tp_init, pfunc: unsafeBitCast(\(tpInitName) as @convention(c) (UnsafeMutablePointer?, UnsafeMutablePointer?, UnsafeMutablePointer?) -> Int32, to: UnsafeMutableRawPointer.self)) - """) - slotEntries.append(""" - PyType_Slot(slot: Py_tp_dealloc, pfunc: unsafeBitCast(\(tpDeallocName) as @convention(c) (UnsafeMutablePointer?) -> Void, to: UnsafeMutableRawPointer.self)) - """) - if hasRepr { - slotEntries.append(""" - PyType_Slot(slot: Py_tp_repr, pfunc: unsafeBitCast(\(tpReprName) as @convention(c) (UnsafeMutablePointer?) -> UnsafeMutablePointer?, to: UnsafeMutableRawPointer.self)) - """) - } - slotEntries.append(""" - PyType_Slot(slot: Py_tp_methods, pfunc: UnsafeMutableRawPointer(&_pyMethods)) - """) - slotEntries.append("PyType_Slot(slot: 0, pfunc: nil)") - - decls.append(""" - static var _pySlots: [PyType_Slot] = [ - \(raw: slotEntries.joined(separator: ",\n ")) - ] - """) - - // 10. Generate PyType_Spec - decls.append(""" - static var _pyTypeSpec: PyType_Spec = { - let name: UnsafePointer = "\(raw: typeName)".withCString { UnsafePointer(strdup($0)!) } - return PyType_Spec( - name: name, - basicsize: Int32(MemoryLayout<\(raw: pyObjName)>.size), - itemsize: 0, - flags: UInt32(Py_TPFLAGS_DEFAULT) | UInt32(Py_TPFLAGS_BASETYPE), - slots: &_pySlots - ) - }() - """) - - // 11. Generate registerType helper - decls.append(""" - public static func registerType(in module: UnsafeMutablePointer) -> Bool { - guard let typeObj = PyType_FromSpec(&_pyTypeSpec) else { return false } - let name: UnsafePointer = "\(raw: typeName)".withCString { UnsafePointer(strdup($0)!) } - if PyModule_AddObject(module, name, typeObj) < 0 { - ApplePy_DECREF(typeObj) - return false - } - return true - } - """) - - return decls - } - - // MARK: - Helpers - - private static func extractInitParams(from declaration: some DeclGroupSyntax) -> [(name: String, type: String)] { - var params: [(name: String, type: String)] = [] - - for member in declaration.memberBlock.members { - if let initDecl = member.decl.as(InitializerDeclSyntax.self) { - let sig = initDecl.signature - for param in sig.parameterClause.parameters { - let name = param.secondName?.text ?? param.firstName.text - let type = param.type.trimmedDescription - params.append((name: name, type: type)) - } - break // use first init - } - } - return params - } - - private static func extractPyMethods(from declaration: some DeclGroupSyntax) -> [MethodInfo] { - var methods: [MethodInfo] = [] - - for member in declaration.memberBlock.members { - guard let funcDecl = member.decl.as(FunctionDeclSyntax.self) else { continue } - - // Check if it has @PyMethod attribute - let hasPyMethod = funcDecl.attributes.contains { attr in - if case .attribute(let a) = attr { - return a.attributeName.trimmedDescription == "PyMethod" - } - return false - } - - guard hasPyMethod else { continue } - - // Extract Python name from @PyMethod("name") or use Swift name - var pythonName = funcDecl.name.text - for attr in funcDecl.attributes { - if case .attribute(let a) = attr, - a.attributeName.trimmedDescription == "PyMethod", - let argList = a.arguments?.as(LabeledExprListSyntax.self), - let firstArg = argList.first, - let strLiteral = firstArg.expression.as(StringLiteralExprSyntax.self) { - pythonName = strLiteral.segments.trimmedDescription - } - } - - let paramCount = funcDecl.signature.parameterClause.parameters.count - methods.append(MethodInfo(swiftName: funcDecl.name.text, pythonName: pythonName, paramCount: paramCount)) - } - - return methods - } - - private static func generateInitBody(typeName: String, params: [(name: String, type: String)]) -> String { - if params.isEmpty { - return """ - _releaseSwiftValue(self_) - let value = \(typeName)() - _setSwiftValue(self_, value) - return 0 - """ - } - - var lines: [String] = [] - lines.append(""" - guard let args = args else { - PyErr_SetString(PyExc_TypeError, "\(typeName)() requires arguments") - return -1 - } - let _nArgs = PyTuple_Size(args) - guard _nArgs == \(params.count) else { - PyErr_SetString(PyExc_TypeError, "\(typeName)() takes exactly \(params.count) argument(s)") - return -1 - } - """) - - var callArgs: [String] = [] - for (i, param) in params.enumerated() { - lines.append(""" - guard let _pyArg\(i) = PyTuple_GetItem(args, \(i)) else { return -1 } - """) - - // For tp_init we can't use try — need to handle errors manually - lines.append(""" - let \(param.name): \(param.type) - do { - \(param.name) = try \(param.type).fromPython(_pyArg\(i), py: _py) - } catch { - PyErr_SetString(PyExc_TypeError, "\\(error)") - return -1 - } - """) - callArgs.append("\(param.name): \(param.name)") - } - - lines.append(""" - _releaseSwiftValue(self_) - let value = \(typeName)(\(callArgs.joined(separator: ", "))) - _setSwiftValue(self_, value) - return 0 - """) - - return lines.joined(separator: "\n") - } -} - -struct MethodInfo { - let swiftName: String - let pythonName: String - let paramCount: Int -} diff --git a/Sources/ApplePyMacroCore/PyEnumMacro.swift b/Sources/ApplePyMacroCore/PyEnumMacro.swift deleted file mode 100644 index ec70859..0000000 --- a/Sources/ApplePyMacroCore/PyEnumMacro.swift +++ /dev/null @@ -1,318 +0,0 @@ -// ApplePy – @PyEnum Macro Implementation -// Generates Python types from Swift enums. -// -// Simple enums (Int raw values) → Python enum.IntEnum -// Variant enums (associated values) → Python class hierarchy -// -// Usage (simple): -// @PyEnum -// enum Color: Int { -// case red = 0 -// case green = 1 -// case blue = 2 -// } -// -// Usage (variant): -// @PyEnum -// enum Shape { -// case circle(radius: Double) -// case rect(width: Double, height: Double) -// } -// -// Generates registerEnum(in:) that creates the appropriate Python types. - -import SwiftSyntax -import SwiftSyntaxMacros - -public struct PyEnumMacro: MemberMacro { - public static func expansion( - of node: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { - throw MacroError("@PyEnum can only be applied to enums") - } - - let enumName = enumDecl.name.text - - // Detect whether this is a simple (Int raw) or variant (associated values) enum - let hasAssociatedValues = enumDecl.memberBlock.members.contains { member in - if let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) { - return caseDecl.elements.contains { $0.parameterClause != nil } - } - return false - } - - if hasAssociatedValues { - return try generateVariantEnum(enumName: enumName, enumDecl: enumDecl) - } else { - return try generateIntEnum(enumName: enumName, enumDecl: enumDecl) - } - } - - // MARK: - Simple IntEnum - - private static func generateIntEnum(enumName: String, enumDecl: EnumDeclSyntax) throws -> [DeclSyntax] { - let hasIntRaw = enumDecl.inheritanceClause?.inheritedTypes.contains { - $0.type.trimmedDescription == "Int" - } ?? false - - var cases: [(name: String, rawValue: String)] = [] - var autoValue = 0 - for member in enumDecl.memberBlock.members { - if let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) { - for element in caseDecl.elements { - let caseName = element.name.text - if let rawValue = element.rawValue?.value.trimmedDescription { - cases.append((name: caseName, rawValue: rawValue)) - if let intVal = Int(rawValue) { autoValue = intVal + 1 } - } else if hasIntRaw { - cases.append((name: caseName, rawValue: "\(autoValue)")) - autoValue += 1 - } else { - cases.append((name: caseName, rawValue: "\(autoValue)")) - autoValue += 1 - } - } - } - } - - var decls: [DeclSyntax] = [] - - decls.append(""" - static func _fromPythonInt(_ val: Int) -> \(raw: enumName)? { - return \(raw: enumName)(rawValue: val) - } - """) - - decls.append(""" - func _toPythonInt() -> Int { - return self.rawValue - } - """) - - decls.append(""" - public static func registerEnum(in module: UnsafeMutablePointer) -> Bool { - guard let enumMod = PyImport_ImportModule("enum") else { return false } - defer { ApplePy_DECREF(enumMod) } - - guard let intEnumClass = PyObject_GetAttrString(enumMod, "IntEnum") else { return false } - defer { ApplePy_DECREF(intEnumClass) } - - let memberList = PyList_New(\(raw: cases.count))! - \(raw: cases.enumerated().map { i, c in - """ - do { - let pair = PyTuple_New(2)! - PyTuple_SetItem(pair, 0, PyUnicode_FromString("\(c.name)")) - PyTuple_SetItem(pair, 1, PyLong_FromLongLong(Int64(\(c.rawValue)))) - PyList_SetItem(memberList, \(i), pair) - } - """ - }.joined(separator: "\n ")) - - let args = PyTuple_New(2)! - PyTuple_SetItem(args, 0, PyUnicode_FromString("\(raw: enumName)")) - PyTuple_SetItem(args, 1, memberList) - - guard let enumType = PyObject_CallObject(intEnumClass, args) else { - ApplePy_DECREF(args) - return false - } - ApplePy_DECREF(args) - - let name: UnsafePointer = "\(raw: enumName)".withCString { UnsafePointer(strdup($0)!) } - if PyModule_AddObject(module, name, enumType) < 0 { - ApplePy_DECREF(enumType) - return false - } - return true - } - """) - - return decls - } - - // MARK: - Variant Enum (Associated Values → Class Hierarchy) - - private static func generateVariantEnum(enumName: String, enumDecl: EnumDeclSyntax) throws -> [DeclSyntax] { - // Extract all cases with their associated values - struct VariantCase { - let name: String - let capitalizedName: String - let params: [(label: String, type: String)] - } - - var variants: [VariantCase] = [] - for member in enumDecl.memberBlock.members { - if let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) { - for element in caseDecl.elements { - let caseName = element.name.text - let capitalized = caseName.prefix(1).uppercased() + caseName.dropFirst() - var params: [(label: String, type: String)] = [] - if let paramClause = element.parameterClause { - for (idx, param) in paramClause.parameters.enumerated() { - let label = param.firstName?.text ?? "_\(idx)" - let type = param.type.trimmedDescription - params.append((label: label, type: type)) - } - } - variants.append(VariantCase(name: caseName, capitalizedName: capitalized, params: params)) - } - } - } - - // Map Swift type names to Python type format codes - func pyTypeString(_ swiftType: String) -> String { - switch swiftType { - case "Int": return "int" - case "Double", "Float": return "float" - case "String": return "str" - case "Bool": return "bool" - default: return "object" - } - } - - var decls: [DeclSyntax] = [] - - // Generate registerEnum that creates: - // class Shape: # base (tag only) - // pass - // class Circle(Shape): - // def __init__(self, radius): self.radius = radius - // class Rectangle(Shape): - // def __init__(self, width, height): ... - decls.append(""" - public static func registerEnum(in module: UnsafeMutablePointer) -> Bool { - // Create base class via: type("EnumName", (object,), {"__slots__": ()}) - guard let builtins = PyImport_ImportModule("builtins") else { return false } - defer { ApplePy_DECREF(builtins) } - guard let typeFunc = PyObject_GetAttrString(builtins, "type") else { return false } - defer { ApplePy_DECREF(typeFunc) } - - // Base class dict with __slots__ = () - guard let baseDict = PyDict_New() else { return false } - guard let emptyTuple = PyTuple_New(0) else { - ApplePy_DECREF(baseDict) - return false - } - PyDict_SetItemString(baseDict, "__slots__", emptyTuple) - ApplePy_DECREF(emptyTuple) - - // type("EnumName", (object,), {"__slots__": ()}) - let baseBases = PyTuple_New(1)! - guard let objectType = PyObject_GetAttrString(builtins, "object") else { - ApplePy_DECREF(baseDict) - return false - } - PyTuple_SetItem(baseBases, 0, objectType) - - let baseArgs = PyTuple_New(3)! - PyTuple_SetItem(baseArgs, 0, PyUnicode_FromString("\(raw: enumName)")) - PyTuple_SetItem(baseArgs, 1, baseBases) - PyTuple_SetItem(baseArgs, 2, baseDict) - - guard let baseClass = PyObject_CallObject(typeFunc, baseArgs) else { - ApplePy_DECREF(baseArgs) - return false - } - ApplePy_DECREF(baseArgs) - - // Add base class to module - if PyModule_AddObject(module, "\(raw: enumName)", baseClass) < 0 { - ApplePy_DECREF(baseClass) - return false - } - - // Create each variant subclass - \(raw: variants.map { v in - let slotNames = v.params.map { "\"\($0.label)\"" }.joined(separator: ", ") - let initParams = v.params.map { "\($0.label)" }.joined(separator: ", ") - let initBody = v.params.map { "self.\($0.label) = \($0.label)" }.joined(separator: "; ") - let initCode: String - if v.params.isEmpty { - initCode = "pass" - } else { - initCode = """ - def __init__(self, \(initParams)): - \(v.params.map { "self.\($0.label) = \($0.label)" }.joined(separator: "\n ")) - """ - } - return """ - do { - // Variant: \(v.capitalizedName) - let variantDict = PyDict_New()! - let slots = PyTuple_New(\(v.params.count))! - \(v.params.enumerated().map { i, p in - "PyTuple_SetItem(slots, \(i), PyUnicode_FromString(\"\(p.label)\"))" - }.joined(separator: "\n ")) - PyDict_SetItemString(variantDict, "__slots__", slots) - ApplePy_DECREF(slots) - - // Create __init__ via compile/exec - \(v.params.isEmpty ? "" : """ - let initSrc = PyUnicode_FromString("def __init__(self, \(initParams)):\\n\(v.params.map { " self.\($0.label) = \($0.label)" }.joined(separator: "\\n"))")! - let initCode = Py_CompileString( - "def __init__(self, \(initParams)):\\n\(v.params.map { " self.\($0.label) = \($0.label)" }.joined(separator: "\\n"))", - "<\(v.name)>", - Int32(258) // Py_file_input - ) - if let initCode = initCode { - let initNs = PyDict_New()! - if let _ = PyEval_EvalCode(initCode, initNs, initNs) { - if let initFn = PyDict_GetItemString(initNs, "__init__") { - ApplePy_INCREF(initFn) - PyDict_SetItemString(variantDict, "__init__", initFn) - ApplePy_DECREF(initFn) - } - } - ApplePy_DECREF(initNs) - ApplePy_DECREF(initCode) - } - ApplePy_DECREF(initSrc) - """) - - // type("VariantName", (BaseClass,), dict) - let variantBases = PyTuple_New(1)! - ApplePy_INCREF(baseClass) - PyTuple_SetItem(variantBases, 0, baseClass) - - let variantArgs = PyTuple_New(3)! - PyTuple_SetItem(variantArgs, 0, PyUnicode_FromString("\(v.capitalizedName)")) - PyTuple_SetItem(variantArgs, 1, variantBases) - PyTuple_SetItem(variantArgs, 2, variantDict) - - if let variantClass = PyObject_CallObject(typeFunc, variantArgs) { - PyModule_AddObject(module, "\(v.capitalizedName)", variantClass) - // Also add as attribute on base: Shape.Circle = Circle - PyObject_SetAttrString(baseClass, "\(v.capitalizedName)", variantClass) - } - ApplePy_DECREF(variantArgs) - } - """ - }.joined(separator: "\n ")) - - return true - } - """) - - // Generate tag-checking helper - decls.append(""" - var _variantTag: String { - switch self { - \(raw: variants.map { v in - if v.params.isEmpty { - return "case .\(v.name): return \"\(v.capitalizedName)\"" - } else { - let underscores = v.params.map { _ in "_" }.joined(separator: ", ") - return "case .\(v.name)(\(underscores)): return \"\(v.capitalizedName)\"" - } - }.joined(separator: "\n ")) - } - } - """) - - return decls - } -} diff --git a/Sources/ApplePyMacroCore/PyFunctionMacro.swift b/Sources/ApplePyMacroCore/PyFunctionMacro.swift deleted file mode 100644 index aea5c16..0000000 --- a/Sources/ApplePyMacroCore/PyFunctionMacro.swift +++ /dev/null @@ -1,176 +0,0 @@ -// ApplePy – @PyFunction Macro Implementation -// Generates a @_cdecl wrapper + PyMethodDef for a top-level Swift function. - -import SwiftSyntax -import SwiftSyntaxMacros - -public struct PyFunctionMacro: PeerMacro { - public static func expansion( - of node: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - // The declaration must be a function - guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else { - throw MacroError("@PyFunction can only be applied to functions") - } - - let funcName = funcDecl.name.text - let wrapperName = "_applepy_\(funcName)" - let params = funcDecl.signature.parameterClause.parameters - let returnsVoid = funcDecl.signature.returnClause == nil - let isThrowing = funcDecl.signature.effectSpecifiers?.throwsClause != nil - - // Build argument unpacking lines - var unpackLines: [String] = [] - var callArgs: [String] = [] - let paramCount = params.count - - // Track param info: (index, name, label, typeName, defaultValue?, isUnderscoreLabel) - var paramIndices: [Int] = [] - var paramNames: [String] = [] - var paramLabels: [String] = [] - var paramTypes: [String] = [] - var paramDefaults: [String?] = [] - var paramIsUnderscore: [Bool] = [] - - for (i, param) in params.enumerated() { - paramIndices.append(i) - paramNames.append(param.secondName?.text ?? param.firstName.text) - paramLabels.append(param.firstName.text) - paramTypes.append(param.type.trimmedDescription) - paramDefaults.append(param.defaultValue?.value.trimmedDescription) - paramIsUnderscore.append(param.firstName.text == "_") - } - - let requiredCount = paramDefaults.filter { $0 == nil }.count - - if paramCount > 0 { - if requiredCount == paramCount { - // No defaults — strict checking (original behavior) - unpackLines.append(""" - guard let args = args else { - PyErr_SetString(PyExc_TypeError, "\\(funcName)() requires arguments") - return nil - } - let _nArgs = PyTuple_Size(args) - guard _nArgs == \(paramCount) else { - PyErr_SetString(PyExc_TypeError, "\\(funcName)() takes exactly \(paramCount) argument(s)") - return nil - } - """) - } else { - // Has defaults — flexible arg count - unpackLines.append(""" - let _nArgs: Py_ssize_t = args != nil ? PyTuple_Size(args!) : 0 - guard _nArgs >= \(requiredCount) && _nArgs <= \(paramCount) else { - PyErr_SetString(PyExc_TypeError, "\\(funcName)() takes \(requiredCount) to \(paramCount) argument(s)") - return nil - } - """) - } - - for idx in 0.. \(pIdx), let _pyArg\(pIdx) = PyTuple_GetItem(args!, \(pIdx)) { - \(pName) = try \(pType).fromPython(_pyArg\(pIdx), py: _py) - } else { - \(pName) = \(defaultValue) - } - """) - } else { - // Required param - unpackLines.append(""" - guard let _pyArg\(pIdx) = PyTuple_GetItem(args, \(pIdx)) else { return nil } - let \(pName): \(pType) = try \(pType).fromPython(_pyArg\(pIdx), py: _py) - """) - } - - if paramIsUnderscore[idx] { - callArgs.append(pName) - } else { - callArgs.append("\(paramLabels[idx]): \(pName)") - } - } - } - - // Build the call expression - let callExpr = "\(funcName)(\(callArgs.joined(separator: ", ")))" - - // Build return conversion - let returnConversion: String - if returnsVoid { - returnConversion = """ - \(isThrowing ? "try " : "")\(callExpr) - return ApplePy_None() - """ - } else { - returnConversion = """ - let _result = \(isThrowing ? "try " : "")\(callExpr) - return _result.intoPython(py: _py) - """ - } - - // Build the full wrapper function body - let unpackBlock = unpackLines.joined(separator: "\n") - - let wrapperBody: String - if isThrowing { - wrapperBody = """ - let _py = PythonHandle() - do { - \(unpackBlock) - \(returnConversion) - } catch { - PyErr_SetString(PyExc_RuntimeError, "\\(error)") - return nil - } - """ - } else { - wrapperBody = """ - let _py = PythonHandle() - \(unpackBlock) - \(returnConversion) - """ - } - - // Generate the @_cdecl wrapper - let wrapperDecl: DeclSyntax = """ - @_cdecl("\(raw: wrapperName)") - public func \(raw: wrapperName)( - _ _self: UnsafeMutablePointer?, - _ args: UnsafeMutablePointer? - ) -> UnsafeMutablePointer? { - \(raw: wrapperBody) - } - """ - - // Generate the PyMethodDef constant name - let methodDefName = "_applepy_methoddef_\(funcName)" - - // Generate the PyMethodDef - // We use strdup for stable C strings - let flags = paramCount == 0 ? "METH_NOARGS" : "METH_VARARGS" - let methodDefDecl: DeclSyntax = """ - public let \(raw: methodDefName): PyMethodDef = { - let name: UnsafePointer = "\(raw: funcName)".withCString { UnsafePointer(strdup($0)!) } - return PyMethodDef(ml_name: name, ml_meth: \(raw: wrapperName), ml_flags: \(raw: flags), ml_doc: nil) - }() - """ - - return [wrapperDecl, methodDefDecl] - } -} - -// MARK: - Error type - -struct MacroError: Error, CustomStringConvertible { - let description: String - init(_ message: String) { self.description = message } -} diff --git a/Sources/ApplePyMacroCore/PyMethodMacro.swift b/Sources/ApplePyMacroCore/PyMethodMacro.swift deleted file mode 100644 index 12822b9..0000000 --- a/Sources/ApplePyMacroCore/PyMethodMacro.swift +++ /dev/null @@ -1,26 +0,0 @@ -// ApplePy – @PyMethod Macro Implementation -// Generates a @_cdecl wrapper for a method inside a @PyClass. -// The wrapper extracts `self` from the PyObject, unpacks args, calls the method, -// and converts the return value. - -import SwiftSyntax -import SwiftSyntaxMacros - -public struct PyMethodMacro: PeerMacro { - public static func expansion( - of node: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - guard declaration.is(FunctionDeclSyntax.self) else { - throw MacroError("@PyMethod can only be applied to functions") - } - - // Find the enclosing type name by looking at the context - // Since we can't get the enclosing type directly in a PeerMacro, - // we rely on @PyClass to generate the actual wrappers. - // @PyMethod on its own just serves as a marker that @PyClass reads. - // It doesn't generate any code independently. - return [] - } -} diff --git a/Sources/ApplePyMacroCore/PyModuleMacro.swift b/Sources/ApplePyMacroCore/PyModuleMacro.swift deleted file mode 100644 index c1c0abe..0000000 --- a/Sources/ApplePyMacroCore/PyModuleMacro.swift +++ /dev/null @@ -1,104 +0,0 @@ -// ApplePy – #pymodule Macro Implementation -// Generates PyInit_ entry point that creates the module and registers types/functions. - -import SwiftSyntax -import SwiftSyntaxMacros - -public struct PyModuleMacro: DeclarationMacro { - public static func expansion( - of node: some FreestandingMacroExpansionSyntax, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - // Parse arguments: #pymodule("name", types: [...], functions: [...]) - let argList = node.arguments - - // First positional argument: module name - guard let firstArg = argList.first, - let strLiteral = firstArg.expression.as(StringLiteralExprSyntax.self) else { - throw MacroError("#pymodule requires a string literal module name") - } - let moduleName = strLiteral.segments.trimmedDescription - - // Collect type names from `types:` arg - var typeNames: [String] = [] - var functionNames: [String] = [] - - for arg in argList { - if arg.label?.text == "types", - let arrayExpr = arg.expression.as(ArrayExprSyntax.self) { - for element in arrayExpr.elements { - // Expect Type.self expressions - if let memberAccess = element.expression.as(MemberAccessExprSyntax.self), - memberAccess.declName.baseName.text == "self", - let base = memberAccess.base { - typeNames.append(base.trimmedDescription) - } else { - typeNames.append(element.expression.trimmedDescription) - } - } - } - - if arg.label?.text == "functions", - let arrayExpr = arg.expression.as(ArrayExprSyntax.self) { - for element in arrayExpr.elements { - functionNames.append(element.expression.trimmedDescription) - } - } - } - - // Build method table entries from @PyFunction-annotated functions - var methodEntries: [String] = [] - for funcName in functionNames { - methodEntries.append("_applepy_methoddef_\(funcName)") - } - - // Build the method table - var methodTableLines: [String] = [] - for entry in methodEntries { - methodTableLines.append("\(entry),") - } - methodTableLines.append("PyMethodDef(ml_name: nil, ml_meth: nil, ml_flags: 0, ml_doc: nil),") - - // Build type registration calls - var typeRegCalls: [String] = [] - for typeName in typeNames { - typeRegCalls.append(""" - guard \(typeName).registerType(in: module) else { - ApplePy_DECREF(module) - return nil - } - """) - } - - let initFuncName = "PyInit_\(moduleName)" - - var decls: [DeclSyntax] = [] - - // Generate the method table - decls.append(""" - private var _applepy_module_methods: [PyMethodDef] = [ - \(raw: methodTableLines.joined(separator: "\n ")) - ] - """) - - // Generate module def (global to survive the function scope) - decls.append(""" - private var _applepy_module_def: PyModuleDef = { - let name: UnsafePointer = "\(raw: moduleName)".withCString { UnsafePointer(strdup($0)!) } - return ApplePy_MakeModuleDef(name, nil, -1, &_applepy_module_methods) - }() - """) - - // Generate PyInit function - decls.append(""" - @_cdecl("\(raw: initFuncName)") - public func \(raw: initFuncName)() -> UnsafeMutablePointer? { - guard let module = ApplePy_ModuleCreate(&_applepy_module_def) else { return nil } - \(raw: typeRegCalls.joined(separator: "\n ")) - return module - } - """) - - return decls - } -} diff --git a/Sources/ApplePyMacroCore/PyPropertyMacro.swift b/Sources/ApplePyMacroCore/PyPropertyMacro.swift deleted file mode 100644 index 0166b44..0000000 --- a/Sources/ApplePyMacroCore/PyPropertyMacro.swift +++ /dev/null @@ -1,34 +0,0 @@ -// ApplePy – @PyProperty Macro Implementation -// Marks a stored property for Python getter/setter exposure. -// The actual code generation happens in `@PyClass`, which reads @PyProperty attributes. -// -// Usage: -// @PyClass -// struct Point { -// @PyProperty var x: Double -// @PyProperty var y: Double -// } -// -// Generates: -// - getter function: _Point_get_x(self, closure) -> PyObject* -// - setter function: _Point_set_x(self, value, closure) -> Int32 -// - PyGetSetDef entry for the type - -import SwiftSyntax -import SwiftSyntaxMacros - -/// @PyProperty is a marker macro — code generation is done by @PyClass. -public struct PyPropertyMacro: PeerMacro { - public static func expansion( - of node: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - // Validate: must be applied to a var declaration - guard declaration.is(VariableDeclSyntax.self) else { - throw MacroError("@PyProperty can only be applied to stored properties") - } - // Marker only — @PyClass reads this attribute to generate getters/setters - return [] - } -} diff --git a/Sources/ApplePyMacroCore/PyStaticMethodMacro.swift b/Sources/ApplePyMacroCore/PyStaticMethodMacro.swift deleted file mode 100644 index 83e63dd..0000000 --- a/Sources/ApplePyMacroCore/PyStaticMethodMacro.swift +++ /dev/null @@ -1,33 +0,0 @@ -// ApplePy – @PyStaticMethod Macro Implementation -// Marks a static function inside a @PyClass for Python exposure. -// The actual code generation happens in `@PyClass`, which reads @PyStaticMethod attributes. -// -// Usage: -// @PyClass -// struct Counter { -// @PyStaticMethod -// static func zero() -> Counter { Counter(count: 0) } -// } - -import SwiftSyntax -import SwiftSyntaxMacros - -/// @PyStaticMethod is a marker macro — code generation is done by @PyClass. -public struct PyStaticMethodMacro: PeerMacro { - public static func expansion( - of node: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else { - throw MacroError("@PyStaticMethod can only be applied to functions") - } - // Verify it's a static function - let isStatic = funcDecl.modifiers.contains { $0.name.text == "static" } - if !isStatic { - throw MacroError("@PyStaticMethod requires the function to be declared as 'static'") - } - // Marker only — @PyClass reads this attribute - return [] - } -} diff --git a/Sources/ApplePyMacros/ApplePyMacros.swift b/Sources/ApplePyMacros/ApplePyMacros.swift index adc1a47..69fe9cf 100644 --- a/Sources/ApplePyMacros/ApplePyMacros.swift +++ b/Sources/ApplePyMacros/ApplePyMacros.swift @@ -1,5 +1,5 @@ // ApplePy – Macro Plugin Entry Point -// Thin wrapper: registers macros from ApplePyMacroCore with the compiler. +// Registers all macro implementations (defined in this target) with the compiler. import SwiftCompilerPlugin import SwiftSyntax From 76e799a6f02a740e22675c94a6bbf20ac280fcea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:06:27 +0000 Subject: [PATCH 2/8] Fix silently-passing macro tests; harden Collections.swift and force-unwraps --- Docs/guide/memory-management.md | 7 +- Package.swift | 3 +- Sources/ApplePy/MemoryBridge.swift | 18 +++- Sources/ApplePy/Types/Collections.swift | 102 ++++++++++++++---- Sources/ApplePy/Types/Primitives.swift | 13 ++- Sources/ApplePyMacros/PyClassMacro.swift | 10 +- Sources/ApplePyMacros/PyFunctionMacro.swift | 14 ++- Sources/ApplePyMacros/PyModuleMacro.swift | 3 +- Sources/ApplePyMacros/PyUnionMacro.swift | 7 ++ Tests/ApplePyTests/MacroTestSupport.swift | 42 ++++++++ Tests/ApplePyTests/PyFunctionMacroTests.swift | 54 ++++------ Tests/ApplePyTests/PyUnionMacroTests.swift | 62 +++++++---- 12 files changed, 255 insertions(+), 80 deletions(-) create mode 100644 Tests/ApplePyTests/MacroTestSupport.swift diff --git a/Docs/guide/memory-management.md b/Docs/guide/memory-management.md index df738fa..5b9916b 100644 --- a/Docs/guide/memory-management.md +++ b/Docs/guide/memory-management.md @@ -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) diff --git a/Package.swift b/Package.swift index 4c0af8c..0131dc9 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), ] ), diff --git a/Sources/ApplePy/MemoryBridge.swift b/Sources/ApplePy/MemoryBridge.swift index fa4afea..592ff7b 100644 --- a/Sources/ApplePy/MemoryBridge.swift +++ b/Sources/ApplePy/MemoryBridge.swift @@ -62,18 +62,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(_ type: T.Type, from pyObj: UnsafeMutablePointer) -> T { + public static func load(_ type: T.Type, from pyObj: UnsafeMutablePointer) -> T? { let typed = UnsafeMutableRawPointer(pyObj).assumingMemoryBound(to: SwiftPyObject.self) - let box = Unmanaged.fromOpaque(typed.pointee.swiftPtr!).takeUnretainedValue() + guard let ptr = typed.pointee.swiftPtr else { return nil } + let box = Unmanaged.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(_ type: T.Type, from pyObj: UnsafeMutablePointer) -> T.Box { + public static func loadBox(_ type: T.Type, from pyObj: UnsafeMutablePointer) -> T.Box? { let typed = UnsafeMutableRawPointer(pyObj).assumingMemoryBound(to: SwiftPyObject.self) - return Unmanaged.fromOpaque(typed.pointee.swiftPtr!).takeUnretainedValue() + guard let ptr = typed.pointee.swiftPtr else { return nil } + return Unmanaged.fromOpaque(ptr).takeUnretainedValue() } /// Release the Swift value stored in a Python object. diff --git a/Sources/ApplePy/Types/Collections.swift b/Sources/ApplePy/Types/Collections.swift index 22f171a..83c7a0a 100644 --- a/Sources/ApplePy/Types/Collections.swift +++ b/Sources/ApplePy/Types/Collections.swift @@ -12,6 +12,10 @@ extension Array: FromPyObject where Element: FromPyObject { throw PythonConversionError.typeMismatch(expected: "list", got: typeName) } let count = PyList_Size(obj) + guard count >= 0 else { + PyErr_Clear() + throw PythonConversionError.pythonError + } var result: [Element] = [] result.reserveCapacity(Int(count)) for i in 0.. UnsafeMutablePointer? { guard let dict = PyDict_New() else { return nil } for (key, value) in self { - guard let pyKey = key.intoPython(py: py), - let pyValue = value.intoPython(py: py) else { + guard let pyKey = key.intoPython(py: py) else { + ApplePy_DECREF(dict) + return nil + } + guard let pyValue = value.intoPython(py: py) else { + ApplePy_DECREF(pyKey) ApplePy_DECREF(dict) return nil } // PyDict_SetItem does NOT steal references — it increfs both key and value - PyDict_SetItem(dict, pyKey, pyValue) + let rc = PyDict_SetItem(dict, pyKey, pyValue) ApplePy_DECREF(pyKey) ApplePy_DECREF(pyValue) + guard rc == 0 else { + ApplePy_DECREF(dict) + return nil + } } return dict } @@ -132,6 +154,10 @@ extension Set: FromPyObject where Element: FromPyObject & Hashable { throw PythonConversionError.typeMismatch(expected: "set", got: typeName) } let count = PySet_Size(obj) + guard count >= 0 else { + PyErr_Clear() + throw PythonConversionError.pythonError + } var result = Set(minimumCapacity: Int(count)) // Iterate via PyObject_GetIter guard let iter = PyObject_GetIter(obj) else { @@ -142,6 +168,12 @@ extension Set: FromPyObject where Element: FromPyObject & Hashable { defer { ApplePy_DECREF(item) } result.insert(try Element.fromPython(item, py: py)) } + // PyIter_Next returns NULL both when iteration is complete and when an + // exception occurred. Without this check a real Python exception raised + // mid-iteration would be silently discarded. + if PyErr_Occurred() != nil { + throw PythonConversionError.pythonError + } return result } } @@ -155,8 +187,12 @@ extension Set: IntoPyObject where Element: IntoPyObject { return nil } // PySet_Add does NOT steal a reference - PySet_Add(pySet, pyItem) + let rc = PySet_Add(pySet, pyItem) ApplePy_DECREF(pyItem) + guard rc == 0 else { + ApplePy_DECREF(pySet) + return nil + } } return pySet } @@ -180,8 +216,11 @@ extension PyTuple2: FromPyObject { guard PyTuple_Size(obj) == 2 else { throw PythonConversionError.typeMismatch(expected: "tuple of 2", got: "tuple of \(PyTuple_Size(obj))") } - let a = try A.fromPython(PyTuple_GetItem(obj, 0)!, py: py) - let b = try B.fromPython(PyTuple_GetItem(obj, 1)!, py: py) + guard let item0 = PyTuple_GetItem(obj, 0), let item1 = PyTuple_GetItem(obj, 1) else { + throw PythonConversionError.nullPointer + } + let a = try A.fromPython(item0, py: py) + let b = try B.fromPython(item1, py: py) return PyTuple2(a, b) } } @@ -189,12 +228,20 @@ extension PyTuple2: FromPyObject { extension PyTuple2: IntoPyObject { public func intoPython(py: PythonHandle) -> UnsafeMutablePointer? { guard let tuple = PyTuple_New(2) else { return nil } - guard let pa = _0.intoPython(py: py), let pb = _1.intoPython(py: py) else { + guard let pa = _0.intoPython(py: py) else { + ApplePy_DECREF(tuple) + return nil + } + guard let pb = _1.intoPython(py: py) else { + ApplePy_DECREF(pa) + ApplePy_DECREF(tuple) + return nil + } + // PyTuple_SetItem steals the reference regardless of success/failure. + guard PyTuple_SetItem(tuple, 0, pa) == 0, PyTuple_SetItem(tuple, 1, pb) == 0 else { ApplePy_DECREF(tuple) return nil } - PyTuple_SetItem(tuple, 0, pa) // steals reference - PyTuple_SetItem(tuple, 1, pb) return tuple } } @@ -218,9 +265,14 @@ extension PyTuple3: FromPyObject { guard PyTuple_Size(obj) == 3 else { throw PythonConversionError.typeMismatch(expected: "tuple of 3", got: "tuple of \(PyTuple_Size(obj))") } - let a = try A.fromPython(PyTuple_GetItem(obj, 0)!, py: py) - let b = try B.fromPython(PyTuple_GetItem(obj, 1)!, py: py) - let c = try C.fromPython(PyTuple_GetItem(obj, 2)!, py: py) + guard let item0 = PyTuple_GetItem(obj, 0), + let item1 = PyTuple_GetItem(obj, 1), + let item2 = PyTuple_GetItem(obj, 2) else { + throw PythonConversionError.nullPointer + } + let a = try A.fromPython(item0, py: py) + let b = try B.fromPython(item1, py: py) + let c = try C.fromPython(item2, py: py) return PyTuple3(a, b, c) } } @@ -228,13 +280,27 @@ extension PyTuple3: FromPyObject { extension PyTuple3: IntoPyObject { public func intoPython(py: PythonHandle) -> UnsafeMutablePointer? { guard let tuple = PyTuple_New(3) else { return nil } - guard let pa = _0.intoPython(py: py), let pb = _1.intoPython(py: py), let pc = _2.intoPython(py: py) else { + guard let pa = _0.intoPython(py: py) else { + ApplePy_DECREF(tuple) + return nil + } + guard let pb = _1.intoPython(py: py) else { + ApplePy_DECREF(pa) + ApplePy_DECREF(tuple) + return nil + } + guard let pc = _2.intoPython(py: py) else { + ApplePy_DECREF(pa) + ApplePy_DECREF(pb) + ApplePy_DECREF(tuple) + return nil + } + guard PyTuple_SetItem(tuple, 0, pa) == 0, + PyTuple_SetItem(tuple, 1, pb) == 0, + PyTuple_SetItem(tuple, 2, pc) == 0 else { ApplePy_DECREF(tuple) return nil } - PyTuple_SetItem(tuple, 0, pa) - PyTuple_SetItem(tuple, 1, pb) - PyTuple_SetItem(tuple, 2, pc) return tuple } } diff --git a/Sources/ApplePy/Types/Primitives.swift b/Sources/ApplePy/Types/Primitives.swift index 55d52df..c621aae 100644 --- a/Sources/ApplePy/Types/Primitives.swift +++ b/Sources/ApplePy/Types/Primitives.swift @@ -13,7 +13,14 @@ extension Int: FromPyObject { let typeName = String(cString: ApplePy_TYPE(obj).pointee.tp_name) throw PythonConversionError.typeMismatch(expected: "int", got: typeName) } - return Int(value) + // `Int` is 64-bit on all currently-supported platforms, so this conversion + // is lossless there. On a hypothetical 32-bit platform `Int` would be + // narrower than `Int64`/`PyLong_AsLongLong`'s range, so we validate the + // conversion explicitly instead of silently truncating out-of-range values. + guard let intValue = Int(exactly: value) else { + throw PythonConversionError.overflow(value: String(value), targetType: "Int") + } + return intValue } } @@ -46,6 +53,10 @@ extension Double: IntoPyObject { // MARK: - Float extension Float: FromPyObject { + /// - Note: Python floats are IEEE-754 doubles; narrowing to `Float` may lose + /// precision or round `Double.greatestFiniteMagnitude`-scale values to + /// `.infinity`. This mirrors `Float(Double)`'s standard rounding behavior + /// and is not treated as a conversion error. public static func fromPython(_ obj: UnsafeMutablePointer, py: PythonHandle) throws -> Float { let d = try Double.fromPython(obj, py: py) return Float(d) diff --git a/Sources/ApplePyMacros/PyClassMacro.swift b/Sources/ApplePyMacros/PyClassMacro.swift index f0f27a4..07f1690 100644 --- a/Sources/ApplePyMacros/PyClassMacro.swift +++ b/Sources/ApplePyMacros/PyClassMacro.swift @@ -56,9 +56,10 @@ public struct PyClassMacro: MemberMacro { // 3. Generate box helpers let boxType = isClass ? typeName : boxName decls.append(""" - private static func _getSwiftValue(_ pyObj: UnsafeMutablePointer) -> \(raw: typeName) { + private static func _getSwiftValue(_ pyObj: UnsafeMutablePointer) -> \(raw: typeName)? { let typed = UnsafeMutableRawPointer(pyObj).assumingMemoryBound(to: \(raw: pyObjName).self) - let box = Unmanaged<\(raw: boxType)>.fromOpaque(typed.pointee.swiftPtr!).takeUnretainedValue() + guard let ptr = typed.pointee.swiftPtr else { return nil } + let box = Unmanaged<\(raw: boxType)>.fromOpaque(ptr).takeUnretainedValue() return \(raw: isClass ? "box" : "box.value") } """) @@ -133,7 +134,10 @@ public struct PyClassMacro: MemberMacro { @_cdecl("\(raw: tpReprName)") public static func _tp_repr(_ self_: UnsafeMutablePointer?) -> UnsafeMutablePointer? { guard let self_ = self_ else { return nil } - let value = _getSwiftValue(self_) + guard let value = _getSwiftValue(self_) else { + PyErr_SetString(PyExc_SystemError, "\(raw: typeName): underlying Swift value is missing (object already deallocated?)") + return nil + } let repr = value.__repr__() return repr.withCString { PyUnicode_FromString($0) } } diff --git a/Sources/ApplePyMacros/PyFunctionMacro.swift b/Sources/ApplePyMacros/PyFunctionMacro.swift index e4029a0..9ceb202 100644 --- a/Sources/ApplePyMacros/PyFunctionMacro.swift +++ b/Sources/ApplePyMacros/PyFunctionMacro.swift @@ -146,6 +146,8 @@ public struct PyFunctionMacro: PeerMacro { // The name _applepy_ satisfies `names: prefixed(_applepy_)` // PyMethodDef is generated by #pymodule which knows the function names let namespaceName = "_applepy_\(funcName)" + let flagsName = "_applepy_flags_\(funcName)" + let flagsValue = paramCount == 0 ? "METH_NOARGS" : "METH_VARARGS" let wrapperDecl: DeclSyntax = """ @_cdecl("\(raw: namespaceName)") @@ -157,7 +159,17 @@ public struct PyFunctionMacro: PeerMacro { } """ - return [wrapperDecl] + // Exposes the correct METH_* flag for this function's arity so + // @PyModule can build an accurate PyMethodDef without guessing. + // Using METH_VARARGS unconditionally would break valid zero-arg + // calls from Python (they'd be invoked with an empty args tuple, + // which is fine for METH_VARARGS, but METH_NOARGS is the correct, + // more efficient flag CPython expects for truly argument-less methods). + let flagsDecl: DeclSyntax = """ + let \(raw: flagsName): Int32 = \(raw: flagsValue) + """ + + return [wrapperDecl, flagsDecl] } } diff --git a/Sources/ApplePyMacros/PyModuleMacro.swift b/Sources/ApplePyMacros/PyModuleMacro.swift index 67af300..657eaac 100644 --- a/Sources/ApplePyMacros/PyModuleMacro.swift +++ b/Sources/ApplePyMacros/PyModuleMacro.swift @@ -63,10 +63,11 @@ public struct PyModuleMacro: PeerMacro { var heapMethodAssignments: [String] = [] for funcName in functionNames { let wrapperName = "_applepy_\(funcName)" + let flagsName = "_applepy_flags_\(funcName)" heapMethodAssignments.append(""" do { let n: UnsafePointer = "\(funcName)".withCString { UnsafePointer(strdup($0)!) } - methodsPtr[idx] = PyMethodDef(ml_name: n, ml_meth: \(wrapperName), ml_flags: METH_VARARGS, ml_doc: nil) + methodsPtr[idx] = PyMethodDef(ml_name: n, ml_meth: \(wrapperName), ml_flags: \(flagsName), ml_doc: nil) idx += 1 } """) diff --git a/Sources/ApplePyMacros/PyUnionMacro.swift b/Sources/ApplePyMacros/PyUnionMacro.swift index b83ddf2..a840e31 100644 --- a/Sources/ApplePyMacros/PyUnionMacro.swift +++ b/Sources/ApplePyMacros/PyUnionMacro.swift @@ -13,6 +13,13 @@ public struct PyUnionMacro: MemberMacro, ExtensionMacro { conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { + // Only enums are valid targets for @PyUnion. If applied to something + // else, the MemberMacro expansion below will emit a diagnostic; + // here we must avoid generating empty (non-functional) conformance + // stubs that would otherwise fail to compile or silently do nothing. + guard declaration.is(EnumDeclSyntax.self) else { + return [] + } let fromPyExt: DeclSyntax = "extension \(type.trimmed): FromPyObject {}" let intoPyExt: DeclSyntax = "extension \(type.trimmed): IntoPyObject {}" return [ diff --git a/Tests/ApplePyTests/MacroTestSupport.swift b/Tests/ApplePyTests/MacroTestSupport.swift new file mode 100644 index 0000000..f59c644 --- /dev/null +++ b/Tests/ApplePyTests/MacroTestSupport.swift @@ -0,0 +1,42 @@ +// ApplePy – Macro test support +// +// `SwiftSyntaxMacrosTestSupport.assertMacroExpansion` reports failures via +// `XCTFail`, which is silently swallowed when called from a Swift Testing +// `@Test` function (no XCTestCase is active), making macro-expansion +// assertions pass even when the expected/actual source mismatches. +// +// This helper uses the framework-agnostic `SwiftSyntaxMacrosGenericTestSupport` +// API and reports failures through Swift Testing's `Issue.record`, so +// mismatches actually fail the test. + +import SwiftSyntax +import SwiftSyntaxMacros +import SwiftSyntaxMacroExpansion +import SwiftSyntaxMacrosGenericTestSupport +import Testing + +func assertMacroExpansion( + _ originalSource: String, + expandedSource expectedExpandedSource: String, + diagnostics: [DiagnosticSpec] = [], + macros: [String: Macro.Type], + testModuleName: String = "TestModule", + testFileName: String = "test.swift", + sourceLocation: Testing.SourceLocation = #_sourceLocation +) { + let specs = macros.mapValues { MacroSpec(type: $0) } + SwiftSyntaxMacrosGenericTestSupport.assertMacroExpansion( + originalSource, + expandedSource: expectedExpandedSource, + diagnostics: diagnostics, + macroSpecs: specs, + testModuleName: testModuleName, + testFileName: testFileName, + failureHandler: { failure in + Issue.record( + Comment(rawValue: failure.message), + sourceLocation: sourceLocation + ) + } + ) +} diff --git a/Tests/ApplePyTests/PyFunctionMacroTests.swift b/Tests/ApplePyTests/PyFunctionMacroTests.swift index 27284f1..98e88b2 100644 --- a/Tests/ApplePyTests/PyFunctionMacroTests.swift +++ b/Tests/ApplePyTests/PyFunctionMacroTests.swift @@ -1,6 +1,6 @@ import Testing import SwiftSyntaxMacros -import SwiftSyntaxMacrosTestSupport +import SwiftSyntaxMacrosGenericTestSupport @testable import ApplePyMacros nonisolated(unsafe) let testMacros: [String: Macro.Type] = [ @@ -27,12 +27,13 @@ struct PyFunctionMacroTests { } @_cdecl("_applepy_greet") - public func _applepy_greet( + func _applepy_greet( _ _self: UnsafeMutablePointer?, _ args: UnsafeMutablePointer? ) -> UnsafeMutablePointer? { - let _py = PythonHandle() - guard let args = args else { + let _py = PythonHandle() + do { + guard let args = args else { PyErr_SetString(PyExc_TypeError, "greet() requires arguments") return nil } @@ -42,19 +43,18 @@ struct PyFunctionMacroTests { return nil } guard let _pyArg0 = PyTuple_GetItem(args, 0) else { - return nil - } + return nil + } let name: String = try String.fromPython(_pyArg0, py: _py) - let _result = greet(name: name) + let _result = greet(name: name) return _result.intoPython(py: _py) + } catch { + setPythonConversionException(error) + return nil + } } - public let _applepy_methoddef_greet: PyMethodDef = { - let name: UnsafePointer = "greet".withCString { - UnsafePointer(strdup($0)!) - } - return PyMethodDef(ml_name: name, ml_meth: _applepy_greet, ml_flags: METH_VARARGS, ml_doc: nil) - }() + let _applepy_flags_greet: Int32 = METH_VARARGS """, macros: testMacros ) @@ -75,21 +75,17 @@ struct PyFunctionMacroTests { } @_cdecl("_applepy_hello") - public func _applepy_hello( + func _applepy_hello( _ _self: UnsafeMutablePointer?, _ args: UnsafeMutablePointer? ) -> UnsafeMutablePointer? { - let _py = PythonHandle() - let _result = hello() + let _py = PythonHandle() + + let _result = hello() return _result.intoPython(py: _py) } - public let _applepy_methoddef_hello: PyMethodDef = { - let name: UnsafePointer = "hello".withCString { - UnsafePointer(strdup($0)!) - } - return PyMethodDef(ml_name: name, ml_meth: _applepy_hello, ml_flags: METH_NOARGS, ml_doc: nil) - }() + let _applepy_flags_hello: Int32 = METH_NOARGS """, macros: testMacros ) @@ -108,21 +104,17 @@ struct PyFunctionMacroTests { } @_cdecl("_applepy_doNothing") - public func _applepy_doNothing( + func _applepy_doNothing( _ _self: UnsafeMutablePointer?, _ args: UnsafeMutablePointer? ) -> UnsafeMutablePointer? { - let _py = PythonHandle() - doNothing() + let _py = PythonHandle() + + doNothing() return ApplePy_None() } - public let _applepy_methoddef_doNothing: PyMethodDef = { - let name: UnsafePointer = "doNothing".withCString { - UnsafePointer(strdup($0)!) - } - return PyMethodDef(ml_name: name, ml_meth: _applepy_doNothing, ml_flags: METH_NOARGS, ml_doc: nil) - }() + let _applepy_flags_doNothing: Int32 = METH_NOARGS """, macros: testMacros ) diff --git a/Tests/ApplePyTests/PyUnionMacroTests.swift b/Tests/ApplePyTests/PyUnionMacroTests.swift index 54fafef..79bf569 100644 --- a/Tests/ApplePyTests/PyUnionMacroTests.swift +++ b/Tests/ApplePyTests/PyUnionMacroTests.swift @@ -1,6 +1,6 @@ import Testing import SwiftSyntaxMacros -import SwiftSyntaxMacrosTestSupport +import SwiftSyntaxMacrosGenericTestSupport @testable import ApplePyMacros nonisolated(unsafe) let unionTestMacros: [String: Macro.Type] = [ @@ -26,18 +26,30 @@ struct PyUnionMacroTests { case int(Int) public static func fromPython(_ obj: UnsafeMutablePointer, py: PythonHandle) throws -> StringOrInt { - if let _v = try? String.fromPython(obj, py: py) { return .string(_v) } - if let _v = try? Int.fromPython(obj, py: py) { return .int(_v) } - let _typeName = String(cString: ApplePy_TYPE(obj).pointee.tp_name) - throw PythonConversionError.unionMismatch(got: _typeName, expected: ["str", "int"]) - } + if let _v = try? String.fromPython(obj, py: py) { + return .string(_v) + } + if let _v = try? Int.fromPython(obj, py: py) { + return .int(_v) + } + let _typeName = String(cString: ApplePy_TYPE(obj).pointee.tp_name) + throw PythonConversionError.unionMismatch(got: _typeName, expected: ["str", "int"]) + } public func intoPython(py: PythonHandle) -> UnsafeMutablePointer? { - switch self { - case .string(let _v): return _v.intoPython(py: py) - case .int(let _v): return _v.intoPython(py: py) + switch self { + case .string(let _v): + return _v.intoPython(py: py) + case .int(let _v): + return _v.intoPython(py: py) + } } - } + } + + extension StringOrInt: FromPyObject { + } + + extension StringOrInt: IntoPyObject { } """, macros: unionTestMacros @@ -60,18 +72,30 @@ struct PyUnionMacroTests { case none public static func fromPython(_ obj: UnsafeMutablePointer, py: PythonHandle) throws -> MaybeString { - if ApplePy_IsNone(obj) != 0 { return .none } - if let _v = try? String.fromPython(obj, py: py) { return .string(_v) } - let _typeName = String(cString: ApplePy_TYPE(obj).pointee.tp_name) - throw PythonConversionError.unionMismatch(got: _typeName, expected: ["str", "None"]) - } + if ApplePy_IsNone(obj) != 0 { + return .none + } + if let _v = try? String.fromPython(obj, py: py) { + return .string(_v) + } + let _typeName = String(cString: ApplePy_TYPE(obj).pointee.tp_name) + throw PythonConversionError.unionMismatch(got: _typeName, expected: ["str", "None"]) + } public func intoPython(py: PythonHandle) -> UnsafeMutablePointer? { - switch self { - case .string(let _v): return _v.intoPython(py: py) - case .none: return ApplePy_None() + switch self { + case .string(let _v): + return _v.intoPython(py: py) + case .none: + return ApplePy_None() + } } - } + } + + extension MaybeString: FromPyObject { + } + + extension MaybeString: IntoPyObject { } """, macros: unionTestMacros From 55425453ef1c1b5381e2349b2b3257b8b38ee7c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:08:48 +0000 Subject: [PATCH 3/8] Fix PyEnumMacro memory leaks and add Python identifier validation --- Sources/ApplePyMacros/PyEnumMacro.swift | 80 ++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/Sources/ApplePyMacros/PyEnumMacro.swift b/Sources/ApplePyMacros/PyEnumMacro.swift index b0494a5..5da272b 100644 --- a/Sources/ApplePyMacros/PyEnumMacro.swift +++ b/Sources/ApplePyMacros/PyEnumMacro.swift @@ -100,11 +100,14 @@ public struct PyEnumMacro: MemberMacro { guard let intEnumClass = PyObject_GetAttrString(enumMod, "IntEnum") else { return false } defer { ApplePy_DECREF(intEnumClass) } - let memberList = PyList_New(\(raw: cases.count))! + guard let memberList = PyList_New(\(raw: cases.count)) else { return false } \(raw: cases.enumerated().map { i, c in """ do { - let pair = PyTuple_New(2)! + guard let pair = PyTuple_New(2) else { + ApplePy_DECREF(memberList) + return false + } PyTuple_SetItem(pair, 0, PyUnicode_FromString("\(c.name)")) PyTuple_SetItem(pair, 1, PyLong_FromLongLong(Int64(\(c.rawValue)))) PyList_SetItem(memberList, \(i), pair) @@ -112,7 +115,10 @@ public struct PyEnumMacro: MemberMacro { """ }.joined(separator: "\n ")) - let args = PyTuple_New(2)! + guard let args = PyTuple_New(2) else { + ApplePy_DECREF(memberList) + return false + } PyTuple_SetItem(args, 0, PyUnicode_FromString("\(raw: enumName)")) PyTuple_SetItem(args, 1, memberList) @@ -163,6 +169,18 @@ public struct PyEnumMacro: MemberMacro { } } + // Validate that generated identifiers are syntactically valid Python + // identifiers and not reserved keywords. The generated `__init__` is + // built via raw string interpolation (Py_CompileString), so an + // invalid label here would otherwise produce syntactically broken + // Python source that only fails at runtime with a confusing error. + for variant in variants { + try validatePythonIdentifier(variant.capitalizedName, context: "case '\(variant.name)'") + for param in variant.params { + try validatePythonIdentifier(param.label, context: "parameter '\(param.label)' of case '\(variant.name)'") + } + } + // Map Swift type names to Python type format codes func pyTypeString(_ swiftType: String) -> String { switch swiftType { @@ -201,14 +219,22 @@ public struct PyEnumMacro: MemberMacro { ApplePy_DECREF(emptyTuple) // type("EnumName", (object,), {"__slots__": ()}) - let baseBases = PyTuple_New(1)! + guard let baseBases = PyTuple_New(1) else { + ApplePy_DECREF(baseDict) + return false + } guard let objectType = PyObject_GetAttrString(builtins, "object") else { + ApplePy_DECREF(baseBases) ApplePy_DECREF(baseDict) return false } PyTuple_SetItem(baseBases, 0, objectType) - let baseArgs = PyTuple_New(3)! + guard let baseArgs = PyTuple_New(3) else { + ApplePy_DECREF(baseBases) + ApplePy_DECREF(baseDict) + return false + } PyTuple_SetItem(baseArgs, 0, PyUnicode_FromString("\(raw: enumName)")) PyTuple_SetItem(baseArgs, 1, baseBases) PyTuple_SetItem(baseArgs, 2, baseDict) @@ -221,6 +247,8 @@ public struct PyEnumMacro: MemberMacro { // Add base class to module if PyModule_AddObject(module, "\(raw: enumName)", baseClass) < 0 { + // PyModule_AddObject only steals the reference on success — + // on failure we still own baseClass and must release it. ApplePy_DECREF(baseClass) return false } @@ -273,9 +301,14 @@ public struct PyEnumMacro: MemberMacro { PyTuple_SetItem(variantArgs, 2, variantDict) if let variantClass = PyObject_CallObject(typeFunc, variantArgs) { - PyModule_AddObject(module, "\(v.capitalizedName)", variantClass) - // Also add as attribute on base: Shape.Circle = Circle - PyObject_SetAttrString(baseClass, "\(v.capitalizedName)", variantClass) + if PyModule_AddObject(module, "\(v.capitalizedName)", variantClass) < 0 { + // PyModule_AddObject only steals the reference on success — + // on failure we still own variantClass and must release it. + ApplePy_DECREF(variantClass) + } else { + // Also add as attribute on base: Shape.Circle = Circle + PyObject_SetAttrString(baseClass, "\(v.capitalizedName)", variantClass) + } } ApplePy_DECREF(variantArgs) } @@ -305,3 +338,34 @@ public struct PyEnumMacro: MemberMacro { return decls } } + +// MARK: - Python identifier validation + +/// Python reserved keywords (Python 3 `keyword.kwlist` plus soft keywords), +/// which cannot be used as identifiers such as parameter names or class names. +private let pythonKeywords: Set = [ + "False", "None", "True", "and", "as", "assert", "async", "await", + "break", "class", "continue", "def", "del", "elif", "else", "except", + "finally", "for", "from", "global", "if", "import", "in", "is", + "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", + "while", "with", "yield", +] + +/// Validates that `name` is a syntactically valid Python identifier and not +/// a reserved keyword. Throws a `MacroError` (surfaced as a compiler +/// diagnostic at the `@PyEnum` attribute site) otherwise, so generation of +/// syntactically-invalid Python source is rejected at macro-expansion time +/// instead of silently producing broken code. +private func validatePythonIdentifier(_ name: String, context: String) throws { + guard let first = name.first else { + throw MacroError("@PyEnum: \(context) has an empty name, which is not a valid Python identifier") + } + let isValidStart = first == "_" || first.isLetter + let isValidRest = name.dropFirst().allSatisfy { $0 == "_" || $0.isLetter || $0.isNumber } + guard isValidStart && isValidRest else { + throw MacroError("@PyEnum: \(context) has name '\(name)' which is not a valid Python identifier") + } + guard !pythonKeywords.contains(name) else { + throw MacroError("@PyEnum: \(context) has name '\(name)' which is a reserved Python keyword") + } +} From b92a769683f240d9b296db0a5d2194b12bdebb21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:10:49 +0000 Subject: [PATCH 4/8] Fix broken example imports and hardcoded python3.13 in build scripts --- Examples/Counter/build.sh | 11 +++++------ Examples/Counter/counter.swift | 2 +- Examples/HelloWorld/build.sh | 15 ++++++++------- Examples/HelloWorld/hello.swift | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Examples/Counter/build.sh b/Examples/Counter/build.sh index 4b3e154..e2a3e8c 100755 --- a/Examples/Counter/build.sh +++ b/Examples/Counter/build.sh @@ -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" @@ -24,9 +24,8 @@ echo "Building ${SO_NAME}..." # Create a temporary module map for CPython TMPDIR_MAP=$(mktemp -d) cat > "$TMPDIR_MAP/module.modulemap" < "$TMPDIR/module.modulemap" < str ──────────────────────────────────────────────── From afb53592192a053ec78a5fb3e1d729fa98e3fbbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:14:55 +0000 Subject: [PATCH 5/8] Add macro-time diagnostics and backfill test coverage --- Sources/ApplePyMacros/PyClassMacro.swift | 14 ++ Sources/ApplePyMacros/PyFunctionMacro.swift | 24 +++ Sources/ApplePyMacros/PyPropertyMacro.swift | 26 ++- Tests/ApplePyTests/ApplePyTests.swift | 59 +++++++ Tests/ApplePyTests/PyClassMacroTests.swift | 163 ++++++++++++++++++ Tests/ApplePyTests/PyFunctionMacroTests.swift | 63 +++++++ 6 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 Tests/ApplePyTests/PyClassMacroTests.swift diff --git a/Sources/ApplePyMacros/PyClassMacro.swift b/Sources/ApplePyMacros/PyClassMacro.swift index 07f1690..52877de 100644 --- a/Sources/ApplePyMacros/PyClassMacro.swift +++ b/Sources/ApplePyMacros/PyClassMacro.swift @@ -12,14 +12,28 @@ public struct PyClassMacro: MemberMacro { ) throws -> [DeclSyntax] { // Get the type name let typeName: String + let genericParameterClause: GenericParameterClauseSyntax? if let structDecl = declaration.as(StructDeclSyntax.self) { typeName = structDecl.name.text + genericParameterClause = structDecl.genericParameterClause } else if let classDecl = declaration.as(ClassDeclSyntax.self) { typeName = classDecl.name.text + genericParameterClause = classDecl.genericParameterClause } else { throw MacroError("@PyClass can only be applied to structs or classes") } + // Reject generic types: the generated PyObject storage struct, + // tp_new/tp_init/tp_dealloc, and PyType_Spec all assume a single + // concrete, monomorphic Swift type. A generic type has no single + // `MemoryLayout<...>.size` or C-callable @_cdecl entry point, so + // allowing this to proceed would silently generate code that either + // fails to compile with a confusing error deep in the macro + // expansion, or (for unconstrained generics) simply doesn't type-check. + if let genericParameterClause, !genericParameterClause.parameters.isEmpty { + throw MacroError("@PyClass does not support generic types") + } + let isClass = declaration.is(ClassDeclSyntax.self) let boxName = "_\(typeName)_Box" let pyObjName = "_\(typeName)_PyObject" diff --git a/Sources/ApplePyMacros/PyFunctionMacro.swift b/Sources/ApplePyMacros/PyFunctionMacro.swift index 9ceb202..7bc9f9e 100644 --- a/Sources/ApplePyMacros/PyFunctionMacro.swift +++ b/Sources/ApplePyMacros/PyFunctionMacro.swift @@ -15,6 +15,30 @@ public struct PyFunctionMacro: PeerMacro { throw MacroError("@PyFunction can only be applied to functions") } + // Reject constructs that would otherwise silently generate broken or + // meaningless bridging code: + // - `async` functions: the generated @_cdecl wrapper is a synchronous + // C entry point and cannot `await` a call into Swift concurrency. + // - generic functions: the wrapper's parameter/return types must be + // concrete `FromPyObject`/`IntoPyObject` conformers known at + // expansion time; a generic parameter has no such conformance. + // - `inout` parameters: the wrapper only ever passes a converted + // *copy* of the Python argument, so writes to an `inout` parameter + // would be silently discarded instead of visible to the caller. + if funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil { + throw MacroError("@PyFunction does not support 'async' functions") + } + if let genericClause = funcDecl.genericParameterClause, !genericClause.parameters.isEmpty { + throw MacroError("@PyFunction does not support generic functions") + } + for param in funcDecl.signature.parameterClause.parameters { + if param.type.is(AttributedTypeSyntax.self), + let attributedType = param.type.as(AttributedTypeSyntax.self), + attributedType.specifiers.contains(where: { $0.trimmedDescription == "inout" }) { + throw MacroError("@PyFunction does not support 'inout' parameters (parameter '\(param.firstName.text)')") + } + } + let funcName = funcDecl.name.text let params = funcDecl.signature.parameterClause.parameters let returnsVoid = funcDecl.signature.returnClause == nil diff --git a/Sources/ApplePyMacros/PyPropertyMacro.swift b/Sources/ApplePyMacros/PyPropertyMacro.swift index 0166b44..5840624 100644 --- a/Sources/ApplePyMacros/PyPropertyMacro.swift +++ b/Sources/ApplePyMacros/PyPropertyMacro.swift @@ -25,9 +25,33 @@ public struct PyPropertyMacro: PeerMacro { in context: some MacroExpansionContext ) throws -> [DeclSyntax] { // Validate: must be applied to a var declaration - guard declaration.is(VariableDeclSyntax.self) else { + guard let varDecl = declaration.as(VariableDeclSyntax.self) else { throw MacroError("@PyProperty can only be applied to stored properties") } + + // Reject computed properties (`get`/`set`, or `get`-only). @PyClass's + // codegen assumes a stored property backed by real storage in the + // Swift value; applying it to a computed property would silently + // generate a getter/setter pair that reads/writes a property with no + // backing storage, producing confusing runtime behavior instead of a + // clear compile-time error. + for binding in varDecl.bindings { + if let accessorBlock = binding.accessorBlock { + switch accessorBlock.accessors { + case .accessors(let accessors): + let hasComputedAccessor = accessors.contains { accessor in + accessor.accessorSpecifier.tokenKind == .keyword(.get) + || accessor.accessorSpecifier.tokenKind == .keyword(.set) + } + if hasComputedAccessor { + throw MacroError("@PyProperty can only be applied to stored properties, not computed properties") + } + case .getter: + throw MacroError("@PyProperty can only be applied to stored properties, not computed properties") + } + } + } + // Marker only — @PyClass reads this attribute to generate getters/setters return [] } diff --git a/Tests/ApplePyTests/ApplePyTests.swift b/Tests/ApplePyTests/ApplePyTests.swift index 40d0bdf..c4b6e6a 100644 --- a/Tests/ApplePyTests/ApplePyTests.swift +++ b/Tests/ApplePyTests/ApplePyTests.swift @@ -149,4 +149,63 @@ struct TypeConversionTests { } } } + + @Test("Set roundtrip") + func setRoundtrip() throws { + try withPython { py in + let original: Set = [1, 2, 3, 4, 5] + let pyObj = original.intoPython(py: py)! + let restored = try Set.fromPython(pyObj, py: py) + ApplePy_DECREF(pyObj) + #expect(restored == original) + } + } + + @Test("Set type mismatch throws error") + func setTypeMismatchThrows() throws { + try withPython { py in + let pyList = [1, 2, 3].intoPython(py: py)! + defer { ApplePy_DECREF(pyList) } + #expect(throws: PythonConversionError.self) { + _ = try Set.fromPython(pyList, py: py) + } + } + } + + @Test("PyTuple2 roundtrip") + func tuple2Roundtrip() throws { + try withPython { py in + let original = PyTuple2(42, "hello") + let pyObj = original.intoPython(py: py)! + let restored = try PyTuple2.fromPython(pyObj, py: py) + ApplePy_DECREF(pyObj) + #expect(restored._0 == 42) + #expect(restored._1 == "hello") + } + } + + @Test("PyTuple2 wrong arity throws error") + func tuple2WrongArityThrows() throws { + try withPython { py in + let original = [1, 2, 3].intoPython(py: py)! + defer { ApplePy_DECREF(original) } + // A list is not a tuple, so this should throw a type mismatch. + #expect(throws: PythonConversionError.self) { + _ = try PyTuple2.fromPython(original, py: py) + } + } + } + + @Test("PyTuple3 roundtrip") + func tuple3Roundtrip() throws { + try withPython { py in + let original = PyTuple3(1, "two", 3.0) + let pyObj = original.intoPython(py: py)! + let restored = try PyTuple3.fromPython(pyObj, py: py) + ApplePy_DECREF(pyObj) + #expect(restored._0 == 1) + #expect(restored._1 == "two") + #expect(restored._2 == 3.0) + } + } } diff --git a/Tests/ApplePyTests/PyClassMacroTests.swift b/Tests/ApplePyTests/PyClassMacroTests.swift new file mode 100644 index 0000000..974b0d2 --- /dev/null +++ b/Tests/ApplePyTests/PyClassMacroTests.swift @@ -0,0 +1,163 @@ +import Testing +import SwiftSyntaxMacros +import SwiftSyntaxMacrosGenericTestSupport +@testable import ApplePyMacros + +nonisolated(unsafe) let classTestMacros: [String: Macro.Type] = [ + "PyClass": PyClassMacro.self, + "PyMethod": PyMethodMacro.self, + "PyProperty": PyPropertyMacro.self, + "PyStaticMethod": PyStaticMethodMacro.self, +] + +@Suite("@PyClass Macro Tests") +struct PyClassMacroTests { + + @Test("Applied to non-struct/class produces error") + func nonStructOrClass() { + assertMacroExpansion( + """ + @PyClass + enum Foo { case bar } + """, + expandedSource: """ + enum Foo { case bar + } + """, + diagnostics: [ + DiagnosticSpec(message: "@PyClass can only be applied to structs or classes", line: 1, column: 1) + ], + macros: classTestMacros + ) + } + + @Test("Generic struct produces error") + func genericStruct() { + assertMacroExpansion( + """ + @PyClass + struct Box { + var value: T + } + """, + expandedSource: """ + struct Box { + var value: T + } + """, + diagnostics: [ + DiagnosticSpec(message: "@PyClass does not support generic types", line: 1, column: 1) + ], + macros: classTestMacros + ) + } + + @Test("Generic class produces error") + func genericClass() { + assertMacroExpansion( + """ + @PyClass + class Box { + var value: T + init(value: T) { self.value = value } + } + """, + expandedSource: """ + class Box { + var value: T + init(value: T) { self.value = value } + } + """, + diagnostics: [ + DiagnosticSpec(message: "@PyClass does not support generic types", line: 1, column: 1) + ], + macros: classTestMacros + ) + } +} + +@Suite("@PyProperty Macro Tests") +struct PyPropertyMacroTests { + + @Test("Applied to non-variable produces error") + func nonVariable() { + assertMacroExpansion( + """ + @PyProperty + func foo() {} + """, + expandedSource: """ + func foo() {} + """, + diagnostics: [ + DiagnosticSpec(message: "@PyProperty can only be applied to stored properties", line: 1, column: 1) + ], + macros: classTestMacros + ) + } + + @Test("Applied to computed property produces error") + func computedProperty() { + assertMacroExpansion( + """ + @PyProperty + var x: Int { + get { 42 } + set { } + } + """, + expandedSource: """ + var x: Int { + get { 42 } + set { } + } + """, + diagnostics: [ + DiagnosticSpec(message: "@PyProperty can only be applied to stored properties, not computed properties", line: 1, column: 1) + ], + macros: classTestMacros + ) + } + + @Test("Applied to get-only computed property produces error") + func getOnlyComputedProperty() { + assertMacroExpansion( + """ + @PyProperty + var x: Int { + 42 + } + """, + expandedSource: """ + var x: Int { + 42 + } + """, + diagnostics: [ + DiagnosticSpec(message: "@PyProperty can only be applied to stored properties, not computed properties", line: 1, column: 1) + ], + macros: classTestMacros + ) + } +} + +@Suite("@PyStaticMethod Macro Tests") +struct PyStaticMethodMacroTests { + + @Test("Non-static function produces error") + func nonStaticFunction() { + assertMacroExpansion( + """ + @PyStaticMethod + func zero() -> Int { 0 } + """, + expandedSource: """ + func zero() -> Int { 0 } + """, + diagnostics: [ + DiagnosticSpec(message: "@PyStaticMethod requires the function to be declared as 'static'", line: 1, column: 1) + ], + macros: classTestMacros + ) + } +} diff --git a/Tests/ApplePyTests/PyFunctionMacroTests.swift b/Tests/ApplePyTests/PyFunctionMacroTests.swift index 98e88b2..8b98e97 100644 --- a/Tests/ApplePyTests/PyFunctionMacroTests.swift +++ b/Tests/ApplePyTests/PyFunctionMacroTests.swift @@ -136,4 +136,67 @@ struct PyFunctionMacroTests { macros: testMacros ) } + + @Test("Async function produces error") + func asyncFunction() { + assertMacroExpansion( + """ + @PyFunction + func fetch() async -> String { + return "data" + } + """, + expandedSource: """ + func fetch() async -> String { + return "data" + } + """, + diagnostics: [ + DiagnosticSpec(message: "@PyFunction does not support 'async' functions", line: 1, column: 1) + ], + macros: testMacros + ) + } + + @Test("Generic function produces error") + func genericFunction() { + assertMacroExpansion( + """ + @PyFunction + func identity(value: T) -> T { + return value + } + """, + expandedSource: """ + func identity(value: T) -> T { + return value + } + """, + diagnostics: [ + DiagnosticSpec(message: "@PyFunction does not support generic functions", line: 1, column: 1) + ], + macros: testMacros + ) + } + + @Test("inout parameter produces error") + func inoutParameter() { + assertMacroExpansion( + """ + @PyFunction + func increment(value: inout Int) { + value += 1 + } + """, + expandedSource: """ + func increment(value: inout Int) { + value += 1 + } + """, + diagnostics: [ + DiagnosticSpec(message: "@PyFunction does not support 'inout' parameters (parameter 'value')", line: 1, column: 1) + ], + macros: testMacros + ) + } } From 4429a2d84fef643b23b6ad7f5c883a903b158099 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:16:35 +0000 Subject: [PATCH 6/8] Document @unchecked Sendable justifications; harden bytes/bytearray conversion --- Sources/ApplePy/BufferBridge.swift | 12 ++++++-- Sources/ApplePy/ErrorBridge.swift | 8 +++++ Sources/ApplePy/MemoryBridge.swift | 8 +++++ Sources/ApplePyFFI/include/applepy_shim.h | 5 ++++ Tests/ApplePyTests/ApplePyTests.swift | 36 +++++++++++++++++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/Sources/ApplePy/BufferBridge.swift b/Sources/ApplePy/BufferBridge.swift index e4d5ba7..149e959 100644 --- a/Sources/ApplePy/BufferBridge.swift +++ b/Sources/ApplePy/BufferBridge.swift @@ -34,9 +34,17 @@ public enum BufferBridge { /// Extract bytes from a Python `bytes` or `bytearray` object (copies data). public static func bytesFromPython(_ obj: UnsafeMutablePointer) -> [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 diff --git a/Sources/ApplePy/ErrorBridge.swift b/Sources/ApplePy/ErrorBridge.swift index dc65f5c..f9aebec 100644 --- a/Sources/ApplePy/ErrorBridge.swift +++ b/Sources/ApplePy/ErrorBridge.swift @@ -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? diff --git a/Sources/ApplePy/MemoryBridge.swift b/Sources/ApplePy/MemoryBridge.swift index 592ff7b..d2ddd5e 100644 --- a/Sources/ApplePy/MemoryBridge.swift +++ b/Sources/ApplePy/MemoryBridge.swift @@ -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: @unchecked Sendable { public var value: T diff --git a/Sources/ApplePyFFI/include/applepy_shim.h b/Sources/ApplePyFFI/include/applepy_shim.h index a0e41d9..1d34a1c 100644 --- a/Sources/ApplePyFFI/include/applepy_shim.h +++ b/Sources/ApplePyFFI/include/applepy_shim.h @@ -107,6 +107,11 @@ static inline int ApplePy_BytesCheck(PyObject *o) { return PyBytes_Check(o); } +/// Check if a PyObject is a bytearray. +static inline int ApplePy_ByteArrayCheck(PyObject *o) { + return PyByteArray_Check(o); +} + /// Check if a PyObject is a Unicode string. static inline int ApplePy_UnicodeCheck(PyObject *o) { return PyUnicode_Check(o); diff --git a/Tests/ApplePyTests/ApplePyTests.swift b/Tests/ApplePyTests/ApplePyTests.swift index c4b6e6a..8749d37 100644 --- a/Tests/ApplePyTests/ApplePyTests.swift +++ b/Tests/ApplePyTests/ApplePyTests.swift @@ -208,4 +208,40 @@ struct TypeConversionTests { #expect(restored._2 == 3.0) } } + + @Test("bytes roundtrip") + func bytesRoundtrip() throws { + try withPython { _ in + let original: [UInt8] = [0x00, 0x01, 0xFF, 0x42, 0x7F] + let pyObj = BufferBridge.bytesToPython(original)! + defer { ApplePy_DECREF(pyObj) } + let restored = BufferBridge.bytesFromPython(pyObj) + #expect(restored == original) + } + } + + @Test("bytearray roundtrip") + func bytearrayRoundtrip() throws { + try withPython { _ in + let original: [UInt8] = [0x10, 0x20, 0x30] + let pyBytes = BufferBridge.bytesToPython(original)! + defer { ApplePy_DECREF(pyBytes) } + guard let pyByteArray = PyByteArray_FromObject(pyBytes) else { + Issue.record("Failed to create bytearray") + return + } + defer { ApplePy_DECREF(pyByteArray) } + let restored = BufferBridge.bytesFromPython(pyByteArray) + #expect(restored == original) + } + } + + @Test("bytesFromPython returns nil for non-bytes type") + func bytesFromPythonTypeMismatch() throws { + try withPython { py in + let pyInt = 42.intoPython(py: py)! + defer { ApplePy_DECREF(pyInt) } + #expect(BufferBridge.bytesFromPython(pyInt) == nil) + } + } } From 806d519ecac683c99c4c0e161970d58300ac75e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:18:32 +0000 Subject: [PATCH 7/8] Address code review feedback: clarify comments --- Docs/guide/building.md | 2 +- Plugins/ApplePyBundle/ApplePyBundlePlugin.swift | 3 ++- Sources/ApplePyMacros/PyEnumMacro.swift | 9 +++++---- Sources/ApplePyMacros/PyPropertyMacro.swift | 8 ++++---- Sources/ApplePyMacros/PyUnionMacro.swift | 3 ++- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Docs/guide/building.md b/Docs/guide/building.md index bf0645f..39db9bd 100644 --- a/Docs/guide/building.md +++ b/Docs/guide/building.md @@ -29,7 +29,7 @@ applepy publish ## Manual Build with SPM -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 and needs to link against `libpython`, unlike a normal CPython extension module. +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 diff --git a/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift b/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift index 2402728..5c03077 100644 --- a/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift +++ b/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift @@ -81,7 +81,8 @@ struct ApplePyBundlePlugin: CommandPlugin { } /// Locate a usable Python 3 interpreter on PATH, trying the generic - /// `python3` first and falling back to specific minor versions. + /// `python3` first and falling back to specific minor versions in + /// descending order (`python3.13`, `python3.12`, ..., `python3.9`). /// This avoids hardcoding a single Python version that may not be /// installed on the host machine. private func detectPythonExecutable() throws -> String { diff --git a/Sources/ApplePyMacros/PyEnumMacro.swift b/Sources/ApplePyMacros/PyEnumMacro.swift index 5da272b..46525fa 100644 --- a/Sources/ApplePyMacros/PyEnumMacro.swift +++ b/Sources/ApplePyMacros/PyEnumMacro.swift @@ -170,10 +170,11 @@ public struct PyEnumMacro: MemberMacro { } // Validate that generated identifiers are syntactically valid Python - // identifiers and not reserved keywords. The generated `__init__` is - // built via raw string interpolation (Py_CompileString), so an - // invalid label here would otherwise produce syntactically broken - // Python source that only fails at runtime with a confusing error. + // identifiers and not reserved keywords. The generated Python source + // (class names and the `__init__` method) is built via raw string + // interpolation (Py_CompileString), so an invalid label here would + // otherwise produce syntactically broken Python source that only + // fails at runtime with a confusing error. for variant in variants { try validatePythonIdentifier(variant.capitalizedName, context: "case '\(variant.name)'") for param in variant.params { diff --git a/Sources/ApplePyMacros/PyPropertyMacro.swift b/Sources/ApplePyMacros/PyPropertyMacro.swift index 5840624..8b70517 100644 --- a/Sources/ApplePyMacros/PyPropertyMacro.swift +++ b/Sources/ApplePyMacros/PyPropertyMacro.swift @@ -31,10 +31,10 @@ public struct PyPropertyMacro: PeerMacro { // Reject computed properties (`get`/`set`, or `get`-only). @PyClass's // codegen assumes a stored property backed by real storage in the - // Swift value; applying it to a computed property would silently - // generate a getter/setter pair that reads/writes a property with no - // backing storage, producing confusing runtime behavior instead of a - // clear compile-time error. + // Swift value; applying @PyProperty to a computed property would + // silently generate a getter/setter pair that reads/writes a + // property with no backing storage, producing confusing runtime + // behavior instead of a clear compile-time error. for binding in varDecl.bindings { if let accessorBlock = binding.accessorBlock { switch accessorBlock.accessors { diff --git a/Sources/ApplePyMacros/PyUnionMacro.swift b/Sources/ApplePyMacros/PyUnionMacro.swift index a840e31..6cba630 100644 --- a/Sources/ApplePyMacros/PyUnionMacro.swift +++ b/Sources/ApplePyMacros/PyUnionMacro.swift @@ -14,7 +14,8 @@ public struct PyUnionMacro: MemberMacro, ExtensionMacro { in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { // Only enums are valid targets for @PyUnion. If applied to something - // else, the MemberMacro expansion below will emit a diagnostic; + // else, this macro's other conformance (the MemberMacro expansion, + // implemented further below in this file) will emit a diagnostic; // here we must avoid generating empty (non-functional) conformance // stubs that would otherwise fail to compile or silently do nothing. guard declaration.is(EnumDeclSyntax.self) else { From e28b19b5e225754da1bb0bd4965d5f1ac3f12c78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:59:11 +0000 Subject: [PATCH 8/8] Add python3.14 and python3.15 to interpreter probe list --- Plugins/ApplePyBundle/ApplePyBundlePlugin.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift b/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift index 5c03077..b8dcc49 100644 --- a/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift +++ b/Plugins/ApplePyBundle/ApplePyBundlePlugin.swift @@ -82,11 +82,16 @@ struct ApplePyBundlePlugin: CommandPlugin { /// Locate a usable Python 3 interpreter on PATH, trying the generic /// `python3` first and falling back to specific minor versions in - /// descending order (`python3.13`, `python3.12`, ..., `python3.9`). + /// descending order (`python3.15`, `python3.14`, ..., `python3.9`). /// This avoids hardcoding a single Python version that may not be - /// installed on the host machine. + /// 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.13", "python3.12", "python3.11", "python3.10", "python3.9"] + 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 @@ -126,7 +131,7 @@ enum PluginError: Error, CustomStringConvertible { var description: String { switch self { case .pythonNotFound: - return "Could not find a Python 3 interpreter on PATH (tried python3, python3.13, python3.12, python3.11, python3.10, python3.9)" + 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)" } } }