Skip to content

Commit 970b122

Browse files
author
Sylvain MARIE
committed
Unbound case functions in a class (e.g. Foo.bar) can now be directly passed to parametrize_with_cases without instantiating the class, e.g. parametrize_with_cases(cases=Foo.bar). Fixes #159.
New utility method `needs_binding` + fixed `get_class_that_defined_method` so that it supports nesting.
1 parent 6ad56fa commit 970b122

File tree

3 files changed

+273
-22
lines changed

3 files changed

+273
-22
lines changed

pytest_cases/case_parametrizer_new.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
pass
1818

1919
from .common_mini_six import string_types
20-
from .common_others import get_code_first_line, AUTO, qname, funcopy
20+
from .common_others import get_code_first_line, AUTO, qname, funcopy, needs_binding
2121
from .common_pytest_marks import copy_pytest_marks, make_marked_parameter_value, remove_pytest_mark, filter_marks, \
2222
get_param_argnames_as_list
2323
from .common_pytest_lazy_values import lazy_value, LazyTupleItem
@@ -264,7 +264,9 @@ def get_all_cases(parametrization_target, # type: Callable
264264
elif callable(c):
265265
# function
266266
if is_case_function(c, check_prefix=False): # do not check prefix, it was explicitly passed
267-
cases_funs.append(c)
267+
# bind it automatically if needed (if unbound class method)
268+
shall_bind, bound_c = needs_binding(c, return_bound=True)
269+
cases_funs.append(bound_c)
268270
else:
269271
raise ValueError("Unsupported case function: %r" % c)
270272
else:
@@ -611,7 +613,7 @@ def extract_cases_from_module(module, # type: ModuleRe
611613
):
612614
# type: (...) -> List[Callable]
613615
"""
614-
Internal method used to create a list of `CaseDataGetter` for all cases available from the given module.
616+
Internal method used to create a list of case functions for all cases available from the given module.
615617
See `@cases_data`
616618
617619
See also `_pytest.python.PyCollector.collect` and `_pytest.python.PyCollector._makeitem` and

pytest_cases/common_others.py

Lines changed: 235 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -211,41 +211,257 @@ def __exit__(self, exc_type, exc_val, exc_tb):
211211
"""Marker for automatic defaults"""
212212

213213

214-
def get_function_host(func):
214+
def get_function_host(func, fallback_to_module=True):
215215
"""
216216
Returns the module or class where func is defined. Approximate method based on qname but "good enough"
217217
218218
:param func:
219+
:param fallback_to_module: if True and an HostNotConstructedYet error is caught, the host module is returned
219220
:return:
220221
"""
221-
host = get_class_that_defined_method(func)
222+
host = None
223+
try:
224+
host = get_class_that_defined_method(func)
225+
except HostNotConstructedYet:
226+
# ignore if `fallback_to_module=True`
227+
if not fallback_to_module:
228+
raise
229+
222230
if host is None:
223231
host = import_module(func.__module__)
224232
# assert func in host
225233

226234
return host
227235

228236

229-
def get_class_that_defined_method(meth):
230-
""" Adapted from https://stackoverflow.com/a/25959545/7262247 , to support python 2 too """
231-
if isinstance(meth, functools.partial):
232-
return get_class_that_defined_method(meth.func)
237+
def needs_binding(f, return_bound=False):
238+
# type: (...) -> Union[bool, Tuple[bool, Callable]]
239+
"""Utility to check if a function needs to be bound to be used """
240+
241+
# detect non-callables
242+
if isinstance(f, staticmethod):
243+
# only happens if the method is provided as Foo.__dict__['b'], not as Foo.b
244+
# binding is really easy here: pass any class
245+
246+
# no need for the actual class
247+
# bound = f.__get__(get_class_that_defined_method(f.__func__))
248+
249+
# f.__func__ (python 3) or f.__get__(object) (py2 and py3) work
250+
return (True, f.__get__(object)) if return_bound else True
233251

234-
if inspect.ismethod(meth) or (inspect.isbuiltin(meth) and getattr(meth, '__self__', None) is not None
235-
and getattr(meth.__self__, '__class__', None)):
236-
for cls in inspect.getmro(meth.__self__.__class__):
237-
if meth.__name__ in cls.__dict__:
238-
return cls
239-
meth = getattr(meth, '__func__', meth) # fallback to __qualname__ parsing
252+
elif isinstance(f, classmethod):
253+
# only happens if the method is provided as Foo.__dict__['b'], not as Foo.b
254+
if not return_bound:
255+
return True
256+
else:
257+
host_cls = get_class_that_defined_method(f.__func__)
258+
bound = f.__get__(host_cls, host_cls)
259+
return True, bound
260+
261+
else:
262+
# note that for the two above cases callable(f) returns False !
263+
if not callable(f) and (PY3 or not inspect.ismethoddescriptor(f)):
264+
raise TypeError("`f` is not a callable !")
240265

241-
if inspect.isfunction(meth):
242-
cls = getattr(inspect.getmodule(meth),
243-
qname(meth).split('.<locals>', 1)[0].rsplit('.', 1)[0],
244-
None)
245-
if isinstance(cls, type):
246-
return cls
266+
if isinstance(f, functools.partial) or fixed_ismethod(f) or is_bound_builtin_method(f):
267+
# already bound, although TODO the functools.partial one is a shortcut that should be analyzed more deeply
268+
return (False, f) if return_bound else False
247269

248-
return getattr(meth, '__objclass__', None) # handle special descriptor objects
270+
else:
271+
# can be a static method, a class method, a descriptor...
272+
if not PY3:
273+
host_cls = getattr(f, "im_class", None)
274+
if host_cls is None:
275+
# defined outside a class: no need for binding
276+
return (False, f) if return_bound else False
277+
else:
278+
bound_obj = getattr(f, "im_self", None)
279+
if bound_obj is None:
280+
# unbound method
281+
if return_bound:
282+
# bind it on an instance
283+
return True, f.__get__(host_cls(), host_cls) # functools.partial(f, host_cls())
284+
else:
285+
return True
286+
else:
287+
# yes: already bound, no binding needed
288+
return (False, f) if return_bound else False
289+
else:
290+
try:
291+
qname = f.__qualname__
292+
except AttributeError:
293+
return (False, f) if return_bound else False
294+
else:
295+
if qname == f.__name__:
296+
# not nested - plain old function in a module
297+
return (False, f) if return_bound else False
298+
else:
299+
# NESTED in a class or a function or ...
300+
qname_parts = qname.split(".")
301+
302+
# normal unbound method (since we already eliminated bound ones above with fixed_ismethod(f))
303+
# or static method accessed on an instance or on a class (!)
304+
# or descriptor-created method
305+
# if "__get__" in qname_parts:
306+
# # a method generated by a descriptor - should be already bound but...
307+
# #
308+
# # see https://docs.python.org/3/reference/datamodel.html#object.__set_name__
309+
# # The attribute __objclass__ may indicate that an instance of the given type (or a subclass)
310+
# # is expected or required as the first positional argument
311+
# cls_needed = getattr(f, '__objclass__', None)
312+
# if cls_needed is not None:
313+
# return (True, functools.partial(f, cls_needed())) if return_bound else True
314+
# else:
315+
# return (False, f) if return_bound else False
316+
317+
if qname_parts[-2] == "<locals>":
318+
# a function generated by another function. most probably does not require binding
319+
# since `get_class_that_defined_method` does not support those (as PEP3155 states)
320+
# we have no choice but to make this assumption.
321+
return (False, f) if return_bound else False
322+
323+
else:
324+
# unfortunately in order to detect static methods we have no choice: we need the host class
325+
host_cls = get_class_that_defined_method(f)
326+
if host_cls is None:
327+
get_class_that_defined_method(f) # for debugging, do it again
328+
raise NotImplementedError("This case does not seem covered, please report")
329+
330+
# is it a static method (on instance or class, it is the same),
331+
# an unbound classmethod, or an unbound method ?
332+
# To answer we need to go back to the definition
333+
func_def = inspect.getattr_static(host_cls, f.__name__)
334+
# assert inspect.getattr(host_cls, f.__name__) is f
335+
if isinstance(func_def, staticmethod):
336+
return (False, f) if return_bound else False
337+
elif isinstance(func_def, classmethod):
338+
# unbound class method
339+
if return_bound:
340+
# bind it on the class
341+
return True, f.__get__(host_cls, host_cls) # functools.partial(f, host_cls)
342+
else:
343+
return True
344+
else:
345+
# unbound method
346+
if return_bound:
347+
# bind it on an instance
348+
return True, f.__get__(host_cls(), host_cls) # functools.partial(f, host_cls())
349+
else:
350+
return True
351+
352+
353+
def is_static_method(cls, func_name, func=None):
354+
""" Adapted from https://stackoverflow.com/a/64436801/7262247
355+
356+
indeed isinstance(staticmethod) does not work if the method is already bound
357+
358+
:param cls:
359+
:param func_name:
360+
:param func: optional, if you have it already
361+
:return:
362+
"""
363+
if func is not None:
364+
assert getattr(cls, func_name) is func
365+
366+
return isinstance(inspect.getattr_static(cls, func_name), staticmethod)
367+
368+
369+
def is_class_method(cls, func_name, func=None):
370+
""" Adapted from https://stackoverflow.com/a/64436801/7262247
371+
372+
indeed isinstance(classmethod) does not work if the method is already bound
373+
374+
:param cls:
375+
:param func_name:
376+
:param func: optional, if you have it already
377+
:return:
378+
"""
379+
if func is not None:
380+
assert getattr(cls, func_name) is func
381+
382+
return isinstance(inspect.getattr_static(cls, func_name), classmethod)
383+
384+
385+
def is_bound_builtin_method(meth):
386+
"""Helper returning True if meth is a bound built-in method"""
387+
return (inspect.isbuiltin(meth)
388+
and getattr(meth, '__self__', None) is not None
389+
and getattr(meth.__self__, '__class__', None))
390+
391+
392+
class HostNotConstructedYet(Exception):
393+
"""Raised by `get_class_that_defined_method` in the situation where the host class is not in the host module yet."""
394+
pass
395+
396+
397+
if PY3:
398+
# this does not need fixing
399+
fixed_ismethod = inspect.ismethod
400+
401+
def get_class_that_defined_method(meth):
402+
"""from https://stackoverflow.com/a/25959545/7262247
403+
404+
Improved to support nesting, and to raise an Exception if __qualname__ does
405+
not properly work (instead of returning None which may be misleading)
406+
407+
And yes PEP3155 states that __qualname__ should be used for such introspection.
408+
See https://www.python.org/dev/peps/pep-3155/#rationale
409+
"""
410+
if isinstance(meth, functools.partial):
411+
return get_class_that_defined_method(meth.func)
412+
413+
if inspect.ismethod(meth) or is_bound_builtin_method(meth):
414+
for cls in inspect.getmro(meth.__self__.__class__):
415+
if meth.__name__ in cls.__dict__:
416+
return cls
417+
meth = getattr(meth, '__func__', meth) # fallback to __qualname__ parsing
418+
419+
if inspect.isfunction(meth):
420+
host = inspect.getmodule(meth)
421+
host_part = meth.__qualname__.split('.<locals>', 1)[0]
422+
# note: the local part of qname is not walkable see https://www.python.org/dev/peps/pep-3155/#limitations
423+
for item in host_part.split('.')[:-1]:
424+
try:
425+
host = getattr(host, item)
426+
except AttributeError:
427+
# non-resolvable __qualname__
428+
raise HostNotConstructedYet(
429+
"__qualname__ is not resolvable, this can happen if the host class of this method "
430+
"%r has not yet been created. PEP3155 does not seem to tell us what we should do "
431+
"in this case." % meth
432+
)
433+
if host is None:
434+
raise ValueError("__qualname__ leads to `None`, this is strange and not PEP3155 compliant, please "
435+
"report")
436+
437+
if isinstance(host, type):
438+
return host
439+
440+
return getattr(meth, '__objclass__', None) # handle special descriptor objects
441+
442+
else:
443+
def fixed_ismethod(f):
444+
"""inspect.ismethod does not have the same contract in python 2: it returns True even for bound methods"""
445+
return hasattr(f, '__self__') and f.__self__ is not None
446+
447+
def get_class_that_defined_method(meth):
448+
"""from https://stackoverflow.com/a/961057/7262247
449+
450+
Adapted to support partial
451+
"""
452+
if isinstance(meth, functools.partial):
453+
return get_class_that_defined_method(meth.func)
454+
455+
try:
456+
_mro = inspect.getmro(meth.im_class)
457+
except AttributeError:
458+
# no host class
459+
return None
460+
else:
461+
for cls in _mro:
462+
if meth.__name__ in cls.__dict__:
463+
return cls
464+
return None
249465

250466

251467
if PY3:
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from pytest_cases import parametrize_with_cases, fixture
2+
3+
4+
@fixture
5+
def my_fix(): # dummy fixture to ensure we don't get lazy values
6+
pass
7+
8+
9+
class CaseGroup(object):
10+
def case_first_lazy(self):
11+
return 1
12+
13+
def case_second_fixture(self, my_fix):
14+
return 1
15+
16+
17+
@parametrize_with_cases("x", cases=(CaseGroup.case_first_lazy, CaseGroup.case_second_fixture))
18+
def test_parametrize_with_single_case_method_unbound(x):
19+
assert x == 1
20+
21+
22+
@parametrize_with_cases("x", cases=(CaseGroup().case_first_lazy, CaseGroup().case_second_fixture))
23+
def test_parametrize_with_single_case_method_bound(x):
24+
assert x == 1
25+
26+
27+
def test_synthesis(module_results_dct):
28+
assert list(module_results_dct) == [
29+
'test_parametrize_with_single_case_method_unbound[first_lazy]',
30+
'test_parametrize_with_single_case_method_unbound[second_fixture]',
31+
'test_parametrize_with_single_case_method_bound[first_lazy]',
32+
'test_parametrize_with_single_case_method_bound[second_fixture]'
33+
]

0 commit comments

Comments
 (0)