Coverage for src/decopatch/utils_disambiguation.py: 62%

102 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-07-06 15:13 +0000

1import sys 

2from enum import Enum 

3from inspect import isclass 

4from linecache import getline 

5from warnings import warn 

6 

7from decopatch.utils_modes import SignatureInfo 

8 

9 

10class FirstArgDisambiguation(Enum): 

11 """ 

12 This enum is used for the output of user-provided first argument disambiguators. 

13 """ 

14 is_normal_arg = 0 

15 is_decorated_target = 1 

16 is_ambiguous = 2 

17 

18 

19_WITH_PARENTHESIS = FirstArgDisambiguation.is_normal_arg 

20"""Alias for the case where the arg is a normal arg""" 

21 

22_NO_PARENTHESIS = FirstArgDisambiguation.is_decorated_target 

23"""Alias for the case where the arg is a decorated target""" 

24 

25 

26def with_parenthesis(ambiguous_first_arg): 

27 """hardcoded disambiguator to say that in case of doubt, it is probably a with-parenthesis call""" 

28 return _WITH_PARENTHESIS 

29 

30 

31def no_parenthesis(ambiguous_first_arg): 

32 """hardcoded disambiguator to say that in case of doubt, it is probably a no-parenthesis""" 

33 return _NO_PARENTHESIS 

34 

35 

36def can_arg_be_a_decorator_target(arg): 

37 """ 

38 Returns True if the argument received has a type that can be decorated. 

39 

40 If this method returns False, we are sure that this is a *with*-parenthesis call 

41 because python does not allow you to decorate anything else than a function or a class 

42 

43 :param arg: 

44 :return: 

45 """ 

46 return callable(arg) or isclass(arg) 

47 

48 

49class DecoratorUsageInfo(object): 

50 """ 

51 Represent the knowledge that we have when the decorator is being used. 

52 Note: arguments binding is computed in a lazy mode (only if needed) 

53 """ 

54 __slots__ = 'sig_info', '_first_arg_value', '_bound', 'args', 'kwargs' 

55 

56 def __init__(self, 

57 sig_info, # type: SignatureInfo 

58 args, kwargs): 

59 self.sig_info = sig_info 

60 self.args = args 

61 self.kwargs = kwargs 

62 self._first_arg_value = DecoratorUsageInfo # this is our way to say 'uninitialized' 

63 self._bound = None 

64 

65 @property 

66 def first_arg_value(self): 

67 if self._first_arg_value is DecoratorUsageInfo: # not yet initialized 67 ↛ 68line 67 didn't jump to line 68, because the condition on line 67 was never true

68 self._first_arg_value = self.bound.arguments[self.sig_info.first_arg_name] 

69 return self._first_arg_value 

70 

71 @property 

72 def bound(self): 

73 if self._bound is None: 

74 self._bound = self.sig_info.exposed_signature.bind(*self.args, **self.kwargs) 

75 return self._bound 

76 

77 

78def disambiguate_call(dk, # type: DecoratorUsageInfo 

79 disambiguator): 

80 """ 

81 

82 :return: 

83 """ 

84 # (1) use the number of args to eliminate a few cases 

85 if dk.sig_info.use_signature_trick: 85 ↛ 93line 85 didn't jump to line 93, because the condition on line 85 was never false

86 # we expose a *args, **kwargs signature so we see exactly what the user has provided. 

87 nb_pos_received, nb_kw_received = len(dk.args), len(dk.kwargs) 

88 if nb_kw_received > 0 or nb_pos_received == 0 or nb_pos_received > 1: 

89 return _WITH_PARENTHESIS 

90 dk._first_arg_value = dk.args[0] 

91 else: 

92 # we expose a "true" signature, the only arguments that remain in *args are the ones that CANNOT become kw 

93 nb_posonly_received = len(dk.args) 

94 if dk.sig_info.contains_varpositional or dk.sig_info.is_first_arg_positional_only: 

95 if nb_posonly_received == 0: 

96 # with parenthesis: @foo_decorator(**kwargs) 

97 return _WITH_PARENTHESIS 

98 elif nb_posonly_received >= 2: 

99 # with parenthesis: @foo_decorator(a, b, **kwargs) 

100 return _WITH_PARENTHESIS 

101 else: 

102 # AMBIGUOUS: 

103 # no parenthesis: @foo_decorator -OR- with 1 positional argument: @foo_decorator(a, **kwargs). 

104 # reminder: we can not count the kwargs because they always contain all the arguments 

105 dk._first_arg_value = dk.args[0] 

106 

107 else: 

108 # first arg can be keyword. So it will be in kwargs (even if it was provided as positional). 

109 if nb_posonly_received > 0: 

110 raise Exception("Internal error - this should not happen, please file an issue on the github page") 

111 

112 # (2) Now work on the values themselves 

113 if not can_arg_be_a_decorator_target(dk.first_arg_value): 

114 # the first argument can NOT be decorated: we are sure that this was a WITH-parenthesis call 

115 return _WITH_PARENTHESIS 

116 elif not dk.sig_info.use_signature_trick: 116 ↛ 118line 116 didn't jump to line 118, because the condition on line 116 was never true

117 # we were not able to use the length of kwargs, but at least we can compare them to defaults. 

118 if dk.first_arg_value is dk.sig_info.first_arg_def.default: 

119 # the first argument has NOT been set, we are sure that's WITH-parenthesis call 

120 return _WITH_PARENTHESIS 

121 else: 

122 # check if there is another argument that is different from its default value 

123 # skip first entry 

124 params = iter(dk.sig_info.exposed_signature.parameters.items()) 

125 next(params) 

126 for p_name, p_def in params: 

127 try: 

128 if dk.bound.arguments[p_name] is not p_def.default: 

129 return _WITH_PARENTHESIS 

130 except KeyError: 

131 pass # this can happen when the argument is **kwargs and not filled: it does not even appear. 

132 

133 # (3) still-ambiguous case, the first parameter is the single non-default one and is a callable or class 

134 # at this point a no-parenthesis call is still possible. 

135 # call disambiguator (it combines our rules with the ones optionally provided by the user) 

136 return disambiguator(dk.first_arg_value) 

137 

138 

139def create_single_arg_callable_or_class_disambiguator(impl_function, 

140 is_function_decorator, 

141 is_class_decorator, 

142 custom_disambiguator, 

143 enable_stack_introspection, 

144 signature_knowledge, # type: SignatureInfo 

145 ): 

146 """ 

147 Returns the function that should be used as disambiguator in "last resort" (when the first argument is the single 

148 non-default argument and it is a callable/class). 

149 

150 :param first_arg_received: 

151 :return: 

152 """ 

153 

154 def _disambiguate_call(first_arg_received): 

155 """ 

156 The first argument received is the single non-default argument and it is a callable/class. 

157 We should try to disambiguate now. 

158 

159 :param first_arg_received: 

160 :return: 

161 """ 

162 

163 # introspection-based 

164 if enable_stack_introspection: 

165 depth = 4 if signature_knowledge.use_signature_trick else 5 

166 try: 

167 res = disambiguate_using_introspection(depth, first_arg_received) 

168 if res is not None: 168 ↛ 180line 168 didn't jump to line 180, because the condition on line 168 was never false

169 return res 

170 except IPythonException as e: 

171 warn("Decorator disambiguation using stack introspection is not available in Jupyter/iPython. " 

172 "Please use the decorator in a non-ambiguous way. For example use explicit parenthesis @%s() " 

173 "for no-arg usage, or use 2 non-default arguments, or use explicit keywords. Ambiguous " 

174 "argument received: %s." % (impl_function.__name__, first_arg_received)) 

175 except Exception: 

176 # silently escape all exceptions by default - safer for users 

177 pass 

178 

179 # we want to eliminate as much as possible the args that cannot be first args 

180 if callable(first_arg_received) and not isclass(first_arg_received) and not is_function_decorator: 180 ↛ 182line 180 didn't jump to line 182, because the condition on line 180 was never true

181 # that function cannot be a decorator target so it has to be the first argument 

182 return FirstArgDisambiguation.is_normal_arg 

183 

184 elif isclass(first_arg_received) and not is_class_decorator: 184 ↛ 186line 184 didn't jump to line 186, because the condition on line 184 was never true

185 # that class cannot be a decorator target so it has to be the first argument 

186 return FirstArgDisambiguation.is_normal_arg 

187 

188 elif custom_disambiguator is not None: 

189 # an explicit disambiguator is provided, use it 

190 return custom_disambiguator(first_arg_received) 

191 

192 else: 

193 # Always say "decorated target" because 

194 # - if 1+ mandatory arg, we can safely do this, it will raise a `TypeError` automatically 

195 # - if 0 mandatory args, the most probable scenario for receiving a single callable or class argument is 

196 # still the no-arg. We do not want to penalize users. 

197 return FirstArgDisambiguation.is_decorated_target 

198 

199 return _disambiguate_call 

200 

201 

202class IPythonException(Exception): 

203 """Exception raised by `disambiguate_using_introspection` when the file where the decorator was used seems to be 

204 an ipython one""" 

205 pass 

206 

207 

208SUPPORTS_INTROSPECTION = sys.version_info < (3, 8) 

209 

210 

211def disambiguate_using_introspection(depth, first_arg): 

212 """ 

213 Tries to disambiguate the call situation betwen with-parenthesis and without-parenthesis using call stack 

214 introspection. 

215 

216 Uses inpect.stack() to get the source code where the decorator is being used. If the line starts with a '@' and does 

217 not contain any '(', this is a no-parenthesis call. Otherwise it is a with-parenthesis call. 

218 TODO it could be worth investigating how to improve this logic.. but remember that the decorator can also be renamed 

219 so we can not check a full string expression 

220 

221 :param depth: 

222 :return: 

223 """ 

224 if not SUPPORTS_INTROSPECTION: 224 ↛ 226line 224 didn't jump to line 226, because the condition on line 224 was never true

225 # This does not seem to work reliably. 

226 raise NotImplementedError("The beta stack introspection feature does not support python 3.8+. Please set" 

227 " `enable_stack_introspection=False`.") 

228 

229 # Unfortunately inspect.stack and inspect.currentframe are extremely slow 

230 # see https://gist.github.com/JettJones/c236494013f22723c1822126df944b12 

231 # -- 

232 # curframe = currentframe() 

233 # calframe = getouterframes(curframe, 4) 

234 # --or 

235 # calframe = stack(context=1) 

236 # filename = calframe[depth][1] 

237 # ---- 

238 # this is fast :) 

239 calframe = sys._getframe(depth) 

240 

241 try: 

242 # if target is a function that should work 

243 is_decorator_call_ = first_arg.__code__.co_firstlineno == calframe.f_lineno 

244 except AttributeError: 

245 # if target is a class rely on source code using linecache 

246 is_decorator_call_ = is_decorator_call(calframe) 

247 

248 if is_decorator_call_: 

249 return FirstArgDisambiguation.is_decorated_target 

250 else: 

251 return FirstArgDisambiguation.is_normal_arg 

252 

253 

254# try: 

255# from opcode import opmap 

256# 

257# def is_decorator_call(frame, first_arg): 

258# """ 

259# This implementation relies on bytecode inspection. 

260# It seems to change too much across versions to be reliable. 

261# 

262# :param frame: 

263# :return: 

264# """ 

265# try: 

266# # if target is a function that should work 

267# return first_arg.__code__.co_firstlineno == frame.f_lineno 

268# except AttributeError: 

269# pass 

270# 

271# # if the last bytecode operation before this frame is a MAKE_FUNCTION or a LOAD_BUILD_CLASS, 

272# # that means that we are a decorator without arguments >> NO, that's wrong !!!! 

273# # --rather 

274# # if the last LOAD_CONST done is for a <code> object that lies in the same line, then that's without arguments. 

275# code = frame.f_code.co_code 

276# opcode = 0 

277# instruction_idx = frame.f_lasti 

278# # instructions are every 3 positions 

279# last_load_const_idx = instruction_idx - (5 * 3) 

280# opcode = code[last_load_const_idx] 

281# 

282# import dis 

283# dis.disassemble(frame.f_code, lasti=instruction_idx) # lasti=frame.f_lasti 

284# 

285# if opcode != opmap['LOAD_CONST']: 

286# return False 

287# else: 

288# # is this loading a code that lies after the frame.f_lineno ? 

289# arg = code[last_load_const_idx + 1] + code[last_load_const_idx + 2] * 256 

290# # argval, argrepr = _get_const_info(arg, constants) 

291# target = frame.f_code.co_consts[arg] 

292# 

293# return target.co_firstlineno == frame.f_lineno 

294# 

295# except ImportError: 

296def is_decorator_call(frame): 

297 """ 

298 This implementation relies on source code inspection thanks to linecache. 

299 :param frame: 

300 :return: 

301 """ 

302 # using inspect.getsource : heavy... 

303 # using traceback.format_stack: seems to fallback to linecache anyway. 

304 # using linecache https://docs.python.org/3/library/linecache.html 

305 cal_line_str = getline(frame.f_code.co_filename, frame.f_lineno).strip() 

306 if cal_line_str.startswith('class'): 

307 cal_line_str = getline(frame.f_code.co_filename, frame.f_lineno - 1).strip() 

308 return cal_line_str.startswith('@') and '(' not in cal_line_str