Passed
Push — main ( 002c08...62641e )
by Peter
01:22
created

pyclean.modern.remove_directory()   A

Complexity

Conditions 2

Size

Total Lines 9
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 8
nop 1
dl 0
loc 9
rs 10
c 0
b 0
f 0
1
"""
2
Modern, cross-platform, pure-Python pyclean implementation.
3
"""
4
import logging
5
6
try:
7
    from pathlib import Path
8
except ImportError:  # Python 2.7, PyPy2
9
    from warnings import warn
10
    warn("Python 3 required for modern implementation. Python 2 is obsolete.")
11
12
BYTECODE_FILES = ['.pyc', '.pyo']
13
BYTECODE_DIRS = ['__pycache__']
14
DEBRIS_TOPICS = {
15
    'build': [
16
        'dist/**/*',
17
        'dist/',
18
        'sdist/**/*',
19
        'sdist/',
20
        '*.egg-info/**/*',
21
        '*.egg-info/',
22
    ],
23
    'cache': [
24
        '.cache/**/*',
25
        '.cache/',
26
    ],
27
    'coverage': [
28
        '.coverage',
29
        'coverage.json',
30
        'coverage.lcov',
31
        'coverage.xml',
32
        'htmlcov/**/*',
33
        'htmlcov/',
34
    ],
35
    'jupyter': [
36
        '.ipynb_checkpoints/**/*',
37
        '.ipynb_checkpoints/',
38
    ],
39
    'pytest': [
40
        '.pytest_cache/**/*',
41
        '.pytest_cache/',
42
        'pytestdebug.log',
43
    ],
44
    'tox': [
45
        '.tox/**/*',
46
        '.tox/',
47
    ],
48
}
49
50
log = logging.getLogger(__name__)
51
52
53
class Runner:  # pylint: disable=too-few-public-methods
54
    """Module-level configuration and value store."""
55
    rmdir_count = 0
56
    rmdir_failed = 0
57
    unlink_count = 0
58
    unlink_failed = 0
59
60
61
def initialize_runner(args):
62
    """Sets up the Runner class with static attributes."""
63
    Runner.unlink = print_filename if args.dry_run else remove_file
64
    Runner.rmdir = print_dirname if args.dry_run else remove_directory
65
    Runner.ignore = args.ignore
66
67
68
def remove_file(fileobj):
69
    """Attempt to delete a file object for real."""
70
    log.debug("Deleting file: %s", fileobj)
71
    try:
72
        fileobj.unlink()
73
        Runner.unlink_count += 1
74
    except OSError as err:
75
        log.debug("File not deleted. %s", err)
76
        Runner.unlink_failed += 1
77
78
79
def remove_directory(dirobj):
80
    """Attempt to remove a directory object for real."""
81
    log.debug("Removing directory: %s", dirobj)
82
    try:
83
        dirobj.rmdir()
84
        Runner.rmdir_count += 1
85
    except OSError as err:
86
        log.debug("Directory not removed. %s", err)
87
        Runner.rmdir_failed += 1
88
89
90
def print_filename(fileobj):
91
    """Only display the file name, used with --dry-run."""
92
    log.debug("Would delete file: %s", fileobj)
93
    Runner.unlink_count += 1
94
95
96
def print_dirname(dirobj):
97
    """Only display the directory name, used with --dry-run."""
98
    log.debug("Would delete directory: %s", dirobj)
99
    Runner.rmdir_count += 1
100
101
102
def pyclean(args):
103
    """Cross-platform cleaning of Python bytecode."""
104
    initialize_runner(args)
105
106
    for dir_name in args.directory:
107
        dir_path = Path(dir_name)
108
109
        log.info("Cleaning directory %s", dir_path)
110
        descend_and_clean(dir_path, BYTECODE_FILES, BYTECODE_DIRS)
111
112
        for topic in args.debris:
113
            remove_debris_for(topic, dir_path)
114
115
        remove_freeform_targets(args.erase, args.yes, dir_path)
116
117
    log.info("Total %d files, %d directories %s.",
118
             Runner.unlink_count, Runner.rmdir_count,
119
             "would be removed" if args.dry_run else "removed")
120
121
    if Runner.unlink_failed or Runner.rmdir_failed:
122
        log.debug("%d files, %d directories could not be removed.",
123
                  Runner.unlink_failed, Runner.rmdir_failed)
124
125
126
def descend_and_clean(directory, file_types, dir_names):
127
    """
128
    Walk and descend a directory tree, cleaning up files of a certain type
129
    along the way. Only delete directories if they are empty, in the end.
130
    """
131
    for child in sorted(directory.iterdir()):
132
        if child.is_file():
133
            if child.suffix in file_types:
134
                Runner.unlink(child)
135
        elif child.is_dir():
136
            if child.name in Runner.ignore:
137
                log.debug("Skipping %s", child)
138
            else:
139
                descend_and_clean(child, file_types, dir_names)
140
141
            if child.name in dir_names:
142
                Runner.rmdir(child)
143
        else:
144
            log.debug("Ignoring %s", child)
145
146
147
def remove_debris_for(topic, directory):
148
    """
149
    Clean up debris for a specific topic.
150
    """
151
    log.debug("Scanning for debris of %s ...", topic.title())
152
153
    for path_glob in DEBRIS_TOPICS[topic]:
154
        delete_filesystem_objects(directory, path_glob)
155
156
157
def remove_freeform_targets(glob_patterns, yes, directory):
158
    """
159
    Remove free-form targets using globbing.
160
161
    This is **potentially dangerous** since users can delete everything
162
    anywhere in their file system, including the entire project they're
163
    working on. For this reason, the implementation imposes the following
164
    (user experience-related) restrictions:
165
166
    - Deleting (directories) is not recursive, directory contents must be
167
      explicitly specified using globbing (e.g. ``dirname/**/*``).
168
    - The user is responsible for the deletion order, so that a directory
169
      is empty when it is attempted to be deleted.
170
    - A confirmation prompt for the deletion of every single file system
171
      object is shown (unless the ``--yes`` option is used, in addition).
172
    """
173
    for path_glob in glob_patterns:
174
        log.debug("Erase file system objects matching: %s", path_glob)
175
        delete_filesystem_objects(directory, path_glob, prompt=not yes)
176
177
178
def delete_filesystem_objects(directory, path_glob, prompt=False):
179
    """
180
    Identifies all pathnames matching a specific glob pattern, and attempts
181
    to delete them in the proper order, optionally asking for confirmation.
182
183
    Implementation Note: We sort the file system objects in *reverse order*
184
    and first delete *all files* before removing directories. This way we
185
    make sure that the directories that are deepest down in the hierarchy
186
    are empty (for both files & directories) when we attempt to remove them.
187
    """
188
    all_names = sorted(directory.glob(path_glob), reverse=True)
189
    dirs = (name for name in all_names if name.is_dir() and not name.is_symlink())
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable name does not seem to be defined.
Loading history...
190
    files = (name for name in all_names if not name.is_dir() or name.is_symlink())
191
192
    for file_object in files:
193
        if prompt and not confirm('Delete %s %s' % (
194
            'symlink' if file_object.is_symlink() else 'file',
195
            file_object,
196
        )):
197
            continue
198
        Runner.unlink(file_object)
199
200
    for dir_object in dirs:
201
        if prompt and not confirm('Remove empty directory %s' % dir_object):
202
            continue
203
        Runner.rmdir(dir_object)
204
205
206
def confirm(message):
207
    """An interactive confirmation prompt."""
208
    answer = input("%s? " % message)
209
    return answer.strip().lower() in ['y', 'yes']
210