674 lines
18 KiB
Python
674 lines
18 KiB
Python
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
|
# License: MIT. See LICENSE
|
|
|
|
import contextlib
|
|
import glob
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import textwrap
|
|
|
|
import click
|
|
import git
|
|
import requests
|
|
|
|
import frappe
|
|
|
|
APP_TITLE_PATTERN = re.compile(r"^(?![\W])[^\d_\s][\w -]+$", flags=re.UNICODE)
|
|
|
|
|
|
def make_boilerplate(dest, app_name, no_git=False):
|
|
if not os.path.exists(dest):
|
|
print("Destination directory does not exist")
|
|
return
|
|
|
|
# app_name should be in snake_case
|
|
app_name = frappe.scrub(app_name)
|
|
hooks = _get_user_inputs(app_name)
|
|
_create_app_boilerplate(dest, hooks, no_git=no_git)
|
|
|
|
|
|
def _get_user_inputs(app_name):
|
|
"""Prompt user for various inputs related to new app and return config."""
|
|
app_name = frappe.scrub(app_name)
|
|
|
|
hooks = frappe._dict()
|
|
hooks.app_name = app_name
|
|
app_title = hooks.app_name.replace("_", " ").title()
|
|
|
|
new_app_config = {
|
|
"app_title": {
|
|
"prompt": "App Title",
|
|
"default": app_title,
|
|
"validator": is_valid_title,
|
|
},
|
|
"app_description": {"prompt": "App Description"},
|
|
"app_publisher": {"prompt": "App Publisher"},
|
|
"app_email": {"prompt": "App Email", "validator": is_valid_email},
|
|
"app_license": {
|
|
"prompt": "App License",
|
|
"default": "mit",
|
|
"type": click.Choice(get_license_options()),
|
|
},
|
|
"create_github_workflow": {
|
|
"prompt": "Create GitHub Workflow action for unittests",
|
|
"default": False,
|
|
"type": bool,
|
|
},
|
|
}
|
|
|
|
for property, config in new_app_config.items():
|
|
value = None
|
|
input_type = config.get("type", str)
|
|
|
|
while value is None:
|
|
if input_type == bool:
|
|
value = click.confirm(config["prompt"], default=config.get("default"))
|
|
else:
|
|
value = click.prompt(config["prompt"], default=config.get("default"), type=input_type)
|
|
|
|
if validator_function := config.get("validator"):
|
|
if not validator_function(value):
|
|
value = None
|
|
hooks[property] = value
|
|
|
|
return hooks
|
|
|
|
|
|
def is_valid_email(email) -> bool:
|
|
from email.headerregistry import Address
|
|
|
|
try:
|
|
Address(addr_spec=email)
|
|
except Exception:
|
|
print("App Email should be a valid email address.")
|
|
return False
|
|
return True
|
|
|
|
|
|
def is_valid_title(title) -> bool:
|
|
if not APP_TITLE_PATTERN.match(title):
|
|
print(
|
|
"App Title should start with a letter and it can only consist of letters, numbers, spaces and underscores"
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def get_license_options() -> list[str]:
|
|
url = "https://api.github.com/licenses"
|
|
res = requests.get(url=url)
|
|
if res.status_code == 200:
|
|
res = res.json()
|
|
ids = [r.get("spdx_id") for r in res]
|
|
return [licencse.lower() for licencse in ids]
|
|
|
|
return ["agpl-3.0", "gpl-3.0", "mit", "custom"]
|
|
|
|
|
|
def get_license_text(license_name: str) -> str:
|
|
url = f"https://api.github.com/licenses/{license_name.lower()}"
|
|
res = requests.get(url=url)
|
|
if res.status_code == 200:
|
|
res = res.json()
|
|
return res.get("body")
|
|
return license_name
|
|
|
|
|
|
def _create_app_boilerplate(dest, hooks, no_git=False):
|
|
frappe.create_folder(
|
|
os.path.join(dest, hooks.app_name, hooks.app_name, frappe.scrub(hooks.app_title)),
|
|
with_init=True,
|
|
)
|
|
frappe.create_folder(os.path.join(dest, hooks.app_name, hooks.app_name, "templates"), with_init=True)
|
|
frappe.create_folder(os.path.join(dest, hooks.app_name, hooks.app_name, "www"))
|
|
frappe.create_folder(
|
|
os.path.join(dest, hooks.app_name, hooks.app_name, "templates", "pages"), with_init=True
|
|
)
|
|
frappe.create_folder(os.path.join(dest, hooks.app_name, hooks.app_name, "templates", "includes"))
|
|
frappe.create_folder(os.path.join(dest, hooks.app_name, hooks.app_name, "config"), with_init=True)
|
|
frappe.create_folder(os.path.join(dest, hooks.app_name, hooks.app_name, "public", "css"))
|
|
frappe.create_folder(os.path.join(dest, hooks.app_name, hooks.app_name, "public", "js"))
|
|
|
|
# add .gitkeep file so that public folder is committed to git
|
|
# this is needed because if public doesn't exist, bench build doesn't symlink the apps assets
|
|
with open(os.path.join(dest, hooks.app_name, hooks.app_name, "public", ".gitkeep"), "w") as f:
|
|
f.write("")
|
|
|
|
with open(os.path.join(dest, hooks.app_name, hooks.app_name, "__init__.py"), "w") as f:
|
|
f.write(frappe.as_unicode(init_template))
|
|
|
|
with open(os.path.join(dest, hooks.app_name, "pyproject.toml"), "w") as f:
|
|
f.write(frappe.as_unicode(pyproject_template.format(**hooks)))
|
|
|
|
with open(os.path.join(dest, hooks.app_name, "README.md"), "w") as f:
|
|
f.write(
|
|
frappe.as_unicode(
|
|
f"## {hooks.app_title}\n\n{hooks.app_description}\n\n#### License\n\n{hooks.app_license}"
|
|
)
|
|
)
|
|
license_body = get_license_text(license_name=hooks.app_license)
|
|
with open(os.path.join(dest, hooks.app_name, "license.txt"), "w") as f:
|
|
f.write(frappe.as_unicode(license_body))
|
|
|
|
with open(os.path.join(dest, hooks.app_name, hooks.app_name, "modules.txt"), "w") as f:
|
|
f.write(frappe.as_unicode(hooks.app_title))
|
|
|
|
# These values could contain quotes and can break string declarations
|
|
# So escaping them before setting variables in setup.py and hooks.py
|
|
for key in ("app_publisher", "app_description", "app_license"):
|
|
hooks[key] = hooks[key].replace("\\", "\\\\").replace("'", "\\'").replace('"', '\\"')
|
|
|
|
with open(os.path.join(dest, hooks.app_name, hooks.app_name, "hooks.py"), "w") as f:
|
|
f.write(frappe.as_unicode(hooks_template.format(**hooks)))
|
|
|
|
with open(os.path.join(dest, hooks.app_name, hooks.app_name, "patches.txt"), "w") as f:
|
|
f.write(frappe.as_unicode(patches_template.format(**hooks)))
|
|
|
|
app_directory = os.path.join(dest, hooks.app_name)
|
|
|
|
if hooks.create_github_workflow:
|
|
_create_github_workflow_files(dest, hooks)
|
|
|
|
if not no_git:
|
|
with open(os.path.join(dest, hooks.app_name, ".gitignore"), "w") as f:
|
|
f.write(frappe.as_unicode(gitignore_template.format(app_name=hooks.app_name)))
|
|
|
|
# initialize git repository
|
|
app_repo = git.Repo.init(app_directory, initial_branch="develop")
|
|
app_repo.git.add(A=True)
|
|
app_repo.index.commit("feat: Initialize App")
|
|
|
|
print(f"'{hooks.app_name}' created at {app_directory}")
|
|
|
|
|
|
def _create_github_workflow_files(dest, hooks):
|
|
workflows_path = pathlib.Path(dest) / hooks.app_name / ".github" / "workflows"
|
|
workflows_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
ci_workflow = workflows_path / "ci.yml"
|
|
with open(ci_workflow, "w") as f:
|
|
f.write(github_workflow_template.format(**hooks))
|
|
|
|
|
|
PATCH_TEMPLATE = textwrap.dedent(
|
|
'''
|
|
import frappe
|
|
|
|
def execute():
|
|
"""{docstring}"""
|
|
|
|
# Write your patch here.
|
|
pass
|
|
'''
|
|
)
|
|
|
|
|
|
class PatchCreator:
|
|
def __init__(self):
|
|
self.all_apps = frappe.get_all_apps(sites_path=".", with_internal_apps=False)
|
|
|
|
self.app = None
|
|
self.app_dir = None
|
|
self.patch_dir = None
|
|
self.filename = None
|
|
self.docstring = None
|
|
self.patch_file = None
|
|
|
|
def fetch_user_inputs(self):
|
|
self._ask_app_name()
|
|
self._ask_doctype_name()
|
|
self._ask_patch_meta_info()
|
|
|
|
def _ask_app_name(self):
|
|
self.app = click.prompt("Select app for new patch", type=click.Choice(self.all_apps))
|
|
self.app_dir = pathlib.Path(frappe.get_app_path(self.app))
|
|
|
|
def _ask_doctype_name(self):
|
|
def _doctype_name(filename):
|
|
with contextlib.suppress(Exception):
|
|
with open(filename) as f:
|
|
return json.load(f).get("name")
|
|
|
|
doctype_files = list(glob.glob(f"{self.app_dir}/**/doctype/**/*.json"))
|
|
doctype_map = {_doctype_name(file): file for file in doctype_files}
|
|
doctype_map.pop(None, None)
|
|
|
|
doctype = click.prompt(
|
|
"Provide DocType name on which this patch will apply",
|
|
type=click.Choice(doctype_map.keys()),
|
|
show_choices=False,
|
|
)
|
|
self.patch_dir = pathlib.Path(doctype_map[doctype]).parents[0] / "patches"
|
|
|
|
def _ask_patch_meta_info(self):
|
|
self.docstring = click.prompt("Describe what this patch does", type=str)
|
|
default_filename = frappe.scrub(self.docstring) + ".py"
|
|
|
|
def _valid_filename(name):
|
|
if not name:
|
|
return
|
|
|
|
match name.partition("."):
|
|
case filename, ".", "py" if filename.isidentifier():
|
|
return True
|
|
case _:
|
|
click.echo(f"{name} is not a valid python file name")
|
|
|
|
while not _valid_filename(self.filename):
|
|
self.filename = click.prompt(
|
|
"Provide filename for this patch", type=str, default=default_filename
|
|
)
|
|
|
|
def create_patch_file(self):
|
|
self._create_parent_folder_if_not_exists()
|
|
|
|
self.patch_file = self.patch_dir / self.filename
|
|
|
|
if self.patch_file.exists():
|
|
raise Exception(f"Patch {self.patch_file} already exists")
|
|
|
|
*path, _filename = self.patch_file.relative_to(self.app_dir.parents[0]).parts
|
|
dotted_path = ".".join(path + [self.patch_file.stem])
|
|
|
|
patches_txt = self.app_dir / "patches.txt"
|
|
existing_patches = patches_txt.read_text()
|
|
|
|
if dotted_path in existing_patches:
|
|
raise Exception(f"Patch {dotted_path} is already present in patches.txt")
|
|
|
|
self.patch_file.write_text(PATCH_TEMPLATE.format(docstring=self.docstring))
|
|
|
|
with open(patches_txt, "a+") as f:
|
|
if not existing_patches.endswith("\n"):
|
|
f.write("\n") # ensure EOF
|
|
f.write(dotted_path + "\n")
|
|
click.echo(f"Created {self.patch_file} and updated patches.txt")
|
|
|
|
def _create_parent_folder_if_not_exists(self):
|
|
if not self.patch_dir.exists():
|
|
click.confirm(
|
|
f"Patch folder '{self.patch_dir}' doesn't exist, create it?",
|
|
abort=True,
|
|
default=True,
|
|
)
|
|
self.patch_dir.mkdir()
|
|
|
|
init_py = self.patch_dir / "__init__.py"
|
|
init_py.touch()
|
|
|
|
|
|
init_template = """__version__ = "0.0.1"
|
|
"""
|
|
|
|
pyproject_template = """[project]
|
|
name = "{app_name}"
|
|
authors = [
|
|
{{ name = "{app_publisher}", email = "{app_email}"}}
|
|
]
|
|
description = "{app_description}"
|
|
requires-python = ">=3.10"
|
|
readme = "README.md"
|
|
dynamic = ["version"]
|
|
dependencies = [
|
|
# "frappe~=15.0.0" # Installed and managed by bench.
|
|
]
|
|
|
|
[build-system]
|
|
requires = ["flit_core >=3.4,<4"]
|
|
build-backend = "flit_core.buildapi"
|
|
|
|
# These dependencies are only installed when developer mode is enabled
|
|
[tool.bench.dev-dependencies]
|
|
# package_name = "~=1.1.0"
|
|
"""
|
|
|
|
hooks_template = """app_name = "{app_name}"
|
|
app_title = "{app_title}"
|
|
app_publisher = "{app_publisher}"
|
|
app_description = "{app_description}"
|
|
app_email = "{app_email}"
|
|
app_license = "{app_license}"
|
|
# required_apps = []
|
|
|
|
# Includes in <head>
|
|
# ------------------
|
|
|
|
# include js, css files in header of desk.html
|
|
# app_include_css = "/assets/{app_name}/css/{app_name}.css"
|
|
# app_include_js = "/assets/{app_name}/js/{app_name}.js"
|
|
|
|
# include js, css files in header of web template
|
|
# web_include_css = "/assets/{app_name}/css/{app_name}.css"
|
|
# web_include_js = "/assets/{app_name}/js/{app_name}.js"
|
|
|
|
# include custom scss in every website theme (without file extension ".scss")
|
|
# website_theme_scss = "{app_name}/public/scss/website"
|
|
|
|
# include js, css files in header of web form
|
|
# webform_include_js = {{"doctype": "public/js/doctype.js"}}
|
|
# webform_include_css = {{"doctype": "public/css/doctype.css"}}
|
|
|
|
# include js in page
|
|
# page_js = {{"page" : "public/js/file.js"}}
|
|
|
|
# include js in doctype views
|
|
# doctype_js = {{"doctype" : "public/js/doctype.js"}}
|
|
# doctype_list_js = {{"doctype" : "public/js/doctype_list.js"}}
|
|
# doctype_tree_js = {{"doctype" : "public/js/doctype_tree.js"}}
|
|
# doctype_calendar_js = {{"doctype" : "public/js/doctype_calendar.js"}}
|
|
|
|
# Svg Icons
|
|
# ------------------
|
|
# include app icons in desk
|
|
# app_include_icons = "{app_name}/public/icons.svg"
|
|
|
|
# Home Pages
|
|
# ----------
|
|
|
|
# application home page (will override Website Settings)
|
|
# home_page = "login"
|
|
|
|
# website user home page (by Role)
|
|
# role_home_page = {{
|
|
# "Role": "home_page"
|
|
# }}
|
|
|
|
# Generators
|
|
# ----------
|
|
|
|
# automatically create page for each record of this doctype
|
|
# website_generators = ["Web Page"]
|
|
|
|
# Jinja
|
|
# ----------
|
|
|
|
# add methods and filters to jinja environment
|
|
# jinja = {{
|
|
# "methods": "{app_name}.utils.jinja_methods",
|
|
# "filters": "{app_name}.utils.jinja_filters"
|
|
# }}
|
|
|
|
# Installation
|
|
# ------------
|
|
|
|
# before_install = "{app_name}.install.before_install"
|
|
# after_install = "{app_name}.install.after_install"
|
|
|
|
# Uninstallation
|
|
# ------------
|
|
|
|
# before_uninstall = "{app_name}.uninstall.before_uninstall"
|
|
# after_uninstall = "{app_name}.uninstall.after_uninstall"
|
|
|
|
# Integration Setup
|
|
# ------------------
|
|
# To set up dependencies/integrations with other apps
|
|
# Name of the app being installed is passed as an argument
|
|
|
|
# before_app_install = "{app_name}.utils.before_app_install"
|
|
# after_app_install = "{app_name}.utils.after_app_install"
|
|
|
|
# Integration Cleanup
|
|
# -------------------
|
|
# To clean up dependencies/integrations with other apps
|
|
# Name of the app being uninstalled is passed as an argument
|
|
|
|
# before_app_uninstall = "{app_name}.utils.before_app_uninstall"
|
|
# after_app_uninstall = "{app_name}.utils.after_app_uninstall"
|
|
|
|
# Desk Notifications
|
|
# ------------------
|
|
# See frappe.core.notifications.get_notification_config
|
|
|
|
# notification_config = "{app_name}.notifications.get_notification_config"
|
|
|
|
# Permissions
|
|
# -----------
|
|
# Permissions evaluated in scripted ways
|
|
|
|
# permission_query_conditions = {{
|
|
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
|
|
# }}
|
|
#
|
|
# has_permission = {{
|
|
# "Event": "frappe.desk.doctype.event.event.has_permission",
|
|
# }}
|
|
|
|
# DocType Class
|
|
# ---------------
|
|
# Override standard doctype classes
|
|
|
|
# override_doctype_class = {{
|
|
# "ToDo": "custom_app.overrides.CustomToDo"
|
|
# }}
|
|
|
|
# Document Events
|
|
# ---------------
|
|
# Hook on document methods and events
|
|
|
|
# doc_events = {{
|
|
# "*": {{
|
|
# "on_update": "method",
|
|
# "on_cancel": "method",
|
|
# "on_trash": "method"
|
|
# }}
|
|
# }}
|
|
|
|
# Scheduled Tasks
|
|
# ---------------
|
|
|
|
# scheduler_events = {{
|
|
# "all": [
|
|
# "{app_name}.tasks.all"
|
|
# ],
|
|
# "daily": [
|
|
# "{app_name}.tasks.daily"
|
|
# ],
|
|
# "hourly": [
|
|
# "{app_name}.tasks.hourly"
|
|
# ],
|
|
# "weekly": [
|
|
# "{app_name}.tasks.weekly"
|
|
# ],
|
|
# "monthly": [
|
|
# "{app_name}.tasks.monthly"
|
|
# ],
|
|
# }}
|
|
|
|
# Testing
|
|
# -------
|
|
|
|
# before_tests = "{app_name}.install.before_tests"
|
|
|
|
# Overriding Methods
|
|
# ------------------------------
|
|
#
|
|
# override_whitelisted_methods = {{
|
|
# "frappe.desk.doctype.event.event.get_events": "{app_name}.event.get_events"
|
|
# }}
|
|
#
|
|
# each overriding function accepts a `data` argument;
|
|
# generated from the base implementation of the doctype dashboard,
|
|
# along with any modifications made in other Frappe apps
|
|
# override_doctype_dashboards = {{
|
|
# "Task": "{app_name}.task.get_dashboard_data"
|
|
# }}
|
|
|
|
# exempt linked doctypes from being automatically cancelled
|
|
#
|
|
# auto_cancel_exempted_doctypes = ["Auto Repeat"]
|
|
|
|
# Ignore links to specified DocTypes when deleting documents
|
|
# -----------------------------------------------------------
|
|
|
|
# ignore_links_on_delete = ["Communication", "ToDo"]
|
|
|
|
# Request Events
|
|
# ----------------
|
|
# before_request = ["{app_name}.utils.before_request"]
|
|
# after_request = ["{app_name}.utils.after_request"]
|
|
|
|
# Job Events
|
|
# ----------
|
|
# before_job = ["{app_name}.utils.before_job"]
|
|
# after_job = ["{app_name}.utils.after_job"]
|
|
|
|
# User Data Protection
|
|
# --------------------
|
|
|
|
# user_data_fields = [
|
|
# {{
|
|
# "doctype": "{{doctype_1}}",
|
|
# "filter_by": "{{filter_by}}",
|
|
# "redact_fields": ["{{field_1}}", "{{field_2}}"],
|
|
# "partial": 1,
|
|
# }},
|
|
# {{
|
|
# "doctype": "{{doctype_2}}",
|
|
# "filter_by": "{{filter_by}}",
|
|
# "partial": 1,
|
|
# }},
|
|
# {{
|
|
# "doctype": "{{doctype_3}}",
|
|
# "strict": False,
|
|
# }},
|
|
# {{
|
|
# "doctype": "{{doctype_4}}"
|
|
# }}
|
|
# ]
|
|
|
|
# Authentication and authorization
|
|
# --------------------------------
|
|
|
|
# auth_hooks = [
|
|
# "{app_name}.auth.validate"
|
|
# ]
|
|
|
|
# Automatically update python controller files with type annotations for this app.
|
|
# export_python_type_annotations = True
|
|
|
|
# default_log_clearing_doctypes = {{
|
|
# "Logging DocType Name": 30 # days to retain logs
|
|
# }}
|
|
|
|
"""
|
|
|
|
gitignore_template = """.DS_Store
|
|
*.pyc
|
|
*.egg-info
|
|
*.swp
|
|
tags
|
|
node_modules
|
|
__pycache__"""
|
|
|
|
github_workflow_template = """
|
|
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- develop
|
|
pull_request:
|
|
|
|
concurrency:
|
|
group: develop-{app_name}-${{{{ github.event.number }}}}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
tests:
|
|
runs-on: ubuntu-latest
|
|
strategy:
|
|
fail-fast: false
|
|
name: Server
|
|
|
|
services:
|
|
redis-cache:
|
|
image: redis:alpine
|
|
ports:
|
|
- 13000:6379
|
|
redis-queue:
|
|
image: redis:alpine
|
|
ports:
|
|
- 11000:6379
|
|
mariadb:
|
|
image: mariadb:10.6
|
|
env:
|
|
MYSQL_ROOT_PASSWORD: root
|
|
ports:
|
|
- 3306:3306
|
|
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
|
|
|
steps:
|
|
- name: Clone
|
|
uses: actions/checkout@v3
|
|
|
|
- name: Setup Python
|
|
uses: actions/setup-python@v4
|
|
with:
|
|
python-version: '3.10'
|
|
|
|
- name: Setup Node
|
|
uses: actions/setup-node@v3
|
|
with:
|
|
node-version: 18
|
|
check-latest: true
|
|
|
|
- name: Cache pip
|
|
uses: actions/cache@v2
|
|
with:
|
|
path: ~/.cache/pip
|
|
key: ${{{{ runner.os }}}}-pip-${{{{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }}}}
|
|
restore-keys: |
|
|
${{{{ runner.os }}}}-pip-
|
|
${{{{ runner.os }}}}-
|
|
|
|
- name: Get yarn cache directory path
|
|
id: yarn-cache-dir-path
|
|
run: 'echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT'
|
|
|
|
- uses: actions/cache@v3
|
|
id: yarn-cache
|
|
with:
|
|
path: ${{{{ steps.yarn-cache-dir-path.outputs.dir }}}}
|
|
key: ${{{{ runner.os }}}}-yarn-${{{{ hashFiles('**/yarn.lock') }}}}
|
|
restore-keys: |
|
|
${{{{ runner.os }}}}-yarn-
|
|
|
|
- name: Install MariaDB Client
|
|
run: sudo apt-get install mariadb-client-10.6
|
|
|
|
- name: Setup
|
|
run: |
|
|
pip install frappe-bench
|
|
bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench
|
|
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
|
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
|
|
|
- name: Install
|
|
working-directory: /home/runner/frappe-bench
|
|
run: |
|
|
bench get-app {app_name} $GITHUB_WORKSPACE
|
|
bench setup requirements --dev
|
|
bench new-site --db-root-password root --admin-password admin test_site
|
|
bench --site test_site install-app {app_name}
|
|
bench build
|
|
env:
|
|
CI: 'Yes'
|
|
|
|
- name: Run Tests
|
|
working-directory: /home/runner/frappe-bench
|
|
run: |
|
|
bench --site test_site set-config allow_tests true
|
|
bench --site test_site run-tests --app {app_name}
|
|
env:
|
|
TYPE: server
|
|
"""
|
|
|
|
patches_template = """[pre_model_sync]
|
|
# Patches added in this section will be executed before doctypes are migrated
|
|
# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations
|
|
|
|
[post_model_sync]
|
|
# Patches added in this section will be executed after doctypes are migrated"""
|