From ba2ac346a2d164a050845f4744b070da50cc5869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brayan=20Cer=C3=B3n?= <86393372+bra-i-am@users.noreply.github.com> Date: Fri, 5 Apr 2024 15:26:41 -0500 Subject: [PATCH] feat: add command to run extra tutor commands (#59) --- README.md | 18 +++ features/run_extra_commands.feature | 14 +++ features/steps/run_extra_commands_step.py | 64 ++++++++++ tests/distro/run_extra_commands/__init__.py | 0 .../application/__init__.py | 0 .../application/test_run_command.py | 109 ++++++++++++++++++ .../infrastructure/__init__.py | 0 .../infrastructure/test_tutor_commands.py | 27 +++++ tutordistro/commands/cli.py | 2 + tutordistro/commands/run_extra_commands.py | 32 +++++ tutordistro/distro/extra_commands/__init__.py | 0 .../extra_commands/application/__init__.py | 0 .../application/commands_runner.py | 38 ++++++ .../distro/extra_commands/domain/__init__.py | 0 .../extra_commands/domain/command_manager.py | 12 ++ .../extra_commands/infrastructure/__init__.py | 0 .../infrastructure/tutor_commands.py | 85 ++++++++++++++ .../distro/share/domain/command_error.py | 11 ++ tutordistro/utils/common.py | 51 ++++++++ tutordistro/utils/constants.py | 5 + 20 files changed, 468 insertions(+) create mode 100644 features/run_extra_commands.feature create mode 100644 features/steps/run_extra_commands_step.py create mode 100644 tests/distro/run_extra_commands/__init__.py create mode 100644 tests/distro/run_extra_commands/application/__init__.py create mode 100644 tests/distro/run_extra_commands/application/test_run_command.py create mode 100644 tests/distro/run_extra_commands/infrastructure/__init__.py create mode 100644 tests/distro/run_extra_commands/infrastructure/test_tutor_commands.py create mode 100644 tutordistro/commands/run_extra_commands.py create mode 100644 tutordistro/distro/extra_commands/__init__.py create mode 100644 tutordistro/distro/extra_commands/application/__init__.py create mode 100644 tutordistro/distro/extra_commands/application/commands_runner.py create mode 100644 tutordistro/distro/extra_commands/domain/__init__.py create mode 100644 tutordistro/distro/extra_commands/domain/command_manager.py create mode 100644 tutordistro/distro/extra_commands/infrastructure/__init__.py create mode 100644 tutordistro/distro/extra_commands/infrastructure/tutor_commands.py create mode 100644 tutordistro/distro/share/domain/command_error.py create mode 100644 tutordistro/utils/common.py create mode 100644 tutordistro/utils/constants.py diff --git a/README.md b/README.md index 0caa62a..eace6d8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ tutor distro repository-validator # Enabler commands tutor distro enable-themes tutor distro enable-private-packages + +# Run extra commands +tutor distro run-extra-commands ``` ### Documentation @@ -239,6 +242,21 @@ The command will check the configuration for: - INSTALL_EXTRA_FILE_REQUIREMENTS - OPENEDX_EXTRA_SETTINGS +# Run tutor extra commands + +You can run tutor extra commands by adding them into the **config.yml** in an attribute `DISTRO_EXTRA_COMMANDS` like this: + +```yaml + DISTRO_EXTRA_COMMANDS: + - tutor plugins install mfe && tutor plugins enable mfe + - tutor plugins index add https://overhang.io/tutor/main +``` +You can only insert commands enabled by the [Tutor CLI](https://docs.tutor.edly.io/reference/cli/index.html). Once you have added the commands you want to execute, you will need to run the following command: + +```bash +tutor distro run-extra-commands +``` + # Other Options ## How to add custom middlewares diff --git a/features/run_extra_commands.feature b/features/run_extra_commands.feature new file mode 100644 index 0000000..e59d58b --- /dev/null +++ b/features/run_extra_commands.feature @@ -0,0 +1,14 @@ +Feature: Run extra commands + @fixture.behave.tutor_root + Scenario: Execute the extra commands from config.yml properly + Given There is a tutor root + And There is a config.yml file + And There are valid commands defined + When I write the command tutor distro run-extra-commands and commands will be properly executed + + @fixture.behave.tutor_root + Scenario: Execute commands that are not valid + Given There is a tutor root + And There is a config.yml file + And There are invalid commands defined + When I write the command tutor distro run-extra-commands and commands execution will fail diff --git a/features/steps/run_extra_commands_step.py b/features/steps/run_extra_commands_step.py new file mode 100644 index 0000000..a7044fa --- /dev/null +++ b/features/steps/run_extra_commands_step.py @@ -0,0 +1,64 @@ +import os + +import subprocess + +from behave import given, when, then # pylint: disable=no-name-in-module +from click.testing import CliRunner +from tutor import config as tutor_config + +from tutordistro.commands.run_extra_commands import run_extra_commands + + +@given("There are valid commands defined") +def step_impl(context): # pylint: disable=function-redefined,missing-function-docstring + extra_commands = [ + "tutor plugins update", + "tutor plugins install forum", + "tutor plugins enable forum" + ] + + config = context.scenario.config + config.update({ + "DISTRO_EXTRA_COMMANDS": extra_commands + }) + + tutor_config.save_config_file(context.scenario.tutor_root, config) + config = tutor_config.load(context.scenario.tutor_root) + context.scenario.config = config + context.scenario.extra_commands = "DISTRO_EXTRA_COMMANDS" + + assert "DISTRO_EXTRA_COMMANDS" in config + + +@given("There are invalid commands defined") +def step_impl(context): # pylint: disable=function-redefined,missing-function-docstring + extra_commands = [ + "pip install application" + ] + + config = context.scenario.config + config.update({ + "DISTRO_EXTRA_COMMANDS": extra_commands + }) + + tutor_config.save_config_file(context.scenario.tutor_root, config) + config = tutor_config.load(context.scenario.tutor_root) + context.scenario.config = config + context.scenario.extra_commands = "DISTRO_EXTRA_COMMANDS" + + assert "DISTRO_EXTRA_COMMANDS" in config + + +@when("I write the command tutor distro run-extra-commands and commands will be properly executed") +def step_impl(context): # pylint: disable=function-redefined,missing-function-docstring + runner = CliRunner() + result = runner.invoke(run_extra_commands, obj=context) + assert result.exit_code == 0 + + +@when("I write the command tutor distro run-extra-commands and commands execution will fail") +def step_impl(context): # pylint: disable=function-redefined,missing-function-docstring + runner = CliRunner() + result = runner.invoke(run_extra_commands, obj=context) + assert result.exit_code != 0 + diff --git a/tests/distro/run_extra_commands/__init__.py b/tests/distro/run_extra_commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/distro/run_extra_commands/application/__init__.py b/tests/distro/run_extra_commands/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/distro/run_extra_commands/application/test_run_command.py b/tests/distro/run_extra_commands/application/test_run_command.py new file mode 100644 index 0000000..3708cbd --- /dev/null +++ b/tests/distro/run_extra_commands/application/test_run_command.py @@ -0,0 +1,109 @@ +""" +Test run commands application. +""" + +import pytest + +from tests.distro.run_extra_commands.infrastructure.test_tutor_commands import TestTutorCommandManager +from tutordistro.distro.extra_commands.application.commands_runner import CommandsRunner +from tutordistro.distro.share.domain.command_error import CommandError +from tutordistro.utils.common import split_string +from tutordistro.utils.constants import COMMAND_CHAINING_OPERATORS + + +def test_valid_tutor_command(): + """ + Test running valid commands. + + This test verifies that are executed all the extra commands successfully. + """ + # Given + valid_tutor_commands = [ + "command with word tutor 1", + "command with word tutor 2", + "command with word tutor 3", + ] + + tutor_commands_manager = TestTutorCommandManager() + run_tutor_command = CommandsRunner( + commands_manager=tutor_commands_manager, commands=valid_tutor_commands + ) + + # When + for command in valid_tutor_commands: + run_tutor_command(command=command) + + assert tutor_commands_manager.commands_ran == len(valid_tutor_commands) + + +def test_invalid_or_misspelled_tutor_command(): + """ + Test running invalid commands. + + This test verifies that the execution fails when is + intended to execute invalid extra commands. + """ + # Given + invalid_tutor_command = [ + "pip command 1", + "tutor command && pip command 2", + "tutor command & pip command 3", + "tutor command || pip command 4", + "tutor command | pip command 5", + "tutor command ; pip command 6", + ] + + with pytest.raises(CommandError) as command_error: + tutor_commands_manager = TestTutorCommandManager() + CommandsRunner( + commands_manager=tutor_commands_manager, commands=invalid_tutor_command + ) + + assert command_error.type is CommandError + + splitted_commands = [ + split_string(command, COMMAND_CHAINING_OPERATORS) + for command in invalid_tutor_command + ] + commands_word_by_word = " ".join(sum(splitted_commands, [])).split(" ") + + pip_commands_sent = commands_word_by_word.count("pip") + pip_commands_found = command_error.value.args[0].split(" ").count("pip") + + assert pip_commands_sent == pip_commands_found + + +def test_misspelled_tutor_command(): + """ + Test running misspelled Tutor commands. + + This test verifies that is warned the user of trying to execute + a misspelled Tutor command. + """ + # Given + misspelled_commands = [ + "totur command 1", + "totur command 2", + "totur command 3", + "totur command 4", + "totur command 5", + ] + + with pytest.raises(CommandError) as command_error: + tutor_commands_manager = TestTutorCommandManager() + CommandsRunner( + commands_manager=tutor_commands_manager, commands=misspelled_commands + ) + + assert command_error.type is CommandError + + splitted_commands = [ + split_string(command, COMMAND_CHAINING_OPERATORS) + for command in misspelled_commands + ] + commands_word_by_word = " ".join(sum(splitted_commands, [])).split(" ") + + misspelled_commands_sent = commands_word_by_word.count("totur") + misspelled_commands_found = command_error.value.args[0].split(" ").count("totur") + + assert misspelled_commands_sent == misspelled_commands_found diff --git a/tests/distro/run_extra_commands/infrastructure/__init__.py b/tests/distro/run_extra_commands/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/distro/run_extra_commands/infrastructure/test_tutor_commands.py b/tests/distro/run_extra_commands/infrastructure/test_tutor_commands.py new file mode 100644 index 0000000..e69c982 --- /dev/null +++ b/tests/distro/run_extra_commands/infrastructure/test_tutor_commands.py @@ -0,0 +1,27 @@ +""" +Test tutor commands functions. +""" + +from tutordistro.distro.extra_commands.infrastructure.tutor_commands import TutorCommandManager + + +class TestTutorCommandManager(TutorCommandManager): + """ + Executes a Tutor command for testing. + + This class provides functionality to execute extra Tutor commands for testing. + + Args: + CommandManager (class): Base command manager class. + """ + commands_ran = 0 + + def run_command(self, command: str): + """ + This method runs an testing command. + + Args: + command (str): Testing command. + """ + + self.commands_ran += 1 diff --git a/tutordistro/commands/cli.py b/tutordistro/commands/cli.py index a592037..005a481 100644 --- a/tutordistro/commands/cli.py +++ b/tutordistro/commands/cli.py @@ -7,6 +7,7 @@ from tutordistro.commands.enable_private_packages import enable_private_packages from tutordistro.commands.enable_themes import enable_themes from tutordistro.commands.repository_validator import repository_validator +from tutordistro.commands.run_extra_commands import run_extra_commands from tutordistro.commands.syntax_validator import syntax_validator @@ -23,3 +24,4 @@ def distro() -> None: distro.add_command(enable_private_packages) distro.add_command(repository_validator) distro.add_command(syntax_validator) +distro.add_command(run_extra_commands) diff --git a/tutordistro/commands/run_extra_commands.py b/tutordistro/commands/run_extra_commands.py new file mode 100644 index 0000000..13a0f09 --- /dev/null +++ b/tutordistro/commands/run_extra_commands.py @@ -0,0 +1,32 @@ +""" +Distro run extra commands command. +""" + +import subprocess + +import click +from tutor import config as tutor_config + +from tutordistro.distro.extra_commands.application.commands_runner import CommandsRunner +from tutordistro.distro.extra_commands.infrastructure.tutor_commands import TutorCommandManager + + +@click.command(name="run-extra-commands", help="Run tutor commands") +def run_extra_commands(): + """ + This command runs tutor commands defined in DISTRO_EXTRA_COMMANDS + """ + directory = ( + subprocess.check_output("tutor config printroot", shell=True) + .decode("utf-8") + .strip() + ) + config = tutor_config.load(directory) + distro_extra_commands = config.get("DISTRO_EXTRA_COMMANDS", None) + + tutor_commands_manager = TutorCommandManager() + run_tutor_command = CommandsRunner(commands_manager=tutor_commands_manager, commands=distro_extra_commands) + + if distro_extra_commands: + for command in distro_extra_commands: + run_tutor_command(command=command) diff --git a/tutordistro/distro/extra_commands/__init__.py b/tutordistro/distro/extra_commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutordistro/distro/extra_commands/application/__init__.py b/tutordistro/distro/extra_commands/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutordistro/distro/extra_commands/application/commands_runner.py b/tutordistro/distro/extra_commands/application/commands_runner.py new file mode 100644 index 0000000..400337f --- /dev/null +++ b/tutordistro/distro/extra_commands/application/commands_runner.py @@ -0,0 +1,38 @@ +""" +Distro command runner. +""" +# Was necessary to use this for compatibility with Python 3.8 +from typing import List, Optional + +from tutordistro.distro.extra_commands.domain.command_manager import CommandManager + + +class CommandsRunner: + """ + Command runner. + + This class is responsible of executing extra commands by invoking the run_command method + on a commands manager. + + Attributes: + commands_manager (ThemeRepository): The command manager to use for executing the extra command. + """ + + def __init__(self, commands_manager: CommandManager, commands: Optional[List[str]]): + self.commands_manager = commands_manager + + if commands is not None: + commands_manager.validate_commands(commands) + + def __call__(self, command: str): + """ + Run the provided command. + + This method runs the provided command by invoking the run_command method + from the given command manager + + Args: + command (str): Command to execute. + """ + + return self.commands_manager.run_command(command=command) diff --git a/tutordistro/distro/extra_commands/domain/__init__.py b/tutordistro/distro/extra_commands/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutordistro/distro/extra_commands/domain/command_manager.py b/tutordistro/distro/extra_commands/domain/command_manager.py new file mode 100644 index 0000000..29f3437 --- /dev/null +++ b/tutordistro/distro/extra_commands/domain/command_manager.py @@ -0,0 +1,12 @@ +"""Command Manager""" + +import abc +from abc import abstractmethod + + +class CommandManager(metaclass=abc.ABCMeta): + """Command Manager""" + + @abstractmethod + def run_command(self, command: str): + """Run a command.""" diff --git a/tutordistro/distro/extra_commands/infrastructure/__init__.py b/tutordistro/distro/extra_commands/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutordistro/distro/extra_commands/infrastructure/tutor_commands.py b/tutordistro/distro/extra_commands/infrastructure/tutor_commands.py new file mode 100644 index 0000000..dbc78f3 --- /dev/null +++ b/tutordistro/distro/extra_commands/infrastructure/tutor_commands.py @@ -0,0 +1,85 @@ +""" +Distro tutor command functions. +""" + +import subprocess +# Was necessary to use this for compatibility with Python 3.8 +from typing import List + +from tutordistro.distro.extra_commands.domain.command_manager import CommandManager +from tutordistro.distro.share.domain.command_error import CommandError +from tutordistro.utils.common import find_tutor_misspelled, split_string +from tutordistro.utils.constants import COMMAND_CHAINING_OPERATORS + + +class TutorCommandManager(CommandManager): + """ + Executes a Tutor extra command. + + This class provides functionality to execute an extra Tutor command. + + Args: + CommandManager (class): Base command manager class. + """ + + def validate_commands(self, commands: List[str]): + """ + Takes all the extra commands sent through config.yml and verifies that + all the commands are correct before executing them + + Args: + commands (list[str] | None): The commands sent through DISTRO_EXTRA_COMMANDS in config.yml + """ + splitted_commands = [ + split_string(command, COMMAND_CHAINING_OPERATORS) for command in commands + ] + flat_commands_array = sum(splitted_commands, []) + + invalid_commands = [] + misspelled_commands = [] + for command in flat_commands_array: + if "tutor" not in command.lower(): + if find_tutor_misspelled(command): + misspelled_commands.append(command) + else: + invalid_commands.append(command) + + if invalid_commands or misspelled_commands: + raise CommandError( + f""" + Error: Were found some issues with the commands: + + {'=> Invalid commands: ' if invalid_commands else ""} + {', '.join(invalid_commands)} + + {'=> Misspelled commands: ' if misspelled_commands else ""} + {', '.join(misspelled_commands)} + + Take a look of the official Tutor commands: https://docs.tutor.edly.io/reference/cli/index.html + """ + ) + + def run_command(self, command: str): + """ + Run an extra command. + + This method runs the extra command provided. + + Args: + command (str): Tutor command. + """ + try: + process = subprocess.run( + command, + shell=True, + check=True, + capture_output=True, + executable="/bin/bash", + ) + # This print is left on purpose to show the command output + print(process.stdout.decode()) + + except subprocess.CalledProcessError as error: + raise CommandError( + f"Error running command '{error.cmd}':\n{error.stderr.decode()}" + ) from error diff --git a/tutordistro/distro/share/domain/command_error.py b/tutordistro/distro/share/domain/command_error.py new file mode 100644 index 0000000..264ed05 --- /dev/null +++ b/tutordistro/distro/share/domain/command_error.py @@ -0,0 +1,11 @@ +""" +Command error exception domain. +""" + + +class CommandError(Exception): + """ + Exception raised when a command execution fails. + + This exception can be raised when an error occurs during the command execution process. + """ diff --git a/tutordistro/utils/common.py b/tutordistro/utils/common.py new file mode 100644 index 0000000..07c7de9 --- /dev/null +++ b/tutordistro/utils/common.py @@ -0,0 +1,51 @@ +""" +Global utils +""" + +import re +# Was necessary to use this for compatibility with Python 3.8 +from typing import List + + +def find_tutor_misspelled(command: str): + """ + This function takes a command and looks if it has the word 'tutor' misspelled + + Args: + command (str): Command to be reviewed + + Return: + If its found the word 'tutor' misspelled is returned True + """ + return re.match(r"[tT](?:[oru]{3}|[oru]{2}[rR]|[oru]u?)", command) + + +def create_regex_from_array(arr: List[str]): + """ + This functions compiles a new regex turning taking care of + escaping special characters + + Args: + arr (list[str]): String that would be used to create a new regex + + Return: + A new compiled regex pattern that can be used for comparisons + """ + escaped_arr = [re.escape(item) for item in arr] + regex_pattern = "|".join(escaped_arr) + return re.compile(regex_pattern) + + +def split_string(string: str, split_by: List[str]): + """ + Takes a string that is wanted to be split according to some + other strings received in a list + + Args: + string (str): String that will be split + split_by (list[str]): Array of strings which will be used to split the string + + Return: + The string split into an array + """ + return re.split(create_regex_from_array(split_by), string) diff --git a/tutordistro/utils/constants.py b/tutordistro/utils/constants.py new file mode 100644 index 0000000..897fe8a --- /dev/null +++ b/tutordistro/utils/constants.py @@ -0,0 +1,5 @@ +""" +File of constant variables +""" + +COMMAND_CHAINING_OPERATORS = ["&&", "&", "||", "|", ";"]