Merge pull request #23848 from barredterra/copy-config-to-new-app
feat: default configuration for new app
This commit is contained in:
commit
e6120f230a
3 changed files with 270 additions and 54 deletions
1
.github/workflows/linters.yml
vendored
1
.github/workflows/linters.yml
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
|
# When updating this file, please also update the linter_workflow_template in frappe/utils/boilerplate.py
|
||||||
name: Linters
|
name: Linters
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|
|
||||||
|
|
@ -33,22 +33,20 @@ class TestBoilerPlate(unittest.TestCase):
|
||||||
"app_publisher": "Test Publisher",
|
"app_publisher": "Test Publisher",
|
||||||
"app_email": "example@example.org",
|
"app_email": "example@example.org",
|
||||||
"app_license": "mit",
|
"app_license": "mit",
|
||||||
|
"branch_name": "develop",
|
||||||
"create_github_workflow": False,
|
"create_github_workflow": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.default_user_input = frappe._dict(
|
cls.default_user_input = [
|
||||||
{
|
"", # title (accept default)
|
||||||
"title": "Test App",
|
"This app's description contains 'single quotes' and \"double quotes\".", #
|
||||||
"description": "This app's description contains 'single quotes' and \"double quotes\".",
|
"Test Publisher", # publisher
|
||||||
"publisher": "Test Publisher",
|
"example@example.org", # email
|
||||||
"email": "example@example.org",
|
"", # license (accept default)
|
||||||
"icon": "", # empty -> default
|
"", # create github workflow (accept default)
|
||||||
"color": "",
|
"develop", # branch name
|
||||||
"app_license": "mit",
|
]
|
||||||
"github_workflow": "n",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
cls.bench_path = frappe.utils.get_bench_path()
|
cls.bench_path = frappe.utils.get_bench_path()
|
||||||
cls.apps_dir = os.path.join(cls.bench_path, "apps")
|
cls.apps_dir = os.path.join(cls.bench_path, "apps")
|
||||||
|
|
@ -86,7 +84,7 @@ class TestBoilerPlate(unittest.TestCase):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_user_input_stream(inputs):
|
def get_user_input_stream(inputs):
|
||||||
user_inputs = []
|
user_inputs = []
|
||||||
for value in inputs.values():
|
for value in inputs:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
user_inputs.extend(value)
|
user_inputs.extend(value)
|
||||||
else:
|
else:
|
||||||
|
|
@ -99,14 +97,13 @@ class TestBoilerPlate(unittest.TestCase):
|
||||||
self.assertDictEqual(hooks, self.default_hooks)
|
self.assertDictEqual(hooks, self.default_hooks)
|
||||||
|
|
||||||
def test_invalid_inputs(self):
|
def test_invalid_inputs(self):
|
||||||
invalid_inputs = copy.copy(self.default_user_input).update(
|
invalid_inputs = copy.copy(self.default_user_input)
|
||||||
{
|
invalid_inputs[0] = ["1nvalid Title", "valid title"]
|
||||||
"title": ["1nvalid Title", "valid title"],
|
invalid_inputs[3] = ["notavalidemail", "what@is@this.email", "example@example.org"]
|
||||||
"email": ["notavalidemail", "what@is@this.email", "example@example.org"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
with patch("sys.stdin", self.get_user_input_stream(invalid_inputs)):
|
with patch("sys.stdin", self.get_user_input_stream(invalid_inputs)):
|
||||||
hooks = _get_user_inputs(self.default_hooks.app_name)
|
hooks = _get_user_inputs(self.default_hooks.app_name)
|
||||||
|
|
||||||
self.assertEqual(hooks.app_title, "valid title")
|
self.assertEqual(hooks.app_title, "valid title")
|
||||||
self.assertEqual(hooks.app_email, "example@example.org")
|
self.assertEqual(hooks.app_email, "example@example.org")
|
||||||
|
|
||||||
|
|
@ -118,17 +115,9 @@ class TestBoilerPlate(unittest.TestCase):
|
||||||
)
|
)
|
||||||
def test_create_app(self):
|
def test_create_app(self):
|
||||||
app_name = "test_app"
|
app_name = "test_app"
|
||||||
|
hooks = self.default_hooks.copy()
|
||||||
hooks = frappe._dict(
|
hooks.app_name = app_name
|
||||||
{
|
del hooks["create_github_workflow"]
|
||||||
"app_name": app_name,
|
|
||||||
"app_title": "Test App",
|
|
||||||
"app_description": "This app's description contains 'single quotes' and \"double quotes\".",
|
|
||||||
"app_publisher": "Test Publisher",
|
|
||||||
"app_email": "example@example.org",
|
|
||||||
"app_license": "mit",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.create_app(hooks)
|
self.create_app(hooks)
|
||||||
new_app_dir = os.path.join(self.bench_path, self.apps_dir, app_name)
|
new_app_dir = os.path.join(self.bench_path, self.apps_dir, app_name)
|
||||||
|
|
@ -152,17 +141,10 @@ class TestBoilerPlate(unittest.TestCase):
|
||||||
)
|
)
|
||||||
def test_create_app_without_git_init(self):
|
def test_create_app_without_git_init(self):
|
||||||
app_name = "test_app_no_git"
|
app_name = "test_app_no_git"
|
||||||
|
hooks = self.default_hooks.copy()
|
||||||
|
hooks.app_name = app_name
|
||||||
|
del hooks["create_github_workflow"]
|
||||||
|
|
||||||
hooks = frappe._dict(
|
|
||||||
{
|
|
||||||
"app_name": app_name,
|
|
||||||
"app_title": "Test App",
|
|
||||||
"app_description": "This app's description contains 'single quotes' and \"double quotes\".",
|
|
||||||
"app_publisher": "Test Publisher",
|
|
||||||
"app_email": "example@example.org",
|
|
||||||
"app_license": "mit",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.create_app(hooks, no_git=True)
|
self.create_app(hooks, no_git=True)
|
||||||
|
|
||||||
new_app_dir = os.path.join(self.apps_dir, app_name)
|
new_app_dir = os.path.join(self.apps_dir, app_name)
|
||||||
|
|
@ -198,13 +180,13 @@ class TestBoilerPlate(unittest.TestCase):
|
||||||
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
|
os.access(frappe.get_app_path("frappe"), os.W_OK), "Only run if frappe app paths is writable"
|
||||||
)
|
)
|
||||||
def test_new_patch_util(self):
|
def test_new_patch_util(self):
|
||||||
user_inputs = {
|
user_inputs = [
|
||||||
"app_name": "frappe",
|
"frappe", # app name
|
||||||
"doctype": "User",
|
"User", # doctype
|
||||||
"docstring": "Delete all users",
|
"Delete all users", # docstring
|
||||||
"file_name": "", # Accept default
|
"", # file_name: accept default
|
||||||
"patch_folder_confirmation": "Y",
|
"Y", # confirm patch folder
|
||||||
}
|
]
|
||||||
|
|
||||||
patches_txt = pathlib.Path(pathlib.Path(frappe.get_app_path("frappe", "patches.txt")))
|
patches_txt = pathlib.Path(pathlib.Path(frappe.get_app_path("frappe", "patches.txt")))
|
||||||
original_patches = patches_txt.read_text()
|
original_patches = patches_txt.read_text()
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import textwrap
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import git
|
import git
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
from frappe.utils.change_log import get_app_branch
|
||||||
|
|
||||||
APP_TITLE_PATTERN = re.compile(r"^(?![\W])[^\d_\s][\w -]+$", flags=re.UNICODE)
|
APP_TITLE_PATTERN = re.compile(r"^(?![\W])[^\d_\s][\w -]+$", flags=re.UNICODE)
|
||||||
|
|
||||||
|
|
@ -56,6 +58,7 @@ def _get_user_inputs(app_name):
|
||||||
"default": False,
|
"default": False,
|
||||||
"type": bool,
|
"type": bool,
|
||||||
},
|
},
|
||||||
|
"branch_name": {"prompt": "Branch Name", "default": get_app_branch("frappe")},
|
||||||
}
|
}
|
||||||
|
|
||||||
for property, config in new_app_config.items():
|
for property, config in new_app_config.items():
|
||||||
|
|
@ -116,6 +119,13 @@ def get_license_text(license_name: str) -> str:
|
||||||
return license_name
|
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):
|
def _create_app_boilerplate(dest, hooks, no_git=False):
|
||||||
frappe.create_folder(
|
frappe.create_folder(
|
||||||
os.path.join(dest, hooks.app_name, hooks.app_name, frappe.scrub(hooks.app_title)),
|
os.path.join(dest, hooks.app_name, hooks.app_name, frappe.scrub(hooks.app_title)),
|
||||||
|
|
@ -142,12 +152,9 @@ def _create_app_boilerplate(dest, hooks, no_git=False):
|
||||||
with open(os.path.join(dest, hooks.app_name, "pyproject.toml"), "w") as f:
|
with open(os.path.join(dest, hooks.app_name, "pyproject.toml"), "w") as f:
|
||||||
f.write(frappe.as_unicode(pyproject_template.format(**hooks)))
|
f.write(frappe.as_unicode(pyproject_template.format(**hooks)))
|
||||||
|
|
||||||
with open(os.path.join(dest, hooks.app_name, "README.md"), "w") as f:
|
with open(os.path.join(dest, hooks.app_name, ".pre-commit-config.yaml"), "w") as f:
|
||||||
f.write(
|
f.write(frappe.as_unicode(precommit_template.format(**hooks)))
|
||||||
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)
|
license_body = get_license_text(license_name=hooks.app_license)
|
||||||
with open(os.path.join(dest, hooks.app_name, "license.txt"), "w") as f:
|
with open(os.path.join(dest, hooks.app_name, "license.txt"), "w") as f:
|
||||||
f.write(frappe.as_unicode(license_body))
|
f.write(frappe.as_unicode(license_body))
|
||||||
|
|
@ -168,15 +175,24 @@ def _create_app_boilerplate(dest, hooks, no_git=False):
|
||||||
|
|
||||||
app_directory = os.path.join(dest, hooks.app_name)
|
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:
|
if hooks.create_github_workflow:
|
||||||
_create_github_workflow_files(dest, hooks)
|
_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:
|
if not no_git:
|
||||||
with open(os.path.join(dest, hooks.app_name, ".gitignore"), "w") as f:
|
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)))
|
f.write(frappe.as_unicode(gitignore_template.format(app_name=hooks.app_name)))
|
||||||
|
|
||||||
# initialize git repository
|
# initialize git repository
|
||||||
app_repo = git.Repo.init(app_directory, initial_branch="develop")
|
app_repo = git.Repo.init(app_directory, initial_branch=hooks.branch_name)
|
||||||
app_repo.git.add(A=True)
|
app_repo.git.add(A=True)
|
||||||
app_repo.index.commit("feat: Initialize App")
|
app_repo.index.commit("feat: Initialize App")
|
||||||
|
|
||||||
|
|
@ -191,6 +207,10 @@ def _create_github_workflow_files(dest, hooks):
|
||||||
with open(ci_workflow, "w") as f:
|
with open(ci_workflow, "w") as f:
|
||||||
f.write(github_workflow_template.format(**hooks))
|
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(
|
PATCH_TEMPLATE = textwrap.dedent(
|
||||||
'''
|
'''
|
||||||
|
|
@ -322,6 +342,41 @@ build-backend = "flit_core.buildapi"
|
||||||
# These dependencies are only installed when developer mode is enabled
|
# These dependencies are only installed when developer mode is enabled
|
||||||
[tool.bench.dev-dependencies]
|
[tool.bench.dev-dependencies]
|
||||||
# package_name = "~=1.1.0"
|
# package_name = "~=1.1.0"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 110
|
||||||
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"F",
|
||||||
|
"E",
|
||||||
|
"W",
|
||||||
|
"I",
|
||||||
|
"UP",
|
||||||
|
"B",
|
||||||
|
]
|
||||||
|
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
|
||||||
|
"F821", # undefined name
|
||||||
|
"W191", # indentation contains tabs
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "tab"
|
||||||
|
docstring-code-format = true
|
||||||
"""
|
"""
|
||||||
|
|
||||||
hooks_template = """app_name = "{app_name}"
|
hooks_template = """app_name = "{app_name}"
|
||||||
|
|
@ -672,3 +727,181 @@ patches_template = """[pre_model_sync]
|
||||||
|
|
||||||
[post_model_sync]
|
[post_model_sync]
|
||||||
# Patches added in this section will be executed after doctypes are migrated"""
|
# Patches added in this section will be executed after doctypes are migrated"""
|
||||||
|
|
||||||
|
|
||||||
|
precommit_template = """exclude: 'node_modules|.git'
|
||||||
|
default_stages: [commit]
|
||||||
|
fail_fast: false
|
||||||
|
|
||||||
|
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.3.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
files: "{app_name}.*"
|
||||||
|
exclude: ".*json$|.*txt$|.*csv|.*md|.*svg"
|
||||||
|
- id: check-yaml
|
||||||
|
- 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.2.0
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
name: "Run ruff linter and apply fixes"
|
||||||
|
args: ["--fix"]
|
||||||
|
|
||||||
|
- id: ruff-format
|
||||||
|
name: "Format Python code"
|
||||||
|
|
||||||
|
- 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@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.10'
|
||||||
|
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@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.10'
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache pip
|
||||||
|
uses: actions/cache@v3
|
||||||
|
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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue