diff --git a/bench/app.py b/bench/app.py index 29c4ab257..a1254ef55 100755 --- a/bench/app.py +++ b/bench/app.py @@ -859,7 +859,20 @@ def install_resolved_deps( app.install_resolved_apps(skip_assets=skip_assets, verbose=verbose) -def new_app(app, no_git=None, bench_path="."): +def new_app( + app, + no_git=None, + bench_path=".", + title=None, + description=None, + publisher=None, + email=None, + license=None, + github_workflow=None, + frontend=None, + route=None, + branch=None, +): if bench.FRAPPE_VERSION in (0, None): click.secho( f"{os.path.realpath(bench_path)} is not a valid bench directory.", @@ -883,6 +896,25 @@ def new_app(app, no_git=None, bench_path="."): return args.append(no_git) + if title is not None: + args += ["--title", title] + if description is not None: + args += ["--description", description] + if publisher is not None: + args += ["--publisher", publisher] + if email is not None: + args += ["--email", email] + if license is not None: + args += ["--license", license] + if github_workflow is not None: + args.append("--github-workflow" if github_workflow else "--no-github-workflow") + if frontend is not None: + args.append("--frontend" if frontend else "--no-frontend") + if route is not None: + args += ["--route", route] + if branch is not None: + args += ["--branch", branch] + logger.log(f"creating new app {app}") run_frappe_cmd(*args, bench_path=bench_path) install_app(app, bench_path=bench_path) diff --git a/bench/commands/make.py b/bench/commands/make.py index ec5de5def..0237f40f8 100755 --- a/bench/commands/make.py +++ b/bench/commands/make.py @@ -198,11 +198,58 @@ def get_app( flag_value="--no-git", help="Do not initialize git repository for the app (available in Frappe v14+)", ) +@click.option("--title", default=None, help="App Title") +@click.option("--description", default=None, help="App Description") +@click.option("--publisher", default=None, help="App Publisher") +@click.option("--email", default=None, help="App Publisher's Email") +@click.option( + "--license", + default=None, + help="App License", +) +@click.option( + "--github-workflow/--no-github-workflow", + "github_workflow", + default=None, + help="Create GitHub Workflow action for unittests", +) +@click.option( + "--frontend/--no-frontend", + "frontend", + default=None, + help="Create frappe-ui frontend", +) +@click.option("--route", default=None, help="Frontend route for SPA when creating a frontend") +@click.option("--branch", default=None, help="Git branch name for the new app") @click.argument("app-name") -def new_app(app_name, no_git=None): - from bench.app import new_app - - new_app(app_name, no_git) +def new_app( + app_name, + no_git=None, + title=None, + description=None, + publisher=None, + email=None, + license=None, + github_workflow=None, + frontend=None, + route=None, + branch=None, +): + from bench.app import new_app as create_new_app + + create_new_app( + app_name, + no_git, + title=title, + description=description, + publisher=publisher, + email=email, + license=license, + github_workflow=github_workflow, + frontend=frontend, + route=route, + branch=branch, + ) @click.command( diff --git a/bench/tests/test_utils.py b/bench/tests/test_utils.py index 2f645497c..eee0fc2a1 100644 --- a/bench/tests/test_utils.py +++ b/bench/tests/test_utils.py @@ -2,6 +2,7 @@ import shutil import subprocess import unittest +from unittest.mock import MagicMock, patch from bench.app import App from bench.bench import Bench @@ -104,3 +105,149 @@ def test_ssh_ports(self): self.assertEqual( (app.use_ssh, app.org, app.repo, app.app_name), (True, "frappe", "frappe", "frappe") ) + + +class TestNewAppCommand(unittest.TestCase): + """Tests for the bench new-app command options and argument construction.""" + + def _make_app_args(self, app, **kwargs): + """Call new_app() with mocked run_frappe_cmd and install_app, return captured args.""" + captured = {} + + def mock_run_frappe_cmd(*args, **kw): + captured["args"] = list(args) + + with ( + patch("bench.app.bench") as mock_bench, + patch("bench.app.run_frappe_cmd", side_effect=mock_run_frappe_cmd), + patch("bench.app.install_app"), + patch("bench.app.logger"), + ): + mock_bench.FRAPPE_VERSION = 15 + from bench.app import new_app + + new_app(app, bench_path="/tmp/fake-bench", **kwargs) + + return captured.get("args", []) + + def test_new_app_basic_args(self): + """make-app receives the apps directory and app name.""" + args = self._make_app_args("my_app") + self.assertEqual(args[0], "make-app") + self.assertTrue(args[1].endswith("/apps")) + self.assertEqual(args[2], "my_app") + + def test_new_app_no_extra_args_by_default(self): + """No extra args are added when no options are passed.""" + args = self._make_app_args("my_app") + self.assertEqual(len(args), 3) + self.assertEqual(args[0], "make-app") + self.assertEqual(args[2], "my_app") + + def test_new_app_title(self): + args = self._make_app_args("my_app", title="My App") + self.assertIn("--title", args) + self.assertEqual(args[args.index("--title") + 1], "My App") + + def test_new_app_description(self): + args = self._make_app_args("my_app", description="A great app") + self.assertIn("--description", args) + self.assertEqual(args[args.index("--description") + 1], "A great app") + + def test_new_app_publisher(self): + args = self._make_app_args("my_app", publisher="ACME Corp") + self.assertIn("--publisher", args) + self.assertEqual(args[args.index("--publisher") + 1], "ACME Corp") + + def test_new_app_email(self): + args = self._make_app_args("my_app", email="dev@example.com") + self.assertIn("--email", args) + self.assertEqual(args[args.index("--email") + 1], "dev@example.com") + + def test_new_app_license(self): + args = self._make_app_args("my_app", license="mit") + self.assertIn("--license", args) + self.assertEqual(args[args.index("--license") + 1], "mit") + + def test_new_app_create_github_workflow_flag(self): + args = self._make_app_args("my_app", github_workflow=True) + self.assertIn("--github-workflow", args) + + def test_new_app_create_github_workflow_false_flag(self): + args = self._make_app_args("my_app", github_workflow=False) + self.assertIn("--no-github-workflow", args) + + def test_new_app_create_github_workflow_omitted_by_default(self): + args = self._make_app_args("my_app") + self.assertNotIn("--github-workflow", args) + self.assertNotIn("--no-github-workflow", args) + + def test_new_app_create_frontend_flag(self): + args = self._make_app_args("my_app", frontend=True) + self.assertIn("--frontend", args) + + def test_new_app_create_frontend_false_flag(self): + args = self._make_app_args("my_app", frontend=False) + self.assertIn("--no-frontend", args) + + def test_new_app_frontend_route(self): + args = self._make_app_args("my_app", route="m") + self.assertIn("--route", args) + self.assertEqual(args[args.index("--route") + 1], "m") + + def test_new_app_branch_name(self): + args = self._make_app_args("my_app", branch="main") + self.assertIn("--branch", args) + self.assertEqual(args[args.index("--branch") + 1], "main") + + def test_new_app_all_options_combined(self): + args = self._make_app_args( + "my_app", + title="My App", + description="A great app", + publisher="ACME Corp", + email="dev@example.com", + license="mit", + github_workflow=True, + frontend=True, + route="m", + branch="main", + ) + for flag, value in ( + ("--title", "My App"), + ("--description", "A great app"), + ("--publisher", "ACME Corp"), + ("--email", "dev@example.com"), + ("--license", "mit"), + ("--route", "m"), + ("--branch", "main"), + ): + self.assertIn(flag, args) + self.assertEqual(args[args.index(flag) + 1], value) + self.assertIn("--github-workflow", args) + self.assertIn("--frontend", args) + + def test_new_app_cli_options_defined(self): + """The new-app click command exposes all expected options.""" + from click.testing import CliRunner + from bench.commands.make import new_app as new_app_cmd + + runner = CliRunner() + result = runner.invoke(new_app_cmd, ["--help"]) + help_text = result.output + + for option in ( + "--title", + "--description", + "--publisher", + "--email", + "--license", + "--github-workflow", + "--no-github-workflow", + "--frontend", + "--no-frontend", + "--route", + "--branch", + "--no-git", + ): + self.assertIn(option, help_text, f"Expected '{option}' in --help output") diff --git a/bench/utils/cli.py b/bench/utils/cli.py index a805305cb..adfb16e9f 100644 --- a/bench/utils/cli.py +++ b/bench/utils/cli.py @@ -4,89 +4,93 @@ def print_bench_version(ctx, param, value): - """Prints current bench version""" - if not value or ctx.resilient_parsing: - return + """Prints current bench version""" + if not value or ctx.resilient_parsing: + return - import bench + import bench - click.echo(bench.VERSION) - ctx.exit() + click.echo(bench.VERSION) + ctx.exit() class MultiCommandGroup(click.Group): - def add_command(self, cmd, name=None): - """Registers another :class:`Command` with this group. If the name - is not provided, the name of the command is used. - - Note: This is a custom Group that allows passing a list of names for - the command name. - """ - name = name or cmd.name - if name is None: - raise TypeError("Command has no name.") - _check_nested_chain(self, name, cmd, register=True) - - try: - self.commands[name] = cmd - except TypeError: - if isinstance(name, list): - for _name in name: - self.commands[_name] = cmd + def add_command(self, cmd, name=None): + """Registers another :class:`Command` with this group. If the name + is not provided, the name of the command is used. + + Note: This is a custom Group that allows passing a list of names for + the command name. + """ + name = name or cmd.name + if name is None: + raise TypeError("Command has no name.") + _check_nested_chain(self, name, cmd, register=True) + + try: + self.commands[name] = cmd + except TypeError: + if isinstance(name, list): + for _name in name: + self.commands[_name] = cmd class SugaredOption(click.Option): - def __init__(self, *args, **kwargs): - self.only_if_set: List = kwargs.pop("only_if_set") - kwargs["help"] = ( - kwargs.get("help", "") - + f". Option is acceptable only if {', '.join(self.only_if_set)} is used." - ) - super().__init__(*args, **kwargs) - - def handle_parse_result(self, ctx, opts, args): - current_opt = self.name in opts - if current_opt and self.only_if_set: - for opt in self.only_if_set: - if opt not in opts: - deafaults_set = [x.default for x in ctx.command.params if x.name == opt] - if not deafaults_set: - raise click.UsageError(f"Illegal Usage: Set '{opt}' before '{self.name}'.") - - return super().handle_parse_result(ctx, opts, args) + def __init__(self, *args, **kwargs): + self.only_if_set: List = kwargs.pop("only_if_set") + kwargs["help"] = ( + kwargs.get("help", "") + + f". Option is acceptable only if {', '.join(self.only_if_set)} is used." + ) + super().__init__(*args, **kwargs) + + def handle_parse_result(self, ctx, opts, args): + current_opt = self.name in opts + if current_opt and self.only_if_set: + for opt in self.only_if_set: + if opt not in opts: + deafaults_set = [ + x.default for x in ctx.command.params if x.name == opt + ] + if not deafaults_set: + raise click.UsageError( + f"Illegal Usage: Set '{opt}' before '{self.name}'." + ) + + return super().handle_parse_result(ctx, opts, args) def use_experimental_feature(ctx, param, value): - if not value: - return + if not value: + return - if value == "dynamic-feed": - import bench.cli + if value == "dynamic-feed": + import bench.cli - bench.cli.dynamic_feed = True - bench.cli.verbose = True - else: - from bench.exceptions import FeatureDoesNotExistError + bench.cli.dynamic_feed = True + bench.cli.verbose = True + else: + from bench.exceptions import FeatureDoesNotExistError - raise FeatureDoesNotExistError(f"Feature {value} does not exist") + raise FeatureDoesNotExistError(f"Feature {value} does not exist") - from bench.cli import is_envvar_warn_set + from bench.cli import is_envvar_warn_set - if is_envvar_warn_set: - return + if is_envvar_warn_set: + return - click.secho( - "WARNING: bench is using it's new CLI rendering engine. This behaviour has" - f" been enabled by passing --{value} in the command. This feature is" - " experimental and may not be implemented for all commands yet.", - fg="yellow", - ) + click.secho( + "WARNING: bench is using it's new CLI rendering engine. This behaviour has" + f" been enabled by passing --{value} in the command. This feature is" + " experimental and may not be implemented for all commands yet.", + fg="yellow", + ) def setup_verbosity(ctx, param, value): - if not value: - return + if not value: + return - import bench.cli + import bench.cli - bench.cli.verbose = True + bench.cli.verbose = True