Text plugin script updates: Better error handling, variable parsing, token caching...
[blender.git] / release / scripts / bpymodules / BPyTextPlugin.py
1 import bpy
2 import __builtin__, tokenize
3 from Blender.sys import time
4 from tokenize import generate_tokens, TokenError
5 # TODO: Remove the dependency for a full Python installation. Currently only the
6 # tokenize module is required 
7
8 # Context types
9 NORMAL = 0
10 SINGLE_QUOTE = 1
11 DOUBLE_QUOTE = 2
12 COMMENT = 3
13
14 # Python keywords
15 KEYWORDS = ['and', 'del', 'from', 'not', 'while', 'as', 'elif', 'global',
16                         'or', 'with', 'assert', 'else', 'if', 'pass', 'yield',
17                         'break', 'except', 'import', 'print', 'class', 'exec', 'in',
18                         'raise', 'continue', 'finally', 'is', 'return', 'def', 'for',
19                         'lambda', 'try' ]
20
21 # Used to cache the return value of generate_tokens
22 _token_cache = None
23 _cache_update = 0
24
25 def suggest_cmp(x, y):
26         """Use this method when sorting a list of suggestions.
27         """
28         
29         return cmp(x[0], y[0])
30
31 def cached_generate_tokens(txt, since=1):
32         """A caching version of generate tokens for multiple parsing of the same
33         document within a given timescale.
34         """
35         
36         global _token_cache, _cache_update
37         
38         if _cache_update < time() - since:
39                 txt.reset()
40                 _token_cache = [g for g in generate_tokens(txt.readline)]
41                 _cache_update = time()
42         return _token_cache
43
44 def get_module(name):
45         """Returns the module specified by its name. The module itself is imported
46         by this method and, as such, any initialization code will be executed.
47         """
48         
49         mod = __import__(name)
50         components = name.split('.')
51         for comp in components[1:]:
52                 mod = getattr(mod, comp)
53         return mod
54
55 def is_module(m):
56         """Taken from the inspect module of the standard Python installation.
57         """
58         
59         return isinstance(m, type(bpy))
60
61 def type_char(v):
62         """Returns the character used to signify the type of a variable. Use this
63         method to identify the type character for an item in a suggestion list.
64         
65         The following values are returned:
66           'm' if the parameter is a module
67           'f' if the parameter is callable
68           'v' if the parameter is variable or otherwise indeterminable
69         """
70         
71         if is_module(v):
72                 return 'm'
73         elif callable(v):
74                 return 'f'
75         else: 
76                 return 'v'
77
78 def get_context(txt):
79         """Establishes the context of the cursor in the given Blender Text object
80         
81         Returns one of:
82           NORMAL - Cursor is in a normal context
83           SINGLE_QUOTE - Cursor is inside a single quoted string
84           DOUBLE_QUOTE - Cursor is inside a double quoted string
85           COMMENT - Cursor is inside a comment
86         
87         """
88         
89         l, cursor = txt.getCursorPos()
90         lines = txt.asLines()[:l+1]
91         
92         # Detect context (in string or comment)
93         in_str = 0                      # 1-single quotes, 2-double quotes
94         for line in lines:
95                 if l == 0:
96                         end = cursor
97                 else:
98                         end = len(line)
99                         l -= 1
100                 
101                 # Comments end at new lines
102                 if in_str == 3:
103                         in_str = 0
104                 
105                 for i in range(end):
106                         if in_str == 0:
107                                 if line[i] == "'": in_str = 1
108                                 elif line[i] == '"': in_str = 2
109                                 elif line[i] == '#': in_str = 3
110                         else:
111                                 if in_str == 1:
112                                         if line[i] == "'":
113                                                 in_str = 0
114                                                 # In again if ' escaped, out again if \ escaped, and so on
115                                                 for a in range(i-1, -1, -1):
116                                                         if line[a] == '\\': in_str = 1-in_str
117                                                         else: break
118                                 elif in_str == 2:
119                                         if line[i] == '"':
120                                                 in_str = 0
121                                                 # In again if " escaped, out again if \ escaped, and so on
122                                                 for a in range(i-1, -1, -1):
123                                                         if line[i-a] == '\\': in_str = 2-in_str
124                                                         else: break
125                 
126         return in_str
127
128 def current_line(txt):
129         """Extracts the Python script line at the cursor in the Blender Text object
130         provided and cursor position within this line as the tuple pair (line,
131         cursor)"""
132         
133         (lineindex, cursor) = txt.getCursorPos()
134         lines = txt.asLines()
135         line = lines[lineindex]
136         
137         # Join previous lines to this line if spanning
138         i = lineindex - 1
139         while i > 0:
140                 earlier = lines[i].rstrip()
141                 if earlier.endswith('\\'):
142                         line = earlier[:-1] + ' ' + line
143                         cursor += len(earlier)
144                 i -= 1
145         
146         # Join later lines while there is an explicit joining character
147         i = lineindex
148         while i < len(lines)-1 and lines[i].rstrip().endswith('\\'):
149                 later = lines[i+1].strip()
150                 line = line + ' ' + later[:-1]
151                 i += 1
152         
153         return line, cursor
154
155 def get_targets(line, cursor):
156         """Parses a period separated string of valid names preceding the cursor and
157         returns them as a list in the same order."""
158         
159         targets = []
160         i = cursor - 1
161         while i >= 0 and (line[i].isalnum() or line[i] == '_' or line[i] == '.'):
162                 i -= 1
163         
164         pre = line[i+1:cursor]
165         return pre.split('.')
166
167 def get_imports(txt):
168         """Returns a dictionary which maps symbol names in the source code to their
169         respective modules.
170         
171         The line 'from Blender import Text as BText' for example, results in the
172         mapping 'BText' : <module 'Blender.Text' (built-in)>
173         
174         Note that this method imports the modules to provide this mapping as as such
175         will execute any initilization code found within.
176         """
177         
178         # Unfortunately, generate_tokens may fail if the script leaves brackets or
179         # strings open or there are other syntax errors. For now we return an empty
180         # dictionary until an alternative parse method is implemented.
181         try:
182                 tokens = cached_generate_tokens(txt)
183         except TokenError:
184                 return dict()
185         
186         imports = dict()
187         step = 0
188         
189         for type, string, start, end, line in tokens:
190                 store = False
191                 
192                 # Default, look for 'from' or 'import' to start
193                 if step == 0:
194                         if string == 'from':
195                                 tmp = []
196                                 step = 1
197                         elif string == 'import':
198                                 fromname = None
199                                 tmp = []
200                                 step = 2
201                 
202                 # Found a 'from', create fromname in form '???.???...'
203                 elif step == 1:
204                         if string == 'import':
205                                 fromname = '.'.join(tmp)
206                                 tmp = []
207                                 step = 2
208                         elif type == tokenize.NAME:
209                                 tmp.append(string)
210                         elif string != '.':
211                                 step = 0 # Invalid syntax
212                 
213                 # Found 'import', fromname is populated or None, create impname
214                 elif step == 2:
215                         if string == 'as':
216                                 impname = '.'.join(tmp)
217                                 step = 3
218                         elif type == tokenize.NAME:
219                                 tmp.append(string)
220                         elif string != '.':
221                                 impname = '.'.join(tmp)
222                                 symbol = impname
223                                 store = True
224                 
225                 # Found 'as', change symbol to this value and go back to step 2
226                 elif step == 3:
227                         if type == tokenize.NAME:
228                                 symbol = string
229                         else:
230                                 store = True
231                 
232                 # Both impname and symbol have now been populated so we can import
233                 if store:
234                         
235                         # Handle special case of 'import *'
236                         if impname == '*':
237                                 parent = get_module(fromname)
238                                 imports.update(parent.__dict__)
239                                 
240                         else:
241                                 # Try importing the name as a module
242                                 try:
243                                         if fromname:
244                                                 module = get_module(fromname +'.'+ impname)
245                                         else:
246                                                 module = get_module(impname)
247                                         imports[symbol] = module
248                                 except (ImportError, ValueError, AttributeError, TypeError):
249                                         # Try importing name as an attribute of the parent
250                                         try:
251                                                 module = __import__(fromname, globals(), locals(), [impname])
252                                                 imports[symbol] = getattr(module, impname)
253                                         except (ImportError, ValueError, AttributeError, TypeError):
254                                                 pass
255                         
256                         # More to import from the same module?
257                         if string == ',':
258                                 tmp = []
259                                 step = 2
260                         else:
261                                 step = 0
262         
263         return imports
264
265 def get_builtins():
266         """Returns a dictionary of built-in modules, functions and variables."""
267         
268         return __builtin__.__dict__
269
270 def get_defs(txt):
271         """Returns a dictionary which maps definition names in the source code to
272         a list of their parameter names.
273         
274         The line 'def doit(one, two, three): print one' for example, results in the
275         mapping 'doit' : [ 'one', 'two', 'three' ]
276         """
277         
278         # See above for problems with generate_tokens
279         try:
280                 tokens = cached_generate_tokens(txt)
281         except TokenError:
282                 return dict()
283         
284         defs = dict()
285         step = 0
286         
287         for type, string, start, end, line in tokens:
288                 
289                 # Look for 'def'
290                 if step == 0:
291                         if string == 'def':
292                                 name = None
293                                 step = 1
294                 
295                 # Found 'def', look for name followed by '('
296                 elif step == 1:
297                         if type == tokenize.NAME:
298                                 name = string
299                                 params = []
300                         elif name and string == '(':
301                                 step = 2
302                 
303                 # Found 'def' name '(', now identify the parameters upto ')'
304                 # TODO: Handle ellipsis '...'
305                 elif step == 2:
306                         if type == tokenize.NAME:
307                                 params.append(string)
308                         elif string == ')':
309                                 defs[name] = params
310                                 step = 0
311                 
312         return defs
313
314 def get_vars(txt):
315         """Returns a dictionary of variable names found in the specified Text
316         object. This method locates all names followed directly by an equal sign:
317         'a = ???' or indirectly as part of a tuple/list assignment or inside a
318         'for ??? in ???:' block.
319         """
320         
321         # See above for problems with generate_tokens
322         try:
323                 tokens = cached_generate_tokens(txt)
324         except TokenError:
325                 return []
326         
327         vars = []
328         accum = [] # Used for tuple/list assignment
329         foring = False
330         
331         for type, string, start, end, line in tokens:
332                 
333                 # Look for names
334                 if string == 'for':
335                         foring = True
336                 if string == '=' or (foring and string == 'in'):
337                         vars.extend(accum)
338                         accum = []
339                         foring = False
340                 elif type == tokenize.NAME:
341                         accum.append(string)
342                 elif not string in [',', '(', ')', '[', ']']:
343                         accum = []
344                         foring = False
345                 
346         return vars