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