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
« 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
7from decopatch.utils_modes import SignatureInfo
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
19_WITH_PARENTHESIS = FirstArgDisambiguation.is_normal_arg
20"""Alias for the case where the arg is a normal arg"""
22_NO_PARENTHESIS = FirstArgDisambiguation.is_decorated_target
23"""Alias for the case where the arg is a decorated target"""
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
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
36def can_arg_be_a_decorator_target(arg):
37 """
38 Returns True if the argument received has a type that can be decorated.
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
43 :param arg:
44 :return:
45 """
46 return callable(arg) or isclass(arg)
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'
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
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
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
78def disambiguate_call(dk, # type: DecoratorUsageInfo
79 disambiguator):
80 """
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]
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")
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.
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)
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).
150 :param first_arg_received:
151 :return:
152 """
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.
159 :param first_arg_received:
160 :return:
161 """
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
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
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
188 elif custom_disambiguator is not None:
189 # an explicit disambiguator is provided, use it
190 return custom_disambiguator(first_arg_received)
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
199 return _disambiguate_call
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
208SUPPORTS_INTROSPECTION = sys.version_info < (3, 8)
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.
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
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`.")
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)
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)
248 if is_decorator_call_:
249 return FirstArgDisambiguation.is_decorated_target
250 else:
251 return FirstArgDisambiguation.is_normal_arg
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