Passed
Pull Request — master (#2)
by William
01:10
created

get_imports()   A

Complexity

Conditions 4

Size

Total Lines 19
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 7
dl 0
loc 19
rs 9.2
c 0
b 0
f 0
cc 4
nop 2
1
import ast
1 ignored issue
show
Coding Style introduced by
This module should have a docstring.

The coding style of this project requires that you add a docstring to this code element. Below, you find an example for methods:

class SomeClass:
    def some_method(self):
        """Do x and return foo."""

If you would like to know more about docstrings, we recommend to read PEP-257: Docstring Conventions.

Loading history...
2
import fnmatch
3
import os
4
from collections import Counter, namedtuple
5
from contextlib import contextmanager
6
7
from click import ClickException, confirm, echo
0 ignored issues
show
introduced by
Unable to import 'click'
Loading history...
8
from git import Repo
0 ignored issues
show
introduced by
Unable to import 'git'
Loading history...
9
from tqdm import tqdm
0 ignored issues
show
introduced by
Unable to import 'tqdm'
Loading history...
10
from yapf.yapflib.yapf_api import FormatFile
0 ignored issues
show
introduced by
Unable to import 'yapf.yapflib.yapf_api'
Loading history...
11
12
CONFLICT_MSG = (
13
    "\n"
14
    "Unfortunately, you moved two objects with the same name on different paths.\n"
15
    "This situation could be catastrophic while running the script for rename the imports. \n"
16
    "These are the imports that have conflict: \n "
17
    " -> {} \n"
18
)
19
20
INFORMATIVE_CONFLICT_MSG = (
21
    "Do you want to generate the file without this import? \n"
22
    "Otherwise this script will be aborted."
23
)
24
25
Import = namedtuple("Import", ["module", "name"])
26
27
28
@contextmanager
29
def checkout_branch(repo, branch_name):
30
    """
31
    Change the current git branch for the branch informed on branch_name
32
33
    :param git.Repo repo: The git repository of the project.
34
35
    :param str branch_name: Name of the branch that Git should move into
36
    """
37
38
    if (repo.is_dirty() or len(repo.untracked_files)) != 0:
39
        raise ClickException("The repository is dirty, please clean it first ")
40
41
    current_branch = repo.active_branch.name
42
    repo.branches[branch_name].checkout()
43
44
    try:
45
        yield
46
    finally:
47
        getattr(repo.heads, current_branch).checkout()
48
49
50
def analyze_modifications(project_path, compare_with, branch, output_file):
51
    """
52
    Track modifications between two different branches.
53
    The output will be a list written directly to a file.
54
    """
55
56
    full_project_path = os.path.join(project_path)
57
    repo = Repo(project_path)
58
59
    origin_branch = compare_with
60
61
    if branch:
62
        work_branch = branch
63
    else:
64
        work_branch = repo.active_branch.name
65
66
    if origin_branch == work_branch:
67
        raise ClickException(
68
            "Origin and Working branch are the same. "
69
            "Please, change your activate branch to where you made you changes on the code, "
70
            "or use the option --branch and --compare-with ."
71
        )
72
73
    file_counter = total_of_py_files_on_project(full_project_path)
74
75
    with checkout_branch(repo, origin_branch):
76
        import_list_from_origin = {imp for imp in get_imports(full_project_path, file_counter)}
77
78
    with checkout_branch(repo, work_branch):
79
        import_list_from_working = {imp for imp in get_imports(full_project_path, file_counter)}
80
81
    list_with_modified_imports = generate_list_with_modified_imports(import_list_from_origin,
82
                                                                     import_list_from_working)
83
    write_list_to_file(list_with_modified_imports, output_file)
84
85
86
def write_list_to_file(list_with_modified_imports, file_name):
87
    """
88
    Write the list of modified imports on a python file, the python file per default will be named
89
    "list_output.py" but can be changed by passing the argument --output_file
90
91
    The name of the list (inside the file) cannot be changed since it will be used later on the
92
    script for renaming the project.
93
    """
94
    echo('Generating the file {0}'.format(file_name))
95
96
    with open(file_name, 'w') as file:
97
        file.write("imports_to_move = ")
98
        file.writelines(repr(list(list_with_modified_imports)))
99
100
    FormatFile(file_name, in_place=True)
101
102
103
def generate_list_with_modified_imports(import_list_from_origin, import_list_from_working):
1 ignored issue
show
Coding Style Naming introduced by
The name generate_list_with_modified_imports does not conform to the function naming conventions ((([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
104
    """
105
    This methods looks for imports that keep the same name but has has different modules path
106
107
    :return: A list with unique elements that has the same name but different modules path
108
    :rtype: set
109
    """
110
111
    origin_filtered, working_filtered = _filter_import(import_list_from_origin,
112
                                                       import_list_from_working)
113
114
    moved_imports = _find_moved_imports(origin_filtered, working_filtered)
115
116
    list_with_modified_imports = _check_for_conflicts(moved_imports)
117
118
    return list_with_modified_imports
119
120
121
def _filter_import(origin_import_list, working_import_list):
122
    """
123
    Create a new set with elements present origin_list or working_list but not on both,
124
    this helps to filter classes that could have same name but different modules.
125
    """
126
    difference = origin_import_list.symmetric_difference(working_import_list)
127
    origin_filtered = origin_import_list.intersection(difference)
128
    working_filtered = working_import_list.intersection(difference)
129
    return origin_filtered, working_filtered
130
131
132
def _find_moved_imports(list_with_import_from_origin_branch, list_with_import_from_working_branch):
0 ignored issues
show
Coding Style Naming introduced by
The name list_with_import_from_origin_branch does not conform to the argument naming conventions ((([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
Coding Style Naming introduced by
The name list_with_import_from_working_branch does not conform to the argument naming conventions ((([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
133
    """
134
    Return a list with tuples where the first element is the origin and the second element is
135
    the branch with the modifications
136
    """
137
    list_with_modified_imports = {
138
        (origin.module + "." + origin.name, working.module + "." + working.name)
139
        for origin in list_with_import_from_origin_branch
140
        for working in list_with_import_from_working_branch
141
        if working.name is not '*'
0 ignored issues
show
introduced by
Comparison to literal
Loading history...
142
        if origin.name == working.name
143
        if origin.module != working.module
144
    }
145
    return list_with_modified_imports
146
147
148
def _check_for_conflicts(list_with_modified_imports):
149
    """
150
    Checks if there is more than one object with the same module path.
151
152
    If any conflict is found, the script will display the list of conflicts for the user
153
    to decide either abort the script or create the list without the conflicted module path.
154
    """
155
    import_from = [modified_import[0] for modified_import in list(list_with_modified_imports)]
156
    imports_with_conflict = [class_name
157
                             for class_name, number_of_occurrences in Counter(import_from).items()
158
                             if number_of_occurrences > 1]
159
    if len(imports_with_conflict) > 0:
0 ignored issues
show
Unused Code introduced by
Do not use len(SEQUENCE) as condition value
Loading history...
160
        echo(CONFLICT_MSG.format('\n -> '.join(imports_with_conflict)))
161
162
        if confirm(INFORMATIVE_CONFLICT_MSG, abort=True):
163
            list_with_modified_imports = [modified_import for modified_import in
164
                                          list_with_modified_imports
165
                                          if modified_import[0] not in imports_with_conflict
166
                                          if modified_import[1] not in imports_with_conflict]
167
    return list_with_modified_imports
168
169
170
def get_imports(project_path, file_counter):
171
    # type: (str) -> Import
172
    """
173
    Look for all .py files on the given project path and return the import statements found on
174
    each file.
175
176
    Note.: I inserted the TQDM here because was the only way that I could have an accurate
177
    progress bar, feel free to share any thoughts or tips on how to improve this progress bar =)
178
179
    :type project_path: str
180
    :rtype: commands.utils.Import
181
    """
182
    with tqdm(total=file_counter, unit='files', leave=False, desc=project_path) as pbar:
183
        for file_path in walk_on_py_files(project_path):
184
            pbar.update()
185
            with open(file_path, mode='r') as file:
186
                file_content = ast.parse(file.read(), file_path)
187
188
            yield from _classify_imports(file_content)
189
190
191
def _classify_imports(file_content):
192
    for node in ast.iter_child_nodes(file_content):
193
        if isinstance(node, ast.Import):
194
            module = ''
195
        elif isinstance(node, ast.ImportFrom):
196
            # node.module can be None when the following statement is used: from . import foo
197
            if node.module is not None:
198
                module = node.module
199
            else:
200
                module = ''
201
        else:
202
            continue
203
204
        for name_node in node.names:
205
            yield Import(module, name_node.name)
206
207
208
def walk_on_py_files(folder):
209
    """
210
    Walk through each python files in a directory
211
    """
212
    for dir_path, _, files in os.walk(folder):
213
        for filename in fnmatch.filter(files, '*.py'):
214
            yield os.path.abspath(os.path.join(dir_path, filename))
215
216
217
def total_of_py_files_on_project(project_path):
218
    """
219
    Helper function to find the total number of py files that needs to be iterated.
220
    :return: Total number of py files
221
    """
222
    file_counter = 0
223
    for _ in walk_on_py_files(project_path):
224
        file_counter += 1
225
    return file_counter
226