diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 960a59306f..ed5471ac59 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -1,3 +1,4 @@ +# When updating this file, please also update the linter_workflow_template in frappe/utils/boilerplate.py name: Linters on: diff --git a/frappe/tests/test_boilerplate.py b/frappe/tests/test_boilerplate.py index c46a6584c4..4f41e178a2 100644 --- a/frappe/tests/test_boilerplate.py +++ b/frappe/tests/test_boilerplate.py @@ -33,22 +33,20 @@ class TestBoilerPlate(unittest.TestCase): "app_publisher": "Test Publisher", "app_email": "example@example.org", "app_license": "mit", + "branch_name": "develop", "create_github_workflow": False, } ) - cls.default_user_input = frappe._dict( - { - "title": "Test App", - "description": "This app's description contains 'single quotes' and \"double quotes\".", - "publisher": "Test Publisher", - "email": "example@example.org", - "icon": "", # empty -> default - "color": "", - "app_license": "mit", - "github_workflow": "n", - } - ) + cls.default_user_input = [ + "", # title (accept default) + "This app's description contains 'single quotes' and \"double quotes\".", # + "Test Publisher", # publisher + "example@example.org", # email + "", # license (accept default) + "", # create github workflow (accept default) + "develop", # branch name + ] cls.bench_path = frappe.utils.get_bench_path() cls.apps_dir = os.path.join(cls.bench_path, "apps") @@ -86,7 +84,7 @@ class TestBoilerPlate(unittest.TestCase): @staticmethod def get_user_input_stream(inputs): user_inputs = [] - for value in inputs.values(): + for value in inputs: if isinstance(value, list): user_inputs.extend(value) else: @@ -99,14 +97,13 @@ class TestBoilerPlate(unittest.TestCase): self.assertDictEqual(hooks, self.default_hooks) def test_invalid_inputs(self): - invalid_inputs = copy.copy(self.default_user_input).update( - { - "title": ["1nvalid Title", "valid title"], - "email": ["notavalidemail", "what@is@this.email", "example@example.org"], - } - ) + invalid_inputs = copy.copy(self.default_user_input) + invalid_inputs[0] = ["1nvalid Title", "valid title"] + invalid_inputs[3] = ["notavalidemail", "what@is@this.email", "example@example.org"] + with patch("sys.stdin", self.get_user_input_stream(invalid_inputs)): hooks = _get_user_inputs(self.default_hooks.app_name) + self.assertEqual(hooks.app_title, "valid title") self.assertEqual(hooks.app_email, "example@example.org") @@ -118,17 +115,9 @@ class TestBoilerPlate(unittest.TestCase): ) def test_create_app(self): app_name = "test_app" - - 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", - } - ) + hooks = self.default_hooks.copy() + hooks.app_name = app_name + del hooks["create_github_workflow"] self.create_app(hooks) 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): 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) 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" ) def test_new_patch_util(self): - user_inputs = { - "app_name": "frappe", - "doctype": "User", - "docstring": "Delete all users", - "file_name": "", # Accept default - "patch_folder_confirmation": "Y", - } + user_inputs = [ + "frappe", # app name + "User", # doctype + "Delete all users", # docstring + "", # file_name: accept default + "Y", # confirm patch folder + ] patches_txt = pathlib.Path(pathlib.Path(frappe.get_app_path("frappe", "patches.txt"))) original_patches = patches_txt.read_text() diff --git a/frappe/utils/boilerplate.py b/frappe/utils/boilerplate.py index d875a6afad..f7bee66008 100644 --- a/frappe/utils/boilerplate.py +++ b/frappe/utils/boilerplate.py @@ -8,12 +8,14 @@ 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) @@ -56,6 +58,7 @@ def _get_user_inputs(app_name): "default": False, "type": bool, }, + "branch_name": {"prompt": "Branch Name", "default": get_app_branch("frappe")}, } for property, config in new_app_config.items(): @@ -116,6 +119,13 @@ def get_license_text(license_name: str) -> str: 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)), @@ -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: 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}" - ) - ) + 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)) @@ -168,15 +175,24 @@ def _create_app_boilerplate(dest, hooks, no_git=False): 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="develop") + 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") @@ -191,6 +207,10 @@ def _create_github_workflow_files(dest, hooks): 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( ''' @@ -322,6 +342,41 @@ build-backend = "flit_core.buildapi" # These dependencies are only installed when developer mode is enabled [tool.bench.dev-dependencies] # 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}" @@ -672,3 +727,181 @@ patches_template = """[pre_model_sync] [post_model_sync] # 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. + +"""