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