Ability to run a few patches after the doctype model schema is synced. Read module-level docstring of patch_handler.py for more info.version-14
@@ -346,14 +346,15 @@ def post_install(rebuild_website=False): | |||||
def set_all_patches_as_completed(app): | def set_all_patches_as_completed(app): | ||||
patch_path = os.path.join(frappe.get_pymodule_path(app), "patches.txt") | |||||
if os.path.exists(patch_path): | |||||
for patch in frappe.get_file_items(patch_path): | |||||
frappe.get_doc({ | |||||
"doctype": "Patch Log", | |||||
"patch": patch | |||||
}).insert(ignore_permissions=True) | |||||
frappe.db.commit() | |||||
from frappe.modules.patch_handler import get_patches_from_app | |||||
patches = get_patches_from_app(app) | |||||
for patch in patches: | |||||
frappe.get_doc({ | |||||
"doctype": "Patch Log", | |||||
"patch": patch | |||||
}).insert(ignore_permissions=True) | |||||
frappe.db.commit() | |||||
def init_singles(): | def init_singles(): | ||||
@@ -19,6 +19,8 @@ from frappe.modules.utils import sync_customizations | |||||
from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs | from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs | ||||
from frappe.search.website_search import build_index_for_all_routes | from frappe.search.website_search import build_index_for_all_routes | ||||
from frappe.database.schema import add_column | from frappe.database.schema import add_column | ||||
from frappe.modules.patch_handler import PatchType | |||||
def migrate(verbose=True, skip_failing=False, skip_search_index=False): | def migrate(verbose=True, skip_failing=False, skip_search_index=False): | ||||
@@ -59,16 +61,13 @@ Otherwise, check the server logs and ensure that all the required services are r | |||||
clear_global_cache() | clear_global_cache() | ||||
#run before_migrate hooks | |||||
for app in frappe.get_installed_apps(): | for app in frappe.get_installed_apps(): | ||||
for fn in frappe.get_hooks('before_migrate', app_name=app): | for fn in frappe.get_hooks('before_migrate', app_name=app): | ||||
frappe.get_attr(fn)() | frappe.get_attr(fn)() | ||||
# run patches | |||||
frappe.modules.patch_handler.run_all(skip_failing) | |||||
# sync | |||||
frappe.modules.patch_handler.run_all(skip_failing=skip_failing, patch_type=PatchType.pre_model_sync) | |||||
frappe.model.sync.sync_all() | frappe.model.sync.sync_all() | ||||
frappe.modules.patch_handler.run_all(skip_failing=skip_failing, patch_type=PatchType.post_model_sync) | |||||
frappe.translate.clear_cache() | frappe.translate.clear_cache() | ||||
sync_jobs() | sync_jobs() | ||||
sync_fixtures() | sync_fixtures() | ||||
@@ -78,18 +77,16 @@ Otherwise, check the server logs and ensure that all the required services are r | |||||
frappe.get_doc('Portal Settings', 'Portal Settings').sync_menu() | frappe.get_doc('Portal Settings', 'Portal Settings').sync_menu() | ||||
# syncs statics | |||||
# syncs static files | |||||
clear_website_cache() | clear_website_cache() | ||||
# updating installed applications data | # updating installed applications data | ||||
frappe.get_single('Installed Applications').update_versions() | frappe.get_single('Installed Applications').update_versions() | ||||
#run after_migrate hooks | |||||
for app in frappe.get_installed_apps(): | for app in frappe.get_installed_apps(): | ||||
for fn in frappe.get_hooks('after_migrate', app_name=app): | for fn in frappe.get_hooks('after_migrate', app_name=app): | ||||
frappe.get_attr(fn)() | frappe.get_attr(fn)() | ||||
# build web_routes index | |||||
if not skip_search_index: | if not skip_search_index: | ||||
# Run this last as it updates the current session | # Run this last as it updates the current session | ||||
print('Building search index for {}'.format(frappe.local.site)) | print('Building search index for {}'.format(frappe.local.site)) | ||||
@@ -1,37 +1,76 @@ | |||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors | |||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors | |||||
# License: MIT. See LICENSE | # License: MIT. See LICENSE | ||||
""" | |||||
Execute Patch Files | |||||
""" 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 | |||||
To run directly | |||||
python lib/wnf.py patch patch1, patch2 etc | |||||
python lib/wnf.py patch -f patch1, patch2 etc | |||||
[post_model_sync] | |||||
app.module.patch3 | |||||
``` | |||||
where patch1, patch2 is module name | |||||
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 frappe, frappe.permissions, time | |||||
class PatchError(Exception): pass | |||||
import configparser | |||||
import time | |||||
from enum import Enum | |||||
from typing import List, Optional | |||||
import frappe | |||||
def run_all(skip_failing=False): | |||||
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""" | """run all pending patches""" | ||||
executed = [p[0] for p in frappe.db.sql("""select patch from `tabPatch Log`""")] | |||||
executed = set(frappe.get_all("Patch Log", fields="patch", pluck="patch")) | |||||
frappe.flags.final_patches = [] | frappe.flags.final_patches = [] | ||||
def run_patch(patch): | def run_patch(patch): | ||||
try: | try: | ||||
if not run_single(patchmodule = patch): | if not run_single(patchmodule = patch): | ||||
log(patch + ': failed: STOPPED') | |||||
print(patch + ': failed: STOPPED') | |||||
raise PatchError(patch) | raise PatchError(patch) | ||||
except Exception: | except Exception: | ||||
if not skip_failing: | if not skip_failing: | ||||
raise | raise | ||||
else: | else: | ||||
log('Failed to execute patch') | |||||
print('Failed to execute patch') | |||||
patches = get_all_patches(patch_type=patch_type) | |||||
for patch in get_all_patches(): | |||||
for patch in patches: | |||||
if patch and (patch not in executed): | if patch and (patch not in executed): | ||||
run_patch(patch) | run_patch(patch) | ||||
@@ -40,18 +79,54 @@ def run_all(skip_failing=False): | |||||
patch = patch.replace('finally:', '') | patch = patch.replace('finally:', '') | ||||
run_patch(patch) | run_patch(patch) | ||||
def get_all_patches(): | |||||
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 = [] | patches = [] | ||||
for app in frappe.get_installed_apps(): | for app in frappe.get_installed_apps(): | ||||
if app == "shopping_cart": | |||||
continue | |||||
# 3-to-4 fix | |||||
if app=="webnotes": | |||||
app="frappe" | |||||
patches.extend(frappe.get_file_items(frappe.get_pymodule_path(app, "patches.txt"))) | |||||
patches.extend(get_patches_from_app(app, patch_type=patch_type)) | |||||
return patches | 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) | |||||
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): | def reload_doc(args): | ||||
import frappe.modules | import frappe.modules | ||||
run_single(method = frappe.modules.reload_doc, methodargs = args) | run_single(method = frappe.modules.reload_doc, methodargs = args) | ||||
@@ -73,7 +148,7 @@ def execute_patch(patchmodule, method=None, methodargs=None): | |||||
frappe.db.begin() | frappe.db.begin() | ||||
start_time = time.time() | start_time = time.time() | ||||
try: | try: | ||||
log('Executing {patch} in {site} ({db})'.format(patch=patchmodule or str(methodargs), | |||||
print('Executing {patch} in {site} ({db})'.format(patch=patchmodule or str(methodargs), | |||||
site=frappe.local.site, db=frappe.db.cur_db_name)) | site=frappe.local.site, db=frappe.db.cur_db_name)) | ||||
if patchmodule: | if patchmodule: | ||||
if patchmodule.startswith("finally:"): | if patchmodule.startswith("finally:"): | ||||
@@ -96,7 +171,7 @@ def execute_patch(patchmodule, method=None, methodargs=None): | |||||
frappe.db.commit() | frappe.db.commit() | ||||
end_time = time.time() | end_time = time.time() | ||||
block_user(False) | block_user(False) | ||||
log('Success: Done in {time}s'.format(time = round(end_time - start_time, 3))) | |||||
print('Success: Done in {time}s'.format(time = round(end_time - start_time, 3))) | |||||
return True | return True | ||||
@@ -109,10 +184,7 @@ def executed(patchmodule): | |||||
if patchmodule.startswith('finally:'): | if patchmodule.startswith('finally:'): | ||||
# patches are saved without the finally: tag | # patches are saved without the finally: tag | ||||
patchmodule = patchmodule.replace('finally:', '') | patchmodule = patchmodule.replace('finally:', '') | ||||
done = frappe.db.get_value("Patch Log", {"patch": patchmodule}) | |||||
# if done: | |||||
# print "Patch %s already executed in %s" % (patchmodule, frappe.db.cur_db_name) | |||||
return done | |||||
return frappe.db.get_value("Patch Log", {"patch": patchmodule}) | |||||
def block_user(block, msg=None): | def block_user(block, msg=None): | ||||
"""stop/start execution till patch is run""" | """stop/start execution till patch is run""" | ||||
@@ -128,6 +200,3 @@ def check_session_stopped(): | |||||
if frappe.db.get_global("__session_status")=='stop': | if frappe.db.get_global("__session_status")=='stop': | ||||
frappe.msgprint(frappe.db.get_global("__session_status_message")) | frappe.msgprint(frappe.db.get_global("__session_status_message")) | ||||
raise frappe.SessionStopped('Session Stopped') | raise frappe.SessionStopped('Session Stopped') | ||||
def log(msg): | |||||
print (msg) |
@@ -1,3 +1,4 @@ | |||||
[pre_model_sync] | |||||
frappe.patches.v12_0.remove_deprecated_fields_from_doctype #3 | frappe.patches.v12_0.remove_deprecated_fields_from_doctype #3 | ||||
execute:frappe.utils.global_search.setup_global_search_table() | execute:frappe.utils.global_search.setup_global_search_table() | ||||
execute:frappe.reload_doc('core', 'doctype', 'doctype_action', force=True) #2019-09-23 | execute:frappe.reload_doc('core', 'doctype', 'doctype_action', force=True) #2019-09-23 | ||||
@@ -87,7 +88,6 @@ frappe.patches.v11_0.set_missing_creation_and_modified_value_for_user_permission | |||||
frappe.patches.v11_0.set_default_letter_head_source | frappe.patches.v11_0.set_default_letter_head_source | ||||
frappe.patches.v12_0.set_primary_key_in_series | frappe.patches.v12_0.set_primary_key_in_series | ||||
execute:frappe.delete_doc("Page", "modules", ignore_missing=True) | execute:frappe.delete_doc("Page", "modules", ignore_missing=True) | ||||
frappe.patches.v11_0.set_default_letter_head_source | |||||
frappe.patches.v12_0.setup_comments_from_communications | frappe.patches.v12_0.setup_comments_from_communications | ||||
frappe.patches.v12_0.replace_null_values_in_tables | frappe.patches.v12_0.replace_null_values_in_tables | ||||
frappe.patches.v12_0.reset_home_settings | frappe.patches.v12_0.reset_home_settings | ||||
@@ -123,7 +123,7 @@ frappe.patches.v12_0.remove_parent_and_parenttype_from_print_formats | |||||
frappe.patches.v12_0.remove_example_email_thread_notify | frappe.patches.v12_0.remove_example_email_thread_notify | ||||
execute:from frappe.desk.page.setup_wizard.install_fixtures import update_genders;update_genders() | execute:from frappe.desk.page.setup_wizard.install_fixtures import update_genders;update_genders() | ||||
frappe.patches.v12_0.set_correct_url_in_files | frappe.patches.v12_0.set_correct_url_in_files | ||||
execute:frappe.reload_doc('core', 'doctype', 'doctype', force=True) | |||||
execute:frappe.reload_doc('core', 'doctype', 'doctype') | |||||
execute:frappe.reload_doc('custom', 'doctype', 'property_setter') | execute:frappe.reload_doc('custom', 'doctype', 'property_setter') | ||||
frappe.patches.v13_0.remove_invalid_options_for_data_fields | frappe.patches.v13_0.remove_invalid_options_for_data_fields | ||||
frappe.patches.v13_0.website_theme_custom_scss | frappe.patches.v13_0.website_theme_custom_scss | ||||
@@ -184,12 +184,14 @@ frappe.patches.v13_0.queryreport_columns | |||||
frappe.patches.v13_0.jinja_hook | frappe.patches.v13_0.jinja_hook | ||||
frappe.patches.v13_0.update_notification_channel_if_empty | frappe.patches.v13_0.update_notification_channel_if_empty | ||||
frappe.patches.v13_0.set_first_day_of_the_week | frappe.patches.v13_0.set_first_day_of_the_week | ||||
frappe.patches.v14_0.drop_data_import_legacy | |||||
frappe.patches.v14_0.rename_cancelled_documents | frappe.patches.v14_0.rename_cancelled_documents | ||||
frappe.patches.v14_0.copy_mail_data #08.03.21 | |||||
frappe.patches.v14_0.update_workspace2 # 20.09.2021 | frappe.patches.v14_0.update_workspace2 # 20.09.2021 | ||||
frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 | |||||
frappe.patches.v14_0.transform_todo_schema | |||||
[post_model_sync] | |||||
frappe.patches.v14_0.drop_data_import_legacy | |||||
frappe.patches.v14_0.copy_mail_data #08.03.21 | |||||
frappe.patches.v14_0.update_github_endpoints #08-11-2021 | frappe.patches.v14_0.update_github_endpoints #08-11-2021 | ||||
frappe.patches.v14_0.remove_db_aggregation | frappe.patches.v14_0.remove_db_aggregation | ||||
frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 | |||||
frappe.patches.v14_0.update_color_names_in_kanban_board_column | frappe.patches.v14_0.update_color_names_in_kanban_board_column | ||||
frappe.patches.v14_0.transform_todo_schema |
@@ -3,9 +3,6 @@ import frappe | |||||
def execute(): | def execute(): | ||||
frappe.reload_doc("email", "doctype", "imap_folder") | |||||
frappe.reload_doc("email", "doctype", "email_account") | |||||
# patch for all Email Account with the flag use_imap | # patch for all Email Account with the flag use_imap | ||||
for email_account in frappe.get_list("Email Account", filters={"enable_incoming": 1, "use_imap": 1}): | for email_account in frappe.get_list("Email Account", filters={"enable_incoming": 1, "use_imap": 1}): | ||||
# get all data from Email Account | # get all data from Email Account | ||||
@@ -5,7 +5,6 @@ from __future__ import unicode_literals | |||||
import frappe | import frappe | ||||
def execute(): | def execute(): | ||||
frappe.reload_doc("desk", "doctype", "kanban_board_column") | |||||
indicator_map = { | indicator_map = { | ||||
'blue': 'Blue', | 'blue': 'Blue', | ||||
'orange': 'Orange', | 'orange': 'Orange', | ||||
@@ -15,3 +15,18 @@ class TestPatches(unittest.TestCase): | |||||
self.assertTrue(frappe.get_attr(patchmodule.split()[0] + ".execute")) | self.assertTrue(frappe.get_attr(patchmodule.split()[0] + ".execute")) | ||||
frappe.flags.in_install = False | frappe.flags.in_install = False | ||||
def test_get_patch_list(self): | |||||
pre = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.pre_model_sync) | |||||
post = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.post_model_sync) | |||||
all_patches = patch_handler.get_patches_from_app("frappe") | |||||
self.assertGreater(len(pre), 0) | |||||
self.assertGreater(len(post), 0) | |||||
self.assertEqual(len(all_patches), len(pre) + len(post)) | |||||
def test_all_patches_are_marked_completed(self): | |||||
all_patches = patch_handler.get_patches_from_app("frappe") | |||||
finished_patches = frappe.db.count("Patch Log") | |||||
self.assertGreaterEqual(finished_patches, len(all_patches)) |
@@ -1,6 +1,11 @@ | |||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors | # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors | ||||
# License: MIT. See LICENSE | # License: MIT. See LICENSE | ||||
import frappe, os, re, git | |||||
import git | |||||
import os | |||||
import re | |||||
import frappe | |||||
from frappe.utils import touch_file, cstr | from frappe.utils import touch_file, cstr | ||||
def make_boilerplate(dest, app_name, no_git=False): | def make_boilerplate(dest, app_name, no_git=False): | ||||