Skip to content

Commit

Permalink
Merge pull request #462 from qqfunc/partial-method-enhancement
Browse files Browse the repository at this point in the history
Partial method enhancement
  • Loading branch information
freakboy3742 authored May 3, 2024
2 parents d2b80f3 + aaf5c63 commit a6bdd62
Show file tree
Hide file tree
Showing 9 changed files with 186 additions and 50 deletions.
1 change: 1 addition & 0 deletions changes/148.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changes/453.removal.rst
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changes/461.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The error message has been improved when an Objective-C selector matching the provided arguments cannot be found.
81 changes: 81 additions & 0 deletions docs/how-to/type-mapping.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 17 additions & 7 deletions docs/tutorial/tutorial-1.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
================
Expand Down
76 changes: 33 additions & 43 deletions src/rubicon/objc/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/objc/Example.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
20 changes: 20 additions & 0 deletions tests/objc/Example.m
Original file line number Diff line number Diff line change
Expand Up @@ -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){
Expand Down
28 changes: 28 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down

0 comments on commit a6bdd62

Please sign in to comment.