Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pikepdf/_augments.py: 85%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

47 statements  

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 collections.abc import Callable 

11from typing import Any, Protocol, TypeVar 

12 

13 

14class AugmentedCallable(Protocol): 

15 """Protocol for any method, with attached booleans.""" 

16 

17 _augment_override_cpp: bool 

18 _augment_if_no_cpp: bool 

19 

20 def __call__(self, *args, **kwargs) -> Any: 

21 """Any function.""" # pragma: no cover 

22 

23 

24def augment_override_cpp(fn: AugmentedCallable) -> AugmentedCallable: 

25 """Replace the C++ implementation, if there is one.""" 

26 fn._augment_override_cpp = True 

27 return fn 

28 

29 

30def augment_if_no_cpp(fn: AugmentedCallable) -> AugmentedCallable: 

31 """Provide a Python implementation if no C++ implementation exists.""" 

32 fn._augment_if_no_cpp = True 

33 return fn 

34 

35 

36def _is_inherited_method(meth: Callable) -> bool: 

37 # Augmenting a C++ with a method that cls inherits from the Python 

38 # object is never what we want. 

39 return meth.__qualname__.startswith('object.') 

40 

41 

42def _is_augmentable(m: Any) -> bool: 

43 return ( 

44 inspect.isfunction(m) and not _is_inherited_method(m) 

45 ) or inspect.isdatadescriptor(m) 

46 

47 

48Tcpp = TypeVar('Tcpp') 

49T = TypeVar('T') 

50 

51 

52def augments(cls_cpp: type[Tcpp]): 

53 """Attach methods of a Python support class to an existing class. 

54 

55 This monkeypatches all methods defined in the support class onto an 

56 existing class. Example: 

57 

58 .. code-block:: python 

59 

60 @augments(ClassDefinedInCpp) 

61 class SupportClass: 

62 def foo(self): 

63 pass 

64 

65 The Python method 'foo' will be monkeypatched on ClassDefinedInCpp. SupportClass 

66 has no meaning on its own and should not be used, but gets returned from 

67 this function so IDE code inspection doesn't get too confused. 

68 

69 We don't subclass because it's much more convenient to monkeypatch Python 

70 methods onto the existing Python binding of the C++ class. For one thing, 

71 this allows the implementation to be moved from Python to C++ or vice 

72 versa. It saves having to implement an intermediate Python subclass and then 

73 ensures that the C++ superclass never 'leaks' to pikepdf users. Finally, 

74 wrapper classes and subclasses can become problematic if the call stack 

75 crosses the C++/Python boundary multiple times. 

76 

77 Any existing methods may be used, regardless of whether they are defined 

78 elsewhere in the support class or in the target class. 

79 

80 For data fields to work, the target class must be 

81 tagged ``py::dynamic_attr`` in pybind11. 

82 

83 Strictly, the target class does not have to be C++ or derived from pybind11. 

84 This works on pure Python classes too. 

85 

86 THIS DOES NOT work for class methods. 

87 

88 (Alternative ideas: https://github.com/pybind/pybind11/issues/1074) 

89 """ 

90 OVERRIDE_WHITELIST = {'__eq__', '__hash__', '__repr__'} 

91 if platform.python_implementation() == 'PyPy': 

92 # Either PyPy or pybind11's interface to PyPy automatically adds a __getattr__ 

93 OVERRIDE_WHITELIST |= {'__getattr__'} # pragma: no cover 

94 

95 def class_augment(cls: type[T], cls_cpp: type[Tcpp] = cls_cpp) -> type[T]: 

96 # inspect.getmembers has different behavior on PyPy - in particular it seems 

97 # that a typical PyPy class like cls will have more methods that it considers 

98 # methods than CPython does. Our predicate should take care of this. 

99 for name, member in inspect.getmembers(cls, predicate=_is_augmentable): 

100 if name == '__weakref__': 

101 continue 

102 if ( 

103 hasattr(cls_cpp, name) 

104 and hasattr(cls, name) 

105 and name not in getattr(cls, '__abstractmethods__', set()) 

106 and name not in OVERRIDE_WHITELIST 

107 and not getattr(getattr(cls, name), '_augment_override_cpp', False) 

108 ): 

109 if getattr(getattr(cls, name), '_augment_if_no_cpp', False): 

110 # If tagged as "augment if no C++", we only want the binding to be 

111 # applied when the primary class does not provide a C++ 

112 # implementation. Usually this would be a function that not is 

113 # provided by pybind11 in some template. 

114 continue 

115 

116 # If the original C++ class and Python support class both define the 

117 # same name, we generally have a conflict, because this is augmentation 

118 # not inheritance. However, if the method provided by the support class 

119 # is an abstract method, then we can consider the C++ version the 

120 # implementation. Also, pybind11 provides defaults for __eq__, 

121 # __hash__ and __repr__ that we often do want to override directly. 

122 

123 raise RuntimeError( 

124 f"C++ {cls_cpp} and Python {cls} both define the same " 

125 f"non-abstract method {name}: " 

126 f"{getattr(cls_cpp, name, '')!r}, " 

127 f"{getattr(cls, name, '')!r}" 

128 ) 

129 if inspect.isfunction(member): 

130 if hasattr(cls_cpp, name): 

131 # If overriding a C++ named method, make a copy of the original 

132 # method. This is so that the Python override can call the C++ 

133 # implementation if it needs to. 

134 setattr(cls_cpp, f"_cpp{name}", getattr(cls_cpp, name)) 

135 setattr(cls_cpp, name, member) 

136 installed_member = getattr(cls_cpp, name) 

137 installed_member.__qualname__ = member.__qualname__.replace( 

138 cls.__name__, cls_cpp.__name__ 

139 ) 

140 elif inspect.isdatadescriptor(member): 

141 setattr(cls_cpp, name, member) 

142 

143 def disable_init(self): 

144 # Prevent initialization of the support class 

145 raise NotImplementedError(self.__class__.__name__ + '.__init__') 

146 

147 cls.__init__ = disable_init # type: ignore 

148 return cls 

149 

150 return class_augment