Skip to content

Commit a6bdd62

Browse files
authored
Merge pull request #462 from qqfunc/partial-method-enhancement
Partial method enhancement
2 parents d2b80f3 + aaf5c63 commit a6bdd62

File tree

9 files changed

+186
-50
lines changed

9 files changed

+186
-50
lines changed

changes/148.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

changes/453.removal.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

changes/461.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The error message has been improved when an Objective-C selector matching the provided arguments cannot be found.

docs/how-to/type-mapping.rst

+81
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,87 @@ them and access their properties. In some cases, Rubicon also provides
111111
additional Python methods on Objective-C objects -
112112
see :ref:`python_style_apis_for_objc` for details.
113113

114+
Invoking Objective-C methods
115+
----------------------------
116+
117+
Once an Objective-C class has been wrapped, the selectors on that class (or
118+
instances of that class) can be invoked as if they were methods on the Python
119+
class. Each Objective-C selector is converted into a Python method name by
120+
replacing the colons in the selector with underscores.
121+
122+
For example, the Objective-C class ``NSURL`` has defines a instance selector of
123+
``-initWithString:relativeToURL:``; this will be converted into the Python
124+
method ``initWithString_relativeToURL_()``. Arguments to this method are all
125+
positional, and passed in the order they are defined in the selector. Selectors
126+
without arguments (such as ``+alloc`` or ``-init``) are defined as methods with
127+
no arguments, and no underscores in the name:
128+
129+
.. code-block:: python
130+
131+
# Wrap the NSURL class
132+
NSURL = ObjCClass("NSURL")
133+
# Invoke the +alloc selector
134+
my_url = NSURL.alloc()
135+
# Invoke -initWithString:relativeToURL:
136+
my_url.initWithString_relativeToURL_("something/", "https://example.com/")
137+
138+
This can result in very long method names; so Rubicon also provides an alternate
139+
mapping for methods, using Python keyword arguments. In this approach, the first
140+
argument is handled as a positional argument, and all subsequent arguments are
141+
handled as keyword arguments, with the underscore suffixes being omitted. The
142+
last method in the previous example could also be invoked as:
143+
144+
.. code-block:: python
145+
146+
# Invoke -initWithString:relativeToURL:
147+
my_url.initWithString("something/", relativeToURL="https://example.com/")
148+
149+
Keyword arguments *must* be passed in the order they are defined in the
150+
selector. For example, if you were invoking
151+
``-initFileURLWithPath:isDirectory:relativeToURL``, it *must* be invoked as:
152+
153+
.. code-block:: python
154+
155+
# Invoke -initFileURLWithPath:isDirectory:relativeToURL
156+
my_url.initFileURLWithPath(
157+
"something/",
158+
isDirectory=True,
159+
relativeToURL="file:///Users/brutus/"
160+
)
161+
162+
Even though from a strict *Python* perspective, passing ``relativeToURL`` before
163+
``isDirectory`` would be syntactically equivalent, this *will not* match the
164+
corresponding Objective-C selector.
165+
166+
This "interleaved" keyword syntax works for *most* Objective-C selectors without
167+
any problem. However, Objective-C allows arguments in a selector to be repeated.
168+
For example, ``NSLayoutConstraint`` defines a
169+
``+constraintWithItem:attribute:relatedBy:toItem:attribute:multiplier:constant:``
170+
selector, duplicating the ``attribute`` keyword. Python will not allow a keyword
171+
argument to be duplicated, so to reach selectors of this type, Rubicon allows
172+
any keyword argument to be appended with a ``__`` suffix to generate a name that
173+
is unique in the Python code:
174+
175+
.. code-block:: python
176+
177+
# Invoke +constraintWithItem:attribute:relatedBy:toItem:attribute:multiplier:constant:
178+
NSLayoutConstraint.constraintWithItem(
179+
first_item,
180+
attribute__1=first_attribute,
181+
relatedBy=relation,
182+
toItem=second_item,
183+
attribute__2=second_attribute,
184+
multiplier=2.0,
185+
constant=1.0
186+
)
187+
188+
The name used after the ``__`` has no significance - it is only used to ensure
189+
that the Python keyword is unique, and is immediately stripped and ignored. By
190+
convention, we recommend using integers as we've done in this example; but you
191+
*can* use any unique text you want. For example, ``attribute__from`` and
192+
``attribute__to`` would also work in this situation, as would ``attribute`` and
193+
``atribute__to`` (as the names are unique in the Python namespace).
194+
114195
.. _python_style_apis_for_objc:
115196

116197
Python-style APIs and methods for Objective-C objects

docs/tutorial/tutorial-1.rst

+17-7
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,26 @@ The second argument (``relativeToURL``) is accessed as a keyword argument. This
5252
argument is declared as being of type ``NSURL *``; since ``base`` is an
5353
instance of ``NSURL``, Rubicon can pass through this instance.
5454

55-
Sometimes, an Objective-C method definition will use the same keyword
56-
argument name twice. This is legal in Objective-C, but not in Python, as you
57-
can't repeat a keyword argument in a method call. In this case, you can use a
58-
"long form" of the method to explicitly invoke a descriptor by replacing
59-
colons with underscores:
55+
Sometimes, an Objective-C method definition will use the same keyword argument
56+
name twice (for example, ``NSLayoutConstraint`` has a
57+
``+constraintWithItem:attribute:relatedBy:toItem:attribute:multiplier:constant:``
58+
selector, using the ``attribute`` keyword twice). This is legal in Objective-C,
59+
but not in Python, as you can't repeat a keyword argument in a method call. In
60+
this case, you can use a ``__`` suffix on the ambiguous keyword argument to make
61+
it unique. Any content after and including the ``__`` will be stripped when
62+
making the Objective-C call:
6063

6164
.. code-block:: pycon
6265
63-
>>> base = NSURL.URLWithString_("https://beeware.org/")
64-
>>> full = NSURL.URLWithString_relativeToURL_("contributing", base)
66+
>>> constraint = NSLayoutConstraint.constraintWithItem(
67+
... first_item,
68+
... attribute__1=first_attribute,
69+
... relatedBy=relation,
70+
... toItem=second_item,
71+
... attribute__2=second_attribute,
72+
... multiplier=2.0,
73+
... constant=1.0
74+
... )
6575
6676
Instance methods
6777
================

src/rubicon/objc/api.py

+33-43
Original file line numberDiff line numberDiff line change
@@ -246,28 +246,32 @@ def __repr__(self):
246246
return f"{type(self).__qualname__}({self.name_start!r})"
247247

248248
def __call__(self, receiver, first_arg=_sentinel, **kwargs):
249+
# Ignore parts of argument names after "__".
250+
order = tuple(argname.split("__")[0] for argname in kwargs)
251+
args = [arg for arg in kwargs.values()]
252+
249253
if first_arg is ObjCPartialMethod._sentinel:
250254
if kwargs:
251255
raise TypeError("Missing first (positional) argument")
252-
253-
args = []
254-
rest = frozenset()
256+
rest = order
255257
else:
256-
args = [first_arg]
257-
# Add "" to rest to indicate that the method takes arguments
258-
rest = frozenset(kwargs) | frozenset(("",))
258+
args.insert(0, first_arg)
259+
rest = ("",) + order
259260

260261
try:
261-
name, order = self.methods[rest]
262+
name = self.methods[rest]
262263
except KeyError:
264+
if first_arg is self._sentinel:
265+
specified_sel = self.name_start
266+
else:
267+
specified_sel = f"{self.name_start}:{':'.join(kwargs.keys())}:"
263268
raise ValueError(
264-
f"No method was found starting with {self.name_start!r} and with keywords {set(kwargs)}\n"
265-
f"Known keywords are:\n"
266-
+ "\n".join(repr(keywords) for keywords in self.methods)
267-
)
269+
f"Invalid selector {specified_sel}. Available selectors are: "
270+
f"{', '.join(sel for sel in self.methods.values())}"
271+
) from None
268272

269273
meth = receiver.objc_class._cache_method(name)
270-
args += [kwargs[name] for name in order]
274+
271275
return meth(receiver, *args)
272276

273277

@@ -1035,28 +1039,11 @@ def __getattr__(self, name):
10351039
10361040
The "interleaved" syntax is usually preferred, since it looks more
10371041
similar to normal Objective-C syntax. However, the "flat" syntax is also
1038-
fully supported. Certain method names require the "flat" syntax, for
1039-
example if two arguments have the same label (e.g.
1040-
``performSelector:withObject:withObject:``), which is not supported by
1041-
Python's keyword argument syntax.
1042-
1043-
.. warning::
1044-
1045-
The "interleaved" syntax currently ignores the ordering of its
1046-
keyword arguments. However, in the interest of readability, the
1047-
keyword arguments should always be passed in the same order as they
1048-
appear in the method name.
1049-
1050-
This also means that two methods whose names which differ only in
1051-
the ordering of their keywords will conflict with each other, and
1052-
can only be called reliably using "flat" syntax.
1053-
1054-
As of Python 3.6, the order of keyword arguments passed to functions
1055-
is preserved (:pep:`468`). In the future, once Rubicon requires
1056-
Python 3.6 or newer, "interleaved" method calls will respect keyword
1057-
argument order. This will fix the kind of conflict described above,
1058-
but will also disallow specifying the keyword arguments out of
1059-
order.
1042+
fully supported. If two arguments have the same name (e.g.
1043+
``performSelector:withObject:withObject:``), you can use ``__`` in the
1044+
keywords to disambiguate (e.g., ``performSelector(..., withObject__1=...,
1045+
withObject__2=...)``. Any content after and including the ``__`` in an argument
1046+
will be ignored.
10601047
"""
10611048
# Search for named instance method in the class object and if it
10621049
# exists, return callable object with self as hidden argument.
@@ -1090,7 +1077,7 @@ def __getattr__(self, name):
10901077
else:
10911078
method = None
10921079

1093-
if method is None or set(method.methods) == {frozenset()}:
1080+
if method is None or set(method.methods) == {()}:
10941081
# Find a method whose full name matches the given name if no partial
10951082
# method was found, or the partial method can only resolve to a
10961083
# single method that takes no arguments. The latter case avoids
@@ -1654,20 +1641,23 @@ def _load_methods(self):
16541641
name = libobjc.method_getName(method).name.decode("utf-8")
16551642
self.instance_method_ptrs[name] = method
16561643

1657-
first, *rest = name.split(":")
1658-
# Selectors end in a colon iff the method takes arguments.
1659-
# Because of this, rest must either be empty (method takes no arguments)
1660-
# or the last element must be an empty string (method takes arguments).
1661-
assert not rest or rest[-1] == ""
1644+
# Selectors end with a colon if the method takes arguments.
1645+
if name.endswith(":"):
1646+
first, *rest, _ = name.split(":")
1647+
# Insert an empty string in order to indicate that the method
1648+
# takes a first argument as a positional argument.
1649+
rest.insert(0, "")
1650+
rest = tuple(rest)
1651+
else:
1652+
first = name
1653+
rest = ()
16621654

16631655
try:
16641656
partial = self.partial_methods[first]
16651657
except KeyError:
16661658
partial = self.partial_methods[first] = ObjCPartialMethod(first)
16671659

1668-
# order is rest without the dummy "" part
1669-
order = rest[:-1]
1670-
partial.methods[frozenset(rest)] = (name, order)
1660+
partial.methods[rest] = name
16711661

16721662
# Set the list of methods for the class to the computed list.
16731663
self.methods_ptr = methods_ptr

tests/objc/Example.h

+4
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ extern NSString *const SomeGlobalStringConstant;
103103
+(NSUInteger) overloaded;
104104
+(NSUInteger) overloaded:(NSUInteger)arg1;
105105
+(NSUInteger) overloaded:(NSUInteger)arg1 extraArg:(NSUInteger)arg2;
106+
+(NSUInteger) overloaded:(NSUInteger)arg1 extraArg1:(NSUInteger)arg2 extraArg2:(NSUInteger)arg3;
107+
+(NSUInteger) overloaded:(NSUInteger)arg1 extraArg2:(NSUInteger)arg2 extraArg1:(NSUInteger)arg3;
108+
+(NSUInteger) overloaded:(NSUInteger)arg1 orderedArg1:(NSUInteger)arg2 orderedArg2:(NSUInteger)arg3;
109+
+(NSUInteger) overloaded:(NSUInteger)arg1 duplicateArg:(NSUInteger)arg2 duplicateArg:(NSUInteger)arg3;
106110

107111
+(struct complex) doStuffWithStruct:(struct simple)simple;
108112

tests/objc/Example.m

+20
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,26 @@ +(NSUInteger) overloaded:(NSUInteger)arg1 extraArg:(NSUInteger)arg2
241241
return arg1 + arg2;
242242
}
243243

244+
+(NSUInteger) overloaded:(NSUInteger)arg1 extraArg1:(NSUInteger)arg2 extraArg2:(NSUInteger)arg3
245+
{
246+
return arg1 + arg2 + arg3;
247+
}
248+
249+
+(NSUInteger) overloaded:(NSUInteger)arg1 extraArg2:(NSUInteger)arg2 extraArg1:(NSUInteger)arg3
250+
{
251+
return arg1 * arg2 * arg3;
252+
}
253+
254+
+(NSUInteger) overloaded:(NSUInteger)arg1 orderedArg1:(NSUInteger)arg2 orderedArg2:(NSUInteger)arg3
255+
{
256+
return 0;
257+
}
258+
259+
+(NSUInteger) overloaded:(NSUInteger)arg1 duplicateArg:(NSUInteger)arg2 duplicateArg:(NSUInteger)arg3
260+
{
261+
return arg1 + 2 * arg2 + 3 * arg3;
262+
}
263+
244264
+(struct complex) doStuffWithStruct:(struct simple)simple
245265
{
246266
return (struct complex){

tests/test_core.py

+28
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,34 @@ def test_partial_method_lots_of_args(self):
986986
)
987987
self.assertEqual(buf.value.decode("utf-8"), pystring)
988988

989+
def test_partial_method_arg_order(self):
990+
Example = ObjCClass("Example")
991+
992+
self.assertEqual(Example.overloaded(3, extraArg1=5, extraArg2=7), 3 + 5 + 7)
993+
self.assertEqual(Example.overloaded(3, extraArg2=5, extraArg1=7), 3 * 5 * 7)
994+
995+
# Although the arguments are a unique match, they're not in the right order.
996+
with self.assertRaises(ValueError):
997+
Example.overloaded(0, orderedArg2=0, orderedArg1=0)
998+
999+
def test_partial_method_duplicate_arg_names(self):
1000+
Example = ObjCClass("Example")
1001+
self.assertEqual(
1002+
Example.overloaded(24, duplicateArg__a=16, duplicateArg__b=6),
1003+
24 + 2 * 16 + 3 * 6,
1004+
)
1005+
1006+
def test_partial_method_exception(self):
1007+
Example = ObjCClass("Example")
1008+
with self.assertRaisesRegex(
1009+
ValueError,
1010+
"Invalid selector overloaded:invalidArgument:. Available selectors are: "
1011+
"overloaded, overloaded:, overloaded:extraArg:, "
1012+
"overloaded:extraArg1:extraArg2:, overloaded:extraArg2:extraArg1:, "
1013+
"overloaded:orderedArg1:orderedArg2:, overloaded:duplicateArg:duplicateArg:",
1014+
):
1015+
Example.overloaded(0, invalidArgument=0)
1016+
9891017
def test_objcmethod_str_repr(self):
9901018
"""Test ObjCMethod, ObjCPartialMethod, and ObjCBoundMethod str and repr"""
9911019

0 commit comments

Comments
 (0)