Browse Source

feat: post model-sync patches (#15351)

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
Ankush Menat 3 years ago
committed by GitHub
parent
commit
fd227d38f4
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 142 additions and 57 deletions
  1. +9
    -8
      frappe/installer.py
  2. +5
    -8
      frappe/migrate.py
  3. +99
    -30
      frappe/modules/patch_handler.py
  4. +8
    -6
      frappe/patches.txt
  5. +0
    -3
      frappe/patches/v14_0/copy_mail_data.py
  6. +0
    -1
      frappe/patches/v14_0/update_color_names_in_kanban_board_column.py
  7. +15
    -0
      frappe/tests/test_patches.py
  8. +6
    -1
      frappe/utils/boilerplate.py

+ 9
- 8
frappe/installer.py View File

@@ -346,14 +346,15 @@ def post_install(rebuild_website=False):


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():


+ 5
- 8
frappe/migrate.py View File

@@ -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.search.website_search import build_index_for_all_routes
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):
@@ -59,16 +61,13 @@ Otherwise, check the server logs and ensure that all the required services are r

clear_global_cache()

#run before_migrate hooks
for app in frappe.get_installed_apps():
for fn in frappe.get_hooks('before_migrate', app_name=app):
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.modules.patch_handler.run_all(skip_failing=skip_failing, patch_type=PatchType.post_model_sync)
frappe.translate.clear_cache()
sync_jobs()
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()

# syncs statics
# syncs static files
clear_website_cache()

# updating installed applications data
frappe.get_single('Installed Applications').update_versions()

#run after_migrate hooks
for app in frappe.get_installed_apps():
for fn in frappe.get_hooks('after_migrate', app_name=app):
frappe.get_attr(fn)()

# build web_routes index
if not skip_search_index:
# Run this last as it updates the current session
print('Building search index for {}'.format(frappe.local.site))


+ 99
- 30
frappe/modules/patch_handler.py View File

@@ -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
"""
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"""
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 = []

def run_patch(patch):
try:
if not run_single(patchmodule = patch):
log(patch + ': failed: STOPPED')
print(patch + ': failed: STOPPED')
raise PatchError(patch)
except Exception:
if not skip_failing:
raise
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):
run_patch(patch)

@@ -40,18 +79,54 @@ def run_all(skip_failing=False):
patch = patch.replace('finally:', '')
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 = []
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

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):
import frappe.modules
run_single(method = frappe.modules.reload_doc, methodargs = args)
@@ -73,7 +148,7 @@ def execute_patch(patchmodule, method=None, methodargs=None):
frappe.db.begin()
start_time = time.time()
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))
if patchmodule:
if patchmodule.startswith("finally:"):
@@ -96,7 +171,7 @@ def execute_patch(patchmodule, method=None, methodargs=None):
frappe.db.commit()
end_time = time.time()
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

@@ -109,10 +184,7 @@ def executed(patchmodule):
if patchmodule.startswith('finally:'):
# patches are saved without the finally: tag
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):
"""stop/start execution till patch is run"""
@@ -128,6 +200,3 @@ 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')

def log(msg):
print (msg)

+ 8
- 6
frappe/patches.txt View File

@@ -1,3 +1,4 @@
[pre_model_sync]
frappe.patches.v12_0.remove_deprecated_fields_from_doctype #3
execute:frappe.utils.global_search.setup_global_search_table()
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.v12_0.set_primary_key_in_series
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.replace_null_values_in_tables
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
execute:from frappe.desk.page.setup_wizard.install_fixtures import update_genders;update_genders()
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')
frappe.patches.v13_0.remove_invalid_options_for_data_fields
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.update_notification_channel_if_empty
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.copy_mail_data #08.03.21
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.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.transform_todo_schema

+ 0
- 3
frappe/patches/v14_0/copy_mail_data.py View File

@@ -3,9 +3,6 @@ import frappe


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
for email_account in frappe.get_list("Email Account", filters={"enable_incoming": 1, "use_imap": 1}):
# get all data from Email Account


+ 0
- 1
frappe/patches/v14_0/update_color_names_in_kanban_board_column.py View File

@@ -5,7 +5,6 @@ from __future__ import unicode_literals
import frappe

def execute():
frappe.reload_doc("desk", "doctype", "kanban_board_column")
indicator_map = {
'blue': 'Blue',
'orange': 'Orange',


+ 15
- 0
frappe/tests/test_patches.py View File

@@ -15,3 +15,18 @@ class TestPatches(unittest.TestCase):
self.assertTrue(frappe.get_attr(patchmodule.split()[0] + ".execute"))

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))

+ 6
- 1
frappe/utils/boilerplate.py View File

@@ -1,6 +1,11 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import frappe, os, re, git

import git
import os
import re

import frappe
from frappe.utils import touch_file, cstr

def make_boilerplate(dest, app_name, no_git=False):


Loading…
Cancel
Save