############################################################################### # Name: pycomp.py # # Purpose: Provides python autocompletion lists and calltips for the editor # # Author: Cody Precord # # Copyright: (c) 2007 Cody Precord # # License: wxWindows License # ############################################################################### """ Provides completion and calltip support for python documents. To provide the completion lists and calltips a mix of parsing and introspection is used to deduct the requested information. @summary: Python autocompletion support """ __author__ = "Cody Precord " __cvsid__ = "$Id: pycomp.py 70229 2012-01-01 01:27:10Z CJP $" __revision__ = "$Revision: 70229 $" #--------------------------------------------------------------------------# # Dependancies import os import sys import time import tokenize import types from token import NAME, DEDENT, STRING import wx from wx.py import introspect from StringIO import StringIO # Local imports import completer #--------------------------------------------------------------------------# class Completer(completer.BaseCompleter): """Python Code completion provider""" def __init__(self, stc_buffer): """Initializes the completer @param stc_buffer: buffer that contains code """ super(Completer, self).__init__(stc_buffer) # Setup self.SetAutoCompKeys([ord('.'), ]) self.SetAutoCompStops(' \'"\\`):') self.SetAutoCompFillups('.,;([]}<>%^&+-=*/|') self.SetCallTipKeys([ord('('), ]) self.SetCallTipCancel([ord(')'), wx.WXK_RETURN]) # Needed for introspect to run try: sys.ps1 except AttributeError: sys.ps1 = '>>>' try: sys.ps2 except AttributeError: sys.ps2 = '...' def _GetCompletionInfo(self, command, calltip=False): """Get Completion list or Calltip @return: list or string """ if command is None or (len(command) and command[0].isdigit()): if calltip: return u"" return list() try: cmpl = PyCompleter() # Put the files directory on the path so eval has a better # chance of getting the proper completions fname = self._buffer.GetFileName() if fname: fpath = os.path.dirname(fname) sys.path.insert(0, fpath) snapshot = list(sys.modules.keys()) t1 = time.time() cmpl.evalsource(self._buffer.GetText(), self._buffer.GetCurrentLine()) dbg("[pycomp][info] Completion eval time: %f" % (time.time() - t1)) if fname: sys.path.pop(0) # Dump any other modules that got brought in during eval # so that they get properly updated on next pass through. nsnapshot = sys.modules.keys() nimport = list(set(nsnapshot).difference(set(snapshot))) for k in nimport: del sys.modules[k] if calltip: return cmpl.get_completions(command + u'(', u'', calltip) else: # Get Auto-completion List complst = cmpl.get_completions(command) sigs = list() tmap = {"function" : completer.TYPE_FUNCTION, "method" : completer.TYPE_METHOD, "class" : completer.TYPE_CLASS, "attribute" : completer.TYPE_ATTRIBUTE, "property" : completer.TYPE_PROPERTY} for sig in complst: word = sig['word'].rstrip(u'(.') tval = tmap.get(sig['type'], completer.TYPE_UNKNOWN) sigs.append(completer.Symbol(word, tval)) sigs.sort(key=lambda x: x.Name.upper()) return sigs except BaseException, msg: self._log("[pycomp][err] _GetCompletionInfo: %s, %s" % \ (sys.exc_info()[0], sys.exc_info()[1])) if calltip: return u"" else: return list() def GetAutoCompList(self, command): """Returns the list of possible completions for a command string. @param command: command lookup is done on """ return self._GetCompletionInfo(command) def GetCallTip(self, command): """Returns the formatted calltip string for the command. @param command: command to get calltip for """ # get the relevant text alltext = self._GetCompletionInfo(command, calltip=True) # split the text into natural paragraphs (a blank line separated) paratext = alltext.split("\n\n") # add text by paragraph until text limit or all paragraphs textlimit = 800 if len(paratext[0]) < textlimit: numpara = len(paratext) calltiptext = paratext[0] ii = 1 while ii < numpara and \ (len(calltiptext) + len(paratext[ii])) < textlimit: calltiptext = calltiptext + "\n\n" + paratext[ii] ii = ii + 1 # if not all texts are added, add "[...]" if ii < numpara: calltiptext = calltiptext + "\n[...]" # present the function signature only (first newline) else: calltiptext = alltext.split("\n")[0] if type(calltiptext) != types.UnicodeType: # Ensure it is unicode try: stcbuff = self.GetBuffer() encoding = stcbuff.GetEncoding() calltiptext = calltiptext.decode(encoding) except Exception, msg: dbg("%s" % msg) return calltiptext #-----------------------------------------------------------------------------# # This code below is a modified and adapted version of the pythoncomplete # Omni completion script for vim. The original vimscript can be found at the # following address: http://www.vim.org/scripts/script.php?script_id=1542 def dbg(msg): wx.GetApp().GetLog()(msg) class PyCompleter(object): """Python code completion provider""" def __init__(self): self.compldict = {} self.parser = PyParser() def evalsource(self, text, line=0): """Evaluate source for introspection @param text: Text to evaluate @keyword line: current line of cursor """ scope = self.parser.parse(text.replace('\r\n', '\n'), line) src = scope.get_code() # Test # f = open('pycompout.py', 'w') # f.write(src) # f.close() # NOTE: keep commented out in production code can cause slow down # when running in editra with DEBUG output. # dbg("[pycomp][info] Generated source: %s" % src) try: exec src in self.compldict except Exception, msg: dbg("[pycomp][err] src exec: %s" % msg) else: dbg("[pycomp][info] Successfully executed source code") for loc in [ l[1] for l in scope.locals]: try: exec loc in self.compldict except Exception, msg: dbg("[pycomp][err] local exec %s [%s]" % (msg, loc)) else: dbg("[pycomp][info] Successfully executed: %s" % loc) def get_arguments(self, func_obj): """Get the arguments of a given function obj @param func_obj: function object to get parameters for """ def _ctor(obj): """Get the constructor for an object""" try: return obj.__init__.im_func except AttributeError: for base in obj.__bases__: constructor = getattr(base, '__init__', None) if constructor is not None: return constructor return None arg_offset = 1 if type(func_obj) == types.ClassType: func_obj = _ctor(func_obj) elif type(func_obj) == types.MethodType: func_obj = func_obj.im_func else: arg_offset = 0 arg_text = '' if type(func_obj) in [types.FunctionType, types.LambdaType]: try: fcode = func_obj.func_code real_args = fcode.co_varnames[arg_offset:fcode.co_argcount] defaults = func_obj.func_defaults or '' defaults = [ "=%s" % name for name in defaults ] defaults = [""] * (len(real_args) - len(defaults)) + defaults items = [ arg + default for arg, default in zip(real_args, defaults) ] if fcode.co_flags & 0x4: items.append("...") if fcode.co_flags & 0x8: items.append("***") arg_text = (','.join(items)) + ')' except Exception, msg: dbg("[pycomp][err] get_arguments: %s" % msg) if len(arg_text) == 0: # The doc string sometimes contains the function signature # this works for alot of C modules that are part of the # standard library doc = getattr(func_obj, '__doc__', False) if doc: doc = str(doc) doc = doc.lstrip() pos = doc.find('\n') if pos > 0: sigline = doc[:pos] lidx = sigline.find('(') ridx = sigline.find(')') if lidx > 0 and ridx > 0: arg_text = sigline[lidx + 1 : ridx] + ')' if len(arg_text) == 0: arg_text = ')' return arg_text def get_completions(self, context, match='', ctip=False): """Get the completions for the given context @param context: command string to get completions for @keyword match: for matching an incomplete command string @keyword ctip: Get a calltip for the context instead of completion list @return: list of dictionaries """ dbg("[pycomp] get_completions('%s','%s')" % (context, match)) stmt = context + match try: result = None compdict = {} ridx = stmt.rfind('.') if len(stmt) > 0 and stmt[-1] == '(': if ctip: # Try introspect.getCallTip since it is generally # better at getting tips for c modules tip = introspect.getCallTip(_sanitize(stmt), self.compldict)[2] if not isinstance(tip, basestring): tip = u"" if not tip: # Internal calltip code result = eval(_sanitize(stmt.rstrip('(')), self.compldict) doc = max(getattr(result, '__doc__', ''), ' ') argval = context + _cleanstr(self.get_arguments(result)) tip = '\n'.join([argval, _cleanstr(doc)]) dbg("[pycomp][info] Used internal calltips") return tip elif ridx == -1: match = stmt compdict = self.compldict else: match = stmt[ridx+1:] stmt = _sanitize(stmt[:ridx]) result = eval(stmt, self.compldict) compdict = dir(result) dbg("[pycomp] completing: stmt:%s" % stmt) completions = [] isdict = isinstance(compdict, dict) for meth in compdict: if meth == "_PyCmplNoType": continue #this is internal try: # dbg('[pycomp] possible completion: %s' % meth) if meth.find(match) == 0: if result is None: # NOTE: when result is none compdict is a list inst = meth #compdict[meth] else: inst = getattr(result, meth, None) # TODO: necessary check to handle some odd swig related # errors. Find out why type 'swigvarlink' causes # the exception Unknown C global variable. if len(dir(inst)): doc = getattr(inst, '__doc__', None) if doc is None: doc = max(getattr(result, '__doc__', ' '), ' ') else: doc = ' ' if isdict: typestr = str(compdict[inst]) else: typestr = str(inst) comp = {'word' : meth, 'abbr' : meth, 'info' : _cleanstr(str(doc)), 'type' : typestr} if "function" in typestr: comp['word'] += '(' comp['abbr'] += '(' + _cleanstr(self.get_arguments(inst)) comp['type'] = "function" elif "method" in typestr or "slot wrapper" in typestr: comp['word'] += '(' comp['abbr'] += '(' + _cleanstr(self.get_arguments(inst)) comp['type'] = "method" elif "module" in typestr: comp['word'] += '.' comp['type'] = "module" elif "class" in typestr: comp['word'] += '(' comp['abbr'] += '(' comp['type'] = "class" elif "attribute" in typestr or \ (not typestr.startswith('__') and \ not typestr.startswith('<')): comp['type'] = "attribute" elif "property" in typestr: comp['type'] = "property" # else: # print typestr, meth completions.append(comp) except Exception, msg: dbg("[pycomp][err] inner completion: %s [stmt='%s']:" % (msg, stmt)) return completions except Exception, msg: dbg("[pycomp][err] get_completions: %s [stmt='%s']" % (msg, stmt)) if ctip: return u"" return list() #-----------------------------------------------------------------------------# # Code objects class Scope(object): """Base class for representing code objects""" def __init__(self, name, indent): """Initialize the scope @param name: name of this object @param indent: the indentation/level of this scope """ super(Scope, self).__init__() # Attributes self.subscopes = list() self.docstr = u'' self.locals = list() self.parent = None self.name = name self.indent = indent self.objid = -1 # Tracks order of declaration DocStr = property(lambda self: self.docstr, lambda self, dstr: setattr(self, 'docstr', dstr)) Locals = property(lambda self: self.locals, lambda self, loc: setattr(self, 'locals', loc)) Parent = property(lambda self: self.parent, lambda self, parent: setattr(self, 'parent', parent)) def Clone(self, indent=0): """Clone this scope object""" obj = Scope(self.name, indent) obj.DocStr = self.DocStr obj.Locals = list(self.Locals) obj.Parent = self.Parent for scope in self.subscopes: obj.subscopes.append((scope[0], scope[1].Clone(indent + 1))) obj.objid = self.objid return obj def NextObjId(self): self.objid += 1 return self.objid def add(self, sub): """Push a subscope into this scope @param sub: sub scope to push """ sub.parent = self self.subscopes.append((self.NextObjId(), sub)) return sub def doc(self, docstr): """Format and set the doc string for this scope @param docstr: Docstring to format and set """ dstr = docstr if len(dstr): while len(dstr) and dstr[0] in '"\' ': dstr = dstr[1:] while len(dstr) and dstr[-1] in '"\' ': dstr = dstr[:-1] dstr = '\n'.join([d.lstrip() for d in dstr.split('\n')]) self.docstr = dstr.rstrip() def local(self, loc): """Add an object to the scopes locals @param loc: local object to add to locals """ self._checkexisting(loc) self.locals.append((self.NextObjId(), loc)) def _checkexisting(self, test): """Convenience function... keep out duplicates @param test: assignment statement to check for existence of variable in the scopes locals """ if '=' in test: var = test.split('=')[0].strip() for loc in self.locals: if '=' in loc[1] and var == loc[1].split('=')[0].strip(): self.locals.remove(loc) def get_code(self): """Get a string of code that represents this scope @return: string """ cstr = '"""' + self.docstr + '"""\n' nonimport = list() for loc in self.locals: if loc[1].startswith('import') or loc[1].startswith('from'): cstr += ("try:\n %s\nexcept ImportError:\n pass\n" % loc[1]) else: nonimport.append(loc) # we need to start with this, to fix up broken completions # hopefully this name is unique enough... cstr += 'class _PyCmplNoType:\n def __getattr__(self,name):\n return None\n' decls = self.subscopes + nonimport decls.sort(key=lambda x: x[0]) for decl in [d[1] for d in decls]: if isinstance(decl, Scope): cstr += decl.get_code() else: cstr += decl + '\n' return cstr def pop(self, indent): """Pop the scope until it is at the level of the given indent. @param indent: indent level to pop scope to @return: scope of given indent level """ outer = self while outer.parent != None and outer.indent >= indent: outer = outer.parent return outer def currentindent(self): """Return string of current scopes indent level @return: string of spaces """ return ' ' * self.indent def childindent(self): """Return string the next scopes indentation level @return: string of spaces """ return ' ' * (self.indent + 1) class Class(Scope): """Class for representing a python class object for the parser""" def __init__(self, name, supers, indent): """initialize the class object @param name: name of class @param supers: classes super classes @param indent: scope of indentation """ super(Class, self).__init__(name, indent) self.supers = supers def Clone(self, indent=0): """Create a clone of this object""" obj = Class(self.name, self.supers, indent) obj.DocStr = self.DocStr obj.Locals = list(self.Locals) obj.Parent = self.Parent obj.objid = self.objid for scope in self.subscopes: obj.subscopes.append((scope[0], scope[1].Clone(indent + 1))) return obj def get_code(self): """Get the code string representation of the Class object @return: string """ cstr = '%sclass %s' % (self.currentindent(), self.name) if len(self.supers) > 0: cstr += '(%s)' % ','.join(self.supers) cstr += ':\n' if len(self.docstr) > 0: cstr += self.childindent() + '"""' + self.docstr + '"""\n' need_pass = True decls = self.locals + self.subscopes decls.sort(key=lambda x: x[0]) for decl in [d[1] for d in decls]: need_pass = False if isinstance(decl, Scope): cstr += decl.get_code() else: cstr += ('%s%s\n' % (self.childindent(), decl)) if need_pass: cstr += '%spass\n' % self.childindent() return cstr def local(self, loc): """Add an object to the scopes locals @param loc: local object to add to locals """ if loc and '.' not in loc: super(Class, self).local(loc) class Function(Scope): """Create a function object for representing a python function definition in the parser. """ def __init__(self, name, params, indent): """Create the function object @param name: name of function @param params: the functions parameters @param indent: indentation level of functions declaration (scope) """ super(Function, self).__init__(name, indent) self.params = params def Clone(self, indent=0): """Create a copy of the functions declaration at the given scope of indentation. @keyword indent: indentation level of the declaration """ obj = Function(self.name, self.params, indent) obj.DocStr = self.DocStr obj.Locals = list(self.Locals) obj.Parent = self.Parent for scope in self.subscopes: obj.subscopes.append((scope[0], scope[1].Clone(indent + 1))) obj.objid = self.objid return obj def get_code(self): """Get code string representation of the function object @return: string """ cstr = "%sdef %s(%s):\n" % \ (self.currentindent(), self.name, ','.join(self.params)) if len(self.docstr) > 0: cstr += self.childindent() + '"""' + self.docstr + '"""\n' cstr += "%spass\n" % self.childindent() return cstr #-----------------------------------------------------------------------------# # Main Parser class PyParser(object): """Python parsing class""" def __init__(self): """Initialize and create the PyParser""" super(PyParser, self).__init__() # Attributes self.top = Scope('global', 0) self.scope = self.top self.gen = None # Token Generator self.curline = 0 # Current parse line self.currentscope = None # Current scope in parse def _parsedotname(self, pre=None): """Parse a dotted name string @return: tuple of (dottedname, nexttoken) """ name = list() if pre is None: tokentype, token = self.next()[:2] if token == '(': self._parenparse() if tokentype != NAME and token != '*': return ('', token) else: token = pre name.append(token) while True: tokentype, token = self.next()[:2] if token == '(': # Handle 'self.foo(a,b,c)' self._parenparse() break if token != '.': break tokentype, token = self.next()[:2] if tokentype != NAME: break name.append(token) return (".".join(name), token) def _parseimportlist(self): """Parse and collect import statements @return: list of imports """ imports = [] while True: name, token = self._parsedotname() if not name: break name2 = '' if token == 'as': name2, token = self._parsedotname() imports.append((name, name2)) while token != "," and "\n" not in token: token = self.next()[1] if token != ",": break return imports def _parenparse(self): """Parse paren enclosed statement @return: list of named items enclosed in the parens """ name = '' names = [] level = 1 while True: token = self.next()[1] if token in (')', ',') and level == 1: # Remove keyword assignments as they can # cause eval breakage when using undefined # vars. if '=' in name: name = name.split('=')[0].strip() names.append(name) name = '' if token == '(': level += 1 elif token == ')': level -= 1 if level == 0: break elif token == ',' and level == 1: pass else: name += str(token) return names def _parsefunction(self, indent): """Parse a function definition at the given scope of indentation and create a class token object from the results. @param indent: scope of functions declaration """ self.scope = self.scope.pop(indent) tokentype, fname, findent = self.next() if tokentype != NAME: return None tokentype, open_paren, tindent = self.next() if open_paren != '(': return None params = self._parenparse() tokentype, colon, tindent = self.next() if colon != ':': return None return Function(fname, params, indent) def _parseclass(self, indent): """Parse a class definition at the given scope of indentation and create a class token object from the results. @param indent: scope of classes declaration """ self.scope = self.scope.pop(indent) tokentype, cname = self.next()[:-1] if tokentype != NAME: return None super_cls = [] tokentype, next = self.next()[:-1] if next == '(': super_cls = self._parenparse() elif next != ':': return None return Class(cname, super_cls, indent) def _parseassignment(self): """Parse a variable assignment to resolve the variables type for introspection. @return: string of objects type """ assign = '' tokentype, token = self.next()[:-1] tokens = { # Dict '{' : '{}', 'dict' : '{}', 'locals' : '{}', 'globals' : '{}', # File 'open' : 'file', 'file' : 'file', # List '[' : '[]', 'list' : '[]', 'dir' : '["",""]', 'zip' : '[]', 'map' : '[]', 'sorted' : '[]', 'range' : '[]', # NoneType 'None' : '_PyCmplNoType()', # Number tokenize.NUMBER : '0', 'ord' : '0', 'id' : '0', 'abs' : '0', 'sum' : '0', 'pow' : '0', 'len' : '0', 'hash' : '0', # Property 'property' : 'property()', # String tokenize.STRING : '""', 'str' : '""', 'repr' : '""', 'chr' : '""', 'unichr' : '""', 'hex' : '""', 'oct' : '""', "'" : '""', '"' : '""', # Type 'type' : 'type(_PyCmplNoType)', # Tuple '(' : '()', 'tuple' : '()', 'coerce' : '()' } if tokentype == tokenize.NUMBER or tokentype == tokenize.STRING: return token #token = tokentype if token in tokens: return tokens[token] else: # NOTE: This part of the parse is where the problem is happening assign += token level = 0 while True: tokentype, token = self.next()[:-1] if token in ('(', '{', '['): level = level + 1 elif token in (']', '}', ')'): level = level - 1 if level == 0 or level == -1: break elif level == 0: # end of line if token in (';', '\n', '='): break elif token in ('+', '*'): # Attempt simple type deduction based on # operator statement. if assign.endswith('"') or assign.endswith("'"): assign = '""' else: assign = '0' break elif token in ('/', '-'): # assignment most likely returns an digit type # this is the best guess we can make assign = '0' break elif token in ('and', 'or', 'in', '==', '<', '>', '!=', 'not', '>=', '<='): # right side of assignment likely evaluates to bool assign = 'bool' break # elif token == ',': # assign = '' else: if token == '.' or assign.endswith('.'): assign += token else: assign += (" %s" % token) # Check syntax to filter out bad tokens try: compile(assign, '_pycomp', 'eval') except: dbg("[pycomp][err] parseassignment bad token: %s" % assign) return '_PyCmplNoType()' else: return assign def next(self): """Get tokens of next line in parse @return: tuple of (type, token, indent) """ ttype, token, (lineno, indent) = self.gen.next()[:3] if lineno == self.curline: self.currentscope = self.scope return (ttype, token, indent) def _adjustvisibility(self): """Adjust the visibility of the current contexts scope @return: current scope """ newscope = Scope('result', 0) scopes = list() tscp = self.currentscope.parent while tscp != None: scopes.append(tscp) tscp = tscp.parent scopes.append(self.currentscope) for scp in scopes: if type(scp) == Function: cut = 0 # Handle 'self' params if scp.parent != None and type(scp.parent) == Class: cut = 1 params = scp.params[0] ind = params.find('=') if ind != -1: params = params[:ind] newscope.local('%s = %s' % (_sanitizeParam(scp.params[0]), scp.parent.name)) for param in scp.params[cut:]: ind = param.find('=') if len(param) == 0: continue if ind == -1: newscope.local('%s = _PyCmplNoType()' % _sanitizeParam(param)) else: newscope.local('%s = %s' % (_sanitizeParam(param[:ind]), _sanitize(param[ind+1:]))) decls = scp.subscopes + scp.locals decls.sort(key=lambda x: x[0]) for decl in [d[1] for d in decls]: if isinstance(decl, Scope): newscope.add(decl.Clone(0)) else: newscope.local(decl) self.currentscope = newscope return self.currentscope def parse(self, text, curline=0): """Parse the given text @param text: python code text to parse @keyword curline: current line of cursor for context """ self.curline = curline buf = StringIO(text) self.gen = tokenize.generate_tokens(buf.readline) self.currentscope = self.scope try: freshscope = True while True: tokentype, token, indent = self.next() if tokentype == DEDENT or token == "pass": self.scope = self.scope.pop(indent) elif token == 'def': func = self._parsefunction(indent) if func == None: continue freshscope = True self.scope = self.scope.add(func) elif token == 'class': cls = self._parseclass(indent) if cls == None: continue freshscope = True self.scope = self.scope.add(cls) elif token == 'import': imports = self._parseimportlist() for mod, alias in imports: loc = "import %s" % mod if len(alias) > 0: loc += " as %s" % alias self.scope.local(loc) freshscope = False elif token == 'from': mod, token = self._parsedotname() if not mod or token != "import": continue names = self._parseimportlist() for name, alias in names: loc = "from %s import %s" % (mod, name) if len(alias) > 0: loc += " as %s" % alias self.scope.local(loc) freshscope = False elif tokentype == STRING: if freshscope: self.scope.doc(token) elif tokentype == NAME: name, token = self._parsedotname(token) if token == '=': stmt = self._parseassignment() dbg("[pycomp] parseassignment: %s = %s" % (name, stmt)) if stmt != None: # XXX Safety Check don't allow assignments to # item attributes unless the attribute is self if u'.' not in name or name.startswith('self.'): self.scope.local("%s = %s" % (name, stmt)) freshscope = False except StopIteration: #thrown on EOF pass except: dbg("[pycomp][err] Pyparser.parse: %s, %s" % (sys.exc_info()[0], sys.exc_info()[1])) return self._adjustvisibility() #-----------------------------------------------------------------------------# # Utility Functions def _cleanstr(doc): """Clean up a docstring by removing quotes @param doc: docstring to clean up """ return doc.replace('"', ' ').replace("'", ' ') def _sanitize(cstr): """Sanitize a command string for namespace lookup @param cstr: command string to cleanup """ val = '' level = 0 for char in cstr: if char in ('(', '{', '['): level += 1 elif char in (']', '}', ')'): level -= 1 elif level == 0: val += char return val def _sanitizeParam(param): """Cleanup a value string value = foo to ensure that 'value' is syntactically correct. """ for i, c in enumerate(param): if c.isalpha() or c == '_': return param[i:] else: return param # something is wrong so let it bomb