diff --git a/changelog.d/20230308_090007_maria.magallanes_listing_patches.md b/changelog.d/20230308_090007_maria.magallanes_listing_patches.md new file mode 100644 index 0000000..8db1b78 --- /dev/null +++ b/changelog.d/20230308_090007_maria.magallanes_listing_patches.md @@ -0,0 +1,12 @@ + + + + +[Feature] Add `tutor config patches list` CLI for listing available patches. (by @mafermazu) diff --git a/docs/reference/patches.rst b/docs/reference/patches.rst index efca194..c5f0baa 100644 --- a/docs/reference/patches.rst +++ b/docs/reference/patches.rst @@ -10,6 +10,10 @@ This is the list of all patches used across Tutor (outside of any plugin). Alter cd tutor git grep "{{ patch" -- tutor/templates +Or you can list all available patches with the following command:: + + tutor config patches list + See also `this GitHub search `__. .. patch:: caddyfile diff --git a/tests/commands/test_config.py b/tests/commands/test_config.py index 731a183..314d8fe 100644 --- a/tests/commands/test_config.py +++ b/tests/commands/test_config.py @@ -58,3 +58,12 @@ class ConfigTests(unittest.TestCase, TestCommandMixin): self.assertFalse(result.exception) self.assertEqual(0, result.exit_code) self.assertTrue(result.output) + + +class PatchesTests(unittest.TestCase, TestCommandMixin): + def test_config_patches_list(self) -> None: + with temporary_root() as root: + self.invoke_in_root(root, ["config", "save"]) + result = self.invoke_in_root(root, ["config", "patches", "list"]) + self.assertFalse(result.exception) + self.assertEqual(0, result.exit_code) diff --git a/tests/test_env.py b/tests/test_env.py index 969ef81..e79d891 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -1,6 +1,7 @@ import os import tempfile import unittest +from io import StringIO from unittest.mock import Mock, patch from tests.helpers import PluginsTestCase, temporary_root @@ -261,3 +262,138 @@ class CurrentVersionTests(unittest.TestCase): self.assertEqual("olive", env.get_env_release(root)) self.assertIsNone(env.should_upgrade_from_release(root)) self.assertTrue(env.is_up_to_date(root)) + + +class PatchRendererTests(unittest.TestCase): + def setUp(self) -> None: + self.render = env.PatchRenderer() + self.render.current_template = "current_template" + return super().setUp() + + @patch("tutor.env.Renderer.render_template") + def test_render_template(self, render_template_mock: Mock) -> None: + """Test that render_template changes the current template and + calls once render_template from Renderer with the current template.""" + self.render.render_template("new_template") + + self.assertEqual(self.render.current_template, "new_template") + render_template_mock.assert_called_once_with("new_template") + + @patch("tutor.env.Renderer.patch") + def test_patch_with_first_patch(self, patch_mock: Mock) -> None: + """Test that patch is called from Renderer and adds patches_locations + when we didn't have that patch.""" + self.render.patches_locations = {} + + self.render.patch("first_patch") + + patch_mock.assert_called_once_with("first_patch", separator="\n", suffix="") + self.assertEqual( + self.render.patches_locations, + {"first_patch": [self.render.current_template]}, + ) + + def test_patch_with_patch_multiple_locations(self) -> None: + """Test add more locations to a patch.""" + self.render.patches_locations = {"first_patch": ["template_1"]} + + self.render.patch("first_patch") + + self.assertEqual( + self.render.patches_locations, + {"first_patch": ["template_1", "current_template"]}, + ) + + @patch("tutor.env.plugins.iter_patches") + def test_patch_with_custom_patch_in_a_plugin_patch( + self, iter_patches_mock: Mock + ) -> None: + """Test the patch function with a plugin with a custom patch. + Examples: + - When first_patch is in a plugin patches and has a 'custom_patch', + the patches_locations will reflect that 'custom_patch' is from + first_patch location. + - If in tutor-mfe/tutormfe/patches/caddyfile you add a custom patch + inside the caddyfile patch, the patches_locations will reflect that. + + Expected behavior: + - Process the first_patch and find the custom_patch in a plugin with + first_patch patch. + - Process the custom_patch and add "within patch: first_patch" in the + patches_locations.""" + iter_patches_mock.side_effect = [ + ["""{{ patch('custom_patch')|indent(4) }}"""], + [], + ] + self.render.patches_locations = {} + calls = [unittest.mock.call("first_patch"), unittest.mock.call("custom_patch")] + + self.render.patch("first_patch") + + iter_patches_mock.assert_has_calls(calls) + self.assertEqual( + self.render.patches_locations, + { + "first_patch": ["current_template"], + "custom_patch": ["within patch: first_patch"], + }, + ) + + @patch("tutor.env.plugins.iter_patches") + def test_patch_with_processed_patch_in_a_plugin_patch( + self, iter_patches_mock: Mock + ) -> None: + """Test the patch function with a plugin with a processed patch. + Example: + - When first_patch was processed and the second_patch is used in a + plugin and call the first_patch again. Then the patches_locations will + reflect that first_patch also have a location from second_patch.""" + iter_patches_mock.side_effect = [ + ["""{{ patch('first_patch')|indent(4) }}"""], + [], + ] + self.render.patches_locations = {"first_patch": ["current_template"]} + + self.render.patch("second_patch") + + self.assertEqual( + self.render.patches_locations, + { + "first_patch": ["current_template", "within patch: second_patch"], + "second_patch": ["current_template"], + }, + ) + + @patch("tutor.env.Renderer.iter_templates_in") + @patch("tutor.env.PatchRenderer.render_template") + def test_render_all( + self, render_template_mock: Mock, iter_templates_in_mock: Mock + ) -> None: + """Test render_template was called for templates in iter_templates_in.""" + iter_templates_in_mock.return_value = ["template_1", "template_2"] + calls = [unittest.mock.call("template_1"), unittest.mock.call("template_2")] + + self.render.render_all() + + iter_templates_in_mock.assert_called_once() + render_template_mock.assert_has_calls(calls) + + @patch("sys.stdout", new_callable=StringIO) + @patch("tutor.env.PatchRenderer.render_all") + def test_print_patches_locations( + self, render_all_mock: Mock, stdout_mock: Mock + ) -> None: + """Test render_all was called and the output of print_patches_locations.""" + self.render.patches_locations = {"first_patch": ["template_1", "template_2"]} + + self.render.print_patches_locations() + + render_all_mock.assert_called_once() + self.assertEqual( + """ +PATCH LOCATIONS +first_patch template_1 + template_2 +""".strip(), + stdout_mock.getvalue().strip(), + ) diff --git a/tutor/commands/config.py b/tutor/commands/config.py index a95e825..19d7ce8 100644 --- a/tutor/commands/config.py +++ b/tutor/commands/config.py @@ -155,6 +155,21 @@ def printvalue(context: Context, key: str) -> None: raise exceptions.TutorError(f"Missing configuration value: {key}") from e +@click.group(name="patches", help="Commands related to patches in configurations") +def patches_command() -> None: + pass + + +@click.command(name="list", help="Print all available patches") +@click.pass_obj +def patches_list(context: Context) -> None: + config = tutor_config.load(context.root) + renderer = env.PatchRenderer(config) + renderer.print_patches_locations() + + config_command.add_command(save) config_command.add_command(printroot) config_command.add_command(printvalue) +config_command.add_command(patches_command) +patches_command.add_command(patches_list) diff --git a/tutor/env.py b/tutor/env.py index 3c16ba6..f0375a4 100644 --- a/tutor/env.py +++ b/tutor/env.py @@ -215,6 +215,69 @@ class Renderer: raise exceptions.TutorError(f"Missing configuration value: {e.args[0]}") +class PatchRenderer(Renderer): + """ + Render patches for print it. + """ + + def __init__(self, config: t.Optional[Config] = None): + self.patches_locations: t.Dict[str, t.List[str]] = {} + self.current_template: str = "" + super().__init__(config) + + def render_template(self, template_name: str) -> t.Union[str, bytes]: + """ + Set the current template and render template from Renderer. + """ + self.current_template = template_name + return super().render_template(self.current_template) + + def patch(self, name: str, separator: str = "\n", suffix: str = "") -> str: + """ + Set the patches locations and render calls to {{ patch("...") }} from Renderer. + """ + if not self.patches_locations.get(name): + self.patches_locations.update({name: [self.current_template]}) + else: + if self.current_template not in self.patches_locations[name]: + self.patches_locations[name].append(self.current_template) + + # Store the template's name, and replace it with the name of this patch. + # This handles the case where patches themselves include patches. + original_template = self.current_template + self.current_template = f"within patch: {name}" + + rendered_patch = super().patch(name, separator=separator, suffix=suffix) + self.current_template = ( + original_template # Restore the template's name from before. + ) + return rendered_patch + + def render_all(self, *prefix: str) -> None: + """ + Render all templates. + """ + for template_name in self.iter_templates_in(*prefix): + self.render_template(template_name) + + def print_patches_locations(self) -> None: + """ + Print patches locations. + """ + plugins_table: list[tuple[str, ...]] = [("PATCH", "LOCATIONS")] + self.render_all() + for patch, locations in sorted(self.patches_locations.items()): + n_locations = 0 + for location in locations: + if n_locations < 1: + plugins_table.append((patch, location)) + n_locations += 1 + else: + plugins_table.append(("", location)) + + fmt.echo(utils.format_table(plugins_table)) + + def is_rendered(path: str) -> bool: """ Return whether the template should be rendered or not.