# 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 from pathlib import Path import click import git import requests import frappe from frappe.utils.change_log import get_app_branch 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, }, "branch_name": {"prompt": "Branch Name", "default": get_app_branch("frappe")}, } for property, config in new_app_config.items(): value = None input_type = config.get("type", str) while value is None: if input_type is 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" try: res = requests.get(url=url) except requests.exceptions.RequestException: return ["agpl-3.0", "gpl-3.0", "mit", "custom"] 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()}" try: res = requests.get(url=url) except requests.exceptions.RequestException: return "No license text found" if res.status_code == 200: res = res.json() return res.get("body") return license_name def copy_from_frappe(rel_path: str, new_app_path: str): """Copy files from frappe app to new app.""" src = Path(frappe.get_app_path("frappe", "..")) / rel_path target = Path(new_app_path) / rel_path Path(target).write_text(Path(src).read_text()) 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")) frappe.create_folder(os.path.join(dest, hooks.app_name, hooks.app_name, "patches"), with_init=True) # 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, ".pre-commit-config.yaml"), "w") as f: f.write(frappe.as_unicode(precommit_template.format(**hooks))) 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, frappe.scrub(hooks.app_title), ".frappe"), "w" ) as f: f.write("") from frappe.deprecation_dumpster import boilerplate_modules_txt boilerplate_modules_txt(dest, hooks.app_name, 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) copy_from_frappe(".editorconfig", app_directory) copy_from_frappe(".eslintrc", app_directory) if hooks.create_github_workflow: _create_github_workflow_files(dest, hooks) hooks.readme_ci_section = readme_ci_section else: hooks.readme_ci_section = "" with open(os.path.join(dest, hooks.app_name, "README.md"), "w") as f: f.write(frappe.as_unicode(readme_template.format(**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=hooks.branch_name) 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)) linter_workflow = workflows_path / "linter.yml" with open(linter_workflow, "w") as f: f.write(linter_workflow_template) 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.14" readme = "README.md" dynamic = ["version"] dependencies = [ # "frappe~=16.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" # These apt dependencies will be installed from Ubuntu repositories when you host your app on Frappe Cloud [deploy.dependencies.apt] packages = [] [tool.ruff] line-length = 110 target-version = "py314" [tool.ruff.lint] select = [ "F", "E", "W", "I", "UP", "B", "RUF", ] ignore = [ "B017", # assertRaises(Exception) - should be more specific "B018", # useless expression, not assigned to anything "B023", # function doesn't bind loop variable - will have last iteration's value "B904", # raise inside except without from "E101", # indentation contains mixed spaces and tabs "E402", # module level import not at top of file "E501", # line too long "E741", # ambiguous variable name "F401", # "unused" imports "F403", # can't detect undefined names from * import "F405", # can't detect undefined names from * import "F722", # syntax error in forward type annotation "W191", # indentation contains tabs "UP030", # Use implicit references for positional format fields (translations) "UP031", # Use format specifiers instead of percent format "UP032", # Use f-string instead of `format` call (translations) "UP037", # quoted annotations "UP040", # Use type aliases instead of type annotations ] typing-modules = ["frappe.types.DF"] [tool.ruff.format] quote-style = "double" indent-style = "tab" docstring-code-format = true """ 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}" # Apps # ------------------ # required_apps = [] # Each item in the list will be shown as an app in the apps page # add_to_apps_screen = [ # {{ # "name": "{app_name}", # "logo": "/assets/{app_name}/logo.png", # "title": "{app_title}", # "route": "/{app_name}", # "has_permission": "{app_name}.api.permission.has_app_permission" # }} # ] # Includes in # ------------------ # 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"] # automatically load and sync documents of this doctype from downstream apps # importable_doctypes = [doctype_1] # 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" # Build # ------------------ # To hook into the build process # after_build = "{app_name}.build.after_build" # 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", # }} # 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" # Extend DocType Class # ------------------------------ # # Specify custom mixins to extend the standard doctype controller. # extend_doctype_class = {{ # "Task": "{app_name}.custom.task.CustomTaskMixin" # }} # 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 # Require all whitelisted methods to have type annotations require_type_annotated_api_methods = True # default_log_clearing_doctypes = {{ # "Logging DocType Name": 30 # days to retain logs # }} # Translation # ------------ # List of apps whose translatable strings should be excluded from this app's translations. # ignore_translatable_strings_from = [] """ gitignore_template = """# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class *.pyc *.py~ # Distribution / packaging .Python develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg tags MANIFEST # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Dependency directories node_modules/ jspm_packages/ # IDEs and editors .vscode/ .vs/ .idea/ .kdev4/ *.kdev4 *.DS_Store *.swp *.comp.js .wnf-lang-status *debug.log # Helix Editor .helix/ # Aider AI Chat .aider* """ github_workflow_template = """name: CI on: push: branches: - {branch_name} pull_request: concurrency: group: {branch_name}-{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:11.8 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@v6 - name: Find tests run: | echo "Finding tests" grep -rn "def test" > /dev/null - name: Setup Python uses: actions/setup-python@v6 with: python-version: '3.14' - name: Setup Node uses: actions/setup-node@v6 with: node-version: 24 check-latest: true - name: Cache pip uses: actions/cache@v4 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@v4 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 update sudo apt-get install mariadb-client - name: Setup run: | pip install frappe-bench bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench - 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://docs.frappe.io/framework/user/en/database-migrations [post_model_sync] # Patches added in this section will be executed after doctypes are migrated""" precommit_template = """exclude: 'node_modules|.git' default_stages: [pre-commit] fail_fast: false repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace files: "{app_name}.*" exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" - id: check-merge-conflict - id: check-ast - id: check-json - id: check-toml - id: check-yaml - id: debug-statements - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.10 hooks: - id: ruff name: "Run ruff import sorter" args: ["--select=I", "--fix"] - id: ruff name: "Run ruff linter" - id: ruff-format name: "Run ruff formatter" - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.7.1 hooks: - id: prettier types_or: [javascript, vue, scss] # Ignore any files that might contain jinja / bundles exclude: | (?x)^( {app_name}/public/dist/.*| .*node_modules.*| .*boilerplate.*| {app_name}/templates/includes/.*| {app_name}/public/js/lib/.* )$ - repo: https://github.com/pre-commit/mirrors-eslint rev: v8.44.0 hooks: - id: eslint types_or: [javascript] args: ['--quiet'] # Ignore any files that might contain jinja / bundles exclude: | (?x)^( {app_name}/public/dist/.*| cypress/.*| .*node_modules.*| .*boilerplate.*| {app_name}/templates/includes/.*| {app_name}/public/js/lib/.* )$ ci: autoupdate_schedule: weekly skip: [] submodules: false """ linter_workflow_template = """name: Linters on: pull_request: workflow_dispatch: permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: linter: name: 'Frappe Linter' runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: '3.14' cache: pip - uses: pre-commit/action@v3.0.0 - name: Download Semgrep rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules - name: Run Semgrep rules run: | pip install semgrep semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness deps-vulnerable-check: name: 'Vulnerable Dependency Check' runs-on: ubuntu-latest steps: - uses: actions/setup-python@v6 with: python-version: '3.14' - uses: actions/checkout@v6 - name: Cache pip uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }} restore-keys: | ${{ runner.os }}-pip- ${{ runner.os }}- - name: Install and run pip-audit run: | pip install pip-audit cd ${GITHUB_WORKSPACE} pip-audit --desc on . """ readme_template = """### {app_title} {app_description} ### Installation You can install this app using the [bench](https://github.com/frappe/bench) CLI: ```bash cd $PATH_TO_YOUR_BENCH bench get-app $URL_OF_THIS_REPO --branch {branch_name} bench install-app {app_name} ``` ### Contributing This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository: ```bash cd apps/{app_name} pre-commit install ``` Pre-commit is configured to use the following tools for checking and formatting your code: - ruff - eslint - prettier - pyupgrade {readme_ci_section} ### License {app_license} """ readme_ci_section = """### CI This app can use GitHub Actions for CI. The following workflows are configured: - CI: Installs this app and runs unit tests on every push to `develop` branch. - Linters: Runs [Frappe Semgrep Rules](https://github.com/frappe/semgrep-rules) and [pip-audit](https://pypi.org/project/pip-audit/) on every pull request. """