# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE """ Patch Handler. This file manages execution of manaully written patches. Patches are script that apply changes in database schema or data to accomodate for changes in the code. Ways to specify patches: 1. patches.txt file specifies patches that run before doctype schema migration. Each line represents one patch (old format). 2. patches.txt can alternatively also separate pre and post model sync patches by using INI like file format: ```patches.txt [pre_model_sync] app.module.patch1 app.module.patch2 [post_model_sync] app.module.patch3 ``` When different sections are specified patches are executed in this order: 1. Run pre_model_sync patches 2. Reload/resync all doctype schema 3. Run post_model_sync patches Hence any patch that just needs to modify data but doesn't depend on old schema should be added to post_model_sync section of file. 3. simple python commands can be added by starting line with `execute:` `execute:` example: `execute:print("hello world")` """ import configparser import time from enum import Enum from typing import List, Optional import frappe class PatchError(Exception): pass class PatchType(Enum): pre_model_sync = "pre_model_sync" post_model_sync = "post_model_sync" def run_all(skip_failing: bool = False, patch_type: Optional[PatchType] = None) -> None: """run all pending patches""" executed = set(frappe.get_all("Patch Log", fields="patch", pluck="patch")) frappe.flags.final_patches = [] def run_patch(patch): try: if not run_single(patchmodule = patch): print(patch + ': failed: STOPPED') raise PatchError(patch) except Exception: if not skip_failing: raise else: print('Failed to execute patch') patches = get_all_patches(patch_type=patch_type) for patch in patches: if patch and (patch not in executed): run_patch(patch) # patches to be run in the end for patch in frappe.flags.final_patches: patch = patch.replace('finally:', '') run_patch(patch) def get_all_patches(patch_type: Optional[PatchType] = None) -> List[str]: if patch_type and not isinstance(patch_type, PatchType): frappe.throw(f"Unsupported patch type specified: {patch_type}") patches = [] for app in frappe.get_installed_apps(): patches.extend(get_patches_from_app(app, patch_type=patch_type)) return patches def get_patches_from_app(app: str, patch_type: Optional[PatchType] = None) -> List[str]: """ Get patches from an app's patches.txt patches.txt can be: 1. ini like file with section for different patch_type 2. plain text file with each line representing a patch. """ patches_txt = frappe.get_pymodule_path(app, "patches.txt") try: # Attempt to parse as ini file with pre/post patches # allow_no_value: patches are not key value pairs # delimiters = '\n' to avoid treating default `:` and `=` in execute as k:v delimiter parser = configparser.ConfigParser(allow_no_value=True, delimiters="\n") # preserve case parser.optionxform = str parser.read(patches_txt) # empty file if not parser.sections(): return [] if not patch_type: return [patch for patch in parser[PatchType.pre_model_sync.value]] + \ [patch for patch in parser[PatchType.post_model_sync.value]] if patch_type.value in parser.sections(): return [patch for patch in parser[patch_type.value]] else: frappe.throw(frappe._("Patch type {} not found in patches.txt").format(patch_type)) except configparser.MissingSectionHeaderError: # treat as old format with each line representing a single patch # backward compatbility with old patches.txt format if not patch_type or patch_type == PatchType.pre_model_sync: return frappe.get_file_items(patches_txt) return [] def reload_doc(args): import frappe.modules run_single(method = frappe.modules.reload_doc, methodargs = args) def run_single(patchmodule=None, method=None, methodargs=None, force=False): from frappe import conf # don't write txt files conf.developer_mode = 0 if force or method or not executed(patchmodule): return execute_patch(patchmodule, method, methodargs) else: return True def execute_patch(patchmodule, method=None, methodargs=None): """execute the patch""" block_user(True) frappe.db.begin() start_time = time.time() try: print('Executing {patch} in {site} ({db})'.format(patch=patchmodule or str(methodargs), site=frappe.local.site, db=frappe.db.cur_db_name)) if patchmodule: if patchmodule.startswith("finally:"): # run run patch at the end frappe.flags.final_patches.append(patchmodule) else: if patchmodule.startswith("execute:"): exec(patchmodule.split("execute:")[1],globals()) else: frappe.get_attr(patchmodule.split()[0] + ".execute")() update_patch_log(patchmodule) elif method: method(**methodargs) except Exception: frappe.db.rollback() raise else: frappe.db.commit() end_time = time.time() block_user(False) print('Success: Done in {time}s'.format(time = round(end_time - start_time, 3))) return True def update_patch_log(patchmodule): """update patch_file in patch log""" frappe.get_doc({"doctype": "Patch Log", "patch": patchmodule}).insert(ignore_permissions=True) def executed(patchmodule): """return True if is executed""" if patchmodule.startswith('finally:'): # patches are saved without the finally: tag patchmodule = patchmodule.replace('finally:', '') return frappe.db.get_value("Patch Log", {"patch": patchmodule}) def block_user(block, msg=None): """stop/start execution till patch is run""" frappe.local.flags.in_patch = block frappe.db.begin() if not msg: msg = "Patches are being executed in the system. Please try again in a few moments." frappe.db.set_global('__session_status', block and 'stop' or None) frappe.db.set_global('__session_status_message', block and msg or None) frappe.db.commit() def check_session_stopped(): if frappe.db.get_global("__session_status")=='stop': frappe.msgprint(frappe.db.get_global("__session_status_message")) raise frappe.SessionStopped('Session Stopped')