diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4f15b9ab2407e9d56f56ed2acb0abceab1dddf9e..ab31bc6b429207e32c03ee700e536ca6bc77fd5e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,10 +1,16 @@ stages: - test - build + - coverage_report variables: BUILD_ENV_DOCKER_IMAGE: docker-registry.ebrains.eu/esd/tmp:latest +default: + before_script: + - chmod +x dedal/utils/bootstrap.sh + - ./dedal/utils/bootstrap.sh + - pip install -e .[test] build-wheel: stage: build @@ -21,17 +27,34 @@ build-wheel: - dist/*.tar.gz expire_in: 1 week +unit_tests: + stage: test + tags: + - docker-runner + image: ubuntu:22.04 + script: + - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/unit_tests + - mv .coverage .coverage.unit # Rename to avoid overwriting + artifacts: + when: always + reports: + junit: test-results.xml + paths: + - test-results.xml + - .dedal.log + - .generate_cache.log + - .coverage.unit + expire_in: 1 week -testing-pytest: +integration_tests: stage: test tags: - docker-runner image: ubuntu:22.04 script: - - chmod +x dedal/utils/bootstrap.sh - - ./dedal/utils/bootstrap.sh - - pip install . - - pytest ./dedal/tests/ -s --junitxml=test-results.xml + - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/integration_tests + - mv .coverage .coverage.integration # Rename to avoid overwriting + needs: ["unit_tests"] artifacts: when: always reports: @@ -40,5 +63,27 @@ testing-pytest: - test-results.xml - .dedal.log - .generate_cache.log + - .coverage.integration + expire_in: 1 week + +merge_coverage: + stage: coverage_report + tags: + - docker-runner + image: ubuntu:22.04 + script: + - coverage combine .coverage.unit .coverage.integration + - coverage report + - coverage xml -o coverage.xml + - coverage html -d coverage_html + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml + paths: + - coverage.xml + - coverage_html expire_in: 1 week + coverage: '/TOTAL.*?(\d+\%)$/' diff --git a/MANIFEST.ini b/MANIFEST.ini new file mode 100644 index 0000000000000000000000000000000000000000..e62be46716825eab56feaa6d891b2f5d0bcf314d --- /dev/null +++ b/MANIFEST.ini @@ -0,0 +1,3 @@ + +include README.md +recursive-include yashchiki/dedal *.* \ No newline at end of file diff --git a/README.md b/README.md index 733d8ff63a4f2aabb3634707b85c252e4cfb284d..55080aabfee964433f5439b43b3b61f727422a2c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Dedal + This repository provides functionalities to easily ```managed spack environments``` and ```helpers for the container image build flow```. @@ -139,4 +140,5 @@ Installs spack packages present in the spack environment defined in configuratio # Dedal's UML diagram - \ No newline at end of file + + diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index ba1f62a3556d31db0d07763fbb92df86a337a861..62cb9af14bef184c87c4d6ffa81c697a272d7036 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -1,12 +1,12 @@ +import glob import os -import time +from os.path import join +from pathlib import Path import oras.client -from pathlib import Path from dedal.build_cache.BuildCacheManagerInterface import BuildCacheManagerInterface from dedal.logger.logger_builder import get_logger -from dedal.utils.utils import clean_up class BuildCacheManager(BuildCacheManagerInterface): @@ -113,7 +113,49 @@ class BuildCacheManager(BuildCacheManagerInterface): if tags is not None: try: self._client.delete_tags(self._oci_registry_path, tags) - self._logger.info(f"Successfully deleted all artifacts form OCI registry.") + self._logger.info("Successfully deleted all artifacts form OCI registry.") except RuntimeError as e: self._logger.error( f"Failed to delete artifacts: {e}") + + def __log_warning_if_needed(self, warn_message: str, items: list[str]) -> None: + """Logs a warning message if the number of items is greater than 1. (Private function) + + This method logs a warning message using the provided message and items if the list of items has more than one element. + + Args: + warn_message (str): The warning message to log. + items (list[str]): The list of items to include in the log message. + """ + if len(items) > 1: + self._logger.warning(warn_message, items, items[0]) + + def get_public_key_from_cache(self, build_cache_dir: str | None) -> str | None: + """Retrieves the public key from the build cache. + + This method searches for the public key within the specified build cache directory. + + Args: + build_cache_dir (str | None): The path to the build cache directory. + + Returns: + str | None: The path to the public key file if found, otherwise None. + """ + + if not build_cache_dir or not os.path.exists(build_cache_dir): + self._logger.warning("Build cache directory does not exist!") + return None + pgp_folders = glob.glob(f"{build_cache_dir}/**/_pgp", recursive=True) + if not pgp_folders: + self._logger.warning("No _pgp folder found in the build cache!") + return None + self.__log_warning_if_needed( + "More than one PGP folders found in the build cache: %s, using the first one in the list: %s", pgp_folders) + pgp_folder = pgp_folders[0] + key_files = glob.glob(join(pgp_folder, "**")) + if not key_files: + self._logger.warning("No PGP key files found in the build cache!") + return None + self.__log_warning_if_needed( + "More than one PGP key files found in the build cache: %s, using the first one in the list: %s", key_files) + return key_files[0] diff --git a/dedal/commands/__init__.py b/dedal/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ea9c384e46ebd5602957ff010a7c2b7c2d7d52e6 --- /dev/null +++ b/dedal/commands/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: __init__.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + diff --git a/dedal/commands/bash_command_executor.py b/dedal/commands/bash_command_executor.py new file mode 100644 index 0000000000000000000000000000000000000000..aef9c57659070d31936ad71967e5ec21e937bdc6 --- /dev/null +++ b/dedal/commands/bash_command_executor.py @@ -0,0 +1,100 @@ +""" Bash Command Executor module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: bash_command_executor.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 +import logging +import subprocess +from logging import Logger +from os import name as os_name + +from dedal.commands.command import Command +from dedal.commands.command_sequence import CommandSequence + + +class BashCommandExecutor: + """Executes commands in a Bash shell. + + Manages a sequence of commands and executes them in a single Bash session, + handling potential errors during execution. + """ + + def __init__(self) -> None: + self.logger: Logger = logging.getLogger(__name__) + self.sequence: CommandSequence = CommandSequence() + command_map: dict[str, list[str]] = { + "nt": ["wsl", "bash", "-c"], # Works only if wsl is installed on windows + "posix": ["bash", "-c"], + } + self.bash_command: list[str] = command_map.get(os_name, ["undefined"]) + + def add_command(self, command: Command) -> None: + """Adds a command to the sequence. + + Appends the given command to the internal sequence of commands to be executed. + + Args: + command (Command): The command to add. + + Raises: + ValueError: If the command is not an instance of the `Command` class. + """ + if not isinstance(command, Command): + raise ValueError("Invalid command type. Use Command.") + self.logger.info("Adding command to the sequence: %s", command) + self.sequence.add_command(command) + + def execute(self) -> tuple[str | None, str | None]: + """Executes all commands in a single Bash session. + + Runs the accumulated commands in a Bash shell and returns the output. + Handles various potential errors during execution. The execution is time + + Returns (tuple[str | None, str | None]): + A tuple containing the output and error message (if any). + output will be None if an error occurred, and the error message will + contain details about the error. + + Raises: + ValueError: If no commands have been added to the sequence. + """ + if not self.sequence.commands: + raise ValueError("No commands to execute.") + try: + result = subprocess.run( + [*self.bash_command, self.sequence.execute()], + capture_output=True, + text=True, + check=True, + timeout=172800 # Given a default timeout of 48 hours + ) + self.logger.info("Successfully executed command sequence, output: %s", result.stdout) + return result.stdout, None + except FileNotFoundError as e: + error = f"Error: Bash Command: {self.bash_command} not found: {e}" + except subprocess.CalledProcessError as e: + error = (f"Error: Command failed with exit code " + f"{e.returncode}, Error Output: {e.stderr}") + except PermissionError as e: + error = f"Error: Permission denied: {e}" + except OSError as e: + error = f"Error: OS error occurred: {e}" + except ValueError as e: + error = f"Error: Invalid argument passed: {e}" + except TypeError as e: + error = f"Error: Invalid type for arguments: {e}" + except subprocess.TimeoutExpired as e: + error = f"Error: Command timed out after {e.timeout} seconds" + except subprocess.SubprocessError as e: + error = f"Subprocess error occurred: {e}" + return None, error + + def reset(self) -> None: + """Resets the command executor. + + Clears the internal command sequence, preparing the executor for a new set of commands. + """ + self.sequence.clear() diff --git a/dedal/commands/command.py b/dedal/commands/command.py new file mode 100644 index 0000000000000000000000000000000000000000..07e9837c142122ff43a5fcd47959a12d7ab0f28f --- /dev/null +++ b/dedal/commands/command.py @@ -0,0 +1,29 @@ +""" Command module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command.py +# Description: Abstract base class for executable commands +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from abc import ABC, abstractmethod + + +class Command(ABC): + """Abstract base class for executable commands. + + Provides a common interface for defining and executing commands. + Subclasses must implement the `execute` method. + """ + + @abstractmethod + def execute(self) -> str: + """Executes the command. + + This method must be implemented by subclasses to define the specific + behavior of the command. + + Returns: + str: The result of the command execution. + """ diff --git a/dedal/commands/command_enum.py b/dedal/commands/command_enum.py new file mode 100644 index 0000000000000000000000000000000000000000..7ef184cbfab3e193c292f7fcbdbedcb7a5783211 --- /dev/null +++ b/dedal/commands/command_enum.py @@ -0,0 +1,37 @@ +""" Command Enum module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_enum.py +# Description: Enumeration of supported commands in command registry +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from enum import Enum + + +class CommandEnum(Enum): + """Enumeration of supported commands. + + Provides a standardized way to refer to different command types, + including Linux commands and Spack commands. + """ + # Linux commands_ + + SOURCE = "source" + LIST_FILES = "list_files" + SHOW_DIRECTORY = "show_directory" + FIND_IN_FILE = "find_in_file" + ECHO_MESSAGE = "echo_message" + CHANGE_DIRECTORY = "change_directory" + + # Spack commands_ + SPACK_COMPILER_FIND = "spack_compiler_find" + SPACK_COMPILERS = "spack_compilers" + SPACK_COMPILER_INFO = "spack_compiler_info" + SPACK_COMPILER_LIST = "spack_compiler_list" + SPACK_ENVIRONMENT_ACTIVATE = "spack_environment_activate" + SPACK_FIND = "spack_find" + SPACK_INSTALL = "spack_install" + SPACK_MIRROR_ADD = "spack_mirror_add" + SPACK_GPG_TRUST = "spack_gpg_trust" diff --git a/dedal/commands/command_registry.py b/dedal/commands/command_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..adaa3d6eb95119f3e028ea05fe7640f9145cdb5f --- /dev/null +++ b/dedal/commands/command_registry.py @@ -0,0 +1,59 @@ +""" Command Registry module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_registry.py +# Description: Registry for storing and retrieving shell commands with placeholders +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command_enum import CommandEnum + + +class CommandRegistry: + """Registry for storing and retrieving commands. + + Holds a dictionary of commands, keyed by `CommandEnum` members, + allowing easy access to command definitions. The generic shell command factory uses this registry for constructing the shell commands. + + The commands are stored in a dictionary where the keys or command names are `CommandEnum` members, and the values corresponding shell commands stored in a list. + The placeholders `{}` in the commands are replaced with the actual values when the command is executed. + + Extend this registry if you want to add more commands and do the necessary mapping in command_runner module. + """ + + COMMANDS: dict[CommandEnum, list[str]] = { + # Linux commands_ + CommandEnum.LIST_FILES: ["ls", "-l", "{folder_location}"], + CommandEnum.SHOW_DIRECTORY: ["pwd"], + CommandEnum.FIND_IN_FILE: ["grep", "-i", "{search_term}", "{file_path}"], + CommandEnum.ECHO_MESSAGE: ["echo", "{message}"], + CommandEnum.SOURCE: ["source", "{file_path}"], + CommandEnum.CHANGE_DIRECTORY: ["cd", "{folder_location}"], + + # Spack commands_ + CommandEnum.SPACK_COMPILER_FIND: ["spack", "compiler", "find", "{compiler_name}"], + CommandEnum.SPACK_COMPILERS: ["spack", "compilers"], + CommandEnum.SPACK_COMPILER_LIST: ["spack", "compiler", "list"], + CommandEnum.SPACK_COMPILER_INFO: ["spack", "compiler", "info", "{compiler_name_with_version}"], + CommandEnum.SPACK_ENVIRONMENT_ACTIVATE: ["spack", "env", "activate", "-p", "{env_path}"], + CommandEnum.SPACK_FIND: ["spack", "find", "{package_name_with_version}"], + CommandEnum.SPACK_INSTALL: ["spack", "install", "{package_name_with_version}"], + CommandEnum.SPACK_MIRROR_ADD: ["spack", "mirror", "add", "{autopush}", "{signed}", "{mirror_name}", + "{mirror_path}"], + CommandEnum.SPACK_GPG_TRUST: ["spack", "gpg", "trust", "{public_key_path}"] + } + + @classmethod + def get_command(cls, command_name: CommandEnum) -> list[str] | None: + """Retrieve a command from the registry. + + Returns the command definition associated with the given `CommandEnum` member. + + Args: + command_name (CommandEnum): The name of the command to retrieve. + + Returns (list[str]): + The shell command in a list format, or None if the command is not found. + """ + return cls.COMMANDS.get(command_name) diff --git a/dedal/commands/command_runner.py b/dedal/commands/command_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..88ee46a8248d900a0e721e83721067f71fde1060 --- /dev/null +++ b/dedal/commands/command_runner.py @@ -0,0 +1,207 @@ +""" Command Runner module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_runner.py +# Description: Manages creation, execution, and result handling of command sequences. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from __future__ import annotations + +import logging +from logging import Logger +from typing import Callable, Any + +from dedal.commands.bash_command_executor import BashCommandExecutor +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_factory import CommandSequenceFactory +from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum +from dedal.commands.spack_command_sequence_factory import SpackCommandSequenceFactory + + +class CommandRunner: + """This module provides a unified interface for executing both predefined and custom command sequences. + + Functionality + + The module offers two primary methods for running command sequences: + + 1. Run Preconfigured Command Sequence: This method allows users to execute preconfigured command sequences using the 'run_preconfigured_command_sequence' method. + 2. Run Custom Command Sequence: This method enables users to execute dynamically generated custom command sequences using the 'run_custom_command_sequence' method. + + Preconfigured Command Sequences + To run a preconfigured command sequence, users can simply call the run_preconfigured_command_sequence method. The module will execute the corresponding command sequence. + To create and run a preconfigured command sequence, users must: + 1. Define a new preconfigured command in the PreconfiguredCommandEnum. + 2. Add a new entry in CommandRunner.commands_map that maps the preconfigured command to its corresponding function defined in the SpackCommandSequenceFactory class. + 4. Call the run_preconfigured_command_sequence method to execute the preconfigured command sequence. + + Custom Command Sequences + To create and execute a custom command sequence, users must: + 1. Define the commands as a dictionary, where the key represents the command already registered in the command_registry, and the value contains the corresponding placeholder values expected by the command. + 2. Invoke the run_custom_command_sequence using generated dictionary to execute the custom command sequence. + + Command Pattern Diagram + ------------------------ + + The Command pattern is used to encapsulate the request as an object, allowing for more flexibility and extensibility in handling requests. + + ``` + +---------------+ + | Client | + +---------------+ + | + | + v + +---------------+ + | Invoker | + | (CommandRunner)| + +---------------+ + | + | + v + +---------------+ + | Command | + | (PreconfiguredCommandEnum)| + +---------------+ + | + | + v + +---------------+ + | Receiver | + | (SpackCommandSequenceFactory)| + +---------------+ + ``` + + In this diagram: + + * The Client is the user of the CommandRunner. + * The Invoker is the CommandRunner itself, which receives the Command object and invokes the corresponding action. + * The Command is the PreconfiguredCommandEnum, which represents the request. + * The Receiver is the SpackCommandSequenceFactory, which performs the action requested by the Command object. + + Benefits + This module provides a flexible and unified interface for executing both predefined and custom command sequences, allowing users to easily manage and execute complex command workflows. + + + Attributes + ---------- + logger : Logger + The logger instance used for logging purposes. + executor : BashCommandExecutor + The BashCommandExecutor instance used for executing commands. + commands_map : dict[PreconfiguredCommandEnum, Callable[..., CommandSequence]] + A dictionary mapping preconfigured commands to their corresponding functions. + + Methods + ------- + run_preconfigured_command_sequence(preconfigured_command: PreconfiguredCommandEnum) + Executes a preconfigured command sequence. + run_custom_command_sequence(command_sequence: dict) + Executes a custom command sequence. + """ + + def __init__(self) -> None: + """Initializes the CommandRunner.""" + self.logger: Logger = logging.getLogger(__name__) + self.executor: BashCommandExecutor = BashCommandExecutor() + self.commands_map: dict[PreconfiguredCommandEnum, Callable[..., CommandSequence]] = { + PreconfiguredCommandEnum.SPACK_COMPILER_FIND: + SpackCommandSequenceFactory.create_spack_compilers_command_sequence, + PreconfiguredCommandEnum.SPACK_COMPILER_LIST: + SpackCommandSequenceFactory.create_spack_compiler_list_command_sequence, + PreconfiguredCommandEnum.SPACK_COMPILERS: + SpackCommandSequenceFactory.create_spack_compiler_list_command_sequence, + PreconfiguredCommandEnum.SPACK_COMPILER_INFO: + SpackCommandSequenceFactory.create_spack_compiler_info_command_sequence, + PreconfiguredCommandEnum.SPACK_FIND: + SpackCommandSequenceFactory.create_spack_post_install_find_command_sequence, + PreconfiguredCommandEnum.SPACK_INSTALL: + SpackCommandSequenceFactory.create_spack_install_package_command_sequence, + PreconfiguredCommandEnum.SPACK_MIRROR_ADD: + SpackCommandSequenceFactory.create_spack_mirror_add_command_sequence, + PreconfiguredCommandEnum.SPACK_GPG_TRUST: + SpackCommandSequenceFactory.create_spack_gpg_trust_command_sequence, + } + + def execute_command(self, + command_sequence: CommandSequence) -> dict[str, str | bool | None]: + """Executes a given command sequence. + + Adds the command sequence to the executor and runs it, returning the result as a dictionary. + + Args: + command_sequence (CommandSequence): The command sequence to execute. + + Returns (dict[str, str | bool | None]): + A dictionary containing the execution result. + The dictionary has the following keys: + - success (bool): True if the command executed successfully, False otherwise. + - output (str | None): The output of the command if successful, None otherwise. + - error (str | None): The error message if the command failed, None otherwise. + """ + self.executor.add_command(command_sequence) + output, error = self.executor.execute() + self.executor.reset() + return { + "success": error is None, + "output": output.strip() if output else None, + "error": error + } + + def run_preconfigured_command_sequence(self, + command_name: PreconfiguredCommandEnum, + *args: Any) -> dict[str, str | bool | None]: + """Runs a predefined command sequence. + + Creates and executes a predefined command based on the given name and arguments. + + For example `run_preconfigured_command_sequence(PreconfiguredCommandEnum.SPACK_COMPILER_FIND, 'gcc', '11')` + will execute the command `source spack_setup_path && spack find gcc@11` + + Args: + command_name (PreconfiguredCommandEnum): The name of the predefined command sequence. + args (tuple(Any, ...)): Arguments to pass to the command constructor. The arguments should correspond to the flags, options, and placeholders of the command. + + Returns: + A dictionary containing the execution result. + The dictionary has the following keys: + - success (bool): True if the command executed successfully, False otherwise. + - output (str | None): The output of the command if successful, None otherwise. + - error (str | None): The error message if the command failed, None otherwise. + If command_type is invalid, returns a dictionary with "success" as False and an error message. + """ + if command_name not in self.commands_map: + return {"success": False, "error": f"Invalid command name: {command_name}"} + + command_sequence = self.commands_map[command_name](*args) + return self.execute_command(command_sequence) + + def run_custom_command_sequence(self, + command_placeholders_map: dict[CommandEnum, dict[str, str]]) \ + -> dict[str, str | bool | None]: + """Runs a custom command sequence. + + Creates and executes a custom command sequence from a map of command names to placeholder values. + + Args: + command_placeholders_map (dict[CommandEnum, dict[str, str]]): A dictionary mapping command name enums to + placeholder values. The key is the command name in `CommandEnum` and the value is a dictionary + mapping placeholder names to their actual values. + + For example, if the key command is `CommandEnum.FIND_IN_FILE`, + this corresponds to the command `grep -i {search_term} {file_path}` in CommandRegistry.COMMANDS. + Hence the placeholder_map_value should be `{search_term: 'python', file_path: '/path/to/file.txt'}`. + + Returns (dict[str, str | bool | None]): + A dictionary containing the execution result. + The dictionary has the following keys: + - success (bool): True if the command executed successfully, False otherwise. + - output (str | None): The output of the command if successful, None otherwise. + - error (str | None): The error message if the command failed, None otherwise. + """ + command_sequence = (CommandSequenceFactory + .create_custom_command_sequence(command_placeholders_map)) + return self.execute_command(command_sequence) diff --git a/dedal/commands/command_sequence.py b/dedal/commands/command_sequence.py new file mode 100644 index 0000000000000000000000000000000000000000..6115215a86a5b9d8fef5d3ea5af37f25210652ad --- /dev/null +++ b/dedal/commands/command_sequence.py @@ -0,0 +1,54 @@ +""" Command Sequence module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_sequence.py +# Description: Command Sequence abstraction +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command import Command + + +class CommandSequence(Command): + """Represents a sequence of executable commands. + + Allows adding commands to a sequence and executing them in order, + combining/chaining their execution strings with "&&". + """ + + def __init__(self) -> None: + """Initializes an empty command sequence.""" + self.commands: list[Command] = [] + + def add_command(self, command: Command) -> None: + """Adds a command to the sequence. + + Appends the given command to the list of commands. + + Args: + command (Command): The command to add. + + Raises: + ValueError: If the provided command is not an instance of `Command`. + """ + if not isinstance(command, Command): + raise ValueError("Command must be an instance of Command") + self.commands.append(command) + + def execute(self) -> str: + """Executes the command sequence. + + Executes each command in the sequence and joins their results with "&&". + + Returns: + The combined execution string of all commands in the sequence. + """ + return " && ".join(cmd.execute().strip() for cmd in self.commands).strip() + + def clear(self) -> None: + """Clears the command sequence. + + Removes all commands from the sequence, making it empty. + """ + self.commands.clear() diff --git a/dedal/commands/command_sequence_builder.py b/dedal/commands/command_sequence_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..bd35505772d8d618b17a7e4a81b8d6c7698ef91c --- /dev/null +++ b/dedal/commands/command_sequence_builder.py @@ -0,0 +1,71 @@ +""" Command Sequence Builder module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_sequence_builder.py +# Description: Command sequence builder module for creating command sequences +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 +from __future__ import annotations + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_registry import CommandRegistry +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.shell_command_factory import ShellCommandFactory + + +class CommandSequenceBuilder: + """Builds a sequence of commands using the builder pattern. + + Facilitates the creation of CommandSequence objects by adding commands incrementally + and then building the final sequence. + """ + + def __init__(self) -> None: + """Initializes a new CommandSequenceBuilder with an empty sequence.""" + self.sequence: CommandSequence = CommandSequence() + + def add_generic_command(self, + command_name: CommandEnum, + placeholders: dict[str, str]) -> CommandSequenceBuilder: + """Adds a generic command to the sequence. + + Retrieves the command definition from the CommandRegistry, replaces placeholders with + provided values, creates a ShellCommand, and adds it to the sequence. + + Args: + command_name (CommandEnum): The enum representing the command name to add. + placeholders (dict[str, str]): A dictionary of placeholder values to substitute in the command. + + Returns: + The CommandSequenceBuilder instance (self) for method chaining. + + Raises: + ValueError: If the command type is invalid or if the command is unknown. + """ + if not isinstance(command_name, CommandEnum): + raise ValueError("Invalid command type. Use CommandEnum.") + command = CommandRegistry.get_command(command_name) + + if command is None: + raise ValueError(f"Unknown command: {command_name}") + full_command = command[:] # Copy the command list to avoid mutation + # Replace placeholders with actual values + if placeholders: + full_command = [placeholders.get(arg.strip("{}"), arg) for arg in full_command] + full_command = list(filter(None, full_command)) + shell_command = ShellCommandFactory.create_command(*full_command) + self.sequence.add_command(shell_command) + return self + + def build(self) -> CommandSequence: + """Builds and returns the CommandSequence. + + Returns the constructed CommandSequence and resets the builder for creating new sequences. + + Returns: + The built CommandSequence. + """ + sequence = self.sequence + self.sequence = CommandSequence() # Reset for next build + return sequence diff --git a/dedal/commands/command_sequence_factory.py b/dedal/commands/command_sequence_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..1c1872451b4ec41950ce7138246996f16018b6e5 --- /dev/null +++ b/dedal/commands/command_sequence_factory.py @@ -0,0 +1,46 @@ +""" Command Sequence Factory module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_sequence_factory.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_builder import CommandSequenceBuilder + + +class CommandSequenceFactory: + """Factory for creating CommandSequence objects.""" + + @staticmethod + def create_custom_command_sequence( + command_placeholders_map: dict[CommandEnum, dict[str, str]]) -> CommandSequence: + """Creates a custom CommandSequence. + + Builds a CommandSequence from a dictionary mapping CommandEnums to placeholder values. + + For example if the key command is `CommandEnum.FIND_IN_FILE` and the value is `{search_term: 'python', file_path: '/path/to/file.txt'}`, + this corresponds to the command `grep -i {search_term} {file_path}` in CommandRegistry.COMMANDS. So the user can create a sequence of such commands. + + e.g. command_placeholders_map = { + CommandEnum.FIND_IN_FILE: { + "search_term": "python", + "file_path": "/path/to/file.txt" + }, + CommandEnum.SHOW_DIRECTORY: {}, + CommandEnum.LIST_FILES: {"folder_location": "/tmp"}, + CommandEnum.ECHO_MESSAGE: {"message": "Hello, world!"} + } + + Args: + command_placeholders_map: A dictionary mapping CommandEnum members to dictionaries of placeholder values. + Returns: + A CommandSequence object representing the custom command sequence. + """ + builder = CommandSequenceBuilder() + for command_type, placeholders in command_placeholders_map.items(): + builder.add_generic_command(command_type, placeholders) + return builder.build() diff --git a/dedal/commands/generic_shell_command.py b/dedal/commands/generic_shell_command.py new file mode 100644 index 0000000000000000000000000000000000000000..0a02b09537722087a93ab43a211b20e1c82750e9 --- /dev/null +++ b/dedal/commands/generic_shell_command.py @@ -0,0 +1,47 @@ +""" Generic Shell Command module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: generic_shell_command.py +# Description: Generic shell command implementation +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command import Command + + +class GenericShellCommand(Command): + """Represents a generic shell command. + + Encapsulates a shell command with its name and arguments, providing + a way to execute it. + """ + + def __init__(self, command_name: str, *args: str) -> None: + """Initializes a new GenericShellCommand. + + Args: + command_name (str): The name of the command. + *args (str): The arguments for the command. + + Raises: + ValueError: If the command name is empty, not a string, or if any of the arguments are not strings. + """ + if not command_name: + raise ValueError("Command name is required!") + if not isinstance(command_name, str): + raise ValueError("Command name must be a string!") + if not all(isinstance(arg, str) for arg in args): + raise ValueError("All arguments must be strings!") + self.args: tuple[str, ...] = tuple(map(str.strip, args)) + self.command_name: str = command_name.strip() + + def execute(self) -> str: + """Executes the command. + + Constructs and returns the full command string, including the command name and arguments. + + Returns: + The full command string. + """ + return f"{self.command_name} {' '.join(self.args)}" if self.args else self.command_name diff --git a/dedal/commands/preconfigured_command_enum.py b/dedal/commands/preconfigured_command_enum.py new file mode 100644 index 0000000000000000000000000000000000000000..14b747ada43cf6e5eb31c0ddcbf1753fa2eac485 --- /dev/null +++ b/dedal/commands/preconfigured_command_enum.py @@ -0,0 +1,28 @@ +""" Preconfigured Command Enum module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_enum.py +# Description: Enumeration of supported predefined commands used in command_runner module: +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from enum import Enum + + +class PreconfiguredCommandEnum(Enum): + """Enumeration of preconfigured composite commands. + + Provides a predefined set of command identifiers for commonly used operations. + + The command_runner module uses these identifiers to construct command sequences and execute them via 'run_preconfigured_command_sequence' method. + """ + SPACK_COMPILER_FIND = "spack_compiler_find" + SPACK_COMPILERS = "spack_compilers" + SPACK_COMPILER_INFO = "spack_compiler_info" + SPACK_COMPILER_LIST = "spack_compiler_list" + SPACK_ENVIRONMENT_ACTIVATE = "spack_environment_activate" + SPACK_FIND = "spack_find" + SPACK_INSTALL = "spack_install" + SPACK_MIRROR_ADD = "spack_mirror_add" + SPACK_GPG_TRUST = "spack_gpg_trust" diff --git a/dedal/commands/shell_command_factory.py b/dedal/commands/shell_command_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..e63a456e201903a8ebf6a4885a4e230c509e97f6 --- /dev/null +++ b/dedal/commands/shell_command_factory.py @@ -0,0 +1,33 @@ +""" Shell Command Factory module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: shell_command_factory.py +# Description: Shell command factory to create shell command instances +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command import Command +from dedal.commands.generic_shell_command import GenericShellCommand + + +class ShellCommandFactory: + """Factory for creating shell command instances. + + Provides a static method for creating GenericShellCommand objects. + """ + + @staticmethod + def create_command(command_name: str, *args: str) -> Command: + """Creates a generic shell command. + + Instantiates and returns a GenericShellCommand object with the given command name and arguments. + + Args: + command_name (str): The name of the command. + *args (str): The arguments for the command. + + Returns: + A GenericShellCommand object. + """ + return GenericShellCommand(command_name, *args) diff --git a/dedal/commands/spack_command_sequence_factory.py b/dedal/commands/spack_command_sequence_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..ce7afac388e152412842ba448a9f17c126f90513 --- /dev/null +++ b/dedal/commands/spack_command_sequence_factory.py @@ -0,0 +1,211 @@ +"""Factory for generating predefined spack related command sequences.""" +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: spack_command_sequence_factory.py +# Description: Factory for generating predefined spack related command sequences +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_builder import CommandSequenceBuilder + + +def get_name_version(name: str | None, version: str | None) -> str: + """Formats a name and version string. + + Returns a string combining the name and version with "@" if both are provided, + otherwise returns the name or an empty string. + + Args: + name (str): The name. + version (str): The version. + + Returns: + The formatted name and version string. + """ + return f"{name}@{version}" if name and version else name or "" + + +class SpackCommandSequenceFactory: + """Factory for creating Spack command sequences. + + Provides methods for building CommandSequence objects for various Spack operations. + """ + + @staticmethod + def create_spack_enabled_command_sequence_builder(spack_setup_script: str) -> CommandSequenceBuilder: + """Creates a CommandSequenceBuilder with Spack setup. + + Initializes a builder with the 'source' command for the given Spack setup script. + + Args: + spack_setup_script (str): Path to the Spack setup script. + + Returns: + A CommandSequenceBuilder pre-configured with the Spack setup command. + """ + return (CommandSequenceBuilder() + .add_generic_command(CommandEnum.SOURCE, {"file_path": spack_setup_script})) + + @staticmethod + def create_spack_compilers_command_sequence(spack_setup_script: str) -> CommandSequence: + """Creates a command sequence for listing Spack compilers. + + Builds a sequence that sources the Spack setup script and then lists available compilers. + + Args: + spack_setup_script (str): Path to the Spack setup script. + + Returns: + A CommandSequence for listing Spack compilers. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_COMPILERS, {}) + .build()) + + @staticmethod + def create_spack_compiler_list_command_sequence(spack_setup_script: str) -> CommandSequence: + """Creates a command sequence for listing Spack compilers. + + Builds a sequence that sources the Spack setup script and then lists available compilers. + + Args: + spack_setup_script (str): Path to the Spack setup script. + + Returns: + A CommandSequence for listing Spack compilers. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_COMPILER_LIST, {}) + .build()) + + @staticmethod + def create_spack_install_package_command_sequence(spack_setup_script: str, + package_name: str | None, + version: str | None) -> CommandSequence: + """Creates a command sequence for installing a Spack package. + + Builds a sequence that sources the Spack setup script and then installs the specified package. + + Args: + spack_setup_script (str): Path to the Spack setup script. + package_name (str | None): The name of the package to install. + version (str | None): The version of the package to install. + + Returns: + A CommandSequence for installing the Spack package. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_INSTALL, { + "package_name_with_version": get_name_version(package_name, version) + }).build()) + + @staticmethod + def create_spack_mirror_add_command_sequence(spack_setup_script: str, + env_name: str, + mirror_name: str, + mirror_path: str, + autopush: bool = False, + signed: bool = False) -> CommandSequence: + """Creates a command sequence for adding a Spack mirror. + + Builds a sequence that sources the Spack setup script, activates an environment (if specified), + and adds the given mirror. + + Args: + spack_setup_script (str): Path to the Spack setup script. + env_name (str): The name of the environment to activate. + mirror_name (str): The name of the mirror. + mirror_path (str): The URL or path of the mirror. + autopush (bool): Whether to enable autopush for the mirror. + signed (bool): Whether to require signed packages from the mirror. + + Returns: + A CommandSequence for adding the Spack mirror. + """ + builder = SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + if env_name: + builder = builder.add_generic_command(CommandEnum.SPACK_ENVIRONMENT_ACTIVATE, + {"env_path": env_name}) + place_holders = { + "mirror_name": mirror_name, + "mirror_path": mirror_path, + "autopush": "--autopush" if autopush else "", + "signed": "--signed" if signed else "" + } + builder = builder.add_generic_command(CommandEnum.SPACK_MIRROR_ADD, place_holders) + return builder.build() + + @staticmethod + def create_spack_compiler_info_command_sequence(spack_setup_script: str, + compiler_name: str, + compiler_version: str) -> CommandSequence: + """Creates a command sequence for getting Spack compiler information. + + Builds a sequence that sources the Spack setup script and retrieves information about the specified compiler. + + + Args: + spack_setup_script (str): Path to the Spack setup script. + compiler_name (str): The name of the compiler. + compiler_version (str): The version of the compiler. + + Returns: + A CommandSequence for getting compiler information. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_COMPILER_INFO, + { + "compiler_name_with_version": + get_name_version(compiler_name, compiler_version) + }) + .build()) + + @staticmethod + def create_spack_post_install_find_command_sequence(spack_setup_script: str, + env_path: str, + package_name: str | None, + version: str | None) -> CommandSequence: + """Creates a command sequence for finding installed Spack packages after installation. + + Builds a sequence that sources the Spack setup script, activates the specified environment, + and then searches for the given package. + + Args: + spack_setup_script (str): Path to the Spack setup script. + env_path (str): The path to the Spack environment. + package_name (str | None): The name of the package to find. + version (str | None): The version of the package to find. + + Returns: + A CommandSequence for finding installed Spack packages. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_ENVIRONMENT_ACTIVATE, {"env_path": env_path}) + .add_generic_command(CommandEnum.SPACK_FIND, + { + "package_name_with_version": + get_name_version(package_name, version) + }).build()) + + @staticmethod + def create_spack_gpg_trust_command_sequence(spack_setup_script: str, + public_key_path: str) -> CommandSequence: + """Creates a command sequence for trusting a GPG key in Spack. + + Builds a sequence that sources the Spack setup script and then trusts the given GPG key. + + Args: + spack_setup_script (str): Path to the Spack setup script. + public_key_path (str): Path to the public key file. + + Returns: + A CommandSequence for trusting the GPG key. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_GPG_TRUST, + { + "public_key_path": public_key_path + }).build()) diff --git a/dedal/spack_factory/SpackOperation.py b/dedal/spack_factory/SpackOperation.py index aebabc0bc816bca5dbf111056251a0b985aad429..ad4f2389edc1c278cfe91eb166be0dc51f60757a 100644 --- a/dedal/spack_factory/SpackOperation.py +++ b/dedal/spack_factory/SpackOperation.py @@ -2,13 +2,16 @@ import os import re import subprocess from pathlib import Path + +from dedal.commands.command_runner import CommandRunner +from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum +from dedal.configuration.SpackConfig import SpackConfig from dedal.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ SpackInstallPackagesException, SpackConcertizeException, SpackMirrorException, SpackGpgException, SpackRepoException from dedal.logger.logger_builder import get_logger -from dedal.configuration.SpackConfig import SpackConfig from dedal.tests.testing_variables import SPACK_VERSION -from dedal.wrapper.spack_wrapper import check_spack_env from dedal.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable +from dedal.wrapper.spack_wrapper import check_spack_env class SpackOperation: @@ -47,6 +50,7 @@ class SpackOperation: if self.spack_config.env and spack_config.env.path: self.spack_config.env.path = spack_config.env.path self.spack_config.env.path.mkdir(parents=True, exist_ok=True) + self.command_runner = CommandRunner() def create_fetch_spack_environment(self): if self.spack_config.env.git_path: @@ -120,9 +124,7 @@ class SpackOperation: check=True, capture_output=True, text=True, logger=self.logger, info_msg=f'Checking if environment {self.spack_config.env.name} exists') - if result is None: - return False - return True + return result is not None @check_spack_env def add_spack_repo(self, repo_path: Path, repo_name: str): @@ -187,26 +189,104 @@ class SpackOperation: else: raise SpackGpgException('No GPG configuration was defined is spack configuration') - def add_mirror(self, mirror_name: str, mirror_path: Path, signed=False, autopush=False, global_mirror=False): - autopush = '--autopush' if autopush else '' - signed = '--signed' if signed else '' - if global_mirror: - run_command("bash", "-c", - f'{self.spack_setup_script} spack mirror add {autopush} {signed} {mirror_name} {mirror_path}', - check=True, - logger=self.logger, - info_msg=f'Added mirror {mirror_name}', - exception_msg=f'Failed to add mirror {mirror_name}', - exception=SpackMirrorException) - else: - check_spack_env( - run_command("bash", "-c", - f'{self.spack_command_on_env} && spack mirror add {autopush} {signed} {mirror_name} {mirror_path}', - check=True, - logger=self.logger, - info_msg=f'Added mirror {mirror_name}', - exception_msg=f'Failed to add mirror {mirror_name}', - exception=SpackMirrorException)) + def add_mirror(self, + mirror_name: str, + mirror_path: str, + signed=False, + autopush=False, + global_mirror=False) -> bool: + """Adds a Spack mirror. + + Adds a new mirror to the Spack configuration, either globally or to a specific environment. + + Args: + mirror_name (str): The name of the mirror. + mirror_path (str): The path or URL of the mirror. + signed (bool): Whether to require signed packages from the mirror. + autopush (bool): Whether to enable autopush for the mirror. + global_mirror (bool): Whether to add the mirror globally (True) or to the current environment (False). + + Returns: + True if the mirror was added successfully, False otherwise. + + Raises: + ValueError: If mirror_name or mirror_path are empty. + NoSpackEnvironmentException: If global_mirror is False and no environment is defined. + """ + if not mirror_name or not mirror_path: + raise ValueError("mirror_name and mirror_path are required") + if not global_mirror and not self.env_path: + raise NoSpackEnvironmentException('No spack environment defined') + result = self.command_runner.run_preconfigured_command_sequence( + PreconfiguredCommandEnum.SPACK_MIRROR_ADD, + self.spack_setup_script, + "" if global_mirror else str(self.env_path), + mirror_name, + mirror_path, + autopush, + signed) + return self.handle_result( + result, + "Failed to add mirror %s, reason: %s, output: %s", + (mirror_name,), + "Added mirror %s", + (mirror_name,) + ) + + def trust_gpg_key(self, public_key_path: str): + """Adds a GPG public key to the trusted keyring. + + This method attempts to add the provided GPG public key to the + Spack trusted keyring. + + Args: + public_key_path (str): Path to the GPG public key file. + + Returns: + bool: True if the key was added successfully, False otherwise. + + Raises: + ValueError: If public_key_path is empty. + """ + if not public_key_path: + raise ValueError("public_key_path is required") + result = self.command_runner.run_preconfigured_command_sequence( + PreconfiguredCommandEnum.SPACK_GPG_TRUST, + self.spack_setup_script, + public_key_path) + return self.handle_result( + result, + "Failed to add public key %s as trusted, reason: %s, output: %s", + (public_key_path,), + "Added public key %s as trusted", + (public_key_path,), + ) + + def handle_result(self, + result: dict[str, str | bool | None], + error_msg: str, + error_msg_args: tuple[str, ...], + info_msg: str, + info_msg_args: tuple[str, ...]): + """Handles the result of a command execution. + + Checks the success status of the result and logs either an error or an info message accordingly. + + Args: + result (dict[str, str | bool | None]): A dictionary containing the result of the command execution. + error_msg (str): The error message to log if the command failed. + error_msg_args (tuple[str, ...]): Arguments to format the error message. + info_msg (str): The info message to log if the command succeeded. + info_msg_args (tuple[str, ...]): Arguments to format the info message. + + Returns: + bool: True if the command succeeded, False otherwise. + """ + if not result["success"]: + self.logger.error(error_msg, *error_msg_args, result['error'], result['output']) + return False + self.logger.info(info_msg, *info_msg_args) + return True def remove_mirror(self, mirror_name: str): run_command("bash", "-c", diff --git a/dedal/spack_factory/SpackOperationCreateCache.py b/dedal/spack_factory/SpackOperationCreateCache.py index 41cfc8454136fd0096f6dcf78c0df62de004c6e6..8d6125fb3ebc3d505509ac29841b0975a0b8e768 100644 --- a/dedal/spack_factory/SpackOperationCreateCache.py +++ b/dedal/spack_factory/SpackOperationCreateCache.py @@ -42,7 +42,10 @@ class SpackOperationCreateCache(SpackOperation): if self.spack_config.gpg: signed = True self.create_gpg_keys() - self.add_mirror('local_cache', self.spack_config.buildcache_dir, signed=signed, autopush=signed, + self.add_mirror('local_cache', + str(self.spack_config.buildcache_dir), + signed=signed, + autopush=signed, global_mirror=False) self.logger.info(f'Added mirror for {self.spack_config.env.name}') super().install_packages(jobs=jobs, signed=signed, debug=debug, fresh=True) diff --git a/dedal/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index 2bb6f76ae6bf0cc56a1cec842684f6a1e22f7ca5..16312e7fd3af19e4ded58eb606f2b1861dedde09 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -3,11 +3,12 @@ import subprocess from pathlib import Path from dedal.build_cache.BuildCacheManager import BuildCacheManager +from dedal.configuration.SpackConfig import SpackConfig +from dedal.error_handling.exceptions import NoSpackEnvironmentException from dedal.error_handling.exceptions import SpackInstallPackagesException from dedal.logger.logger_builder import get_logger from dedal.spack_factory.SpackOperation import SpackOperation -from dedal.configuration.SpackConfig import SpackConfig -from dedal.utils.utils import file_exists_and_not_empty, run_command, log_command, copy_to_tmp, copy_file +from dedal.utils.utils import file_exists_and_not_empty, run_command, log_command, copy_file from dedal.wrapper.spack_wrapper import check_spack_env @@ -29,9 +30,33 @@ class SpackOperationUseCache(SpackOperation): os.environ.get('BUILDCACHE_OCI_PASSWORD'), cache_version=spack_config.cache_version_build) - def setup_spack_env(self): + def setup_spack_env(self) -> None: + """Set up the spack environment for using the cache. + + Downloads the build cache, adds the public key to trusted keys, + and adds the build cache mirror. + + Raises: + ValueError: If there is an issue with the build cache setup (mirror_name/mirror_path are empty). + NoSpackEnvironmentException: If the spack environment is not set up. + """ super().setup_spack_env() - # todo add buildcache to the spack environment + try: + # Download build cache from OCI Registry and add public key to trusted keys + self.build_cache.download(self.spack_config.buildcache_dir) + cached_public_key = self.build_cache.get_public_key_from_cache(str(self.spack_config.buildcache_dir)) + signed = cached_public_key is not None and self.trust_gpg_key(cached_public_key) + if not signed: + self.logger.warning("Public key not found in cache or failed to trust pgp keys!") + # Add build cache mirror + self.add_mirror('local_cache', + str(self.spack_config.buildcache_dir), + signed=signed, + autopush=True, + global_mirror=False) + except (ValueError, NoSpackEnvironmentException) as e: + self.logger.error("Error adding buildcache mirror: %s", e) + raise @check_spack_env def concretize_spack_env(self): diff --git a/dedal/tests/spack_from_scratch_test.py b/dedal/tests/spack_from_scratch_test.py new file mode 100644 index 0000000000000000000000000000000000000000..2fec80f743d72190d0b175191660250981f98255 --- /dev/null +++ b/dedal/tests/spack_from_scratch_test.py @@ -0,0 +1,204 @@ +from pathlib import Path +import pytest +from dedal.configuration.SpackConfig import SpackConfig +from dedal.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException +from dedal.spack_factory.SpackOperationCreator import SpackOperationCreator +from dedal.model.SpackDescriptor import SpackDescriptor +from dedal.tests.testing_variables import test_spack_env_git, ebrains_spack_builds_git +from dedal.utils.utils import file_exists_and_not_empty + + +def test_spack_repo_exists_1(): + spack_operation = SpackOperationCreator.get_spack_operator() + spack_operation.install_spack() + assert spack_operation.spack_repo_exists('ebrains-spack-builds') == False + + +def test_spack_repo_exists_2(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + with pytest.raises(NoSpackEnvironmentException): + spack_operation.spack_repo_exists(env.env_name) + + +def test_spack_repo_exists_3(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + print(spack_operation.get_spack_installed_version()) + spack_operation.setup_spack_env() + assert spack_operation.spack_repo_exists(env.env_name) == False + + +def test_spack_from_scratch_setup_1(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.spack_repo_exists(env.env_name) == False + + +def test_spack_from_scratch_setup_2(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.spack_repo_exists(env.env_name) == True + + +def test_spack_from_scratch_setup_3(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('new_env1', install_dir) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + with pytest.raises(BashCommandException): + spack_operation.setup_spack_env() + + +def test_spack_from_scratch_setup_4(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('new_env2', install_dir) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.spack_env_exists() == True + + +def test_spack_not_a_valid_repo(): + env = SpackDescriptor('ebrains-spack-builds', Path(), None) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab') + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + with pytest.raises(BashCommandException): + spack_operation.add_spack_repo(repo.path, repo.env_name) + + +def test_spack_from_scratch_concretize_1(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_2(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=False) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_3(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == False + + +def test_spack_from_scratch_concretize_4(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=False) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_5(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_6(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env, install_dir=install_dir) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=False) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_7(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_install(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + install_result = spack_operation.install_packages(jobs=2, signed=False, fresh=True, debug=False) + assert install_result.returncode == 0 diff --git a/dedal/tests/spack_install_test.py b/dedal/tests/spack_install_test.py new file mode 100644 index 0000000000000000000000000000000000000000..564d5c6aa2138e815cd7d092215a4f2eee8816f6 --- /dev/null +++ b/dedal/tests/spack_install_test.py @@ -0,0 +1,12 @@ +import pytest +from dedal.spack_factory.SpackOperation import SpackOperation +from dedal.tests.testing_variables import SPACK_VERSION + + +# run this test first so that spack is installed only once for all the tests +@pytest.mark.run(order=1) +def test_spack_install_scratch(): + spack_operation = SpackOperation() + spack_operation.install_spack(spack_version=f'v{SPACK_VERSION}') + installed_spack_version = spack_operation.get_spack_installed_version() + assert SPACK_VERSION == installed_spack_version diff --git a/dedal/tests/unit_tests/test_bash_command_executor.py b/dedal/tests/unit_tests/test_bash_command_executor.py new file mode 100644 index 0000000000000000000000000000000000000000..e216cc0f85dd22841d3c956b1714e8f444f46c7f --- /dev/null +++ b/dedal/tests/unit_tests/test_bash_command_executor.py @@ -0,0 +1,236 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_bash_command_executor.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-18 +import os +import subprocess +from unittest.mock import patch + +import pytest + +from dedal.commands.bash_command_executor import BashCommandExecutor +from dedal.commands.command import Command + + +class MockCommand(Command): + def __init__(self, cmd: str): + self._cmd = cmd + + def execute(self) -> str: + return self._cmd + + +class TestBashCommandExecutor: + @pytest.mark.parametrize( + "test_id, os_name, expected_bash_command", + [ + ("posix_system", "posix", ["bash", "-c"]), + ("nt_system", "nt", ["wsl", "bash", "-c"]), + ], + ) + def test_init_success_path(self, mocker, test_id, os_name, expected_bash_command): + # Arrange + mock_get_logger = mocker.patch("dedal.commands.bash_command_executor.logging.getLogger") + mocker.patch("dedal.commands.bash_command_executor.os_name", os_name) + + # Act + executor = BashCommandExecutor() + + # Assert + assert executor.bash_command == expected_bash_command + mock_get_logger.assert_called_once_with('dedal.commands.bash_command_executor') + + @pytest.mark.parametrize( + "test_id, num_commands", [(1, 1), (5, 5), (0, 0)] + ) + def test_add_command_success_path(self, mocker, test_id: int, num_commands: int): + # Arrange + executor = BashCommandExecutor() + executor.logger = mocker.MagicMock() + commands = [MockCommand("some_command") for _ in range(num_commands)] + + # Act + for command in commands: + executor.add_command(command) + + # Assert + assert len(executor.sequence.commands) == num_commands + executor.logger.info.assert_has_calls( + [mocker.call("Adding command to the sequence: %s", command) for command in commands]) + + def test_add_command_invalid_type(self): + # Arrange + executor = BashCommandExecutor() + invalid_command = "not a command object" # type: ignore + + # Act + with pytest.raises(ValueError) as except_info: + executor.add_command(invalid_command) + + # Assert + assert str(except_info.value) == "Invalid command type. Use Command." + + @patch("dedal.commands.bash_command_executor.os_name", "unknown") + def test_init_unknown_os(self): + + # Act + executor = BashCommandExecutor() + + # Assert + assert executor.bash_command == ["undefined"] + + @pytest.mark.parametrize( + "test_id, commands, expected_output", + [ + ("single_command", [MockCommand("echo hello")], "hello\n"), + ("multiple_commands", [MockCommand("echo hello"), MockCommand("echo world")], "hello\nworld\n"), + ("command_with_pipe", [MockCommand("echo hello | grep hello")], "hello\n"), + + ], + ) + @patch("dedal.commands.bash_command_executor.subprocess.run") + def test_execute_success_path_posix(self, mock_subprocess_run, test_id, commands, expected_output, mocker): + # Arrange + executor = BashCommandExecutor() + executor.logger = mocker.MagicMock() + for cmd in commands: + executor.add_command(cmd) + mock_subprocess_run.return_value.stdout = expected_output + + # Act + stdout, err = executor.execute() + + # Assert + assert stdout == expected_output + assert err is None + executor.logger.info.assert_has_calls( + [mocker.call('Adding command to the sequence: %s', cmd) for cmd in commands] + + [mocker.call("Successfully executed command sequence, output: %s", + mock_subprocess_run.return_value.stdout)]) + + @patch("dedal.commands.bash_command_executor.subprocess.run", + side_effect=FileNotFoundError("Mock file not found error")) + def test_execute_file_not_found_error(self, mock_subprocess_run): + # Arrange + executor = BashCommandExecutor() + executor.bash_command = ["bash", "-c"] + executor.add_command(MockCommand("some_command")) + + # Act + stdout, err = executor.execute() + + # Assert + assert stdout is None + assert err == "Error: Bash Command: ['bash', '-c'] not found: Mock file not found error" + mock_subprocess_run.assert_called_once_with(['bash', '-c', 'some_command'], capture_output=True, text=True, + check=True, timeout=172800) + + @patch("dedal.commands.bash_command_executor.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "some_command", stderr="Mock stderr")) + def test_execute_called_process_error(self, mock_subprocess_run, mocker): + # Arrange + mocker.patch("dedal.commands.bash_command_executor.os_name", "nt") + executor = BashCommandExecutor() + executor.add_command(MockCommand("failing_command")) + + # Act + stdout, err = executor.execute() + + # Assert + assert stdout is None + assert err == "Error: Command failed with exit code 1, Error Output: Mock stderr" + mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'failing_command'], capture_output=True, + text=True, check=True, timeout=172800) + + @pytest.mark.parametrize( + "test_id, exception, expected_error_message", + [ + ("permission_error", PermissionError("Mock permission denied"), + "Error: Permission denied: Mock permission denied"), + ("os_error", OSError("Mock OS error"), "Error: OS error occurred: Mock OS error"), + ("value_error", ValueError("Mock invalid argument"), + "Error: Invalid argument passed: Mock invalid argument"), + ("type_error", TypeError("Mock invalid type"), "Error: Invalid type for arguments: Mock invalid type"), + ("timeout_expired", subprocess.TimeoutExpired("some_command", 10), + "Error: Command timed out after 10 seconds"), + ("subprocess_error", subprocess.SubprocessError("Mock subprocess error"), + "Subprocess error occurred: Mock subprocess error"), + ], + ) + def test_execute_other_errors(self, test_id, exception, expected_error_message, mocker): + # Arrange + mocker.patch("dedal.commands.bash_command_executor.os_name", "nt") + with patch("dedal.commands.bash_command_executor.subprocess.run", side_effect=exception) as mock_subprocess_run: + executor = BashCommandExecutor() + executor.add_command(MockCommand("some_command")) + + # Act + stdout, err = executor.execute() + + # Assert + assert stdout is None + assert err == expected_error_message + mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'some_command'], capture_output=True, + text=True, check=True, timeout=172800) + + def test_execute_no_commands(self): + # Arrange + executor = BashCommandExecutor() + + # Act + with pytest.raises(ValueError) as except_info: + executor.execute() + + # Assert + assert str(except_info.value) == "No commands to execute." + + @patch("dedal.commands.bash_command_executor.subprocess.run") + def test_execute_happy_path_nt(self, mock_subprocess_run, mocker): + # Arrange + mocker.patch("dedal.commands.bash_command_executor.os_name", "nt") + executor = BashCommandExecutor() + executor.add_command(MockCommand("echo hello")) + mock_subprocess_run.return_value.stdout = "hello\n" + + # Act + stdout, err = executor.execute() + + # Assert + assert stdout == "hello\n" + assert err is None + assert executor.bash_command == ['wsl', 'bash', '-c'] + mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'echo hello'], capture_output=True, text=True, + check=True, timeout=172800) + + def test_execute_unknown_os(self, mocker): + # Arrange + errors = { + "posix": "Error: Bash Command: ['undefined'] not found: [Errno 2] No such file or directory: 'undefined'", + "nt": "Error: Bash Command: ['undefined'] not found: [WinError 2] The system cannot find the file " + 'specified' + } + original_os = os.name + expected_error = errors.get(original_os, "Error: Unknown OS") + mocker.patch("dedal.commands.bash_command_executor.os_name") + executor = BashCommandExecutor() + executor.add_command(MockCommand("echo hello")) + + # Act + assert executor.execute() == (None, expected_error) + + def test_reset(self, mocker): + # Test ID: reset + + # Arrange + executor = BashCommandExecutor() + mock_sequence = mocker.MagicMock() + executor.sequence = mock_sequence + + # Act + executor.reset() + + # Assert + mock_sequence.clear.assert_called_once() diff --git a/dedal/tests/unit_tests/test_build_cache_manager.py b/dedal/tests/unit_tests/test_build_cache_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..af5690ebb2c0bc94ef9701e4bad6dde7bde61dd5 --- /dev/null +++ b/dedal/tests/unit_tests/test_build_cache_manager.py @@ -0,0 +1,161 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_build_cache_manager.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-20 +import pytest +from _pytest.fixtures import fixture + +from dedal.build_cache.BuildCacheManager import BuildCacheManager + + +class TestBuildCacheManager: + + @fixture(scope="function") + def mock_build_cache_manager(self, mocker): + mocker.patch("dedal.build_cache.BuildCacheManager.get_logger") + return BuildCacheManager("TEST_HOST", "TEST_PROJECT", "TEST_USERNAME", "TEST_PASSWORD", "TEST_VERSION") + + def test_get_public_key_from_cache_success_path(self, mock_build_cache_manager, tmp_path): + + # Arrange + build_cache_dir = tmp_path / "build_cache" + pgp_folder = build_cache_dir / "project" / "_pgp" + pgp_folder.mkdir(parents=True) + key_file = pgp_folder / "key.pub" + key_file.write_text("public key content") + + # Act + result = mock_build_cache_manager.get_public_key_from_cache(str(build_cache_dir)) + + # Assert + assert result == str(key_file) + + @pytest.mark.parametrize("test_id, num_pgp_folders, num_key_files, expected_log_message", [ + ("more_than_one_gpg_folder", 2, 1, + "More than one PGP folders found in the build cache: %s, using the first one in the list: %s"), + ("more_than_one_key_file", 1, 2, + "More than one PGP key files found in the build cache: %s, using the first one in the list: %s"), + ]) + def test_get_public_key_from_cache_multiple_files_or_folders(self, mock_build_cache_manager, test_id, + tmp_path, num_pgp_folders, + num_key_files, expected_log_message): + + # Arrange + pgp_folders = [] + key_files = [] + build_cache_dir = tmp_path / "build_cache" + for i in range(num_pgp_folders): + pgp_folder = build_cache_dir / f"project{i}" / "_pgp" + pgp_folders.append(str(pgp_folder)) + pgp_folder.mkdir(parents=True) + for j in range(num_key_files): + key_file = pgp_folder / f"key{j}.pub" + key_files.append(str(key_file)) + key_file.write_text(f"public key {j} content") + + # Act + result = mock_build_cache_manager.get_public_key_from_cache(str(build_cache_dir)) + + # Assert + # Cannot assure the order in which the OS returns the files, + # hence check if the result is in the expected list + assert result in [str(build_cache_dir / "project0" / "_pgp" / "key0.pub"), + str(build_cache_dir / "project0" / "_pgp" / "key1.pub"), + str(build_cache_dir / "project1" / "_pgp" / "key0.pub")] + assert mock_build_cache_manager._logger.warning.call_args[0][0] == expected_log_message + assert set(mock_build_cache_manager._logger.warning.call_args[0][1]) == set( + pgp_folders) if test_id == "more_than_one_gpg_folder" else set(key_files) + assert mock_build_cache_manager._logger.warning.call_args[0][ + 2] in pgp_folders if test_id == "more_than_one_gpg_folder" else key_files + + @pytest.mark.parametrize("build_cache_dir, expected_log_message", [ + (None, 'Build cache directory does not exist!'), + ("non_existent_dir", 'Build cache directory does not exist!'), + ]) + def test_get_public_key_from_cache_no_build_cache(self, mock_build_cache_manager, build_cache_dir, + expected_log_message, tmp_path): + + # Arrange + build_cache_dir = str(tmp_path / build_cache_dir) if build_cache_dir else None + + # Act + result = mock_build_cache_manager.get_public_key_from_cache(build_cache_dir) + + # Assert + assert result is None + mock_build_cache_manager._logger.warning.assert_called_once_with(expected_log_message) + + # Assert + assert result is None + mock_build_cache_manager._logger.warning.assert_called_once_with(expected_log_message) + + @pytest.mark.parametrize("build_cache_dir, expected_log_message", [ + ("non_existent_dir", "No _pgp folder found in the build cache!"), + ]) + def test_get_public_key_from_cache_no_pgp_folder(self, mock_build_cache_manager, build_cache_dir, + expected_log_message, tmp_path): + + # Arrange + if build_cache_dir == "non_existent_dir": + build_cache_dir = tmp_path / build_cache_dir + build_cache_dir.mkdir(parents=True) + + # Act + result = mock_build_cache_manager.get_public_key_from_cache(build_cache_dir) + + # Assert + assert result is None + mock_build_cache_manager._logger.warning.assert_called_once_with(expected_log_message) + + # Assert + assert result is None + mock_build_cache_manager._logger.warning.assert_called_once_with(expected_log_message) + + def test_get_public_key_from_cache_empty_pgp_folder(self, mock_build_cache_manager, tmp_path): + + # Arrange + build_cache_dir = tmp_path / "build_cache" + pgp_folder = build_cache_dir / "project" / "_pgp" + pgp_folder.mkdir(parents=True) + + # Act + result = mock_build_cache_manager.get_public_key_from_cache(str(build_cache_dir)) + + # Assert + assert result is None + mock_build_cache_manager._logger.warning.assert_called_once_with("No PGP key files found in the build cache!") + + @pytest.mark.parametrize("items, expected_log_message", [ + (["item1", "item2"], "test message item1 item2 item1"), + (["item1", "item2", "item3"], "test message item1 item2 item3 item1"), + ]) + def test_log_warning_if_needed_multiple_items(self, mock_build_cache_manager, items, expected_log_message): + # Test ID: multiple_items + + # Arrange + warn_message = "test message" + + # Act + mock_build_cache_manager._BuildCacheManager__log_warning_if_needed(warn_message, items) + + # Assert + mock_build_cache_manager._logger.warning.assert_called_once_with(warn_message, items, items[0]) + + @pytest.mark.parametrize("items", [ + [], + ["item1"], + ]) + def test_log_warning_if_needed_no_warning(self, mock_build_cache_manager, items): + # Test ID: no_warning + + # Arrange + warn_message = "test message" + + # Act + mock_build_cache_manager._BuildCacheManager__log_warning_if_needed(warn_message, items) + + # Assert + mock_build_cache_manager._logger.warning.assert_not_called() diff --git a/dedal/tests/unit_tests/test_command.py b/dedal/tests/unit_tests/test_command.py new file mode 100644 index 0000000000000000000000000000000000000000..3c86404199262dec907c983620f7c95a7adf0bc6 --- /dev/null +++ b/dedal/tests/unit_tests/test_command.py @@ -0,0 +1,45 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-18 + +import pytest + +from dedal.commands.command import Command + +class ConcreteCommand(Command): + def __init__(self, return_value: str): + self._return_value = return_value + + def execute(self) -> str: + return self._return_value + + +class TestCommand: + def test_execute_abstract_method(self): + # Act + with pytest.raises(TypeError): + Command() # type: ignore + + + @pytest.mark.parametrize( + "test_id, return_value", + [ + ("empty_string", ""), + ("non_empty_string", "some_command"), + ("string_with_spaces", "command with spaces"), + ("non_ascii_chars", "αβγδ"), + ], + ) + def test_execute_concrete_command(self, test_id, return_value): + # Arrange + command = ConcreteCommand(return_value) + + # Act + result = command.execute() + + # Assert + assert result == return_value diff --git a/dedal/tests/unit_tests/test_command_enum.py b/dedal/tests/unit_tests/test_command_enum.py new file mode 100644 index 0000000000000000000000000000000000000000..f29e2b4c707a318100fa3d90c9f62d54d8b64ebb --- /dev/null +++ b/dedal/tests/unit_tests/test_command_enum.py @@ -0,0 +1,38 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command_enum.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-18 + +import pytest + +from dedal.commands.command_enum import CommandEnum + +class TestCommandEnum: + + @pytest.mark.parametrize( + "test_id, command_name, expected_value", + [ + ("source", "SOURCE", "source"), + ("list_files", "LIST_FILES", "list_files"), + ("spack_install", "SPACK_INSTALL", "spack_install"), + ], + ) + def test_command_enum_values(self, test_id, command_name, expected_value): + + # Act + command = CommandEnum[command_name] + + # Assert + assert command.value == expected_value + assert command.name == command_name + + + def test_command_enum_invalid_name(self): + + # Act + with pytest.raises(KeyError): + CommandEnum["INVALID_COMMAND"] # type: ignore + diff --git a/dedal/tests/unit_tests/test_command_runner.py b/dedal/tests/unit_tests/test_command_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..ac30fa083b82a1dc8684c33dfcf91c19ea0d2a3e --- /dev/null +++ b/dedal/tests/unit_tests/test_command_runner.py @@ -0,0 +1,125 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command_runner.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-18 + +import pytest + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_runner import CommandRunner +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum + + +class MockCommandSequence(CommandSequence): + def __init__(self, cmd_str: str): + self._cmd_str = cmd_str + + def execute(self) -> str: + return self._cmd_str + + +class TestCommandRunner: + @pytest.fixture(scope="function") + def mock_command_runner(self, mocker): + mocker.patch("dedal.commands.command_runner.BashCommandExecutor") + mocker.patch("dedal.commands.command_runner.logging.getLogger") + return CommandRunner() + + @pytest.mark.parametrize( + "test_id, command_str, mock_output, expected_output", + [ + ("simple_command", "echo hello", "hello\n", {"success": True, "output": "hello", "error": None}), + ("complex_command", "ls -l /tmp", "mock_output\n", + {"success": True, "output": "mock_output", "error": None}), + ("empty_output", "ls -l /tmp", "", {"success": True, "output": None, "error": None}), + ("command_with_error", "failing_command", "", {"success": False, "output": None, "error": "mock_error"}), + + ], + ) + def test_execute_command(self, mock_command_runner, test_id, command_str, mock_output, expected_output): + # Arrange + mock_command_runner.executor.execute.return_value = ( + mock_output, "mock_error" if "failing" in command_str else None) + command_sequence = MockCommandSequence(command_str) + + # Act + result = mock_command_runner.execute_command(command_sequence) + + # Assert + assert result == expected_output + mock_command_runner.executor.execute.assert_called_once() + mock_command_runner.executor.reset.assert_called_once() + + @pytest.mark.parametrize( + "test_id, command_type, args, expected_result", + [ + ( + "valid_command", + PreconfiguredCommandEnum.SPACK_COMPILER_FIND, + ["gcc"], + {"success": True, "output": "mock_output", "error": None}, + ), + ], + ) + def test_run_predefined_command_success_path(self, mock_command_runner, test_id, command_type, args, + expected_result): + # Arrange + mock_command_runner.executor.execute.return_value = ("mock_output\n", None) + + # Act + result = mock_command_runner.run_preconfigured_command_sequence(command_type, *args) + + # Assert + assert result == expected_result + + def test_run_predefined_command_invalid_type(self, mock_command_runner): + # Arrange + invalid_command_type = "INVALID_COMMAND" # type: ignore + + # Act + result = mock_command_runner.run_preconfigured_command_sequence(invalid_command_type) + + # Assert + assert result == {"success": False, "error": "Invalid command name: INVALID_COMMAND"} + + @pytest.mark.parametrize( + "test_id, command_placeholders_map, expected_result", + [ + ( + "single_command", + {CommandEnum.LIST_FILES: {"folder_location": "/tmp"}}, + {"success": True, "output": "mock_output", "error": None}, + ), + ( + "multiple_commands", + { + CommandEnum.LIST_FILES: {"folder_location": "/tmp"}, + CommandEnum.SHOW_DIRECTORY: {}, + }, + {"success": True, "output": "mock_output", "error": None}, + ), + ( + "empty_placeholders", + {CommandEnum.SHOW_DIRECTORY: {}}, + {"success": True, "output": "mock_output", "error": None}, + ), + ], + ) + def test_run_custom_command(self, mocker, mock_command_runner, test_id, command_placeholders_map, expected_result): + # Arrange + mock_command_runner.execute_command = mocker.MagicMock(return_value=expected_result) + mock_create_custom_command_sequence = mocker.patch( + "dedal.commands.command_runner.CommandSequenceFactory.create_custom_command_sequence") + mock_create_custom_command_sequence.return_value = MockCommandSequence("mock_command") + + # Act + result = mock_command_runner.run_custom_command_sequence(command_placeholders_map) + + # Assert + assert result == expected_result + mock_create_custom_command_sequence.assert_called_once_with(command_placeholders_map) + mock_command_runner.execute_command.assert_called_once_with(mock_create_custom_command_sequence.return_value) diff --git a/dedal/tests/unit_tests/test_command_sequence.py b/dedal/tests/unit_tests/test_command_sequence.py new file mode 100644 index 0000000000000000000000000000000000000000..e663c06b57e32630666510e0b6ed55313012fe26 --- /dev/null +++ b/dedal/tests/unit_tests/test_command_sequence.py @@ -0,0 +1,108 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command_sequence.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 +from unittest.mock import Mock + +import pytest + +from dedal.commands.command import Command +from dedal.commands.command_sequence import CommandSequence + + +class MockCommand(Command): + def __init__(self, cmd_str: str): + self._cmd_str = cmd_str + + def execute(self) -> str: + return self._cmd_str + + +class TestCommandSequence: + + @pytest.mark.parametrize( + "test_id, commands, expected_output", + [ + ("single_command", ["echo hello"], "echo hello"), + ("single_command_with_spaces at_beginning", [" pwd"], "pwd"), + ("single_command_with_spaces at end", [" pwd "], "pwd"), + ("multiple_commands_with_spaces", [" echo hello", " ls -l /tmp "], "echo hello && ls -l /tmp"), + ("multiple_commands", ["echo hello", "ls -l /tmp"], "echo hello && ls -l /tmp"), + ("multiple_commands_with_spaces_in_between_and_end", ["echo hello ", "ls -l /tmp "], + "echo hello && ls -l /tmp"), + ("empty_command", [""], ""), + ("commands_with_spaces", ["command with spaces", "another command"], + "command with spaces && another command"), + ], + ) + def test_execute_success_path(self, test_id, commands, expected_output): + # Arrange + sequence = CommandSequence() + for cmd in commands: + sequence.add_command(MockCommand(cmd)) + + # Act + result = sequence.execute() + + # Assert + assert result == expected_output + + def test_execute_no_commands(self): + # Arrange + sequence = CommandSequence() + + # Act + result = sequence.execute() + + # Assert + assert result == "" + + @pytest.mark.parametrize( + "test_id, num_commands", + [ + ("one_command", 1), + ("multiple_commands", 5), + ("no_commands", 0), + ], + ) + def test_add_command_success_path(self, test_id, num_commands): + # Arrange + sequence = CommandSequence() + commands = [MockCommand(f"command_{i}") for i in range(num_commands)] + + # Act + for command in commands: + sequence.add_command(command) + + # Assert + assert len(sequence.commands) == num_commands + + def test_add_command_invalid_type(self): + # Arrange + sequence = CommandSequence() + invalid_command = "not a command object" # type: ignore + + # Act + with pytest.raises(ValueError) as except_info: + sequence.add_command(invalid_command) + + # Assert + assert str(except_info.value) == "Command must be an instance of Command" + + @pytest.mark.parametrize("initial_commands", [[], [Mock(spec=Command), Mock(spec=Command)]]) + def test_clear(self, mocker, initial_commands): + # Test ID: clear + + # Arrange + sequence = CommandSequence() + for command in initial_commands: + sequence.add_command(command) + + # Act + sequence.clear() + + # Assert + assert len(sequence.commands) == 0 diff --git a/dedal/tests/unit_tests/test_command_sequence_builder.py b/dedal/tests/unit_tests/test_command_sequence_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..e4004255128836a4643665bf6acf338cd9497155 --- /dev/null +++ b/dedal/tests/unit_tests/test_command_sequence_builder.py @@ -0,0 +1,95 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command_sequence_builder.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +from unittest.mock import Mock, patch + +import pytest + +from dedal.commands.command import Command +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_registry import CommandRegistry +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_builder import CommandSequenceBuilder +from dedal.commands.shell_command_factory import ShellCommandFactory + + +class TestCommandSequenceBuilder: + + @pytest.mark.parametrize( + "test_id, command_name, placeholders, expected_command", + [ + ("no_placeholders", CommandEnum.SHOW_DIRECTORY, {}, ["pwd"]), + ("with_placeholders", CommandEnum.LIST_FILES, {"folder_location": "/tmp "}, ["ls", "-l", "/tmp"]), + ("missing_placeholders", CommandEnum.FIND_IN_FILE, {"search_term": "error"}, + ["grep", "-i", "error", "{file_path}"]), + ("missing_placeholders_values_1", CommandEnum.SPACK_MIRROR_ADD, {"autopush": ""}, + ["spack", "mirror", "add", "{signed}", "{mirror_name}","{mirror_path}"]), + ("missing_placeholders_values_2", CommandEnum.SPACK_MIRROR_ADD, {"autopush": "", "signed": ""}, + ["spack", "mirror", "add", "{mirror_name}", "{mirror_path}"]), + ("missing_placeholders_values_3", CommandEnum.SPACK_MIRROR_ADD, {"autopush": "", "signed": "", "mirror_name": "test", "mirror_path": "test_path"}, + ["spack", "mirror", "add", "test", "test_path"]), + ("extra_placeholders", CommandEnum.ECHO_MESSAGE, {"message": "hello", "extra": "world"}, ["echo", "hello"]), + ], + ) + def test_add_generic_command_success_path(self, mocker, test_id, command_name, + placeholders, expected_command): + # Arrange + mock_get_command = mocker.patch.object(CommandRegistry, "get_command") + mock_create_command = mocker.patch.object(ShellCommandFactory, "create_command") + builder = CommandSequenceBuilder() + mock_get_command.return_value = expected_command + mock_create_command.return_value = Mock(spec=Command, + execute=lambda: " ".join(expected_command) if expected_command else "") + + # Act + builder.add_generic_command(command_name, placeholders) + + # Assert + mock_get_command.assert_called_once_with(command_name) + mock_create_command.assert_called_once_with(*expected_command) + assert len(builder.sequence.commands) == 1 + assert builder.sequence.commands[0].execute() == " ".join(expected_command) if expected_command else "" + + def test_add_generic_command_invalid_command_type(self): + # Arrange + builder = CommandSequenceBuilder() + invalid_command_name = "invalid" # type: ignore + + # Act + with pytest.raises(ValueError) as except_info: + builder.add_generic_command(invalid_command_name, {}) + + # Assert + assert str(except_info.value) == "Invalid command type. Use CommandEnum." + + def test_add_generic_command_unknown_command(self, mocker): + # Arrange + mock_get_command = mocker.patch.object(CommandRegistry, "get_command") + builder = CommandSequenceBuilder() + mock_get_command.return_value = None + + # Act + with pytest.raises(ValueError) as except_info: + builder.add_generic_command(CommandEnum.LIST_FILES, {}) + + # Assert + assert str(except_info.value) == "Unknown command: CommandEnum.LIST_FILES" + + def test_build(self): + # Arrange + builder = CommandSequenceBuilder() + builder.add_generic_command(CommandEnum.SHOW_DIRECTORY, {}) + + # Act + sequence = builder.build() + + # Assert + assert isinstance(sequence, CommandSequence) + assert sequence.execute() == "pwd" + assert len(sequence.commands) == 1 + assert len(builder.sequence.commands) == 0 # Check if the builder's sequence is reset diff --git a/dedal/tests/unit_tests/test_command_sequence_factory.py b/dedal/tests/unit_tests/test_command_sequence_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..7048690aac222786ae5b8794c07969e8e8e91544 --- /dev/null +++ b/dedal/tests/unit_tests/test_command_sequence_factory.py @@ -0,0 +1,49 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command_sequence_factory.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +from unittest.mock import Mock, patch + +import pytest + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_builder import CommandSequenceBuilder +from dedal.commands.command_sequence_factory import CommandSequenceFactory + + +class TestCommandSequenceFactory: + + @pytest.mark.parametrize( + "test_id, command_placeholders_map, expected_calls", + [ + ("single_command", {CommandEnum.SHOW_DIRECTORY: {}}, [(CommandEnum.SHOW_DIRECTORY, {})]), + ( + "multiple_commands", + {CommandEnum.LIST_FILES: {"folder_location": "/tmp"}, CommandEnum.SHOW_DIRECTORY: {}}, + [(CommandEnum.LIST_FILES, {"folder_location": "/tmp"}), (CommandEnum.SHOW_DIRECTORY, {})], + ), + ("no_commands", {}, []), + ], + ) + def test_create_custom_command_sequence(self, mocker, test_id, command_placeholders_map, expected_calls): + # Arrange + mock_add_generic_command = mocker.patch.object(CommandSequenceBuilder, "add_generic_command") + mock_builder = Mock(spec=CommandSequenceBuilder, build=Mock(return_value=CommandSequence())) + mock_builder.add_generic_command = mock_add_generic_command + mock_add_generic_command.return_value = mock_builder + + with patch("dedal.commands.command_sequence_factory.CommandSequenceBuilder", return_value=mock_builder): + # Act + CommandSequenceFactory.create_custom_command_sequence(command_placeholders_map) + + # Assert + assert mock_add_generic_command.call_count == len(expected_calls) + mock_add_generic_command.assert_has_calls( + [mocker.call(args, kwargs) for args, kwargs in expected_calls], any_order=True + ) + mock_builder.build.assert_called_once() diff --git a/dedal/tests/unit_tests/test_generic_shell_command.py b/dedal/tests/unit_tests/test_generic_shell_command.py new file mode 100644 index 0000000000000000000000000000000000000000..7ed779aee2faf531e2472d9219551d1792aa7bf1 --- /dev/null +++ b/dedal/tests/unit_tests/test_generic_shell_command.py @@ -0,0 +1,63 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_generic_shell_command.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +import pytest + +from dedal.commands.command import Command +from dedal.commands.generic_shell_command import GenericShellCommand + +class TestGenericShellCommand: + + @pytest.mark.parametrize( + "test_id, command_name, args, expected_output", + [ + ("no_args", "ls", [], "ls"), + ("single_arg", "echo", ["hello"], "echo hello"), + ("multiple_args", "grep", ["-i", "error", "file.txt"], "grep -i error file.txt"), + ("command_with_spaces", " ls ", ["-l", "/tmp"], "ls -l /tmp"), + ("args_with_spaces", "ls", [" -l ", " /tmp "], "ls -l /tmp"), + ("command_and_args_with_spaces", " ls ", [" -l ", " /tmp "], "ls -l /tmp"), + ("empty_args", "ls", [""], "ls "), # Empty arguments are preserved, but stripped + ], + ) + def test_execute_success_path(self, test_id, command_name, args, expected_output): + + # Act + command = GenericShellCommand(command_name, *args) + result = command.execute() + + # Assert + assert result == expected_output + + + @pytest.mark.parametrize( + "test_id, command_name, args, expected_error", + [ + ("empty_command_name", "", [], ValueError), + ("none_command_name", None, [], ValueError), # type: ignore + ("int_command_name", 123, [], ValueError), # type: ignore + ("invalid_arg_type", "ls", [123], ValueError), # type: ignore + ("mixed_arg_types", "ls", ["valid", 456], ValueError), # type: ignore + ], + ) + def test_init_error_cases(self, test_id, command_name, args, expected_error): + + # Act + with pytest.raises(expected_error) as except_info: + GenericShellCommand(command_name, *args) + + # Assert + if expected_error is ValueError: + if not command_name: + assert str(except_info.value) == "Command name is required!" + elif not isinstance(command_name, str): + assert str(except_info.value) == "Command name must be a string!" + else: + assert str(except_info.value) == "All arguments must be strings!" + + diff --git a/dedal/tests/unit_tests/test_preconfigured_command_enum.py b/dedal/tests/unit_tests/test_preconfigured_command_enum.py new file mode 100644 index 0000000000000000000000000000000000000000..820c4d4175903e12c4cd125cf327371fa08145f4 --- /dev/null +++ b/dedal/tests/unit_tests/test_preconfigured_command_enum.py @@ -0,0 +1,37 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_preconfigured_command_enum.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +import pytest + +from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum + +class TestPreconfiguredCommandEnum: + + @pytest.mark.parametrize( + "test_id, command_name, expected_value", + [ + ("spack_compiler_find", "SPACK_COMPILER_FIND", "spack_compiler_find"), + ("spack_compilers", "SPACK_COMPILERS", "spack_compilers"), + ("spack_install", "SPACK_INSTALL", "spack_install"), + ], + ) + def test_preconfigured_command_enum_values(self, test_id, command_name, expected_value): + + # Act + command = PreconfiguredCommandEnum[command_name] + + # Assert + assert command.value == expected_value + assert command.name == command_name + + + def test_preconfigured_command_enum_invalid_name(self): + + # Act + with pytest.raises(KeyError): + PreconfiguredCommandEnum["INVALID_COMMAND"] # type: ignore diff --git a/dedal/tests/unit_tests/test_shell_command_factory.py b/dedal/tests/unit_tests/test_shell_command_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..bfd2b2db3f98d45419cf2f6e3246cf5d2d4f4c00 --- /dev/null +++ b/dedal/tests/unit_tests/test_shell_command_factory.py @@ -0,0 +1,61 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_shell_command_factory.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +import pytest + +from dedal.commands.generic_shell_command import GenericShellCommand +from dedal.commands.shell_command_factory import ShellCommandFactory + + +class TestShellCommandFactory: + + @pytest.mark.parametrize( + "test_id, command_name, args", + [ + ("no_args", "ls", []), + ("single_arg", "echo", ["hello"]), + ("multiple_args", "grep", ["-i", "error", "file.txt"]), + ("command_with_spaces", " ls ", ["-l", "/tmp"]), + ("args_with_spaces", "ls", [" -l ", " /tmp "]), + ("command_and_args_with_spaces", " ls ", [" -l ", " /tmp "]), + ], + ) + def test_create_command_happy_path(self, test_id, command_name, args): + + # Act + command = ShellCommandFactory.create_command(command_name, *args) + + # Assert + assert isinstance(command, GenericShellCommand) + assert command.command_name == command_name.strip() + assert command.args == tuple(map(str.strip, args)) + + @pytest.mark.parametrize( + "test_id, command_name, args, expected_error", + [ + ("empty_command_name", "", [], ValueError), + ("none_command_name", None, [], ValueError), # type: ignore + ("int_command_name", 123, [], ValueError), # type: ignore + ("invalid_arg_type", "ls", [123], ValueError), # type: ignore + ("mixed_arg_types", "ls", ["valid", 456], ValueError), # type: ignore + ], + ) + def test_create_command_error_cases(self, test_id, command_name, args, expected_error): + + # Act + with pytest.raises(expected_error) as except_info: + ShellCommandFactory.create_command(command_name, *args) + + # Assert + if expected_error is ValueError: + if not command_name: + assert str(except_info.value) == "Command name is required!" + elif not isinstance(command_name, str): + assert str(except_info.value) == "Command name must be a string!" + else: + assert str(except_info.value) == "All arguments must be strings!" diff --git a/dedal/tests/unit_tests/test_spack_command_sequence_factory.py b/dedal/tests/unit_tests/test_spack_command_sequence_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..0ffebcf3034236b5940ffbd529d5cb23ed9dc3c4 --- /dev/null +++ b/dedal/tests/unit_tests/test_spack_command_sequence_factory.py @@ -0,0 +1,159 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_spack_command_sequence_factory.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +import pytest + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_builder import CommandSequenceBuilder +from dedal.commands.spack_command_sequence_factory import SpackCommandSequenceFactory + + +class TestSpackCommandSequenceFactory: + + @pytest.mark.parametrize( + "test_id, spack_setup_script", + [ + ("with_setup_script", "/path/to/setup.sh"), + ("empty_setup_script", "")], + ) + def test_create_spack_enabled_command_sequence_builder(self, test_id, spack_setup_script): + # Act + builder = SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + + # Assert + assert isinstance(builder, CommandSequenceBuilder) + assert len(builder.sequence.commands) == 1 + assert builder.sequence.commands[0].command_name == "source" + if spack_setup_script: + assert builder.sequence.commands[0].args == ("/path/to/setup.sh",) + else: + assert len(builder.sequence.commands[0].args) == 0 + + @pytest.mark.parametrize( + "test_id, method_name, args, kwargs, expected_command, expected_args", + [ + ( + "spack_compilers", "create_spack_compilers_command_sequence", ["/path/to/setup.sh"], {}, "spack", + ["compilers"] + ), + ( + "spack_compiler_list", "create_spack_compiler_list_command_sequence", ["/path/to/setup.sh"], {}, + "spack", ["compiler", "list"] + ), + ( + "spack_install_with_version", "create_spack_install_package_command_sequence", + ["/path/to/setup.sh", "package", "1.0"], {}, "spack", ["install", "package@1.0"] + ), + ( + "spack_install_no_version", "create_spack_install_package_command_sequence", + ["/path/to/setup.sh", "package", None], {}, "spack", ["install", "package"] + ), + ( + "spack_install_no_package", "create_spack_install_package_command_sequence", + ["/path/to/setup.sh", None, "1.0"], {}, "spack", ["install"] + ), + ( + "spack_compiler_info", "create_spack_compiler_info_command_sequence", + ["/path/to/setup.sh", "gcc", "10.2"], {}, "spack", ["compiler", "info", "gcc@10.2"] + ), + ], + ) + def test_create_command_sequence(self, test_id, method_name, args, kwargs, expected_command, expected_args): + # Arrange + method = getattr(SpackCommandSequenceFactory, method_name) + + # Act + sequence = method(*args, **kwargs) + + # Assert + assert isinstance(sequence, CommandSequence) + assert len(sequence.commands) == 2 + assert sequence.commands[1].command_name == expected_command + assert sequence.commands[1].args == tuple(expected_args) + + @pytest.mark.parametrize( + "test_id, env_name, mirror_name, mirror_path, autopush, signed, expected_length, expected_autopush, expected_signed, expected_output", + [ + ("no_env", "", "mymirror", "/path/to/mirror", False, False, 2, "", "", + ("mirror", "add", "mymirror", "/path/to/mirror")), + ("with_env", "myenv", "mymirror", "/path/to/mirror", True, True, 3, "--autopush", "--signed", + ("mirror", "add", "--autopush", "--signed", "mymirror", "/path/to/mirror")), + ], + ) + def test_create_spack_mirror_add_command_sequence(self, test_id, env_name, mirror_name, mirror_path, autopush, + signed, expected_length, expected_autopush, expected_signed, + expected_output): + # Arrange + spack_setup_script = "/path/to/setup.sh" + + # Act + sequence = SpackCommandSequenceFactory.create_spack_mirror_add_command_sequence( + spack_setup_script, env_name, mirror_name, mirror_path, autopush, signed + ) + + # Assert + assert isinstance(sequence, CommandSequence) + assert len(sequence.commands) == expected_length + assert sequence.commands[-1].command_name == "spack" + assert sequence.commands[-1].args == expected_output + + @pytest.mark.parametrize( + "test_id, package_name, version, expected_package_arg", + [ + ("with_package_and_version", "mypackage", "1.2.3", "mypackage@1.2.3"), + ("only_package_name", "mypackage", None, "mypackage"), + ("no_package", None, "1.2.3", ""), + ], + ) + def test_create_spack_post_install_find_command_sequence(self, test_id, package_name, version, + expected_package_arg): + # Arrange + spack_setup_script = "/path/to/setup.sh" + env_path = "/path/to/env" + + # Act + sequence = SpackCommandSequenceFactory.create_spack_post_install_find_command_sequence( + spack_setup_script, env_path, package_name, version + ) + + # Assert + assert isinstance(sequence, CommandSequence) + assert len(sequence.commands) == 3 + assert sequence.commands[1].command_name == "spack" + assert sequence.commands[1].args == ("env", "activate", "-p", env_path) + assert sequence.commands[2].command_name == "spack" + assert sequence.commands[2].args == ("find", expected_package_arg) if expected_package_arg else ("find",) + + @pytest.mark.parametrize("spack_setup_script, public_key_path", [ + ("/path/to/setup-env.sh", "key.gpg"), + ("./setup-env.sh", "path/to/key.gpg"), + ]) + def test_create_spack_gpg_trust_command_sequence(self, mocker, spack_setup_script, public_key_path): + # Test ID: create_spack_gpg_trust_command_sequence + + # Arrange + mock_command_sequence_builder = mocker.MagicMock() + mocker.patch.object(SpackCommandSequenceFactory, "create_spack_enabled_command_sequence_builder", + return_value=mock_command_sequence_builder) + + mock_command_sequence = mocker.MagicMock() + mock_command_sequence_builder.add_generic_command.return_value = mock_command_sequence_builder + mock_command_sequence_builder.build.return_value = mock_command_sequence + + # Act + result = SpackCommandSequenceFactory.create_spack_gpg_trust_command_sequence(spack_setup_script, + public_key_path) + + # Assert + assert result == mock_command_sequence + SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder.assert_called_once_with( + spack_setup_script) + mock_command_sequence_builder.add_generic_command.assert_called_once_with(CommandEnum.SPACK_GPG_TRUST, + {"public_key_path": public_key_path}) + mock_command_sequence_builder.build.assert_called_once() diff --git a/dedal/tests/unit_tests/test_spack_operation.py b/dedal/tests/unit_tests/test_spack_operation.py new file mode 100644 index 0000000000000000000000000000000000000000..f053459c04e2f8f10206d570cbf3857f1ec1de3c --- /dev/null +++ b/dedal/tests/unit_tests/test_spack_operation.py @@ -0,0 +1,209 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_spack_operation.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +import pytest +from _pytest.fixtures import fixture + +from dedal.build_cache.BuildCacheManager import BuildCacheManager +from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum +from dedal.error_handling.exceptions import NoSpackEnvironmentException +from dedal.spack_factory.SpackOperation import SpackOperation + + +class TestSpackOperationAddMirrorWithComposite: + + @fixture + def mock_spack_operation(self, mocker): + mocker.resetall() + mocker.patch("dedal.spack_factory.SpackOperation.CommandRunner") + mocker.patch("dedal.spack_factory.SpackOperation.get_logger") + mock_object = SpackOperation() + mock_object.logger = mocker.MagicMock() + return mock_object + + @pytest.mark.parametrize( + "test_id, global_mirror, env_path, mirror_name, mirror_path, autopush, signed, expected_result, expected_env_arg", + [ + ("global_mirror", True, None, "global_mirror", "/path/to/global/mirror", False, False, True, ""), + ("local_mirror", False, "/path/to/env", "local_mirror", "/path/to/local/mirror", True, True, True, + "/path/to/env"), + ], + ) + def test_add_mirror_with_composite_success_path(self, mock_spack_operation, test_id, global_mirror, env_path, + mirror_name, mirror_path, autopush, signed, expected_result, + expected_env_arg): + # Arrange + mock_spack_operation.spack_setup_script = "setup.sh" + mock_spack_operation.env_path = env_path + mock_spack_operation.command_runner.run_preconfigured_command_sequence.return_value = {"success": True, + "error": None, + "output": None} + + # Act + result = mock_spack_operation.add_mirror(mirror_name, mirror_path, signed, autopush, + global_mirror) + + # Assert + assert result == expected_result + mock_spack_operation.command_runner.run_preconfigured_command_sequence.assert_called_once_with( + PreconfiguredCommandEnum.SPACK_MIRROR_ADD, + "setup.sh", + expected_env_arg, + mirror_name, + mirror_path, + autopush, + signed, + ) + mock_spack_operation.logger.info.assert_called_once_with("Added mirror %s", mirror_name) + + @pytest.mark.parametrize( + "test_id, mirror_name, mirror_path, expected_error", + [ + ("missing_mirror_name", "", "/path/to/mirror", ValueError), + ("missing_mirror_path", "mirror_name", "", ValueError), + ("missing_both", "", "", ValueError), + ], + ) + def test_add_mirror_with_composite_missing_args(self, mock_spack_operation, test_id, mirror_name, mirror_path, + expected_error): + # Arrange + mock_spack_operation.spack_setup_script = "setup.sh" + + # Act + with pytest.raises(expected_error) as except_info: + mock_spack_operation.add_mirror(mirror_name, mirror_path) + + # Assert + assert str(except_info.value) == "mirror_name and mirror_path are required" + + def test_add_mirror_with_composite_no_env(self, mock_spack_operation): + # Arrange + mock_spack_operation.spack_setup_script = "setup.sh" + mock_spack_operation.env_path = None + + # Act + with pytest.raises(NoSpackEnvironmentException) as except_info: + mock_spack_operation.add_mirror("mirror_name", "/path/to/mirror") + + # Assert + assert str(except_info.value) == "No spack environment defined" + + def test_add_mirror_with_composite_command_failure(self, mock_spack_operation): + # Arrange + mock_spack_operation.spack_setup_script = "setup.sh" + mock_spack_operation.env_path = "/path/to/env" + mock_spack_operation.command_runner.run_preconfigured_command_sequence.return_value = {"success": False, + "error": "Error: Command failed with exit code 1, Error Output: ==> Error: Mirror with name mirror_name already exists.", + "output": None} + + # Act + result = mock_spack_operation.add_mirror("mirror_name", "/path/to/mirror") + + # Assert + assert result is False + mock_spack_operation.command_runner.run_preconfigured_command_sequence.assert_called_once_with( + PreconfiguredCommandEnum.SPACK_MIRROR_ADD, + "setup.sh", + "/path/to/env", + "mirror_name", + "/path/to/mirror", + False, + False + ) + mock_spack_operation.logger.error.assert_called_once_with('Failed to add mirror %s, reason: %s, output: %s', + 'mirror_name', + 'Error: Command failed with exit code 1, Error Output: ==> Error: Mirror with ' + 'name mirror_name already exists.', + None) + + @pytest.mark.parametrize("result, expected_log_message, expected_return_value", [ + ({"success": True, "output": "test output"}, "test info message", True), + ({"success": True, "error": "test error", "output": "test output"}, "test info message", True), + ]) + def test_handle_result_success(self, mock_spack_operation, result, expected_log_message, expected_return_value): + # Test ID: success + + # Arrange + error_msg = "test error message" + error_msg_args = ("arg1", "arg2") + info_msg = "test info message" + info_msg_args = ("arg3", "arg4") + + # Act + return_value = mock_spack_operation.handle_result(result, error_msg, error_msg_args, info_msg, info_msg_args) + + # Assert + assert return_value == expected_return_value + mock_spack_operation.logger.info.assert_called_once_with(info_msg, *info_msg_args) + mock_spack_operation.logger.error.assert_not_called() + + @pytest.mark.parametrize("result, expected_log_message", [ + ({"success": False, "error": "test error", "output": "test output"}, + "test error message arg1 arg2 test error test output"), + ({"success": False, "error": None, "output": None}, "test error message arg1 arg2 None None"), + ]) + def test_handle_result_failure(self, mock_spack_operation, result, expected_log_message): + # Test ID: failure + + # Arrange + error_msg = "test error message" + error_msg_args = ("arg1", "arg2") + info_msg = "test info message" + info_msg_args = ("arg3", "arg4") + + # Act + return_value = mock_spack_operation.handle_result(result, error_msg, error_msg_args, info_msg, info_msg_args) + + # Assert + assert return_value is False + mock_spack_operation.logger.error.assert_called_once_with(error_msg, *error_msg_args, result["error"], + result["output"]) + mock_spack_operation.logger.info.assert_not_called() + + @pytest.mark.parametrize("public_key_path, result_success, expected_log_message", [ + ("test_key.gpg", True, ('Added public key %s as trusted', 'test_key.gpg')), + ("test_key.gpg", False, + ('Failed to add public key %s as trusted, reason: %s, output: %s', + 'test_key.gpg', + 'test_error', + 'test_output')), + ]) + def test_trust_gpg_key(self, mock_spack_operation, public_key_path, result_success, expected_log_message): + # Test ID: trust_gpg_key + + # Arrange + mock_result = {"success": result_success, "error": "test_error", "output": "test_output"} + mock_spack_operation.command_runner.run_preconfigured_command_sequence.return_value = mock_result + + # Act + if result_success: + result = mock_spack_operation.trust_gpg_key(public_key_path) + else: + result = mock_spack_operation.trust_gpg_key(public_key_path) + + # Assert + if result_success: + assert result is True + mock_spack_operation.logger.info.assert_called_once_with(*expected_log_message) + else: + assert result is False + mock_spack_operation.logger.error.assert_called_once_with(*expected_log_message) + + mock_spack_operation.command_runner.run_preconfigured_command_sequence.assert_called_once_with( + PreconfiguredCommandEnum.SPACK_GPG_TRUST, + mock_spack_operation.spack_setup_script, + public_key_path + ) + + def test_trust_gpg_key_empty_path(self, mock_spack_operation): + # Test ID: empty_path + + # Act & Assert + with pytest.raises(ValueError) as except_info: + mock_spack_operation.trust_gpg_key("") + assert str(except_info.value) == "public_key_path is required" diff --git a/dedal/tests/unit_tests/test_spack_operation_use_cache.py b/dedal/tests/unit_tests/test_spack_operation_use_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..fe5d9da3d9831990266b4b380837b246724d2489 --- /dev/null +++ b/dedal/tests/unit_tests/test_spack_operation_use_cache.py @@ -0,0 +1,89 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_spack_operation_use_cache.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-20 +from pathlib import Path + +import pytest + +from dedal.error_handling.exceptions import NoSpackEnvironmentException +from dedal.spack_factory.SpackOperationUseCache import SpackOperationUseCache + + +@pytest.fixture +def spack_operation_use_cache_mock(mocker): + super_mock = mocker.patch("dedal.spack_factory.SpackOperationUseCache.super") + super_mock.return_value.setup_spack_env = mocker.MagicMock() + mocker.patch("dedal.spack_factory.SpackOperationUseCache.BuildCacheManager") + mock_spack_operation_use_cache = SpackOperationUseCache() + mock_spack_operation_use_cache.build_cache = mocker.MagicMock() + mock_spack_operation_use_cache.spack_config = mocker.MagicMock() + mock_spack_operation_use_cache.spack_config.buildcache_dir = Path("path/to/buildcache") + mock_spack_operation_use_cache.logger = mocker.MagicMock() + return mock_spack_operation_use_cache + + +class TestSpackOperationUseCache: + + @pytest.mark.parametrize("test_id, signed, key_path", [ + ("key_path_exists", True, "path/to/key.gpg"), + ("key_path_does_not_exist", False, None)]) + def test_setup_spack_env(self, mocker, spack_operation_use_cache_mock, test_id, signed, key_path): + # Test ID: setup_spack_env_success + super_mock = mocker.patch("dedal.spack_factory.SpackOperationUseCache.super") + spack_operation_use_cache_mock.trust_gpg_key = mocker.MagicMock() + spack_operation_use_cache_mock.add_mirror = mocker.MagicMock() + + # Arrange + spack_operation_use_cache_mock.build_cache.get_public_key_from_cache.return_value = key_path + spack_operation_use_cache_mock.trust_gpg_key.return_value = signed + spack_operation_use_cache_mock.add_mirror.return_value = None + + # Act + spack_operation_use_cache_mock.setup_spack_env() + + # Assert + spack_operation_use_cache_mock.build_cache.download.assert_called_once_with( + spack_operation_use_cache_mock.spack_config.buildcache_dir) + spack_operation_use_cache_mock.build_cache.get_public_key_from_cache.assert_called_once_with( + str(spack_operation_use_cache_mock.spack_config.buildcache_dir)) + + if key_path: + spack_operation_use_cache_mock.trust_gpg_key.assert_called_once_with(key_path) + else: + spack_operation_use_cache_mock.trust_gpg_key.assert_not_called() + + if not signed: + spack_operation_use_cache_mock.logger.warning.assert_called_once_with( + "Public key not found in cache or failed to trust pgp keys!") + + spack_operation_use_cache_mock.add_mirror.assert_called_once_with( + 'local_cache', + str(spack_operation_use_cache_mock.spack_config.buildcache_dir), + signed=signed, + autopush=True, + global_mirror=False + ) + super_mock.return_value.setup_spack_env.assert_called_once() # call original method + + @pytest.mark.parametrize("exception_type", [ValueError, NoSpackEnvironmentException]) + def test_setup_spack_env_exceptions(self, mocker, spack_operation_use_cache_mock, exception_type): + # Test ID: setup_spack_env_exceptions + spack_operation_use_cache_mock.trust_gpg_key = mocker.MagicMock() + spack_operation_use_cache_mock.add_mirror = mocker.MagicMock() + + # Arrange + spack_operation_use_cache_mock.build_cache.get_public_key_from_cache.return_value = "path/to/key.gpg" + spack_operation_use_cache_mock.trust_gpg_key.return_value = True + exception = exception_type("test exception") + spack_operation_use_cache_mock.add_mirror.side_effect = exception + + # Act & Assert + with pytest.raises(exception_type): + spack_operation_use_cache_mock.setup_spack_env() + + spack_operation_use_cache_mock.logger.error.assert_called_once_with("Error adding buildcache mirror: %s", + exception) diff --git a/dedal/utils/bootstrap.sh b/dedal/utils/bootstrap.sh index d103e44055db47f958114bc8092c7cee09f262c2..9cd2e1e11b4ab82fd301cc86179de7ba373f5d03 100644 --- a/dedal/utils/bootstrap.sh +++ b/dedal/utils/bootstrap.sh @@ -1,4 +1,4 @@ -# Minimal prerequisites for installing the dedal library +# Minimal prerequisites for installing the esd_library # pip must be installed on the OS echo "Bootstrapping..." set -euo pipefail diff --git a/pyproject.toml b/pyproject.toml index aad18fa55c29b67f30ba7464f2b180a8256d479d..c7ea27622c32e267ecf0559f1f457656a2f60216 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,19 +6,17 @@ build-backend = "setuptools.build_meta" name = "dedal" version = "0.1.0" authors = [ - {name = "Eric Müller", email = "mueller@kip.uni-heidelberg.de"}, - {name = "Adrian Ciu", email = "adrian.ciu@codemart.ro"}, + { name = "Eric Müller", email = "mueller@kip.uni-heidelberg.de" }, + { name = "Adrian Ciu", email = "adrian.ciu@codemart.ro" }, + { name = "Jithu Murugan", email = "j.murugan@fz-juelich.de" } ] -description = "This package provides all the necessary tools to create an Ebrains Software Distribution environment" +description = "This package includes all the essential tools required to set up an EBRAINS Software Distribution environment." readme = "README.md" requires-python = ">=3.10" dependencies = [ "oras", "spack", "ruamel.yaml", - "pytest", - "pytest-mock", - "pytest-ordering", "click", "jsonpickle", ] @@ -27,4 +25,8 @@ dependencies = [ dedal = "dedal.cli.spack_manager_api:cli" [tool.setuptools.data-files] -"dedal" = ["dedal/logger/logging.conf"] \ No newline at end of file +"dedal" = ["dedal/logger/logging.conf"] + +[project.optional-dependencies] +test = ["pytest", "pytest-mock", "pytest-ordering", "coverage"] +dev = ["mypy", "pylint", "black", "flake8"] \ No newline at end of file