diff --git a/setup.py b/setup.py index 7cd3175..5d753dd 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ def load_about(): packages=find_packages(exclude=["tests*"]), include_package_data=True, python_requires=">=3.8", - install_requires=["tutor>=18.0.0,<19.0.0"], + install_requires=["tutor>=18.0.0,<19.0.0", "schema"], extras_require={ "dev": [ "tutor[dev]>=18.0.0,<19.0.0", diff --git a/tutorpicasso/commands/cli.py b/tutorpicasso/commands/cli.py new file mode 100644 index 0000000..04efbf9 --- /dev/null +++ b/tutorpicasso/commands/cli.py @@ -0,0 +1,27 @@ +""" +Picasso commands group. +""" + +import click + +from tutorpicasso.commands.enable_private_packages import enable_private_packages +from tutorpicasso.commands.enable_themes import enable_themes +from tutorpicasso.commands.repository_validator import repository_validator +from tutorpicasso.commands.run_extra_commands import run_extra_commands +from tutorpicasso.commands.syntax_validator import syntax_validator + + +@click.group(help="Run picasso commands") +def picasso() -> None: + """ + Main picasso command group. + + This command group provides functionality to run picasso commands. + """ + + +picasso.add_command(enable_themes) +picasso.add_command(enable_private_packages) +picasso.add_command(repository_validator) +picasso.add_command(syntax_validator) +picasso.add_command(run_extra_commands) diff --git a/tutorpicasso/commands/enable_private_packages.py b/tutorpicasso/commands/enable_private_packages.py new file mode 100644 index 0000000..bc1f3a6 --- /dev/null +++ b/tutorpicasso/commands/enable_private_packages.py @@ -0,0 +1,102 @@ +""" +Picasso enable private packages command. +""" + +import subprocess + +import click +from tutor import config as tutor_config + +from tutorpicasso.picasso.packages.application.package_cloner import PackageCloner +from tutorpicasso.picasso.packages.infrastructure.package_git_repository import PackageGitRepository +from tutorpicasso.utils.packages import get_private_picasso_packages + + +@click.command(name="enable-private-packages", help="Enable picasso private packages") +def enable_private_packages(): + """ + Enable private packages command. + + This command enables picasso private packages by cloning the packages and + defining them as private. + + Raises: + Exception: If an error occurs during the cloning or defining process. + """ + directory = subprocess.check_output("tutor config printroot", shell=True).\ + decode("utf-8").strip() + config = tutor_config.load(directory) + + repository = PackageGitRepository() + cloner = PackageCloner(repository=repository) + + private_packages = get_private_picasso_packages(config) + requirements_directory = f"{directory}/env/build/openedx/requirements/" + for package in private_packages.values(): + try: + cloner( + name=package["name"], + version=package["version"], + domain=package["domain"], + extra={ # pylint:disable=duplicate-code + "repo": package["repo"], + "protocol": package["protocol"], + "path": package["path"] + }, + path=requirements_directory + ) + + # Run tutor mounts add command for the package + subprocess.check_output(f"tutor mounts add {requirements_directory}{package['name']}", shell=True) + hook = f'hooks.Filters.MOUNTED_DIRECTORIES.add_item(("openedx", "{package["name"]}"))' + + hook_writer(hook_to_append=hook) + subprocess.check_output("tutor config save", shell=True) + except Exception as error: # pylint: disable=broad-exception-caught + click.echo(error) + + +def get_picasso_location(): + """ + Function to get the right picasso path + """ + + try: + result = subprocess.run(['pip', 'show', 'tutor-contrib-picasso'], + capture_output=True, text=True, check=True) + + # Check if the command was successful + if result.returncode == 0: + # Split the output into lines + lines = result.stdout.splitlines() + + # Loop through each line to find the Location + for line in lines: + if line.startswith('Location:'): + # Extract the location path + location_path = line.split(':', 1)[1].strip() + "/tutorpicasso/plugin.py" + return location_path + except subprocess.CalledProcessError as e: + # Print error message if command failed + print("Error running pip show picasso:", e.stderr) + + # Return a default value if the location is not found or an error occurs + return None + + +def hook_writer(hook_to_append): + """ + Function to write the corresponding hooks depending on the private packages. + """ + file_path = get_picasso_location() + with open(file_path, 'a+', encoding='utf-8') as my_file: # Open file in append and read mode + + my_file.seek(0) # Move the cursor to the beginning of the file + existing_lines = my_file.readlines() + package_name = hook_to_append.split('"')[3] # Extract package name from hook_to_append + + # Check if package name already exists in the file + if any(package_name in line for line in existing_lines): + print(f"Package '{package_name}' already present in the file.") + else: + my_file.write(hook_to_append + "\n") diff --git a/tutorpicasso/commands/enable_themes.py b/tutorpicasso/commands/enable_themes.py new file mode 100644 index 0000000..e12d140 --- /dev/null +++ b/tutorpicasso/commands/enable_themes.py @@ -0,0 +1,31 @@ +""" +Picasso enable theme command. +""" + +import subprocess + +import click +from tutor import config as tutor_config + +from tutorpicasso.picasso.themes.application.theme_enabler import ThemeEnabler +from tutorpicasso.picasso.themes.infraestructure.theme_git_repository import ThemeGitRepository + + +@click.command(name="enable-themes", help="Enable picasso themes") +def enable_themes() -> None: + """ + Enable picasso themes. + + This function enables the themes specified in the `PICASSO_THEMES` configuration + and applies them using the ThemeEnabler and ThemeGitRepository classes. + """ + directory = subprocess.check_output("tutor config printroot", shell=True).\ + decode("utf-8").strip() + config = tutor_config.load(directory) + + repository = ThemeGitRepository() + enabler = ThemeEnabler(repository=repository) + + if config.get("PICASSO_THEMES"): + for theme in config["PICASSO_THEMES"]: + enabler(settings=theme, tutor_root=directory, tutor_config=config) diff --git a/tutorpicasso/commands/repository_validator.py b/tutorpicasso/commands/repository_validator.py new file mode 100644 index 0000000..e2b1fac --- /dev/null +++ b/tutorpicasso/commands/repository_validator.py @@ -0,0 +1,76 @@ +""" +This module provides a repository validator command-line tool. +It validates the repositories used in the project, checking for specific conditions and +requirements. +""" + + +import subprocess + +import click +from tutor import config as tutor_config + +from tutorpicasso.picasso.repository_validator.application.dpkg_url_validator import DPKGUrlValidator +from tutorpicasso.picasso.repository_validator.application.extra_pip_requirements_url_validator import ( + ExtraPipRequirementsUrlValidator, +) +from tutorpicasso.picasso.repository_validator.infrastructure.git_package_repository import GitPackageRepository +from tutorpicasso.utils.packages import get_public_picasso_packages + + +@click.command(name="repository-validator", help="Repository validator") +def repository_validator() -> None: + """ + Validate the repositories used in the project. + + This function checks for specific conditions and requirements in the repositories + defined in the project's configuration. It validates GitHub repositories that end with + 'DPKG' and the repositories specified in the 'OPENEDX_EXTRA_PIP_REQUIREMENTS' configuration. + + It uses the DPKGUrlValidator to validate GitHub repositories and the + ExtraPipRequirementsUrlValidator to validate 'OPENEDX_EXTRA_PIP_REQUIREMENTS' repositories. + + The validation results are printed using click.echo. + + Raises: + Exception: If an error occurs during validation. + + Returns: + None + """ + directory = subprocess.check_output("tutor config printroot", shell=True).\ + decode("utf-8").strip() + config = tutor_config.load(directory) + + public_packages = get_public_picasso_packages(config) + + repository = GitPackageRepository() + dpkg_controller = DPKGUrlValidator(repository=repository) + + # Check github repos that end with 'DPKG' + + for package in public_packages.values(): + try: + dpkg_controller( + name=package["name"], + version=package["version"], + domain=package["domain"], + extra={ + "repo": package["repo"], + "protocol": package["protocol"], + "path": package["path"] + } + ) + except Exception as error: # pylint: disable=broad-except + click.echo(error) + + # Check the openedx_extra_pip_requirements repos + openedx_extra_pip_requirements = config.get('OPENEDX_EXTRA_PIP_REQUIREMENTS', []) + + epr_controller = ExtraPipRequirementsUrlValidator(repository=repository) + + for git_url in openedx_extra_pip_requirements: + try: + epr_controller(url=git_url) + except Exception as error: # pylint: disable=broad-except + click.echo(error) diff --git a/tutorpicasso/commands/run_extra_commands.py b/tutorpicasso/commands/run_extra_commands.py new file mode 100644 index 0000000..044d483 --- /dev/null +++ b/tutorpicasso/commands/run_extra_commands.py @@ -0,0 +1,32 @@ +""" +Picasso run extra commands command. +""" + +import subprocess + +import click +from tutor import config as tutor_config + +from tutorpicasso.picasso.extra_commands.application.commands_runner import CommandsRunner +from tutorpicasso.picasso.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 PICASSO_EXTRA_COMMANDS + """ + directory = ( + subprocess.check_output("tutor config printroot", shell=True) + .decode("utf-8") + .strip() + ) + config = tutor_config.load(directory) + picasso_extra_commands = config.get("PICASSO_EXTRA_COMMANDS", None) + + tutor_commands_manager = TutorCommandManager() + run_tutor_command = CommandsRunner(commands_manager=tutor_commands_manager, commands=picasso_extra_commands) + + if picasso_extra_commands: + for command in picasso_extra_commands: + run_tutor_command(command=command) diff --git a/tutorpicasso/commands/syntax_validator.py b/tutorpicasso/commands/syntax_validator.py new file mode 100644 index 0000000..c4a9d9b --- /dev/null +++ b/tutorpicasso/commands/syntax_validator.py @@ -0,0 +1,43 @@ +""" +Picasso config syntax validator command. +""" + +import subprocess + +import click + +from tutorpicasso.picasso.share.domain.config_extra_files_requirements_setting import ConfigExtraFilesRequirementsSetting +from tutorpicasso.picasso.share.domain.config_extra_pip_requirements_setting import ConfigExtraPipRequirementsSetting +from tutorpicasso.picasso.share.domain.config_extra_setting import ConfigExtraSetting +from tutorpicasso.picasso.share.domain.config_packages_setting import ConfigPackagesSetting +from tutorpicasso.picasso.share.domain.config_themes_setting import ConfigThemesSetting +from tutorpicasso.picasso.syntax_validator.application.config_syntax_validator import ConfigSyntaxValidator +from tutorpicasso.picasso.syntax_validator.infrastructure.config_repository import ConfigRepository + + +@click.command(name="syntax-validator", help="Syntax validator") +def syntax_validator() -> None: + """ + Command to perform syntax validation on the configuration. + + This command loads the Tutor configuration, validates it using the + ConfigSyntaxValidator, and displays the validation result. + """ + file_path = subprocess.check_output("tutor config printroot", shell=True).decode("utf-8").strip() + + config_settings = [ + ConfigExtraFilesRequirementsSetting, + ConfigExtraPipRequirementsSetting, + ConfigExtraSetting, + ConfigPackagesSetting, + ConfigThemesSetting, + ] + repository = ConfigRepository(config_settings) + + syntax_validator = ConfigSyntaxValidator(repository) + is_valid = syntax_validator.execute(file_path) + + if is_valid: + click.echo("Success validation") + else: + click.echo("Failed validation. Check settings") diff --git a/tutorpicasso/picasso/extra_commands/__init__.py b/tutorpicasso/picasso/extra_commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/extra_commands/application/__init__.py b/tutorpicasso/picasso/extra_commands/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/extra_commands/application/commands_runner.py b/tutorpicasso/picasso/extra_commands/application/commands_runner.py new file mode 100644 index 0000000..627d5b1 --- /dev/null +++ b/tutorpicasso/picasso/extra_commands/application/commands_runner.py @@ -0,0 +1,38 @@ +""" +Picasso command runner. +""" +# Was necessary to use this for compatibility with Python 3.8 +from typing import List, Optional + +from tutorpicasso.picasso.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/tutorpicasso/picasso/extra_commands/domain/__init__.py b/tutorpicasso/picasso/extra_commands/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/extra_commands/domain/command_manager.py b/tutorpicasso/picasso/extra_commands/domain/command_manager.py new file mode 100644 index 0000000..29f3437 --- /dev/null +++ b/tutorpicasso/picasso/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/tutorpicasso/picasso/extra_commands/infrastructure/__init__.py b/tutorpicasso/picasso/extra_commands/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/extra_commands/infrastructure/tutor_commands.py b/tutorpicasso/picasso/extra_commands/infrastructure/tutor_commands.py new file mode 100644 index 0000000..46ca314 --- /dev/null +++ b/tutorpicasso/picasso/extra_commands/infrastructure/tutor_commands.py @@ -0,0 +1,94 @@ +""" +Picasso tutor command functions. +""" + +import subprocess +# Was necessary to use this for compatibility with Python 3.8 +from typing import List + +from tutorpicasso.picasso.extra_commands.domain.command_manager import CommandManager +from tutorpicasso.picasso.share.domain.command_error import CommandError +from tutorpicasso.utils.common import find_tutor_misspelled, split_string +from tutorpicasso.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 PICASSO_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: + with subprocess.Popen( + command, + shell=True, + executable="/bin/bash", + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) as process: + + # It is sent a 'y' to say 'yes' on overriding the existing folders + stdout, stderr = process.communicate(input="y") + + if process.returncode != 0 or "error" in stderr.lower(): + raise subprocess.CalledProcessError( + process.returncode, command, output=stdout, stderr=stderr + ) + + # This print is left on purpose to show the command output + print(stdout) + + except subprocess.CalledProcessError as error: + raise CommandError(f"\n{error.stderr}") from error diff --git a/tutorpicasso/picasso/packages/__init__.py b/tutorpicasso/picasso/packages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/packages/application/__init__.py b/tutorpicasso/picasso/packages/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/packages/application/package_cloner.py b/tutorpicasso/picasso/packages/application/package_cloner.py new file mode 100644 index 0000000..46c239d --- /dev/null +++ b/tutorpicasso/picasso/packages/application/package_cloner.py @@ -0,0 +1,51 @@ +""" +Package cloner process. +""" + +from tutorpicasso.picasso.packages.domain.package_repository import PackageRepository +from tutorpicasso.picasso.share.domain.package import Package +from tutorpicasso.picasso.share.domain.package_domain import PackageDomain +from tutorpicasso.picasso.share.domain.package_name import PackageName +from tutorpicasso.picasso.share.domain.package_version import PackageVersion + + +class PackageCloner: + """ + Package cloner process. + + This class is responsible for cloning a package using a given package repository. + + Args: + repository (PackageRepository): The package repository used for cloning. + + Attributes: + repository (PackageRepository): The package repository used for cloning. + """ + + def __init__(self, repository: PackageRepository) -> None: + self.repository = repository + + def __call__( # pylint: disable=too-many-arguments + self, + name: str, + version: str, + domain: str, + path: str, + extra: dict = None + ) -> None: # pylint:disable=duplicate-code + """ + Clone a package to the specified path. + + Args: + name (str): The name of the package. + version (str): The version of the package. + domain (str): The domain of the package. + path (str): The path to clone the package. + extra (dict, optional): Extra metadata associated with the package. Defaults to None. + """ + name = PackageName(name) + version = PackageVersion(version) + domain = PackageDomain(domain) + package = Package(name=name, version=version, domain=domain, extra=extra if extra else {}) + + self.repository.clone(package=package, path=path) diff --git a/tutorpicasso/picasso/packages/application/private_package_definer.py b/tutorpicasso/picasso/packages/application/private_package_definer.py new file mode 100644 index 0000000..6d94e4e --- /dev/null +++ b/tutorpicasso/picasso/packages/application/private_package_definer.py @@ -0,0 +1,38 @@ +""" +Private package denifer process. +""" + +from tutorpicasso.picasso.packages.domain.package_repository import PackageRepository +from tutorpicasso.picasso.share.domain.package_name import PackageName + + +class PrivatePackageDefiner: + """ + Private package definer process. + + This class is responsible for setting a package as private in the package repository. + + Args: + repository (PackageRepository): The package repository used for defining private packages. + + Attributes: + repository (PackageRepository): The package repository used for defining private packages. + """ + + def __init__(self, repository: PackageRepository) -> None: + self.repository = repository + + def __call__( + self, + name: str, + file_path: str + ) -> None: + """ + Define a package as private. + + Args: + name (str): The name of the package. + file_path (str): The file path of the package. + """ + name = PackageName(name) + self.repository.set_as_private(name=name, file_path=file_path) diff --git a/tutorpicasso/picasso/packages/domain/__init__.py b/tutorpicasso/picasso/packages/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/packages/domain/package_repository.py b/tutorpicasso/picasso/packages/domain/package_repository.py new file mode 100644 index 0000000..2ab31da --- /dev/null +++ b/tutorpicasso/picasso/packages/domain/package_repository.py @@ -0,0 +1,44 @@ +""" +Picasso package repository. +""" + +from abc import ABC, abstractmethod + +from tutorpicasso.picasso.share.domain.package import Package +from tutorpicasso.picasso.share.domain.package_name import PackageName + + +class PackageRepository(ABC): + """ + Abstract base class for package repositories. + + This class defines the interface for package repositories, which are responsible for + cloning packages and setting them as private. + + Concrete implementations of package repositories should inherit from this class and + provide implementations for the `clone` and `set_as_private` methods. + """ + + @abstractmethod + def clone(self, package: Package, path: str) -> None: + """ + Clone a package. + + This method should clone the specified package to the specified path. + + Args: + package (Package): The package to clone. + path (str): The path to clone the package to. + """ + + @abstractmethod + def set_as_private(self, name: PackageName, file_path: str) -> None: + """ + Set a package as private. + + This method should mark the specified package as private, using the specified file path. + + Args: + name (PackageName): The name of the package to set as private. + file_path (str): The file path to use for marking the package as private. + """ diff --git a/tutorpicasso/picasso/packages/infrastructure/__init__.py b/tutorpicasso/picasso/packages/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/packages/infrastructure/package_git_repository.py b/tutorpicasso/picasso/packages/infrastructure/package_git_repository.py new file mode 100644 index 0000000..7321479 --- /dev/null +++ b/tutorpicasso/picasso/packages/infrastructure/package_git_repository.py @@ -0,0 +1,85 @@ +""" +Package git repository ingrastructure. +""" + +import os +import shutil +import subprocess + +import click + +from tutorpicasso.picasso.packages.domain.package_repository import PackageRepository +from tutorpicasso.picasso.share.domain.clone_exception import CloneException +from tutorpicasso.picasso.share.domain.package import Package +from tutorpicasso.picasso.share.domain.package_name import PackageName + + +class PackageGitRepository(PackageRepository): + """ + Package Git repository infrastructure. + + This class provides functionality to clone and manage packages from a Git repository. + """ + + def set_as_private(self, name: PackageName, file_path: str) -> None: + """ + Set a package as private. + + This method appends the package as a private requirement in the given requirements file. + + Args: + name (PackageName): The name of the package. + file_path (str): The file path of the requirements file. + """ + already_exist = False + + if os.path.exists(file_path): + with open(file_path, mode='r', encoding="utf-8") as private_requirements_file: + if name in private_requirements_file.read(): + already_exist = True + + if not already_exist: + with open(file_path, mode='a+', encoding="utf-8") as private_requirements_file: + private_requirements_file.write(f"\n-e ./{name}") + + def clone(self, package: Package, path: str) -> None: + """ + Clone a package from the Git repository. + + This method clones the package from the specified Git repository. + + Args: + package (Package): The package to be cloned. + path (str): The destination path for cloning the package. + """ + repo = None + if "https" == package.extra["protocol"]: + repo = ( + f"https://{package.domain}/" + f"{package.extra['path']}/" + f"{package.extra['repo']}" + ) + elif "ssh" == package.extra["protocol"]: + repo = ( + f"git@{package.domain}:" + f"{package.extra['path']}/" + f"{package.extra['repo']}.git" + ) + + package_folder = f"{path}{package.name}" + + if os.path.exists(f"{package_folder}"): + if not click.confirm(f"Do you want to overwrite {package.name}? "): + raise CloneException() + shutil.rmtree(package_folder) + + subprocess.call( + [ + "git", + "clone", + "-b", + package.version, + repo, + f"{package_folder}", + ] + ) diff --git a/tutorpicasso/picasso/repository_validator/__init__.py b/tutorpicasso/picasso/repository_validator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/repository_validator/application/__init__.py b/tutorpicasso/picasso/repository_validator/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/repository_validator/application/dpkg_url_validator.py b/tutorpicasso/picasso/repository_validator/application/dpkg_url_validator.py new file mode 100644 index 0000000..e469f6e --- /dev/null +++ b/tutorpicasso/picasso/repository_validator/application/dpkg_url_validator.py @@ -0,0 +1,36 @@ +""" +This module provides the DPKGUrlValidator class for validating DPKG URLs. +""" + + +from tutorpicasso.picasso.share.domain.cloud_package import CloudPackage +from tutorpicasso.picasso.share.domain.cloud_package_repository import CloudPackageRepository +from tutorpicasso.picasso.share.domain.package import Package +from tutorpicasso.picasso.share.domain.package_domain import PackageDomain +from tutorpicasso.picasso.share.domain.package_name import PackageName +from tutorpicasso.picasso.share.domain.package_version import PackageVersion + + +class DPKGUrlValidator: + """ + Validator class for validating DPKG URLs. + + It uses a CloudPackageRepository to validate the package represented by the DPKG URL. + """ + def __init__(self, repository: CloudPackageRepository) -> None: + self.repository = repository + + def __call__( # pylint:disable=duplicate-code + self, + name: str, + version: str, + domain: str, + extra: dict = None + ) -> None: + name = PackageName(name) + version = PackageVersion(version) + domain = PackageDomain(domain) + package = Package(name=name, version=version, domain=domain, extra=extra if extra else {}) + git_package = CloudPackage.from_package(package=package) + + self.repository.validate(package=git_package) diff --git a/tutorpicasso/picasso/repository_validator/application/extra_pip_requirements_url_validator.py b/tutorpicasso/picasso/repository_validator/application/extra_pip_requirements_url_validator.py new file mode 100644 index 0000000..dbe6a6b --- /dev/null +++ b/tutorpicasso/picasso/repository_validator/application/extra_pip_requirements_url_validator.py @@ -0,0 +1,26 @@ +""" +This module provides the ExtraPipRequirementsUrlValidator class for validating +extra pip requirements URLs. +""" + + +from tutorpicasso.picasso.share.domain.cloud_package import CloudPackage +from tutorpicasso.picasso.share.domain.cloud_package_repository import CloudPackageRepository + + +class ExtraPipRequirementsUrlValidator: + """ + Validator class for validating extra pip requirements URLs. + + It uses a CloudPackageRepository to validate the package represented by the URL. + """ + def __init__(self, repository: CloudPackageRepository) -> None: + self.repository = repository + + def __call__( + self, + url: str + ) -> None: + if CloudPackage.is_valid_requirement(url): + git_package = CloudPackage.from_string(url) + self.repository.validate(package=git_package) diff --git a/tutorpicasso/picasso/repository_validator/infrastructure/__init__.py b/tutorpicasso/picasso/repository_validator/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/repository_validator/infrastructure/git_package_repository.py b/tutorpicasso/picasso/repository_validator/infrastructure/git_package_repository.py new file mode 100644 index 0000000..25340ff --- /dev/null +++ b/tutorpicasso/picasso/repository_validator/infrastructure/git_package_repository.py @@ -0,0 +1,87 @@ +""" +This module provides the GitPackageRepository class, which is a specific implementation +of the CloudPackageRepository. +It interacts with a Git repository to validate a CloudPackage. +""" + + +from __future__ import annotations + +import subprocess +from typing import Optional + +from tutorpicasso.picasso.share.domain.cloud_package import CloudPackage +from tutorpicasso.picasso.share.domain.cloud_package_repository import CloudPackageRepository +from tutorpicasso.picasso.share.domain.package_does_not_exist import PackageDoesNotExist + + +class GitPackageRepository(CloudPackageRepository): + """ + Repository class for validating CloudPackages using a Git repository. + + This class inherits from CloudPackageRepository and provides the implementation for + the validation method. It verifies the existence of the repository and checks + if the specified branch or tag exists. + """ + def validate(self, package: CloudPackage) -> None: + package_url = package.to_url() + repo_url, version_name = self._parse_package_url(package_url) + + self._verify_repository_exists(repo_url) + if version_name: + self._verify_version_exists(repo_url, version_name) + + def _parse_package_url(self, package_url: str) -> tuple[str, Optional[str]]: + """ + Parse the package URL to extract the repository URL and the version name. + + Args: + package_url (str): The full URL of the package. + + Returns: + tuple: A tuple containing the repository URL and the version name (branch/tag). + """ + split_url = package_url.split('/tree/') + repo_url = split_url[0] + version_name = split_url[1] if len(split_url) > 1 else None + return repo_url, version_name + + def _verify_repository_exists(self, repo_url: str) -> None: + """ + Verify that the repository exists. + + Args: + repo_url (str): The URL of the repository. + + Raises: + PackageDoesNotExist: If the repository does not exist or is private. + """ + result = subprocess.run( + ['git', 'ls-remote', repo_url], + capture_output=True, text=True, check=False + ) + if result.returncode != 0: + raise PackageDoesNotExist(f'The package "{repo_url}" does not exist or is private') + + def _verify_version_exists(self, repo_url: str, version_name: str) -> None: + """ + Verify that the branch or tag exists in the repository. + + Args: + repo_url (str): The URL of the repository. + version_name (str): The branch or tag name to verify. + + Raises: + PackageDoesNotExist: If neither the branch nor the tag exists. + """ + branch_result = subprocess.run( + ['git', 'ls-remote', '--heads', repo_url, version_name], + capture_output=True, text=True, check=False + ) + tag_result = subprocess.run( + ['git', 'ls-remote', '--tags', repo_url, version_name], + capture_output=True, text=True, check=False + ) + + if not branch_result.stdout and not tag_result.stdout: + raise PackageDoesNotExist(f'Neither branch nor tag "{version_name}" exists on "{repo_url}"') diff --git a/tutorpicasso/picasso/share/__init__.py b/tutorpicasso/picasso/share/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/share/domain/__init__.py b/tutorpicasso/picasso/share/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/share/domain/clone_exception.py b/tutorpicasso/picasso/share/domain/clone_exception.py new file mode 100644 index 0000000..45cae99 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/clone_exception.py @@ -0,0 +1,11 @@ +""" +Cloner exception domain. +""" + + +class CloneException(Exception): + """ + Exception raised when a cloning operation fails. + + This exception can be raised when an error occurs during the cloning process. + """ diff --git a/tutorpicasso/picasso/share/domain/cloud_package.py b/tutorpicasso/picasso/share/domain/cloud_package.py new file mode 100644 index 0000000..58ab049 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/cloud_package.py @@ -0,0 +1,124 @@ +""" +This module provides the CloudPackage class representing a cloud package. + +The CloudPackage class is used to parse and manipulate URLs representing cloud-based packages. +""" + + +from __future__ import annotations + +import re +from urllib.parse import urlparse + +from tutorpicasso.picasso.share.domain.package import Package + + +class CloudPackage: + """ + Representation of a cloud package. + + The CloudPackage class is used to parse and manipulate URLs representing cloud-based packages. + """ + domain: str | None = None + name: str | None = None + version: str | None = None + protocol: str | None = None + path: str | None = None + + def __init__(self, domain: str, name: str, version: str, protocol: str, path: str) -> None: # pylint: disable=too-many-arguments + self.domain = domain + self.name = name + self.version = version + self.protocol = protocol + self.path = path + + @staticmethod + def is_valid_requirement(url) -> bool: + """ + Check if the provided URL is a valid requirement. + + Args: + url (str): The URL to check. + + Returns: + bool: True if the URL is a valid requirement, False otherwise. + """ + pattern = r"git\+(https?://\S+?)(?:#|$)" + result = re.search(pattern, url) + return bool(result) + + @staticmethod + def __parse_url(url) -> CloudPackage: + version: str = '' + + pattern = r"git\+(https?://\S+?)(?:#|$)" + found_package_url = re.search(pattern, url).group(1) + github_url = found_package_url.replace('@', '/tree/').replace('.git', '') + + parsed_url = urlparse(github_url) + split_path = parsed_url.path.split('/') + + protocol = parsed_url.scheme + domain = parsed_url.netloc + org_path = split_path[1] + package_name = split_path[2] + + if '/tree/' in github_url: + version = github_url.split('/tree/')[-1] # This is the branch name or tag + + return CloudPackage( + domain=domain, + name=package_name, + version=version, + protocol=protocol, + path=org_path + ) + + @staticmethod + def __parse_package(package: Package) -> CloudPackage: + return CloudPackage( + path=package.extra["path"], + protocol=package.extra["protocol"], + domain=package.domain, + version=package.version, + name=package.extra["repo"] + ) + + @staticmethod + def from_string(url: str) -> CloudPackage: + """ + Create a CloudPackage object from the provided URL string. + + Args: + url (str): The URL string representing the cloud package. + + Returns: + CloudPackage: The created CloudPackage object. + """ + return CloudPackage.__parse_url(url=url) + + @staticmethod + def from_package(package: Package) -> CloudPackage: + """ + Create a CloudPackage object from the provided Package object. + + Args: + package (Package): The Package object representing the cloud package. + + Returns: + CloudPackage: The created CloudPackage object. + """ + return CloudPackage.__parse_package(package=package) + + def to_url(self) -> str: + """ + Convert the CloudPackage object to a URL string. + + Returns: + str: The URL string representing the CloudPackage. + """ + version_url = f"/tree/{self.version}" if self.version != "" else "" + return f"{self.protocol}://{self.domain}/{self.path}/{self.name}{version_url}" + + def __str__(self) -> str: + return self.to_url() diff --git a/tutorpicasso/picasso/share/domain/cloud_package_repository.py b/tutorpicasso/picasso/share/domain/cloud_package_repository.py new file mode 100644 index 0000000..1cff218 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/cloud_package_repository.py @@ -0,0 +1,31 @@ +""" +This module provides the abstract base class for cloud package repositories. +""" + + +from abc import ABC, abstractmethod + +from tutorpicasso.picasso.share.domain.cloud_package import CloudPackage + + +class CloudPackageRepository(ABC): + """ + Abstract base class for cloud package repositories. + + The CloudPackageRepository class defines the interface for a cloud package repository, + which provides the ability to validate cloud packages. + """ + + @abstractmethod + def validate(self, package: CloudPackage) -> None: + """ + Validate a cloud package. + + This method is responsible for validating a cloud package in the repository. + + Args: + package (CloudPackage): The cloud package to validate. + + Raises: + NotImplementedError: This method should be implemented in concrete subclasses. + """ diff --git a/tutorpicasso/picasso/share/domain/command_error.py b/tutorpicasso/picasso/share/domain/command_error.py new file mode 100644 index 0000000..264ed05 --- /dev/null +++ b/tutorpicasso/picasso/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/tutorpicasso/picasso/share/domain/config_extra_files_requirements_setting.py b/tutorpicasso/picasso/share/domain/config_extra_files_requirements_setting.py new file mode 100644 index 0000000..2682d92 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/config_extra_files_requirements_setting.py @@ -0,0 +1,38 @@ +""" +Domain module to structure extra files requirements validator. +""" +from schema import And, Schema, SchemaError + +from tutorpicasso.picasso.share.domain.config_file import ConfigFile +from tutorpicasso.picasso.share.domain.config_file_validation_error import ConfigFileValidationError +from tutorpicasso.picasso.share.domain.config_setting import ConfigSetting + + +class ConfigExtraFilesRequirementsSetting(ConfigSetting): + """ + Represents a configuration setting for validating extra file requirements. + + This class is responsible for validating the extra file requirements in the configuration. + """ + + def __init__(self, config: ConfigFile) -> None: + self.config = config + + def validate(self) -> None: + """ + Validate the extra file requirements in the configuration. + + Raises: + ConfigFileValidationError: If an extra file requirement validation fails. + """ + extra_files_schema = Schema( + { + "files": And(list), + "path": And(str, len), + }, + ) + if "INSTALL_EXTRA_FILE_REQUIREMENTS" in self.config: + try: + extra_files_schema.validate(self.config["INSTALL_EXTRA_FILE_REQUIREMENTS"]) + except SchemaError as error: + raise ConfigFileValidationError("INSTALL_EXTRA_FILE_REQUIREMENTS", str(error)) from error diff --git a/tutorpicasso/picasso/share/domain/config_extra_pip_requirements_setting.py b/tutorpicasso/picasso/share/domain/config_extra_pip_requirements_setting.py new file mode 100644 index 0000000..d174237 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/config_extra_pip_requirements_setting.py @@ -0,0 +1,35 @@ +""" +Domain module to structure extra pip requirements validator. +""" +from schema import Schema, SchemaError + +from tutorpicasso.picasso.share.domain.config_file import ConfigFile +from tutorpicasso.picasso.share.domain.config_file_validation_error import ConfigFileValidationError +from tutorpicasso.picasso.share.domain.config_setting import ConfigSetting + + +class ConfigExtraPipRequirementsSetting(ConfigSetting): + """ + Represents a configuration setting for validating extra pip requirements. + + This class is responsible for validating the extra pip requirements in the configuration. + """ + + def __init__(self, config: ConfigFile) -> None: + self.config = config + + def validate(self) -> None: + """ + Validate the extra pip requirements in the configuration. + + Raises: + ConfigFileValidationError: If an extra pip requirement validation fails. + """ + pip_requirement_schema = Schema(str, len) + if "OPENEDX_EXTRA_PIP_REQUIREMENTS" in self.config: + ep_requirements = self.config["OPENEDX_EXTRA_PIP_REQUIREMENTS"] + for requirement in ep_requirements: + try: + pip_requirement_schema.validate(requirement) + except SchemaError as error: + raise ConfigFileValidationError(requirement, str(error)) from error diff --git a/tutorpicasso/picasso/share/domain/config_extra_setting.py b/tutorpicasso/picasso/share/domain/config_extra_setting.py new file mode 100644 index 0000000..4fa3b6f --- /dev/null +++ b/tutorpicasso/picasso/share/domain/config_extra_setting.py @@ -0,0 +1,50 @@ +""" +Domain module to structure extra settings validator. +""" +from schema import And, Optional, Schema, SchemaError + +from tutorpicasso.picasso.share.domain.config_file import ConfigFile +from tutorpicasso.picasso.share.domain.config_file_validation_error import ConfigFileValidationError +from tutorpicasso.picasso.share.domain.config_setting import ConfigSetting + + +class ConfigExtraSetting(ConfigSetting): + """ + Represents a configuration setting for extra settings validation. + + This class is responsible for validating extra settings in the configuration. + """ + + def __init__(self, config: ConfigFile) -> None: + self.config = config + + def validate(self) -> None: + """ + Validate the extra settings in the configuration. + + Raises: + ConfigFileValidationError: If an extra settings validation fails. + """ + extra_settings_schema = Schema( + { + Optional("cms_env"): And(list), + Optional("lms_env"): And(list), + Optional("pre_init_lms_tasks"): And(list), + }, + ) + extra_picasso_settings_schemas = Schema( + { + Optional("PICASSO_EXTRA_MIDDLEWARES"): And(list), + Optional("PICASSO_DISABLE_MFE"): And(bool), + }, + ignore_extra_keys=True + ) + if "OPENEDX_EXTRA_SETTINGS" in self.config: + try: + extra_settings_schema.validate(self.config["OPENEDX_EXTRA_SETTINGS"]) + except SchemaError as error: + raise ConfigFileValidationError("OPENEDX_EXTRA_SETTINGS", str(error)) from error + try: + extra_picasso_settings_schemas.validate(self.config) + except SchemaError as error: + raise ConfigFileValidationError("Settings", str(error)) from error diff --git a/tutorpicasso/picasso/share/domain/config_file.py b/tutorpicasso/picasso/share/domain/config_file.py new file mode 100644 index 0000000..f8c6556 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/config_file.py @@ -0,0 +1,31 @@ +""" +Module for handling configuration files. +""" +from tutor import config as tutor_config + + +class ConfigFile: + """ + Represents a configuration file. + + Args: + file_path (str): The path to the configuration file. + + Attributes: + file_path (str): The path to the configuration file. + """ + + def __init__(self, file_path): + self.file_path = file_path + + def config_file(self): + """ + Load and return the configuration file. + + Returns: + dict: The loaded configuration file. + + Note: + This method uses the 'tutor_config.load' function to load the configuration file. + """ + return tutor_config.load(self.file_path) diff --git a/tutorpicasso/picasso/share/domain/config_file_validation_error.py b/tutorpicasso/picasso/share/domain/config_file_validation_error.py new file mode 100644 index 0000000..e6cfde6 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/config_file_validation_error.py @@ -0,0 +1,24 @@ +""" +Module for handling configuration file validation errors. +""" + + +class ConfigFileValidationError(Exception): + """ + Exception raised for syntax errors in configuration settings. + + Args: + setting (str): The name of the setting with the syntax error. + error_message (str): The error message describing the syntax error. + + Attributes: + setting (str): The name of the setting with the syntax error. + error_message (str): The error message describing the syntax error. + """ + + def __init__(self, setting, error_message): + self.setting = setting + self.error_message = error_message + + def __str__(self): + return f"Syntax error in {self.setting}: {self.error_message}" diff --git a/tutorpicasso/picasso/share/domain/config_packages_setting.py b/tutorpicasso/picasso/share/domain/config_packages_setting.py new file mode 100644 index 0000000..0181627 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/config_packages_setting.py @@ -0,0 +1,47 @@ +""" +Domain module to structure packages validator. +""" +from schema import And, Optional, Or, Schema, SchemaError + +from tutorpicasso.picasso.share.domain.config_file import ConfigFile +from tutorpicasso.picasso.share.domain.config_file_validation_error import ConfigFileValidationError +from tutorpicasso.picasso.share.domain.config_setting import ConfigSetting +from tutorpicasso.utils.packages import get_picasso_packages + + +class ConfigPackagesSetting(ConfigSetting): + """ + Represents a configuration setting for packages validation. + + This class is responsible for validating packages in the configuration. + """ + + def __init__(self, config: ConfigFile) -> None: + self.config = config + + def validate(self) -> None: + """ + Validate the packages in the configuration. + + Raises: + ConfigFileValidationError: If a package validation fails. + """ + package_schema = Schema( + { + "index": And(str, len), + "name": And(str, len), + "repo": And(str, len), + "domain": And(str, len), + "path": And(str, len), + "protocol": Or("ssh", "https"), + Optional("variables"): dict, + "version": And(str, len), + "private": And(bool) + }, + ) + public_packages = get_picasso_packages(self.config) + for package in public_packages.values(): + try: + package_schema.validate(package) + except SchemaError as error: + raise ConfigFileValidationError(package["name"], str(error)) from error diff --git a/tutorpicasso/picasso/share/domain/config_setting.py b/tutorpicasso/picasso/share/domain/config_setting.py new file mode 100644 index 0000000..62000dc --- /dev/null +++ b/tutorpicasso/picasso/share/domain/config_setting.py @@ -0,0 +1,18 @@ +""" +This module defines the ConfigSetting class, which represents a specific configuration. +""" +from abc import ABC, abstractmethod + + +class ConfigSetting(ABC): + """ + This is an abstract class representing a specific configuration. + """ + + @abstractmethod + def validate(self) -> None: + """ + Validate the configuration. + + This method should be implemented in the derived class. + """ diff --git a/tutorpicasso/picasso/share/domain/config_themes_setting.py b/tutorpicasso/picasso/share/domain/config_themes_setting.py new file mode 100644 index 0000000..7d35292 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/config_themes_setting.py @@ -0,0 +1,57 @@ +""" +Domain module to structure themes validator. +""" + +from schema import And, Optional, Or, Schema, SchemaError + +from tutorpicasso.picasso.share.domain.config_file import ConfigFile +from tutorpicasso.picasso.share.domain.config_file_validation_error import ConfigFileValidationError +from tutorpicasso.picasso.share.domain.config_setting import ConfigSetting + + +class ConfigThemesSetting(ConfigSetting): + """ + Validate the themes settings in the configuration. + + Raises: + ConfigFileValidationError: If an extra file requirement validation fails. + """ + + def __init__(self, config: ConfigFile) -> None: + self.config = config + + def validate(self) -> None: + """ + Validate the themes in the configuration. + + Raises: + ConfigFileValidationError: If a theme validation fails. + """ + themes_schema = Schema( + { + "name": And(str, len), + "repo": And(str, len), + "domain": And(str, len), + "path": And(str, len), + "protocol": Or("ssh", "https"), + "version": And(str, len), + }, + ) + themes_settings_schema = Schema( + { + Optional("PICASSO_THEMES_NAME"): And(list, len), + Optional("PICASSO_THEME_DIRS"): And(list, len), + Optional("PICASSO_THEMES_ROOT"): And(str, len), + }, + ignore_extra_keys=True + ) + if "PICASSO_THEMES" in self.config: + for theme in self.config["PICASSO_THEMES"]: + try: + themes_schema.validate(theme) + except SchemaError as error: + raise ConfigFileValidationError(theme["name"], str(error)) from error + try: + themes_settings_schema.validate(self.config) + except SchemaError as error: + raise ConfigFileValidationError("Theme settings", str(error)) from error diff --git a/tutorpicasso/picasso/share/domain/package.py b/tutorpicasso/picasso/share/domain/package.py new file mode 100644 index 0000000..db5c22d --- /dev/null +++ b/tutorpicasso/picasso/share/domain/package.py @@ -0,0 +1,78 @@ +""" +Package module. +""" + +from __future__ import annotations + +from abc import ABC +from typing import Dict + +from tutorpicasso.picasso.share.domain.package_domain import PackageDomain +from tutorpicasso.picasso.share.domain.package_name import PackageName +from tutorpicasso.picasso.share.domain.package_version import PackageVersion + + +class Package(ABC): + """ + Abstract base class for a package. + + This class represents a package with a name, domain, version, and additional extra metadata. + + Args: + name (PackageName): The name of the package. + domain (PackageDomain): The domain of the package. + version (PackageVersion): The version of the package. + extra (Dict): Extra metadata associated with the package. + """ + + def __init__( + self, + name: PackageName, + domain: PackageDomain, + version: PackageVersion, + extra: Dict + ) -> None: + self._name = name + self._domain = domain + self._version = version + self._extra = extra + + @property + def name(self) -> PackageName: + """ + Get the name of the package. + + Returns: + PackageName: The name of the package. + """ + return self._name + + @property + def domain(self) -> PackageDomain: + """ + Get the domain of the package. + + Returns: + PackageDomain: The domain of the package. + """ + return self._domain + + @property + def version(self) -> PackageVersion: + """ + Get the version of the package. + + Returns: + PackageVersion: The version of the package. + """ + return self._version + + @property + def extra(self) -> Dict: + """ + Get the extra metadata of the package. + + Returns: + Dict: Extra metadata associated with the package. + """ + return self._extra diff --git a/tutorpicasso/picasso/share/domain/package_does_not_exist.py b/tutorpicasso/picasso/share/domain/package_does_not_exist.py new file mode 100644 index 0000000..796bec8 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/package_does_not_exist.py @@ -0,0 +1,9 @@ +""" +Package does not exist. +""" + + +class PackageDoesNotExist(Exception): + """ + Exception raised when a package does not exist. + """ diff --git a/tutorpicasso/picasso/share/domain/package_domain.py b/tutorpicasso/picasso/share/domain/package_domain.py new file mode 100644 index 0000000..1937292 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/package_domain.py @@ -0,0 +1,9 @@ +""" +Package domain. +""" + + +class PackageDomain(str): + """ + Custom class for representing a package domain. + """ diff --git a/tutorpicasso/picasso/share/domain/package_name.py b/tutorpicasso/picasso/share/domain/package_name.py new file mode 100644 index 0000000..875dbcd --- /dev/null +++ b/tutorpicasso/picasso/share/domain/package_name.py @@ -0,0 +1,9 @@ +""" +Package name. +""" + + +class PackageName(str): + """ + Custom class for representing a package name. + """ diff --git a/tutorpicasso/picasso/share/domain/package_version.py b/tutorpicasso/picasso/share/domain/package_version.py new file mode 100644 index 0000000..11366c7 --- /dev/null +++ b/tutorpicasso/picasso/share/domain/package_version.py @@ -0,0 +1,12 @@ +""" +Package version. +""" + + +class PackageVersion(str): + """ + Package version. + + This class represents a package version and extends the built-in `str` class. + It can be used to store and manipulate package versions. + """ diff --git a/tutorpicasso/picasso/syntax_validator/__init__.py b/tutorpicasso/picasso/syntax_validator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/syntax_validator/application/__init__.py b/tutorpicasso/picasso/syntax_validator/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/syntax_validator/application/config_syntax_validator.py b/tutorpicasso/picasso/syntax_validator/application/config_syntax_validator.py new file mode 100644 index 0000000..624278d --- /dev/null +++ b/tutorpicasso/picasso/syntax_validator/application/config_syntax_validator.py @@ -0,0 +1,34 @@ +""" +Aplication module to validate the configuration uses case. +""" +from tutorpicasso.picasso.share.domain.config_file import ConfigFile +from tutorpicasso.picasso.syntax_validator.infrastructure.config_repository import ConfigRepository + + +class ConfigSyntaxValidator: + """ + Use case class for validating a configuration file. + + This class encapsulates the logic for validating a configuration file + using the provided ConfigRepository. + """ + + def __init__(self, repository: ConfigRepository): + """ + Initialize the ConfigValidor. + + Args: + config_repository (ConfigRepository): The repository to use for config validation. + """ + self.repository = repository + + def execute(self, file_path) -> bool: + """ + Execute the configuration file validation. + + Args: + file_path (str): The path to the configuration file. + """ + config_file = ConfigFile(file_path) + config = config_file.config_file() + return self.repository.validate_syntax(config) diff --git a/tutorpicasso/picasso/syntax_validator/infrastructure/__init__.py b/tutorpicasso/picasso/syntax_validator/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/syntax_validator/infrastructure/config_repository.py b/tutorpicasso/picasso/syntax_validator/infrastructure/config_repository.py new file mode 100644 index 0000000..391c2c6 --- /dev/null +++ b/tutorpicasso/picasso/syntax_validator/infrastructure/config_repository.py @@ -0,0 +1,40 @@ +""" +Infrastructure module to validate the configuration. +""" + +from typing import List + +import click + +from tutorpicasso.picasso.share.domain.config_file import ConfigFile +from tutorpicasso.picasso.share.domain.config_setting import ConfigSetting + + +class ConfigRepository: + """ + Repository class for validating the configuration. + + This class provides methods to validate the configuration file using various validators. + """ + + def __init__(self, config_settings: List[ConfigSetting]): + self.config_settings = config_settings + + def validate_syntax(self, config: ConfigFile) -> bool: + """ + Validate the configuration file. + + Args: + config: The configuration file to validate. + + Returns: + bool: True if the configuration is valid, False otherwise. + """ + try: + for config_setting in self.config_settings: + setting = config_setting(config=config) + setting.validate() + return True + except Exception as error: # pylint: disable=broad-exception-caught + click.echo(error) + return False diff --git a/tutorpicasso/picasso/themes/__init__.py b/tutorpicasso/picasso/themes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/themes/application/__init__.py b/tutorpicasso/picasso/themes/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/themes/application/theme_enabler.py b/tutorpicasso/picasso/themes/application/theme_enabler.py new file mode 100644 index 0000000..028e33f --- /dev/null +++ b/tutorpicasso/picasso/themes/application/theme_enabler.py @@ -0,0 +1,41 @@ +""" +Picasso theme enabler config. +""" + +from typing import Dict + +from tutor.types import Config + +from tutorpicasso.picasso.themes.domain.theme_repository import ThemeRepository +from tutorpicasso.picasso.themes.domain.theme_settings import ThemeSettings + + +class ThemeEnabler: + """ + Theme enabler configuration. + + This class is responsible for enabling themes by invoking the clone method + on a theme repository. + + Attributes: + repository (ThemeRepository): The theme repository to use for cloning themes. + """ + + def __init__(self, repository: ThemeRepository): + self.repository = repository + + def __call__(self, settings: Dict, tutor_root: str, tutor_config: Config): + """ + Enable a theme based on the provided settings. + + This method enables a theme by creating theme settings and invoking + the clone method on the theme repository. + + Args: + settings (Dict): The theme settings. + tutor_root (str): The root directory of the Tutor installation. + tutor_config (Config): The Tutor configuration. + """ + theme_settings = ThemeSettings(settings=settings, tutor_root=tutor_root, + tutor_config=tutor_config) + return self.repository.clone(theme_settings=theme_settings) diff --git a/tutorpicasso/picasso/themes/domain/__init__.py b/tutorpicasso/picasso/themes/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/themes/domain/theme_repository.py b/tutorpicasso/picasso/themes/domain/theme_repository.py new file mode 100644 index 0000000..01798ba --- /dev/null +++ b/tutorpicasso/picasso/themes/domain/theme_repository.py @@ -0,0 +1,29 @@ +""" +Picasso theme repository. +""" + +from abc import ABC, abstractmethod + +from tutorpicasso.picasso.themes.domain.theme_settings import ThemeSettings + + +class ThemeRepository(ABC): + """ + Abstract base class for theme repositories. + + This class defines the interface for theme repositories and provides + an abstract method for cloning themes. + """ + + @abstractmethod + def clone(self, theme_settings: ThemeSettings): + """ + Clone a theme repository. + + This is an abstract method that should be implemented by subclasses. + It defines the behavior for cloning a theme repository based on the + provided theme settings. + + Args: + theme_settings (ThemeSettings): The theme settings. + """ diff --git a/tutorpicasso/picasso/themes/domain/theme_settings.py b/tutorpicasso/picasso/themes/domain/theme_settings.py new file mode 100644 index 0000000..5c02f59 --- /dev/null +++ b/tutorpicasso/picasso/themes/domain/theme_settings.py @@ -0,0 +1,43 @@ +""" +Picasso theme settings. +""" + +from typing import Dict + +from tutor.types import Config + + +class ThemeSettings: + """ + Settings for a theme. + + This class represents the settings for a theme, including its name, directory, + and other configuration options. + + Args: + settings (Dict): The theme settings. + tutor_root (str): The root directory of Tutor. + tutor_config (Config): The Tutor configuration. + + Attributes: + name (str): The name of the theme. + dir (str): The directory of the theme. + tutor_path (str): The path to Tutor. + settings (Dict): The theme settings. + """ + + def __init__(self, settings: Dict, tutor_root: str, tutor_config: Config): + self.name = settings["name"] + self.dir = f"env/build{tutor_config['PICASSO_THEMES_ROOT']}/{self.name}" + self.tutor_path = str(tutor_root) + self.settings = settings + + @property + def get_full_directory(self): + """ + Get the full directory path of the theme. + + Returns: + str: The full directory path of the theme. + """ + return f"{self.tutor_path}/{self.dir}" diff --git a/tutorpicasso/picasso/themes/infraestructure/__init__.py b/tutorpicasso/picasso/themes/infraestructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/picasso/themes/infraestructure/theme_git_repository.py b/tutorpicasso/picasso/themes/infraestructure/theme_git_repository.py new file mode 100644 index 0000000..f7a6251 --- /dev/null +++ b/tutorpicasso/picasso/themes/infraestructure/theme_git_repository.py @@ -0,0 +1,66 @@ +""" +Picasso theme funtions. +""" + +import os +import shutil +import subprocess + +import click + +from tutorpicasso.picasso.share.domain.clone_exception import CloneException +from tutorpicasso.picasso.themes.domain.theme_repository import ThemeRepository +from tutorpicasso.picasso.themes.domain.theme_settings import ThemeSettings + + +class ThemeGitRepository(ThemeRepository): + """ + Git repository for themes. + + This class provides functionality to clone theme repositories. + + Args: + ThemeRepository (class): Base theme repository class. + """ + + def clone(self, theme_settings: type(ThemeSettings)): + """ + Clone the theme repository. + + This method clones the theme repository based on the provided theme settings. + + Args: + theme_settings (ThemeSettings): Theme settings. + """ + repo = None + if "https" == theme_settings.settings["protocol"]: + repo = ( + f"https://{theme_settings.settings['domain']}/" + f"{theme_settings.settings['path']}/" + f"{theme_settings.settings['repo']}" + ) + elif "ssh" == theme_settings.settings["protocol"]: + repo = ( + f"git@{theme_settings.settings['domain']}:" + f"{theme_settings.settings['path']}/" + f"{theme_settings.settings['repo']}.git" + ) + + try: + if os.path.exists(f"{theme_settings.get_full_directory}"): + if not click.confirm(f"Do you want to overwrite \ + {theme_settings.get_full_directory}? "): + raise CloneException() + shutil.rmtree(f"{theme_settings.get_full_directory}") + subprocess.call( + [ + "git", + "clone", + "-b", + theme_settings.settings["version"], + repo, + f"{theme_settings.get_full_directory}", + ] + ) + except CloneException: + pass diff --git a/tutorpicasso/plugin.py b/tutorpicasso/plugin.py index 60be7db..9d29237 100644 --- a/tutorpicasso/plugin.py +++ b/tutorpicasso/plugin.py @@ -6,6 +6,7 @@ import click import importlib_resources from tutor import hooks +from tutorpicasso.commands.cli import picasso from .__about__ import __version__ @@ -206,12 +207,11 @@ # group and then add it to CLI_COMMANDS: -### @click.group() -### def picasso() -> None: -### pass - - -### hooks.Filters.CLI_COMMANDS.add_item(picasso) +hooks.Filters.CLI_COMMANDS.add_items( + [ + picasso, + ] +) # Then, you would add subcommands directly to the Click group, for example: diff --git a/tutorpicasso/utils/__init__.py b/tutorpicasso/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tutorpicasso/utils/common.py b/tutorpicasso/utils/common.py new file mode 100644 index 0000000..07c7de9 --- /dev/null +++ b/tutorpicasso/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/tutorpicasso/utils/constants.py b/tutorpicasso/utils/constants.py new file mode 100644 index 0000000..897fe8a --- /dev/null +++ b/tutorpicasso/utils/constants.py @@ -0,0 +1,5 @@ +""" +File of constant variables +""" + +COMMAND_CHAINING_OPERATORS = ["&&", "&", "||", "|", ";"] diff --git a/tutorpicasso/utils/packages.py b/tutorpicasso/utils/packages.py new file mode 100644 index 0000000..0abc28d --- /dev/null +++ b/tutorpicasso/utils/packages.py @@ -0,0 +1,54 @@ +""" +Module: tutorpicasso.utils.packages +This module provides utility functions to handle distribution packages in the tutor configuration settings. +""" + + +def get_picasso_packages(settings) -> dict: + """ + Get the distribution packages from the provided settings. + + Args: + settings (dict): The tutor configuration settings. + + Returns: + dict: A dictionary of distribution packages, where the keys are package names + and the values are package details. + """ + picasso_packages = {key: val for key, + val in settings.items() if key.endswith("_DPKG") and val != 'None'} + return picasso_packages + + +def get_public_picasso_packages(settings) -> dict: + """ + Get the public distribution packages from the provided settings. + + Args: + settings (dict): The tutor configuration settings. + + Returns: + dict: A dictionary of public distribution packages, where the keys are package names + and the values are package details. + """ + picasso_packages = get_picasso_packages(settings) + public_packages = {key: val for key, + val in picasso_packages.items() if not val["private"]} + return public_packages + + +def get_private_picasso_packages(settings) -> dict: + """ + Get the private distribution packages from the provided settings. + + Args: + settings (dict): The tutor configuration settings. + + Returns: + dict: A dictionary of private distribution packages, where the keys are package names + and the values are package details. + """ + picasso_packages = get_picasso_packages(settings) + private_packages = {key: val for key, + val in picasso_packages.items() if val["private"]} + return private_packages