⬅ decopatch/utils_disambiguation.py source

1 import sys
2 from enum import Enum
3 from inspect import isclass
4 from linecache import getline
5 from warnings import warn
6  
7 from decopatch.utils_modes import SignatureInfo
8  
9  
10 class 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  
26 def 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  
31 def 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  
36 def 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  
49 class 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
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  
78 def 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:
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:
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  
139 def 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:
169 return res
  • F841 Local variable 'e' is assigned to but never used
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))
  • S110 Try, Except, Pass detected.
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:
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:
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  
202 class 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  
208 SUPPORTS_INTROSPECTION = sys.version_info < (3, 8)
209  
210  
211 def 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:
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
  • E501 Line too long (121 > 120 characters)
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:
296 def 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