diff --git a/changes/148.feature.rst b/changes/148.feature.rst new file mode 100644 index 00000000..1257d1fd --- /dev/null +++ b/changes/148.feature.rst @@ -0,0 +1 @@ +Objective-C methods with repeated argument names can now be called by using a "__" suffix in the Python keyword argument to provide a unique name. diff --git a/changes/453.removal.rst b/changes/453.removal.rst new file mode 100644 index 00000000..4100f262 --- /dev/null +++ b/changes/453.removal.rst @@ -0,0 +1 @@ +The order of keyword arguments used when invoking methods must now match the order they are defined in the Objective-C API. Previously arguments could be in any order. diff --git a/changes/461.feature.rst b/changes/461.feature.rst new file mode 100644 index 00000000..43e2b540 --- /dev/null +++ b/changes/461.feature.rst @@ -0,0 +1 @@ +The error message has been improved when an Objective-C selector matching the provided arguments cannot be found. diff --git a/docs/how-to/type-mapping.rst b/docs/how-to/type-mapping.rst index 37324487..5b49c30b 100644 --- a/docs/how-to/type-mapping.rst +++ b/docs/how-to/type-mapping.rst @@ -111,6 +111,87 @@ them and access their properties. In some cases, Rubicon also provides additional Python methods on Objective-C objects - see :ref:`python_style_apis_for_objc` for details. +Invoking Objective-C methods +---------------------------- + +Once an Objective-C class has been wrapped, the selectors on that class (or +instances of that class) can be invoked as if they were methods on the Python +class. Each Objective-C selector is converted into a Python method name by +replacing the colons in the selector with underscores. + +For example, the Objective-C class ``NSURL`` has defines a instance selector of +``-initWithString:relativeToURL:``; this will be converted into the Python +method ``initWithString_relativeToURL_()``. Arguments to this method are all +positional, and passed in the order they are defined in the selector. Selectors +without arguments (such as ``+alloc`` or ``-init``) are defined as methods with +no arguments, and no underscores in the name: + +.. code-block:: python + + # Wrap the NSURL class + NSURL = ObjCClass("NSURL") + # Invoke the +alloc selector + my_url = NSURL.alloc() + # Invoke -initWithString:relativeToURL: + my_url.initWithString_relativeToURL_("something/", "https://example.com/") + +This can result in very long method names; so Rubicon also provides an alternate +mapping for methods, using Python keyword arguments. In this approach, the first +argument is handled as a positional argument, and all subsequent arguments are +handled as keyword arguments, with the underscore suffixes being omitted. The +last method in the previous example could also be invoked as: + +.. code-block:: python + + # Invoke -initWithString:relativeToURL: + my_url.initWithString("something/", relativeToURL="https://example.com/") + +Keyword arguments *must* be passed in the order they are defined in the +selector. For example, if you were invoking +``-initFileURLWithPath:isDirectory:relativeToURL``, it *must* be invoked as: + +.. code-block:: python + + # Invoke -initFileURLWithPath:isDirectory:relativeToURL + my_url.initFileURLWithPath( + "something/", + isDirectory=True, + relativeToURL="file:///Users/brutus/" + ) + +Even though from a strict *Python* perspective, passing ``relativeToURL`` before +``isDirectory`` would be syntactically equivalent, this *will not* match the +corresponding Objective-C selector. + +This "interleaved" keyword syntax works for *most* Objective-C selectors without +any problem. However, Objective-C allows arguments in a selector to be repeated. +For example, ``NSLayoutConstraint`` defines a +``+constraintWithItem:attribute:relatedBy:toItem:attribute:multiplier:constant:`` +selector, duplicating the ``attribute`` keyword. Python will not allow a keyword +argument to be duplicated, so to reach selectors of this type, Rubicon allows +any keyword argument to be appended with a ``__`` suffix to generate a name that +is unique in the Python code: + +.. code-block:: python + + # Invoke +constraintWithItem:attribute:relatedBy:toItem:attribute:multiplier:constant: + NSLayoutConstraint.constraintWithItem( + first_item, + attribute__1=first_attribute, + relatedBy=relation, + toItem=second_item, + attribute__2=second_attribute, + multiplier=2.0, + constant=1.0 + ) + +The name used after the ``__`` has no significance - it is only used to ensure +that the Python keyword is unique, and is immediately stripped and ignored. By +convention, we recommend using integers as we've done in this example; but you +*can* use any unique text you want. For example, ``attribute__from`` and +``attribute__to`` would also work in this situation, as would ``attribute`` and +``atribute__to`` (as the names are unique in the Python namespace). + .. _python_style_apis_for_objc: Python-style APIs and methods for Objective-C objects diff --git a/docs/tutorial/tutorial-1.rst b/docs/tutorial/tutorial-1.rst index 60ef1032..8cf7d40f 100644 --- a/docs/tutorial/tutorial-1.rst +++ b/docs/tutorial/tutorial-1.rst @@ -52,16 +52,26 @@ The second argument (``relativeToURL``) is accessed as a keyword argument. This argument is declared as being of type ``NSURL *``; since ``base`` is an instance of ``NSURL``, Rubicon can pass through this instance. -Sometimes, an Objective-C method definition will use the same keyword -argument name twice. This is legal in Objective-C, but not in Python, as you -can't repeat a keyword argument in a method call. In this case, you can use a -"long form" of the method to explicitly invoke a descriptor by replacing -colons with underscores: +Sometimes, an Objective-C method definition will use the same keyword argument +name twice (for example, ``NSLayoutConstraint`` has a +``+constraintWithItem:attribute:relatedBy:toItem:attribute:multiplier:constant:`` +selector, using the ``attribute`` keyword twice). This is legal in Objective-C, +but not in Python, as you can't repeat a keyword argument in a method call. In +this case, you can use a ``__`` suffix on the ambiguous keyword argument to make +it unique. Any content after and including the ``__`` will be stripped when +making the Objective-C call: .. code-block:: pycon - >>> base = NSURL.URLWithString_("https://beeware.org/") - >>> full = NSURL.URLWithString_relativeToURL_("contributing", base) + >>> constraint = NSLayoutConstraint.constraintWithItem( + ... first_item, + ... attribute__1=first_attribute, + ... relatedBy=relation, + ... toItem=second_item, + ... attribute__2=second_attribute, + ... multiplier=2.0, + ... constant=1.0 + ... ) Instance methods ================ diff --git a/src/rubicon/objc/api.py b/src/rubicon/objc/api.py index 6bb9be0b..71a83d20 100644 --- a/src/rubicon/objc/api.py +++ b/src/rubicon/objc/api.py @@ -246,28 +246,32 @@ def __repr__(self): return f"{type(self).__qualname__}({self.name_start!r})" def __call__(self, receiver, first_arg=_sentinel, **kwargs): + # Ignore parts of argument names after "__". + order = tuple(argname.split("__")[0] for argname in kwargs) + args = [arg for arg in kwargs.values()] + if first_arg is ObjCPartialMethod._sentinel: if kwargs: raise TypeError("Missing first (positional) argument") - - args = [] - rest = frozenset() + rest = order else: - args = [first_arg] - # Add "" to rest to indicate that the method takes arguments - rest = frozenset(kwargs) | frozenset(("",)) + args.insert(0, first_arg) + rest = ("",) + order try: - name, order = self.methods[rest] + name = self.methods[rest] except KeyError: + if first_arg is self._sentinel: + specified_sel = self.name_start + else: + specified_sel = f"{self.name_start}:{':'.join(kwargs.keys())}:" raise ValueError( - f"No method was found starting with {self.name_start!r} and with keywords {set(kwargs)}\n" - f"Known keywords are:\n" - + "\n".join(repr(keywords) for keywords in self.methods) - ) + f"Invalid selector {specified_sel}. Available selectors are: " + f"{', '.join(sel for sel in self.methods.values())}" + ) from None meth = receiver.objc_class._cache_method(name) - args += [kwargs[name] for name in order] + return meth(receiver, *args) @@ -1035,28 +1039,11 @@ def __getattr__(self, name): The "interleaved" syntax is usually preferred, since it looks more similar to normal Objective-C syntax. However, the "flat" syntax is also - fully supported. Certain method names require the "flat" syntax, for - example if two arguments have the same label (e.g. - ``performSelector:withObject:withObject:``), which is not supported by - Python's keyword argument syntax. - - .. warning:: - - The "interleaved" syntax currently ignores the ordering of its - keyword arguments. However, in the interest of readability, the - keyword arguments should always be passed in the same order as they - appear in the method name. - - This also means that two methods whose names which differ only in - the ordering of their keywords will conflict with each other, and - can only be called reliably using "flat" syntax. - - As of Python 3.6, the order of keyword arguments passed to functions - is preserved (:pep:`468`). In the future, once Rubicon requires - Python 3.6 or newer, "interleaved" method calls will respect keyword - argument order. This will fix the kind of conflict described above, - but will also disallow specifying the keyword arguments out of - order. + fully supported. If two arguments have the same name (e.g. + ``performSelector:withObject:withObject:``), you can use ``__`` in the + keywords to disambiguate (e.g., ``performSelector(..., withObject__1=..., + withObject__2=...)``. Any content after and including the ``__`` in an argument + will be ignored. """ # Search for named instance method in the class object and if it # exists, return callable object with self as hidden argument. @@ -1090,7 +1077,7 @@ def __getattr__(self, name): else: method = None - if method is None or set(method.methods) == {frozenset()}: + if method is None or set(method.methods) == {()}: # Find a method whose full name matches the given name if no partial # method was found, or the partial method can only resolve to a # single method that takes no arguments. The latter case avoids @@ -1654,20 +1641,23 @@ def _load_methods(self): name = libobjc.method_getName(method).name.decode("utf-8") self.instance_method_ptrs[name] = method - first, *rest = name.split(":") - # Selectors end in a colon iff the method takes arguments. - # Because of this, rest must either be empty (method takes no arguments) - # or the last element must be an empty string (method takes arguments). - assert not rest or rest[-1] == "" + # Selectors end with a colon if the method takes arguments. + if name.endswith(":"): + first, *rest, _ = name.split(":") + # Insert an empty string in order to indicate that the method + # takes a first argument as a positional argument. + rest.insert(0, "") + rest = tuple(rest) + else: + first = name + rest = () try: partial = self.partial_methods[first] except KeyError: partial = self.partial_methods[first] = ObjCPartialMethod(first) - # order is rest without the dummy "" part - order = rest[:-1] - partial.methods[frozenset(rest)] = (name, order) + partial.methods[rest] = name # Set the list of methods for the class to the computed list. self.methods_ptr = methods_ptr diff --git a/tests/objc/Example.h b/tests/objc/Example.h index f6025524..517ee574 100644 --- a/tests/objc/Example.h +++ b/tests/objc/Example.h @@ -103,6 +103,10 @@ extern NSString *const SomeGlobalStringConstant; +(NSUInteger) overloaded; +(NSUInteger) overloaded:(NSUInteger)arg1; +(NSUInteger) overloaded:(NSUInteger)arg1 extraArg:(NSUInteger)arg2; ++(NSUInteger) overloaded:(NSUInteger)arg1 extraArg1:(NSUInteger)arg2 extraArg2:(NSUInteger)arg3; ++(NSUInteger) overloaded:(NSUInteger)arg1 extraArg2:(NSUInteger)arg2 extraArg1:(NSUInteger)arg3; ++(NSUInteger) overloaded:(NSUInteger)arg1 orderedArg1:(NSUInteger)arg2 orderedArg2:(NSUInteger)arg3; ++(NSUInteger) overloaded:(NSUInteger)arg1 duplicateArg:(NSUInteger)arg2 duplicateArg:(NSUInteger)arg3; +(struct complex) doStuffWithStruct:(struct simple)simple; diff --git a/tests/objc/Example.m b/tests/objc/Example.m index 746de128..e6b963da 100644 --- a/tests/objc/Example.m +++ b/tests/objc/Example.m @@ -241,6 +241,26 @@ +(NSUInteger) overloaded:(NSUInteger)arg1 extraArg:(NSUInteger)arg2 return arg1 + arg2; } ++(NSUInteger) overloaded:(NSUInteger)arg1 extraArg1:(NSUInteger)arg2 extraArg2:(NSUInteger)arg3 +{ + return arg1 + arg2 + arg3; +} + ++(NSUInteger) overloaded:(NSUInteger)arg1 extraArg2:(NSUInteger)arg2 extraArg1:(NSUInteger)arg3 +{ + return arg1 * arg2 * arg3; +} + ++(NSUInteger) overloaded:(NSUInteger)arg1 orderedArg1:(NSUInteger)arg2 orderedArg2:(NSUInteger)arg3 +{ + return 0; +} + ++(NSUInteger) overloaded:(NSUInteger)arg1 duplicateArg:(NSUInteger)arg2 duplicateArg:(NSUInteger)arg3 +{ + return arg1 + 2 * arg2 + 3 * arg3; +} + +(struct complex) doStuffWithStruct:(struct simple)simple { return (struct complex){ diff --git a/tests/test_core.py b/tests/test_core.py index 7f14f9ee..353da443 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -986,6 +986,34 @@ def test_partial_method_lots_of_args(self): ) self.assertEqual(buf.value.decode("utf-8"), pystring) + def test_partial_method_arg_order(self): + Example = ObjCClass("Example") + + self.assertEqual(Example.overloaded(3, extraArg1=5, extraArg2=7), 3 + 5 + 7) + self.assertEqual(Example.overloaded(3, extraArg2=5, extraArg1=7), 3 * 5 * 7) + + # Although the arguments are a unique match, they're not in the right order. + with self.assertRaises(ValueError): + Example.overloaded(0, orderedArg2=0, orderedArg1=0) + + def test_partial_method_duplicate_arg_names(self): + Example = ObjCClass("Example") + self.assertEqual( + Example.overloaded(24, duplicateArg__a=16, duplicateArg__b=6), + 24 + 2 * 16 + 3 * 6, + ) + + def test_partial_method_exception(self): + Example = ObjCClass("Example") + with self.assertRaisesRegex( + ValueError, + "Invalid selector overloaded:invalidArgument:. Available selectors are: " + "overloaded, overloaded:, overloaded:extraArg:, " + "overloaded:extraArg1:extraArg2:, overloaded:extraArg2:extraArg1:, " + "overloaded:orderedArg1:orderedArg2:, overloaded:duplicateArg:duplicateArg:", + ): + Example.overloaded(0, invalidArgument=0) + def test_objcmethod_str_repr(self): """Test ObjCMethod, ObjCPartialMethod, and ObjCBoundMethod str and repr"""