Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add cli for listing available patches #798

Merged
merged 1 commit into from
Mar 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions changelog.d/20230308_090007_maria.magallanes_listing_patches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!--
Create a changelog entry for every new user-facing change. Please respect the following instructions:
- Indicate breaking changes by prepending an explosion 💥 character.
- Prefix your changes with either [Bugfix], [Improvement], [Feature], [Security], [Deprecation].
- You may optionally append "(by @<author>)" at the end of the line, where "<author>" is either one (just one)
of your GitHub username, real name or affiliated organization. These affiliations will be displayed in
the release notes for every release.
-->

<!-- - 💥[Feature] Foobarize the blorginator. This breaks plugins by renaming the `FOO_DO` filter to `BAR_DO`. (by @regisb) -->
<!-- - [Improvement] This is a non-breaking change. Life is good. (by @billgates) -->
[Feature] Add `tutor config patches list` CLI for listing available patches. (by @mafermazu)
4 changes: 4 additions & 0 deletions docs/reference/patches.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/search?utf8=✓&q={{+patch+repo%3Aoverhangio%2Ftutor+path%3A%2Ftutor%2Ftemplates&type=Code&ref=advsearch&l=&l= 8>`__.

.. patch:: caddyfile
Expand Down
9 changes: 9 additions & 0 deletions tests/commands/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,12 @@ def test_config_printvalue(self) -> None:
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)
136 changes: 136 additions & 0 deletions tests/test_env.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -261,3 +262,138 @@ def test_current_version_in_latest_env(self) -> None:
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(),
)
15 changes: 15 additions & 0 deletions tutor/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
63 changes: 63 additions & 0 deletions tutor/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,69 @@ def __render(self, template: jinja2.Template) -> str:
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.
Expand Down