diff --git a/nodescraper/base/inbandcollectortask.py b/nodescraper/base/inbandcollectortask.py index b3e30c98..fd079a27 100644 --- a/nodescraper/base/inbandcollectortask.py +++ b/nodescraper/base/inbandcollectortask.py @@ -57,6 +57,22 @@ def __init__( session_id: Optional[str] = None, **kwargs, ): + """Creates a InBandDataCollector Class + + Args: + system_info (SystemInfo): Information about the system being targeted for data collection. + connection (InBandConnection): The in-band connection used to communicate with the target system. + logger (Optional[logging.Logger], optional): Logger instance for logging messages. Defaults to None. + system_interaction_level (SystemInteractionLevel, optional): Level of interaction with the system. Defaults to SystemInteractionLevel.INTERACTIVE. + max_event_priority_level (Union[EventPriority, str], optional): Maximum priority level for events. Defaults to EventPriority.CRITICAL. + parent (Optional[str], optional): Parent task identifier. Defaults to None. + task_result_hooks (Optional[list[TaskResultHook]], optional): List of hooks to be called with task results. Defaults to None. + event_reporter (str, optional): Event reporter identifier. Defaults to DEFAULT_EVENT_REPORTER. + session_id (Optional[str], optional): Session identifier for the data collection task. Defaults to None. + + Raises: + SystemCompatibilityError: Raised if the system's OS family is not supported. + """ super().__init__( system_info=system_info, system_interaction_level=system_interaction_level, diff --git a/nodescraper/base/redfishcollectortask.py b/nodescraper/base/redfishcollectortask.py index bdeb65cd..48c11ae2 100644 --- a/nodescraper/base/redfishcollectortask.py +++ b/nodescraper/base/redfishcollectortask.py @@ -52,6 +52,18 @@ def __init__( session_id: Optional[str] = None, **kwargs, ): + """Creates a RedfishDataCollector instance. + + Args: + system_info (SystemInfo): system info object for target system for data collection + connection (TConnection): connection object for the data collector + logger (Optional[logging.Logger], optional): python logger object. Defaults to None. + max_event_priority_level (Union[EventPriority, str], optional): priority limit for events. Defaults to EventPriority.CRITICAL. + parent (Optional[str], optional): parent task identifier. Defaults to None. + task_result_hooks (Optional[list[TaskResultHook]], optional): list of task result hooks. Defaults to None. + event_reporter (str, optional): Reporter string stored on emitted events. Defaults to DEFAULT_EVENT_REPORTER. + session_id (Optional[str], optional): session identifier. Defaults to None. + """ super().__init__( system_info=system_info, connection=connection, diff --git a/nodescraper/connection/inband/inbandmanager.py b/nodescraper/connection/inband/inbandmanager.py index 5547e270..bec1931d 100644 --- a/nodescraper/connection/inband/inbandmanager.py +++ b/nodescraper/connection/inband/inbandmanager.py @@ -56,6 +56,17 @@ def __init__( connection_args: Optional[SSHConnectionParams | dict[str, Any]] = None, **kwargs, ): + """Initialize a new InBandConnectionManager instance. + + Args: + system_info (SystemInfo): System info for the targeted system + logger (Optional[Logger], optional): Logger for the connection manager. Defaults to None. + max_event_priority_level (Union[EventPriority, str], optional): Maximum event priority level for logging. Defaults to EventPriority.CRITICAL. + parent (Optional[str], optional): Parent task identifier. Defaults to None. + task_result_hooks (Optional[list[TaskResultHook]], optional): List of task result hooks. Defaults to None. + connection_args (Optional[SSHConnectionParams], optional): SSH connection parameters + These connections args will be used to establish the SSH connection to the targeted system. Defaults to None. + """ super().__init__( system_info, logger, diff --git a/nodescraper/connection/inband/inbandremote.py b/nodescraper/connection/inband/inbandremote.py index 6b8dbf56..a528e513 100644 --- a/nodescraper/connection/inband/inbandremote.py +++ b/nodescraper/connection/inband/inbandremote.py @@ -55,6 +55,11 @@ def __init__( self, ssh_params: SSHConnectionParams, ) -> None: + """Creates a RemoteShell Instance. + + Args: + ssh_params (SSHConnectionParams): The parameters used to initiate the SSH connection username/password/ect. + """ self.ssh_params = ssh_params self.client = paramiko.SSHClient() self.client.load_system_host_keys() diff --git a/nodescraper/connection/redfish/redfish_connection.py b/nodescraper/connection/redfish/redfish_connection.py index eabee716..d8cbcd2b 100644 --- a/nodescraper/connection/redfish/redfish_connection.py +++ b/nodescraper/connection/redfish/redfish_connection.py @@ -92,13 +92,24 @@ def __init__( verify_ssl: bool = True, api_root: Optional[str] = None, ): - self.base_url = base_url.rstrip("/") - self.api_root = (api_root or DEFAULT_REDFISH_API_ROOT).strip("/") - self.username = username - self.password = password or "" - self.timeout = timeout - self.use_session_auth = use_session_auth - self.verify_ssl = verify_ssl + """Creates a RedfishConnection instance. + + Args: + base_url (str): The base url for the redfish service typically this is /redfish/v1 + username (str): The username for the redfish service. + password (Optional[str], optional): The password for the redfish service. Defaults to None. + timeout (float, optional): The timeout for redfish requests in seconds. Defaults to 10.0. + use_session_auth (bool, optional): Whether to use session-based authentication. Defaults to True. + verify_ssl (bool, optional): Whether to verify the SSL certificate. Defaults to True. + api_root (Optional[str], optional): The API root for the redfish services. Defaults to None. + """ + self.base_url: str = base_url.rstrip("/") + self.api_root: str = (api_root or DEFAULT_REDFISH_API_ROOT).strip("/") + self.username: str = username + self.password: str = password or "" + self.timeout: float = timeout + self.use_session_auth: bool = use_session_auth + self.verify_ssl: bool = verify_ssl self._session: Optional[requests.Session] = None self._session_token: Optional[str] = None self._session_uri: Optional[str] = None # For logout DELETE @@ -133,7 +144,10 @@ def _transport_error_message(self, exc: Exception) -> str: def _execute_request(self, request_fn: Callable[[], _T]) -> _T: try: return request_fn() - except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc: + except ( + requests.exceptions.Timeout, + requests.exceptions.ConnectionError, + ) as exc: raise RedfishConnectionError(self._transport_error_message(exc)) from exc except socket.gaierror as exc: raise RedfishConnectionError(self._transport_error_message(exc)) from exc diff --git a/nodescraper/connection/redfish/redfish_manager.py b/nodescraper/connection/redfish/redfish_manager.py index bc5b37d2..cd2a9b00 100644 --- a/nodescraper/connection/redfish/redfish_manager.py +++ b/nodescraper/connection/redfish/redfish_manager.py @@ -58,6 +58,20 @@ def __init__( connection_args: Optional[RedfishConnectionParams] = None, **kwargs, ): + """Creates a RedfishConnectionManager Task instance. + + Args: + system_info (SystemInfo): System information for the connection manager task. + logger (Optional[logging.Logger], optional): _description_. Defaults to None. + max_event_priority_level (Union[EventPriority, str], optional): _description_. Defaults to EventPriority.CRITICAL. + parent (Optional[str], optional): _description_. Defaults to None. + task_result_hooks (Optional[list[TaskResultHook], None], optional): _description_. Defaults to None. + connection_args (Optional[Union[TConnectArg, dict]], optional): _description_. Defaults to None. + kwargs (dict, optional): Additional keyword arguments passed to the parent class. + + Raises: + ValueError: Will raise a ValueError when the connection_args cannot be mapped to the expected model. + """ super().__init__( system_info, logger, diff --git a/nodescraper/interfaces/connectionmanager.py b/nodescraper/interfaces/connectionmanager.py index 0369b928..7c649021 100644 --- a/nodescraper/interfaces/connectionmanager.py +++ b/nodescraper/interfaces/connectionmanager.py @@ -101,6 +101,23 @@ def __init__( session_id: Optional[str] = None, **kwargs, ): + """Creates a Connection Manager Task instance. + + Args: + system_info (SystemInfo): System information for the connection manager task. + logger (Optional[logging.Logger], optional): Logger instance for the connection manager task. Defaults to None. + max_event_priority_level (Union[EventPriority, str], optional): Maximum priority level for events. Defaults to EventPriority.CRITICAL. + parent (Optional[str], optional): Parent task identifier. Defaults to None. + task_result_hooks (Optional[list[TaskResultHook], None], optional): List of task result hooks to be executed. Defaults to None. + connection_args (Optional[Union[TConnectArg, dict]], optional): Connection arguments for the connection manager. + If a dictionary is provided, it will be transformed into a BaseModel instance of the expected type. Defaults to None. + When it is None then connection args will be None and it will not be built into the default connection model. + event_reporter (str, optional): Event reporter identifier. Defaults to DEFAULT_EVENT_REPORTER. + session_id (Optional[str], optional): Session identifier for the connection manager task. Defaults to None. + + Raises: + ValueError: Will raise a ValueError when the connection_args cannot be mapped to the expected model. + """ super().__init__( system_info=system_info, logger=logger, diff --git a/nodescraper/interfaces/datacollectortask.py b/nodescraper/interfaces/datacollectortask.py index 034303bd..acd9c227 100644 --- a/nodescraper/interfaces/datacollectortask.py +++ b/nodescraper/interfaces/datacollectortask.py @@ -49,7 +49,9 @@ from .taskresulthook import TaskResultHook -def _supported_sku_name_set(supported: Optional[set[Union[str, Enum]]]) -> Optional[set[str]]: +def _supported_sku_name_set( + supported: Optional[set[Union[str, Enum]]], +) -> Optional[set[str]]: """Map ``SUPPORTED_SKUS`` to string names for comparison with ``SystemInfo.sku``.""" if not supported: return None @@ -156,15 +158,22 @@ def __init__( task_result_hooks: Optional[list[TaskResultHook]] = None, event_reporter: str = DEFAULT_EVENT_REPORTER, session_id: Optional[str] = None, + log_path: Optional[str] = None, **kwargs, ): """data collector init function Args: system_info (SystemInfo): system info object for target system for data collection - system_interaction (SystemInteraction): enum to indicate the type of actions that can be performed when interacting with the system - event_reporter (str, optional): Reporter string stored on emitted events. Defaults to DEFAULT_EVENT_REPORTER. + connection (TConnection): connection object for the data collector logger (Optional[logging.Logger], optional): python logger object. Defaults to None. + system_interaction_level (SystemInteractionLevel | str): The interaction level which the collector will use + determine which commands it can run and how invasive the interactions can be when running those commands.Defaults to SystemInteractionLevel.INTERACTIVE. + max_event_priority_level (Union[EventPriority, str], optional): priority limit for events. Defaults to EventPriority.CRITICAL. + parent (Optional[str], optional): parent task identifier. Defaults to None. + task_result_hooks (Optional[list[TaskResultHook]], optional): list of task result hooks. Defaults to None. + event_reporter (str, optional): Reporter string stored on emitted events. Defaults to DEFAULT_EVENT_REPORTER. + session_id (Optional[str], optional): session identifier. Defaults to None. log_path (Optional[str], optional): file system log path. Defaults to None. """ super().__init__( @@ -176,13 +185,13 @@ def __init__( event_reporter=event_reporter, session_id=session_id, ) - if isinstance(system_interaction_level, str): system_interaction_level = getattr(SystemInteractionLevel, system_interaction_level) - self.system_interaction_level = system_interaction_level - self.connection = connection - self._html_view = False + self.log_path: str | None = log_path + self.system_interaction_level: SystemInteractionLevel = system_interaction_level + self.connection: TConnection = connection + self._html_view: bool = False allowed_skus = _supported_sku_name_set(self.SUPPORTED_SKUS) if ( @@ -230,6 +239,10 @@ def collect_data( ) -> tuple[TaskResult, Optional[TDataModel]]: """Collect data from a target system + Args: + args (Optional[TCollectArg], optional): collection arguments for this data collection run. Defaults to None. + Returns: - tuple[TaskResult, DataModel]: tuple containing result and data model + tuple[TaskResult, Optional[TDataModel]]: tuple containing result and data model. No DataModel for collection failure cases therefore + it is optional. """ diff --git a/nodescraper/interfaces/dataplugin.py b/nodescraper/interfaces/dataplugin.py index 1b18df28..07ff437f 100644 --- a/nodescraper/interfaces/dataplugin.py +++ b/nodescraper/interfaces/dataplugin.py @@ -46,7 +46,7 @@ SystemInfo, TaskResult, ) -from nodescraper.utils import pascal_to_snake +from nodescraper.utils import pascal_to_snake, resolve_log_dir_name from .connectionmanager import TConnectArg, TConnectionManager from .task import SystemCompatibilityError @@ -94,6 +94,19 @@ def __init__( session_id: Optional[str] = None, **kwargs, ): + """_summary_ + + Args: + system_info (Optional[SystemInfo], optional): system info object. Defaults to None. + logger (Optional[logging.Logger], optional): python logger instance. Defaults to None. + connection_manager (Optional[TConnectionManager], optional): connection manager instance. Defaults to None. + connection_args (Optional[Union[TConnectArg , dict]], optional): connection args. Defaults to None. + task_result_hooks (Optional[list[TaskResultHook]], optional): list of task result hooks. Defaults to None. + log_path (Optional[str], optional): path for file system logs. Defaults to None. + event_reporter (str, optional): Reporter string stored on emitted events. Defaults to DEFAULT_EVENT_REPORTER. + session_id (Optional[str], optional): session identifier. Defaults to None. + kwargs (optional): additional keyword arguments. These given to the baseclass as well. + """ super().__init__( system_info, logger, @@ -357,7 +370,16 @@ def collect( for collector_cls in collector_classes: collector_args = self._resolve_collector_args(collector_cls, collection_args) - collection_task = collector_cls( + collector_log_path = None + if self.log_path: + _log_path = ( + Path(self.log_path) + / resolve_log_dir_name(self.__class__.__name__) + / resolve_log_dir_name(collector_cls.__name__) + ) + _log_path.mkdir(parents=True, exist_ok=True) + collector_log_path = str(_log_path) + collection_task: DataCollector[Any, Any, Any] = collector_cls( system_info=self.system_info.model_copy(), logger=self.logger, system_interaction_level=system_interaction_level, @@ -365,7 +387,7 @@ def collect( max_event_priority_level=max_event_priority_level, parent=self.__class__.__name__, task_result_hooks=self.task_result_hooks, - log_path=self.log_path, + log_path=collector_log_path, event_reporter=self.event_reporter, session_id=self.session_id, ) diff --git a/nodescraper/interfaces/plugin.py b/nodescraper/interfaces/plugin.py index 5e2b33a4..5533ddc5 100644 --- a/nodescraper/interfaces/plugin.py +++ b/nodescraper/interfaces/plugin.py @@ -64,6 +64,9 @@ def __init__( task_result_hooks (Optional[list[TaskResultHook]], optional): list of task result hooks. Defaults to None. log_path (Optional[str], optional): path for file system logs. Defaults to None. queue_callback (Optional[Callable], optional): function to add additional plugins to plugin executor queue. Defaults to None. + event_reporter (str, optional): Reporter string stored on emitted events. Defaults to DEFAULT_EVENT_REPORTER. + session_id (Optional[str], optional): session identifier. Defaults to None. + kwargs (optional): additional keyword arguments. These are currently not used by the base plugin interface. """ if logger is None: logger = logging.getLogger(DEFAULT_LOGGER) diff --git a/nodescraper/interfaces/resultcollator.py b/nodescraper/interfaces/resultcollator.py index b61fe313..77cb1e45 100644 --- a/nodescraper/interfaces/resultcollator.py +++ b/nodescraper/interfaces/resultcollator.py @@ -25,7 +25,8 @@ ############################################################################### import abc import logging -from typing import Optional +from logging import Logger +from typing import Optional, Union from nodescraper.constants import DEFAULT_LOGGER from nodescraper.models import PluginResult, TaskResult @@ -39,14 +40,23 @@ def __init__( logger: Optional[logging.Logger] = None, log_path: Optional[str] = None, ): + """Creates a PluginResultCollator instance. + + Args: + logger (Optional[logging.Logger], optional): logger instance for the collator. Defaults to None. + log_path (Optional[str], optional): path to the log file. Defaults to None. + """ if logger is None: logger = logging.getLogger(DEFAULT_LOGGER) - self.logger = logger - self.log_path = log_path + self.logger: Logger = logger + self.log_path: Union[str, None] = log_path @abc.abstractmethod def collate_results( - self, plugin_results: list[PluginResult], connection_results: list[TaskResult], **kwargs + self, + plugin_results: list[PluginResult], + connection_results: list[TaskResult], + **kwargs, ): """Function to process the result of a plugin executor run diff --git a/nodescraper/interfaces/task.py b/nodescraper/interfaces/task.py index aa19d56f..6503bde3 100644 --- a/nodescraper/interfaces/task.py +++ b/nodescraper/interfaces/task.py @@ -59,6 +59,20 @@ def __init__( session_id: Optional[str] = None, **kwargs: dict[str, Any], ): + """Creates a Task instance. + + Args: + system_info (SystemInfo): system info object for target system for data collection + logger (Optional[logging.Logger], optional): python logger object. Defaults to None. + max_event_priority_level (Union[EventPriority, str], optional): priority limit for events. Defaults to EventPriority.CRITICAL. + parent (Optional[str], optional): parent task identifier. Defaults to None. + task_result_hooks (Optional[list[TaskResultHook]], optional): list of task result hooks. Defaults to None. + event_reporter (str, optional): Reporter string stored on emitted events. Defaults to DEFAULT_EVENT_REPORTER. + session_id (Optional[str], optional): session identifier. Defaults to None. + + Raises: + ValueError: Will raise a ValueError when the session_id is not a Valid UUID string. + """ if logger is None: logger = logging.getLogger(DEFAULT_LOGGER) self.system_info = system_info @@ -106,6 +120,7 @@ def max_event_priority_level(self, input_value: Union[str, EventPriority]): self._max_event_priority_level = value def __init_subclass__(cls, **kwargs) -> None: + """Validates that the subclass contains a TASK_TYPE attribute which is not None.""" super().__init_subclass__(**kwargs) if cls.TASK_TYPE is None: raise TypeError(f"No value provided for TASK_TYPE in task class {cls.__name__}") @@ -118,13 +133,27 @@ def _build_event( data: Optional[dict] = None, timestamp: Optional[datetime.datetime] = None, ) -> Event: + """This will build an event + + Args: + category (Union[EventCategory, str]): The category of the event. + description (str): The description of the event, typically a human-readable message. + priority (EventPriority): The priority level of the event. + data (Optional[dict], optional): Additional data associated with the event. Defaults to None. + timestamp (Optional[datetime.datetime], optional): The timestamp of the event. Defaults to None. + Returns: + Event: The constructed event object. + """ if data is None: - data = {"task_name": self.__class__.__name__, "task_type": self.TASK_TYPE} + data: dict[Any, Any] = { + "task_name": self.__class__.__name__, + "task_type": self.TASK_TYPE, + } else: # Copy to avoid mutating the caller's dict - data = copy.copy(data) + data: dict[Any, Any] = copy.copy(data) data["task_name"] = self.__class__.__name__ data["task_type"] = self.TASK_TYPE @@ -162,6 +191,16 @@ def _log_event( timestamp: Optional[datetime.datetime] = None, console_log: bool = False, ): + """Log an Event + + Args: + category (Union[EventCategory, str]): The category of the event. + description (str): The description of the event, typically a human-readable message. + priority (EventPriority): The priority level of the event. + data (Optional[dict], optional): Additional data associated with the event. Defaults to None. + timestamp (Optional[datetime.datetime], optional): The timestamp of the event. Defaults to None. + console_log (bool, optional): Whether to also log the event to the console. Defaults to False. + """ event = self._build_event( category=category, description=description, diff --git a/nodescraper/pluginexecutor.py b/nodescraper/pluginexecutor.py index 921f0fbe..4571ab03 100644 --- a/nodescraper/pluginexecutor.py +++ b/nodescraper/pluginexecutor.py @@ -67,7 +67,35 @@ def __init__( session_id: Optional[str] = None, plugin_run_result_hooks: Optional[Sequence[Callable[[PluginResult], None]]] = None, ): + """Initialize the PluginExecutor instance. + Args: + plugin_configs (list[PluginConfig]): This is a list of the PluginConfig Object, when this list is greater than a single PluginConfig + then the plugin_configs will be merged into a single PluginConfig. The single PluginConfig post merge will be used to control + all of the execution that is done by this executor. It will run all `plugins` defined in the merged PluginConfig. + connections (Optional[dict[str, Union[dict, BaseModel]]], optional): Connections is a dictionary where the + keys represent connection names and the values are either dictionaries or BaseModel instances containing the connection details + Optionally the user can provide just the dict[str, dict[Any,Any]] In this case the dictionary attributed to that connection name + will be transformed into a BaseModel instance and it will raise an error when this model fails to validate. + Any key in this dict will be promptly built using the args during `__init__` even if the connection is not used by any of the tasks + defined in the plugin_configs. If the connection isn't defined for a particular Plugin but that plugin requires the connection then + that connection will be built anyway but the arguments will not be provided which may lead to connection to not being properly established. + It is recommended that in-band connection arg should always be provided for remote connections. Defaults to None. + system_info (Optional[SystemInfo], optional): System information for the plugin executor, this is passed to the connection, and plugins, The executor will + only give out copies to other components. If the original system_info passed here is OSFamily.UNKOWN then the executor will attempt to detect the correct OS family + by starting a in-band connection and then determining the correct OS family. Defaults to None. + logger (Optional[logging.Logger], optional): Logger instance for the plugin executor. Defaults to None. + plugin_registry (Optional[PluginRegistry], optional): Plugin registry instance for the plugin executor, when this is None then the PluginRegistry will be + assigned a default `PluginRegistry()`. Defaults to None. + log_path (Optional[str], optional): Path to the log file for the plugin executor. When this is defined then the FileSystemLogHook will be automatically + added to the connection results hooks resulting in it logging to the defined folder. Defaults to None. + session_id (Optional[str], optional): Session identifier for the plugin executor. Defaults to None. + plugin_run_result_hooks (Optional[Sequence[Callable[[PluginResult], None]]], optional): Sequence of callables to be executed with the result of each plugin run. When this is + None then then this will be made empty list [] . Defaults to None. + + Raises: + ValueError: If the provided session_id is not a valid UUID string. + """ if logger is None: logger = logging.getLogger(DEFAULT_LOGGER) self.logger = logger @@ -167,6 +195,14 @@ def _deep_merge_plugin_args(existing: dict, incoming: dict) -> dict: @staticmethod def merge_configs(plugin_configs: list[PluginConfig]) -> PluginConfig: + """Merge multiple PluginConfig instances into a single PluginConfig. + + Args: + plugin_configs (list[PluginConfig]): A list of PluginConfig instances to merge. + + Returns: + PluginConfig: A single PluginConfig instance containing the merged configurations. + """ merged_config = PluginConfig() for config in plugin_configs: merged_config.global_args.update(config.global_args) diff --git a/nodescraper/taskresulthooks/filesystemloghook.py b/nodescraper/taskresulthooks/filesystemloghook.py index 50184b4e..bf407c26 100644 --- a/nodescraper/taskresulthooks/filesystemloghook.py +++ b/nodescraper/taskresulthooks/filesystemloghook.py @@ -32,8 +32,13 @@ class FileSystemLogHook(TaskResultHook): - def __init__(self, log_base_path=None, **kwargs) -> None: + """Create a FileSystemLogHook Instance + + Args: + log_base_path (Optional[str], optional): The base path where logs will be stored. Defaults to the current working directory. + **kwargs: Additional keyword arguments, which are not used. + """ if log_base_path is None: log_base_path = os.getcwd() diff --git a/test/unit/framework/test_dataplugin.py b/test/unit/framework/test_dataplugin.py index 67b92fb9..e0af541f 100644 --- a/test/unit/framework/test_dataplugin.py +++ b/test/unit/framework/test_dataplugin.py @@ -682,3 +682,85 @@ def test_find_datamodel_path_in_run_checks_all_collectors(self, tmp_path: Path) found = MultiCollectorPlugin.find_datamodel_path_in_run(str(tmp_path)) assert found is not None assert found.endswith("multipartdatamodel.json") + + def test_log_path_creates_collector_subdirectories(self, plugin_with_conn, tmp_path): + """Test that log_path creates subdirectories for each collector.""" + log_path = tmp_path / "test_logs" + + # Create plugin with log_path + multi_plugin = MultiCollectorPlugin( + system_info=plugin_with_conn.system_info, + logger=plugin_with_conn.logger, + connection_manager=plugin_with_conn.connection_manager, + log_path=str(log_path), + ) + + with ( + patch.object(AlphaCollector, "__init__", return_value=None) as alpha_init, + patch.object(BetaCollector, "__init__", return_value=None) as beta_init, + patch.object(AlphaCollector, "collect_data") as alpha_collect, + patch.object(BetaCollector, "collect_data") as beta_collect, + ): + alpha_collect.return_value = ( + TaskResult(status=ExecutionStatus.OK, task="AlphaCollector"), + MultiPartDataModel(alpha="alpha-value"), + ) + beta_collect.return_value = ( + TaskResult(status=ExecutionStatus.OK, task="BetaCollector"), + MultiPartDataModel(beta="beta-value"), + ) + + multi_plugin.collect() + + # Verify that AlphaCollector was initialized with correct log_path + alpha_call_kwargs = alpha_init.call_args[1] + assert "log_path" in alpha_call_kwargs + alpha_log_path = Path(alpha_call_kwargs["log_path"]) + assert alpha_log_path.parent.parent == log_path + assert alpha_log_path.parent.name == "multi_collector_plugin" + assert alpha_log_path.name == "alpha_collector" + + # Verify that BetaCollector was initialized with correct log_path + beta_call_kwargs = beta_init.call_args[1] + assert "log_path" in beta_call_kwargs + beta_log_path = Path(beta_call_kwargs["log_path"]) + assert beta_log_path.parent.parent == log_path + assert beta_log_path.parent.name == "multi_collector_plugin" + assert beta_log_path.name == "beta_collector" + + # Verify directories were created + assert alpha_log_path.exists() + assert beta_log_path.exists() + + def test_log_path_none_does_not_create_directories(self, plugin_with_conn): + """Test that when log_path is None, no directories are created.""" + multi_plugin = MultiCollectorPlugin( + system_info=plugin_with_conn.system_info, + logger=plugin_with_conn.logger, + connection_manager=plugin_with_conn.connection_manager, + log_path=None, + ) + + with ( + patch.object(AlphaCollector, "__init__", return_value=None) as alpha_init, + patch.object(BetaCollector, "__init__", return_value=None) as beta_init, + patch.object(AlphaCollector, "collect_data") as alpha_collect, + patch.object(BetaCollector, "collect_data") as beta_collect, + ): + alpha_collect.return_value = ( + TaskResult(status=ExecutionStatus.OK, task="AlphaCollector"), + MultiPartDataModel(alpha="alpha-value"), + ) + beta_collect.return_value = ( + TaskResult(status=ExecutionStatus.OK, task="BetaCollector"), + MultiPartDataModel(beta="beta-value"), + ) + + multi_plugin.collect() + + # Verify that collectors were initialized with log_path=None + alpha_call_kwargs = alpha_init.call_args[1] + assert alpha_call_kwargs["log_path"] is None + + beta_call_kwargs = beta_init.call_args[1] + assert beta_call_kwargs["log_path"] is None