diff --git a/changelog.d/20240629_120748_codewithemad_cleanup_flag.md b/changelog.d/20240629_120748_codewithemad_cleanup_flag.md new file mode 100644 index 0000000000..d44e7e2119 --- /dev/null +++ b/changelog.d/20240629_120748_codewithemad_cleanup_flag.md @@ -0,0 +1 @@ +- [Feature] Added `-c` or `--clean` option to tutor config save: For plugin developers and advanced users, this option cleans the `env/` folder before saving, ensuring a fresh environment for testing and development. (by @CodeWithEmad) diff --git a/docs/tutorials/edx-platform.rst b/docs/tutorials/edx-platform.rst index ea687b9687..125a0cb324 100644 --- a/docs/tutorials/edx-platform.rst +++ b/docs/tutorials/edx-platform.rst @@ -106,7 +106,7 @@ You should then re-build the "openedx" Docker image to pick up your changes:: tutor images build openedx-dev -Then, whenever you run ``tutor dev start``, the "lms" and "cms" container should automatically hot-reload your changes. +Then, whenever you run ``tutor dev start``, the "lms" and "cms" containers should automatically hot-reload your changes. To push your changes in production, you should do the same with ``tutor local`` and the "openedx" image:: diff --git a/tests/commands/test_config.py b/tests/commands/test_config.py index 58b15edd93..d62d2a9550 100644 --- a/tests/commands/test_config.py +++ b/tests/commands/test_config.py @@ -17,6 +17,11 @@ def test_config_save(self) -> None: self.assertFalse(result.exception) self.assertEqual(0, result.exit_code) + def test_config_save_cleanup_env_dir(self) -> None: + result = self.invoke(["config", "save", "-c"]) + self.assertFalse(result.exception) + self.assertEqual(0, result.exit_code) + def test_config_save_interactive(self) -> None: result = self.invoke(["config", "save", "-i"]) self.assertFalse(result.exception) diff --git a/tests/test_config.py b/tests/test_config.py index ed1971bcb7..cd8a3958e8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -48,7 +48,7 @@ def mock_prompt(*_args: None, **kwargs: str) -> str: with patch.object(click, "prompt", new=mock_prompt): with patch.object(click, "confirm", new=mock_prompt): config = tutor_config.load_minimal(rootdir) - interactive.ask_questions(config) + interactive.ask_questions(config, rootdir) self.assertIn("MYSQL_ROOT_PASSWORD", config) self.assertEqual(8, len(get_typed(config, "MYSQL_ROOT_PASSWORD", str))) diff --git a/tutor/commands/compose.py b/tutor/commands/compose.py index 41dae2735a..cde530c0a2 100644 --- a/tutor/commands/compose.py +++ b/tutor/commands/compose.py @@ -187,12 +187,18 @@ def interactive_upgrade( def interactive_configuration( - context: click.Context, interactive: bool, run_for_prod: t.Optional[bool] = None + context: click.Context, + interactive: bool, + run_for_prod: t.Optional[bool] = None, ) -> None: - click.echo(fmt.title("Interactive platform configuration")) config = tutor_config.load_minimal(context.obj.root) if interactive: - interactive_config.ask_questions(config, run_for_prod=run_for_prod) + click.echo(fmt.title("Interactive platform configuration")) + interactive_config.ask_questions( + config, + context.obj.root, + run_for_prod=run_for_prod, + ) tutor_config.save_config_file(context.obj.root, config) config = tutor_config.load_full(context.obj.root) tutor_env.save(context.obj.root, config) diff --git a/tutor/commands/config.py b/tutor/commands/config.py index 58e18334d9..6c927d2c67 100644 --- a/tutor/commands/config.py +++ b/tutor/commands/config.py @@ -136,6 +136,13 @@ def _candidate_config_items(self) -> t.Iterable[tuple[str, ConfigValue]]: @click.option( "-e", "--env-only", "env_only", is_flag=True, help="Skip updating config.yml" ) +@click.option( + "-c", + "--clean", + "clean_env", + is_flag=True, + help="Remove everything in the env directory before save", +) @click.pass_obj def save( context: Context, @@ -145,10 +152,13 @@ def save( remove_vars: list[tuple[str, t.Any]], unset_vars: list[str], env_only: bool, + clean_env: bool, ) -> None: config = tutor_config.load_minimal(context.root) if interactive: - interactive_config.ask_questions(config) + interactive_config.ask_questions(config, context.root, clean_env_prompt=True) + if clean_env: + env.delete_env_dir(context.root) if set_vars: for key, value in set_vars: config[key] = env.render_unknown(config, value) diff --git a/tutor/commands/k8s.py b/tutor/commands/k8s.py index 6d39ec7f64..4af21ac1d7 100644 --- a/tutor/commands/k8s.py +++ b/tutor/commands/k8s.py @@ -225,10 +225,10 @@ def launch(context: click.Context, non_interactive: bool) -> None: from_release=tutor_env.get_env_release(context.obj.root), ) - click.echo(fmt.title("Interactive platform configuration")) config = tutor_config.load_minimal(context.obj.root) if not non_interactive: - interactive_config.ask_questions(config, run_for_prod=True) + click.echo(fmt.title("Interactive platform configuration")) + interactive_config.ask_questions(config, context.obj.root, run_for_prod=True) tutor_config.save_config_file(context.obj.root, config) config = tutor_config.load_full(context.obj.root) tutor_env.save(context.obj.root, config) diff --git a/tutor/commands/plugins.py b/tutor/commands/plugins.py index a7409852be..4908c93bf2 100644 --- a/tutor/commands/plugins.py +++ b/tutor/commands/plugins.py @@ -99,7 +99,7 @@ def plugins_command() -> None: @click.command( short_help="Print the location of file-based plugins", - help=f"""Print the location of yaml-based plugins: nboth python v1 and yaml v0 plugins. This location can be manually + help=f"""Print the location of yaml-based plugins: both python v1 and yaml v0 plugins. This location can be manually defined by setting the {PLUGINS_ROOT_ENV_VAR_NAME} environment variable""", ) def printroot() -> None: diff --git a/tutor/env.py b/tutor/env.py index 4b91d2d539..8aa510a551 100644 --- a/tutor/env.py +++ b/tutor/env.py @@ -533,6 +533,20 @@ def root_dir(root: str) -> str: return os.path.abspath(root) +def delete_env_dir(root: str) -> None: + env_path = base_dir(root) + + try: + shutil.rmtree(env_path) + fmt.echo_alert(f"Removed existing Tutor environment at: {env_path}") + except PermissionError: + raise exceptions.TutorError( + f"Permission Denied while trying to remove existing Tutor environment at: {env_path}" + ) + except FileNotFoundError: + fmt.echo_info(f"No existing Tutor environment to remove at: {env_path}") + + @hooks.Actions.PLUGIN_UNLOADED.add() def _delete_plugin_templates(plugin: str, root: str, _config: Config) -> None: """ diff --git a/tutor/interactive.py b/tutor/interactive.py index d85f9913df..5e059a005f 100644 --- a/tutor/interactive.py +++ b/tutor/interactive.py @@ -7,7 +7,12 @@ from .types import Config, get_typed -def ask_questions(config: Config, run_for_prod: Optional[bool] = None) -> None: +def ask_questions( + config: Config, + root: str, + run_for_prod: Optional[bool] = None, + clean_env_prompt: bool = False, +) -> None: """ Interactively ask questions to collect configuration values from the user. @@ -15,6 +20,10 @@ def ask_questions(config: Config, run_for_prod: Optional[bool] = None) -> None: config: Existing (or minimal) configuration. Modified in-place. run_for_prod: Whether platform should be configured for production. If None, then ask the user. + clean_env_prompt: Whether to show the clean environment prompt before running. + defaults to False. + Returns: + None """ defaults = tutor_config.get_defaults() if run_for_prod is None: @@ -148,6 +157,14 @@ def ask_questions(config: Config, run_for_prod: Optional[bool] = None) -> None: config, defaults, ) + if clean_env_prompt: + run_clean = click.confirm( + fmt.question("Remove existing Tutor environment directory?"), + prompt_suffix=" ", + default=True, + ) + if run_clean: + env.delete_env_dir(root) hooks.Actions.CONFIG_INTERACTIVE.do(config)