├── .gitignore ├── LICENSE ├── README.md ├── fastentrypoints.py ├── ropecli └── __init__.py ├── setup.py └── tests └── test_all.py /.gitignore: -------------------------------------------------------------------------------- 1 | .ropeproject/ 2 | *.pyc 3 | tags 4 | .clirope/ 5 | rope_cli.egg-info/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rope CLI 2 | ======== 3 | 4 | [Rope](https://github.com/python-rope/rope) is a Python refactoring library, 5 | with integrations into some IDEs. 6 | 7 | In order to make it easy to perform large refactorings without relying on an 8 | IDE, and to make refactorings easily repeatable, here is a CLI for performing 9 | these refactorings using rope. 10 | 11 | This can make it easier to correctly rebase refactoring commits on top of new 12 | changes, as well as correctly rebasing changes on top of refactorings. 13 | 14 | This is ideal for use in conjunction with an automatic formatter like 15 | [black](https://black.readthedocs.io/en/stable/), to minimize any resulting 16 | formatting fixups. 17 | 18 | Installation 19 | ------------ 20 | 21 | ``` 22 | pip install git+https://github.com/aevri/ropecli.git 23 | ``` 24 | 25 | Summary 26 | ------- 27 | 28 | ``` 29 | Usage: rope [OPTIONS] COMMAND [ARGS]... 30 | 31 | A refactoring tool for Python programs. 32 | 33 | Built on the excellent 'rope' refactoring library, which powers the 34 | refactoring capabilities of a number of IDEs. 35 | 36 | Options: 37 | --help Show this message and exit. 38 | 39 | Commands: 40 | froms-to-imports Change the 'from X import Y' statements in PATH to... 41 | list List the global entities in PATH. 42 | move Move the global entry SOURCE to the TARGET_FILE. 43 | organize-imports Organize the import statements in PATH in an 44 | opinionated... 45 | rename Rename the global entry OLD_NAME in PATH to NEW_NAME. 46 | ``` 47 | 48 | 49 | Examples 50 | -------- 51 | 52 | Given two files, `vegetables.py:` 53 | ```python 54 | from sys import stderr 55 | import argparse 56 | 57 | 58 | def carrots(): 59 | print("carrots!", file=stderr) 60 | 61 | 62 | def tomatoes(): 63 | print("tomatoes!") 64 | 65 | 66 | def all(): 67 | carrots() 68 | tomatoes() 69 | ``` 70 | 71 | and `fruit.py:` 72 | 73 | ```python 74 | def cherries(): 75 | print("cherries!") 76 | ``` 77 | 78 | We can apply refactorings like the following. 79 | 80 | ### Convert 'from' imports to regular imports 81 | 82 | ```bash 83 | rope froms-to-imports vegetables.py 84 | ``` 85 | 86 | Resulting in this change: 87 | 88 | ```diff 89 | diff --git a/vegetables.py b/vegetables.py 90 | index 86d22fb..6e2b3c4 100644 91 | --- a/vegetables.py 92 | +++ b/vegetables.py 93 | @@ -1,9 +1,8 @@ 94 | -from sys import stderr 95 | -import argparse 96 | +import sys 97 | 98 | 99 | def carrots(): 100 | - print("carrots!", file=stderr) 101 | + print("carrots!", file=sys.stderr) 102 | 103 | 104 | def tomatoes(): 105 | ``` 106 | 107 | ### Move functions and classes 108 | 109 | ```bash 110 | rope move vegetables.py::tomatoes fruit.py 111 | black *.py 112 | ``` 113 | 114 | Note that we also use `black` here, to fix formatting, resulting in this 115 | change: 116 | 117 | ```diff 118 | diff --git a/vegetables.py b/vegetables.py 119 | index 86d22fb..ba5a551 100644 120 | --- a/vegetables.py 121 | +++ b/vegetables.py 122 | @@ -1,15 +1,11 @@ 123 | from sys import stderr 124 | -import argparse 125 | +import fruit 126 | 127 | 128 | def carrots(): 129 | print("carrots!", file=stderr) 130 | 131 | 132 | -def tomatoes(): 133 | - print("tomatoes!") 134 | - 135 | - 136 | def all(): 137 | carrots() 138 | - tomatoes() 139 | + fruit.tomatoes() 140 | diff --git a/fruit.py b/fruit.py 141 | index b423336..6416371 100644 142 | --- a/fruit.py 143 | +++ b/fruit.py 144 | @@ -1,2 +1,6 @@ 145 | +def tomatoes(): 146 | + print("tomatoes!") 147 | + 148 | + 149 | def cherries(): 150 | print("cherries!") 151 | ``` 152 | 153 | ### Move functions and classes with wildcards 154 | 155 | ```bash 156 | rope move vegetables.py::* fruit.py --exclude vegetables.py::tomatoes 157 | black *.py 158 | ``` 159 | 160 | Note that we also use `black` here, to fix formatting, resulting in this 161 | change: 162 | 163 | ```diff 164 | diff --git a/vegetables.py b/vegetables.py 165 | index 86d22fb..4561d71 100644 166 | --- a/vegetables.py 167 | +++ b/vegetables.py 168 | @@ -1,15 +1,2 @@ 169 | -from sys import stderr 170 | -import argparse 171 | - 172 | - 173 | -def carrots(): 174 | - print("carrots!", file=stderr) 175 | - 176 | - 177 | def tomatoes(): 178 | print("tomatoes!") 179 | - 180 | - 181 | -def all(): 182 | - carrots() 183 | - tomatoes() 184 | diff --git a/fruit.py b/fruit.py 185 | index b423336..383c1bc 100644 186 | --- a/fruit.py 187 | +++ b/fruit.py 188 | @@ -1,2 +1,15 @@ 189 | +from sys import stderr 190 | +from vegetables import tomatoes 191 | + 192 | + 193 | +def all(): 194 | + carrots() 195 | + tomatoes() 196 | + 197 | + 198 | +def carrots(): 199 | + print("carrots!", file=stderr) 200 | + 201 | + 202 | def cherries(): 203 | print("cherries!") 204 | ``` 205 | -------------------------------------------------------------------------------- /fastentrypoints.py: -------------------------------------------------------------------------------- 1 | # From https://github.com/ninjaaron/fast-entry_points 2 | 3 | # Copyright (c) 2016, Aaron Christianson 4 | # All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # 1. Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # 13 | # 2. Redistributions in binary form must reproduce the above copyright 14 | # notice, this list of conditions and the following disclaimer in the 15 | # documentation and/or other materials provided with the distribution. 16 | # 17 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 18 | # IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 19 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 20 | # PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | # HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 23 | # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | ''' 29 | Monkey patch setuptools to write faster console_scripts with this format: 30 | 31 | import sys 32 | from mymodule import entry_function 33 | sys.exit(entry_function()) 34 | 35 | This is better. 36 | 37 | (c) 2016, Aaron Christianson 38 | http://github.com/ninjaaron/fast-entry_points 39 | ''' 40 | from setuptools.command import easy_install 41 | import re 42 | TEMPLATE = '''\ 43 | # -*- coding: utf-8 -*- 44 | # EASY-INSTALL-ENTRY-SCRIPT: '{3}','{4}','{5}' 45 | __requires__ = '{3}' 46 | import re 47 | import sys 48 | 49 | from {0} import {1} 50 | 51 | if __name__ == '__main__': 52 | sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) 53 | sys.exit({2}())''' 54 | 55 | 56 | @classmethod 57 | def get_args(cls, dist, header=None): 58 | """ 59 | Yield write_script() argument tuples for a distribution's 60 | console_scripts and gui_scripts entry points. 61 | """ 62 | if header is None: 63 | header = cls.get_header() 64 | spec = str(dist.as_requirement()) 65 | for type_ in 'console', 'gui': 66 | group = type_ + '_scripts' 67 | for name, ep in dist.get_entry_map(group).items(): 68 | # ensure_safe_name 69 | if re.search(r'[\\/]', name): 70 | raise ValueError("Path separators not allowed in script names") 71 | script_text = TEMPLATE.format( 72 | ep.module_name, ep.attrs[0], '.'.join(ep.attrs), 73 | spec, group, name) 74 | args = cls._get_script_args(type_, name, header, script_text) 75 | for res in args: 76 | yield res 77 | 78 | 79 | easy_install.ScriptWriter.get_args = get_args 80 | 81 | 82 | def main(): 83 | import os 84 | import re 85 | import shutil 86 | import sys 87 | dests = sys.argv[1:] or ['.'] 88 | filename = re.sub('\.pyc$', '.py', __file__) 89 | 90 | for dst in dests: 91 | shutil.copy(filename, dst) 92 | manifest_path = os.path.join(dst, 'MANIFEST.in') 93 | setup_path = os.path.join(dst, 'setup.py') 94 | 95 | # Insert the include statement to MANIFEST.in if not present 96 | with open(manifest_path, 'a+') as manifest: 97 | manifest.seek(0) 98 | manifest_content = manifest.read() 99 | if not 'include fastentrypoints.py' in manifest_content: 100 | manifest.write(('\n' if manifest_content else '') 101 | + 'include fastentrypoints.py') 102 | 103 | # Insert the import statement to setup.py if not present 104 | with open(setup_path, 'a+') as setup: 105 | setup.seek(0) 106 | setup_content = setup.read() 107 | if not 'import fastentrypoints' in setup_content: 108 | setup.seek(0) 109 | setup.truncate() 110 | setup.write('import fastentrypoints\n' + setup_content) 111 | 112 | print(__name__) 113 | -------------------------------------------------------------------------------- /ropecli/__init__.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | """A command-line tool for refactoring Python programs.""" 3 | 4 | import ast 5 | import fnmatch 6 | import pathlib 7 | import sys 8 | 9 | import click 10 | import rope.base.libutils 11 | import rope.base.project 12 | import rope.refactor.importutils 13 | import rope.refactor.move 14 | import rope.refactor.rename 15 | 16 | 17 | # Note that we can easily support the following import refactors, along the 18 | # lines of 'organize_imports' and 'froms_to_imports': 19 | # 20 | # o: expand_stars 21 | # o: handle_long_imports 22 | # o: relatives_to_absolutes 23 | # o: sort_imports 24 | # 25 | 26 | # We can sort of implement command aliases in Click like so: 27 | # https://stackoverflow.com/questions/46641928/python-click-multiple-command-names 28 | 29 | 30 | @click.group() 31 | def main(): 32 | """A refactoring tool for Python programs. 33 | 34 | Built on the excellent 'rope' refactoring library, which powers the 35 | refactoring capabilities of a number of IDEs. 36 | """ 37 | pass 38 | 39 | 40 | @main.command() 41 | @click.argument("path", type=click.Path(exists=True, dir_okay=False)) 42 | def froms_to_imports(path): 43 | """Change the 'from X import Y' statements in PATH to 'import X.Y'. 44 | 45 | e.g. 46 | 47 | rope froms-to-imports mypackage/mymodule.py 48 | 49 | """ 50 | project = rope.base.project.Project(".", ropefolder=".clirope") 51 | 52 | resource = project.get_resource(path) 53 | pymodule = project.get_pymodule(resource) 54 | project.validate(resource) 55 | 56 | tools = rope.refactor.importutils.ImportTools(project) 57 | new_content = tools.froms_to_imports(pymodule) 58 | 59 | pathlib.Path(path).write_text(new_content) 60 | 61 | 62 | @main.command(name="list") 63 | @click.argument("path", type=click.Path(exists=True, dir_okay=False)) 64 | def list_command(path): 65 | """List the global entities in PATH. 66 | 67 | This will show things that might be used as arguments in invocations of 68 | other commands. 69 | """ 70 | # Note that if we called this function 'list', it would collide with the 71 | # built-in. 72 | project = rope.base.project.Project(".", ropefolder=".clirope") 73 | resource = project.get_resource(path) 74 | project.validate(resource) 75 | with open(path) as f: 76 | print_offsets(f) 77 | 78 | 79 | @main.command() 80 | @click.argument("source") 81 | @click.argument("target_file", type=click.Path(exists=True, dir_okay=False)) 82 | @click.option( 83 | "--glob/--no-glob", 84 | default=True, 85 | help="Apply globbing to the second part of 'source'.", 86 | ) 87 | @click.option( 88 | "--exclude", 89 | "-e", 90 | help="Exclude this from list of sources, can be specified multiple times. Globbing rules apply.", 91 | multiple=True, 92 | ) 93 | def move(source, target_file, glob, exclude): 94 | """Move the global entry SOURCE to the TARGET_FILE. 95 | 96 | All references to the entry will be adjusted to refer to the new location. 97 | 98 | e.g. 99 | 100 | \b 101 | # Move "MyClass" from modulea.py to moduleb.py 102 | rope move modulea.py::MyClass moduleb.py 103 | 104 | \b 105 | # Move all things that start with "Thingy" from modulea.py to thingy.py 106 | rope move modulea.py::Thingy* thingy.py 107 | 108 | \b 109 | # Move all things that start with "Thingy" from modulea.py to thingy.py, 110 | # except for "ThingyB": 111 | rope move modulea.py::Thingy* thingy.py --exclude *::ThingyB 112 | 113 | """ 114 | project = rope.base.project.Project(".", ropefolder=".clirope") 115 | 116 | source_list = [source] 117 | if glob: 118 | source_list = list(glob_resourcespec(source)) 119 | 120 | for pattern in exclude: 121 | old_source_list = source_list 122 | source_list = [] 123 | for current_source in old_source_list: 124 | if fnmatch.fnmatchcase(current_source, pattern): 125 | continue 126 | source_list.append(current_source) 127 | if len(old_source_list) == len(source_list): 128 | click.echo( 129 | f"Warning: exclude pattern '{pattern}' didn't match anything.", 130 | err=True, 131 | ) 132 | 133 | if not source_list: 134 | click.echo("Nothing to do.", err=True) 135 | elif len(source_list) == 1: 136 | do_move(project, source_list[0], target_file) 137 | else: 138 | with click.progressbar( 139 | source_list, file=sys.stderr, item_show_func=str 140 | ) as bar: 141 | for current_source in bar: 142 | do_move(project, current_source, target_file) 143 | 144 | 145 | def do_move(project, source, target_file): 146 | filefrom, offset = resourcespec_to_resource_offset(project, source) 147 | 148 | fileto = project.get_resource(target_file) 149 | 150 | mover = rope.refactor.move.create_move(project, filefrom, offset) 151 | changes = mover.get_changes(fileto) 152 | project.do(changes) 153 | 154 | 155 | def resourcespec_to_resource_offset(project, resourcespec): 156 | if "::" in resourcespec: 157 | file_path, module_item = resourcespec.split("::") 158 | else: 159 | file_path = resourcespec 160 | module_item = None 161 | file_resource = project.get_resource(file_path) 162 | 163 | if module_item is not None: 164 | with open(file_path) as f: 165 | offset = get_offset_in_file(f, module_item) 166 | else: 167 | offset = None 168 | 169 | return file_resource, offset 170 | 171 | 172 | def glob_resourcespec(resourcespec): 173 | if "::" not in resourcespec: 174 | raise ValueError(f"'{resourcespec}' does not contain '::'") 175 | file_path, pattern = resourcespec.split("::") 176 | for name in yield_module_path_itemnames(file_path, only_toplevel=True): 177 | if fnmatch.fnmatchcase(name, pattern): 178 | yield f"{file_path}::{name}" 179 | 180 | 181 | @main.command() 182 | @click.argument("path", type=click.Path(exists=True, dir_okay=False)) 183 | def organize_imports(path): 184 | """Organize the import statements in PATH in an opinionated way. 185 | 186 | In particular; unused or duplicate imports will be dropped, imports will be 187 | sorted and grouped, and the standard import group will appear first. 188 | 189 | e.g. 190 | 191 | rope organize_imports mypackage/mymodule.py 192 | 193 | """ 194 | project = rope.base.project.Project(".", ropefolder=".clirope") 195 | 196 | resource = project.get_resource(path) 197 | pymodule = project.get_pymodule(resource) 198 | project.validate(resource) 199 | 200 | tools = rope.refactor.importutils.ImportTools(project) 201 | new_content = tools.organize_imports(pymodule) 202 | 203 | pathlib.Path(path).write_text(new_content) 204 | 205 | 206 | @main.command() 207 | @click.argument("target") 208 | @click.argument("new_name") 209 | @click.option("--dry-run/--no-dry-run", default=False) 210 | def rename(target, new_name, dry_run): 211 | """Rename the global entry TARGET to NEW_NAME. 212 | 213 | All references to the entry will be adjusted to refer to the new name, 214 | including in documentation. 215 | 216 | e.g. 217 | 218 | rope rename modulea.py::MyClass MyAwesomeClass 219 | 220 | or 221 | 222 | rope rename modulea.py moduleb 223 | 224 | """ 225 | project = rope.base.project.Project(".", ropefolder=".clirope") 226 | 227 | resource, offset = resourcespec_to_resource_offset(project, target) 228 | 229 | def very_sure(_): 230 | return True 231 | 232 | renamer = rope.refactor.rename.Rename(project, resource, offset) 233 | changes = renamer.get_changes(new_name, docs=True, unsure=very_sure) 234 | 235 | if dry_run: 236 | print(changes.get_description()) 237 | else: 238 | project.do(changes) 239 | 240 | 241 | def print_offsets(file_): 242 | for name, offset in yield_name_offsets(file_): 243 | print("{offset: >7,} {name}".format(name=name, offset=offset)) 244 | 245 | 246 | def get_offset_in_file(file_, target_name): 247 | for name, offset in yield_name_offsets(file_): 248 | if name == target_name: 249 | return offset 250 | raise KeyError(f"'{target_name}' does not exist in the supplied file.") 251 | 252 | 253 | def yield_name_offsets(file_): 254 | lines = list(file_) 255 | 256 | lines_to_bytes = [] 257 | acc = 0 258 | for line in lines: 259 | lines_to_bytes.append(acc) 260 | acc += len(line) 261 | 262 | text = "".join(lines) 263 | 264 | for item in yield_module_items(text): 265 | name, line, col = item 266 | offset = lines_to_bytes[line - 1] + col 267 | yield name, offset 268 | 269 | 270 | def yield_module_path_itemnames(file_path, only_toplevel=False): 271 | text = pathlib.Path(file_path).read_text() 272 | for name, _, _ in yield_module_items(text, only_toplevel): 273 | yield name 274 | 275 | 276 | def yield_module_items(s, only_toplevel=False): 277 | module = ast.parse(s) 278 | for c in ast.iter_child_nodes(module): 279 | fields = dict(ast.iter_fields(c)) 280 | if isinstance(c, ast.FunctionDef): 281 | yield c.name, c.lineno, c.col_offset + len("def ") 282 | elif isinstance(c, ast.ClassDef): 283 | yield c.name, c.lineno, c.col_offset + len("class ") 284 | if only_toplevel: 285 | continue 286 | for member in fields["body"]: 287 | if isinstance(member, ast.FunctionDef): 288 | mname = ".".join([c.name, member.name]) 289 | yield mname, member.lineno, member.col_offset + len("def ") 290 | elif isinstance(c, ast.Assign): 291 | yield c 292 | 293 | 294 | # ----------------------------------------------------------------------------- 295 | # Copyright (C) 2019 Angelos Evripiotis. 296 | # 297 | # Licensed under the Apache License, Version 2.0 (the "License"); 298 | # you may not use this file except in compliance with the License. 299 | # You may obtain a copy of the License at 300 | # 301 | # http://www.apache.org/licenses/LICENSE-2.0 302 | # 303 | # Unless required by applicable law or agreed to in writing, software 304 | # distributed under the License is distributed on an "AS IS" BASIS, 305 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 306 | # See the License for the specific language governing permissions and 307 | # limitations under the License. 308 | # ------------------------------ END-OF-FILE ---------------------------------- 309 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import fastentrypoints 2 | import setuptools 3 | 4 | setuptools.setup( 5 | name='rope-cli', 6 | author='Angelos Evripiotis', 7 | author_email='angelos.evripiotis@gmail.com', 8 | zip_safe=False, 9 | packages=[ 10 | 'ropecli', 11 | ], 12 | entry_points={ 13 | 'console_scripts': [ 14 | 'rope=ropecli:main', 15 | ] 16 | }, 17 | install_requires=[ 18 | 'click', 19 | 'rope', 20 | ], 21 | extras_require={ 22 | 'dev': [ 23 | 'pytest', 24 | ] 25 | }, 26 | python_requires='>=3.6', 27 | ) 28 | 29 | 30 | # ----------------------------------------------------------------------------- 31 | # Copyright (C) 2019 Angelos Evripiotis. 32 | # 33 | # Licensed under the Apache License, Version 2.0 (the "License"); 34 | # you may not use this file except in compliance with the License. 35 | # You may obtain a copy of the License at 36 | # 37 | # http://www.apache.org/licenses/LICENSE-2.0 38 | # 39 | # Unless required by applicable law or agreed to in writing, software 40 | # distributed under the License is distributed on an "AS IS" BASIS, 41 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 42 | # See the License for the specific language governing permissions and 43 | # limitations under the License. 44 | # ------------------------------ END-OF-FILE ---------------------------------- 45 | -------------------------------------------------------------------------------- /tests/test_all.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import click.testing 4 | 5 | import ropecli 6 | 7 | import io 8 | import sys 9 | 10 | 11 | class CaptureOutput: 12 | def __enter__(self): 13 | captured_output = io.StringIO() 14 | sys.stdout = captured_output 15 | return captured_output 16 | 17 | def __exit__(self, exc_type, exc_value, traceback): 18 | sys.stdout = sys.__stdout__ 19 | 20 | 21 | VEGETABLES_PY = """ 22 | from sys import stderr 23 | import argparse 24 | 25 | 26 | def carrots(): 27 | print("carrots!", file=stderr) 28 | 29 | 30 | def tomatoes(): 31 | print("tomatoes!") 32 | 33 | 34 | def all(): 35 | carrots() 36 | tomatoes() 37 | """ 38 | 39 | FRUIT_PY = """ 40 | def cherries(): 41 | print("cherries!") 42 | """ 43 | 44 | 45 | def make_veg_fruit_pyfiles(): 46 | veg = pathlib.Path("veg.py") 47 | veg.write_text(VEGETABLES_PY) 48 | fruit = pathlib.Path("fruit.py") 49 | fruit.write_text(FRUIT_PY) 50 | return veg, fruit 51 | 52 | 53 | def run(runner, *args): 54 | str_args = [str(a) for a in args] 55 | result = runner.invoke(ropecli.main, str_args) 56 | print(result.stdout) 57 | # It seems that the "mix_stderr" parameter to CliRunner() is not working as 58 | # expected, so everything will come out on stdout for now. 59 | # print(result.stderr, file=sys.stderr) 60 | 61 | assert result.exit_code == 0 62 | 63 | return result 64 | 65 | 66 | def test_smoketest(): 67 | # Make sure we can do the bare minimum without an error. 68 | runner = click.testing.CliRunner() 69 | assert runner.invoke(ropecli.main).exit_code == 0 70 | 71 | 72 | def test_help(): 73 | runner = click.testing.CliRunner() 74 | run(runner, "--help") 75 | for command in ropecli.main.list_commands(ctx=None): 76 | run(runner, command, "--help") 77 | 78 | 79 | def test_froms_to_imports_simple(): 80 | runner = click.testing.CliRunner() 81 | with runner.isolated_filesystem(): 82 | veg, fruit = make_veg_fruit_pyfiles() 83 | assert "from sys import stderr" in veg.read_text() 84 | run(runner, "froms-to-imports", veg) 85 | assert "from sys import stderr" not in veg.read_text() 86 | 87 | 88 | def test_list_simple(): 89 | runner = click.testing.CliRunner() 90 | with runner.isolated_filesystem(): 91 | veg, fruit = make_veg_fruit_pyfiles() 92 | result = run(runner, "list", fruit) 93 | assert "cherries" in result.stdout 94 | 95 | 96 | def test_move_simple(): 97 | runner = click.testing.CliRunner() 98 | with runner.isolated_filesystem(): 99 | veg, fruit = make_veg_fruit_pyfiles() 100 | assert "fruit.tomatoes()" not in veg.read_text() 101 | assert "def tomatoes()" not in fruit.read_text() 102 | run(runner, "move", f"{veg}::tomatoes", fruit) 103 | assert "fruit.tomatoes()" in veg.read_text() 104 | assert "def tomatoes()" in fruit.read_text() 105 | 106 | 107 | def test_organize_imports_simple(): 108 | runner = click.testing.CliRunner() 109 | with runner.isolated_filesystem(): 110 | veg, fruit = make_veg_fruit_pyfiles() 111 | assert "import argparse" in veg.read_text() 112 | run(runner, "organize-imports", veg) 113 | assert "import argparse" not in veg.read_text() 114 | 115 | 116 | def test_rename_simple(): 117 | runner = click.testing.CliRunner() 118 | with runner.isolated_filesystem(): 119 | veg, fruit = make_veg_fruit_pyfiles() 120 | assert "def tomafingers()" not in veg.read_text() 121 | assert " tomafingers()" not in veg.read_text() 122 | run(runner, "rename", f"{veg}::tomatoes", "tomafingers") 123 | assert "def tomafingers()" in veg.read_text() 124 | assert " tomafingers()" in veg.read_text() 125 | 126 | 127 | def test_rename_module(): 128 | runner = click.testing.CliRunner() 129 | with runner.isolated_filesystem(): 130 | veg, fruit = make_veg_fruit_pyfiles() 131 | run(runner, "move", f"{veg}::tomatoes", fruit) 132 | run(runner, "rename", fruit, "fruity") 133 | assert "fruity.tomatoes()" in veg.read_text() 134 | assert not fruit.exists() 135 | assert pathlib.Path("fruity.py").exists() 136 | 137 | 138 | def test_rename_module__dry(): 139 | runner = click.testing.CliRunner() 140 | with runner.isolated_filesystem(): 141 | veg, fruit = make_veg_fruit_pyfiles() 142 | 143 | with CaptureOutput() as output: 144 | run(runner, "rename", "--dry-run", fruit, "fruity") 145 | assert "Renaming" in output.getvalue() 146 | 147 | with CaptureOutput() as output: 148 | run(runner, "rename", "--dry-run", f"{fruit}::cherries", "berries") 149 | assert "Renaming" in output.getvalue() 150 | 151 | assert not pathlib.Path("fruity.py").exists() 152 | assert "berries" not in fruit.read_text() 153 | 154 | 155 | # ----------------------------------------------------------------------------- 156 | # Copyright (C) 2019 Angelos Evripiotis. 157 | # 158 | # Licensed under the Apache License, Version 2.0 (the "License"); 159 | # you may not use this file except in compliance with the License. 160 | # You may obtain a copy of the License at 161 | # 162 | # http://www.apache.org/licenses/LICENSE-2.0 163 | # 164 | # Unless required by applicable law or agreed to in writing, software 165 | # distributed under the License is distributed on an "AS IS" BASIS, 166 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 167 | # See the License for the specific language governing permissions and 168 | # limitations under the License. 169 | # ------------------------------ END-OF-FILE ---------------------------------- 170 | --------------------------------------------------------------------------------