1# -*- coding: UTF-8 -*-
2"""
3Provides cleanup tasks for invoke build scripts (as generic invoke tasklet).
4Simplifies writing common, composable and extendable cleanup tasks.
5
6PYTHON PACKAGE REQUIREMENTS:
7* path.py >= 8.2.1  (as path-object abstraction)
8* pathlib (for ant-like wildcard patterns; since: python > 3.5)
9* pycmd (required-by: clean_python())
10
11clean task: Add Additional Directories and Files to be removed
12-------------------------------------------------------------------------------
13
14Create an invoke configuration file (YAML of JSON) with the additional
15configuration data:
16
17.. code-block:: yaml
18
19    # -- FILE: invoke.yaml
20    # USE: clean.directories, clean.files to override current configuration.
21    clean:
22        extra_directories:
23            - **/tmp/
24        extra_files:
25            - **/*.log
26            - **/*.bak
27
28
29Registration of Cleanup Tasks
30------------------------------
31
32Other task modules often have an own cleanup task to recover the clean state.
33The :meth:`clean` task, that is provided here, supports the registration
34of additional cleanup tasks. Therefore, when the :meth:`clean` task is executed,
35all registered cleanup tasks will be executed.
36
37EXAMPLE::
38
39    # -- FILE: tasks/docs.py
40    from __future__ import absolute_import
41    from invoke import task, Collection
42    from tasklet_cleanup import cleanup_tasks, cleanup_dirs
43
44    @task
45    def clean(ctx, dry_run=False):
46        "Cleanup generated documentation artifacts."
47        cleanup_dirs(["build/docs"])
48
49    namespace = Collection(clean)
50    ...
51
52    # -- REGISTER CLEANUP TASK:
53    cleanup_tasks.add_task(clean, "clean_docs")
54    cleanup_tasks.configure(namespace.configuration())
55"""
56
57from __future__ import absolute_import, print_function
58import os.path
59import sys
60import pathlib
61from invoke import task, Collection
62from invoke.executor import Executor
63from invoke.exceptions import Exit, Failure, UnexpectedExit
64from path import Path
65
66
67# -----------------------------------------------------------------------------
68# CLEANUP UTILITIES:
69# -----------------------------------------------------------------------------
70def cleanup_accept_old_config(ctx):
71    ctx.cleanup.directories.extend(ctx.clean.directories or [])
72    ctx.cleanup.extra_directories.extend(ctx.clean.extra_directories or [])
73    ctx.cleanup.files.extend(ctx.clean.files or [])
74    ctx.cleanup.extra_files.extend(ctx.clean.extra_files or [])
75
76    ctx.cleanup_all.directories.extend(ctx.clean_all.directories or [])
77    ctx.cleanup_all.extra_directories.extend(ctx.clean_all.extra_directories or [])
78    ctx.cleanup_all.files.extend(ctx.clean_all.files or [])
79    ctx.cleanup_all.extra_files.extend(ctx.clean_all.extra_files or [])
80
81
82def execute_cleanup_tasks(ctx, cleanup_tasks, dry_run=False):
83    """Execute several cleanup tasks as part of the cleanup.
84
85    REQUIRES: ``clean(ctx, dry_run=False)`` signature in cleanup tasks.
86
87    :param ctx:             Context object for the tasks.
88    :param cleanup_tasks:   Collection of cleanup tasks (as Collection).
89    :param dry_run:         Indicates dry-run mode (bool)
90    """
91    # pylint: disable=redefined-outer-name
92    executor = Executor(cleanup_tasks, ctx.config)
93    failure_count = 0
94    for cleanup_task in cleanup_tasks.tasks:
95        try:
96            print("CLEANUP TASK: %s" % cleanup_task)
97            executor.execute((cleanup_task, dict(dry_run=dry_run)))
98        except (Exit, Failure, UnexpectedExit) as e:
99            print("FAILURE in CLEANUP TASK: %s (GRACEFULLY-IGNORED)" % cleanup_task)
100            failure_count += 1
101
102    if failure_count:
103        print("CLEANUP TASKS: %d failure(s) occured" % failure_count)
104
105
106def cleanup_dirs(patterns, dry_run=False, workdir="."):
107    """Remove directories (and their contents) recursively.
108    Skips removal if directories does not exist.
109
110    :param patterns:    Directory name patterns, like "**/tmp*" (as list).
111    :param dry_run:     Dry-run mode indicator (as bool).
112    :param workdir:     Current work directory (default=".")
113    """
114    current_dir = Path(workdir)
115    python_basedir = Path(Path(sys.executable).dirname()).joinpath("..").abspath()
116    warn2_counter = 0
117    for dir_pattern in patterns:
118        for directory in path_glob(dir_pattern, current_dir):
119            directory2 = directory.abspath()
120            if sys.executable.startswith(directory2):
121                # pylint: disable=line-too-long
122                print("SKIP-SUICIDE: '%s' contains current python executable" % directory)
123                continue
124            elif directory2.startswith(python_basedir):
125                # -- PROTECT CURRENTLY USED VIRTUAL ENVIRONMENT:
126                if warn2_counter <= 4:
127                    print("SKIP-SUICIDE: '%s'" % directory)
128                warn2_counter += 1
129                continue
130
131            if not directory.isdir():
132                print("RMTREE: %s (SKIPPED: Not a directory)" % directory)
133                continue
134
135            if dry_run:
136                print("RMTREE: %s (dry-run)" % directory)
137            else:
138                print("RMTREE: %s" % directory)
139                directory.rmtree_p()
140
141
142def cleanup_files(patterns, dry_run=False, workdir="."):
143    """Remove files or files selected by file patterns.
144    Skips removal if file does not exist.
145
146    :param patterns:    File patterns, like "**/*.pyc" (as list).
147    :param dry_run:     Dry-run mode indicator (as bool).
148    :param workdir:     Current work directory (default=".")
149    """
150    current_dir = Path(workdir)
151    python_basedir = Path(Path(sys.executable).dirname()).joinpath("..").abspath()
152    error_message = None
153    error_count = 0
154    for file_pattern in patterns:
155        for file_ in path_glob(file_pattern, current_dir):
156            if file_.abspath().startswith(python_basedir):
157                # -- PROTECT CURRENTLY USED VIRTUAL ENVIRONMENT:
158                continue
159            if not file_.isfile():
160                print("REMOVE: %s (SKIPPED: Not a file)" % file_)
161                continue
162
163            if dry_run:
164                print("REMOVE: %s (dry-run)" % file_)
165            else:
166                print("REMOVE: %s" % file_)
167                try:
168                    file_.remove_p()
169                except os.error as e:
170                    message = "%s: %s" % (e.__class__.__name__, e)
171                    print(message + " basedir: "+ python_basedir)
172                    error_count += 1
173                    if not error_message:
174                        error_message = message
175    if False and error_message:
176        class CleanupError(RuntimeError):
177            pass
178        raise CleanupError(error_message)
179
180
181def path_glob(pattern, current_dir=None):
182    """Use pathlib for ant-like patterns, like: "**/*.py"
183
184    :param pattern:      File/directory pattern to use (as string).
185    :param current_dir:  Current working directory (as Path, pathlib.Path, str)
186    :return Resolved Path (as path.Path).
187    """
188    if not current_dir:
189        current_dir = pathlib.Path.cwd()
190    elif not isinstance(current_dir, pathlib.Path):
191        # -- CASE: string, path.Path (string-like)
192        current_dir = pathlib.Path(str(current_dir))
193
194    for p in current_dir.glob(pattern):
195        yield Path(str(p))
196
197
198# -----------------------------------------------------------------------------
199# GENERIC CLEANUP TASKS:
200# -----------------------------------------------------------------------------
201@task
202def clean(ctx, dry_run=False):
203    """Cleanup temporary dirs/files to regain a clean state."""
204    cleanup_accept_old_config(ctx)
205    directories = ctx.cleanup.directories or []
206    directories.extend(ctx.cleanup.extra_directories or [])
207    files = ctx.cleanup.files or []
208    files.extend(ctx.cleanup.extra_files or [])
209
210    # -- PERFORM CLEANUP:
211    execute_cleanup_tasks(ctx, cleanup_tasks, dry_run=dry_run)
212    cleanup_dirs(directories, dry_run=dry_run)
213    cleanup_files(files, dry_run=dry_run)
214
215
216@task(name="all", aliases=("distclean",))
217def clean_all(ctx, dry_run=False):
218    """Clean up everything, even the precious stuff.
219    NOTE: clean task is executed first.
220    """
221    cleanup_accept_old_config(ctx)
222    directories = ctx.config.cleanup_all.directories or []
223    directories.extend(ctx.config.cleanup_all.extra_directories or [])
224    files = ctx.config.cleanup_all.files or []
225    files.extend(ctx.config.cleanup_all.extra_files or [])
226
227    # -- PERFORM CLEANUP:
228    # HINT: Remove now directories, files first before cleanup-tasks.
229    cleanup_dirs(directories, dry_run=dry_run)
230    cleanup_files(files, dry_run=dry_run)
231    execute_cleanup_tasks(ctx, cleanup_all_tasks, dry_run=dry_run)
232    clean(ctx, dry_run=dry_run)
233
234
235@task(name="python")
236def clean_python(ctx, dry_run=False):
237    """Cleanup python related files/dirs: *.pyc, *.pyo, ..."""
238    # MAYBE NOT: "**/__pycache__"
239    cleanup_dirs(["build", "dist", "*.egg-info", "**/__pycache__"],
240                 dry_run=dry_run)
241    if not dry_run:
242        ctx.run("py.cleanup")
243    cleanup_files(["**/*.pyc", "**/*.pyo", "**/*$py.class"], dry_run=dry_run)
244
245
246# -----------------------------------------------------------------------------
247# TASK CONFIGURATION:
248# -----------------------------------------------------------------------------
249CLEANUP_EMPTY_CONFIG = {
250    "directories": [],
251    "files": [],
252    "extra_directories": [],
253    "extra_files": [],
254}
255def make_cleanup_config(**kwargs):
256    config_data = CLEANUP_EMPTY_CONFIG.copy()
257    config_data.update(kwargs)
258    return config_data
259
260
261namespace = Collection(clean_all, clean_python)
262namespace.add_task(clean, default=True)
263namespace.configure({
264    "cleanup": make_cleanup_config(
265        files=["*.bak", "*.log", "*.tmp", "**/.DS_Store", "**/*.~*~"]
266    ),
267    "cleanup_all": make_cleanup_config(
268        directories=[".venv*", ".tox", "downloads", "tmp"]
269    ),
270    # -- BACKWARD-COMPATIBLE: OLD-STYLE
271    "clean":     CLEANUP_EMPTY_CONFIG.copy(),
272    "clean_all": CLEANUP_EMPTY_CONFIG.copy(),
273})
274
275
276# -- EXTENSION-POINT: CLEANUP TASKS (called by: clean, clean_all task)
277# NOTE: Can be used by other tasklets to register cleanup tasks.
278cleanup_tasks = Collection("cleanup_tasks")
279cleanup_all_tasks = Collection("cleanup_all_tasks")
280
281# -- EXTEND NORMAL CLEANUP-TASKS:
282# DISABLED: cleanup_tasks.add_task(clean_python)
283#
284# -----------------------------------------------------------------------------
285# EXTENSION-POINT: CONFIGURATION HELPERS: Can be used from other task modules
286# -----------------------------------------------------------------------------
287def config_add_cleanup_dirs(directories):
288    # pylint: disable=protected-access
289    the_cleanup_directories = namespace._configuration["clean"]["directories"]
290    the_cleanup_directories.extend(directories)
291
292def config_add_cleanup_files(files):
293    # pylint: disable=protected-access
294    the_cleanup_files = namespace._configuration["clean"]["files"]
295    the_cleanup_files.extend(files)
296