1# SPDX-FileCopyrightText: 2022 James R. Barlow
2# SPDX-License-Identifier: MPL-2.0
3
4"""A peculiar method of monkeypatching C++ binding classes with Python methods."""
5
6from __future__ import annotations
7
8import inspect
9import platform
10from typing import Any, Callable, Protocol, TypeVar
11
12
13class AugmentedCallable(Protocol):
14 """Protocol for any method, with attached booleans."""
15
16 _augment_override_cpp: bool
17 _augment_if_no_cpp: bool
18
19 def __call__(self, *args, **kwargs) -> Any:
20 """Any function.""" # pragma: no cover
21
22
23def augment_override_cpp(fn: AugmentedCallable) -> AugmentedCallable:
24 """Replace the C++ implementation, if there is one."""
25 fn._augment_override_cpp = True
26 return fn
27
28
29def augment_if_no_cpp(fn: AugmentedCallable) -> AugmentedCallable:
30 """Provide a Python implementation if no C++ implementation exists."""
31 fn._augment_if_no_cpp = True
32 return fn
33
34
35def _is_inherited_method(meth: Callable) -> bool:
36 # Augmenting a C++ with a method that cls inherits from the Python
37 # object is never what we want.
38 return meth.__qualname__.startswith('object.')
39
40
41def _is_augmentable(m: Any) -> bool:
42 return (
43 inspect.isfunction(m) and not _is_inherited_method(m)
44 ) or inspect.isdatadescriptor(m)
45
46
47Tcpp = TypeVar('Tcpp')
48T = TypeVar('T')
49
50
51def augments(cls_cpp: type[Tcpp]):
52 """Attach methods of a Python support class to an existing class.
53
54 This monkeypatches all methods defined in the support class onto an
55 existing class. Example:
56
57 .. code-block:: python
58
59 @augments(ClassDefinedInCpp)
60 class SupportClass:
61 def foo(self):
62 pass
63
64 The Python method 'foo' will be monkeypatched on ClassDefinedInCpp. SupportClass
65 has no meaning on its own and should not be used, but gets returned from
66 this function so IDE code inspection doesn't get too confused.
67
68 We don't subclass because it's much more convenient to monkeypatch Python
69 methods onto the existing Python binding of the C++ class. For one thing,
70 this allows the implementation to be moved from Python to C++ or vice
71 versa. It saves having to implement an intermediate Python subclass and then
72 ensures that the C++ superclass never 'leaks' to pikepdf users. Finally,
73 wrapper classes and subclasses can become problematic if the call stack
74 crosses the C++/Python boundary multiple times.
75
76 Any existing methods may be used, regardless of whether they are defined
77 elsewhere in the support class or in the target class.
78
79 For data fields to work, the target class must be
80 tagged ``py::dynamic_attr`` in pybind11.
81
82 Strictly, the target class does not have to be C++ or derived from pybind11.
83 This works on pure Python classes too.
84
85 THIS DOES NOT work for class methods.
86
87 (Alternative ideas: https://github.com/pybind/pybind11/issues/1074)
88 """
89 OVERRIDE_WHITELIST = {'__eq__', '__hash__', '__repr__'}
90 if platform.python_implementation() == 'PyPy':
91 # Either PyPy or pybind11's interface to PyPy automatically adds a __getattr__
92 OVERRIDE_WHITELIST |= {'__getattr__'} # pragma: no cover
93
94 def class_augment(cls: type[T], cls_cpp: type[Tcpp] = cls_cpp) -> type[T]:
95 # inspect.getmembers has different behavior on PyPy - in particular it seems
96 # that a typical PyPy class like cls will have more methods that it considers
97 # methods than CPython does. Our predicate should take care of this.
98 for name, member in inspect.getmembers(cls, predicate=_is_augmentable):
99 if name == '__weakref__':
100 continue
101 if (
102 hasattr(cls_cpp, name)
103 and hasattr(cls, name)
104 and name not in getattr(cls, '__abstractmethods__', set())
105 and name not in OVERRIDE_WHITELIST
106 and not getattr(getattr(cls, name), '_augment_override_cpp', False)
107 ):
108 if getattr(getattr(cls, name), '_augment_if_no_cpp', False):
109 # If tagged as "augment if no C++", we only want the binding to be
110 # applied when the primary class does not provide a C++
111 # implementation. Usually this would be a function that not is
112 # provided by pybind11 in some template.
113 continue
114
115 # If the original C++ class and Python support class both define the
116 # same name, we generally have a conflict, because this is augmentation
117 # not inheritance. However, if the method provided by the support class
118 # is an abstract method, then we can consider the C++ version the
119 # implementation. Also, pybind11 provides defaults for __eq__,
120 # __hash__ and __repr__ that we often do want to override directly.
121
122 raise RuntimeError(
123 f"C++ {cls_cpp} and Python {cls} both define the same "
124 f"non-abstract method {name}: "
125 f"{getattr(cls_cpp, name, '')!r}, "
126 f"{getattr(cls, name, '')!r}"
127 )
128 if inspect.isfunction(member):
129 if hasattr(cls_cpp, name):
130 # If overriding a C++ named method, make a copy of the original
131 # method. This is so that the Python override can call the C++
132 # implementation if it needs to.
133 setattr(cls_cpp, f"_cpp{name}", getattr(cls_cpp, name))
134 setattr(cls_cpp, name, member)
135 installed_member = getattr(cls_cpp, name)
136 installed_member.__qualname__ = member.__qualname__.replace(
137 cls.__name__, cls_cpp.__name__
138 )
139 elif inspect.isdatadescriptor(member):
140 setattr(cls_cpp, name, member)
141
142 def disable_init(self):
143 # Prevent initialization of the support class
144 raise NotImplementedError(self.__class__.__name__ + '.__init__')
145
146 cls.__init__ = disable_init # type: ignore
147 return cls
148
149 return class_augment