"""Refactoring methods for elpy. This interfaces directly with rope, regardless of the backend used, because the other backends don't really offer refactoring choices. Once Jedi is similarly featureful as Rope we can try and offer both. # Too complex: - Restructure: Interesting, but too complex, and needs deep Rope knowledge to do well. - ChangeSignature: Slightly less complex interface, but still to complex, requiring a large effort for the benefit. # Too useless: I could not get these to work in any useful fashion. I might be doing something wrong. - ExtractVariable does not replace the code extracted with the variable, making it a glorified copy&paste function. Emacs can do better than this interface by itself. - EncapsulateField: Getter/setter methods are outdated, this should be using properties. - IntroduceFactory: Inserts a trivial method to the current class. Cute. - IntroduceParameter: Introduces a parameter correctly, but does not replace the old code with the parameter. So it just edits the argument list and adds a shiny default. - LocalToField: Seems to just add "self." in front of all occurrences of a variable in the local scope. - MethodObject: This turns the current method into a callable class/object. Not sure what that would be good for. # Can't even get to work: - ImportOrganizer expand_star_imports, handle_long_imports, relatives_to_absolutes: Seem not to do anything. - create_move: I was not able to figure out what it would like to see as its attrib argument. """ import os from elpy.rpc import Fault try: from rope.base.exceptions import RefactoringError from rope.base.project import Project from rope.base.libutils import path_to_resource from rope.base import change as rope_change from rope.base import worder from rope.refactor.importutils import ImportOrganizer from rope.refactor.topackage import ModuleToPackage from rope.refactor.rename import Rename from rope.refactor.move import create_move from rope.refactor.inline import create_inline from rope.refactor.extract import ExtractMethod from rope.refactor.usefunction import UseFunction ROPE_AVAILABLE = True except ImportError: ROPE_AVAILABLE = False def options(description, **kwargs): """Decorator to set some options on a method.""" def set_notes(function): function.refactor_notes = {'name': function.__name__, 'category': "Miscellaneous", 'description': description, 'doc': getattr(function, '__doc__', ''), 'args': []} function.refactor_notes.update(kwargs) return function return set_notes class Refactor(object): """The main refactoring interface. Once initialized, the first call should be to get_refactor_options to get a list of refactoring options at a given position. The returned value will also list any additional options required. Once you picked one, you can call get_changes to get the actual refactoring changes. """ def __init__(self, project_root, filename): self.project_root = project_root if not ROPE_AVAILABLE: raise Fault('rope not installed, cannot refactor code.', code=400) if not os.path.exists(project_root): raise Fault( "cannot do refactoring without a local project root", code=400 ) self.project = Project(project_root, ropefolder=None) self.resource = path_to_resource(self.project, filename) def get_refactor_options(self, start, end=None): """Return a list of options for refactoring at the given position. If `end` is also given, refactoring on a region is assumed. Each option is a dictionary of key/value pairs. The value of the key 'name' is the one to be used for get_changes. The key 'args' contains a list of additional arguments required for get_changes. """ result = [] for symbol in dir(self): if not symbol.startswith("refactor_"): continue method = getattr(self, symbol) if not method.refactor_notes.get('available', True): continue category = method.refactor_notes['category'] if end is not None and category != 'Region': continue if end is None and category == 'Region': continue is_on_symbol = self._is_on_symbol(start) if not is_on_symbol and category in ('Symbol', 'Method'): continue requires_import = method.refactor_notes.get('only_on_imports', False) if requires_import and not self._is_on_import_statement(start): continue result.append(method.refactor_notes) return result def _is_on_import_statement(self, offset): "Does this offset point to an import statement?" data = self.resource.read() bol = data.rfind("\n", 0, offset) + 1 eol = data.find("\n", 0, bol) if eol == -1: eol = len(data) line = data[bol:eol] line = line.strip() if line.startswith("import ") or line.startswith("from "): return True else: return False def _is_on_symbol(self, offset): "Is this offset on a symbol?" if not ROPE_AVAILABLE: return False data = self.resource.read() if offset >= len(data): return False if data[offset] != '_' and not data[offset].isalnum(): return False word = worder.get_name_at(self.resource, offset) if word: return True else: return False def get_changes(self, name, *args): """Return a list of changes for the named refactoring action. Changes are dictionaries describing a single action to be taken for the refactoring to be successful. A change has an action and possibly a type. In the description below, the action is before the slash and the type after it. change: Change file contents - file: The path to the file to change - contents: The new contents for the file - Diff: A unified diff showing the changes introduced create/file: Create a new file - file: The file to create create/directory: Create a new directory - path: The directory to create move/file: Rename a file - source: The path to the source file - destination: The path to the destination file name move/directory: Rename a directory - source: The path to the source directory - destination: The path to the destination directory name delete/file: Delete a file - file: The file to delete delete/directory: Delete a directory - path: The directory to delete """ if not name.startswith("refactor_"): raise ValueError("Bad refactoring name {0}".format(name)) method = getattr(self, name) if not method.refactor_notes.get('available', True): raise RuntimeError("Method not available") return method(*args) @options("Convert from x import y to import x.y as y", category="Imports", args=[("offset", "offset", None)], only_on_imports=True, available=ROPE_AVAILABLE) def refactor_froms_to_imports(self, offset): """Converting imports of the form "from ..." to "import ...".""" refactor = ImportOrganizer(self.project) changes = refactor.froms_to_imports(self.resource, offset) return translate_changes(changes) @options("Reorganize and clean up", category="Imports", available=ROPE_AVAILABLE) def refactor_organize_imports(self): """Clean up and organize imports.""" refactor = ImportOrganizer(self.project) changes = refactor.organize_imports(self.resource) return translate_changes(changes) @options("Convert the current module into a package", category="Module", available=ROPE_AVAILABLE) def refactor_module_to_package(self): """Convert the current module into a package.""" refactor = ModuleToPackage(self.project, self.resource) return self._get_changes(refactor) @options("Rename symbol at point", category="Symbol", args=[("offset", "offset", None), ("new_name", "string", "Rename to: "), ("in_hierarchy", "boolean", "Rename in super-/subclasses as well? "), ("docs", "boolean", "Replace occurences in docs and strings? ") ], available=ROPE_AVAILABLE) def refactor_rename_at_point(self, offset, new_name, in_hierarchy, docs): """Rename the symbol at point.""" try: refactor = Rename(self.project, self.resource, offset) except RefactoringError as e: raise Fault(str(e), code=400) return self._get_changes(refactor, new_name, in_hierarchy=in_hierarchy, docs=docs) @options("Rename current module", category="Module", args=[("new_name", "string", "Rename to: ")], available=ROPE_AVAILABLE) def refactor_rename_current_module(self, new_name): """Rename the current module.""" refactor = Rename(self.project, self.resource, None) return self._get_changes(refactor, new_name) @options("Move the current module to a different package", category="Module", args=[("new_name", "directory", "Destination package: ")], available=ROPE_AVAILABLE) def refactor_move_module(self, new_name): """Move the current module.""" refactor = create_move(self.project, self.resource) resource = path_to_resource(self.project, new_name) return self._get_changes(refactor, resource) @options("Inline function call at point", category="Symbol", args=[("offset", "offset", None), ("only_this", "boolean", "Only this occurrence? ")], available=ROPE_AVAILABLE) def refactor_create_inline(self, offset, only_this): """Inline the function call at point.""" refactor = create_inline(self.project, self.resource, offset) if only_this: return self._get_changes(refactor, remove=False, only_current=True) else: return self._get_changes(refactor, remove=True, only_current=False) @options("Extract current region as a method", category="Region", args=[("start", "start_offset", None), ("end", "end_offset", None), ("name", "string", "Method name: "), ("make_global", "boolean", "Create global method? ")], available=ROPE_AVAILABLE) def refactor_extract_method(self, start, end, name, make_global): """Extract region as a method.""" refactor = ExtractMethod(self.project, self.resource, start, end) return self._get_changes( refactor, name, similar=True, global_=make_global ) @options("Use the function at point wherever possible", category="Method", args=[("offset", "offset", None)], available=ROPE_AVAILABLE) def refactor_use_function(self, offset): """Use the function at point wherever possible.""" try: refactor = UseFunction(self.project, self.resource, offset) except RefactoringError as e: raise Fault( 'Refactoring error: {}'.format(e), code=400 ) return self._get_changes(refactor) def _get_changes(self, refactor, *args, **kwargs): try: changes = refactor.get_changes(*args, **kwargs) except Exception as e: raise Fault("Error during refactoring: {}".format(e), code=400) return translate_changes(changes) def translate_changes(initial_change): """Translate rope.base.change.Change instances to dictionaries. See Refactor.get_changes for an explanation of the resulting dictionary. """ agenda = [initial_change] result = [] while agenda: change = agenda.pop(0) if isinstance(change, rope_change.ChangeSet): agenda.extend(change.changes) elif isinstance(change, rope_change.ChangeContents): result.append({'action': 'change', 'file': change.resource.real_path, 'contents': change.new_contents, 'diff': change.get_description()}) elif isinstance(change, rope_change.CreateFile): result.append({'action': 'create', 'type': 'file', 'file': change.resource.real_path}) elif isinstance(change, rope_change.CreateFolder): result.append({'action': 'create', 'type': 'directory', 'path': change.resource.real_path}) elif isinstance(change, rope_change.MoveResource): result.append({'action': 'move', 'type': ('directory' if change.new_resource.is_folder() else 'file'), 'source': change.resource.real_path, 'destination': change.new_resource.real_path}) elif isinstance(change, rope_change.RemoveResource): if change.resource.is_folder(): result.append({'action': 'delete', 'type': 'directory', 'path': change.resource.real_path}) else: result.append({'action': 'delete', 'type': 'file', 'file': change.resource.real_path}) return result class FakeResource(object): """A fake resource in case Rope is absence.""" def __init__(self, filename): self.real_path = filename def read(self): with open(self.real_path) as f: return f.read()