diff --git a/frappe/hooks.py b/frappe/hooks.py index f7a67dc7ec..ee8417a3ec 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -4,8 +4,6 @@ app_name = "frappe" app_title = "Frappe Framework" app_publisher = "Frappe Technologies" app_description = "Full stack web framework with Python, Javascript, MariaDB, Redis, Node" -app_icon = "octicon octicon-circuit-board" -app_color = "orange" source_link = "https://github.com/frappe/frappe" app_license = "MIT" app_logo_url = "/assets/frappe/images/frappe-framework-logo.svg" diff --git a/frappe/tests/test_boilerplate.py b/frappe/tests/test_boilerplate.py index 4dfb6e615b..092a1ff126 100644 --- a/frappe/tests/test_boilerplate.py +++ b/frappe/tests/test_boilerplate.py @@ -1,38 +1,52 @@ import ast +import copy import glob import os import shutil import unittest +from io import StringIO from unittest.mock import patch +import yaml + import frappe -from frappe.utils.boilerplate import make_boilerplate +from frappe.utils.boilerplate import ( + _create_app_boilerplate, + _get_user_inputs, + github_workflow_template, +) class TestBoilerPlate(unittest.TestCase): @classmethod def setUpClass(cls): - 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" + cls.default_hooks = frappe._dict( + { + "app_name": "test_app", + "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", + "create_github_workflow": False, + } + ) - cls.user_input = [ - title, - description, - publisher, - email, - icon, - color, - app_license, - ] + 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.bench_path = frappe.utils.get_bench_path() cls.apps_dir = os.path.join(cls.bench_path, "apps") - cls.app_names = ("test_app", "test_app_no_git") cls.gitignore_file = ".gitignore" cls.git_folder = ".git" @@ -55,39 +69,90 @@ class TestBoilerPlate(unittest.TestCase): "public", ] + def create_app(self, hooks, no_git=False): + self.addCleanup(self.delete_test_app, hooks.app_name) + _create_app_boilerplate(self.apps_dir, hooks, no_git) + @classmethod - def tearDownClass(cls): - test_app_dirs = (os.path.join(cls.bench_path, "apps", app_name) for app_name in cls.app_names) - for test_app_dir in test_app_dirs: - if os.path.exists(test_app_dir): - shutil.rmtree(test_app_dir) + def delete_test_app(cls, app_name): + test_app_dir = os.path.join(cls.bench_path, "apps", app_name) + if os.path.exists(test_app_dir): + shutil.rmtree(test_app_dir) + + @staticmethod + def get_user_input_stream(inputs): + user_inputs = [] + for value in inputs.values(): + if isinstance(value, list): + user_inputs.extend(value) + else: + user_inputs.append(value) + return StringIO("\n".join(user_inputs)) + + def test_simple_input_to_boilerplate(self): + with patch("sys.stdin", self.get_user_input_stream(self.default_user_input)): + hooks = _get_user_inputs(self.default_hooks.app_name) + 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"], + } + ) + 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") + + def test_valid_ci_yaml(self): + yaml.safe_load(github_workflow_template.format(**self.default_hooks)) def test_create_app(self): - with patch("builtins.input", side_effect=self.user_input): - make_boilerplate(self.apps_dir, self.app_names[0]) + app_name = "test_app" - new_app_dir = os.path.join(self.bench_path, self.apps_dir, self.app_names[0]) + 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", + } + ) - paths = self.get_paths(new_app_dir, self.app_names[0]) + self.create_app(hooks) + new_app_dir = os.path.join(self.bench_path, self.apps_dir, app_name) + + paths = self.get_paths(new_app_dir, app_name) for path in paths: - self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {self.app_names[0]} app") + self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {app_name} app") self.check_parsable_python_files(new_app_dir) def test_create_app_without_git_init(self): - with patch("builtins.input", side_effect=self.user_input): - make_boilerplate(self.apps_dir, self.app_names[1], no_git=True) + app_name = "test_app_no_git" - new_app_dir = os.path.join(self.apps_dir, self.app_names[1]) + 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) - paths = self.get_paths(new_app_dir, self.app_names[1]) + new_app_dir = os.path.join(self.apps_dir, app_name) + + paths = self.get_paths(new_app_dir, app_name) for path in paths: if os.path.basename(path) in (self.git_folder, self.gitignore_file): - self.assertFalse( - os.path.exists(path), msg=f"{path} shouldn't exist in {self.app_names[1]} app" - ) + self.assertFalse(os.path.exists(path), msg=f"{path} shouldn't exist in {app_name} app") else: - self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {self.app_names[1]} app") + self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {app_name} app") self.check_parsable_python_files(new_app_dir) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 67de6c6e41..d4a86bd111 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -413,26 +413,6 @@ class TestCommands(BaseTestCommands): self.assertEqual(self.returncode, 0) self.assertEqual(check_password("Administrator", "test2"), "Administrator") - def test_make_app(self): - user_input = [ - b"Test App", # title - b"This app's description contains 'single quotes' and \"double quotes\".", # description - b"Test Publisher", # publisher - b"example@example.org", # email - b"", # icon - b"", # color - b"MIT", # app_license - ] - app_name = "testapp0" - apps_path = os.path.join(get_bench_path(), "apps") - test_app_path = os.path.join(apps_path, app_name) - self.execute(f"bench make-app {apps_path} {app_name}", {"cmd_input": b"\n".join(user_input)}) - self.assertEqual(self.returncode, 0) - self.assertTrue(os.path.exists(test_app_path)) - - # cleanup - shutil.rmtree(test_app_path) - @skipIf( not ( frappe.conf.root_password and frappe.conf.admin_password and frappe.conf.db_type == "mariadb" diff --git a/frappe/utils/boilerplate.py b/frappe/utils/boilerplate.py index f5243ecc95..22ca64eb1a 100644 --- a/frappe/utils/boilerplate.py +++ b/frappe/utils/boilerplate.py @@ -2,12 +2,14 @@ # License: MIT. See LICENSE import os +import pathlib import re +import click import git import frappe -from frappe.utils import cstr, touch_file +from frappe.utils import touch_file def make_boilerplate(dest, app_name, no_git=False): @@ -17,47 +19,63 @@ def make_boilerplate(dest, app_name, no_git=False): # 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() - for key in ( - "App Title (default: {0})".format(app_title), - "App Description", - "App Publisher", - "App Email", - "App Icon (default 'octicon octicon-file-directory')", - "App Color (default 'grey')", - "App License (default 'MIT')", - ): - hook_key = key.split(" (")[0].lower().replace(" ", "_") - hook_val = None - while not hook_val: - hook_val = cstr(input(key + ": ")) - if not hook_val: - defaults = { - "app_title": app_title, - "app_icon": "octicon octicon-file-directory", - "app_color": "grey", - "app_license": "MIT", - } - if hook_key in defaults: - hook_val = defaults[hook_key] + 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"}, + "app_license": {"prompt": "App License", "default": "MIT"}, + "create_github_workflow": { + "prompt": "Create GitHub Workflow action for unittests", + "default": False, + "type": bool, + }, + } - if hook_key == "app_name" and hook_val.lower().replace(" ", "_") != hook_val: - print("App Name must be all lowercase and without spaces") - hook_val = "" - elif hook_key == "app_title" and not re.match( - r"^(?![\W])[^\d_\s][\w -]+$", hook_val, re.UNICODE - ): - print( - "App Title should start with a letter and it can only consist of letters, numbers, spaces and underscores" - ) - hook_val = "" + for property, config in new_app_config.items(): + value = None + input_type = config.get("type", str) - hooks[hook_key] = hook_val + 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_title(title) -> bool: + if not re.match(r"^(?![\W])[^\d_\s][\w -]+$", title, re.UNICODE): + print( + "App Title should start with a letter and it can only consist of letters, numbers, spaces and underscores" + ) + return False + return True + + +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 ) @@ -123,6 +141,9 @@ def make_boilerplate(dest, app_name, no_git=False): 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))) @@ -132,7 +153,16 @@ def make_boilerplate(dest, app_name, no_git=False): app_repo.git.add(A=True) app_repo.index.commit("feat: Initialize App") - print("'{app}' created at {path}".format(app=app_name, path=app_directory)) + 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)) manifest_template = """include MANIFEST.in @@ -165,8 +195,6 @@ app_name = "{app_name}" app_title = "{app_title}" app_publisher = "{app_publisher}" app_description = "{app_description}" -app_icon = "{app_icon}" -app_color = "{app_color}" app_email = "{app_email}" app_license = "{app_license}" @@ -364,8 +392,6 @@ def get_data(): return [ {{ "module_name": "{app_title}", - "color": "{app_color}", - "icon": "{app_icon}", "type": "module", "label": _("{app_title}") }} @@ -398,7 +424,8 @@ gitignore_template = """.DS_Store *.egg-info *.swp tags -{app_name}/docs/current""" +{app_name}/docs/current +node_modules/""" docs_template = '''""" Configuration for docs @@ -411,3 +438,96 @@ Configuration for docs def get_context(context): context.brand_html = "{app_title}" ''' + + +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: + mariadb: + image: mariadb:10.3 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Setup Node + uses: actions/setup-node@v2 + with: + node-version: 14 + check-latest: true + + - name: Cache pip + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: ${{{{ runner.os }}}}-pip-${{{{ hashFiles('**/*requirements.txt') }}}} + restore-keys: | + ${{{{ runner.os }}}}-pip- + ${{{{ runner.os }}}}- + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: 'echo "::set-output name=dir::$(yarn cache dir)"' + + - uses: actions/cache@v2 + 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: Setup + run: | + pip install frappe-bench + bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench + mysql --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" + mysql --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 +""" diff --git a/hooks.md b/hooks.md index 5ff70471c9..efb58f2645 100644 --- a/hooks.md +++ b/hooks.md @@ -7,8 +7,6 @@ 1. `app_publisher` 1. `app_description` 1. `app_version` -1. `app_icon` - font-awesome icon or image url -1. `app_color` - hex colour background of the app icon #### Install