diff --git a/docs/source/core_tutorials.rst b/docs/source/core_tutorials.rst index c2a1ac8..4b07fb5 100644 --- a/docs/source/core_tutorials.rst +++ b/docs/source/core_tutorials.rst @@ -705,6 +705,22 @@ Here, we specify that our object method `connection_didFailWithError_` handles the `connection:didFailWithError:` delegation of the `NSURLConnectionDelegate` protocol. Pyobjus then redirects this Objective-C message to our method. +If the protocol method has a non-void return type, the value returned from +Python is written back onto the ``NSInvocation`` so Objective-C callers receive +it (for example an ``NSString *``, ``BOOL``, integer, ``char`` / +``unsigned char``, or ``float`` / ``double``). Supported return kinds today +are objects (``@``), common integer widths, ``BOOL``, ``char`` / +``unsigned char``, and ``f`` / ``d``; other encodings (structs, …) are not +forwarded yet. + +Struct, union, and array arguments are not decoded yet either: those +parameters are passed to the Python method as ``None``. + +On 64-bit platforms ``CGFloat`` is ``double``. Load the framework that +declares a protocol (AppKit, UIKit, …) before creating the delegate so +pyobjus can use the runtime type encodings; the static +``pyobjus/protocols.py`` table is only a fallback. + For a complete example, please see the `examples/delegate.py` file. Using enum types diff --git a/pyobjus/common.pxi b/pyobjus/common.pxi index 61a835d..254dd7d 100644 --- a/pyobjus/common.pxi +++ b/pyobjus/common.pxi @@ -9,6 +9,8 @@ cdef extern from "string.h": cdef extern from "CoreFoundation/CoreFoundation.h": ctypedef struct CFRange: pass + void *CFRetain(void *cf) + void *CFAutorelease(void *cf) cdef extern from "CoreGraphics/CoreGraphics.h": diff --git a/pyobjus/protocols.py b/pyobjus/protocols.py index 814f704..ef92cfe 100644 --- a/pyobjus/protocols.py +++ b/pyobjus/protocols.py @@ -499,8 +499,8 @@ "webView:dragSourceActionMaskForPoint:": ("I16@0:4@8@12", "I32@0:8@16@24"), "webView:willPerformDragSourceAction:fromPoint:withPasteboard:": ("v24@0:4@8@12@16@20", "v48@0:8@16@24@32@40"), "webView:printFrameView:": ("v16@0:4@8@12", "v32@0:8@16@24"), - "webViewHeaderHeight:": ("f12@0:4@8", "f24@0:8@16"), - "webViewFooterHeight:": ("f12@0:4@8", "f24@0:8@16"), + "webViewHeaderHeight:": ("f12@0:4@8", "d24@0:8@16"), + "webViewFooterHeight:": ("f12@0:4@8", "d24@0:8@16"), "webView:drawHeaderInRect:": ("v16@0:4@8@12", "v32@0:8@16@24"), "webView:drawFooterInRect:": ("v16@0:4@8@12", "v32@0:8@16@24"), "webView:runJavaScriptAlertPanelWithMessage:": ("v16@0:4@8@12", "v32@0:8@16@24"), @@ -1294,12 +1294,12 @@ "tableView:mouseDownInHeaderOfTableColumn:": ("v16@0:4@8@12", "v32@0:8@16@24"), "tableView:didClickTableColumn:": ("v16@0:4@8@12", "v32@0:8@16@24"), "tableView:didDragTableColumn:": ("v16@0:4@8@12", "v32@0:8@16@24"), - "tableView:heightOfRow:": ("f16@0:4@8i12", "f32@0:8@16i24"), + "tableView:heightOfRow:": ("f16@0:4@8i12", "d32@0:8@16q24"), "tableView:typeSelectStringForTableColumn:row:": ("@20@0:4@8@12i16", "@40@0:8@16@24i32"), "tableView:nextTypeSelectMatchFromRow:toRow:forString:": ("i24@0:4@8i12i16@20", "i48@0:8@16i24i32@40"), "tableView:shouldTypeSelectForEvent:withCurrentSearchString:": ("B20@0:4@8@12@16", "B40@0:8@16@24@32"), "tableView:isGroupRow:": ("B16@0:4@8i12", "B32@0:8@16i24"), - "tableView:sizeToFitWidthOfColumn:": ("f16@0:4@8i12", "f32@0:8@16i24"), + "tableView:sizeToFitWidthOfColumn:": ("f16@0:4@8i12", "d32@0:8@16q24"), "tableView:shouldReorderColumn:toColumn:": ("B20@0:4@8i12i16", "B40@0:8@16i24i32"), "tableView:rowActionsForRow:edge:": ("@20@0:4@8i12@16", "@40@0:8@16i24@32"), "tableViewSelectionDidChange:": ("v12@0:4@8", "v24@0:8@16"), @@ -1397,7 +1397,7 @@ "animationShouldStart:": ("B12@0:4@8", "B24@0:8@16"), "animationDidStop:": ("v12@0:4@8", "v24@0:8@16"), "animationDidEnd:": ("v12@0:4@8", "v24@0:8@16"), - "animation:valueForProgress:": ("f16@0:4@8@12", "f32@0:8@16@24"), + "animation:valueForProgress:": ("f16@0:4@8f12", "f28@0:8@16f24"), "animation:didReachProgressMark:": ("v16@0:4@8@12", "v32@0:8@16@24"), }, "NSAccessibilityRow": { @@ -1417,7 +1417,7 @@ "outlineView:didClickTableColumn:": ("v16@0:4@8@12", "v32@0:8@16@24"), "outlineView:didDragTableColumn:": ("v16@0:4@8@12", "v32@0:8@16@24"), "outlineView:toolTipForCell:rect:tableColumn:item:mouseLocation:": ("@32@0:4@8@12@16@20@24@28", "@64@0:8@16@24@32@40@48@56"), - "outlineView:heightOfRowByItem:": ("f16@0:4@8@12", "f32@0:8@16@24"), + "outlineView:heightOfRowByItem:": ("f16@0:4@8@12", "d32@0:8@16@24"), "outlineView:typeSelectStringForTableColumn:item:": ("@20@0:4@8@12@16", "@40@0:8@16@24@32"), "outlineView:nextTypeSelectMatchFromItem:toItem:forString:": ("@24@0:4@8@12@16@20", "@48@0:8@16@24@32@40"), "outlineView:shouldTypeSelectForEvent:withCurrentSearchString:": ("B20@0:4@8@12@16", "B40@0:8@16@24@32"), @@ -1428,7 +1428,7 @@ "outlineView:shouldExpandItem:": ("B16@0:4@8@12", "B32@0:8@16@24"), "outlineView:shouldCollapseItem:": ("B16@0:4@8@12", "B32@0:8@16@24"), "outlineView:willDisplayOutlineCell:forTableColumn:item:": ("v24@0:4@8@12@16@20", "v48@0:8@16@24@32@40"), - "outlineView:sizeToFitWidthOfColumn:": ("f16@0:4@8i12", "f32@0:8@16i24"), + "outlineView:sizeToFitWidthOfColumn:": ("f16@0:4@8i12", "d32@0:8@16q24"), "outlineView:shouldReorderColumn:toColumn:": ("B20@0:4@8i12i16", "B40@0:8@16i24i32"), "outlineView:shouldShowOutlineCellForItem:": ("B16@0:4@8@12", "B32@0:8@16@24"), "outlineViewSelectionDidChange:": ("v12@0:4@8", "v24@0:8@16"), @@ -1538,7 +1538,7 @@ "NSAccessibilityGroup": { }, "NSDraggingSource": { - "draggingSession:sourceOperationMaskForDraggingContext:": ("@16@0:4@8@12", "@32@0:8@16@24"), + "draggingSession:sourceOperationMaskForDraggingContext:": ("I16@0:4@8i12", "Q32@0:8@16q24"), "draggingSession:willBeginAtPoint:": ("v16@0:4@8@12", "v32@0:8@16@24"), "draggingSession:movedToPoint:": ("v16@0:4@8@12", "v32@0:8@16@24"), "draggingSession:endedAtPoint:operation:": ("v20@0:4@8@12@16", "v40@0:8@16@24@32"), @@ -1574,7 +1574,7 @@ "browser:child:ofItem:": ("@20@0:4@8i12@16", "@40@0:8@16i24@32"), "browser:isLeafItem:": ("B16@0:4@8@12", "B32@0:8@16@24"), "browser:objectValueForItem:": ("@16@0:4@8@12", "@32@0:8@16@24"), - "browser:heightOfRow:inColumn:": ("f20@0:4@8i12i16", "f40@0:8@16i24i32"), + "browser:heightOfRow:inColumn:": ("f20@0:4@8i12i16", "d40@0:8@16q24q32"), "rootItemForBrowser:": ("@12@0:4@8", "@24@0:8@16"), "browser:setObjectValue:forItem:": ("v20@0:4@8@12@16", "v40@0:8@16@24@32"), "browser:shouldEditItem:": ("B16@0:4@8@12", "B32@0:8@16@24"), @@ -1585,8 +1585,8 @@ "browser:isColumnValid:": ("B16@0:4@8i12", "B32@0:8@16i24"), "browserWillScroll:": ("v12@0:4@8", "v24@0:8@16"), "browserDidScroll:": ("v12@0:4@8", "v24@0:8@16"), - "browser:shouldSizeColumn:forUserResize:toWidth:": ("f24@0:4@8i12B16f20", "f48@0:8@16i24B32f40"), - "browser:sizeToFitWidthOfColumn:": ("f16@0:4@8i12", "f32@0:8@16i24"), + "browser:shouldSizeColumn:forUserResize:toWidth:": ("f24@0:4@8i12B16f20", "d48@0:8@16q24B32d40"), + "browser:sizeToFitWidthOfColumn:": ("f16@0:4@8i12", "d32@0:8@16q24"), "browserColumnConfigurationDidChange:": ("v12@0:4@8", "v24@0:8@16"), "browser:shouldShowCellExpansionForRow:column:": ("B20@0:4@8i12i16", "B40@0:8@16i24i32"), "browser:writeRowsWithIndexes:inColumn:toPasteboard:": ("B24@0:4@8@12i16@20", "B48@0:8@16@24i32@40"), @@ -1638,9 +1638,9 @@ }, "NSLayoutManagerDelegate": { "layoutManager:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:": ("I32@0:4@8r@12r@16r@20@24@28", "I64@0:8@16r@24r@32r@40@48@56"), - "layoutManager:lineSpacingAfterGlyphAtIndex:withProposedLineFragmentRect:": ("f20@0:4@8I12@16", "f40@0:8@16I24@32"), - "layoutManager:paragraphSpacingBeforeGlyphAtIndex:withProposedLineFragmentRect:": ("f20@0:4@8I12@16", "f40@0:8@16I24@32"), - "layoutManager:paragraphSpacingAfterGlyphAtIndex:withProposedLineFragmentRect:": ("f20@0:4@8I12@16", "f40@0:8@16I24@32"), + "layoutManager:lineSpacingAfterGlyphAtIndex:withProposedLineFragmentRect:": ("f20@0:4@8I12@16", "d64@0:8@16Q24{CGRect={CGPoint=dd}{CGSize=dd}}32"), + "layoutManager:paragraphSpacingBeforeGlyphAtIndex:withProposedLineFragmentRect:": ("f20@0:4@8I12@16", "d64@0:8@16Q24{CGRect={CGPoint=dd}{CGSize=dd}}32"), + "layoutManager:paragraphSpacingAfterGlyphAtIndex:withProposedLineFragmentRect:": ("f20@0:4@8I12@16", "d64@0:8@16Q24{CGRect={CGPoint=dd}{CGSize=dd}}32"), "layoutManager:shouldUseAction:forControlCharacterAtIndex:": ("@20@0:4@8@12I16", "@40@0:8@16@24I32"), "layoutManager:shouldBreakLineByWordBeforeCharacterAtIndex:": ("B16@0:4@8I12", "B32@0:8@16I24"), "layoutManager:shouldBreakLineByHyphenatingBeforeCharacterAtIndex:": ("B16@0:4@8I12", "B32@0:8@16I24"), @@ -1722,9 +1722,9 @@ "NSSplitViewDelegate": { "splitView:canCollapseSubview:": ("B16@0:4@8@12", "B32@0:8@16@24"), "splitView:shouldCollapseSubview:forDoubleClickOnDividerAtIndex:": ("B20@0:4@8@12i16", "B40@0:8@16@24i32"), - "splitView:constrainMinCoordinate:ofSubviewAt:": ("f20@0:4@8f12i16", "f40@0:8@16f24i32"), - "splitView:constrainMaxCoordinate:ofSubviewAt:": ("f20@0:4@8f12i16", "f40@0:8@16f24i32"), - "splitView:constrainSplitPosition:ofSubviewAt:": ("f20@0:4@8f12i16", "f40@0:8@16f24i32"), + "splitView:constrainMinCoordinate:ofSubviewAt:": ("f20@0:4@8f12i16", "d40@0:8@16d24q32"), + "splitView:constrainMaxCoordinate:ofSubviewAt:": ("f20@0:4@8f12i16", "d40@0:8@16d24q32"), + "splitView:constrainSplitPosition:ofSubviewAt:": ("f20@0:4@8f12i16", "d40@0:8@16d24q32"), "splitView:resizeSubviewsWithOldSize:": ("v16@0:4@8@12", "v32@0:8@16@24"), "splitView:shouldAdjustSizeOfSubview:": ("B16@0:4@8@12", "B32@0:8@16@24"), "splitView:shouldHideDividerAtIndex:": ("B16@0:4@8i12", "B32@0:8@16i24"), @@ -1781,8 +1781,8 @@ "attributedSubstringForProposedRange:actualRange:": ("@16@0:4@8@12", "@32@0:8@16@24"), "firstRectForCharacterRange:actualRange:": ("@16@0:4@8@12", "@32@0:8@16@24"), "characterIndexForPoint:": ("I12@0:4@8", "I24@0:8@16"), - "fractionOfDistanceThroughGlyphForPoint:": ("f12@0:4@8", "f24@0:8@16"), - "baselineDeltaForCharacterAtIndex:": ("f12@0:4I8", "f24@0:8I16"), + "fractionOfDistanceThroughGlyphForPoint:": ("f12@0:4@8", "d32@0:8{CGPoint=dd}16"), + "baselineDeltaForCharacterAtIndex:": ("f12@0:4I8", "d24@0:8Q16"), "drawsVerticallyForCharacterAtIndex:": ("B12@0:4I8", "B24@0:8I16"), }, "NSMenuDelegate": { @@ -1855,8 +1855,8 @@ "NSCollectionViewDelegateFlowLayout": { "collectionView:layout:sizeForItemAtIndexPath:": ("@20@0:4@8@12@16", "@40@0:8@16@24@32"), "collectionView:layout:insetForSectionAtIndex:": ("@20@0:4@8@12i16", "@40@0:8@16@24i32"), - "collectionView:layout:minimumLineSpacingForSectionAtIndex:": ("f20@0:4@8@12i16", "f40@0:8@16@24i32"), - "collectionView:layout:minimumInteritemSpacingForSectionAtIndex:": ("f20@0:4@8@12i16", "f40@0:8@16@24i32"), + "collectionView:layout:minimumLineSpacingForSectionAtIndex:": ("f20@0:4@8@12i16", "d40@0:8@16@24q32"), + "collectionView:layout:minimumInteritemSpacingForSectionAtIndex:": ("f20@0:4@8@12i16", "d40@0:8@16@24q32"), "collectionView:layout:referenceSizeForHeaderInSection:": ("@20@0:4@8@12i16", "@40@0:8@16@24i32"), "collectionView:layout:referenceSizeForFooterInSection:": ("@20@0:4@8@12i16", "@40@0:8@16@24i32"), }, diff --git a/pyobjus/pyobjus.pyx b/pyobjus/pyobjus.pyx index 3deeae5..d580ea1 100644 --- a/pyobjus/pyobjus.pyx +++ b/pyobjus/pyobjus.pyx @@ -45,6 +45,7 @@ import os from cpython.version cimport PY_MAJOR_VERSION from cpython.ref cimport Py_INCREF, Py_DECREF from libc.stdlib cimport malloc, free +from libc.string cimport memset from libcpp cimport bool # library files @@ -812,32 +813,47 @@ cdef id protocol_methodSignatureForSelector(id self, SEL _cmd, SEL selector) wit sel_name = sel_getName(selector) py_sel_name = (sel_name).decode("utf8") sig_name = "_sig_{}".format(py_sel_name) + enc_name = "_sigenc_{}".format(py_sel_name) delegate = get_python_delegate_from_id(self) if not delegate: return NULL - if not hasattr(delegate, sig_name): - # we didn't find a cached method signature, so create a new one. - py_method_name = sel_name.replace(b':', b'_').decode("utf8") - - protocol_name = getattr(delegate, py_method_name).__protocol__ - d = objc_protocol_get_delegates(protocol_name) - sigs = d.get(py_sel_name) - + py_method_name = sel_name.replace(b':', b'_').decode("utf8") + py_method = getattr(delegate, py_method_name, None) + if py_method is None: + return NULL + protocol_name = getattr(py_method, '__protocol__', None) + if not protocol_name: + return NULL + d = objc_protocol_get_delegates(protocol_name) or {} + sigs = d.get(py_sel_name) + if not sigs: + return NULL + enc = sigs[-1] + if isinstance(enc, unicode): + enc = enc.encode('utf8') + + # Refresh the cached signature if the encoding changed (e.g. protocols.py + # fallback used before the framework was loaded; runtime CGFloat is + # double on 64-bit, while the static table may still say float). + if hasattr(delegate, sig_name) and getattr(delegate, enc_name, None) == enc: + sig = getattr(delegate, sig_name) + else: NSMethodSignature = autoclass("NSMethodSignature") - sig = NSMethodSignature.signatureWithObjCTypes_(sigs[-1]) + sig = NSMethodSignature.signatureWithObjCTypes_(enc) setattr(delegate, sig_name, sig) - else: - sig = getattr(delegate, sig_name) + setattr(delegate, enc_name, enc) return sig.o_instance -cdef id protocol_forwardInvocation(id self, SEL _cmd, id invocation) with gil: +cdef void protocol_forwardInvocation(id self, SEL _cmd, id invocation) with gil: # Implementation of dynamically added protocol instance method. # This function dispatches the protocol method call to the corresponded # Python method implementation. It also convert Objective C arguments to # corresponded python objects. + # Afterwards, write the Python return value onto the NSInvocation + # (previously the return was discarded). dprint('-' * 80) dprint('protocol_forwardInvocation called from Objective-C') @@ -855,26 +871,143 @@ cdef id protocol_forwardInvocation(id self, SEL _cmd, id invocation) with gil: cdef id c_arg cdef Class cls = object_getClass(self) cdef long i + cdef id ret_id + cdef unsigned long long ret_uint + cdef unsigned char ret_bool + cdef char ret_char + cdef unsigned char ret_uchar + cdef float ret_float + cdef double ret_double + cdef size_t ret_len + cdef void *ret_buf cls_name = class_getName(cls) for i in range(2, signature.numberOfArguments): tp = signature.getArgumentTypeAtIndex_(i) + if isinstance(tp, unicode): + tp = tp.encode('utf8') + tp = clean_type_specifier(tp) dprint("pfi: argument type at {}: {}".format(i, tp)) - arg_type = type_encoding_to_ffitype(tp[:1]) - dprint('pfi: convert arg {} with type {}'.format(i, tp[:1])) + # Struct/union/array args need a full-size buffer; do not copy them + # into a pointer-sized slot (memory corruption). Unsupported for now. + if tp.startswith((b'{', b'(', b'[')): + dprint('pfi: unsupported complex arg type {!r}, passing None'.format(tp)) + py_method_args.append(None) + continue + try: + arg_type = type_encoding_to_ffitype(tp) + except Exception as e: + dprint('pfi: unknown arg type {!r}: {}'.format(tp, e)) + py_method_args.append(None) + continue + if arg_type == NULL: + dprint('pfi: null ffi type for arg {!r}, passing None'.format(tp)) + py_method_args.append(None) + continue + if arg_type.size > sizeof(c_arg): + dprint('pfi: arg type {!r} too large ({}), passing None'.format( + tp, arg_type.size)) + py_method_args.append(None) + continue + dprint('pfi: convert arg {} with type {}'.format(i, tp)) c_arg = NULL inv.getArgument_atIndex_(&c_arg, i) - py_arg = convert_cy_ret_to_py(&c_arg, tp[:1], + py_arg = convert_cy_ret_to_py(&c_arg, tp, arg_type.size, members=None, objc_prop=False, main_cls_name=cls_name) py_method_args.append(py_arg) # Calls the protocol method defined in Python object. # search the delegate object in our database + py_ret = None delegate = get_python_delegate_from_id(self) + py_method = None if delegate: py_method_name = sel_getName(_cmd).replace(b':', b'_').decode("utf8") - py_method = getattr(delegate, py_method_name) - py_method(*py_method_args) + py_method = getattr(delegate, py_method_name, None) + if py_method is not None: + py_ret = py_method(*py_method_args) + + # Return type from the live NSInvocation signature (preferred) or the + # @protocol encoding. Strip ObjC type qualifiers (r/n/N/o/O/R/V) before + # selecting the kind, consistent with clean_type_specifier elsewhere. + kind = b'v' + ret_tp = signature.methodReturnType + if ret_tp is not None: + if isinstance(ret_tp, unicode): + ret_tp = ret_tp.encode('utf8') + kind = clean_type_specifier(ret_tp)[:1] + elif py_method is not None and hasattr(py_method, '__protocol__'): + sel_name = sel_getName(_cmd).decode('utf8') + d = objc_protocol_get_delegates(py_method.__protocol__) + sigs = d.get(sel_name) if d else None + if sigs: + enc = sigs[-1] + if isinstance(enc, unicode): + enc = enc.encode('utf8') + kind = clean_type_specifier(enc)[:1] + if kind == b'v': + return + if kind == b'@': + # Ownership: convert_py_to_nsobject() returns an existing wrapper as-is + # (borrowed from Python) or a new +1 object from alloc/init. Cocoa + # returns are typically +0 autoreleased, so: + # - existing ObjcClassInstance: retain+autorelease for the handoff + # - newly created object: autorelease only (balances the +1) + already_objc = isinstance(py_ret, ObjcClassInstance) + obj = convert_py_to_nsobject(py_ret) if py_ret is not None else None + ret_id = (obj).o_instance if obj is not None else NULL + if ret_id != NULL: + # setup.py links -framework CoreFoundation on macOS and iOS. + if already_objc: + CFRetain(ret_id) + CFAutorelease(ret_id) + inv.setReturnValue_(&ret_id) + elif kind in (b'Q', b'q', b'L', b'l', b'I', b'i', b'S', b's'): + ret_uint = (int(py_ret) if py_ret is not None else 0) + inv.setReturnValue_(&ret_uint) + elif kind == b'B': + ret_bool = (1 if py_ret else 0) + inv.setReturnValue_(&ret_bool) + elif kind == b'c': + if isinstance(py_ret, (bytes, bytearray)) and len(py_ret) >= 1: + ret_char = py_ret[0] + elif isinstance(py_ret, unicode) and len(py_ret) == 1: + ret_char = ord(py_ret) + else: + ret_char = (int(py_ret) if py_ret is not None else 0) + inv.setReturnValue_(&ret_char) + elif kind == b'C': + if isinstance(py_ret, (bytes, bytearray)) and len(py_ret) >= 1: + ret_uchar = py_ret[0] + elif isinstance(py_ret, unicode) and len(py_ret) == 1: + ret_uchar = ord(py_ret) + else: + ret_uchar = (int(py_ret) if py_ret is not None else 0) + inv.setReturnValue_(&ret_uchar) + elif kind == b'f': + ret_float = (float(py_ret) if py_ret is not None else 0.0) + inv.setReturnValue_(&ret_float) + elif kind == b'd': + ret_double = (float(py_ret) if py_ret is not None else 0.0) + inv.setReturnValue_(&ret_double) + else: + dprint('pfi: unhandled return type {!r}'.format(kind)) + # Deterministic zero for unsupported encodings (structs, etc.) so + # callers do not see an uninitialized return buffer. + ret_len = 0 + ret_buf = NULL + try: + ret_len = int(signature.methodReturnLength) + except Exception: + ret_len = 0 + if ret_len == 0 and kind in (b'#', b':', b'*', b'^', b'?'): + ret_len = sizeof(id) + if ret_len > 0: + ret_buf = malloc(ret_len) + if ret_buf != NULL: + memset(ret_buf, 0, ret_len) + inv.setReturnValue_(ret_buf) + free(ret_buf) def protocol(protocol_name): @@ -1028,9 +1161,10 @@ cdef ObjcClassInstance objc_create_delegate(py_obj): objc_cls, sel_registerName(b"forwardInvocation:"), &protocol_forwardInvocation, "v@:@") dprint(' register respondsToSelector:') + # Encoding must be BOOL (B), matching -[NSObject respondsToSelector:]. class_addMethod( objc_cls, sel_registerName(b"respondsToSelector:"), - &protocol_respondsToSelector, "v@::") + &protocol_respondsToSelector, "B@::") dprint('Registering Class Pair: {}...'.format(pr(objc_cls))) objc_registerClassPair(objc_cls) diff --git a/pyobjus/type_enc.pxi b/pyobjus/type_enc.pxi index cbab532..939abbf 100644 --- a/pyobjus/type_enc.pxi +++ b/pyobjus/type_enc.pxi @@ -3,8 +3,10 @@ def clean_type_specifier(sig): Clean up the type specifier for a function signature. See: https://gcc.gnu.org/onlinedocs/gcc-5.3.0/gcc/Type-encoding.html """ - if sig[:1] in b'rnNoORV': - return sig[1:] + if isinstance(sig, unicode): + sig = sig.encode('utf8') + while sig[:1] in b'rnNoORV': + sig = sig[1:] return sig def signature_types_to_list(type_encoding): diff --git a/setup.py b/setup.py index f0b22da..c93dd63 100644 --- a/setup.py +++ b/setup.py @@ -62,6 +62,10 @@ def build_extensions(self): include_dirs.append(join(ffi_lib_path, "include", "ffi")) libraries.append('objc') +if sys.platform in ("ios", "darwin"): + # protocol_forwardInvocation uses CFRetain/CFAutorelease for object returns. + extra_link_args.extend(['-framework', 'CoreFoundation']) + depends = [join('pyobjus', x) for x in ( 'common.pxi', 'config.pxi', diff --git a/tests/test_delegate_returns.py b/tests/test_delegate_returns.py new file mode 100644 index 0000000..b9cc60a --- /dev/null +++ b/tests/test_delegate_returns.py @@ -0,0 +1,336 @@ +"""@protocol methods can return values to Objective-C via forwardInvocation:.""" + +import ctypes +import ctypes.util +import sys +import unittest + +import pytest + +from pyobjus import ( + autoclass, convert_py_to_nsobject, objc_str, protocol) +from pyobjus.dylib_manager import INCLUDE, load_framework +from pyobjus.protocols import protocols + + +pytestmark = pytest.mark.skipif( + sys.platform != 'darwin', reason='macOS + pyobjus only') + +_objc_path = ctypes.util.find_library('objc') if sys.platform == 'darwin' else None +OBJC = None +if _objc_path: + OBJC = ctypes.cdll.LoadLibrary(_objc_path) + OBJC.sel_registerName.restype = ctypes.c_void_p + OBJC.sel_registerName.argtypes = [ctypes.c_char_p] + + +def _msg_send(restype, target, sel_name, *args): + sel = OBJC.sel_registerName( + sel_name.encode('utf8') if isinstance(sel_name, str) else sel_name) + argtypes = [ctypes.c_void_p, ctypes.c_void_p] + [ + ctypes.c_void_p] * len(args) + if restype is None: + fn = ctypes.CFUNCTYPE(None, *argtypes)(('objc_msgSend', OBJC)) + else: + fn = ctypes.CFUNCTYPE(restype, *argtypes)(('objc_msgSend', OBJC)) + call_args = [target.get_address() if hasattr(target, 'get_address') + else target, sel] + for a in args: + call_args.append(a.get_address() if hasattr(a, 'get_address') else a) + return fn(*call_args) + + +def _restore_protocol(name, mapping): + if mapping is None: + protocols.pop(name, None) + else: + protocols[name] = mapping + + +def _poison_table_height_encoding(test_case): + """Temporarily install a stale float encoding for NSTableViewDelegate.""" + original = protocols.get('NSTableViewDelegate') + poisoned = dict(original or {}) + poisoned['tableView:heightOfRow:'] = ( + 'f16@0:4@8i12', 'f32@0:8@16i24') + protocols['NSTableViewDelegate'] = poisoned + test_case.addCleanup(_restore_protocol, 'NSTableViewDelegate', original) + + +class _ReturnDelegate(object): + def __init__(self): + self.log = [] + + @protocol('PyobjusReturnTest') + def nameForKey_(self, key): + self.log.append('name') + return objc_str('icon.png') + + @protocol('PyobjusReturnTest') + def pyNameForKey_(self, key): + # Plain Python str → alloc/init NSString; must not CFRetain (leak). + self.log.append('py-name') + return 'from-python.png' + + @protocol('PyobjusReturnTest') + def constNameForKey_(self, key): + # Encoding uses leading 'r' (const) qualifier — must still forward @. + self.log.append('const-name') + return objc_str('icon.png') + + @protocol('PyobjusReturnTest') + def doubleConstNameForKey_(self, key): + # Multiple ObjC qualifiers (rn) must still resolve to object return. + self.log.append('double-const-name') + return objc_str('icon.png') + + @protocol('PyobjusReturnTest') + def consumePointer_(self, ptr): + self.log.append(('pointer', ptr)) + return None + + @protocol('PyobjusReturnTest') + def flagForKey_(self, key): + self.log.append('flag') + return True + + @protocol('PyobjusReturnTest') + def charForKey_(self, key): + self.log.append('char') + return 65 # 'A', must not be collapsed to boolean 1 + + @protocol('PyobjusReturnTest') + def ucharForKey_(self, key): + self.log.append('uchar') + return 200 + + @protocol('PyobjusReturnTest') + def countForKey_(self, key): + self.log.append('count') + return 1 + + @protocol('PyobjusReturnTest') + def floatForKey_(self, key): + self.log.append('float') + return 1.5 + + @protocol('PyobjusReturnTest') + def doubleForKey_(self, key): + self.log.append('double') + return 2.25 + + @protocol('PyobjusReturnTest') + def voidForKey_(self, key): + self.log.append('void') + + +class DelegateReturnsTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + if OBJC is None: + raise unittest.SkipTest('libobjc not found') + load_framework(INCLUDE.Foundation) + load_framework(INCLUDE.AppKit) + protocols['PyobjusReturnTest'] = { + 'nameForKey:': ('@@:@', '@@:@'), + 'pyNameForKey:': ('@@:@', '@@:@'), + 'constNameForKey:': ('r@@:@', 'r@@:@'), + 'doubleConstNameForKey:': ('rn@@:@', 'rn@@:@'), + 'consumePointer:': ('v@:^i', 'v24@0:8^i16'), + 'flagForKey:': ('B@:@', 'B@:@'), + 'charForKey:': ('c@:@', 'c@:@'), + 'ucharForKey:': ('C@:@', 'C@:@'), + 'countForKey:': ('Q@:@', 'Q@:@'), + 'floatForKey:': ('f@:@', 'f@:@'), + 'doubleForKey:': ('d@:@', 'd@:@'), + 'voidForKey:': ('v@:@', 'v@:@'), + } + cls.key = autoclass('NSString').alloc().initWithUTF8String_('key') + + def setUp(self): + self.delegate = _ReturnDelegate() + self.target = convert_py_to_nsobject(self.delegate) + + def test_msgsend_string_return(self): + ptr = _msg_send( + ctypes.c_void_p, self.target, 'nameForKey:', self.key) + self.assertIn('name', self.delegate.log) + self.assertTrue(ptr, 'NSString return was NULL') + # Prefer length over UTF8String: tagged-pointer NSStrings are fine, + # and this avoids an extra c_char_p round-trip in the test process. + length = _msg_send(ctypes.c_ulong, ptr, 'length') + self.assertEqual(length, len('icon.png')) + + def test_msgsend_python_str_return(self): + ptr = _msg_send( + ctypes.c_void_p, self.target, 'pyNameForKey:', self.key) + self.assertIn('py-name', self.delegate.log) + self.assertTrue(ptr, 'NSString from Python str was NULL') + length = _msg_send(ctypes.c_ulong, ptr, 'length') + self.assertEqual(length, len('from-python.png')) + + def test_msgsend_const_qualified_string_return(self): + ptr = _msg_send( + ctypes.c_void_p, self.target, 'constNameForKey:', self.key) + self.assertIn('const-name', self.delegate.log) + self.assertTrue( + ptr, 'const-qualified NSString return was NULL (qualifier strip?)') + length = _msg_send(ctypes.c_ulong, ptr, 'length') + self.assertEqual(length, len('icon.png')) + + def test_msgsend_double_const_qualified_string_return(self): + ptr = _msg_send( + ctypes.c_void_p, self.target, 'doubleConstNameForKey:', self.key) + self.assertIn('double-const-name', self.delegate.log) + self.assertTrue( + ptr, 'double-qualified NSString return was NULL') + length = _msg_send(ctypes.c_ulong, ptr, 'length') + self.assertEqual(length, len('icon.png')) + + def test_pointer_argument_preserves_pointee_type(self): + value = ctypes.c_int(42) + ptr = ctypes.cast(ctypes.pointer(value), ctypes.c_void_p) + _msg_send(None, self.target, 'consumePointer:', ptr) + entries = [e for e in self.delegate.log if e[0] == 'pointer'] + self.assertEqual(len(entries), 1) + ref = entries[0][1] + self.assertEqual(ref.of_type, b'i') + + def test_msgsend_bool_return(self): + val = _msg_send( + ctypes.c_ubyte, self.target, 'flagForKey:', self.key) + self.assertIn('flag', self.delegate.log) + self.assertEqual(val, 1) + + def test_msgsend_char_return(self): + val = _msg_send( + ctypes.c_byte, self.target, 'charForKey:', self.key) + self.assertIn('char', self.delegate.log) + self.assertEqual(val, 65) + + def test_msgsend_uchar_return(self): + val = _msg_send( + ctypes.c_ubyte, self.target, 'ucharForKey:', self.key) + self.assertIn('uchar', self.delegate.log) + self.assertEqual(val, 200) + + def test_msgsend_uint_return(self): + val = _msg_send( + ctypes.c_ulong, self.target, 'countForKey:', self.key) + self.assertIn('count', self.delegate.log) + self.assertEqual(val, 1) + + def test_msgsend_float_return(self): + val = _msg_send( + ctypes.c_float, self.target, 'floatForKey:', self.key) + self.assertIn('float', self.delegate.log) + self.assertAlmostEqual(val, 1.5, places=5) + + def test_msgsend_double_return(self): + val = _msg_send( + ctypes.c_double, self.target, 'doubleForKey:', self.key) + self.assertIn('double', self.delegate.log) + self.assertAlmostEqual(val, 2.25, places=10) + + def test_msgsend_void_return(self): + _msg_send(None, self.target, 'voidForKey:', self.key) + self.assertIn('void', self.delegate.log) + + def test_responds_to_selector_returns_bool(self): + sel_name = OBJC.sel_registerName(b'nameForKey:') + sel_missing = OBJC.sel_registerName(b'noSuchMethod:') + self.assertEqual( + _msg_send( + ctypes.c_ubyte, self.target, 'respondsToSelector:', sel_name), + 1) + self.assertEqual( + _msg_send( + ctypes.c_ubyte, self.target, 'respondsToSelector:', + sel_missing), + 0) + + def test_method_signature_unknown_selector_is_null(self): + # ObjC may still ask for a signature; must return nil, not raise. + sel_missing = OBJC.sel_registerName(b'noSuchMethod:') + sig = _msg_send( + ctypes.c_void_p, self.target, 'methodSignatureForSelector:', + sel_missing) + self.assertFalse(sig) + + +class _TableHeightDelegate(object): + """NSTableViewDelegate height — CGFloat is double on 64-bit.""" + + @protocol('NSTableViewDelegate') + def tableView_heightOfRow_(self, table_view, row): + return 42.5 + + +class CGFloatReturnRegressionTest(unittest.TestCase): + """Stale protocols.py 'f' encodings must not win over runtime 'd' (CGFloat).""" + + @classmethod + def setUpClass(cls): + if OBJC is None: + raise unittest.SkipTest('libobjc not found') + load_framework(INCLUDE.Foundation) + load_framework(INCLUDE.AppKit) + + def test_runtime_table_height_encoding_is_double(self): + from pyobjus.pyobjus import objc_protocol_get_delegates + + _poison_table_height_encoding(self) + + d = objc_protocol_get_delegates('NSTableViewDelegate') + enc = d['tableView:heightOfRow:'][-1] + if isinstance(enc, bytes): + enc = enc.decode('utf8') + self.assertEqual( + enc[:1], 'd', + 'expected runtime CGFloat encoding to start with d, got %r' % enc) + + def test_msgsend_table_height_returns_double(self): + # Even if the static table says float, a loaded AppKit protocol must + # make heightOfRow: return a double to the caller. + _poison_table_height_encoding(self) + + delegate = _TableHeightDelegate() + target = convert_py_to_nsobject(delegate) + sel = OBJC.sel_registerName(b'tableView:heightOfRow:') + fn = ctypes.CFUNCTYPE( + ctypes.c_double, + ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_longlong)( + ('objc_msgSend', OBJC)) + val = fn(target.get_address(), sel, None, 0) + self.assertAlmostEqual(val, 42.5, places=10) + + def test_signature_cache_refreshes_when_encoding_changes(self): + # Simulate a delegate that cached a stale float signature; runtime + # lookup should rebuild it as double once AppKit is loaded. + delegate = _TableHeightDelegate() + target = convert_py_to_nsobject(delegate) + stale = 'f32@0:8@16i24' + NSMethodSignature = autoclass('NSMethodSignature') + setattr( + delegate, + '_sig_tableView:heightOfRow:', + NSMethodSignature.signatureWithObjCTypes_(stale)) + setattr(delegate, '_sigenc_tableView:heightOfRow:', stale) + + sel = OBJC.sel_registerName(b'tableView:heightOfRow:') + sig = _msg_send( + ctypes.c_void_p, target, 'methodSignatureForSelector:', sel) + self.assertTrue(sig) + + fresh_enc = getattr(delegate, '_sigenc_tableView:heightOfRow:') + if isinstance(fresh_enc, bytes): + fresh_enc = fresh_enc.decode('utf8') + self.assertTrue( + fresh_enc.startswith('d'), + 'expected refreshed signature encoding to start with d, got %r' + % fresh_enc) + + +if __name__ == '__main__': + unittest.main()