From ae891b246f52a4c85c12c6576f25b256ba22b7e4 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 09:19:42 -0700 Subject: [PATCH 01/91] add files that we'll need for this refactor --- merlin/workers/__init__.py | 5 + merlin/workers/handlers/__init__.py | 10 ++ merlin/workers/handlers/base_handler.py | 13 +++ merlin/workers/handlers/celery_handler.py | 12 ++ merlin/workers/handlers/handler_factory.py | 115 +++++++++++++++++++ merlin/workers/watchdogs/__init__.py | 10 ++ merlin/workers/watchdogs/base_watchdog.py | 13 +++ merlin/workers/watchdogs/celery_watchdog.py | 12 ++ merlin/workers/watchdogs/watchodg_factory.py | 23 ++++ 9 files changed, 213 insertions(+) create mode 100644 merlin/workers/__init__.py create mode 100644 merlin/workers/handlers/__init__.py create mode 100644 merlin/workers/handlers/base_handler.py create mode 100644 merlin/workers/handlers/celery_handler.py create mode 100644 merlin/workers/handlers/handler_factory.py create mode 100644 merlin/workers/watchdogs/__init__.py create mode 100644 merlin/workers/watchdogs/base_watchdog.py create mode 100644 merlin/workers/watchdogs/celery_watchdog.py create mode 100644 merlin/workers/watchdogs/watchodg_factory.py diff --git a/merlin/workers/__init__.py b/merlin/workers/__init__.py new file mode 100644 index 00000000..3232b50b --- /dev/null +++ b/merlin/workers/__init__.py @@ -0,0 +1,5 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## diff --git a/merlin/workers/handlers/__init__.py b/merlin/workers/handlers/__init__.py new file mode 100644 index 00000000..059df0bc --- /dev/null +++ b/merlin/workers/handlers/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +from merlin.workers.handlers.base_handler import BaseWorkerHandler +from merlin.workers.handlers.celery_handler import CeleryWorkerHandler + +__all__ = ["BaseWorkerHandler", "CeleryWorkerHandler"] diff --git a/merlin/workers/handlers/base_handler.py b/merlin/workers/handlers/base_handler.py new file mode 100644 index 00000000..e0ef328b --- /dev/null +++ b/merlin/workers/handlers/base_handler.py @@ -0,0 +1,13 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +from abc import ABC, abstractmethod + + +class BaseWorkerHandler(ABC): + """ + + """ \ No newline at end of file diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py new file mode 100644 index 00000000..3ec0c646 --- /dev/null +++ b/merlin/workers/handlers/celery_handler.py @@ -0,0 +1,12 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +from merlin.workers.handlers.base_handler import BaseWorkerHandler + +class CeleryWorkerHandler(BaseWorkerHandler): + """ + + """ diff --git a/merlin/workers/handlers/handler_factory.py b/merlin/workers/handlers/handler_factory.py new file mode 100644 index 00000000..bcffe2c4 --- /dev/null +++ b/merlin/workers/handlers/handler_factory.py @@ -0,0 +1,115 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" + +""" + +import logging +from typing import Dict, List + +from merlin.workers.handlers import BaseWorkerHandler, CeleryWorkerHandler + + +LOG = logging.getLogger("merlin") + + +class WorkerHandlerFactory: + """ + + """ + + # def __init__(self): + # """ + + # """ + # # Map canonical handler names to their classes + # self._handlers: Dict[str, BaseWorkerHandler] = {} + + # # Map aliases to canonical handler names + # self._handler_aliases: Dict[str, str] = {} + + # # Register built-in handlers + # self._register_builtin_handlers() + + # def _register_builtin_handlers(self): + # """Register built-in worker handler implementations.""" + # try: + # self.register("celery", CeleryWorkerHandler) + # LOG.debug("Registered CeleryWorkerHandler") + # except ImportError as e: + # LOG.warning(f"Could not register CeleryWorkerHandler: {e}") + + # def list_available(self) -> List[str]: + # """ + # Get a list of the supported task servers in Merlin. + + # Returns: + # A list of names representing the supported task servers in Merlin. + # """ + # self._discover_plugins() + # return list(self._task_servers.keys()) + + # def register(self, name: str, handler_class: BaseWorkerHandler, aliases: List[str] = None): + # """ + # Register a new worker handler implementation. + + # Args: + # name: The canonical name for the handler. + # handler_class: The class implementing BaseWorkerHandler. + # aliases: Optional list of alternative names for this handler. + + # Raises: + # TypeError: If the handler_class does not implement BaseWorkerHandler. + # """ + # if not issubclass(handler_class, BaseWorkerHandler): + # raise TypeError(f"{handler_class} must implement BaseWorkerHandler") + + # self._handlers[name] = handler_class + # LOG.debug(f"Registered handler: {name}") + + # if aliases: + # for alias in aliases: + # self._handler_aliases[alias] = name + # LOG.debug(f"Registered alias '{alias}' for handler '{name}'") + + # def create(self, server_type: str, config: Dict = None) -> TaskServerInterface: + # """ + # Create and return a task server instance for the specified type. + # Args: + # server_type: The name of the task server to create. + # config: Optional configuration dictionary for task server initialization. + # Returns: + # An instantiation of a TaskServerInterface object. + # Raises: + # MerlinInvalidTaskServerError: If the requested task server is not supported. + # """ + # # Resolve alias to canonical task server name + # server_type = self._task_server_aliases.get(server_type, server_type) + + # # Discover plugins if server not found + # if server_type not in self._task_servers: + # self._discover_plugins() + + # # Get correct task server class + # task_server_class = self._task_servers.get(server_type) + + # if task_server_class is None: + # available = ", ".join(self.list_available()) + # raise MerlinInvalidTaskServerError( + # f"Task server '{server_type}' is not supported by Merlin. " + # f"Available task servers: {available}" + # ) + + # # Create instance + # try: + # instance = task_server_class() + # LOG.info(f"Created {server_type} task server") + # return instance + # except Exception as e: + # raise MerlinInvalidTaskServerError( + # f"Failed to create {server_type} task server: {e}" + # ) from e \ No newline at end of file diff --git a/merlin/workers/watchdogs/__init__.py b/merlin/workers/watchdogs/__init__.py new file mode 100644 index 00000000..aa2759b6 --- /dev/null +++ b/merlin/workers/watchdogs/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +from merlin.workers.watchdogs.base_watchdog import BaseWorkerWatchdog +from merlin.workers.watchdogs.celery_watchdog import CeleryWorkerWatchdog + +__all__ = ["BaseWorkerWatchdog", "CeleryWorkerWatchdog"] \ No newline at end of file diff --git a/merlin/workers/watchdogs/base_watchdog.py b/merlin/workers/watchdogs/base_watchdog.py new file mode 100644 index 00000000..c6ffed04 --- /dev/null +++ b/merlin/workers/watchdogs/base_watchdog.py @@ -0,0 +1,13 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +from abc import ABC, abstractmethod + + +class BaseWorkerWatchdog(ABC): + """ + + """ diff --git a/merlin/workers/watchdogs/celery_watchdog.py b/merlin/workers/watchdogs/celery_watchdog.py new file mode 100644 index 00000000..f298e40f --- /dev/null +++ b/merlin/workers/watchdogs/celery_watchdog.py @@ -0,0 +1,12 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +from merlin.workers.watchdogs.base_watchdog import BaseWorkerWatchdog + +class CeleryWorkerWatchdog(BaseWorkerWatchdog): + """ + + """ \ No newline at end of file diff --git a/merlin/workers/watchdogs/watchodg_factory.py b/merlin/workers/watchdogs/watchodg_factory.py new file mode 100644 index 00000000..f190e525 --- /dev/null +++ b/merlin/workers/watchdogs/watchodg_factory.py @@ -0,0 +1,23 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" + +""" + +import logging +from typing import Dict, List + +from merlin.workers.watchdogs import BaseWorkerWatchdog, CeleryWorkerWatchdog + + +LOG = logging.getLogger("merlin") + + +class WorkerWatchdogFactory: + """ + + """ \ No newline at end of file From 3254380a48942c5462d836c91328669c48bdefd7 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 10:51:10 -0700 Subject: [PATCH 02/91] add MerlinBaseFactory class --- merlin/abstracts/__init__.py | 13 ++ merlin/abstracts/factory.py | 225 +++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 merlin/abstracts/__init__.py create mode 100644 merlin/abstracts/factory.py diff --git a/merlin/abstracts/__init__.py b/merlin/abstracts/__init__.py new file mode 100644 index 00000000..5ca894c0 --- /dev/null +++ b/merlin/abstracts/__init__.py @@ -0,0 +1,13 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +The `abstracts` package provides ABC classes that can be used throughout +Merlin's codebase. + +Modules: + factory: Contains `MerlinBaseFactory`, used to manage pluggable components in Merlin. +""" diff --git a/merlin/abstracts/factory.py b/merlin/abstracts/factory.py new file mode 100644 index 00000000..cc18e1e4 --- /dev/null +++ b/merlin/abstracts/factory.py @@ -0,0 +1,225 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Base factory class for managing pluggable components in Merlin. + +This module defines an abstract `MerlinBaseFactory` class that provides a reusable +infrastructure for registering, discovering, and instantiating pluggable components. +It supports alias resolution, entry-point-based plugin discovery, and runtime +introspection of registered components. + +Subclasses must define how to register built-in components, validate component classes, +and identify the appropriate entry point group for plugin discovery. +""" + +import logging +import pkg_resources +from abc import ABC, abstractmethod +from typing import Any, Dict, List + + +LOG = logging.getLogger("merlin") + + +class MerlinBaseFactory(ABC): + """ + Abstract base factory for managing and instantiating pluggable components. + + This class provides the infrastructure for: + - Registering components and their aliases + - Discovering plugins via Python entry points + - Creating instances of registered components + - Listing and introspecting available components + + Subclasses are required to: + - Implement `_register_builtins()` to register default implementations + - Implement `_validate_component()` to enforce interface/type constraints + - Define `_entry_point_group()` to identify the entry point namespace for discovery + + Attributes: + _registry (Dict[str, Any]): Maps canonical component names to their classes. + _aliases (Dict[str, str]): Maps alias names to canonical component names. + + Methods: + register: Register a new component and its optional aliases. + list_available: Return a list of all registered component names. + create: Instantiate a registered component by name or alias. + get_component_info: Return introspection metadata for a registered component. + _discover_plugins: Discover and register plugin components using entry points. + _register_builtins: Abstract method for registering built-in/default components. + _validate_component: Abstract method for enforcing type/interface constraints. + _entry_point_group: Abstract method for returning the entry point namespace. + """ + + def __init__(self): + """ + Initialize the base factory. + + This base class provides common functionality for managing + a registry of available implementations and any aliases for them. + Subclasses can extend this to register built-in or default items. + """ + # Map canonical names to implementation classes or instances + self._registry: Dict[str, Any] = {} + + # Map aliases to canonical names (e.g., legacy names or shorthand) + self._aliases: Dict[str, str] = {} + + # Register built-in implementations, if any + self._register_builtins() + + @abstractmethod + def _register_builtins(self) -> None: + """ + Register built-in components. + + Subclasses must implement this to register relevant components. + """ + raise NotImplementedError("Subclasses of `MerlinBaseFactory` must implement a `_register_builtins` method.") + + @abstractmethod + def _validate_component(self, component_class: Any) -> None: + """ + Validate the component class before registration. + + Subclasses must implement this to enforce type or interface constraints. + + Raises: + TypeError: If `component_class` is not valid. + """ + raise NotImplementedError("Subclasses of `MerlinBaseFactory` must implement a `_validate_component` method.") + + @abstractmethod + def _entry_point_group(self) -> str: + """ + Return the entry point group used for plugin discovery. + + Subclasses must override this. + """ + raise NotImplementedError("Subclasses must define an entry point group.") + + def _discover_plugins(self): + """ + Discover and register plugin components via entry points. + + Subclasses can override this to support more discovery mechanisms. + """ + try: + for entry_point in pkg_resources.iter_entry_points(self._entry_point_group()): + try: + plugin_class = entry_point.load() + self.register(entry_point.name, plugin_class) + LOG.info(f"Loaded plugin: {entry_point.name}") + except Exception as e: + LOG.warning(f"Failed to load plugin '{entry_point.name}': {e}") + except ImportError: + LOG.debug("pkg_resources not available for plugin discovery") + + def register(self, name: str, component_class: Any, aliases: List[str] = None) -> None: + """ + Register a new component implementation. + + Args: + name: Canonical name for the component. + component_class: The class or implementation to register. + aliases: Optional alternative names for this component. + + Raises: + TypeError: If the component_class fails validation. + """ + self._validate_component(component_class) + + self._registry[name] = component_class + LOG.debug(f"Registered component: {name}") + + if aliases: + for alias in aliases: + self._aliases[alias] = name + LOG.debug(f"Registered alias '{alias}' for component '{name}'") + + def list_available(self) -> List[str]: + """ + Return a list of supported component names. + + This includes both built-in and dynamically discovered components. + + Returns: + A list of canonical names for all available components. + """ + self._discover_plugins() + return list(self._registry.keys()) + + def create(self, component_type: str, config: Dict = None) -> Any: + """ + Instantiate and return a component of the specified type. + + Args: + component_type: The name or alias of the component to create. + config: Optional configuration for initializing the component. + + Returns: + An instance of the requested component. + + Raises: + ValueError: If the component is not registered or instantiation fails. + """ + # Resolve alias + canonical_name = self._aliases.get(component_type, component_type) + + # Discover plugins if needed + if canonical_name not in self._registry: + self._discover_plugins() + + component_class = self._registry.get(canonical_name) + if component_class is None: + available = ", ".join(self.list_available()) + raise ValueError( + f"Component '{component_type}' is not supported. " + f"Available components: {available}" + ) + + try: + instance = component_class() if config is None else component_class(**config) + LOG.info(f"Created component '{canonical_name}'") + return instance + except Exception as e: + raise ValueError( + f"Failed to create component '{canonical_name}': {e}" + ) from e + + def get_component_info(self, component_type: str) -> Dict: + """ + Get introspection information about a registered component. + + Args: + component_type: The name or alias of the component. + + Returns: + Dictionary containing metadata such as name, class, module, and docstring. + + Raises: + ValueError: If the component is not registered. + """ + canonical_name = self._aliases.get(component_type, component_type) + + if canonical_name not in self._registry: + self._discover_plugins() + + component_class = self._registry.get(canonical_name) + if component_class is None: + available = ", ".join(self.list_available()) + raise ValueError( + f"Component '{component_type}' is not supported. " + f"Available components: {available}" + ) + + return { + "name": canonical_name, + "class": component_class.__name__, + "module": component_class.__module__, + "description": component_class.__doc__ or "No description available", + } From 1e48dea732d1526fea6c54ce1ed41575caf1f7b3 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 14:29:57 -0700 Subject: [PATCH 03/91] refactor MerlinBackendFactory to use MerlinBaseFactory --- merlin/abstracts/__init__.py | 4 ++ merlin/abstracts/factory.py | 53 +++++++++++++--- merlin/backends/backend_factory.py | 79 ++++++++++++------------ merlin/backends/redis/redis_backend.py | 9 ++- merlin/backends/sqlite/sqlite_backend.py | 7 +-- merlin/db_scripts/merlin_db.py | 2 +- 6 files changed, 95 insertions(+), 59 deletions(-) diff --git a/merlin/abstracts/__init__.py b/merlin/abstracts/__init__.py index 5ca894c0..aae1b246 100644 --- a/merlin/abstracts/__init__.py +++ b/merlin/abstracts/__init__.py @@ -11,3 +11,7 @@ Modules: factory: Contains `MerlinBaseFactory`, used to manage pluggable components in Merlin. """ + +from merlin.abstracts.factory import MerlinBaseFactory + +__all__ = ["MerlinBaseFactory"] diff --git a/merlin/abstracts/factory.py b/merlin/abstracts/factory.py index cc18e1e4..88f3c1e4 100644 --- a/merlin/abstracts/factory.py +++ b/merlin/abstracts/factory.py @@ -73,7 +73,7 @@ def __init__(self): self._register_builtins() @abstractmethod - def _register_builtins(self) -> None: + def _register_builtins(self): """ Register built-in components. @@ -82,12 +82,15 @@ def _register_builtins(self) -> None: raise NotImplementedError("Subclasses of `MerlinBaseFactory` must implement a `_register_builtins` method.") @abstractmethod - def _validate_component(self, component_class: Any) -> None: + def _validate_component(self, component_class: Any): """ Validate the component class before registration. Subclasses must implement this to enforce type or interface constraints. + Args: + component_class: The class to validate. + Raises: TypeError: If `component_class` is not valid. """ @@ -99,26 +102,57 @@ def _entry_point_group(self) -> str: Return the entry point group used for plugin discovery. Subclasses must override this. + + Returns: + The entry point group used for plugin discovery. """ raise NotImplementedError("Subclasses must define an entry point group.") - - def _discover_plugins(self): + + def _discover_plugins_via_entry_points(self): """ - Discover and register plugin components via entry points. - - Subclasses can override this to support more discovery mechanisms. + Discover and register plugins via Python entry points. """ try: for entry_point in pkg_resources.iter_entry_points(self._entry_point_group()): try: plugin_class = entry_point.load() self.register(entry_point.name, plugin_class) - LOG.info(f"Loaded plugin: {entry_point.name}") + LOG.info(f"Loaded plugin via entry point: {entry_point.name}") except Exception as e: LOG.warning(f"Failed to load plugin '{entry_point.name}': {e}") except ImportError: LOG.debug("pkg_resources not available for plugin discovery") + def _discover_builtin_modules(self): + """ + Optional hook to discover built-in components by scanning local modules. + + Default implementation does nothing. + + Subclasses can override this method to implement package/module scanning. + """ + pass + + def _discover_plugins(self): + """ + Discover and register plugin components via entry points. + + Subclasses can override this to support more discovery mechanisms. + """ + self._discover_plugins_via_entry_points() + self._discover_builtin_modules() + + def _get_component_error_class(self) -> type[Exception]: + """ + Return the exception type to raise when an invalid component is requested. + + Subclasses should override this to raise more specific exceptions. + + Returns: + A subclass of Exception (e.g., ValueError by default). + """ + return ValueError + def register(self, name: str, component_class: Any, aliases: List[str] = None) -> None: """ Register a new component implementation. @@ -177,7 +211,8 @@ def create(self, component_type: str, config: Dict = None) -> Any: component_class = self._registry.get(canonical_name) if component_class is None: available = ", ".join(self.list_available()) - raise ValueError( + error_cls = self._get_component_error_class() + raise error_cls( f"Component '{component_type}' is not supported. " f"Available components: {available}" ) diff --git a/merlin/backends/backend_factory.py b/merlin/backends/backend_factory.py index b3284147..12db995a 100644 --- a/merlin/backends/backend_factory.py +++ b/merlin/backends/backend_factory.py @@ -16,75 +16,76 @@ if an unsupported backend is requested. """ -from typing import Dict, List +from typing import Any +from merlin.abstracts import MerlinBaseFactory from merlin.backends.redis.redis_backend import RedisBackend from merlin.backends.results_backend import ResultsBackend from merlin.backends.sqlite.sqlite_backend import SQLiteBackend from merlin.exceptions import BackendNotSupportedError -# TODO add register_backend call to this when we create task server interface? # TODO could this factory replace the functions in config/results_backend.py? # - Perhaps it should be a class outside of this? -class MerlinBackendFactory: +class MerlinBackendFactory(MerlinBaseFactory): """ Factory class for managing and instantiating supported Merlin backends. - This class maintains a registry of available results backends (e.g., Redis, SQLite) - and provides a unified interface for retrieving a backend implementation by name. + This subclass of `MerlinBaseFactory` handles registration, validation, + and instantiation of results backends (e.g., Redis, SQLite). Attributes: - _backends (Dict[str, backends.results_backend.ResultsBackend]): Mapping of backend names to their classes. - _backend_aliases (Dict[str, str]): Optional aliases for resolving canonical backend names. + _registry (Dict[str, ResultsBackend]): Maps canonical backend names to backend classes. + _aliases (Dict[str, str]): Maps legacy or alternate names to canonical backend names. Methods: - get_supported_backends: Returns a list of supported backend names. - get_backend: Instantiates and returns the backend associated with the given name. + register: Register a new backend class and optional aliases. + list_available: Return a list of supported backend names. + create: Instantiate a backend class by name or alias. + get_component_info: Return metadata about a registered backend. """ - def __init__(self): - """Initialize the Merlin backend factory.""" - # Map canonical backend names to their classes - self._backends: Dict[str, ResultsBackend] = { - "redis": RedisBackend, - "sqlite": SQLiteBackend, - } - # Map aliases to canonical backend names - self._backend_aliases: Dict[str, str] = {"rediss": "redis"} - - def get_supported_backends(self) -> List[str]: + def _register_builtins(self): """ - Get a list of the supported backends in Merlin. - - Returns: - A list of names representing the supported backends in Merlin. + Register built-in backend implementations. """ - return list(self._backends.keys()) + self.register("redis", RedisBackend, aliases=["rediss"]) + self.register("sqlite", SQLiteBackend) - def get_backend(self, backend: str) -> ResultsBackend: + def _validate_component(self, component_class: Any): """ - Get backend handler for whichever backend the user is using. + Ensure registered component is a subclass of ResultsBackend. Args: - backend: The name of the backend to load up. - - Returns: - An instantiation of a [`ResultsBackend`][backends.results_backend.ResultsBackend] object. + component_class: The class to validate. Raises: - (exceptions.BackendNotSupportedError): If the requested backend is not supported. + TypeError: If the component does not subclass ResultsBackend. """ - # Resolve the alias to the canonical backend name - backend = self._backend_aliases.get(backend, backend) + if not issubclass(component_class, ResultsBackend): + raise TypeError(f"{component_class} must inherit from ResultsBackend") - # Get the correct backend class - backend_object = self._backends.get(backend) + def _entry_point_group(self) -> str: + """ + Entry point group used for discovering backend plugins. - if backend_object is None: - raise BackendNotSupportedError(f"Backend unsupported by Merlin: {backend}.") + Returns: + The entry point namespace for Merlin backend plugins. + """ + return "merlin.backends" + + def _get_component_error_class(self) -> type[Exception]: + """ + Return the exception type to raise for unsupported components. + + This method is used by the base factory logic to determine which + exception to raise when a requested component is not found or fails + to initialize. - return backend_object(backend) + Returns: + The exception class to raise. + """ + return BackendNotSupportedError backend_factory = MerlinBackendFactory() diff --git a/merlin/backends/redis/redis_backend.py b/merlin/backends/redis/redis_backend.py index 7498446a..8126ea7f 100644 --- a/merlin/backends/redis/redis_backend.py +++ b/merlin/backends/redis/redis_backend.py @@ -63,19 +63,18 @@ class RedisBackend(ResultsBackend): Delete an entity from the specified store. """ - def __init__(self, backend_name: str): + def __init__(self): """ Initialize the `RedisBackend` instance, setting up the Redis client connection and store mappings. - - Args: - backend_name (str): The name of the backend (e.g., "redis"). """ from merlin.config.configfile import CONFIG # pylint: disable=import-outside-toplevel from merlin.config.results_backend import get_connection_string # pylint: disable=import-outside-toplevel + backend_name = CONFIG.results_backend.name + # Get the Redis client connection redis_config = {"url": get_connection_string(), "decode_responses": True} - if CONFIG.results_backend.name == "rediss": + if backend_name == "rediss": redis_config.update({"ssl_cert_reqs": getattr(CONFIG.results_backend, "cert_reqs", "required")}) self.client: Redis = Redis.from_url(**redis_config) diff --git a/merlin/backends/sqlite/sqlite_backend.py b/merlin/backends/sqlite/sqlite_backend.py index 4adacfcf..5c3756f5 100644 --- a/merlin/backends/sqlite/sqlite_backend.py +++ b/merlin/backends/sqlite/sqlite_backend.py @@ -58,12 +58,9 @@ class SQLiteBackend(ResultsBackend): Delete an entity from the specified store. """ - def __init__(self, backend_name: str): + def __init__(self): """ Initialize the `SQLiteBackend` instance, setting up the store mappings and tables. - - Args: - backend_name (str): The name of the backend (e.g., "sqlite"). """ stores = { "study": SQLiteStudyStore(), @@ -72,7 +69,7 @@ def __init__(self, backend_name: str): "physical_worker": SQLitePhysicalWorkerStore(), } - super().__init__(backend_name, stores) + super().__init__("sqlite", stores) # Initialize database schema self._initialize_schema() diff --git a/merlin/db_scripts/merlin_db.py b/merlin/db_scripts/merlin_db.py index eedbd05d..4a657732 100644 --- a/merlin/db_scripts/merlin_db.py +++ b/merlin/db_scripts/merlin_db.py @@ -59,7 +59,7 @@ def __init__(self): """ from merlin.config.configfile import CONFIG # pylint: disable=import-outside-toplevel - self.backend: ResultsBackend = backend_factory.get_backend(CONFIG.results_backend.name.lower()) + self.backend: ResultsBackend = backend_factory.create(CONFIG.results_backend.name.lower()) self._entity_managers: Dict[str, EntityManager] = { "study": StudyManager(self.backend), "run": RunManager(self.backend), From 1edf5ba559fc4eeefebacb7d4f53ec3963a85289 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 14:30:19 -0700 Subject: [PATCH 04/91] add tests for MerlinBaseFactory and fix backend tests --- tests/unit/abstracts/test_factory.py | 176 ++++++++++++++++++ .../unit/backends/redis/test_redis_backend.py | 2 +- .../backends/sqlite/test_sqlite_backend.py | 2 +- tests/unit/backends/test_backend_factory.py | 99 +++++----- tests/unit/db_scripts/test_merlin_db.py | 6 +- 5 files changed, 234 insertions(+), 51 deletions(-) create mode 100644 tests/unit/abstracts/test_factory.py diff --git a/tests/unit/abstracts/test_factory.py b/tests/unit/abstracts/test_factory.py new file mode 100644 index 00000000..d7fc3f59 --- /dev/null +++ b/tests/unit/abstracts/test_factory.py @@ -0,0 +1,176 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `factory.py` module of the `abstracts/` directory. +""" + +from typing import Any + +import pytest +from pytest_mock import MockerFixture + +from merlin.abstracts import MerlinBaseFactory + +# --- Dummy Components --- +class DummyComponent: + """A testable dummy component.""" + + +class DummyComponentWithInit: + def __init__(self, foo=None, bar=None): + self.foo = foo + self.bar = bar + + +# --- Concrete Subclass for Testing --- +class TestableFactory(MerlinBaseFactory): + def _register_builtins(self) -> None: + self.register("dummy", DummyComponent, aliases=["alias_dummy"]) + + def _validate_component(self, component_class: Any) -> None: + if not isinstance(component_class, type): + raise TypeError("Component must be a class") + + def _entry_point_group(self) -> str: + return "merlin.test_plugins" + + def _get_component_error_class(self) -> type[Exception]: + return RuntimeError # Use a distinct error type for test verification + + +class TestMerlinBaseFactory: + """ + Unit test suite for the `MerlinBaseFactory` abstract base class. + + This suite verifies the expected behavior of the factory's core logic through a + concrete subclass (`TestableFactory`) that defines the required abstract methods. + The tests ensure that the factory: + + - Registers components and their aliases correctly + - Validates component classes during registration + - Creates instances with and without initialization arguments + - Resolves aliases when creating components + - Raises appropriate errors for unknown components + - Provides accurate component metadata through introspection + - Invokes plugin discovery hooks properly + + The tests do not rely on external entry points or plugins, and use simple + dummy component classes to isolate and validate base functionality. + """ + + @pytest.fixture + def factory(self) -> TestableFactory: + """ + An instance of the dummy `TestableFactory` class. Resets on each test. + + Returns: + An instance of the dummy `TestableFactory` class for testing. + """ + return TestableFactory() + + def test_register_and_list(self, factory: TestableFactory): + """ + Test that components are registered and listed properly. + + Args: + factory: An instance of the dummy `TestableFactory` class for testing. + """ + assert "dummy" in factory.list_available() + assert factory._registry["dummy"] is DummyComponent + assert factory._aliases["alias_dummy"] == "dummy" + + def test_create_component_without_config(self, factory: TestableFactory): + """ + Test instantiation of a registered component with no config. + + Args: + factory: An instance of the dummy `TestableFactory` class for testing. + """ + instance = factory.create("dummy") + assert isinstance(instance, DummyComponent) + + def test_create_component_with_config(self, factory: TestableFactory): + """ + Test instantiation of a component with constructor args. + + Args: + factory: An instance of the dummy `TestableFactory` class for testing. + """ + factory.register("with_init", DummyComponentWithInit) + config = {"foo": "a", "bar": 42} + instance = factory.create("with_init", config=config) + assert isinstance(instance, DummyComponentWithInit) + assert instance.foo == "a" + assert instance.bar == 42 + + def test_create_component_using_alias(self, factory: TestableFactory): + """ + Test alias resolution in component creation. + + Args: + factory: An instance of the dummy `TestableFactory` class for testing. + """ + instance = factory.create("alias_dummy") + assert isinstance(instance, DummyComponent) + + def test_create_unregistered_component_raises(self, factory: TestableFactory): + """ + Test that creating an unknown component raises the correct error. + + Args: + factory: An instance of the dummy `TestableFactory` class for testing. + """ + with pytest.raises(RuntimeError, match="not supported"): + factory.create("unknown") + + def test_register_invalid_component_raises(self, factory: TestableFactory): + """ + Test that register raises TypeError for non-class input. + + Args: + factory: An instance of the dummy `TestableFactory` class for testing. + """ + with pytest.raises(TypeError): + factory.register("bad", object()) # not a class + + def test_get_component_info(self, factory: TestableFactory): + """ + Test metadata returned from `get_component_info`. + + Args: + factory: An instance of the dummy `TestableFactory` class for testing. + """ + info = factory.get_component_info("dummy") + assert info["name"] == "dummy" + assert info["class"] == "DummyComponent" + assert info["module"] == DummyComponent.__module__ + assert "description" in info + + def test_get_component_info_for_invalid_component(self, factory: TestableFactory): + """ + Test that get_component_info raises when the component is unknown. + + Args: + factory: An instance of the dummy `TestableFactory` class for testing. + """ + with pytest.raises(ValueError, match="not supported"): + factory.get_component_info("not_registered") + + def test_discover_plugins_calls_both_hooks(self, mocker: MockerFixture, factory: TestableFactory): + """ + Test that _discover_plugins calls both plugin and module hooks. + + Args: + mocker: PyTest mocker fixture. + factory: An instance of the dummy `TestableFactory` class for testing. + """ + plugin_mock = mocker.patch.object(factory, "_discover_plugins_via_entry_points") + builtin_mock = mocker.patch.object(factory, "_discover_builtin_modules") + + factory._discover_plugins() + plugin_mock.assert_called_once() + builtin_mock.assert_called_once() diff --git a/tests/unit/backends/redis/test_redis_backend.py b/tests/unit/backends/redis/test_redis_backend.py index 40efc906..9a9c0c95 100644 --- a/tests/unit/backends/redis/test_redis_backend.py +++ b/tests/unit/backends/redis/test_redis_backend.py @@ -81,7 +81,7 @@ def redis_backend_instance( mocker.patch("merlin.config.results_backend.get_connection_string", return_value=redis_backend_connection_string) # Initialize RedisBackend - backend = RedisBackend("redis") + backend = RedisBackend() # Override the client and stores with mocked objects backend.client = redis_backend_mock_redis_client diff --git a/tests/unit/backends/sqlite/test_sqlite_backend.py b/tests/unit/backends/sqlite/test_sqlite_backend.py index d9f16cf2..1319d597 100644 --- a/tests/unit/backends/sqlite/test_sqlite_backend.py +++ b/tests/unit/backends/sqlite/test_sqlite_backend.py @@ -34,7 +34,7 @@ def sqlite_backend_instance(mocker: MockerFixture) -> SQLiteBackend: # Patch the initialization method to avoid real DB operations mocker.patch.object(SQLiteBackend, "_initialize_schema", return_value=None) - backend = SQLiteBackend("sqlite") + backend = SQLiteBackend() return backend diff --git a/tests/unit/backends/test_backend_factory.py b/tests/unit/backends/test_backend_factory.py index 4f51ae4f..83b7622d 100644 --- a/tests/unit/backends/test_backend_factory.py +++ b/tests/unit/backends/test_backend_factory.py @@ -9,78 +9,85 @@ """ import pytest -from pytest_mock import MockerFixture -from merlin.backends.backend_factory import backend_factory +from merlin.backends.backend_factory import MerlinBackendFactory from merlin.backends.redis.redis_backend import RedisBackend +from merlin.backends.results_backend import ResultsBackend +from merlin.backends.sqlite.sqlite_backend import SQLiteBackend from merlin.exceptions import BackendNotSupportedError -class TestBackendFactory: +class TestMerlinBackendFactory: """ - Test suite for the `backend_factory` module. + Test suite for the `MerlinBackendFactory`. - This class contains unit tests to validate the functionality of the `backend_factory`, which is responsible - for managing backend instances and providing an interface for retrieving supported backends and resolving - backend aliases. - - Fixtures and mocking are used to isolate the tests from the actual backend implementations, ensuring that - the tests focus on the behavior of the `backend_factory` module. - - These tests ensure the robustness and correctness of the `backend_factory` module, which is critical for - backend management in the Merlin framework. + This class tests that the backend factory correctly registers, resolves, instantiates, + and reports supported Merlin backends. It uses mocking to isolate backend behavior + and focuses on the factory's interface and logic. """ - def test_get_supported_backends(self): + @pytest.fixture + def backend_factory(self) -> MerlinBackendFactory: """ - Test that `get_supported_backends` returns the correct list of supported backends. + An instance of the `MerlinBackendFactory` class. Resets on each test. + + Returns: + An instance of the `MerlinBackendFactory` class for testing. """ - supported_backends = backend_factory.get_supported_backends() - assert supported_backends == ["redis", "sqlite"] + return MerlinBackendFactory() - def test_get_backend_with_valid_backend(self, mocker: MockerFixture): + def test_list_available_backends(self, backend_factory: MerlinBackendFactory): """ - Test that `get_backend` returns the correct backend instance for a valid backend. + Test that `list_available` returns the correct set of built-in backends. Args: - mocker (MockerFixture): A built-in fixture from the pytest-mock library to create a Mock object. + backend_factory: An instance of the `MerlinBackendFactory` class for testing. """ - backend_name = "redis" - - # Mock the RedisBackend class to avoid instantiation issues - RedisBackendMock = mocker.MagicMock(spec=RedisBackend) - backend_factory._backends["redis"] = RedisBackendMock + available = backend_factory.list_available() + assert set(available) == {"redis", "sqlite"} - backend_instance = backend_factory.get_backend(backend_name) + @pytest.mark.parametrize("backend_type, expected_cls", [("redis", RedisBackend), ("sqlite", SQLiteBackend)]) + def test_create_valid_backend(self, backend_factory: MerlinBackendFactory, backend_type: str, expected_cls: ResultsBackend): + """ + Test that `create` returns a valid backend instance for a registered name. - # Verify the backend instance is created correctly - RedisBackendMock.assert_called_once_with(backend_name) - assert backend_instance == RedisBackendMock(backend_name), "Backend instance should match the mocked backend." + Args: + backend_factory: An instance of the `MerlinBackendFactory` class for testing. + backend_type: The type of backend to create. + expected_cls: The class that we're expecting `backend_factory` to create. + """ + instance = backend_factory.create(backend_type) + assert isinstance(instance, expected_cls) - def test_get_backend_with_alias(self, mocker: MockerFixture): + def test_create_valid_backend_with_alias(self, backend_factory: MerlinBackendFactory): """ - Test that `get_backend` correctly resolves aliases to canonical backend names. + Test that aliases (e.g. 'rediss') are resolved to canonical backend names. Args: - mocker (MockerFixture): A built-in fixture from the pytest-mock library to create a Mock object. + backend_factory: An instance of the `MerlinBackendFactory` class for testing. """ - alias_name = "rediss" + instance = backend_factory.create("rediss") + assert isinstance(instance, RedisBackend) - # Mock the RedisBackend class to avoid instantiation issues - RedisBackendMock = mocker.MagicMock(spec=RedisBackend) - backend_factory._backends["redis"] = RedisBackendMock - - backend_instance = backend_factory.get_backend(alias_name) + def test_create_invalid_backend_raises(self, backend_factory: MerlinBackendFactory): + """ + Test that `create` raises `BackendNotSupportedError` for unknown backends. - # Verify the alias resolves and the backend instance is created correctly - RedisBackendMock.assert_called_once_with("redis") - assert backend_instance == RedisBackendMock("redis"), "Backend instance should match the mocked backend." + Args: + backend_factory: An instance of the `MerlinBackendFactory` class for testing. + """ + with pytest.raises(BackendNotSupportedError, match="unsupported_backend"): + backend_factory.create("unsupported_backend") - def test_get_backend_with_invalid_backend(self): + def test_invalid_registration_type_error(self, backend_factory: MerlinBackendFactory): """ - Test that get_backend raises BackendNotSupportedError for an unsupported backend. + Test that trying to register a non-ResultsBackend raises TypeError. + + Args: + backend_factory: An instance of the `MerlinBackendFactory` class for testing. """ - invalid_backend_name = "unsupported_backend" + class NotAResultsBackend: + pass - with pytest.raises(BackendNotSupportedError, match=f"Backend unsupported by Merlin: {invalid_backend_name}."): - backend_factory.get_backend(invalid_backend_name) + with pytest.raises(TypeError, match="must inherit from ResultsBackend"): + backend_factory.register("fake", NotAResultsBackend) \ No newline at end of file diff --git a/tests/unit/db_scripts/test_merlin_db.py b/tests/unit/db_scripts/test_merlin_db.py index c793db6f..0ecd510e 100644 --- a/tests/unit/db_scripts/test_merlin_db.py +++ b/tests/unit/db_scripts/test_merlin_db.py @@ -97,7 +97,7 @@ def mock_merlin_db( patch("merlin.db_scripts.merlin_db.PhysicalWorkerManager", return_value=mock_entity_managers["physical_worker"]) ) - mock_factory.get_backend.return_value = mock_backend + mock_factory.create.return_value = mock_backend db = MerlinDatabase() # Replace backend and entity managers with mocks @@ -135,14 +135,14 @@ def test_init(self, mock_backend: MagicMock): mock_config = stack.enter_context(patch("merlin.config.configfile.CONFIG")) # Configure mocks - mock_factory.get_backend.return_value = mock_backend + mock_factory.create.return_value = mock_backend mock_config.results_backend.name = "redis" # Create instances db = MerlinDatabase() # Verify backend was created - mock_factory.get_backend.assert_called_once_with("redis") + mock_factory.create.assert_called_once_with("redis") # Verify entity managers were created with the backend mock_study_manager.assert_called_once_with(mock_backend) From 0134cd3f2d2be17b69387c808a72703711b7e9c1 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 15:01:37 -0700 Subject: [PATCH 05/91] convert monitor factory to use new MerlinBaseFactory --- merlin/abstracts/factory.py | 3 +- merlin/monitor/monitor.py | 2 +- merlin/monitor/monitor_factory.py | 123 ++++++++++++----- tests/unit/abstracts/test_factory.py | 2 +- tests/unit/monitor/test_monitor_factory.py | 153 ++++++++++++++++----- 5 files changed, 209 insertions(+), 74 deletions(-) diff --git a/merlin/abstracts/factory.py b/merlin/abstracts/factory.py index 88f3c1e4..594b5c21 100644 --- a/merlin/abstracts/factory.py +++ b/merlin/abstracts/factory.py @@ -247,7 +247,8 @@ def get_component_info(self, component_type: str) -> Dict: component_class = self._registry.get(canonical_name) if component_class is None: available = ", ".join(self.list_available()) - raise ValueError( + error_cls = self._get_component_error_class() + raise error_cls( f"Component '{component_type}' is not supported. " f"Available components: {available}" ) diff --git a/merlin/monitor/monitor.py b/merlin/monitor/monitor.py index f041e95d..f50a89fc 100644 --- a/merlin/monitor/monitor.py +++ b/merlin/monitor/monitor.py @@ -74,7 +74,7 @@ def __init__(self, spec: MerlinSpec, sleep: int, task_server: str, no_restart: b self.spec: MerlinSpec = spec self.sleep: int = sleep self.no_restart: bool = no_restart - self.task_server_monitor: TaskServerMonitor = monitor_factory.get_monitor(task_server) + self.task_server_monitor: TaskServerMonitor = monitor_factory.create(task_server) self.merlin_db = MerlinDatabase() def monitor_all_runs(self): diff --git a/merlin/monitor/monitor_factory.py b/merlin/monitor/monitor_factory.py index b3c5fc13..469bc95d 100644 --- a/merlin/monitor/monitor_factory.py +++ b/merlin/monitor/monitor_factory.py @@ -9,67 +9,114 @@ for supported task servers in Merlin. """ -from typing import Dict, List +from typing import Any +from merlin.abstracts import MerlinBaseFactory from merlin.exceptions import MerlinInvalidTaskServerError from merlin.monitor.celery_monitor import CeleryMonitor from merlin.monitor.task_server_monitor import TaskServerMonitor -class MonitorFactory: - """ - A factory class for managing and retrieving task server monitors - for supported task servers in Merlin. +# class MonitorFactory: +# """ +# A factory class for managing and retrieving task server monitors +# for supported task servers in Merlin. + +# Attributes: +# _monitors (Dict[str, TaskServerMonitor]): A dictionary mapping task server names +# to their corresponding monitor classes. + +# Methods: +# get_supported_task_servers: Get a list of the supported task servers in Merlin. +# get_monitor: Get the monitor instance for the specified task server. +# """ + +# def __init__(self): +# """ +# Initialize the `MonitorFactory` with the supported task server monitors. +# """ +# self._monitors: Dict[str, TaskServerMonitor] = { +# "celery": CeleryMonitor, +# } + +# def get_supported_task_servers(self) -> List[str]: +# """ +# Get a list of the supported task servers in Merlin. + +# Returns: +# A list of names representing the supported task servers in Merlin. +# """ +# return list(self._monitors.keys()) - Attributes: - _monitors (Dict[str, TaskServerMonitor]): A dictionary mapping task server names - to their corresponding monitor classes. +# def get_monitor(self, task_server: str) -> TaskServerMonitor: +# """ +# Get the task server monitor for whichever task server the user is utilizing. - Methods: - get_supported_task_servers: Get a list of the supported task servers in Merlin. - get_monitor: Get the monitor instance for the specified task server. +# Args: +# task_server: The name of the task server to use when loading a task server monitor. + +# Returns: +# An instantiated [`TaskServerMonitor`][monitor.task_server_monitor.TaskServerMonitor] +# object for the specified task server. + +# Raises: +# MerlinInvalidTaskServerError: If the requested task server is not supported. +# """ +# monitor_object = self._monitors.get(task_server, None) + +# if monitor_object is None: +# raise MerlinInvalidTaskServerError( +# f"Task server unsupported by Merlin: {task_server}. " +# "Supported task servers are: {self.get_supported_task_servers()}" +# ) + +# return monitor_object() + +class MonitorFactory(MerlinBaseFactory): + """ + """ - def __init__(self): + def _register_builtins(self): """ - Initialize the `MonitorFactory` with the supported task server monitors. + Register built-in monitor implementations. """ - self._monitors: Dict[str, TaskServerMonitor] = { - "celery": CeleryMonitor, - } + self.register("celery", CeleryMonitor) - def get_supported_task_servers(self) -> List[str]: + def _validate_component(self, component_class: Any): """ - Get a list of the supported task servers in Merlin. + Ensure registered component is a subclass of TaskServerMonitor. - Returns: - A list of names representing the supported task servers in Merlin. - """ - return list(self._monitors.keys()) + Args: + component_class: The class to validate. - def get_monitor(self, task_server: str) -> TaskServerMonitor: + Raises: + TypeError: If the component does not subclass TaskServerMonitor. """ - Get the task server monitor for whichever task server the user is utilizing. + if not issubclass(component_class, TaskServerMonitor): + raise TypeError(f"{component_class} must inherit from TaskServerMonitor") - Args: - task_server: The name of the task server to use when loading a task server monitor. + def _entry_point_group(self) -> str: + """ + Entry point group used for discovering monitor plugins. Returns: - An instantiated [`TaskServerMonitor`][monitor.task_server_monitor.TaskServerMonitor] - object for the specified task server. - - Raises: - MerlinInvalidTaskServerError: If the requested task server is not supported. + The entry point namespace for Merlin monitor plugins. + """ + return "merlin.monitor" + + def _get_component_error_class(self) -> type[Exception]: """ - monitor_object = self._monitors.get(task_server, None) + Return the exception type to raise for unsupported components. - if monitor_object is None: - raise MerlinInvalidTaskServerError( - f"Task server unsupported by Merlin: {task_server}. " - "Supported task servers are: {self.get_supported_task_servers()}" - ) + This method is used by the base factory logic to determine which + exception to raise when a requested component is not found or fails + to initialize. - return monitor_object() + Returns: + The exception class to raise. + """ + return MerlinInvalidTaskServerError monitor_factory = MonitorFactory() diff --git a/tests/unit/abstracts/test_factory.py b/tests/unit/abstracts/test_factory.py index d7fc3f59..e402c21f 100644 --- a/tests/unit/abstracts/test_factory.py +++ b/tests/unit/abstracts/test_factory.py @@ -157,7 +157,7 @@ def test_get_component_info_for_invalid_component(self, factory: TestableFactory Args: factory: An instance of the dummy `TestableFactory` class for testing. """ - with pytest.raises(ValueError, match="not supported"): + with pytest.raises(RuntimeError, match="not supported"): # raises RuntimeError because of `_get_component_error_class` factory.get_component_info("not_registered") def test_discover_plugins_calls_both_hooks(self, mocker: MockerFixture, factory: TestableFactory): diff --git a/tests/unit/monitor/test_monitor_factory.py b/tests/unit/monitor/test_monitor_factory.py index 66bcda4e..72f2b0ad 100644 --- a/tests/unit/monitor/test_monitor_factory.py +++ b/tests/unit/monitor/test_monitor_factory.py @@ -15,49 +15,136 @@ from merlin.monitor.monitor_factory import MonitorFactory -@pytest.fixture -def factory() -> MonitorFactory: - """ - Fixture to provide a `MonitorFactory` instance. +# @pytest.fixture +# def factory() -> MonitorFactory: +# """ +# Fixture to provide a `MonitorFactory` instance. - Returns: - An instance of the `MonitorFactory` object. - """ - return MonitorFactory() +# Returns: +# An instance of the `MonitorFactory` object. +# """ +# return MonitorFactory() -def test_get_supported_task_servers(factory: MonitorFactory): - """ - Test that the correct list of supported task servers is returned. +# def test_get_supported_task_servers(factory: MonitorFactory): +# """ +# Test that the correct list of supported task servers is returned. - Args: - factory: An instance of the `MonitorFactory` object. - """ - supported = factory.get_supported_task_servers() - assert isinstance(supported, list) - assert "celery" in supported - assert len(supported) == 1 +# Args: +# factory: An instance of the `MonitorFactory` object. +# """ +# supported = factory.get_supported_task_servers() +# assert isinstance(supported, list) +# assert "celery" in supported +# assert len(supported) == 1 -def test_get_monitor_valid(factory: MonitorFactory): - """ - Test that get_monitor returns the correct monitor for a valid task server. +# def test_get_monitor_valid(factory: MonitorFactory): +# """ +# Test that get_monitor returns the correct monitor for a valid task server. - Args: - factory: An instance of the `MonitorFactory` object. - """ - monitor = factory.get_monitor("celery") - assert isinstance(monitor, CeleryMonitor) +# Args: +# factory: An instance of the `MonitorFactory` object. +# """ +# monitor = factory.get_monitor("celery") +# assert isinstance(monitor, CeleryMonitor) + + +# def test_get_monitor_invalid(factory: MonitorFactory): +# """ +# Test that get_monitor raises an error for an unsupported task server. + +# Args: +# factory: An instance of the `MonitorFactory` object. +# """ +# with pytest.raises(MerlinInvalidTaskServerError) as excinfo: +# factory.get_monitor("invalid") + +# assert "Task server unsupported by Merlin: invalid" in str(excinfo.value) -def test_get_monitor_invalid(factory: MonitorFactory): +class TestMonitorFactory: """ - Test that get_monitor raises an error for an unsupported task server. + Test suite for the `MonitorFactory`. - Args: - factory: An instance of the `MonitorFactory` object. + This class validates that the factory properly registers and instantiates + task server monitor classes (like `CeleryMonitor`), resolves component names, + and raises appropriate errors for unsupported types. These tests focus + on the behavior of the generic factory logic applied to monitor components. """ - with pytest.raises(MerlinInvalidTaskServerError) as excinfo: - factory.get_monitor("invalid") - assert "Task server unsupported by Merlin: invalid" in str(excinfo.value) + @pytest.fixture + def monitor_factory(self) -> MonitorFactory: + """ + Create a fresh `MonitorFactory` instance for each test. + + Returns: + A new instance of `MonitorFactory`. + """ + return MonitorFactory() + + def test_list_available_monitors(self, monitor_factory: MonitorFactory): + """ + Test that `list_available` returns registered task server monitors. + + Args: + monitor_factory: Instance of `MonitorFactory` for testing. + """ + available = monitor_factory.list_available() + assert "celery" in available + assert len(available) == 1 + + def test_create_valid_monitor(self, monitor_factory: MonitorFactory): + """ + Test that `create` instantiates a monitor for a valid task server. + + Args: + monitor_factory: Instance of `MonitorFactory` for testing. + """ + monitor = monitor_factory.create("celery") + assert isinstance(monitor, CeleryMonitor) + + def test_create_invalid_monitor_raises(self, monitor_factory: MonitorFactory): + """ + Test that creating a monitor for an unknown task server raises error. + + Args: + monitor_factory: Instance of `MonitorFactory` for testing. + """ + with pytest.raises(MerlinInvalidTaskServerError, match="not supported"): + monitor_factory.create("invalid_task_server") + + def test_register_invalid_component_type(self, monitor_factory: MonitorFactory): + """ + Test that registering a non-TaskServerMonitor subclass raises TypeError. + + Args: + monitor_factory: Instance of `MonitorFactory` for testing. + """ + class NotAMonitor: + pass + + with pytest.raises(TypeError, match="must inherit from TaskServerMonitor"): + monitor_factory.register("invalid", NotAMonitor) + + def test_get_component_info_returns_expected_metadata(self, monitor_factory: MonitorFactory): + """ + Test that `get_component_info` returns metadata about a monitor class. + + Args: + monitor_factory: Instance of `MonitorFactory` for testing. + """ + info = monitor_factory.get_component_info("celery") + assert info["name"] == "celery" + assert info["class"] == "CeleryMonitor" + assert "description" in info + + def test_get_component_info_for_unknown_component_raises(self, monitor_factory: MonitorFactory): + """ + Test that `get_component_info` raises for unknown component types. + + Args: + monitor_factory: Instance of `MonitorFactory` for testing. + """ + with pytest.raises(MerlinInvalidTaskServerError, match="not supported"): + monitor_factory.get_component_info("unknown") From 1c0088f739af3aeb131caea4d26366369234d13d Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 15:05:00 -0700 Subject: [PATCH 06/91] remove comment and update MonitorFactory docstring --- merlin/monitor/monitor_factory.py | 79 +++++++++---------------------- 1 file changed, 22 insertions(+), 57 deletions(-) diff --git a/merlin/monitor/monitor_factory.py b/merlin/monitor/monitor_factory.py index 469bc95d..1b8ca5e3 100644 --- a/merlin/monitor/monitor_factory.py +++ b/merlin/monitor/monitor_factory.py @@ -16,65 +16,30 @@ from merlin.monitor.celery_monitor import CeleryMonitor from merlin.monitor.task_server_monitor import TaskServerMonitor - -# class MonitorFactory: -# """ -# A factory class for managing and retrieving task server monitors -# for supported task servers in Merlin. - -# Attributes: -# _monitors (Dict[str, TaskServerMonitor]): A dictionary mapping task server names -# to their corresponding monitor classes. - -# Methods: -# get_supported_task_servers: Get a list of the supported task servers in Merlin. -# get_monitor: Get the monitor instance for the specified task server. -# """ - -# def __init__(self): -# """ -# Initialize the `MonitorFactory` with the supported task server monitors. -# """ -# self._monitors: Dict[str, TaskServerMonitor] = { -# "celery": CeleryMonitor, -# } - -# def get_supported_task_servers(self) -> List[str]: -# """ -# Get a list of the supported task servers in Merlin. - -# Returns: -# A list of names representing the supported task servers in Merlin. -# """ -# return list(self._monitors.keys()) - -# def get_monitor(self, task_server: str) -> TaskServerMonitor: -# """ -# Get the task server monitor for whichever task server the user is utilizing. - -# Args: -# task_server: The name of the task server to use when loading a task server monitor. - -# Returns: -# An instantiated [`TaskServerMonitor`][monitor.task_server_monitor.TaskServerMonitor] -# object for the specified task server. - -# Raises: -# MerlinInvalidTaskServerError: If the requested task server is not supported. -# """ -# monitor_object = self._monitors.get(task_server, None) - -# if monitor_object is None: -# raise MerlinInvalidTaskServerError( -# f"Task server unsupported by Merlin: {task_server}. " -# "Supported task servers are: {self.get_supported_task_servers()}" -# ) - -# return monitor_object() - class MonitorFactory(MerlinBaseFactory): """ - + Factory class for managing and instantiating Merlin task server monitors. + + This subclass of `MerlinBaseFactory` is responsible for registering, + validating, and creating instances of supported `TaskServerMonitor` + implementations (e.g., `CeleryMonitor`). It also supports plugin-based + extension via Python entry points. + + Responsibilities: + - Register built-in task server monitors. + - Validate that components conform to the `TaskServerMonitor` interface. + - Support creation and introspection of registered monitor types. + - Optionally discover external monitor plugins. + + Attributes: + _registry (Dict[str, TaskServerMonitor]): Maps canonical task server names to monitor classes. + _aliases (Dict[str, str]): Maps aliases to canonical monitor names. + + Methods: + register: Register a new monitor class and optional aliases. + list_available: Return a list of supported monitor names. + create: Instantiate a monitor class by name or alias. + get_component_info: Return metadata about a registered monitor. """ def _register_builtins(self): From b8c9e191a16e8ac85f24b81028934174b9b6ad61 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 15:31:17 -0700 Subject: [PATCH 07/91] update MerlinStatusRendererFactory to use MerlinBaseFactory and fix TODOs related to Maestro --- merlin/cli/commands/status.py | 2 +- merlin/display.py | 3 +- merlin/exceptions/__init__.py | 9 ++ merlin/study/status.py | 3 +- merlin/study/status_renderers.py | 142 ++++++++++++------------------- requirements/release.txt | 2 +- 6 files changed, 70 insertions(+), 91 deletions(-) diff --git a/merlin/cli/commands/status.py b/merlin/cli/commands/status.py index 4958a540..9f6f586d 100644 --- a/merlin/cli/commands/status.py +++ b/merlin/cli/commands/status.py @@ -248,7 +248,7 @@ def add_parser(self, subparsers: ArgumentParser): status_display_group.add_argument( "--layout", type=str, - choices=status_renderer_factory.get_layouts(), + choices=status_renderer_factory.list_available(), default="default", help="Alternate status layouts [Default: %(default)s]", ) diff --git a/merlin/display.py b/merlin/display.py index f96227d9..3ce33406 100644 --- a/merlin/display.py +++ b/merlin/display.py @@ -309,7 +309,8 @@ def display_status_task_by_task(status_obj: "DetailedStatus", test_mode: bool = """ args = status_obj.args try: - status_renderer = status_renderer_factory.get_renderer(args.layout, args.disable_theme, args.disable_pager) + renderer_config = {"disable_theme": args.disable_theme, "disable_pager": args.disable_pager} + status_renderer = status_renderer_factory.create(args.layout, config=renderer_config) except ValueError: LOG.error(f"Layout '{args.layout}' not implemented.") raise diff --git a/merlin/exceptions/__init__.py b/merlin/exceptions/__init__.py index 8262d0f9..1cabe577 100644 --- a/merlin/exceptions/__init__.py +++ b/merlin/exceptions/__init__.py @@ -99,6 +99,15 @@ def __init__(self, message): super().__init__(message) +class MerlinInvalidStatusRendererError(Exception): + """ + Exception to signal that an invalid status renderer was provided. + """ + + def __init__(self, message): + super().__init__(message) + + ############################### # Database-Related Exceptions # ############################### diff --git a/merlin/study/status.py b/merlin/study/status.py index 37b7b7ba..dc71239a 100644 --- a/merlin/study/status.py +++ b/merlin/study/status.py @@ -1221,7 +1221,8 @@ def get_user_filters(self) -> bool: } # Display the filter options - filter_option_renderer = status_renderer_factory.get_renderer("table", disable_theme=True, disable_pager=True) + renderer_config = {"disable_theme": True, "disable_pager": True} + filter_option_renderer = status_renderer_factory.create("table", config=renderer_config) filter_option_renderer.layout(status_data=filter_info) filter_option_renderer.render() diff --git a/merlin/study/status_renderers.py b/merlin/study/status_renderers.py index 49d99a0f..b34928d1 100644 --- a/merlin/study/status_renderers.py +++ b/merlin/study/status_renderers.py @@ -6,9 +6,9 @@ """This module handles creating a formatted task-by-task status display""" import logging -from typing import Dict, List, Union +from typing import Any, Dict, List, Union -from maestrowf import BaseStatusRenderer, FlatStatusRenderer, StatusRendererFactory +from maestrowf import BaseStatusRenderer, FlatStatusRenderer from rich import box from rich.columns import Columns from rich.console import Console @@ -16,6 +16,8 @@ from rich.text import Text from rich.theme import Theme +from merlin.abstracts import MerlinBaseFactory +from merlin.exceptions import MerlinInvalidStatusRendererError from merlin.study.status_constants import NON_WORKSPACE_KEYS @@ -72,9 +74,6 @@ def __init__(self, *args: List, **kwargs: Dict): """ super().__init__(*args, **kwargs) - self.disable_theme: bool = kwargs.pop("disable_theme", False) - self.disable_pager: bool = kwargs.pop("disable_pager", False) - # Setup default theme # TODO modify this theme to add more colors self._theme_dict: Dict[str, str] = { @@ -319,41 +318,6 @@ def layout(self, status_data: Dict, study_title: str = None, status_time: str = # Add this step to the full status table self._status_table.add_row(step_table, end_section=True) - def render(self, theme: Dict[str, str] = None): - """ - Do the actual printing of the status table. - - This method is responsible for rendering the status table to the console, applying any specified - theme settings for visual customization. It handles the enabling or disabling of themes and - manages the output display, either using a pager for long outputs or printing directly to the console. - - Args: - theme: A dictionary of theme settings that define the appearance of the output. The keys and - values should correspond to the layout defined in `self._theme_dict`. - """ - # Apply any theme customization - if theme: - LOG.debug(f"Applying theme: {theme}") - for key, value in theme.items(): - self._theme_dict[key] = value - - # If we're disabling the theme, we need to set all themes in the theme dict to none - if self.disable_theme: - LOG.debug("Disabling theme.") - for key in self._theme_dict: - self._theme_dict[key] = "none" - - # Get the rich Console - status_theme = Theme(self._theme_dict) - _printer = Console(theme=status_theme) - - # Display the status table - if self.disable_pager: - _printer.print(self._status_table) - else: - with _printer.pager(styles=(not self.disable_theme)): - _printer.print(self._status_table) - class MerlinFlatRenderer(FlatStatusRenderer): """ @@ -371,11 +335,6 @@ class MerlinFlatRenderer(FlatStatusRenderer): managing the output display. """ - def __init__(self, *args, **kwargs): - super().__init__(args, kwargs) - self.disable_theme: bool = kwargs.pop("disable_theme", False) - self.disable_pager: bool = kwargs.pop("disable_pager", False) - def layout(self, status_data: Dict[str, List[Union[str, int]]], study_title: str = None): # pylint: disable=W0221 """ Set up the layout of the display for the status information. @@ -443,65 +402,74 @@ def render(self, theme: Dict[str, str] = None): _printer.print(self._status_table) -class MerlinStatusRendererFactory(StatusRendererFactory): +class MerlinStatusRendererFactory(MerlinBaseFactory): """ - This class keeps track of all available status layouts for Merlin. + Factory class for managing and instantiating Merlin status renderers. + + This subclass of `MerlinBaseFactory` is responsible for registering, + validating, and creating instances of supported `BaseStatusRenderer` + implementations (e.g., `MerlinFlatRenderer`, `MerlinDefaultRenderer`). + It also supports dynamic discovery of plugins via Python entry points. - The `MerlinStatusRendererFactory` is responsible for managing different - status layout renderers used in the Merlin application. It provides a - method to retrieve the appropriate renderer based on the specified layout - type and user preferences regarding theme and pager usage. + Responsibilities: + - Register built-in status renderer implementations. + - Validate that all components subclass `BaseStatusRenderer`. + - Provide a unified interface for instantiating renderers by name or alias. + - Optionally support discovery of external plugins. Attributes: - _layouts (Dict[str, BaseStatusRenderer]): A dictionary mapping layout names to their corresponding renderer - classes. Currently includes "table" for - [`MerlinFlatRenderer`][study.status_renderers.MerlinFlatRenderer] and - "default" for [`MerlinDefaultRenderer`][study.status_renderers.MerlinDefaultRenderer]. + _registry (Dict[str, BaseStatusRenderer]): Maps canonical names to renderer classes. + _aliases (Dict[str, str]): Maps alternate names to canonical names. Methods: - get_renderer: Retrieves an instance of the specified layout renderer, applying - user preferences for theme and pager settings. + register: Register a renderer class and optional aliases. + list_available: Return a list of supported renderers. + create: Instantiate a renderer by name or alias. + get_component_info: Return metadata about a registered renderer. """ - # TODO: when maestro releases the pager changes: - # - remove init and render in MerlinFlatRenderer - # - remove the get_renderer method below - # - remove self.disable_theme and self.disable_pager from MerlinFlatRenderer and MerlinDefaultRenderer - # - these variables will be in BaseStatusRenderer in Maestro - # - remove render method in MerlinDefaultRenderer - # - this will also be in BaseStatusRenderer in Maestro - def __init__(self): # pylint: disable=W0231 - self._layouts: Dict[str, BaseStatusRenderer] = { - "table": MerlinFlatRenderer, - "default": MerlinDefaultRenderer, - } + def _register_builtins(self): + """ + Register built-in status renderer implementations. + """ + self.register("table", MerlinFlatRenderer) + self.register("default", MerlinDefaultRenderer) - def get_renderer( - self, layout: str, disable_theme: bool, disable_pager: bool - ) -> BaseStatusRenderer: # pylint: disable=W0221 + def _validate_component(self, component_class: Any): """ - Get handle for specific layout renderer to instantiate. + Ensure registered component is a subclass of BaseStatusRenderer. Args: - layout: A string denoting the name of the layout renderer to use. - disable_theme: True if the user wants to disable themes when displaying - status; False otherwise. - disable_pager: True if the user wants to disable the pager when displaying - status; False otherwise. - - Returns: - The status renderer class to use for displaying the output. + component_class: The class to validate. Raises: - ValueError: If the specified layout is not found in the available layouts. + TypeError: If the component does not subclass BaseStatusRenderer. + """ + if not issubclass(component_class, BaseStatusRenderer): + raise TypeError(f"{component_class} must inherit from BaseStatusRenderer") + + def _entry_point_group(self) -> str: """ - renderer = self._layouts.get(layout) + Entry point group used for discovering status renderer plugins. - # Note, need to wrap renderer in try/catch too, or return default val? - if not renderer: - raise ValueError(layout) + Returns: + The entry point namespace for Merlin status renderer plugins. + """ + return "merlin.study" # TODO change this to merlin.status when we refactor status + + def _get_component_error_class(self) -> type[Exception]: + """ + Return the exception type to raise for unsupported components. + + This method is used by the base factory logic to determine which + exception to raise when a requested component is not found or fails + to initialize. + + Returns: + The exception class to raise. + """ + return MerlinInvalidStatusRendererError - return renderer(disable_theme=disable_theme, disable_pager=disable_pager) status_renderer_factory = MerlinStatusRendererFactory() diff --git a/requirements/release.txt b/requirements/release.txt index b2b0309c..3ddc2f01 100644 --- a/requirements/release.txt +++ b/requirements/release.txt @@ -2,7 +2,7 @@ cached_property celery[redis,sqlalchemy]>=5.0.3 coloredlogs cryptography -maestrowf>=1.1.9dev1 +maestrowf>=1.1.10 numpy parse psutil>=5.1.0 From 88cda38e61f4834ba5300397031bd29df2d96433 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 15:31:43 -0700 Subject: [PATCH 08/91] add tests for the status renderer factory --- .../study/test_status_renderer_factory.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/unit/study/test_status_renderer_factory.py diff --git a/tests/unit/study/test_status_renderer_factory.py b/tests/unit/study/test_status_renderer_factory.py new file mode 100644 index 00000000..7f85aa32 --- /dev/null +++ b/tests/unit/study/test_status_renderer_factory.py @@ -0,0 +1,69 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `MerlinStatusRendererFactory` class in the `status_renderers.py` module. +""" + +import pytest +from maestrowf import BaseStatusRenderer + +from merlin.exceptions import MerlinInvalidStatusRendererError +from merlin.study.status_renderers import MerlinDefaultRenderer, MerlinFlatRenderer, MerlinStatusRendererFactory + + +class TestMerlinStatusRendererFactory: + """ + Test suite for the `MerlinStatusRendererFactory`. + + This class verifies that the factory correctly registers, resolves, instantiates, + and reports status renderer components for the Merlin workflow framework. + """ + + @pytest.fixture + def renderer_factory(self) -> MerlinStatusRendererFactory: + """ + An instance of the `MerlinStatusRendererFactory` class. Resets on each test. + + Returns: + A fresh instance of the renderer factory. + """ + return MerlinStatusRendererFactory() + + def test_list_available_renderers(self, renderer_factory: MerlinStatusRendererFactory): + """ + Test that `list_available` returns the correct built-in renderers. + """ + available = renderer_factory.list_available() + assert set(available) == {"default", "table"} + + @pytest.mark.parametrize("renderer_type, expected_cls", [ + ("default", MerlinDefaultRenderer), + ("table", MerlinFlatRenderer), + ]) + def test_create_valid_renderer(self, renderer_factory: MerlinStatusRendererFactory, renderer_type: str, expected_cls: BaseStatusRenderer): + """ + Test that `create` returns the correct renderer instance for each type. + """ + instance = renderer_factory.create(renderer_type) + assert isinstance(instance, expected_cls) + + def test_create_invalid_renderer_raises(self, renderer_factory: MerlinStatusRendererFactory): + """ + Test that `create` raises an error for an unrecognized renderer name. + """ + with pytest.raises(MerlinInvalidStatusRendererError, match="not supported"): + renderer_factory.create("unsupported_renderer") + + def test_invalid_registration_raises_type_error(self, renderer_factory: MerlinStatusRendererFactory): + """ + Test that trying to register a non-renderer raises a TypeError. + """ + class NotARenderer: + pass + + with pytest.raises(TypeError, match="must inherit from BaseStatusRenderer"): + renderer_factory.register("fake", NotARenderer) From 1a9cff28d5387040666d29a517b4c981b03fc375 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 15:32:04 -0700 Subject: [PATCH 09/91] update CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d518e1c5..9615e94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Unit tests for the `spec/` folder - A page in the docs explaining the `feature_demo` example +- New `MerlinBaseFactory` class to help enable future plugins for backends, monitors, status renderers, etc. + +### Changed +- Maestro version requirement is now at minimum 1.1.10 for status renderer changes +- The `BackendFactory`, `MonitorFactory`, and `StatusRendererFactory` classes all now inherit from `MerlinBaseFactory` ## [1.13.0b2] ### Added From f2efe9107b7b961815de410d09928610dfdd4a6a Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 15:37:57 -0700 Subject: [PATCH 10/91] run fix-style --- merlin/abstracts/__init__.py | 1 + merlin/abstracts/factory.py | 24 +++++++------------ merlin/backends/backend_factory.py | 2 +- merlin/monitor/monitor_factory.py | 3 ++- merlin/study/status.py | 2 +- merlin/study/status_renderers.py | 3 +-- tests/unit/abstracts/test_factory.py | 3 ++- tests/unit/backends/test_backend_factory.py | 7 ++++-- tests/unit/monitor/test_monitor_factory.py | 1 + .../study/test_status_renderer_factory.py | 16 +++++++++---- 10 files changed, 33 insertions(+), 29 deletions(-) diff --git a/merlin/abstracts/__init__.py b/merlin/abstracts/__init__.py index aae1b246..8b3faec6 100644 --- a/merlin/abstracts/__init__.py +++ b/merlin/abstracts/__init__.py @@ -14,4 +14,5 @@ from merlin.abstracts.factory import MerlinBaseFactory + __all__ = ["MerlinBaseFactory"] diff --git a/merlin/abstracts/factory.py b/merlin/abstracts/factory.py index 594b5c21..57d61fd0 100644 --- a/merlin/abstracts/factory.py +++ b/merlin/abstracts/factory.py @@ -17,10 +17,11 @@ """ import logging -import pkg_resources from abc import ABC, abstractmethod from typing import Any, Dict, List +import pkg_resources + LOG = logging.getLogger("merlin") @@ -95,7 +96,7 @@ def _validate_component(self, component_class: Any): TypeError: If `component_class` is not valid. """ raise NotImplementedError("Subclasses of `MerlinBaseFactory` must implement a `_validate_component` method.") - + @abstractmethod def _entry_point_group(self) -> str: """ @@ -107,7 +108,7 @@ def _entry_point_group(self) -> str: The entry point group used for plugin discovery. """ raise NotImplementedError("Subclasses must define an entry point group.") - + def _discover_plugins_via_entry_points(self): """ Discover and register plugins via Python entry points. @@ -118,7 +119,7 @@ def _discover_plugins_via_entry_points(self): plugin_class = entry_point.load() self.register(entry_point.name, plugin_class) LOG.info(f"Loaded plugin via entry point: {entry_point.name}") - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught LOG.warning(f"Failed to load plugin '{entry_point.name}': {e}") except ImportError: LOG.debug("pkg_resources not available for plugin discovery") @@ -131,7 +132,6 @@ def _discover_builtin_modules(self): Subclasses can override this method to implement package/module scanning. """ - pass def _discover_plugins(self): """ @@ -212,19 +212,14 @@ def create(self, component_type: str, config: Dict = None) -> Any: if component_class is None: available = ", ".join(self.list_available()) error_cls = self._get_component_error_class() - raise error_cls( - f"Component '{component_type}' is not supported. " - f"Available components: {available}" - ) + raise error_cls(f"Component '{component_type}' is not supported. " f"Available components: {available}") try: instance = component_class() if config is None else component_class(**config) LOG.info(f"Created component '{canonical_name}'") return instance except Exception as e: - raise ValueError( - f"Failed to create component '{canonical_name}': {e}" - ) from e + raise ValueError(f"Failed to create component '{canonical_name}': {e}") from e def get_component_info(self, component_type: str) -> Dict: """ @@ -248,10 +243,7 @@ def get_component_info(self, component_type: str) -> Dict: if component_class is None: available = ", ".join(self.list_available()) error_cls = self._get_component_error_class() - raise error_cls( - f"Component '{component_type}' is not supported. " - f"Available components: {available}" - ) + raise error_cls(f"Component '{component_type}' is not supported. " f"Available components: {available}") return { "name": canonical_name, diff --git a/merlin/backends/backend_factory.py b/merlin/backends/backend_factory.py index 12db995a..2155f502 100644 --- a/merlin/backends/backend_factory.py +++ b/merlin/backends/backend_factory.py @@ -73,7 +73,7 @@ def _entry_point_group(self) -> str: The entry point namespace for Merlin backend plugins. """ return "merlin.backends" - + def _get_component_error_class(self) -> type[Exception]: """ Return the exception type to raise for unsupported components. diff --git a/merlin/monitor/monitor_factory.py b/merlin/monitor/monitor_factory.py index 1b8ca5e3..04f50d34 100644 --- a/merlin/monitor/monitor_factory.py +++ b/merlin/monitor/monitor_factory.py @@ -16,6 +16,7 @@ from merlin.monitor.celery_monitor import CeleryMonitor from merlin.monitor.task_server_monitor import TaskServerMonitor + class MonitorFactory(MerlinBaseFactory): """ Factory class for managing and instantiating Merlin task server monitors. @@ -69,7 +70,7 @@ def _entry_point_group(self) -> str: The entry point namespace for Merlin monitor plugins. """ return "merlin.monitor" - + def _get_component_error_class(self) -> type[Exception]: """ Return the exception type to raise for unsupported components. diff --git a/merlin/study/status.py b/merlin/study/status.py index dc71239a..d6143b0e 100644 --- a/merlin/study/status.py +++ b/merlin/study/status.py @@ -1221,7 +1221,7 @@ def get_user_filters(self) -> bool: } # Display the filter options - renderer_config = {"disable_theme": True, "disable_pager": True} + renderer_config = {"disable_theme": True, "disable_pager": True} filter_option_renderer = status_renderer_factory.create("table", config=renderer_config) filter_option_renderer.layout(status_data=filter_info) filter_option_renderer.render() diff --git a/merlin/study/status_renderers.py b/merlin/study/status_renderers.py index b34928d1..68113048 100644 --- a/merlin/study/status_renderers.py +++ b/merlin/study/status_renderers.py @@ -456,7 +456,7 @@ def _entry_point_group(self) -> str: The entry point namespace for Merlin status renderer plugins. """ return "merlin.study" # TODO change this to merlin.status when we refactor status - + def _get_component_error_class(self) -> type[Exception]: """ Return the exception type to raise for unsupported components. @@ -471,5 +471,4 @@ def _get_component_error_class(self) -> type[Exception]: return MerlinInvalidStatusRendererError - status_renderer_factory = MerlinStatusRendererFactory() diff --git a/tests/unit/abstracts/test_factory.py b/tests/unit/abstracts/test_factory.py index e402c21f..f8082f70 100644 --- a/tests/unit/abstracts/test_factory.py +++ b/tests/unit/abstracts/test_factory.py @@ -15,6 +15,7 @@ from merlin.abstracts import MerlinBaseFactory + # --- Dummy Components --- class DummyComponent: """A testable dummy component.""" @@ -157,7 +158,7 @@ def test_get_component_info_for_invalid_component(self, factory: TestableFactory Args: factory: An instance of the dummy `TestableFactory` class for testing. """ - with pytest.raises(RuntimeError, match="not supported"): # raises RuntimeError because of `_get_component_error_class` + with pytest.raises(RuntimeError, match="not supported"): # raises RuntimeError because of `_get_component_error_class` factory.get_component_info("not_registered") def test_discover_plugins_calls_both_hooks(self, mocker: MockerFixture, factory: TestableFactory): diff --git a/tests/unit/backends/test_backend_factory.py b/tests/unit/backends/test_backend_factory.py index 83b7622d..2c0d0ea4 100644 --- a/tests/unit/backends/test_backend_factory.py +++ b/tests/unit/backends/test_backend_factory.py @@ -47,7 +47,9 @@ def test_list_available_backends(self, backend_factory: MerlinBackendFactory): assert set(available) == {"redis", "sqlite"} @pytest.mark.parametrize("backend_type, expected_cls", [("redis", RedisBackend), ("sqlite", SQLiteBackend)]) - def test_create_valid_backend(self, backend_factory: MerlinBackendFactory, backend_type: str, expected_cls: ResultsBackend): + def test_create_valid_backend( + self, backend_factory: MerlinBackendFactory, backend_type: str, expected_cls: ResultsBackend + ): """ Test that `create` returns a valid backend instance for a registered name. @@ -86,8 +88,9 @@ def test_invalid_registration_type_error(self, backend_factory: MerlinBackendFac Args: backend_factory: An instance of the `MerlinBackendFactory` class for testing. """ + class NotAResultsBackend: pass with pytest.raises(TypeError, match="must inherit from ResultsBackend"): - backend_factory.register("fake", NotAResultsBackend) \ No newline at end of file + backend_factory.register("fake", NotAResultsBackend) diff --git a/tests/unit/monitor/test_monitor_factory.py b/tests/unit/monitor/test_monitor_factory.py index 72f2b0ad..ebd4934f 100644 --- a/tests/unit/monitor/test_monitor_factory.py +++ b/tests/unit/monitor/test_monitor_factory.py @@ -121,6 +121,7 @@ def test_register_invalid_component_type(self, monitor_factory: MonitorFactory): Args: monitor_factory: Instance of `MonitorFactory` for testing. """ + class NotAMonitor: pass diff --git a/tests/unit/study/test_status_renderer_factory.py b/tests/unit/study/test_status_renderer_factory.py index 7f85aa32..756b5392 100644 --- a/tests/unit/study/test_status_renderer_factory.py +++ b/tests/unit/study/test_status_renderer_factory.py @@ -40,11 +40,16 @@ def test_list_available_renderers(self, renderer_factory: MerlinStatusRendererFa available = renderer_factory.list_available() assert set(available) == {"default", "table"} - @pytest.mark.parametrize("renderer_type, expected_cls", [ - ("default", MerlinDefaultRenderer), - ("table", MerlinFlatRenderer), - ]) - def test_create_valid_renderer(self, renderer_factory: MerlinStatusRendererFactory, renderer_type: str, expected_cls: BaseStatusRenderer): + @pytest.mark.parametrize( + "renderer_type, expected_cls", + [ + ("default", MerlinDefaultRenderer), + ("table", MerlinFlatRenderer), + ], + ) + def test_create_valid_renderer( + self, renderer_factory: MerlinStatusRendererFactory, renderer_type: str, expected_cls: BaseStatusRenderer + ): """ Test that `create` returns the correct renderer instance for each type. """ @@ -62,6 +67,7 @@ def test_invalid_registration_raises_type_error(self, renderer_factory: MerlinSt """ Test that trying to register a non-renderer raises a TypeError. """ + class NotARenderer: pass From bfbfe0358a9ee4dfd9bccd892bfff7cb7f12a467 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 15:49:49 -0700 Subject: [PATCH 11/91] fix issue with typehint that breaks in python 3.8 --- merlin/abstracts/factory.py | 4 ++-- merlin/backends/backend_factory.py | 4 ++-- merlin/monitor/monitor_factory.py | 4 ++-- merlin/study/status_renderers.py | 4 ++-- tests/unit/abstracts/test_factory.py | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/merlin/abstracts/factory.py b/merlin/abstracts/factory.py index 57d61fd0..144436d6 100644 --- a/merlin/abstracts/factory.py +++ b/merlin/abstracts/factory.py @@ -18,7 +18,7 @@ import logging from abc import ABC, abstractmethod -from typing import Any, Dict, List +from typing import Any, Dict, List, Type import pkg_resources @@ -142,7 +142,7 @@ def _discover_plugins(self): self._discover_plugins_via_entry_points() self._discover_builtin_modules() - def _get_component_error_class(self) -> type[Exception]: + def _get_component_error_class(self) -> Type[Exception]: """ Return the exception type to raise when an invalid component is requested. diff --git a/merlin/backends/backend_factory.py b/merlin/backends/backend_factory.py index 2155f502..d9546f33 100644 --- a/merlin/backends/backend_factory.py +++ b/merlin/backends/backend_factory.py @@ -16,7 +16,7 @@ if an unsupported backend is requested. """ -from typing import Any +from typing import Any, Type from merlin.abstracts import MerlinBaseFactory from merlin.backends.redis.redis_backend import RedisBackend @@ -74,7 +74,7 @@ def _entry_point_group(self) -> str: """ return "merlin.backends" - def _get_component_error_class(self) -> type[Exception]: + def _get_component_error_class(self) -> Type[Exception]: """ Return the exception type to raise for unsupported components. diff --git a/merlin/monitor/monitor_factory.py b/merlin/monitor/monitor_factory.py index 04f50d34..4c003683 100644 --- a/merlin/monitor/monitor_factory.py +++ b/merlin/monitor/monitor_factory.py @@ -9,7 +9,7 @@ for supported task servers in Merlin. """ -from typing import Any +from typing import Any, Type from merlin.abstracts import MerlinBaseFactory from merlin.exceptions import MerlinInvalidTaskServerError @@ -71,7 +71,7 @@ def _entry_point_group(self) -> str: """ return "merlin.monitor" - def _get_component_error_class(self) -> type[Exception]: + def _get_component_error_class(self) -> Type[Exception]: """ Return the exception type to raise for unsupported components. diff --git a/merlin/study/status_renderers.py b/merlin/study/status_renderers.py index 68113048..762d767f 100644 --- a/merlin/study/status_renderers.py +++ b/merlin/study/status_renderers.py @@ -6,7 +6,7 @@ """This module handles creating a formatted task-by-task status display""" import logging -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Type, Union from maestrowf import BaseStatusRenderer, FlatStatusRenderer from rich import box @@ -457,7 +457,7 @@ def _entry_point_group(self) -> str: """ return "merlin.study" # TODO change this to merlin.status when we refactor status - def _get_component_error_class(self) -> type[Exception]: + def _get_component_error_class(self) -> Type[Exception]: """ Return the exception type to raise for unsupported components. diff --git a/tests/unit/abstracts/test_factory.py b/tests/unit/abstracts/test_factory.py index f8082f70..703ba409 100644 --- a/tests/unit/abstracts/test_factory.py +++ b/tests/unit/abstracts/test_factory.py @@ -8,7 +8,7 @@ Tests for the `factory.py` module of the `abstracts/` directory. """ -from typing import Any +from typing import Any, Type import pytest from pytest_mock import MockerFixture @@ -39,7 +39,7 @@ def _validate_component(self, component_class: Any) -> None: def _entry_point_group(self) -> str: return "merlin.test_plugins" - def _get_component_error_class(self) -> type[Exception]: + def _get_component_error_class(self) -> Type[Exception]: return RuntimeError # Use a distinct error type for test verification From 9a5e25796829b5252b7ba0926d2d6ee3d0c47a9c Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 16 Jul 2025 08:04:33 -0700 Subject: [PATCH 12/91] mocked more items to try to fix broken tests on github --- merlin/backends/redis/redis_backend.py | 6 ++-- tests/unit/backends/test_backend_factory.py | 37 ++++++++++++++++++--- tests/unit/monitor/test_monitor.py | 2 +- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/merlin/backends/redis/redis_backend.py b/merlin/backends/redis/redis_backend.py index 8126ea7f..285ec119 100644 --- a/merlin/backends/redis/redis_backend.py +++ b/merlin/backends/redis/redis_backend.py @@ -70,11 +70,9 @@ def __init__(self): from merlin.config.configfile import CONFIG # pylint: disable=import-outside-toplevel from merlin.config.results_backend import get_connection_string # pylint: disable=import-outside-toplevel - backend_name = CONFIG.results_backend.name - # Get the Redis client connection redis_config = {"url": get_connection_string(), "decode_responses": True} - if backend_name == "rediss": + if CONFIG.results_backend.name == "rediss": redis_config.update({"ssl_cert_reqs": getattr(CONFIG.results_backend, "cert_reqs", "required")}) self.client: Redis = Redis.from_url(**redis_config) @@ -86,7 +84,7 @@ def __init__(self): "physical_worker": RedisPhysicalWorkerStore(self.client), } - super().__init__(backend_name, stores) + super().__init__("redis", stores) def get_version(self) -> str: """ diff --git a/tests/unit/backends/test_backend_factory.py b/tests/unit/backends/test_backend_factory.py index 2c0d0ea4..82b93293 100644 --- a/tests/unit/backends/test_backend_factory.py +++ b/tests/unit/backends/test_backend_factory.py @@ -9,14 +9,35 @@ """ import pytest +from pytest_mock import MockerFixture from merlin.backends.backend_factory import MerlinBackendFactory -from merlin.backends.redis.redis_backend import RedisBackend from merlin.backends.results_backend import ResultsBackend -from merlin.backends.sqlite.sqlite_backend import SQLiteBackend from merlin.exceptions import BackendNotSupportedError +class DummyRedisBackend(ResultsBackend): + def __init__(self, *args, **kwargs): + pass + + def get_version(self): + pass + + def flush_database(self): + pass + + +class DummySQLiteBackend(ResultsBackend): + def __init__(self, *args, **kwargs): + pass + + def get_version(self): + pass + + def flush_database(self): + pass + + class TestMerlinBackendFactory: """ Test suite for the `MerlinBackendFactory`. @@ -27,13 +48,19 @@ class TestMerlinBackendFactory: """ @pytest.fixture - def backend_factory(self) -> MerlinBackendFactory: + def backend_factory(self, mocker: MockerFixture) -> MerlinBackendFactory: """ An instance of the `MerlinBackendFactory` class. Resets on each test. + Args: + mocker: PyTest mocker fixture. + Returns: An instance of the `MerlinBackendFactory` class for testing. """ + mocker.patch("merlin.backends.backend_factory.RedisBackend", DummyRedisBackend) + mocker.patch("merlin.backends.backend_factory.SQLiteBackend", DummySQLiteBackend) + return MerlinBackendFactory() def test_list_available_backends(self, backend_factory: MerlinBackendFactory): @@ -46,7 +73,7 @@ def test_list_available_backends(self, backend_factory: MerlinBackendFactory): available = backend_factory.list_available() assert set(available) == {"redis", "sqlite"} - @pytest.mark.parametrize("backend_type, expected_cls", [("redis", RedisBackend), ("sqlite", SQLiteBackend)]) + @pytest.mark.parametrize("backend_type, expected_cls", [("redis", DummyRedisBackend), ("sqlite", DummySQLiteBackend)]) def test_create_valid_backend( self, backend_factory: MerlinBackendFactory, backend_type: str, expected_cls: ResultsBackend ): @@ -69,7 +96,7 @@ def test_create_valid_backend_with_alias(self, backend_factory: MerlinBackendFac backend_factory: An instance of the `MerlinBackendFactory` class for testing. """ instance = backend_factory.create("rediss") - assert isinstance(instance, RedisBackend) + assert isinstance(instance, DummyRedisBackend) def test_create_invalid_backend_raises(self, backend_factory: MerlinBackendFactory): """ diff --git a/tests/unit/monitor/test_monitor.py b/tests/unit/monitor/test_monitor.py index 682076ee..235ee524 100644 --- a/tests/unit/monitor/test_monitor.py +++ b/tests/unit/monitor/test_monitor.py @@ -31,8 +31,8 @@ def monitor(mocker: MockerFixture) -> Monitor: A `Monitor` object with mocked properties. """ mock_spec = MagicMock(name="MockSpec") + mocker.patch("merlin.monitor.monitor.MerlinDatabase", autospec=True) mock_monitor = Monitor(spec=mock_spec, sleep=1, task_server="celery", no_restart=False) - mock_monitor.merlin_db = mocker.MagicMock(name="MockMerlinDB") mock_monitor.task_server_monitor = mocker.MagicMock(name="MockTaskServerMonitor") return mock_monitor From 4128bf8cf84f6c3a77f69a33c943b32a63d1cc2e Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Mon, 21 Jul 2025 13:42:28 -0700 Subject: [PATCH 13/91] create MerlinWorker and CeleryWorker classes to handle the launching of workers --- merlin/cli/commands/run_workers.py | 97 ++++++++++++-- merlin/exceptions/__init__.py | 9 ++ merlin/spec/specification.py | 29 ++++- merlin/study/batch.py | 31 +---- merlin/study/celeryadapter.py | 1 + merlin/workers/__init__.py | 13 ++ merlin/workers/celery_worker.py | 202 +++++++++++++++++++++++++++++ merlin/workers/worker.py | 73 +++++++++++ 8 files changed, 419 insertions(+), 36 deletions(-) create mode 100644 merlin/workers/__init__.py create mode 100644 merlin/workers/celery_worker.py create mode 100644 merlin/workers/worker.py diff --git a/merlin/cli/commands/run_workers.py b/merlin/cli/commands/run_workers.py index ce08a7f4..cdc35385 100644 --- a/merlin/cli/commands/run_workers.py +++ b/merlin/cli/commands/run_workers.py @@ -17,13 +17,15 @@ import logging from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace +from typing import List, Set, Union from merlin.ascii_art import banner_small from merlin.cli.commands.command_entry_point import CommandEntryPoint from merlin.cli.utils import get_merlin_spec_with_override from merlin.config.configfile import initialize_config from merlin.db_scripts.merlin_db import MerlinDatabase -from merlin.router import launch_workers +from merlin.spec.specification import MerlinSpec +from merlin.workers.celery_worker import CeleryWorker LOG = logging.getLogger("merlin") @@ -93,6 +95,75 @@ def add_parser(self, subparsers: ArgumentParser): "in your workers' args section will overwrite this flag for that worker.", ) + # TODO when we move the queues setting to within the worker then this will no longer be necessary + def _get_workers_to_start(self, spec: MerlinSpec, steps: Union[List[str], None]) -> Set[str]: + """ + Determine the set of workers to start based on the specified steps (if any) + + This helper function retrieves a mapping of steps to their corresponding workers + from a [`MerlinSpec`][spec.specification.MerlinSpec] object and returns a unique + set of workers that should be started for the provided list of steps. If a step + is not found in the mapping, a warning is logged. + + Args: + spec (spec.specification.MerlinSpec): An instance of the + [`MerlinSpec`][spec.specification.MerlinSpec] class that contains the + mapping of steps to workers. + steps: A list of steps for which workers need to be started or None if the user + didn't provide specific steps. + + Returns: + A set of unique workers to be started based on the specified steps. + """ + steps_provided = False if "all" in steps else True + + if steps_provided: + workers_to_start = [] + step_worker_map = spec.get_step_worker_map() + for step in steps: + try: + workers_to_start.extend(step_worker_map[step]) + except KeyError: + LOG.warning(f"Cannot start workers for step: {step}. This step was not found.") + + workers_to_start = set(workers_to_start) + else: + workers_to_start = set(spec.merlin["resources"]["workers"]) + + LOG.debug(f"workers_to_start: {workers_to_start}") + return workers_to_start + + # TODO this should move to TaskServerInterface and be abstracted (build_worker_list ?) + def _get_worker_instances(self, workers_to_start: Set[str], spec: MerlinSpec) -> List[CeleryWorker]: + """ + """ + workers = [] + all_workers = spec.merlin["resources"]["workers"] + overlap = spec.merlin["resources"]["overlap"] + full_env = spec.get_full_environment() + + for worker_name in workers_to_start: + settings = all_workers[worker_name] + config = { + "args": settings.get("args", ""), + "machines": settings.get("machines", []), + "queues": spec.get_queue_list(settings["steps"]), + "batch": settings["batch"] if settings["batch"] is not None else spec.batch.copy() + } + + if "nodes" in settings and settings["nodes"] is not None: + if config["batch"]: + config["batch"]["nodes"] = settings["nodes"] + else: + config["batch"] = {"nodes": settings["nodes"]} + + LOG.debug(f"config for worker '{worker_name}': {config}") + + workers.append(CeleryWorker(name=worker_name, config=config, env=full_env, overlap=overlap)) + LOG.debug(f"Created CeleryWorker object for worker '{worker_name}'.") + + return workers + def process_command(self, args: Namespace): """ CLI command for launching workers. @@ -126,12 +197,18 @@ def process_command(self, args: Namespace): worker_queues = {step_queue_map[step] for step in steps} merlin_db.create("logical_worker", worker, worker_queues) - # Launch the workers - launch_worker_status = launch_workers( - spec, args.worker_steps, args.worker_args, args.disable_logs, args.worker_echo_only - ) - - if args.worker_echo_only: - print(launch_worker_status) - else: - LOG.debug(f"celery command: {launch_worker_status}") + # Get the names of the workers that the user is requesting to start + workers_to_start = self._get_workers_to_start(spec, args.worker_steps) + + # Build a list of MerlinWorker instances + worker_instances = self._get_worker_instances(workers_to_start, spec) + + # Launch the workers or echo out the command that will be used to launch the workers + for worker in worker_instances: + if args.worker_echo_only: + LOG.debug(f"Not launching worker '{worker.name}', just echoing command.") + launch_cmd = worker.get_launch_command(override_args=args.worker_args, disable_logs=args.disable_logs) + print(launch_cmd) + else: + LOG.debug(f"Launching worker '{worker.name}'.") + worker.launch_worker(override_args=args.worker_args, disable_logs=args.disable_logs) diff --git a/merlin/exceptions/__init__.py b/merlin/exceptions/__init__.py index 1cabe577..93ce253b 100644 --- a/merlin/exceptions/__init__.py +++ b/merlin/exceptions/__init__.py @@ -108,6 +108,15 @@ def __init__(self, message): super().__init__(message) +class MerlinWorkerLaunchError(Exception): + """ + Exception to signal that an there was a problem when launching workers. + """ + + def __init__(self, message): + super().__init__(message) + + ############################### # Database-Related Exceptions # ############################### diff --git a/merlin/spec/specification.py b/merlin/spec/specification.py index d9218a01..da272269 100644 --- a/merlin/spec/specification.py +++ b/merlin/spec/specification.py @@ -24,7 +24,7 @@ from maestrowf.specification import YAMLSpecification from merlin.spec import all_keys, defaults -from merlin.utils import find_vlaunch_var, load_array_file, needs_merlin_expansion, repr_timedelta +from merlin.utils import find_vlaunch_var, get_yaml_var, load_array_file, needs_merlin_expansion, repr_timedelta LOG = logging.getLogger(__name__) @@ -1172,3 +1172,30 @@ def get_step_param_map(self) -> Dict: # pylint: disable=R0914 step_param_map[step_name_with_params]["restart_cmd"][token] = param_value return step_param_map + + def get_full_environment(self): + """ + Construct the full environment for the current context. + + This method starts with a copy of the current OS environment and + overlays any additional environment variables defined in the spec's + `environment` section. These variables are added both to the returned + dictionary and the live `os.environ` to support variable expansion. + + Returns: + dict: A dictionary representing the full environment with any + user-defined variables applied. + """ + # Start with the global environment + full_env = os.environ.copy() + + # If the environment from the spec has anything in it, + # read in the variables and save them to the shell environment + if self.environment: + yaml_vars = get_yaml_var(self.environment, "variables", {}) + for var_name, var_val in yaml_vars.items(): + full_env[str(var_name)] = str(var_val) + # For expandvars + os.environ[str(var_name)] = str(var_val) + + return full_env diff --git a/merlin/study/batch.py b/merlin/study/batch.py index 61ba4870..643a3853 100644 --- a/merlin/study/batch.py +++ b/merlin/study/batch.py @@ -22,7 +22,7 @@ LOG = logging.getLogger(__name__) -def batch_check_parallel(spec: MerlinSpec) -> bool: +def batch_check_parallel(batch: Dict) -> bool: """ Check for a parallel batch section in the provided MerlinSpec object. @@ -33,9 +33,8 @@ def batch_check_parallel(spec: MerlinSpec) -> bool: parallel processing is enabled. Args: - spec (spec.specification.MerlinSpec): An instance of the - [`MerlinSpec`][spec.specification.MerlinSpec] class that contains the - configuration details, including the batch section. + batch: The batch section from either the YAML `batch` block or the worker-specific + batch block. Returns: Returns True if the batch type is set to a value other than 'local', @@ -47,12 +46,6 @@ def batch_check_parallel(spec: MerlinSpec) -> bool: """ parallel = False - try: - batch = spec.batch - except AttributeError as exc: - LOG.error("The batch section is required in the specification file.") - raise exc - btype = get_yaml_var(batch, "type", "local") if btype != "local": parallel = True @@ -303,10 +296,9 @@ def get_flux_launch(parsed_batch: Dict) -> str: def batch_worker_launch( - spec: MerlinSpec, + batch: Dict, com: str, nodes: Union[str, int] = None, - batch: Dict = None, ) -> str: """ Create the worker launch command based on the batch configuration in the @@ -318,15 +310,11 @@ def batch_worker_launch( node specifications. Args: - spec (spec.specification.MerlinSpec): An instance of the - [`MerlinSpec`][spec.specification.MerlinSpec] class that contains the - configuration details, including the batch section. + batch: The batch section from either the YAML `batch` block or the worker-specific + batch block. com: The command to launch with the batch configuration. nodes: The number of nodes to use in the batch launch. If not specified, it will default to the value in the batch configuration. - batch: An optional batch override from the worker configuration. If not - provided, the function will attempt to retrieve the batch section from - the specification. Returns: The constructed worker launch command, ready to be executed. @@ -335,13 +323,6 @@ def batch_worker_launch( AttributeError: If the batch section is missing in the specification. TypeError: If the `nodes` parameter is of an invalid type. """ - if batch is None: - try: - batch = spec.batch - except AttributeError: - LOG.error("The batch section is required in the specification file.") - raise - parsed_batch = parse_batch_block(batch) # A jsrun submission cannot be run under a parent jsrun so diff --git a/merlin/study/celeryadapter.py b/merlin/study/celeryadapter.py index 2840f5fc..f650ee61 100644 --- a/merlin/study/celeryadapter.py +++ b/merlin/study/celeryadapter.py @@ -68,6 +68,7 @@ def run_celery(study: MerlinStudy, run_mode: str = None): queue_merlin_study(study, adapter_config) +# TODO should probably create a celery_utils.py file or something and store this function there def get_running_queues(celery_app_name: str, test_mode: bool = False) -> List[str]: """ Check for running Celery workers and retrieve their associated queues. diff --git a/merlin/workers/__init__.py b/merlin/workers/__init__.py new file mode 100644 index 00000000..11187717 --- /dev/null +++ b/merlin/workers/__init__.py @@ -0,0 +1,13 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" + +""" + +from merlin.workers.celery_worker import CeleryWorker + +__all__ = ["CeleryWorker"] \ No newline at end of file diff --git a/merlin/workers/celery_worker.py b/merlin/workers/celery_worker.py new file mode 100644 index 00000000..73aadb77 --- /dev/null +++ b/merlin/workers/celery_worker.py @@ -0,0 +1,202 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Implements a Celery-based MerlinWorker. + +This module defines the `CeleryWorker` class, which extends the abstract +`MerlinWorker` base class to implement worker launching and management using +Celery. Celery workers are responsible for processing tasks from specified queues +and can be launched either locally or through a batch system. +""" + +import logging +import os +import socket +import subprocess +import time +from typing import Dict + + +from merlin.exceptions import MerlinWorkerLaunchError +from merlin.study.batch import batch_check_parallel, batch_worker_launch +from merlin.study.celeryadapter import get_running_queues +from merlin.utils import check_machines +from merlin.workers.worker import MerlinWorker + +LOG = logging.getLogger(__name__) + + +class CeleryWorker(MerlinWorker): + """ + Concrete implementation of a single Celery-based Merlin worker. + + This class provides logic for validating configuration, constructing launch + commands, checking launch eligibility, and launching Celery workers that process + jobs from specific task queues. + + Attributes: + name (str): The name of the worker. + config (dict): Configuration settings for the worker. + env (dict): Environment variables used by the worker process. + args (str): Additional CLI arguments passed to Celery. + queues (List[str]): Queues the worker listens to. + batch (dict): Optional batch submission settings. + machines (List[str]): List of hostnames the worker is allowed to run on. + overlap (bool): Whether this worker can overlap queues with others. + + Methods: + _verify_args: Validate and adjust CLI args based on worker setup. + get_launch_command: Construct the Celery launch command. + should_launch: Determine whether the worker should be launched based on system state. + launch_worker: Launch the worker using subprocess. + get_metadata: Return identifying metadata about the worker. + """ + + def __init__( + self, + name: str, + config: Dict, + env: Dict[str, str] = None, + overlap: bool = False, + ): + """ + Constructor for Celery workers. + + Args: + name: The name of the worker. + config: A dictionary containing optional configuration settings for this worker including:\n + - `args`: A string of arguments to pass to the launch command + - `queues`: A list of task queues for this worker to watch + - `batch`: A dictionary of specific batch configuration settings to use for this worker + - `nodes`: The number of nodes to launch this worker on + - `machines`: A list of machines that this worker is allowed to run on + env: A dictionary of environment variables set by the user. + overlap: If True multiple workers can pull tasks from overlapping queues. + """ + super().__init__(name, config, env) + self.args = self.config.get("args", "") + self.queues = self.config.get("queues", ["[merlin]_merlin"]) + self.batch = self.config.get("batch", {}) + self.machines = self.config.get("machines", []) + self.overlap = overlap + + def _verify_args(self, disable_logs: bool = False) -> str: + """ + Validate and modify the CLI arguments for the Celery worker. + + Adds concurrency and logging-related flags if necessary, and ensures + the worker name is unique when overlap is allowed. + + Args: + disable_logs: If True, logging level will not be appended. + """ + if batch_check_parallel(self.batch): + if "--concurrency" not in self.args: + LOG.warning("Missing --concurrency in worker args for parallel tasks.") + if "--prefetch-multiplier" not in self.args: + LOG.warning("Missing --prefetch-multiplier in worker args for parallel tasks.") + if "fair" not in self.args: + LOG.warning("Missing -O fair in worker args for parallel tasks.") + + if "-n" not in self.args: + nhash = time.strftime("%Y%m%d-%H%M%S") if self.overlap else "" + self.args += f" -n {self.name}{nhash}.%%h" + + if not disable_logs and "-l" not in self.args: + self.args += f" -l {logging.getLevelName(LOG.getEffectiveLevel())}" + + def get_launch_command(self, override_args: str = "", disable_logs: bool = False) -> str: + """ + Construct the shell command to launch this Celery worker. + + Args: + override_args: If provided, these arguments will replace the default `args`. + disable_logs: If True, logging level will not be added to the command. + + Returns: + A shell command string suitable for subprocess execution. + """ + # Override existing arguments if necessary + if override_args != "": + self.args = override_args + + # Validate args + self._verify_args(disable_logs=disable_logs) + + # Construct the launch command + celery_cmd = f"celery -A merlin worker {self.args} -Q {self.queues}" + nodes = self.batch.get("nodes", None) + launch_cmd = batch_worker_launch(self.batch, celery_cmd, nodes=nodes) + return os.path.expandvars(launch_cmd) + + def should_launch(self) -> bool: + """ + Determine whether this worker should be launched. + + Performs checks on allowed machines and queue overlap (if applicable). + + Returns: + True if the worker should be launched, False otherwise. + """ + machines = self.config.get("machines", None) + queues = self.config.get("queues", ["[merlin]_merlin"]) + + if machines: + if not check_machines(machines): + LOG.error( + f"The following machines were provided for worker '{self.name}': {machines}. However, the current machine '{socket.gethostname()}' is not in this list." + ) + return False + + output_path = self.env.get("OUTPUT_PATH") + if output_path and not os.path.exists(output_path): + LOG.error(f"{output_path} not accessible on host {socket.gethostname()}") + return False + + if not self.overlap: + running_queues = get_running_queues("merlin") + for queue in queues: + if queue in running_queues: + LOG.warning(f"Queue {queue} is already being processed by another worker.") + return False + + return True + + def launch_worker(self, override_args: str = "", disable_logs: bool = False): + """ + Launch the worker as a subprocess using the constructed launch command. + + Args: + override_args: Optional CLI arguments to override the default worker args. + disable_logs: If True, suppresses automatic logging level injection. + + Raises: + MerlinWorkerLaunchError: If the worker fails to launch. + """ + if self.should_launch(): + launch_cmd = self.get_launch_command(override_args=override_args, disable_logs=disable_logs) + try: + subprocess.Popen(launch_cmd, env=self.env, shell=True, universal_newlines=True) # pylint: disable=R1732 + LOG.debug(f"Launched worker '{self.name}' with command: {launch_cmd}.") + except Exception as e: # pylint: disable=C0103 + LOG.error(f"Cannot start celery workers, {e}") + raise MerlinWorkerLaunchError + + def get_metadata(self) -> Dict: + """ + Return metadata about this worker instance. + + Returns: + A dictionary containing key details about this worker. + """ + return { + "name": self.name, + "queues": self.queues, + "args": self.args, + "machines": self.machines, + "batch": self.batch, + } diff --git a/merlin/workers/worker.py b/merlin/workers/worker.py new file mode 100644 index 00000000..954c7501 --- /dev/null +++ b/merlin/workers/worker.py @@ -0,0 +1,73 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Defines an abstract base class for a single Merlin worker. +""" + +import os +from abc import ABC, abstractmethod +from typing import Dict + + +class MerlinWorker(ABC): + """ + Abstract base class representing a single task server worker. + + This class defines the required interface for constructing and launching + an individual worker based on its configuration. + + Attributes: + name: The name of the worker. + config: The dictionary configuration for the worker. + env: A dictionary representing the full environment for the current context. + + Methods: + get_launch_command: Build the shell command to launch the worker. + launch_worker: Launch the worker process. + get_metadata: Return identifying metadata about the worker. + """ + + def __init__(self, name: str, config: Dict, env: Dict[str, str] = None): + """ + Initialize a `MerlinWorker` instance. + + Args: + name: The name of the worker. + config: A dictionary containing the worker configuration. + env: Optional dictionary of environment variables to use; if not provided, + a copy of the current OS environment is used. + """ + self.name = name + self.config = config + self.env = env or os.environ.copy() + + @abstractmethod + def get_launch_command(self, override_args: str = "") -> str: + """ + Build the command to launch this worker. + + Args: + override_args: CLI arguments to override the default ones from the spec. + + Returns: + A shell command string. + """ + + @abstractmethod + def launch_worker(self): + """ + Launch this worker. + """ + + @abstractmethod + def get_metadata(self) -> Dict: + """ + Return a dictionary of metadata about this worker (for logging/debugging). + + Returns: + A metadata dictionary (e.g., name, queues, machines). + """ From 5d554ecbbadcc81e0064d5a0f56ade5f13c59a7f Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 17:51:47 -0700 Subject: [PATCH 14/91] implement worker-handler related classes --- merlin/workers/handlers/__init__.py | 28 +++++++ merlin/workers/handlers/celery_handler.py | 75 +++++++++++++++++++ merlin/workers/handlers/handler_factory.py | 87 ++++++++++++++++++++++ merlin/workers/handlers/worker_handler.py | 66 ++++++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 merlin/workers/handlers/__init__.py create mode 100644 merlin/workers/handlers/celery_handler.py create mode 100644 merlin/workers/handlers/handler_factory.py create mode 100644 merlin/workers/handlers/worker_handler.py diff --git a/merlin/workers/handlers/__init__.py b/merlin/workers/handlers/__init__.py new file mode 100644 index 00000000..b95145b8 --- /dev/null +++ b/merlin/workers/handlers/__init__.py @@ -0,0 +1,28 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Worker handler interface and implementations for Merlin task servers. + +The `handlers` package defines the extensible framework for managing task server +workers in Merlin. It includes an abstract base interface, a concrete implementation +for Celery, and a factory for dynamic registration and instantiation of worker handlers. + +This design allows Merlin to support multiple task server backends through a consistent +interface while enabling future integration with additional systems such as Kafka. + +Modules: + handler_factory.py: Factory for registering and instantiating Merlin worker + handler implementations. + worker_handler.py: Abstract base class that defines the interface for all Merlin + worker handlers. + celery_handler.py: Celery-specific implementation of the worker handler interface. +""" + + +from merlin.workers.handlers.celery_handler import CeleryWorkerHandler + +__all__ = ["CeleryWorkerHandler"] diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py new file mode 100644 index 00000000..25df733b --- /dev/null +++ b/merlin/workers/handlers/celery_handler.py @@ -0,0 +1,75 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Provides a concrete implementation of the +[`MerlinWorkerHandler`][workers.handlers.worker_handler.MerlinWorkerHandler] for Celery. + +This module defines the `CeleryWorkerHandler` class, which is responsible for launching, +stopping, and querying Celery-based worker processes. It supports additional options +such as echoing launch commands, overriding default worker arguments, and disabling logs. +""" + +import logging +from typing import List + +from merlin.workers import CeleryWorker +from merlin.workers.handlers.worker_handler import MerlinWorkerHandler + + +LOG = logging.getLogger("merlin") + + +class CeleryWorkerHandler(MerlinWorkerHandler): + """ + Worker handler for launching and managing Celery-based Merlin workers. + + This class implements the abstract methods defined in + [`MerlinWorkerHandler`][workers.handlers.worker_handler.MerlinWorkerHandler] to provide + Celery-specific behavior, including launching workers with optional command-line overrides, + stopping workers, and querying their status. + + Methods: + launch_workers: Launch or echo Celery workers with optional arguments. + stop_workers: Attempt to stop active Celery workers. + query_workers: Return a basic summary of Celery worker status. + """ + + def launch_workers(self, workers: List[CeleryWorker], **kwargs): + """ + Launch or echo Celery workers with optional override behavior. + + Args: + workers (List[CeleryWorker]): Workers to launch. + **kwargs: + - echo_only (bool): If True, print the launch command instead of running it. + - override_args (str): Arguments to override default worker args. + - disable_logs (bool): If True, disables logging during worker launch. + """ + echo_only = kwargs.get("echo_only", False) + override_args = kwargs.get("override_args", "") + disable_logs = kwargs.get("disable_logs", False) + + # Launch the workers or echo out the command that will be used to launch the workers + for worker in workers: + if echo_only: + LOG.debug(f"Not launching worker '{worker.name}', just echoing command.") + launch_cmd = worker.get_launch_command(override_args=override_args, disable_logs=disable_logs) + print(launch_cmd) + else: + LOG.debug(f"Launching worker '{worker.name}'.") + worker.launch_worker(override_args=override_args, disable_logs=disable_logs) + + def stop_workers(self): + """ + Attempt to stop Celery workers. + """ + + def query_workers(self): + """ + Query the status of Celery workers. + """ + \ No newline at end of file diff --git a/merlin/workers/handlers/handler_factory.py b/merlin/workers/handlers/handler_factory.py new file mode 100644 index 00000000..a4c125ff --- /dev/null +++ b/merlin/workers/handlers/handler_factory.py @@ -0,0 +1,87 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Factory for registering and instantiating Merlin worker handler implementations. + +This module defines the `WorkerHandlerFactory`, which manages the lifecycle and registration +of supported task server worker handlers (e.g., Celery). It extends `MerlinBaseFactory` to +provide a pluggable architecture for loading handlers via entry points or direct registration. + +The factory enforces type safety by validating that all registered components inherit from +`MerlinWorkerHandler`. It also provides aliasing support and a standard mechanism for plugin +discovery and instantiation. +""" + +from typing import Any, Type + +from merlin.abstracts import MerlinBaseFactory +from merlin.exceptions import MerlinWorkerHandlerNotSupportedError +from merlin.workers.handlers import CeleryWorkerHandler +from merlin.workers.handlers.worker_handler import MerlinWorkerHandler + + +class WorkerHandlerFactory(MerlinBaseFactory): + """ + Factory class for managing and instantiating supported Merlin worker handlers. + + This subclass of `MerlinBaseFactory` handles registration, validation, + and instantiation of worker handlers (e.g., Celery, Kafka). + + Attributes: + _registry (Dict[str, MerlinWorkerHandler]): Maps canonical handler names to handler classes. + _aliases (Dict[str, str]): Maps legacy or alternate names to canonical handler names. + + Methods: + register: Register a new handler class and optional aliases. + list_available: Return a list of supported handler names. + create: Instantiate a handler class by name or alias. + get_component_info: Return metadata about a registered handler. + """ + + def _register_builtins(self): + """ + Register built-in worker handler implementations. + """ + self.register("celery", CeleryWorkerHandler) + + def _validate_component(self, component_class: Any): + """ + Ensure registered component is a subclass of MerlinWorkerHandler. + + Args: + component_class: The class to validate. + + Raises: + TypeError: If the component does not subclass MerlinWorkerHandler. + """ + if not issubclass(component_class, MerlinWorkerHandler): + raise TypeError(f"{component_class} must inherit from MerlinWorkerHandler") + + def _entry_point_group(self) -> str: + """ + Entry point group used for discovering worker handler plugins. + + Returns: + The entry point namespace for Merlin worker handler plugins. + """ + return "merlin.workers.handlers" + + def _get_component_error_class(self) -> Type[Exception]: + """ + Return the exception type to raise for unsupported components. + + This method is used by the base factory logic to determine which + exception to raise when a requested component is not found or fails + to initialize. + + Returns: + The exception class to raise. + """ + return MerlinWorkerHandlerNotSupportedError + + +worker_handler_factory = WorkerHandlerFactory() diff --git a/merlin/workers/handlers/worker_handler.py b/merlin/workers/handlers/worker_handler.py new file mode 100644 index 00000000..1ae6e2a0 --- /dev/null +++ b/merlin/workers/handlers/worker_handler.py @@ -0,0 +1,66 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Defines an abstract base class for worker handlers in the Merlin workflow framework. + +Worker handlers are responsible for launching, stopping, and querying the status +of task server workers (e.g., Celery workers). This interface allows support +for different task servers to be plugged in with consistent behavior. +""" + +from abc import ABC, abstractmethod +from typing import Any, List + +from merlin.workers.worker import MerlinWorker + + +class MerlinWorkerHandler(ABC): + """ + Abstract base class for launching and managing Merlin worker processes. + + Subclasses must implement the methods to launch, stop, and query workers + using a particular task server (e.g., Celery, Kafka, etc.). + + Methods: + launch_workers: Launch a list of MerlinWorker instances with optional configuration. + stop_workers: Stop running worker processes managed by this handler. + query_workers: Query the status of running workers and return summary information. + """ + + def __init__(self): + """Initialize the worker handler.""" + + @abstractmethod + def launch_workers(self, workers: List[MerlinWorker], **kwargs): + """ + Launch a list of worker instances. + + Args: + workers (List[MerlinWorker]): The list of workers to launch. + **kwargs: Optional keyword arguments passed to subclass-specific logic. + """ + raise NotImplementedError("Subclasses of `MerlinWorkerHandler` must implement a `launch_workers` method.") + + @abstractmethod + def stop_workers(self): + """ + Stop worker processes. + + This method should terminate any active worker sessions that were previously launched. + """ + raise NotImplementedError("Subclasses of `MerlinWorkerHandler` must implement a `stop_workers` method.") + + @abstractmethod + def query_workers(self) -> Any: + """ + Query the status of all currently running workers. + + Returns: + Subclasses should return an appropriate data structure summarizing + the current state of managed workers (e.g., dict, list, string). + """ + raise NotImplementedError("Subclasses of `MerlinWorkerHandler` must implement a `query_workers` method.") From 45afd6407d53abb2618c4ff1dfef81eb29de57b7 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 17:52:27 -0700 Subject: [PATCH 15/91] add worker factory class and small cleanup to the rest of the worker files --- merlin/workers/__init__.py | 22 ++++++++ merlin/workers/celery_worker.py | 18 ++++--- merlin/workers/worker.py | 10 +++- merlin/workers/worker_factory.py | 88 ++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 merlin/workers/worker_factory.py diff --git a/merlin/workers/__init__.py b/merlin/workers/__init__.py index 11187717..784eba2a 100644 --- a/merlin/workers/__init__.py +++ b/merlin/workers/__init__.py @@ -5,7 +5,29 @@ ############################################################################## """ +Worker framework for managing task execution in Merlin. +The `workers` package defines the core abstractions and implementations for launching +and managing task server workers in the Merlin workflow framework. It includes an +extensible system for defining worker behavior, instantiating worker instances, and +handling task server-specific logic (e.g., Celery). + +This package supports a plugin-based architecture through factories, allowing new +task server backends to be added seamlessly via Python entry points. + +Subpackages: + - `handlers/`: Defines the interface and implementations for worker handler classes + responsible for launching and managing groups of workers. + +Modules: + worker.py: Defines the `MerlinWorker` abstract base class, which represents a single + task server worker and provides a common interface for launching and + configuring worker instances. + celery_worker.py: Implements `CeleryWorker`, a concrete subclass of `MerlinWorker` that uses + Celery to process tasks from configured queues. Supports local and batch launch modes. + worker_factory.py: Defines the `WorkerFactory`, which manages the registration, validation, + and instantiation of individual worker implementations such as `CeleryWorker`. + Supports plugin discovery via entry points. """ from merlin.workers.celery_worker import CeleryWorker diff --git a/merlin/workers/celery_worker.py b/merlin/workers/celery_worker.py index 73aadb77..def1ddb7 100644 --- a/merlin/workers/celery_worker.py +++ b/merlin/workers/celery_worker.py @@ -20,14 +20,13 @@ import time from typing import Dict - +from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.exceptions import MerlinWorkerLaunchError from merlin.study.batch import batch_check_parallel, batch_worker_launch -from merlin.study.celeryadapter import get_running_queues from merlin.utils import check_machines from merlin.workers.worker import MerlinWorker -LOG = logging.getLogger(__name__) +LOG = logging.getLogger("merlin") class CeleryWorker(MerlinWorker): @@ -66,11 +65,13 @@ def __init__( """ Constructor for Celery workers. + Sets up attributes used throughout this worker object and saves this worker to the database. + Args: name: The name of the worker. config: A dictionary containing optional configuration settings for this worker including:\n - `args`: A string of arguments to pass to the launch command - - `queues`: A list of task queues for this worker to watch + - `queues`: A set of task queues for this worker to watch - `batch`: A dictionary of specific batch configuration settings to use for this worker - `nodes`: The number of nodes to launch this worker on - `machines`: A list of machines that this worker is allowed to run on @@ -79,11 +80,15 @@ def __init__( """ super().__init__(name, config, env) self.args = self.config.get("args", "") - self.queues = self.config.get("queues", ["[merlin]_merlin"]) + self.queues = self.config.get("queues", {"[merlin]_merlin"}) self.batch = self.config.get("batch", {}) self.machines = self.config.get("machines", []) self.overlap = overlap + # Add this worker to the database + merlin_db = MerlinDatabase() + merlin_db.create("logical_worker", self.name, self.queues) + def _verify_args(self, disable_logs: bool = False) -> str: """ Validate and modify the CLI arguments for the Celery worker. @@ -128,7 +133,7 @@ def get_launch_command(self, override_args: str = "", disable_logs: bool = False self._verify_args(disable_logs=disable_logs) # Construct the launch command - celery_cmd = f"celery -A merlin worker {self.args} -Q {self.queues}" + celery_cmd = f"celery -A merlin worker {self.args} -Q {','.join(self.queues)}" nodes = self.batch.get("nodes", None) launch_cmd = batch_worker_launch(self.batch, celery_cmd, nodes=nodes) return os.path.expandvars(launch_cmd) @@ -158,6 +163,7 @@ def should_launch(self) -> bool: return False if not self.overlap: + from merlin.study.celeryadapter import get_running_queues running_queues = get_running_queues("merlin") for queue in queues: if queue in running_queues: diff --git a/merlin/workers/worker.py b/merlin/workers/worker.py index 954c7501..7ba6c617 100644 --- a/merlin/workers/worker.py +++ b/merlin/workers/worker.py @@ -5,7 +5,15 @@ ############################################################################## """ -Defines an abstract base class for a single Merlin worker. +Defines an abstract base class for a single Merlin worker instance. + +This module provides the `MerlinWorker` interface, which standardizes how individual +task server workers are defined, configured, and launched in the Merlin framework. +Each concrete implementation (e.g., for Celery or other task servers) must provide +logic for constructing the launch command, starting the process, and exposing worker metadata. + +This abstraction allows Merlin to support multiple task execution backends while maintaining +a consistent interface for launching and managing worker processes. """ import os diff --git a/merlin/workers/worker_factory.py b/merlin/workers/worker_factory.py new file mode 100644 index 00000000..fd86f83e --- /dev/null +++ b/merlin/workers/worker_factory.py @@ -0,0 +1,88 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Factory for registering and instantiating individual Merlin worker implementations. + +This module defines the `WorkerFactory`, a subclass of +[`MerlinBaseFactory`][abstracts.factory.MerlinBaseFactory], which manages +the registration, validation, and creation of concrete worker classes such as +[`CeleryWorker`][workers.celery_worker.CeleryWorker]. It supports plugin-based discovery +via Python entry points, enabling extensibility for other task server backends (e.g., Kafka). + +The factory ensures that all registered components conform to the `MerlinWorker` interface +and provides useful utilities such as aliasing and error handling for unsupported components. +""" + +from typing import Any, Type + +from merlin.abstracts import MerlinBaseFactory +from merlin.exceptions import MerlinWorkerNotSupportedError +from merlin.workers import CeleryWorker +from merlin.workers.worker import MerlinWorker + + +class WorkerFactory(MerlinBaseFactory): + """ + Factory class for managing and instantiating supported Merlin workers. + + This subclass of `MerlinBaseFactory` handles registration, validation, + and instantiation of workers (e.g., Celery, Kafka). + + Attributes: + _registry (Dict[str, MerlinWorker]): Maps canonical worker names to worker classes. + _aliases (Dict[str, str]): Maps legacy or alternate names to canonical worker names. + + Methods: + register: Register a new worker class and optional aliases. + list_available: Return a list of supported worker names. + create: Instantiate a worker class by name or alias. + get_component_info: Return metadata about a registered worker. + """ + + def _register_builtins(self): + """ + Register built-in worker implementations. + """ + self.register("celery", CeleryWorker) + + def _validate_component(self, component_class: Any): + """ + Ensure registered component is a subclass of MerlinWorker. + + Args: + component_class: The class to validate. + + Raises: + TypeError: If the component does not subclass MerlinWorker. + """ + if not issubclass(component_class, MerlinWorker): + raise TypeError(f"{component_class} must inherit from MerlinWorker") + + def _entry_point_group(self) -> str: + """ + Entry point group used for discovering worker plugins. + + Returns: + The entry point namespace for Merlin worker plugins. + """ + return "merlin.workers" + + def _get_component_error_class(self) -> Type[Exception]: + """ + Return the exception type to raise for unsupported components. + + This method is used by the base factory logic to determine which + exception to raise when a requested component is not found or fails + to initialize. + + Returns: + The exception class to raise. + """ + return MerlinWorkerNotSupportedError + + +worker_factory = WorkerFactory() From edead13fa10c7424ac673e360e054bfb651d2ba8 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 17:53:59 -0700 Subject: [PATCH 16/91] remove functions that are now in the new worker files --- merlin/router.py | 41 ---- merlin/study/celeryadapter.py | 378 ---------------------------------- 2 files changed, 419 deletions(-) diff --git a/merlin/router.py b/merlin/router.py index 51ad03a2..d3ea0b91 100644 --- a/merlin/router.py +++ b/merlin/router.py @@ -28,7 +28,6 @@ query_celery_queues, query_celery_workers, run_celery, - start_celery_workers, stop_celery_workers, ) from merlin.study.study import MerlinStudy @@ -62,46 +61,6 @@ def run_task_server(study: MerlinStudy, run_mode: str = None): LOG.error("Celery is not specified as the task server!") -def launch_workers( - spec: MerlinSpec, - steps: List[str], - worker_args: str = "", - disable_logs: bool = False, - just_return_command: bool = False, -) -> str: - """ - Launches workers for the specified study based on the provided - specification and steps. - - This function checks if Celery is configured as the task server - and initiates the specified workers accordingly. It provides options - for additional worker arguments, logging control, and command-only - execution without launching the workers. - - Args: - spec (spec.specification.MerlinSpec): Specification details - necessary for launching the workers. - steps: The specific steps in the specification that the workers - will be associated with. - worker_args: Additional arguments to be passed to the workers. - Defaults to an empty string. - disable_logs: Flag to disable logging during worker execution. - Defaults to False. - just_return_command: If True, the function will not execute the - command but will return it instead. Defaults to False. - - Returns: - A string of the worker launch command(s). - """ - if spec.merlin["resources"]["task_server"] == "celery": # pylint: disable=R1705 - # Start workers - cproc = start_celery_workers(spec, steps, worker_args, disable_logs, just_return_command) - return cproc - else: - LOG.error("Celery is not specified as the task server!") - return "No workers started" - - def purge_tasks(task_server: str, spec: MerlinSpec, force: bool, steps: List[str]) -> int: """ Purges all tasks from the specified task server. diff --git a/merlin/study/celeryadapter.py b/merlin/study/celeryadapter.py index f650ee61..b3543def 100644 --- a/merlin/study/celeryadapter.py +++ b/merlin/study/celeryadapter.py @@ -621,384 +621,6 @@ def check_celery_workers_processing(queues_in_spec: List[str], app: Celery) -> b return False -def _get_workers_to_start(spec: MerlinSpec, steps: List[str]) -> Set[str]: - """ - Determine the set of workers to start based on the specified steps. - - This helper function retrieves a mapping of steps to their corresponding workers - from a [`MerlinSpec`][spec.specification.MerlinSpec] object and returns a unique - set of workers that should be started for the provided list of steps. If a step - is not found in the mapping, a warning is logged. - - Args: - spec (spec.specification.MerlinSpec): An instance of the - [`MerlinSpec`][spec.specification.MerlinSpec] class that contains the - mapping of steps to workers. - steps: A list of steps for which workers need to be started. - - Returns: - A set of unique workers to be started based on the specified steps. - """ - workers_to_start = [] - step_worker_map = spec.get_step_worker_map() - for step in steps: - try: - workers_to_start.extend(step_worker_map[step]) - except KeyError: - LOG.warning(f"Cannot start workers for step: {step}. This step was not found.") - - workers_to_start = set(workers_to_start) - LOG.debug(f"workers_to_start: {workers_to_start}") - - return workers_to_start - - -def _create_kwargs(spec: MerlinSpec) -> Tuple[Dict[str, str], Dict]: - """ - Construct the keyword arguments for launching a worker process. - - This helper function creates a dictionary of keyword arguments that will be - passed to `subprocess.Popen` when launching a worker. It retrieves the - environment variables defined in a [`MerlinSpec`][spec.specification.MerlinSpec] - object and updates the shell environment accordingly. - - Args: - spec (spec.specification.MerlinSpec): An instance of the MerlinSpec class - that contains environment specifications. - - Returns: - A tuple containing: - - A dictionary of keyword arguments for `subprocess.Popen`, including - the updated environment. - - A dictionary of variables defined in the spec, or None if no variables - were defined. - """ - # Get the environment from the spec and the shell - spec_env = spec.environment - shell_env = os.environ.copy() - yaml_vars = None - - # If the environment from the spec has anything in it, - # read in the variables and save them to the shell environment - if spec_env: - yaml_vars = get_yaml_var(spec_env, "variables", {}) - for var_name, var_val in yaml_vars.items(): - shell_env[str(var_name)] = str(var_val) - # For expandvars - os.environ[str(var_name)] = str(var_val) - - # Create the kwargs dict - kwargs = {"env": shell_env, "shell": True, "universal_newlines": True} - return kwargs, yaml_vars - - -def _get_steps_to_start(wsteps: List[str], steps: List[str], steps_provided: bool) -> List[str]: - """ - Identify the steps for which workers should be started. - - This function determines which steps to initiate based on the steps - associated with a worker and the user-provided steps. If specific steps - are provided by the user, only those steps that match the worker's steps - will be included. If no specific steps are provided, all worker-associated - steps will be returned. - - Args: - wsteps: A list of steps that are associated with a worker. - steps: A list of steps specified by the user to start workers for. - steps_provided: A boolean indicating whether the user provided - specific steps to start. - - Returns: - A list of steps for which workers should be started. - """ - steps_to_start = [] - if steps_provided: - for wstep in wsteps: - if wstep in steps: - steps_to_start.append(wstep) - else: - steps_to_start.extend(wsteps) - - return steps_to_start - - -def start_celery_workers( - spec: MerlinSpec, steps: List[str], celery_args: str, disable_logs: bool, just_return_command: bool -) -> str: # pylint: disable=R0914,R0915 - """ - Start Celery workers based on the provided specifications and steps. - - This function initializes and starts Celery workers for the specified steps - in the given [`MerlinSpec`][spec.specification.MerlinSpec]. It constructs - the necessary command-line arguments and handles the launching of subprocesses - for each worker. If the `just_return_command` flag is set to `True`, it will - return the command(s) to start the workers without actually launching them. - - Args: - spec (spec.specification.MerlinSpec): A [`MerlinSpec`][spec.specification.MerlinSpec] - object representing the study configuration. - steps: A list of steps for which to start workers. - celery_args: A string of additional arguments to pass to the Celery workers. - disable_logs: A flag to disable logging for the Celery workers. - just_return_command: If `True`, returns the launch command(s) without starting the workers. - - Returns: - A string containing all the worker launch commands. - - Side Effects: - - Starts subprocesses for each worker that is launched, so long as `just_return_command` - is not True. - - Example: - Below is an example configuration for Merlin workers: - - ```yaml - merlin: - resources: - task_server: celery - overlap: False - workers: - simworkers: - args: -O fair --prefetch-multiplier 1 -E -l info --concurrency 4 - steps: [run, data] - nodes: 1 - machine: [hostA, hostB] - ``` - """ - if not just_return_command: - LOG.info("Starting workers") - - overlap = spec.merlin["resources"]["overlap"] - workers = spec.merlin["resources"]["workers"] - - # Build kwargs dict for subprocess.Popen to use when we launch the worker - kwargs, yenv = _create_kwargs(spec) - - worker_list = [] - local_queues = [] - - # Get the workers we need to start if we're only starting certain steps - steps_provided = False if "all" in steps else True # pylint: disable=R1719 - if steps_provided: - workers_to_start = _get_workers_to_start(spec, steps) - - for worker_name, worker_val in workers.items(): - # Only triggered if --steps flag provided - if steps_provided and worker_name not in workers_to_start: - continue - - skip_loop_step: bool = examine_and_log_machines(worker_val, yenv) - if skip_loop_step: - continue - - worker_args = get_yaml_var(worker_val, "args", celery_args) - with suppress(KeyError): - if worker_val["args"] is None: - worker_args = "" - - worker_nodes = get_yaml_var(worker_val, "nodes", None) - worker_batch = get_yaml_var(worker_val, "batch", None) - - # Get the correct steps to start workers for - wsteps = get_yaml_var(worker_val, "steps", steps) - steps_to_start = _get_steps_to_start(wsteps, steps, steps_provided) - queues = spec.make_queue_string(steps_to_start) - - # Check for missing arguments - worker_args = verify_args(spec, worker_args, worker_name, overlap, disable_logs=disable_logs) - - # Add a per worker log file (debug) - if LOG.isEnabledFor(logging.DEBUG): - LOG.debug("Redirecting worker output to individual log files") - worker_args += " --logfile %p.%i" - - # Get the celery command & add it to the batch launch command - celery_com = get_celery_cmd(queues, worker_args=worker_args, just_return_command=True) - celery_cmd = os.path.expandvars(celery_com) - worker_cmd = batch_worker_launch(spec, celery_cmd, nodes=worker_nodes, batch=worker_batch) - worker_cmd = os.path.expandvars(worker_cmd) - - LOG.debug(f"worker cmd={worker_cmd}") - - if just_return_command: - worker_list = "" - print(worker_cmd) - continue - - # Get the running queues - running_queues = [] - running_queues.extend(local_queues) - queues = queues.split(",") - if not overlap: - running_queues.extend(get_running_queues("merlin")) - # Cache the queues from this worker to use to test - # for existing queues in any subsequent workers. - # If overlap is True, then do not check the local queues. - # This will allow multiple workers to pull from the same - # queue. - local_queues.extend(queues) - - # Search for already existing queues and log a warning if we try to start one that already exists - found = [] - for q in queues: # pylint: disable=C0103 - if q in running_queues: - found.append(q) - if found: - LOG.warning( - f"A celery worker named '{worker_name}' is already configured/running for queue(s) = {' '.join(found)}" - ) - continue - - # Start the worker - launch_celery_worker(worker_cmd, worker_list, kwargs) - - # Return a string with the worker commands for logging - return str(worker_list) - - -def examine_and_log_machines(worker_val: Dict, yenv: Dict[str, str]) -> bool: - """ - Determine if a worker should be skipped based on machine availability and log any errors. - - This function checks the specified machines for a worker and determines - whether the worker can be started. If the machines are not available, - it logs an error message regarding the output path for the Celery worker. - If the environment variables (`yenv`) are not provided or do not specify - an output path, a warning is logged. - - Args: - worker_val: A dictionary containing worker configuration, including - the list of machines associated with the worker. - yenv: A dictionary of environment variables that may include the - output path for logging. - - Returns: - Returns `True` if the worker should be skipped (i.e., machines are - unavailable), otherwise returns `False`. - """ - worker_machines = get_yaml_var(worker_val, "machines", None) - if worker_machines: - LOG.debug(f"check machines = {check_machines(worker_machines)}") - if not check_machines(worker_machines): - return True - - if yenv: - output_path = get_yaml_var(yenv, "OUTPUT_PATH", None) - if output_path and not os.path.exists(output_path): - hostname = socket.gethostname() - LOG.error(f"The output path, {output_path}, is not accessible on this host, {hostname}") - else: - LOG.warning( - "The env:variables section does not have an OUTPUT_PATH specified, multi-machine checks cannot be performed." - ) - return False - return False - - -def verify_args(spec: MerlinSpec, worker_args: str, worker_name: str, overlap: bool, disable_logs: bool = False) -> str: - """ - Validate and enhance the arguments passed to a Celery worker for completeness. - - This function checks the provided worker arguments to ensure that they include - recommended settings for running parallel tasks. It adds default values for - concurrency, prefetch multiplier, and logging level if they are not specified. - Additionally, it generates a unique worker name based on the current time if - the `-n` argument is not provided. - - Args: - spec (spec.specification.MerlinSpec): A [`MerlinSpec`][spec.specification.MerlinSpec] - object containing the study configuration. - worker_args: A string of arguments passed to the worker that may need validation. - worker_name: The name of the worker, used for generating a unique worker identifier. - overlap: A flag indicating whether multiple workers can overlap in their queue processing. - disable_logs: A flag to disable logging configuration for the worker. - - Returns: - The validated and potentially modified worker arguments string. - """ - parallel = batch_check_parallel(spec) - if parallel: - if "--concurrency" not in worker_args: - LOG.warning("The worker arg --concurrency [1-4] is recommended when running parallel tasks") - if "--prefetch-multiplier" not in worker_args: - LOG.warning("The worker arg --prefetch-multiplier 1 is recommended when running parallel tasks") - if "fair" not in worker_args: - LOG.warning("The worker arg -O fair is recommended when running parallel tasks") - - if "-n" not in worker_args: - nhash = "" - if overlap: - nhash = time.strftime("%Y%m%d-%H%M%S") - # TODO: Once flux fixes their bug, change this back to %h - # %h in Celery is short for hostname including domain name - worker_args += f" -n {worker_name}{nhash}.%%h" - - if not disable_logs and "-l" not in worker_args: - worker_args += f" -l {logging.getLevelName(LOG.getEffectiveLevel())}" - - return worker_args - - -def launch_celery_worker(worker_cmd: str, worker_list: List[str], kwargs: Dict): - """ - Launch a Celery worker using the specified command and parameters. - - This function executes the provided Celery command to start a worker as a - subprocess. It appends the command to the given list of worker commands - for tracking purposes. If the worker fails to start, an error is logged. - - Args: - worker_cmd: The command string used to launch the Celery worker. - worker_list: A list that will be updated to include the launched - worker command for tracking active workers. - kwargs: A dictionary of additional keyword arguments to pass to - `subprocess.Popen`, allowing for customization of the subprocess - behavior. - - Raises: - Exception: If the worker fails to start, an error is logged, and the - exception is re-raised. - - Side Effects: - - Launches a Celery worker process in the background. - - Modifies the `worker_list` by appending the launched worker command. - """ - try: - subprocess.Popen(worker_cmd, **kwargs) # pylint: disable=R1732 - worker_list.append(worker_cmd) - except Exception as e: # pylint: disable=C0103 - LOG.error(f"Cannot start celery workers, {e}") - raise - - -def get_celery_cmd(queue_names: str, worker_args: str = "", just_return_command: bool = False) -> str: - """ - Construct the command to launch Celery workers for the specified queues. - - This function generates a command string that can be used to start Celery - workers associated with the provided queue names. It allows for optional - worker arguments to be included and can return the command without executing it. - - Args: - queue_names: A comma-separated string of the queue name(s) to which the worker - will be associated. - worker_args: Additional command-line arguments for the Celery worker. - just_return_command: If True, the function will return the constructed command - without executing it. - - Returns: - The constructed command string for launching the Celery worker. If - `just_return_command` is True, returns the command; otherwise, returns an - empty string. - """ - worker_command = " ".join(["celery -A merlin worker", worker_args, "-Q", queue_names]) - if just_return_command: - return worker_command - # If we get down here, this only runs celery locally the user would need to - # add all of the flux config themselves. - return "" - - def purge_celery_tasks(queues: str, force: bool) -> int: """ Purge Celery tasks from the specified queues. From 7a501988e70f086b069cc74ac87efc254e7f8c80 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 17:54:22 -0700 Subject: [PATCH 17/91] link the new worker classes to the actual launching of workers --- merlin/cli/commands/run_workers.py | 102 +++-------------------------- merlin/exceptions/__init__.py | 18 +++++ merlin/spec/specification.py | 84 ++++++++++++++++++++++++ merlin/study/batch.py | 1 - 4 files changed, 112 insertions(+), 93 deletions(-) diff --git a/merlin/cli/commands/run_workers.py b/merlin/cli/commands/run_workers.py index cdc35385..3cf7cebc 100644 --- a/merlin/cli/commands/run_workers.py +++ b/merlin/cli/commands/run_workers.py @@ -17,15 +17,12 @@ import logging from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace -from typing import List, Set, Union from merlin.ascii_art import banner_small from merlin.cli.commands.command_entry_point import CommandEntryPoint from merlin.cli.utils import get_merlin_spec_with_override from merlin.config.configfile import initialize_config -from merlin.db_scripts.merlin_db import MerlinDatabase -from merlin.spec.specification import MerlinSpec -from merlin.workers.celery_worker import CeleryWorker +from merlin.workers.handlers.handler_factory import worker_handler_factory LOG = logging.getLogger("merlin") @@ -95,75 +92,6 @@ def add_parser(self, subparsers: ArgumentParser): "in your workers' args section will overwrite this flag for that worker.", ) - # TODO when we move the queues setting to within the worker then this will no longer be necessary - def _get_workers_to_start(self, spec: MerlinSpec, steps: Union[List[str], None]) -> Set[str]: - """ - Determine the set of workers to start based on the specified steps (if any) - - This helper function retrieves a mapping of steps to their corresponding workers - from a [`MerlinSpec`][spec.specification.MerlinSpec] object and returns a unique - set of workers that should be started for the provided list of steps. If a step - is not found in the mapping, a warning is logged. - - Args: - spec (spec.specification.MerlinSpec): An instance of the - [`MerlinSpec`][spec.specification.MerlinSpec] class that contains the - mapping of steps to workers. - steps: A list of steps for which workers need to be started or None if the user - didn't provide specific steps. - - Returns: - A set of unique workers to be started based on the specified steps. - """ - steps_provided = False if "all" in steps else True - - if steps_provided: - workers_to_start = [] - step_worker_map = spec.get_step_worker_map() - for step in steps: - try: - workers_to_start.extend(step_worker_map[step]) - except KeyError: - LOG.warning(f"Cannot start workers for step: {step}. This step was not found.") - - workers_to_start = set(workers_to_start) - else: - workers_to_start = set(spec.merlin["resources"]["workers"]) - - LOG.debug(f"workers_to_start: {workers_to_start}") - return workers_to_start - - # TODO this should move to TaskServerInterface and be abstracted (build_worker_list ?) - def _get_worker_instances(self, workers_to_start: Set[str], spec: MerlinSpec) -> List[CeleryWorker]: - """ - """ - workers = [] - all_workers = spec.merlin["resources"]["workers"] - overlap = spec.merlin["resources"]["overlap"] - full_env = spec.get_full_environment() - - for worker_name in workers_to_start: - settings = all_workers[worker_name] - config = { - "args": settings.get("args", ""), - "machines": settings.get("machines", []), - "queues": spec.get_queue_list(settings["steps"]), - "batch": settings["batch"] if settings["batch"] is not None else spec.batch.copy() - } - - if "nodes" in settings and settings["nodes"] is not None: - if config["batch"]: - config["batch"]["nodes"] = settings["nodes"] - else: - config["batch"] = {"nodes": settings["nodes"]} - - LOG.debug(f"config for worker '{worker_name}': {config}") - - workers.append(CeleryWorker(name=worker_name, config=config, env=full_env, overlap=overlap)) - LOG.debug(f"Created CeleryWorker object for worker '{worker_name}'.") - - return workers - def process_command(self, args: Namespace): """ CLI command for launching workers. @@ -188,27 +116,17 @@ def process_command(self, args: Namespace): if not args.worker_echo_only: LOG.info(f"Launching workers from '{filepath}'") - # Initialize the database - merlin_db = MerlinDatabase() - - # Create logical worker entries - step_queue_map = spec.get_task_queues() - for worker, steps in spec.get_worker_step_map().items(): - worker_queues = {step_queue_map[step] for step in steps} - merlin_db.create("logical_worker", worker, worker_queues) - # Get the names of the workers that the user is requesting to start - workers_to_start = self._get_workers_to_start(spec, args.worker_steps) + workers_to_start = spec.get_workers_to_start(args.worker_steps) # Build a list of MerlinWorker instances - worker_instances = self._get_worker_instances(workers_to_start, spec) + worker_instances = spec.build_worker_list(workers_to_start) # Launch the workers or echo out the command that will be used to launch the workers - for worker in worker_instances: - if args.worker_echo_only: - LOG.debug(f"Not launching worker '{worker.name}', just echoing command.") - launch_cmd = worker.get_launch_command(override_args=args.worker_args, disable_logs=args.disable_logs) - print(launch_cmd) - else: - LOG.debug(f"Launching worker '{worker.name}'.") - worker.launch_worker(override_args=args.worker_args, disable_logs=args.disable_logs) + worker_handler = worker_handler_factory.create(spec.merlin["resources"]["task_server"]) + worker_handler.launch_workers( + worker_instances, + echo_only=args.worker_echo_only, + override_args=args.worker_args, + disable_logs=args.disable_logs, + ) diff --git a/merlin/exceptions/__init__.py b/merlin/exceptions/__init__.py index 93ce253b..4a048969 100644 --- a/merlin/exceptions/__init__.py +++ b/merlin/exceptions/__init__.py @@ -108,6 +108,24 @@ def __init__(self, message): super().__init__(message) +class MerlinWorkerHandlerNotSupportedError(Exception): + """ + Exception to signal that the provided worker handler is not supported by Merlin. + """ + + def __init__(self, message): + super().__init__(message) + + +class MerlinWorkerNotSupportedError(Exception): + """ + Exception to signal that the provided worker is not supported by Merlin. + """ + + def __init__(self, message): + super().__init__(message) + + class MerlinWorkerLaunchError(Exception): """ Exception to signal that an there was a problem when launching workers. diff --git a/merlin/spec/specification.py b/merlin/spec/specification.py index da272269..83e97168 100644 --- a/merlin/spec/specification.py +++ b/merlin/spec/specification.py @@ -25,6 +25,8 @@ from merlin.spec import all_keys, defaults from merlin.utils import find_vlaunch_var, get_yaml_var, load_array_file, needs_merlin_expansion, repr_timedelta +from merlin.workers.worker_factory import worker_factory +from merlin.workers.worker import MerlinWorker LOG = logging.getLogger(__name__) @@ -1199,3 +1201,85 @@ def get_full_environment(self): os.environ[str(var_name)] = str(var_val) return full_env + + # TODO when we move the queues setting to within the worker then we'll have to update this + def get_workers_to_start(self, steps: Union[List[str], None]) -> Set[str]: + """ + Determine the set of workers to start based on the specified steps (if any). + + This method retrieves a mapping of steps to their corresponding workers + from a [`MerlinSpec`][spec.specification.MerlinSpec] object and returns a unique + set of workers that should be started for the provided list of steps. If a step + is not found in the mapping, a warning is logged. + + Args: + steps: A list of steps for which workers need to be started or None if the user + didn't provide specific steps. + + Returns: + A set of unique workers to be started based on the specified steps. + """ + steps_provided = False if "all" in steps else True + + if steps_provided: + workers_to_start = [] + step_worker_map = self.get_step_worker_map() + for step in steps: + try: + workers_to_start.extend(step_worker_map[step]) + except KeyError: + LOG.warning(f"Cannot start workers for step: {step}. This step was not found.") + + workers_to_start = set(workers_to_start) + else: + workers_to_start = set(self.merlin["resources"]["workers"]) + + LOG.debug(f"workers_to_start: {workers_to_start}") + return workers_to_start + + # TODO some of this logic should move to TaskServerInterface and be abstracted + def build_worker_list(self, workers_to_start: Set[str]) -> List[MerlinWorker]: + """ + Construct and return a list of worker instances based on provided worker names. + + This method reads configuration from the Merlin spec to instantiate worker + objects for each worker name in `workers_to_start`. It gathers the required + parameters such as command-line arguments, machines, queue list, and batch + settings (including any overrides like number of nodes). These configurations + are passed along with environment variables and overlap settings to the + appropriate worker factory for instantiation. + + Args: + workers_to_start (Set[str]): A set of worker names to be initialized. + + Returns: + List[MerlinWorker]: A list of instantiated worker objects ready to be launched. + """ + workers = [] + all_workers = self.merlin["resources"]["workers"] + overlap = self.merlin["resources"]["overlap"] + full_env = self.get_full_environment() + + for worker_name in workers_to_start: + settings = all_workers[worker_name] + config = { + "args": settings.get("args", ""), + "machines": settings.get("machines", []), + "queues": set(self.get_queue_list(settings["steps"])), + "batch": settings["batch"] if settings["batch"] is not None else self.batch.copy() + } + + if "nodes" in settings and settings["nodes"] is not None: + if config["batch"]: + config["batch"]["nodes"] = settings["nodes"] + else: + config["batch"] = {"nodes": settings["nodes"]} + + LOG.debug(f"config for worker '{worker_name}': {config}") + + worker_params = {"name": worker_name, "config": config, "env": full_env, "overlap": overlap} + worker_instance = worker_factory.create(self.merlin["resources"]["task_server"], worker_params) + workers.append(worker_instance) + LOG.debug(f"Created CeleryWorker object for worker '{worker_name}'.") + + return workers diff --git a/merlin/study/batch.py b/merlin/study/batch.py index 643a3853..3c8b72d5 100644 --- a/merlin/study/batch.py +++ b/merlin/study/batch.py @@ -15,7 +15,6 @@ import subprocess from typing import Dict, Union -from merlin.spec.specification import MerlinSpec from merlin.utils import convert_timestring, get_flux_alloc, get_flux_version, get_yaml_var From 60ee2c2bb45875f42bea702bc1048d2bac2511b4 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 18:04:30 -0700 Subject: [PATCH 18/91] fix regex in test --- tests/integration/definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/definitions.py b/tests/integration/definitions.py index 108664b5..7d326484 100644 --- a/tests/integration/definitions.py +++ b/tests/integration/definitions.py @@ -314,7 +314,7 @@ def define_tests(): # pylint: disable=R0914,R0915 }, "default_worker assigned": { "cmds": f"{workers} {test_specs}/default_worker_test.yaml --echo", - "conditions": [HasReturnCode(), HasRegex(r"default_worker.*-Q '\[merlin\]_step_4_queue'")], + "conditions": [HasReturnCode(), HasRegex(r"default_worker.*-Q \[merlin\]_step_4_queue")], "run type": "local", }, "no default_worker assigned": { From c5ba679be68754077c83d541e4a80f28ff3ef4eb Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 18:09:36 -0700 Subject: [PATCH 19/91] remove watchdog files and run fix-style --- merlin/spec/specification.py | 16 +++++++------- merlin/study/celeryadapter.py | 7 +----- merlin/workers/__init__.py | 1 + merlin/workers/celery_worker.py | 11 ++++++---- merlin/workers/handlers/__init__.py | 1 + merlin/workers/handlers/base_handler.py | 13 ----------- merlin/workers/handlers/celery_handler.py | 1 - merlin/workers/watchdogs/__init__.py | 10 --------- merlin/workers/watchdogs/base_watchdog.py | 13 ----------- merlin/workers/watchdogs/celery_watchdog.py | 12 ---------- merlin/workers/watchdogs/watchodg_factory.py | 23 -------------------- merlin/workers/worker_factory.py | 2 +- 12 files changed, 19 insertions(+), 91 deletions(-) delete mode 100644 merlin/workers/handlers/base_handler.py delete mode 100644 merlin/workers/watchdogs/__init__.py delete mode 100644 merlin/workers/watchdogs/base_watchdog.py delete mode 100644 merlin/workers/watchdogs/celery_watchdog.py delete mode 100644 merlin/workers/watchdogs/watchodg_factory.py diff --git a/merlin/spec/specification.py b/merlin/spec/specification.py index 83e97168..66c58da4 100644 --- a/merlin/spec/specification.py +++ b/merlin/spec/specification.py @@ -25,8 +25,8 @@ from merlin.spec import all_keys, defaults from merlin.utils import find_vlaunch_var, get_yaml_var, load_array_file, needs_merlin_expansion, repr_timedelta -from merlin.workers.worker_factory import worker_factory from merlin.workers.worker import MerlinWorker +from merlin.workers.worker_factory import worker_factory LOG = logging.getLogger(__name__) @@ -1179,13 +1179,13 @@ def get_full_environment(self): """ Construct the full environment for the current context. - This method starts with a copy of the current OS environment and - overlays any additional environment variables defined in the spec's - `environment` section. These variables are added both to the returned + This method starts with a copy of the current OS environment and + overlays any additional environment variables defined in the spec's + `environment` section. These variables are added both to the returned dictionary and the live `os.environ` to support variable expansion. Returns: - dict: A dictionary representing the full environment with any + dict: A dictionary representing the full environment with any user-defined variables applied. """ # Start with the global environment @@ -1201,7 +1201,7 @@ def get_full_environment(self): os.environ[str(var_name)] = str(var_val) return full_env - + # TODO when we move the queues setting to within the worker then we'll have to update this def get_workers_to_start(self, steps: Union[List[str], None]) -> Set[str]: """ @@ -1236,7 +1236,7 @@ def get_workers_to_start(self, steps: Union[List[str], None]) -> Set[str]: LOG.debug(f"workers_to_start: {workers_to_start}") return workers_to_start - + # TODO some of this logic should move to TaskServerInterface and be abstracted def build_worker_list(self, workers_to_start: Set[str]) -> List[MerlinWorker]: """ @@ -1266,7 +1266,7 @@ def build_worker_list(self, workers_to_start: Set[str]) -> List[MerlinWorker]: "args": settings.get("args", ""), "machines": settings.get("machines", []), "queues": set(self.get_queue_list(settings["steps"])), - "batch": settings["batch"] if settings["batch"] is not None else self.batch.copy() + "batch": settings["batch"] if settings["batch"] is not None else self.batch.copy(), } if "nodes" in settings and settings["nodes"] is not None: diff --git a/merlin/study/celeryadapter.py b/merlin/study/celeryadapter.py index b3543def..87a68978 100644 --- a/merlin/study/celeryadapter.py +++ b/merlin/study/celeryadapter.py @@ -8,11 +8,7 @@ This module provides an adapter to the Celery Distributed Task Queue. """ import logging -import os -import socket import subprocess -import time -from contextlib import suppress from datetime import datetime from types import SimpleNamespace from typing import Dict, List, Set, Tuple @@ -24,9 +20,8 @@ from merlin.common.dumper import dump_handler from merlin.config import Config from merlin.spec.specification import MerlinSpec -from merlin.study.batch import batch_check_parallel, batch_worker_launch from merlin.study.study import MerlinStudy -from merlin.utils import apply_list_of_regex, check_machines, get_procs, get_yaml_var, is_running +from merlin.utils import apply_list_of_regex, get_procs, is_running LOG = logging.getLogger(__name__) diff --git a/merlin/workers/__init__.py b/merlin/workers/__init__.py index cd12b6af..06a19c24 100644 --- a/merlin/workers/__init__.py +++ b/merlin/workers/__init__.py @@ -32,4 +32,5 @@ from merlin.workers.celery_worker import CeleryWorker + __all__ = ["CeleryWorker"] diff --git a/merlin/workers/celery_worker.py b/merlin/workers/celery_worker.py index def1ddb7..becd92ba 100644 --- a/merlin/workers/celery_worker.py +++ b/merlin/workers/celery_worker.py @@ -26,6 +26,7 @@ from merlin.utils import check_machines from merlin.workers.worker import MerlinWorker + LOG = logging.getLogger("merlin") @@ -153,7 +154,8 @@ def should_launch(self) -> bool: if machines: if not check_machines(machines): LOG.error( - f"The following machines were provided for worker '{self.name}': {machines}. However, the current machine '{socket.gethostname()}' is not in this list." + f"The following machines were provided for worker '{self.name}': {machines}. " + f"However, the current machine '{socket.gethostname()}' is not in this list." ) return False @@ -163,7 +165,8 @@ def should_launch(self) -> bool: return False if not self.overlap: - from merlin.study.celeryadapter import get_running_queues + from merlin.study.celeryadapter import get_running_queues # pylint: disable=import-outside-toplevel + running_queues = get_running_queues("merlin") for queue in queues: if queue in running_queues: @@ -171,7 +174,7 @@ def should_launch(self) -> bool: return False return True - + def launch_worker(self, override_args: str = "", disable_logs: bool = False): """ Launch the worker as a subprocess using the constructed launch command. @@ -190,7 +193,7 @@ def launch_worker(self, override_args: str = "", disable_logs: bool = False): LOG.debug(f"Launched worker '{self.name}' with command: {launch_cmd}.") except Exception as e: # pylint: disable=C0103 LOG.error(f"Cannot start celery workers, {e}") - raise MerlinWorkerLaunchError + raise MerlinWorkerLaunchError from e def get_metadata(self) -> Dict: """ diff --git a/merlin/workers/handlers/__init__.py b/merlin/workers/handlers/__init__.py index b95145b8..30969814 100644 --- a/merlin/workers/handlers/__init__.py +++ b/merlin/workers/handlers/__init__.py @@ -25,4 +25,5 @@ from merlin.workers.handlers.celery_handler import CeleryWorkerHandler + __all__ = ["CeleryWorkerHandler"] diff --git a/merlin/workers/handlers/base_handler.py b/merlin/workers/handlers/base_handler.py deleted file mode 100644 index e0ef328b..00000000 --- a/merlin/workers/handlers/base_handler.py +++ /dev/null @@ -1,13 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -from abc import ABC, abstractmethod - - -class BaseWorkerHandler(ABC): - """ - - """ \ No newline at end of file diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index 8ae99cb8..a73d6f8f 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -72,4 +72,3 @@ def query_workers(self): """ Query the status of Celery workers. """ - diff --git a/merlin/workers/watchdogs/__init__.py b/merlin/workers/watchdogs/__init__.py deleted file mode 100644 index aa2759b6..00000000 --- a/merlin/workers/watchdogs/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -from merlin.workers.watchdogs.base_watchdog import BaseWorkerWatchdog -from merlin.workers.watchdogs.celery_watchdog import CeleryWorkerWatchdog - -__all__ = ["BaseWorkerWatchdog", "CeleryWorkerWatchdog"] \ No newline at end of file diff --git a/merlin/workers/watchdogs/base_watchdog.py b/merlin/workers/watchdogs/base_watchdog.py deleted file mode 100644 index c6ffed04..00000000 --- a/merlin/workers/watchdogs/base_watchdog.py +++ /dev/null @@ -1,13 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -from abc import ABC, abstractmethod - - -class BaseWorkerWatchdog(ABC): - """ - - """ diff --git a/merlin/workers/watchdogs/celery_watchdog.py b/merlin/workers/watchdogs/celery_watchdog.py deleted file mode 100644 index f298e40f..00000000 --- a/merlin/workers/watchdogs/celery_watchdog.py +++ /dev/null @@ -1,12 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -from merlin.workers.watchdogs.base_watchdog import BaseWorkerWatchdog - -class CeleryWorkerWatchdog(BaseWorkerWatchdog): - """ - - """ \ No newline at end of file diff --git a/merlin/workers/watchdogs/watchodg_factory.py b/merlin/workers/watchdogs/watchodg_factory.py deleted file mode 100644 index f190e525..00000000 --- a/merlin/workers/watchdogs/watchodg_factory.py +++ /dev/null @@ -1,23 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -""" - -""" - -import logging -from typing import Dict, List - -from merlin.workers.watchdogs import BaseWorkerWatchdog, CeleryWorkerWatchdog - - -LOG = logging.getLogger("merlin") - - -class WorkerWatchdogFactory: - """ - - """ \ No newline at end of file diff --git a/merlin/workers/worker_factory.py b/merlin/workers/worker_factory.py index fd86f83e..30112cc7 100644 --- a/merlin/workers/worker_factory.py +++ b/merlin/workers/worker_factory.py @@ -9,7 +9,7 @@ This module defines the `WorkerFactory`, a subclass of [`MerlinBaseFactory`][abstracts.factory.MerlinBaseFactory], which manages -the registration, validation, and creation of concrete worker classes such as +the registration, validation, and creation of concrete worker classes such as [`CeleryWorker`][workers.celery_worker.CeleryWorker]. It supports plugin-based discovery via Python entry points, enabling extensibility for other task server backends (e.g., Kafka). From 17ae6138129fa092c2a4f2a2a9eaf44386cb30d7 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 23 Jul 2025 08:22:08 -0700 Subject: [PATCH 20/91] fix tests that broke after refactor --- tests/unit/cli/commands/test_run_workers.py | 45 ++++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/tests/unit/cli/commands/test_run_workers.py b/tests/unit/cli/commands/test_run_workers.py index 8729822c..b7f3f7e8 100644 --- a/tests/unit/cli/commands/test_run_workers.py +++ b/tests/unit/cli/commands/test_run_workers.py @@ -14,6 +14,7 @@ from pytest_mock import MockerFixture from merlin.cli.commands.run_workers import RunWorkersCommand +from merlin.workers.handlers import CeleryWorkerHandler from tests.fixture_types import FixtureCallable @@ -37,7 +38,7 @@ def test_add_parser_sets_up_run_workers_command(create_parser: FixtureCallable): assert args.disable_logs is False -def test_process_command_launches_workers_and_creates_logical_workers(mocker: MockerFixture): +def test_process_command_launches_workers(mocker: MockerFixture): """ Test `process_command` launches workers and creates logical worker entries in normal mode. @@ -45,14 +46,19 @@ def test_process_command_launches_workers_and_creates_logical_workers(mocker: Mo mocker: PyTest mocker fixture. """ mock_spec = mocker.Mock() - mock_spec.get_task_queues.return_value = {"step1": "queue1", "step2": "queue2"} - mock_spec.get_worker_step_map.return_value = {"workerA": ["step1", "step2"]} + mock_spec.get_workers_to_start.return_value = ["workerA"] + mock_spec.build_worker_list.return_value = ["worker-instance"] + mock_spec.merlin = {"resources": {"task_server": "celery"}} mock_get_spec = mocker.patch( - "merlin.cli.commands.run_workers.get_merlin_spec_with_override", return_value=(mock_spec, "workflow.yaml") + "merlin.cli.commands.run_workers.get_merlin_spec_with_override", + return_value=(mock_spec, "workflow.yaml") + ) + mock_handler = mocker.Mock() + mock_factory = mocker.patch( + "merlin.cli.commands.run_workers.worker_handler_factory.create", + return_value=mock_handler ) - mock_launch = mocker.patch("merlin.cli.commands.run_workers.launch_workers", return_value="launched") - mock_db = mocker.patch("merlin.cli.commands.run_workers.MerlinDatabase") mock_log = mocker.patch("merlin.cli.commands.run_workers.LOG") args = Namespace( @@ -67,10 +73,16 @@ def test_process_command_launches_workers_and_creates_logical_workers(mocker: Mo RunWorkersCommand().process_command(args) mock_get_spec.assert_called_once_with(args) - mock_db.return_value.create.assert_called_once_with("logical_worker", "workerA", {"queue1", "queue2"}) - mock_launch.assert_called_once_with(mock_spec, ["step1"], "--concurrency=4", False, False) + mock_spec.get_workers_to_start.assert_called_once_with(["step1"]) + mock_spec.build_worker_list.assert_called_once_with(["workerA"]) + mock_factory.assert_called_once_with("celery") + mock_handler.launch_workers.assert_called_once_with( + ["worker-instance"], + echo_only=False, + override_args="--concurrency=4", + disable_logs=False + ) mock_log.info.assert_called_once_with("Launching workers from 'workflow.yaml'") - mock_log.debug.assert_called_once_with("celery command: launched") def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, capsys: CaptureFixture): @@ -82,13 +94,17 @@ def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, ca capsys: PyTest capsys fixture. """ mock_spec = mocker.Mock() - mock_spec.get_task_queues.return_value = {} - mock_spec.get_worker_step_map.return_value = {} + mock_spec.get_workers_to_start.return_value = ["workerB"] + mock_spec.merlin = {"resources": {"task_server": "celery"}} + + mock_worker = mocker.Mock() + mock_worker.name = "workerB" + mock_worker.get_launch_command.return_value = "echo-launch-cmd" + mock_spec.build_worker_list.return_value = [mock_worker] mocker.patch("merlin.cli.commands.run_workers.get_merlin_spec_with_override", return_value=(mock_spec, "file.yaml")) mocker.patch("merlin.cli.commands.run_workers.initialize_config") - mocker.patch("merlin.cli.commands.run_workers.MerlinDatabase") - mock_launch = mocker.patch("merlin.cli.commands.run_workers.launch_workers", return_value="echo-cmd") + mocker.patch("merlin.cli.commands.run_workers.worker_handler_factory.create", wraps=lambda _: CeleryWorkerHandler()) args = Namespace( specification="spec.yaml", @@ -102,5 +118,4 @@ def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, ca RunWorkersCommand().process_command(args) captured = capsys.readouterr() - assert "echo-cmd" in captured.out - mock_launch.assert_called_once_with(mock_spec, ["all"], "--autoscale=2,10", False, True) + assert "echo-launch-cmd" in captured.out From 889541e6499a6a68e95c7434da53492f8fd2e598 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 23 Jul 2025 10:45:18 -0700 Subject: [PATCH 21/91] run fix-style --- tests/unit/cli/commands/test_run_workers.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/unit/cli/commands/test_run_workers.py b/tests/unit/cli/commands/test_run_workers.py index b7f3f7e8..643c3aca 100644 --- a/tests/unit/cli/commands/test_run_workers.py +++ b/tests/unit/cli/commands/test_run_workers.py @@ -51,14 +51,10 @@ def test_process_command_launches_workers(mocker: MockerFixture): mock_spec.merlin = {"resources": {"task_server": "celery"}} mock_get_spec = mocker.patch( - "merlin.cli.commands.run_workers.get_merlin_spec_with_override", - return_value=(mock_spec, "workflow.yaml") + "merlin.cli.commands.run_workers.get_merlin_spec_with_override", return_value=(mock_spec, "workflow.yaml") ) mock_handler = mocker.Mock() - mock_factory = mocker.patch( - "merlin.cli.commands.run_workers.worker_handler_factory.create", - return_value=mock_handler - ) + mock_factory = mocker.patch("merlin.cli.commands.run_workers.worker_handler_factory.create", return_value=mock_handler) mock_log = mocker.patch("merlin.cli.commands.run_workers.LOG") args = Namespace( @@ -77,10 +73,7 @@ def test_process_command_launches_workers(mocker: MockerFixture): mock_spec.build_worker_list.assert_called_once_with(["workerA"]) mock_factory.assert_called_once_with("celery") mock_handler.launch_workers.assert_called_once_with( - ["worker-instance"], - echo_only=False, - override_args="--concurrency=4", - disable_logs=False + ["worker-instance"], echo_only=False, override_args="--concurrency=4", disable_logs=False ) mock_log.info.assert_called_once_with("Launching workers from 'workflow.yaml'") From 4e1e1fb70adfd1a6dc3f5e7a25715fe476a99dc4 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 23 Jul 2025 10:48:46 -0700 Subject: [PATCH 22/91] update CHANGELOG --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9615e94f..cad30264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Unit tests for the `spec/` folder - A page in the docs explaining the `feature_demo` example - New `MerlinBaseFactory` class to help enable future plugins for backends, monitors, status renderers, etc. +- New worker related classes: + - `MerlinWorker`: base class for defining task server workers + - `CeleryWorker`: implementation of `MerlinWorker` specifically for Celery workers + - `WorkerFactory`: to help determine which task server worker to use + - `MerlinWorkerHandler`: base class for managing launching, stopping, and querying multiple workers + - `CeleryWorkerHandler`: implementation of `MerlinWorkerHandler` specifically for manager Celery workers + - `WorkerHandlerFactory`: to help determine which task server handler to use ### Changed - Maestro version requirement is now at minimum 1.1.10 for status renderer changes - The `BackendFactory`, `MonitorFactory`, and `StatusRendererFactory` classes all now inherit from `MerlinBaseFactory` +- Launching workers is now handled through worker classes rather than functions in the `celeryadapter.py` file ## [1.13.0b2] ### Added From b7caa852ae872efb5f654740321d04d5b9b3506d Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 30 Jul 2025 08:17:37 -0700 Subject: [PATCH 23/91] refactored how the database command is initially processed --- merlin/cli/commands/__init__.py | 5 +- merlin/cli/commands/database.py | 322 ------------------ merlin/cli/commands/database/__init__.py | 34 ++ merlin/cli/commands/database/database.py | 86 +++++ merlin/cli/commands/database/delete.py | 182 ++++++++++ .../cli/commands/database/entity_registry.py | 66 ++++ merlin/cli/commands/database/get.py | 150 ++++++++ merlin/cli/commands/database/info.py | 73 ++++ merlin/cli/utils.py | 143 +++++++- merlin/db_scripts/__init__.py | 2 - merlin/db_scripts/db_commands.py | 171 ---------- merlin/db_scripts/merlin_db.py | 93 ++++- merlin/utils.py | 53 +++ 13 files changed, 877 insertions(+), 503 deletions(-) delete mode 100644 merlin/cli/commands/database.py create mode 100644 merlin/cli/commands/database/__init__.py create mode 100644 merlin/cli/commands/database/database.py create mode 100644 merlin/cli/commands/database/delete.py create mode 100644 merlin/cli/commands/database/entity_registry.py create mode 100644 merlin/cli/commands/database/get.py create mode 100644 merlin/cli/commands/database/info.py delete mode 100644 merlin/db_scripts/db_commands.py diff --git a/merlin/cli/commands/__init__.py b/merlin/cli/commands/__init__.py index 65d21454..17e8bdd7 100644 --- a/merlin/cli/commands/__init__.py +++ b/merlin/cli/commands/__init__.py @@ -15,7 +15,6 @@ Modules: command_entry_point: Defines the abstract base class `CommandEntryPoint` for all CLI commands. config: Implements the `config` command for managing Merlin configuration files. - database: Implements the `database` command for interacting with the underlying database (view, delete, inspect). example: Implements the `example` command to download and set up example workflows. info: Implements the `info` command for displaying configuration and environment diagnostics. monitor: Implements the `monitor` command to keep workflow allocations alive. @@ -28,6 +27,10 @@ server: Implements the `server` command to manage containerized Redis server components. status: Implements the `status` and `detailed-status` commands for workflow state inspection. stop_workers: Implements the `stop-workers` command for terminating active workers. + +Subpackages: + database: Implements the `database` command group and its subcommands (`get`, `delete`, `info`) + for interacting with the Merlin database. """ from merlin.cli.commands.config import ConfigCommand diff --git a/merlin/cli/commands/database.py b/merlin/cli/commands/database.py deleted file mode 100644 index 94db4d08..00000000 --- a/merlin/cli/commands/database.py +++ /dev/null @@ -1,322 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -""" -This module defines the `DatabaseCommand` class, which provides CLI subcommands -for interacting with the Merlin application's underlying database. It supports -commands for retrieving, deleting, and inspecting database contents, including -entities like studies, runs, and workers. - -The commands are registered under the `database` top-level command and integrated -into Merlin's argument parser system. -""" - -# pylint: disable=duplicate-code - -import logging -from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace - -from merlin.cli.commands.command_entry_point import CommandEntryPoint -from merlin.config.configfile import initialize_config -from merlin.db_scripts.db_commands import database_delete, database_get, database_info - - -LOG = logging.getLogger("merlin") - - -class DatabaseCommand(CommandEntryPoint): - """ - Handles `database` CLI commands for interacting with Merlin's database. - - Methods: - add_parser: Adds the `database` command and its subcommands to the CLI parser. - process_command: Processes the CLI input and dispatches the appropriate action. - """ - - def _add_delete_subcommand(self, database_commands: ArgumentParser): - """ - Add the `delete` subcommand and its options to remove data from the database. - - Parameters: - database_commands (ArgumentParser): The parent parser for database subcommands. - """ - db_delete: ArgumentParser = database_commands.add_parser( - "delete", - help="Delete information stored in the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - - # Add subcommands for delete - delete_subcommands = db_delete.add_subparsers(dest="delete_type", required=True) - - # TODO enable support for deletion of study by passing in spec file - # Subcommand: delete study - delete_study = delete_subcommands.add_parser( - "study", - help="Delete one or more studies by ID or name.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - delete_study.add_argument( - "study", - type=str, - nargs="+", - help="A space-delimited list of IDs or names of studies to delete.", - ) - delete_study.add_argument( - "-k", - "--keep-associated-runs", - action="store_true", - help="Keep runs associated with the studies.", - ) - - # Subcommand: delete run - delete_run = delete_subcommands.add_parser( - "run", - help="Delete one or more runs by ID or workspace.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - delete_run.add_argument( - "run", - type=str, - nargs="+", - help="A space-delimited list of IDs or workspaces of runs to delete.", - ) - # TODO implement the below option; this removes the output workspace from file system - # delete_run.add_argument( - # "--delete-workspace", - # action="store_true", - # help="Delete the output workspace for the run.", - # ) - - # Subcommand: delete logical-worker - delete_logical_worker = delete_subcommands.add_parser( - "logical-worker", - help="Delete one or more logical workers by ID.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - delete_logical_worker.add_argument( - "worker", - type=str, - nargs="+", - help="A space-delimited list of IDs of logical workers to delete.", - ) - - # Subcommand: delete physical-worker - delete_physical_worker = delete_subcommands.add_parser( - "physical-worker", - help="Delete one or more physical workers by ID or name.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - delete_physical_worker.add_argument( - "worker", - type=str, - nargs="+", - help="A space-delimited list of IDs of physical workers to delete.", - ) - - # Subcommand: delete all-studies - delete_all_studies = delete_subcommands.add_parser( - "all-studies", - help="Delete all studies from the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - delete_all_studies.add_argument( - "-k", - "--keep-associated-runs", - action="store_true", - help="Keep runs associated with the studies.", - ) - - # Subcommand: delete all-runs - delete_subcommands.add_parser( - "all-runs", - help="Delete all runs from the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - - # Subcommand: delete all-logical-workers - delete_subcommands.add_parser( - "all-logical-workers", - help="Delete all logical workers from the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - - # Subcommand: delete all-physical-workers - delete_subcommands.add_parser( - "all-physical-workers", - help="Delete all physical workers from the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - - # Subcommand: delete everything - delete_everything = delete_subcommands.add_parser( - "everything", - help="Delete everything from the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - delete_everything.add_argument( - "-f", - "--force", - action="store_true", - help="Delete everything in the database without confirmation.", - ) - - def _add_get_subcommand(self, database_commands: ArgumentParser): - """ - Add the `get` subcommand and its options to retrieve data from the database. - - Parameters: - database_commands (ArgumentParser): The parent parser for database subcommands. - """ - db_get: ArgumentParser = database_commands.add_parser( - "get", - help="Get information stored in the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - - # Add subcommands for get - get_subcommands = db_get.add_subparsers(dest="get_type", required=True) - - # TODO enable support for retrieval of study by passing in spec file - # Subcommand: get study - get_study = get_subcommands.add_parser( - "study", - help="Get one or more studies by ID or name.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - get_study.add_argument( - "study", - type=str, - nargs="+", - help="A space-delimited list of IDs or names of the studies to get.", - ) - - # Subcommand: get run - get_run = get_subcommands.add_parser( - "run", - help="Get one or more runs by ID or workspace.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - get_run.add_argument( - "run", - type=str, - nargs="+", - help="A space-delimited list of IDs or workspaces of the runs to get.", - ) - - # Subcommand get logical-worker - get_logical_worker = get_subcommands.add_parser( - "logical-worker", - help="Get one or more logical workers by ID.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - get_logical_worker.add_argument( - "worker", - type=str, - nargs="+", - help="A space-delimited list of IDs of the logical workers to get.", - ) - - # Subcommand get physical-worker - get_physical_worker = get_subcommands.add_parser( - "physical-worker", - help="Get one or more physical workers by ID or name.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - get_physical_worker.add_argument( - "worker", - type=str, - nargs="+", - help="A space-delimited list of IDs or names of the physical workers to get.", - ) - - # Subcommand: get all-studies - get_subcommands.add_parser( - "all-studies", - help="Get all studies from the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - - # Subcommand: get all-runs - get_subcommands.add_parser( - "all-runs", - help="Get all runs from the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - - # Subcommand: get all-logical-workers - get_subcommands.add_parser( - "all-logical-workers", - help="Get all logical workers from the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - - # Subcommand: get all-physical-workers - get_subcommands.add_parser( - "all-physical-workers", - help="Get all physical workers from the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - - # Subcommand: get everything - get_subcommands.add_parser( - "everything", - help="Get everything from the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - - def add_parser(self, subparsers: ArgumentParser): - """ - Add the `database` command parser to the CLI argument parser. - - Parameters: - subparsers (ArgumentParser): The subparsers object to which the `database` command parser will be added. - """ - database: ArgumentParser = subparsers.add_parser( - "database", - help="Interact with Merlin's database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - database.set_defaults(func=self.process_command) - - database.add_argument( - "-l", - "--local", - action="store_true", - help="Use the local SQLite database for this command.", - ) - - database_commands: ArgumentParser = database.add_subparsers(dest="commands") - - # Subcommand: database info - database_commands.add_parser( - "info", - help="Print information about the database.", - formatter_class=ArgumentDefaultsHelpFormatter, - ) - - # Subcommand: database delete - self._add_delete_subcommand(database_commands) - - # Subcommand: database get - self._add_get_subcommand(database_commands) - - def process_command(self, args: Namespace): - """ - Process database commands by routing to the correct function. - - Args: - args: An argparse Namespace containing user arguments. - """ - if args.local: - initialize_config(local_mode=True) - - if args.commands == "info": - database_info() - elif args.commands == "get": - database_get(args) - elif args.commands == "delete": - database_delete(args) diff --git a/merlin/cli/commands/database/__init__.py b/merlin/cli/commands/database/__init__.py new file mode 100644 index 00000000..68f460d8 --- /dev/null +++ b/merlin/cli/commands/database/__init__.py @@ -0,0 +1,34 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +CLI command package for interacting with the Merlin database. + +This package defines and implements the `database` command group in the Merlin CLI, +enabling users to inspect, retrieve, and modify entities in the Merlin database, +including studies, runs, logical workers, and physical workers. + +The modules within this package work together to provide a dynamic, extensible interface +that adapts to the available entity types and supports filtering, bulk operations, +and safe inspection mechanisms. + +Modules: + database: Entry point for the `database` command group. Registers subcommands like `get`, `delete`, and `info`. + get: Defines the `database get` subcommand, which allows users to query and retrieve database contents. + delete: Defines the `database delete` subcommand, which supports deletion of individual entities, filtered bulk + deletions, or full database wipes. + info: Defines the `database info` subcommand, which displays configuration details and previews of database contents. + entity_registry: Maintains a central registry of supported entity types and their CLI metadata, enabling dynamic + argument generation and command dispatching. + +This package is designed for extensibility. Adding support for a new entity type generally requires only registering +it in the `ENTITY_REGISTRY`, with no changes needed to the subcommand logic (unless adding specific arguments to `get` +or `delete` logic). +""" + +from merlin.cli.commands.database.database import DatabaseCommand + +__all__ = ["DatabaseCommand"] diff --git a/merlin/cli/commands/database/database.py b/merlin/cli/commands/database/database.py new file mode 100644 index 00000000..5f919a33 --- /dev/null +++ b/merlin/cli/commands/database/database.py @@ -0,0 +1,86 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +This module defines the `DatabaseCommand` class, which provides CLI subcommands +for interacting with the Merlin application's underlying database. It supports +commands for retrieving, deleting, and inspecting database contents, including +entities like studies, runs, and workers. + +The commands are registered under the `database` top-level command and integrated +into Merlin's argument parser system. +""" + +import logging +from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace + +from merlin.cli.commands.command_entry_point import CommandEntryPoint +from merlin.cli.commands.database.delete import DatabaseDeleteCommand +from merlin.cli.commands.database.get import DatabaseGetCommand +from merlin.cli.commands.database.info import DatabaseInfoCommand + + +LOG = logging.getLogger("merlin") + + +class DatabaseCommand(CommandEntryPoint): + """ + Handles `database` CLI commands for interacting with Merlin's database. + + Attributes: + info_command (cli.commands.database.info.DatabaseInfoCommand): Handles the `database info` subcommand. + get_command (cli.commands.database.get.DatabaseGetCommand): Handles the `database get` subcommand. + delete_command (cli.commands.database.delete.DatabaseDeleteCommand): Handles the `database delete` subcommand. + + Methods: + add_parser: Adds the `database` command and its subcommands to the CLI parser. + process_command: Processes the CLI input and dispatches the appropriate action. + """ + + def __init__(self): + """ + Initialize the `DatabaseCommand` instance and its subcommand handlers. + """ + self.info_command = DatabaseInfoCommand() + self.get_command = DatabaseGetCommand() + self.delete_command = DatabaseDeleteCommand() + + def add_parser(self, subparsers: ArgumentParser): + """ + Add the `database` command parser to the CLI argument parser. + + Parameters: + subparsers (ArgumentParser): The subparsers object to which the `database` command parser will be added. + """ + database: ArgumentParser = subparsers.add_parser( + "database", + help="Interact with Merlin's database.", + formatter_class=ArgumentDefaultsHelpFormatter, + ) + database.set_defaults(func=self.process_command) + + database.add_argument( + "-l", + "--local", + action="store_true", + help="Use the local SQLite database for this command.", + ) + + database_commands: ArgumentParser = database.add_subparsers(dest="commands", required=True) + + self.info_command.add_parser(database_commands) + self.get_command.add_parser(database_commands) + self.delete_command.add_parser(database_commands) + + def process_command(self, args: Namespace): + """ + This method doesn't do anything as the subcommands each have logic + for processing their respective commands. This still has to be implemented + as we inherit from CommandEntryPoint. + + Args: + args: An argparse Namespace containing user arguments. + """ diff --git a/merlin/cli/commands/database/delete.py b/merlin/cli/commands/database/delete.py new file mode 100644 index 00000000..498674e1 --- /dev/null +++ b/merlin/cli/commands/database/delete.py @@ -0,0 +1,182 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Implements the `database delete` subcommand for the Merlin CLI. + +This module defines the `DatabaseDeleteCommand` class, which enables users to +remove entities from the Merlin database through the CLI. It supports targeted +deletion by identifier, bulk deletion of entities with optional filters, and +complete database clearance. + +Entity types and their supported operations are dynamically registered via the +`ENTITY_REGISTRY`, and entity-specific flags (e.g., study-related options) are +automatically integrated into the CLI. This design ensures easy extensibility +as new entities or deletion options are introduced. + +Main Capabilities: +- `database delete `: Delete specific entities by their IDs. +- `database delete all-`: Delete all instances of an entity type with optional filters. +- `database delete everything`: Delete the entire database, with optional force flag. +""" + +import logging +from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace +from typing import Dict, List + +from merlin.cli.commands.command_entry_point import CommandEntryPoint +from merlin.cli.commands.database.entity_registry import ENTITY_REGISTRY +from merlin.cli.utils import ( + get_filters_for_entity, + get_singular_of_entity, + setup_db_entity_subcommands, +) +from merlin.config.configfile import initialize_config +from merlin.db_scripts.merlin_db import MerlinDatabase + +LOG = logging.getLogger("merlin") + + +class DatabaseDeleteCommand(CommandEntryPoint): + """ + Handles the `database delete` subcommand, which deletes entities from the + Merlin database based on type, identifiers, and filters. + + Methods: + add_parser: Register the `database delete` command and its subcommands with the CLI parser. + process_command: Dispatch the appropriate database deletion logic based on CLI args. + _delete_entities: Deletes specific entities. + _delete_all_entities: Deletes all entities of a type, applying filters if present. + """ + + def add_parser(self, database_commands: ArgumentParser): + """ + Add the `database delete` subcommand parser to the CLI argument parser. + + Parameters: + database_commands (ArgumentParser): The subparsers object to which the `database delete` + subcommand parser will be added. + """ + # Subcommand: database delete + db_delete_parser = database_commands.add_parser( + "delete", + help=f"Delete information stored in the database.", + formatter_class=ArgumentDefaultsHelpFormatter, + ) + db_delete_parser.set_defaults(func=self.process_command) + + # Subcommand: database delete or all- + delete_subcommands_parser = db_delete_parser.add_subparsers(dest="delete_type", required=True) + db_delete_subcommands = setup_db_entity_subcommands(delete_subcommands_parser, "delete") + + # Add additional arguments for specific commands + db_delete_subcommands["study"].add_argument( + "-k", + "--keep-associated-runs", + action="store_true", + help="Keep runs associated with the studies.", + ) + db_delete_subcommands["all-studies"].add_argument( + "-k", + "--keep-associated-runs", + action="store_true", + help="Keep runs associated with the studies.", + ) + + # Subcommand: database delete everything + delete_everything = delete_subcommands_parser.add_parser( + "everything", + help="Delete everything from the database.", + formatter_class=ArgumentDefaultsHelpFormatter, + ) + delete_everything.add_argument( + "-f", + "--force", + action="store_true", + help="Delete everything in the database without confirmation.", + ) + + def _extract_entity_kwargs(self, args: Namespace, entity_type: str) -> Dict: + """ + Extracts entity-specific keyword arguments from the CLI args for deletion logic. + + Parameters: + args (Namespace): Parsed CLI arguments. + entity_type (str): The singular form of the entity type being deleted. + + Returns: + Keyword arguments to pass to `delete` or `delete_all`. + """ + kwargs = {} + + if entity_type == "study": + kwargs["remove_associated_runs"] = not args.keep_associated_runs + + # Add more entity-specific argument extraction here as needed + return kwargs + + def _delete_entities(self, entity_type: str, identifiers: List[str], merlin_db: MerlinDatabase, **kwargs): + """ + Delete individual entities of the given type by their identifiers. + + Parameters: + entity_type (str): The type of entity to delete (e.g., "run", "study"). + identifiers (List[str]): A list of entity identifiers to delete. + merlin_db (MerlinDatabase): Interface to the Merlin database. + """ + for identifier in identifiers: + merlin_db.delete(entity_type.replace("-", "_"), identifier, **kwargs) + + def _delete_all_entities(self, entity_type: str, args: Namespace, merlin_db: MerlinDatabase, **kwargs): + """ + Delete all entities of the given type, optionally applying filters. + + Parameters: + entity_type (str): The type of entity to delete (e.g., "run", "study"). + args (Namespace): Parsed CLI arguments containing optional filter values. + merlin_db (MerlinDatabase): Interface to the Merlin database. + """ + db_entity_type = entity_type.replace("-", "_") + filters = get_filters_for_entity(args, entity_type) + + if filters: + LOG.info(f"Deleting all {entity_type} entities matching filters: {filters}") + entities = merlin_db.get_all(db_entity_type, filters=filters) + for entity in entities: + merlin_db.delete(db_entity_type, entity.id, **kwargs) + else: + LOG.info(f"Deleting all {entity_type} entities (no filters applied)") + merlin_db.delete_all(db_entity_type, **kwargs) + + def process_command(self, args: Namespace): + """ + Process the `database delete` command using the provided CLI arguments. + + Args: + args (Namespace): Parsed CLI arguments from the user. + """ + if args.local: + initialize_config(local_mode=True) + + merlin_db = MerlinDatabase() + delete_type = args.delete_type + + # Process `delete everything` command + if delete_type == "everything": + merlin_db.delete_everything(force=args.force) + # Process `delete all-` commands + elif delete_type.startswith("all-"): + entity_type = get_singular_of_entity(delete_type[4:]) + kwargs = self._extract_entity_kwargs(args, entity_type) + self._delete_all_entities(entity_type, args, merlin_db, **kwargs) + # Process `delete ` commands + elif delete_type in ENTITY_REGISTRY: + entity_type = delete_type + kwargs = self._extract_entity_kwargs(args, entity_type) + self._delete_entities(entity_type, args.entity, merlin_db, **kwargs) + # Fallback for unrecognized + else: + LOG.error(f"Unrecognized delete_type: {delete_type}") diff --git a/merlin/cli/commands/database/entity_registry.py b/merlin/cli/commands/database/entity_registry.py new file mode 100644 index 00000000..3b86f539 --- /dev/null +++ b/merlin/cli/commands/database/entity_registry.py @@ -0,0 +1,66 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Defines the entity registry used for dynamic CLI command generation in the Merlin database interface. + +This registry maps entity types (e.g., study, run, logical-worker, physical-worker) to their +corresponding CLI argument metadata and supported filtering options. It is used +by CLI subcommands (such as `get`, `delete`, and `info`) to automatically construct argument +parsers and handle entity-specific logic in a generic, extensible way. + +Each entry in the registry includes: +- `filters`: A list of supported filters, where each filter specifies a name and its type + (and optionally `nargs` for multi-valued arguments). +- `identifiers`: A human-readable description of valid identifiers for referencing entities. +- `ident_arg`: The name used for the positional identifier argument in CLI commands. +- `ident_help`: The help string template for CLI identifier arguments, parameterized with `{verb}`. + +This design allows new entity types to be added to the CLI with minimal changes to the command logic. +""" + + +ENTITY_REGISTRY = { + "study": { + "filters": [ + {"name": "name", "type": str}, + ], + "identifiers": "ID or name", + "ident_arg": "study", + "ident_help": "IDs or names of the studies to {verb}.", + }, + "run": { + "filters": [ + {"name": "study_id", "type": str}, + {"name": "run_complete", "type": bool}, + {"name": "queues", "type": str, "nargs": "+"}, + {"name": "workers", "type": str, "nargs": "+"}, + ], + "identifiers": "ID or workspace", + "ident_arg": "run", + "ident_help": "IDs or workspaces of the runs to {verb}.", + }, + "logical-worker": { + "filters": [ + {"name": "name", "type": str}, + {"name": "queues", "type": str, "nargs": "+"}, + ], + "identifiers": "ID", + "ident_arg": "worker", + "ident_help": "IDs of the logical workers to {verb}.", + }, + "physical-worker": { + "filters": [ + {"name": "logical_worker_id", "type": str}, + {"name": "name", "type": str}, + {"name": "status", "type": str}, + {"name": "host", "type": str}, + ], + "identifiers": "ID or name", + "ident_arg": "worker", + "ident_help": "IDs or names of the physical workers to {verb}.", + }, +} diff --git a/merlin/cli/commands/database/get.py b/merlin/cli/commands/database/get.py new file mode 100644 index 00000000..b010d7e9 --- /dev/null +++ b/merlin/cli/commands/database/get.py @@ -0,0 +1,150 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Implements the `database get` subcommand for the Merlin CLI. + +This module defines the `DatabaseGetCommand` class, which enables users to retrieve +data from the Merlin database. Users can query individual entities by identifier, +retrieve all entities of a given type (optionally filtered), or dump the entire +database contents. + +The command supports dynamic registration of entity types using the `ENTITY_REGISTRY` +and integrates filter support for more precise queries. It also ensures compatibility +with both local and distributed configurations through the Merlin configuration system. + +Main Capabilities: +- `database get `: Retrieve one or more specific entities. +- `database get all-`: Retrieve all instances of an entity type with optional filters. +- `database get everything`: Retrieve all data from all registered entity types. +""" + +import logging +from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace +from typing import Any, List + +from merlin.cli.commands.command_entry_point import CommandEntryPoint +from merlin.cli.commands.database.entity_registry import ENTITY_REGISTRY +from merlin.cli.utils import ( + get_filters_for_entity, + get_plural_of_entity, + get_singular_of_entity, + setup_db_entity_subcommands, +) +from merlin.config.configfile import initialize_config +from merlin.db_scripts.merlin_db import MerlinDatabase + +LOG = logging.getLogger("merlin") + + +class DatabaseGetCommand(CommandEntryPoint): + """ + Handles the `database get` subcommand, which retrieves data from the + Merlin database based on entity type, identifiers, and filters. + + Methods: + add_parser: Adds the `database get` parser and its subcommands. + process_command: Dispatches the appropriate get operation based on CLI args. + _print_items: Outputs items or logs a fallback message. + _get_and_print: Fetches and prints specific entities. + _get_all_and_print: Fetches and prints filtered entities of a type. + """ + + def add_parser(self, database_commands: ArgumentParser): + """ + Add the `database get` subcommand parser to the CLI argument parser. + + Parameters: + database_commands (ArgumentParser): The subparsers object to which the `database get` + subcommand parser will be added. + """ + # Subcommand: database get + db_get_parser = database_commands.add_parser( + "get", + help=f"Get information stored in the database.", + formatter_class=ArgumentDefaultsHelpFormatter, + ) + db_get_parser.set_defaults(func=self.process_command) + + # Subcommand: database get or all- + get_subcommands_parser = db_get_parser.add_subparsers(dest="get_type", required=True) + setup_db_entity_subcommands(get_subcommands_parser, "get") + + # Subcommand: database get everything + get_subcommands_parser.add_parser( + "everything", + help="Get everything from the database.", + formatter_class=ArgumentDefaultsHelpFormatter, + ) + + def _print_items(self, items: List[Any], empty_message: str): + """ + Print each item in a list, or log a message if the list is empty. + + Args: + items (List[Any]): List of items to print. + empty_message (str): Message to log if the list is empty. + """ + if items: + for item in items: + print(item) + else: + LOG.info(empty_message) + + def _get_and_print(self, entity_type: str, identifiers: List[str], merlin_db: MerlinDatabase): + """ + Fetch and print specific entities by type and identifier(s). + + Args: + entity_type (str): The type of entity to retrieve (e.g., "study", "run"). + identifiers (List[str]): List of identifiers to retrieve. + merlin_db (MerlinDatabase): Interface to the Merlin database. + """ + items = [merlin_db.get(entity_type.replace("-", "_"), ident) for ident in identifiers] + plural_name = get_plural_of_entity(entity_type, join_delimiter=" ") + self._print_items(items, f"No {plural_name} found for the given identifiers.") + + def _get_all_and_print(self, args: Namespace, entity_type: str, merlin_db: MerlinDatabase): + """ + Fetch and print all entities of a given type, with optional filters. + + Args: + args (Namespace): Parsed CLI arguments. + entity_type (str): The type of entity to retrieve (e.g., "run", "logical-worker"). + merlin_db (MerlinDatabase): Interface to the Merlin database. + """ + filters = get_filters_for_entity(args, entity_type) + items = merlin_db.get_all(entity_type.replace("-", "_"), filters=filters) + plural_name = get_plural_of_entity(entity_type, join_delimiter=" ") + filter_msg = f" with filters {filters}" if filters else "" + self._print_items(items, f"No {plural_name}{filter_msg} found in the database.") + + def process_command(self, args: Namespace): + """ + Process the `database get` command using the provided CLI arguments. + + Args: + args: An argparse Namespace containing user arguments. + """ + if args.local: + initialize_config(local_mode=True) + + merlin_db = MerlinDatabase() + get_type = args.get_type + + # Process `get everything` command + if get_type == "everything": + self._print_items(merlin_db.get_everything(), "Nothing found in the database.") + # Process `get all-` commands + elif get_type.startswith("all-"): + entity_type = get_singular_of_entity(get_type[4:]) # Remove "all-" prefix first + self._get_all_and_print(args, entity_type, merlin_db) + # Process `get ` commands + elif get_type in ENTITY_REGISTRY: + self._get_and_print(get_type, args.entity, merlin_db) + # Fallback for unrecognized + else: + LOG.error(f"Unrecognized get_type: {get_type}") diff --git a/merlin/cli/commands/database/info.py b/merlin/cli/commands/database/info.py new file mode 100644 index 00000000..df06878a --- /dev/null +++ b/merlin/cli/commands/database/info.py @@ -0,0 +1,73 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +This module defines the `DatabaseInfoCommand` class, which implements the +`database info` subcommand for the Merlin CLI. + +The `database info` subcommand provides users with summary details about the +currently active database configuration and contents. This includes backend type, +connection information, and a preview of stored entities. + +The command is integrated into the broader Merlin CLI infrastructure through +the `CommandEntryPoint` base class and is registered under the top-level +`database` command group. +""" + +from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace + +from merlin.cli.commands.command_entry_point import CommandEntryPoint +from merlin.config.configfile import initialize_config +from merlin.db_scripts.merlin_db import MerlinDatabase + + +class DatabaseInfoCommand(CommandEntryPoint): + """ + Handles the `database info` subcommand, which prints configuration + details about the currently active database backend. + + Methods: + add_parser: Adds the `database info` command to the CLI parser. + process_command: Processes the CLI input and dispatches the appropriate action. + """ + + def add_parser(self, subparsers: ArgumentParser): + """ + Add the `database info` subcommand parser to the CLI argument parser. + + Parameters: + subparsers (ArgumentParser): The subparsers object to which the `database info` + subcommand parser will be added. + """ + parser = subparsers.add_parser( + "info", + help="Print information about the database.", + formatter_class=ArgumentDefaultsHelpFormatter, + ) + parser.set_defaults(func=self.process_command) + + parser.add_argument( + "-m", + "--max-preview", + type=int, + default=3, + help="The maximum number of entities to preview in the output.", + ) + + def process_command(self, args: Namespace): + """ + Print information about the database to the console. + + Args: + args: An argparse Namespace containing user arguments. + """ + # TODO figure out a better way to handle configurations so we don't have to + # check this in each of the database subcommands + if args.local: + initialize_config(local_mode=True) + + merlin_db = MerlinDatabase() + merlin_db.info(max_preview=args.max_preview) diff --git a/merlin/cli/utils.py b/merlin/cli/utils.py index 4f461d34..2932acc9 100644 --- a/merlin/cli/utils.py +++ b/merlin/cli/utils.py @@ -14,13 +14,14 @@ """ import logging -from argparse import Namespace +from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace from contextlib import suppress -from typing import Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union +from merlin.cli.commands.database.entity_registry import ENTITY_REGISTRY from merlin.spec.expansion import RESERVED, get_spec_with_expansion from merlin.spec.specification import MerlinSpec -from merlin.utils import verify_filepath +from merlin.utils import pluralize, singularize, verify_filepath LOG = logging.getLogger("merlin") @@ -108,3 +109,139 @@ def get_merlin_spec_with_override(args: Namespace) -> Tuple[MerlinSpec, str]: variables_dict = parse_override_vars(args.variables) spec = get_spec_with_expansion(filepath, override_vars=variables_dict) return spec, filepath + + +def transform_entity_suffix(entity_name: str, transform_fn, split_delimiter: str = "-", join_delimiter: str = "-") -> str: + """ + Applies a transformation function to the last word of a `delimiter`-separated entity name. + + Args: + entity_name (str): The original entity name (e.g., "logical-worker"). + transform_fn (Callable): A function like `pluralize` or `singularize`. + split_delimiter (str): The delimiter to use when splitting the entity name (default: "-"). + join_delimiter (str): The delimiter to use when joining the entity name (default: "-"). + + Returns: + The transformed entity name with the suffix modified. + """ + entity_words = entity_name.split(split_delimiter) + entity_words[-1] = transform_fn(entity_words[-1]) + return join_delimiter.join(entity_words) + + +def get_plural_of_entity(entity_name: str, split_delimiter: str = "-", join_delimiter: str = "-") -> str: + """ + Converts the last word of a `delimiter`-separated entity name to its plural form. + + Args: + entity_name (str): The original entity name (e.g., "logical-worker"). + split_delimiter (str): The delimiter to use when splitting the entity name (default: "-"). + join_delimiter (str): The delimiter to use when joining the entity name (default: "-"). + + Returns: + The pluralized entity name. + """ + return transform_entity_suffix(entity_name, pluralize, split_delimiter=split_delimiter, join_delimiter=join_delimiter) + + +def get_singular_of_entity(entity_name: str, split_delimiter: str = "-", join_delimiter: str = "-") -> str: + """ + Converts the last word of a `delimiter`-separated entity name to its singular form. + + Args: + entity_name (str): The plural entity name (e.g., "logical-workers"). + split_delimiter (str): The delimiter to use when splitting the entity name (default: "-"). + join_delimiter (str): The delimiter to use when joining the entity name (default: "-"). + + Returns: + The singularized entity name. + """ + return transform_entity_suffix(entity_name, singularize, split_delimiter=split_delimiter, join_delimiter=join_delimiter) + + +def setup_db_entity_subcommands(subcommand_parser: ArgumentParser, subcommand_name: str) -> dict[str, ArgumentParser]: + """ + Dynamically sets up subcommands for each entity type for a given subcommand. + + This function adds both singular (``) and plural (`all-`) variants + to support direct targeting and filter-based selection, respectively. + + Args: + subcommand_parser (ArgumentParser): The parser to which entity subcommands should be added. + subcommand_name (str): The name of the subcommand being configured (e.g., "delete", "get"). + + Returns: + A mapping from subcommand name to the corresponding ArgumentParser instance. + """ + parser_map = {} + + for entity_key, config in ENTITY_REGISTRY.items(): + identifiers = config["identifiers"] + ident_help = config["ident_help"].format(verb=subcommand_name) + plural_name = get_plural_of_entity(entity_key) + filters = config["filters"] + + # command + singular = subcommand_parser.add_parser( + entity_key, + help=f"{subcommand_name.capitalize()} one or more {plural_name} by {identifiers}.", + formatter_class=ArgumentDefaultsHelpFormatter, + ) + singular.add_argument( + "entity", + type=str, + nargs="+", + help=ident_help, + ) + parser_map[entity_key] = singular + + # all- command + all_name = f"all-{plural_name}" + all_parser = subcommand_parser.add_parser( + all_name, + help=f"{subcommand_name.capitalize()} all {plural_name} (supports filters).", + formatter_class=ArgumentDefaultsHelpFormatter, + ) + for filt in filters: + arg_name = filt["name"] + arg_type = filt["type"] + nargs = filt.get("nargs") + all_parser.add_argument( + f"--{arg_name.replace('_', '-')}", + type=arg_type, + nargs=nargs, + help=f"Filter by {arg_name.replace('_', ' ')}.", + ) + parser_map[all_name] = all_parser + + return parser_map + + +def get_filters_for_entity(args: Namespace, entity_type: str) -> Dict: + """ + Extracts filter arguments from parsed CLI input for a specific entity type. + + This is used to dynamically build the keyword arguments for querying or deleting + entities via the database manager. + + Args: + args (Namespace): Parsed command-line arguments. + entity_type (str): The entity type whose filter definitions should be used. + + Returns: + A dictionary of filter argument names to their provided values. Returns an + empry dictionary if the entity is invalid or has no filters. + """ + entity_config = ENTITY_REGISTRY.get(entity_type, None) + if not entity_config: + LOG.error(f"Invalid entity: '{entity_type}'.") + return {} + + filter_options = entity_config.get("filters", {}) + if not filter_options: + LOG.error(f"No filters supported for '{entity_type}'.") + return {} + + filter_keys = [filter["name"] for filter in filter_options] + filters = {key: getattr(args, key) for key in filter_keys if getattr(args, key) is not None} + return filters diff --git a/merlin/db_scripts/__init__.py b/merlin/db_scripts/__init__.py index c03bdfb4..b1330b7d 100644 --- a/merlin/db_scripts/__init__.py +++ b/merlin/db_scripts/__init__.py @@ -22,8 +22,6 @@ data_models.py: Defines the dataclasses used to represent raw records in the database, such as [`StudyModel`][db_scripts.data_models.StudyModel], [`RunModel`][db_scripts.data_models.RunModel], etc. - db_commands.py: Exposes database-related commands intended for external use (e.g., CLI or scripts), - allowing users to interact with Merlin's stored data. merlin_db.py: Contains the [`MerlinDatabase`][db_scripts.merlin_db.MerlinDatabase] class, which aggregates all entity managers and acts as the central access point for database operations across the system. diff --git a/merlin/db_scripts/db_commands.py b/merlin/db_scripts/db_commands.py deleted file mode 100644 index 45231325..00000000 --- a/merlin/db_scripts/db_commands.py +++ /dev/null @@ -1,171 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -""" -This module acts as an interface for users to interact with Merlin's -database. -""" - -import logging -from argparse import Namespace -from typing import Any, List - -from merlin.db_scripts.merlin_db import MerlinDatabase - - -LOG = logging.getLogger("merlin") - - -def database_info(): - """ - Print information about the database to the console. - """ - merlin_db = MerlinDatabase() - db_studies = merlin_db.get_all("study") - db_runs = merlin_db.get_all("run") - db_logical_workers = merlin_db.get_all("logical_worker") - db_physical_workers = merlin_db.get_all("physical_worker") - - print("Merlin Database Information") - print("---------------------------") - print("General Information:") - print(f"- Database Type: {merlin_db.get_db_type()}") - print(f"- Database Version: {merlin_db.get_db_version()}") - print(f"- Connection String: {merlin_db.get_connection_string()}") - - print() - print("Studies:") - print(f"- Total: {len(db_studies)}") - # TODO add something about recent studies that looks like so: - # - Recent Studies: - # 1. Study ID: 123, Name: "Experiment A" - # 2. Study ID: 124, Name: "Experiment B" - # 3. Study ID: 125, Name: "Experiment C" - # (and 9 more studies) - - print() - print("Runs:") - print(f"- Total: {len(db_runs)}") - # TODO add something about recent runs that looks like so: - # - Recent Runs: - # 1. Run ID: 456, Workspace: "/path/to/workspace" - # 2. Run ID: 457, Workspace: "/path/to/workspace" - # 3. Run ID: 458, Workspace: "/path/to/workspace" - # (and 42 more runs) - - print() - print("Logical Workers:") - print(f"- Total: {len(db_logical_workers)}") - - print() - print("Physical Workers:") - print(f"- Total: {len(db_physical_workers)}") - - print() - - -def database_get(args: Namespace): - """ - Handles the delegation of get operations to Merlin's database. - - Args: - args: Parsed CLI arguments from the user. - """ - merlin_db = MerlinDatabase() - - def print_items(items: List[Any], empty_message: str): - """ - Prints a list of items or logs a message if the list is empty. - - Args: - items: List of items to print. - empty_message: Message to log if the list is empty. - """ - if items: - for item in items: - print(item) - else: - LOG.info(empty_message) - - def get_and_print(entity_type: str, identifiers: List[str]): - """ - Get entities by type and identifiers, then print them. - - Args: - entity_type: The entity type (study, run, logical_worker, etc.). - identifiers: List of identifiers for fetching. - """ - items = [merlin_db.get(entity_type, identifier) for identifier in identifiers] - print_items(items, f"No {entity_type.replace('_', ' ')}s found for the given identifiers.") - - def get_all_and_print(entity_type: str): - """ - Get all entities of a given type and print them. - - Args: - entity_type: The entity type. - """ - items = merlin_db.get_all(entity_type) - entity_type_str = "studies" if entity_type == "study" else f"{entity_type.replace('_', ' ')}s" - print_items(items, f"No {entity_type_str} found in the database.") - - operations = { - "study": lambda: get_and_print("study", args.study), - "run": lambda: get_and_print("run", args.run), - "logical-worker": lambda: get_and_print("logical_worker", args.worker), - "physical-worker": lambda: get_and_print("physical_worker", args.worker), - "all-studies": lambda: get_all_and_print("study"), - "all-runs": lambda: get_all_and_print("run"), - "all-logical-workers": lambda: get_all_and_print("logical_worker"), - "all-physical-workers": lambda: get_all_and_print("physical_worker"), - "everything": lambda: print_items(merlin_db.get_everything(), "Nothing found in the database."), - } - - operation = operations.get(args.get_type) - if operation: - operation() - else: - LOG.error("No valid get option provided.") - - -def database_delete(args: Namespace): - """ - Handles the delegation of delete operations to Merlin's database. - - Args: - args: Parsed CLI arguments from the user. - """ - merlin_db = MerlinDatabase() - - def delete_entities(entity_type: str, identifiers: List[str], **kwargs): - """ - Delete a list of entities by type and identifier. - - Args: - entity_type: The entity type (study, run, etc.). - identifiers: The identifiers of the entities to delete. - kwargs: Additional keyword args for the delete call. - """ - for identifier in identifiers: - merlin_db.delete(entity_type, identifier, **kwargs) - - operations = { - "study": lambda: delete_entities("study", args.study, remove_associated_runs=not args.keep_associated_runs), - "run": lambda: delete_entities("run", args.run), - "logical-worker": lambda: delete_entities("logical_worker", args.worker), - "physical-worker": lambda: delete_entities("physical_worker", args.worker), - "all-studies": lambda: merlin_db.delete_all("study", remove_associated_runs=not args.keep_associated_runs), - "all-runs": lambda: merlin_db.delete_all("run"), - "all-logical-workers": lambda: merlin_db.delete_all("logical_worker"), - "all-physical-workers": lambda: merlin_db.delete_all("physical_worker"), - "everything": lambda: merlin_db.delete_everything(force=args.force), - } - - operation = operations.get(args.delete_type) - if operation: - operation() - else: - LOG.error("No valid delete option provided.") diff --git a/merlin/db_scripts/merlin_db.py b/merlin/db_scripts/merlin_db.py index eedbd05d..bc92b214 100644 --- a/merlin/db_scripts/merlin_db.py +++ b/merlin/db_scripts/merlin_db.py @@ -20,6 +20,7 @@ from merlin.db_scripts.entity_managers.run_manager import RunManager from merlin.db_scripts.entity_managers.study_manager import StudyManager from merlin.exceptions import EntityManagerNotSupportedError +from merlin.utils import pluralize LOG = logging.getLogger("merlin") @@ -186,21 +187,22 @@ def get(self, entity_type: str, *args, **kwargs) -> Any: self._validate_entity_type(entity_type) return self._entity_managers[entity_type].get(*args, **kwargs) - def get_all(self, entity_type: str) -> List[Any]: + def get_all(self, entity_type: str, **filters) -> List[Any]: """ - Get all entities of a specific type. + Get all entities of a specific type, optionally filtering results. Args: entity_type: The type of entities to get (study, run, logical_worker, physical_worker). + **filters: Arbitrary keyword filters to apply (e.g., status="running"). Returns: - A list of all entities of the specified type. + A list of all entities of the specified type matching the filters. Raises: EntityManagerNotSupportedError: If the entity type is not supported. """ self._validate_entity_type(entity_type) - return self._entity_managers[entity_type].get_all() + return self._entity_managers[entity_type].get_all(**filters) def delete(self, entity_type: str, *args, **kwargs) -> None: """ @@ -265,3 +267,86 @@ def delete_everything(self, force: bool = False) -> None: LOG.info("Database successfully flushed.") else: LOG.info("Database flush cancelled.") + + def _fetch_info_data(self, max_preview: int): + """ + + """ + display_config = { + "study": {"ID": "get_id", "Name": "get_name"}, + "run": {"ID": "get_id", "Workspace": "get_workspace"}, + "logical_worker": {"ID": "get_id", "Name": "get_name", "Queues": "get_queues"}, + "physical_worker": {"ID": "get_id", "Name": "get_name"}, + } + + entity_summaries = {} + + for entity_type, manager in self._entity_managers.items(): + all_entities = manager.get_all() + preview_config = display_config.get(entity_type, {}) + preview_data = [] + + for entity in all_entities[:max_preview]: + preview = {} + for label, method_name in preview_config.items(): + value = "" + method = getattr(entity, method_name, None) + if callable(method): + value = method() + else: + LOG.warning(f"Method '{method}' is not callable.") + preview[label] = value + preview_data.append(preview) + + entity_summaries[entity_type] = { + "total": len(all_entities), + "preview": preview_data, + "fields": list(preview_config.keys()), + } + + return entity_summaries + + def _display_info_data(self, entity_summaries: Dict): + """ + """ + # Display general information + print("Merlin Database Information") + print("---------------------------") + print("General Information:") + print(f"- Database Type: {self.get_db_type()}") + print(f"- Database Version: {self.get_db_version()}") + print(f"- Connection String: {self.get_connection_string()}\n") + + # Display entity-specific information + for entity_type, summary in entity_summaries.items(): + title_words = entity_type.replace("_", " ").title().split() + title_words[-1] = pluralize(title_words[-1]) + title = " ".join(title_words) + print(f"{title}:") + print(f"- Total: {summary['total']}") + + if summary["total"] > 0 and summary["fields"]: + print(f"- Recent {title}:") + for i, preview in enumerate(summary["preview"], start=1): + detail_str = ", ".join( + f"{field.title()}: {preview.get(field, '')}" + for field in summary["fields"] + ) + print(f" {i}. {detail_str}") + remaining = summary["total"] - len(summary["preview"]) + if remaining > 0: + print(f" (and {remaining} more {entity_type}s)") + print() + + def info(self, max_preview: int = 3): + """ + Print summarized information about the database contents. + + Args: + max_preview: Number of recent entries to preview per entity type. + """ + # Step 1: Fetch all data first to avoid log statements cluttering output + entity_summaries = self._fetch_info_data(max_preview) + + # Step 2: Print everything after fetching + self._display_info_data(entity_summaries) \ No newline at end of file diff --git a/merlin/utils.py b/merlin/utils.py index b8ad8c07..091ac585 100644 --- a/merlin/utils.py +++ b/merlin/utils.py @@ -33,6 +33,59 @@ DEFAULT_FLUX_VERSION = "0.48.0" +def pluralize(word: str) -> str: + """ + Return the plural form of a given English noun. + + This does not take into account irregular nouns like "goose", + "man", etc. + + Args: + word (str): The singular noun to pluralize. + + Returns: + str: The plural form of the noun. + """ + if word.endswith(("s", "sh", "ch", "x", "z")): # e.g., "bash" -> "bashes" + return word + "es" + elif word.endswith("y") and word[-2] not in "aeiou": # e.g., "study" -> "studies" + return word[:-1] + "ies" + elif word.endswith("f"): # e.g., "wolf" -> "wolves" + return word[:-1] + "ves" + elif word.endswith("fe"): # e.g., "knife" -> "knives" + return word[:-2] + "ves" + else: # e.g., "worker" -> "workers" + return word + "s" + + +def singularize(word: str) -> str: + """ + Return the singular form of a given English noun. + + This is the inverse of the `pluralize` function and assumes regular + pluralization rules only (no irregulars like "geese" → "goose"). + + Args: + word (str): The plural noun to singularize. + + Returns: + str: The singular form of the noun. + """ + if word.endswith("ies") and len(word) > 3 and word[-4] not in "aeiou": # e.g., "studies" -> "study" + return word[:-3] + "y" + elif word.endswith("ves"): + if len(word) > 4 and word[-4] == "l": # e.g., "wolves" -> "wolf" + return word[:-3] + "f" + else: # default assumption: e.g., "knives" -> "knife" + return word[:-3] + "fe" + elif word.endswith("es") and any(word.endswith(suffix + "es") for suffix in ("s", "sh", "ch", "x", "z")): # e.g., "bashes" -> "bash" + return word[:-2] + elif word.endswith("s") and not word.endswith("ss"): # e.g., "workers" -> "worker" + return word[:-1] + else: + return word # likely already singular or unrecognized plural + + def get_user_process_info(user: str = None, attrs: List[str] = None) -> List[Dict]: """ Return a list of process information for all of the user's running processes. From b08f3589b0d1f92e267aea15e665b7e82d59389f Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 30 Jul 2025 11:15:19 -0700 Subject: [PATCH 24/91] add filtering logic to the entity managers --- .../entity_managers/entity_manager.py | 72 ++++++++++++++++--- .../entity_managers/logical_worker_manager.py | 38 +++++++--- .../physical_worker_manager.py | 40 ++++++++--- .../db_scripts/entity_managers/run_manager.py | 40 ++++++++--- .../entity_managers/study_manager.py | 37 +++++++--- merlin/db_scripts/merlin_db.py | 9 ++- 6 files changed, 181 insertions(+), 55 deletions(-) diff --git a/merlin/db_scripts/entity_managers/entity_manager.py b/merlin/db_scripts/entity_managers/entity_manager.py index 8c890fe4..e0640334 100644 --- a/merlin/db_scripts/entity_managers/entity_manager.py +++ b/merlin/db_scripts/entity_managers/entity_manager.py @@ -16,7 +16,7 @@ import logging from abc import ABC, abstractmethod -from typing import Any, Callable, Generic, List, Optional, Type, TypeVar +from typing import Any, Callable, Dict, Generic, List, Optional, Type, TypeVar from merlin.backends.results_backend import ResultsBackend from merlin.db_scripts.data_models import BaseDataModel @@ -44,6 +44,10 @@ class EntityManager(Generic[T, M], ABC): Attributes: backend: The backend interface used to persist and retrieve entity data. + _filter_accessor_map: A dictionary mapping supported filter keys to accessor functions + for the entity type. Used by filtering logic (e.g., in `get_all`) to dynamically + retrieve values from entity instances. Subclasses must override this to enable + filtering support. Methods: create: Abstract method to create a new entity. @@ -58,6 +62,8 @@ class EntityManager(Generic[T, M], ABC): _delete_all_by_type: Deletes all entities of a certain type using the provided getter and deleter functions. """ + _filter_accessor_map: Dict[str, Callable[[T], Any]] = {} + def __init__(self, backend: ResultsBackend): """ Initialize the EntityManager with a backend. @@ -103,18 +109,68 @@ def get(self, identifier: str) -> T: """ raise NotImplementedError("Subclasses of `EntityManager` must implement a `get` method.") - @abstractmethod - def get_all(self) -> List[T]: + def _matches_filters(self, entity: T, filters: Dict) -> bool: """ - Retrieve all entities managed by this entity manager. + Determines whether a given entity matches all provided filter criteria. + + This method uses a predefined mapping of filter keys to accessor functions + (`_filter_accessor_map` which subclasses will need to implement) to retrieve + values from the entity. It supports both scalar and list-based comparisons. + For list filters (e.g., "queues"), the filter matches if any expected value + is present in the entity's corresponding list. + + Args: + entity: The entity instance to check against the filters. + filters: A dictionary of filter keys and values used to narrow down the query results. + Filter keys must correspond to entries in the `_filter_accessor_map` defined + by the subclass. Values are compared against the entity’s corresponding attributes + or methods (e.g., {"name": "foo"}, {"queues": ["queue1", "queue2"]}). Returns: - A list of all entities of the specified type. + True if the entity matches all filter conditions, False otherwise. + """ + for key, expected in filters.items(): + # Obtain the correct getter method + accessor = self._filter_accessor_map.get(key, None) + if not accessor: + LOG.warning(f"Could not obtain accessor for filter '{key}'. Skipping this filter.") + continue + + # Call the getter on the entity to get the actual value + actual = accessor(entity) + + # Case where filter is a list + if isinstance(expected, list): + # Match if any expected value is in the actual list (e.g., queues) + if not isinstance(actual, list) or not any(val in actual for val in expected): + return False + # Case where filter is str or bool + else: + if actual != expected: + return False + return True + + def get_all(self, filters: Dict = None) -> List[T]: + """ + Retrieve all entities managed by this entity manager, optionally filtered by attributes. - Raises: - NotImplementedError: If a subclass has not implemented this method. + Args: + filters: A dictionary of filter keys and values used to narrow down the query results. + Filter keys must correspond to supported filters defined in the ENTITY_REGISTRY + for the given entity type. Values are compared against entity attributes or + accessor methods (e.g., {"name": "foo"}, {"queues": ["queue1", "queue2"]}). + + Returns: + A list of all entities of the specified type matching the filters. """ - raise NotImplementedError("Subclasses of `EntityManager` must implement a `get_all` method.") + all_entities = self._get_all_entities(self._entity_class, self._entity_type) + + if not filters: + return all_entities + + entities_matching_filters = [entity for entity in all_entities if self._matches_filters(entity, filters)] + LOG.info(f"Found {len(entities_matching_filters)} entities matching the filters '{filters}'.") + return entities_matching_filters @abstractmethod def delete(self, identifier: str, **kwargs: Any): diff --git a/merlin/db_scripts/entity_managers/logical_worker_manager.py b/merlin/db_scripts/entity_managers/logical_worker_manager.py index 9cebe5f2..57e791d1 100644 --- a/merlin/db_scripts/entity_managers/logical_worker_manager.py +++ b/merlin/db_scripts/entity_managers/logical_worker_manager.py @@ -20,11 +20,12 @@ from __future__ import annotations import logging -from typing import List +from typing import Any, Callable, Dict, List +from merlin.backends.results_backend import ResultsBackend from merlin.db_scripts.data_models import LogicalWorkerModel from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity -from merlin.db_scripts.entity_managers.entity_manager import EntityManager +from merlin.db_scripts.entity_managers.entity_manager import EntityManager, T from merlin.exceptions import RunNotFoundError @@ -46,6 +47,10 @@ class LogicalWorkerManager(EntityManager[LogicalWorkerEntity, LogicalWorkerModel backend: The backend interface used for storing and retrieving logical workers. db: Reference to the main database interface, used for cross-entity operations such as detaching workers from runs. + _filter_accessor_map: A dictionary mapping supported filter keys to accessor functions + for the entity type. Used by filtering logic (e.g., in `get_all`) to dynamically + retrieve values from entity instances. Subclasses must override this to enable + filtering support. Methods: create: Create a logical worker with the given name and queue list. @@ -56,6 +61,26 @@ class LogicalWorkerManager(EntityManager[LogicalWorkerEntity, LogicalWorkerModel set_db_reference: Set a reference to the main database object for accessing related entities. """ + _filter_accessor_map: Dict[str, Callable[[T], Any]] = { + "name": lambda e: e.get_name(), + "queues": lambda e: e.get_queues(), + } + + def __init__(self, backend: ResultsBackend): + """ + Initialize the PhysicalWorkerManager with the given backend. + + This sets up the manager to handle physical worker entities by specifying + the associated entity class and entity type string. These are used by the + base EntityManager to perform generic operations like retrieving and filtering entities. + + Args: + backend (ResultsBackend): The backend used to persist and retrieve physical worker data. + """ + super().__init__(backend) + self._entity_class = LogicalWorkerEntity + self._entity_type = "logical_worker" + def _resolve_worker_id(self, worker_id: str = None, worker_name: str = None, queues: List[str] = None) -> str: """ Resolve the logical worker ID based on provided parameters. @@ -129,15 +154,6 @@ def get(self, worker_id: str = None, worker_name: str = None, queues: List[str] worker_id = self._resolve_worker_id(worker_id=worker_id, worker_name=worker_name, queues=queues) return self._get_entity(LogicalWorkerEntity, worker_id) - def get_all(self) -> List[LogicalWorkerEntity]: - """ - Retrieve all logical worker entities from the backend. - - Returns: - A list of all logical worker entities. - """ - return self._get_all_entities(LogicalWorkerEntity, "logical_worker") - def delete(self, worker_id: str = None, worker_name: str = None, queues: List[str] = None): """ Delete a logical worker entity and clean up any references from associated runs. diff --git a/merlin/db_scripts/entity_managers/physical_worker_manager.py b/merlin/db_scripts/entity_managers/physical_worker_manager.py index 6ec06d07..c27abcba 100644 --- a/merlin/db_scripts/entity_managers/physical_worker_manager.py +++ b/merlin/db_scripts/entity_managers/physical_worker_manager.py @@ -21,11 +21,12 @@ from __future__ import annotations import logging -from typing import Any, List +from typing import Any, Callable, Dict +from merlin.backends.results_backend import ResultsBackend from merlin.db_scripts.data_models import PhysicalWorkerModel from merlin.db_scripts.entities.physical_worker_entity import PhysicalWorkerEntity -from merlin.db_scripts.entity_managers.entity_manager import EntityManager +from merlin.db_scripts.entity_managers.entity_manager import EntityManager, T from merlin.exceptions import WorkerNotFoundError @@ -47,6 +48,10 @@ class PhysicalWorkerManager(EntityManager[PhysicalWorkerEntity, PhysicalWorkerMo Attributes: backend (backends.results_backend.ResultsBackend): The backend used for database operations. db (db_scripts.merlin_db.MerlinDatabase): Optional reference to the main database for cross-entity logic. + _filter_accessor_map: A dictionary mapping supported filter keys to accessor functions + for the entity type. Used by filtering logic (e.g., in `get_all`) to dynamically + retrieve values from entity instances. Subclasses must override this to enable + filtering support. Methods: create: Create a new physical worker if it does not already exist. @@ -57,6 +62,28 @@ class PhysicalWorkerManager(EntityManager[PhysicalWorkerEntity, PhysicalWorkerMo set_db_reference: Set a reference to the main database object for cross-entity operations. """ + _filter_accessor_map: Dict[str, Callable[[T], Any]] = { + "logical_worker_id": lambda e: e.get_logical_worker_id(), + "name": lambda e: e.get_name(), + "status": lambda e: e.get_status(), + "host": lambda e: e.get_host(), + } + + def __init__(self, backend: ResultsBackend): + """ + Initialize the PhysicalWorkerManager with the given backend. + + This sets up the manager to handle physical worker entities by specifying + the associated entity class and entity type string. These are used by the + base EntityManager to perform generic operations like retrieving and filtering entities. + + Args: + backend (ResultsBackend): The backend used to persist and retrieve physical worker data. + """ + super().__init__(backend) + self._entity_class = PhysicalWorkerEntity + self._entity_type = "physical_worker" + def create(self, name: str, **kwargs: Any) -> PhysicalWorkerEntity: """ Create a physical worker entity if it does not already exist. @@ -98,15 +125,6 @@ def get(self, worker_id_or_name: str) -> PhysicalWorkerEntity: """ return self._get_entity(PhysicalWorkerEntity, worker_id_or_name) - def get_all(self) -> List[PhysicalWorkerEntity]: - """ - Retrieve all physical worker entities from the database. - - Returns: - A list of all physical workers stored in the database. - """ - return self._get_all_entities(PhysicalWorkerEntity, "physical_worker") - def delete(self, worker_id_or_name: str): """ Delete a physical worker entity by its ID or name. diff --git a/merlin/db_scripts/entity_managers/run_manager.py b/merlin/db_scripts/entity_managers/run_manager.py index d22821e9..361df87b 100644 --- a/merlin/db_scripts/entity_managers/run_manager.py +++ b/merlin/db_scripts/entity_managers/run_manager.py @@ -17,11 +17,12 @@ class to provide CRUD operations for runs associated with studies, workspaces, from __future__ import annotations import logging -from typing import Any, List +from typing import Any, Callable, Dict, List +from merlin.backends.results_backend import ResultsBackend from merlin.db_scripts.data_models import RunModel from merlin.db_scripts.entities.run_entity import RunEntity -from merlin.db_scripts.entity_managers.entity_manager import EntityManager +from merlin.db_scripts.entity_managers.entity_manager import EntityManager, T from merlin.exceptions import StudyNotFoundError, WorkerNotFoundError @@ -43,6 +44,10 @@ class RunManager(EntityManager[RunEntity, RunModel]): backend: The database backend used for storing run entities. db: Reference to the main Merlin database, allowing access to other entity managers such as studies and logical workers. + _filter_accessor_map: A dictionary mapping supported filter keys to accessor functions + for the entity type. Used by filtering logic (e.g., in `get_all`) to dynamically + retrieve values from entity instances. Subclasses must override this to enable + filtering support. Methods: create: Create a new run associated with a study and workspace. @@ -52,6 +57,28 @@ class RunManager(EntityManager[RunEntity, RunModel]): delete_all: Delete all runs in the database. set_db_reference: Set the reference to the main Merlin database for cross-entity operations. """ + + _filter_accessor_map: Dict[str, Callable[[T], Any]] = { + "study_id": lambda e: e.get_study_id(), + "run_complete": lambda e: e.run_complete, + "queues": lambda e: e.get_queues(), + "workers": lambda e: e.get_workers(), + } + + def __init__(self, backend: ResultsBackend): + """ + Initialize the PhysicalWorkerManager with the given backend. + + This sets up the manager to handle physical worker entities by specifying + the associated entity class and entity type string. These are used by the + base EntityManager to perform generic operations like retrieving and filtering entities. + + Args: + backend (ResultsBackend): The backend used to persist and retrieve physical worker data. + """ + super().__init__(backend) + self._entity_class = RunEntity + self._entity_type = "run" def create(self, study_name: str, workspace: str, queues: List[str], **kwargs: Any) -> RunEntity: """ @@ -110,15 +137,6 @@ def get(self, run_id_or_workspace: str) -> RunEntity: """ return self._get_entity(RunEntity, run_id_or_workspace) - def get_all(self) -> List[RunEntity]: - """ - Retrieve all run entities stored in the database. - - Returns: - A list of all run entities. - """ - return self._get_all_entities(RunEntity, "run") - def delete(self, run_id_or_workspace: str): """ Delete a run entity by its ID or workspace identifier. diff --git a/merlin/db_scripts/entity_managers/study_manager.py b/merlin/db_scripts/entity_managers/study_manager.py index 45765baf..70bd90c6 100644 --- a/merlin/db_scripts/entity_managers/study_manager.py +++ b/merlin/db_scripts/entity_managers/study_manager.py @@ -14,11 +14,12 @@ from __future__ import annotations -from typing import List +from typing import Any, Callable, Dict +from merlin.backends.results_backend import ResultsBackend from merlin.db_scripts.data_models import StudyModel from merlin.db_scripts.entities.study_entity import StudyEntity -from merlin.db_scripts.entity_managers.entity_manager import EntityManager +from merlin.db_scripts.entity_managers.entity_manager import EntityManager, T # Purposefully ignoring this pylint message as each entity will have different parameter requirements @@ -38,6 +39,10 @@ class StudyManager(EntityManager[StudyEntity, StudyModel]): and query study data. db (db_scripts.merlin_db.MerlinDatabase): Reference to the full `MerlinDatabase`, used for cross-entity operations (e.g., deleting associated runs). + _filter_accessor_map: A dictionary mapping supported filter keys to accessor functions + for the entity type. Used by filtering logic (e.g., in `get_all`) to dynamically + retrieve values from entity instances. Subclasses must override this to enable + filtering support. Methods: create: Create a new study if it doesn't already exist. @@ -48,6 +53,25 @@ class StudyManager(EntityManager[StudyEntity, StudyModel]): set_db_reference: Set reference to the MerlinDatabase for cross-entity access. """ + _filter_accessor_map: Dict[str, Callable[[T], Any]] = { + "name": lambda e: e.get_name(), + } + + def __init__(self, backend: ResultsBackend): + """ + Initialize the PhysicalWorkerManager with the given backend. + + This sets up the manager to handle physical worker entities by specifying + the associated entity class and entity type string. These are used by the + base EntityManager to perform generic operations like retrieving and filtering entities. + + Args: + backend (ResultsBackend): The backend used to persist and retrieve physical worker data. + """ + super().__init__(backend) + self._entity_class = StudyEntity + self._entity_type = "study" + def create(self, study_name: str) -> StudyEntity: """ Create a study if it does not already exist. @@ -85,15 +109,6 @@ def get(self, study_id_or_name: str) -> StudyEntity: """ return self._get_entity(StudyEntity, study_id_or_name) - def get_all(self) -> List[StudyEntity]: - """ - Retrieve all study entities stored in the database. - - Returns: - A list of all available study entities. - """ - return self._get_all_entities(StudyEntity, "study") - def delete(self, study_id_or_name: str, remove_associated_runs: bool = True): """ Delete a study and optionally its associated runs. diff --git a/merlin/db_scripts/merlin_db.py b/merlin/db_scripts/merlin_db.py index bc92b214..84f0cfed 100644 --- a/merlin/db_scripts/merlin_db.py +++ b/merlin/db_scripts/merlin_db.py @@ -187,13 +187,16 @@ def get(self, entity_type: str, *args, **kwargs) -> Any: self._validate_entity_type(entity_type) return self._entity_managers[entity_type].get(*args, **kwargs) - def get_all(self, entity_type: str, **filters) -> List[Any]: + def get_all(self, entity_type: str, filters: Dict = {}) -> List[Any]: """ Get all entities of a specific type, optionally filtering results. Args: entity_type: The type of entities to get (study, run, logical_worker, physical_worker). - **filters: Arbitrary keyword filters to apply (e.g., status="running"). + filters: A dictionary of filter keys and values used to narrow down the query results. + Filter keys must correspond to supported filters defined in the ENTITY_REGISTRY + for the given entity type. Values are compared against entity attributes or + accessor methods (e.g., {"name": "foo"}, {"queues": ["queue1", "queue2"]}). Returns: A list of all entities of the specified type matching the filters. @@ -202,7 +205,7 @@ def get_all(self, entity_type: str, **filters) -> List[Any]: EntityManagerNotSupportedError: If the entity type is not supported. """ self._validate_entity_type(entity_type) - return self._entity_managers[entity_type].get_all(**filters) + return self._entity_managers[entity_type].get_all(filters) def delete(self, entity_type: str, *args, **kwargs) -> None: """ From 2e12115efe7a2713274ae27322e9748c04e07af3 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 30 Jul 2025 15:39:09 -0700 Subject: [PATCH 25/91] add support for database-level filtering --- merlin/backends/filter_support_mixin.py | 48 ++++++++++ merlin/backends/redis/redis_store_base.py | 4 +- merlin/backends/sqlite/sqlite_backend.py | 29 ++++-- merlin/backends/sqlite/sqlite_store_base.py | 88 ++++++++++++++++--- .../entity_managers/entity_manager.py | 44 +++++----- 5 files changed, 172 insertions(+), 41 deletions(-) create mode 100644 merlin/backends/filter_support_mixin.py diff --git a/merlin/backends/filter_support_mixin.py b/merlin/backends/filter_support_mixin.py new file mode 100644 index 00000000..b856f6d0 --- /dev/null +++ b/merlin/backends/filter_support_mixin.py @@ -0,0 +1,48 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Provides a mixin class that adds filter-based retrieval support to backend +implementations within the Merlin database system. + +This module defines the `FilterSupportMixin`, which can be used by backend +classes that support querying entities using field-based filters. It assumes +the backend implements `_get_store_by_type(store_type)` and that each store +supports a `retrieve_all_filtered(filters)` method. +""" + +from typing import Dict, List + +from merlin.db_scripts.data_models import BaseDataModel + + +class FilterSupportMixin: + """ + Mixin for backends that support retrieving filtered entities from their + underlying stores. + + This mixin provides a `retrieve_all_filtered` method that dispatches the + filter query to the appropriate store based on `store_type`. It assumes the + backend provides a `_get_store_by_type(store_type)` method and that each + store supports filtering via a `retrieve_all_filtered(filters)` method. + + This class should be inherited alongside a ResultsBackend implementation + that supports filtering (e.g., SQLite-based backends). + """ + + def retrieve_all_filtered(self, store_type: str, filters: Dict) -> List[BaseDataModel]: + """ + Retrieve all objects from the specified store that match the given filters. + + Args: + store_type: The type of store to query (e.g., 'study', 'run', etc.) + filters: Dictionary of field-value pairs to filter on. + + Returns: + A list of filtered data model objects. + """ + store = self._get_store_by_type(store_type) + return store.retrieve_all_filtered(filters) diff --git a/merlin/backends/redis/redis_store_base.py b/merlin/backends/redis/redis_store_base.py index 5b52515d..46b2d328 100644 --- a/merlin/backends/redis/redis_store_base.py +++ b/merlin/backends/redis/redis_store_base.py @@ -124,7 +124,9 @@ def retrieve_all(self) -> List[T]: Returns: A list of entities. """ - entity_type = f"{self.key}s" if self.key != "study" else "studies" + from merlin.cli.utils import get_plural_of_entity # pylint: disable=import-outside-toplevel + + entity_type = get_plural_of_entity(self.key, split_delimiter="_", join_delimiter=" ") LOG.info(f"Fetching all {entity_type} from Redis...") pattern = f"{self.key}:*" diff --git a/merlin/backends/sqlite/sqlite_backend.py b/merlin/backends/sqlite/sqlite_backend.py index 4adacfcf..5fcadd8c 100644 --- a/merlin/backends/sqlite/sqlite_backend.py +++ b/merlin/backends/sqlite/sqlite_backend.py @@ -7,14 +7,19 @@ """ SQLite backend implementation for the Merlin application. -This module provides a concrete implementation of the `ResultsBackend` interface using SQLite -as the underlying database. It defines the `SQLiteBackend` class, which manages interactions -with SQLite-specific store classes for different data models, including schema initialization, -CRUD operations, and database flushing. +This module defines the `SQLiteBackend` class, which provides a concrete +implementation of the `ResultsBackend` interface using SQLite as the underlying +storage system. It coordinates interactions with entity-specific SQLite store +classes for studies, runs, logical workers, and physical workers. + +The backend supports standard CRUD operations, schema initialization, and +database flushing. When used with the `FilterSupportMixin`, it also supports +field-based filtering for entity retrieval. """ import logging +from merlin.backends.filter_support_mixin import FilterSupportMixin from merlin.backends.results_backend import ResultsBackend from merlin.backends.sqlite.sqlite_connection import SQLiteConnection from merlin.backends.sqlite.sqlite_stores import ( @@ -28,9 +33,18 @@ LOG = logging.getLogger(__name__) -class SQLiteBackend(ResultsBackend): +class SQLiteBackend(ResultsBackend, FilterSupportMixin): """ - A SQLite-based implementation of the `ResultsBackend` interface for interacting with a SQLite database. + A SQLite-based implementation of the `ResultsBackend` interface for managing + entity data in a local SQLite database. + + This backend delegates entity-specific operations to corresponding SQLite store + classes and provides methods for common database operations such as saving, + retrieving, deleting, and filtering entities. It also supports complete + database flushing by dropping and recreating schema tables. + + Filtering functionality is enabled via the `FilterSupportMixin`, allowing + for flexible retrieval of entities based on field-value filters. Attributes: backend_name (str): The name of the backend (e.g., "sqlite"). @@ -54,6 +68,9 @@ class SQLiteBackend(ResultsBackend): retrieve_all: Retrieve all entities from the specified store. + retrieve_all_filtered: + Retrieve all entities from the specified store, applying filters if given. + delete: Delete an entity from the specified store. """ diff --git a/merlin/backends/sqlite/sqlite_store_base.py b/merlin/backends/sqlite/sqlite_store_base.py index e1b2ddc9..506ad128 100644 --- a/merlin/backends/sqlite/sqlite_store_base.py +++ b/merlin/backends/sqlite/sqlite_store_base.py @@ -23,7 +23,7 @@ import logging from datetime import datetime -from typing import Any, Generic, List, Optional, Type +from typing import Any, Dict, Generic, List, Optional, Tuple, Type from merlin.backends.sqlite.sqlite_connection import SQLiteConnection from merlin.backends.store_base import StoreBase, T @@ -176,26 +176,71 @@ def retrieve(self, identifier: str, by_name: bool = False) -> Optional[T]: return None return deserialize_entity(dict(row), self.model_class) + + def _build_where_clause_and_params(self, filters: Dict[str, Any]) -> Tuple[str, List[Any]]: + """ + Build the SQL WHERE clause and associated parameter list from a filters dictionary. - def retrieve_all(self) -> List[T]: + Args: + filters: Dictionary where keys are column names and values are either + single values (for equality) or lists (for IN clauses). + + Returns: + A tuple of (where_clause: str, params: List[Any]) """ - Query the SQLite database for all entities of this type. + if not filters: + return "", [] + + conditions = [] + params = [] + + for column, value in filters.items(): + if isinstance(value, list): + if not value: + # Avoid generating invalid SQL like `IN ()` + conditions.append("1 = 0") + else: + # We have to use LIKE for lists since they're stored as strings + sub_conditions = [f"{column} LIKE ?" for _ in value] + conditions.append("(" + " OR ".join(sub_conditions) + ")") + params.extend([f"%{v}%" for v in value]) + else: + conditions.append(f"{column} = ?") + params.append(value) + + where_clause = "WHERE " + " AND ".join(conditions) + return where_clause, params + + def _retrieve_by_query(self, filters: Optional[Dict[str, Any]] = {}) -> List[T]: + """ + Internal method to query the SQLite database for entities with optional filters. + + Args: + filters: Optional dictionary of column filters. Returns: - A list of entities. + A list of matching entities. """ - entity_type = f"{self.table_name}s" if self.table_name != "study" else "studies" - LOG.info(f"Fetching all {entity_type} from SQLite...") + from merlin.cli.utils import get_plural_of_entity # pylint: disable=import-outside-toplevel + + entity_type = get_plural_of_entity(self.table_name, split_delimiter="_", join_delimiter=" ") + log_action = "filtered" if filters else "all" + LOG.info(f"Fetching {log_action} {entity_type} from SQLite{f' with filters: {filters}' if filters else ''}...") + + where_clause, params = self._build_where_clause_and_params(filters) + query = f"SELECT * FROM {self.table_name} {where_clause}" + LOG.debug(f"SQLite query: {query}") + LOG.debug(f"SQLite params: {params}") with SQLiteConnection() as conn: - cursor = conn.execute(f"SELECT * FROM {self.table_name}") - all_entities = [] + cursor = conn.execute(query, params) + entities = [] for row in cursor.fetchall(): try: entity = deserialize_entity(dict(row), self.model_class) if entity: - all_entities.append(entity) + entities.append(entity) else: LOG.warning( f"{self.table_name.capitalize()} with id '{row['id']}' could not be retrieved or does not exist." @@ -203,8 +248,29 @@ def retrieve_all(self) -> List[T]: except Exception as exc: # pylint: disable=broad-except LOG.error(f"Error retrieving {self.table_name} with id '{row['id']}': {exc}") - LOG.info(f"Successfully retrieved {len(all_entities)} {entity_type} from SQLite.") - return all_entities + LOG.info(f"Successfully retrieved {len(entities)} {entity_type} from SQLite ({log_action}).") + return entities + + def retrieve_all(self) -> List[T]: + """ + Query the SQLite database for all entities of this type. + + Returns: + A list of entities. + """ + return self._retrieve_by_query() + + def retrieve_all_filtered(self, filters: Dict[str, Any]) -> List[T]: + """ + Query the SQLite database for all entities of this type that match the given filters. + + Args: + filters: A dictionary where keys are column names and values are the values to match. + + Returns: + A list of filtered entities. + """ + return self._retrieve_by_query(filters=filters) def delete(self, identifier: str, by_name: bool = False): """ diff --git a/merlin/db_scripts/entity_managers/entity_manager.py b/merlin/db_scripts/entity_managers/entity_manager.py index e0640334..6608f133 100644 --- a/merlin/db_scripts/entity_managers/entity_manager.py +++ b/merlin/db_scripts/entity_managers/entity_manager.py @@ -18,6 +18,7 @@ from abc import ABC, abstractmethod from typing import Any, Callable, Dict, Generic, List, Optional, Type, TypeVar +from merlin.backends.filter_support_mixin import FilterSupportMixin from merlin.backends.results_backend import ResultsBackend from merlin.db_scripts.data_models import BaseDataModel from merlin.db_scripts.entities.db_entity import DatabaseEntity @@ -139,10 +140,13 @@ def _matches_filters(self, entity: T, filters: Dict) -> bool: # Call the getter on the entity to get the actual value actual = accessor(entity) + LOG.debug(f"actual for filter '{key}': {actual}") + LOG.debug(f"expected for filter '{key}': {expected}") + # Case where filter is a list if isinstance(expected, list): # Match if any expected value is in the actual list (e.g., queues) - if not isinstance(actual, list) or not any(val in actual for val in expected): + if not isinstance(actual, (list, set)) or not any(val in actual for val in expected): return False # Case where filter is str or bool else: @@ -163,14 +167,24 @@ def get_all(self, filters: Dict = None) -> List[T]: Returns: A list of all entities of the specified type matching the filters. """ - all_entities = self._get_all_entities(self._entity_class, self._entity_type) + if isinstance(self.backend, FilterSupportMixin) and filters: + LOG.debug(f"Using backend filtering with filters: {filters}") + raw_entities = self.backend.retrieve_all_filtered(self._entity_type, filters) + else: + mode = "with in-memory filtering" if filters else "without filters" + LOG.debug(f"Using full retrieval {mode}.") + raw_entities = self.backend.retrieve_all(self._entity_type) + + if not raw_entities: + return [] - if not filters: - return all_entities + entities = [self._entity_class(data, self.backend) for data in raw_entities] - entities_matching_filters = [entity for entity in all_entities if self._matches_filters(entity, filters)] - LOG.info(f"Found {len(entities_matching_filters)} entities matching the filters '{filters}'.") - return entities_matching_filters + if filters and not isinstance(self.backend, FilterSupportMixin): + entities = [entity for entity in entities if self._matches_filters(entity, filters)] + LOG.info(f"Filtered down to {len(entities)} entities using in-memory filters: {filters}") + + return entities @abstractmethod def delete(self, identifier: str, **kwargs: Any): @@ -245,22 +259,6 @@ def _get_entity(self, entity_class: Type[T], identifier: str) -> T: """ return entity_class.load(identifier, self.backend) - def _get_all_entities(self, entity_class: Type[T], entity_type: str) -> List[T]: - """ - Retrieve all entities of a specific type from the backend. - - Args: - entity_class (Type[T]): The class used to instantiate each entity. - entity_type (str): The type identifier used by the backend to filter entities. - - Returns: - A list of all entities of the specified type. - """ - all_entities = self.backend.retrieve_all(entity_type) - if not all_entities: - return [] - return [entity_class(entity_data, self.backend) for entity_data in all_entities] - def _delete_entity(self, entity_class: Type[T], identifier: str, cleanup_fn: Optional[Callable] = None): """ Delete a single entity, optionally performing cleanup beforehand. From 595df60a4ebb9eebcc88234519c556232ba05813 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 30 Jul 2025 16:12:46 -0700 Subject: [PATCH 26/91] move get_plural_of_entity and get_singular_of_entity to utils.py --- merlin/backends/redis/redis_store_base.py | 3 +- merlin/backends/sqlite/sqlite_store_base.py | 3 +- merlin/cli/commands/database/delete.py | 7 +-- merlin/cli/commands/database/get.py | 8 +--- merlin/cli/utils.py | 52 +-------------------- merlin/utils.py | 48 +++++++++++++++++++ 6 files changed, 56 insertions(+), 65 deletions(-) diff --git a/merlin/backends/redis/redis_store_base.py b/merlin/backends/redis/redis_store_base.py index 46b2d328..035006fe 100644 --- a/merlin/backends/redis/redis_store_base.py +++ b/merlin/backends/redis/redis_store_base.py @@ -24,6 +24,7 @@ from merlin.backends.store_base import StoreBase, T from merlin.backends.utils import deserialize_entity, get_not_found_error_class, serialize_entity +from merlin.utils import get_plural_of_entity LOG = logging.getLogger(__name__) @@ -124,8 +125,6 @@ def retrieve_all(self) -> List[T]: Returns: A list of entities. """ - from merlin.cli.utils import get_plural_of_entity # pylint: disable=import-outside-toplevel - entity_type = get_plural_of_entity(self.key, split_delimiter="_", join_delimiter=" ") LOG.info(f"Fetching all {entity_type} from Redis...") diff --git a/merlin/backends/sqlite/sqlite_store_base.py b/merlin/backends/sqlite/sqlite_store_base.py index 506ad128..0777c21b 100644 --- a/merlin/backends/sqlite/sqlite_store_base.py +++ b/merlin/backends/sqlite/sqlite_store_base.py @@ -28,6 +28,7 @@ from merlin.backends.sqlite.sqlite_connection import SQLiteConnection from merlin.backends.store_base import StoreBase, T from merlin.backends.utils import deserialize_entity, get_not_found_error_class, serialize_entity +from merlin.utils import get_plural_of_entity LOG = logging.getLogger(__name__) @@ -221,8 +222,6 @@ def _retrieve_by_query(self, filters: Optional[Dict[str, Any]] = {}) -> List[T]: Returns: A list of matching entities. """ - from merlin.cli.utils import get_plural_of_entity # pylint: disable=import-outside-toplevel - entity_type = get_plural_of_entity(self.table_name, split_delimiter="_", join_delimiter=" ") log_action = "filtered" if filters else "all" LOG.info(f"Fetching {log_action} {entity_type} from SQLite{f' with filters: {filters}' if filters else ''}...") diff --git a/merlin/cli/commands/database/delete.py b/merlin/cli/commands/database/delete.py index 498674e1..0d822100 100644 --- a/merlin/cli/commands/database/delete.py +++ b/merlin/cli/commands/database/delete.py @@ -29,13 +29,10 @@ from merlin.cli.commands.command_entry_point import CommandEntryPoint from merlin.cli.commands.database.entity_registry import ENTITY_REGISTRY -from merlin.cli.utils import ( - get_filters_for_entity, - get_singular_of_entity, - setup_db_entity_subcommands, -) +from merlin.cli.utils import get_filters_for_entity, setup_db_entity_subcommands from merlin.config.configfile import initialize_config from merlin.db_scripts.merlin_db import MerlinDatabase +from merlin.utils import get_singular_of_entity LOG = logging.getLogger("merlin") diff --git a/merlin/cli/commands/database/get.py b/merlin/cli/commands/database/get.py index b010d7e9..4ae49de9 100644 --- a/merlin/cli/commands/database/get.py +++ b/merlin/cli/commands/database/get.py @@ -28,14 +28,10 @@ from merlin.cli.commands.command_entry_point import CommandEntryPoint from merlin.cli.commands.database.entity_registry import ENTITY_REGISTRY -from merlin.cli.utils import ( - get_filters_for_entity, - get_plural_of_entity, - get_singular_of_entity, - setup_db_entity_subcommands, -) +from merlin.cli.utils import get_filters_for_entity, setup_db_entity_subcommands from merlin.config.configfile import initialize_config from merlin.db_scripts.merlin_db import MerlinDatabase +from merlin.utils import get_plural_of_entity, get_singular_of_entity LOG = logging.getLogger("merlin") diff --git a/merlin/cli/utils.py b/merlin/cli/utils.py index 2932acc9..2c8f01a8 100644 --- a/merlin/cli/utils.py +++ b/merlin/cli/utils.py @@ -16,12 +16,12 @@ import logging from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace from contextlib import suppress -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union from merlin.cli.commands.database.entity_registry import ENTITY_REGISTRY from merlin.spec.expansion import RESERVED, get_spec_with_expansion from merlin.spec.specification import MerlinSpec -from merlin.utils import pluralize, singularize, verify_filepath +from merlin.utils import get_plural_of_entity, verify_filepath LOG = logging.getLogger("merlin") @@ -111,54 +111,6 @@ def get_merlin_spec_with_override(args: Namespace) -> Tuple[MerlinSpec, str]: return spec, filepath -def transform_entity_suffix(entity_name: str, transform_fn, split_delimiter: str = "-", join_delimiter: str = "-") -> str: - """ - Applies a transformation function to the last word of a `delimiter`-separated entity name. - - Args: - entity_name (str): The original entity name (e.g., "logical-worker"). - transform_fn (Callable): A function like `pluralize` or `singularize`. - split_delimiter (str): The delimiter to use when splitting the entity name (default: "-"). - join_delimiter (str): The delimiter to use when joining the entity name (default: "-"). - - Returns: - The transformed entity name with the suffix modified. - """ - entity_words = entity_name.split(split_delimiter) - entity_words[-1] = transform_fn(entity_words[-1]) - return join_delimiter.join(entity_words) - - -def get_plural_of_entity(entity_name: str, split_delimiter: str = "-", join_delimiter: str = "-") -> str: - """ - Converts the last word of a `delimiter`-separated entity name to its plural form. - - Args: - entity_name (str): The original entity name (e.g., "logical-worker"). - split_delimiter (str): The delimiter to use when splitting the entity name (default: "-"). - join_delimiter (str): The delimiter to use when joining the entity name (default: "-"). - - Returns: - The pluralized entity name. - """ - return transform_entity_suffix(entity_name, pluralize, split_delimiter=split_delimiter, join_delimiter=join_delimiter) - - -def get_singular_of_entity(entity_name: str, split_delimiter: str = "-", join_delimiter: str = "-") -> str: - """ - Converts the last word of a `delimiter`-separated entity name to its singular form. - - Args: - entity_name (str): The plural entity name (e.g., "logical-workers"). - split_delimiter (str): The delimiter to use when splitting the entity name (default: "-"). - join_delimiter (str): The delimiter to use when joining the entity name (default: "-"). - - Returns: - The singularized entity name. - """ - return transform_entity_suffix(entity_name, singularize, split_delimiter=split_delimiter, join_delimiter=join_delimiter) - - def setup_db_entity_subcommands(subcommand_parser: ArgumentParser, subcommand_name: str) -> dict[str, ArgumentParser]: """ Dynamically sets up subcommands for each entity type for a given subcommand. diff --git a/merlin/utils.py b/merlin/utils.py index 091ac585..969a916d 100644 --- a/merlin/utils.py +++ b/merlin/utils.py @@ -84,6 +84,54 @@ def singularize(word: str) -> str: return word[:-1] else: return word # likely already singular or unrecognized plural + + +def transform_entity_suffix(entity_name: str, transform_fn, split_delimiter: str = "-", join_delimiter: str = "-") -> str: + """ + Applies a transformation function to the last word of a `delimiter`-separated entity name. + + Args: + entity_name (str): The original entity name (e.g., "logical-worker"). + transform_fn (Callable): A function like `pluralize` or `singularize`. + split_delimiter (str): The delimiter to use when splitting the entity name (default: "-"). + join_delimiter (str): The delimiter to use when joining the entity name (default: "-"). + + Returns: + The transformed entity name with the suffix modified. + """ + entity_words = entity_name.split(split_delimiter) + entity_words[-1] = transform_fn(entity_words[-1]) + return join_delimiter.join(entity_words) + + +def get_plural_of_entity(entity_name: str, split_delimiter: str = "-", join_delimiter: str = "-") -> str: + """ + Converts the last word of a `delimiter`-separated entity name to its plural form. + + Args: + entity_name (str): The original entity name (e.g., "logical-worker"). + split_delimiter (str): The delimiter to use when splitting the entity name (default: "-"). + join_delimiter (str): The delimiter to use when joining the entity name (default: "-"). + + Returns: + The pluralized entity name. + """ + return transform_entity_suffix(entity_name, pluralize, split_delimiter=split_delimiter, join_delimiter=join_delimiter) + + +def get_singular_of_entity(entity_name: str, split_delimiter: str = "-", join_delimiter: str = "-") -> str: + """ + Converts the last word of a `delimiter`-separated entity name to its singular form. + + Args: + entity_name (str): The plural entity name (e.g., "logical-workers"). + split_delimiter (str): The delimiter to use when splitting the entity name (default: "-"). + join_delimiter (str): The delimiter to use when joining the entity name (default: "-"). + + Returns: + The singularized entity name. + """ + return transform_entity_suffix(entity_name, singularize, split_delimiter=split_delimiter, join_delimiter=join_delimiter) def get_user_process_info(user: str = None, attrs: List[str] = None) -> List[Dict]: From b4bab5e6e5bb2756399021ad122dd7fc7553151f Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 5 Aug 2025 08:19:17 -0700 Subject: [PATCH 27/91] fix database command tests --- .../backends/sqlite/test_sqlite_store_base.py | 2 +- .../database/test_database_command.py | 116 +++ .../database/test_delete_subcommand.py | 217 ++++++ .../commands/database/test_get_subcommand.py | 200 ++++++ .../commands/database/test_info_subcommand.py | 126 ++++ tests/unit/cli/commands/test_database.py | 157 ---- tests/unit/db_scripts/test_db_commands.py | 668 ------------------ 7 files changed, 660 insertions(+), 826 deletions(-) create mode 100644 tests/unit/cli/commands/database/test_database_command.py create mode 100644 tests/unit/cli/commands/database/test_delete_subcommand.py create mode 100644 tests/unit/cli/commands/database/test_get_subcommand.py create mode 100644 tests/unit/cli/commands/database/test_info_subcommand.py delete mode 100644 tests/unit/cli/commands/test_database.py delete mode 100644 tests/unit/db_scripts/test_db_commands.py diff --git a/tests/unit/backends/sqlite/test_sqlite_store_base.py b/tests/unit/backends/sqlite/test_sqlite_store_base.py index 390333b7..c1815e7a 100644 --- a/tests/unit/backends/sqlite/test_sqlite_store_base.py +++ b/tests/unit/backends/sqlite/test_sqlite_store_base.py @@ -311,7 +311,7 @@ def test_retrieve_all( results = simple_store.retrieve_all() # Verify method calls - mock_conn.execute.assert_called_once_with("SELECT * FROM test_table") + mock_conn.execute.assert_called_once_with("SELECT * FROM test_table ", []) mock_cursor.fetchall.assert_called_once() assert mock_deserialize.call_count == 2 assert len(results) == 2 diff --git a/tests/unit/cli/commands/database/test_database_command.py b/tests/unit/cli/commands/database/test_database_command.py new file mode 100644 index 00000000..55242044 --- /dev/null +++ b/tests/unit/cli/commands/database/test_database_command.py @@ -0,0 +1,116 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the merlin/cli/commands/database/database.py module. +""" + + +import pytest +from argparse import ArgumentParser, Namespace +from pytest_mock import MockerFixture +from unittest.mock import MagicMock + +from merlin.cli.commands.database.database import DatabaseCommand + + +@pytest.fixture +def mock_info_command(mocker: MockerFixture) -> MagicMock: + """ + Fixture to patch the `DatabaseInfoCommand` class with a mock object. + + Args: + mocker: PyTest mocker fixture. + + Returns: + Mocked `DatabaseInfoCommand` class. + """ + return mocker.patch("merlin.cli.commands.database.database.DatabaseInfoCommand") + + +@pytest.fixture +def mock_get_command(mocker: MockerFixture) -> MagicMock: + """ + Fixture to patch the `DatabaseGetCommand` class with a mock object. + + Args: + mocker: PyTest mocker fixture. + + Returns: + Mocked `DatabaseGetCommand` class. + """ + return mocker.patch("merlin.cli.commands.database.database.DatabaseGetCommand") + + +@pytest.fixture +def mock_delete_command(mocker: MockerFixture) -> MagicMock: + """ + Fixture to patch the `DatabaseDeleteCommand` class with a mock object. + + Returns: + Mocked `DatabaseDeleteCommand` class. + """ + return mocker.patch("merlin.cli.commands.database.database.DatabaseDeleteCommand") + + +@pytest.fixture +def database_command( + mock_info_command: MagicMock, + mock_get_command: MagicMock, + mock_delete_command: MagicMock, +) -> DatabaseCommand: + """ + Fixture to create a DatabaseCommand instance using the mocked subcommands. + + Args: + mock_info_command: Mocked `DatabaseInfoCommand` class. + mock_get_command: Mocked `DatabaseGetCommand` class. + mock_delete_command: Mocked `DatabaseDeleteCommand` class. + + Returns: + Instance of DatabaseCommand. + """ + return DatabaseCommand() + + +def test_add_parser_calls_subcommand_add_parser_methods( + mock_info_command: MagicMock, + mock_get_command: MagicMock, + mock_delete_command: MagicMock, + database_command: DatabaseCommand, +): + """ + Test that the `add_parser` method on each subcommand (info, get, delete) + is called exactly once when `DatabaseCommand.add_parser` is invoked. + + Args: + mock_info_command: Mocked `DatabaseInfoCommand` class. + mock_get_command: Mocked `DatabaseGetCommand` class. + mock_delete_command: Mocked `DatabaseDeleteCommand` class. + database_command: Instance of `DatabaseCommand`. + """ + parser = ArgumentParser() + subparsers = parser.add_subparsers(dest="main", required=True) + + database_command.add_parser(subparsers) + + # Ensure subcommand `add_parser` methods are called with subparsers + mock_info_command.return_value.add_parser.assert_called_once() + mock_get_command.return_value.add_parser.assert_called_once() + mock_delete_command.return_value.add_parser.assert_called_once() + + +def test_process_command_noop(database_command: DatabaseCommand): + """ + Test that the process_command method exists and is callable, + even though it performs no operation by design. + + Args: + database_command: Instance of `DatabaseCommand`. + """ + # process_command is a no-op, but should still be callable + args = Namespace() + assert database_command.process_command(args) is None diff --git a/tests/unit/cli/commands/database/test_delete_subcommand.py b/tests/unit/cli/commands/database/test_delete_subcommand.py new file mode 100644 index 00000000..584aeb84 --- /dev/null +++ b/tests/unit/cli/commands/database/test_delete_subcommand.py @@ -0,0 +1,217 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the merlin/cli/commands/database/delete.py module. +""" + +import pytest +from argparse import Namespace +from pytest_mock import MockerFixture +from unittest.mock import MagicMock + +from merlin.cli.commands.database.delete import DatabaseDeleteCommand +from tests.fixture_types import FixtureDict + + +@pytest.fixture +def mock_merlin_db(mocker: MockerFixture) -> MagicMock: + """ + Fixture to patch the `MerlinDatabase` class with a mock object. + + Args: + mocker: PyTest mocker fixture. + + Returns: + Mocked `MerlinDatabase` class. + """ + return mocker.patch("merlin.cli.commands.database.delete.MerlinDatabase") + + +@pytest.fixture +def mock_initialize_config(mocker: MockerFixture) -> MagicMock: + """ + Fixture to patch the `initialize_config` function. + + Args: + mocker: PyTest mocker fixture. + + Returns: + Mocked `initialize_config` function. + """ + return mocker.patch("merlin.cli.commands.database.delete.initialize_config") + + +@pytest.fixture +def mock_entity_registry(mocker: MockerFixture) -> FixtureDict[str, MagicMock]: + """ + Fixture to patch the `ENTITY_REGISTRY` with mocked entity managers. + + Args: + mocker: PyTest mocker fixture. + + Returns: + A dictionary mapping entity names to mock managers. + """ + registry = {"study": mocker.Mock(), "run": mocker.Mock()} + mocker.patch("merlin.cli.commands.database.delete.ENTITY_REGISTRY", registry) + return registry + + +@pytest.fixture +def command() -> DatabaseDeleteCommand: + """ + Fixture to create an instance of `DatabaseDeleteCommand`. + + Returns: + Instance under test. + """ + return DatabaseDeleteCommand() + + +def test_process_command_local_triggers_initialize_config( + command: DatabaseDeleteCommand, + mocker: MockerFixture, + mock_initialize_config: MagicMock, + mock_merlin_db: MagicMock, +): + """ + Test that `initialize_config` is called with `local_mode=True` when the local flag is set. + + Args: + command: Instance of `DatabaseDeleteCommand`. + mocker: Pytest mocker fixture. + mock_initialize_config: Mocked `initialize_config` function. + mock_merlin_db: Mocked `MerlinDatabase` class. + """ + args = Namespace(delete_type="everything", force=True, local=True) + command.process_command(args) + mock_initialize_config.assert_called_once_with(local_mode=True) + + +def test_process_command_delete_everything(command: DatabaseDeleteCommand, mock_merlin_db: MagicMock): + """ + Test that `delete_everything` is called when `delete_type` is 'everything'. + + Args: + command: Instance of `DatabaseDeleteCommand`. + mock_merlin_db: Mocked `MerlinDatabase` class. + """ + args = Namespace(delete_type="everything", force=True, local=False) + command.process_command(args) + mock_merlin_db.return_value.delete_everything.assert_called_once_with(force=True) + + +def test_process_command_delete_specific_entities( + command: DatabaseDeleteCommand, + mocker: MockerFixture, + mock_merlin_db: MagicMock, + mock_entity_registry: MagicMock, +): + """ + Test deletion of specific entities. + + Args: + command: Instance of `DatabaseDeleteCommand`. + mocker: Pytest mocker fixture. + mock_merlin_db: Mocked `MerlinDatabase` class. + mock_entity_registry: Patched `ENTITY_REGISTRY`. + """ + args = Namespace(delete_type="study", entity=["abc", "def"], keep_associated_runs=False, local=False) + command.process_command(args) + + merlin_db_instance = mock_merlin_db.return_value + merlin_db_instance.delete.assert_any_call("study", "abc", remove_associated_runs=True) + merlin_db_instance.delete.assert_any_call("study", "def", remove_associated_runs=True) + assert merlin_db_instance.delete.call_count == 2 + + +def test_process_command_delete_all_entities_with_filters( + command: DatabaseDeleteCommand, + mocker: MockerFixture, + mock_merlin_db: MagicMock, +): + """ + Test deletion of all entities of a type using filters. + + Args: + command: Instance of `DatabaseDeleteCommand`. + mocker: Pytest mocker fixture. + mock_merlin_db: Mocked `MerlinDatabase` class. + """ + mocker.patch("merlin.cli.commands.database.delete.get_singular_of_entity", return_value="run") + mocker.patch("merlin.cli.commands.database.delete.get_filters_for_entity", return_value={"status": "complete"}) + + dummy_entity = mocker.Mock() + dummy_entity.id = "r1" + mock_merlin_db.return_value.get_all.return_value = [dummy_entity] + + args = Namespace(delete_type="all-runs", local=False) + command.process_command(args) + + merlin_db_instance = mock_merlin_db.return_value + merlin_db_instance.get_all.assert_called_once_with("run", filters={"status": "complete"}) + merlin_db_instance.delete.assert_called_once_with("run", "r1") + + +def test_process_command_delete_all_entities_no_filters( + command: DatabaseDeleteCommand, + mocker: MockerFixture, + mock_merlin_db: MagicMock, +): + """ + Test deletion of all entities of a type when no filters are provided. + + Args: + command: Instance of `DatabaseDeleteCommand`. + mocker: Pytest mocker fixture. + mock_merlin_db: Mocked `MerlinDatabase` class. + """ + mocker.patch("merlin.cli.commands.database.delete.get_singular_of_entity", return_value="run") + mocker.patch("merlin.cli.commands.database.delete.get_filters_for_entity", return_value={}) + + args = Namespace(delete_type="all-runs", local=False) + command.process_command(args) + + mock_merlin_db.return_value.delete_all.assert_called_once_with("run") + + +def test_process_command_unrecognized_type_logs_error(command: DatabaseDeleteCommand, mocker: MockerFixture): + """ + Test that an error is logged when `delete_type` is unrecognized. + + Args: + command: Instance of `DatabaseDeleteCommand`. + mocker: Pytest mocker fixture. + """ + mock_log = mocker.patch("merlin.cli.commands.database.delete.LOG") + args = Namespace(delete_type="bad-type", local=False) + command.process_command(args) + mock_log.error.assert_called_once_with("Unrecognized delete_type: bad-type") + + +def test_extract_entity_kwargs_study_flag(command: DatabaseDeleteCommand): + """ + Test `_extract_entity_kwargs` returns correct kwargs for study entities. + + Args: + command: Instance of `DatabaseDeleteCommand`. + """ + args = Namespace(keep_associated_runs=False) + result = command._extract_entity_kwargs(args, "study") + assert result == {"remove_associated_runs": True} + + +def test_extract_entity_kwargs_unrecognized_type(command: DatabaseDeleteCommand): + """ + Test `_extract_entity_kwargs` returns an empty dict for unrecognized entity types. + + Args: + command: Instance of `DatabaseDeleteCommand`. + """ + args = Namespace() + result = command._extract_entity_kwargs(args, "run") + assert result == {} diff --git a/tests/unit/cli/commands/database/test_get_subcommand.py b/tests/unit/cli/commands/database/test_get_subcommand.py new file mode 100644 index 00000000..fd4ca940 --- /dev/null +++ b/tests/unit/cli/commands/database/test_get_subcommand.py @@ -0,0 +1,200 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the merlin/cli/commands/database/get.py module. +""" + +import pytest +from argparse import Namespace +from unittest.mock import MagicMock + +from pytest_mock import MockerFixture + +from merlin.cli.commands.database.get import DatabaseGetCommand + +# TODO write docstrings/type hints for this file and test_info_subcommand.py +# TODO fix the remainder of the broken tests +# TODO update the documentation for the database command + +@pytest.fixture +def mock_merlin_db(mocker: MockerFixture) -> MagicMock: + """ + Fixture that mocks the MerlinDatabase class used in the `database.get` module. + + This prevents real database connections and allows inspection of database method calls. + + Args: + mocker: PyTest mocker fixture. + + Returns: + The mocked MerlinDatabase class. + """ + return mocker.patch("merlin.cli.commands.database.get.MerlinDatabase") + + +@pytest.fixture +def mock_initialize_config(mocker: MockerFixture) -> MagicMock: + """ + Fixture that mocks the `initialize_config` function in the `database.get` module. + + This prevents configuration initialization from affecting test environments. + + Args: + mocker: PyTest mocker fixture. + + Returns: + The mocked initialize_config function. + """ + return mocker.patch("merlin.cli.commands.database.get.initialize_config") + + +@pytest.fixture +def mock_entity_registry(mocker: MockerFixture) -> MagicMock: + """ + Fixture that replaces ENTITY_REGISTRY in the `database.get` module with a mock registry. + + This allows tests to simulate entity-specific behavior (e.g., "study", "run") + without relying on the actual registry. + + Args: + mocker: PyTest mocker fixture. + + Returns: + The mocked entity registry dictionary. + """ + registry = {"study": mocker.Mock(), "run": mocker.Mock()} + mocker.patch("merlin.cli.commands.database.get.ENTITY_REGISTRY", registry) + return registry + + +@pytest.fixture +def command() -> DatabaseGetCommand: + """ + Fixture that returns a fresh instance of the `DatabaseGetCommand` class. + + Useful for testing `add_parser` and `process_command` methods in isolation. + + Returns: + A new instance of the command. + """ + return DatabaseGetCommand() + + +def test_process_command_local_triggers_initialize_config( + command: DatabaseGetCommand, + mock_initialize_config: MagicMock, + mock_merlin_db: MagicMock, +): + """ + Test that `initialize_config` is called with `local_mode=True` when the `--local` flag is set. + + Args: + command: Instance of the `DatabaseGetCommand` under test. + mock_initialize_config: Mocked `initialize_config` function. + mock_merlin_db: Mocked `MerlinDatabase` class. + """ + args = Namespace(get_type="everything", local=True) + command.process_command(args) + mock_initialize_config.assert_called_once_with(local_mode=True) + + +def test_process_command_get_everything(command: DatabaseGetCommand, mock_merlin_db: MagicMock, mocker: MockerFixture): + """ + Test that calling `process_command` with `get_type='everything'` retrieves all items + and passes them to `_print_items`. + + Args: + command: Instance of the `DatabaseGetCommand` under test. + mock_merlin_db: Mocked `MerlinDatabase` class. + mocker: PyTest mocker fixture.. + """ + mock_print_items = mocker.patch.object(command, "_print_items") + args = Namespace(get_type="everything", local=False) + command.process_command(args) + mock_print_items.assert_called_once_with(mock_merlin_db.return_value.get_everything.return_value, "Nothing found in the database.") + + +def test_process_command_get_specific_entities( + command: DatabaseGetCommand, + mock_merlin_db: MagicMock, + mock_entity_registry: MagicMock, + mocker: MockerFixture, +): + """ + Test that `process_command` retrieves specific entities and calls + `_print_items` with the results. + + Args: + command: Instance of the `DatabaseGetCommand` under test. + mock_merlin_db: Mocked `MerlinDatabase` class. + mock_entity_registry: Mocked ENTITY_REGISTRY mapping entity types to handler classes. + mocker: PyTest mocker fixture. + """ + mock_print_items = mocker.patch.object(command, "_print_items") + args = Namespace(get_type="study", entity=["s1", "s2"], local=False) + command.process_command(args) + + merlin_db_instance = mock_merlin_db.return_value + merlin_db_instance.get.assert_any_call("study", "s1") + merlin_db_instance.get.assert_any_call("study", "s2") + assert merlin_db_instance.get.call_count == 2 + mock_print_items.assert_called_once() + + +def test_process_command_get_all_entities_with_filters( + command: DatabaseGetCommand, + mock_merlin_db: MagicMock, + mocker: MockerFixture, +): + """ + Test that `process_command` retrieves all entities of a given type with applied filters. + + Mocks the helper functions `get_singular_of_entity` and `get_filters_for_entity` to return + a valid entity type and a non-empty filter dictionary, then verifies that `get_all` is called + with the correct arguments and `_print_items` is invoked. + + Args: + command: Instance of the `DatabaseGetCommand` under test. + mock_merlin_db: Mocked `MerlinDatabase` class. + mocker: PyTest mocker fixture. + """ + mocker.patch("merlin.cli.commands.database.get.get_singular_of_entity", return_value="run") + mocker.patch("merlin.cli.commands.database.get.get_filters_for_entity", return_value={"status": "complete"}) + mock_print_items = mocker.patch.object(command, "_print_items") + + args = Namespace(get_type="all-runs", local=False) + command.process_command(args) + + mock_merlin_db.return_value.get_all.assert_called_once_with("run", filters={"status": "complete"}) + mock_print_items.assert_called_once() + + +def test_process_command_get_all_entities_without_filters( + command: DatabaseGetCommand, + mock_merlin_db: MagicMock, + mocker: MockerFixture, +): + """ + Test that `process_command` retrieves all entities of a given type without filters. + + Mocks the helper functions `get_singular_of_entity` and `get_filters_for_entity` to return + a valid entity type and an empty filter dictionary, then verifies that `get_all` is called + with the expected arguments. + + Args: + command: Instance of the `DatabaseGetCommand` under test. + mock_merlin_db: Mocked `MerlinDatabase` class. + mocker: PyTest mocker fixture. + """ + mocker.patch("merlin.cli.commands.database.get.get_singular_of_entity", return_value="run") + mocker.patch("merlin.cli.commands.database.get.get_filters_for_entity", return_value={}) + mocker.patch.object(command, "_print_items") + + args = Namespace(get_type="all-runs", local=False) + command.process_command(args) + + mock_merlin_db.return_value.get_al diff --git a/tests/unit/cli/commands/database/test_info_subcommand.py b/tests/unit/cli/commands/database/test_info_subcommand.py new file mode 100644 index 00000000..c3d794ea --- /dev/null +++ b/tests/unit/cli/commands/database/test_info_subcommand.py @@ -0,0 +1,126 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the merlin/cli/commands/database/info.py module. +""" + +import pytest +from argparse import ArgumentParser, Namespace +from unittest.mock import MagicMock + +from pytest_mock import MockerFixture + +from merlin.cli.commands.database.info import DatabaseInfoCommand + + +@pytest.fixture +def mock_initialize_config(mocker: MockerFixture) -> MagicMock: + """ + Fixture that mocks the `initialize_config` function in the `database.info` module. + + Prevents actual configuration initialization during tests and allows verification + that it is called with the correct parameters. + + Args: + mocker: PyTest mocker fixture. + + Returns: + The mocked `initialize_config` function. + """ + return mocker.patch("merlin.cli.commands.database.info.initialize_config") + + +@pytest.fixture +def mock_merlin_db(mocker: MockerFixture) -> MagicMock: + """ + Fixture that mocks the `MerlinDatabase` class in the `database.info` module. + + Prevents real database connections during tests and enables control over + return values and method call assertions. + + Args: + mocker: PyTest mocker fixture. + + Returns: + The mocked `MerlinDatabase` class. + """ + return mocker.patch("merlin.cli.commands.database.info.MerlinDatabase") + + +@pytest.fixture +def command() -> DatabaseInfoCommand: + """ + Fixture that returns a fresh instance of the `DatabaseInfoCommand` class. + + Useful for testing `add_parser` and `process_command` methods in isolation. + + Returns: + A new instance of the command. + """ + return DatabaseInfoCommand() + + +def test_add_parser_registers_info_command(command: DatabaseInfoCommand): + """ + Test that `add_parser` correctly registers the `info` subcommand and its arguments. + + Verifies that: + - The `info` subcommand sets `process_command` as the handler function. + - The `--max-preview` optional argument is parsed correctly. + + Args: + command: Instance of the `DatabaseInfoCommand` under test. + """ + parser = ArgumentParser() + subparsers = parser.add_subparsers(dest="subcmd", required=True) + command.add_parser(subparsers) + + args = parser.parse_args(["info"]) + assert args.func == command.process_command + + args_with_flag = parser.parse_args(["info", "--max-preview", "5"]) + assert args_with_flag.max_preview == 5 + + +def test_process_command_with_local_flag_calls_initialize_config( + command: DatabaseInfoCommand, mock_initialize_config: MagicMock, mock_merlin_db: MagicMock +): + """ + Test that `process_command` calls `initialize_config(local_mode=True)` when the `--local` flag is set. + + Also verifies that `MerlinDatabase.info()` is called with the provided `max_preview` value. + + Args: + command: Instance of the `DatabaseInfoCommand` under test. + mock_initialize_config: Mocked `initialize_config` function. + mock_merlin_db: Mocked `MerlinDatabase` class. + """ + args = Namespace(local=True, max_preview=2) + command.process_command(args) + + mock_initialize_config.assert_called_once_with(local_mode=True) + mock_merlin_db.return_value.info.assert_called_once_with(max_preview=2) + + +def test_process_command_without_local_flag_does_not_call_initialize_config( + command: DatabaseInfoCommand, mock_initialize_config: MagicMock, mock_merlin_db: MagicMock +): + """ + Test that `process_command` does not call `initialize_config` when `--local` is not set. + + Verifies that `MerlinDatabase.info()` is still called with the expected `max_preview` value. + + Args: + command: Instance of the `DatabaseInfoCommand` under test. + mock_initialize_config: Mocked `initialize_config` function. + mock_merlin_db: Mocked `MerlinDatabase` class. + """ + args = Namespace(local=False, max_preview=4) + command.process_command(args) + + mock_initialize_config.assert_not_called() + mock_merlin_db.return_value.info.assert_called_once_with(max_preview=4) diff --git a/tests/unit/cli/commands/test_database.py b/tests/unit/cli/commands/test_database.py deleted file mode 100644 index b8fcdd79..00000000 --- a/tests/unit/cli/commands/test_database.py +++ /dev/null @@ -1,157 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -""" -Tests for the `database.py` file of the `cli/` folder. -""" - -from argparse import ArgumentParser, Namespace -from typing import List - -import pytest -from pytest_mock import MockerFixture - -from merlin.cli.commands.database import DatabaseCommand -from tests.fixture_types import FixtureCallable - - -@pytest.fixture -def parser(create_parser: FixtureCallable) -> ArgumentParser: - """ - Returns an `ArgumentParser` configured with the `database` command and its subcommands. - - Args: - create_parser: A fixture to help create a parser. - - Returns: - Parser with the `database` command and its subcommands registered. - """ - return create_parser(DatabaseCommand()) - - -def test_process_command_info_calls_info(mocker: MockerFixture): - """ - Ensure that when `commands` is `info`, database_info() is invoked. - - Args: - mocker: PyTest mocker fixture. - """ - mock_info = mocker.patch("merlin.cli.commands.database.database_info") - cmd = DatabaseCommand() - args = Namespace(commands="info", local=False) - cmd.process_command(args) - mock_info.assert_called_once() - - -def test_process_command_get_calls_get(mocker: MockerFixture): - """ - Ensure that when `commands` is `get`, database_get(args) is invoked. - - Args: - mocker: PyTest mocker fixture. - """ - mock_get = mocker.patch("merlin.cli.commands.database.database_get") - cmd = DatabaseCommand() - args = Namespace(commands="get", local=False) - cmd.process_command(args) - mock_get.assert_called_once_with(args) - - -def test_process_command_delete_calls_delete(mocker: MockerFixture): - """ - Ensure that when `commands` is `delete`, database_delete(args) is invoked. - - Args: - mocker: PyTest mocker fixture. - """ - mock_delete = mocker.patch("merlin.cli.commands.database.database_delete") - cmd = DatabaseCommand() - args = Namespace(commands="delete", local=False) - cmd.process_command(args) - mock_delete.assert_called_once_with(args) - - -def test_process_command_local_initializes_config(mocker: MockerFixture): - """ - Verify that the local flag triggers initialize_config(local_mode=True) before calling the info command. - - Args: - mocker: PyTest mocker fixture. - """ - mock_init_config = mocker.patch("merlin.cli.commands.database.initialize_config") - mock_info = mocker.patch("merlin.cli.commands.database.database_info") - - cmd = DatabaseCommand() - args = Namespace(commands="info", local=True) - cmd.process_command(args) - - mock_init_config.assert_called_once_with(local_mode=True) - mock_info.assert_called_once() - - -@pytest.mark.parametrize( - "command, args", - [ - ("info", ["database", "info"]), - ("get", ["database", "get", "study", "dummy_id"]), - ("delete", ["database", "delete", "study", "dummy_id"]), - ], -) -def test_add_parser_creates_expected_commands(parser: ArgumentParser, command: str, args: List[str]): - """ - Validate that the parser correctly sets `commands` for top-level database subcommands. - - Args: - parser: Parser with the `database` command and its subcommands registered. - command: The command to test against. - args: The arguments to give to the `database` parser. - """ - parsed = parser.parse_args(args) - assert parsed.commands == command - - -@pytest.mark.parametrize("command", ["get", "delete"]) -@pytest.mark.parametrize("subcmd", ["study", "run", "logical-worker", "physical-worker"]) -def test_subcommands_with_id(parser: ArgumentParser, command: str, subcmd: str): - """ - Test that subcommands requiring an ID are parsed correctly. - - Args: - parser: Parser with the `database` command and its subcommands registered. - command: The command to test against. - subcmd: The subcommand to test against. - """ - args = ["database", command, subcmd, "dummy-id"] - parsed = parser.parse_args(args) - - assert parsed.commands == command - - if command == "get": - assert parsed.get_type == subcmd - elif command == "delete": - assert parsed.delete_type == subcmd - - -@pytest.mark.parametrize("command", ["get", "delete"]) -@pytest.mark.parametrize("subcmd", ["all-studies", "all-runs", "all-logical-workers", "all-physical-workers", "everything"]) -def test_subcommands_without_id(parser: ArgumentParser, command: str, subcmd: str): - """ - Test that subcommands not requiring an ID are parsed correctly. - - Args: - parser: Parser with the `database` command and its subcommands registered. - command: The command to test against. - subcmd: The subcommand to test against. - """ - args = ["database", command, subcmd] - parsed = parser.parse_args(args) - - assert parsed.commands == command - - if command == "get": - assert parsed.get_type == subcmd - elif command == "delete": - assert parsed.delete_type == subcmd diff --git a/tests/unit/db_scripts/test_db_commands.py b/tests/unit/db_scripts/test_db_commands.py deleted file mode 100644 index 9bb0e5ce..00000000 --- a/tests/unit/db_scripts/test_db_commands.py +++ /dev/null @@ -1,668 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -""" -Tests for the `db_commands.py` module. -""" - -import logging -from argparse import Namespace -from unittest.mock import call - -from _pytest.capture import CaptureFixture -from pytest_mock import MockerFixture - -# Import the functions being tested -from merlin.db_scripts.db_commands import database_delete, database_get, database_info - - -class TestDatabaseInfo: - """Tests for the database_info function.""" - - def test_database_info(self, mocker: MockerFixture, capsys: CaptureFixture): - """ - Test that database_info prints the correct information. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - capsys: A built-in fixture from the pytest library to capture stdout and stderr. - """ - # Mock MerlinDatabase and its methods - mock_db = mocker.MagicMock() - mock_db.get_db_type.return_value = "SQLite" - mock_db.get_db_version.return_value = "1.0.0" - mock_db.get_connection_string.return_value = "sqlite:///merlin.db" - mock_db.get_all.side_effect = [ - ["study1", "study2"], # studies - ["run1", "run2", "run3"], # runs - ["worker1"], # logical workers - ["worker1", "worker2"], # physical workers - ] - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Call the function - database_info() - - # Capture the printed output - captured = capsys.readouterr() - - # Verify the output contains the expected information - assert "Merlin Database Information" in captured.out - assert "Database Type: SQLite" in captured.out - assert "Database Version: 1.0.0" in captured.out - assert "Connection String: sqlite:///merlin.db" in captured.out - assert "Studies:" in captured.out - assert "Total: 2" in captured.out - assert "Runs:" in captured.out - assert "Total: 3" in captured.out - assert "Logical Workers:" in captured.out - assert "Total: 1" in captured.out - assert "Physical Workers:" in captured.out - assert "Total: 2" in captured.out - - # Verify get_all was called with the correct entity types - mock_db.get_all.assert_has_calls([call("study"), call("run"), call("logical_worker"), call("physical_worker")]) - - -class TestDatabaseGet: - """Tests for the database_get function.""" - - def test_get_study(self, mocker: MockerFixture, capsys: CaptureFixture): - """ - Test getting studies by ID. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - capsys: A built-in fixture from the pytest library to capture stdout and stderr. - """ - # Mock MerlinDatabase and its get method - mock_db = mocker.MagicMock() - mock_study1 = mocker.MagicMock() - mock_study1.__str__.return_value = "Study 1" - mock_study2 = mocker.MagicMock() - mock_study2.__str__.return_value = "Study 2" - mock_db.get.side_effect = [mock_study1, mock_study2] - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args with study IDs - args = Namespace(get_type="study", study=["study1", "study2"]) - - # Call the function - database_get(args) - - # Capture the printed output - captured = capsys.readouterr() - - # Verify the output contains the expected information - assert "Study 1" in captured.out - assert "Study 2" in captured.out - - # Verify the get method was called with the correct arguments - mock_db.get.assert_has_calls([call("study", "study1"), call("study", "study2")]) - - def test_get_run(self, mocker: MockerFixture, capsys: CaptureFixture): - """ - Test getting runs by ID. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - capsys: A built-in fixture from the pytest library to capture stdout and stderr. - """ - # Mock MerlinDatabase and its get method - mock_db = mocker.MagicMock() - mock_run = mocker.MagicMock() - mock_run.__str__.return_value = "Run 1" - mock_db.get.return_value = mock_run - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args with run IDs - args = Namespace(get_type="run", run=["run1"]) - - # Call the function - database_get(args) - - # Capture the printed output - captured = capsys.readouterr() - - # Verify the output contains the expected information - assert "Run 1" in captured.out - - # Verify the get method was called with the correct arguments - mock_db.get.assert_called_once_with("run", "run1") - - def test_get_logical_worker(self, mocker: MockerFixture, capsys: CaptureFixture): - """ - Test getting logical workers by ID. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - capsys: A built-in fixture from the pytest library to capture stdout and stderr. - """ - # Mock MerlinDatabase and its get method - mock_db = mocker.MagicMock() - mock_worker = mocker.MagicMock() - mock_worker.__str__.return_value = "Logical Worker 1" - mock_db.get.return_value = mock_worker - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args with worker IDs - args = Namespace(get_type="logical-worker", worker=["worker1"]) - - # Call the function - database_get(args) - - # Capture the printed output - captured = capsys.readouterr() - - # Verify the output contains the expected information - assert "Logical Worker 1" in captured.out - - # Verify the get method was called with the correct arguments - mock_db.get.assert_called_once_with("logical_worker", "worker1") - - def test_get_physical_worker(self, mocker: MockerFixture, capsys: CaptureFixture): - """ - Test getting physical workers by ID. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - capsys: A built-in fixture from the pytest library to capture stdout and stderr. - """ - # Mock MerlinDatabase and its get method - mock_db = mocker.MagicMock() - mock_worker = mocker.MagicMock() - mock_worker.__str__.return_value = "Physical Worker 1" - mock_db.get.return_value = mock_worker - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args with worker IDs - args = Namespace(get_type="physical-worker", worker=["worker1"]) - - # Call the function - database_get(args) - - # Capture the printed output - captured = capsys.readouterr() - - # Verify the output contains the expected information - assert "Physical Worker 1" in captured.out - - # Verify the get method was called with the correct arguments - mock_db.get.assert_called_once_with("physical_worker", "worker1") - - def test_get_all_studies(self, mocker: MockerFixture, capsys: CaptureFixture): - """ - Test getting all studies. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - capsys: A built-in fixture from the pytest library to capture stdout and stderr. - """ - # Mock MerlinDatabase and its get_all method - mock_db = mocker.MagicMock() - mock_study1 = mocker.MagicMock() - mock_study1.__str__.return_value = "Study 1" - mock_study2 = mocker.MagicMock() - mock_study2.__str__.return_value = "Study 2" - mock_db.get_all.return_value = [mock_study1, mock_study2] - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args - args = Namespace(get_type="all-studies") - - # Call the function - database_get(args) - - # Capture the printed output - captured = capsys.readouterr() - - # Verify the output contains the expected information - assert "Study 1" in captured.out - assert "Study 2" in captured.out - - # Verify the get_all method was called with the correct entity type - mock_db.get_all.assert_called_once_with("study") - - def test_get_all_runs(self, mocker: MockerFixture, capsys: CaptureFixture): - """ - Test getting all runs. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - capsys: A built-in fixture from the pytest library to capture stdout and stderr. - """ - # Mock MerlinDatabase and its get_all method - mock_db = mocker.MagicMock() - mock_run1 = mocker.MagicMock() - mock_run1.__str__.return_value = "Run 1" - mock_run2 = mocker.MagicMock() - mock_run2.__str__.return_value = "Run 2" - mock_db.get_all.return_value = [mock_run1, mock_run2] - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args - args = Namespace(get_type="all-runs") - - # Call the function - database_get(args) - - # Capture the printed output - captured = capsys.readouterr() - - # Verify the output contains the expected information - assert "Run 1" in captured.out - assert "Run 2" in captured.out - - # Verify the get_all method was called with the correct entity type - mock_db.get_all.assert_called_once_with("run") - - def test_get_all_logical_workers(self, mocker: MockerFixture, capsys: CaptureFixture): - """ - Test getting all logical workers. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - capsys: A built-in fixture from the pytest library to capture stdout and stderr. - """ - # Mock MerlinDatabase and its get_all method - mock_db = mocker.MagicMock() - mock_worker = mocker.MagicMock() - mock_worker.__str__.return_value = "Logical Worker 1" - mock_db.get_all.return_value = [mock_worker] - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args - args = Namespace(get_type="all-logical-workers") - - # Call the function - database_get(args) - - # Capture the printed output - captured = capsys.readouterr() - - # Verify the output contains the expected information - assert "Logical Worker 1" in captured.out - - # Verify the get_all method was called with the correct entity type - mock_db.get_all.assert_called_once_with("logical_worker") - - def test_get_all_physical_workers(self, mocker: MockerFixture, capsys: CaptureFixture): - """ - Test getting all physical workers. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - capsys: A built-in fixture from the pytest library to capture stdout and stderr. - """ - # Mock MerlinDatabase and its get_all method - mock_db = mocker.MagicMock() - mock_worker = mocker.MagicMock() - mock_worker.__str__.return_value = "Physical Worker 1" - mock_db.get_all.return_value = [mock_worker] - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args - args = Namespace(get_type="all-physical-workers") - - # Call the function - database_get(args) - - # Capture the printed output - captured = capsys.readouterr() - - # Verify the output contains the expected information - assert "Physical Worker 1" in captured.out - - # Verify the get_all method was called with the correct entity type - mock_db.get_all.assert_called_once_with("physical_worker") - - def test_get_everything(self, mocker: MockerFixture, capsys: CaptureFixture): - """ - Test getting everything from the database. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - capsys: A built-in fixture from the pytest library to capture stdout and stderr. - """ - # Mock MerlinDatabase and its get_everything method - mock_db = mocker.MagicMock() - mock_entity = mocker.MagicMock() - mock_entity.__str__.return_value = "Database Entity" - mock_db.get_everything.return_value = [mock_entity] - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args - args = Namespace(get_type="everything") - - # Call the function - database_get(args) - - # Capture the printed output - captured = capsys.readouterr() - - # Verify the output contains the expected information - assert "Database Entity" in captured.out - - # Verify the get_everything method was called - mock_db.get_everything.assert_called_once() - - def test_get_empty_studies(self, mocker: MockerFixture, caplog: CaptureFixture): - """ - Test getting studies when none are found. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - caplog: A built-in fixture from the pytest library to capture logs. - """ - caplog.set_level(logging.INFO) - - # Mock MerlinDatabase and its get_all method - mock_db = mocker.MagicMock() - mock_db.get_all.return_value = [] - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args - args = Namespace(get_type="all-studies") - - # Call the function - database_get(args) - - # Verify LOG was called with the correct message - assert "No studies found in the database." in caplog.text - - def test_get_invalid_option(self, mocker: MockerFixture, caplog: CaptureFixture): - """ - Test providing an invalid get option. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - caplog: A built-in fixture from the pytest library to capture logs. - """ - # Mock MerlinDatabase - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args with invalid get_type - args = Namespace(get_type="invalid-option") - - # Call the function - database_get(args) - - # Verify LOG was called with the correct message - assert "No valid get option provided." in caplog.text - - -class TestDatabaseDelete: - """Tests for the database_delete function.""" - - def test_delete_study(self, mocker: MockerFixture): - """ - Test deleting studies by ID. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - """ - # Mock MerlinDatabase and its delete method - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args with study IDs - args = Namespace(delete_type="study", study=["study1", "study2"], keep_associated_runs=False) - - # Call the function - database_delete(args) - - # Verify the delete method was called with the correct arguments - mock_db.delete.assert_has_calls( - [call("study", "study1", remove_associated_runs=True), call("study", "study2", remove_associated_runs=True)] - ) - - def test_delete_study_keep_runs(self, mocker: MockerFixture): - """ - Test deleting studies by ID while keeping associated runs. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - """ - # Mock MerlinDatabase and its delete method - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args with study IDs and keep_associated_runs=True - args = Namespace(delete_type="study", study=["study1"], keep_associated_runs=True) - - # Call the function - database_delete(args) - - # Verify the delete method was called with the correct arguments - mock_db.delete.assert_called_once_with("study", "study1", remove_associated_runs=False) - - def test_delete_run(self, mocker: MockerFixture): - """ - Test deleting runs by ID. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - """ - # Mock MerlinDatabase and its delete method - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args with run IDs - args = Namespace(delete_type="run", run=["run1", "run2"]) - - # Call the function - database_delete(args) - - # Verify the delete method was called with the correct arguments - mock_db.delete.assert_has_calls([call("run", "run1"), call("run", "run2")]) - - def test_delete_logical_worker(self, mocker: MockerFixture): - """ - Test deleting logical workers by ID. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - """ - # Mock MerlinDatabase and its delete method - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args with worker IDs - args = Namespace(delete_type="logical-worker", worker=["worker1"]) - - # Call the function - database_delete(args) - - # Verify the delete method was called with the correct arguments - mock_db.delete.assert_called_once_with("logical_worker", "worker1") - - def test_delete_physical_worker(self, mocker: MockerFixture): - """ - Test deleting physical workers by ID. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - """ - # Mock MerlinDatabase and its delete method - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args with worker IDs - args = Namespace(delete_type="physical-worker", worker=["worker1"]) - - # Call the function - database_delete(args) - - # Verify the delete method was called with the correct arguments - mock_db.delete.assert_called_once_with("physical_worker", "worker1") - - def test_delete_all_studies(self, mocker: MockerFixture): - """ - Test deleting all studies. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - """ - # Mock MerlinDatabase and its delete_all method - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args - args = Namespace(delete_type="all-studies", keep_associated_runs=False) - - # Call the function - database_delete(args) - - # Verify the delete_all method was called with the correct arguments - mock_db.delete_all.assert_called_once_with("study", remove_associated_runs=True) - - def test_delete_all_runs(self, mocker: MockerFixture): - """ - Test deleting all runs. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - """ - # Mock MerlinDatabase and its delete_all method - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args - args = Namespace(delete_type="all-runs") - - # Call the function - database_delete(args) - - # Verify the delete_all method was called with the correct entity type - mock_db.delete_all.assert_called_once_with("run") - - def test_delete_all_logical_workers(self, mocker: MockerFixture): - """ - Test deleting all logical workers. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - """ - # Mock MerlinDatabase and its delete_all method - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args - args = Namespace(delete_type="all-logical-workers") - - # Call the function - database_delete(args) - - # Verify the delete_all method was called with the correct entity type - mock_db.delete_all.assert_called_once_with("logical_worker") - - def test_delete_all_physical_workers(self, mocker: MockerFixture): - """ - Test deleting all physical workers. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - """ - # Mock MerlinDatabase and its delete_all method - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args - args = Namespace(delete_type="all-physical-workers") - - # Call the function - database_delete(args) - - # Verify the delete_all method was called with the correct entity type - mock_db.delete_all.assert_called_once_with("physical_worker") - - def test_delete_everything(self, mocker: MockerFixture): - """ - Test deleting everything from the database. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - """ - # Mock MerlinDatabase and its delete_everything method - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args - args = Namespace(delete_type="everything", force=True) - - # Call the function - database_delete(args) - - # Verify the delete_everything method was called with the correct arguments - mock_db.delete_everything.assert_called_once_with(force=True) - - def test_delete_invalid_option(self, mocker: MockerFixture, caplog: CaptureFixture): - """ - Test providing an invalid delete option. - - Args: - mocker: A built-in fixture from the pytest-mock library to create a Mock object. - caplog: A built-in fixture from the pytest library to capture logs. - """ - # Mock MerlinDatabase - mock_db = mocker.MagicMock() - - # Patch the MerlinDatabase class - mocker.patch("merlin.db_scripts.db_commands.MerlinDatabase", return_value=mock_db) - - # Create args with invalid delete_type - args = Namespace(delete_type="invalid-option") - - # Call the function - database_delete(args) - - # Verify LOG.error was called with the correct message - assert "No valid delete option provided." in caplog.text From 37feee9153f4b9de58e40cfd8cd81523aa202607 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 5 Aug 2025 08:52:51 -0700 Subject: [PATCH 28/91] fix broken tests for entity manager --- .../entity_managers/test_entity_manager.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/unit/db_scripts/entity_managers/test_entity_manager.py b/tests/unit/db_scripts/entity_managers/test_entity_manager.py index fe123b22..4eb34c0b 100644 --- a/tests/unit/db_scripts/entity_managers/test_entity_manager.py +++ b/tests/unit/db_scripts/entity_managers/test_entity_manager.py @@ -66,6 +66,16 @@ def delete(cls, identifier: str, backend: ResultsBackend): class TestEntityManager(EntityManager[TestEntity, TestDataModel]): """A concrete test implementation of an entity manager.""" + _filter_accessor_map = { + "name": lambda e: e.entity_info.name, + "attr1": lambda e: e.entity_info.attr1, + } + + def __init__(self, backend: ResultsBackend): + super().__init__(backend) + self._entity_type = "test_entity" + self._entity_class = TestEntity + def create(self, name: str, **kwargs: Any) -> TestEntity: return self._create_entity_if_not_exists( TestEntity, @@ -80,9 +90,6 @@ def create(self, name: str, **kwargs: Any) -> TestEntity: def get(self, identifier: str) -> TestEntity: return self._get_entity(TestEntity, identifier) - def get_all(self) -> List[TestEntity]: - return self._get_all_entities(TestEntity, "test_entity") - def delete(self, identifier: str, **kwargs: Any): cleanup_fn = kwargs.get("cleanup_fn") self._delete_entity(TestEntity, identifier, cleanup_fn=cleanup_fn) From 64f23a24466275e8de6f65631e864f48ab72dbbc Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 5 Aug 2025 11:31:42 -0700 Subject: [PATCH 29/91] run fix-style and update CHANGELOG --- CHANGELOG.md | 1 + merlin/backends/filter_support_mixin.py | 2 +- merlin/backends/sqlite/sqlite_store_base.py | 4 ++-- merlin/cli/commands/database/__init__.py | 1 + merlin/cli/commands/database/delete.py | 9 +++++---- merlin/cli/commands/database/get.py | 7 ++++--- merlin/cli/utils.py | 4 ++-- .../entity_managers/entity_manager.py | 2 ++ .../db_scripts/entity_managers/run_manager.py | 2 +- merlin/db_scripts/merlin_db.py | 18 +++++++----------- merlin/utils.py | 8 +++++--- .../commands/database/test_database_command.py | 7 ++++--- .../database/test_delete_subcommand.py | 5 +++-- .../commands/database/test_get_subcommand.py | 16 ++++++++++------ .../commands/database/test_info_subcommand.py | 2 +- .../entity_managers/test_entity_manager.py | 2 +- 16 files changed, 50 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d518e1c5..cccef97f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Unit tests for the `spec/` folder - A page in the docs explaining the `feature_demo` example +- Ability to filter database queries for the `get all-*` and `delete all-*` commands ## [1.13.0b2] ### Added diff --git a/merlin/backends/filter_support_mixin.py b/merlin/backends/filter_support_mixin.py index b856f6d0..28422ca9 100644 --- a/merlin/backends/filter_support_mixin.py +++ b/merlin/backends/filter_support_mixin.py @@ -33,7 +33,7 @@ class FilterSupportMixin: that supports filtering (e.g., SQLite-based backends). """ - def retrieve_all_filtered(self, store_type: str, filters: Dict) -> List[BaseDataModel]: + def retrieve_all_filtered(self, store_type: str, filters: Dict) -> List[BaseDataModel]: """ Retrieve all objects from the specified store that match the given filters. diff --git a/merlin/backends/sqlite/sqlite_store_base.py b/merlin/backends/sqlite/sqlite_store_base.py index 0777c21b..280ca550 100644 --- a/merlin/backends/sqlite/sqlite_store_base.py +++ b/merlin/backends/sqlite/sqlite_store_base.py @@ -177,7 +177,7 @@ def retrieve(self, identifier: str, by_name: bool = False) -> Optional[T]: return None return deserialize_entity(dict(row), self.model_class) - + def _build_where_clause_and_params(self, filters: Dict[str, Any]) -> Tuple[str, List[Any]]: """ Build the SQL WHERE clause and associated parameter list from a filters dictionary. @@ -212,7 +212,7 @@ def _build_where_clause_and_params(self, filters: Dict[str, Any]) -> Tuple[str, where_clause = "WHERE " + " AND ".join(conditions) return where_clause, params - def _retrieve_by_query(self, filters: Optional[Dict[str, Any]] = {}) -> List[T]: + def _retrieve_by_query(self, filters: Optional[Dict[str, Any]] = None) -> List[T]: """ Internal method to query the SQLite database for entities with optional filters. diff --git a/merlin/cli/commands/database/__init__.py b/merlin/cli/commands/database/__init__.py index 68f460d8..41f85b8f 100644 --- a/merlin/cli/commands/database/__init__.py +++ b/merlin/cli/commands/database/__init__.py @@ -31,4 +31,5 @@ from merlin.cli.commands.database.database import DatabaseCommand + __all__ = ["DatabaseCommand"] diff --git a/merlin/cli/commands/database/delete.py b/merlin/cli/commands/database/delete.py index 0d822100..ecc04965 100644 --- a/merlin/cli/commands/database/delete.py +++ b/merlin/cli/commands/database/delete.py @@ -34,6 +34,7 @@ from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.utils import get_singular_of_entity + LOG = logging.getLogger("merlin") @@ -49,7 +50,7 @@ class DatabaseDeleteCommand(CommandEntryPoint): _delete_all_entities: Deletes all entities of a type, applying filters if present. """ - def add_parser(self, database_commands: ArgumentParser): + def add_parser(self, database_commands: ArgumentParser): # pylint: disable=arguments-renamed """ Add the `database delete` subcommand parser to the CLI argument parser. @@ -60,7 +61,7 @@ def add_parser(self, database_commands: ArgumentParser): # Subcommand: database delete db_delete_parser = database_commands.add_parser( "delete", - help=f"Delete information stored in the database.", + help="Delete information stored in the database.", formatter_class=ArgumentDefaultsHelpFormatter, ) db_delete_parser.set_defaults(func=self.process_command) @@ -123,7 +124,7 @@ def _delete_entities(self, entity_type: str, identifiers: List[str], merlin_db: entity_type (str): The type of entity to delete (e.g., "run", "study"). identifiers (List[str]): A list of entity identifiers to delete. merlin_db (MerlinDatabase): Interface to the Merlin database. - """ + """ for identifier in identifiers: merlin_db.delete(entity_type.replace("-", "_"), identifier, **kwargs) @@ -157,7 +158,7 @@ def process_command(self, args: Namespace): """ if args.local: initialize_config(local_mode=True) - + merlin_db = MerlinDatabase() delete_type = args.delete_type diff --git a/merlin/cli/commands/database/get.py b/merlin/cli/commands/database/get.py index 4ae49de9..d2f8ee6e 100644 --- a/merlin/cli/commands/database/get.py +++ b/merlin/cli/commands/database/get.py @@ -33,6 +33,7 @@ from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.utils import get_plural_of_entity, get_singular_of_entity + LOG = logging.getLogger("merlin") @@ -49,7 +50,7 @@ class DatabaseGetCommand(CommandEntryPoint): _get_all_and_print: Fetches and prints filtered entities of a type. """ - def add_parser(self, database_commands: ArgumentParser): + def add_parser(self, database_commands: ArgumentParser): # pylint: disable=arguments-renamed """ Add the `database get` subcommand parser to the CLI argument parser. @@ -60,7 +61,7 @@ def add_parser(self, database_commands: ArgumentParser): # Subcommand: database get db_get_parser = database_commands.add_parser( "get", - help=f"Get information stored in the database.", + help="Get information stored in the database.", formatter_class=ArgumentDefaultsHelpFormatter, ) db_get_parser.set_defaults(func=self.process_command) @@ -127,7 +128,7 @@ def process_command(self, args: Namespace): """ if args.local: initialize_config(local_mode=True) - + merlin_db = MerlinDatabase() get_type = args.get_type diff --git a/merlin/cli/utils.py b/merlin/cli/utils.py index 2c8f01a8..a4380fbb 100644 --- a/merlin/cli/utils.py +++ b/merlin/cli/utils.py @@ -188,12 +188,12 @@ def get_filters_for_entity(args: Namespace, entity_type: str) -> Dict: if not entity_config: LOG.error(f"Invalid entity: '{entity_type}'.") return {} - + filter_options = entity_config.get("filters", {}) if not filter_options: LOG.error(f"No filters supported for '{entity_type}'.") return {} - + filter_keys = [filter["name"] for filter in filter_options] filters = {key: getattr(args, key) for key in filter_keys if getattr(args, key) is not None} return filters diff --git a/merlin/db_scripts/entity_managers/entity_manager.py b/merlin/db_scripts/entity_managers/entity_manager.py index 6608f133..b5854f89 100644 --- a/merlin/db_scripts/entity_managers/entity_manager.py +++ b/merlin/db_scripts/entity_managers/entity_manager.py @@ -74,6 +74,8 @@ def __init__(self, backend: ResultsBackend): """ self.backend = backend self.db = None # Subclasses can set this by creating a set_db_reference method + self._entity_type = None # Subclasses need to set this + self._entity_class = None # Subclasses need to set this @abstractmethod def create(self, *args: Any, **kwargs: Any) -> T: diff --git a/merlin/db_scripts/entity_managers/run_manager.py b/merlin/db_scripts/entity_managers/run_manager.py index 361df87b..a5844892 100644 --- a/merlin/db_scripts/entity_managers/run_manager.py +++ b/merlin/db_scripts/entity_managers/run_manager.py @@ -57,7 +57,7 @@ class RunManager(EntityManager[RunEntity, RunModel]): delete_all: Delete all runs in the database. set_db_reference: Set the reference to the main Merlin database for cross-entity operations. """ - + _filter_accessor_map: Dict[str, Callable[[T], Any]] = { "study_id": lambda e: e.get_study_id(), "run_complete": lambda e: e.run_complete, diff --git a/merlin/db_scripts/merlin_db.py b/merlin/db_scripts/merlin_db.py index 84f0cfed..94ed0d0a 100644 --- a/merlin/db_scripts/merlin_db.py +++ b/merlin/db_scripts/merlin_db.py @@ -187,7 +187,7 @@ def get(self, entity_type: str, *args, **kwargs) -> Any: self._validate_entity_type(entity_type) return self._entity_managers[entity_type].get(*args, **kwargs) - def get_all(self, entity_type: str, filters: Dict = {}) -> List[Any]: + def get_all(self, entity_type: str, filters: Dict = None) -> List[Any]: """ Get all entities of a specific type, optionally filtering results. @@ -272,9 +272,7 @@ def delete_everything(self, force: bool = False) -> None: LOG.info("Database flush cancelled.") def _fetch_info_data(self, max_preview: int): - """ - - """ + """ """ display_config = { "study": {"ID": "get_id", "Name": "get_name"}, "run": {"ID": "get_id", "Workspace": "get_workspace"}, @@ -308,10 +306,9 @@ def _fetch_info_data(self, max_preview: int): } return entity_summaries - + def _display_info_data(self, entity_summaries: Dict): - """ - """ + """ """ # Display general information print("Merlin Database Information") print("---------------------------") @@ -332,8 +329,7 @@ def _display_info_data(self, entity_summaries: Dict): print(f"- Recent {title}:") for i, preview in enumerate(summary["preview"], start=1): detail_str = ", ".join( - f"{field.title()}: {preview.get(field, '')}" - for field in summary["fields"] + f"{field.title()}: {preview.get(field, '')}" for field in summary["fields"] ) print(f" {i}. {detail_str}") remaining = summary["total"] - len(summary["preview"]) @@ -344,7 +340,7 @@ def _display_info_data(self, entity_summaries: Dict): def info(self, max_preview: int = 3): """ Print summarized information about the database contents. - + Args: max_preview: Number of recent entries to preview per entity type. """ @@ -352,4 +348,4 @@ def info(self, max_preview: int = 3): entity_summaries = self._fetch_info_data(max_preview) # Step 2: Print everything after fetching - self._display_info_data(entity_summaries) \ No newline at end of file + self._display_info_data(entity_summaries) diff --git a/merlin/utils.py b/merlin/utils.py index 969a916d..8d51d495 100644 --- a/merlin/utils.py +++ b/merlin/utils.py @@ -56,7 +56,7 @@ def pluralize(word: str) -> str: return word[:-2] + "ves" else: # e.g., "worker" -> "workers" return word + "s" - + def singularize(word: str) -> str: """ @@ -78,13 +78,15 @@ def singularize(word: str) -> str: return word[:-3] + "f" else: # default assumption: e.g., "knives" -> "knife" return word[:-3] + "fe" - elif word.endswith("es") and any(word.endswith(suffix + "es") for suffix in ("s", "sh", "ch", "x", "z")): # e.g., "bashes" -> "bash" + elif word.endswith("es") and any( + word.endswith(suffix + "es") for suffix in ("s", "sh", "ch", "x", "z") + ): # e.g., "bashes" -> "bash" return word[:-2] elif word.endswith("s") and not word.endswith("ss"): # e.g., "workers" -> "worker" return word[:-1] else: return word # likely already singular or unrecognized plural - + def transform_entity_suffix(entity_name: str, transform_fn, split_delimiter: str = "-", join_delimiter: str = "-") -> str: """ diff --git a/tests/unit/cli/commands/database/test_database_command.py b/tests/unit/cli/commands/database/test_database_command.py index 55242044..23b2e96a 100644 --- a/tests/unit/cli/commands/database/test_database_command.py +++ b/tests/unit/cli/commands/database/test_database_command.py @@ -9,11 +9,12 @@ """ -import pytest from argparse import ArgumentParser, Namespace -from pytest_mock import MockerFixture from unittest.mock import MagicMock +import pytest +from pytest_mock import MockerFixture + from merlin.cli.commands.database.database import DatabaseCommand @@ -94,7 +95,7 @@ def test_add_parser_calls_subcommand_add_parser_methods( """ parser = ArgumentParser() subparsers = parser.add_subparsers(dest="main", required=True) - + database_command.add_parser(subparsers) # Ensure subcommand `add_parser` methods are called with subparsers diff --git a/tests/unit/cli/commands/database/test_delete_subcommand.py b/tests/unit/cli/commands/database/test_delete_subcommand.py index 584aeb84..3a164795 100644 --- a/tests/unit/cli/commands/database/test_delete_subcommand.py +++ b/tests/unit/cli/commands/database/test_delete_subcommand.py @@ -8,11 +8,12 @@ Tests for the merlin/cli/commands/database/delete.py module. """ -import pytest from argparse import Namespace -from pytest_mock import MockerFixture from unittest.mock import MagicMock +import pytest +from pytest_mock import MockerFixture + from merlin.cli.commands.database.delete import DatabaseDeleteCommand from tests.fixture_types import FixtureDict diff --git a/tests/unit/cli/commands/database/test_get_subcommand.py b/tests/unit/cli/commands/database/test_get_subcommand.py index fd4ca940..921e61ee 100644 --- a/tests/unit/cli/commands/database/test_get_subcommand.py +++ b/tests/unit/cli/commands/database/test_get_subcommand.py @@ -8,25 +8,27 @@ Tests for the merlin/cli/commands/database/get.py module. """ -import pytest from argparse import Namespace from unittest.mock import MagicMock +import pytest from pytest_mock import MockerFixture from merlin.cli.commands.database.get import DatabaseGetCommand + # TODO write docstrings/type hints for this file and test_info_subcommand.py # TODO fix the remainder of the broken tests # TODO update the documentation for the database command + @pytest.fixture def mock_merlin_db(mocker: MockerFixture) -> MagicMock: """ Fixture that mocks the MerlinDatabase class used in the `database.get` module. This prevents real database connections and allows inspection of database method calls. - + Args: mocker: PyTest mocker fixture. @@ -42,10 +44,10 @@ def mock_initialize_config(mocker: MockerFixture) -> MagicMock: Fixture that mocks the `initialize_config` function in the `database.get` module. This prevents configuration initialization from affecting test environments. - + Args: mocker: PyTest mocker fixture. - + Returns: The mocked initialize_config function. """ @@ -59,7 +61,7 @@ def mock_entity_registry(mocker: MockerFixture) -> MagicMock: This allows tests to simulate entity-specific behavior (e.g., "study", "run") without relying on the actual registry. - + Args: mocker: PyTest mocker fixture. @@ -115,7 +117,9 @@ def test_process_command_get_everything(command: DatabaseGetCommand, mock_merlin mock_print_items = mocker.patch.object(command, "_print_items") args = Namespace(get_type="everything", local=False) command.process_command(args) - mock_print_items.assert_called_once_with(mock_merlin_db.return_value.get_everything.return_value, "Nothing found in the database.") + mock_print_items.assert_called_once_with( + mock_merlin_db.return_value.get_everything.return_value, "Nothing found in the database." + ) def test_process_command_get_specific_entities( diff --git a/tests/unit/cli/commands/database/test_info_subcommand.py b/tests/unit/cli/commands/database/test_info_subcommand.py index c3d794ea..2d415a8b 100644 --- a/tests/unit/cli/commands/database/test_info_subcommand.py +++ b/tests/unit/cli/commands/database/test_info_subcommand.py @@ -8,10 +8,10 @@ Tests for the merlin/cli/commands/database/info.py module. """ -import pytest from argparse import ArgumentParser, Namespace from unittest.mock import MagicMock +import pytest from pytest_mock import MockerFixture from merlin.cli.commands.database.info import DatabaseInfoCommand diff --git a/tests/unit/db_scripts/entity_managers/test_entity_manager.py b/tests/unit/db_scripts/entity_managers/test_entity_manager.py index 4eb34c0b..ddfad4b9 100644 --- a/tests/unit/db_scripts/entity_managers/test_entity_manager.py +++ b/tests/unit/db_scripts/entity_managers/test_entity_manager.py @@ -9,7 +9,7 @@ """ from dataclasses import dataclass -from typing import Any, List +from typing import Any from unittest.mock import MagicMock, call import pytest From 14b6ce0fc620b88f09738fd8bc964166405eff59 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 5 Aug 2025 12:17:47 -0700 Subject: [PATCH 30/91] add tests for new backend filtering --- .../backends/sqlite/test_sqlite_store_base.py | 128 ++++++++++++++++++ .../backends/test_filter_support_mixin.py | 88 ++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 tests/unit/backends/test_filter_support_mixin.py diff --git a/tests/unit/backends/sqlite/test_sqlite_store_base.py b/tests/unit/backends/sqlite/test_sqlite_store_base.py index c1815e7a..209c4933 100644 --- a/tests/unit/backends/sqlite/test_sqlite_store_base.py +++ b/tests/unit/backends/sqlite/test_sqlite_store_base.py @@ -350,6 +350,134 @@ def test_retrieve_all_with_deserialization_error( assert len(results) == 1 assert results[0] == run1 + def test_retrieve_all_filtered_with_scalar_values( + self, + mocker: MockerFixture, + test_models: FixtureDict, + simple_store: SQLiteStoreBase, + mock_sqlite_connection: FixtureTuple[MagicMock], + ): + """ + Test retrieving filtered entities using scalar column values. + + Args: + mocker: PyTest mocker fixture. + test_models: A fixture providing test model instances. + simple_store: A fixture providing a SQLiteStoreBase instance. + mock_sqlite_connection: Fixture providing mocked SQLite connection and cursor. + """ + run1 = test_models["run"] + + # Mock database rows + mock_conn, mock_cursor = mock_sqlite_connection + mock_rows = [{"id": run1.id, "study_id": "study1"}] + mock_cursor.fetchall.return_value = mock_rows + + # Mock deserialization + mocker.patch( + "merlin.backends.sqlite.sqlite_store_base.deserialize_entity", return_value=run1 + ) + + filters = {"study_id": "study1"} + results = simple_store.retrieve_all_filtered(filters) + + # Verify SQL query and parameters + mock_conn.execute.assert_called_once_with( + "SELECT * FROM test_table WHERE study_id = ?", ["study1"] + ) + assert len(results) == 1 + assert results[0] == run1 + + def test_retrieve_all_filtered_with_list_values( + self, + mocker: MockerFixture, + test_models: FixtureDict, + simple_store: SQLiteStoreBase, + mock_sqlite_connection: FixtureTuple[MagicMock], + ): + """ + Test retrieving filtered entities using a list of values (should generate LIKE/OR clause). + + Args: + mocker: PyTest mocker fixture. + test_models: A fixture providing test model instances. + simple_store: A fixture providing a SQLiteStoreBase instance. + mock_sqlite_connection: Fixture providing mocked SQLite connection and cursor. + """ + run1 = test_models["run"] + run2 = RunModel(id="run2", study_id="study1") + + mock_conn, mock_cursor = mock_sqlite_connection + mock_rows = [{"id": run1.id, "study_id": "study1"}, {"id": run2.id, "study_id": "study1"}] + mock_cursor.fetchall.return_value = mock_rows + + mocker.patch( + "merlin.backends.sqlite.sqlite_store_base.deserialize_entity", side_effect=[run1, run2] + ) + + filters = {"id": ["run1", "run2"]} + results = simple_store.retrieve_all_filtered(filters) + + expected_query = "SELECT * FROM test_table WHERE (id LIKE ? OR id LIKE ?)" + expected_params = ["%run1%", "%run2%"] + mock_conn.execute.assert_called_once_with(expected_query, expected_params) + + assert len(results) == 2 + assert run1 in results + assert run2 in results + + def test_retrieve_all_filtered_with_empty_list( + self, + simple_store: SQLiteStoreBase, + mock_sqlite_connection: FixtureTuple[MagicMock], + ): + """ + Test filtering with an empty list value, which should yield no results (1 = 0 condition). + + Args: + simple_store: A fixture providing a SQLiteStoreBase instance. + mock_sqlite_connection: Fixture providing mocked SQLite connection and cursor. + """ + mock_conn, mock_cursor = mock_sqlite_connection + mock_cursor.fetchall.return_value = [] + + filters = {"id": []} + results = simple_store.retrieve_all_filtered(filters) + + expected_query = "SELECT * FROM test_table WHERE 1 = 0" + mock_conn.execute.assert_called_once_with(expected_query, []) + assert results == [] + + def test_retrieve_all_filtered_with_partial_deserialization_failure( + self, + mocker: MockerFixture, + simple_store: SQLiteStoreBase, + mock_sqlite_connection: FixtureTuple[MagicMock], + ): + """ + Test that retrieve_all_filtered continues even if some rows fail to deserialize. + + Args: + mocker: PyTest mocker fixture. + simple_store: A fixture providing a SQLiteStoreBase instance. + mock_sqlite_connection: Fixture providing mocked SQLite connection and cursor. + """ + _, mock_cursor = mock_sqlite_connection + mock_rows = [{"id": "run1", "study_id": "study1"}, {"id": "run2", "study_id": "study1"}] + mock_cursor.fetchall.return_value = mock_rows + + run1 = RunModel(id="run1", study_id="study1") + mocker.patch( + "merlin.backends.sqlite.sqlite_store_base.deserialize_entity", + side_effect=[run1, Exception("Deserialization error")], + ) + + filters = {"study_id": "study1"} + results = simple_store.retrieve_all_filtered(filters) + + assert len(results) == 1 + assert results[0] == run1 + @pytest.mark.parametrize( "identifier, by_name, identifier_key", [ diff --git a/tests/unit/backends/test_filter_support_mixin.py b/tests/unit/backends/test_filter_support_mixin.py new file mode 100644 index 00000000..881ab1b6 --- /dev/null +++ b/tests/unit/backends/test_filter_support_mixin.py @@ -0,0 +1,88 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the merlin/backends/filter_support_mixin.py module. +""" + +import pytest + +from pytest_mock import MockerFixture + +from merlin.backends.filter_support_mixin import FilterSupportMixin +from merlin.backends.results_backend import ResultsBackend +from merlin.db_scripts.data_models import BaseDataModel, LogicalWorkerModel, PhysicalWorkerModel, RunModel, StudyModel + + +@pytest.fixture() +def filter_support_backend_test_instance(mocker: MockerFixture) -> ResultsBackend: + """ + Provides a concrete test instance of the `ResultsBackend` class with `FilterSupportMixin` + for use in tests. + + This fixture dynamically creates a subclass of `ResultsBackend` called `Test` and + overrides its abstract methods, allowing it to be instantiated. The abstract methods + are bypassed by setting the `__abstractmethods__` attribute to an empty frozenset. + The resulting instance is initialized with the provided `results_backend_test_name`. + + Args: + mocker: PyTest mocker fixture. + + Returns: + A concrete instance of the `ResultsBackend` class for testing purposes. + """ + + class Test(ResultsBackend, FilterSupportMixin): + def __init__(self, backend_name: str): + stores = { + "study": mocker.MagicMock(), + "run": mocker.MagicMock(), + "logical_worker": mocker.MagicMock(), + "physical_worker": mocker.MagicMock(), + } + super().__init__(backend_name, stores) + + Test.__abstractmethods__ = frozenset() + return Test("test-filter-support-backend") + + +@pytest.mark.parametrize( + "db_model, model_type", + [ + (StudyModel, "study"), + (RunModel, "run"), + (LogicalWorkerModel, "logical_worker"), + (PhysicalWorkerModel, "physical_worker"), + ], +) +def test_retrieve_all_filtered( + mocker: MockerFixture, + filter_support_backend_test_instance: FilterSupportMixin, + db_model: BaseDataModel, + model_type: str, +): + """ + Test retrieving filtered entities from a store that supports filtering. + + Args: + mocker (MockerFixture): PyTest mocker fixture. + filter_support_backend_test_instance (FilterSupportMixin): A fixture representing a `ResultsBackend` + that also mixes in filtering support via `FilterSupportMixin`. + db_model (BaseDataModel): The database model class representing the entity type being tested. + model_type (str): A string identifier for the type of entity being tested. This corresponds to + the key used in the `ResultsBackend.stores` dictionary. + """ + filters = {"name": "example"} + mock_model_instance = mocker.MagicMock(spec=db_model) + filter_support_backend_test_instance.stores[model_type].retrieve_all_filtered.return_value = [mock_model_instance] + + # Call the method under test + entities = filter_support_backend_test_instance.retrieve_all_filtered(model_type, filters) + + # Verify + filter_support_backend_test_instance.stores[model_type].retrieve_all_filtered.assert_called_once_with(filters) + assert len(entities) == 1, "Should retrieve one filtered entity." + assert isinstance(entities[0], db_model), f"Retrieved entity should be of type {type(db_model)}." From 7eb7529a65b847caf80efc2c72af92052c66027f Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 5 Aug 2025 12:59:49 -0700 Subject: [PATCH 31/91] add tests for new cli utils functions --- tests/unit/cli/test_cli_utils.py | 107 ++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 2 deletions(-) diff --git a/tests/unit/cli/test_cli_utils.py b/tests/unit/cli/test_cli_utils.py index 36b1633c..3876cbd5 100644 --- a/tests/unit/cli/test_cli_utils.py +++ b/tests/unit/cli/test_cli_utils.py @@ -8,12 +8,33 @@ Tests for the `utils.py` file of the `cli/` folder. """ -from argparse import Namespace +from argparse import ArgumentParser, Namespace +from unittest.mock import MagicMock import pytest from pytest_mock import MockerFixture -from merlin.cli.utils import get_merlin_spec_with_override, parse_override_vars +from merlin.cli.utils import get_filters_for_entity, get_merlin_spec_with_override, parse_override_vars, setup_db_entity_subcommands + + +@pytest.fixture +def patched_registry(mocker: MockerFixture) -> MagicMock: + entity_registry = { + "study": { + "identifiers": "study_id", + "ident_help": "Study ID(s) to {verb}.", + "filters": [ + {"name": "user", "type": str}, + {"name": "status", "type": str, "nargs": "+"}, + ], + }, + "run": { + "identifiers": "run_id", + "ident_help": "Run ID(s) to {verb}.", + "filters": [], + }, + } + return mocker.patch("merlin.cli.utils.ENTITY_REGISTRY", entity_registry) class TestParseOverrideVars: @@ -90,3 +111,85 @@ def test_returns_spec_and_filepath(self, mocker: MockerFixture): assert spec is fake_spec assert path == fake_filepath + + +class TestSetupDbEntitySubcommands: + """ + Unit tests for the `setup_db_entity_subcommands` function in `cli/utils.py`. + """ + + def test_creates_expected_subcommands(self, patched_registry: MagicMock): + """ + Test that both singular and all-entity subcommands are added for each registered entity. + + Args: + patched_registry: Mocked ENTITY_REGISTRY. + """ + parser = ArgumentParser() + subparsers = parser.add_subparsers(dest="entity") + + result = setup_db_entity_subcommands(subparsers, "delete") + assert "study" in result + assert "all-studies" in result + + study_args = result["study"].parse_args(["study123"]) + assert study_args.entity == ["study123"] + + all_args = result["all-studies"].parse_args(["--status", "running", "paused"]) + assert all_args.status == ["running", "paused"] + + +class TestGetFiltersForEntity: + """ + Unit tests for the get_filters_for_entity utility function. + """ + + def test_returns_correct_filters(self, patched_registry: MagicMock): + """ + Test that get_filters_for_entity returns only non-None filter values. + + Args: + patched_registry: Mocked ENTITY_REGISTRY. + """ + args = Namespace(user="alice", status=["complete", "failed"]) + filters = get_filters_for_entity(args, "study") + assert filters == {"user": "alice", "status": ["complete", "failed"]} + + def test_ignores_none_values(self, patched_registry: MagicMock): + """ + Test that filters with None values are excluded. + + Args: + patched_registry: Mocked ENTITY_REGISTRY. + """ + args = Namespace(user=None, status=["running"]) + filters = get_filters_for_entity(args, "study") + assert filters == {"status": ["running"]} + + def test_invalid_entity_returns_empty_dict(self, patched_registry: MagicMock, mocker: MockerFixture): + """ + Test that invalid entity types return an empty dict and log an error. + + Args: + patched_registry: Mocked ENTITY_REGISTRY. + mocker: Pytest mocker fixture for capturing logs. + """ + mock_logger = mocker.patch("merlin.cli.utils.LOG") + args = Namespace() + filters = get_filters_for_entity(args, "invalid") + assert filters == {} + mock_logger.error.assert_called_once_with("Invalid entity: 'invalid'.") + + def test_no_filters_defined_returns_empty_dict(self, patched_registry: MagicMock, mocker: MockerFixture): + """ + Test that an entity with no filter config logs and returns an empty dict. + + Args: + patched_registry: Mocked ENTITY_REGISTRY. + mocker: Pytest mocker fixture for capturing logs. + """ + mock_logger = mocker.patch("merlin.cli.utils.LOG") + args = Namespace() + filters = get_filters_for_entity(args, "run") + assert filters == {} + mock_logger.error.assert_called_once_with("No filters supported for 'run'.") From 29151e640fb980f6c5bb6928010346b4d87be469 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 5 Aug 2025 13:06:40 -0700 Subject: [PATCH 32/91] fix typo in cli/utils.py --- merlin/cli/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merlin/cli/utils.py b/merlin/cli/utils.py index a4380fbb..df0a1d53 100644 --- a/merlin/cli/utils.py +++ b/merlin/cli/utils.py @@ -111,7 +111,7 @@ def get_merlin_spec_with_override(args: Namespace) -> Tuple[MerlinSpec, str]: return spec, filepath -def setup_db_entity_subcommands(subcommand_parser: ArgumentParser, subcommand_name: str) -> dict[str, ArgumentParser]: +def setup_db_entity_subcommands(subcommand_parser: ArgumentParser, subcommand_name: str) -> Dict[str, ArgumentParser]: """ Dynamically sets up subcommands for each entity type for a given subcommand. From 0e747e99b047c9b812dfada15bd225b832ceb54d Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 5 Aug 2025 13:08:29 -0700 Subject: [PATCH 33/91] run fix-style --- tests/unit/backends/sqlite/test_sqlite_store_base.py | 12 +++--------- tests/unit/backends/test_filter_support_mixin.py | 1 - tests/unit/cli/test_cli_utils.py | 7 ++++++- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/unit/backends/sqlite/test_sqlite_store_base.py b/tests/unit/backends/sqlite/test_sqlite_store_base.py index 209c4933..4ce134ba 100644 --- a/tests/unit/backends/sqlite/test_sqlite_store_base.py +++ b/tests/unit/backends/sqlite/test_sqlite_store_base.py @@ -374,17 +374,13 @@ def test_retrieve_all_filtered_with_scalar_values( mock_cursor.fetchall.return_value = mock_rows # Mock deserialization - mocker.patch( - "merlin.backends.sqlite.sqlite_store_base.deserialize_entity", return_value=run1 - ) + mocker.patch("merlin.backends.sqlite.sqlite_store_base.deserialize_entity", return_value=run1) filters = {"study_id": "study1"} results = simple_store.retrieve_all_filtered(filters) # Verify SQL query and parameters - mock_conn.execute.assert_called_once_with( - "SELECT * FROM test_table WHERE study_id = ?", ["study1"] - ) + mock_conn.execute.assert_called_once_with("SELECT * FROM test_table WHERE study_id = ?", ["study1"]) assert len(results) == 1 assert results[0] == run1 @@ -411,9 +407,7 @@ def test_retrieve_all_filtered_with_list_values( mock_rows = [{"id": run1.id, "study_id": "study1"}, {"id": run2.id, "study_id": "study1"}] mock_cursor.fetchall.return_value = mock_rows - mocker.patch( - "merlin.backends.sqlite.sqlite_store_base.deserialize_entity", side_effect=[run1, run2] - ) + mocker.patch("merlin.backends.sqlite.sqlite_store_base.deserialize_entity", side_effect=[run1, run2]) filters = {"id": ["run1", "run2"]} results = simple_store.retrieve_all_filtered(filters) diff --git a/tests/unit/backends/test_filter_support_mixin.py b/tests/unit/backends/test_filter_support_mixin.py index 881ab1b6..7e8cf77a 100644 --- a/tests/unit/backends/test_filter_support_mixin.py +++ b/tests/unit/backends/test_filter_support_mixin.py @@ -9,7 +9,6 @@ """ import pytest - from pytest_mock import MockerFixture from merlin.backends.filter_support_mixin import FilterSupportMixin diff --git a/tests/unit/cli/test_cli_utils.py b/tests/unit/cli/test_cli_utils.py index 3876cbd5..4e882503 100644 --- a/tests/unit/cli/test_cli_utils.py +++ b/tests/unit/cli/test_cli_utils.py @@ -14,7 +14,12 @@ import pytest from pytest_mock import MockerFixture -from merlin.cli.utils import get_filters_for_entity, get_merlin_spec_with_override, parse_override_vars, setup_db_entity_subcommands +from merlin.cli.utils import ( + get_filters_for_entity, + get_merlin_spec_with_override, + parse_override_vars, + setup_db_entity_subcommands, +) @pytest.fixture From 24042dab8a56b14af89b970628af03dad856645b Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 6 Aug 2025 16:07:58 -0700 Subject: [PATCH 34/91] add filter options to the command line page --- docs/user_guide/command_line.md | 46 ++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/docs/user_guide/command_line.md b/docs/user_guide/command_line.md index 6cb41e51..8842a11d 100644 --- a/docs/user_guide/command_line.md +++ b/docs/user_guide/command_line.md @@ -526,10 +526,10 @@ merlin database get [OPTIONS] SUBCOMMAND ... | [run](#get-run-merlin-database-get-run) | Retrieve and print specific run(s) from the database | | [logical-worker](#get-logical-worker-merlin-database-get-logical-worker) | Retrieve and print specific logical worker(s) from the database | | [physical-worker](#get-physical-worker-merlin-database-get-physical-worker) | Retrieve and print specific physical worker(s) from the database | -| [all-studies](#get-all-studies-merlin-database-get-all-studies) | Retrieve and print all studies from the database | -| [all-runs](#get-all-runs-merlin-database-get-all-runs) | Retrieve and print all runs from the database | -| [all-logical-workers](#get-all-logical-workers-merlin-database-get-all-logical-workers) | Retrieve and print all logical workers from the database | -| [all-physical-workers](#get-all-physical-workers-merlin-database-get-all-physical-workers) | Retrieve and print all physical workers from the database | +| [all-studies](#get-all-studies-merlin-database-get-all-studies) | Retrieve and print all studies from the database (supports filters) | +| [all-runs](#get-all-runs-merlin-database-get-all-runs) | Retrieve and print all runs from the database (supports filters) | +| [all-logical-workers](#get-all-logical-workers-merlin-database-get-all-logical-workers) | Retrieve and print all logical workers from the database (supports filters) | +| [all-physical-workers](#get-all-physical-workers-merlin-database-get-all-physical-workers) | Retrieve and print all physical workers from the database (supports filters) | | [everything](#get-everything-merlin-database-get-everything) | Retrieve and print every entry from the database | ##### Get Study (`merlin database get study`) @@ -598,7 +598,7 @@ merlin database get physical-worker [OPTIONS] PHYSICAL_WORKER_ID_OR_NAME [PHYSIC ##### Get All-Studies (`merlin database get all-studies`) -The `get all-studies` subcommand allows users to retrieve all study entries from the database and print them to the console. +The `get all-studies` subcommand allows users to retrieve all study entries from the database and print them to the console. This command supports filtering. **Usage:** @@ -611,10 +611,11 @@ merlin database get all-studies [OPTIONS] | Name | Type | Description | Default | | ------------ | ------- | ----------- | ------- | | `-h`, `--help` | boolean | Show this help message and exit | `False` | +| `--name` | string | Filter by name | None | ##### Get All-Runs (`merlin database get all-runs`) -The `get all-runs` subcommand allows users to retrieve all run entries from the database and print them to the console. +The `get all-runs` subcommand allows users to retrieve all run entries from the database and print them to the console. This command supports filtering. **Usage:** @@ -627,10 +628,14 @@ merlin database get all-runs [OPTIONS] | Name | Type | Description | Default | | ------------ | ------- | ----------- | ------- | | `-h`, `--help` | boolean | Show this help message and exit | `False` | +| `--study-id` | string | Filter by study id | None | +| `--run-complete` | choice(`true` \| `false`) | Filter by run complete | None | +| `--queues` | List[string] | Filter by queues | None | +| `--workers` | List[string] | Filter by workers | None | ##### Get All-Logical-Workers (`merlin database get all-logical-workers`) -The `get all-logical-workers` subcommand allows users to retrieve all logical-worker entries from the database and print them to the console. +The `get all-logical-workers` subcommand allows users to retrieve all logical-worker entries from the database and print them to the console. This command supports filtering. **Usage:** @@ -643,10 +648,12 @@ merlin database get all-logical-workers [OPTIONS] | Name | Type | Description | Default | | ------------ | ------- | ----------- | ------- | | `-h`, `--help` | boolean | Show this help message and exit | `False` | +| `--name` | string | Filter by name | None | +| `--queues` | List[string] | Filter by queues | None | ##### Get All-Physical-Workers (`merlin database get all-physical-workers`) -The `get all-physical-workers` subcommand allows users to retrieve all physical-worker entries from the database and print them to the console. +The `get all-physical-workers` subcommand allows users to retrieve all physical-worker entries from the database and print them to the console. This command supports filtering. **Usage:** @@ -659,6 +666,10 @@ merlin database get all-physical-workers [OPTIONS] | Name | Type | Description | Default | | ------------ | ------- | ----------- | ------- | | `-h`, `--help` | boolean | Show this help message and exit | `False` | +| `--logical-worker-id` | string | Filter by logical worker id | None | +| `--name` | string | Filter by name | None | +| `--status` | string | Filter by status | None | +| `--host` | string | Filter by host | None | ##### Get Everything (`merlin database get everything`) @@ -700,10 +711,10 @@ merlin database delete [OPTIONS] SUBCOMMAND ... | [run](#delete-run-merlin-database-delete-run) | Delete specific run(s) from the database | | [logical-worker](#delete-logical-worker-merlin-database-delete-logical-worker) | Delete specific logical worker(s) from the database | | [physical-worker](#delete-physical-worker-merlin-database-delete-physical-worker) | Delete specific physical worker(s) from the database | -| [all-studies](#delete-all-studies-merlin-database-delete-all-studies) | Delete all studies from the database | -| [all-runs](#delete-all-runs-merlin-database-delete-all-runs) | Delete all runs from the database | -| [all-logical-workers](#delete-all-logical-workers-merlin-database-delete-all-logical-workers) | Delete all logical workers from the database | -| [all-physical-workers](#delete-all-physical-workers-merlin-database-delete-all-physical-workers) | Delete all physical workers from the database | +| [all-studies](#delete-all-studies-merlin-database-delete-all-studies) | Delete all studies from the database (supports filters) | +| [all-runs](#delete-all-runs-merlin-database-delete-all-runs) | Delete all runs from the database (supports filters) | +| [all-logical-workers](#delete-all-logical-workers-merlin-database-delete-all-logical-workers) | Delete all logical workers from the database (supports filters) | +| [all-physical-workers](#delete-all-physical-workers-merlin-database-delete-all-physical-workers) | Delete all physical workers from the database (supports filters) | | [everything](#delete-everything-merlin-database-delete-everything) | Delete everything from the database | ##### Delete Study (`merlin database delete study`) @@ -795,6 +806,7 @@ merlin database delete all-studies [OPTIONS] | ------------ | ------- | ----------- | ------- | | `-h`, `--help` | boolean | Show this help message and exit | `False` | | `-k`, `--keep-associated-runs` | boolean | Keep runs associated with the studies | `False` | +| `--name` | string | Filter by name | None | ##### Delete All-Runs (`merlin database delete all-runs`) @@ -811,6 +823,10 @@ merlin database delete all-runs [OPTIONS] | Name | Type | Description | Default | | ------------ | ------- | ----------- | ------- | | `-h`, `--help` | boolean | Show this help message and exit | `False` | +| `--study-id` | string | Filter by study id | None | +| `--run-complete` | choice(`true` \| `false`) | Filter by run complete | None | +| `--queues` | List[string] | Filter by queues | None | +| `--workers` | List[string] | Filter by workers | None | ##### Delete All-Logical-Workers (`merlin database delete all-logical-workers`) @@ -827,6 +843,8 @@ merlin database delete all-logical-workers [OPTIONS] | Name | Type | Description | Default | | ------------ | ------- | ----------- | ------- | | `-h`, `--help` | boolean | Show this help message and exit | `False` | +| `--name` | string | Filter by name | None | +| `--queues` | List[string] | Filter by queues | None | ##### Delete All-Physical-Workers (`merlin database delete all-physical-workers`) @@ -843,6 +861,10 @@ merlin database delete all-physical-workers [OPTIONS] | Name | Type | Description | Default | | ------------ | ------- | ----------- | ------- | | `-h`, `--help` | boolean | Show this help message and exit | `False` | +| `--logical-worker-id` | string | Filter by logical worker id | None | +| `--name` | string | Filter by name | None | +| `--status` | string | Filter by status | None | +| `--host` | string | Filter by host | None | ##### Delete Everything (`merlin database delete everything`) From c7949f3057dbe8fb1963b099c6f3cc6101487f37 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 14:29:57 -0700 Subject: [PATCH 35/91] resolve conflicts --- merlin/abstracts/__init__.py | 1 - merlin/backends/redis/redis_backend.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/merlin/abstracts/__init__.py b/merlin/abstracts/__init__.py index 8b3faec6..aae1b246 100644 --- a/merlin/abstracts/__init__.py +++ b/merlin/abstracts/__init__.py @@ -14,5 +14,4 @@ from merlin.abstracts.factory import MerlinBaseFactory - __all__ = ["MerlinBaseFactory"] diff --git a/merlin/backends/redis/redis_backend.py b/merlin/backends/redis/redis_backend.py index 285ec119..20aea8f6 100644 --- a/merlin/backends/redis/redis_backend.py +++ b/merlin/backends/redis/redis_backend.py @@ -70,9 +70,11 @@ def __init__(self): from merlin.config.configfile import CONFIG # pylint: disable=import-outside-toplevel from merlin.config.results_backend import get_connection_string # pylint: disable=import-outside-toplevel + backend_name = CONFIG.results_backend.name + # Get the Redis client connection redis_config = {"url": get_connection_string(), "decode_responses": True} - if CONFIG.results_backend.name == "rediss": + if backend_name == "rediss": redis_config.update({"ssl_cert_reqs": getattr(CONFIG.results_backend, "cert_reqs", "required")}) self.client: Redis = Redis.from_url(**redis_config) From 50743c5d37657aede5332f57d23a7e1648c54f54 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 14:30:19 -0700 Subject: [PATCH 36/91] resolve conflicts --- tests/unit/abstracts/test_factory.py | 1 - tests/unit/backends/test_backend_factory.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/abstracts/test_factory.py b/tests/unit/abstracts/test_factory.py index a92b1481..123ca162 100644 --- a/tests/unit/abstracts/test_factory.py +++ b/tests/unit/abstracts/test_factory.py @@ -15,7 +15,6 @@ from merlin.abstracts import MerlinBaseFactory - # --- Dummy Components --- class DummyComponent: """A testable dummy component.""" diff --git a/tests/unit/backends/test_backend_factory.py b/tests/unit/backends/test_backend_factory.py index 82b93293..56ce7c51 100644 --- a/tests/unit/backends/test_backend_factory.py +++ b/tests/unit/backends/test_backend_factory.py @@ -9,6 +9,7 @@ """ import pytest + from pytest_mock import MockerFixture from merlin.backends.backend_factory import MerlinBackendFactory From 6daae0fe3d3d5f023909146905507a317a1a0ac5 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 16 Jul 2025 08:04:33 -0700 Subject: [PATCH 37/91] mocked more items to try to fix broken tests on github --- merlin/backends/redis/redis_backend.py | 4 +--- tests/unit/backends/test_backend_factory.py | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/merlin/backends/redis/redis_backend.py b/merlin/backends/redis/redis_backend.py index 20aea8f6..285ec119 100644 --- a/merlin/backends/redis/redis_backend.py +++ b/merlin/backends/redis/redis_backend.py @@ -70,11 +70,9 @@ def __init__(self): from merlin.config.configfile import CONFIG # pylint: disable=import-outside-toplevel from merlin.config.results_backend import get_connection_string # pylint: disable=import-outside-toplevel - backend_name = CONFIG.results_backend.name - # Get the Redis client connection redis_config = {"url": get_connection_string(), "decode_responses": True} - if backend_name == "rediss": + if CONFIG.results_backend.name == "rediss": redis_config.update({"ssl_cert_reqs": getattr(CONFIG.results_backend, "cert_reqs", "required")}) self.client: Redis = Redis.from_url(**redis_config) diff --git a/tests/unit/backends/test_backend_factory.py b/tests/unit/backends/test_backend_factory.py index 56ce7c51..f065df21 100644 --- a/tests/unit/backends/test_backend_factory.py +++ b/tests/unit/backends/test_backend_factory.py @@ -9,6 +9,7 @@ """ import pytest +from pytest_mock import MockerFixture from pytest_mock import MockerFixture From dd5d19ddd25fc9e77077841f941947728fe20cbe Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Mon, 21 Jul 2025 13:42:28 -0700 Subject: [PATCH 38/91] create MerlinWorker and CeleryWorker classes to handle the launching of workers --- merlin/cli/commands/run_workers.py | 97 ++++++++++++-- merlin/exceptions/__init__.py | 9 ++ merlin/spec/specification.py | 29 ++++- merlin/study/batch.py | 31 +---- merlin/study/celeryadapter.py | 1 + merlin/workers/__init__.py | 13 ++ merlin/workers/celery_worker.py | 202 +++++++++++++++++++++++++++++ merlin/workers/worker.py | 73 +++++++++++ 8 files changed, 419 insertions(+), 36 deletions(-) create mode 100644 merlin/workers/__init__.py create mode 100644 merlin/workers/celery_worker.py create mode 100644 merlin/workers/worker.py diff --git a/merlin/cli/commands/run_workers.py b/merlin/cli/commands/run_workers.py index ce08a7f4..cdc35385 100644 --- a/merlin/cli/commands/run_workers.py +++ b/merlin/cli/commands/run_workers.py @@ -17,13 +17,15 @@ import logging from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace +from typing import List, Set, Union from merlin.ascii_art import banner_small from merlin.cli.commands.command_entry_point import CommandEntryPoint from merlin.cli.utils import get_merlin_spec_with_override from merlin.config.configfile import initialize_config from merlin.db_scripts.merlin_db import MerlinDatabase -from merlin.router import launch_workers +from merlin.spec.specification import MerlinSpec +from merlin.workers.celery_worker import CeleryWorker LOG = logging.getLogger("merlin") @@ -93,6 +95,75 @@ def add_parser(self, subparsers: ArgumentParser): "in your workers' args section will overwrite this flag for that worker.", ) + # TODO when we move the queues setting to within the worker then this will no longer be necessary + def _get_workers_to_start(self, spec: MerlinSpec, steps: Union[List[str], None]) -> Set[str]: + """ + Determine the set of workers to start based on the specified steps (if any) + + This helper function retrieves a mapping of steps to their corresponding workers + from a [`MerlinSpec`][spec.specification.MerlinSpec] object and returns a unique + set of workers that should be started for the provided list of steps. If a step + is not found in the mapping, a warning is logged. + + Args: + spec (spec.specification.MerlinSpec): An instance of the + [`MerlinSpec`][spec.specification.MerlinSpec] class that contains the + mapping of steps to workers. + steps: A list of steps for which workers need to be started or None if the user + didn't provide specific steps. + + Returns: + A set of unique workers to be started based on the specified steps. + """ + steps_provided = False if "all" in steps else True + + if steps_provided: + workers_to_start = [] + step_worker_map = spec.get_step_worker_map() + for step in steps: + try: + workers_to_start.extend(step_worker_map[step]) + except KeyError: + LOG.warning(f"Cannot start workers for step: {step}. This step was not found.") + + workers_to_start = set(workers_to_start) + else: + workers_to_start = set(spec.merlin["resources"]["workers"]) + + LOG.debug(f"workers_to_start: {workers_to_start}") + return workers_to_start + + # TODO this should move to TaskServerInterface and be abstracted (build_worker_list ?) + def _get_worker_instances(self, workers_to_start: Set[str], spec: MerlinSpec) -> List[CeleryWorker]: + """ + """ + workers = [] + all_workers = spec.merlin["resources"]["workers"] + overlap = spec.merlin["resources"]["overlap"] + full_env = spec.get_full_environment() + + for worker_name in workers_to_start: + settings = all_workers[worker_name] + config = { + "args": settings.get("args", ""), + "machines": settings.get("machines", []), + "queues": spec.get_queue_list(settings["steps"]), + "batch": settings["batch"] if settings["batch"] is not None else spec.batch.copy() + } + + if "nodes" in settings and settings["nodes"] is not None: + if config["batch"]: + config["batch"]["nodes"] = settings["nodes"] + else: + config["batch"] = {"nodes": settings["nodes"]} + + LOG.debug(f"config for worker '{worker_name}': {config}") + + workers.append(CeleryWorker(name=worker_name, config=config, env=full_env, overlap=overlap)) + LOG.debug(f"Created CeleryWorker object for worker '{worker_name}'.") + + return workers + def process_command(self, args: Namespace): """ CLI command for launching workers. @@ -126,12 +197,18 @@ def process_command(self, args: Namespace): worker_queues = {step_queue_map[step] for step in steps} merlin_db.create("logical_worker", worker, worker_queues) - # Launch the workers - launch_worker_status = launch_workers( - spec, args.worker_steps, args.worker_args, args.disable_logs, args.worker_echo_only - ) - - if args.worker_echo_only: - print(launch_worker_status) - else: - LOG.debug(f"celery command: {launch_worker_status}") + # Get the names of the workers that the user is requesting to start + workers_to_start = self._get_workers_to_start(spec, args.worker_steps) + + # Build a list of MerlinWorker instances + worker_instances = self._get_worker_instances(workers_to_start, spec) + + # Launch the workers or echo out the command that will be used to launch the workers + for worker in worker_instances: + if args.worker_echo_only: + LOG.debug(f"Not launching worker '{worker.name}', just echoing command.") + launch_cmd = worker.get_launch_command(override_args=args.worker_args, disable_logs=args.disable_logs) + print(launch_cmd) + else: + LOG.debug(f"Launching worker '{worker.name}'.") + worker.launch_worker(override_args=args.worker_args, disable_logs=args.disable_logs) diff --git a/merlin/exceptions/__init__.py b/merlin/exceptions/__init__.py index 1cabe577..93ce253b 100644 --- a/merlin/exceptions/__init__.py +++ b/merlin/exceptions/__init__.py @@ -108,6 +108,15 @@ def __init__(self, message): super().__init__(message) +class MerlinWorkerLaunchError(Exception): + """ + Exception to signal that an there was a problem when launching workers. + """ + + def __init__(self, message): + super().__init__(message) + + ############################### # Database-Related Exceptions # ############################### diff --git a/merlin/spec/specification.py b/merlin/spec/specification.py index d9218a01..da272269 100644 --- a/merlin/spec/specification.py +++ b/merlin/spec/specification.py @@ -24,7 +24,7 @@ from maestrowf.specification import YAMLSpecification from merlin.spec import all_keys, defaults -from merlin.utils import find_vlaunch_var, load_array_file, needs_merlin_expansion, repr_timedelta +from merlin.utils import find_vlaunch_var, get_yaml_var, load_array_file, needs_merlin_expansion, repr_timedelta LOG = logging.getLogger(__name__) @@ -1172,3 +1172,30 @@ def get_step_param_map(self) -> Dict: # pylint: disable=R0914 step_param_map[step_name_with_params]["restart_cmd"][token] = param_value return step_param_map + + def get_full_environment(self): + """ + Construct the full environment for the current context. + + This method starts with a copy of the current OS environment and + overlays any additional environment variables defined in the spec's + `environment` section. These variables are added both to the returned + dictionary and the live `os.environ` to support variable expansion. + + Returns: + dict: A dictionary representing the full environment with any + user-defined variables applied. + """ + # Start with the global environment + full_env = os.environ.copy() + + # If the environment from the spec has anything in it, + # read in the variables and save them to the shell environment + if self.environment: + yaml_vars = get_yaml_var(self.environment, "variables", {}) + for var_name, var_val in yaml_vars.items(): + full_env[str(var_name)] = str(var_val) + # For expandvars + os.environ[str(var_name)] = str(var_val) + + return full_env diff --git a/merlin/study/batch.py b/merlin/study/batch.py index 61ba4870..643a3853 100644 --- a/merlin/study/batch.py +++ b/merlin/study/batch.py @@ -22,7 +22,7 @@ LOG = logging.getLogger(__name__) -def batch_check_parallel(spec: MerlinSpec) -> bool: +def batch_check_parallel(batch: Dict) -> bool: """ Check for a parallel batch section in the provided MerlinSpec object. @@ -33,9 +33,8 @@ def batch_check_parallel(spec: MerlinSpec) -> bool: parallel processing is enabled. Args: - spec (spec.specification.MerlinSpec): An instance of the - [`MerlinSpec`][spec.specification.MerlinSpec] class that contains the - configuration details, including the batch section. + batch: The batch section from either the YAML `batch` block or the worker-specific + batch block. Returns: Returns True if the batch type is set to a value other than 'local', @@ -47,12 +46,6 @@ def batch_check_parallel(spec: MerlinSpec) -> bool: """ parallel = False - try: - batch = spec.batch - except AttributeError as exc: - LOG.error("The batch section is required in the specification file.") - raise exc - btype = get_yaml_var(batch, "type", "local") if btype != "local": parallel = True @@ -303,10 +296,9 @@ def get_flux_launch(parsed_batch: Dict) -> str: def batch_worker_launch( - spec: MerlinSpec, + batch: Dict, com: str, nodes: Union[str, int] = None, - batch: Dict = None, ) -> str: """ Create the worker launch command based on the batch configuration in the @@ -318,15 +310,11 @@ def batch_worker_launch( node specifications. Args: - spec (spec.specification.MerlinSpec): An instance of the - [`MerlinSpec`][spec.specification.MerlinSpec] class that contains the - configuration details, including the batch section. + batch: The batch section from either the YAML `batch` block or the worker-specific + batch block. com: The command to launch with the batch configuration. nodes: The number of nodes to use in the batch launch. If not specified, it will default to the value in the batch configuration. - batch: An optional batch override from the worker configuration. If not - provided, the function will attempt to retrieve the batch section from - the specification. Returns: The constructed worker launch command, ready to be executed. @@ -335,13 +323,6 @@ def batch_worker_launch( AttributeError: If the batch section is missing in the specification. TypeError: If the `nodes` parameter is of an invalid type. """ - if batch is None: - try: - batch = spec.batch - except AttributeError: - LOG.error("The batch section is required in the specification file.") - raise - parsed_batch = parse_batch_block(batch) # A jsrun submission cannot be run under a parent jsrun so diff --git a/merlin/study/celeryadapter.py b/merlin/study/celeryadapter.py index 2840f5fc..f650ee61 100644 --- a/merlin/study/celeryadapter.py +++ b/merlin/study/celeryadapter.py @@ -68,6 +68,7 @@ def run_celery(study: MerlinStudy, run_mode: str = None): queue_merlin_study(study, adapter_config) +# TODO should probably create a celery_utils.py file or something and store this function there def get_running_queues(celery_app_name: str, test_mode: bool = False) -> List[str]: """ Check for running Celery workers and retrieve their associated queues. diff --git a/merlin/workers/__init__.py b/merlin/workers/__init__.py new file mode 100644 index 00000000..11187717 --- /dev/null +++ b/merlin/workers/__init__.py @@ -0,0 +1,13 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" + +""" + +from merlin.workers.celery_worker import CeleryWorker + +__all__ = ["CeleryWorker"] \ No newline at end of file diff --git a/merlin/workers/celery_worker.py b/merlin/workers/celery_worker.py new file mode 100644 index 00000000..73aadb77 --- /dev/null +++ b/merlin/workers/celery_worker.py @@ -0,0 +1,202 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Implements a Celery-based MerlinWorker. + +This module defines the `CeleryWorker` class, which extends the abstract +`MerlinWorker` base class to implement worker launching and management using +Celery. Celery workers are responsible for processing tasks from specified queues +and can be launched either locally or through a batch system. +""" + +import logging +import os +import socket +import subprocess +import time +from typing import Dict + + +from merlin.exceptions import MerlinWorkerLaunchError +from merlin.study.batch import batch_check_parallel, batch_worker_launch +from merlin.study.celeryadapter import get_running_queues +from merlin.utils import check_machines +from merlin.workers.worker import MerlinWorker + +LOG = logging.getLogger(__name__) + + +class CeleryWorker(MerlinWorker): + """ + Concrete implementation of a single Celery-based Merlin worker. + + This class provides logic for validating configuration, constructing launch + commands, checking launch eligibility, and launching Celery workers that process + jobs from specific task queues. + + Attributes: + name (str): The name of the worker. + config (dict): Configuration settings for the worker. + env (dict): Environment variables used by the worker process. + args (str): Additional CLI arguments passed to Celery. + queues (List[str]): Queues the worker listens to. + batch (dict): Optional batch submission settings. + machines (List[str]): List of hostnames the worker is allowed to run on. + overlap (bool): Whether this worker can overlap queues with others. + + Methods: + _verify_args: Validate and adjust CLI args based on worker setup. + get_launch_command: Construct the Celery launch command. + should_launch: Determine whether the worker should be launched based on system state. + launch_worker: Launch the worker using subprocess. + get_metadata: Return identifying metadata about the worker. + """ + + def __init__( + self, + name: str, + config: Dict, + env: Dict[str, str] = None, + overlap: bool = False, + ): + """ + Constructor for Celery workers. + + Args: + name: The name of the worker. + config: A dictionary containing optional configuration settings for this worker including:\n + - `args`: A string of arguments to pass to the launch command + - `queues`: A list of task queues for this worker to watch + - `batch`: A dictionary of specific batch configuration settings to use for this worker + - `nodes`: The number of nodes to launch this worker on + - `machines`: A list of machines that this worker is allowed to run on + env: A dictionary of environment variables set by the user. + overlap: If True multiple workers can pull tasks from overlapping queues. + """ + super().__init__(name, config, env) + self.args = self.config.get("args", "") + self.queues = self.config.get("queues", ["[merlin]_merlin"]) + self.batch = self.config.get("batch", {}) + self.machines = self.config.get("machines", []) + self.overlap = overlap + + def _verify_args(self, disable_logs: bool = False) -> str: + """ + Validate and modify the CLI arguments for the Celery worker. + + Adds concurrency and logging-related flags if necessary, and ensures + the worker name is unique when overlap is allowed. + + Args: + disable_logs: If True, logging level will not be appended. + """ + if batch_check_parallel(self.batch): + if "--concurrency" not in self.args: + LOG.warning("Missing --concurrency in worker args for parallel tasks.") + if "--prefetch-multiplier" not in self.args: + LOG.warning("Missing --prefetch-multiplier in worker args for parallel tasks.") + if "fair" not in self.args: + LOG.warning("Missing -O fair in worker args for parallel tasks.") + + if "-n" not in self.args: + nhash = time.strftime("%Y%m%d-%H%M%S") if self.overlap else "" + self.args += f" -n {self.name}{nhash}.%%h" + + if not disable_logs and "-l" not in self.args: + self.args += f" -l {logging.getLevelName(LOG.getEffectiveLevel())}" + + def get_launch_command(self, override_args: str = "", disable_logs: bool = False) -> str: + """ + Construct the shell command to launch this Celery worker. + + Args: + override_args: If provided, these arguments will replace the default `args`. + disable_logs: If True, logging level will not be added to the command. + + Returns: + A shell command string suitable for subprocess execution. + """ + # Override existing arguments if necessary + if override_args != "": + self.args = override_args + + # Validate args + self._verify_args(disable_logs=disable_logs) + + # Construct the launch command + celery_cmd = f"celery -A merlin worker {self.args} -Q {self.queues}" + nodes = self.batch.get("nodes", None) + launch_cmd = batch_worker_launch(self.batch, celery_cmd, nodes=nodes) + return os.path.expandvars(launch_cmd) + + def should_launch(self) -> bool: + """ + Determine whether this worker should be launched. + + Performs checks on allowed machines and queue overlap (if applicable). + + Returns: + True if the worker should be launched, False otherwise. + """ + machines = self.config.get("machines", None) + queues = self.config.get("queues", ["[merlin]_merlin"]) + + if machines: + if not check_machines(machines): + LOG.error( + f"The following machines were provided for worker '{self.name}': {machines}. However, the current machine '{socket.gethostname()}' is not in this list." + ) + return False + + output_path = self.env.get("OUTPUT_PATH") + if output_path and not os.path.exists(output_path): + LOG.error(f"{output_path} not accessible on host {socket.gethostname()}") + return False + + if not self.overlap: + running_queues = get_running_queues("merlin") + for queue in queues: + if queue in running_queues: + LOG.warning(f"Queue {queue} is already being processed by another worker.") + return False + + return True + + def launch_worker(self, override_args: str = "", disable_logs: bool = False): + """ + Launch the worker as a subprocess using the constructed launch command. + + Args: + override_args: Optional CLI arguments to override the default worker args. + disable_logs: If True, suppresses automatic logging level injection. + + Raises: + MerlinWorkerLaunchError: If the worker fails to launch. + """ + if self.should_launch(): + launch_cmd = self.get_launch_command(override_args=override_args, disable_logs=disable_logs) + try: + subprocess.Popen(launch_cmd, env=self.env, shell=True, universal_newlines=True) # pylint: disable=R1732 + LOG.debug(f"Launched worker '{self.name}' with command: {launch_cmd}.") + except Exception as e: # pylint: disable=C0103 + LOG.error(f"Cannot start celery workers, {e}") + raise MerlinWorkerLaunchError + + def get_metadata(self) -> Dict: + """ + Return metadata about this worker instance. + + Returns: + A dictionary containing key details about this worker. + """ + return { + "name": self.name, + "queues": self.queues, + "args": self.args, + "machines": self.machines, + "batch": self.batch, + } diff --git a/merlin/workers/worker.py b/merlin/workers/worker.py new file mode 100644 index 00000000..954c7501 --- /dev/null +++ b/merlin/workers/worker.py @@ -0,0 +1,73 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Defines an abstract base class for a single Merlin worker. +""" + +import os +from abc import ABC, abstractmethod +from typing import Dict + + +class MerlinWorker(ABC): + """ + Abstract base class representing a single task server worker. + + This class defines the required interface for constructing and launching + an individual worker based on its configuration. + + Attributes: + name: The name of the worker. + config: The dictionary configuration for the worker. + env: A dictionary representing the full environment for the current context. + + Methods: + get_launch_command: Build the shell command to launch the worker. + launch_worker: Launch the worker process. + get_metadata: Return identifying metadata about the worker. + """ + + def __init__(self, name: str, config: Dict, env: Dict[str, str] = None): + """ + Initialize a `MerlinWorker` instance. + + Args: + name: The name of the worker. + config: A dictionary containing the worker configuration. + env: Optional dictionary of environment variables to use; if not provided, + a copy of the current OS environment is used. + """ + self.name = name + self.config = config + self.env = env or os.environ.copy() + + @abstractmethod + def get_launch_command(self, override_args: str = "") -> str: + """ + Build the command to launch this worker. + + Args: + override_args: CLI arguments to override the default ones from the spec. + + Returns: + A shell command string. + """ + + @abstractmethod + def launch_worker(self): + """ + Launch this worker. + """ + + @abstractmethod + def get_metadata(self) -> Dict: + """ + Return a dictionary of metadata about this worker (for logging/debugging). + + Returns: + A metadata dictionary (e.g., name, queues, machines). + """ From 032405c05a6820ba83ac8d3bca4a821115b97385 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 17:51:47 -0700 Subject: [PATCH 39/91] implement worker-handler related classes --- merlin/workers/handlers/__init__.py | 28 +++++++ merlin/workers/handlers/celery_handler.py | 75 +++++++++++++++++++ merlin/workers/handlers/handler_factory.py | 87 ++++++++++++++++++++++ merlin/workers/handlers/worker_handler.py | 66 ++++++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 merlin/workers/handlers/__init__.py create mode 100644 merlin/workers/handlers/celery_handler.py create mode 100644 merlin/workers/handlers/handler_factory.py create mode 100644 merlin/workers/handlers/worker_handler.py diff --git a/merlin/workers/handlers/__init__.py b/merlin/workers/handlers/__init__.py new file mode 100644 index 00000000..b95145b8 --- /dev/null +++ b/merlin/workers/handlers/__init__.py @@ -0,0 +1,28 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Worker handler interface and implementations for Merlin task servers. + +The `handlers` package defines the extensible framework for managing task server +workers in Merlin. It includes an abstract base interface, a concrete implementation +for Celery, and a factory for dynamic registration and instantiation of worker handlers. + +This design allows Merlin to support multiple task server backends through a consistent +interface while enabling future integration with additional systems such as Kafka. + +Modules: + handler_factory.py: Factory for registering and instantiating Merlin worker + handler implementations. + worker_handler.py: Abstract base class that defines the interface for all Merlin + worker handlers. + celery_handler.py: Celery-specific implementation of the worker handler interface. +""" + + +from merlin.workers.handlers.celery_handler import CeleryWorkerHandler + +__all__ = ["CeleryWorkerHandler"] diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py new file mode 100644 index 00000000..25df733b --- /dev/null +++ b/merlin/workers/handlers/celery_handler.py @@ -0,0 +1,75 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Provides a concrete implementation of the +[`MerlinWorkerHandler`][workers.handlers.worker_handler.MerlinWorkerHandler] for Celery. + +This module defines the `CeleryWorkerHandler` class, which is responsible for launching, +stopping, and querying Celery-based worker processes. It supports additional options +such as echoing launch commands, overriding default worker arguments, and disabling logs. +""" + +import logging +from typing import List + +from merlin.workers import CeleryWorker +from merlin.workers.handlers.worker_handler import MerlinWorkerHandler + + +LOG = logging.getLogger("merlin") + + +class CeleryWorkerHandler(MerlinWorkerHandler): + """ + Worker handler for launching and managing Celery-based Merlin workers. + + This class implements the abstract methods defined in + [`MerlinWorkerHandler`][workers.handlers.worker_handler.MerlinWorkerHandler] to provide + Celery-specific behavior, including launching workers with optional command-line overrides, + stopping workers, and querying their status. + + Methods: + launch_workers: Launch or echo Celery workers with optional arguments. + stop_workers: Attempt to stop active Celery workers. + query_workers: Return a basic summary of Celery worker status. + """ + + def launch_workers(self, workers: List[CeleryWorker], **kwargs): + """ + Launch or echo Celery workers with optional override behavior. + + Args: + workers (List[CeleryWorker]): Workers to launch. + **kwargs: + - echo_only (bool): If True, print the launch command instead of running it. + - override_args (str): Arguments to override default worker args. + - disable_logs (bool): If True, disables logging during worker launch. + """ + echo_only = kwargs.get("echo_only", False) + override_args = kwargs.get("override_args", "") + disable_logs = kwargs.get("disable_logs", False) + + # Launch the workers or echo out the command that will be used to launch the workers + for worker in workers: + if echo_only: + LOG.debug(f"Not launching worker '{worker.name}', just echoing command.") + launch_cmd = worker.get_launch_command(override_args=override_args, disable_logs=disable_logs) + print(launch_cmd) + else: + LOG.debug(f"Launching worker '{worker.name}'.") + worker.launch_worker(override_args=override_args, disable_logs=disable_logs) + + def stop_workers(self): + """ + Attempt to stop Celery workers. + """ + + def query_workers(self): + """ + Query the status of Celery workers. + """ + \ No newline at end of file diff --git a/merlin/workers/handlers/handler_factory.py b/merlin/workers/handlers/handler_factory.py new file mode 100644 index 00000000..a4c125ff --- /dev/null +++ b/merlin/workers/handlers/handler_factory.py @@ -0,0 +1,87 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Factory for registering and instantiating Merlin worker handler implementations. + +This module defines the `WorkerHandlerFactory`, which manages the lifecycle and registration +of supported task server worker handlers (e.g., Celery). It extends `MerlinBaseFactory` to +provide a pluggable architecture for loading handlers via entry points or direct registration. + +The factory enforces type safety by validating that all registered components inherit from +`MerlinWorkerHandler`. It also provides aliasing support and a standard mechanism for plugin +discovery and instantiation. +""" + +from typing import Any, Type + +from merlin.abstracts import MerlinBaseFactory +from merlin.exceptions import MerlinWorkerHandlerNotSupportedError +from merlin.workers.handlers import CeleryWorkerHandler +from merlin.workers.handlers.worker_handler import MerlinWorkerHandler + + +class WorkerHandlerFactory(MerlinBaseFactory): + """ + Factory class for managing and instantiating supported Merlin worker handlers. + + This subclass of `MerlinBaseFactory` handles registration, validation, + and instantiation of worker handlers (e.g., Celery, Kafka). + + Attributes: + _registry (Dict[str, MerlinWorkerHandler]): Maps canonical handler names to handler classes. + _aliases (Dict[str, str]): Maps legacy or alternate names to canonical handler names. + + Methods: + register: Register a new handler class and optional aliases. + list_available: Return a list of supported handler names. + create: Instantiate a handler class by name or alias. + get_component_info: Return metadata about a registered handler. + """ + + def _register_builtins(self): + """ + Register built-in worker handler implementations. + """ + self.register("celery", CeleryWorkerHandler) + + def _validate_component(self, component_class: Any): + """ + Ensure registered component is a subclass of MerlinWorkerHandler. + + Args: + component_class: The class to validate. + + Raises: + TypeError: If the component does not subclass MerlinWorkerHandler. + """ + if not issubclass(component_class, MerlinWorkerHandler): + raise TypeError(f"{component_class} must inherit from MerlinWorkerHandler") + + def _entry_point_group(self) -> str: + """ + Entry point group used for discovering worker handler plugins. + + Returns: + The entry point namespace for Merlin worker handler plugins. + """ + return "merlin.workers.handlers" + + def _get_component_error_class(self) -> Type[Exception]: + """ + Return the exception type to raise for unsupported components. + + This method is used by the base factory logic to determine which + exception to raise when a requested component is not found or fails + to initialize. + + Returns: + The exception class to raise. + """ + return MerlinWorkerHandlerNotSupportedError + + +worker_handler_factory = WorkerHandlerFactory() diff --git a/merlin/workers/handlers/worker_handler.py b/merlin/workers/handlers/worker_handler.py new file mode 100644 index 00000000..1ae6e2a0 --- /dev/null +++ b/merlin/workers/handlers/worker_handler.py @@ -0,0 +1,66 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Defines an abstract base class for worker handlers in the Merlin workflow framework. + +Worker handlers are responsible for launching, stopping, and querying the status +of task server workers (e.g., Celery workers). This interface allows support +for different task servers to be plugged in with consistent behavior. +""" + +from abc import ABC, abstractmethod +from typing import Any, List + +from merlin.workers.worker import MerlinWorker + + +class MerlinWorkerHandler(ABC): + """ + Abstract base class for launching and managing Merlin worker processes. + + Subclasses must implement the methods to launch, stop, and query workers + using a particular task server (e.g., Celery, Kafka, etc.). + + Methods: + launch_workers: Launch a list of MerlinWorker instances with optional configuration. + stop_workers: Stop running worker processes managed by this handler. + query_workers: Query the status of running workers and return summary information. + """ + + def __init__(self): + """Initialize the worker handler.""" + + @abstractmethod + def launch_workers(self, workers: List[MerlinWorker], **kwargs): + """ + Launch a list of worker instances. + + Args: + workers (List[MerlinWorker]): The list of workers to launch. + **kwargs: Optional keyword arguments passed to subclass-specific logic. + """ + raise NotImplementedError("Subclasses of `MerlinWorkerHandler` must implement a `launch_workers` method.") + + @abstractmethod + def stop_workers(self): + """ + Stop worker processes. + + This method should terminate any active worker sessions that were previously launched. + """ + raise NotImplementedError("Subclasses of `MerlinWorkerHandler` must implement a `stop_workers` method.") + + @abstractmethod + def query_workers(self) -> Any: + """ + Query the status of all currently running workers. + + Returns: + Subclasses should return an appropriate data structure summarizing + the current state of managed workers (e.g., dict, list, string). + """ + raise NotImplementedError("Subclasses of `MerlinWorkerHandler` must implement a `query_workers` method.") From cc327aa5fc9509053ddc65e34424d4ba35b364bb Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 17:52:27 -0700 Subject: [PATCH 40/91] add worker factory class and small cleanup to the rest of the worker files --- merlin/workers/__init__.py | 22 ++++++++ merlin/workers/celery_worker.py | 18 ++++--- merlin/workers/worker.py | 10 +++- merlin/workers/worker_factory.py | 88 ++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 merlin/workers/worker_factory.py diff --git a/merlin/workers/__init__.py b/merlin/workers/__init__.py index 11187717..784eba2a 100644 --- a/merlin/workers/__init__.py +++ b/merlin/workers/__init__.py @@ -5,7 +5,29 @@ ############################################################################## """ +Worker framework for managing task execution in Merlin. +The `workers` package defines the core abstractions and implementations for launching +and managing task server workers in the Merlin workflow framework. It includes an +extensible system for defining worker behavior, instantiating worker instances, and +handling task server-specific logic (e.g., Celery). + +This package supports a plugin-based architecture through factories, allowing new +task server backends to be added seamlessly via Python entry points. + +Subpackages: + - `handlers/`: Defines the interface and implementations for worker handler classes + responsible for launching and managing groups of workers. + +Modules: + worker.py: Defines the `MerlinWorker` abstract base class, which represents a single + task server worker and provides a common interface for launching and + configuring worker instances. + celery_worker.py: Implements `CeleryWorker`, a concrete subclass of `MerlinWorker` that uses + Celery to process tasks from configured queues. Supports local and batch launch modes. + worker_factory.py: Defines the `WorkerFactory`, which manages the registration, validation, + and instantiation of individual worker implementations such as `CeleryWorker`. + Supports plugin discovery via entry points. """ from merlin.workers.celery_worker import CeleryWorker diff --git a/merlin/workers/celery_worker.py b/merlin/workers/celery_worker.py index 73aadb77..def1ddb7 100644 --- a/merlin/workers/celery_worker.py +++ b/merlin/workers/celery_worker.py @@ -20,14 +20,13 @@ import time from typing import Dict - +from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.exceptions import MerlinWorkerLaunchError from merlin.study.batch import batch_check_parallel, batch_worker_launch -from merlin.study.celeryadapter import get_running_queues from merlin.utils import check_machines from merlin.workers.worker import MerlinWorker -LOG = logging.getLogger(__name__) +LOG = logging.getLogger("merlin") class CeleryWorker(MerlinWorker): @@ -66,11 +65,13 @@ def __init__( """ Constructor for Celery workers. + Sets up attributes used throughout this worker object and saves this worker to the database. + Args: name: The name of the worker. config: A dictionary containing optional configuration settings for this worker including:\n - `args`: A string of arguments to pass to the launch command - - `queues`: A list of task queues for this worker to watch + - `queues`: A set of task queues for this worker to watch - `batch`: A dictionary of specific batch configuration settings to use for this worker - `nodes`: The number of nodes to launch this worker on - `machines`: A list of machines that this worker is allowed to run on @@ -79,11 +80,15 @@ def __init__( """ super().__init__(name, config, env) self.args = self.config.get("args", "") - self.queues = self.config.get("queues", ["[merlin]_merlin"]) + self.queues = self.config.get("queues", {"[merlin]_merlin"}) self.batch = self.config.get("batch", {}) self.machines = self.config.get("machines", []) self.overlap = overlap + # Add this worker to the database + merlin_db = MerlinDatabase() + merlin_db.create("logical_worker", self.name, self.queues) + def _verify_args(self, disable_logs: bool = False) -> str: """ Validate and modify the CLI arguments for the Celery worker. @@ -128,7 +133,7 @@ def get_launch_command(self, override_args: str = "", disable_logs: bool = False self._verify_args(disable_logs=disable_logs) # Construct the launch command - celery_cmd = f"celery -A merlin worker {self.args} -Q {self.queues}" + celery_cmd = f"celery -A merlin worker {self.args} -Q {','.join(self.queues)}" nodes = self.batch.get("nodes", None) launch_cmd = batch_worker_launch(self.batch, celery_cmd, nodes=nodes) return os.path.expandvars(launch_cmd) @@ -158,6 +163,7 @@ def should_launch(self) -> bool: return False if not self.overlap: + from merlin.study.celeryadapter import get_running_queues running_queues = get_running_queues("merlin") for queue in queues: if queue in running_queues: diff --git a/merlin/workers/worker.py b/merlin/workers/worker.py index 954c7501..7ba6c617 100644 --- a/merlin/workers/worker.py +++ b/merlin/workers/worker.py @@ -5,7 +5,15 @@ ############################################################################## """ -Defines an abstract base class for a single Merlin worker. +Defines an abstract base class for a single Merlin worker instance. + +This module provides the `MerlinWorker` interface, which standardizes how individual +task server workers are defined, configured, and launched in the Merlin framework. +Each concrete implementation (e.g., for Celery or other task servers) must provide +logic for constructing the launch command, starting the process, and exposing worker metadata. + +This abstraction allows Merlin to support multiple task execution backends while maintaining +a consistent interface for launching and managing worker processes. """ import os diff --git a/merlin/workers/worker_factory.py b/merlin/workers/worker_factory.py new file mode 100644 index 00000000..fd86f83e --- /dev/null +++ b/merlin/workers/worker_factory.py @@ -0,0 +1,88 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Factory for registering and instantiating individual Merlin worker implementations. + +This module defines the `WorkerFactory`, a subclass of +[`MerlinBaseFactory`][abstracts.factory.MerlinBaseFactory], which manages +the registration, validation, and creation of concrete worker classes such as +[`CeleryWorker`][workers.celery_worker.CeleryWorker]. It supports plugin-based discovery +via Python entry points, enabling extensibility for other task server backends (e.g., Kafka). + +The factory ensures that all registered components conform to the `MerlinWorker` interface +and provides useful utilities such as aliasing and error handling for unsupported components. +""" + +from typing import Any, Type + +from merlin.abstracts import MerlinBaseFactory +from merlin.exceptions import MerlinWorkerNotSupportedError +from merlin.workers import CeleryWorker +from merlin.workers.worker import MerlinWorker + + +class WorkerFactory(MerlinBaseFactory): + """ + Factory class for managing and instantiating supported Merlin workers. + + This subclass of `MerlinBaseFactory` handles registration, validation, + and instantiation of workers (e.g., Celery, Kafka). + + Attributes: + _registry (Dict[str, MerlinWorker]): Maps canonical worker names to worker classes. + _aliases (Dict[str, str]): Maps legacy or alternate names to canonical worker names. + + Methods: + register: Register a new worker class and optional aliases. + list_available: Return a list of supported worker names. + create: Instantiate a worker class by name or alias. + get_component_info: Return metadata about a registered worker. + """ + + def _register_builtins(self): + """ + Register built-in worker implementations. + """ + self.register("celery", CeleryWorker) + + def _validate_component(self, component_class: Any): + """ + Ensure registered component is a subclass of MerlinWorker. + + Args: + component_class: The class to validate. + + Raises: + TypeError: If the component does not subclass MerlinWorker. + """ + if not issubclass(component_class, MerlinWorker): + raise TypeError(f"{component_class} must inherit from MerlinWorker") + + def _entry_point_group(self) -> str: + """ + Entry point group used for discovering worker plugins. + + Returns: + The entry point namespace for Merlin worker plugins. + """ + return "merlin.workers" + + def _get_component_error_class(self) -> Type[Exception]: + """ + Return the exception type to raise for unsupported components. + + This method is used by the base factory logic to determine which + exception to raise when a requested component is not found or fails + to initialize. + + Returns: + The exception class to raise. + """ + return MerlinWorkerNotSupportedError + + +worker_factory = WorkerFactory() From 15a33869f5bbecce9f9a288a24d5cc1bc5d82a69 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 17:53:59 -0700 Subject: [PATCH 41/91] remove functions that are now in the new worker files --- merlin/router.py | 41 ---- merlin/study/celeryadapter.py | 378 ---------------------------------- 2 files changed, 419 deletions(-) diff --git a/merlin/router.py b/merlin/router.py index 51ad03a2..d3ea0b91 100644 --- a/merlin/router.py +++ b/merlin/router.py @@ -28,7 +28,6 @@ query_celery_queues, query_celery_workers, run_celery, - start_celery_workers, stop_celery_workers, ) from merlin.study.study import MerlinStudy @@ -62,46 +61,6 @@ def run_task_server(study: MerlinStudy, run_mode: str = None): LOG.error("Celery is not specified as the task server!") -def launch_workers( - spec: MerlinSpec, - steps: List[str], - worker_args: str = "", - disable_logs: bool = False, - just_return_command: bool = False, -) -> str: - """ - Launches workers for the specified study based on the provided - specification and steps. - - This function checks if Celery is configured as the task server - and initiates the specified workers accordingly. It provides options - for additional worker arguments, logging control, and command-only - execution without launching the workers. - - Args: - spec (spec.specification.MerlinSpec): Specification details - necessary for launching the workers. - steps: The specific steps in the specification that the workers - will be associated with. - worker_args: Additional arguments to be passed to the workers. - Defaults to an empty string. - disable_logs: Flag to disable logging during worker execution. - Defaults to False. - just_return_command: If True, the function will not execute the - command but will return it instead. Defaults to False. - - Returns: - A string of the worker launch command(s). - """ - if spec.merlin["resources"]["task_server"] == "celery": # pylint: disable=R1705 - # Start workers - cproc = start_celery_workers(spec, steps, worker_args, disable_logs, just_return_command) - return cproc - else: - LOG.error("Celery is not specified as the task server!") - return "No workers started" - - def purge_tasks(task_server: str, spec: MerlinSpec, force: bool, steps: List[str]) -> int: """ Purges all tasks from the specified task server. diff --git a/merlin/study/celeryadapter.py b/merlin/study/celeryadapter.py index f650ee61..b3543def 100644 --- a/merlin/study/celeryadapter.py +++ b/merlin/study/celeryadapter.py @@ -621,384 +621,6 @@ def check_celery_workers_processing(queues_in_spec: List[str], app: Celery) -> b return False -def _get_workers_to_start(spec: MerlinSpec, steps: List[str]) -> Set[str]: - """ - Determine the set of workers to start based on the specified steps. - - This helper function retrieves a mapping of steps to their corresponding workers - from a [`MerlinSpec`][spec.specification.MerlinSpec] object and returns a unique - set of workers that should be started for the provided list of steps. If a step - is not found in the mapping, a warning is logged. - - Args: - spec (spec.specification.MerlinSpec): An instance of the - [`MerlinSpec`][spec.specification.MerlinSpec] class that contains the - mapping of steps to workers. - steps: A list of steps for which workers need to be started. - - Returns: - A set of unique workers to be started based on the specified steps. - """ - workers_to_start = [] - step_worker_map = spec.get_step_worker_map() - for step in steps: - try: - workers_to_start.extend(step_worker_map[step]) - except KeyError: - LOG.warning(f"Cannot start workers for step: {step}. This step was not found.") - - workers_to_start = set(workers_to_start) - LOG.debug(f"workers_to_start: {workers_to_start}") - - return workers_to_start - - -def _create_kwargs(spec: MerlinSpec) -> Tuple[Dict[str, str], Dict]: - """ - Construct the keyword arguments for launching a worker process. - - This helper function creates a dictionary of keyword arguments that will be - passed to `subprocess.Popen` when launching a worker. It retrieves the - environment variables defined in a [`MerlinSpec`][spec.specification.MerlinSpec] - object and updates the shell environment accordingly. - - Args: - spec (spec.specification.MerlinSpec): An instance of the MerlinSpec class - that contains environment specifications. - - Returns: - A tuple containing: - - A dictionary of keyword arguments for `subprocess.Popen`, including - the updated environment. - - A dictionary of variables defined in the spec, or None if no variables - were defined. - """ - # Get the environment from the spec and the shell - spec_env = spec.environment - shell_env = os.environ.copy() - yaml_vars = None - - # If the environment from the spec has anything in it, - # read in the variables and save them to the shell environment - if spec_env: - yaml_vars = get_yaml_var(spec_env, "variables", {}) - for var_name, var_val in yaml_vars.items(): - shell_env[str(var_name)] = str(var_val) - # For expandvars - os.environ[str(var_name)] = str(var_val) - - # Create the kwargs dict - kwargs = {"env": shell_env, "shell": True, "universal_newlines": True} - return kwargs, yaml_vars - - -def _get_steps_to_start(wsteps: List[str], steps: List[str], steps_provided: bool) -> List[str]: - """ - Identify the steps for which workers should be started. - - This function determines which steps to initiate based on the steps - associated with a worker and the user-provided steps. If specific steps - are provided by the user, only those steps that match the worker's steps - will be included. If no specific steps are provided, all worker-associated - steps will be returned. - - Args: - wsteps: A list of steps that are associated with a worker. - steps: A list of steps specified by the user to start workers for. - steps_provided: A boolean indicating whether the user provided - specific steps to start. - - Returns: - A list of steps for which workers should be started. - """ - steps_to_start = [] - if steps_provided: - for wstep in wsteps: - if wstep in steps: - steps_to_start.append(wstep) - else: - steps_to_start.extend(wsteps) - - return steps_to_start - - -def start_celery_workers( - spec: MerlinSpec, steps: List[str], celery_args: str, disable_logs: bool, just_return_command: bool -) -> str: # pylint: disable=R0914,R0915 - """ - Start Celery workers based on the provided specifications and steps. - - This function initializes and starts Celery workers for the specified steps - in the given [`MerlinSpec`][spec.specification.MerlinSpec]. It constructs - the necessary command-line arguments and handles the launching of subprocesses - for each worker. If the `just_return_command` flag is set to `True`, it will - return the command(s) to start the workers without actually launching them. - - Args: - spec (spec.specification.MerlinSpec): A [`MerlinSpec`][spec.specification.MerlinSpec] - object representing the study configuration. - steps: A list of steps for which to start workers. - celery_args: A string of additional arguments to pass to the Celery workers. - disable_logs: A flag to disable logging for the Celery workers. - just_return_command: If `True`, returns the launch command(s) without starting the workers. - - Returns: - A string containing all the worker launch commands. - - Side Effects: - - Starts subprocesses for each worker that is launched, so long as `just_return_command` - is not True. - - Example: - Below is an example configuration for Merlin workers: - - ```yaml - merlin: - resources: - task_server: celery - overlap: False - workers: - simworkers: - args: -O fair --prefetch-multiplier 1 -E -l info --concurrency 4 - steps: [run, data] - nodes: 1 - machine: [hostA, hostB] - ``` - """ - if not just_return_command: - LOG.info("Starting workers") - - overlap = spec.merlin["resources"]["overlap"] - workers = spec.merlin["resources"]["workers"] - - # Build kwargs dict for subprocess.Popen to use when we launch the worker - kwargs, yenv = _create_kwargs(spec) - - worker_list = [] - local_queues = [] - - # Get the workers we need to start if we're only starting certain steps - steps_provided = False if "all" in steps else True # pylint: disable=R1719 - if steps_provided: - workers_to_start = _get_workers_to_start(spec, steps) - - for worker_name, worker_val in workers.items(): - # Only triggered if --steps flag provided - if steps_provided and worker_name not in workers_to_start: - continue - - skip_loop_step: bool = examine_and_log_machines(worker_val, yenv) - if skip_loop_step: - continue - - worker_args = get_yaml_var(worker_val, "args", celery_args) - with suppress(KeyError): - if worker_val["args"] is None: - worker_args = "" - - worker_nodes = get_yaml_var(worker_val, "nodes", None) - worker_batch = get_yaml_var(worker_val, "batch", None) - - # Get the correct steps to start workers for - wsteps = get_yaml_var(worker_val, "steps", steps) - steps_to_start = _get_steps_to_start(wsteps, steps, steps_provided) - queues = spec.make_queue_string(steps_to_start) - - # Check for missing arguments - worker_args = verify_args(spec, worker_args, worker_name, overlap, disable_logs=disable_logs) - - # Add a per worker log file (debug) - if LOG.isEnabledFor(logging.DEBUG): - LOG.debug("Redirecting worker output to individual log files") - worker_args += " --logfile %p.%i" - - # Get the celery command & add it to the batch launch command - celery_com = get_celery_cmd(queues, worker_args=worker_args, just_return_command=True) - celery_cmd = os.path.expandvars(celery_com) - worker_cmd = batch_worker_launch(spec, celery_cmd, nodes=worker_nodes, batch=worker_batch) - worker_cmd = os.path.expandvars(worker_cmd) - - LOG.debug(f"worker cmd={worker_cmd}") - - if just_return_command: - worker_list = "" - print(worker_cmd) - continue - - # Get the running queues - running_queues = [] - running_queues.extend(local_queues) - queues = queues.split(",") - if not overlap: - running_queues.extend(get_running_queues("merlin")) - # Cache the queues from this worker to use to test - # for existing queues in any subsequent workers. - # If overlap is True, then do not check the local queues. - # This will allow multiple workers to pull from the same - # queue. - local_queues.extend(queues) - - # Search for already existing queues and log a warning if we try to start one that already exists - found = [] - for q in queues: # pylint: disable=C0103 - if q in running_queues: - found.append(q) - if found: - LOG.warning( - f"A celery worker named '{worker_name}' is already configured/running for queue(s) = {' '.join(found)}" - ) - continue - - # Start the worker - launch_celery_worker(worker_cmd, worker_list, kwargs) - - # Return a string with the worker commands for logging - return str(worker_list) - - -def examine_and_log_machines(worker_val: Dict, yenv: Dict[str, str]) -> bool: - """ - Determine if a worker should be skipped based on machine availability and log any errors. - - This function checks the specified machines for a worker and determines - whether the worker can be started. If the machines are not available, - it logs an error message regarding the output path for the Celery worker. - If the environment variables (`yenv`) are not provided or do not specify - an output path, a warning is logged. - - Args: - worker_val: A dictionary containing worker configuration, including - the list of machines associated with the worker. - yenv: A dictionary of environment variables that may include the - output path for logging. - - Returns: - Returns `True` if the worker should be skipped (i.e., machines are - unavailable), otherwise returns `False`. - """ - worker_machines = get_yaml_var(worker_val, "machines", None) - if worker_machines: - LOG.debug(f"check machines = {check_machines(worker_machines)}") - if not check_machines(worker_machines): - return True - - if yenv: - output_path = get_yaml_var(yenv, "OUTPUT_PATH", None) - if output_path and not os.path.exists(output_path): - hostname = socket.gethostname() - LOG.error(f"The output path, {output_path}, is not accessible on this host, {hostname}") - else: - LOG.warning( - "The env:variables section does not have an OUTPUT_PATH specified, multi-machine checks cannot be performed." - ) - return False - return False - - -def verify_args(spec: MerlinSpec, worker_args: str, worker_name: str, overlap: bool, disable_logs: bool = False) -> str: - """ - Validate and enhance the arguments passed to a Celery worker for completeness. - - This function checks the provided worker arguments to ensure that they include - recommended settings for running parallel tasks. It adds default values for - concurrency, prefetch multiplier, and logging level if they are not specified. - Additionally, it generates a unique worker name based on the current time if - the `-n` argument is not provided. - - Args: - spec (spec.specification.MerlinSpec): A [`MerlinSpec`][spec.specification.MerlinSpec] - object containing the study configuration. - worker_args: A string of arguments passed to the worker that may need validation. - worker_name: The name of the worker, used for generating a unique worker identifier. - overlap: A flag indicating whether multiple workers can overlap in their queue processing. - disable_logs: A flag to disable logging configuration for the worker. - - Returns: - The validated and potentially modified worker arguments string. - """ - parallel = batch_check_parallel(spec) - if parallel: - if "--concurrency" not in worker_args: - LOG.warning("The worker arg --concurrency [1-4] is recommended when running parallel tasks") - if "--prefetch-multiplier" not in worker_args: - LOG.warning("The worker arg --prefetch-multiplier 1 is recommended when running parallel tasks") - if "fair" not in worker_args: - LOG.warning("The worker arg -O fair is recommended when running parallel tasks") - - if "-n" not in worker_args: - nhash = "" - if overlap: - nhash = time.strftime("%Y%m%d-%H%M%S") - # TODO: Once flux fixes their bug, change this back to %h - # %h in Celery is short for hostname including domain name - worker_args += f" -n {worker_name}{nhash}.%%h" - - if not disable_logs and "-l" not in worker_args: - worker_args += f" -l {logging.getLevelName(LOG.getEffectiveLevel())}" - - return worker_args - - -def launch_celery_worker(worker_cmd: str, worker_list: List[str], kwargs: Dict): - """ - Launch a Celery worker using the specified command and parameters. - - This function executes the provided Celery command to start a worker as a - subprocess. It appends the command to the given list of worker commands - for tracking purposes. If the worker fails to start, an error is logged. - - Args: - worker_cmd: The command string used to launch the Celery worker. - worker_list: A list that will be updated to include the launched - worker command for tracking active workers. - kwargs: A dictionary of additional keyword arguments to pass to - `subprocess.Popen`, allowing for customization of the subprocess - behavior. - - Raises: - Exception: If the worker fails to start, an error is logged, and the - exception is re-raised. - - Side Effects: - - Launches a Celery worker process in the background. - - Modifies the `worker_list` by appending the launched worker command. - """ - try: - subprocess.Popen(worker_cmd, **kwargs) # pylint: disable=R1732 - worker_list.append(worker_cmd) - except Exception as e: # pylint: disable=C0103 - LOG.error(f"Cannot start celery workers, {e}") - raise - - -def get_celery_cmd(queue_names: str, worker_args: str = "", just_return_command: bool = False) -> str: - """ - Construct the command to launch Celery workers for the specified queues. - - This function generates a command string that can be used to start Celery - workers associated with the provided queue names. It allows for optional - worker arguments to be included and can return the command without executing it. - - Args: - queue_names: A comma-separated string of the queue name(s) to which the worker - will be associated. - worker_args: Additional command-line arguments for the Celery worker. - just_return_command: If True, the function will return the constructed command - without executing it. - - Returns: - The constructed command string for launching the Celery worker. If - `just_return_command` is True, returns the command; otherwise, returns an - empty string. - """ - worker_command = " ".join(["celery -A merlin worker", worker_args, "-Q", queue_names]) - if just_return_command: - return worker_command - # If we get down here, this only runs celery locally the user would need to - # add all of the flux config themselves. - return "" - - def purge_celery_tasks(queues: str, force: bool) -> int: """ Purge Celery tasks from the specified queues. From c4cbe13f86825962ab58ebbf502a60f7b9014eb9 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 17:54:22 -0700 Subject: [PATCH 42/91] link the new worker classes to the actual launching of workers --- merlin/cli/commands/run_workers.py | 102 +++-------------------------- merlin/exceptions/__init__.py | 18 +++++ merlin/spec/specification.py | 84 ++++++++++++++++++++++++ merlin/study/batch.py | 1 - 4 files changed, 112 insertions(+), 93 deletions(-) diff --git a/merlin/cli/commands/run_workers.py b/merlin/cli/commands/run_workers.py index cdc35385..3cf7cebc 100644 --- a/merlin/cli/commands/run_workers.py +++ b/merlin/cli/commands/run_workers.py @@ -17,15 +17,12 @@ import logging from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace -from typing import List, Set, Union from merlin.ascii_art import banner_small from merlin.cli.commands.command_entry_point import CommandEntryPoint from merlin.cli.utils import get_merlin_spec_with_override from merlin.config.configfile import initialize_config -from merlin.db_scripts.merlin_db import MerlinDatabase -from merlin.spec.specification import MerlinSpec -from merlin.workers.celery_worker import CeleryWorker +from merlin.workers.handlers.handler_factory import worker_handler_factory LOG = logging.getLogger("merlin") @@ -95,75 +92,6 @@ def add_parser(self, subparsers: ArgumentParser): "in your workers' args section will overwrite this flag for that worker.", ) - # TODO when we move the queues setting to within the worker then this will no longer be necessary - def _get_workers_to_start(self, spec: MerlinSpec, steps: Union[List[str], None]) -> Set[str]: - """ - Determine the set of workers to start based on the specified steps (if any) - - This helper function retrieves a mapping of steps to their corresponding workers - from a [`MerlinSpec`][spec.specification.MerlinSpec] object and returns a unique - set of workers that should be started for the provided list of steps. If a step - is not found in the mapping, a warning is logged. - - Args: - spec (spec.specification.MerlinSpec): An instance of the - [`MerlinSpec`][spec.specification.MerlinSpec] class that contains the - mapping of steps to workers. - steps: A list of steps for which workers need to be started or None if the user - didn't provide specific steps. - - Returns: - A set of unique workers to be started based on the specified steps. - """ - steps_provided = False if "all" in steps else True - - if steps_provided: - workers_to_start = [] - step_worker_map = spec.get_step_worker_map() - for step in steps: - try: - workers_to_start.extend(step_worker_map[step]) - except KeyError: - LOG.warning(f"Cannot start workers for step: {step}. This step was not found.") - - workers_to_start = set(workers_to_start) - else: - workers_to_start = set(spec.merlin["resources"]["workers"]) - - LOG.debug(f"workers_to_start: {workers_to_start}") - return workers_to_start - - # TODO this should move to TaskServerInterface and be abstracted (build_worker_list ?) - def _get_worker_instances(self, workers_to_start: Set[str], spec: MerlinSpec) -> List[CeleryWorker]: - """ - """ - workers = [] - all_workers = spec.merlin["resources"]["workers"] - overlap = spec.merlin["resources"]["overlap"] - full_env = spec.get_full_environment() - - for worker_name in workers_to_start: - settings = all_workers[worker_name] - config = { - "args": settings.get("args", ""), - "machines": settings.get("machines", []), - "queues": spec.get_queue_list(settings["steps"]), - "batch": settings["batch"] if settings["batch"] is not None else spec.batch.copy() - } - - if "nodes" in settings and settings["nodes"] is not None: - if config["batch"]: - config["batch"]["nodes"] = settings["nodes"] - else: - config["batch"] = {"nodes": settings["nodes"]} - - LOG.debug(f"config for worker '{worker_name}': {config}") - - workers.append(CeleryWorker(name=worker_name, config=config, env=full_env, overlap=overlap)) - LOG.debug(f"Created CeleryWorker object for worker '{worker_name}'.") - - return workers - def process_command(self, args: Namespace): """ CLI command for launching workers. @@ -188,27 +116,17 @@ def process_command(self, args: Namespace): if not args.worker_echo_only: LOG.info(f"Launching workers from '{filepath}'") - # Initialize the database - merlin_db = MerlinDatabase() - - # Create logical worker entries - step_queue_map = spec.get_task_queues() - for worker, steps in spec.get_worker_step_map().items(): - worker_queues = {step_queue_map[step] for step in steps} - merlin_db.create("logical_worker", worker, worker_queues) - # Get the names of the workers that the user is requesting to start - workers_to_start = self._get_workers_to_start(spec, args.worker_steps) + workers_to_start = spec.get_workers_to_start(args.worker_steps) # Build a list of MerlinWorker instances - worker_instances = self._get_worker_instances(workers_to_start, spec) + worker_instances = spec.build_worker_list(workers_to_start) # Launch the workers or echo out the command that will be used to launch the workers - for worker in worker_instances: - if args.worker_echo_only: - LOG.debug(f"Not launching worker '{worker.name}', just echoing command.") - launch_cmd = worker.get_launch_command(override_args=args.worker_args, disable_logs=args.disable_logs) - print(launch_cmd) - else: - LOG.debug(f"Launching worker '{worker.name}'.") - worker.launch_worker(override_args=args.worker_args, disable_logs=args.disable_logs) + worker_handler = worker_handler_factory.create(spec.merlin["resources"]["task_server"]) + worker_handler.launch_workers( + worker_instances, + echo_only=args.worker_echo_only, + override_args=args.worker_args, + disable_logs=args.disable_logs, + ) diff --git a/merlin/exceptions/__init__.py b/merlin/exceptions/__init__.py index 93ce253b..4a048969 100644 --- a/merlin/exceptions/__init__.py +++ b/merlin/exceptions/__init__.py @@ -108,6 +108,24 @@ def __init__(self, message): super().__init__(message) +class MerlinWorkerHandlerNotSupportedError(Exception): + """ + Exception to signal that the provided worker handler is not supported by Merlin. + """ + + def __init__(self, message): + super().__init__(message) + + +class MerlinWorkerNotSupportedError(Exception): + """ + Exception to signal that the provided worker is not supported by Merlin. + """ + + def __init__(self, message): + super().__init__(message) + + class MerlinWorkerLaunchError(Exception): """ Exception to signal that an there was a problem when launching workers. diff --git a/merlin/spec/specification.py b/merlin/spec/specification.py index da272269..83e97168 100644 --- a/merlin/spec/specification.py +++ b/merlin/spec/specification.py @@ -25,6 +25,8 @@ from merlin.spec import all_keys, defaults from merlin.utils import find_vlaunch_var, get_yaml_var, load_array_file, needs_merlin_expansion, repr_timedelta +from merlin.workers.worker_factory import worker_factory +from merlin.workers.worker import MerlinWorker LOG = logging.getLogger(__name__) @@ -1199,3 +1201,85 @@ def get_full_environment(self): os.environ[str(var_name)] = str(var_val) return full_env + + # TODO when we move the queues setting to within the worker then we'll have to update this + def get_workers_to_start(self, steps: Union[List[str], None]) -> Set[str]: + """ + Determine the set of workers to start based on the specified steps (if any). + + This method retrieves a mapping of steps to their corresponding workers + from a [`MerlinSpec`][spec.specification.MerlinSpec] object and returns a unique + set of workers that should be started for the provided list of steps. If a step + is not found in the mapping, a warning is logged. + + Args: + steps: A list of steps for which workers need to be started or None if the user + didn't provide specific steps. + + Returns: + A set of unique workers to be started based on the specified steps. + """ + steps_provided = False if "all" in steps else True + + if steps_provided: + workers_to_start = [] + step_worker_map = self.get_step_worker_map() + for step in steps: + try: + workers_to_start.extend(step_worker_map[step]) + except KeyError: + LOG.warning(f"Cannot start workers for step: {step}. This step was not found.") + + workers_to_start = set(workers_to_start) + else: + workers_to_start = set(self.merlin["resources"]["workers"]) + + LOG.debug(f"workers_to_start: {workers_to_start}") + return workers_to_start + + # TODO some of this logic should move to TaskServerInterface and be abstracted + def build_worker_list(self, workers_to_start: Set[str]) -> List[MerlinWorker]: + """ + Construct and return a list of worker instances based on provided worker names. + + This method reads configuration from the Merlin spec to instantiate worker + objects for each worker name in `workers_to_start`. It gathers the required + parameters such as command-line arguments, machines, queue list, and batch + settings (including any overrides like number of nodes). These configurations + are passed along with environment variables and overlap settings to the + appropriate worker factory for instantiation. + + Args: + workers_to_start (Set[str]): A set of worker names to be initialized. + + Returns: + List[MerlinWorker]: A list of instantiated worker objects ready to be launched. + """ + workers = [] + all_workers = self.merlin["resources"]["workers"] + overlap = self.merlin["resources"]["overlap"] + full_env = self.get_full_environment() + + for worker_name in workers_to_start: + settings = all_workers[worker_name] + config = { + "args": settings.get("args", ""), + "machines": settings.get("machines", []), + "queues": set(self.get_queue_list(settings["steps"])), + "batch": settings["batch"] if settings["batch"] is not None else self.batch.copy() + } + + if "nodes" in settings and settings["nodes"] is not None: + if config["batch"]: + config["batch"]["nodes"] = settings["nodes"] + else: + config["batch"] = {"nodes": settings["nodes"]} + + LOG.debug(f"config for worker '{worker_name}': {config}") + + worker_params = {"name": worker_name, "config": config, "env": full_env, "overlap": overlap} + worker_instance = worker_factory.create(self.merlin["resources"]["task_server"], worker_params) + workers.append(worker_instance) + LOG.debug(f"Created CeleryWorker object for worker '{worker_name}'.") + + return workers diff --git a/merlin/study/batch.py b/merlin/study/batch.py index 643a3853..3c8b72d5 100644 --- a/merlin/study/batch.py +++ b/merlin/study/batch.py @@ -15,7 +15,6 @@ import subprocess from typing import Dict, Union -from merlin.spec.specification import MerlinSpec from merlin.utils import convert_timestring, get_flux_alloc, get_flux_version, get_yaml_var From d2d324b42340cd8f0a262830dd305b19b5b23715 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 15 Jul 2025 09:19:42 -0700 Subject: [PATCH 43/91] add files that we'll need for this refactor --- merlin/workers/__init__.py | 2 +- merlin/workers/handlers/base_handler.py | 13 +++++++++++ merlin/workers/handlers/celery_handler.py | 2 +- merlin/workers/watchdogs/__init__.py | 10 +++++++++ merlin/workers/watchdogs/base_watchdog.py | 13 +++++++++++ merlin/workers/watchdogs/celery_watchdog.py | 12 ++++++++++ merlin/workers/watchdogs/watchodg_factory.py | 23 ++++++++++++++++++++ 7 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 merlin/workers/handlers/base_handler.py create mode 100644 merlin/workers/watchdogs/__init__.py create mode 100644 merlin/workers/watchdogs/base_watchdog.py create mode 100644 merlin/workers/watchdogs/celery_watchdog.py create mode 100644 merlin/workers/watchdogs/watchodg_factory.py diff --git a/merlin/workers/__init__.py b/merlin/workers/__init__.py index 784eba2a..cd12b6af 100644 --- a/merlin/workers/__init__.py +++ b/merlin/workers/__init__.py @@ -32,4 +32,4 @@ from merlin.workers.celery_worker import CeleryWorker -__all__ = ["CeleryWorker"] \ No newline at end of file +__all__ = ["CeleryWorker"] diff --git a/merlin/workers/handlers/base_handler.py b/merlin/workers/handlers/base_handler.py new file mode 100644 index 00000000..e0ef328b --- /dev/null +++ b/merlin/workers/handlers/base_handler.py @@ -0,0 +1,13 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +from abc import ABC, abstractmethod + + +class BaseWorkerHandler(ABC): + """ + + """ \ No newline at end of file diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index 25df733b..8ae99cb8 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -72,4 +72,4 @@ def query_workers(self): """ Query the status of Celery workers. """ - \ No newline at end of file + diff --git a/merlin/workers/watchdogs/__init__.py b/merlin/workers/watchdogs/__init__.py new file mode 100644 index 00000000..aa2759b6 --- /dev/null +++ b/merlin/workers/watchdogs/__init__.py @@ -0,0 +1,10 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +from merlin.workers.watchdogs.base_watchdog import BaseWorkerWatchdog +from merlin.workers.watchdogs.celery_watchdog import CeleryWorkerWatchdog + +__all__ = ["BaseWorkerWatchdog", "CeleryWorkerWatchdog"] \ No newline at end of file diff --git a/merlin/workers/watchdogs/base_watchdog.py b/merlin/workers/watchdogs/base_watchdog.py new file mode 100644 index 00000000..c6ffed04 --- /dev/null +++ b/merlin/workers/watchdogs/base_watchdog.py @@ -0,0 +1,13 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +from abc import ABC, abstractmethod + + +class BaseWorkerWatchdog(ABC): + """ + + """ diff --git a/merlin/workers/watchdogs/celery_watchdog.py b/merlin/workers/watchdogs/celery_watchdog.py new file mode 100644 index 00000000..f298e40f --- /dev/null +++ b/merlin/workers/watchdogs/celery_watchdog.py @@ -0,0 +1,12 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +from merlin.workers.watchdogs.base_watchdog import BaseWorkerWatchdog + +class CeleryWorkerWatchdog(BaseWorkerWatchdog): + """ + + """ \ No newline at end of file diff --git a/merlin/workers/watchdogs/watchodg_factory.py b/merlin/workers/watchdogs/watchodg_factory.py new file mode 100644 index 00000000..f190e525 --- /dev/null +++ b/merlin/workers/watchdogs/watchodg_factory.py @@ -0,0 +1,23 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" + +""" + +import logging +from typing import Dict, List + +from merlin.workers.watchdogs import BaseWorkerWatchdog, CeleryWorkerWatchdog + + +LOG = logging.getLogger("merlin") + + +class WorkerWatchdogFactory: + """ + + """ \ No newline at end of file From 9a81f88ac4d6ccf448a7a3366f7e92ac4c60d1b7 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 18:04:30 -0700 Subject: [PATCH 44/91] fix regex in test --- tests/integration/definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/definitions.py b/tests/integration/definitions.py index 108664b5..7d326484 100644 --- a/tests/integration/definitions.py +++ b/tests/integration/definitions.py @@ -314,7 +314,7 @@ def define_tests(): # pylint: disable=R0914,R0915 }, "default_worker assigned": { "cmds": f"{workers} {test_specs}/default_worker_test.yaml --echo", - "conditions": [HasReturnCode(), HasRegex(r"default_worker.*-Q '\[merlin\]_step_4_queue'")], + "conditions": [HasReturnCode(), HasRegex(r"default_worker.*-Q \[merlin\]_step_4_queue")], "run type": "local", }, "no default_worker assigned": { From caddb710b4c6d84448ee0535c2a46dc465441e15 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 22 Jul 2025 18:09:36 -0700 Subject: [PATCH 45/91] remove watchdog files and run fix-style --- merlin/spec/specification.py | 16 +++++++------- merlin/study/celeryadapter.py | 7 +----- merlin/workers/__init__.py | 1 + merlin/workers/celery_worker.py | 11 ++++++---- merlin/workers/handlers/__init__.py | 1 + merlin/workers/handlers/base_handler.py | 13 ----------- merlin/workers/handlers/celery_handler.py | 1 - merlin/workers/watchdogs/__init__.py | 10 --------- merlin/workers/watchdogs/base_watchdog.py | 13 ----------- merlin/workers/watchdogs/celery_watchdog.py | 12 ---------- merlin/workers/watchdogs/watchodg_factory.py | 23 -------------------- merlin/workers/worker_factory.py | 2 +- 12 files changed, 19 insertions(+), 91 deletions(-) delete mode 100644 merlin/workers/handlers/base_handler.py delete mode 100644 merlin/workers/watchdogs/__init__.py delete mode 100644 merlin/workers/watchdogs/base_watchdog.py delete mode 100644 merlin/workers/watchdogs/celery_watchdog.py delete mode 100644 merlin/workers/watchdogs/watchodg_factory.py diff --git a/merlin/spec/specification.py b/merlin/spec/specification.py index 83e97168..66c58da4 100644 --- a/merlin/spec/specification.py +++ b/merlin/spec/specification.py @@ -25,8 +25,8 @@ from merlin.spec import all_keys, defaults from merlin.utils import find_vlaunch_var, get_yaml_var, load_array_file, needs_merlin_expansion, repr_timedelta -from merlin.workers.worker_factory import worker_factory from merlin.workers.worker import MerlinWorker +from merlin.workers.worker_factory import worker_factory LOG = logging.getLogger(__name__) @@ -1179,13 +1179,13 @@ def get_full_environment(self): """ Construct the full environment for the current context. - This method starts with a copy of the current OS environment and - overlays any additional environment variables defined in the spec's - `environment` section. These variables are added both to the returned + This method starts with a copy of the current OS environment and + overlays any additional environment variables defined in the spec's + `environment` section. These variables are added both to the returned dictionary and the live `os.environ` to support variable expansion. Returns: - dict: A dictionary representing the full environment with any + dict: A dictionary representing the full environment with any user-defined variables applied. """ # Start with the global environment @@ -1201,7 +1201,7 @@ def get_full_environment(self): os.environ[str(var_name)] = str(var_val) return full_env - + # TODO when we move the queues setting to within the worker then we'll have to update this def get_workers_to_start(self, steps: Union[List[str], None]) -> Set[str]: """ @@ -1236,7 +1236,7 @@ def get_workers_to_start(self, steps: Union[List[str], None]) -> Set[str]: LOG.debug(f"workers_to_start: {workers_to_start}") return workers_to_start - + # TODO some of this logic should move to TaskServerInterface and be abstracted def build_worker_list(self, workers_to_start: Set[str]) -> List[MerlinWorker]: """ @@ -1266,7 +1266,7 @@ def build_worker_list(self, workers_to_start: Set[str]) -> List[MerlinWorker]: "args": settings.get("args", ""), "machines": settings.get("machines", []), "queues": set(self.get_queue_list(settings["steps"])), - "batch": settings["batch"] if settings["batch"] is not None else self.batch.copy() + "batch": settings["batch"] if settings["batch"] is not None else self.batch.copy(), } if "nodes" in settings and settings["nodes"] is not None: diff --git a/merlin/study/celeryadapter.py b/merlin/study/celeryadapter.py index b3543def..87a68978 100644 --- a/merlin/study/celeryadapter.py +++ b/merlin/study/celeryadapter.py @@ -8,11 +8,7 @@ This module provides an adapter to the Celery Distributed Task Queue. """ import logging -import os -import socket import subprocess -import time -from contextlib import suppress from datetime import datetime from types import SimpleNamespace from typing import Dict, List, Set, Tuple @@ -24,9 +20,8 @@ from merlin.common.dumper import dump_handler from merlin.config import Config from merlin.spec.specification import MerlinSpec -from merlin.study.batch import batch_check_parallel, batch_worker_launch from merlin.study.study import MerlinStudy -from merlin.utils import apply_list_of_regex, check_machines, get_procs, get_yaml_var, is_running +from merlin.utils import apply_list_of_regex, get_procs, is_running LOG = logging.getLogger(__name__) diff --git a/merlin/workers/__init__.py b/merlin/workers/__init__.py index cd12b6af..06a19c24 100644 --- a/merlin/workers/__init__.py +++ b/merlin/workers/__init__.py @@ -32,4 +32,5 @@ from merlin.workers.celery_worker import CeleryWorker + __all__ = ["CeleryWorker"] diff --git a/merlin/workers/celery_worker.py b/merlin/workers/celery_worker.py index def1ddb7..becd92ba 100644 --- a/merlin/workers/celery_worker.py +++ b/merlin/workers/celery_worker.py @@ -26,6 +26,7 @@ from merlin.utils import check_machines from merlin.workers.worker import MerlinWorker + LOG = logging.getLogger("merlin") @@ -153,7 +154,8 @@ def should_launch(self) -> bool: if machines: if not check_machines(machines): LOG.error( - f"The following machines were provided for worker '{self.name}': {machines}. However, the current machine '{socket.gethostname()}' is not in this list." + f"The following machines were provided for worker '{self.name}': {machines}. " + f"However, the current machine '{socket.gethostname()}' is not in this list." ) return False @@ -163,7 +165,8 @@ def should_launch(self) -> bool: return False if not self.overlap: - from merlin.study.celeryadapter import get_running_queues + from merlin.study.celeryadapter import get_running_queues # pylint: disable=import-outside-toplevel + running_queues = get_running_queues("merlin") for queue in queues: if queue in running_queues: @@ -171,7 +174,7 @@ def should_launch(self) -> bool: return False return True - + def launch_worker(self, override_args: str = "", disable_logs: bool = False): """ Launch the worker as a subprocess using the constructed launch command. @@ -190,7 +193,7 @@ def launch_worker(self, override_args: str = "", disable_logs: bool = False): LOG.debug(f"Launched worker '{self.name}' with command: {launch_cmd}.") except Exception as e: # pylint: disable=C0103 LOG.error(f"Cannot start celery workers, {e}") - raise MerlinWorkerLaunchError + raise MerlinWorkerLaunchError from e def get_metadata(self) -> Dict: """ diff --git a/merlin/workers/handlers/__init__.py b/merlin/workers/handlers/__init__.py index b95145b8..30969814 100644 --- a/merlin/workers/handlers/__init__.py +++ b/merlin/workers/handlers/__init__.py @@ -25,4 +25,5 @@ from merlin.workers.handlers.celery_handler import CeleryWorkerHandler + __all__ = ["CeleryWorkerHandler"] diff --git a/merlin/workers/handlers/base_handler.py b/merlin/workers/handlers/base_handler.py deleted file mode 100644 index e0ef328b..00000000 --- a/merlin/workers/handlers/base_handler.py +++ /dev/null @@ -1,13 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -from abc import ABC, abstractmethod - - -class BaseWorkerHandler(ABC): - """ - - """ \ No newline at end of file diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index 8ae99cb8..a73d6f8f 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -72,4 +72,3 @@ def query_workers(self): """ Query the status of Celery workers. """ - diff --git a/merlin/workers/watchdogs/__init__.py b/merlin/workers/watchdogs/__init__.py deleted file mode 100644 index aa2759b6..00000000 --- a/merlin/workers/watchdogs/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -from merlin.workers.watchdogs.base_watchdog import BaseWorkerWatchdog -from merlin.workers.watchdogs.celery_watchdog import CeleryWorkerWatchdog - -__all__ = ["BaseWorkerWatchdog", "CeleryWorkerWatchdog"] \ No newline at end of file diff --git a/merlin/workers/watchdogs/base_watchdog.py b/merlin/workers/watchdogs/base_watchdog.py deleted file mode 100644 index c6ffed04..00000000 --- a/merlin/workers/watchdogs/base_watchdog.py +++ /dev/null @@ -1,13 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -from abc import ABC, abstractmethod - - -class BaseWorkerWatchdog(ABC): - """ - - """ diff --git a/merlin/workers/watchdogs/celery_watchdog.py b/merlin/workers/watchdogs/celery_watchdog.py deleted file mode 100644 index f298e40f..00000000 --- a/merlin/workers/watchdogs/celery_watchdog.py +++ /dev/null @@ -1,12 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -from merlin.workers.watchdogs.base_watchdog import BaseWorkerWatchdog - -class CeleryWorkerWatchdog(BaseWorkerWatchdog): - """ - - """ \ No newline at end of file diff --git a/merlin/workers/watchdogs/watchodg_factory.py b/merlin/workers/watchdogs/watchodg_factory.py deleted file mode 100644 index f190e525..00000000 --- a/merlin/workers/watchdogs/watchodg_factory.py +++ /dev/null @@ -1,23 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -""" - -""" - -import logging -from typing import Dict, List - -from merlin.workers.watchdogs import BaseWorkerWatchdog, CeleryWorkerWatchdog - - -LOG = logging.getLogger("merlin") - - -class WorkerWatchdogFactory: - """ - - """ \ No newline at end of file diff --git a/merlin/workers/worker_factory.py b/merlin/workers/worker_factory.py index fd86f83e..30112cc7 100644 --- a/merlin/workers/worker_factory.py +++ b/merlin/workers/worker_factory.py @@ -9,7 +9,7 @@ This module defines the `WorkerFactory`, a subclass of [`MerlinBaseFactory`][abstracts.factory.MerlinBaseFactory], which manages -the registration, validation, and creation of concrete worker classes such as +the registration, validation, and creation of concrete worker classes such as [`CeleryWorker`][workers.celery_worker.CeleryWorker]. It supports plugin-based discovery via Python entry points, enabling extensibility for other task server backends (e.g., Kafka). From 547e3c5addd9db4f4cbf5becc0138df4d2990e87 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 23 Jul 2025 08:22:08 -0700 Subject: [PATCH 46/91] fix tests that broke after refactor --- tests/unit/cli/commands/test_run_workers.py | 45 ++++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/tests/unit/cli/commands/test_run_workers.py b/tests/unit/cli/commands/test_run_workers.py index 8729822c..b7f3f7e8 100644 --- a/tests/unit/cli/commands/test_run_workers.py +++ b/tests/unit/cli/commands/test_run_workers.py @@ -14,6 +14,7 @@ from pytest_mock import MockerFixture from merlin.cli.commands.run_workers import RunWorkersCommand +from merlin.workers.handlers import CeleryWorkerHandler from tests.fixture_types import FixtureCallable @@ -37,7 +38,7 @@ def test_add_parser_sets_up_run_workers_command(create_parser: FixtureCallable): assert args.disable_logs is False -def test_process_command_launches_workers_and_creates_logical_workers(mocker: MockerFixture): +def test_process_command_launches_workers(mocker: MockerFixture): """ Test `process_command` launches workers and creates logical worker entries in normal mode. @@ -45,14 +46,19 @@ def test_process_command_launches_workers_and_creates_logical_workers(mocker: Mo mocker: PyTest mocker fixture. """ mock_spec = mocker.Mock() - mock_spec.get_task_queues.return_value = {"step1": "queue1", "step2": "queue2"} - mock_spec.get_worker_step_map.return_value = {"workerA": ["step1", "step2"]} + mock_spec.get_workers_to_start.return_value = ["workerA"] + mock_spec.build_worker_list.return_value = ["worker-instance"] + mock_spec.merlin = {"resources": {"task_server": "celery"}} mock_get_spec = mocker.patch( - "merlin.cli.commands.run_workers.get_merlin_spec_with_override", return_value=(mock_spec, "workflow.yaml") + "merlin.cli.commands.run_workers.get_merlin_spec_with_override", + return_value=(mock_spec, "workflow.yaml") + ) + mock_handler = mocker.Mock() + mock_factory = mocker.patch( + "merlin.cli.commands.run_workers.worker_handler_factory.create", + return_value=mock_handler ) - mock_launch = mocker.patch("merlin.cli.commands.run_workers.launch_workers", return_value="launched") - mock_db = mocker.patch("merlin.cli.commands.run_workers.MerlinDatabase") mock_log = mocker.patch("merlin.cli.commands.run_workers.LOG") args = Namespace( @@ -67,10 +73,16 @@ def test_process_command_launches_workers_and_creates_logical_workers(mocker: Mo RunWorkersCommand().process_command(args) mock_get_spec.assert_called_once_with(args) - mock_db.return_value.create.assert_called_once_with("logical_worker", "workerA", {"queue1", "queue2"}) - mock_launch.assert_called_once_with(mock_spec, ["step1"], "--concurrency=4", False, False) + mock_spec.get_workers_to_start.assert_called_once_with(["step1"]) + mock_spec.build_worker_list.assert_called_once_with(["workerA"]) + mock_factory.assert_called_once_with("celery") + mock_handler.launch_workers.assert_called_once_with( + ["worker-instance"], + echo_only=False, + override_args="--concurrency=4", + disable_logs=False + ) mock_log.info.assert_called_once_with("Launching workers from 'workflow.yaml'") - mock_log.debug.assert_called_once_with("celery command: launched") def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, capsys: CaptureFixture): @@ -82,13 +94,17 @@ def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, ca capsys: PyTest capsys fixture. """ mock_spec = mocker.Mock() - mock_spec.get_task_queues.return_value = {} - mock_spec.get_worker_step_map.return_value = {} + mock_spec.get_workers_to_start.return_value = ["workerB"] + mock_spec.merlin = {"resources": {"task_server": "celery"}} + + mock_worker = mocker.Mock() + mock_worker.name = "workerB" + mock_worker.get_launch_command.return_value = "echo-launch-cmd" + mock_spec.build_worker_list.return_value = [mock_worker] mocker.patch("merlin.cli.commands.run_workers.get_merlin_spec_with_override", return_value=(mock_spec, "file.yaml")) mocker.patch("merlin.cli.commands.run_workers.initialize_config") - mocker.patch("merlin.cli.commands.run_workers.MerlinDatabase") - mock_launch = mocker.patch("merlin.cli.commands.run_workers.launch_workers", return_value="echo-cmd") + mocker.patch("merlin.cli.commands.run_workers.worker_handler_factory.create", wraps=lambda _: CeleryWorkerHandler()) args = Namespace( specification="spec.yaml", @@ -102,5 +118,4 @@ def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, ca RunWorkersCommand().process_command(args) captured = capsys.readouterr() - assert "echo-cmd" in captured.out - mock_launch.assert_called_once_with(mock_spec, ["all"], "--autoscale=2,10", False, True) + assert "echo-launch-cmd" in captured.out From 8b212eb1a1f664fbba496b7dc2d5e9a7ee71b459 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 23 Jul 2025 10:45:18 -0700 Subject: [PATCH 47/91] run fix-style --- tests/unit/cli/commands/test_run_workers.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/unit/cli/commands/test_run_workers.py b/tests/unit/cli/commands/test_run_workers.py index b7f3f7e8..643c3aca 100644 --- a/tests/unit/cli/commands/test_run_workers.py +++ b/tests/unit/cli/commands/test_run_workers.py @@ -51,14 +51,10 @@ def test_process_command_launches_workers(mocker: MockerFixture): mock_spec.merlin = {"resources": {"task_server": "celery"}} mock_get_spec = mocker.patch( - "merlin.cli.commands.run_workers.get_merlin_spec_with_override", - return_value=(mock_spec, "workflow.yaml") + "merlin.cli.commands.run_workers.get_merlin_spec_with_override", return_value=(mock_spec, "workflow.yaml") ) mock_handler = mocker.Mock() - mock_factory = mocker.patch( - "merlin.cli.commands.run_workers.worker_handler_factory.create", - return_value=mock_handler - ) + mock_factory = mocker.patch("merlin.cli.commands.run_workers.worker_handler_factory.create", return_value=mock_handler) mock_log = mocker.patch("merlin.cli.commands.run_workers.LOG") args = Namespace( @@ -77,10 +73,7 @@ def test_process_command_launches_workers(mocker: MockerFixture): mock_spec.build_worker_list.assert_called_once_with(["workerA"]) mock_factory.assert_called_once_with("celery") mock_handler.launch_workers.assert_called_once_with( - ["worker-instance"], - echo_only=False, - override_args="--concurrency=4", - disable_logs=False + ["worker-instance"], echo_only=False, override_args="--concurrency=4", disable_logs=False ) mock_log.info.assert_called_once_with("Launching workers from 'workflow.yaml'") From fcc6b8e901a17cbebaf59079998b9ea6bca43921 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 23 Jul 2025 10:48:46 -0700 Subject: [PATCH 48/91] update CHANGELOG --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9615e94f..cad30264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Unit tests for the `spec/` folder - A page in the docs explaining the `feature_demo` example - New `MerlinBaseFactory` class to help enable future plugins for backends, monitors, status renderers, etc. +- New worker related classes: + - `MerlinWorker`: base class for defining task server workers + - `CeleryWorker`: implementation of `MerlinWorker` specifically for Celery workers + - `WorkerFactory`: to help determine which task server worker to use + - `MerlinWorkerHandler`: base class for managing launching, stopping, and querying multiple workers + - `CeleryWorkerHandler`: implementation of `MerlinWorkerHandler` specifically for manager Celery workers + - `WorkerHandlerFactory`: to help determine which task server handler to use ### Changed - Maestro version requirement is now at minimum 1.1.10 for status renderer changes - The `BackendFactory`, `MonitorFactory`, and `StatusRendererFactory` classes all now inherit from `MerlinBaseFactory` +- Launching workers is now handled through worker classes rather than functions in the `celeryadapter.py` file ## [1.13.0b2] ### Added From 4d84bd885cd413114fcfb55b25b47590349a3d6b Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 6 Aug 2025 17:01:50 -0700 Subject: [PATCH 49/91] run fix-style --- merlin/abstracts/__init__.py | 1 + tests/unit/abstracts/test_factory.py | 1 + tests/unit/backends/test_backend_factory.py | 2 -- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merlin/abstracts/__init__.py b/merlin/abstracts/__init__.py index aae1b246..8b3faec6 100644 --- a/merlin/abstracts/__init__.py +++ b/merlin/abstracts/__init__.py @@ -14,4 +14,5 @@ from merlin.abstracts.factory import MerlinBaseFactory + __all__ = ["MerlinBaseFactory"] diff --git a/tests/unit/abstracts/test_factory.py b/tests/unit/abstracts/test_factory.py index 123ca162..a92b1481 100644 --- a/tests/unit/abstracts/test_factory.py +++ b/tests/unit/abstracts/test_factory.py @@ -15,6 +15,7 @@ from merlin.abstracts import MerlinBaseFactory + # --- Dummy Components --- class DummyComponent: """A testable dummy component.""" diff --git a/tests/unit/backends/test_backend_factory.py b/tests/unit/backends/test_backend_factory.py index f065df21..82b93293 100644 --- a/tests/unit/backends/test_backend_factory.py +++ b/tests/unit/backends/test_backend_factory.py @@ -11,8 +11,6 @@ import pytest from pytest_mock import MockerFixture -from pytest_mock import MockerFixture - from merlin.backends.backend_factory import MerlinBackendFactory from merlin.backends.results_backend import ResultsBackend from merlin.exceptions import BackendNotSupportedError From be71c82199ffa7626fb2e445b62386e4fb02456f Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 6 Aug 2025 17:26:12 -0700 Subject: [PATCH 50/91] fix worker-related factory classes --- merlin/workers/handlers/handler_factory.py | 17 +++++++++-------- merlin/workers/worker_factory.py | 17 +++++++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/merlin/workers/handlers/handler_factory.py b/merlin/workers/handlers/handler_factory.py index a4c125ff..d0d78e28 100644 --- a/merlin/workers/handlers/handler_factory.py +++ b/merlin/workers/handlers/handler_factory.py @@ -70,18 +70,19 @@ def _entry_point_group(self) -> str: """ return "merlin.workers.handlers" - def _get_component_error_class(self) -> Type[Exception]: + def _raise_component_error_class(self, msg: str) -> Type[Exception]: """ - Return the exception type to raise for unsupported components. + Raise an appropriate exception when an invalid component is requested. - This method is used by the base factory logic to determine which - exception to raise when a requested component is not found or fails - to initialize. + Subclasses should override this to raise more specific exceptions. - Returns: - The exception class to raise. + Args: + msg: The message to add to the error being raised. + + Raises: + A subclass of Exception (e.g., ValueError by default). """ - return MerlinWorkerHandlerNotSupportedError + raise MerlinWorkerHandlerNotSupportedError(msg) worker_handler_factory = WorkerHandlerFactory() diff --git a/merlin/workers/worker_factory.py b/merlin/workers/worker_factory.py index 30112cc7..7b327eaa 100644 --- a/merlin/workers/worker_factory.py +++ b/merlin/workers/worker_factory.py @@ -71,18 +71,19 @@ def _entry_point_group(self) -> str: """ return "merlin.workers" - def _get_component_error_class(self) -> Type[Exception]: + def _raise_component_error_class(self, msg: str) -> Type[Exception]: """ - Return the exception type to raise for unsupported components. + Raise an appropriate exception when an invalid component is requested. - This method is used by the base factory logic to determine which - exception to raise when a requested component is not found or fails - to initialize. + Subclasses should override this to raise more specific exceptions. - Returns: - The exception class to raise. + Args: + msg: The message to add to the error being raised. + + Raises: + A subclass of Exception (e.g., ValueError by default). """ - return MerlinWorkerNotSupportedError + return MerlinWorkerNotSupportedError(msg) worker_factory = WorkerFactory() From 534769e12c60e1ac453890bcb83f95039ff981bf Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 7 Aug 2025 12:25:50 -0700 Subject: [PATCH 51/91] add tests for new workers files --- merlin/exceptions/__init__.py | 9 - merlin/workers/worker_factory.py | 2 +- .../workers/handlers/test_celery_handler.py | 93 +++++ .../workers/handlers/test_handler_factory.py | 123 ++++++ .../workers/handlers/test_worker_handler.py | 106 +++++ tests/unit/workers/test_celery_worker.py | 375 ++++++++++++++++++ tests/unit/workers/test_worker.py | 89 +++++ tests/unit/workers/test_worker_factory.py | 124 ++++++ 8 files changed, 911 insertions(+), 10 deletions(-) create mode 100644 tests/unit/workers/handlers/test_celery_handler.py create mode 100644 tests/unit/workers/handlers/test_handler_factory.py create mode 100644 tests/unit/workers/handlers/test_worker_handler.py create mode 100644 tests/unit/workers/test_celery_worker.py create mode 100644 tests/unit/workers/test_worker.py create mode 100644 tests/unit/workers/test_worker_factory.py diff --git a/merlin/exceptions/__init__.py b/merlin/exceptions/__init__.py index 4a048969..e5c49610 100644 --- a/merlin/exceptions/__init__.py +++ b/merlin/exceptions/__init__.py @@ -113,27 +113,18 @@ class MerlinWorkerHandlerNotSupportedError(Exception): Exception to signal that the provided worker handler is not supported by Merlin. """ - def __init__(self, message): - super().__init__(message) - class MerlinWorkerNotSupportedError(Exception): """ Exception to signal that the provided worker is not supported by Merlin. """ - def __init__(self, message): - super().__init__(message) - class MerlinWorkerLaunchError(Exception): """ Exception to signal that an there was a problem when launching workers. """ - def __init__(self, message): - super().__init__(message) - ############################### # Database-Related Exceptions # diff --git a/merlin/workers/worker_factory.py b/merlin/workers/worker_factory.py index 7b327eaa..96dc991b 100644 --- a/merlin/workers/worker_factory.py +++ b/merlin/workers/worker_factory.py @@ -83,7 +83,7 @@ def _raise_component_error_class(self, msg: str) -> Type[Exception]: Raises: A subclass of Exception (e.g., ValueError by default). """ - return MerlinWorkerNotSupportedError(msg) + raise MerlinWorkerNotSupportedError(msg) worker_factory = WorkerFactory() diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py new file mode 100644 index 00000000..4fc838de --- /dev/null +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -0,0 +1,93 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `merlin/workers/handlers/celery_handler.py` module. +""" + +import pytest +from typing import Dict, List + +from merlin.workers.celery_worker import CeleryWorker +from merlin.workers.handlers.celery_handler import CeleryWorkerHandler + + +class DummyCeleryWorker(CeleryWorker): + def __init__(self, name: str, config: Dict = None, env: Dict = None): + super().__init__(name, config or {}, env or {}) + self.launched_with = None + self.launch_command = f"celery --worker-name={name}" + + def get_launch_command(self, override_args: str = "", disable_logs: bool = False) -> str: + parts = [self.launch_command] + if override_args: + parts.append(override_args) + if disable_logs: + parts.append("--no-logs") + return " ".join(parts) + + def launch_worker(self, override_args: str = "", disable_logs: bool = False): + self.launched_with = (override_args, disable_logs) + return f"Launching {self.name} with {override_args} and logs {'off' if disable_logs else 'on'}" + + +class TestCeleryWorkerHandler: + """ + Unit tests for the CeleryWorkerHandler class. + """ + + @pytest.fixture + def handler(self) -> CeleryWorkerHandler: + return CeleryWorkerHandler() + + @pytest.fixture + def workers(self) -> List[DummyCeleryWorker]: + return [ + DummyCeleryWorker("worker1"), + DummyCeleryWorker("worker2"), + ] + + def test_echo_only_prints_commands(self, handler: CeleryWorkerHandler, workers: List[DummyCeleryWorker], capsys: pytest.CaptureFixture): + """ + Test that `launch_workers` prints launch commands when `echo_only=True`. + + Args: + handler: CeleryWorkerHandler instance. + workers: DummyCeleryWorker instances. + capsys: Pytest fixture to capture stdout. + """ + handler.launch_workers(workers, echo_only=True, override_args="--debug", disable_logs=True) + output = capsys.readouterr().out + + for worker in workers: + expected = worker.get_launch_command(override_args="--debug", disable_logs=True) + assert expected in output + + def test_launch_workers_calls_worker_launch(self, handler: CeleryWorkerHandler, workers: List[DummyCeleryWorker]): + """ + Test that `launch_workers` invokes `launch_worker()` on each worker when `echo_only=False`. + + Args: + handler: CeleryWorkerHandler instance. + workers: DummyCeleryWorker instances. + """ + handler.launch_workers(workers, echo_only=False, override_args="--custom", disable_logs=True) + + for worker in workers: + assert worker.launched_with == ("--custom", True) + + def test_default_kwargs_are_used(self, handler: CeleryWorkerHandler, workers: List[DummyCeleryWorker]): + """ + Test that `launch_workers` uses defaults when optional kwargs are omitted. + + Args: + handler: CeleryWorkerHandler instance. + workers: DummyCeleryWorker instances. + """ + handler.launch_workers(workers) + + for worker in workers: + assert worker.launched_with == ("", False) diff --git a/tests/unit/workers/handlers/test_handler_factory.py b/tests/unit/workers/handlers/test_handler_factory.py new file mode 100644 index 00000000..9cda5040 --- /dev/null +++ b/tests/unit/workers/handlers/test_handler_factory.py @@ -0,0 +1,123 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `merlin/workers/handlers/handler_factory.py` module. +""" + +import pytest + +from pytest_mock import MockerFixture + +from merlin.exceptions import MerlinWorkerHandlerNotSupportedError +from merlin.workers.handlers.handler_factory import WorkerHandlerFactory +from merlin.workers.handlers.worker_handler import MerlinWorkerHandler + + +class DummyCeleryWorkerHandler(MerlinWorkerHandler): + def __init__(self, *args, **kwargs): + pass + + def launch_workers(self): + pass + + def stop_workers(self): + pass + + def query_workers(self): + pass + + +class DummyKafkaWorkerHandler(MerlinWorkerHandler): + def __init__(self, *args, **kwargs): + pass + + def launch_workers(self): + pass + + def stop_workers(self): + pass + + def query_workers(self): + pass + + +class TestWorkerHandlerFactory: + """ + Test suite for the `WorkerHandlerFactory`. + + This class verifies that the factory properly registers, validates, instantiates, + and handles Merlin worker handlers. It mocks built-ins for test isolation. + """ + + @pytest.fixture + def handler_factory(self, mocker: MockerFixture) -> WorkerHandlerFactory: + """ + A fixture that returns a fresh instance of `WorkerHandlerFactory` with built-in handlers patched. + + Args: + mocker: PyTest mocker fixture. + + Returns: + A factory instance with mocked handler classes. + """ + mocker.patch("merlin.workers.handlers.handler_factory.CeleryWorkerHandler", DummyCeleryWorkerHandler) + return WorkerHandlerFactory() + + def test_list_available_handlers(self, handler_factory: WorkerHandlerFactory): + """ + Test that `list_available` returns the expected built-in handler names. + + Args: + handler_factory: Instance of the `WorkerHandlerFactory` for testing. + """ + available = handler_factory.list_available() + assert set(available) == {"celery"} + + def test_create_valid_handler(self, handler_factory: WorkerHandlerFactory): + """ + Test that `create` returns a valid handler instance for a registered name. + + Args: + handler_factory: Instance of the `WorkerHandlerFactory` for testing. + """ + instance = handler_factory.create("celery") + assert isinstance(instance, DummyCeleryWorkerHandler) + + def test_create_valid_handler_with_alias(self, handler_factory: WorkerHandlerFactory): + """ + Test that aliases are resolved to canonical handler names. + + Args: + handler_factory: Instance of the `WorkerHandlerFactory` for testing. + """ + handler_factory.register("kafka", DummyKafkaWorkerHandler, aliases=["kfk", "legacy-kafka"]) + instance = handler_factory.create("legacy-kafka") + assert isinstance(instance, DummyKafkaWorkerHandler) + + def test_create_invalid_handler_raises(self, handler_factory: WorkerHandlerFactory): + """ + Test that `create` raises `MerlinWorkerHandlerNotSupportedError` for unknown handler types. + + Args: + handler_factory: Instance of the `WorkerHandlerFactory` for testing. + """ + with pytest.raises(MerlinWorkerHandlerNotSupportedError, match="unknown_handler"): + handler_factory.create("unknown_handler") + + def test_invalid_registration_type_error(self, handler_factory: WorkerHandlerFactory): + """ + Test that trying to register a non-MerlinWorkerHandler raises TypeError. + + Args: + handler_factory: Instance of the `WorkerHandlerFactory` for testing. + """ + + class NotAWorkerHandler: + pass + + with pytest.raises(TypeError, match="must inherit from MerlinWorkerHandler"): + handler_factory.register("fake_handler", NotAWorkerHandler) diff --git a/tests/unit/workers/handlers/test_worker_handler.py b/tests/unit/workers/handlers/test_worker_handler.py new file mode 100644 index 00000000..4ad1d30b --- /dev/null +++ b/tests/unit/workers/handlers/test_worker_handler.py @@ -0,0 +1,106 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `merlin/workers/handlers/worker_handler.py` module. +""" + +import pytest +from typing import Any + +from merlin.workers.handlers.worker_handler import MerlinWorkerHandler +from merlin.workers.worker import MerlinWorker + + +class DummyWorker(MerlinWorker): + def get_launch_command(self, override_args: str = "") -> str: + return "launch" + + def launch_worker(self) -> str: + return "launched" + + def get_metadata(self) -> dict: + return {} + + +class DummyWorkerHandler(MerlinWorkerHandler): + def __init__(self): + super().__init__() + self.started = False + self.stopped = False + self.queried = False + + def launch_workers(self, workers: list[MerlinWorker], **kwargs): + self.started = True + self.last_workers = workers + return [worker.launch_worker() for worker in workers] + + def stop_workers(self): + self.stopped = True + return "Stopped all workers" + + def query_workers(self) -> Any: + self.queried = True + return {"status": "ok", "workers": len(getattr(self, "last_workers", []))} + + +def test_abstract_handler_cannot_be_instantiated(): + """ + Test that attempting to instantiate the abstract base class raises a TypeError. + """ + with pytest.raises(TypeError): + MerlinWorkerHandler() + + +def test_unimplemented_methods_raise_not_implemented(): + """ + Test that calling abstract methods on a subclass without implementation raises NotImplementedError. + """ + + class IncompleteHandler(MerlinWorkerHandler): + pass + + # Should raise TypeError due to unimplemented abstract methods + with pytest.raises(TypeError): + IncompleteHandler() + + +def test_launch_workers_calls_worker_launch(): + """ + Test that `launch_workers` calls each worker's `launch_worker` method. + """ + handler = DummyWorkerHandler() + workers = [DummyWorker("w1", {}, {}), DummyWorker("w2", {}, {})] + + result = handler.launch_workers(workers) + + assert handler.started + assert result == ["launched", "launched"] + + +def test_stop_workers_sets_flag(): + """ + Test that `stop_workers` sets the internal state and returns expected value. + """ + handler = DummyWorkerHandler() + response = handler.stop_workers() + + assert handler.stopped + assert response == "Stopped all workers" + + +def test_query_workers_returns_summary(): + """ + Test that `query_workers` returns a valid summary of current worker state. + """ + handler = DummyWorkerHandler() + workers = [DummyWorker("a", {}, {}), DummyWorker("b", {}, {})] + handler.launch_workers(workers) + + summary = handler.query_workers() + + assert handler.queried + assert summary == {"status": "ok", "workers": 2} diff --git a/tests/unit/workers/test_celery_worker.py b/tests/unit/workers/test_celery_worker.py new file mode 100644 index 00000000..424e7ff0 --- /dev/null +++ b/tests/unit/workers/test_celery_worker.py @@ -0,0 +1,375 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `merlin/workers/celery_worker.py` module. +""" + +import os +import pytest +from typing import Any + +from pytest_mock import MockerFixture + +from merlin.db_scripts.merlin_db import MerlinDatabase +from merlin.exceptions import MerlinWorkerLaunchError +from merlin.workers.celery_worker import CeleryWorker +from tests.fixture_types import FixtureCallable, FixtureDict, FixtureStr + + +@pytest.fixture +def workers_testing_dir(create_testing_dir: FixtureCallable, temp_output_dir: FixtureStr) -> FixtureStr: + """ + Fixture to create a temporary output directory for tests related to the workers functionality. + + Args: + create_testing_dir: A fixture which returns a function that creates the testing directory. + temp_output_dir: The path to the temporary output directory we'll be using for this test run. + + Returns: + The path to the temporary testing directory for workers tests. + """ + return create_testing_dir(temp_output_dir, "workers_testing") + + +@pytest.fixture +def basic_config() -> FixtureDict[str, Any]: + """ + Fixture that provides a basic CeleryWorker configuration dictionary. + + Returns: + A dictionary representing a minimal valid CeleryWorker config. + """ + return { + "args": "", + "queues": ["queue1", "queue2"], + "batch": {"nodes": 1}, + "machines": [], + } + + +@pytest.fixture +def dummy_env(workers_testing_dir: FixtureStr) -> FixtureDict[str, str]: + """ + Fixture that provides a mock environment dictionary with OUTPUT_PATH set. + + Args: + workers_testing_dir: The path to the temporary testing directory for workers tests. + + Returns: + A dictionary simulating environment variables, including OUTPUT_PATH. + """ + return {"OUTPUT_PATH": workers_testing_dir} + + +@pytest.fixture +def mock_db(mocker: MockerFixture) -> MerlinDatabase: + """ + Fixture that patches the MerlinDatabase constructor. + + This prevents CeleryWorker from writing to the real Merlin database during + unit tests. Returns a mock instance of MerlinDatabase. + + Args: + mocker: Pytest mocker fixture. + + Returns: + A mocked MerlinDatabase instance. + """ + return mocker.patch("merlin.workers.celery_worker.MerlinDatabase") + + +def test_constructor_sets_fields_and_calls_db_create( + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MerlinDatabase, +): + """ + Test that CeleryWorker constructor sets all fields correctly and triggers database creation. + + This test verifies that: + - The worker fields (name, args, queues, batch, machines, overlap) are set from config. + - The MerlinDatabase.create method is called with the correct arguments. + + Args: + basic_config: A minimal configuration dictionary for the worker. + dummy_env: A dictionary simulating the environment variables. + mock_db: A mocked MerlinDatabase to prevent real database interaction. + """ + worker = CeleryWorker("worker1", basic_config, dummy_env, overlap=True) + + assert worker.name == "worker1" + assert worker.args == "" + assert worker.queues == ["queue1", "queue2"] + assert worker.batch == {"nodes": 1} + assert worker.machines == [] + assert worker.overlap is True + + mock_db.return_value.create.assert_called_once_with("logical_worker", "worker1", ["queue1", "queue2"]) + + +def test_verify_args_adds_name_and_logging_flags( + mocker: MockerFixture, + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MerlinDatabase, +): + """ + Test that `_verify_args()` appends required flags to the Celery args string. + + This test ensures that the `-n ` and `-l ` flags are added + to the worker's CLI args if they are not already present. It also verifies + that warnings are logged if the worker is configured for parallel batch execution + but missing concurrency-related flags. + + NOTE: Although the mock_db fixture is not directly used in this test, it is required + to prevent the constructor from making real database writes during CeleryWorker + instantiation. + + Args: + mocker: Pytest mocker fixture. + basic_config: Fixture providing a basic CeleryWorker configuration. + dummy_env: Fixture providing a mock environment dictionary. + mock_db: Mocked MerlinDatabase to avoid real database writes. + """ + mocker.patch("merlin.workers.celery_worker.batch_check_parallel", return_value=True) + mock_logger = mocker.patch("merlin.workers.celery_worker.LOG") + worker = CeleryWorker("w1", basic_config, dummy_env) + + worker._verify_args() + + assert "-n w1" in worker.args + assert "-l" in worker.args + assert mock_logger.warning.called + + +def test_get_launch_command_returns_expanded_command( + mocker: MockerFixture, + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MerlinDatabase, +): + """ + Test that `get_launch_command()` constructs a valid Celery command. + + This test verifies that the command string returned by `get_launch_command()` + includes a Celery invocation and is properly constructed using the + `batch_worker_launch` utility. It mocks the batch launcher to ensure + consistent output. + + NOTE: Although the mock_db fixture is not directly used in this test, it is required + to prevent the constructor from making real database writes during CeleryWorker + instantiation. + + Args: + mocker: Pytest mocker fixture. + basic_config: Fixture providing a basic CeleryWorker configuration. + dummy_env: Fixture providing a mock environment dictionary. + mock_db: Mocked MerlinDatabase to avoid real database writes. + """ + mocker.patch("merlin.workers.celery_worker.batch_worker_launch", return_value="celery -A ...") + worker = CeleryWorker("w2", basic_config, dummy_env) + + cmd = worker.get_launch_command("--override", disable_logs=True) + + assert isinstance(cmd, str) + assert "celery" in cmd + + +def test_should_launch_rejects_if_machine_check_fails( + mocker: MockerFixture, + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MerlinDatabase, +): + """ + Test that `should_launch` returns False if the machine check fails. + + This test simulates a scenario where `check_machines` returns False, + indicating that the current machine is not authorized to launch the worker. + It verifies that `should_launch` correctly rejects launching in this case. + + NOTE: Although the mock_db fixture is not directly used in this test, it is required + to prevent the constructor from making real database writes during CeleryWorker + instantiation. + + Args: + mocker: Pytest mocker fixture. + basic_config: Configuration dictionary containing the list of valid machines. + dummy_env: Environment variable dictionary (unused in this test). + mock_db: Mocked MerlinDatabase to avoid real database writes. + """ + basic_config["machines"] = ["host1"] + mocker.patch("merlin.workers.celery_worker.check_machines", return_value=False) + + worker = CeleryWorker("w3", basic_config, dummy_env) + result = worker.should_launch() + + assert result is False + + +def test_should_launch_rejects_if_output_path_missing( + mocker: MockerFixture, + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MerlinDatabase, +): + """ + Test that `should_launch` returns False if the output path does not exist. + + This test verifies that `should_launch` refuses to launch if the `OUTPUT_PATH` + specified in the environment does not exist, even when the machine check passes. + + NOTE: Although the mock_db fixture is not directly used in this test, it is required + to prevent the constructor from making real database writes during CeleryWorker + instantiation. + + Args: + mocker: Pytest mocker fixture. + basic_config: Configuration dictionary including machine constraints. + dummy_env: Environment variable dictionary containing an invalid output path. + mock_db: Mocked MerlinDatabase to avoid real database writes. + """ + basic_config["machines"] = ["host1"] + dummy_env["OUTPUT_PATH"] = "/nonexistent" + mocker.patch("merlin.workers.celery_worker.check_machines", return_value=True) + mocker.patch("os.path.exists", return_value=False) + + worker = CeleryWorker("w4", basic_config, dummy_env) + result = worker.should_launch() + + assert result is False + + +def test_should_launch_rejects_due_to_running_queues( + mocker: MockerFixture, + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MerlinDatabase, +): + """ + Test that `should_launch` returns False when a conflicting queue is already running. + + This test simulates the scenario where one of the worker's queues is already active + in the system. The `get_running_queues` function is patched to return a list of + active queues containing "queue1", which matches the worker's queue configuration. + + NOTE: Although the mock_db fixture is not directly used in this test, it is required + to prevent the constructor from making real database writes during CeleryWorker + instantiation. + + Args: + mocker: Pytest mocker fixture. + basic_config: Fixture providing base worker config. + dummy_env: Fixture providing environment variables. + mock_db: Fixture for the Merlin database mock. + """ + mocker.patch("merlin.study.celeryadapter.get_running_queues", return_value=["queue1"]) + + worker = CeleryWorker("w5", basic_config, dummy_env) + result = worker.should_launch() + + assert result is False + + +def test_launch_worker_runs_if_should_launch( + mocker: MockerFixture, + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MerlinDatabase, +): + """ + Test that `launch_worker` executes the launch command if `should_launch` returns True. + + This test verifies that when a worker passes the `should_launch` check, it constructs + a launch command and executes it via `subprocess.Popen`. Both the launch condition + and the command are mocked to avoid side effects. It also confirms that a debug + log message is emitted during execution. + + NOTE: Although the mock_db fixture is not directly used in this test, it is required + to prevent the constructor from making real database writes during CeleryWorker + instantiation. + + Args: + mocker: Pytest mocker fixture. + basic_config: Fixture providing base worker config. + dummy_env: Fixture providing environment variables. + mock_db: Fixture for the Merlin database mock. + """ + mocker.patch.object(CeleryWorker, "should_launch", return_value=True) + mocker.patch.object(CeleryWorker, "get_launch_command", return_value="echo hello") + mock_popen = mocker.patch("merlin.workers.celery_worker.subprocess.Popen") + mock_logger = mocker.patch("merlin.workers.celery_worker.LOG") + + worker = CeleryWorker("w6", basic_config, dummy_env) + worker.launch_worker() + + mock_popen.assert_called_once() + assert mock_logger.debug.called + + +def test_launch_worker_raises_if_popen_fails( + mocker: MockerFixture, + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MerlinDatabase, +): + """ + Test that `launch_worker` raises `MerlinWorkerLaunchError` when `subprocess.Popen` fails. + + This test simulates a failure in launching a worker by patching `Popen` to raise an `OSError`. + It verifies that the appropriate exception is raised and that the failure is not silently ignored. + + NOTE: Although the mock_db fixture is not directly used in this test, it is required + to prevent the constructor from making real database writes during CeleryWorker + instantiation. + + Args: + mocker: Pytest mocker fixture. + basic_config: Basic configuration dictionary fixture. + dummy_env: Dummy environment dictionary fixture. + mock_db: Mocked MerlinDatabase object. + """ + mocker.patch.object(CeleryWorker, "should_launch", return_value=True) + mocker.patch.object(CeleryWorker, "get_launch_command", return_value="fail") + mocker.patch("merlin.workers.celery_worker.subprocess.Popen", side_effect=OSError("boom")) + mocker.patch("merlin.workers.celery_worker.LOG") + + worker = CeleryWorker("w7", basic_config, dummy_env) + + with pytest.raises(MerlinWorkerLaunchError): + worker.launch_worker() + + +def test_get_metadata_returns_expected_dict( + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MerlinDatabase, +): + """ + Test that `get_metadata` returns the expected dictionary with worker configuration. + + This test constructs a `CeleryWorker` and calls `get_metadata`, verifying that + the returned dictionary matches the fields set during initialization. + + NOTE: Although the mock_db fixture is not directly used in this test, it is required + to prevent the constructor from making real database writes during CeleryWorker + instantiation. + + Args: + basic_config: Basic configuration dictionary fixture. + dummy_env: Dummy environment dictionary fixture. + mock_db: Mocked MerlinDatabase object. + """ + worker = CeleryWorker("meta_worker", basic_config, dummy_env) + + metadata = worker.get_metadata() + + assert metadata["name"] == "meta_worker" + assert metadata["queues"] == ["queue1", "queue2"] + assert metadata["args"] == "" + assert metadata["machines"] == [] + assert metadata["batch"] == {"nodes": 1} diff --git a/tests/unit/workers/test_worker.py b/tests/unit/workers/test_worker.py new file mode 100644 index 00000000..c6c27166 --- /dev/null +++ b/tests/unit/workers/test_worker.py @@ -0,0 +1,89 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `merlin/workers/worker.py` module. +""" + +import os + +from pytest_mock import MockerFixture + +from merlin.workers.worker import MerlinWorker + + +class DummyMerlinWorker(MerlinWorker): + def get_launch_command(self, override_args: str = "") -> str: + return f"run_worker --name {self.name} {override_args}" + + def launch_worker(self): + return f"Launching {self.name}" + + def get_metadata(self) -> dict: + return {"name": self.name, "config": self.config} + + +def test_init_sets_attributes(): + """ + Test that the constructor sets name, config, and env correctly. + """ + name = "test_worker" + config = {"foo": "bar"} + env = {"TEST_ENV": "123"} + + worker = DummyMerlinWorker(name, config, env) + + assert worker.name == name + assert worker.config == config + assert worker.env == env + + +def test_init_uses_os_environ_when_env_none(mocker: MockerFixture): + """ + Test that os.environ is copied when no env is provided. + + Args: + mocker: Pytest mocker fixture. + """ + mock_environ = {"MY_VAR": "xyz"} + mocker.patch.dict("os.environ", mock_environ, clear=True) + + worker = DummyMerlinWorker("w", {}, None) + + assert "MY_VAR" in worker.env + assert worker.env["MY_VAR"] == "xyz" + assert worker.env is not os.environ # ensure it's a copy + + +def test_get_launch_command_returns_expected_string(): + """ + Test that get_launch_command builds the correct shell string. + """ + worker = DummyMerlinWorker("dummy", {}, {}) + cmd = worker.get_launch_command("--debug") + + assert "--debug" in cmd + assert "dummy" in cmd + + +def test_launch_worker_returns_expected_string(): + """ + Test that launch_worker returns a string indicating launch. + """ + worker = DummyMerlinWorker("dummy", {}, {}) + result = worker.launch_worker() + assert result == "Launching dummy" + + +def test_get_metadata_returns_expected_dict(): + """ + Test that get_metadata returns the correct metadata dictionary. + """ + config = {"foo": "bar"} + worker = DummyMerlinWorker("dummy", config, {}) + meta = worker.get_metadata() + + assert meta == {"name": "dummy", "config": config} diff --git a/tests/unit/workers/test_worker_factory.py b/tests/unit/workers/test_worker_factory.py new file mode 100644 index 00000000..856b7a9b --- /dev/null +++ b/tests/unit/workers/test_worker_factory.py @@ -0,0 +1,124 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `merlin/workers/worker_factory.py` module. +""" + +import pytest + +from pytest_mock import MockerFixture + +from merlin.exceptions import MerlinWorkerNotSupportedError +from merlin.workers.worker_factory import WorkerFactory +from merlin.workers.worker import MerlinWorker + + +class DummyCeleryWorker(MerlinWorker): + def __init__(self, *args, **kwargs): + pass + + def get_launch_command(self): + pass + + def launch_worker(self): + pass + + def get_metadata(self): + pass + + +class DummyOtherWorker(MerlinWorker): + def __init__(self, *args, **kwargs): + pass + + def get_launch_command(self): + pass + + def launch_worker(self): + pass + + def get_metadata(self): + pass + + +class TestWorkerFactory: + """ + Test suite for the `WorkerFactory`. + + This class tests that the worker factory correctly registers, resolves, instantiates, + and reports supported Merlin workers. It uses mocking to isolate worker behavior + and focuses on the factory's interface and logic. + """ + + @pytest.fixture + def worker_factory(self, mocker: MockerFixture) -> WorkerFactory: + """ + An instance of the `WorkerFactory` class. Resets on each test. + + Args: + mocker: PyTest mocker fixture. + + Returns: + An instance of the `WorkerFactory` class for testing. + """ + mocker.patch("merlin.workers.worker_factory.CeleryWorker", DummyCeleryWorker) + return WorkerFactory() + + def test_list_available_workers(self, worker_factory: WorkerFactory): + """ + Test that `list_available` returns the correct set of built-in workers. + + Args: + worker_factory: An instance of the `WorkerFactory` class for testing. + """ + available = worker_factory.list_available() + assert set(available) == {"celery"} + + def test_create_valid_worker(self, worker_factory: WorkerFactory): + """ + Test that `create` returns a valid worker instance for a registered name. + + Args: + worker_factory: An instance of the `WorkerFactory` class for testing. + """ + instance = worker_factory.create("celery") + assert isinstance(instance, DummyCeleryWorker) + + def test_create_invalid_worker_raises(self, worker_factory: WorkerFactory): + """ + Test that `create` raises `MerlinWorkerNotSupportedError` for unknown workers. + + Args: + worker_factory: An instance of the `WorkerFactory` class for testing. + """ + with pytest.raises(MerlinWorkerNotSupportedError, match="unknown_worker"): + worker_factory.create("unknown_worker") + + def test_invalid_registration_type_error(self, worker_factory: WorkerFactory): + """ + Test that trying to register a non-MerlinWorker raises TypeError. + + Args: + worker_factory: An instance of the `WorkerFactory` class for testing. + """ + + class NotAWorker: + pass + + with pytest.raises(TypeError, match="must inherit from MerlinWorker"): + worker_factory.register("fake_worker", NotAWorker) + + def test_create_valid_worker_with_alias(self, worker_factory: WorkerFactory): + """ + Test that aliases are resolved to canonical worker names. + + Args: + worker_factory: An instance of the `WorkerFactory` class for testing. + """ + worker_factory.register("other", DummyOtherWorker, aliases=["alt", "legacy"]) + instance = worker_factory.create("alt") + assert isinstance(instance, DummyOtherWorker) From 272a0a7be08c3e0a6db4c41159f7d7ceb352b4bd Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 7 Aug 2025 12:31:07 -0700 Subject: [PATCH 52/91] change imports for Celery worker and handler in tests --- tests/unit/workers/handlers/test_celery_handler.py | 2 +- tests/unit/workers/test_celery_worker.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py index 4fc838de..9e48c566 100644 --- a/tests/unit/workers/handlers/test_celery_handler.py +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -12,7 +12,7 @@ from typing import Dict, List from merlin.workers.celery_worker import CeleryWorker -from merlin.workers.handlers.celery_handler import CeleryWorkerHandler +from merlin.workers.handlers import CeleryWorkerHandler class DummyCeleryWorker(CeleryWorker): diff --git a/tests/unit/workers/test_celery_worker.py b/tests/unit/workers/test_celery_worker.py index 424e7ff0..d915e71a 100644 --- a/tests/unit/workers/test_celery_worker.py +++ b/tests/unit/workers/test_celery_worker.py @@ -16,7 +16,7 @@ from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.exceptions import MerlinWorkerLaunchError -from merlin.workers.celery_worker import CeleryWorker +from merlin.workers import CeleryWorker from tests.fixture_types import FixtureCallable, FixtureDict, FixtureStr From 4d5efd45e37367903ca902731b9da9596d8b2966 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 7 Aug 2025 12:33:49 -0700 Subject: [PATCH 53/91] run fix-style --- tests/unit/workers/handlers/test_celery_handler.py | 7 +++++-- tests/unit/workers/handlers/test_handler_factory.py | 1 - tests/unit/workers/handlers/test_worker_handler.py | 5 +++-- tests/unit/workers/test_celery_worker.py | 13 ++++++------- tests/unit/workers/test_worker.py | 2 +- tests/unit/workers/test_worker_factory.py | 3 +-- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py index 9e48c566..d610372e 100644 --- a/tests/unit/workers/handlers/test_celery_handler.py +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -8,9 +8,10 @@ Tests for the `merlin/workers/handlers/celery_handler.py` module. """ -import pytest from typing import Dict, List +import pytest + from merlin.workers.celery_worker import CeleryWorker from merlin.workers.handlers import CeleryWorkerHandler @@ -50,7 +51,9 @@ def workers(self) -> List[DummyCeleryWorker]: DummyCeleryWorker("worker2"), ] - def test_echo_only_prints_commands(self, handler: CeleryWorkerHandler, workers: List[DummyCeleryWorker], capsys: pytest.CaptureFixture): + def test_echo_only_prints_commands( + self, handler: CeleryWorkerHandler, workers: List[DummyCeleryWorker], capsys: pytest.CaptureFixture + ): """ Test that `launch_workers` prints launch commands when `echo_only=True`. diff --git a/tests/unit/workers/handlers/test_handler_factory.py b/tests/unit/workers/handlers/test_handler_factory.py index 9cda5040..df48122c 100644 --- a/tests/unit/workers/handlers/test_handler_factory.py +++ b/tests/unit/workers/handlers/test_handler_factory.py @@ -9,7 +9,6 @@ """ import pytest - from pytest_mock import MockerFixture from merlin.exceptions import MerlinWorkerHandlerNotSupportedError diff --git a/tests/unit/workers/handlers/test_worker_handler.py b/tests/unit/workers/handlers/test_worker_handler.py index 4ad1d30b..3b66a79c 100644 --- a/tests/unit/workers/handlers/test_worker_handler.py +++ b/tests/unit/workers/handlers/test_worker_handler.py @@ -8,9 +8,10 @@ Tests for the `merlin/workers/handlers/worker_handler.py` module. """ -import pytest from typing import Any +import pytest + from merlin.workers.handlers.worker_handler import MerlinWorkerHandler from merlin.workers.worker import MerlinWorker @@ -24,7 +25,7 @@ def launch_worker(self) -> str: def get_metadata(self) -> dict: return {} - + class DummyWorkerHandler(MerlinWorkerHandler): def __init__(self): diff --git a/tests/unit/workers/test_celery_worker.py b/tests/unit/workers/test_celery_worker.py index d915e71a..b2537e53 100644 --- a/tests/unit/workers/test_celery_worker.py +++ b/tests/unit/workers/test_celery_worker.py @@ -8,10 +8,9 @@ Tests for the `merlin/workers/celery_worker.py` module. """ -import os -import pytest from typing import Any +import pytest from pytest_mock import MockerFixture from merlin.db_scripts.merlin_db import MerlinDatabase @@ -253,8 +252,8 @@ def test_should_launch_rejects_due_to_running_queues( """ Test that `should_launch` returns False when a conflicting queue is already running. - This test simulates the scenario where one of the worker's queues is already active - in the system. The `get_running_queues` function is patched to return a list of + This test simulates the scenario where one of the worker's queues is already active + in the system. The `get_running_queues` function is patched to return a list of active queues containing "queue1", which matches the worker's queue configuration. NOTE: Although the mock_db fixture is not directly used in this test, it is required @@ -284,9 +283,9 @@ def test_launch_worker_runs_if_should_launch( """ Test that `launch_worker` executes the launch command if `should_launch` returns True. - This test verifies that when a worker passes the `should_launch` check, it constructs - a launch command and executes it via `subprocess.Popen`. Both the launch condition - and the command are mocked to avoid side effects. It also confirms that a debug + This test verifies that when a worker passes the `should_launch` check, it constructs + a launch command and executes it via `subprocess.Popen`. Both the launch condition + and the command are mocked to avoid side effects. It also confirms that a debug log message is emitted during execution. NOTE: Although the mock_db fixture is not directly used in this test, it is required diff --git a/tests/unit/workers/test_worker.py b/tests/unit/workers/test_worker.py index c6c27166..a34933d4 100644 --- a/tests/unit/workers/test_worker.py +++ b/tests/unit/workers/test_worker.py @@ -24,7 +24,7 @@ def launch_worker(self): def get_metadata(self) -> dict: return {"name": self.name, "config": self.config} - + def test_init_sets_attributes(): """ diff --git a/tests/unit/workers/test_worker_factory.py b/tests/unit/workers/test_worker_factory.py index 856b7a9b..02ddb974 100644 --- a/tests/unit/workers/test_worker_factory.py +++ b/tests/unit/workers/test_worker_factory.py @@ -9,12 +9,11 @@ """ import pytest - from pytest_mock import MockerFixture from merlin.exceptions import MerlinWorkerNotSupportedError -from merlin.workers.worker_factory import WorkerFactory from merlin.workers.worker import MerlinWorker +from merlin.workers.worker_factory import WorkerFactory class DummyCeleryWorker(MerlinWorker): From 799d667561c562b3f183bd09840407c83c116798 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 7 Aug 2025 12:44:37 -0700 Subject: [PATCH 54/91] attempt to fix broken unit tests --- .../workers/handlers/test_celery_handler.py | 8 ++++++- tests/unit/workers/test_celery_worker.py | 21 ++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py index d610372e..abf910c0 100644 --- a/tests/unit/workers/handlers/test_celery_handler.py +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -9,8 +9,10 @@ """ from typing import Dict, List +from unittest.mock import MagicMock import pytest +from pytest_mock import MockerFixture from merlin.workers.celery_worker import CeleryWorker from merlin.workers.handlers import CeleryWorkerHandler @@ -43,9 +45,13 @@ class TestCeleryWorkerHandler: @pytest.fixture def handler(self) -> CeleryWorkerHandler: return CeleryWorkerHandler() + + @pytest.fixture + def mock_db(self, mocker: MockerFixture) -> MagicMock: + return mocker.patch("merlin.workers.celery_worker.MerlinDatabase") @pytest.fixture - def workers(self) -> List[DummyCeleryWorker]: + def workers(self, mock_db: MagicMock) -> List[DummyCeleryWorker]: return [ DummyCeleryWorker("worker1"), DummyCeleryWorker("worker2"), diff --git a/tests/unit/workers/test_celery_worker.py b/tests/unit/workers/test_celery_worker.py index b2537e53..f2038694 100644 --- a/tests/unit/workers/test_celery_worker.py +++ b/tests/unit/workers/test_celery_worker.py @@ -9,6 +9,7 @@ """ from typing import Any +from unittest.mock import MagicMock import pytest from pytest_mock import MockerFixture @@ -65,7 +66,7 @@ def dummy_env(workers_testing_dir: FixtureStr) -> FixtureDict[str, str]: @pytest.fixture -def mock_db(mocker: MockerFixture) -> MerlinDatabase: +def mock_db(mocker: MockerFixture) -> MagicMock: """ Fixture that patches the MerlinDatabase constructor. @@ -84,7 +85,7 @@ def mock_db(mocker: MockerFixture) -> MerlinDatabase: def test_constructor_sets_fields_and_calls_db_create( basic_config: FixtureDict[str, Any], dummy_env: FixtureDict[str, str], - mock_db: MerlinDatabase, + mock_db: MagicMock, ): """ Test that CeleryWorker constructor sets all fields correctly and triggers database creation. @@ -114,7 +115,7 @@ def test_verify_args_adds_name_and_logging_flags( mocker: MockerFixture, basic_config: FixtureDict[str, Any], dummy_env: FixtureDict[str, str], - mock_db: MerlinDatabase, + mock_db: MagicMock, ): """ Test that `_verify_args()` appends required flags to the Celery args string. @@ -149,7 +150,7 @@ def test_get_launch_command_returns_expanded_command( mocker: MockerFixture, basic_config: FixtureDict[str, Any], dummy_env: FixtureDict[str, str], - mock_db: MerlinDatabase, + mock_db: MagicMock, ): """ Test that `get_launch_command()` constructs a valid Celery command. @@ -182,7 +183,7 @@ def test_should_launch_rejects_if_machine_check_fails( mocker: MockerFixture, basic_config: FixtureDict[str, Any], dummy_env: FixtureDict[str, str], - mock_db: MerlinDatabase, + mock_db: MagicMock, ): """ Test that `should_launch` returns False if the machine check fails. @@ -214,7 +215,7 @@ def test_should_launch_rejects_if_output_path_missing( mocker: MockerFixture, basic_config: FixtureDict[str, Any], dummy_env: FixtureDict[str, str], - mock_db: MerlinDatabase, + mock_db: MagicMock, ): """ Test that `should_launch` returns False if the output path does not exist. @@ -247,7 +248,7 @@ def test_should_launch_rejects_due_to_running_queues( mocker: MockerFixture, basic_config: FixtureDict[str, Any], dummy_env: FixtureDict[str, str], - mock_db: MerlinDatabase, + mock_db: MagicMock, ): """ Test that `should_launch` returns False when a conflicting queue is already running. @@ -278,7 +279,7 @@ def test_launch_worker_runs_if_should_launch( mocker: MockerFixture, basic_config: FixtureDict[str, Any], dummy_env: FixtureDict[str, str], - mock_db: MerlinDatabase, + mock_db: MagicMock, ): """ Test that `launch_worker` executes the launch command if `should_launch` returns True. @@ -314,7 +315,7 @@ def test_launch_worker_raises_if_popen_fails( mocker: MockerFixture, basic_config: FixtureDict[str, Any], dummy_env: FixtureDict[str, str], - mock_db: MerlinDatabase, + mock_db: MagicMock, ): """ Test that `launch_worker` raises `MerlinWorkerLaunchError` when `subprocess.Popen` fails. @@ -346,7 +347,7 @@ def test_launch_worker_raises_if_popen_fails( def test_get_metadata_returns_expected_dict( basic_config: FixtureDict[str, Any], dummy_env: FixtureDict[str, str], - mock_db: MerlinDatabase, + mock_db: MagicMock, ): """ Test that `get_metadata` returns the expected dictionary with worker configuration. From bfe02a2b02ed5e3396c266df95e59014594a2872 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 7 Aug 2025 12:52:02 -0700 Subject: [PATCH 55/91] fix style --- tests/unit/workers/handlers/test_celery_handler.py | 2 +- tests/unit/workers/test_celery_worker.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py index abf910c0..1fc42b7a 100644 --- a/tests/unit/workers/handlers/test_celery_handler.py +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -45,7 +45,7 @@ class TestCeleryWorkerHandler: @pytest.fixture def handler(self) -> CeleryWorkerHandler: return CeleryWorkerHandler() - + @pytest.fixture def mock_db(self, mocker: MockerFixture) -> MagicMock: return mocker.patch("merlin.workers.celery_worker.MerlinDatabase") diff --git a/tests/unit/workers/test_celery_worker.py b/tests/unit/workers/test_celery_worker.py index f2038694..73e67b1c 100644 --- a/tests/unit/workers/test_celery_worker.py +++ b/tests/unit/workers/test_celery_worker.py @@ -14,7 +14,6 @@ import pytest from pytest_mock import MockerFixture -from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.exceptions import MerlinWorkerLaunchError from merlin.workers import CeleryWorker from tests.fixture_types import FixtureCallable, FixtureDict, FixtureStr From 51db25c1b3ae909031eac3fef8a46e7f981641d6 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 7 Aug 2025 13:17:33 -0700 Subject: [PATCH 56/91] add mocked merlin db to broken test --- tests/unit/cli/commands/database/test_delete_subcommand.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/cli/commands/database/test_delete_subcommand.py b/tests/unit/cli/commands/database/test_delete_subcommand.py index 3a164795..b59c5e94 100644 --- a/tests/unit/cli/commands/database/test_delete_subcommand.py +++ b/tests/unit/cli/commands/database/test_delete_subcommand.py @@ -180,13 +180,14 @@ def test_process_command_delete_all_entities_no_filters( mock_merlin_db.return_value.delete_all.assert_called_once_with("run") -def test_process_command_unrecognized_type_logs_error(command: DatabaseDeleteCommand, mocker: MockerFixture): +def test_process_command_unrecognized_type_logs_error(command: DatabaseDeleteCommand, mocker: MockerFixture, mock_merlin_db: MagicMock): """ Test that an error is logged when `delete_type` is unrecognized. Args: command: Instance of `DatabaseDeleteCommand`. mocker: Pytest mocker fixture. + mock_merlin_db: Mocked `MerlinDatabase` class. """ mock_log = mocker.patch("merlin.cli.commands.database.delete.LOG") args = Namespace(delete_type="bad-type", local=False) From 45e7e64ddfc29085bc67a141104e793db8a55892 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 7 Aug 2025 13:18:13 -0700 Subject: [PATCH 57/91] run fix-style --- tests/unit/cli/commands/database/test_delete_subcommand.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/cli/commands/database/test_delete_subcommand.py b/tests/unit/cli/commands/database/test_delete_subcommand.py index b59c5e94..6c03964c 100644 --- a/tests/unit/cli/commands/database/test_delete_subcommand.py +++ b/tests/unit/cli/commands/database/test_delete_subcommand.py @@ -180,7 +180,9 @@ def test_process_command_delete_all_entities_no_filters( mock_merlin_db.return_value.delete_all.assert_called_once_with("run") -def test_process_command_unrecognized_type_logs_error(command: DatabaseDeleteCommand, mocker: MockerFixture, mock_merlin_db: MagicMock): +def test_process_command_unrecognized_type_logs_error( + command: DatabaseDeleteCommand, mocker: MockerFixture, mock_merlin_db: MagicMock +): """ Test that an error is logged when `delete_type` is unrecognized. From 0a4de9c2ded6f084145784bfafe66d5ba41afd68 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 7 Aug 2025 13:24:50 -0700 Subject: [PATCH 58/91] fix susbcriptable error in test --- tests/unit/workers/handlers/test_worker_handler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/workers/handlers/test_worker_handler.py b/tests/unit/workers/handlers/test_worker_handler.py index 3b66a79c..c3b358ff 100644 --- a/tests/unit/workers/handlers/test_worker_handler.py +++ b/tests/unit/workers/handlers/test_worker_handler.py @@ -8,7 +8,7 @@ Tests for the `merlin/workers/handlers/worker_handler.py` module. """ -from typing import Any +from typing import Any, Dict, List import pytest @@ -23,7 +23,7 @@ def get_launch_command(self, override_args: str = "") -> str: def launch_worker(self) -> str: return "launched" - def get_metadata(self) -> dict: + def get_metadata(self) -> Dict: return {} @@ -34,7 +34,7 @@ def __init__(self): self.stopped = False self.queried = False - def launch_workers(self, workers: list[MerlinWorker], **kwargs): + def launch_workers(self, workers: List[MerlinWorker], **kwargs): self.started = True self.last_workers = workers return [worker.launch_worker() for worker in workers] From f6865c700f8257a3dffeba1c6c7b84adc6a13031 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Mon, 18 Aug 2025 11:37:26 -0700 Subject: [PATCH 59/91] first pass at adding new worker formatter classes --- merlin/workers/formatters/__init__.py | 5 + .../workers/formatters/compact_formatter.py | 74 ++++ .../workers/formatters/formatter_factory.py | 84 ++++ merlin/workers/formatters/json_formatter.py | 102 +++++ merlin/workers/formatters/rich_formatter.py | 403 ++++++++++++++++++ merlin/workers/formatters/worker_formatter.py | 25 ++ 6 files changed, 693 insertions(+) create mode 100644 merlin/workers/formatters/__init__.py create mode 100644 merlin/workers/formatters/compact_formatter.py create mode 100644 merlin/workers/formatters/formatter_factory.py create mode 100644 merlin/workers/formatters/json_formatter.py create mode 100644 merlin/workers/formatters/rich_formatter.py create mode 100644 merlin/workers/formatters/worker_formatter.py diff --git a/merlin/workers/formatters/__init__.py b/merlin/workers/formatters/__init__.py new file mode 100644 index 00000000..3232b50b --- /dev/null +++ b/merlin/workers/formatters/__init__.py @@ -0,0 +1,5 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## diff --git a/merlin/workers/formatters/compact_formatter.py b/merlin/workers/formatters/compact_formatter.py new file mode 100644 index 00000000..40ebb3d3 --- /dev/null +++ b/merlin/workers/formatters/compact_formatter.py @@ -0,0 +1,74 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" + +""" + +from typing import List, Dict + +from rich.console import Console + +from merlin.workers.formatters.worker_formatter import WorkerFormatter + + +class CompactWorkerFormatter(WorkerFormatter): + """Simple text formatter for CI/scripting environments.""" + + def format_and_display(self, logical_workers: List, filters: Dict, merlin_db, console: Console = None): + """Format and display worker information as simple text.""" + if console is None: + console = Console() + + stats = self._get_worker_statistics(logical_workers, merlin_db) + + # Simple text summary + console.print(f"Workers: {stats['physical_running']}/{stats['total_physical']} running") + + if filters: + filter_text = ", ".join([f"{k}={','.join(v)}" for k, v in filters.items()]) + console.print(f"Filters: {filter_text}") + + console.print() + + # Simple list format + for logical_worker in logical_workers: + worker_name = logical_worker.get_name() + physical_worker_ids = logical_worker.get_physical_workers() + + if not physical_worker_ids: + console.print(f"{worker_name}: NO INSTANCES") + else: + physical_workers = [ + merlin_db.get("physical_worker", pid) for pid in physical_worker_ids + ] + + for physical_worker in physical_workers: + status = str(physical_worker.get_status()).replace("WorkerStatus.", "") + host = physical_worker.get_host() or "unknown" + pid = physical_worker.get_pid() or "N/A" + console.print(f"{worker_name}@{host}: {status} (PID: {pid})") + + def _get_worker_statistics(self, logical_workers, merlin_db) -> Dict: + """Calculate basic worker statistics.""" + stats = { + 'total_physical': 0, + 'physical_running': 0, + } + + for logical_worker in logical_workers: + physical_worker_ids = logical_worker.get_physical_workers() + physical_workers = [ + merlin_db.get("physical_worker", pid) for pid in physical_worker_ids + ] + + for physical_worker in physical_workers: + stats['total_physical'] += 1 + status = str(physical_worker.get_status()).replace("WorkerStatus.", "") + if status == "RUNNING": + stats['physical_running'] += 1 + + return stats diff --git a/merlin/workers/formatters/formatter_factory.py b/merlin/workers/formatters/formatter_factory.py new file mode 100644 index 00000000..c5018fe5 --- /dev/null +++ b/merlin/workers/formatters/formatter_factory.py @@ -0,0 +1,84 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" + +""" + +from typing import Any, Type + +from merlin.abstracts import MerlinBaseFactory +from merlin.exceptions import MerlinWorkerFormatterNotSupportedError +from merlin.workers.formatters.compact_formatter import CompactWorkerFormatter +from merlin.workers.formatters.json_formatter import JSONWorkerFormatter +from merlin.workers.formatters.rich_formatter import RichWorkerFormatter +from merlin.workers.formatters.worker_formatter import WorkerFormatter + + +class WorkerFormatterFactory(MerlinBaseFactory): + """ + Factory class for managing and instantiating supported Merlin worker formatters. + + This subclass of `MerlinBaseFactory` handles registration, validation, + and instantiation of worker formatters (e.g., rich, json). + + Attributes: + _registry (Dict[str, WorkerFormatter]): Maps canonical formatter names to formatter classes. + _aliases (Dict[str, str]): Maps legacy or alternate names to canonical formatter names. + + Methods: + register: Register a new formatter class and optional aliases. + list_available: Return a list of supported formatter names. + create: Instantiate a formatter class by name or alias. + get_component_info: Return metadata about a registered formatter. + """ + + def _register_builtins(self): + """ + Register built-in worker formatter implementations. + """ + self.register("compact", CompactWorkerFormatter) + self.register("json", JSONWorkerFormatter) + self.register("rich", RichWorkerFormatter) + + def _validate_component(self, component_class: Any): + """ + Ensure registered component is a subclass of WorkerFormatter. + + Args: + component_class: The class to validate. + + Raises: + TypeError: If the component does not subclass WorkerFormatter. + """ + if not issubclass(component_class, WorkerFormatter): + raise TypeError(f"{component_class} must inherit from WorkerFormatter") + + def _entry_point_group(self) -> str: + """ + Entry point group used for discovering worker formatter plugins. + + Returns: + The entry point namespace for Merlin worker formatter plugins. + """ + return "merlin.workers.formatters" + + def _raise_component_error_class(self, msg: str) -> Type[Exception]: + """ + Raise an appropriate exception when an invalid component is requested. + + Subclasses should override this to raise more specific exceptions. + + Args: + msg: The message to add to the error being raised. + + Raises: + A subclass of Exception (e.g., ValueError by default). + """ + raise MerlinWorkerFormatterNotSupportedError(msg) + + +worker_formatter_factory = WorkerFormatterFactory() diff --git a/merlin/workers/formatters/json_formatter.py b/merlin/workers/formatters/json_formatter.py new file mode 100644 index 00000000..7026e155 --- /dev/null +++ b/merlin/workers/formatters/json_formatter.py @@ -0,0 +1,102 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" + +""" + +import json +from datetime import datetime +from typing import List, Dict + +from rich.console import Console + +from merlin.workers.formatters.worker_formatter import WorkerFormatter + + +class JSONWorkerFormatter(WorkerFormatter): + """JSON formatter for programmatic consumption.""" + + def format_and_display(self, logical_workers: List, filters: Dict, merlin_db, console: Console = None): + """Format and display worker information as JSON.""" + if console is None: + console = Console() + + data = { + "filters": filters, + "timestamp": datetime.now().isoformat(), + "logical_workers": [], + "summary": self._get_worker_statistics(logical_workers, merlin_db) + } + + for logical_worker in logical_workers: + logical_data = { + "name": logical_worker.get_name(), + "queues": [q[len("[merlin]_"):] if q.startswith("[merlin]_") else q + for q in sorted(logical_worker.get_queues())], + "physical_workers": [] + } + + physical_worker_ids = logical_worker.get_physical_workers() + physical_workers = [ + merlin_db.get("physical_worker", pid) for pid in physical_worker_ids + ] + + for physical_worker in physical_workers: + physical_data = { + "id": physical_worker.get_id() if hasattr(physical_worker, 'get_id') else None, + "name": physical_worker.get_name(), + "host": physical_worker.get_host(), + "pid": physical_worker.get_pid(), + "status": str(physical_worker.get_status()).replace("WorkerStatus.", ""), + "restart_count": physical_worker.get_restart_count(), + "latest_start_time": physical_worker.get_latest_start_time().isoformat() if physical_worker.get_latest_start_time() else None, + "heartbeat_timestamp": physical_worker.get_heartbeat_timestamp().isoformat() if physical_worker.get_heartbeat_timestamp() else None + } + logical_data["physical_workers"].append(physical_data) + + data["logical_workers"].append(logical_data) + + console.print(json.dumps(data, indent=2)) + + def _get_worker_statistics(self, logical_workers, merlin_db) -> Dict: + """Calculate comprehensive worker statistics for JSON output.""" + stats = { + 'total_logical': len(logical_workers), + 'logical_with_instances': 0, + 'logical_without_instances': 0, + 'total_physical': 0, + 'physical_running': 0, + 'physical_stopped': 0, + 'physical_stalled': 0, + 'physical_rebooting': 0 + } + + for logical_worker in logical_workers: + physical_worker_ids = logical_worker.get_physical_workers() + + if physical_worker_ids: + stats['logical_with_instances'] += 1 + physical_workers = [ + merlin_db.get("physical_worker", pid) for pid in physical_worker_ids + ] + + for physical_worker in physical_workers: + stats['total_physical'] += 1 + status = str(physical_worker.get_status()).replace("WorkerStatus.", "") + + if status == "RUNNING": + stats['physical_running'] += 1 + elif status == "STOPPED": + stats['physical_stopped'] += 1 + elif status == "STALLED": + stats['physical_stalled'] += 1 + elif status == "REBOOTING": + stats['physical_rebooting'] += 1 + else: + stats['logical_without_instances'] += 1 + + return stats diff --git a/merlin/workers/formatters/rich_formatter.py b/merlin/workers/formatters/rich_formatter.py new file mode 100644 index 00000000..406e1b52 --- /dev/null +++ b/merlin/workers/formatters/rich_formatter.py @@ -0,0 +1,403 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" + +""" + +from datetime import datetime, timedelta +from typing import List, Dict + +from rich.console import Console +from rich.table import Table +from rich.text import Text +from rich.panel import Panel +from rich.columns import Columns + +from merlin.workers.formatters.worker_formatter import WorkerFormatter + + +class RichWorkerFormatter(WorkerFormatter): + """Rich-based formatter with responsive tables and panels.""" + + def format_and_display(self, logical_workers: List, filters: Dict, merlin_db, console: Console = None): + """Format and display worker information using Rich components.""" + if console is None: + console = Console() + + # Calculate statistics + stats = self._get_worker_statistics(logical_workers, merlin_db) + console_width = console.size.width + + console.print() # Empty line + + # For very narrow terminals (< 60 chars), use compact view + if console_width < 60: + console.print("[bold cyan]Worker Status[/bold cyan]") + if filters: + filter_text = " | ".join([ + f"{k}: {','.join(v)}" for k, v in filters.items() + ]) + console.print(f"[dim]Filters: {filter_text}[/dim]") + + console.print(f"[bold]Summary:[/bold] {stats['physical_running']}/{stats['total_physical']} running, {stats['logical_without_instances']} logical workers without instances\n") + + compact_view = self._build_compact_view(logical_workers, merlin_db) + console.print(compact_view) + return + + # For wider terminals, use panel + table layout + summary_panels = self._build_summary_panels(stats, filters) + if console_width >= 100 and len(summary_panels) <= 3: + console.print(Columns(summary_panels, equal=True)) + else: + # Stack panels vertically for narrow terminals + for panel in summary_panels: + console.print(panel) + + console.print() # Empty line + + # Show physical workers table if any exist + if stats['total_physical'] > 0: + physical_table = self._build_physical_workers_table(logical_workers, console_width, merlin_db) + console.print(physical_table) + console.print() # Empty line + + # Show logical workers without instances if any exist + if stats['logical_without_instances'] > 0: + no_instances_table = self._build_logical_workers_without_instances_table(logical_workers, console_width) + console.print(no_instances_table) + + def _format_status(self, status) -> Text: + """Format worker status with icons and colors.""" + status_str = str(status).replace("WorkerStatus.", "") + + status_config = { + "RUNNING": ("✓", "bold green"), + "STALLED": ("⚠", "bold yellow"), + "STOPPED": ("✗", "bold red"), + "REBOOTING": ("↻", "bold cyan"), + } + + icon, color = status_config.get(status_str.upper(), ("?", "white")) + return Text(f"{icon} {status_str}", style=color) + + def _format_uptime_or_downtime(self, physical_worker) -> str: + """Format uptime for running workers, downtime for stopped workers.""" + status = str(physical_worker.get_status()).replace("WorkerStatus.", "") + + if status == "RUNNING": + start_time = physical_worker.get_latest_start_time() + if start_time: + uptime = datetime.now() - start_time + return self._format_time_duration(uptime) + else: + # For stopped workers, show downtime if stop_time is available + stop_time = getattr(physical_worker, 'get_stop_time', lambda: None)() + if stop_time: + downtime = datetime.now() - stop_time + return f"down {self._format_time_duration(downtime)}" + else: + return "stopped" + + return "-" + + def _format_time_duration(self, duration: timedelta) -> str: + """Format time duration in human-readable format.""" + if duration.days > 0: + return f"{duration.days}d {duration.seconds // 3600}h" + elif duration.seconds >= 3600: + return f"{duration.seconds // 3600}h {(duration.seconds % 3600) // 60}m" + elif duration.seconds >= 60: + return f"{duration.seconds // 60}m" + else: + return f"{duration.seconds}s" + + def _format_last_heartbeat(self, heartbeat_timestamp: datetime) -> Text: + """Format last heartbeat with color coding based on recency.""" + if not heartbeat_timestamp: + return Text("-", style="dim") + + time_diff = datetime.now() - heartbeat_timestamp + + if time_diff < timedelta(minutes=1): + return Text("Just now", style="green") + elif time_diff < timedelta(minutes=5): + return Text(f"{int(time_diff.total_seconds() // 60)}m ago", style="yellow") + elif time_diff < timedelta(hours=1): + return Text(f"{int(time_diff.total_seconds() // 60)}m ago", style="orange3") + else: + return Text(f"{int(time_diff.total_seconds() // 3600)}h ago", style="red") + + def _get_worker_statistics(self, logical_workers, merlin_db) -> Dict: + """Calculate comprehensive worker statistics.""" + stats = { + 'total_logical': len(logical_workers), + 'logical_with_instances': 0, + 'logical_without_instances': 0, + 'total_physical': 0, + 'physical_running': 0, + 'physical_stopped': 0, + 'physical_stalled': 0, + 'physical_rebooting': 0 + } + + for logical_worker in logical_workers: + physical_worker_ids = logical_worker.get_physical_workers() + + if physical_worker_ids: + stats['logical_with_instances'] += 1 + physical_workers = [ + merlin_db.get("physical_worker", pid) for pid in physical_worker_ids + ] + + for physical_worker in physical_workers: + stats['total_physical'] += 1 + status = str(physical_worker.get_status()).replace("WorkerStatus.", "") + + if status == "RUNNING": + stats['physical_running'] += 1 + elif status == "STOPPED": + stats['physical_stopped'] += 1 + elif status == "STALLED": + stats['physical_stalled'] += 1 + elif status == "REBOOTING": + stats['physical_rebooting'] += 1 + else: + stats['logical_without_instances'] += 1 + + return stats + + def _build_summary_panels(self, stats: Dict, filters: Dict) -> List[Panel]: + """Build summary panels showing different aspects of worker status.""" + panels = [] + + # Filter information + if filters: + filter_parts = [] + if "queues" in filters: + filter_parts.append(f"Queues: {', '.join(filters['queues'])}") + if "workers" in filters: + filter_parts.append(f"Workers: {', '.join(filters['workers'])}") + + filter_text = "\n".join(filter_parts) + panels.append(Panel(filter_text, title="[bold blue]Applied Filters[/bold blue]", border_style="blue")) + + # Logical worker summary + logical_summary = f"Total: [bold white]{stats['total_logical']}[/bold white]\n" \ + f"With Instances: [bold green]{stats['logical_with_instances']}[/bold green]\n" \ + f"Without Instances: [bold dim]{stats['logical_without_instances']}[/bold dim]" + + panels.append(Panel(logical_summary, title="[bold cyan]Logical Workers[/bold cyan]", border_style="cyan")) + + # Physical worker summary + if stats['total_physical'] > 0: + physical_summary = f"Total: [bold white]{stats['total_physical']}[/bold white]\n" \ + f"Running: [bold green]{stats['physical_running']}[/bold green]\n" \ + f"Stopped: [bold red]{stats['physical_stopped']}[/bold red]" + + if stats['physical_stalled'] > 0: + physical_summary += f"\nStalled: [bold yellow]{stats['physical_stalled']}[/bold yellow]" + if stats['physical_rebooting'] > 0: + physical_summary += f"\nRebooting: [bold cyan]{stats['physical_rebooting']}[/bold cyan]" + + panels.append(Panel(physical_summary, title="[bold magenta]Physical Instances[/bold magenta]", border_style="magenta")) + + return panels + + def _build_physical_workers_table(self, logical_workers, console_width: int, merlin_db) -> Table: + """Build table showing only physical worker instances, responsive to terminal width.""" + table = Table( + show_header=True, + header_style="bold white", + title="[bold magenta]Physical Worker Instances[/bold magenta]", + ) + + # Responsive column configuration based on terminal width + if console_width < 80: + # Narrow terminal - show only essential columns + table.add_column("Worker", style="bold cyan", max_width=12) + table.add_column("Host", style="blue", max_width=10) + table.add_column("PID", justify="right", style="yellow", width=8) + table.add_column("Status", style="bold", width=10) + elif console_width < 120: + # Medium terminal - show core columns + table.add_column("Worker", style="bold cyan", max_width=15) + table.add_column("Instance", style="bold magenta", max_width=15, no_wrap=True) + table.add_column("Host", style="blue", max_width=12) + table.add_column("PID", justify="right", style="yellow", width=8) + table.add_column("Status", style="bold", width=10) + table.add_column("Runtime", style="cyan", width=8) + else: + # Wide terminal - show all columns + table.add_column("Logical Worker", style="bold cyan", max_width=15) + table.add_column("Queues", style="green", max_width=20, no_wrap=True) + table.add_column("Instance Name", style="bold magenta", max_width=20, no_wrap=True) + table.add_column("Host", style="blue", max_width=12) + table.add_column("PID", justify="right", style="yellow", width=8) + table.add_column("Status", style="bold", width=10) + table.add_column("Runtime", style="cyan", width=8) + table.add_column("Heartbeat", style="bright_blue", width=10) + table.add_column("Restarts", justify="right", style="red", width=8) + + # Collect all physical workers + physical_worker_rows = [] + + for logical_worker in logical_workers: + worker_name = logical_worker.get_name() + queues_str = ", ".join( + q[len("[merlin]_"):] if q.startswith("[merlin]_") else q + for q in sorted(logical_worker.get_queues()) + ) + + physical_worker_ids = logical_worker.get_physical_workers() + physical_workers = [ + merlin_db.get("physical_worker", pid) for pid in physical_worker_ids + ] + + for physical_worker in physical_workers: + status = str(physical_worker.get_status()).replace("WorkerStatus.", "") + physical_worker_rows.append({ + 'logical_name': worker_name, + 'queues': queues_str, + 'physical_worker': physical_worker, + 'status': status + }) + + # Sort: running first, then by logical worker name, then by instance name + physical_worker_rows.sort(key=lambda row: ( + 0 if row['status'] == "RUNNING" else 1, + row['logical_name'], + row['physical_worker'].get_name() or "" + )) + + for row in physical_worker_rows: + physical_worker = row['physical_worker'] + status = physical_worker.get_status() + status_str = str(status).replace("WorkerStatus.", "") + + # Only show heartbeat for running workers + heartbeat_text = "-" + if status_str == "RUNNING": + heartbeat_text = str(self._format_last_heartbeat(physical_worker.get_heartbeat_timestamp())) + + # Responsive row data based on terminal width + if console_width < 80: + # Narrow: Worker, Host, PID, Status + table.add_row( + row['logical_name'][:12], # Truncate long names + (physical_worker.get_host() or "-")[:10], + str(physical_worker.get_pid()) if physical_worker.get_pid() else "-", + self._format_status(status) + ) + elif console_width < 120: + # Medium: Worker, Instance, Host, PID, Status, Runtime + instance_name = physical_worker.get_name() or "-" + if len(instance_name) > 15: + instance_name = instance_name[:12] + "..." + + table.add_row( + row['logical_name'], + instance_name, + physical_worker.get_host() or "-", + str(physical_worker.get_pid()) if physical_worker.get_pid() else "-", + self._format_status(status), + self._format_uptime_or_downtime(physical_worker) + ) + else: + # Wide: All columns + table.add_row( + row['logical_name'], + row['queues'], + physical_worker.get_name() or "-", + physical_worker.get_host() or "-", + str(physical_worker.get_pid()) if physical_worker.get_pid() else "-", + self._format_status(status), + self._format_uptime_or_downtime(physical_worker), + heartbeat_text, + str(physical_worker.get_restart_count()) + ) + + return table + + def _build_compact_view(self, logical_workers, merlin_db) -> str: + """Build a compact text view for very narrow terminals.""" + output_lines = [] + + for logical_worker in logical_workers: + worker_name = logical_worker.get_name() + physical_worker_ids = logical_worker.get_physical_workers() + + if not physical_worker_ids: + output_lines.append(f"[bold white]{worker_name}[/bold white]: [bold red]NO INSTANCES[/bold red]") + else: + physical_workers = [ + merlin_db.get("physical_worker", pid) for pid in physical_worker_ids + ] + + for physical_worker in physical_workers: + status = str(physical_worker.get_status()).replace("WorkerStatus.", "") + host = physical_worker.get_host() or "?" + pid = physical_worker.get_pid() or "-" + + status_icon = "✓" if status == "RUNNING" else "✗" + color = "green" if status == "RUNNING" else "red" + + output_lines.append( + f"[bold white]{worker_name}[/bold white]@[blue]{host}[/blue] " + f"[{color}]{status_icon} {status}[/{color}] (PID: {pid})" + ) + + return "\n".join(output_lines) + + def _build_logical_workers_without_instances_table(self, logical_workers, console_width) -> Table: + """Build table showing logical workers without physical instances, responsive to width.""" + table = Table( + show_header=True, + header_style="bold white", + title="[bold yellow]Logical Workers Without Instances[/bold yellow]" + ) + + # Responsive columns + if console_width < 80: + table.add_column("Worker", style="bold white", max_width=20) + table.add_column("Status", style="bold red", width=12) + else: + table.add_column("Worker Name", style="bold white", max_width=25) + table.add_column("Queues", style="green", max_width=30, no_wrap=True) + table.add_column("Status", style="bold red", width=12) + + workers_without_instances = [] + + for logical_worker in logical_workers: + physical_worker_ids = logical_worker.get_physical_workers() + if not physical_worker_ids: + workers_without_instances.append(logical_worker) + + # Sort by name + workers_without_instances.sort(key=lambda w: w.get_name()) + + for logical_worker in workers_without_instances: + queues_str = ", ".join( + q[len("[merlin]_"):] if q.startswith("[merlin]_") else q + for q in sorted(logical_worker.get_queues()) + ) + + if console_width < 80: + # Narrow: Just worker name and status + table.add_row( + logical_worker.get_name(), + Text("NO INSTANCES", style="bold red") + ) + else: + # Wide: Include queues + table.add_row( + logical_worker.get_name(), + queues_str, + Text("NO INSTANCES", style="bold red") + ) + + return table diff --git a/merlin/workers/formatters/worker_formatter.py b/merlin/workers/formatters/worker_formatter.py new file mode 100644 index 00000000..ab8f7846 --- /dev/null +++ b/merlin/workers/formatters/worker_formatter.py @@ -0,0 +1,25 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" + +""" + +from abc import ABC, abstractmethod +from typing import List, Dict + +from rich.console import Console + +from merlin.db_scripts.merlin_db import MerlinDatabase + + +class WorkerFormatter(ABC): + """Base formatter for worker query output.""" + + @abstractmethod + def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase, console: Console = None): + """Format and display worker information.""" + pass From 4d9d31e86c73af6763bff0967d52dbac51de0200 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Mon, 18 Aug 2025 11:38:53 -0700 Subject: [PATCH 60/91] link new formatters to new query-workers refactor --- merlin/cli/commands/query_workers.py | 15 +++++- .../entities/physical_worker_entity.py | 2 +- merlin/exceptions/__init__.py | 6 +++ merlin/workers/handlers/celery_handler.py | 48 +++++++++++++++++-- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/merlin/cli/commands/query_workers.py b/merlin/cli/commands/query_workers.py index f09225ee..df5acfb4 100644 --- a/merlin/cli/commands/query_workers.py +++ b/merlin/cli/commands/query_workers.py @@ -23,6 +23,8 @@ from merlin.router import query_workers from merlin.spec.specification import MerlinSpec from merlin.utils import verify_filepath +from merlin.workers.handlers.handler_factory import worker_handler_factory +from merlin.workers.formatters.formatter_factory import worker_formatter_factory LOG = logging.getLogger("merlin") @@ -68,6 +70,14 @@ def add_parser(self, subparsers: ArgumentParser): default=None, help="Regex match for specific workers to query.", ) + format_default = "rich" + query.add_argument( + "-f", + "--format", + choices=worker_formatter_factory.list_available(), + default=format_default, + help=f"Output format. Default: {format_default}", + ) def process_command(self, args: Namespace): """ @@ -87,6 +97,7 @@ def process_command(self, args: Namespace): # Get the workers from the spec file if --spec provided worker_names = [] + spec = None if args.spec: spec_path = verify_filepath(args.spec) spec = MerlinSpec.load_specification(spec_path) @@ -96,4 +107,6 @@ def process_command(self, args: Namespace): LOG.warning(f"Worker '{worker_name}' is unexpanded. Target provenance spec instead?") LOG.debug(f"Searching for the following workers to stop based on the spec {args.spec}: {worker_names}") - query_workers(args.task_server, worker_names, args.queues, args.workers) + task_server = spec.merlin["resources"]["task_server"] if spec else args.task_server + worker_handler = worker_handler_factory.create(task_server) + worker_handler.query_workers(args.format, queues=args.queues, workers=worker_names) diff --git a/merlin/db_scripts/entities/physical_worker_entity.py b/merlin/db_scripts/entities/physical_worker_entity.py index b866ca62..18c8f7e2 100644 --- a/merlin/db_scripts/entities/physical_worker_entity.py +++ b/merlin/db_scripts/entities/physical_worker_entity.py @@ -294,7 +294,7 @@ def get_restart_count(self) -> int: The number of times that this worker has been restarted. """ self.reload_data() - return self.entity_info.restart_count + return int(float(self.entity_info.restart_count)) def increment_restart_count(self): """ diff --git a/merlin/exceptions/__init__.py b/merlin/exceptions/__init__.py index e5c49610..ec919dda 100644 --- a/merlin/exceptions/__init__.py +++ b/merlin/exceptions/__init__.py @@ -114,6 +114,12 @@ class MerlinWorkerHandlerNotSupportedError(Exception): """ +class MerlinWorkerFormatterNotSupportedError(Exception): + """ + Exception to signal that the provided worker formatter is not supported by Merlin. + """ + + class MerlinWorkerNotSupportedError(Exception): """ Exception to signal that the provided worker is not supported by Merlin. diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index a73d6f8f..80bb6eb0 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -14,9 +14,13 @@ """ import logging -from typing import List +from typing import Dict, List +from rich.console import Console + +from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.workers import CeleryWorker +from merlin.workers.formatters.formatter_factory import worker_formatter_factory from merlin.workers.handlers.worker_handler import MerlinWorkerHandler @@ -38,6 +42,12 @@ class CeleryWorkerHandler(MerlinWorkerHandler): query_workers: Return a basic summary of Celery worker status. """ + def __init__(self): + """ + """ + super().__init__() + self.merlin_db = MerlinDatabase() + def launch_workers(self, workers: List[CeleryWorker], **kwargs): """ Launch or echo Celery workers with optional override behavior. @@ -68,7 +78,39 @@ def stop_workers(self): Attempt to stop Celery workers. """ - def query_workers(self): + def _build_filters(self, queues: List[str], workers: List[str]) -> Dict[str, List[str]]: + """ + Build filters dictionary for database queries. + + Args: + queues: List of queue names to filter by. + workers: List of worker names to filter by. + + Returns: + Dictionary containing filter criteria. + """ + filters = {} + if queues: + filters["queues"] = queues + if workers: + filters["workers"] = workers + return filters + + def query_workers(self, formatter: str, queues: List[str] = None, workers: List[str] = None): """ - Query the status of Celery workers. + Query the status of Celery workers and display using the configured formatter. + + Args: + queues: List of queue names to filter by (optional). + workers: List of worker names to filter by (optional). """ + # Build filters dictionary + filters = self._build_filters(queues, workers) + + # Retrieve workers from database + logical_workers = self.merlin_db.get_all("logical_worker", filters=filters) + + # Use formatter to display the results + console = Console() + formatter = worker_formatter_factory.create(formatter) + formatter.format_and_display(logical_workers, filters, self.merlin_db, console) From 413c39d679fad545524cd954b8f0f0d60c9e1a49 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 26 Aug 2025 13:02:30 -0700 Subject: [PATCH 61/91] remove _discover_builtins method that's not used and annoying --- merlin/abstracts/factory.py | 14 -------------- tests/unit/abstracts/test_factory.py | 6 ++---- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/merlin/abstracts/factory.py b/merlin/abstracts/factory.py index 22719864..6616d829 100644 --- a/merlin/abstracts/factory.py +++ b/merlin/abstracts/factory.py @@ -124,19 +124,6 @@ def _discover_plugins_via_entry_points(self): except ImportError: LOG.debug("pkg_resources not available for plugin discovery") - def _discover_builtin_modules(self): - """ - Optional hook to discover built-in components by scanning local modules. - - Default implementation does nothing. - - Subclasses can override this method to implement package/module scanning. - """ - LOG.warning( - f"Class {self.__class__.__name__} did not override _discover_builtin_modules(). " - "Built-in module discovery will be skipped." - ) - def _discover_plugins(self): """ Discover and register plugin components via entry points. @@ -144,7 +131,6 @@ def _discover_plugins(self): Subclasses can override this to support more discovery mechanisms. """ self._discover_plugins_via_entry_points() - self._discover_builtin_modules() def _raise_component_error_class(self, msg: str) -> Type[Exception]: """ diff --git a/tests/unit/abstracts/test_factory.py b/tests/unit/abstracts/test_factory.py index a92b1481..2147e511 100644 --- a/tests/unit/abstracts/test_factory.py +++ b/tests/unit/abstracts/test_factory.py @@ -163,17 +163,15 @@ def test_get_component_info_for_invalid_component(self, factory: TestableFactory ): # raises RuntimeError because of `_raise_component_error_class` factory.get_component_info("not_registered") - def test_discover_plugins_calls_both_hooks(self, mocker: MockerFixture, factory: TestableFactory): + def test_discover_plugins_calls_hooks(self, mocker: MockerFixture, factory: TestableFactory): """ - Test that _discover_plugins calls both plugin and module hooks. + Test that _discover_plugins calls plugin hooks. Args: mocker: PyTest mocker fixture. factory: An instance of the dummy `TestableFactory` class for testing. """ plugin_mock = mocker.patch.object(factory, "_discover_plugins_via_entry_points") - builtin_mock = mocker.patch.object(factory, "_discover_builtin_modules") factory._discover_plugins() plugin_mock.assert_called_once() - builtin_mock.assert_called_once() From c420d5ecf40e7ab854157e460179d63569a5a869 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 27 Aug 2025 06:50:14 -0700 Subject: [PATCH 62/91] change database to only store base name of queue --- merlin/cli/commands/run.py | 2 +- merlin/spec/specification.py | 2 +- merlin/workers/celery_worker.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/merlin/cli/commands/run.py b/merlin/cli/commands/run.py index c7b9e927..09f5a4a5 100644 --- a/merlin/cli/commands/run.py +++ b/merlin/cli/commands/run.py @@ -170,7 +170,7 @@ def process_command(self, args: Namespace): ) # Create logical worker entries - step_queue_map = study.expanded_spec.get_task_queues() + step_queue_map = study.expanded_spec.get_task_queues(omit_tag=True) for worker, steps in study.expanded_spec.get_worker_step_map().items(): worker_queues = {step_queue_map[step] for step in steps} logical_worker_entity = merlin_db.create("logical_worker", worker, worker_queues) diff --git a/merlin/spec/specification.py b/merlin/spec/specification.py index 66c58da4..6bd07384 100644 --- a/merlin/spec/specification.py +++ b/merlin/spec/specification.py @@ -1265,7 +1265,7 @@ def build_worker_list(self, workers_to_start: Set[str]) -> List[MerlinWorker]: config = { "args": settings.get("args", ""), "machines": settings.get("machines", []), - "queues": set(self.get_queue_list(settings["steps"])), + "queues": set(self.get_queue_list(settings["steps"], omit_tag=True)), "batch": settings["batch"] if settings["batch"] is not None else self.batch.copy(), } diff --git a/merlin/workers/celery_worker.py b/merlin/workers/celery_worker.py index becd92ba..b928d465 100644 --- a/merlin/workers/celery_worker.py +++ b/merlin/workers/celery_worker.py @@ -81,7 +81,7 @@ def __init__( """ super().__init__(name, config, env) self.args = self.config.get("args", "") - self.queues = self.config.get("queues", {"[merlin]_merlin"}) + self.queues = self.config.get("queues", {"merlin"}) self.batch = self.config.get("batch", {}) self.machines = self.config.get("machines", []) self.overlap = overlap From 0d47eaa864807c58573483cd36ae32ca2f8e9be5 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 27 Aug 2025 06:52:42 -0700 Subject: [PATCH 63/91] get filters working for query-workers command --- merlin/cli/commands/query_workers.py | 9 ++++++--- merlin/db_scripts/entity_managers/entity_manager.py | 7 +++++-- merlin/workers/handlers/__init__.py | 6 +++--- merlin/workers/handlers/celery_handler.py | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/merlin/cli/commands/query_workers.py b/merlin/cli/commands/query_workers.py index df5acfb4..fb5343b3 100644 --- a/merlin/cli/commands/query_workers.py +++ b/merlin/cli/commands/query_workers.py @@ -68,7 +68,7 @@ def add_parser(self, subparsers: ArgumentParser): action="store", nargs="+", default=None, - help="Regex match for specific workers to query.", + help="Specific logical worker names to query.", ) format_default = "rich" query.add_argument( @@ -95,13 +95,16 @@ def process_command(self, args: Namespace): """ print(banner_small) - # Get the workers from the spec file if --spec provided worker_names = [] + if args.workers: + worker_names.extend(args.workers) + + # Get the workers from the spec file if --spec provided spec = None if args.spec: spec_path = verify_filepath(args.spec) spec = MerlinSpec.load_specification(spec_path) - worker_names = spec.get_worker_names() + worker_names.extend(spec.get_worker_names()) for worker_name in worker_names: if "$" in worker_name: LOG.warning(f"Worker '{worker_name}' is unexpanded. Target provenance spec instead?") diff --git a/merlin/db_scripts/entity_managers/entity_manager.py b/merlin/db_scripts/entity_managers/entity_manager.py index b5854f89..b81e69b4 100644 --- a/merlin/db_scripts/entity_managers/entity_manager.py +++ b/merlin/db_scripts/entity_managers/entity_manager.py @@ -126,7 +126,7 @@ def _matches_filters(self, entity: T, filters: Dict) -> bool: entity: The entity instance to check against the filters. filters: A dictionary of filter keys and values used to narrow down the query results. Filter keys must correspond to entries in the `_filter_accessor_map` defined - by the subclass. Values are compared against the entity’s corresponding attributes + by the subclass. Values are compared against the entity's corresponding attributes or methods (e.g., {"name": "foo"}, {"queues": ["queue1", "queue2"]}). Returns: @@ -147,8 +147,11 @@ def _matches_filters(self, entity: T, filters: Dict) -> bool: # Case where filter is a list if isinstance(expected, list): + # Normalize actual to a list for comparison + actual_values = actual if isinstance(actual, (list, set)) else [actual] + # Match if any expected value is in the actual list (e.g., queues) - if not isinstance(actual, (list, set)) or not any(val in actual for val in expected): + if not any(val in actual_values for val in expected): return False # Case where filter is str or bool else: diff --git a/merlin/workers/handlers/__init__.py b/merlin/workers/handlers/__init__.py index 30969814..f1a79e28 100644 --- a/merlin/workers/handlers/__init__.py +++ b/merlin/workers/handlers/__init__.py @@ -15,11 +15,11 @@ interface while enabling future integration with additional systems such as Kafka. Modules: - handler_factory.py: Factory for registering and instantiating Merlin worker + handler_factory: Factory for registering and instantiating Merlin worker handler implementations. - worker_handler.py: Abstract base class that defines the interface for all Merlin + worker_handler: Abstract base class that defines the interface for all Merlin worker handlers. - celery_handler.py: Celery-specific implementation of the worker handler interface. + celery_handler: Celery-specific implementation of the worker handler interface. """ diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index 80bb6eb0..316a4b68 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -93,7 +93,7 @@ def _build_filters(self, queues: List[str], workers: List[str]) -> Dict[str, Lis if queues: filters["queues"] = queues if workers: - filters["workers"] = workers + filters["name"] = workers return filters def query_workers(self, formatter: str, queues: List[str] = None, workers: List[str] = None): From 64ac2c850e31d42b97128aca60201ecac13ec491 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 27 Aug 2025 06:57:42 -0700 Subject: [PATCH 64/91] finalize worker formatters --- merlin/workers/__init__.py | 6 +- merlin/workers/formatters/__init__.py | 24 + .../workers/formatters/compact_formatter.py | 74 -- .../workers/formatters/formatter_factory.py | 7 +- merlin/workers/formatters/json_formatter.py | 104 +- merlin/workers/formatters/rich_formatter.py | 980 +++++++++++++----- merlin/workers/formatters/worker_formatter.py | 109 +- 7 files changed, 926 insertions(+), 378 deletions(-) delete mode 100644 merlin/workers/formatters/compact_formatter.py diff --git a/merlin/workers/__init__.py b/merlin/workers/__init__.py index 06a19c24..34fb591e 100644 --- a/merlin/workers/__init__.py +++ b/merlin/workers/__init__.py @@ -20,12 +20,12 @@ responsible for launching and managing groups of workers. Modules: - worker.py: Defines the `MerlinWorker` abstract base class, which represents a single + worker: Defines the `MerlinWorker` abstract base class, which represents a single task server worker and provides a common interface for launching and configuring worker instances. - celery_worker.py: Implements `CeleryWorker`, a concrete subclass of `MerlinWorker` that uses + celery_worker: Implements `CeleryWorker`, a concrete subclass of `MerlinWorker` that uses Celery to process tasks from configured queues. Supports local and batch launch modes. - worker_factory.py: Defines the `WorkerFactory`, which manages the registration, validation, + worker_factory: Defines the `WorkerFactory`, which manages the registration, validation, and instantiation of individual worker implementations such as `CeleryWorker`. Supports plugin discovery via entry points. """ diff --git a/merlin/workers/formatters/__init__.py b/merlin/workers/formatters/__init__.py index 3232b50b..50ef8e18 100644 --- a/merlin/workers/formatters/__init__.py +++ b/merlin/workers/formatters/__init__.py @@ -3,3 +3,27 @@ # Project developers. See top-level LICENSE and COPYRIGHT files for dates and # other details. No copyright assignment is required to contribute to Merlin. ############################################################################## + +""" +Merlin Worker Formatters Package. + +This package provides classes and utilities for formatting and displaying +Merlin worker information. Worker formatters can render logical and physical +worker data in multiple formats, including JSON for programmatic consumption +and Rich for interactive terminal visualization. The package also includes +a factory for managing supported formatter implementations. + +Modules: + formatter_factory: WorkerFormatterFactory for managing supported worker + formatters. Allows creation by name or alias and ensures consistent + handling of different output formats. + json_formatter: JSONWorkerFormatter that outputs structured, machine-readable + JSON data, including detailed logical and physical worker records, + applied filters, timestamps, and summary statistics. + rich_formatter: RichWorkerFormatter and related layout classes for formatting + and displaying worker information in the terminal with responsive layouts, + summary panels, compact views, and rich styling. Adapts to terminal width + for optimal readability. + worker_formatter: WorkerFormatter abstract base class defining the standard + interface for all worker formatters. +""" diff --git a/merlin/workers/formatters/compact_formatter.py b/merlin/workers/formatters/compact_formatter.py deleted file mode 100644 index 40ebb3d3..00000000 --- a/merlin/workers/formatters/compact_formatter.py +++ /dev/null @@ -1,74 +0,0 @@ -############################################################################## -# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin -# Project developers. See top-level LICENSE and COPYRIGHT files for dates and -# other details. No copyright assignment is required to contribute to Merlin. -############################################################################## - -""" - -""" - -from typing import List, Dict - -from rich.console import Console - -from merlin.workers.formatters.worker_formatter import WorkerFormatter - - -class CompactWorkerFormatter(WorkerFormatter): - """Simple text formatter for CI/scripting environments.""" - - def format_and_display(self, logical_workers: List, filters: Dict, merlin_db, console: Console = None): - """Format and display worker information as simple text.""" - if console is None: - console = Console() - - stats = self._get_worker_statistics(logical_workers, merlin_db) - - # Simple text summary - console.print(f"Workers: {stats['physical_running']}/{stats['total_physical']} running") - - if filters: - filter_text = ", ".join([f"{k}={','.join(v)}" for k, v in filters.items()]) - console.print(f"Filters: {filter_text}") - - console.print() - - # Simple list format - for logical_worker in logical_workers: - worker_name = logical_worker.get_name() - physical_worker_ids = logical_worker.get_physical_workers() - - if not physical_worker_ids: - console.print(f"{worker_name}: NO INSTANCES") - else: - physical_workers = [ - merlin_db.get("physical_worker", pid) for pid in physical_worker_ids - ] - - for physical_worker in physical_workers: - status = str(physical_worker.get_status()).replace("WorkerStatus.", "") - host = physical_worker.get_host() or "unknown" - pid = physical_worker.get_pid() or "N/A" - console.print(f"{worker_name}@{host}: {status} (PID: {pid})") - - def _get_worker_statistics(self, logical_workers, merlin_db) -> Dict: - """Calculate basic worker statistics.""" - stats = { - 'total_physical': 0, - 'physical_running': 0, - } - - for logical_worker in logical_workers: - physical_worker_ids = logical_worker.get_physical_workers() - physical_workers = [ - merlin_db.get("physical_worker", pid) for pid in physical_worker_ids - ] - - for physical_worker in physical_workers: - stats['total_physical'] += 1 - status = str(physical_worker.get_status()).replace("WorkerStatus.", "") - if status == "RUNNING": - stats['physical_running'] += 1 - - return stats diff --git a/merlin/workers/formatters/formatter_factory.py b/merlin/workers/formatters/formatter_factory.py index c5018fe5..4767130f 100644 --- a/merlin/workers/formatters/formatter_factory.py +++ b/merlin/workers/formatters/formatter_factory.py @@ -5,14 +5,18 @@ ############################################################################## """ +Worker formatter factory for Merlin. +This module provides the `WorkerFormatterFactory`, a central registry and +factory class for managing supported worker formatter implementations. +It allows clients to create worker formatters by name or alias, ensuring +consistent handling of different output formats (e.g., JSON, Rich). """ from typing import Any, Type from merlin.abstracts import MerlinBaseFactory from merlin.exceptions import MerlinWorkerFormatterNotSupportedError -from merlin.workers.formatters.compact_formatter import CompactWorkerFormatter from merlin.workers.formatters.json_formatter import JSONWorkerFormatter from merlin.workers.formatters.rich_formatter import RichWorkerFormatter from merlin.workers.formatters.worker_formatter import WorkerFormatter @@ -40,7 +44,6 @@ def _register_builtins(self): """ Register built-in worker formatter implementations. """ - self.register("compact", CompactWorkerFormatter) self.register("json", JSONWorkerFormatter) self.register("rich", RichWorkerFormatter) diff --git a/merlin/workers/formatters/json_formatter.py b/merlin/workers/formatters/json_formatter.py index 7026e155..16820510 100644 --- a/merlin/workers/formatters/json_formatter.py +++ b/merlin/workers/formatters/json_formatter.py @@ -5,23 +5,78 @@ ############################################################################## """ +JSON-based worker information formatter for Merlin. +This module provides a JSON formatter for displaying worker information +in a structured, machine-readable format. It is primarily intended for +programmatic consumption by downstream tools, scripts, or external systems +that need to parse and analyze worker data rather than display it in a +human-friendly format. + +The formatter includes:\n + - Detailed records of logical workers and their associated queues + - Physical worker details such as ID, host, PID, status, restart counts, + and timestamps + - Relationships between logical and physical workers + - Applied filters and generation timestamp metadata + - Summary statistics for logical and physical workers """ import json from datetime import datetime -from typing import List, Dict +from typing import List, Dict, Optional from rich.console import Console +from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity +from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.workers.formatters.worker_formatter import WorkerFormatter class JSONWorkerFormatter(WorkerFormatter): - """JSON formatter for programmatic consumption.""" + """ + JSON formatter for programmatic worker information consumption. + + This formatter generates structured JSON output representing logical + and physical worker entities. The output includes worker details, + relationships between logical and physical workers, and comprehensive + statistics. Designed for use cases where downstream tools or scripts + need to parse worker information in a machine-readable format. + + Methods: + format_and_display: Format and print worker information as structured JSON, + including details for logical and physical workers, filters, timestamp, + and summary statistics. + get_worker_statistics: Compute worker statistics, including counts of logical + and physical workers by status, for inclusion in JSON output. + """ - def format_and_display(self, logical_workers: List, filters: Dict, merlin_db, console: Console = None): - """Format and display worker information as JSON.""" + def format_and_display( + self, + logical_workers: List[LogicalWorkerEntity], + filters: Dict, + merlin_db: MerlinDatabase, + console: Optional[Console] = None + ): + """ + Format and display worker information as JSON. + + This method produces JSON output containing:\n + - A record of applied filters + - A timestamp of when the report was generated + - Detailed logical worker entries (name, queues, associated physical workers) + - Detailed physical worker entries (ID, host, PID, status, restart count, timestamps) + - A summary of worker statistics + + Args: + logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): + A list of logical worker entities to format. + filters (Dict): A dictionary of filters applied to the query. + merlin_db (db_scripts.merlin_db.MerlinDatabase): Database interface for retrieving + physical worker details. + console (Optional[Console]): Rich console for printing output. Defaults to a new + console if None. + """ if console is None: console = Console() @@ -29,7 +84,7 @@ def format_and_display(self, logical_workers: List, filters: Dict, merlin_db, co "filters": filters, "timestamp": datetime.now().isoformat(), "logical_workers": [], - "summary": self._get_worker_statistics(logical_workers, merlin_db) + "summary": self.get_worker_statistics(logical_workers, merlin_db) } for logical_worker in logical_workers: @@ -61,42 +116,3 @@ def format_and_display(self, logical_workers: List, filters: Dict, merlin_db, co data["logical_workers"].append(logical_data) console.print(json.dumps(data, indent=2)) - - def _get_worker_statistics(self, logical_workers, merlin_db) -> Dict: - """Calculate comprehensive worker statistics for JSON output.""" - stats = { - 'total_logical': len(logical_workers), - 'logical_with_instances': 0, - 'logical_without_instances': 0, - 'total_physical': 0, - 'physical_running': 0, - 'physical_stopped': 0, - 'physical_stalled': 0, - 'physical_rebooting': 0 - } - - for logical_worker in logical_workers: - physical_worker_ids = logical_worker.get_physical_workers() - - if physical_worker_ids: - stats['logical_with_instances'] += 1 - physical_workers = [ - merlin_db.get("physical_worker", pid) for pid in physical_worker_ids - ] - - for physical_worker in physical_workers: - stats['total_physical'] += 1 - status = str(physical_worker.get_status()).replace("WorkerStatus.", "") - - if status == "RUNNING": - stats['physical_running'] += 1 - elif status == "STOPPED": - stats['physical_stopped'] += 1 - elif status == "STALLED": - stats['physical_stalled'] += 1 - elif status == "REBOOTING": - stats['physical_rebooting'] += 1 - else: - stats['logical_without_instances'] += 1 - - return stats diff --git a/merlin/workers/formatters/rich_formatter.py b/merlin/workers/formatters/rich_formatter.py index 406e1b52..a449659c 100644 --- a/merlin/workers/formatters/rich_formatter.py +++ b/merlin/workers/formatters/rich_formatter.py @@ -5,74 +5,687 @@ ############################################################################## """ +Rich-based worker formatter with responsive layout for Merlin. +This module provides classes and utilities to format and display +logical and physical worker information in a terminal using Rich. +It adapts automatically to different terminal widths, providing +compact views for narrow screens and detailed tables and panels +for wider screens. Key features include: + +- Responsive layouts: Adjusts tables and panels based on terminal width. +- Summary panels: Displays aggregated statistics for logical and + physical workers, including running, stopped, stalled, and rebooting counts. +- Compact view: Condensed text output for narrow terminals where full tables + would not fit. +- Rich styling: Uses colors, icons, and text formatting to make worker + statuses and information visually distinct. + +Classes: + LayoutSize: Enum defining terminal width categories for responsive layouts. + ColumnConfig: Dataclass defining display properties for individual table columns. + LayoutConfig: Dataclass defining full layout configuration for a given size. + ResponsiveLayoutManager: Selects and provides layout configurations based + on terminal width. + RichWorkerFormatter: Formats and renders worker information using Rich + components with responsive layouts. + +This module depends on the Merlin database interface and worker entities +([`LogicalWorkerEntity`][db_scripts.entities.logical_worker_entity.LogicalWorkerEntity] +and [`PhysicalWorkerEntity`][db_scripts.entities.physical_worker_entity.PhysicalWorkerEntity]) +to retrieve and display worker information. """ +from dataclasses import dataclass from datetime import datetime, timedelta -from typing import List, Dict +from enum import Enum +from typing import List, Dict, Callable, Any, Optional +from rich.columns import Columns from rich.console import Console +from rich.panel import Panel from rich.table import Table from rich.text import Text -from rich.panel import Panel -from rich.columns import Columns +from merlin.common.enums import WorkerStatus +from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity +from merlin.db_scripts.entities.physical_worker_entity import PhysicalWorkerEntity +from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.workers.formatters.worker_formatter import WorkerFormatter +class LayoutSize(Enum): + """ + Enumeration of terminal width categories for responsive layouts. + + This enum is used to adapt table and panel rendering based on + the current terminal width, enabling responsive designs that + remain readable across narrow and wide displays. + + Attributes: + COMPACT (str): Very small terminals (< 60 characters wide). + NARROW (str): Narrow terminals (60-79 characters wide). + MEDIUM (str): Standard terminals (80-119 characters wide). + WIDE (str): Wide terminals (>= 120 characters wide). + """ + COMPACT = "compact" # < 60 chars + NARROW = "narrow" # 60-79 chars + MEDIUM = "medium" # 80-119 chars + WIDE = "wide" # >= 120 chars + + +@dataclass +class ColumnConfig: + """ + Configuration for an individual table column. + + Defines how a column should be displayed within a table, + including its label, alignment, width constraints, and + optional formatting logic. + + Attributes: + key (str): The field or attribute name mapped to this column. + title (str): The display name of the column header. + style (str): Text style to apply (e.g., "white", "bold red"). + width (Optional[int]): Fixed width of the column, if specified. + max_width (Optional[int]): Maximum allowed width before truncation or wrapping. + justify (str): Text alignment ("left", "center", or "right"). + no_wrap (bool): Whether to prevent text wrapping in this column. + formatter (Optional[Callable[[Any], Any]]): Optional callable to transform cell + values before display. + """ + key: str + title: str + style: str = "white" + width: Optional[int] = None + max_width: Optional[int] = None + justify: str = "left" + no_wrap: bool = False + formatter: Optional[Callable[[Any], Any]] = None + + +@dataclass +class LayoutConfig: + """ + Configuration for rendering worker information at a given layout size. + + Determines which elements (tables, panels) are displayed and how + they are arranged, based on the current terminal width category. + + Attributes: + size (workers.formatters.rich_formatter.LayoutSize): The layout + size category. + show_summary_panels (bool): Whether to display summary panels. + panels_horizontal (bool): If True, display panels side-by-side + (horizontal), otherwise stack them vertically. + physical_worker_columns (List[workers.formatters.rich_formatter.ColumnConfig]): + Column configurations for physical worker tables. + logical_worker_columns (List[workers.formatters.rich_formatter.ColumnConfig]): + Column configurations for logical worker tables. + use_compact_view (bool): Whether to enable a simplified, space-saving + view of worker information. + """ + size: LayoutSize + show_summary_panels: bool = True + panels_horizontal: bool = True + physical_worker_columns: List[ColumnConfig] = None + logical_worker_columns: List[ColumnConfig] = None + use_compact_view: bool = False + + +class ResponsiveLayoutManager: + """ + Manage and provide responsive layout configurations for terminal output. + + This class adapts the display of worker information (both physical and logical) + to different terminal widths. It selects column visibility, ordering, and + formatting rules depending on the width category (compact, narrow, medium, wide). + + Attributes: + layouts (Dict[workers.formatters.rich_formatter.LayoutSize, workers.formatters.rich_formatter.LayoutConfig]): + A mapping of layout sizes to their respective configurations, including + column definitions and display options. + + Methods: + get_layout_size: Determine the appropriate layout size category based on terminal width. + get_layout_config: Retrieve the full layout configuration object for a given terminal width. + _format_status: Format a worker status into a styled Rich Text object with icons. + """ + + def __init__(self): + """ + Initialize the responsive layout manager. + + Predefines layout configurations for all supported terminal width categories + (compact, narrow, medium, wide). Each configuration specifies which columns + should be shown for physical and logical worker tables, whether summary panels + are displayed, and how they are arranged. + """ + self.layouts = { + LayoutSize.COMPACT: LayoutConfig( + size=LayoutSize.COMPACT, + show_summary_panels=False, + use_compact_view=True, + physical_worker_columns=[], + logical_worker_columns=[] + ), + LayoutSize.NARROW: LayoutConfig( + size=LayoutSize.NARROW, + show_summary_panels=True, + panels_horizontal=False, + physical_worker_columns=[ + ColumnConfig(key="worker", title="Worker", style="bold cyan", max_width=12), + ColumnConfig(key="host", title="Host", style="blue", max_width=10), + ColumnConfig(key="pid", title="PID", style="yellow", width=8, justify="right"), + ColumnConfig(key="status", title="Status", style="bold", width=10, formatter=self._format_status), + ], + logical_worker_columns=[ + ColumnConfig(key="worker", title="Worker", style="bold white", max_width=20), + ColumnConfig(key="status", title="Status", style="bold red", width=12), + ] + ), + LayoutSize.MEDIUM: LayoutConfig( + size=LayoutSize.MEDIUM, + show_summary_panels=True, + panels_horizontal=True, + physical_worker_columns=[ + ColumnConfig(key="worker", title="Worker", style="bold cyan", max_width=15), + ColumnConfig(key="instance", title="Instance", style="bold magenta", max_width=25), + ColumnConfig(key="host", title="Host", style="blue", max_width=12), + ColumnConfig(key="pid", title="PID", style="yellow", width=8, justify="right"), + ColumnConfig(key="status", title="Status", style="bold", width=10, formatter=self._format_status), + ColumnConfig(key="runtime", title="Runtime", style="cyan", width=8), + ], + logical_worker_columns=[ + ColumnConfig(key="worker", title="Worker Name", style="bold white", max_width=25), + ColumnConfig(key="queues", title="Queues", style="green", max_width=30, no_wrap=True), + ColumnConfig(key="status", title="Status", style="bold red", width=12), + ] + ), + LayoutSize.WIDE: LayoutConfig( + size=LayoutSize.WIDE, + show_summary_panels=True, + panels_horizontal=True, + physical_worker_columns=[ + ColumnConfig(key="worker", title="Logical Worker", style="bold cyan", max_width=15), + ColumnConfig(key="queues", title="Queues", style="green", max_width=20, no_wrap=True), + ColumnConfig(key="instance", title="Instance Name", style="bold magenta", max_width=30), + ColumnConfig(key="host", title="Host", style="blue", max_width=12), + ColumnConfig(key="pid", title="PID", style="yellow", width=8, justify="right"), + ColumnConfig(key="status", title="Status", style="bold", width=10, formatter=self._format_status), + ColumnConfig(key="runtime", title="Runtime", style="cyan", width=8), + ColumnConfig(key="heartbeat", title="Heartbeat", style="bright_blue", width=10), + ColumnConfig(key="restarts", title="Restarts", style="red", width=8, justify="right"), + ], + logical_worker_columns=[ + ColumnConfig(key="worker", title="Worker Name", style="bold white", max_width=25), + ColumnConfig(key="queues", title="Queues", style="green", max_width=30, no_wrap=True), + ColumnConfig(key="status", title="Status", style="bold red", width=12), + ] + ) + } + + def get_layout_size(self, width: int) -> LayoutSize: + """ + Determine the layout size category for a given terminal width. + + Args: + width (int): The terminal width in characters. + + Returns: + The corresponding layout size category (COMPACT, NARROW, MEDIUM, or WIDE). + """ + if width < 60: + return LayoutSize.COMPACT + elif width < 80: + return LayoutSize.NARROW + elif width < 120: + return LayoutSize.MEDIUM + else: + return LayoutSize.WIDE + + def get_layout_config(self, width: int) -> LayoutConfig: + """ + Retrieve the layout configuration for a given terminal width. + + Args: + width (int): The terminal width in characters. + + Returns: + The full layout configuration for the determined size, + including column definitions and panel options. + """ + size = self.get_layout_size(width) + return self.layouts[size] + + def _format_status(self, status: WorkerStatus) -> Text: + """ + Format a worker status value into a Rich `Text` object. + + Adds an icon and applies color styling to make worker statuses + visually distinguishable in tables. + + Args: + status (common.enums.WorkerStatus): The worker status value. + + Returns: + A Rich Text object containing the styled status with an icon. + """ + status_str = str(status).replace("WorkerStatus.", "") + + status_config = { + "RUNNING": ("✓", "bold green"), + "STALLED": ("⚠", "bold yellow"), + "STOPPED": ("✗", "bold red"), + "REBOOTING": ("↻", "bold cyan"), + } + + icon, color = status_config.get(status_str.upper(), ("?", "white")) + return Text(f"{icon} {status_str}", style=color) + + class RichWorkerFormatter(WorkerFormatter): - """Rich-based formatter with responsive tables and panels.""" + """ + Format and display worker information using Rich with responsive layouts. + + This class provides a Rich-based implementation of a worker formatter that + adapts to terminal width. It uses responsive tables, panels, and compact + views to display logical and physical worker information in a clear, + visually rich way. Layouts are selected automatically based on terminal + width using a [`ResponsiveLayoutManager`][workers.formatters.rich_formatter.ResponsiveLayoutManager]. + + Attributes: + layout_manager (workers.formatters.rich_formatter.ResponsiveLayoutManager): + The layout manager responsible for selecting column and panel + configurations based on terminal width. + + Methods: + format_and_display: Format and display worker information with responsive Rich components. + get_worker_statistics: Compute summary statistics for logical and physical workers. + _display_compact_view: Display a simplified worker summary for very narrow terminals. + _display_summary_panels: Render summary panels (logical, physical, filters) + depending on layout settings. + _build_responsive_table: Construct a Rich table using the given column configuration and data. + _get_physical_worker_data: Extract detailed physical worker data for table display. + _get_logical_workers_without_instances_data: Extract logical workers that have no + physical instances. + _sort_physical_workers: Sort physical worker data by status (running first), then by + worker and instance name. + _format_status: Format a worker status with icons and Rich color highlighting. + _format_uptime_or_downtime: Return uptime for running workers or downtime for stopped + workers in human-readable format. + _format_time_duration: Format a time duration into a human-readable string (e.g., "2h 15m"). + _format_last_heartbeat: Format last heartbeat timestamp with color coding based on recency. + _build_summary_panels: Build summary panels showing filters, logical workers, and + physical workers. + _build_compact_view: Build a compact text-only view of worker status for very narrowterminals. + """ + + def __init__(self): + """ + Initialize the Rich-based worker formatter. + + This constructor sets up the responsive layout manager + that determines how worker data will be displayed based + on the terminal width. + """ + self.layout_manager = ResponsiveLayoutManager() - def format_and_display(self, logical_workers: List, filters: Dict, merlin_db, console: Console = None): - """Format and display worker information using Rich components.""" + def format_and_display( + self, + logical_workers: List[LogicalWorkerEntity], + filters: Dict, + merlin_db: MerlinDatabase, + console: Optional[Console] = None + ): + """ + Format and display worker information using Rich components. + + This method generates a responsive console output of worker + statistics, tables, and summary panels. The output adapts + to the current terminal width and user-defined filters. + + Args: + logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): + A list of logical worker objects to display and summarize. + filters (Dict): Active filters applied to the display + (e.g., by worker type or status). + merlin_db (db_scripts.merlin_db.MerlinDatabase): Reference to + the Merlin database, used to query worker-related data. + console (Optional[Console]): Rich console instance to + render output. If None, a new console is created. + """ if console is None: console = Console() # Calculate statistics - stats = self._get_worker_statistics(logical_workers, merlin_db) + stats = self.get_worker_statistics(logical_workers, merlin_db) console_width = console.size.width + layout_config = self.layout_manager.get_layout_config(console_width) console.print() # Empty line - # For very narrow terminals (< 60 chars), use compact view - if console_width < 60: - console.print("[bold cyan]Worker Status[/bold cyan]") - if filters: - filter_text = " | ".join([ - f"{k}: {','.join(v)}" for k, v in filters.items() - ]) - console.print(f"[dim]Filters: {filter_text}[/dim]") - - console.print(f"[bold]Summary:[/bold] {stats['physical_running']}/{stats['total_physical']} running, {stats['logical_without_instances']} logical workers without instances\n") - - compact_view = self._build_compact_view(logical_workers, merlin_db) - console.print(compact_view) + # Handle compact view for very narrow terminals + if layout_config.use_compact_view: + self._display_compact_view(logical_workers, merlin_db, filters, stats, console) return - # For wider terminals, use panel + table layout - summary_panels = self._build_summary_panels(stats, filters) - if console_width >= 100 and len(summary_panels) <= 3: - console.print(Columns(summary_panels, equal=True)) - else: - # Stack panels vertically for narrow terminals - for panel in summary_panels: - console.print(panel) - - console.print() # Empty line + # Display summary panels + if layout_config.show_summary_panels: + self._display_summary_panels(stats, filters, layout_config, console) + console.print() # Empty line # Show physical workers table if any exist if stats['total_physical'] > 0: - physical_table = self._build_physical_workers_table(logical_workers, console_width, merlin_db) + physical_table = self._build_responsive_table( + "[bold magenta]Physical Worker Instances[/bold magenta]", + layout_config.physical_worker_columns, + self._get_physical_worker_data(logical_workers, merlin_db), + self._sort_physical_workers + ) console.print(physical_table) console.print() # Empty line # Show logical workers without instances if any exist if stats['logical_without_instances'] > 0: - no_instances_table = self._build_logical_workers_without_instances_table(logical_workers, console_width) + no_instances_table = self._build_responsive_table( + "[bold cyan]Logical Workers Without Instances[/bold cyan]", + layout_config.logical_worker_columns, + self._get_logical_workers_without_instances_data(logical_workers), + lambda data: sorted(data, key=lambda x: x['worker']) + ) console.print(no_instances_table) - def _format_status(self, status) -> Text: - """Format worker status with icons and colors.""" + def _get_queues_str(self, queues: List[str]) -> str: + """ + Given a list of queue names, remove the '[merlin]_' prefix and + combine them into a comma-delimited string. + + Args: + queues (List[str]): The list of queue names to combine. + + Returns: + A comma-delimited string of queues without the '[merlin]_' prefix. + """ + return ", ".join( + q[len("[merlin]_"):] if q.startswith("[merlin]_") else q + for q in sorted(queues) + ) + + def _display_compact_view( + self, + logical_workers: List[LogicalWorkerEntity], + merlin_db: MerlinDatabase, + filters: Dict, + stats: Dict[str, int], + console: Console + ): + """ + Display a compact view of worker information for narrow terminals. + + This fallback view is used when the terminal width is too small + to render full tables or summary panels. It prints a concise + summary of worker status, applied filters, and worker details. + + Args: + logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): + List of logical worker objects to display. + merlin_db (db_scripts.merlin_db.MerlinDatabase): Reference to the Merlin database for + querying data. + filters (Dict): Active filters applied to the display. + stats (Dict[str, int]): Aggregated worker statistics (e.g., running counts). + console (Console): Rich console instance used for output. + """ + console.print("[bold cyan]Worker Status[/bold cyan]") + if filters: + filter_text = " | ".join([ + f"{k}: {','.join(v)}" for k, v in filters.items() + ]) + console.print(f"[dim]Filters: {filter_text}[/dim]") + + console.print(f"[bold]Summary:[/bold] {stats['physical_running']}/{stats['total_physical']} running, {stats['logical_without_instances']} logical workers without instances\n") + + compact_view = self._build_compact_view(logical_workers, merlin_db) + console.print(compact_view) + + def _display_summary_panels( + self, + stats: Dict[str, int], + filters: Dict, + layout_config: LayoutConfig, + console: Console + ): + """ + Display summary panels based on the given layout configuration. + + This method renders one or more Rich panels summarizing worker statistics. + The panels are built dynamically from the provided stats and filters, and + displayed either horizontally in columns or vertically in sequence, + depending on the layout configuration. + + Args: + stats (Dict[str, int]): + A dictionary of aggregated worker statistics to summarize. + filters (Dict): + A dictionary of filter criteria used to generate the summary content. + layout_config (workers.formatters.rich_formatter.LayoutConfig): + An object defining layout preferences (e.g., whether to + arrange panels horizontally). + console (Console): + Rich Console object used to render the panels. + """ + summary_panels = self._build_summary_panels(stats, filters) + + if layout_config.panels_horizontal: + console.print(Columns(summary_panels, equal=True)) + else: + for panel in summary_panels: + console.print(panel) + + def _build_responsive_table( + self, + title: str, + columns: List[ColumnConfig], + data: List[Dict], + sort_func: Callable = None + ) -> Table: + """ + Build and return a Rich table with responsive column configuration. + + This method creates a table with headers, applies styles and sizing rules + based on column configuration, and optionally sorts the data before + rendering. Each row of the table is constructed by extracting values + from the input data and applying any specified formatters. + + Args: + title (str): + Title displayed above the table. + columns (List[workers.formatters.rich_formatter.ColumnConfig]): + A list of column configuration objects defining headers, + widths, alignment, and formatting functions. + data (List[Dict]): + List of dictionaries containing the row data to display. + sort_func (Optional[Callable]): + A function to sort the data before rendering. Defaults to None. + + Returns: + A fully constructed Rich Table object ready for display. + """ + table = Table( + show_header=True, + header_style="bold white", + title=title, + ) + + # Add columns based on configuration + for col in columns: + table.add_column( + col.title, + style=col.style, + width=col.width, + max_width=col.max_width, + justify=col.justify, + no_wrap=col.no_wrap + ) + + # Sort data if sort function provided + if sort_func: + data = sort_func(data) + + # Add rows + for row_data in data: + row_values = [] + for col in columns: + value = row_data.get(col.key, "-") + if col.formatter: + value = col.formatter(value) + row_values.append(value) + table.add_row(*row_values) + + return table + + def _get_physical_worker_data(self, logical_workers: List[LogicalWorkerEntity], merlin_db) -> List[Dict]: + """ + Extract and format physical worker data for table display. + + This method retrieves all physical workers associated with the given + logical workers, queries the database for their details, and formats + the data into a list of dictionaries suitable for table rendering. + Each entry includes identifiers, host, status, runtime, heartbeat, + and restart count. + + Args: + logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): + A list of logical worker entities, each referencing one or more + associated physical workers. + merlin_db (db_scripts.merlin_db.MerlinDatabase): + The database interface used to fetch physical worker entities. + + Returns: + A list of dictionaries, where each dictionary contains:\n + - worker (str): Logical worker name. + - queues (str): Comma-separated queue names without "[merlin]_". + - instance (str): Instance/worker name. + - host (str): Hostname where the worker is running. + - pid (str): Process ID of the worker, or "-" if unavailable. + - status (common.enums.WorkerStatus): Raw worker status object (used for formatting). + - runtime (str): Formatted uptime/downtime string. + - heartbeat (str): Last heartbeat timestamp or "-". + - restarts (str): Number of times the worker has restarted. + - _sort_status (str): String representation of the status + used for sorting. + """ + data = [] + + for logical_worker in logical_workers: + worker_name = logical_worker.get_name() + queues_str = self._get_queues_str(logical_worker.get_queues()) + + physical_worker_ids = logical_worker.get_physical_workers() + physical_workers = [ + merlin_db.get("physical_worker", pid) for pid in physical_worker_ids + ] + + for physical_worker in physical_workers: + status = physical_worker.get_status() + status_str = str(status).replace("WorkerStatus.", "") + + # Only show heartbeat for running workers + heartbeat_text = "-" + if status_str == "RUNNING": + heartbeat_text = str(self._format_last_heartbeat(physical_worker.get_heartbeat_timestamp())) + + instance_name = physical_worker.get_name() or "-" + + data.append({ + 'worker': worker_name, + 'queues': queues_str, + 'instance': instance_name, + 'host': physical_worker.get_host() or "-", + 'pid': str(physical_worker.get_pid()) if physical_worker.get_pid() else "-", + 'status': status, # Raw status for formatter + 'runtime': self._format_uptime_or_downtime(physical_worker), + 'heartbeat': heartbeat_text, + 'restarts': str(physical_worker.get_restart_count()), + '_sort_status': status_str # For sorting + }) + + return data + + def _get_logical_workers_without_instances_data(self, logical_workers: List[LogicalWorkerEntity]) -> List[Dict]: + """ + Extract logical workers that have no associated physical instances. + + This method identifies all logical workers that currently do not have + any physical worker instances. It formats their data into a list of + dictionaries suitable for table rendering, including a status field + indicating "NO INSTANCES". + + Args: + logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): + A list of logical worker entities to check. + + Returns: + A list of dictionaries with the following keys:\n + - worker (str): Name of the logical worker. + - queues (str): Comma-separated list of queues the worker is associated with. + - status (Text): Rich-formatted status indicating no instances. + """ + data = [] + + for logical_worker in logical_workers: + physical_worker_ids = logical_worker.get_physical_workers() + if not physical_worker_ids: + queues_str = self._get_queues_str(logical_worker.get_queues()) + + data.append({ + 'worker': logical_worker.get_name(), + 'queues': queues_str, + 'status': Text("NO INSTANCES", style="bold red") + }) + + return data + + def _sort_physical_workers(self, data: List[Dict]) -> List[Dict]: + """ + Sort a list of physical worker data dictionaries for display. + + Sorting prioritizes workers that are currently running first, + followed by sorting alphabetically by logical worker name and + then by physical instance name. + + Args: + data (List[Dict]): List of dictionaries containing physical + worker data, including a '_sort_status' key. + + Returns: + Sorted list of physical worker dictionaries. + """ + return sorted(data, key=lambda row: ( + 0 if row['_sort_status'] == "RUNNING" else 1, + row['worker'], + row['instance'] + )) + + def _format_status(self, status: WorkerStatus) -> Text: + """ + Format a worker status into a Rich Text object with an icon and color. + + Converts the raw status into a human-readable string with an + associated Unicode icon and color highlighting for easier visualization. + + Args: + status (common.enums.WorkerStatus): Raw worker status object. + + Returns: + Rich Text object combining an icon and colored status string. + Status mapping:\n + - "RUNNING": ✓ green + - "STALLED": ⚠ yellow + - "STOPPED": ✗ red + - "REBOOTING": ↻ cyan + - Unknown: ? white + """ status_str = str(status).replace("WorkerStatus.", "") status_config = { @@ -85,8 +698,22 @@ def _format_status(self, status) -> Text: icon, color = status_config.get(status_str.upper(), ("?", "white")) return Text(f"{icon} {status_str}", style=color) - def _format_uptime_or_downtime(self, physical_worker) -> str: - """Format uptime for running workers, downtime for stopped workers.""" + def _format_uptime_or_downtime(self, physical_worker: PhysicalWorkerEntity) -> str: + """ + Format the uptime for running workers or downtime for stopped workers. + + For running workers, this method calculates the elapsed time since the + latest start and returns a human-readable string. For stopped workers, + it calculates the time since the stop event and prefixes it with "down". + If no start or stop times are available, returns a placeholder string. + + Args: + physical_worker (db_scripts.entities.physical_worker_entity.PhysicalWorkerEntity): + The physical worker entity to calculate uptime/downtime for. + + Returns: + Human-readable uptime or downtime string. + """ status = str(physical_worker.get_status()).replace("WorkerStatus.", "") if status == "RUNNING": @@ -95,7 +722,6 @@ def _format_uptime_or_downtime(self, physical_worker) -> str: uptime = datetime.now() - start_time return self._format_time_duration(uptime) else: - # For stopped workers, show downtime if stop_time is available stop_time = getattr(physical_worker, 'get_stop_time', lambda: None)() if stop_time: downtime = datetime.now() - stop_time @@ -106,7 +732,18 @@ def _format_uptime_or_downtime(self, physical_worker) -> str: return "-" def _format_time_duration(self, duration: timedelta) -> str: - """Format time duration in human-readable format.""" + """ + Convert a timedelta into a compact, human-readable string. + + Formats the duration using days, hours, minutes, and seconds in a + concise format suitable for table display. + + Args: + duration (timedelta): Time duration to format. + + Returns: + Formatted duration string. + """ if duration.days > 0: return f"{duration.days}d {duration.seconds // 3600}h" elif duration.seconds >= 3600: @@ -117,7 +754,18 @@ def _format_time_duration(self, duration: timedelta) -> str: return f"{duration.seconds}s" def _format_last_heartbeat(self, heartbeat_timestamp: datetime) -> Text: - """Format last heartbeat with color coding based on recency.""" + """ + Format the last heartbeat timestamp with color coding based on recency. + + Displays a human-readable time difference between the current time + and the last heartbeat. Uses color to indicate how recent the heartbeat was. + + Args: + heartbeat_timestamp (datetime): Timestamp of the last heartbeat. + + Returns: + Rich Text object containing the formatted heartbeat. + """ if not heartbeat_timestamp: return Text("-", style="dim") @@ -125,63 +773,42 @@ def _format_last_heartbeat(self, heartbeat_timestamp: datetime) -> Text: if time_diff < timedelta(minutes=1): return Text("Just now", style="green") - elif time_diff < timedelta(minutes=5): - return Text(f"{int(time_diff.total_seconds() // 60)}m ago", style="yellow") - elif time_diff < timedelta(hours=1): - return Text(f"{int(time_diff.total_seconds() // 60)}m ago", style="orange3") - else: - return Text(f"{int(time_diff.total_seconds() // 3600)}h ago", style="red") - - def _get_worker_statistics(self, logical_workers, merlin_db) -> Dict: - """Calculate comprehensive worker statistics.""" - stats = { - 'total_logical': len(logical_workers), - 'logical_with_instances': 0, - 'logical_without_instances': 0, - 'total_physical': 0, - 'physical_running': 0, - 'physical_stopped': 0, - 'physical_stalled': 0, - 'physical_rebooting': 0 - } - - for logical_worker in logical_workers: - physical_worker_ids = logical_worker.get_physical_workers() - - if physical_worker_ids: - stats['logical_with_instances'] += 1 - physical_workers = [ - merlin_db.get("physical_worker", pid) for pid in physical_worker_ids - ] - - for physical_worker in physical_workers: - stats['total_physical'] += 1 - status = str(physical_worker.get_status()).replace("WorkerStatus.", "") - - if status == "RUNNING": - stats['physical_running'] += 1 - elif status == "STOPPED": - stats['physical_stopped'] += 1 - elif status == "STALLED": - stats['physical_stalled'] += 1 - elif status == "REBOOTING": - stats['physical_rebooting'] += 1 + elif time_diff.total_seconds() < 3600: # Less than 1 hour + minutes = int(time_diff.total_seconds() // 60) + if minutes < 5: + return Text(f"{minutes}m ago", style="yellow") else: - stats['logical_without_instances'] += 1 - - return stats + return Text(f"{minutes}m ago", style="orange3") + else: + hours = int(time_diff.total_seconds() // 3600) + return Text(f"{hours}h ago", style="red") + + def _build_summary_panels(self, stats: Dict[str, int], filters: Dict) -> List[Panel]: + """ + Build Rich summary panels displaying worker statistics and applied filters. + + Creates panels for:\n + - Applied filters (queues, workers) if any. + - Logical worker counts, with and without instances. + - Physical worker counts, categorized by running, stopped, stalled, and rebooting. - def _build_summary_panels(self, stats: Dict, filters: Dict) -> List[Panel]: - """Build summary panels showing different aspects of worker status.""" + Args: + stats (Dict[str, int]): Dictionary of worker statistics, as returned by `get_worker_statistics`. + filters (Dict): Dictionary of applied filters, e.g., queues or worker names. + + Returns: + List of Rich Panel objects ready for display in the console. + """ panels = [] # Filter information if filters: filter_parts = [] if "queues" in filters: - filter_parts.append(f"Queues: {', '.join(filters['queues'])}") - if "workers" in filters: - filter_parts.append(f"Workers: {', '.join(filters['workers'])}") + queues_str = self._get_queues_str(filters["queues"]) + filter_parts.append(f"Queues: {queues_str}") + if "name" in filters: + filter_parts.append(f"Workers: {', '.join(filters['name'])}") filter_text = "\n".join(filter_parts) panels.append(Panel(filter_text, title="[bold blue]Applied Filters[/bold blue]", border_style="blue")) @@ -208,123 +835,23 @@ def _build_summary_panels(self, stats: Dict, filters: Dict) -> List[Panel]: return panels - def _build_physical_workers_table(self, logical_workers, console_width: int, merlin_db) -> Table: - """Build table showing only physical worker instances, responsive to terminal width.""" - table = Table( - show_header=True, - header_style="bold white", - title="[bold magenta]Physical Worker Instances[/bold magenta]", - ) - - # Responsive column configuration based on terminal width - if console_width < 80: - # Narrow terminal - show only essential columns - table.add_column("Worker", style="bold cyan", max_width=12) - table.add_column("Host", style="blue", max_width=10) - table.add_column("PID", justify="right", style="yellow", width=8) - table.add_column("Status", style="bold", width=10) - elif console_width < 120: - # Medium terminal - show core columns - table.add_column("Worker", style="bold cyan", max_width=15) - table.add_column("Instance", style="bold magenta", max_width=15, no_wrap=True) - table.add_column("Host", style="blue", max_width=12) - table.add_column("PID", justify="right", style="yellow", width=8) - table.add_column("Status", style="bold", width=10) - table.add_column("Runtime", style="cyan", width=8) - else: - # Wide terminal - show all columns - table.add_column("Logical Worker", style="bold cyan", max_width=15) - table.add_column("Queues", style="green", max_width=20, no_wrap=True) - table.add_column("Instance Name", style="bold magenta", max_width=20, no_wrap=True) - table.add_column("Host", style="blue", max_width=12) - table.add_column("PID", justify="right", style="yellow", width=8) - table.add_column("Status", style="bold", width=10) - table.add_column("Runtime", style="cyan", width=8) - table.add_column("Heartbeat", style="bright_blue", width=10) - table.add_column("Restarts", justify="right", style="red", width=8) - - # Collect all physical workers - physical_worker_rows = [] - - for logical_worker in logical_workers: - worker_name = logical_worker.get_name() - queues_str = ", ".join( - q[len("[merlin]_"):] if q.startswith("[merlin]_") else q - for q in sorted(logical_worker.get_queues()) - ) + def _build_compact_view(self, logical_workers: List[LogicalWorkerEntity], merlin_db: MerlinDatabase) -> str: + """ + Build a compact text-based view of workers for narrow terminals. - physical_worker_ids = logical_worker.get_physical_workers() - physical_workers = [ - merlin_db.get("physical_worker", pid) for pid in physical_worker_ids - ] + Displays each logical worker and its physical instances in a concise format. + Logical workers without instances are marked explicitly as "NO INSTANCES". + Each physical worker shows status, host, and PID with basic coloring for status. - for physical_worker in physical_workers: - status = str(physical_worker.get_status()).replace("WorkerStatus.", "") - physical_worker_rows.append({ - 'logical_name': worker_name, - 'queues': queues_str, - 'physical_worker': physical_worker, - 'status': status - }) - - # Sort: running first, then by logical worker name, then by instance name - physical_worker_rows.sort(key=lambda row: ( - 0 if row['status'] == "RUNNING" else 1, - row['logical_name'], - row['physical_worker'].get_name() or "" - )) - - for row in physical_worker_rows: - physical_worker = row['physical_worker'] - status = physical_worker.get_status() - status_str = str(status).replace("WorkerStatus.", "") - - # Only show heartbeat for running workers - heartbeat_text = "-" - if status_str == "RUNNING": - heartbeat_text = str(self._format_last_heartbeat(physical_worker.get_heartbeat_timestamp())) - - # Responsive row data based on terminal width - if console_width < 80: - # Narrow: Worker, Host, PID, Status - table.add_row( - row['logical_name'][:12], # Truncate long names - (physical_worker.get_host() or "-")[:10], - str(physical_worker.get_pid()) if physical_worker.get_pid() else "-", - self._format_status(status) - ) - elif console_width < 120: - # Medium: Worker, Instance, Host, PID, Status, Runtime - instance_name = physical_worker.get_name() or "-" - if len(instance_name) > 15: - instance_name = instance_name[:12] + "..." - - table.add_row( - row['logical_name'], - instance_name, - physical_worker.get_host() or "-", - str(physical_worker.get_pid()) if physical_worker.get_pid() else "-", - self._format_status(status), - self._format_uptime_or_downtime(physical_worker) - ) - else: - # Wide: All columns - table.add_row( - row['logical_name'], - row['queues'], - physical_worker.get_name() or "-", - physical_worker.get_host() or "-", - str(physical_worker.get_pid()) if physical_worker.get_pid() else "-", - self._format_status(status), - self._format_uptime_or_downtime(physical_worker), - heartbeat_text, - str(physical_worker.get_restart_count()) - ) - - return table + Args: + logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): + List of logical worker entities. + merlin_db (db_scripts.merlin_db.MerlinDatabase): Database interface to fetch physical + worker details. - def _build_compact_view(self, logical_workers, merlin_db) -> str: - """Build a compact text view for very narrow terminals.""" + Returns: + Multi-line string representing the compact worker view. + """ output_lines = [] for logical_worker in logical_workers: @@ -352,52 +879,3 @@ def _build_compact_view(self, logical_workers, merlin_db) -> str: ) return "\n".join(output_lines) - - def _build_logical_workers_without_instances_table(self, logical_workers, console_width) -> Table: - """Build table showing logical workers without physical instances, responsive to width.""" - table = Table( - show_header=True, - header_style="bold white", - title="[bold yellow]Logical Workers Without Instances[/bold yellow]" - ) - - # Responsive columns - if console_width < 80: - table.add_column("Worker", style="bold white", max_width=20) - table.add_column("Status", style="bold red", width=12) - else: - table.add_column("Worker Name", style="bold white", max_width=25) - table.add_column("Queues", style="green", max_width=30, no_wrap=True) - table.add_column("Status", style="bold red", width=12) - - workers_without_instances = [] - - for logical_worker in logical_workers: - physical_worker_ids = logical_worker.get_physical_workers() - if not physical_worker_ids: - workers_without_instances.append(logical_worker) - - # Sort by name - workers_without_instances.sort(key=lambda w: w.get_name()) - - for logical_worker in workers_without_instances: - queues_str = ", ".join( - q[len("[merlin]_"):] if q.startswith("[merlin]_") else q - for q in sorted(logical_worker.get_queues()) - ) - - if console_width < 80: - # Narrow: Just worker name and status - table.add_row( - logical_worker.get_name(), - Text("NO INSTANCES", style="bold red") - ) - else: - # Wide: Include queues - table.add_row( - logical_worker.get_name(), - queues_str, - Text("NO INSTANCES", style="bold red") - ) - - return table diff --git a/merlin/workers/formatters/worker_formatter.py b/merlin/workers/formatters/worker_formatter.py index ab8f7846..12779531 100644 --- a/merlin/workers/formatters/worker_formatter.py +++ b/merlin/workers/formatters/worker_formatter.py @@ -5,21 +5,122 @@ ############################################################################## """ +Worker formatter base module for displaying worker query results. +This module defines the abstract base class `WorkerFormatter`, which provides a +standard interface for formatting and displaying information about Merlin workers. +Worker formatters are responsible for presenting logical and physical worker +information in a structured, user-friendly manner (e.g., through text, tables, +or rich console output). + +Intended Usage:\n + Subclasses of `WorkerFormatter` (e.g., those using Rich for terminal + visualization) should implement `format_and_display` to render + worker information, while reusing `get_worker_statistics` for + consistent metrics across implementations. """ from abc import ABC, abstractmethod -from typing import List, Dict +from typing import List, Dict, Optional from rich.console import Console +from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity from merlin.db_scripts.merlin_db import MerlinDatabase class WorkerFormatter(ABC): - """Base formatter for worker query output.""" + """ + Abstract base class for formatting and displaying worker query results. + + Provides a consistent interface for formatting logical and physical worker + information, including a utility method to calculate worker statistics. + + Methods: + format_and_display: Abstract method that formats and outputs worker + information. Must be implemented by subclasses. + get_worker_statistics: Compute counts and statuses of logical and + physical workers, including totals and breakdown by status. + """ @abstractmethod - def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase, console: Console = None): - """Format and display worker information.""" + def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase, console: Optional[Console] = None): + """ + Format and display information about logical and physical workers. + + This method must be implemented by subclasses to define the output + format (e.g., JSON, Rich tables, text). Implementations should make + use of `get_worker_statistics` if worker summary metrics are required. + + Args: + logical_workers (List[LogicalWorkerEntity]): List of logical worker + entities to be displayed. + filters (Dict): Optional filters applied to the worker query. + merlin_db (MerlinDatabase): Database interface for retrieving + physical worker details. + console (Optional[Console]): Rich console object for printing + output. If None, a default console may be used. + """ pass + + def get_worker_statistics(self, logical_workers: List[LogicalWorkerEntity], merlin_db: MerlinDatabase) -> Dict[str, int]: + """ + Calculate comprehensive statistics for logical and physical workers. + + Iterates through all logical workers and their associated physical + instances to compute counts of running, stopped, stalled, and rebooting + workers, as well as counts of logical workers with or without instances. + + Args: + logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): + List of logical worker entities. + merlin_db (db_scripts.merlin_db.MerlinDatabase): Database interface to fetch physical + worker details. + + Returns: + Dictionary containing worker statistics:\n + - total_logical: Total number of logical workers. + - logical_with_instances: Number of logical workers with physical instances. + - logical_without_instances: Number of logical workers without physical instances. + - total_physical: Total number of physical workers. + - physical_running: Count of running physical workers. + - physical_stopped: Count of stopped physical workers. + - physical_stalled: Count of stalled physical workers. + - physical_rebooting: Count of rebooting physical workers. + """ + stats = { + 'total_logical': len(logical_workers), + 'logical_with_instances': 0, + 'logical_without_instances': 0, + 'total_physical': 0, + 'physical_running': 0, + 'physical_stopped': 0, + 'physical_stalled': 0, + 'physical_rebooting': 0 + } + + for logical_worker in logical_workers: + physical_worker_ids = logical_worker.get_physical_workers() + + if physical_worker_ids: + stats['logical_with_instances'] += 1 + physical_workers = [ + merlin_db.get("physical_worker", pid) for pid in physical_worker_ids + ] + + for physical_worker in physical_workers: + stats['total_physical'] += 1 + status = str(physical_worker.get_status()).replace("WorkerStatus.", "") + + if status == "RUNNING": + stats['physical_running'] += 1 + elif status == "STOPPED": + stats['physical_stopped'] += 1 + elif status == "STALLED": + stats['physical_stalled'] += 1 + elif status == "REBOOTING": + stats['physical_rebooting'] += 1 + else: + stats['logical_without_instances'] += 1 + + return stats From 7a1b3a828da002b19a14f0c0b8b3719c45312e20 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 27 Aug 2025 07:28:40 -0700 Subject: [PATCH 65/91] add console as class attribute for formmatters and fix pylint issues --- merlin/cli/commands/query_workers.py | 7 +- merlin/workers/formatters/json_formatter.py | 71 ++- merlin/workers/formatters/rich_formatter.py | 410 ++++++++---------- merlin/workers/formatters/worker_formatter.py | 85 ++-- merlin/workers/handlers/celery_handler.py | 13 +- merlin/workers/handlers/worker_handler.py | 9 +- 6 files changed, 287 insertions(+), 308 deletions(-) diff --git a/merlin/cli/commands/query_workers.py b/merlin/cli/commands/query_workers.py index fb5343b3..675c7626 100644 --- a/merlin/cli/commands/query_workers.py +++ b/merlin/cli/commands/query_workers.py @@ -20,11 +20,10 @@ from merlin.ascii_art import banner_small from merlin.cli.commands.command_entry_point import CommandEntryPoint -from merlin.router import query_workers from merlin.spec.specification import MerlinSpec from merlin.utils import verify_filepath -from merlin.workers.handlers.handler_factory import worker_handler_factory from merlin.workers.formatters.formatter_factory import worker_formatter_factory +from merlin.workers.handlers.handler_factory import worker_handler_factory LOG = logging.getLogger("merlin") @@ -73,7 +72,7 @@ def add_parser(self, subparsers: ArgumentParser): format_default = "rich" query.add_argument( "-f", - "--format", + "--format", choices=worker_formatter_factory.list_available(), default=format_default, help=f"Output format. Default: {format_default}", @@ -98,7 +97,7 @@ def process_command(self, args: Namespace): worker_names = [] if args.workers: worker_names.extend(args.workers) - + # Get the workers from the spec file if --spec provided spec = None if args.spec: diff --git a/merlin/workers/formatters/json_formatter.py b/merlin/workers/formatters/json_formatter.py index 16820510..a11bd075 100644 --- a/merlin/workers/formatters/json_formatter.py +++ b/merlin/workers/formatters/json_formatter.py @@ -7,15 +7,15 @@ """ JSON-based worker information formatter for Merlin. -This module provides a JSON formatter for displaying worker information -in a structured, machine-readable format. It is primarily intended for -programmatic consumption by downstream tools, scripts, or external systems -that need to parse and analyze worker data rather than display it in a +This module provides a JSON formatter for displaying worker information +in a structured, machine-readable format. It is primarily intended for +programmatic consumption by downstream tools, scripts, or external systems +that need to parse and analyze worker data rather than display it in a human-friendly format. The formatter includes:\n - Detailed records of logical workers and their associated queues - - Physical worker details such as ID, host, PID, status, restart counts, + - Physical worker details such as ID, host, PID, status, restart counts, and timestamps - Relationships between logical and physical workers - Applied filters and generation timestamp metadata @@ -24,9 +24,7 @@ import json from datetime import datetime -from typing import List, Dict, Optional - -from rich.console import Console +from typing import Dict, List from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity from merlin.db_scripts.merlin_db import MerlinDatabase @@ -38,25 +36,24 @@ class JSONWorkerFormatter(WorkerFormatter): JSON formatter for programmatic worker information consumption. This formatter generates structured JSON output representing logical - and physical worker entities. The output includes worker details, - relationships between logical and physical workers, and comprehensive - statistics. Designed for use cases where downstream tools or scripts + and physical worker entities. The output includes worker details, + relationships between logical and physical workers, and comprehensive + statistics. Designed for use cases where downstream tools or scripts need to parse worker information in a machine-readable format. Methods: format_and_display: Format and print worker information as structured JSON, - including details for logical and physical workers, filters, timestamp, + including details for logical and physical workers, filters, timestamp, and summary statistics. get_worker_statistics: Compute worker statistics, including counts of logical and physical workers by status, for inclusion in JSON output. """ - + def format_and_display( self, logical_workers: List[LogicalWorkerEntity], filters: Dict, merlin_db: MerlinDatabase, - console: Optional[Console] = None ): """ Format and display worker information as JSON. @@ -69,50 +66,52 @@ def format_and_display( - A summary of worker statistics Args: - logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): + logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): A list of logical worker entities to format. filters (Dict): A dictionary of filters applied to the query. merlin_db (db_scripts.merlin_db.MerlinDatabase): Database interface for retrieving physical worker details. - console (Optional[Console]): Rich console for printing output. Defaults to a new - console if None. """ - if console is None: - console = Console() - data = { "filters": filters, "timestamp": datetime.now().isoformat(), "logical_workers": [], - "summary": self.get_worker_statistics(logical_workers, merlin_db) + "summary": self.get_worker_statistics(logical_workers, merlin_db), } - + for logical_worker in logical_workers: logical_data = { "name": logical_worker.get_name(), - "queues": [q[len("[merlin]_"):] if q.startswith("[merlin]_") else q - for q in sorted(logical_worker.get_queues())], - "physical_workers": [] + "queues": [ + q[len("[merlin]_") :] if q.startswith("[merlin]_") else q for q in sorted(logical_worker.get_queues()) + ], + "physical_workers": [], } - + physical_worker_ids = logical_worker.get_physical_workers() - physical_workers = [ - merlin_db.get("physical_worker", pid) for pid in physical_worker_ids - ] - + physical_workers = [merlin_db.get("physical_worker", pid) for pid in physical_worker_ids] + for physical_worker in physical_workers: physical_data = { - "id": physical_worker.get_id() if hasattr(physical_worker, 'get_id') else None, + "id": physical_worker.get_id() if hasattr(physical_worker, "get_id") else None, "name": physical_worker.get_name(), "host": physical_worker.get_host(), "pid": physical_worker.get_pid(), "status": str(physical_worker.get_status()).replace("WorkerStatus.", ""), "restart_count": physical_worker.get_restart_count(), - "latest_start_time": physical_worker.get_latest_start_time().isoformat() if physical_worker.get_latest_start_time() else None, - "heartbeat_timestamp": physical_worker.get_heartbeat_timestamp().isoformat() if physical_worker.get_heartbeat_timestamp() else None + "latest_start_time": ( + physical_worker.get_latest_start_time().isoformat() + if physical_worker.get_latest_start_time() + else None + ), + "heartbeat_timestamp": ( + physical_worker.get_heartbeat_timestamp().isoformat() + if physical_worker.get_heartbeat_timestamp() + else None + ), } logical_data["physical_workers"].append(physical_data) - + data["logical_workers"].append(logical_data) - - console.print(json.dumps(data, indent=2)) + + self.console.print(json.dumps(data, indent=2)) diff --git a/merlin/workers/formatters/rich_formatter.py b/merlin/workers/formatters/rich_formatter.py index a449659c..0184dba3 100644 --- a/merlin/workers/formatters/rich_formatter.py +++ b/merlin/workers/formatters/rich_formatter.py @@ -39,10 +39,9 @@ from dataclasses import dataclass from datetime import datetime, timedelta from enum import Enum -from typing import List, Dict, Callable, Any, Optional +from typing import Any, Callable, Dict, List, Optional from rich.columns import Columns -from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.text import Text @@ -68,14 +67,15 @@ class LayoutSize(Enum): MEDIUM (str): Standard terminals (80-119 characters wide). WIDE (str): Wide terminals (>= 120 characters wide). """ - COMPACT = "compact" # < 60 chars - NARROW = "narrow" # 60-79 chars - MEDIUM = "medium" # 80-119 chars - WIDE = "wide" # >= 120 chars + + COMPACT = "compact" # < 60 chars + NARROW = "narrow" # 60-79 chars + MEDIUM = "medium" # 80-119 chars + WIDE = "wide" # >= 120 chars @dataclass -class ColumnConfig: +class ColumnConfig: # pylint: disable=too-many-instance-attributes """ Configuration for an individual table column. @@ -94,6 +94,7 @@ class ColumnConfig: formatter (Optional[Callable[[Any], Any]]): Optional callable to transform cell values before display. """ + key: str title: str style: str = "white" @@ -125,6 +126,7 @@ class LayoutConfig: use_compact_view (bool): Whether to enable a simplified, space-saving view of worker information. """ + size: LayoutSize show_summary_panels: bool = True panels_horizontal: bool = True @@ -138,12 +140,12 @@ class ResponsiveLayoutManager: Manage and provide responsive layout configurations for terminal output. This class adapts the display of worker information (both physical and logical) - to different terminal widths. It selects column visibility, ordering, and + to different terminal widths. It selects column visibility, ordering, and formatting rules depending on the width category (compact, narrow, medium, wide). Attributes: - layouts (Dict[workers.formatters.rich_formatter.LayoutSize, workers.formatters.rich_formatter.LayoutConfig]): - A mapping of layout sizes to their respective configurations, including + layouts (Dict[workers.formatters.rich_formatter.LayoutSize, workers.formatters.rich_formatter.LayoutConfig]): + A mapping of layout sizes to their respective configurations, including column definitions and display options. Methods: @@ -151,7 +153,7 @@ class ResponsiveLayoutManager: get_layout_config: Retrieve the full layout configuration object for a given terminal width. _format_status: Format a worker status into a styled Rich Text object with icons. """ - + def __init__(self): """ Initialize the responsive layout manager. @@ -167,7 +169,7 @@ def __init__(self): show_summary_panels=False, use_compact_view=True, physical_worker_columns=[], - logical_worker_columns=[] + logical_worker_columns=[], ), LayoutSize.NARROW: LayoutConfig( size=LayoutSize.NARROW, @@ -182,7 +184,7 @@ def __init__(self): logical_worker_columns=[ ColumnConfig(key="worker", title="Worker", style="bold white", max_width=20), ColumnConfig(key="status", title="Status", style="bold red", width=12), - ] + ], ), LayoutSize.MEDIUM: LayoutConfig( size=LayoutSize.MEDIUM, @@ -200,7 +202,7 @@ def __init__(self): ColumnConfig(key="worker", title="Worker Name", style="bold white", max_width=25), ColumnConfig(key="queues", title="Queues", style="green", max_width=30, no_wrap=True), ColumnConfig(key="status", title="Status", style="bold red", width=12), - ] + ], ), LayoutSize.WIDE: LayoutConfig( size=LayoutSize.WIDE, @@ -221,10 +223,10 @@ def __init__(self): ColumnConfig(key="worker", title="Worker Name", style="bold white", max_width=25), ColumnConfig(key="queues", title="Queues", style="green", max_width=30, no_wrap=True), ColumnConfig(key="status", title="Status", style="bold red", width=12), - ] - ) + ], + ), } - + def get_layout_size(self, width: int) -> LayoutSize: """ Determine the layout size category for a given terminal width. @@ -237,13 +239,12 @@ def get_layout_size(self, width: int) -> LayoutSize: """ if width < 60: return LayoutSize.COMPACT - elif width < 80: + if width < 80: return LayoutSize.NARROW - elif width < 120: + if width < 120: return LayoutSize.MEDIUM - else: - return LayoutSize.WIDE - + return LayoutSize.WIDE + def get_layout_config(self, width: int) -> LayoutConfig: """ Retrieve the layout configuration for a given terminal width. @@ -257,7 +258,7 @@ def get_layout_config(self, width: int) -> LayoutConfig: """ size = self.get_layout_size(width) return self.layouts[size] - + def _format_status(self, status: WorkerStatus) -> Text: """ Format a worker status value into a Rich `Text` object. @@ -272,14 +273,14 @@ def _format_status(self, status: WorkerStatus) -> Text: A Rich Text object containing the styled status with an icon. """ status_str = str(status).replace("WorkerStatus.", "") - + status_config = { "RUNNING": ("✓", "bold green"), - "STALLED": ("⚠", "bold yellow"), + "STALLED": ("⚠", "bold yellow"), "STOPPED": ("✗", "bold red"), "REBOOTING": ("↻", "bold cyan"), } - + icon, color = status_config.get(status_str.upper(), ("?", "white")) return Text(f"{icon} {status_str}", style=color) @@ -295,6 +296,8 @@ class RichWorkerFormatter(WorkerFormatter): width using a [`ResponsiveLayoutManager`][workers.formatters.rich_formatter.ResponsiveLayoutManager]. Attributes: + console (rich.console.Console): A Rich Console object used for displaying + output to the terminal. layout_manager (workers.formatters.rich_formatter.ResponsiveLayoutManager): The layout manager responsible for selecting column and panel configurations based on terminal width. @@ -320,7 +323,7 @@ class RichWorkerFormatter(WorkerFormatter): physical workers. _build_compact_view: Build a compact text-only view of worker status for very narrowterminals. """ - + def __init__(self): """ Initialize the Rich-based worker formatter. @@ -329,72 +332,68 @@ def __init__(self): that determines how worker data will be displayed based on the terminal width. """ + super().__init__() self.layout_manager = ResponsiveLayoutManager() - + def format_and_display( self, logical_workers: List[LogicalWorkerEntity], filters: Dict, merlin_db: MerlinDatabase, - console: Optional[Console] = None ): """ Format and display worker information using Rich components. - This method generates a responsive console output of worker - statistics, tables, and summary panels. The output adapts + This method generates a responsive console output of worker + statistics, tables, and summary panels. The output adapts to the current terminal width and user-defined filters. Args: logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): A list of logical worker objects to display and summarize. - filters (Dict): Active filters applied to the display + filters (Dict): Active filters applied to the display (e.g., by worker type or status). merlin_db (db_scripts.merlin_db.MerlinDatabase): Reference to the Merlin database, used to query worker-related data. - console (Optional[Console]): Rich console instance to - render output. If None, a new console is created. """ - if console is None: - console = Console() - # Calculate statistics stats = self.get_worker_statistics(logical_workers, merlin_db) - console_width = console.size.width + console_width = self.console.size.width layout_config = self.layout_manager.get_layout_config(console_width) - console.print() # Empty line - + self.console.print() # Empty line + # Handle compact view for very narrow terminals if layout_config.use_compact_view: - self._display_compact_view(logical_workers, merlin_db, filters, stats, console) + compact_view = self._build_compact_view(logical_workers, merlin_db) + self._display_compact_view(compact_view, filters, stats) return - + # Display summary panels if layout_config.show_summary_panels: - self._display_summary_panels(stats, filters, layout_config, console) - console.print() # Empty line - + self._display_summary_panels(stats, filters, layout_config) + self.console.print() # Empty line + # Show physical workers table if any exist - if stats['total_physical'] > 0: + if stats["total_physical"] > 0: physical_table = self._build_responsive_table( "[bold magenta]Physical Worker Instances[/bold magenta]", layout_config.physical_worker_columns, self._get_physical_worker_data(logical_workers, merlin_db), - self._sort_physical_workers + self._sort_physical_workers, ) - console.print(physical_table) - console.print() # Empty line - + self.console.print(physical_table) + self.console.print() # Empty line + # Show logical workers without instances if any exist - if stats['logical_without_instances'] > 0: + if stats["logical_without_instances"] > 0: no_instances_table = self._build_responsive_table( "[bold cyan]Logical Workers Without Instances[/bold cyan]", layout_config.logical_worker_columns, self._get_logical_workers_without_instances_data(logical_workers), - lambda data: sorted(data, key=lambda x: x['worker']) + lambda data: sorted(data, key=lambda x: x["worker"]), ) - console.print(no_instances_table) + self.console.print(no_instances_table) def _get_queues_str(self, queues: List[str]) -> str: """ @@ -407,105 +406,84 @@ def _get_queues_str(self, queues: List[str]) -> str: Returns: A comma-delimited string of queues without the '[merlin]_' prefix. """ - return ", ".join( - q[len("[merlin]_"):] if q.startswith("[merlin]_") else q - for q in sorted(queues) - ) + return ", ".join(q[len("[merlin]_") :] if q.startswith("[merlin]_") else q for q in sorted(queues)) def _display_compact_view( self, - logical_workers: List[LogicalWorkerEntity], - merlin_db: MerlinDatabase, + compact_view: str, filters: Dict, stats: Dict[str, int], - console: Console ): """ Display a compact view of worker information for narrow terminals. This fallback view is used when the terminal width is too small - to render full tables or summary panels. It prints a concise + to render full tables or summary panels. It prints a concise summary of worker status, applied filters, and worker details. Args: - logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): - List of logical worker objects to display. - merlin_db (db_scripts.merlin_db.MerlinDatabase): Reference to the Merlin database for - querying data. + compact_view (str): Multi-line string representing the compact worker view. filters (Dict): Active filters applied to the display. stats (Dict[str, int]): Aggregated worker statistics (e.g., running counts). - console (Console): Rich console instance used for output. """ - console.print("[bold cyan]Worker Status[/bold cyan]") + self.console.print("[bold cyan]Worker Status[/bold cyan]") if filters: - filter_text = " | ".join([ - f"{k}: {','.join(v)}" for k, v in filters.items() - ]) - console.print(f"[dim]Filters: {filter_text}[/dim]") - - console.print(f"[bold]Summary:[/bold] {stats['physical_running']}/{stats['total_physical']} running, {stats['logical_without_instances']} logical workers without instances\n") - - compact_view = self._build_compact_view(logical_workers, merlin_db) - console.print(compact_view) - - def _display_summary_panels( - self, - stats: Dict[str, int], - filters: Dict, - layout_config: LayoutConfig, - console: Console - ): + filter_text = " | ".join([f"{k}: {','.join(v)}" for k, v in filters.items()]) + self.console.print(f"[dim]Filters: {filter_text}[/dim]") + + self.console.print( + f"[bold]Summary:[/bold] {stats['physical_running']}/{stats['total_physical']} " + f"running, {stats['logical_without_instances']} logical workers without instances\n" + ) + + self.console.print(compact_view) + + def _display_summary_panels(self, stats: Dict[str, int], filters: Dict, layout_config: LayoutConfig): """ Display summary panels based on the given layout configuration. - This method renders one or more Rich panels summarizing worker statistics. - The panels are built dynamically from the provided stats and filters, and - displayed either horizontally in columns or vertically in sequence, + This method renders one or more Rich panels summarizing worker statistics. + The panels are built dynamically from the provided stats and filters, and + displayed either horizontally in columns or vertically in sequence, depending on the layout configuration. Args: - stats (Dict[str, int]): + stats (Dict[str, int]): A dictionary of aggregated worker statistics to summarize. - filters (Dict): + filters (Dict): A dictionary of filter criteria used to generate the summary content. - layout_config (workers.formatters.rich_formatter.LayoutConfig): - An object defining layout preferences (e.g., whether to + layout_config (workers.formatters.rich_formatter.LayoutConfig): + An object defining layout preferences (e.g., whether to arrange panels horizontally). - console (Console): - Rich Console object used to render the panels. """ summary_panels = self._build_summary_panels(stats, filters) - + if layout_config.panels_horizontal: - console.print(Columns(summary_panels, equal=True)) + self.console.print(Columns(summary_panels, equal=True)) else: for panel in summary_panels: - console.print(panel) - + self.console.print(panel) + def _build_responsive_table( - self, - title: str, - columns: List[ColumnConfig], - data: List[Dict], - sort_func: Callable = None + self, title: str, columns: List[ColumnConfig], data: List[Dict], sort_func: Callable = None ) -> Table: """ Build and return a Rich table with responsive column configuration. This method creates a table with headers, applies styles and sizing rules - based on column configuration, and optionally sorts the data before - rendering. Each row of the table is constructed by extracting values + based on column configuration, and optionally sorts the data before + rendering. Each row of the table is constructed by extracting values from the input data and applying any specified formatters. Args: - title (str): + title (str): Title displayed above the table. - columns (List[workers.formatters.rich_formatter.ColumnConfig]): - A list of column configuration objects defining headers, + columns (List[workers.formatters.rich_formatter.ColumnConfig]): + A list of column configuration objects defining headers, widths, alignment, and formatting functions. - data (List[Dict]): + data (List[Dict]): List of dictionaries containing the row data to display. - sort_func (Optional[Callable]): + sort_func (Optional[Callable]): A function to sort the data before rendering. Defaults to None. Returns: @@ -516,22 +494,17 @@ def _build_responsive_table( header_style="bold white", title=title, ) - + # Add columns based on configuration for col in columns: table.add_column( - col.title, - style=col.style, - width=col.width, - max_width=col.max_width, - justify=col.justify, - no_wrap=col.no_wrap + col.title, style=col.style, width=col.width, max_width=col.max_width, justify=col.justify, no_wrap=col.no_wrap ) - + # Sort data if sort function provided if sort_func: data = sort_func(data) - + # Add rows for row_data in data: row_values = [] @@ -541,9 +514,9 @@ def _build_responsive_table( value = col.formatter(value) row_values.append(value) table.add_row(*row_values) - + return table - + def _get_physical_worker_data(self, logical_workers: List[LogicalWorkerEntity], merlin_db) -> List[Dict]: """ Extract and format physical worker data for table display. @@ -555,10 +528,10 @@ def _get_physical_worker_data(self, logical_workers: List[LogicalWorkerEntity], and restart count. Args: - logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): - A list of logical worker entities, each referencing one or more + logical_workers (List[db_scripts.entities.logical_worker_entity.LogicalWorkerEntity]): + A list of logical worker entities, each referencing one or more associated physical workers. - merlin_db (db_scripts.merlin_db.MerlinDatabase): + merlin_db (db_scripts.merlin_db.MerlinDatabase): The database interface used to fetch physical worker entities. Returns: @@ -572,53 +545,53 @@ def _get_physical_worker_data(self, logical_workers: List[LogicalWorkerEntity], - runtime (str): Formatted uptime/downtime string. - heartbeat (str): Last heartbeat timestamp or "-". - restarts (str): Number of times the worker has restarted. - - _sort_status (str): String representation of the status + - _sort_status (str): String representation of the status used for sorting. """ data = [] - + for logical_worker in logical_workers: worker_name = logical_worker.get_name() queues_str = self._get_queues_str(logical_worker.get_queues()) physical_worker_ids = logical_worker.get_physical_workers() - physical_workers = [ - merlin_db.get("physical_worker", pid) for pid in physical_worker_ids - ] + physical_workers = [merlin_db.get("physical_worker", pid) for pid in physical_worker_ids] for physical_worker in physical_workers: status = physical_worker.get_status() status_str = str(status).replace("WorkerStatus.", "") - + # Only show heartbeat for running workers heartbeat_text = "-" if status_str == "RUNNING": heartbeat_text = str(self._format_last_heartbeat(physical_worker.get_heartbeat_timestamp())) - + instance_name = physical_worker.get_name() or "-" - - data.append({ - 'worker': worker_name, - 'queues': queues_str, - 'instance': instance_name, - 'host': physical_worker.get_host() or "-", - 'pid': str(physical_worker.get_pid()) if physical_worker.get_pid() else "-", - 'status': status, # Raw status for formatter - 'runtime': self._format_uptime_or_downtime(physical_worker), - 'heartbeat': heartbeat_text, - 'restarts': str(physical_worker.get_restart_count()), - '_sort_status': status_str # For sorting - }) - + + data.append( + { + "worker": worker_name, + "queues": queues_str, + "instance": instance_name, + "host": physical_worker.get_host() or "-", + "pid": str(physical_worker.get_pid()) if physical_worker.get_pid() else "-", + "status": status, # Raw status for formatter + "runtime": self._format_uptime_or_downtime(physical_worker), + "heartbeat": heartbeat_text, + "restarts": str(physical_worker.get_restart_count()), + "_sort_status": status_str, # For sorting + } + ) + return data - + def _get_logical_workers_without_instances_data(self, logical_workers: List[LogicalWorkerEntity]) -> List[Dict]: """ Extract logical workers that have no associated physical instances. This method identifies all logical workers that currently do not have - any physical worker instances. It formats their data into a list of - dictionaries suitable for table rendering, including a status field + any physical worker instances. It formats their data into a list of + dictionaries suitable for table rendering, including a status field indicating "NO INSTANCES". Args: @@ -632,46 +605,44 @@ def _get_logical_workers_without_instances_data(self, logical_workers: List[Logi - status (Text): Rich-formatted status indicating no instances. """ data = [] - + for logical_worker in logical_workers: physical_worker_ids = logical_worker.get_physical_workers() if not physical_worker_ids: queues_str = self._get_queues_str(logical_worker.get_queues()) - - data.append({ - 'worker': logical_worker.get_name(), - 'queues': queues_str, - 'status': Text("NO INSTANCES", style="bold red") - }) - + + data.append( + { + "worker": logical_worker.get_name(), + "queues": queues_str, + "status": Text("NO INSTANCES", style="bold red"), + } + ) + return data - + def _sort_physical_workers(self, data: List[Dict]) -> List[Dict]: """ Sort a list of physical worker data dictionaries for display. - Sorting prioritizes workers that are currently running first, - followed by sorting alphabetically by logical worker name and + Sorting prioritizes workers that are currently running first, + followed by sorting alphabetically by logical worker name and then by physical instance name. Args: - data (List[Dict]): List of dictionaries containing physical + data (List[Dict]): List of dictionaries containing physical worker data, including a '_sort_status' key. Returns: Sorted list of physical worker dictionaries. """ - return sorted(data, key=lambda row: ( - 0 if row['_sort_status'] == "RUNNING" else 1, - row['worker'], - row['instance'] - )) - + return sorted(data, key=lambda row: (0 if row["_sort_status"] == "RUNNING" else 1, row["worker"], row["instance"])) + def _format_status(self, status: WorkerStatus) -> Text: """ Format a worker status into a Rich Text object with an icon and color. - Converts the raw status into a human-readable string with an + Converts the raw status into a human-readable string with an associated Unicode icon and color highlighting for easier visualization. Args: @@ -687,14 +658,14 @@ def _format_status(self, status: WorkerStatus) -> Text: - Unknown: ? white """ status_str = str(status).replace("WorkerStatus.", "") - + status_config = { "RUNNING": ("✓", "bold green"), - "STALLED": ("⚠", "bold yellow"), + "STALLED": ("⚠", "bold yellow"), "STOPPED": ("✗", "bold red"), "REBOOTING": ("↻", "bold cyan"), } - + icon, color = status_config.get(status_str.upper(), ("?", "white")) return Text(f"{icon} {status_str}", style=color) @@ -702,9 +673,9 @@ def _format_uptime_or_downtime(self, physical_worker: PhysicalWorkerEntity) -> s """ Format the uptime for running workers or downtime for stopped workers. - For running workers, this method calculates the elapsed time since the - latest start and returns a human-readable string. For stopped workers, - it calculates the time since the stop event and prefixes it with "down". + For running workers, this method calculates the elapsed time since the + latest start and returns a human-readable string. For stopped workers, + it calculates the time since the stop event and prefixes it with "down". If no start or stop times are available, returns a placeholder string. Args: @@ -715,27 +686,26 @@ def _format_uptime_or_downtime(self, physical_worker: PhysicalWorkerEntity) -> s Human-readable uptime or downtime string. """ status = str(physical_worker.get_status()).replace("WorkerStatus.", "") - + if status == "RUNNING": start_time = physical_worker.get_latest_start_time() if start_time: uptime = datetime.now() - start_time return self._format_time_duration(uptime) else: - stop_time = getattr(physical_worker, 'get_stop_time', lambda: None)() + stop_time = getattr(physical_worker, "get_stop_time", lambda: None)() if stop_time: downtime = datetime.now() - stop_time return f"down {self._format_time_duration(downtime)}" - else: - return "stopped" - + return "stopped" + return "-" def _format_time_duration(self, duration: timedelta) -> str: """ Convert a timedelta into a compact, human-readable string. - Formats the duration using days, hours, minutes, and seconds in a + Formats the duration using days, hours, minutes, and seconds in a concise format suitable for table display. Args: @@ -746,18 +716,17 @@ def _format_time_duration(self, duration: timedelta) -> str: """ if duration.days > 0: return f"{duration.days}d {duration.seconds // 3600}h" - elif duration.seconds >= 3600: + if duration.seconds >= 3600: return f"{duration.seconds // 3600}h {(duration.seconds % 3600) // 60}m" - elif duration.seconds >= 60: + if duration.seconds >= 60: return f"{duration.seconds // 60}m" - else: - return f"{duration.seconds}s" + return f"{duration.seconds}s" def _format_last_heartbeat(self, heartbeat_timestamp: datetime) -> Text: """ Format the last heartbeat timestamp with color coding based on recency. - Displays a human-readable time difference between the current time + Displays a human-readable time difference between the current time and the last heartbeat. Uses color to indicate how recent the heartbeat was. Args: @@ -768,20 +737,21 @@ def _format_last_heartbeat(self, heartbeat_timestamp: datetime) -> Text: """ if not heartbeat_timestamp: return Text("-", style="dim") - + time_diff = datetime.now() - heartbeat_timestamp - - if time_diff < timedelta(minutes=1): + + if time_diff < timedelta(minutes=1): # Less than 1 minute return Text("Just now", style="green") - elif time_diff.total_seconds() < 3600: # Less than 1 hour + + if time_diff.total_seconds() < 3600: # Less than 1 hour minutes = int(time_diff.total_seconds() // 60) if minutes < 5: return Text(f"{minutes}m ago", style="yellow") - else: - return Text(f"{minutes}m ago", style="orange3") - else: - hours = int(time_diff.total_seconds() // 3600) - return Text(f"{hours}h ago", style="red") + return Text(f"{minutes}m ago", style="orange3") + + # More than 1 hour + hours = int(time_diff.total_seconds() // 3600) + return Text(f"{hours}h ago", style="red") def _build_summary_panels(self, stats: Dict[str, int], filters: Dict) -> List[Panel]: """ @@ -800,7 +770,7 @@ def _build_summary_panels(self, stats: Dict[str, int], filters: Dict) -> List[Pa List of Rich Panel objects ready for display in the console. """ panels = [] - + # Filter information if filters: filter_parts = [] @@ -809,30 +779,36 @@ def _build_summary_panels(self, stats: Dict[str, int], filters: Dict) -> List[Pa filter_parts.append(f"Queues: {queues_str}") if "name" in filters: filter_parts.append(f"Workers: {', '.join(filters['name'])}") - + filter_text = "\n".join(filter_parts) panels.append(Panel(filter_text, title="[bold blue]Applied Filters[/bold blue]", border_style="blue")) - + # Logical worker summary - logical_summary = f"Total: [bold white]{stats['total_logical']}[/bold white]\n" \ - f"With Instances: [bold green]{stats['logical_with_instances']}[/bold green]\n" \ + logical_summary = ( + f"Total: [bold white]{stats['total_logical']}[/bold white]\n" + f"With Instances: [bold green]{stats['logical_with_instances']}[/bold green]\n" f"Without Instances: [bold dim]{stats['logical_without_instances']}[/bold dim]" - + ) + panels.append(Panel(logical_summary, title="[bold cyan]Logical Workers[/bold cyan]", border_style="cyan")) - + # Physical worker summary - if stats['total_physical'] > 0: - physical_summary = f"Total: [bold white]{stats['total_physical']}[/bold white]\n" \ - f"Running: [bold green]{stats['physical_running']}[/bold green]\n" \ + if stats["total_physical"] > 0: + physical_summary = ( + f"Total: [bold white]{stats['total_physical']}[/bold white]\n" + f"Running: [bold green]{stats['physical_running']}[/bold green]\n" f"Stopped: [bold red]{stats['physical_stopped']}[/bold red]" - - if stats['physical_stalled'] > 0: + ) + + if stats["physical_stalled"] > 0: physical_summary += f"\nStalled: [bold yellow]{stats['physical_stalled']}[/bold yellow]" - if stats['physical_rebooting'] > 0: + if stats["physical_rebooting"] > 0: physical_summary += f"\nRebooting: [bold cyan]{stats['physical_rebooting']}[/bold cyan]" - - panels.append(Panel(physical_summary, title="[bold magenta]Physical Instances[/bold magenta]", border_style="magenta")) - + + panels.append( + Panel(physical_summary, title="[bold magenta]Physical Instances[/bold magenta]", border_style="magenta") + ) + return panels def _build_compact_view(self, logical_workers: List[LogicalWorkerEntity], merlin_db: MerlinDatabase) -> str: @@ -840,7 +816,7 @@ def _build_compact_view(self, logical_workers: List[LogicalWorkerEntity], merlin Build a compact text-based view of workers for narrow terminals. Displays each logical worker and its physical instances in a concise format. - Logical workers without instances are marked explicitly as "NO INSTANCES". + Logical workers without instances are marked explicitly as "NO INSTANCES". Each physical worker shows status, host, and PID with basic coloring for status. Args: @@ -853,29 +829,27 @@ def _build_compact_view(self, logical_workers: List[LogicalWorkerEntity], merlin Multi-line string representing the compact worker view. """ output_lines = [] - + for logical_worker in logical_workers: worker_name = logical_worker.get_name() physical_worker_ids = logical_worker.get_physical_workers() - + if not physical_worker_ids: output_lines.append(f"[bold white]{worker_name}[/bold white]: [bold red]NO INSTANCES[/bold red]") else: - physical_workers = [ - merlin_db.get("physical_worker", pid) for pid in physical_worker_ids - ] - + physical_workers = [merlin_db.get("physical_worker", pid) for pid in physical_worker_ids] + for physical_worker in physical_workers: status = str(physical_worker.get_status()).replace("WorkerStatus.", "") host = physical_worker.get_host() or "?" pid = physical_worker.get_pid() or "-" - + status_icon = "✓" if status == "RUNNING" else "✗" color = "green" if status == "RUNNING" else "red" - + output_lines.append( f"[bold white]{worker_name}[/bold white]@[blue]{host}[/blue] " f"[{color}]{status_icon} {status}[/{color}] (PID: {pid})" ) - + return "\n".join(output_lines) diff --git a/merlin/workers/formatters/worker_formatter.py b/merlin/workers/formatters/worker_formatter.py index 12779531..c87f39ae 100644 --- a/merlin/workers/formatters/worker_formatter.py +++ b/merlin/workers/formatters/worker_formatter.py @@ -7,21 +7,21 @@ """ Worker formatter base module for displaying worker query results. -This module defines the abstract base class `WorkerFormatter`, which provides a -standard interface for formatting and displaying information about Merlin workers. -Worker formatters are responsible for presenting logical and physical worker -information in a structured, user-friendly manner (e.g., through text, tables, +This module defines the abstract base class `WorkerFormatter`, which provides a +standard interface for formatting and displaying information about Merlin workers. +Worker formatters are responsible for presenting logical and physical worker +information in a structured, user-friendly manner (e.g., through text, tables, or rich console output). Intended Usage:\n - Subclasses of `WorkerFormatter` (e.g., those using Rich for terminal - visualization) should implement `format_and_display` to render - worker information, while reusing `get_worker_statistics` for + Subclasses of `WorkerFormatter` (e.g., those using Rich for terminal + visualization) should implement `format_and_display` to render + worker information, while reusing `get_worker_statistics` for consistent metrics across implementations. """ from abc import ABC, abstractmethod -from typing import List, Dict, Optional +from typing import Dict, List from rich.console import Console @@ -36,15 +36,28 @@ class WorkerFormatter(ABC): Provides a consistent interface for formatting logical and physical worker information, including a utility method to calculate worker statistics. + Attributes: + console (rich.console.Console): A Rich Console object used for displaying + output to the terminal. + Methods: format_and_display: Abstract method that formats and outputs worker information. Must be implemented by subclasses. get_worker_statistics: Compute counts and statuses of logical and physical workers, including totals and breakdown by status. """ - + + def __init__(self): + """ + Initializer for the WorkerFormatter class. + + Sets up a rich console object so that subclasses can easily print formatted + output to the console. + """ + self.console = Console() + @abstractmethod - def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase, console: Optional[Console] = None): + def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase): """ Format and display information about logical and physical workers. @@ -58,8 +71,6 @@ def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: Me filters (Dict): Optional filters applied to the worker query. merlin_db (MerlinDatabase): Database interface for retrieving physical worker details. - console (Optional[Console]): Rich console object for printing - output. If None, a default console may be used. """ pass @@ -67,8 +78,8 @@ def get_worker_statistics(self, logical_workers: List[LogicalWorkerEntity], merl """ Calculate comprehensive statistics for logical and physical workers. - Iterates through all logical workers and their associated physical - instances to compute counts of running, stopped, stalled, and rebooting + Iterates through all logical workers and their associated physical + instances to compute counts of running, stopped, stalled, and rebooting workers, as well as counts of logical workers with or without instances. Args: @@ -87,40 +98,38 @@ def get_worker_statistics(self, logical_workers: List[LogicalWorkerEntity], merl - physical_stopped: Count of stopped physical workers. - physical_stalled: Count of stalled physical workers. - physical_rebooting: Count of rebooting physical workers. - """ + """ stats = { - 'total_logical': len(logical_workers), - 'logical_with_instances': 0, - 'logical_without_instances': 0, - 'total_physical': 0, - 'physical_running': 0, - 'physical_stopped': 0, - 'physical_stalled': 0, - 'physical_rebooting': 0 + "total_logical": len(logical_workers), + "logical_with_instances": 0, + "logical_without_instances": 0, + "total_physical": 0, + "physical_running": 0, + "physical_stopped": 0, + "physical_stalled": 0, + "physical_rebooting": 0, } - + for logical_worker in logical_workers: physical_worker_ids = logical_worker.get_physical_workers() - + if physical_worker_ids: - stats['logical_with_instances'] += 1 - physical_workers = [ - merlin_db.get("physical_worker", pid) for pid in physical_worker_ids - ] - + stats["logical_with_instances"] += 1 + physical_workers = [merlin_db.get("physical_worker", pid) for pid in physical_worker_ids] + for physical_worker in physical_workers: - stats['total_physical'] += 1 + stats["total_physical"] += 1 status = str(physical_worker.get_status()).replace("WorkerStatus.", "") - + if status == "RUNNING": - stats['physical_running'] += 1 + stats["physical_running"] += 1 elif status == "STOPPED": - stats['physical_stopped'] += 1 + stats["physical_stopped"] += 1 elif status == "STALLED": - stats['physical_stalled'] += 1 + stats["physical_stalled"] += 1 elif status == "REBOOTING": - stats['physical_rebooting'] += 1 + stats["physical_rebooting"] += 1 else: - stats['logical_without_instances'] += 1 - + stats["logical_without_instances"] += 1 + return stats diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index 316a4b68..c5a2f77d 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -16,8 +16,6 @@ import logging from typing import Dict, List -from rich.console import Console - from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.workers import CeleryWorker from merlin.workers.formatters.formatter_factory import worker_formatter_factory @@ -43,8 +41,7 @@ class CeleryWorkerHandler(MerlinWorkerHandler): """ def __init__(self): - """ - """ + """ """ super().__init__() self.merlin_db = MerlinDatabase() @@ -81,11 +78,11 @@ def stop_workers(self): def _build_filters(self, queues: List[str], workers: List[str]) -> Dict[str, List[str]]: """ Build filters dictionary for database queries. - + Args: queues: List of queue names to filter by. workers: List of worker names to filter by. - + Returns: Dictionary containing filter criteria. """ @@ -101,6 +98,7 @@ def query_workers(self, formatter: str, queues: List[str] = None, workers: List[ Query the status of Celery workers and display using the configured formatter. Args: + formatter: The worker formatter to use (rich or json). queues: List of queue names to filter by (optional). workers: List of worker names to filter by (optional). """ @@ -111,6 +109,5 @@ def query_workers(self, formatter: str, queues: List[str] = None, workers: List[ logical_workers = self.merlin_db.get_all("logical_worker", filters=filters) # Use formatter to display the results - console = Console() formatter = worker_formatter_factory.create(formatter) - formatter.format_and_display(logical_workers, filters, self.merlin_db, console) + formatter.format_and_display(logical_workers, filters, self.merlin_db) diff --git a/merlin/workers/handlers/worker_handler.py b/merlin/workers/handlers/worker_handler.py index 1ae6e2a0..84062cc2 100644 --- a/merlin/workers/handlers/worker_handler.py +++ b/merlin/workers/handlers/worker_handler.py @@ -55,12 +55,13 @@ def stop_workers(self): raise NotImplementedError("Subclasses of `MerlinWorkerHandler` must implement a `stop_workers` method.") @abstractmethod - def query_workers(self) -> Any: + def query_workers(self, formatter: str, queues: List[str] = None, workers: List[str] = None): """ Query the status of all currently running workers. - Returns: - Subclasses should return an appropriate data structure summarizing - the current state of managed workers (e.g., dict, list, string). + Args: + formatter: The worker formatter to use (rich or json). + queues: List of queue names to filter by (optional). + workers: List of worker names to filter by (optional). """ raise NotImplementedError("Subclasses of `MerlinWorkerHandler` must implement a `query_workers` method.") From dfdf7d97212fd62bdc8982252c6905c34b3e195c Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 27 Aug 2025 07:33:18 -0700 Subject: [PATCH 66/91] more pylint fixes --- merlin/workers/formatters/worker_formatter.py | 2 +- merlin/workers/handlers/worker_handler.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merlin/workers/formatters/worker_formatter.py b/merlin/workers/formatters/worker_formatter.py index c87f39ae..6a376f9e 100644 --- a/merlin/workers/formatters/worker_formatter.py +++ b/merlin/workers/formatters/worker_formatter.py @@ -72,7 +72,7 @@ def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: Me merlin_db (MerlinDatabase): Database interface for retrieving physical worker details. """ - pass + raise NotImplementedError("Subclasses of `WorkerFormatter` must implement a `format_and_display` method.") def get_worker_statistics(self, logical_workers: List[LogicalWorkerEntity], merlin_db: MerlinDatabase) -> Dict[str, int]: """ diff --git a/merlin/workers/handlers/worker_handler.py b/merlin/workers/handlers/worker_handler.py index 84062cc2..5ea725e9 100644 --- a/merlin/workers/handlers/worker_handler.py +++ b/merlin/workers/handlers/worker_handler.py @@ -13,7 +13,7 @@ """ from abc import ABC, abstractmethod -from typing import Any, List +from typing import List from merlin.workers.worker import MerlinWorker From add1927913f44873a4478ce4e441b0cc45da2535 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 27 Aug 2025 07:35:56 -0700 Subject: [PATCH 67/91] add attributes section to json formatter --- merlin/workers/formatters/json_formatter.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/merlin/workers/formatters/json_formatter.py b/merlin/workers/formatters/json_formatter.py index a11bd075..34a65d38 100644 --- a/merlin/workers/formatters/json_formatter.py +++ b/merlin/workers/formatters/json_formatter.py @@ -41,6 +41,10 @@ class JSONWorkerFormatter(WorkerFormatter): statistics. Designed for use cases where downstream tools or scripts need to parse worker information in a machine-readable format. + Attributes: + console (rich.console.Console): A Rich Console object used for displaying + output to the terminal. + Methods: format_and_display: Format and print worker information as structured JSON, including details for logical and physical workers, filters, timestamp, From 74fcd1d4cb42cc23db2cdfe8b4adcc09bb3a0a7b Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 27 Aug 2025 07:54:07 -0700 Subject: [PATCH 68/91] fix broken tests --- tests/integration/definitions.py | 2 +- tests/unit/cli/commands/test_query_workers.py | 47 +++++++++++++++---- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/tests/integration/definitions.py b/tests/integration/definitions.py index 7d326484..5d864569 100644 --- a/tests/integration/definitions.py +++ b/tests/integration/definitions.py @@ -314,7 +314,7 @@ def define_tests(): # pylint: disable=R0914,R0915 }, "default_worker assigned": { "cmds": f"{workers} {test_specs}/default_worker_test.yaml --echo", - "conditions": [HasReturnCode(), HasRegex(r"default_worker.*-Q \[merlin\]_step_4_queue")], + "conditions": [HasReturnCode(), HasRegex(r"default_worker.*-Q step_4_queue")], "run type": "local", }, "no default_worker assigned": { diff --git a/tests/unit/cli/commands/test_query_workers.py b/tests/unit/cli/commands/test_query_workers.py index 09152b3c..19665200 100644 --- a/tests/unit/cli/commands/test_query_workers.py +++ b/tests/unit/cli/commands/test_query_workers.py @@ -38,29 +38,37 @@ def test_add_parser_sets_up_query_workers_command(create_parser: FixtureCallable def test_process_command_without_spec(mocker: MockerFixture): """ - Ensure `process_command` calls `query_workers` directly if no spec is provided. + Ensure `process_command` calls worker_handler.query_workers if no spec is provided. Args: mocker: PyTest mocker fixture. """ - query_workers_mock = mocker.patch("merlin.cli.commands.query_workers.query_workers") + worker_handler_mock = mocker.Mock() + create_mock = mocker.patch( + "merlin.cli.commands.query_workers.worker_handler_factory.create", + return_value=worker_handler_mock, + ) args = Namespace( task_server="celery", spec=None, queues=["q1", "q2"], workers=["worker1", "worker2"], + format="rich", ) cmd = QueryWorkersCommand() cmd.process_command(args) - query_workers_mock.assert_called_once_with("celery", [], ["q1", "q2"], ["worker1", "worker2"]) + create_mock.assert_called_once_with("celery") + worker_handler_mock.query_workers.assert_called_once_with( + "rich", queues=["q1", "q2"], workers=["worker1", "worker2"] + ) def test_process_command_with_spec(mocker: MockerFixture, caplog: CaptureFixture): """ - Ensure `process_command` loads worker names from spec and passes them to `query_workers`. + Ensure `process_command` loads worker names from spec and passes them to worker_handler.query_workers. Args: mocker: PyTest mocker fixture. @@ -70,22 +78,32 @@ def test_process_command_with_spec(mocker: MockerFixture, caplog: CaptureFixture mock_spec = mocker.Mock() mock_spec.get_worker_names.return_value = ["foo", "bar"] + mock_spec.merlin = {"resources": {"task_server": "celery"}} mocker.patch("merlin.cli.commands.query_workers.verify_filepath", return_value="some/path/spec.yaml") mocker.patch("merlin.cli.commands.query_workers.MerlinSpec.load_specification", return_value=mock_spec) - query_workers_mock = mocker.patch("merlin.cli.commands.query_workers.query_workers") + + worker_handler_mock = mocker.Mock() + create_mock = mocker.patch( + "merlin.cli.commands.query_workers.worker_handler_factory.create", + return_value=worker_handler_mock, + ) args = Namespace( - task_server="celery", + task_server="ignored", spec="workflow.yaml", queues=None, workers=None, + format="rich", ) cmd = QueryWorkersCommand() cmd.process_command(args) - query_workers_mock.assert_called_once_with("celery", ["foo", "bar"], None, None) + create_mock.assert_called_once_with("celery") + worker_handler_mock.query_workers.assert_called_once_with( + "rich", queues=None, workers=["foo", "bar"] + ) assert "Searching for the following workers to stop" in caplog.text @@ -101,20 +119,29 @@ def test_process_command_logs_warning_for_unexpanded_worker(mocker: MockerFixtur mock_spec = mocker.Mock() mock_spec.get_worker_names.return_value = ["$ENV_VAR", "actual_worker"] + mock_spec.merlin = {"resources": {"task_server": "celery"}} mocker.patch("merlin.cli.commands.query_workers.verify_filepath", return_value="workflow.yaml") mocker.patch("merlin.cli.commands.query_workers.MerlinSpec.load_specification", return_value=mock_spec) - query_workers_mock = mocker.patch("merlin.cli.commands.query_workers.query_workers") + + worker_handler_mock = mocker.Mock() + mocker.patch( + "merlin.cli.commands.query_workers.worker_handler_factory.create", + return_value=worker_handler_mock, + ) args = Namespace( - task_server="celery", + task_server="ignored", spec="workflow.yaml", queues=None, workers=None, + format="rich", ) cmd = QueryWorkersCommand() cmd.process_command(args) assert "Worker '$ENV_VAR' is unexpanded. Target provenance spec instead?" in caplog.text - query_workers_mock.assert_called_once_with("celery", ["$ENV_VAR", "actual_worker"], None, None) + worker_handler_mock.query_workers.assert_called_once_with( + "rich", queues=None, workers=["$ENV_VAR", "actual_worker"] + ) From 3defbaa17fc1f73e26862bd8841ddc79e0fdccdf Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 27 Aug 2025 07:59:30 -0700 Subject: [PATCH 69/91] update CHANGELOG --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 421e3a5d..68c76f4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `MerlinWorkerHandler`: base class for managing launching, stopping, and querying multiple workers - `CeleryWorkerHandler`: implementation of `MerlinWorkerHandler` specifically for manager Celery workers - `WorkerHandlerFactory`: to help determine which task server handler to use +- New classes for formatting `query-workers` output: + - ### Changed - Maestro version requirement is now at minimum 1.1.10 for status renderer changes +- Changes to the `query-workers` command: + - Output now displays a lot more information, including logical and physical worker specific info + - Output now formatted using rich tables or json + - Now handled through worker classes rather than functions in `celeryadapter.py` file + - Behind the scenes this is now querying the new Merlin Database - The `BackendFactory`, `MonitorFactory`, and `StatusRendererFactory` classes all now inherit from `MerlinBaseFactory` - Launching workers is now handled through worker classes rather than functions in the `celeryadapter.py` file From e1033d7f67b41e777903fea86d03fff2767edb03 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 27 Aug 2025 12:39:46 -0700 Subject: [PATCH 70/91] updated the query-workers docs page --- .../queues_and_workers/connected-workers.png | Bin 28087 -> 0 bytes .../no-connected-workers.png | Bin 19790 -> 0 bytes .../query-workers-queues-all-workers.png | Bin 0 -> 56850 bytes .../query-workers-queues-option.png | Bin 0 -> 52305 bytes .../query-workers-spec-all-workers.png | Bin 26557 -> 53911 bytes .../query-workers-spec-option.png | Bin 29054 -> 61408 bytes ...y-workers-worker-entities-do-not-exist.png | Bin 0 -> 29378 bytes .../query-workers-worker-entities-exist.png | Bin 0 -> 139484 bytes .../query-workers-workers-option.png | Bin 0 -> 53576 bytes .../queues-example-all-workers.png | Bin 27058 -> 0 bytes .../queues-example-filtered-workers.png | Bin 24183 -> 0 bytes .../workers-option-with-regex.png | Bin 24539 -> 0 bytes .../workers-option-with-worker-names.png | Bin 25095 -> 0 bytes .../monitoring/queues_and_workers.md | 51 +++++++----------- 14 files changed, 20 insertions(+), 31 deletions(-) delete mode 100644 docs/assets/images/monitoring/queues_and_workers/connected-workers.png delete mode 100644 docs/assets/images/monitoring/queues_and_workers/no-connected-workers.png create mode 100644 docs/assets/images/monitoring/queues_and_workers/query-workers-queues-all-workers.png create mode 100644 docs/assets/images/monitoring/queues_and_workers/query-workers-queues-option.png create mode 100644 docs/assets/images/monitoring/queues_and_workers/query-workers-worker-entities-do-not-exist.png create mode 100644 docs/assets/images/monitoring/queues_and_workers/query-workers-worker-entities-exist.png create mode 100644 docs/assets/images/monitoring/queues_and_workers/query-workers-workers-option.png delete mode 100644 docs/assets/images/monitoring/queues_and_workers/queues-example-all-workers.png delete mode 100644 docs/assets/images/monitoring/queues_and_workers/queues-example-filtered-workers.png delete mode 100644 docs/assets/images/monitoring/queues_and_workers/workers-option-with-regex.png delete mode 100644 docs/assets/images/monitoring/queues_and_workers/workers-option-with-worker-names.png diff --git a/docs/assets/images/monitoring/queues_and_workers/connected-workers.png b/docs/assets/images/monitoring/queues_and_workers/connected-workers.png deleted file mode 100644 index 2f030aa7191b700de1695ab002ff9c3ad377d5a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28087 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 diff --git a/docs/assets/images/monitoring/queues_and_workers/no-connected-workers.png b/docs/assets/images/monitoring/queues_and_workers/no-connected-workers.png deleted file mode 100644 index 1dec3631dffa21feb92a1f745debf7e38e06f9dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19790 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 literal 0 HcmV?d00001 diff --git a/docs/assets/images/monitoring/queues_and_workers/query-workers-queues-option.png b/docs/assets/images/monitoring/queues_and_workers/query-workers-queues-option.png new file mode 100644 index 0000000000000000000000000000000000000000..8d0365cfddaba1f2f7f7f370e93d78e854ab6fc1 GIT binary patch literal 52305 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 C2S989 literal 0 HcmV?d00001 diff --git a/docs/assets/images/monitoring/queues_and_workers/query-workers-spec-all-workers.png b/docs/assets/images/monitoring/queues_and_workers/query-workers-spec-all-workers.png index 8620ef77fc1c6192024e31b3979fa0a764fa0710..c4bad03bda2e0adc495127dc4d2b7a9ee5f419fa 100644 GIT binary patch literal 53911 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 B-I@RZ literal 26557 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%0AiUXuOL>snOvfixd(o5*TMq3U2TT>!fCA$AL= zIiCE&#hA*ZUjf8^l$>#JveIjE)u@z|%-??O1NNiDA0z*%gOly#@Ay*&Oj5g$6g0^M zJTMS{s>>RvaTB>wJ4`xhHt4cm2S;}vHguN`zz3tLR0POi_SJ{>MkeX*40xGueS@L8 zU>UgBCdHk{qW}~2e;TiNY+h9 zpJb7EM>5uB<sa)J|Nu{XLz!JzX`o_1%3qxv%gIbyI#59-U6i+XX_VaGzDUMz&Idxb6 zI_5p-{U5W3|4ImWHHpVSArH+05HDE)*6cCnKI#jUtPAf`8Khjn29*Ms3mw*Hsl*%0 zGBPBZDShetyN14z+d*D-fz_0D1EtFjl4qK=yY3X&6^I5cq2ou^(^91l zHY`AEjV*-+ja95gp{y6D*&DYCw{~$st4?%*+LA|`Jic9TRFTo^CzfEp?Uh@NPK(cV z7vSBd{N(K15>I&rfZj@yBEheNsg^iSH zza9d+CgLUgRhR2J>?$6+8)2(nMc)^F`F?bTn^Es-ner8bysN%V`lYhc)rfxzwAnh# zEX}IMI|dd@dALwl(8lg5nV_<^K-kFj8WZDs5(nG#MaQzj4kQIx;u^&mk&sM|1e)55_qb|?EURcgJ4M+C1*wLTJ(vJqn5K=G7)J6`>}AXP$^ z?-l;PIgiYF2EKE}jpVJq_)*kO|hZ zEpKM_&*Hx^q_kpeo5#Kji+gZn2vg&SRVS(cDt1sz;mEBPm}|?H5-WZxy9-6xEF3gy z`k%JKq4WW$xJezG#$#6$NYz+ZB7EgYj{UdgC)3@X1~!|4R+g&6EWfe(MAaN$AD~nT zUhXKS9-Go-bgti?u^6c5l47%Eho%OZa*3)x!6z)#&ftI7fJMTRm~B%lpCodu$VOAi z|3|H~A|WSYWKO;`K9Wuk_i3IPQMF&CP|I(NDDHL7xvo_00s_fB()Hx?BzeU8BrD_^ z{v#hh*sI8}!9^dj*x|;4Xmd)g~;yH#BWc3n#=< zxGR6Y6*~o_e%M%?%^9;;i^7BRQpsy>B&Fj2r{tZv2B^y44)icx8$@M@ca05Mtm~s` zG0B;;&ZPIRAPYRxkgfS12b&R0@9wu%9WDXtkf!wzi`AVK&sseOSF*QsRNE6kXJTS$ z#5^d4Naif1y3mwe$@J=oRciSa@=tuN`bqd?30!`gu;k?6oXx97j>4Hs)-%z8Y1k;U zu=|J?iHYIaH5}i!C!fB<|3E@K2lTaL=;W-sVi88hS2xiYzEWnGW<+WJw%#sHM2iZ8 zqfpZ-bY3b;4yvM^nF6XubW5B(<;Idnz$ zmLt3KuPcrG<^sQ}RkxLoxX1krwBNY;yan#VP?*eHA$h#OkQBWx(&BM*SH&0#@ZT38(>&X?FYh0H!%u{^2V+v0{~Gk)+{lRB59wEzK(1FecQlM+&~}rN zwDYA1j>lq5-RXwQTslRyYX$-`wp4nSV|TrVFRjSg-+S$;X2%77>L%C6p<+WyTRg41 zS0VzyC#~jdTf0XjjoZq79bnAdnYd0bBG>;c`0d%S-nRLBa1!yuCu3J^KnU1AB(y5O z07)$#5<1DZk7Gch`%nmF+|b1CZu?YY{ZPnc*j0by=DmVt`B0qhV{$!)poITmXMPX<+~ zv9AGJCW*tj5~dNRlG>@;-jEmuBCnUzmG>o{l%E*+1I*T~b&@m-)%bjZP?LW+RHl-a z`bAIqb*!-{-_cn$-LQp*m-0TF8WsV@wpjC*uk`I+cy4Cz324Xm#=3~87l&NJI)AX_ z7g`0&N2sOcJNRT-72GfJ8^d;%uiUNz)#kcPO-jy(`%WK6$C_N-6Qtq>k9;C?`!^s!y9jNEP^lJJ2dSyNO>rBpoT1M;ZM6t0cQfV)P+aQ30^VCS@wZ zP1=%DlfRz(pW(ri@y+PH^wf1wl9*Zoj5J1l(FF|;>Y=XxV*%aLxX~G<~e` zfawk_(sd_ya?J_gww5rxKk9u5B*aBI9t3zNOIYw1;JQ5ntJEZYc0U>mxjp1`U9#X! zLtZ%AoTzDj>{CchW;#P`k7x-EXl2E0BSC4S}|paDbMs$*iCCpt2d2{FAvmJ z70*RBv+#oaWK4C+*Tgm(=)~AuJ=SPq%QIp^RH6%uh;7n_@sUAQNw+@Oy~h7|<~n5y&=O{qs8TMQEjNA8*OPdhY`L_h zii_K-+CnS4Zn=2?ZqKgH-I$)0Yz)|A!FZ3D=Abs=ljfh2m-nYxk@t7kZSdH>$M=g7 z@lFbh029!9gxj~3fi6voHEf z#&Htm9Uq-m;O>{6wL^4|zqJMwAGC{)+iY%np^87^)ID~(n)INW(m-rki%ow-d*+0* z$?AOzEDg(jiY54j7S43HcIx|-Fdd-yt@eE}uY1~&>77rU z$%0UYY4Hu?bNWp5!M0(AM%DmBp;P#VZHK>1PT@-F`(EjjP_;{zDwe0%;;0`~l` zlz=T$c5ZjsD`MzO3QPuAaSWy=U$VX`tM^t^E_)xyns=eK_c9;iQHAs8?~YJmK1Vy+ z1Tgt6&)XI-f^j3Ho`csJLyJ=>IGGe?kOG?iV>p{6?QS){l2wB>MfELQXLJ;LXWREa zitQn5_HL&P!s@RC2ha~$8^ks`CHdw#!ozXr?;o(w9(9Ls(uQU`m7Z<)SnpWOCJaDA zsBdJ-vgk8BF_7gGckA$1fV(@dqw2QZyHyQ`cn^3_ zmgQuY3br(PioE@|5k#6RK2d*d?Gw>rZn&N%oiqQ86_okgEw|&XRL3U^+W6{*5~ila zNU?rkAPCsE2=K13izog)i$mW(i$lT~uQ@ARmIF(+N9=VVQ0qFDL%szQ}t|0|TS0=o9XFr0R;InqoEPpN9L(KIH$^;r?aZLHiQ^`fGCxsmU%WRQ3G6 z#;}r8zU@AP*xK1jE6GuWy~XC3^kT~e7PT~c6>55BX^5de^~X;A;6*r*@KOVOg`wQddqJCJ9d? zJ5_va+!#vtHx24+7RcmU3%5nCv8Q~KPR$Y9DW_vP@7bKv8Bx8kWYK1h>@r5{_`IsM z6^xyKa@5!3UA09qa-je?D*%?8!y{Rp2R=1B&IYmaTIk}gp`w2ji`KdSC>8}1{wfy5 z?Jj11>7K@PxCIk#2zPhfa%?=pv)MeH81Vb~`zpY1`u6b%`7yIXvTy^>Fn0KT#hAmJ z2zji$#?7YXwL8**!=-!MrpJ|!7M9G@gQpt7`2 z)pntO9{TZ&F8&TWbq)p!F1Z_;Hl-S358GW1Y;}*!tPvf^86ZK0kSD4~-Wt40uRpxe zwoK8Jmz5lncAL(szvf!cOV%cw4;}hP;kfV! zZY)FkvvA~0ApSQCM@D(|*HBD~-HD^aUl}*6X%@oj3!56i8@vT**Febbs zg|ocI`)T}p8W^24P-fGUjorDKg7qnCk6{&y3f~2KnNI1{PbE4JJP+EmgU_nhM!kp?7(8C*Bt1#Qzhd^hCG+b|bBq|FqutFURs7TTAd7o( z+dz9Ymad@=EbM?Ubl-V9u|4pp)y9e%2%$5WeebiiK>M)oZ$|umzI4gaopmov>UB0;Ay2Y@_!(D; zv|cU9)Gkp4?5xIV)O_FMsQhxPlpsh;mofAfpw2k^^+FAH-YBUg4^Yb2U&~c#o+Jj| z>6sgK=>QD&=RA*sPJhoke`f6_LrYHr;y#B0^kB$^73V^+Jkj}z6UW@eS9gX!NI9N2 zKl_Tr@7Gj@U-|~nViDeE00tuIN*sVBl#3%BN8U?=IpSTYfc6f^&Ek1X)xbHMlo44?9NNU_g03E z5kDpT{6hPaTFLZ2E4EX-VUkN_vbZIMC|z72K%&)63hD|06_B3`siE%@Um*&=b=J7J zYe!i}@hlj)SY~tYslG_dVed2=tQ(f zV?f2w>ZQ&kSsX+vUFNj$r|QsWq|5md3ioPuLZ_zDB1{ksmIrD2u(pyAa$C1)dS&QR zMS2Zt{@u-#;zn^;PuP-&`_U_fcV{GoQ%1UjzTXGXPMERkmfe1+t33(Wh-XS+B96Wa zJ+PqQgq56nQjM*=g$wNXM=ojaVp(ZucjT2|OVs-t-=%%nmt(GAJDV)xJREV((2JKk zs%!;?QZL5(vb{)?{Kb<1t`C^gs^r==IrpaySWNAlF0@Wt>nAs-vi-yCf=jcQKNPN9 z9~`f0?MY2EQ8+g7(D?+ z-=GY3hcse6)BumQr{hfRVzrPg@p_M7u` zz|LTj*ytE`U^ZFRC`ngbn=D^!$%F`=$+p>FK)(E0{M?0)Y;of#zBgN4eA83w*WQ|4 zV!n^`IirMXfrwP^jXYs6ZR%zctyI1>a~ug2h#!)BpFWI{xX{zQpZc_zoHm{(@utV& zVbG1nq*f9oOT*#8|k3?Y4POs1;(ZF1@6Kr`EWx&L+b4c1NP}0O<$9;xT={O7^4SSp)rHgk+fu zf5=x``QJruH+^mRDHT=cBK+Q$PK*~XdcG*0-}+|Os4(BQH}mzj4hm3Ds^nBKpOpV# z5bYX6Y3)XV3vwan3X|~5Z{Vh|R3%CbR`NsrsIvS_T`(38+-w!3f2I^P!)MOlM>dEk z)NMJ{5oR#4{HsoESCMeXRJYGK{%l<#KC%Z>E0bp*-TRash?@0g(tnNV=os~9LJzku zAByGCHx%rWkgq#O41uQrJj51s1G=_{4OHE|PQ_1xthcFa5|vxUA?)u#?(cGETAejw z_}{l?0LCrDIs`>2}FiyEP5 z7(O5HGI#dbtFj=B&IbvBi{KdccY~&V7z|rPbvgI^IY>L1{sVpETaz#!dQYI#7*-0Y8vueKWMzW4? ztVyMu7;VK?p_jmR;o5|}>_m=8()qdO)Pg{ztfT}&hO)}DUGqhsPwWWn+8dQh>|kgb zLe@VjMtyF>LO)TlGq7BV8f5x+0R#1ji-l}C2qG(DLGM~-arJAx6m_SIfFcGRHV^l^ z=ac;Q(I5P{(f=RMnFm&0cLC@r8R?WxQo(C%rEQcKZpF|Bf9m{_gxbm6wK|{g>Fue# zYxODVXAQ@Gl-+dh?$}$&rg?P^TwSu0xL!l}^sCfIE65(_75Wd!Ne}VfvRMhkCh4gk zHD}`-{5QgxYqzc;=W(aBvV(0?GEy>?9OKiotJC9t`iNP7<*v3fu}X%7^wq-)$-(3u z<_%W~)4KKKiU>W+R{JN{eU7YSJyA0s`sM78 z3Cq=;>{oYvW#H$gnof!j%8O0T*JkAOl+qE^V){X2N(PH6(Y8rIomX6PR_OuNW9AMR zfFrtE^(ySyy~vL#z54D}`eC1kp_IW{GP$1U39)_~zc!Qu1;Frrn;(3lZ#}cJ0hV&L z6lLKxN{EB7mE=v$Ih$pq*t9QHFxkS@t9va+mD)*89-O}LB$?o*1=NwjrRi;e&)Wln zjP()!)-gT*q$4xeJyUJ)Qr=gP`TH!&UXEvGfc=p{PDS;kW7AhgSGVEG^#OF?;8P%FGS833t_0MxGeM z8Tf=Rh3XuN4;ffo4PZ%D=X7sl1w(vvc*0F~MdKo{;W4AZA-e>dt_F`kxQ5J@C$Rt# z;m9|NLa_rhd02@;o&D@WN@&CTCT;^4%(ytlM*FYM+nvA)cUXtn&8lJwty!Nir7zp0&Z)(Dm%K=$&Bs17^LSNBZfe8_F?B5xWQ` zM}rYWV$lV?5e}l1s%jH#29)NFk314mRm`k7;(^lHQWhM=?BYqa=RzH~sXi9sGDen$ z!h;&=;Y_omQkBWwDf%Ug$reagi*R?)@SIC`Xl$eb8-PHf==>767HCkGU9wbSAk;?N z=~dipx=V1UN)oue?X4z2=KmB=cJNriw8zg3$q|Of(c4L>Lp`hpJP(Z@lswGQ8l|U~ z_!OHZSuAZ;q1+FYsS8b`X!c$g&jAsKXcau!ufIekI;R8{!s#>4;1y9%e0j|TDCI%V(p*{PhKNFWXID6|r}lo)n^X zw>#NO=&Z(AHpPX{vNu|COn(o97s3`iJqnd@hxQjO%VQ&SokA9%rCMYNA>2A-^4%}Z z*vH4Z*u~ewR|^5s%i6Omr&L_WZq@sKT`x{Ac~@g$M3jt7*@5W%gQ$P!$Sk60+)*c^{ZFmu<^xGq7{P(K;4HdQ6M+~ZPm!(H(^cjfkB8Q(8YU5X3f zmI_ykQjSTw!=ttxy~OcUbhL^QdM=l}88jzk38*qqm&fwbkHDdkju3k{R^p$dc&)9L zx`jq&mea8gJ0!A|*)@;TPK0gTRg(yWlsdNL6IKBAa^%>kOJ)&?Y0`8U8X=N>o@>vZ7_D2+&6LEXK`-5acg>6wy zuKp|0clh;@p!X0ro!I_PvD?|yP(8I*ud)=8XIMroWB#w?-EzIK{X-P1V! zEgm~)vImteuT>@T4jSE5S+@C@diqYL8sr&`@*se3sfYbmi9*3f8eO7cx-s>EJ}rUK z@$~o8#!V@6^v4)!Re;wRe`GHrQH1Xp4spwk?{N3*P1r(h3v=O&^2?=+R12hRhM)ZA-4B${njfImcXV5d2X$b6RUd z)d`F)0I@%JT0KegV~p33a^fIG~cVxBu13Jz+^ zZ*gJK6!n>p0(5E_J&ZKZhIBPPdYX6SYH-^tW5!;QS8@&a_x>IVG}cwq8-1#e8O3%* z^R~njGRZrkqDjc=bQV0A~()SB98sy*K`vOrrV zuhaPS+5{jrRoOxZsPTcprM8~2&|S!sRl$Mm(on#%-Wuk#Q0>4e!$G6->yzSvo~FF7 z_Ao9lY9XBgs8PVB>DMkzLxI*OaL(boHr5MuIaWHa^NmzuQ%6Qd+@*Hf*UPAT3IEPl5(RpP>#y-2&hvzBYlKW>-jt%Rr)g}NHP zwH4gw_Tg*tX!<4F5MT7!oOy2*6{myh zQOXO&l#&yoCXxq5rT9i~>7J~?AC&=5o2b146Ej?6<-y;Wvze}Z4iT8nG;_CiF0pT1 z`54drRIGEQG`VYTlb^3(dCJ$IV?fD5|5+JXWhb0imfUV_($-AHbdm(C3JNV3eD`KT z0wT^bG|rr7)G@Y@kCht-AA?uicDuPV%=~Jt>Dl)_{ppw^?Bu)d-f3w`K0P2==2}iw@3w8A zXh1pgbEDC*@j}FK&FI7zJ>ysqaob#tUqleQtT~d(JX({<2{juw{w1Q}BvLBXb*Rkz zyD3>8JKX5LN>GK`*eeY8r4S(xr1u7%)-vfLm$Y_D1fIozurCo1CKlVZzh&I6-Bv|Y zsSCX{{rN*xHNN;$$qg&v-Hm9IOgbts>uGZLm+7!0>yTwRtchpQJrd>7(5gWHn2_O@ zGudp8+Pv~ibE4-Unw~H4K?o_vS!&>Y%eIea+()WR2N=bk%!|d@0O!sd3jm3`#qOf~ zR*AC6rs6$V)6WG?)YG&=srqd@$;vvvDYo*a(lbwxJT~|GKx|Wt?jdFMU9rnnD;F-w z*&M_z$M8C3AJr<19gq+z5Xpfj^D|3s^{_cK+<6qarD?Yj@fn~HZcn{(ltD4fd% zKeZ{wAre?Hvhv0uuG%CSfShA(Iw;(R8N$-gd3#3IOwb^9V|4y1TYWLqhU3tXs!Lm+ z`!dmcbCHB1i0PJY^$PIzeOEm`IX#JQZ5sUfq!f@seAu;8G@F}Y<72NgyL-D1ig4C) z8yf4k-n*tQNsIhr1%q5s)Zz!m^*2IT z%`G`5_LDHqiGiCZ_XcmBI`PyXQ1(?*B|tec46vUDkhDJBFHOVWvy2Qi1+bam(e$jU zjXqfy&2je8H3Y;BaBEs7P4aK{0fAamq!wBb1XGK9tw{ixHg)?oc{Q=TGvKM3=E=M& zSzyaB*iOpMhyUg`A?fqFYw2yPF2T2D0RZVb4wNU#TC7)Xzs^-q4&!m%C%r{n<##RJ&N8I+>Sd9gy9?}QUNIGK_yjIcV^NBL zN-nUcb;;L$Si@`FY8awnEtxQsUDrp#73{T?aj}h|^!4DXhD$eU?W#?BasBE$3V;}` z!gMe|S%8#Tx8-5pYTuy`yhUf?f41eVvHrv=3@5wjYLqA%Y&@l;219JI6)57Z)bmX7 z&*ey_{}Nr{X=3w|{cd^eSdcm{)T6sqef`3P;T1(Pt!*e`VSN=$)XU~VT{rcKl_-mR zRq+wb2`)EuM)5}EK{=+21-qX+o#Jxm!pY*Hda^~3TlTwQb~d(YDcIEAwy;E=0N3m8 zT>rW5{iEL5Q+w2SxQ>Ko6^rHP6ROo(+xpYNx*M|#&+_*BbNioWDjBl_V;n~q((w;m zg6Fi>1I9#yyzD^XNCt=-Lgc{;`iz@h?ib_9{jWVUzlVxNHTfb~AqyfcL-@wc9jFDT zP(`%`a<+pgi*@}qvq`fWy=O~>vDbV~8*3>peN4*{*G$kk%M%i|{U`lZ-mEJWXpf4a zI^{L{@9zxyqf4#db~qew*L)PbP*Ob-UcYD}t5}+KH7GpyzyoD{B@Wy^vD$5H|6tgy zK&7(Ni?dV&H^Ok#@5}wsaD&>HL%-x#3!>z`%|-}!zrm&of7^|IrJE%E;Sx+yRWi8b z$m4I%WO@PrLD&_S)pW@!PKJOtr4BzDe1JPVD_C`Rwsg{Q0j#X7o|KY*Lv2C~o#@=M zvNvfEnU?(SjqCYl=u~!!$u8Y%qmzPKi{aB|BMUV-p{{_O{^+nhu{-SOCHEbZDl-{v z;M615nWKNKy_iA!JT{Kj07wDjwon^yy%G87dSH;UW1?tC^+(2!L3~rOux@bU=#p~4 zHR_21mmcWLX#o<#ntk)@`F@RXE7oP&l%m2Xli(}WxUV3FXi)eLoOMHIAtmVh`YXa_ zC=mE&uCZhyxiD@`Q{K-C9X$0h1~C<}zD|?ox?O4rDCUf~^e_uYJ&!NBXA*MgYRKx? z7bh!p5qC_o6K_IR@**!bx2lg$_qFt^ZShiY5T3CQnR{-(IXe4{EU%HVY<2DG|HR0z zQ+n{GCpAX3JalNa{h-9p2TeL#-+u*ZDFx5?m`EZZ9!^WIR*VQs8arM;rV+KeJsr)p z!q{KlTzm}}IvC=^p5=Db%2oI(c1T{hzNKTA;MzPUIFs*KVU*{1Szu6EQp28tPGv{z z9RQ~Ft4*be8Lg!#PrHWy@(3`kxx>Zsp5h+2#Y}sG{@ip0StC|85I$&Pq?x^qTIUKX zkEl1cIQ6w??2VYqUCpyw4xBiqgg7cA|9j3{kYY{Rwz;AEp6q+kzbm3{8^nFSNyOp< z#RD@wCcdjX=8?3*EZ-y_C!hDvlp^)nD||Qy*`p~NijEFkgg1;a&OFr&6S0h*nc1+; zKj2RXY4~A6-j!@k^{snBd$Myn$AIl&aN>u(vcpZ=ZY+uFWoeahCbn%UR4yhqZPJ!l ze_%ZHMUZRj*$~;EM|qrzZ<{t1F#*M$b1oIweZ|6v#fYd<9g{uzq{RY<))SB4W*d1I z)ADb0j#0N%B>jgDtDB!F zkWV$$C@_)zeeiX@jhXJ16F;=VE9a!!K=^#)=7T?GMuY3P$MwS)=);Zu_rets=_a04 zC$3=DGdYucwx}bJ{DEbDR)O-b3msLB5;evWB>ap01~)K9reR<02@BdQ4O54;A9O!# za2?Lskl-nFJ*fY$Oz!`ogJa4%qbmPN24@{yxO>${Uk~f?hhJhzk+~Ud2BTzwpZRa3 z1h~E2fB(lI(kU$>POC?rEFA(!!k8dv!k~3(g35az5c5k3=HmPzT*K8nv3faDaAF6) zdkPJN{u{8BUfc|+TiRqt$zx*2^mc7I;6XMmULtHa!Xsu?Oe zYY_D|`*_p*!v|++KTRCOsQO*IF(p;O^C>D@1{wfybFqL{+73S5e|XQEKEE!FUFnRW ztc<_ehV~3P{r!Q)aSl;qm9nIvbWr}zzT>y{Cxfw1Us`LF9lG3^Gu!+OC)xM0WxH*| zTvxR&rC{(!|5=@GWYHSPyD_Nau>I<3_N#Hrzx8MQ`05i^dxN<(PGg^ES?ZL`>Xb}3 zbxb!tXCd&R{@l3t0K#;h^5`*I#pywRtAt-#5Fr%?G^fc#1-axiVag9~I_yDWvNA*y z+%owmr04xBB)f_=VucT~4vA;EJkOb2uQT3~!Y3Z+I8Za&I4_KSn%QOi z2DV=Bz9$kSe+*1$)Zibh_1h2=>i*glX8Yvxc7~=)iK)*h|471Zh>@nFGL45fEb4<|I4S-k-^X;9RL_z0dygy6s z>9ut&_~BR(iB6B}g+XEJUP?qh@oOBE>V2&Z&srcVvQa7I!!?ep-B@V;FT=CSwcx$8 zMy1is>e@pjEFM@Ro>FQpOW&jOFy9aiekaECi;e5X?(rj}r1lWe{ zxB%6$h+_|F?n`uiz}7y$K1o$Vs4B7f z=BFpa&}+#`GuR7tx=_lOb~DFMZbex95GQl@mfPifA9!iLxu~FvKKfxtFD|1ry;(oF zR+eX|uI(%F=Z495gBFPi+F3?`_mwVL=XM0aefm#_EU zKZC4u7&Cvu#{V`9y9Th}0r0BzvQ!h-F0*6JD>-elCT+V^biyZetM7UC@!i1#sKKM( zd-?1|^Uft{FidBgrAR^5B4?hSp5NHMB;x}C8ul$-LEaG^q@>`i6tD@-H~CPMGQBw= z>hn-sp9;sgKKG7mVyP@CC^$Vepl||Th#xNJ<2Amf>>r)jdGTCL2fZdvLKwZbn&+Gn zv5C`?3h8YS`kF#0m{g0)-r`ChUuJP#eo%fKP|p8XMj-R^l`2XXseu`v5xPjwLd-8+_mK#7;E-IKcXWk zyvBWF`Itn6@=KwXJwRjPETQFYaM(_N9aE8gJKiQU#oj3P?0GQb`TD8U)8K0_(tpKI zr8`(->>a}HWsj_`XaIeFzbf_w#XvHtG-@7d_1g^9d==AbVo2bgq&F{P%b=Hr-L)X$ zqh4-c)5pS(I9jl)&VOM4Ch6uJ;K%=)=s%OJ#Drh%ZJAKR3|lPlXG^8EF6SYv{+ZC~ z%)&zP@wd5=mraI%{nLw9QhkNcnqS(#xMGSFlqwDaNjg)O7Andmgd`zt(K==W@53FK z|F?9(l~hfoDuYjL)9L zj#n_RBRbu-k?&6j0;XSMH4@e%6?p|e5qP{WLQn1Ps)oD>dsnI&Z6UPC#fF78KO-Fl zP6OJ%Bx(fq$J(CU80K{!3n~+3fH%1y; z&)%;FW*8}UgoSu{u&!6RR$^RiKPJoi<71^>>V>R0+VlWSZsuY$5Ry%TyH!~vt-O)Q0Xn6{ z^H!xnomp)Kb&m$8+#oJo{wFk^MtcDmDL<9BdYudc(iAqdDz1=a*OMDWi z#gm(Cb>~B4G%vjjY?(Wu>S$u<`WLfpfOb{k2oS(+7|@iFeAg5EM)e5VoL1s4PCx7D zG`N8lEG001Xf5J(nN@0`w4r3l)nD;N4-treH$QaV|G*C&K-BwBZs>Rb9r_Gv>f&+_ zh=e}3&=DK=CoEpRp9_Rd8w(Frmy86s29x?SRF?*+CGJx_t9X=0y zDVHQ$b$}35sCQr^Pm8)m{V8%sU?3$UmQ}7Q^fH8Xoc*I+$F(6zY?6E>Wa3%F*l)*v z3@s+lP9JV@8-x+!;J`*U)l=5xQk z`Rjv}R$83^(5%ey(mXz(N&lYSmOkZ~^9OF!RduENsG-^qpVCk=cM{)cEq08ssF~G3 zeC2C+<`UM&2kM_=ed0-0gQff`u9D~#Jcw1uL-NMjUUq-CQ~JN-Vu|(c16(YLDsReT zc~Iy~Kc@zgt%tMh!~=7|Olj!!QXruO03XzyC`EoqkbJ9Q%O{HU1Us=`@iYVO=t0|kA(C}0#QR$vPpszDbo2X7sc zV_HdCJBEw~e%m#QR2O}fWqW9;B{%}ZAH!^0hGQ^Van`KjYkynV4py(89LfYm+hsqx zmrTtL|6C`zIdy!n)Qvg*h1t-0=R$cnt!xIZ@s3CM?JCAmId8=6TD3oFm+4tO2UKW9 zz6dPAFRs3#)l|$S^s{7LEm{A%@#?WA73D&KUJ3QiR=7)j@lzAO&>_jQ1B2w<&f5z$ zL5|4$VgcIgp*aKL?^-rH~4t zm*eYs8F4I_i;M2#fFnp<<*jc)gEuRe#4kXr&SF!mxb zfxgIbjmyR^o>v0pYo2ogZ$ymyrZW}y!gk3=CMZ72LQIhz`?kh!I`~Mb3JxtGb*8?+ z&vvwKhFP821W;R-213eOwm%A=m3j+{l6W)7WJPspt1F4z_5*wGs=69DEy>P+iH{l4 zqj4N3x>Ggo>{yeRsQ!1;ARUC(!^!P$cb)}~4= z+kd=UpRqfA)KObjPo4LmIF@(uC|jDHpZ)V-^?UPhhpZk>df4U0q0=UkSqTB~ReJ4Y z3UeJdR(BM5E;#}J5{eien38)pPVa4E?52{Ff9!f?C`)70$D57UV>{K;KRG5kkjEg2 z!JD`UhtbXG94PtKd%Mb_mBqg11G2ZjpH->0%K0L3-Q9MxDM74zw6IYr_im$Dx%P#R z^!ErVq}Fv!Uaw&oPW{>qV=vAt6^n(toa?mQfc%t*18U(O8cM4U?6Vl3h7Cra@~`7#rP?X7^>ny)BzILiFT(xz9H=)|g6=Ds_?i%_H z;BcF8`pWdYclQ}LtYbr-UHN*vp^=s>L5&|O30l#j8)bu2HBtFVqegQ{?WK*k-ABoW zA>L`tH!;BS1&cck2ji>2VbjC(l{Gf(cIM1KQ4ZZThn&gPAe42 zCulu*zv&85q%!r{U2RIGP?>>uktgw>kX-2fcb;!MQ4uVP!|(-|rTLX}t)+WnwzB)d zFW2)8$5*ASG>GJ&Mf&WEJNT)4+xCEt-9^=~z3WdzkX}jTMv5SzxK#sxV+B(4i;6{4 zlXKG@8QdcwReXm*TNsp^rIND7D4d!@!F%j7asyfarNh@rqZAM%Rfon9Ix{yj*X+!b z?SwVZQ&^WD)-EY-=L`txs!N~|iyh^v>2t(1bjUf`HpjGxByfb`>UuFTURv65EiOC3 zQLlWsCU1hlnaivW_sJGIl97nBu3-wdMeTPiZSa$# zwVu16i3(+9svA6jR)qxgY)&;JI>K>r-8U<)pRk^O_2ymO<@&G_OGQ18K5|{F5ZbAiAiQ~<>P=(WQALb5q8|9kcgJ@ z%@FI-Bh2IXpJrGp=;H6asFKS{6sJD8<1sjxgph`2*Dkb+LYRiw&}yjJ8nu%BojBIMr_wuu7(^o z&o-Rr_gHCFnTnXB%qO2-;-W3W+1VtFMExeZ67^D~=se6OY@2$NDTd zVeSs9BRfebr9JJh1)5H4ZR#zzmWXex6gBwI9%NfF?OY9yc02edPAy*QW?;UwE^*&K zCwW}8cX&Ndu@ZdafIER>j63eP7I;^H)P+!U(^!NVmhtCI(tx(5?po)-Lo35rSu~%qS*ed#!VyXCq2){nw7wYwAfC->}fwB48 zev{EBx_jz$HaDDeZJv9V2zMEf{PczT{_`YS2R?s@`qE9o&0&7a=Q%9Ba?K!C&U`5- zPZoEvOQt4EdHu^+(m@7GjC-|qWKe;%UW6^Rs&d#QD77*5tzxWwK|Pt!g}JyAwP-FKNo!|+fKe~u%%w0rUX9BBlCNN`82o(ch_$ia7*AD zg7oF!&5{u=1)-lsPE=~+-A{qhl9mD}q`hyGH4SdH1j;Y`@iifX&0p`#<%VS<*PPcT z#$Jw5y#PkKi>VK6;b8r0#iHaOCVPaDKrc+$49sjkDkgFEcg*Aiesk@Hz%>@a=Ti0< zD}bJai#Ky$57)W%`>fcctT7z7o>^jhYf1M&M=9yG(9M|-`U=^u$HrgpFASwAL|Qjm zjEXb{>tw}b7O13N?1RTd#(vL#pV1U*`;2vJCgcDx?QjRG*6A!l2a5hUb@;H4#L+jy zh~#6;uaOcre~0qC2iJ}kf3 zC-PwETtRedN#Qd^*R3x1pPG*%riL8Xi&s|Q8*)|ZGXC`ey3XcP;HBL&wt(tj>GtL*okh|uv!*evU8dbeD;^tD zxWpq>j0zuqMoRN!^h}HIdYZVvv>aC3%_5DwzLu2{Jgyhsx_ggzo`lh+Ui~s31o8S~ z*i;OmE!)C@`dF2Eh4# zNJ{B&?7H6MxcR1%O6|e7K37nMw&DfDZzOvtb7W^xyELrpppo|_+F49#ulo`dg*4~R zs%tykP~ZfJCokbVbsUhPboa?g0$<11JYr4Qed<|Ma!Svlb9-QGoMU{f32Kx+GcGGL zvFZzW|M9>HlPVrX>@Kc9@}@K-ZaRnzhx$@})X01yK|Q+BGSp|29)r?sIYwfM;HWzi zw%baiP_RFZ{&pd>Q>-7=srJ_2Tw;(~n}%_Ax5A9Q7=3LNK%!i$r&o(qF*NM-1U7qF znFEO7$^t^-;)fxXlp>$ut<$VNq^{BjmgjOzw+ouZmeIWldI)QdB;c0tEzSVl$<>E1^}H>YZl_1S@Ky=kU>qO>M3PAo(WGcFnLs7aSLbXW~P!^>`~NCi@ddJbxYkK>Y!#tsg`%I*ulER!jhb=i94Qt&4G z(>}(jLUrgj;Mr{fy->9NAX6iawZCh3Jru|Qw#$eFgl_Kl|7-;Rb~x|V{LtiAMi&ra z%NREOGn=buXrPI1-aWmQS@nmit@R?<99Df`VEsZc@K6_FX3i$Cp)yTCI5#Mvv#0x6s}eLKK9viWAkx;m^xn@0-K0ld-nz)w*S z!2X*$IOKNi8tJtGkZ=Feb(iW~esfn5G%~Hz_+^s;B3Y(;y{M!(@4<~M&Wn(VB**US zG6`{xVUzJ~P1$eEacrWi4*C?{0r-1&oTTiK=@qd&Wl(?EE+qEmYr~~`CfD2o#ZvDY zrmM}BILLH&*Kwahieyg&uQCpLTnN|^m3xNECdm#^!786+t zIXb!&hCs1;59@8D`_G9r9mC=`k2-Nig=BKmkWzVqS^TS4^=tR?m7P|fqHN34n+v04x(-K$J@PL6wq3CQ%UTKMYf#_( z5uf|;rKAE#dgg`2A)gK0cCBvKbmv-Dvs`YPy~Z{!OjAaAVd4`qWxQZdGj$u_|2`7m zk;hZc9Itj5fxjSRia7|oiwkL@;SrbEUiNMj#K{Us*daVvrZWUax|m!1RQduCSna`ER(+s zl_T}Vd4ZTZMckntTvnRkGB3)^ypilV3|IyVxt)knFhCT%B$71shmu5NNYb4QdRotF zd^U?WH^S}>r2|)A(pqe*4s9Ci_IUb;&6&`+MX&MN5?oQod@z%SQ*`nzVx`@~IaXR8`+ zaRfc}fcTM$kl%~}1Y)b{+F~Dd>(qx{Nb%V&UYcv&k=Z)m9|{6+X<#+$W1?rXzx|b(9`kA}>APyNhB@)j zu5j0Dnch<_YAWQU9K0F9&BDyA!TYR?~1K|l*pIbJW zWM(&K!A-_~i_X}jp1jN_@cSZgw%yVCb%$>iKm1$oUI0*Dw(>QnF%)ppLd#>9PoR+J z1LEv7`)x^GX_ZDiOOho6>1XnNdH1%NW%zEh`{x&=lWtw16g>A(xKSnIAKHjJQC1)0lfa~LZoZg*JM%++@8P8Z>Y2Vz+*qwu?Q7l_^fXXACj~*l z<6D+ zP8t!l0uA1w@rHH46!4hqT`QR?fQ)O(0MM^8O!#hJ_gtTyuCxd#4JF9SaiUCw%tBB4 zBk@!s-@H&dPKuT^IaJ=p47GLO?<>f7=iY)4`?R~JC?Y-Lps=a@r?kqL_3e}9nM#Z~ zb$nj2z)9pLM$%ph^h1L;EbN$6#InwN5ZLvwrzwPT$WTQ1j-Rt*iP@y?BZ&@IeTekQC z8SB6PbN$e^KmDw`uKc2-0ADe80rmFIH-ARO#j6=To;Bzb<7L7h4>||kTktN1NJ<|YuMN`} zOrR7x>N#6^K_#a<(JxPg+xl!!kmKBPKz0TE-2oGTia6*uUIODkmJANfZbL0L#F{k< zbjjt|J11rGEe;kZ%OG06#lB*k3yp}HSS?>nj@?`?Z=Wn*PYeCgx)dEc zb+>*zV!g%&$4r`LYUUR5jFQ;j>S$!n@#jKQ>#s%>BRnKnotrpk=&U=Ah$5vP|LR zc=>uj{p!*}eYz6Ytsco;7z`+Hykln*zc%Ydsm2~g1T@?J7U&fjiyh+}_rkWY9;?ja z{vX=jIxNb!YZn$N5d;Axq?A;UMmj`FS_CAA?yjL>Kt!b_hX(2H0hyts2I(Gp=o-44 zJ^1|I_uc#3&-WeQaqRuxf6c*Mb93KUtaYt(t?N7miuJ^HK%8uK`O;;Lzl;WMFR|+{ z0LPA-xGUpy5OVa88u?g(z{R^)wj1X+;YTyKhDl8KvNq3uf6G}Lp{&2ybhVK8;B&7= ziwn!=2#T9P{1Zo&E%Z}Y4p+PtpV>Z3;|9HmK}$e)=b)vBXL8J~X4zTpctccpMV$3{ ziVu%{mt|$v7+<5MzfI{!`uSOv|Dfe?#jj5-v0c&n*=D8HEcr}o{x-ewX=_804+=Q0OguM}KzBzP(Th4*e^SUl~ z4dkiqP%gR176lTMO-=8s}!EN;Ysy*l}^$Z7k#L!xNsTa zTAIxM;-7A&KOF6gOUR-wZ=sBcQ3)|aw{GwE?we#H$mXzP0W11@6X%j<@Z){Wn|AYN`1jgX@e6 z*GAH}cG!1PEOveSyY%I8Eqbfas>+e0@8t3Y=woxmDa*D;{^a`~LB$?dRW~$oh-R=~ z4rRN*#*Sr?Gq>S0=eQD%c^Op+_M9qgX-hoOn&tV5^!>4Y=mkQzKXHK%lJI0jQ{4&8 zI+d-X#i6T@M*0lmY!5J3Jtbb(`Jc+vU%BS@dEXgM^6;OSb}yXaoETHNBBtw%U*1@# zl)r}JwmP&43a=A%-Z>k2DSR4qRI~7QH(9c(S`K%#O0zhH0sZR=+Pl2d`j;fNBu6w} zRWFA?yBH(b9ZA~TZ=)G@t4dg|=w^d|o1s74Bl-I8(^L1C%@v4Ng+*TIR5NP-r&95= z<@|>{BE;LXcTDtH=)2mf`wJv^d&LDP)j2BZAv0+7UD7$0mA1PMKQ+G^5WnC(;FU1V zlM}ENQmzs^^)z(wMt-= zLlbQrsR#dugUTm}2xlp0oXrUml6VecZa&s)W@!>9+wYn$&1x=Q>h*kBg6!;u?R68eWRnidfdC91AE~TzW+> z7|TzynKhjhSj-l+>Xd0H@>pYsZC(PamX;l>c%D==zx3F1pTg{kEi;NVaMw5UR`Z6V z#o!#(k)@3euPU~@d-ArQb(7afFi9{4FX;AZW4sh;R<`Dkn->y-OYSkj*Od#J1L-%G66`XD)FFvuH;@Wd`g#hzg^pyH^8t3g1 zkJiK_M;C8vm(nr=56Q?a&wVGGxJw~FoMyxGzJ1H#*{F$>@A)xyEQ6eY>S-1LW1=g6 z+yK;0$k9jRObyOQB?FgYbTkDf`kQ(qe6$VFtXQMU&D9ZrrX`4j#LZ=OkdWWUs`1HK z*S3s|XRa$U_6Pxel)^H2;Mc`&gZlMtsD{?I7tOrTV6ov=oJI!3%=INlmy0H1tz@(a z@jzVGcrEjiVuKH@6>kg}Xt~`x{L1UD{T2=r%Y4Qh# zrGS6ey!Yl3tQZbGXc%$HdWq&!h1qJ|B$|CR*C~N0z3e{w%cIYW^h-Z?EAWNrGIZ># zX@x~Yti5+R@0eB#6Y>Dl!bg>_dVJYEb2shK1~V|*hK+$MBdRWCoWVv7DvJ@nM3lo_&lV{`|Z=HBg-2eN6`y<37&#qC9dJc6J740C;H(JrJ zXbUfx<9sjObI}aV8x^6P~V+J7s^^dvHqLLE4*ERAGdd1Zhi#$Cq zZFa6+BUm&pVhj-2%kO=#by5Ab%oS~W6tv)Zs+;E(%vyCN6X!>jx{X;k&FcR1%?gUg z5?2XVYvS;V4+fH7k4wchc8MSUzer_y($ig5oqLCrT`A}68TCJf)=ID zv#ZU|n^Vm1R;&2kY90G1<7QceDd&@0#l?W%<2T$XD?B&)$f=F17ac$;beS^rhED;^ zTexD-@=HF>K;#c8Q5 zP|{Z*$T0#fDg#4>y|Y4e@Yq>wGO(rik!B^wt_ zf|DE_(b}dR#uY!fI9E%go$*4oIZhZUfHW8anVeQCCuxx9am z*Rm!Sz^hwxg!K1o;;86bp>?eRNq7q$9d?Pwn&!5m1Z4Y{60FvKpF&6#!cz_0{c~78 zcx)r)*^M^e@1NZlZXqK%wRmO)F2iyYZYAvg732VvL{(`16$O`<`hQ{S{E-zNbp|Ya zsC!2vX-DwyBPaA%|4&LFJ0_%8N5eh-&Pc>!S`f&4L$#01$n?rO-t{rMDL#7HDN>4&Y(*NCtlu>l`bdTHlTH7 zZkc#--4K14AZ3U1YJpyT+osDQkCkug5_YQrJHl>jFU|+(t}W4r1<@)je}6U}j7*@) zxc#i(b5)>o-wpmZ7c<}|>EO3t*B3K>)$h~HAmO$~^e3ngw~r)K1f4JW^w#pG(W@Hm zK%Z0oXU6~Wu9oJ#l!37f;THn_>$j{fYYx>|>zO^|D+vswEdbjHs-^<0im6T#6pCY=WVuJqDmk<8Kl=?UR{5QS$_fP*8_55#@au2u# z`J7?`G=XdQS3RJ2B+rSt3LmHZAPWfyA2b_-6kuIRvAyrfQP+0d>}?R}dZ1oh{JL@^ zxU;He-6dh1%y+)Qi?_ESP>Z+r;U|s9uN^lS-iTvS<#gsqzb-Vn&$w`#Q3vwbb=|2y zP50T!xLTN7zI3LkU)%EOpY+Lyb=^ImZ9ILEMbH*NNc^u$?W^(IZ{PoQy=>#kl#>3} z6=zH__=5glS2q{I`s07vn^WMp{nPwA4*{m|Kh32nzhcn*)BOMc?X$2nO}0M^>~;3_ zZ2Jmy1H!;$9&5E=Q`0&JPE9dw%e$M8mUG`d)C(1(4iTZ>-)E-P5+1qL(C{73zT%`4 zIx(;7Vt*HS!IIR#(X0~>{H?1o<<*Au)gYOyiO*O}$Fj-pIh)Jo*MOL^j1HKz7GWs=Vnm7p@a;nXHEhdW%-%`Zw$cI>G+~a+_d<9~UQY{UV!YAY`P#r{lM;%R zwd25FDqr+aYByqFE5BtGk$Z^Sm=;8Q>3Xu#w6^l8H`bf0Fdaz0+4~CCaFSr?yvb&j z5)#L9Avh&WS$J}7ApYrcv>wTwTn!P*x{*pffD02|h6bIIyV;qJhmjoWS5@B)$2GPd zdM-rUx}?v%tjnCl!T7)jKsqUcU&MY2J+MVpTJ%tlQfpQRj|84*sUZsuqF%ee*fb-@ zY0}+#$kuRX?2j2@n=s1Tr-m+%8mdbxc0{3<)OgGjWimZsnj29&m`68^Z%s~E&egj*Ix_UrzVGaaxvaSm%6(kObmWroLWU#zsR2RxrEH!>oi-o zEC$iTRI!Hu*=BwaBvno?_RH=!=P_jOtC}M(@@K|5aHkoaqJyqPc{v+4=rt^h2>92H z?!WnPUJFDe{FZn?THn`xhFy~?94YQ^Fa+<~!&%qIfGFDk8i_~4s{q`_c8F{MT$Y4!CXx~-UGYC>()}$Ra~vj>wUpyDubG;;l?aNzcLJ-U89_=dy`f`xHN!JL@7X9S1!>T$|RCUjD}Z?|o<<9I9XG zrH^biRh$i8BRQ7wyKY!T+8=0JHhu%9iH_^+8Ut9OfUJiCM z;nD56048_&%><)12MGIQdb-W%czN8;Yx27hI41P}hN7*dwJn>ebEeO7bMC+t9(zOb zd@|yZ*zunPpQp@|;KJ!}^J+_@&Ty7}6&TtxiAQbqM5ZbJk~tOb;AfvsVy>n4mV~? zcCj>J3KEXvTXWP&lj51aAxw`uFSvuqf|P_^dOf+l7_QMA9;<=i z)$?kd7!}{UtlwCc%sb7`guBcAbbUvo2Xv~eg0o#JfCO^Ghf!-R#O7IiMC;NwE~^nY z7q>-Zp-)8A-P$LvvL1V@t|Pu?ULSwazN|L?!I_f&vT>I)NUeEZE)Mx9ld-bTp~9@q z4v0S$>2Whs_}k_xpU<&7?Mt6Z{VWl)2wjp^3ViTD13?-M>?0%e3lb`W3E1sX8}3#f zOYE#Zt03WZT@mZhp3jj>*V|dX^6Qw+sNNog{%o$yJmhU!ob=y49msb$*H$+j(YWDf z^iIn>B1Ta^DNIM?*c*JEC23U4kgJXr+Al7CeyaI0eNl*P)=K_GX8~x6X!}z}Y{FE2 zd&0*?3+!@BM96v4CMf+_erz`-Ae{_mkc#ln!1xWMaqc_55<5*p0eO(W>ARVCBhDO> zCyq-7t5tW;lY9+^FD@wE{X@;V!Ru>Ms?-d(4Ug4M6_x@b^|2b7HU5-aZkqh$c)7Z` zC?9J$Jvw~ik*;w|v_s28o)N#zpPRt)Xw)ym&Ye&vbe;GNdEM70KA4X&x+^mYtV zi53Ml^$;!76wdreh=QuBt{FOtRH+XR6J zjY+UqSv*u#4M{^hd3D9hO1$dY_)WLhUcAVK5wyuR7NNTS)iB4qPvJO~E2Hpw-L={~ zw1^uBxxV_56xFuN`}|kglH2US9_qNN@ArB5?X~a)Y$Hdye|9nHmrdR4I$&LIy)1JE zxTeYfYg&fnO4zkW%H|tJ#!1sBBAn8rjl%+&rkn=KFmPd%WMhl{!VB`JA>_ZBO(r=d z_cfOWn8HgqI2_uDqc&MpNwqCgxS>i+aw}SZXWZ8O3G0UQqo|ex9oFJ+{O>O;w!|5= z6wkN&e{z(tSr*k5q%^YFH-URQ837HqH7f<3mnR5m@Blyl$Sd9S_mo$R8Yk;iCYub& zwVQ^OfX&_U_*UmAAiLA~4(I#@iIYEb9AtJCQNQ>d*45%$OV{df+3{+N{I+a07#d&i zCD3f3!1Y0K{V)(w9mhTyFbVyPRIPSxdZu|Ht5A?q$ml<`Q@P9OcP+|hV03ds7OsZO zeJ8Qkv2sW`m<#RCL_v1nu&IbI`R5e@)h`@2X>mn*1N9lydliNKp1Pl>Wniqh9=D&S zYeW%C6o2>DpTcoVc;X#I9!)6e&)C!0?OzWu^e3eSuTH1|g${`M?=9YM?Bj?H+|{`0 zq!#{Snl8Ebxo7RcwYL>Ls=6<{t1sTR*msFY2F3!&1$#-@0Ws6LhNhK@t-Riu?-5rR zK!m4y_!Bw*bl|5hrA&a$y7d*IE;BF}_B~irj<*49>yh8S-PO6Kd-Qvz3_w3Vki}ZZ z#=kx_kfm=RH86vSDq+t%foE;?y+1*8<*U}6uGpbSEGid9$3vdp+O?pax!fDw+%d_S zw&^~kA=K@LPuPZ-`jSJ672JwHbqn$~J%RM_VwfG2;(kdS(`1o5 z1pIu3?I$jML!Z3mr40B0$OhpN0fFllHkWgiC#k!_Kg)~@4XPJYN&Pbli7zhJ^3A)BE_>oo!29P)+n2!4$MuY(5mmNc@&hQ#iSwj(p`j6^Pk8%gNx7la_p%&5 zy!8UA+jtdM(Kj1IQ}%`~K-ZdK>l>D6Q@j~Fe$|2pZx*U!BW(m3ih2T!tL&$V0FLUp zpOP$kjZxj+?kOsyo$*@RwS8$TAD^105C@TUj@}3zF%8R($)HsrNXX_=EAw>w1`-OO zu@WM|k8H*j8Ce}!r&gunRdHlTR@BYGe0nF?6Px1@hgF(tg2Hlg{_ZBeI*-G58v{cA zCyJ0m0n)Vw!t2>i@trYEmwwMO3Fk8G{q}|?tn`eK;v3b&Qx5*;5Z}Bp;AdZZZIYw& zAB$ef-0jUxzd!@{p~pn>cv00cC7Y~UO{}94t>w1bMTpn8uJk9CHuQ)A@s2r0#kx<| zr{viNB*evK%6_sSfk!c~5Vo^V`96um_L5ldms>W?zJw)!Ya0F<&Nb;>&@S=swT85d zi#K1^A3pn_#*M0K5cEBhYRqJ-wV$>y#5i0Vs4MplI6AB`EJ;Ou_JR`Z^~X|6HnQaS z+pT;~SEvZRC^CSlJU-59@;5l#<@YM7o@;}u(4tCCE6QTw!VC)fvJpR*_Ncgc3imAw z$b%&S0sq>@eur`}U<&gXBvy>u#k9g6*HLIsQ!i7c2zo_^;3ZClFEZ{|tlxD($EFFaOznzXbm6 zk?&nZcZkBjuKxvHQmS*WCmG%jRbIMarq$cz5Cs=-Zlc!S+&Z9p10`01TCVK(CPE2& zmGEY@|2J&p|93=7E&1%ziPaUO?c=h_!$=(+)BWxoPGj|x=dds6k+^{QKOQ`%t-3WQ zI`F^9Nh9~~c}WT=xS>kbf*OANJ#1ngJMAyP~lu zegnhaM!BU3D4rnFD-4?_I|ejI~~{wtF3O*YtuYl+ehk^@McD9 z(6+^&vg_%gA9o0oMbjx7`_p6mk zr3G2haev~BiBm86fZXLHV$q9!kwivyTX6%*M|L*nX-Qh@*$qQ}1G`QLN|=GI)Vi3? z(msQ#6OPK%rJNH_5?{?CHcVOU4Q*XdU?&c&M?pPZB?3(7IVg zErRPdTK+o?`t)1POPz=VyK3q*h<5cD)nlh&st=@O6W<}}0KzxTRKIQ`UAhd+LDPqy zvWqIDW{;2?IQDMEk!YlrEmpwczKwxHhbW9tm})TK<4hw2hl*V5XJ!)_yZMSuh*K@8 z4`{!KW%ftW*2(L5aZ6P30;)j&3wX9!Jfrp6xJQ3DbX8eL#0Avvz7+ogN6#ZGy9wtQ z@#*JQg$K>;LDB)dJ4|QC5OE+}ATE|8k{H*15vH^YfCP1-kRFe8<{P*mt54| zRYTe=v;7tHfz#~#G!Sgi_I;P5Ra3YU1EM;UZ75TUxVmpsCu(ciu=BeemkgtPE;MSc zI&AU%%5H75BBXzHRi$N0Udwbj7{6uyx8V84?!MEZ{MA(6H%fy5q>5UGz1$pJOSgX_ zcxWbbU{|+7zE2UAMprm?(&YGAq!nhj^@M7x@^@lJTyJ~U)rj*5moqbK6=UYece+gt zMcwtdRa8Jf!rm8NvUWK4gwYUT*cMDz-r5oEP8LDojy!cbFT0I!6}ZsHz1?FY@VO8= zHhI-AajmS+qZk+qq&OLr+6=aH#NMM5aXQ~kb*t{1=Od0L_b{aA$=JN?fkm0F=OHKR zNB13}r$;a-t=gB;r`QEZy)e!E-mLB{7g3GD&vXcWIne6G{JDHwVvW^L-d|bwJq>hw zHI_z0^uy2eb|?7FyKeN3)AaKH_}qr+u41_|I?E!BUj6SLCeCvViY+s9w12(#hX5msfZ}L5=iXDiC1ke0&B0tzKx>g zmBTI8ph1_>ya!w^ue2&1i>H8<7O?1^&GD47m^~zN@9JxKT|UHa@(I4p8r$V<{tLvv z7q?x-1o%8IAm6x>St2-UW9n7;h2U9lv)n00EtOiI8suzQd`qMdnYR)bj|nBH>Z6Vu zFtAmZKDzmQXpQ-d)+hRQ4ZTY)mReTic86&^orNFeB%4<0sLt~ zr8gPDmE1n>f}17uKO{E?<(q-d6aBHDZRj`IVsq5qJZ#&6wW%=w;xQWWIJ){Fk=PQF zQil1OME{(b1J*;Ql~Kv-bkdaT4LF}}IP<78Y5@tL2+pPC-&uoq^j7|aOXz}n12qyipz%0cnE7rVD9Q5pJYikkkfI7q7TyFDZE{*%#yi&i5!(sz}a=vfE z>|Wa|1y7{DRXrioY=%CK6L|SFiF^_S>r>3rj$|6Pk)EA!Jek?Esjtgc@{?FEK-%eL zl)%ODa-OIV5i(+xtbN=#hN1>I8oYl=v423Mul6y2aNgb?e33Q-Nz{7KJ6T03!?3d2 z{9t6vS87C3jBds1(ayN}M6d_yIkSjIS0;QdYB_oFxCrqynW zq3U1hGjN>LuF&)C74kwrj6HHHWr+SZ^61Fk6*U!8^JM_6FzcuCS$8@}mjWoWV4C=0 z^l|oaI8+X_O-mi$U=14@9}O(I8hrmgqxph8Wk(r?s}*eTDiC>t#4&q{v{;jp+tUgC0_OBiIug3|pamG2hk>MHHJ3cnzt8X}-ULNG7A_2dy%5V%*zv zUsNuB>j6*m6LP9e9@mQHSaD@Hd$!Qjs{hj4h7p$)6)*GqPbbZP ziD}KAkb?`i-_N&xoIW-n{;cakZFg9bf4;@}JR?3E=2?1JJaF&tV>uvd5?L$VYin$# zB3G%ljoQNmI+2DG_d1DZt4!qAe~FZO=A?Y`6o42!^^&az%0=6!VF6jPg#_-lBb4#EVasCbIy~{4(8%ZV ziG3Rbt9Wdkg2Z*25oRHH|3n`Dc`oxyUUN}dZ>YAlq&2bWIu;N+HbE?dX|gI`&G ztitLB_&9FwUKh*sRv1=9Z#Ady`zau)6Xj3eb!*8GKdIzbS9BD&E&fVw`Q-NZqocM$ zJPYV?r;jOwkF^C%zFb6(23q8g5fTH#YVg+ys2L>IHj%ofwhqCf%aWa0&M=9MLMj{~ zTGvv)Xkm4n1E4-=B)n~;yFCfY*X2x#;WFa}gz^<@&o=sqgt>W~FYYbN zK=mGZv&8DIc^05~3Ky)Wb%BC1oi@(Uoy1rD5uPpH`|E*`FJ{XUmQHNPluw4XZ8q+* zmu%pvGUL@1Lu7(*_p)fcWRzYez~LGb`%5)WgSm$cWidUI$(BFBeaGUIgGjQ3Dm z>~EIte`?LPXeg1g2suxWl0*_g!vy5(-_)54rP80(#8zmPu6DEn{Gz7H)ay)co4W29 zz2-@e9MSAiNpFe3a?Ml56~7U%?4@~F*dhCaqD(6UJ2`&wPQMHWv% z@rPku`5fP#^+<&EgkN)O0F@RK*=PvCpnfc_7=`(3NsQ+L%AF(52PQ0H9IDAe7f7e& z5bLw9G_onItOxnRqigmoHmBpJX#g{*F)(wkpG58SeR)9tC`&XGyS?}Y$U-~;vwU7Z zywZx}I>Vc0i_$xL17FM&*goIIJ?WsA&HBUdn+{38JFb+cN1@_CQ9}Xf+uUk+(xDi^ zh`DD+Z;Ux@Awd^neo{i)^R4rUUaQF z%EI$WF-HS2qU7(=UDO)^iv4n*FiW#DO~?Uu6V5Tr4%8hLQ}M4Ii1@W#lnbB1Bl$4^ zP(BtGRMWNln1D<*VvEnb7`5}L_`dUz`A6=FM-b@`oiZ3WDd3ZD-kVqxJpptAbU*JS zU&B+H0L2_%75tai1+Leuk%DkQCs_=c2+1BEs%(rj(m!0C715vFrd&*UIOV*l$|G3y z@r|k0- zidxfcwY0>}Rl19cpHX0r-t-T=spc^{Jm*YCNLz1fR9`go$)3jzM#)8i^&=t^Oywbj z%voo{44J(9oJxpSUJv6gmkXZr{Hmn!op7%ojwJ~7HDVGrC!m7$>0jO7CMg&)yJzZE zewf1sYocj6YfaXU_KAW`UkiBY2IpKA?Su)W7A(?b63<~^-7ylFV>(|7F zE*}+mYouV2^5E1W)5x)ok=M$J^sA%LqJugP;R)g6*Q$}jJuC7ba^3x?1f!iTs^(}{ z98{Ti-?qnyKN)$qF&7X<*jfNu3O6UUo`(t(Ty5H=ijdK^hXR_6&?y2%968$3@i6ia zis~q2YBnw>YA8|hwf_37s*)!xezo=f?&4S9@nJJ4g*Njck|D%Od>M0ELYw}T$My-d~g zI;CoWIu5y@tQUnQ+%Tq;)-Go-SG>aepb~(ScI>HnJE?$Ru6{%#I`o@tGM}=>)9dXy ztLBvZ@zjPeMA#9QLe@?|Mw2amEGW)wwYVK)-VaXSdo4a3MOX(YIxsw}Y4sw@C_t#_ zo_icRz5Yoi40CpC%l3Y5=2*wZ`|?2Smn?3u+1r&5y608!qvSZ+P~51_tarfFe%iLk z@sWZKbGwTzsVmv*zVJrqV`R+DYILN43H$-&NW@B^+mR= z@WFlYSj)+s^t0keJyQsEOL(~YWfL0w;JSd_0!uPPbagCdxOaY zr}=_d;HsC8a3sud-npOVGL!Osyqum|FZ194;eKsO>4F5Tck5}w6aj6_i!adUK$Xqe zeJTU4OTR+_x}vqI7(aS{Y%O6j-E(&KSyx+5Qc70>y7%6sZ$BH$4s~8du7sHWlq@RG z2?ssNbvv)mre=5SbJlZt-F*H?BO-kNfgO#JJjcCvN%`dxCh<`}lnttYwQe(vC9k_% zqw~B=&aSAp&gx&%i9Z9y1Bpc<>z2TQ*FG2JY#{$+c$lI8AtAw}HF^E#;WFwQm zAUdTj9U_n3lYAcz72UZ)@`@!gU=&U!iBPyqRR*2O*7WP#mRUEt+|~>};EtE+na%5z zmflyQi^opAp%#hy_E{+Fq}RE`%JgrxzZG2~_QY%&B_?6zOcawK7~ZHZXdP?{ z<$jaNT*ahv8T9M0^lQbAaIxbaP0rWvU5L)kQ)kdM-1Bw6Y4=@KNBdL4^(%t312urHCrZoe(XD*QZ7-LYWOU5yJ;Q#ufNmim zQk2B+UoaxerZA=1*=VSmdzr)!c-rkFVUT!Rzhw*>GBmpi-lKZCdtCk?$5nn{OlR?AuSwEfVxG zBF%!E1ISBCU$O{GArlvn`$iMkd8e#=Qiz^@b9eZ8CNFuLKNQ#N_F%xhDJ7!2j0Q1nyd)VrAVI9W;+}i19igyRsp94tVHjZU9o9V ze@@ID_54kRTq4JfBK^A&B!H%F>%$LII$`-I-^B1sJi44KFqp+=XV`u=9BG|V%wn^1TU zTV?UEin;05NBT__zhA+TexJt;Z?!~JrT^HrI*Bu}N&Z_HQ0(H!V(LxuzGd+&zrBr}$@s!Sg81gG)Yd+_!<}*~I992yc0wtXKAB zI&z_m*S&H4>!43n=aO9dJV9#BY1F(KkF`|?zI%$dji>_{GvblEjue}0WX2*+#`gk8 z9kov(2a%X)kXcdnk(O%5cos2#{C8;29a4XRk@SrSR6y=FhE7^=fo^<7$nECEnmY?Z z^+ne9hd2jo^Jv@rbt8x;!boqi%x*L92WbumH;!3>KBZs21QgxempXp9C`Hu}jV`hi ztNZ!6T0aqpvojl}`1Gm!tb6JO+;M&j!;7YJC#2Ep@@&*G!s-^oB3?0-F>X7*r^y{lH1Ch})>=mz;)(a+2uodJUm% zrV@^xO4qk?*Ux4O^+4}?z+*25_ zPN&yB(+s95@HG zHaEBSZsL~&h0khxTU{&CNS%B0RTk3Y#k51 zKe}7mQKW96X09eg%WCyI$~GliqS$Zmicz8Wsr^sajY-GM?eb!01tgW?yo~N>fpy=2 z8&vJx#V5ZEsVen%!yiD9Xnf!|>gl&AHJ`NHr3?((j)AsI1^#I{5VJ z{@RZ)Vr1NLs!?PtQ9OT9tHa1b26?C?uB)u^I_a(Y>{iMjyf5E-3;|z8Oi@P%C3Vxz z&gqo-GPutpx7`tx!WOmD@$18(dVvu?w;y%zrSUrfBGT!`jqY=(KuNzmKu_ncW3$Uh zjYAUKMh$dX&Ua0Oq?dCo+ip}sm@83l%X1&QZ?tsCT%PtNEY~|kj{}Y9t-gQ!HUY6t z#$CMRUMX+Z$TK5B<=O2%8B->WN?FG0lGJf7fWQw-@ZRtxg3v{^cBk3N**5!s{3bj| zwA1PU+ie>cD_iQy#u#7MujL&QkfJn~si$}4fN@z0YrOv8s?;i%w}>RxjBbzfh$;gc z^V2+8>!#^tRptMH^Ckl#u=G=}C!Y|*d}Yxp7Y7H*ABwg!lT;b9$!{N!sx%ns_7-fM z_VG(n`cHcVfbDFPS!g@pXQ7t9Y1B$*JNAOPdea3pq`3~dXZ)H|1v1oN7R+tCTMCFh zb7Ig4sI>3t9>9Y??`@g9OxOgjI36w{XFp@y z&X({l6EWfC0-%oZESxqe=SVl0PjG&cFL7=ou~65B-EViQ-rtElqI}#`U7wIYx6tmB zb_2PhFw2mBnIHe_OPb(q;EXnz(S!ytyRma5ezV2+&=#I(du*pqcYQyNa~kn(JmmsN zpm_;JgL#v|5^a-FqY|XIy}h9nUN1oZz;5s#*Y1*x){jhr2r*Cjj9aWIcMB-U6{5ai zGkmp^_}mlQw|j_*2zGQF{Ozt!OXZlFGjHF?XOGh_Q?eGXYw*#qT+tT=we_ORamY#o z`qUEiR7&gC-PP5uebwq|q%q&Tl$5)+nC3ATJCT2$>*VcpkKRZOtWb^se05(KYooD| z#29Rt#1brPq!G@Eg^?XT3>4Di&$i0JdKAh9=o8?h89kKWpEOh}>z+(W|0J{BXB7>S zBTKr5yVl@%_@I4HsUQCqA9KwWBx#5Cmu?biZxV=3*my2 zJfv&;lJ%9M)N)kp!uE8&AWnWdqBf7vY~`D*{KeIn5nK zj>mhSULUwvhgaACbI#)+%lg*y#0_^YjF<2LkvkphA0H*-?VCJEAyi1gD{8W`fc;B` zW52X?h~3(EB-JxUedKWB8-7btkl#$==ZJKH;nXcf|&~%f}55m@xg*3 z6SNBX)kwnPA~}^9yUQp)(yo1FST6wOAC7NqElnw_yV-RnwCP`Hfxx(KBk%zb1Ir00 zr1Cr5zkRkJGKI;_?hkau=4)^KnLcKfkvWG_jm?MJc);pOl|Q}@Z}<^qqEB6_Qqjhp zEdL6V(Az&BY50wJ*xXWAoWodDc;$~m|MEr^lVpN=Iv=u3HhR&XSF>B;VQa*zjT6-*`GHK&+Z>C7P6HDHzk&JJ;QyzAL3=z`XiPK_ki3WU~*3{G_yF=Yt z+CEORS8&R>l)lS4&4-Z%KiZ=jvVZmTRl@AH37I<6cz?pU{Pd7-b}TZYfO@->n65C6 z^Cyqs9n_Yt=pM>b32%6FB|5hmdQk30ox(i zoY|l*vj3=h(b+{m!()-?FZTKtUptlbn&OblQg`pEh(RBIY1MQ5r=*RhQ;cg9l+&Zk z&7)Z6dExN)s^o0!FWF59(lX18hO5@98THU(YaNoU_eb=)DxHM(-nm(dUvy@)*GVvu zJj~%f2SXf@82igg$M?L!x0p+G6Hy^MM_J0x52D40i8PmuV6;;Cv?$r|56EcY!L6F9 z7g-L`NkR0gekAqCn;1%4_y(+!=ddy|UzZCjM9137h3%|0~m@Iy@-1u8%eGprgE%>CB7XH*~vcg|X%4;F}{D zRY{~ne78J@NJg+XXX$%KmI&lMO6T`_%QSkxEzXAi)mW|pU+YNlWOGw|xb|U&7$;&j zFqPTR>r2tVwzvNmF}0@uPE5`1f5+5<0{T_-Yp9f^^gf|+rB~kUI7XA*M_3|2nH=nUqnb;?G`21DFY(P(sL&En-UX2~|#b%^d zv66CG#XN46tmziyhuedH4#_}sRl=a~KLei(4U%*0hdB69;>6e?K*Iy4`|mE)<}WnK z-3qrt(jOhPAhQdZH``%8C+MU4x73$H`nr_IMZrS`~b zwOp)H@(fC9oe&B{zR{5+&DJY+HmRN=!gwTW9PR8*?PHzWye(eb6%WQ=%_q2(0Nf}g z1U;p`4pD7MV!qTLclz!+qf(Wq+^R5hkNbSjXx&d0)T*Hs0$XS~wg_q5)9V>#WK$u*h(|?lhgtdizF|E!A>A#Ctm@9wa~n zC$afiO(iEFT7>)V#2v%V>Zysy6`M@8j08B1BMG>Sa>uVI5el%7ZKlkU+%p3 z<-POa%$%7$v)5j;_L?(m|9@+@nZ9hrNj~J^go}Z3ccIlg@Hd zK`;#6X4K1-n;bn6_wjFbKBhcaq6>2)#t5c+(*0BQ2gEvNoQM#4{(E@z_oRQbrs2+s z5<^viU!!<<1}{=J)N88z+xL5)6D&vl|H8Wd2Xp@prIq;96D!q!j1T>MWjUy*s&=`2 zeNL7ehI`;qvu1C8Dz)s6T?`a(`Wrv4XN8ZByViOP@n*WX26b0zq1HUlLZP5Cn~j%D zSZzx>B5#9suELA&$hIQv4BB8{E_e4lO6JQ&!HQJZAzlTE@!2$a&4y3?=?e=@?Y6T6 z`|zpA4!h$uZ3G2V%Gn)svxqwNT@2o>u$z3=mhJ*bVcUpYHfq{*cwZL}?M6hikm|TO zYe==a;)dqQd<1=Kq3;wGe|acH6G?pz4sjG&Ff;~|i?y3;z(~0;Xj`X6=fbtJY}E%{ zKGfHC8rNge50S)2HA-GwT08tBfIT7g*h8-?*sI4j7#EzDV-(O+0Rri%X#RYPLP;qO zJg^YB-vwGqVU`F-Ljn%s7Ps(hg zs>)0}a*0zqu7OnbL#^;)G;AwSu;nZu^S@qB)x|OTC(@e9(un-P)qA zSNe2BO>?!}f`Leree~56BF)T!BDbo!^CPgzWsWNU9pot1|Bky z__8VJHjm98r>7>m(qcRggyUVY^ug|SR3?F$_$UlMn`5F z z&y+89>p40RTlT>r-*aOz>?<~|M9J2XQbsw)Y+uwkDchd5ucZTJS1M_eQpF+oo% zGDj|9`jKHxLJb@yOdnt2R_@oO9qjhbMm?L3*$%vSVFAix>t!o#-=I1@3MbBU=vofUF&}H zEtmvYUod*o0+fCl+D6i`xKnGiiQ5^00*axV!-bq<0o*uaK6>4fg2LrX??bG@iI2Pr zs$h;DzF1mzQQnR63F(s-zL9vxd1X0lVKAdSOg) zH_Loh*^Q)P5KuS_`RL1_QdecA5NNRq=sd8o>mK;B^6+)4vaJmU4Jx{eJoGZ%cu0-N zk{i9r_iGPMsiwF(w4oOdc47=m^Iy;&B}p z&d*D<-1p9Y<%+oiURmk-u_x@!@JO`kYT;R}_iWwT`MZ3myiF-gqS=>J@B29YY&Mr< zaY)|iwMX1okv(eNvE~MObTohch@#x6m}l^sz%5DRd$A=m^&8pPo+dUh`_aS^3HrUA ztJJrKay&rww4UdFs$tSvy~u($H6be3b!kr_xF*P&5vzNeSX~rhi|C+u?FZN)+@Ioo z{3Suy&-MwwQ53vv!)8FIOvyy(D<@kmWB!!f^c4}U+?a9q8zuDe^TVavRTa0RoAi{X zGm@?5>_;8_*#J0;G07Y2hj;OILXWS!bk~8=UWQ1zX&;AJ$Zc~KEz6}Gia zq@>IPM)6V%TjG|ZMvkMxCALRnO;D%6t%9D$5eYCFB8WV zc@}JdR7!Nhz#KmP)pf9)(UCx^ZZ$C6KHg6lWJ`-70zC_9<(|2gcut&lc}@t57L7!I z=evRCzdhZ%K!60w&WmzJ-(Qb_M-uw{Ohqone6X69$}IIl(Og3|UVheTP+JtBNyM+G zGged}kbn7_J&^@Xw9`M`!RiKgxtyn|Cm&ttmqN77HYCo98>F7OmtoM`j3RoUGO4>e zO1axqrh!^(y-61vm9bLhIW7+BEAOxKH0}WL+aYOXG0MFuM9x0BGDD7@u1= ziQx)MAP_*-Z2|Owfq}pBsH1K^TT;91w9^}BCddSOOx`wm-47`4PfdWuzqfo=T6D0N zki!(ATG#Uyq_W@aEHm${AWgpi=2}#cl2Ax-wx#>QB*a-1NG{fNG&~yO+fCwRm%FtG zyD#ZvD?f)eaw6`%vIlND`s55eZy$ z^Ke20$~*J$gThKMRQ*sxta!Pq+!#rCWI$)tGUWOrz^Gf!}B!fQ*%^hN@K*v zazlJ>@-f75D6%L9{(gW_E~u`i_3HSBLae+Lir4B(8_;>Tu9Gwj-&6$|0h_+6soBu0 zE%&Tto0LSxyc)Q0hx8mC32Ma`^i3MA5P(z(du&W^Gh8d#TJ_s9`r^RY8~~XOIh{Be{%GHiThMV^8SM^sG;5C=GzTi z)sY%&+dZ_iVswyQYTz?v)>L0X8#ZM=Rujj7BlPo{!7< z{kisXn6s!$;2k?@>jwB>d}v%Y^hY^s|C=z9p9qwWnHn((z%e1Rl(2G$_LZ$&%t^_W4`v|4(vf;hJJTGXI1nX z+Mq};E3YO!-Z9p@Dy4H2M+rFR!*`x3zOj(^=OhKg(}{*H3UHYzeo63tZu@0?Ib{UT|-o%?b z@@izuDifkUN_g5?FBidjM#j&^%4Z=^d?M3`1+|kNDh!5hAMH%wzqT5856w>cW}M*U zhp!L-@=x^}=9Bg=z(wi54oNkx|7!3O@$Z<-Izct%=fU^Sr?lj_%gS*ENd<=gpu^x( Vln3tBZvX6)qoZM4fBH}M?HKsGy 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 diff --git a/docs/assets/images/monitoring/queues_and_workers/query-workers-worker-entities-do-not-exist.png b/docs/assets/images/monitoring/queues_and_workers/query-workers-worker-entities-do-not-exist.png new file mode 100644 index 0000000000000000000000000000000000000000..833c298b60c5fdc68f90c736d4106d61c4bc745a GIT binary patch literal 29378 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%0AU9+{Kr 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

kZ|3sAOZ3)DFLs~l1t`xVW~0J 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 F{{!l57-;|i literal 0 HcmV?d00001 diff --git a/docs/assets/images/monitoring/queues_and_workers/query-workers-workers-option.png b/docs/assets/images/monitoring/queues_and_workers/query-workers-workers-option.png new file mode 100644 index 0000000000000000000000000000000000000000..6fadf0593ec6575d1a3727ed388b2c95653c00cd GIT binary patch literal 53576 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

9a;#JfD&*4Y~;!CPx0u@f&7e@ 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 diff --git a/docs/assets/images/monitoring/queues_and_workers/queues-example-filtered-workers.png b/docs/assets/images/monitoring/queues_and_workers/queues-example-filtered-workers.png deleted file mode 100644 index 0ca463a1d8a13f3619d010eeb1bc5172b501111a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24183 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

85(+HvW7y#pRl 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 diff --git a/docs/assets/images/monitoring/queues_and_workers/workers-option-with-regex.png b/docs/assets/images/monitoring/queues_and_workers/workers-option-with-regex.png deleted file mode 100644 index ec96871103e3dbbf55012ee611159bdf8971381d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24539 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 diff --git a/docs/assets/images/monitoring/queues_and_workers/workers-option-with-worker-names.png b/docs/assets/images/monitoring/queues_and_workers/workers-option-with-worker-names.png deleted file mode 100644 index 147b29108c249f167d5414e29ff1c0be8ada6ca6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25095 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 ZWB)bNbD!O4G@gG-UT-5*o diff --git a/docs/user_guide/monitoring/queues_and_workers.md b/docs/user_guide/monitoring/queues_and_workers.md index 101c37ec..7cba3228 100644 --- a/docs/user_guide/monitoring/queues_and_workers.md +++ b/docs/user_guide/monitoring/queues_and_workers.md @@ -280,28 +280,30 @@ merlin queue-info --spec --vars = ## Query Workers -Merlin provides users with the [`merlin query-workers`](../command_line.md#query-workers-merlin-query-workers) command to help users see which workers are running and what task queues they're watching. +Merlin provides users with the [`merlin query-workers`](../command_line.md#query-workers-merlin-query-workers) command to help users see information about the [worker entities](../database/entities.md#worker-entities) that currently exist in their database. -This command will output content to a table format with two columns: workers and queues. The workers column will contain one connected worker per row. The queues column will contain a comma-delimited list of queues that the connected worker is watching. +This command will output a summary of both the [logical worker entities](../database/entities.md#logical-worker-entity) and the [physical worker entities](../database/entities.md#physical-worker-entity), detailed information on the physical workers that exist, and information on logical workers that don't have any physical instances yet. -**Usage:** +There are two different ways that output of the `query-workers` command can be formattted: `rich` or `json`. By default, this is set to `rich`. + +**Basic Usage:** ```bash merlin query-workers ``` -??? example "Example Query-Workers Output With No Connected Workers" +??? example "Example Query-Workers Output With No Worker Entities in the Database"

- ![Output of Query-Workers When No Workers Are Connected](../../assets/images/monitoring/queues_and_workers/no-connected-workers.png) -
Output of Query-Workers When No Workers Are Connected
+ ![Output of Query-Workers When Worker Entities Do Not Exist](../../assets/images/monitoring/queues_and_workers/query-workers-worker-entities-do-not-exist.png) +
Output of Query-Workers When Worker Entities Do Not Exist
-??? example "Example Query-Workers Output With Connected Workers" +??? example "Example Query-Workers Output With Worker Entities in the Database"
- ![Output of Query-Workers When There Are Workers Connected](../../assets/images/monitoring/queues_and_workers/connected-workers.png) -
Output of Query-Workers When There Are Workers Connected
+ ![Output of Query-Workers When Worker Entities Exist](../../assets/images/monitoring/queues_and_workers/query-workers-worker-entities-exist.png) +
Output of Query-Workers When Worker Entities Exist
### Query Workers by Spec File @@ -456,7 +458,7 @@ merlin query-workers --queues Say we have the below spec file with four workers `creator`, `trainer`, `predictor`, and `verifier` that are each attached to their respective steps/task queues. In other words, `creator` will be connected to the `create_data` task queue, `trainer` will be connected to the `train` task queue, etc.: - ```yaml title="demo_workflow.yaml" hl_lines="33-44" + ```yaml title="demo_workflow_queues_option.yaml" hl_lines="33-44" description: name: demo_workflow description: a very simple merlin workflow @@ -506,14 +508,14 @@ merlin query-workers --queues We can start these workers with: ```bash - merlin run-workers demo_workflow.yaml + merlin run-workers demo_workflow_queues_option.yaml ``` Now if we query the workers *without* the `--queues` option, we'll see all four workers alive and connected to their respective queues:
- ![All Four Workers From 'demo_workflow.yaml' Being Queried](../../assets/images/monitoring/queues_and_workers/queues-example-all-workers.png) -
All Four Workers From 'demo_workflow.yaml' Being Queried
+ ![All Four Workers From 'demo_workflow_queues_option.yaml' Being Queried](../../assets/images/monitoring/queues_and_workers/query-workers-queues-all-workers.png) +
All Four Workers From 'demo_workflow_queues_option.yaml' Being Queried
Let's refine this query to just view the workers connected to the `train` and `predict` queues: @@ -525,25 +527,25 @@ merlin query-workers --queues As we can see in the output below, only the `trainer` and `predictor` workers are now displayed:
- ![Output of Query-Workers Using the Queues Option](../../assets/images/monitoring/queues_and_workers/queues-example-filtered-workers.png) + ![Output of Query-Workers Using the Queues Option](../../assets/images/monitoring/queues_and_workers/query-workers-queues-option.png)
Output of Query-Workers Using the Queues Option
### Query Workers by Worker Regex -There will be instances when you know precisely which workers you want to query. In such cases, the `--workers` option in the `query-workers` command proves useful. This option facilitates querying workers using [regular expressions](https://docs.python.org/3/library/re.html). As full strings are accepted as regular expressions, you can also query workers by worker name. +There will be instances when you know precisely which workers you want to query. In such cases, the `--workers` option in the `query-workers` command proves useful. This option facilitates querying workers by their logical names. **Usage:** ```bash -merlin query-workers --workers +merlin query-workers --workers ``` ??? example "Example of Using the `--workers` Option With Query-Workers" Say we have the following spec file with 3 workers `step_1_worker`, `step_2_worker`, and `other_worker`: - ```yaml title="demo_workflow.yaml" hl_lines="27-35" + ```yaml title="demo_workflow_workers_option.yaml" hl_lines="27-35" description: name: demo_workflow description: a very simple merlin workflow @@ -590,19 +592,6 @@ merlin query-workers --workers In our output we see that both workers that we asked for were queried but `other_worker` was ignored:
- ![Output of Query-Workers Using the Workers Option With Worker Names](../../assets/images/monitoring/queues_and_workers/workers-option-with-worker-names.png) + ![Output of Query-Workers Using the Workers Option With Worker Names](../../assets/images/monitoring/queues_and_workers/query-workers-workers-option.png)
Output of Query-Workers Using the Workers Option With Worker Names
- - Alternatively, we can do the exact same query using a regular expression: - - ```bash - merlin query-workers --workers ^step - ``` - - The `^` operator for regular expressions will match the beginning of a string. In this example when we say `^step` we're asking Merlin to match any worker starting with the word `step`, which in this case is `step_1_worker` and `step_2_worker`. We can see this in the output below: - -
- ![Output of Query-Workers Using the Workers Option With RegEx](../../assets/images/monitoring/queues_and_workers/workers-option-with-regex.png) -
Output of Query-Workers Using the Workers Option With RegEx
-
From cac4d2f7e26c8b13f0649f45673d64754c9ef17f Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 27 Aug 2025 13:04:42 -0700 Subject: [PATCH 71/91] update the query-workers CLI docs --- docs/user_guide/command_line.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/docs/user_guide/command_line.md b/docs/user_guide/command_line.md index 8842a11d..e043f201 100644 --- a/docs/user_guide/command_line.md +++ b/docs/user_guide/command_line.md @@ -1156,7 +1156,7 @@ The Merlin library comes equipped with several commands to help monitor your wor - *[detailed-status](#detailed-status-merlin-detailed-status)*: Display task-by-task status information for a study - *[monitor](#monitor-merlin-monitor)*: Keep your allocation alive while tasks are being processed -- *[query-workers](#query-workers-merlin-query-workers)*: Communicate with Celery to view information on active workers +- *[query-workers](#query-workers-merlin-query-workers)*: View information on [worker entities](./database/entities.md#worker-entities) - *[queue-info](#queue-info-merlin-queue-info)*: Communicate with Celery to view the status of queues in your workflow(s) - *[status](#status-merlin-status)*: Display a summary of the status of a study @@ -1322,9 +1322,9 @@ merlin monitor [OPTIONS] SPECIFICATION ### Query Workers (`merlin query-workers`) -Check which workers are currently connected to the task server. +View information on [worker entities](./database/entities.md#worker-entities) in your database. -This will broadcast a command to all connected workers and print the names of any that respond and the queues they're attached to. This is useful for interacting with workers, such as via `merlin stop-workers --workers`. +This will query the [Merlin Database](./database/index.md) for information on [logical worker entities](./database/entities.md#logical-worker-entity) and [physical worker entities](./database/entities.md#physical-worker-entity). This can be useful for seeing which workers are running and where. For more information, see the [Query Workers documentation](./monitoring/queues_and_workers.md#query-workers). @@ -1342,7 +1342,8 @@ merlin query-workers [OPTIONS] | `--task_server` | string | Task server type for which to query workers. Currently only "celery" is implemented. | "celery" | | `--spec` | filename | Query for the workers named in the `merlin` block of the spec file given here | None | | `--queues` | List[string] | Takes a space-delimited list of queues as input. This will query for workers associated with the names of the queues you provide here. | None | -| `--workers` | List[regex] | A space-delimited list of regular expressions representing workers to query | None | +| `--workers` | List[string] | A space-delimited list of logical worker names to query | None | +| `-f`, `--format` | choice(`rich` \| `json`) | Output format | rich | **Examples:** @@ -1372,14 +1373,6 @@ merlin query-workers [OPTIONS] merlin query-workers --workers step_1_worker ``` -!!! example "Query Workers Using Regex" - - This will query only workers whose names start with `step`: - - ```bash - merlin query-workers --workers ^step - ``` - ### Queue Info (`merlin queue-info`) !!! note From 2fb872d14069e90a1df03cc5a9cd73044e26e1b9 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Mon, 22 Sep 2025 12:44:44 -0700 Subject: [PATCH 72/91] fix one more merge problem --- merlin/db_scripts/entity_managers/entity_manager.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/merlin/db_scripts/entity_managers/entity_manager.py b/merlin/db_scripts/entity_managers/entity_manager.py index ce543674..cbd78cad 100644 --- a/merlin/db_scripts/entity_managers/entity_manager.py +++ b/merlin/db_scripts/entity_managers/entity_manager.py @@ -147,16 +147,8 @@ def _matches_filters(self, entity: T, filters: Dict) -> bool: # Case where filter is a list if isinstance(expected, list): -<<<<<<< HEAD - # Normalize actual to a list for comparison - actual_values = actual if isinstance(actual, (list, set)) else [actual] - - # Match if any expected value is in the actual list (e.g., queues) - if not any(val in actual_values for val in expected): -======= # Match if any expected value is in the actual list (e.g., queues) if not isinstance(actual, (list, set)) or not any(val in actual for val in expected): ->>>>>>> upstream/develop-2.0 return False # Case where filter is str or bool else: From 0320158ca732a481d4fc1f79a41c71557f9ed46c Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 23 Sep 2025 08:53:14 -0700 Subject: [PATCH 73/91] add query-workers tests to celery worker handler test file --- .../workers/handlers/test_celery_handler.py | 249 +++++++++++++++++- 1 file changed, 248 insertions(+), 1 deletion(-) diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py index 62340f9d..b88661d3 100644 --- a/tests/unit/workers/handlers/test_celery_handler.py +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -43,19 +43,83 @@ class TestCeleryWorkerHandler: """ @pytest.fixture - def handler(self) -> CeleryWorkerHandler: + def handler(self, mocker: MockerFixture) -> CeleryWorkerHandler: + """ + Create a CeleryWorkerHandler instance with mocked database. + + Args: + mocker: Pytest mocker fixture. + + Returns: + CeleryWorkerHandler instance with mocked dependencies. + """ + mocker.patch("merlin.workers.handlers.celery_handler.MerlinDatabase") return CeleryWorkerHandler() @pytest.fixture def mock_db(self, mocker: MockerFixture) -> MagicMock: + """ + Mock the MerlinDatabase used in CeleryWorker constructor. + + Args: + mocker: Pytest mocker fixture. + + Returns: + A mocked MerlinDatabase instance. + """ return mocker.patch("merlin.workers.celery_worker.MerlinDatabase") @pytest.fixture def workers(self, mock_db: MagicMock) -> List[DummyCeleryWorker]: + """ + Create a list of dummy CeleryWorker instances for testing. + + Args: + mock_db: Mocked MerlinDatabase instance. + + Returns: + List of DummyCeleryWorker instances. + """ return [ DummyCeleryWorker("worker1"), DummyCeleryWorker("worker2"), ] + + @pytest.fixture + def mock_logical_workers(self) -> List[MagicMock]: + """ + Create mock logical worker entities for testing query_workers. + + Returns: + List of mock logical worker entities. + """ + worker1 = MagicMock() + worker1.get_name.return_value = "logical_worker1" + worker1.get_queues.return_value = ["[merlin]_queue1", "[merlin]_queue2"] + + worker2 = MagicMock() + worker2.get_name.return_value = "logical_worker2" + worker2.get_queues.return_value = ["[merlin]_queue3"] + + return [worker1, worker2] + + @pytest.fixture + def mock_formatter(self, mocker: MockerFixture) -> MagicMock: + """ + Mock the worker formatter factory and formatter. + + Args: + mocker: Pytest mocker fixture. + + Returns: + Mock formatter instance. + """ + mock_formatter = MagicMock() + mocker.patch( + "merlin.workers.handlers.celery_handler.worker_formatter_factory.create", + return_value=mock_formatter + ) + return mock_formatter def test_echo_only_prints_commands( self, handler: CeleryWorkerHandler, workers: List[DummyCeleryWorker], capsys: pytest.CaptureFixture @@ -100,3 +164,186 @@ def test_default_kwargs_are_used(self, handler: CeleryWorkerHandler, workers: Li for worker in workers: assert worker.launched_with == ("", False) + + def test_build_filters_with_queues_and_workers(self, handler: CeleryWorkerHandler): + """ + Test that `_build_filters` correctly constructs filters dictionary. + + Args: + handler: CeleryWorkerHandler instance. + """ + queues = ["queue1", "queue2"] + workers = ["worker1", "worker2"] + + filters = handler._build_filters(queues, workers) + + assert filters == { + "queues": ["queue1", "queue2"], + "name": ["worker1", "worker2"] + } + + def test_build_filters_with_only_queues(self, handler: CeleryWorkerHandler): + """ + Test that `_build_filters` handles only queues parameter. + + Args: + handler: CeleryWorkerHandler instance. + """ + queues = ["queue1"] + + filters = handler._build_filters(queues, None) + + assert filters == {"queues": ["queue1"]} + + def test_build_filters_with_only_workers(self, handler: CeleryWorkerHandler): + """ + Test that `_build_filters` handles only workers parameter. + + Args: + handler: CeleryWorkerHandler instance. + """ + workers = ["worker1"] + + filters = handler._build_filters(None, workers) + + assert filters == {"name": ["worker1"]} + + def test_build_filters_with_no_parameters(self, handler: CeleryWorkerHandler): + """ + Test that `_build_filters` returns empty dict when no parameters provided. + + Args: + handler: CeleryWorkerHandler instance. + """ + filters = handler._build_filters(None, None) + + assert filters == {} + + def test_query_workers_calls_database_and_formatter( + self, + handler: CeleryWorkerHandler, + mock_logical_workers: List[MagicMock], + mock_formatter: MagicMock + ): + """ + Test that `query_workers` retrieves data from database and calls formatter. + + Args: + handler: CeleryWorkerHandler instance. + mock_logical_workers: Mock logical worker entities. + mock_formatter: Mock formatter instance. + """ + # Mock the database get_all method + handler.merlin_db.get_all.return_value = mock_logical_workers + + handler.query_workers("rich", queues=["queue1"], workers=["worker1"]) + + # Verify database was called with correct filters + expected_filters = {"queues": ["queue1"], "name": ["worker1"]} + handler.merlin_db.get_all.assert_called_once_with("logical_worker", filters=expected_filters) + + # Verify formatter was created and called + mock_formatter.format_and_display.assert_called_once_with( + mock_logical_workers, expected_filters, handler.merlin_db + ) + + def test_query_workers_with_no_filters( + self, + handler: CeleryWorkerHandler, + mock_logical_workers: List[MagicMock], + mock_formatter: MagicMock + ): + """ + Test that `query_workers` works correctly when no filters are provided. + + Args: + handler: CeleryWorkerHandler instance. + mock_logical_workers: Mock logical worker entities. + mock_formatter: Mock formatter instance. + """ + handler.merlin_db.get_all.return_value = mock_logical_workers + + handler.query_workers("json") + + # Verify database was called with empty filters + handler.merlin_db.get_all.assert_called_once_with("logical_worker", filters={}) + + # Verify formatter was called correctly + mock_formatter.format_and_display.assert_called_once_with( + mock_logical_workers, {}, handler.merlin_db + ) + + def test_query_workers_uses_correct_formatter( + self, + handler: CeleryWorkerHandler, + mock_logical_workers: List[MagicMock], + mocker: MockerFixture + ): + """ + Test that `query_workers` uses the correct formatter type. + + Args: + handler: CeleryWorkerHandler instance. + mock_logical_workers: Mock logical worker entities. + mocker: Pytest mocker fixture. + """ + handler.merlin_db.get_all.return_value = mock_logical_workers + + mock_factory = mocker.patch("merlin.workers.handlers.celery_handler.worker_formatter_factory") + mock_formatter = MagicMock() + mock_factory.create.return_value = mock_formatter + + handler.query_workers("json", queues=["test_queue"]) + + # Verify the correct formatter type was requested + mock_factory.create.assert_called_once_with("json") + mock_formatter.format_and_display.assert_called_once() + + def test_query_workers_handles_empty_results( + self, + handler: CeleryWorkerHandler, + mock_formatter: MagicMock + ): + """ + Test that `query_workers` handles empty database results gracefully. + + Args: + handler: CeleryWorkerHandler instance. + mock_formatter: Mock formatter instance. + """ + handler.merlin_db.get_all.return_value = [] + + handler.query_workers("rich") + + # Verify formatter is still called with empty list + mock_formatter.format_and_display.assert_called_once_with([], {}, handler.merlin_db) + + def test_query_workers_passes_all_parameters_to_formatter( + self, + handler: CeleryWorkerHandler, + mock_logical_workers: List[MagicMock], + mock_formatter: MagicMock + ): + """ + Test that `query_workers` passes all necessary parameters to formatter. + + Args: + handler: CeleryWorkerHandler instance. + mock_logical_workers: Mock logical worker entities. + mock_formatter: Mock formatter instance. + """ + handler.merlin_db.get_all.return_value = mock_logical_workers + + queues = ["queue1", "queue2"] + workers = ["worker1"] + + handler.query_workers("rich", queues=queues, workers=workers) + + expected_filters = {"queues": queues, "name": workers} + + # Verify all parameters are passed correctly + mock_formatter.format_and_display.assert_called_once_with( + mock_logical_workers, + expected_filters, + handler.merlin_db + ) From 82772b4015c133cbc0bab41bae1939bdb8d82152 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 23 Sep 2025 08:53:25 -0700 Subject: [PATCH 74/91] add tests for rich formatter --- merlin/workers/formatters/rich_formatter.py | 7 +- .../workers/formatters/test_rich_formatter.py | 727 ++++++++++++++++++ 2 files changed, 731 insertions(+), 3 deletions(-) create mode 100644 tests/unit/workers/formatters/test_rich_formatter.py diff --git a/merlin/workers/formatters/rich_formatter.py b/merlin/workers/formatters/rich_formatter.py index 0184dba3..ef62a16f 100644 --- a/merlin/workers/formatters/rich_formatter.py +++ b/merlin/workers/formatters/rich_formatter.py @@ -406,7 +406,7 @@ def _get_queues_str(self, queues: List[str]) -> str: Returns: A comma-delimited string of queues without the '[merlin]_' prefix. """ - return ", ".join(q[len("[merlin]_") :] if q.startswith("[merlin]_") else q for q in sorted(queues)) + return ", ".join(sorted(q[len("[merlin]_") :] if q.startswith("[merlin]_") else q for q in queues)) def _display_compact_view( self, @@ -517,7 +517,7 @@ def _build_responsive_table( return table - def _get_physical_worker_data(self, logical_workers: List[LogicalWorkerEntity], merlin_db) -> List[Dict]: + def _get_physical_worker_data(self, logical_workers: List[LogicalWorkerEntity], merlin_db: MerlinDatabase) -> List[Dict]: """ Extract and format physical worker data for table display. @@ -693,6 +693,7 @@ def _format_uptime_or_downtime(self, physical_worker: PhysicalWorkerEntity) -> s uptime = datetime.now() - start_time return self._format_time_duration(uptime) else: + # TODO when we refactor stop-workers, add this in stop_time = getattr(physical_worker, "get_stop_time", lambda: None)() if stop_time: downtime = datetime.now() - stop_time @@ -715,7 +716,7 @@ def _format_time_duration(self, duration: timedelta) -> str: Formatted duration string. """ if duration.days > 0: - return f"{duration.days}d {duration.seconds // 3600}h" + return f"{duration.days}d {duration.seconds // 3600}h {duration.seconds % 3600 // 60}m" if duration.seconds >= 3600: return f"{duration.seconds // 3600}h {(duration.seconds % 3600) // 60}m" if duration.seconds >= 60: diff --git a/tests/unit/workers/formatters/test_rich_formatter.py b/tests/unit/workers/formatters/test_rich_formatter.py new file mode 100644 index 00000000..95bf6ef9 --- /dev/null +++ b/tests/unit/workers/formatters/test_rich_formatter.py @@ -0,0 +1,727 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `merlin/workers/formatters/rich_formatter.py` module. +""" + +from datetime import datetime, timedelta +from typing import List, Union +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture +from rich.text import Text + +from merlin.common.enums import WorkerStatus +from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity +from merlin.db_scripts.entities.physical_worker_entity import PhysicalWorkerEntity +from merlin.db_scripts.merlin_db import MerlinDatabase +from merlin.workers.formatters.rich_formatter import ( + ColumnConfig, + LayoutConfig, + LayoutSize, + ResponsiveLayoutManager, + RichWorkerFormatter, +) + + +class TestLayoutSize: + """Tests for the LayoutSize enum.""" + + def test_layout_size_values(self): + """Test that LayoutSize enum has expected values.""" + assert LayoutSize.COMPACT.value == "compact" + assert LayoutSize.NARROW.value == "narrow" + assert LayoutSize.MEDIUM.value == "medium" + assert LayoutSize.WIDE.value == "wide" + + +class TestColumnConfig: + """Tests for the ColumnConfig dataclass.""" + + def test_column_config_defaults(self): + """Test that ColumnConfig has correct default values.""" + config = ColumnConfig("test_key", "Test Title") + + assert config.key == "test_key" + assert config.title == "Test Title" + assert config.style == "white" + assert config.width is None + assert config.max_width is None + assert config.justify == "left" + assert config.no_wrap is False + assert config.formatter is None + + def test_column_config_custom_values(self): + """Test that ColumnConfig accepts custom values.""" + formatter = lambda x: str(x).upper() + config = ColumnConfig( + key="custom_key", + title="Custom Title", + style="bold red", + width=10, + max_width=20, + justify="center", + no_wrap=True, + formatter=formatter + ) + + assert config.key == "custom_key" + assert config.title == "Custom Title" + assert config.style == "bold red" + assert config.width == 10 + assert config.max_width == 20 + assert config.justify == "center" + assert config.no_wrap is True + assert config.formatter == formatter + + +class TestLayoutConfig: + """Tests for the LayoutConfig dataclass.""" + + def test_layout_config_defaults(self): + """Test that LayoutConfig has correct default values.""" + config = LayoutConfig(LayoutSize.MEDIUM) + + assert config.size == LayoutSize.MEDIUM + assert config.show_summary_panels is True + assert config.panels_horizontal is True + assert config.physical_worker_columns is None + assert config.logical_worker_columns is None + assert config.use_compact_view is False + + +class TestResponsiveLayoutManager: + """Tests for the ResponsiveLayoutManager class.""" + + @pytest.fixture + def layout_manager(self) -> ResponsiveLayoutManager: + """ + Create a ResponsiveLayoutManager instance for testing. + + Returns: + ResponsiveLayoutManager instance. + """ + return ResponsiveLayoutManager() + + @pytest.mark.parametrize("width, expected_size", [ + (30, LayoutSize.COMPACT), # Compact + (59, LayoutSize.COMPACT), + (60, LayoutSize.NARROW), # Narrow + (70, LayoutSize.NARROW), + (79, LayoutSize.NARROW), + (80, LayoutSize.MEDIUM), # Medium + (100, LayoutSize.MEDIUM), + (119, LayoutSize.MEDIUM), + (120, LayoutSize.WIDE), # Wide + (150, LayoutSize.WIDE), + (200, LayoutSize.WIDE), + ]) + def test_get_layout_size(self, layout_manager: ResponsiveLayoutManager, width: int, expected_size: LayoutSize): + """ + Test that get_layout_size returns the correct layout size. + + Args: + layout_manager: ResponsiveLayoutManager instance. + width: Terminal width to test. + expected_size: Expected LayoutSize result. + """ + assert layout_manager.get_layout_size(width) == expected_size + + def test_get_layout_config_returns_correct_config(self, layout_manager: ResponsiveLayoutManager): + """ + Test that get_layout_config returns the correct LayoutConfig. + + Args: + layout_manager: ResponsiveLayoutManager instance. + """ + config = layout_manager.get_layout_config(100) + assert config.size == LayoutSize.MEDIUM + assert isinstance(config, LayoutConfig) + + @pytest.mark.parametrize("status, expected_icon, expected_text", [ + (WorkerStatus.RUNNING, "✓", "RUNNING"), + (WorkerStatus.STOPPED, "✗", "STOPPED"), + (WorkerStatus.STALLED, "⚠", "STALLED"), + (WorkerStatus.REBOOTING, "↻", "REBOOTING"), + ]) + def test_format_status(self, layout_manager: ResponsiveLayoutManager, status: WorkerStatus, expected_icon: str, expected_text: str): + """ + Test that various worker statuses are formatted correctly. + + Args: + layout_manager: ResponsiveLayoutManager instance. + status: WorkerStatus to format. + expected_icon: Expected icon in the formatted output. + expected_text: Expected text in the formatted output. + """ + formatted = layout_manager._format_status(status) + assert isinstance(formatted, Text) + assert expected_icon in str(formatted) + assert expected_text in str(formatted) + + +class TestRichWorkerFormatter: + """Tests for the RichWorkerFormatter class.""" + + @pytest.fixture + def formatter(self, mocker: MockerFixture) -> RichWorkerFormatter: + """ + Create a RichWorkerFormatter instance for testing. + + Args: + mocker: Pytest mocker fixture. + + Returns: + RichWorkerFormatter instance. + """ + # Mock the console to control its behavior + mock_console = MagicMock() + mock_console.size.width = 120 # Default to wide layout + mocker.patch("merlin.workers.formatters.worker_formatter.Console", return_value=mock_console) + return RichWorkerFormatter() + + @pytest.fixture + def mock_logical_workers(self) -> List[MagicMock]: + """ + Create mock logical worker entities for testing. + + Returns: + List of mock LogicalWorkerEntity instances. + """ + worker1 = MagicMock(spec=LogicalWorkerEntity) + worker1.get_name.return_value = "logical_worker1" + worker1.get_queues.return_value = ["queue1", "queue2"] + worker1.get_physical_workers.return_value = ["phys1", "phys2"] + + worker2 = MagicMock(spec=LogicalWorkerEntity) + worker2.get_name.return_value = "logical_worker2" + worker2.get_queues.return_value = ["queue3"] + worker2.get_physical_workers.return_value = [] # No physical workers + + return [worker1, worker2] + + @pytest.fixture + def mock_physical_workers(self) -> List[MagicMock]: + """ + Create mock physical worker entities for testing. + + Returns: + List of mock PhysicalWorkerEntity instances. + """ + worker1 = MagicMock(spec=PhysicalWorkerEntity) + worker1.get_id.return_value = "phys1" + worker1.get_name.return_value = "physical_worker1" + worker1.get_host.return_value = "host1" + worker1.get_pid.return_value = 12345 + worker1.get_status.return_value = WorkerStatus.RUNNING + worker1.get_restart_count.return_value = 0 + worker1.get_latest_start_time.return_value = datetime.now() - timedelta(hours=2) + worker1.get_heartbeat_timestamp.return_value = datetime.now() - timedelta(minutes=1) + + worker2 = MagicMock(spec=PhysicalWorkerEntity) + worker2.get_id.return_value = "phys2" + worker2.get_name.return_value = "physical_worker2" + worker2.get_host.return_value = "host2" + worker2.get_pid.return_value = 54321 + worker2.get_status.return_value = WorkerStatus.STOPPED + worker2.get_restart_count.return_value = 2 + worker2.get_latest_start_time.return_value = None + worker2.get_heartbeat_timestamp.return_value = None + + return [worker1, worker2] + + @pytest.fixture + def mock_db(self) -> MagicMock: + """ + Create a mock MerlinDatabase for testing. + + Returns: + Mock MerlinDatabase instance. + """ + return MagicMock(spec=MerlinDatabase) + + def test_get_queues_str_removes_merlin_prefix(self, formatter: RichWorkerFormatter): + """ + Test that _get_queues_str removes [merlin]_ prefix correctly. + + Args: + formatter: RichWorkerFormatter instance. + """ + queues = ["[merlin]_queue1", "[merlin]_queue2", "custom_queue"] + result = formatter._get_queues_str(queues) + assert result == "custom_queue, queue1, queue2" + + def test_get_queues_str_sorts_queues(self, formatter: RichWorkerFormatter): + """ + Test that _get_queues_str sorts queue names. + + Args: + formatter: RichWorkerFormatter instance. + """ + queues = ["[merlin]_zebra", "[merlin]_alpha", "[merlin]_beta"] + result = formatter._get_queues_str(queues) + assert result == "alpha, beta, zebra" + + @pytest.mark.parametrize("status, expected_icon, expected_color", [ + (WorkerStatus.RUNNING, "✓", "green"), + (WorkerStatus.STOPPED, "✗", "red"), + (WorkerStatus.STALLED, "⚠", "yellow"), + (WorkerStatus.REBOOTING, "↻", "cyan"), + ("unknown", "?", "white"), + ]) + def test_format_status(self, formatter: RichWorkerFormatter, status: Union[WorkerStatus, str], expected_icon: str, expected_color: str): + """ + Test that _format_status returns Rich Text with icons. + + Args: + formatter: RichWorkerFormatter instance. + status: WorkerStatus or string to format. + expected_icon: Expected icon in the formatted output. + expected_color: Expected color in the formatted output. + """ + formatted = formatter._format_status(status) + assert isinstance(formatted, Text) + assert expected_icon in str(formatted) + assert expected_color in formatted.style + + if isinstance(status, WorkerStatus): + assert status.name in str(formatted) + else: + assert status in str(formatted) + + @pytest.mark.parametrize("duration, expected", [ + (timedelta(days=2, hours=5), "2d 5h 0m"), + (timedelta(days=1, hours=1, minutes=1), "1d 1h 1m"), + (timedelta(hours=3, minutes=30), "3h 30m"), + (timedelta(minutes=45), "45m"), + (timedelta(seconds=30), "30s"), + ]) + def test_format_time_duration(self, formatter: RichWorkerFormatter, duration: timedelta, expected: str): + """ + Test time duration formatting. + + Args: + formatter: RichWorkerFormatter instance. + duration: timedelta to format. + expected: Expected formatted string. + """ + result = formatter._format_time_duration(duration) + assert result == expected + + @pytest.mark.parametrize("timestamp, expected", [ + (datetime.now() - timedelta(seconds=30), "Just now"), + (datetime.now() - timedelta(minutes=10), "10m ago"), + (datetime.now() - timedelta(hours=2), "2h ago"), + (None, "-"), + ]) + def test_format_last_heartbeat(self, formatter: RichWorkerFormatter, timestamp: datetime, expected: str): + """ + Test heartbeat formatting for very recent heartbeats. + + Args: + formatter: RichWorkerFormatter instance. + timestamp: datetime of the last heartbeat. + expected: Expected formatted string. + """ + result = formatter._format_last_heartbeat(timestamp) + assert isinstance(result, Text) + assert expected in str(result) + + @pytest.mark.parametrize("status, timestamp, expected", [ + (WorkerStatus.RUNNING, datetime.now() - timedelta(hours=1, minutes=30), "1h 30m"), + (WorkerStatus.RUNNING, None, "-"), + ]) + def test_format_uptime_or_downtime_running_worker(self, formatter: RichWorkerFormatter, status: WorkerStatus, timestamp: timedelta, expected: str): + """ + Test uptime or downtime formatting. + + Args: + formatter: RichWorkerFormatter instance. + status: WorkerStatus of the worker. + timestamp: datetime of the latest start time. + expected: Expected formatted string. + """ + mock_worker = MagicMock() + mock_worker.get_status.return_value = status + mock_worker.get_latest_start_time.return_value = timestamp + + result = formatter._format_uptime_or_downtime(mock_worker) + assert result == expected + + @pytest.mark.parametrize("status, timestamp, expected", [ + (WorkerStatus.STOPPED, datetime.now() - timedelta(minutes=15), "down 15m"), + (WorkerStatus.STOPPED, None, "stopped"), + ]) + def test_format_uptime_or_downtime_stopped_worker(self, formatter: RichWorkerFormatter, status: WorkerStatus, timestamp: timedelta, expected: str): + """ + Test uptime or downtime formatting. + + Args: + formatter: RichWorkerFormatter instance. + status: WorkerStatus of the worker. + timestamp: datetime of the stop time. + expected: Expected formatted string. + """ + mock_worker = MagicMock() + mock_worker.get_status.return_value = status + mock_worker.get_stop_time.return_value = timestamp + + result = formatter._format_uptime_or_downtime(mock_worker) + assert result == expected + + def test_get_physical_worker_data( + self, + formatter: RichWorkerFormatter, + mock_logical_workers: List[MagicMock], + mock_physical_workers: List[MagicMock], + mock_db: MagicMock + ): + """ + Test extraction of physical worker data for table display. + + Args: + formatter: RichWorkerFormatter instance. + mock_logical_workers: List of mock LogicalWorkerEntity instances. + mock_physical_workers: List of mock PhysicalWorkerEntity instances. + mock_db: Mock MerlinDatabase instance. + """ + # Setup database to return physical workers + mock_db.get.side_effect = mock_physical_workers + + data = formatter._get_physical_worker_data(mock_logical_workers, mock_db) + + assert len(data) == 2 # 2 physical workers for first logical, 0 for second logical + assert data[0]["worker"] == "logical_worker1" + assert data[0]["host"] == "host1" + assert data[0]["pid"] == "12345" + assert data[0]["status"] == WorkerStatus.RUNNING + + def test_get_logical_workers_without_instances_data( + self, + formatter: RichWorkerFormatter, + mock_logical_workers: List[MagicMock] + ): + """ + Test extraction of logical workers without physical instances. + + Args: + formatter: RichWorkerFormatter instance. + mock_logical_workers: List of mock LogicalWorkerEntity instances. + """ + data = formatter._get_logical_workers_without_instances_data(mock_logical_workers) + + # Only logical_worker2 has no physical workers + assert len(data) == 1 + assert data[0]["worker"] == "logical_worker2" + assert data[0]["queues"] == "queue3" + assert isinstance(data[0]["status"], Text) + assert "NO INSTANCES" in str(data[0]["status"]) + + def test_sort_physical_workers(self, formatter: RichWorkerFormatter): + """ + Test that physical workers are sorted correctly. + + Args: + formatter: RichWorkerFormatter instance. + """ + data = [ + {"_sort_status": "STOPPED", "worker": "worker2", "instance": "inst2"}, + {"_sort_status": "RUNNING", "worker": "worker1", "instance": "inst1"}, + {"_sort_status": "RUNNING", "worker": "worker1", "instance": "inst2"}, + {"_sort_status": "STOPPED", "worker": "worker1", "instance": "inst1"}, + ] + + sorted_data = formatter._sort_physical_workers(data) + + # Running workers should come first, then sorted by worker and instance name + assert sorted_data[0]["_sort_status"] == "RUNNING" + assert sorted_data[1]["_sort_status"] == "RUNNING" + assert sorted_data[2]["_sort_status"] == "STOPPED" + assert sorted_data[3]["_sort_status"] == "STOPPED" + + def test_build_summary_panels_with_filters(self, formatter: RichWorkerFormatter): + """ + Test building summary panels with filters applied. + + Args: + formatter: RichWorkerFormatter instance. + """ + stats = { + "total_logical": 5, + "logical_with_instances": 3, + "logical_without_instances": 2, + "total_physical": 8, + "physical_running": 6, + "physical_stopped": 2, + "physical_stalled": 0, + "physical_rebooting": 0, + } + filters = { + "queues": ["queue1", "queue2"], + "name": ["worker1", "worker2"] + } + + panels = formatter._build_summary_panels(stats, filters) + + assert len(panels) == 3 # Filter, Logical, Physical panels + # Check that filter panel contains expected content + filter_panel_content = panels[0] + assert "queue1, queue2" in filter_panel_content.renderable + assert "worker1, worker2" in filter_panel_content.renderable + + def test_build_summary_panels_no_filters(self, formatter: RichWorkerFormatter): + """ + Test building summary panels without filters. + + Args: + formatter: RichWorkerFormatter instance. + """ + stats = { + "total_logical": 3, + "logical_with_instances": 2, + "logical_without_instances": 1, + "total_physical": 0, + "physical_running": 0, + "physical_stopped": 0, + "physical_stalled": 0, + "physical_rebooting": 0, + } + filters = {} + + panels = formatter._build_summary_panels(stats, filters) + + # No filter panel, only logical panel (no physical since total is 0) + assert len(panels) == 1 + + def test_build_compact_view( + self, + formatter: RichWorkerFormatter, + mock_logical_workers: List[MagicMock], + mock_physical_workers: List[MagicMock], + mock_db: MagicMock + ): + """ + Test building compact view for narrow terminals. + + Args: + formatter: RichWorkerFormatter instance. + mock_logical_workers: List of mock LogicalWorkerEntity instances. + mock_physical_workers: List of mock PhysicalWorkerEntity instances. + mock_db: Mock MerlinDatabase instance. + """ + mock_db.get.side_effect = mock_physical_workers + + compact_view = formatter._build_compact_view(mock_logical_workers, mock_db) + + assert "logical_worker1" in compact_view + assert "logical_worker2" in compact_view + assert "NO INSTANCES" in compact_view + assert "host1" in compact_view + + def test_build_responsive_table(self, formatter: RichWorkerFormatter): + """ + Test building a responsive table with column configuration. + + Args: + formatter: RichWorkerFormatter instance. + """ + columns = [ + ColumnConfig(key="name", title="Name", style="bold white"), + ColumnConfig(key="status", title="Status", style="green", formatter=lambda x: f"[{x}]") + ] + data = [ + {"name": "worker1", "status": "running"}, + {"name": "worker2", "status": "stopped"} + ] + + table = formatter._build_responsive_table("Test Table", columns, data) + + assert table.title == "Test Table" + assert len(table.columns) == 2 + + def test_format_and_display_compact_view( + self, + mocker: MockerFixture, + mock_logical_workers: List[MagicMock], + mock_db: MagicMock + ): + """ + Test format_and_display uses compact view for narrow terminals. + + Args: + mocker: Pytest mocker fixture. + mock_logical_workers: List of mock LogicalWorkerEntity instances. + mock_db: Mock MerlinDatabase instance. + """ + # Create formatter with narrow console + mock_console = MagicMock() + mock_console.size.width = 40 # Compact layout + mocker.patch("merlin.workers.formatters.worker_formatter.Console", return_value=mock_console) + formatter = RichWorkerFormatter() + + # Mock get_worker_statistics + stats = { + "total_logical": 2, + "total_physical": 2, + "logical_with_instances": 1, + "logical_without_instances": 1, + "physical_running": 1, + "physical_stopped": 1, + "physical_stalled": 0, + "physical_rebooting": 0, + } + mocker.patch.object(formatter, "get_worker_statistics", return_value=stats) + + filters = {} + formatter.format_and_display(mock_logical_workers, filters, mock_db) + + # Should call _display_compact_view instead of normal tables + assert mock_console.print.called + + def test_format_and_display_normal_view( + self, + mocker: MockerFixture, + mock_logical_workers: List[MagicMock], + mock_physical_workers: List[MagicMock], + mock_db: MagicMock + ): + """ + Test format_and_display uses normal view for wide terminals. + + Args: + mocker: Pytest mocker fixture. + mock_logical_workers: List of mock LogicalWorkerEntity instances. + mock_physical_workers: List of mock PhysicalWorkerEntity instances. + mock_db: Mock MerlinDatabase instance. + """ + # Create formatter with wide console + mock_console = MagicMock() + mock_console.size.width = 150 # Wide layout + mocker.patch("merlin.workers.formatters.worker_formatter.Console", return_value=mock_console) + formatter = RichWorkerFormatter() + + # Mock database to return physical workers + mock_db.get.side_effect = mock_physical_workers + + # Mock get_worker_statistics + stats = { + "total_logical": 2, + "total_physical": 2, + "logical_with_instances": 1, + "logical_without_instances": 1, + "physical_running": 1, + "physical_stopped": 1, + "physical_stalled": 0, + "physical_rebooting": 0, + } + mocker.patch.object(formatter, "get_worker_statistics", return_value=stats) + + filters = {"queues": ["queue1"]} + formatter.format_and_display(mock_logical_workers, filters, mock_db) + + # Should display summary panels and tables + assert mock_console.print.called + # Should be called multiple times (empty lines, panels, tables) + assert mock_console.print.call_count > 3 + + def test_display_summary_panels_horizontal(self, mocker: MockerFixture, formatter: RichWorkerFormatter): + """ + Test that summary panels are displayed horizontally when configured. + + Args: + mocker: Pytest mocker fixture. + formatter: RichWorkerFormatter instance. + """ + mock_columns = mocker.patch("merlin.workers.formatters.rich_formatter.Columns") + stats = { + "total_logical": 1, + "logical_with_instances": 1, + "logical_without_instances": 0, + "total_physical": 1, + "physical_running": 0, + "physical_stopped": 1, + "physical_stalled": 0, + "physical_rebooting": 0, + } + filters = {} + layout_config = LayoutConfig(LayoutSize.WIDE, panels_horizontal=True) + + formatter._display_summary_panels(stats, filters, layout_config) + + # Should create Columns object for horizontal layout + mock_columns.assert_called_once() + + def test_display_summary_panels_vertical( + self, + mocker: MockerFixture, + formatter: RichWorkerFormatter + ): + """ + Test that summary panels are displayed vertically when configured. + + Args: + mocker: Pytest mocker fixture. + formatter: RichWorkerFormatter instance. + """ + stats = { + "total_logical": 1, + "logical_with_instances": 0, + "logical_without_instances": 1, + "total_physical": 0, + "physical_running": 0, + "physical_stopped": 0, + "physical_stalled": 0, + "physical_rebooting": 0, + } + filters = {} + layout_config = LayoutConfig(LayoutSize.NARROW, panels_horizontal=False) + + # Mock console.print to track calls + mock_print = mocker.patch.object(formatter.console, 'print') + + formatter._display_summary_panels(stats, filters, layout_config) + + # Should print each panel individually (not using Columns) + assert mock_print.called + + def test_display_compact_view( + self, + mocker: MockerFixture, + formatter: RichWorkerFormatter + ): + """ + Test display of compact view. + + Args: + mocker: Pytest mocker fixture. + formatter: RichWorkerFormatter instance. + """ + compact_view = "Test compact view content" + filters = {"queues": ["queue1"]} + stats = { + "total_logical": 1, + "logical_with_instances": 1, + "logical_without_instances": 0, + "total_physical": 2, + "physical_running": 1, + "physical_stopped": 1, + "physical_stalled": 0, + "physical_rebooting": 0, + } + + mock_print = mocker.patch.object(formatter.console, 'print') + + formatter._display_compact_view(compact_view, filters, stats) + + # Should print title, filters, summary, and compact view + assert mock_print.call_count == 4 + + # Check that filter information was included in one of the print calls + print_args = [str(call[0][0]) for call in mock_print.call_args_list] + filter_found = any("queue1" in arg for arg in print_args) + assert filter_found From d4c7e46a43b9c7c3548689c9b74be57096e71afb Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 23 Sep 2025 12:01:03 -0700 Subject: [PATCH 75/91] add tests for all formatter modules --- merlin/abstracts/factory.py | 1 + merlin/workers/formatters/json_formatter.py | 7 +- .../formatters/test_formatter_factory.py | 285 ++++++++++++++ .../workers/formatters/test_json_formatter.py | 256 +++++++++++++ .../formatters/test_worker_formatter.py | 354 ++++++++++++++++++ 5 files changed, 900 insertions(+), 3 deletions(-) create mode 100644 tests/unit/workers/formatters/test_formatter_factory.py create mode 100644 tests/unit/workers/formatters/test_json_formatter.py create mode 100644 tests/unit/workers/formatters/test_worker_formatter.py diff --git a/merlin/abstracts/factory.py b/merlin/abstracts/factory.py index 6616d829..343a6ad1 100644 --- a/merlin/abstracts/factory.py +++ b/merlin/abstracts/factory.py @@ -213,6 +213,7 @@ def _get_component_class(self, canonical_name: str, component_type: str) -> Any: return component_class + # TODO should we change 'config' to 'kwargs'? def create(self, component_type: str, config: Dict = None) -> Any: """ Instantiate and return a component of the specified type. diff --git a/merlin/workers/formatters/json_formatter.py b/merlin/workers/formatters/json_formatter.py index 34a65d38..0a6a8d89 100644 --- a/merlin/workers/formatters/json_formatter.py +++ b/merlin/workers/formatters/json_formatter.py @@ -86,9 +86,9 @@ def format_and_display( for logical_worker in logical_workers: logical_data = { "name": logical_worker.get_name(), - "queues": [ - q[len("[merlin]_") :] if q.startswith("[merlin]_") else q for q in sorted(logical_worker.get_queues()) - ], + "queues": sorted([ + q[len("[merlin]_") :] if q.startswith("[merlin]_") else q for q in logical_worker.get_queues() + ]), "physical_workers": [], } @@ -118,4 +118,5 @@ def format_and_display( data["logical_workers"].append(logical_data) + print(f"data: {data}") self.console.print(json.dumps(data, indent=2)) diff --git a/tests/unit/workers/formatters/test_formatter_factory.py b/tests/unit/workers/formatters/test_formatter_factory.py new file mode 100644 index 00000000..8191537d --- /dev/null +++ b/tests/unit/workers/formatters/test_formatter_factory.py @@ -0,0 +1,285 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `merlin/workers/formatters/formatter_factory.py` module. +""" + +from typing import Dict, List + +import pytest +from pytest_mock import MockerFixture + +from merlin.db_scripts.merlin_db import MerlinDatabase +from merlin.exceptions import MerlinWorkerFormatterNotSupportedError +from merlin.workers.formatters.formatter_factory import WorkerFormatterFactory +from merlin.workers.formatters.worker_formatter import WorkerFormatter + + +class DummyJSONFormatter(WorkerFormatter): + """Dummy JSON formatter implementation for testing.""" + + def __init__(self, *args, **kwargs): + pass + + def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase): + return f"JSON formatted {len(logical_workers)} workers" + + +class DummyRichFormatter(WorkerFormatter): + """Dummy Rich formatter implementation for testing.""" + + def __init__(self, *args, **kwargs): + pass + + def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase): + return f"Rich formatted {len(logical_workers)} workers" + + +class DummyCSVFormatter(WorkerFormatter): + """Dummy CSV formatter implementation for testing.""" + + def __init__(self, *args, **kwargs): + pass + + def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase): + return f"CSV formatted {len(logical_workers)} workers" + + +class TestWorkerFormatterFactory: + """ + Test suite for the `WorkerFormatterFactory`. + + This class verifies that the factory properly registers, validates, instantiates, + and handles Merlin worker formatters. It mocks built-ins for test isolation. + """ + + @pytest.fixture + def formatter_factory(self, mocker: MockerFixture) -> WorkerFormatterFactory: + """ + A fixture that returns a fresh instance of `WorkerFormatterFactory` with built-in formatters patched. + + Args: + mocker: PyTest mocker fixture. + + Returns: + A factory instance with mocked formatter classes. + """ + mocker.patch("merlin.workers.formatters.formatter_factory.JSONWorkerFormatter", DummyJSONFormatter) + mocker.patch("merlin.workers.formatters.formatter_factory.RichWorkerFormatter", DummyRichFormatter) + return WorkerFormatterFactory() + + def test_list_available_formatters(self, formatter_factory: WorkerFormatterFactory): + """ + Test that `list_available` returns the expected built-in formatter names. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + available = formatter_factory.list_available() + assert set(available) == {"json", "rich"} + + def test_create_valid_formatter(self, formatter_factory: WorkerFormatterFactory): + """ + Test that `create` returns a valid formatter instance for a registered name. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + json_instance = formatter_factory.create("json") + assert isinstance(json_instance, DummyJSONFormatter) + + rich_instance = formatter_factory.create("rich") + assert isinstance(rich_instance, DummyRichFormatter) + + def test_create_valid_formatter_with_alias(self, formatter_factory: WorkerFormatterFactory): + """ + Test that aliases are resolved to canonical formatter names. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + formatter_factory.register("csv", DummyCSVFormatter, aliases=["comma", "spreadsheet"]) + + instance_by_name = formatter_factory.create("csv") + instance_by_alias = formatter_factory.create("comma") + instance_by_alias2 = formatter_factory.create("spreadsheet") + + assert isinstance(instance_by_name, DummyCSVFormatter) + assert isinstance(instance_by_alias, DummyCSVFormatter) + assert isinstance(instance_by_alias2, DummyCSVFormatter) + + def test_create_invalid_formatter_raises(self, formatter_factory: WorkerFormatterFactory): + """ + Test that `create` raises `MerlinWorkerFormatterNotSupportedError` for unknown formatter types. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + with pytest.raises(MerlinWorkerFormatterNotSupportedError, match="unknown_formatter"): + formatter_factory.create("unknown_formatter") + + def test_invalid_registration_type_error(self, formatter_factory: WorkerFormatterFactory): + """ + Test that trying to register a non-WorkerFormatter raises TypeError. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + + class NotAFormatter: + pass + + with pytest.raises(TypeError, match="must inherit from WorkerFormatter"): + formatter_factory.register("fake_formatter", NotAFormatter) + + def test_register_overwrites_existing_formatter(self, formatter_factory: WorkerFormatterFactory): + """ + Test that registering a formatter with an existing name overwrites it. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + # Initially json should be DummyJSONFormatter + instance1 = formatter_factory.create("json") + assert isinstance(instance1, DummyJSONFormatter) + + # Register a different formatter with the same name + formatter_factory.register("json", DummyCSVFormatter) + + # Should now return the new formatter type + instance2 = formatter_factory.create("json") + assert isinstance(instance2, DummyCSVFormatter) + + def test_list_available_includes_registered_formatters(self, formatter_factory: WorkerFormatterFactory): + """ + Test that `list_available` includes dynamically registered formatters. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + # Initial built-in formatters + initial_available = set(formatter_factory.list_available()) + assert initial_available == {"json", "rich"} + + # Register additional formatter + formatter_factory.register("csv", DummyCSVFormatter) + + # Should now include the new formatter + updated_available = set(formatter_factory.list_available()) + assert updated_available == {"json", "rich", "csv"} + + # TODO should the factory list aliases as well? + def test_list_available_excludes_aliases(self, formatter_factory: WorkerFormatterFactory): + """ + Test that `list_available` returns canonical names, not aliases. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + formatter_factory.register("csv", DummyCSVFormatter, aliases=["comma", "spreadsheet"]) + + available = set(formatter_factory.list_available()) + # Should contain canonical name but not aliases + assert "csv" in available + assert "comma" not in available + assert "spreadsheet" not in available + + # TODO if we change 'config' to 'kwargs' in MerlinBaseFactory class create method, uncomment this + # def test_create_with_constructor_arguments(self, formatter_factory: WorkerFormatterFactory): + # """ + # Test that `create` can pass arguments to formatter constructors. + + # Args: + # formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + # """ + # class ParameterizedFormatter(WorkerFormatter): + # def __init__(self, param1=None, param2=None): + # self.param1 = param1 + # self.param2 = param2 + + # def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase): + # return f"Formatted with {self.param1} and {self.param2}" + + # formatter_factory.register("parameterized", ParameterizedFormatter) + + # # Test creating with arguments + # instance = formatter_factory.create("parameterized", param1="test", param2=42) + # assert isinstance(instance, ParameterizedFormatter) + # assert instance.param1 == "test" + # assert instance.param2 == 42 + + def test_entry_point_group_returns_correct_namespace(self, formatter_factory: WorkerFormatterFactory): + """ + Test that the factory uses the correct entry point namespace. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + entry_point_group = formatter_factory._entry_point_group() + assert entry_point_group == "merlin.workers.formatters" + + def test_validate_component_accepts_valid_formatter(self, formatter_factory: WorkerFormatterFactory): + """ + Test that `_validate_component` accepts valid WorkerFormatter subclasses. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + # Should not raise an exception + formatter_factory._validate_component(DummyJSONFormatter) + formatter_factory._validate_component(DummyRichFormatter) + formatter_factory._validate_component(DummyCSVFormatter) + + def test_validate_component_rejects_invalid_formatter(self, formatter_factory: WorkerFormatterFactory): + """ + Test that `_validate_component` rejects non-WorkerFormatter classes. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + class InvalidFormatter: + pass + + with pytest.raises(TypeError, match="must inherit from WorkerFormatter"): + formatter_factory._validate_component(InvalidFormatter) + + def test_raise_component_error_class_returns_correct_exception(self, formatter_factory: WorkerFormatterFactory): + """ + Test that the factory raises the correct exception type for invalid components. + + Args: + formatter_factory: Instance of the `WorkerFormatterFactory` for testing. + """ + with pytest.raises(MerlinWorkerFormatterNotSupportedError, match="test message"): + formatter_factory._raise_component_error_class("test message") + + def test_factory_instance_isolation(self, mocker: MockerFixture): + """ + Test that different factory instances don't interfere with each other. + + Args: + mocker: PyTest mocker fixture. + """ + # Mock the built-ins for both factories + mocker.patch("merlin.workers.formatters.formatter_factory.JSONWorkerFormatter", DummyJSONFormatter) + mocker.patch("merlin.workers.formatters.formatter_factory.RichWorkerFormatter", DummyRichFormatter) + + factory1 = WorkerFormatterFactory() + factory2 = WorkerFormatterFactory() + + # Register formatter in only one factory + factory1.register("csv", DummyCSVFormatter) + + # factory1 should have the new formatter + assert "csv" in factory1.list_available() + csv_instance = factory1.create("csv") + assert isinstance(csv_instance, DummyCSVFormatter) + + # factory2 should not have the new formatter + assert "csv" not in factory2.list_available() + with pytest.raises(MerlinWorkerFormatterNotSupportedError): + factory2.create("csv") \ No newline at end of file diff --git a/tests/unit/workers/formatters/test_json_formatter.py b/tests/unit/workers/formatters/test_json_formatter.py new file mode 100644 index 00000000..6f405ac3 --- /dev/null +++ b/tests/unit/workers/formatters/test_json_formatter.py @@ -0,0 +1,256 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `merlin/workers/formatters/json_formatter.py` module. +""" + +import json +from datetime import datetime, timedelta +from typing import Dict, List +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from merlin.common.enums import WorkerStatus +from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity +from merlin.db_scripts.entities.physical_worker_entity import PhysicalWorkerEntity +from merlin.db_scripts.merlin_db import MerlinDatabase +from merlin.workers.formatters.json_formatter import JSONWorkerFormatter + + +class TestJSONWorkerFormatter: + """Tests for the JSONWorkerFormatter class.""" + + @pytest.fixture + def formatter(self, mocker: MockerFixture) -> JSONWorkerFormatter: + """ + Create a JSONWorkerFormatter instance for testing. + + Args: + mocker: Pytest mocker fixture. + + Returns: + JSONWorkerFormatter instance with mocked console. + """ + # Mock the console from the base class + mock_console = MagicMock() + mocker.patch("merlin.workers.formatters.worker_formatter.Console", return_value=mock_console) + return JSONWorkerFormatter() + + @pytest.fixture + def mock_logical_workers(self) -> List[MagicMock]: + """ + Create mock logical worker entities for testing. + + Returns: + List of mock logical worker entities. + """ + worker1 = MagicMock(spec=LogicalWorkerEntity) + worker1.get_name.return_value = "logical_worker1" + worker1.get_queues.return_value = ["queue1", "queue2", "custom_queue"] + worker1.get_physical_workers.return_value = ["phys1", "phys2"] + + worker2 = MagicMock(spec=LogicalWorkerEntity) + worker2.get_name.return_value = "logical_worker2" + worker2.get_queues.return_value = ["queue3"] + worker2.get_physical_workers.return_value = [] # No physical workers + + worker3 = MagicMock(spec=LogicalWorkerEntity) + worker3.get_name.return_value = "logical_worker3" + worker3.get_queues.return_value = ["queue4"] + worker3.get_physical_workers.return_value = ["phys3"] + + return [worker1, worker2, worker3] + + @pytest.fixture + def mock_physical_workers(self) -> List[MagicMock]: + """ + Create mock physical worker entities for testing. + + Returns: + List of mock physical worker entities. + """ + # Current time for consistent testing + now = datetime.now() + + worker1 = MagicMock(spec=PhysicalWorkerEntity) + worker1.get_id.return_value = "phys1" + worker1.get_name.return_value = "physical_worker1" + worker1.get_host.return_value = "host1.example.com" + worker1.get_pid.return_value = 12345 + worker1.get_status.return_value = WorkerStatus.RUNNING + worker1.get_restart_count.return_value = 0 + worker1.get_latest_start_time.return_value = now - timedelta(hours=2) + worker1.get_heartbeat_timestamp.return_value = now - timedelta(minutes=1) + + worker2 = MagicMock(spec=PhysicalWorkerEntity) + worker2.get_id.return_value = "phys2" + worker2.get_name.return_value = "physical_worker2" + worker2.get_host.return_value = "host2.example.com" + worker2.get_pid.return_value = 54321 + worker2.get_status.return_value = WorkerStatus.STOPPED + worker2.get_restart_count.return_value = 3 + worker2.get_latest_start_time.return_value = None + worker2.get_heartbeat_timestamp.return_value = None + + worker3 = MagicMock(spec=PhysicalWorkerEntity) + worker3.get_id.return_value = "phys3" + worker3.get_name.return_value = "physical_worker3" + worker3.get_host.return_value = "host3.example.com" + worker3.get_pid.return_value = 99999 + worker3.get_status.return_value = WorkerStatus.STALLED + worker3.get_restart_count.return_value = 1 + worker3.get_latest_start_time.return_value = now - timedelta(hours=1) + worker3.get_heartbeat_timestamp.return_value = now - timedelta(minutes=30) + + return [worker1, worker2, worker3] + + @pytest.fixture + def mock_db(self) -> MagicMock: + """ + Create a mock MerlinDatabase for testing. + + Returns: + Mock MerlinDatabase instance. + """ + return MagicMock(spec=MerlinDatabase) + + @pytest.fixture + def sample_stats(self) -> Dict[str, int]: + """ + Create sample worker statistics for testing. + + Returns: + Dictionary containing sample worker statistics. + """ + return { + "total_logical": 3, + "logical_with_instances": 2, + "logical_without_instances": 1, + "total_physical": 3, + "physical_running": 1, + "physical_stopped": 1, + "physical_stalled": 1, + "physical_rebooting": 0, + } + + def test_format_and_display_basic_structure( + self, + formatter: JSONWorkerFormatter, + mock_logical_workers: List[MagicMock], + mock_physical_workers: List[MagicMock], + mock_db: MagicMock, + sample_stats: Dict[str, int] + ): + """ + Test that format_and_display outputs valid JSON with correct basic structure. + + Args: + formatter: JSONWorkerFormatter instance for testing. + mock_logical_workers: List of mock logical worker entities. + mock_physical_workers: List of mock physical worker entities. + mock_db: Mock MerlinDatabase instance. + sample_stats: Sample worker statistics dictionary. + """ + # Setup database to return physical workers + mock_db.get.side_effect = mock_physical_workers + + # Mock get_worker_statistics method + formatter.get_worker_statistics = MagicMock(return_value=sample_stats) + + filters = {"queues": ["queue1"], "name": ["worker1"]} + formatter.format_and_display(mock_logical_workers, filters, mock_db) + + # Get the JSON output from the mocked console.print call + formatter.console.print.assert_called_once() + json_output = formatter.console.print.call_args[0][0] + data = json.loads(json_output) + + # Verify top-level structure + assert "filters" in data + assert "timestamp" in data + assert "logical_workers" in data + assert "summary" in data + + # Verify filters are preserved + assert data["filters"] == filters + + # Verify timestamp is ISO format + datetime.fromisoformat(data["timestamp"]) # Should not raise exception + + # Verify summary matches expected stats + assert data["summary"] == sample_stats + + # Verify logical workers array exists + assert isinstance(data["logical_workers"], list) + + def test_format_and_display_with_filters( + self, + formatter: JSONWorkerFormatter, + mock_logical_workers: List[MagicMock], + mock_physical_workers: List[MagicMock], + mock_db: MagicMock, + sample_stats: Dict[str, int] + ): + """ + Test JSON output includes filters correctly. + + Args: + formatter: JSONWorkerFormatter instance for testing. + mock_logical_workers: List of mock logical worker entities. + mock_physical_workers: List of mock physical worker entities. + mock_db: Mock MerlinDatabase instance. + sample_stats: Sample worker statistics dictionary. + """ + mock_db.get.side_effect = mock_physical_workers + formatter.get_worker_statistics = MagicMock(return_value=sample_stats) + + filters = { + "queues": ["queue1", "queue2"], + "name": ["worker_a", "worker_b"] + } + formatter.format_and_display(mock_logical_workers, filters, mock_db) + + # Get the JSON output from the mocked console.print call + formatter.console.print.assert_called_once() + json_output = formatter.console.print.call_args[0][0] + data = json.loads(json_output) + + assert data["filters"]["queues"] == ["queue1", "queue2"] + assert data["filters"]["name"] == ["worker_a", "worker_b"] + + def test_format_and_display_with_empty_filters( + self, + formatter: JSONWorkerFormatter, + mock_logical_workers: List[MagicMock], + mock_physical_workers: List[MagicMock], + mock_db: MagicMock, + sample_stats: Dict[str, int] + ): + """ + Test JSON output with empty filters. + + Args: + formatter: JSONWorkerFormatter instance for testing. + mock_logical_workers: List of mock logical worker entities. + mock_physical_workers: List of mock physical worker entities. + mock_db: Mock MerlinDatabase instance. + sample_stats: Sample worker statistics dictionary. + """ + mock_db.get.side_effect = mock_physical_workers + formatter.get_worker_statistics = MagicMock(return_value=sample_stats) + + filters = {} + formatter.format_and_display(mock_logical_workers, filters, mock_db) + + # Get the JSON output from the mocked console.print call + formatter.console.print.assert_called_once() + json_output = formatter.console.print.call_args[0][0] + data = json.loads(json_output) + + assert data["filters"] == {} diff --git a/tests/unit/workers/formatters/test_worker_formatter.py b/tests/unit/workers/formatters/test_worker_formatter.py new file mode 100644 index 00000000..3375265f --- /dev/null +++ b/tests/unit/workers/formatters/test_worker_formatter.py @@ -0,0 +1,354 @@ +############################################################################## +# Copyright (c) Lawrence Livermore National Security, LLC and other Merlin +# Project developers. See top-level LICENSE and COPYRIGHT files for dates and +# other details. No copyright assignment is required to contribute to Merlin. +############################################################################## + +""" +Tests for the `merlin/workers/formatters/worker_formatter.py` module. +""" + +from typing import Dict, List +from unittest.mock import MagicMock + +import pytest +from pytest_mock import MockerFixture + +from merlin.common.enums import WorkerStatus +from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity +from merlin.db_scripts.merlin_db import MerlinDatabase +from merlin.workers.formatters.worker_formatter import WorkerFormatter + + +class DummyWorkerFormatter(WorkerFormatter): + """Dummy implementation of WorkerFormatter for testing.""" + + def __init__(self): + super().__init__() + self.formatted_data = None + self.display_called = False + + def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase): + """Mock implementation that stores the call parameters.""" + self.formatted_data = { + "logical_workers": logical_workers, + "filters": filters, + "merlin_db": merlin_db + } + self.display_called = True + return f"Formatted {len(logical_workers)} workers with filters {filters}" + + +def test_abstract_formatter_cannot_be_instantiated(): + """Test that attempting to instantiate the abstract base class raises a TypeError.""" + with pytest.raises(TypeError): + WorkerFormatter() + + +def test_unimplemented_method_raises_not_implemented(): + """Test that calling abstract methods on a subclass without implementation raises NotImplementedError.""" + + class IncompleteFormatter(WorkerFormatter): + pass + + # Should raise TypeError due to unimplemented abstract method + with pytest.raises(TypeError): + IncompleteFormatter() + + +class TestWorkerFormatter: + """Tests for the WorkerFormatter abstract base class and its concrete implementations.""" + + @pytest.fixture + def formatter(self, mocker: MockerFixture) -> DummyWorkerFormatter: + """ + Create a DummyWorkerFormatter instance for testing. + + Args: + mocker: Pytest mocker fixture. + + Returns: + DummyWorkerFormatter instance with mocked console. + """ + # Mock the console to avoid actual Rich console creation + mock_console = MagicMock() + mocker.patch("merlin.workers.formatters.worker_formatter.Console", return_value=mock_console) + return DummyWorkerFormatter() + + @pytest.fixture + def mock_logical_workers(self) -> List[MagicMock]: + """ + Create mock logical worker entities for testing. + + Returns: + List of mock logical worker entities. + """ + worker1 = MagicMock(spec=LogicalWorkerEntity) + worker1.get_name.return_value = "worker1" + worker1.get_queues.return_value = ["queue1", "queue2"] + worker1.get_physical_workers.return_value = ["phys1", "phys2"] + + worker2 = MagicMock(spec=LogicalWorkerEntity) + worker2.get_name.return_value = "worker2" + worker2.get_queues.return_value = ["queue3"] + worker2.get_physical_workers.return_value = [] + + return [worker1, worker2] + + @pytest.fixture + def mock_physical_workers(self) -> List[MagicMock]: + """ + Create mock physical worker entities for testing. + + Returns: + List of mock physical worker entities. + """ + worker1 = MagicMock() + worker1.get_status.return_value = WorkerStatus.RUNNING + + worker2 = MagicMock() + worker2.get_status.return_value = WorkerStatus.STOPPED + + worker3 = MagicMock() + worker3.get_status.return_value = WorkerStatus.STALLED + + return [worker1, worker2, worker3] + + @pytest.fixture + def mock_db(self) -> MagicMock: + """ + Create a mock MerlinDatabase for testing. + + Returns: + Mock MerlinDatabase instance. + """ + return MagicMock(spec=MerlinDatabase) + + def test_console_initialization(self, formatter: DummyWorkerFormatter): + """ + Test that WorkerFormatter initializes with a Rich Console. + + Args: + formatter: DummyWorkerFormatter instance for testing. + """ + assert hasattr(formatter, 'console') + assert formatter.console is not None + + def test_format_and_display_abstract_method_implemented( + self, + formatter: DummyWorkerFormatter, + mock_logical_workers: List[MagicMock], + mock_db: MagicMock + ): + """ + Test that concrete implementation can override format_and_display. + + Args: + formatter: DummyWorkerFormatter instance for testing. + mock_logical_workers: List of mock logical worker entities. + mock_db: Mock MerlinDatabase instance. + """ + filters = {"queues": ["test_queue"]} + result = formatter.format_and_display(mock_logical_workers, filters, mock_db) + + assert formatter.display_called is True + assert "Formatted 2 workers" in result + assert formatter.formatted_data["logical_workers"] == mock_logical_workers + assert formatter.formatted_data["filters"] == filters + assert formatter.formatted_data["merlin_db"] == mock_db + + def test_get_worker_statistics_basic_functionality( + self, + formatter: DummyWorkerFormatter, + mock_logical_workers: List[MagicMock], + mock_physical_workers: List[MagicMock], + mock_db: MagicMock + ): + """ + Test that get_worker_statistics computes basic statistics correctly. + + Args: + formatter: DummyWorkerFormatter instance for testing. + mock_logical_workers: List of mock logical worker entities. + mock_physical_workers: List of mock physical worker entities. + mock_db: Mock MerlinDatabase instance. + """ + # Setup database to return physical workers + mock_db.get.side_effect = mock_physical_workers + + stats = formatter.get_worker_statistics(mock_logical_workers, mock_db) + + # Verify basic counts + assert stats["total_logical"] == 2 + assert stats["logical_with_instances"] == 1 # Only worker1 has physical workers + assert stats["logical_without_instances"] == 1 # worker2 has no physical workers + assert stats["total_physical"] == 2 # worker1 has 2 physical workers + + def test_get_worker_statistics_status_counts( + self, + formatter: DummyWorkerFormatter, + mock_logical_workers: List[MagicMock], + mock_physical_workers: List[MagicMock], + mock_db: MagicMock + ): + """ + Test that get_worker_statistics counts worker statuses correctly. + + Args: + formatter: DummyWorkerFormatter instance for testing. + mock_logical_workers: List of mock logical worker entities. + mock_physical_workers: List of mock physical worker entities. + mock_db: Mock MerlinDatabase instance. + """ + # Setup database to return physical workers for first logical worker only + mock_db.get.side_effect = mock_physical_workers[:2] # First 2 physical workers + + stats = formatter.get_worker_statistics(mock_logical_workers, mock_db) + + # Verify status counts + assert stats["physical_running"] == 1 + assert stats["physical_stopped"] == 1 + assert stats["physical_stalled"] == 0 + assert stats["physical_rebooting"] == 0 + + def test_get_worker_statistics_with_empty_logical_workers( + self, + formatter: DummyWorkerFormatter, + mock_db: MagicMock + ): + """ + Test get_worker_statistics with empty logical workers list. + + Args: + formatter: DummyWorkerFormatter instance for testing. + mock_db: Mock MerlinDatabase instance. + """ + stats = formatter.get_worker_statistics([], mock_db) + + # All counts should be zero + assert stats["total_logical"] == 0 + assert stats["logical_with_instances"] == 0 + assert stats["logical_without_instances"] == 0 + assert stats["total_physical"] == 0 + assert stats["physical_running"] == 0 + assert stats["physical_stopped"] == 0 + assert stats["physical_stalled"] == 0 + assert stats["physical_rebooting"] == 0 + + def test_get_worker_statistics_with_all_status_types( + self, + formatter: DummyWorkerFormatter, + mock_db: MagicMock + ): + """ + Test get_worker_statistics counts all worker status types correctly. + + Args: + formatter: DummyWorkerFormatter instance for testing. + mock_db: Mock MerlinDatabase instance. + """ + # Create logical worker with multiple physical workers of different statuses + logical_worker = MagicMock(spec=LogicalWorkerEntity) + logical_worker.get_physical_workers.return_value = ["p1", "p2", "p3", "p4"] + + # Create physical workers with all status types + physical_workers = [] + statuses = [WorkerStatus.RUNNING, WorkerStatus.STOPPED, WorkerStatus.STALLED, WorkerStatus.REBOOTING] + for i, status in enumerate(statuses): + worker = MagicMock() + worker.get_status.return_value = status + physical_workers.append(worker) + + mock_db.get.side_effect = physical_workers + + stats = formatter.get_worker_statistics([logical_worker], mock_db) + + # Verify all status types are counted + assert stats["total_logical"] == 1 + assert stats["logical_with_instances"] == 1 + assert stats["logical_without_instances"] == 0 + assert stats["total_physical"] == 4 + assert stats["physical_running"] == 1 + assert stats["physical_stopped"] == 1 + assert stats["physical_stalled"] == 1 + assert stats["physical_rebooting"] == 1 + + def test_get_worker_statistics_database_interaction( + self, + formatter: DummyWorkerFormatter, + mock_logical_workers: List[MagicMock], + mock_physical_workers: List[MagicMock], + mock_db: MagicMock + ): + """ + Test that get_worker_statistics interacts with database correctly. + + Args: + formatter: DummyWorkerFormatter instance for testing. + mock_logical_workers: List of mock logical worker entities. + mock_physical_workers: List of mock physical worker entities. + mock_db: Mock MerlinDatabase instance. + """ + mock_db.get.side_effect = mock_physical_workers + + formatter.get_worker_statistics(mock_logical_workers, mock_db) + + # Verify database was called for each physical worker ID + expected_calls = [ + ("physical_worker", "phys1"), + ("physical_worker", "phys2") + ] + actual_calls = [call[0] for call in mock_db.get.call_args_list] + + for expected_call in expected_calls: + assert expected_call in actual_calls + + def test_get_worker_statistics_handles_mixed_scenarios( + self, + formatter: DummyWorkerFormatter, + mock_db: MagicMock + ): + """ + Test get_worker_statistics with mixed scenarios (some workers with/without instances). + + Args: + formatter: DummyWorkerFormatter instance for testing. + mock_db: Mock MerlinDatabase instance. + """ + # Create logical workers: one with multiple instances, one without, one with single instance + logical_workers = [] + + # Worker 1: Has 2 physical workers + worker1 = MagicMock(spec=LogicalWorkerEntity) + worker1.get_physical_workers.return_value = ["p1", "p2"] + logical_workers.append(worker1) + + # Worker 2: No physical workers + worker2 = MagicMock(spec=LogicalWorkerEntity) + worker2.get_physical_workers.return_value = [] + logical_workers.append(worker2) + + # Worker 3: Has 1 physical worker + worker3 = MagicMock(spec=LogicalWorkerEntity) + worker3.get_physical_workers.return_value = ["p3"] + logical_workers.append(worker3) + + # Create physical workers + physical_workers = [ + MagicMock(get_status=lambda: WorkerStatus.RUNNING), + MagicMock(get_status=lambda: WorkerStatus.STOPPED), + MagicMock(get_status=lambda: WorkerStatus.STALLED), + ] + + mock_db.get.side_effect = physical_workers + + stats = formatter.get_worker_statistics(logical_workers, mock_db) + + assert stats["total_logical"] == 3 + assert stats["logical_with_instances"] == 2 # worker1 and worker3 + assert stats["logical_without_instances"] == 1 # worker2 + assert stats["total_physical"] == 3 + assert stats["physical_running"] == 1 + assert stats["physical_stopped"] == 1 + assert stats["physical_stalled"] == 1 + assert stats["physical_rebooting"] == 0 \ No newline at end of file From f070fc9065a1b95f92444a8cb50eb3d1ca078fe0 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 23 Sep 2025 12:10:00 -0700 Subject: [PATCH 76/91] run fix-style --- merlin/workers/formatters/json_formatter.py | 6 +- tests/unit/cli/commands/test_query_workers.py | 12 +- .../formatters/test_formatter_factory.py | 43 +-- .../workers/formatters/test_json_formatter.py | 71 ++-- .../workers/formatters/test_rich_formatter.py | 305 +++++++++--------- .../formatters/test_worker_formatter.py | 128 +++----- .../workers/handlers/test_celery_handler.py | 100 ++---- 7 files changed, 309 insertions(+), 356 deletions(-) diff --git a/merlin/workers/formatters/json_formatter.py b/merlin/workers/formatters/json_formatter.py index 0a6a8d89..00bcc975 100644 --- a/merlin/workers/formatters/json_formatter.py +++ b/merlin/workers/formatters/json_formatter.py @@ -86,9 +86,9 @@ def format_and_display( for logical_worker in logical_workers: logical_data = { "name": logical_worker.get_name(), - "queues": sorted([ - q[len("[merlin]_") :] if q.startswith("[merlin]_") else q for q in logical_worker.get_queues() - ]), + "queues": sorted( + [q[len("[merlin]_") :] if q.startswith("[merlin]_") else q for q in logical_worker.get_queues()] + ), "physical_workers": [], } diff --git a/tests/unit/cli/commands/test_query_workers.py b/tests/unit/cli/commands/test_query_workers.py index 19665200..d55b3eb6 100644 --- a/tests/unit/cli/commands/test_query_workers.py +++ b/tests/unit/cli/commands/test_query_workers.py @@ -61,9 +61,7 @@ def test_process_command_without_spec(mocker: MockerFixture): cmd.process_command(args) create_mock.assert_called_once_with("celery") - worker_handler_mock.query_workers.assert_called_once_with( - "rich", queues=["q1", "q2"], workers=["worker1", "worker2"] - ) + worker_handler_mock.query_workers.assert_called_once_with("rich", queues=["q1", "q2"], workers=["worker1", "worker2"]) def test_process_command_with_spec(mocker: MockerFixture, caplog: CaptureFixture): @@ -101,9 +99,7 @@ def test_process_command_with_spec(mocker: MockerFixture, caplog: CaptureFixture cmd.process_command(args) create_mock.assert_called_once_with("celery") - worker_handler_mock.query_workers.assert_called_once_with( - "rich", queues=None, workers=["foo", "bar"] - ) + worker_handler_mock.query_workers.assert_called_once_with("rich", queues=None, workers=["foo", "bar"]) assert "Searching for the following workers to stop" in caplog.text @@ -142,6 +138,4 @@ def test_process_command_logs_warning_for_unexpanded_worker(mocker: MockerFixtur cmd.process_command(args) assert "Worker '$ENV_VAR' is unexpanded. Target provenance spec instead?" in caplog.text - worker_handler_mock.query_workers.assert_called_once_with( - "rich", queues=None, workers=["$ENV_VAR", "actual_worker"] - ) + worker_handler_mock.query_workers.assert_called_once_with("rich", queues=None, workers=["$ENV_VAR", "actual_worker"]) diff --git a/tests/unit/workers/formatters/test_formatter_factory.py b/tests/unit/workers/formatters/test_formatter_factory.py index 8191537d..07c247a7 100644 --- a/tests/unit/workers/formatters/test_formatter_factory.py +++ b/tests/unit/workers/formatters/test_formatter_factory.py @@ -21,7 +21,7 @@ class DummyJSONFormatter(WorkerFormatter): """Dummy JSON formatter implementation for testing.""" - + def __init__(self, *args, **kwargs): pass @@ -31,7 +31,7 @@ def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: Me class DummyRichFormatter(WorkerFormatter): """Dummy Rich formatter implementation for testing.""" - + def __init__(self, *args, **kwargs): pass @@ -41,7 +41,7 @@ def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: Me class DummyCSVFormatter(WorkerFormatter): """Dummy CSV formatter implementation for testing.""" - + def __init__(self, *args, **kwargs): pass @@ -91,7 +91,7 @@ def test_create_valid_formatter(self, formatter_factory: WorkerFormatterFactory) """ json_instance = formatter_factory.create("json") assert isinstance(json_instance, DummyJSONFormatter) - + rich_instance = formatter_factory.create("rich") assert isinstance(rich_instance, DummyRichFormatter) @@ -103,11 +103,11 @@ def test_create_valid_formatter_with_alias(self, formatter_factory: WorkerFormat formatter_factory: Instance of the `WorkerFormatterFactory` for testing. """ formatter_factory.register("csv", DummyCSVFormatter, aliases=["comma", "spreadsheet"]) - + instance_by_name = formatter_factory.create("csv") instance_by_alias = formatter_factory.create("comma") instance_by_alias2 = formatter_factory.create("spreadsheet") - + assert isinstance(instance_by_name, DummyCSVFormatter) assert isinstance(instance_by_alias, DummyCSVFormatter) assert isinstance(instance_by_alias2, DummyCSVFormatter) @@ -129,7 +129,7 @@ def test_invalid_registration_type_error(self, formatter_factory: WorkerFormatte Args: formatter_factory: Instance of the `WorkerFormatterFactory` for testing. """ - + class NotAFormatter: pass @@ -146,10 +146,10 @@ def test_register_overwrites_existing_formatter(self, formatter_factory: WorkerF # Initially json should be DummyJSONFormatter instance1 = formatter_factory.create("json") assert isinstance(instance1, DummyJSONFormatter) - + # Register a different formatter with the same name formatter_factory.register("json", DummyCSVFormatter) - + # Should now return the new formatter type instance2 = formatter_factory.create("json") assert isinstance(instance2, DummyCSVFormatter) @@ -164,10 +164,10 @@ def test_list_available_includes_registered_formatters(self, formatter_factory: # Initial built-in formatters initial_available = set(formatter_factory.list_available()) assert initial_available == {"json", "rich"} - + # Register additional formatter formatter_factory.register("csv", DummyCSVFormatter) - + # Should now include the new formatter updated_available = set(formatter_factory.list_available()) assert updated_available == {"json", "rich", "csv"} @@ -181,7 +181,7 @@ def test_list_available_excludes_aliases(self, formatter_factory: WorkerFormatte formatter_factory: Instance of the `WorkerFormatterFactory` for testing. """ formatter_factory.register("csv", DummyCSVFormatter, aliases=["comma", "spreadsheet"]) - + available = set(formatter_factory.list_available()) # Should contain canonical name but not aliases assert "csv" in available @@ -200,12 +200,12 @@ def test_list_available_excludes_aliases(self, formatter_factory: WorkerFormatte # def __init__(self, param1=None, param2=None): # self.param1 = param1 # self.param2 = param2 - + # def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase): # return f"Formatted with {self.param1} and {self.param2}" - + # formatter_factory.register("parameterized", ParameterizedFormatter) - + # # Test creating with arguments # instance = formatter_factory.create("parameterized", param1="test", param2=42) # assert isinstance(instance, ParameterizedFormatter) @@ -241,9 +241,10 @@ def test_validate_component_rejects_invalid_formatter(self, formatter_factory: W Args: formatter_factory: Instance of the `WorkerFormatterFactory` for testing. """ + class InvalidFormatter: pass - + with pytest.raises(TypeError, match="must inherit from WorkerFormatter"): formatter_factory._validate_component(InvalidFormatter) @@ -267,19 +268,19 @@ def test_factory_instance_isolation(self, mocker: MockerFixture): # Mock the built-ins for both factories mocker.patch("merlin.workers.formatters.formatter_factory.JSONWorkerFormatter", DummyJSONFormatter) mocker.patch("merlin.workers.formatters.formatter_factory.RichWorkerFormatter", DummyRichFormatter) - + factory1 = WorkerFormatterFactory() factory2 = WorkerFormatterFactory() - + # Register formatter in only one factory factory1.register("csv", DummyCSVFormatter) - + # factory1 should have the new formatter assert "csv" in factory1.list_available() csv_instance = factory1.create("csv") assert isinstance(csv_instance, DummyCSVFormatter) - + # factory2 should not have the new formatter assert "csv" not in factory2.list_available() with pytest.raises(MerlinWorkerFormatterNotSupportedError): - factory2.create("csv") \ No newline at end of file + factory2.create("csv") diff --git a/tests/unit/workers/formatters/test_json_formatter.py b/tests/unit/workers/formatters/test_json_formatter.py index 6f405ac3..b88c3017 100644 --- a/tests/unit/workers/formatters/test_json_formatter.py +++ b/tests/unit/workers/formatters/test_json_formatter.py @@ -30,10 +30,10 @@ class TestJSONWorkerFormatter: def formatter(self, mocker: MockerFixture) -> JSONWorkerFormatter: """ Create a JSONWorkerFormatter instance for testing. - + Args: mocker: Pytest mocker fixture. - + Returns: JSONWorkerFormatter instance with mocked console. """ @@ -46,7 +46,7 @@ def formatter(self, mocker: MockerFixture) -> JSONWorkerFormatter: def mock_logical_workers(self) -> List[MagicMock]: """ Create mock logical worker entities for testing. - + Returns: List of mock logical worker entities. """ @@ -54,30 +54,30 @@ def mock_logical_workers(self) -> List[MagicMock]: worker1.get_name.return_value = "logical_worker1" worker1.get_queues.return_value = ["queue1", "queue2", "custom_queue"] worker1.get_physical_workers.return_value = ["phys1", "phys2"] - + worker2 = MagicMock(spec=LogicalWorkerEntity) worker2.get_name.return_value = "logical_worker2" worker2.get_queues.return_value = ["queue3"] worker2.get_physical_workers.return_value = [] # No physical workers - + worker3 = MagicMock(spec=LogicalWorkerEntity) worker3.get_name.return_value = "logical_worker3" worker3.get_queues.return_value = ["queue4"] worker3.get_physical_workers.return_value = ["phys3"] - + return [worker1, worker2, worker3] @pytest.fixture def mock_physical_workers(self) -> List[MagicMock]: """ Create mock physical worker entities for testing. - + Returns: List of mock physical worker entities. """ # Current time for consistent testing now = datetime.now() - + worker1 = MagicMock(spec=PhysicalWorkerEntity) worker1.get_id.return_value = "phys1" worker1.get_name.return_value = "physical_worker1" @@ -87,7 +87,7 @@ def mock_physical_workers(self) -> List[MagicMock]: worker1.get_restart_count.return_value = 0 worker1.get_latest_start_time.return_value = now - timedelta(hours=2) worker1.get_heartbeat_timestamp.return_value = now - timedelta(minutes=1) - + worker2 = MagicMock(spec=PhysicalWorkerEntity) worker2.get_id.return_value = "phys2" worker2.get_name.return_value = "physical_worker2" @@ -97,7 +97,7 @@ def mock_physical_workers(self) -> List[MagicMock]: worker2.get_restart_count.return_value = 3 worker2.get_latest_start_time.return_value = None worker2.get_heartbeat_timestamp.return_value = None - + worker3 = MagicMock(spec=PhysicalWorkerEntity) worker3.get_id.return_value = "phys3" worker3.get_name.return_value = "physical_worker3" @@ -107,14 +107,14 @@ def mock_physical_workers(self) -> List[MagicMock]: worker3.get_restart_count.return_value = 1 worker3.get_latest_start_time.return_value = now - timedelta(hours=1) worker3.get_heartbeat_timestamp.return_value = now - timedelta(minutes=30) - + return [worker1, worker2, worker3] @pytest.fixture def mock_db(self) -> MagicMock: """ Create a mock MerlinDatabase for testing. - + Returns: Mock MerlinDatabase instance. """ @@ -124,7 +124,7 @@ def mock_db(self) -> MagicMock: def sample_stats(self) -> Dict[str, int]: """ Create sample worker statistics for testing. - + Returns: Dictionary containing sample worker statistics. """ @@ -145,11 +145,11 @@ def test_format_and_display_basic_structure( mock_logical_workers: List[MagicMock], mock_physical_workers: List[MagicMock], mock_db: MagicMock, - sample_stats: Dict[str, int] + sample_stats: Dict[str, int], ): """ Test that format_and_display outputs valid JSON with correct basic structure. - + Args: formatter: JSONWorkerFormatter instance for testing. mock_logical_workers: List of mock logical worker entities. @@ -159,33 +159,33 @@ def test_format_and_display_basic_structure( """ # Setup database to return physical workers mock_db.get.side_effect = mock_physical_workers - + # Mock get_worker_statistics method formatter.get_worker_statistics = MagicMock(return_value=sample_stats) - + filters = {"queues": ["queue1"], "name": ["worker1"]} formatter.format_and_display(mock_logical_workers, filters, mock_db) - + # Get the JSON output from the mocked console.print call formatter.console.print.assert_called_once() json_output = formatter.console.print.call_args[0][0] data = json.loads(json_output) - + # Verify top-level structure assert "filters" in data assert "timestamp" in data assert "logical_workers" in data assert "summary" in data - + # Verify filters are preserved assert data["filters"] == filters - + # Verify timestamp is ISO format datetime.fromisoformat(data["timestamp"]) # Should not raise exception - + # Verify summary matches expected stats assert data["summary"] == sample_stats - + # Verify logical workers array exists assert isinstance(data["logical_workers"], list) @@ -195,11 +195,11 @@ def test_format_and_display_with_filters( mock_logical_workers: List[MagicMock], mock_physical_workers: List[MagicMock], mock_db: MagicMock, - sample_stats: Dict[str, int] + sample_stats: Dict[str, int], ): """ Test JSON output includes filters correctly. - + Args: formatter: JSONWorkerFormatter instance for testing. mock_logical_workers: List of mock logical worker entities. @@ -209,18 +209,15 @@ def test_format_and_display_with_filters( """ mock_db.get.side_effect = mock_physical_workers formatter.get_worker_statistics = MagicMock(return_value=sample_stats) - - filters = { - "queues": ["queue1", "queue2"], - "name": ["worker_a", "worker_b"] - } + + filters = {"queues": ["queue1", "queue2"], "name": ["worker_a", "worker_b"]} formatter.format_and_display(mock_logical_workers, filters, mock_db) - + # Get the JSON output from the mocked console.print call formatter.console.print.assert_called_once() json_output = formatter.console.print.call_args[0][0] data = json.loads(json_output) - + assert data["filters"]["queues"] == ["queue1", "queue2"] assert data["filters"]["name"] == ["worker_a", "worker_b"] @@ -230,11 +227,11 @@ def test_format_and_display_with_empty_filters( mock_logical_workers: List[MagicMock], mock_physical_workers: List[MagicMock], mock_db: MagicMock, - sample_stats: Dict[str, int] + sample_stats: Dict[str, int], ): """ Test JSON output with empty filters. - + Args: formatter: JSONWorkerFormatter instance for testing. mock_logical_workers: List of mock logical worker entities. @@ -244,13 +241,13 @@ def test_format_and_display_with_empty_filters( """ mock_db.get.side_effect = mock_physical_workers formatter.get_worker_statistics = MagicMock(return_value=sample_stats) - + filters = {} formatter.format_and_display(mock_logical_workers, filters, mock_db) - + # Get the JSON output from the mocked console.print call formatter.console.print.assert_called_once() json_output = formatter.console.print.call_args[0][0] data = json.loads(json_output) - + assert data["filters"] == {} diff --git a/tests/unit/workers/formatters/test_rich_formatter.py b/tests/unit/workers/formatters/test_rich_formatter.py index 95bf6ef9..ff480342 100644 --- a/tests/unit/workers/formatters/test_rich_formatter.py +++ b/tests/unit/workers/formatters/test_rich_formatter.py @@ -46,7 +46,7 @@ class TestColumnConfig: def test_column_config_defaults(self): """Test that ColumnConfig has correct default values.""" config = ColumnConfig("test_key", "Test Title") - + assert config.key == "test_key" assert config.title == "Test Title" assert config.style == "white" @@ -58,7 +58,10 @@ def test_column_config_defaults(self): def test_column_config_custom_values(self): """Test that ColumnConfig accepts custom values.""" - formatter = lambda x: str(x).upper() + + def formatter(x): + return str(x).upper() + config = ColumnConfig( key="custom_key", title="Custom Title", @@ -67,9 +70,9 @@ def test_column_config_custom_values(self): max_width=20, justify="center", no_wrap=True, - formatter=formatter + formatter=formatter, ) - + assert config.key == "custom_key" assert config.title == "Custom Title" assert config.style == "bold red" @@ -86,7 +89,7 @@ class TestLayoutConfig: def test_layout_config_defaults(self): """Test that LayoutConfig has correct default values.""" config = LayoutConfig(LayoutSize.MEDIUM) - + assert config.size == LayoutSize.MEDIUM assert config.show_summary_panels is True assert config.panels_horizontal is True @@ -102,29 +105,32 @@ class TestResponsiveLayoutManager: def layout_manager(self) -> ResponsiveLayoutManager: """ Create a ResponsiveLayoutManager instance for testing. - + Returns: ResponsiveLayoutManager instance. """ return ResponsiveLayoutManager() - @pytest.mark.parametrize("width, expected_size", [ - (30, LayoutSize.COMPACT), # Compact - (59, LayoutSize.COMPACT), - (60, LayoutSize.NARROW), # Narrow - (70, LayoutSize.NARROW), - (79, LayoutSize.NARROW), - (80, LayoutSize.MEDIUM), # Medium - (100, LayoutSize.MEDIUM), - (119, LayoutSize.MEDIUM), - (120, LayoutSize.WIDE), # Wide - (150, LayoutSize.WIDE), - (200, LayoutSize.WIDE), - ]) - def test_get_layout_size(self, layout_manager: ResponsiveLayoutManager, width: int, expected_size: LayoutSize): + @pytest.mark.parametrize( + "width, expected_size", + [ + (30, LayoutSize.COMPACT), # Compact + (59, LayoutSize.COMPACT), + (60, LayoutSize.NARROW), # Narrow + (70, LayoutSize.NARROW), + (79, LayoutSize.NARROW), + (80, LayoutSize.MEDIUM), # Medium + (100, LayoutSize.MEDIUM), + (119, LayoutSize.MEDIUM), + (120, LayoutSize.WIDE), # Wide + (150, LayoutSize.WIDE), + (200, LayoutSize.WIDE), + ], + ) + def test_get_layout_size(self, layout_manager: ResponsiveLayoutManager, width: int, expected_size: LayoutSize): """ Test that get_layout_size returns the correct layout size. - + Args: layout_manager: ResponsiveLayoutManager instance. width: Terminal width to test. @@ -135,7 +141,7 @@ def test_get_layout_size(self, layout_manager: ResponsiveLayoutManager, width: i def test_get_layout_config_returns_correct_config(self, layout_manager: ResponsiveLayoutManager): """ Test that get_layout_config returns the correct LayoutConfig. - + Args: layout_manager: ResponsiveLayoutManager instance. """ @@ -143,16 +149,21 @@ def test_get_layout_config_returns_correct_config(self, layout_manager: Responsi assert config.size == LayoutSize.MEDIUM assert isinstance(config, LayoutConfig) - @pytest.mark.parametrize("status, expected_icon, expected_text", [ - (WorkerStatus.RUNNING, "✓", "RUNNING"), - (WorkerStatus.STOPPED, "✗", "STOPPED"), - (WorkerStatus.STALLED, "⚠", "STALLED"), - (WorkerStatus.REBOOTING, "↻", "REBOOTING"), - ]) - def test_format_status(self, layout_manager: ResponsiveLayoutManager, status: WorkerStatus, expected_icon: str, expected_text: str): + @pytest.mark.parametrize( + "status, expected_icon, expected_text", + [ + (WorkerStatus.RUNNING, "✓", "RUNNING"), + (WorkerStatus.STOPPED, "✗", "STOPPED"), + (WorkerStatus.STALLED, "⚠", "STALLED"), + (WorkerStatus.REBOOTING, "↻", "REBOOTING"), + ], + ) + def test_format_status( + self, layout_manager: ResponsiveLayoutManager, status: WorkerStatus, expected_icon: str, expected_text: str + ): """ Test that various worker statuses are formatted correctly. - + Args: layout_manager: ResponsiveLayoutManager instance. status: WorkerStatus to format. @@ -172,7 +183,7 @@ class TestRichWorkerFormatter: def formatter(self, mocker: MockerFixture) -> RichWorkerFormatter: """ Create a RichWorkerFormatter instance for testing. - + Args: mocker: Pytest mocker fixture. @@ -189,7 +200,7 @@ def formatter(self, mocker: MockerFixture) -> RichWorkerFormatter: def mock_logical_workers(self) -> List[MagicMock]: """ Create mock logical worker entities for testing. - + Returns: List of mock LogicalWorkerEntity instances. """ @@ -197,12 +208,12 @@ def mock_logical_workers(self) -> List[MagicMock]: worker1.get_name.return_value = "logical_worker1" worker1.get_queues.return_value = ["queue1", "queue2"] worker1.get_physical_workers.return_value = ["phys1", "phys2"] - + worker2 = MagicMock(spec=LogicalWorkerEntity) worker2.get_name.return_value = "logical_worker2" worker2.get_queues.return_value = ["queue3"] worker2.get_physical_workers.return_value = [] # No physical workers - + return [worker1, worker2] @pytest.fixture @@ -222,7 +233,7 @@ def mock_physical_workers(self) -> List[MagicMock]: worker1.get_restart_count.return_value = 0 worker1.get_latest_start_time.return_value = datetime.now() - timedelta(hours=2) worker1.get_heartbeat_timestamp.return_value = datetime.now() - timedelta(minutes=1) - + worker2 = MagicMock(spec=PhysicalWorkerEntity) worker2.get_id.return_value = "phys2" worker2.get_name.return_value = "physical_worker2" @@ -232,14 +243,14 @@ def mock_physical_workers(self) -> List[MagicMock]: worker2.get_restart_count.return_value = 2 worker2.get_latest_start_time.return_value = None worker2.get_heartbeat_timestamp.return_value = None - + return [worker1, worker2] @pytest.fixture def mock_db(self) -> MagicMock: """ Create a mock MerlinDatabase for testing. - + Returns: Mock MerlinDatabase instance. """ @@ -248,7 +259,7 @@ def mock_db(self) -> MagicMock: def test_get_queues_str_removes_merlin_prefix(self, formatter: RichWorkerFormatter): """ Test that _get_queues_str removes [merlin]_ prefix correctly. - + Args: formatter: RichWorkerFormatter instance. """ @@ -259,7 +270,7 @@ def test_get_queues_str_removes_merlin_prefix(self, formatter: RichWorkerFormatt def test_get_queues_str_sorts_queues(self, formatter: RichWorkerFormatter): """ Test that _get_queues_str sorts queue names. - + Args: formatter: RichWorkerFormatter instance. """ @@ -267,17 +278,22 @@ def test_get_queues_str_sorts_queues(self, formatter: RichWorkerFormatter): result = formatter._get_queues_str(queues) assert result == "alpha, beta, zebra" - @pytest.mark.parametrize("status, expected_icon, expected_color", [ - (WorkerStatus.RUNNING, "✓", "green"), - (WorkerStatus.STOPPED, "✗", "red"), - (WorkerStatus.STALLED, "⚠", "yellow"), - (WorkerStatus.REBOOTING, "↻", "cyan"), - ("unknown", "?", "white"), - ]) - def test_format_status(self, formatter: RichWorkerFormatter, status: Union[WorkerStatus, str], expected_icon: str, expected_color: str): + @pytest.mark.parametrize( + "status, expected_icon, expected_color", + [ + (WorkerStatus.RUNNING, "✓", "green"), + (WorkerStatus.STOPPED, "✗", "red"), + (WorkerStatus.STALLED, "⚠", "yellow"), + (WorkerStatus.REBOOTING, "↻", "cyan"), + ("unknown", "?", "white"), + ], + ) + def test_format_status( + self, formatter: RichWorkerFormatter, status: Union[WorkerStatus, str], expected_icon: str, expected_color: str + ): """ Test that _format_status returns Rich Text with icons. - + Args: formatter: RichWorkerFormatter instance. status: WorkerStatus or string to format. @@ -294,17 +310,20 @@ def test_format_status(self, formatter: RichWorkerFormatter, status: Union[Worke else: assert status in str(formatted) - @pytest.mark.parametrize("duration, expected", [ - (timedelta(days=2, hours=5), "2d 5h 0m"), - (timedelta(days=1, hours=1, minutes=1), "1d 1h 1m"), - (timedelta(hours=3, minutes=30), "3h 30m"), - (timedelta(minutes=45), "45m"), - (timedelta(seconds=30), "30s"), - ]) + @pytest.mark.parametrize( + "duration, expected", + [ + (timedelta(days=2, hours=5), "2d 5h 0m"), + (timedelta(days=1, hours=1, minutes=1), "1d 1h 1m"), + (timedelta(hours=3, minutes=30), "3h 30m"), + (timedelta(minutes=45), "45m"), + (timedelta(seconds=30), "30s"), + ], + ) def test_format_time_duration(self, formatter: RichWorkerFormatter, duration: timedelta, expected: str): """ Test time duration formatting. - + Args: formatter: RichWorkerFormatter instance. duration: timedelta to format. @@ -313,16 +332,19 @@ def test_format_time_duration(self, formatter: RichWorkerFormatter, duration: ti result = formatter._format_time_duration(duration) assert result == expected - @pytest.mark.parametrize("timestamp, expected", [ - (datetime.now() - timedelta(seconds=30), "Just now"), - (datetime.now() - timedelta(minutes=10), "10m ago"), - (datetime.now() - timedelta(hours=2), "2h ago"), - (None, "-"), - ]) + @pytest.mark.parametrize( + "timestamp, expected", + [ + (datetime.now() - timedelta(seconds=30), "Just now"), + (datetime.now() - timedelta(minutes=10), "10m ago"), + (datetime.now() - timedelta(hours=2), "2h ago"), + (None, "-"), + ], + ) def test_format_last_heartbeat(self, formatter: RichWorkerFormatter, timestamp: datetime, expected: str): """ Test heartbeat formatting for very recent heartbeats. - + Args: formatter: RichWorkerFormatter instance. timestamp: datetime of the last heartbeat. @@ -332,14 +354,19 @@ def test_format_last_heartbeat(self, formatter: RichWorkerFormatter, timestamp: assert isinstance(result, Text) assert expected in str(result) - @pytest.mark.parametrize("status, timestamp, expected", [ - (WorkerStatus.RUNNING, datetime.now() - timedelta(hours=1, minutes=30), "1h 30m"), - (WorkerStatus.RUNNING, None, "-"), - ]) - def test_format_uptime_or_downtime_running_worker(self, formatter: RichWorkerFormatter, status: WorkerStatus, timestamp: timedelta, expected: str): + @pytest.mark.parametrize( + "status, timestamp, expected", + [ + (WorkerStatus.RUNNING, datetime.now() - timedelta(hours=1, minutes=30), "1h 30m"), + (WorkerStatus.RUNNING, None, "-"), + ], + ) + def test_format_uptime_or_downtime_running_worker( + self, formatter: RichWorkerFormatter, status: WorkerStatus, timestamp: timedelta, expected: str + ): """ Test uptime or downtime formatting. - + Args: formatter: RichWorkerFormatter instance. status: WorkerStatus of the worker. @@ -353,14 +380,19 @@ def test_format_uptime_or_downtime_running_worker(self, formatter: RichWorkerFor result = formatter._format_uptime_or_downtime(mock_worker) assert result == expected - @pytest.mark.parametrize("status, timestamp, expected", [ - (WorkerStatus.STOPPED, datetime.now() - timedelta(minutes=15), "down 15m"), - (WorkerStatus.STOPPED, None, "stopped"), - ]) - def test_format_uptime_or_downtime_stopped_worker(self, formatter: RichWorkerFormatter, status: WorkerStatus, timestamp: timedelta, expected: str): + @pytest.mark.parametrize( + "status, timestamp, expected", + [ + (WorkerStatus.STOPPED, datetime.now() - timedelta(minutes=15), "down 15m"), + (WorkerStatus.STOPPED, None, "stopped"), + ], + ) + def test_format_uptime_or_downtime_stopped_worker( + self, formatter: RichWorkerFormatter, status: WorkerStatus, timestamp: timedelta, expected: str + ): """ Test uptime or downtime formatting. - + Args: formatter: RichWorkerFormatter instance. status: WorkerStatus of the worker. @@ -379,11 +411,11 @@ def test_get_physical_worker_data( formatter: RichWorkerFormatter, mock_logical_workers: List[MagicMock], mock_physical_workers: List[MagicMock], - mock_db: MagicMock + mock_db: MagicMock, ): """ Test extraction of physical worker data for table display. - + Args: formatter: RichWorkerFormatter instance. mock_logical_workers: List of mock LogicalWorkerEntity instances. @@ -392,9 +424,9 @@ def test_get_physical_worker_data( """ # Setup database to return physical workers mock_db.get.side_effect = mock_physical_workers - + data = formatter._get_physical_worker_data(mock_logical_workers, mock_db) - + assert len(data) == 2 # 2 physical workers for first logical, 0 for second logical assert data[0]["worker"] == "logical_worker1" assert data[0]["host"] == "host1" @@ -402,19 +434,17 @@ def test_get_physical_worker_data( assert data[0]["status"] == WorkerStatus.RUNNING def test_get_logical_workers_without_instances_data( - self, - formatter: RichWorkerFormatter, - mock_logical_workers: List[MagicMock] + self, formatter: RichWorkerFormatter, mock_logical_workers: List[MagicMock] ): """ Test extraction of logical workers without physical instances. - + Args: formatter: RichWorkerFormatter instance. mock_logical_workers: List of mock LogicalWorkerEntity instances. """ data = formatter._get_logical_workers_without_instances_data(mock_logical_workers) - + # Only logical_worker2 has no physical workers assert len(data) == 1 assert data[0]["worker"] == "logical_worker2" @@ -425,7 +455,7 @@ def test_get_logical_workers_without_instances_data( def test_sort_physical_workers(self, formatter: RichWorkerFormatter): """ Test that physical workers are sorted correctly. - + Args: formatter: RichWorkerFormatter instance. """ @@ -435,9 +465,9 @@ def test_sort_physical_workers(self, formatter: RichWorkerFormatter): {"_sort_status": "RUNNING", "worker": "worker1", "instance": "inst2"}, {"_sort_status": "STOPPED", "worker": "worker1", "instance": "inst1"}, ] - + sorted_data = formatter._sort_physical_workers(data) - + # Running workers should come first, then sorted by worker and instance name assert sorted_data[0]["_sort_status"] == "RUNNING" assert sorted_data[1]["_sort_status"] == "RUNNING" @@ -461,13 +491,10 @@ def test_build_summary_panels_with_filters(self, formatter: RichWorkerFormatter) "physical_stalled": 0, "physical_rebooting": 0, } - filters = { - "queues": ["queue1", "queue2"], - "name": ["worker1", "worker2"] - } - + filters = {"queues": ["queue1", "queue2"], "name": ["worker1", "worker2"]} + panels = formatter._build_summary_panels(stats, filters) - + assert len(panels) == 3 # Filter, Logical, Physical panels # Check that filter panel contains expected content filter_panel_content = panels[0] @@ -477,7 +504,7 @@ def test_build_summary_panels_with_filters(self, formatter: RichWorkerFormatter) def test_build_summary_panels_no_filters(self, formatter: RichWorkerFormatter): """ Test building summary panels without filters. - + Args: formatter: RichWorkerFormatter instance. """ @@ -492,9 +519,9 @@ def test_build_summary_panels_no_filters(self, formatter: RichWorkerFormatter): "physical_rebooting": 0, } filters = {} - + panels = formatter._build_summary_panels(stats, filters) - + # No filter panel, only logical panel (no physical since total is 0) assert len(panels) == 1 @@ -503,11 +530,11 @@ def test_build_compact_view( formatter: RichWorkerFormatter, mock_logical_workers: List[MagicMock], mock_physical_workers: List[MagicMock], - mock_db: MagicMock + mock_db: MagicMock, ): """ Test building compact view for narrow terminals. - + Args: formatter: RichWorkerFormatter instance. mock_logical_workers: List of mock LogicalWorkerEntity instances. @@ -515,9 +542,9 @@ def test_build_compact_view( mock_db: Mock MerlinDatabase instance. """ mock_db.get.side_effect = mock_physical_workers - + compact_view = formatter._build_compact_view(mock_logical_workers, mock_db) - + assert "logical_worker1" in compact_view assert "logical_worker2" in compact_view assert "NO INSTANCES" in compact_view @@ -526,33 +553,27 @@ def test_build_compact_view( def test_build_responsive_table(self, formatter: RichWorkerFormatter): """ Test building a responsive table with column configuration. - + Args: formatter: RichWorkerFormatter instance. """ columns = [ ColumnConfig(key="name", title="Name", style="bold white"), - ColumnConfig(key="status", title="Status", style="green", formatter=lambda x: f"[{x}]") - ] - data = [ - {"name": "worker1", "status": "running"}, - {"name": "worker2", "status": "stopped"} + ColumnConfig(key="status", title="Status", style="green", formatter=lambda x: f"[{x}]"), ] - + data = [{"name": "worker1", "status": "running"}, {"name": "worker2", "status": "stopped"}] + table = formatter._build_responsive_table("Test Table", columns, data) - + assert table.title == "Test Table" assert len(table.columns) == 2 def test_format_and_display_compact_view( - self, - mocker: MockerFixture, - mock_logical_workers: List[MagicMock], - mock_db: MagicMock + self, mocker: MockerFixture, mock_logical_workers: List[MagicMock], mock_db: MagicMock ): """ Test format_and_display uses compact view for narrow terminals. - + Args: mocker: Pytest mocker fixture. mock_logical_workers: List of mock LogicalWorkerEntity instances. @@ -563,7 +584,7 @@ def test_format_and_display_compact_view( mock_console.size.width = 40 # Compact layout mocker.patch("merlin.workers.formatters.worker_formatter.Console", return_value=mock_console) formatter = RichWorkerFormatter() - + # Mock get_worker_statistics stats = { "total_logical": 2, @@ -576,10 +597,10 @@ def test_format_and_display_compact_view( "physical_rebooting": 0, } mocker.patch.object(formatter, "get_worker_statistics", return_value=stats) - + filters = {} formatter.format_and_display(mock_logical_workers, filters, mock_db) - + # Should call _display_compact_view instead of normal tables assert mock_console.print.called @@ -588,11 +609,11 @@ def test_format_and_display_normal_view( mocker: MockerFixture, mock_logical_workers: List[MagicMock], mock_physical_workers: List[MagicMock], - mock_db: MagicMock + mock_db: MagicMock, ): """ Test format_and_display uses normal view for wide terminals. - + Args: mocker: Pytest mocker fixture. mock_logical_workers: List of mock LogicalWorkerEntity instances. @@ -604,10 +625,10 @@ def test_format_and_display_normal_view( mock_console.size.width = 150 # Wide layout mocker.patch("merlin.workers.formatters.worker_formatter.Console", return_value=mock_console) formatter = RichWorkerFormatter() - + # Mock database to return physical workers mock_db.get.side_effect = mock_physical_workers - + # Mock get_worker_statistics stats = { "total_logical": 2, @@ -620,10 +641,10 @@ def test_format_and_display_normal_view( "physical_rebooting": 0, } mocker.patch.object(formatter, "get_worker_statistics", return_value=stats) - + filters = {"queues": ["queue1"]} formatter.format_and_display(mock_logical_workers, filters, mock_db) - + # Should display summary panels and tables assert mock_console.print.called # Should be called multiple times (empty lines, panels, tables) @@ -632,7 +653,7 @@ def test_format_and_display_normal_view( def test_display_summary_panels_horizontal(self, mocker: MockerFixture, formatter: RichWorkerFormatter): """ Test that summary panels are displayed horizontally when configured. - + Args: mocker: Pytest mocker fixture. formatter: RichWorkerFormatter instance. @@ -650,20 +671,16 @@ def test_display_summary_panels_horizontal(self, mocker: MockerFixture, formatte } filters = {} layout_config = LayoutConfig(LayoutSize.WIDE, panels_horizontal=True) - + formatter._display_summary_panels(stats, filters, layout_config) - + # Should create Columns object for horizontal layout mock_columns.assert_called_once() - def test_display_summary_panels_vertical( - self, - mocker: MockerFixture, - formatter: RichWorkerFormatter - ): + def test_display_summary_panels_vertical(self, mocker: MockerFixture, formatter: RichWorkerFormatter): """ Test that summary panels are displayed vertically when configured. - + Args: mocker: Pytest mocker fixture. formatter: RichWorkerFormatter instance. @@ -680,23 +697,19 @@ def test_display_summary_panels_vertical( } filters = {} layout_config = LayoutConfig(LayoutSize.NARROW, panels_horizontal=False) - + # Mock console.print to track calls - mock_print = mocker.patch.object(formatter.console, 'print') - + mock_print = mocker.patch.object(formatter.console, "print") + formatter._display_summary_panels(stats, filters, layout_config) - + # Should print each panel individually (not using Columns) assert mock_print.called - def test_display_compact_view( - self, - mocker: MockerFixture, - formatter: RichWorkerFormatter - ): + def test_display_compact_view(self, mocker: MockerFixture, formatter: RichWorkerFormatter): """ Test display of compact view. - + Args: mocker: Pytest mocker fixture. formatter: RichWorkerFormatter instance. @@ -713,14 +726,14 @@ def test_display_compact_view( "physical_stalled": 0, "physical_rebooting": 0, } - - mock_print = mocker.patch.object(formatter.console, 'print') - + + mock_print = mocker.patch.object(formatter.console, "print") + formatter._display_compact_view(compact_view, filters, stats) - + # Should print title, filters, summary, and compact view assert mock_print.call_count == 4 - + # Check that filter information was included in one of the print calls print_args = [str(call[0][0]) for call in mock_print.call_args_list] filter_found = any("queue1" in arg for arg in print_args) diff --git a/tests/unit/workers/formatters/test_worker_formatter.py b/tests/unit/workers/formatters/test_worker_formatter.py index 3375265f..1ba9ea37 100644 --- a/tests/unit/workers/formatters/test_worker_formatter.py +++ b/tests/unit/workers/formatters/test_worker_formatter.py @@ -22,19 +22,15 @@ class DummyWorkerFormatter(WorkerFormatter): """Dummy implementation of WorkerFormatter for testing.""" - + def __init__(self): super().__init__() self.formatted_data = None self.display_called = False - + def format_and_display(self, logical_workers: List, filters: Dict, merlin_db: MerlinDatabase): """Mock implementation that stores the call parameters.""" - self.formatted_data = { - "logical_workers": logical_workers, - "filters": filters, - "merlin_db": merlin_db - } + self.formatted_data = {"logical_workers": logical_workers, "filters": filters, "merlin_db": merlin_db} self.display_called = True return f"Formatted {len(logical_workers)} workers with filters {filters}" @@ -47,10 +43,10 @@ def test_abstract_formatter_cannot_be_instantiated(): def test_unimplemented_method_raises_not_implemented(): """Test that calling abstract methods on a subclass without implementation raises NotImplementedError.""" - + class IncompleteFormatter(WorkerFormatter): pass - + # Should raise TypeError due to unimplemented abstract method with pytest.raises(TypeError): IncompleteFormatter() @@ -63,10 +59,10 @@ class TestWorkerFormatter: def formatter(self, mocker: MockerFixture) -> DummyWorkerFormatter: """ Create a DummyWorkerFormatter instance for testing. - + Args: mocker: Pytest mocker fixture. - + Returns: DummyWorkerFormatter instance with mocked console. """ @@ -79,7 +75,7 @@ def formatter(self, mocker: MockerFixture) -> DummyWorkerFormatter: def mock_logical_workers(self) -> List[MagicMock]: """ Create mock logical worker entities for testing. - + Returns: List of mock logical worker entities. """ @@ -87,31 +83,31 @@ def mock_logical_workers(self) -> List[MagicMock]: worker1.get_name.return_value = "worker1" worker1.get_queues.return_value = ["queue1", "queue2"] worker1.get_physical_workers.return_value = ["phys1", "phys2"] - + worker2 = MagicMock(spec=LogicalWorkerEntity) worker2.get_name.return_value = "worker2" worker2.get_queues.return_value = ["queue3"] worker2.get_physical_workers.return_value = [] - + return [worker1, worker2] @pytest.fixture def mock_physical_workers(self) -> List[MagicMock]: """ Create mock physical worker entities for testing. - + Returns: List of mock physical worker entities. """ worker1 = MagicMock() worker1.get_status.return_value = WorkerStatus.RUNNING - + worker2 = MagicMock() worker2.get_status.return_value = WorkerStatus.STOPPED - + worker3 = MagicMock() worker3.get_status.return_value = WorkerStatus.STALLED - + return [worker1, worker2, worker3] @pytest.fixture @@ -131,18 +127,15 @@ def test_console_initialization(self, formatter: DummyWorkerFormatter): Args: formatter: DummyWorkerFormatter instance for testing. """ - assert hasattr(formatter, 'console') + assert hasattr(formatter, "console") assert formatter.console is not None def test_format_and_display_abstract_method_implemented( - self, - formatter: DummyWorkerFormatter, - mock_logical_workers: List[MagicMock], - mock_db: MagicMock + self, formatter: DummyWorkerFormatter, mock_logical_workers: List[MagicMock], mock_db: MagicMock ): """ Test that concrete implementation can override format_and_display. - + Args: formatter: DummyWorkerFormatter instance for testing. mock_logical_workers: List of mock logical worker entities. @@ -150,7 +143,7 @@ def test_format_and_display_abstract_method_implemented( """ filters = {"queues": ["test_queue"]} result = formatter.format_and_display(mock_logical_workers, filters, mock_db) - + assert formatter.display_called is True assert "Formatted 2 workers" in result assert formatter.formatted_data["logical_workers"] == mock_logical_workers @@ -162,11 +155,11 @@ def test_get_worker_statistics_basic_functionality( formatter: DummyWorkerFormatter, mock_logical_workers: List[MagicMock], mock_physical_workers: List[MagicMock], - mock_db: MagicMock + mock_db: MagicMock, ): """ Test that get_worker_statistics computes basic statistics correctly. - + Args: formatter: DummyWorkerFormatter instance for testing. mock_logical_workers: List of mock logical worker entities. @@ -175,9 +168,9 @@ def test_get_worker_statistics_basic_functionality( """ # Setup database to return physical workers mock_db.get.side_effect = mock_physical_workers - + stats = formatter.get_worker_statistics(mock_logical_workers, mock_db) - + # Verify basic counts assert stats["total_logical"] == 2 assert stats["logical_with_instances"] == 1 # Only worker1 has physical workers @@ -189,11 +182,11 @@ def test_get_worker_statistics_status_counts( formatter: DummyWorkerFormatter, mock_logical_workers: List[MagicMock], mock_physical_workers: List[MagicMock], - mock_db: MagicMock + mock_db: MagicMock, ): """ Test that get_worker_statistics counts worker statuses correctly. - + Args: formatter: DummyWorkerFormatter instance for testing. mock_logical_workers: List of mock logical worker entities. @@ -202,29 +195,25 @@ def test_get_worker_statistics_status_counts( """ # Setup database to return physical workers for first logical worker only mock_db.get.side_effect = mock_physical_workers[:2] # First 2 physical workers - + stats = formatter.get_worker_statistics(mock_logical_workers, mock_db) - + # Verify status counts assert stats["physical_running"] == 1 assert stats["physical_stopped"] == 1 assert stats["physical_stalled"] == 0 assert stats["physical_rebooting"] == 0 - def test_get_worker_statistics_with_empty_logical_workers( - self, - formatter: DummyWorkerFormatter, - mock_db: MagicMock - ): + def test_get_worker_statistics_with_empty_logical_workers(self, formatter: DummyWorkerFormatter, mock_db: MagicMock): """ Test get_worker_statistics with empty logical workers list. - + Args: formatter: DummyWorkerFormatter instance for testing. mock_db: Mock MerlinDatabase instance. """ stats = formatter.get_worker_statistics([], mock_db) - + # All counts should be zero assert stats["total_logical"] == 0 assert stats["logical_with_instances"] == 0 @@ -235,22 +224,18 @@ def test_get_worker_statistics_with_empty_logical_workers( assert stats["physical_stalled"] == 0 assert stats["physical_rebooting"] == 0 - def test_get_worker_statistics_with_all_status_types( - self, - formatter: DummyWorkerFormatter, - mock_db: MagicMock - ): + def test_get_worker_statistics_with_all_status_types(self, formatter: DummyWorkerFormatter, mock_db: MagicMock): """ Test get_worker_statistics counts all worker status types correctly. - + Args: formatter: DummyWorkerFormatter instance for testing. mock_db: Mock MerlinDatabase instance. - """ + """ # Create logical worker with multiple physical workers of different statuses logical_worker = MagicMock(spec=LogicalWorkerEntity) logical_worker.get_physical_workers.return_value = ["p1", "p2", "p3", "p4"] - + # Create physical workers with all status types physical_workers = [] statuses = [WorkerStatus.RUNNING, WorkerStatus.STOPPED, WorkerStatus.STALLED, WorkerStatus.REBOOTING] @@ -258,11 +243,11 @@ def test_get_worker_statistics_with_all_status_types( worker = MagicMock() worker.get_status.return_value = status physical_workers.append(worker) - + mock_db.get.side_effect = physical_workers - + stats = formatter.get_worker_statistics([logical_worker], mock_db) - + # Verify all status types are counted assert stats["total_logical"] == 1 assert stats["logical_with_instances"] == 1 @@ -278,11 +263,11 @@ def test_get_worker_statistics_database_interaction( formatter: DummyWorkerFormatter, mock_logical_workers: List[MagicMock], mock_physical_workers: List[MagicMock], - mock_db: MagicMock + mock_db: MagicMock, ): """ Test that get_worker_statistics interacts with database correctly. - + Args: formatter: DummyWorkerFormatter instance for testing. mock_logical_workers: List of mock logical worker entities. @@ -290,60 +275,53 @@ def test_get_worker_statistics_database_interaction( mock_db: Mock MerlinDatabase instance. """ mock_db.get.side_effect = mock_physical_workers - + formatter.get_worker_statistics(mock_logical_workers, mock_db) - + # Verify database was called for each physical worker ID - expected_calls = [ - ("physical_worker", "phys1"), - ("physical_worker", "phys2") - ] + expected_calls = [("physical_worker", "phys1"), ("physical_worker", "phys2")] actual_calls = [call[0] for call in mock_db.get.call_args_list] - + for expected_call in expected_calls: assert expected_call in actual_calls - def test_get_worker_statistics_handles_mixed_scenarios( - self, - formatter: DummyWorkerFormatter, - mock_db: MagicMock - ): + def test_get_worker_statistics_handles_mixed_scenarios(self, formatter: DummyWorkerFormatter, mock_db: MagicMock): """ Test get_worker_statistics with mixed scenarios (some workers with/without instances). - + Args: formatter: DummyWorkerFormatter instance for testing. mock_db: Mock MerlinDatabase instance. - """ + """ # Create logical workers: one with multiple instances, one without, one with single instance logical_workers = [] - + # Worker 1: Has 2 physical workers worker1 = MagicMock(spec=LogicalWorkerEntity) worker1.get_physical_workers.return_value = ["p1", "p2"] logical_workers.append(worker1) - + # Worker 2: No physical workers worker2 = MagicMock(spec=LogicalWorkerEntity) worker2.get_physical_workers.return_value = [] logical_workers.append(worker2) - + # Worker 3: Has 1 physical worker worker3 = MagicMock(spec=LogicalWorkerEntity) worker3.get_physical_workers.return_value = ["p3"] logical_workers.append(worker3) - + # Create physical workers physical_workers = [ MagicMock(get_status=lambda: WorkerStatus.RUNNING), MagicMock(get_status=lambda: WorkerStatus.STOPPED), MagicMock(get_status=lambda: WorkerStatus.STALLED), ] - + mock_db.get.side_effect = physical_workers - + stats = formatter.get_worker_statistics(logical_workers, mock_db) - + assert stats["total_logical"] == 3 assert stats["logical_with_instances"] == 2 # worker1 and worker3 assert stats["logical_without_instances"] == 1 # worker2 @@ -351,4 +329,4 @@ def test_get_worker_statistics_handles_mixed_scenarios( assert stats["physical_running"] == 1 assert stats["physical_stopped"] == 1 assert stats["physical_stalled"] == 1 - assert stats["physical_rebooting"] == 0 \ No newline at end of file + assert stats["physical_rebooting"] == 0 diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py index b88661d3..68640401 100644 --- a/tests/unit/workers/handlers/test_celery_handler.py +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -84,7 +84,7 @@ def workers(self, mock_db: MagicMock) -> List[DummyCeleryWorker]: DummyCeleryWorker("worker1"), DummyCeleryWorker("worker2"), ] - + @pytest.fixture def mock_logical_workers(self) -> List[MagicMock]: """ @@ -96,11 +96,11 @@ def mock_logical_workers(self) -> List[MagicMock]: worker1 = MagicMock() worker1.get_name.return_value = "logical_worker1" worker1.get_queues.return_value = ["[merlin]_queue1", "[merlin]_queue2"] - + worker2 = MagicMock() worker2.get_name.return_value = "logical_worker2" worker2.get_queues.return_value = ["[merlin]_queue3"] - + return [worker1, worker2] @pytest.fixture @@ -115,10 +115,7 @@ def mock_formatter(self, mocker: MockerFixture) -> MagicMock: Mock formatter instance. """ mock_formatter = MagicMock() - mocker.patch( - "merlin.workers.handlers.celery_handler.worker_formatter_factory.create", - return_value=mock_formatter - ) + mocker.patch("merlin.workers.handlers.celery_handler.worker_formatter_factory.create", return_value=mock_formatter) return mock_formatter def test_echo_only_prints_commands( @@ -174,13 +171,10 @@ def test_build_filters_with_queues_and_workers(self, handler: CeleryWorkerHandle """ queues = ["queue1", "queue2"] workers = ["worker1", "worker2"] - + filters = handler._build_filters(queues, workers) - - assert filters == { - "queues": ["queue1", "queue2"], - "name": ["worker1", "worker2"] - } + + assert filters == {"queues": ["queue1", "queue2"], "name": ["worker1", "worker2"]} def test_build_filters_with_only_queues(self, handler: CeleryWorkerHandler): """ @@ -190,9 +184,9 @@ def test_build_filters_with_only_queues(self, handler: CeleryWorkerHandler): handler: CeleryWorkerHandler instance. """ queues = ["queue1"] - + filters = handler._build_filters(queues, None) - + assert filters == {"queues": ["queue1"]} def test_build_filters_with_only_workers(self, handler: CeleryWorkerHandler): @@ -203,9 +197,9 @@ def test_build_filters_with_only_workers(self, handler: CeleryWorkerHandler): handler: CeleryWorkerHandler instance. """ workers = ["worker1"] - + filters = handler._build_filters(None, workers) - + assert filters == {"name": ["worker1"]} def test_build_filters_with_no_parameters(self, handler: CeleryWorkerHandler): @@ -216,14 +210,11 @@ def test_build_filters_with_no_parameters(self, handler: CeleryWorkerHandler): handler: CeleryWorkerHandler instance. """ filters = handler._build_filters(None, None) - + assert filters == {} def test_query_workers_calls_database_and_formatter( - self, - handler: CeleryWorkerHandler, - mock_logical_workers: List[MagicMock], - mock_formatter: MagicMock + self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock ): """ Test that `query_workers` retrieves data from database and calls formatter. @@ -235,23 +226,18 @@ def test_query_workers_calls_database_and_formatter( """ # Mock the database get_all method handler.merlin_db.get_all.return_value = mock_logical_workers - + handler.query_workers("rich", queues=["queue1"], workers=["worker1"]) - + # Verify database was called with correct filters expected_filters = {"queues": ["queue1"], "name": ["worker1"]} handler.merlin_db.get_all.assert_called_once_with("logical_worker", filters=expected_filters) - + # Verify formatter was created and called - mock_formatter.format_and_display.assert_called_once_with( - mock_logical_workers, expected_filters, handler.merlin_db - ) + mock_formatter.format_and_display.assert_called_once_with(mock_logical_workers, expected_filters, handler.merlin_db) def test_query_workers_with_no_filters( - self, - handler: CeleryWorkerHandler, - mock_logical_workers: List[MagicMock], - mock_formatter: MagicMock + self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock ): """ Test that `query_workers` works correctly when no filters are provided. @@ -262,22 +248,17 @@ def test_query_workers_with_no_filters( mock_formatter: Mock formatter instance. """ handler.merlin_db.get_all.return_value = mock_logical_workers - + handler.query_workers("json") - + # Verify database was called with empty filters handler.merlin_db.get_all.assert_called_once_with("logical_worker", filters={}) - + # Verify formatter was called correctly - mock_formatter.format_and_display.assert_called_once_with( - mock_logical_workers, {}, handler.merlin_db - ) + mock_formatter.format_and_display.assert_called_once_with(mock_logical_workers, {}, handler.merlin_db) def test_query_workers_uses_correct_formatter( - self, - handler: CeleryWorkerHandler, - mock_logical_workers: List[MagicMock], - mocker: MockerFixture + self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mocker: MockerFixture ): """ Test that `query_workers` uses the correct formatter type. @@ -288,22 +269,18 @@ def test_query_workers_uses_correct_formatter( mocker: Pytest mocker fixture. """ handler.merlin_db.get_all.return_value = mock_logical_workers - + mock_factory = mocker.patch("merlin.workers.handlers.celery_handler.worker_formatter_factory") mock_formatter = MagicMock() mock_factory.create.return_value = mock_formatter - + handler.query_workers("json", queues=["test_queue"]) - + # Verify the correct formatter type was requested mock_factory.create.assert_called_once_with("json") mock_formatter.format_and_display.assert_called_once() - def test_query_workers_handles_empty_results( - self, - handler: CeleryWorkerHandler, - mock_formatter: MagicMock - ): + def test_query_workers_handles_empty_results(self, handler: CeleryWorkerHandler, mock_formatter: MagicMock): """ Test that `query_workers` handles empty database results gracefully. @@ -312,17 +289,14 @@ def test_query_workers_handles_empty_results( mock_formatter: Mock formatter instance. """ handler.merlin_db.get_all.return_value = [] - + handler.query_workers("rich") - + # Verify formatter is still called with empty list mock_formatter.format_and_display.assert_called_once_with([], {}, handler.merlin_db) def test_query_workers_passes_all_parameters_to_formatter( - self, - handler: CeleryWorkerHandler, - mock_logical_workers: List[MagicMock], - mock_formatter: MagicMock + self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock ): """ Test that `query_workers` passes all necessary parameters to formatter. @@ -333,17 +307,13 @@ def test_query_workers_passes_all_parameters_to_formatter( mock_formatter: Mock formatter instance. """ handler.merlin_db.get_all.return_value = mock_logical_workers - + queues = ["queue1", "queue2"] workers = ["worker1"] - + handler.query_workers("rich", queues=queues, workers=workers) - + expected_filters = {"queues": queues, "name": workers} - + # Verify all parameters are passed correctly - mock_formatter.format_and_display.assert_called_once_with( - mock_logical_workers, - expected_filters, - handler.merlin_db - ) + mock_formatter.format_and_display.assert_called_once_with(mock_logical_workers, expected_filters, handler.merlin_db) From a873e58a90afac766804259c8abe62dd16a03f0b Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Tue, 23 Sep 2025 15:50:18 -0700 Subject: [PATCH 77/91] fix broken test --- tests/unit/cli/commands/test_run_workers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/cli/commands/test_run_workers.py b/tests/unit/cli/commands/test_run_workers.py index bd784f8d..247afc8e 100644 --- a/tests/unit/cli/commands/test_run_workers.py +++ b/tests/unit/cli/commands/test_run_workers.py @@ -97,6 +97,7 @@ def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, ca mocker.patch("merlin.cli.commands.run_workers.get_merlin_spec_with_override", return_value=(mock_spec, "file.yaml")) mocker.patch("merlin.cli.commands.run_workers.initialize_config") + mocker.patch("merlin.workers.handlers.celery_handler.MerlinDatabase") mocker.patch("merlin.cli.commands.run_workers.worker_handler_factory.create", wraps=lambda _: CeleryWorkerHandler()) args = Namespace( From 4c90c69dab49b68c0744f56f197f3a424b70606c Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 25 Sep 2025 11:58:35 -0700 Subject: [PATCH 78/91] remove query-workers integration tests --- ..._query_workers.py => test_stop_workers.py} | 152 ++++++------------ 1 file changed, 47 insertions(+), 105 deletions(-) rename tests/integration/commands/{test_stop_and_query_workers.py => test_stop_workers.py} (64%) diff --git a/tests/integration/commands/test_stop_and_query_workers.py b/tests/integration/commands/test_stop_workers.py similarity index 64% rename from tests/integration/commands/test_stop_and_query_workers.py rename to tests/integration/commands/test_stop_workers.py index a2f5d337..3fb40a5d 100644 --- a/tests/integration/commands/test_stop_and_query_workers.py +++ b/tests/integration/commands/test_stop_workers.py @@ -5,8 +5,7 @@ ############################################################################## """ -This module will contain the testing logic for -the `stop-workers` and `query-workers` commands. +This module will contain the testing logic for the `stop-workers` command. """ import os @@ -15,8 +14,6 @@ from enum import Enum from typing import List -import pytest - from tests.context_managers.celery_workers_manager import CeleryWorkersManager from tests.fixture_data_classes import RedisBrokerAndBackend from tests.fixture_types import FixtureStr @@ -35,15 +32,14 @@ class WorkerMessages(Enum): """ NO_WORKERS_MSG_STOP = "No workers found to stop" - NO_WORKERS_MSG_QUERY = "No workers found!" STEP_1_WORKER = "step_1_merlin_test_worker" STEP_2_WORKER = "step_2_merlin_test_worker" OTHER_WORKER = "other_merlin_test_worker" -class TestStopAndQueryWorkersCommands: +class TestStopWorkersCommands: """ - Tests for the `merlin stop-workers` and `merlin query-workers` commands. + Tests for the `merlin stop-workers` command. Most of these tests will: 1. Start workers from a spec file used for testing - Use CeleryWorkerManager for this to ensure safe stoppage of workers @@ -57,7 +53,6 @@ def run_test_with_workers( # pylint: disable=too-many-arguments path_to_test_specs: FixtureStr, merlin_server_dir: FixtureStr, conditions: List[Condition], - command: str, flag: str = None, ): """ @@ -90,8 +85,6 @@ def run_test_with_workers( # pylint: disable=too-many-arguments conditions: A list of `Condition` instances that need to pass in order for this test to be successful. - command: - The command that we're testing. E.g. "merlin stop-workers" flag: An optional flag to add to the command that we're testing so we can test different functionality for the command. @@ -110,8 +103,10 @@ def run_test_with_workers( # pylint: disable=too-many-arguments copy_app_yaml_to_cwd(merlin_server_dir) # Run the test - cmd_to_test = f"{command} {flag}" if flag else command - result = subprocess.run(cmd_to_test, capture_output=True, text=True, shell=True) + command = "merlin stop-workers" + if flag: + command += f" {flag}" + result = subprocess.run(command, capture_output=True, text=True, shell=True) info = { "stdout": result.stdout, @@ -124,34 +119,13 @@ def run_test_with_workers( # pylint: disable=too-many-arguments yield - def get_no_workers_msg(self, command_to_test: str) -> WorkerMessages: - """ - Retrieve the appropriate "no workers" found message. - - This method checks the command to test and returns a corresponding - message based on whether the command is to stop workers or query for them. - - Returns: - The message indicating that no workers are available, depending on the - command being tested. - """ - no_workers_msg = None - if command_to_test == "merlin stop-workers": - no_workers_msg = WorkerMessages.NO_WORKERS_MSG_STOP.value - else: - no_workers_msg = WorkerMessages.NO_WORKERS_MSG_QUERY.value - return no_workers_msg - - @pytest.mark.parametrize("command_to_test", ["merlin stop-workers", "merlin query-workers"]) def test_no_workers( self, redis_broker_and_backend_function: RedisBrokerAndBackend, merlin_server_dir: FixtureStr, - command_to_test: str, ): """ - Test the `merlin stop-workers` and `merlin query-workers` commands with no workers - started in the first place. + Test the `merlin stop-workers` command with no workers started in the first place. This test will: 0. Setup the pytest fixtures which include: @@ -170,11 +144,9 @@ def test_no_workers( merlin_server_dir: A fixture to provide the path to the merlin_server directory that will be created by the `redis_server` fixture. - command_to_test: - The command that we're testing, obtained from the parametrize call. """ conditions = [ - HasRegex(self.get_no_workers_msg(command_to_test)), + HasRegex(WorkerMessages.NO_WORKERS_MSG_STOP.value), HasRegex(WorkerMessages.STEP_1_WORKER.value, negate=True), HasRegex(WorkerMessages.STEP_2_WORKER.value, negate=True), HasRegex(WorkerMessages.OTHER_WORKER.value, negate=True), @@ -184,7 +156,7 @@ def test_no_workers( copy_app_yaml_to_cwd(merlin_server_dir) # Run the test - result = subprocess.run(command_to_test, capture_output=True, text=True, shell=True) + result = subprocess.run("merlin stop-workers", capture_output=True, text=True, shell=True) info = { "stdout": result.stdout, "stderr": result.stderr, @@ -194,19 +166,16 @@ def test_no_workers( # Ensure all test conditions are satisfied check_test_conditions(conditions, info) - @pytest.mark.parametrize("command_to_test", ["merlin stop-workers", "merlin query-workers"]) def test_no_flags( self, redis_broker_and_backend_function: RedisBrokerAndBackend, path_to_test_specs: FixtureStr, merlin_server_dir: FixtureStr, - command_to_test: str, ): """ - Test the `merlin stop-workers` and `merlin query-workers` commands with no flags. + Test the `merlin stop-workers` command with no flags. - Run the commands referenced above and ensure the text output from Merlin is correct. - For the `stop-workers` command, we check if all workers are stopped as well. + Run the command and ensure the text output from Merlin is correct. To see more information on exactly what this test is doing, see the `run_test_with_workers()` method. @@ -218,39 +187,32 @@ def test_no_flags( merlin_server_dir: A fixture to provide the path to the merlin_server directory that will be created by the `redis_server` fixture. - command_to_test: - The command that we're testing, obtained from the parametrize call. """ conditions = [ - HasRegex(self.get_no_workers_msg(command_to_test), negate=True), + HasRegex(WorkerMessages.NO_WORKERS_MSG_STOP.value, negate=True), HasRegex(WorkerMessages.STEP_1_WORKER.value), HasRegex(WorkerMessages.STEP_2_WORKER.value), HasRegex(WorkerMessages.OTHER_WORKER.value), ] - with self.run_test_with_workers(path_to_test_specs, merlin_server_dir, conditions, command_to_test): - if command_to_test == "merlin stop-workers": - # After the test runs and before the CeleryWorkersManager exits, ensure there are no workers on the app - from merlin.celery import app as celery_app + with self.run_test_with_workers(path_to_test_specs, merlin_server_dir, conditions): + # After the test runs and before the CeleryWorkersManager exits, ensure there are no workers on the app + from merlin.celery import app as celery_app - active_queues = celery_app.control.inspect().active_queues() - assert active_queues is None + active_queues = celery_app.control.inspect().active_queues() + assert active_queues is None - @pytest.mark.parametrize("command_to_test", ["merlin stop-workers", "merlin query-workers"]) def test_spec_flag( self, redis_broker_and_backend_function: RedisBrokerAndBackend, path_to_test_specs: FixtureStr, merlin_server_dir: FixtureStr, - command_to_test: str, ): """ - Test the `merlin stop-workers` and `merlin query-workers` commands with the `--spec` - flag. + Test the `merlin stop-workers` command with the `--spec` flag. - Run the commands referenced above with the `--spec` flag and ensure the text output - from Merlin is correct. For the `stop-workers` command, we check if all workers defined - in the spec file are stopped as well. To see more information on exactly what this test - is doing, see the `run_test_with_workers()` method. + Run the command with the `--spec` flag and ensure the text output + from Merlin is correct. To see more information on exactly what this + test is doing, see the `run_test_with_workers()` method. Parameters: redis_broker_and_backend_function: Fixture for setting up Redis broker and @@ -260,11 +222,9 @@ def test_spec_flag( merlin_server_dir: A fixture to provide the path to the merlin_server directory that will be created by the `redis_server` fixture. - command_to_test: - The command that we're testing, obtained from the parametrize call. """ conditions = [ - HasRegex(self.get_no_workers_msg(command_to_test), negate=True), + HasRegex(WorkerMessages.NO_WORKERS_MSG_STOP.value, negate=True), HasRegex(WorkerMessages.STEP_1_WORKER.value), HasRegex(WorkerMessages.STEP_2_WORKER.value), HasRegex(WorkerMessages.OTHER_WORKER.value), @@ -273,30 +233,24 @@ def test_spec_flag( path_to_test_specs, merlin_server_dir, conditions, - command_to_test, flag=f"--spec {os.path.join(path_to_test_specs, 'multiple_workers.yaml')}", ): - if command_to_test == "merlin stop-workers": - from merlin.celery import app as celery_app + from merlin.celery import app as celery_app - active_queues = celery_app.control.inspect().active_queues() - assert active_queues is None + active_queues = celery_app.control.inspect().active_queues() + assert active_queues is None - @pytest.mark.parametrize("command_to_test", ["merlin stop-workers", "merlin query-workers"]) def test_workers_flag( self, redis_broker_and_backend_function: RedisBrokerAndBackend, path_to_test_specs: FixtureStr, merlin_server_dir: FixtureStr, - command_to_test: str, ): """ - Test the `merlin stop-workers` and `merlin query-workers` commands with the `--workers` - flag. + Test the `merlin stop-workers` command with the `--workers` flag. - Run the commands referenced above with the `--workers` flag and ensure the text output - from Merlin is correct. For the `stop-workers` command, we check to make sure that all - workers given with this flag are stopped. To see more information on exactly what this + Run the command with the `--workers` flag and ensure the text output + from Merlin is correct. To see more information on exactly what this test is doing, see the `run_test_with_workers()` method. Parameters: @@ -307,11 +261,9 @@ def test_workers_flag( merlin_server_dir: A fixture to provide the path to the merlin_server directory that will be created by the `redis_server` fixture. - command_to_test: - The command that we're testing, obtained from the parametrize call. """ conditions = [ - HasRegex(self.get_no_workers_msg(command_to_test), negate=True), + HasRegex(WorkerMessages.NO_WORKERS_MSG_STOP.value, negate=True), HasRegex(WorkerMessages.STEP_1_WORKER.value), HasRegex(WorkerMessages.STEP_2_WORKER.value), HasRegex(WorkerMessages.OTHER_WORKER.value, negate=True), @@ -320,31 +272,25 @@ def test_workers_flag( path_to_test_specs, merlin_server_dir, conditions, - command_to_test, flag=f"--workers {WorkerMessages.STEP_1_WORKER.value} {WorkerMessages.STEP_2_WORKER.value}", ): - if command_to_test == "merlin stop-workers": - from merlin.celery import app as celery_app + from merlin.celery import app as celery_app - active_queues = celery_app.control.inspect().active_queues() - worker_name = f"celery@{WorkerMessages.OTHER_WORKER.value}" - assert worker_name in active_queues + active_queues = celery_app.control.inspect().active_queues() + worker_name = f"celery@{WorkerMessages.OTHER_WORKER.value}" + assert worker_name in active_queues - @pytest.mark.parametrize("command_to_test", ["merlin stop-workers", "merlin query-workers"]) def test_queues_flag( self, redis_broker_and_backend_function: RedisBrokerAndBackend, path_to_test_specs: FixtureStr, merlin_server_dir: FixtureStr, - command_to_test: str, ): """ - Test the `merlin stop-workers` and `merlin query-workers` commands with the `--queues` - flag. + Test the `merlin stop-workers` command with the `--queues` flag. - Run the commands referenced above with the `--queues` flag and ensure the text output - from Merlin is correct. For the `stop-workers` command, we check that only the workers - attached to the given queues are stopped. To see more information on exactly what this + Run the command with the `--queues` flag and ensure the text output + from Merlin is correct. To see more information on exactly what this test is doing, see the `run_test_with_workers()` method. Parameters: @@ -355,11 +301,9 @@ def test_queues_flag( merlin_server_dir: A fixture to provide the path to the merlin_server directory that will be created by the `redis_server` fixture. - command_to_test: - The command that we're testing, obtained from the parametrize call. """ conditions = [ - HasRegex(self.get_no_workers_msg(command_to_test), negate=True), + HasRegex(WorkerMessages.NO_WORKERS_MSG_STOP.value, negate=True), HasRegex(WorkerMessages.STEP_1_WORKER.value), HasRegex(WorkerMessages.STEP_2_WORKER.value, negate=True), HasRegex(WorkerMessages.OTHER_WORKER.value, negate=True), @@ -368,19 +312,17 @@ def test_queues_flag( path_to_test_specs, merlin_server_dir, conditions, - command_to_test, flag="--queues hello_queue", ): - if command_to_test == "merlin stop-workers": - from merlin.celery import app as celery_app - - active_queues = celery_app.control.inspect().active_queues() - workers_that_should_be_alive = [ - f"celery@{WorkerMessages.OTHER_WORKER.value}", - f"celery@{WorkerMessages.STEP_2_WORKER.value}", - ] - for worker_name in workers_that_should_be_alive: - assert worker_name in active_queues + from merlin.celery import app as celery_app + + active_queues = celery_app.control.inspect().active_queues() + workers_that_should_be_alive = [ + f"celery@{WorkerMessages.OTHER_WORKER.value}", + f"celery@{WorkerMessages.STEP_2_WORKER.value}", + ] + for worker_name in workers_that_should_be_alive: + assert worker_name in active_queues # pylint: enable=unused-argument,import-outside-toplevel From f6452672781263b84ea2a691d8921e0246b1ced4 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Mon, 29 Sep 2025 11:04:54 -0700 Subject: [PATCH 79/91] fix issue with tests --- merlin/cli/commands/run.py | 2 +- merlin/spec/specification.py | 2 +- merlin/workers/handlers/celery_handler.py | 5 ++++- tests/integration/commands/test_monitor.py | 7 ++++++- tests/integration/definitions.py | 2 +- tests/unit/workers/handlers/test_celery_handler.py | 8 ++++---- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/merlin/cli/commands/run.py b/merlin/cli/commands/run.py index 09f5a4a5..c7b9e927 100644 --- a/merlin/cli/commands/run.py +++ b/merlin/cli/commands/run.py @@ -170,7 +170,7 @@ def process_command(self, args: Namespace): ) # Create logical worker entries - step_queue_map = study.expanded_spec.get_task_queues(omit_tag=True) + step_queue_map = study.expanded_spec.get_task_queues() for worker, steps in study.expanded_spec.get_worker_step_map().items(): worker_queues = {step_queue_map[step] for step in steps} logical_worker_entity = merlin_db.create("logical_worker", worker, worker_queues) diff --git a/merlin/spec/specification.py b/merlin/spec/specification.py index 6bd07384..66c58da4 100644 --- a/merlin/spec/specification.py +++ b/merlin/spec/specification.py @@ -1265,7 +1265,7 @@ def build_worker_list(self, workers_to_start: Set[str]) -> List[MerlinWorker]: config = { "args": settings.get("args", ""), "machines": settings.get("machines", []), - "queues": set(self.get_queue_list(settings["steps"], omit_tag=True)), + "queues": set(self.get_queue_list(settings["steps"])), "batch": settings["batch"] if settings["batch"] is not None else self.batch.copy(), } diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index 99d2e6c2..ca1bd3a0 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -88,7 +88,10 @@ def _build_filters(self, queues: List[str], workers: List[str]) -> Dict[str, Lis """ filters = {} if queues: - filters["queues"] = queues + filters["queues"] = [ + queue if queue.startswith("[merlin]_") else f"[merlin]_{queue}" + for queue in queues + ] if workers: filters["name"] = workers return filters diff --git a/tests/integration/commands/test_monitor.py b/tests/integration/commands/test_monitor.py index c3a41387..fdbdaa0b 100644 --- a/tests/integration/commands/test_monitor.py +++ b/tests/integration/commands/test_monitor.py @@ -98,7 +98,12 @@ def test_auto_restart( f"merlin purge -f {monitor_setup.auto_restart_yaml}".split(), capture_output=True, text=True ) - monitor_stdout, monitor_stderr = monitor_proc.communicate() + # Obtain stdout and stderr from the monitor process + try: + monitor_stdout, monitor_stderr = monitor_proc.communicate(timeout=30) + except subprocess.TimeoutExpired: + monitor_proc.kill() + monitor_stdout, monitor_stderr = monitor_proc.communicate() # Define our test conditions study_name = "monitor_auto_restart_test" diff --git a/tests/integration/definitions.py b/tests/integration/definitions.py index bb7fd787..0ece8f5e 100644 --- a/tests/integration/definitions.py +++ b/tests/integration/definitions.py @@ -314,7 +314,7 @@ def define_tests(): # pylint: disable=R0914,R0915 }, "default_worker assigned": { "cmds": f"{workers} {test_specs}/default_worker_test.yaml --echo", - "conditions": [HasReturnCode(), HasRegex(r"default_worker.*-Q step_4_queue")], + "conditions": [HasReturnCode(), HasRegex(r"default_worker.*-Q .*step_4_queue")], "run type": "local", }, "no default_worker assigned": { diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py index 68640401..8dde6745 100644 --- a/tests/unit/workers/handlers/test_celery_handler.py +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -174,7 +174,7 @@ def test_build_filters_with_queues_and_workers(self, handler: CeleryWorkerHandle filters = handler._build_filters(queues, workers) - assert filters == {"queues": ["queue1", "queue2"], "name": ["worker1", "worker2"]} + assert filters == {"queues": ["[merlin]_queue1", "[merlin]_queue2"], "name": ["worker1", "worker2"]} def test_build_filters_with_only_queues(self, handler: CeleryWorkerHandler): """ @@ -187,7 +187,7 @@ def test_build_filters_with_only_queues(self, handler: CeleryWorkerHandler): filters = handler._build_filters(queues, None) - assert filters == {"queues": ["queue1"]} + assert filters == {"queues": ["[merlin]_queue1"]} def test_build_filters_with_only_workers(self, handler: CeleryWorkerHandler): """ @@ -230,7 +230,7 @@ def test_query_workers_calls_database_and_formatter( handler.query_workers("rich", queues=["queue1"], workers=["worker1"]) # Verify database was called with correct filters - expected_filters = {"queues": ["queue1"], "name": ["worker1"]} + expected_filters = {"queues": ["[merlin]_queue1"], "name": ["worker1"]} handler.merlin_db.get_all.assert_called_once_with("logical_worker", filters=expected_filters) # Verify formatter was created and called @@ -308,7 +308,7 @@ def test_query_workers_passes_all_parameters_to_formatter( """ handler.merlin_db.get_all.return_value = mock_logical_workers - queues = ["queue1", "queue2"] + queues = ["[merlin]_queue1", "[merlin]_queue2"] workers = ["worker1"] handler.query_workers("rich", queues=queues, workers=workers) From 16d4e42ba8569d6e8c006e1b1211d9ed9d6658df Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Mon, 29 Sep 2025 11:47:42 -0700 Subject: [PATCH 80/91] run fix style --- merlin/workers/handlers/celery_handler.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index ca1bd3a0..b5f19a97 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -88,10 +88,7 @@ def _build_filters(self, queues: List[str], workers: List[str]) -> Dict[str, Lis """ filters = {} if queues: - filters["queues"] = [ - queue if queue.startswith("[merlin]_") else f"[merlin]_{queue}" - for queue in queues - ] + filters["queues"] = [queue if queue.startswith("[merlin]_") else f"[merlin]_{queue}" for queue in queues] if workers: filters["name"] = workers return filters From c22a9e706021126ad963875ef29615f3eaa34ee3 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 8 Oct 2025 18:14:38 -0700 Subject: [PATCH 81/91] fix issue with pid and use WorkerStatus enum properly --- merlin/celery.py | 2 +- merlin/common/enums.py | 8 +++---- merlin/db_scripts/data_models.py | 6 +++--- .../entities/physical_worker_entity.py | 21 ++++++++++++++----- merlin/workers/formatters/json_formatter.py | 2 +- merlin/workers/formatters/rich_formatter.py | 19 +++++++---------- merlin/workers/formatters/worker_formatter.py | 11 +++++----- 7 files changed, 38 insertions(+), 31 deletions(-) diff --git a/merlin/celery.py b/merlin/celery.py index 30e556a1..a4c9f8d5 100644 --- a/merlin/celery.py +++ b/merlin/celery.py @@ -214,7 +214,7 @@ def handle_worker_startup(sender: str = None, **kwargs): "physical_worker", name=str(sender), host=host, - status=WorkerStatus.RUNNING, + status=WorkerStatus.RUNNING.value, logical_worker_id=logical_worker.get_id(), pid=os.getpid(), ) diff --git a/merlin/common/enums.py b/merlin/common/enums.py index a201867b..a5782e2f 100644 --- a/merlin/common/enums.py +++ b/merlin/common/enums.py @@ -53,7 +53,7 @@ class WorkerStatus(Enum): REBOOTING (str): Indicates the worker is actively restarting itself. String value: "rebooting". """ - RUNNING = "running" - STALLED = "stalled" - STOPPED = "stopped" - REBOOTING = "rebooting" + RUNNING = "RUNNING" + STALLED = "STALLED" + STOPPED = "STOPPED" + REBOOTING = "REBOOTING" diff --git a/merlin/db_scripts/data_models.py b/merlin/db_scripts/data_models.py index 88aa9cbd..27c1669e 100644 --- a/merlin/db_scripts/data_models.py +++ b/merlin/db_scripts/data_models.py @@ -417,7 +417,7 @@ class PhysicalWorkerModel(BaseDataModel): # pylint: disable=too-many-instance-a name (str): The name of the physical worker. pid (str): The process ID (PID) of the worker process. restart_count (int): The number of times this worker has been restarted. - status (WorkerStatus): The current status of the worker (e.g., running, stopped). + status (str): The current status of the worker (e.g., RUNNING, STOPPED). """ id: str = field(default_factory=lambda: str(uuid.uuid4())) # pylint: disable=invalid-name @@ -425,8 +425,8 @@ class PhysicalWorkerModel(BaseDataModel): # pylint: disable=too-many-instance-a name: str = None # Will be of the form celery@worker_name.hostname launch_cmd: str = None args: Dict = field(default_factory=dict) - pid: str = None - status: WorkerStatus = WorkerStatus.STOPPED + pid: int = None + status: str = field(default=WorkerStatus.STOPPED.value) heartbeat_timestamp: datetime = field(default_factory=datetime.now) latest_start_time: datetime = field(default_factory=datetime.now) host: str = None diff --git a/merlin/db_scripts/entities/physical_worker_entity.py b/merlin/db_scripts/entities/physical_worker_entity.py index 18c8f7e2..5da18694 100644 --- a/merlin/db_scripts/entities/physical_worker_entity.py +++ b/merlin/db_scripts/entities/physical_worker_entity.py @@ -202,9 +202,18 @@ def get_pid(self) -> Optional[int]: The process ID for this worker or None if not set. """ self.reload_data() - return int(self.entity_info.pid) if self.entity_info.pid else None - - def set_pid(self, pid: str): + self.reload_data() + if not self.entity_info.pid: + return None + + # Handle both int strings and float strings + try: + # Convert to float first, then to int + return int(float(self.entity_info.pid)) + except (ValueError, TypeError): + return None + + def set_pid(self, pid: int): """ Set the PID of this worker. @@ -223,7 +232,8 @@ def get_status(self) -> WorkerStatus: the status of this worker. """ self.reload_data() - return self.entity_info.status + # Convert string value to enum + return WorkerStatus(self.entity_info.status) def set_status(self, status: WorkerStatus): """ @@ -233,7 +243,8 @@ def set_status(self, status: WorkerStatus): status: A [`WorkerStatus`][common.enums.WorkerStatus] enum representing the new status of the worker. """ - self.entity_info.status = status + # Store the string value + self.entity_info.status = status.value self.save() def get_heartbeat_timestamp(self) -> str: diff --git a/merlin/workers/formatters/json_formatter.py b/merlin/workers/formatters/json_formatter.py index 00bcc975..b4f42735 100644 --- a/merlin/workers/formatters/json_formatter.py +++ b/merlin/workers/formatters/json_formatter.py @@ -101,7 +101,7 @@ def format_and_display( "name": physical_worker.get_name(), "host": physical_worker.get_host(), "pid": physical_worker.get_pid(), - "status": str(physical_worker.get_status()).replace("WorkerStatus.", ""), + "status": physical_worker.get_status().value, "restart_count": physical_worker.get_restart_count(), "latest_start_time": ( physical_worker.get_latest_start_time().isoformat() diff --git a/merlin/workers/formatters/rich_formatter.py b/merlin/workers/formatters/rich_formatter.py index ef62a16f..6652d8ba 100644 --- a/merlin/workers/formatters/rich_formatter.py +++ b/merlin/workers/formatters/rich_formatter.py @@ -272,8 +272,6 @@ def _format_status(self, status: WorkerStatus) -> Text: Returns: A Rich Text object containing the styled status with an icon. """ - status_str = str(status).replace("WorkerStatus.", "") - status_config = { "RUNNING": ("✓", "bold green"), "STALLED": ("⚠", "bold yellow"), @@ -281,8 +279,8 @@ def _format_status(self, status: WorkerStatus) -> Text: "REBOOTING": ("↻", "bold cyan"), } - icon, color = status_config.get(status_str.upper(), ("?", "white")) - return Text(f"{icon} {status_str}", style=color) + icon, color = status_config.get(status.value, ("?", "white")) + return Text(f"{icon} {status.value}", style=color) class RichWorkerFormatter(WorkerFormatter): @@ -559,11 +557,10 @@ def _get_physical_worker_data(self, logical_workers: List[LogicalWorkerEntity], for physical_worker in physical_workers: status = physical_worker.get_status() - status_str = str(status).replace("WorkerStatus.", "") # Only show heartbeat for running workers heartbeat_text = "-" - if status_str == "RUNNING": + if status.value == "RUNNING": heartbeat_text = str(self._format_last_heartbeat(physical_worker.get_heartbeat_timestamp())) instance_name = physical_worker.get_name() or "-" @@ -575,11 +572,11 @@ def _get_physical_worker_data(self, logical_workers: List[LogicalWorkerEntity], "instance": instance_name, "host": physical_worker.get_host() or "-", "pid": str(physical_worker.get_pid()) if physical_worker.get_pid() else "-", - "status": status, # Raw status for formatter + "status": status, "runtime": self._format_uptime_or_downtime(physical_worker), "heartbeat": heartbeat_text, "restarts": str(physical_worker.get_restart_count()), - "_sort_status": status_str, # For sorting + "_sort_status": status.value, } ) @@ -657,8 +654,6 @@ def _format_status(self, status: WorkerStatus) -> Text: - "REBOOTING": ↻ cyan - Unknown: ? white """ - status_str = str(status).replace("WorkerStatus.", "") - status_config = { "RUNNING": ("✓", "bold green"), "STALLED": ("⚠", "bold yellow"), @@ -666,8 +661,8 @@ def _format_status(self, status: WorkerStatus) -> Text: "REBOOTING": ("↻", "bold cyan"), } - icon, color = status_config.get(status_str.upper(), ("?", "white")) - return Text(f"{icon} {status_str}", style=color) + icon, color = status_config.get(status.value, ("?", "white")) + return Text(f"{icon} {status.value}", style=color) def _format_uptime_or_downtime(self, physical_worker: PhysicalWorkerEntity) -> str: """ diff --git a/merlin/workers/formatters/worker_formatter.py b/merlin/workers/formatters/worker_formatter.py index 6a376f9e..e31cdf4f 100644 --- a/merlin/workers/formatters/worker_formatter.py +++ b/merlin/workers/formatters/worker_formatter.py @@ -25,6 +25,7 @@ from rich.console import Console +from merlin.common.enums import WorkerStatus from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity from merlin.db_scripts.merlin_db import MerlinDatabase @@ -119,15 +120,15 @@ def get_worker_statistics(self, logical_workers: List[LogicalWorkerEntity], merl for physical_worker in physical_workers: stats["total_physical"] += 1 - status = str(physical_worker.get_status()).replace("WorkerStatus.", "") + status = physical_worker.get_status() - if status == "RUNNING": + if status == WorkerStatus.RUNNING: stats["physical_running"] += 1 - elif status == "STOPPED": + elif status == WorkerStatus.STOPPED: stats["physical_stopped"] += 1 - elif status == "STALLED": + elif status == WorkerStatus.STALLED: stats["physical_stalled"] += 1 - elif status == "REBOOTING": + elif status == WorkerStatus.REBOOTING: stats["physical_rebooting"] += 1 else: stats["logical_without_instances"] += 1 From dd00638a230f03e6d91d4b7cd0c7715f11426893 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 8 Oct 2025 18:15:12 -0700 Subject: [PATCH 82/91] add functionality to compare db data against live celery data --- merlin/workers/handlers/celery_handler.py | 67 +++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index b5f19a97..c51d07d1 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -16,7 +16,11 @@ import logging from typing import Dict, List +from celery import Celery + +from merlin.common.enums import WorkerStatus from merlin.db_scripts.merlin_db import MerlinDatabase +from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity from merlin.workers import CeleryWorker from merlin.workers.formatters.formatter_factory import worker_formatter_factory from merlin.workers.handlers.worker_handler import MerlinWorkerHandler @@ -75,6 +79,40 @@ def stop_workers(self): Attempt to stop Celery workers. """ + def get_active_workers(self, app: Celery) -> Dict[str, List[str]]: + """ + Retrieve a mapping of active workers to their associated queues for a Celery application. + + This function serves as the inverse of + [`get_active_celery_queues()`][study.celeryadapter.get_active_celery_queues]. It constructs + a dictionary where each key is a worker's name and the corresponding value is a + list of queues that the worker is connected to. This allows for easy identification + of which queues are being handled by each worker. + + Args: + app: The Celery application instance. + + Returns: + A dictionary mapping active worker names to lists of queue names they are + attached to. If no active workers are found, an empty dictionary is returned. + """ + # Get the information we need from celery + i = app.control.inspect() + active_workers = i.active_queues() + if active_workers is None: + active_workers = {} + + # Build the mapping dictionary + worker_queue_map = {} + for worker, queues in active_workers.items(): + for queue in queues: + if worker in worker_queue_map: + worker_queue_map[worker].append(queue["name"]) + else: + worker_queue_map[worker] = [queue["name"]] + + return worker_queue_map + def _build_filters(self, queues: List[str], workers: List[str]) -> Dict[str, List[str]]: """ Build filters dictionary for database queries. @@ -92,6 +130,32 @@ def _build_filters(self, queues: List[str], workers: List[str]) -> Dict[str, Lis if workers: filters["name"] = workers return filters + + def _validate_worker_status(self, logical_workers: List[LogicalWorkerEntity]): + """ + Cross-check database state with live Celery workers. + Update status for workers that are actually dead but marked running. + + Args: + logical_workers: List of logical worker entities to validate. + """ + from merlin.celery import app + + # Get actual running workers from Celery + live_workers = self.get_active_workers(app) # Uses Celery inspection + + for logical_worker in logical_workers: + physical_ids = logical_worker.get_physical_workers() + for pid in physical_ids: + physical = self.merlin_db.get("physical_worker", pid) + + # If database says running but Celery doesn't know about it + if physical.get_status() == WorkerStatus.RUNNING: + worker_name = physical.get_name() + if worker_name not in live_workers: + # Mark as stalled in database + LOG.warning(f"Worker {worker_name} marked running but not found in Celery") + physical.set_status(WorkerStatus.STALLED) def query_workers(self, formatter: str, queues: List[str] = None, workers: List[str] = None): """ @@ -108,6 +172,9 @@ def query_workers(self, formatter: str, queues: List[str] = None, workers: List[ # Retrieve workers from database logical_workers = self.merlin_db.get_all("logical_worker", filters=filters) + # Validate/enrich with live Celery data + self._validate_worker_status(logical_workers) + # Use formatter to display the results formatter = worker_formatter_factory.create(formatter) formatter.format_and_display(logical_workers, filters, self.merlin_db) From c8f3c7b3f67de8e8cc027143911e4e814349beee Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Wed, 22 Oct 2025 17:15:51 -0700 Subject: [PATCH 83/91] run fix style and fix tests --- merlin/db_scripts/entities/physical_worker_entity.py | 2 +- merlin/workers/handlers/celery_handler.py | 8 ++++---- .../db_scripts/entities/test_physical_worker_entity.py | 2 +- tests/unit/db_scripts/test_data_models.py | 2 +- tests/unit/workers/formatters/test_rich_formatter.py | 6 +----- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/merlin/db_scripts/entities/physical_worker_entity.py b/merlin/db_scripts/entities/physical_worker_entity.py index 5da18694..2fd55770 100644 --- a/merlin/db_scripts/entities/physical_worker_entity.py +++ b/merlin/db_scripts/entities/physical_worker_entity.py @@ -205,7 +205,7 @@ def get_pid(self) -> Optional[int]: self.reload_data() if not self.entity_info.pid: return None - + # Handle both int strings and float strings try: # Convert to float first, then to int diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index c51d07d1..a4c86d9d 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -19,8 +19,8 @@ from celery import Celery from merlin.common.enums import WorkerStatus -from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity +from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.workers import CeleryWorker from merlin.workers.formatters.formatter_factory import worker_formatter_factory from merlin.workers.handlers.worker_handler import MerlinWorkerHandler @@ -130,7 +130,7 @@ def _build_filters(self, queues: List[str], workers: List[str]) -> Dict[str, Lis if workers: filters["name"] = workers return filters - + def _validate_worker_status(self, logical_workers: List[LogicalWorkerEntity]): """ Cross-check database state with live Celery workers. @@ -140,10 +140,10 @@ def _validate_worker_status(self, logical_workers: List[LogicalWorkerEntity]): logical_workers: List of logical worker entities to validate. """ from merlin.celery import app - + # Get actual running workers from Celery live_workers = self.get_active_workers(app) # Uses Celery inspection - + for logical_worker in logical_workers: physical_ids = logical_worker.get_physical_workers() for pid in physical_ids: diff --git a/tests/unit/db_scripts/entities/test_physical_worker_entity.py b/tests/unit/db_scripts/entities/test_physical_worker_entity.py index f5095a0d..1b9bbd1e 100644 --- a/tests/unit/db_scripts/entities/test_physical_worker_entity.py +++ b/tests/unit/db_scripts/entities/test_physical_worker_entity.py @@ -213,7 +213,7 @@ def test_set_status(self, worker_entity: PhysicalWorkerEntity, mock_backend: Mag """ new_status = WorkerStatus.STOPPED worker_entity.set_status(new_status) - assert worker_entity.entity_info.status == new_status + assert worker_entity.entity_info.status == new_status.value mock_backend.save.assert_called_once() def test_get_heartbeat_timestamp(self, worker_entity: PhysicalWorkerEntity, mock_model: MagicMock): diff --git a/tests/unit/db_scripts/test_data_models.py b/tests/unit/db_scripts/test_data_models.py index c92593d2..280051c8 100644 --- a/tests/unit/db_scripts/test_data_models.py +++ b/tests/unit/db_scripts/test_data_models.py @@ -512,7 +512,7 @@ def test_default_initialization(self): assert worker.launch_cmd is None assert worker.args == {} assert worker.pid is None - assert worker.status == WorkerStatus.STOPPED + assert worker.status == WorkerStatus.STOPPED.value assert isinstance(worker.heartbeat_timestamp, datetime) assert isinstance(worker.latest_start_time, datetime) assert worker.host is None diff --git a/tests/unit/workers/formatters/test_rich_formatter.py b/tests/unit/workers/formatters/test_rich_formatter.py index ff480342..2d6b00ce 100644 --- a/tests/unit/workers/formatters/test_rich_formatter.py +++ b/tests/unit/workers/formatters/test_rich_formatter.py @@ -285,7 +285,6 @@ def test_get_queues_str_sorts_queues(self, formatter: RichWorkerFormatter): (WorkerStatus.STOPPED, "✗", "red"), (WorkerStatus.STALLED, "⚠", "yellow"), (WorkerStatus.REBOOTING, "↻", "cyan"), - ("unknown", "?", "white"), ], ) def test_format_status( @@ -305,10 +304,7 @@ def test_format_status( assert expected_icon in str(formatted) assert expected_color in formatted.style - if isinstance(status, WorkerStatus): - assert status.name in str(formatted) - else: - assert status in str(formatted) + assert status.name in str(formatted) @pytest.mark.parametrize( "duration, expected", From be5be8c9195eb006ea2e093e54a9065b673a7a05 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 23 Oct 2025 12:35:15 -0700 Subject: [PATCH 84/91] move function to CeleryWorkerHandler and add/fix tests --- merlin/monitor/celery_monitor.py | 22 +- merlin/monitor/monitor.py | 2 +- merlin/router.py | 19 - merlin/study/celeryadapter.py | 135 ------- merlin/workers/handlers/celery_handler.py | 44 ++- merlin/workers/handlers/worker_handler.py | 14 +- tests/conftest.py | 36 ++ tests/unit/cli/commands/test_run_workers.py | 8 +- tests/unit/common/test_encryption.py | 41 ++- tests/unit/monitor/test_celery_monitor.py | 13 +- tests/unit/monitor/test_monitor_factory.py | 9 +- .../workers/handlers/test_celery_handler.py | 329 +++++++++++++++++- .../workers/handlers/test_worker_handler.py | 17 +- 13 files changed, 478 insertions(+), 211 deletions(-) diff --git a/merlin/monitor/celery_monitor.py b/merlin/monitor/celery_monitor.py index 0e11fee9..400b738c 100644 --- a/merlin/monitor/celery_monitor.py +++ b/merlin/monitor/celery_monitor.py @@ -23,10 +23,14 @@ import time from typing import List, Set +from celery import Celery + from merlin.db_scripts.entities.run_entity import RunEntity +from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.exceptions import NoWorkersException from merlin.monitor.task_server_monitor import TaskServerMonitor -from merlin.study.celeryadapter import get_workers_from_app, query_celery_queues +from merlin.study.celeryadapter import query_celery_queues +from merlin.workers.handlers.celery_handler import CeleryWorkerHandler LOG = logging.getLogger(__name__) @@ -38,6 +42,9 @@ class CeleryMonitor(TaskServerMonitor): for Celery task servers. This class provides methods to monitor Celery workers, tasks, and workflows. + Attributes: + worker_handler (CeleryWorkerHandler): The worker handler for managing Celery workers. + Methods: wait_for_workers: Wait for Celery workers to start up. check_workers_processing: Check if any Celery workers are still processing tasks. @@ -47,6 +54,16 @@ class CeleryMonitor(TaskServerMonitor): check_tasks: Checks the status of tasks in the Celery queues for a given workflow run. """ + def __init__(self, merlin_db: MerlinDatabase = None, app: Celery = None): + """ + Constructor for CeleryMonitor. + + Args: + merlin_db: The MerlinDatabase instance or None. + app: The Celery application instance or None. + """ + self.worker_handler: CeleryWorkerHandler = CeleryWorkerHandler(merlin_db=merlin_db, app=app) + def wait_for_workers(self, workers: List[str], sleep: int): """ Wait for Celery workers to start up. @@ -61,7 +78,7 @@ def wait_for_workers(self, workers: List[str], sleep: int): count = 0 max_count = 10 while count < max_count: - worker_status = get_workers_from_app() + worker_status = self.worker_handler.get_workers_from_app() LOG.debug(f"CeleryMonitor: checking for workers, running workers = {worker_status} ...") # Check if any of the desired workers have started @@ -114,6 +131,7 @@ def _restart_workers(self, workers: List[str]): except Exception as e: # pylint: disable=broad-exception-caught LOG.error(f"CeleryMonitor: Failed to restart worker '{worker}'. Error: {e}") + # TODO when we create worker watchdog process we may need a method like this in the CeleryWorkerHandler def _get_dead_workers(self, workers: List[str]) -> Set[str]: """ Identify unresponsive Celery workers from a given list. diff --git a/merlin/monitor/monitor.py b/merlin/monitor/monitor.py index 5a12b9c2..de40df8d 100644 --- a/merlin/monitor/monitor.py +++ b/merlin/monitor/monitor.py @@ -77,8 +77,8 @@ def __init__(self, spec: MerlinSpec, sleep: int, task_server: str, no_restart: b self.spec: MerlinSpec = spec self.sleep: int = sleep self.no_restart: bool = no_restart - self.task_server_monitor: TaskServerMonitor = monitor_factory.create(task_server) self.merlin_db = MerlinDatabase() + self.task_server_monitor: TaskServerMonitor = monitor_factory.create(task_server, {"merlin_db": self.merlin_db}) # Run garbage collection if enabled if auto_cleanup: diff --git a/merlin/router.py b/merlin/router.py index 48fdb653..2b8e376f 100644 --- a/merlin/router.py +++ b/merlin/router.py @@ -26,7 +26,6 @@ get_workers_from_app, purge_celery_tasks, query_celery_queues, - query_celery_workers, run_celery, stop_celery_workers, ) @@ -159,24 +158,6 @@ def query_queues( return {} -def query_workers(task_server: str, spec_worker_names: List[str], queues: List[str], workers_regex: str): - """ - Retrieves information from workers associated with the specified task server. - - Args: - task_server: The task server to query. - spec_worker_names: A list of specific worker names to query. - queues: A list of queues to search for associated workers. - workers_regex: A regex pattern used to filter worker names during the query. - """ - LOG.info("Searching for workers...") - - if task_server == "celery": - query_celery_workers(spec_worker_names, queues, workers_regex) - else: - LOG.error("Celery is not specified as the task server!") - - def get_workers(task_server: str) -> List[str]: """ This function queries the designated task server to obtain a list of all diff --git a/merlin/study/celeryadapter.py b/merlin/study/celeryadapter.py index 87a68978..c46df29f 100644 --- a/merlin/study/celeryadapter.py +++ b/merlin/study/celeryadapter.py @@ -152,41 +152,6 @@ def get_active_celery_queues(app: Celery) -> Tuple[Dict[str, List[str]], List[st return queues, [*active_workers] -def get_active_workers(app: Celery) -> Dict[str, List[str]]: - """ - Retrieve a mapping of active workers to their associated queues for a Celery application. - - This function serves as the inverse of - [`get_active_celery_queues()`][study.celeryadapter.get_active_celery_queues]. It constructs - a dictionary where each key is a worker's name and the corresponding value is a - list of queues that the worker is connected to. This allows for easy identification - of which queues are being handled by each worker. - - Args: - app: The Celery application instance. - - Returns: - A dictionary mapping active worker names to lists of queue names they are - attached to. If no active workers are found, an empty dictionary is returned. - """ - # Get the information we need from celery - i = app.control.inspect() - active_workers = i.active_queues() - if active_workers is None: - active_workers = {} - - # Build the mapping dictionary - worker_queue_map = {} - for worker, queues in active_workers.items(): - for queue in queues: - if worker in worker_queue_map: - worker_queue_map[worker].append(queue["name"]) - else: - worker_queue_map[worker] = [queue["name"]] - - return worker_queue_map - - def celerize_queues(queues: List[str], config: SimpleNamespace = None): """ Prepend a queue tag to each queue in the provided list to conform to Celery's @@ -208,106 +173,6 @@ def celerize_queues(queues: List[str], config: SimpleNamespace = None): queues[i] = f"{config.celery.queue_tag}{queue}" -def _build_output_table(worker_list: List[str], output_table: List[Tuple[str, str]]): - """ - Construct an output table for displaying the status of workers and their associated queues. - - This helper function populates the provided output table with entries for each worker - in the given worker list. It retrieves the mapping of active workers to their queues - and formats the data accordingly. - - Args: - worker_list: A list of worker names to be included in the output table. - output_table: A list of tuples where each entry will be of the form - (worker name, associated queues). - """ - from merlin.celery import app # pylint: disable=C0415 - - # Get a mapping between workers and the queues they're watching - worker_queue_map = get_active_workers(app) - - # Loop through the list of workers and add an entry in the table - # of the form (worker name, queues attached to this worker) - for worker in worker_list: - if "celery@" not in worker: - worker = f"celery@{worker}" - output_table.append((worker, ", ".join(worker_queue_map[worker]))) - - -def query_celery_workers(spec_worker_names: List[str], queues: List[str], workers_regex: List[str]): - """ - Query and filter existing Celery workers based on specified criteria, - and print a table of the workers along with their associated queues. - - This function retrieves the list of active Celery workers and filters them - according to the provided specifications, including worker names from a - spec file, specific queues, and regular expressions for worker names. - It then constructs and displays a table of the matching workers and their - associated queues. - - Args: - spec_worker_names: A list of worker names defined in a spec file - to filter the workers. - queues: A list of queues to filter the workers by. - workers_regex: A list of regular expressions to filter the worker names. - """ - from merlin.celery import app # pylint: disable=C0415 - - # Ping all workers and grab which ones are running - workers = get_workers_from_app() - if not workers: - LOG.warning("No workers found!") - return - - # Remove prepended celery tag while we filter - workers = [worker.replace("celery@", "") for worker in workers] - workers_to_query = [] - - # --queues flag - if queues: - # Get a mapping between queues and the workers watching them - queue_worker_map, _ = get_active_celery_queues(app) - # Remove duplicates and prepend the celery queue tag to all queues - queues = list(set(queues)) - celerize_queues(queues) - # Add the workers associated to each queue to the list of workers we're - # going to query - for queue in queues: - try: - workers_to_query.extend(queue_worker_map[queue]) - except KeyError: - LOG.warning(f"No workers connected to {queue}.") - - # --spec flag - if spec_worker_names: - apply_list_of_regex(spec_worker_names, workers, workers_to_query) - - # --workers flag - if workers_regex: - apply_list_of_regex(workers_regex, workers, workers_to_query) - - # Remove any potential duplicates - workers = set(workers) - workers_to_query = set(workers_to_query) - - # If there were filters and nothing was found then we can't display a table - if (queues or spec_worker_names or workers_regex) and not workers_to_query: - LOG.warning("No workers found that match your filters.") - return - - # Build the output table based on our filters - table = [] - if workers_to_query: - _build_output_table(workers_to_query, table) - else: - _build_output_table(workers, table) - - # Display the output table - LOG.info("Found these connected workers:") - print(tabulate(table, headers=["Workers", "Queues"])) - print() - - def build_csv_queue_info(query_return: List[Tuple[str, int, int]], date: str) -> Dict[str, List]: """ Construct a dictionary containing queue information and column labels diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index a4c86d9d..6225f8f0 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -38,16 +38,20 @@ class CeleryWorkerHandler(MerlinWorkerHandler): Celery-specific behavior, including launching workers with optional command-line overrides, stopping workers, and querying their status. + Attributes: + merlin_db (MerlinDatabase): The database instance used for worker management. + Methods: start_workers: Launch or echo Celery workers with optional arguments. stop_workers: Attempt to stop active Celery workers. query_workers: Return a basic summary of Celery worker status. """ - def __init__(self): - """ """ - super().__init__() - self.merlin_db = MerlinDatabase() + def __init__(self, merlin_db: MerlinDatabase = None, app: Celery = None): + super().__init__(merlin_db=merlin_db) + if app is None: + from merlin.celery import app # pylint: disable=import-outside-toplevel + self.app = app def start_workers(self, workers: List[CeleryWorker], **kwargs): """ @@ -79,7 +83,28 @@ def stop_workers(self): Attempt to stop Celery workers. """ - def get_active_workers(self, app: Celery) -> Dict[str, List[str]]: + def get_workers_from_app(self) -> List[str]: + """ + Retrieve a list of all workers connected to the Celery application. + + This method uses the Celery control interface to inspect the current state + of the application and returns a list of workers that are currently connected. + If no workers are found, an empty list is returned. + + Args: + app: The Celery application instance. + + Returns: + A list of worker names that are currently connected to the Celery application. + If no workers are connected, an empty list is returned. + """ + i = self.app.control.inspect() + workers = i.ping() + if workers is None: + return [] + return [*workers] + + def get_active_workers(self) -> Dict[str, List[str]]: """ Retrieve a mapping of active workers to their associated queues for a Celery application. @@ -89,15 +114,12 @@ def get_active_workers(self, app: Celery) -> Dict[str, List[str]]: list of queues that the worker is connected to. This allows for easy identification of which queues are being handled by each worker. - Args: - app: The Celery application instance. - Returns: A dictionary mapping active worker names to lists of queue names they are attached to. If no active workers are found, an empty dictionary is returned. """ # Get the information we need from celery - i = app.control.inspect() + i = self.app.control.inspect() active_workers = i.active_queues() if active_workers is None: active_workers = {} @@ -139,10 +161,8 @@ def _validate_worker_status(self, logical_workers: List[LogicalWorkerEntity]): Args: logical_workers: List of logical worker entities to validate. """ - from merlin.celery import app - # Get actual running workers from Celery - live_workers = self.get_active_workers(app) # Uses Celery inspection + live_workers = self.get_active_workers() # Uses Celery inspection for logical_worker in logical_workers: physical_ids = logical_worker.get_physical_workers() diff --git a/merlin/workers/handlers/worker_handler.py b/merlin/workers/handlers/worker_handler.py index a102c914..9ec9484e 100644 --- a/merlin/workers/handlers/worker_handler.py +++ b/merlin/workers/handlers/worker_handler.py @@ -15,6 +15,7 @@ from abc import ABC, abstractmethod from typing import List +from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.workers.worker import MerlinWorker @@ -25,14 +26,23 @@ class MerlinWorkerHandler(ABC): Subclasses must implement the methods to launch, stop, and query workers using a particular task server (e.g., Celery, Kafka, etc.). + Attributes: + merlin_db (MerlinDatabase): The database instance used for worker management. + Methods: start_workers: Launch a list of MerlinWorker instances with optional configuration. stop_workers: Stop running worker processes managed by this handler. query_workers: Query the status of running workers and return summary information. """ - def __init__(self): - """Initialize the worker handler.""" + def __init__(self, merlin_db: MerlinDatabase = None): + """ + Initialize the worker handler. + + Args: + merlin_db: The database instance used for worker management or None. + """ + self.merlin_db = merlin_db or MerlinDatabase() @abstractmethod def start_workers(self, workers: List[MerlinWorker], **kwargs): diff --git a/tests/conftest.py b/tests/conftest.py index 5a40e0cd..dee20bea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,11 +13,13 @@ from glob import glob from time import sleep from typing import Dict +from unittest.mock import MagicMock import pytest import yaml from _pytest.tmpdir import TempPathFactory from celery import Celery +from pytest_mock import MockerFixture from redis import Redis from merlin.config.configfile import CONFIG @@ -211,6 +213,40 @@ def merlin_server_dir(temp_output_dir: FixtureStr) -> FixtureStr: return server_dir +@pytest.fixture +def mock_db_class(mocker: MockerFixture) -> MagicMock: + """ + Mock MerlinDatabase globally for all tests. + + This fixture mocks MerlinDatabase at its source, so all imports + across the codebase will use this mock. + + Args: + mocker: Pytest mocker fixture. + + Returns: + A mocked MerlinDatabase class. + """ + mock_db_class = mocker.patch("merlin.db_scripts.merlin_db.MerlinDatabase", autospec=True) + return mock_db_class + + +@pytest.fixture +def mock_db_instance(mock_db_class: MagicMock) -> MagicMock: + """ + Returns a mocked instance of MerlinDatabase. + + Use this when you need an instance rather than the class itself. + + Args: + mock_db_class: The mocked MerlinDatabase class. + + Returns: + A mocked MerlinDatabase instance. + """ + return mock_db_class.return_value + + @pytest.fixture(scope="session") def redis_server(merlin_server_dir: FixtureStr, test_encryption_key: FixtureBytes) -> FixtureStr: """ diff --git a/tests/unit/cli/commands/test_run_workers.py b/tests/unit/cli/commands/test_run_workers.py index 247afc8e..33cbe22d 100644 --- a/tests/unit/cli/commands/test_run_workers.py +++ b/tests/unit/cli/commands/test_run_workers.py @@ -9,6 +9,7 @@ """ from argparse import Namespace +from unittest.mock import MagicMock from _pytest.capture import CaptureFixture from pytest_mock import MockerFixture @@ -78,13 +79,14 @@ def test_process_command_launches_workers(mocker: MockerFixture): mock_log.info.assert_called_once_with("Launching workers from 'workflow.yaml'") -def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, capsys: CaptureFixture): +def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, capsys: CaptureFixture, mock_db_instance: MagicMock): """ Test `process_command` prints the launch command and initializes config in echo-only mode. Args: mocker: PyTest mocker fixture. capsys: PyTest capsys fixture. + mock_db_instance: Mocked MerlinDatabase instance. """ mock_spec = mocker.Mock() mock_spec.get_workers_to_start.return_value = ["workerB"] @@ -97,8 +99,8 @@ def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, ca mocker.patch("merlin.cli.commands.run_workers.get_merlin_spec_with_override", return_value=(mock_spec, "file.yaml")) mocker.patch("merlin.cli.commands.run_workers.initialize_config") - mocker.patch("merlin.workers.handlers.celery_handler.MerlinDatabase") - mocker.patch("merlin.cli.commands.run_workers.worker_handler_factory.create", wraps=lambda _: CeleryWorkerHandler()) + mock_app = mocker.patch("merlin.celery.app") + mocker.patch("merlin.cli.commands.run_workers.worker_handler_factory.create", wraps=lambda _: CeleryWorkerHandler(merlin_db=mock_db_instance, app=mock_app)) args = Namespace( specification="spec.yaml", diff --git a/tests/unit/common/test_encryption.py b/tests/unit/common/test_encryption.py index 30aad87a..843672e0 100644 --- a/tests/unit/common/test_encryption.py +++ b/tests/unit/common/test_encryption.py @@ -10,8 +10,8 @@ import os -import celery import pytest +from pytest_mock import MockerFixture from merlin.common.security.encrypt import _gen_key, _get_key, _get_key_path, decrypt, encrypt from merlin.common.security.encrypt_backend_traffic import _decrypt_decode, _encrypt_encode, set_backend_funcs @@ -124,22 +124,31 @@ def test_get_key( with open(key_path, "w") as key_file: key_file.write(test_encryption_key.decode("utf-8")) - def test_set_backend_funcs(self): + def test_set_backend_funcs(self, mocker: MockerFixture): """ Test the `set_backend_funcs` function. + + Args: + mocker: Pytest mocker fixture. """ - orig_encode = celery.backends.base.Backend.encode - orig_decode = celery.backends.base.Backend.decode - - # Make sure these values haven't been set yet - assert celery.backends.base.Backend.encode != _encrypt_encode - assert celery.backends.base.Backend.decode != _decrypt_decode - + # Mock the Backend class to ensure clean state + mock_backend = mocker.patch("celery.backends.base.Backend") + + # Set up mock encode/decode attributes + mock_backend.encode = mocker.MagicMock() + mock_backend.decode = mocker.MagicMock() + + # Store original values + orig_encode = mock_backend.encode + orig_decode = mock_backend.decode + + # Call the function set_backend_funcs() - - # Ensure the new functions have been set - assert celery.backends.base.Backend.encode == _encrypt_encode - assert celery.backends.base.Backend.decode == _decrypt_decode - - celery.backends.base.Backend.encode = orig_encode - celery.backends.base.Backend.decode = orig_decode + + # Verify the functions were replaced + assert mock_backend.encode == _encrypt_encode + assert mock_backend.decode == _decrypt_decode + + # Verify they're different from the originals + assert mock_backend.encode != orig_encode + assert mock_backend.decode != orig_decode diff --git a/tests/unit/monitor/test_celery_monitor.py b/tests/unit/monitor/test_celery_monitor.py index b82d98de..5c803291 100644 --- a/tests/unit/monitor/test_celery_monitor.py +++ b/tests/unit/monitor/test_celery_monitor.py @@ -19,14 +19,19 @@ @pytest.fixture -def monitor() -> CeleryMonitor: +def monitor(mocker: MockerFixture, mock_db_instance: MagicMock) -> CeleryMonitor: """ Fixture to provide a CeleryMonitor instance. + Args: + mocker: Pytest mocker fixture. + mock_db_instance: Mocked MerlinDatabase instance. + Returns: An instance of the `CeleryMonitor` object. """ - return CeleryMonitor() + mock_app = mocker.patch("merlin.celery.Celery") + return CeleryMonitor(merlin_db=mock_db_instance, app=mock_app) def test_wait_for_workers_success(mocker: MockerFixture, monitor: CeleryMonitor): @@ -37,7 +42,7 @@ def test_wait_for_workers_success(mocker: MockerFixture, monitor: CeleryMonitor) mocker: PyTest mocker fixture. monitor: An instance of the `CeleryMonitor` object. """ - mock_get_workers = mocker.patch("merlin.monitor.celery_monitor.get_workers_from_app", return_value=["worker1@node"]) + mock_get_workers = monitor.worker_handler.get_workers_from_app = MagicMock(return_value=["worker1@node"]) monitor.wait_for_workers(["worker1"], sleep=1) @@ -52,7 +57,7 @@ def test_wait_for_workers_timeout(mocker: MockerFixture, monitor: CeleryMonitor) mocker: PyTest mocker fixture. monitor: An instance of the `CeleryMonitor` object. """ - mocker.patch("merlin.monitor.celery_monitor.get_workers_from_app", return_value=[]) + monitor.worker_handler.get_workers_from_app = MagicMock(return_value=[]) mocker.patch("time.sleep") with pytest.raises(NoWorkersException): diff --git a/tests/unit/monitor/test_monitor_factory.py b/tests/unit/monitor/test_monitor_factory.py index ebd4934f..e995281c 100644 --- a/tests/unit/monitor/test_monitor_factory.py +++ b/tests/unit/monitor/test_monitor_factory.py @@ -8,7 +8,10 @@ Tests for the `monitor_factory.py` module. """ +from unittest.mock import MagicMock + import pytest +from pytest_mock import MockerFixture from merlin.exceptions import MerlinInvalidTaskServerError from merlin.monitor.celery_monitor import CeleryMonitor @@ -94,14 +97,16 @@ def test_list_available_monitors(self, monitor_factory: MonitorFactory): assert "celery" in available assert len(available) == 1 - def test_create_valid_monitor(self, monitor_factory: MonitorFactory): + def test_create_valid_monitor(self, mocker: MockerFixture, monitor_factory: MonitorFactory, mock_db_instance: MagicMock): """ Test that `create` instantiates a monitor for a valid task server. Args: + mocker: Pytest mocker fixture. monitor_factory: Instance of `MonitorFactory` for testing. + mock_db_instance: Mocked MerlinDatabase instance. """ - monitor = monitor_factory.create("celery") + monitor = monitor_factory.create("celery", {"merlin_db": mock_db_instance}) assert isinstance(monitor, CeleryMonitor) def test_create_invalid_monitor_raises(self, monitor_factory: MonitorFactory): diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py index 8dde6745..364a65c2 100644 --- a/tests/unit/workers/handlers/test_celery_handler.py +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -14,6 +14,7 @@ import pytest from pytest_mock import MockerFixture +from merlin.common.enums import WorkerStatus from merlin.workers.celery_worker import CeleryWorker from merlin.workers.handlers import CeleryWorkerHandler @@ -43,18 +44,19 @@ class TestCeleryWorkerHandler: """ @pytest.fixture - def handler(self, mocker: MockerFixture) -> CeleryWorkerHandler: + def handler(self, mocker: MockerFixture, mock_db_instance: MagicMock) -> CeleryWorkerHandler: """ Create a CeleryWorkerHandler instance with mocked database. Args: mocker: Pytest mocker fixture. + mock_db_instance: Mocked MerlinDatabase instance. Returns: CeleryWorkerHandler instance with mocked dependencies. """ - mocker.patch("merlin.workers.handlers.celery_handler.MerlinDatabase") - return CeleryWorkerHandler() + mock_app = mocker.patch("merlin.celery.app") + return CeleryWorkerHandler(merlin_db=mock_db_instance, app=mock_app) @pytest.fixture def mock_db(self, mocker: MockerFixture) -> MagicMock: @@ -214,7 +216,7 @@ def test_build_filters_with_no_parameters(self, handler: CeleryWorkerHandler): assert filters == {} def test_query_workers_calls_database_and_formatter( - self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock + self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock, mocker: MockerFixture ): """ Test that `query_workers` retrieves data from database and calls formatter. @@ -223,9 +225,13 @@ def test_query_workers_calls_database_and_formatter( handler: CeleryWorkerHandler instance. mock_logical_workers: Mock logical worker entities. mock_formatter: Mock formatter instance. + mocker: Pytest mocker fixture. """ # Mock the database get_all method handler.merlin_db.get_all.return_value = mock_logical_workers + + # Mock the validation method to avoid Celery inspection + mocker.patch.object(handler, "_validate_worker_status") handler.query_workers("rich", queues=["queue1"], workers=["worker1"]) @@ -237,7 +243,7 @@ def test_query_workers_calls_database_and_formatter( mock_formatter.format_and_display.assert_called_once_with(mock_logical_workers, expected_filters, handler.merlin_db) def test_query_workers_with_no_filters( - self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock + self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock, mocker: MockerFixture ): """ Test that `query_workers` works correctly when no filters are provided. @@ -246,8 +252,12 @@ def test_query_workers_with_no_filters( handler: CeleryWorkerHandler instance. mock_logical_workers: Mock logical worker entities. mock_formatter: Mock formatter instance. + mocker: Pytest mocker fixture. """ handler.merlin_db.get_all.return_value = mock_logical_workers + + # Mock the validation method to avoid Celery inspection + mocker.patch.object(handler, "_validate_worker_status") handler.query_workers("json") @@ -269,6 +279,9 @@ def test_query_workers_uses_correct_formatter( mocker: Pytest mocker fixture. """ handler.merlin_db.get_all.return_value = mock_logical_workers + + # Mock the validation method to avoid Celery inspection + mocker.patch.object(handler, "_validate_worker_status") mock_factory = mocker.patch("merlin.workers.handlers.celery_handler.worker_formatter_factory") mock_formatter = MagicMock() @@ -280,15 +293,19 @@ def test_query_workers_uses_correct_formatter( mock_factory.create.assert_called_once_with("json") mock_formatter.format_and_display.assert_called_once() - def test_query_workers_handles_empty_results(self, handler: CeleryWorkerHandler, mock_formatter: MagicMock): + def test_query_workers_handles_empty_results(self, handler: CeleryWorkerHandler, mock_formatter: MagicMock, mocker: MockerFixture): """ Test that `query_workers` handles empty database results gracefully. Args: handler: CeleryWorkerHandler instance. mock_formatter: Mock formatter instance. + mocker: Pytest mocker fixture. """ handler.merlin_db.get_all.return_value = [] + + # Mock the validation method to avoid Celery inspection + mocker.patch.object(handler, "_validate_worker_status") handler.query_workers("rich") @@ -296,7 +313,7 @@ def test_query_workers_handles_empty_results(self, handler: CeleryWorkerHandler, mock_formatter.format_and_display.assert_called_once_with([], {}, handler.merlin_db) def test_query_workers_passes_all_parameters_to_formatter( - self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock + self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock, mocker: MockerFixture ): """ Test that `query_workers` passes all necessary parameters to formatter. @@ -305,8 +322,12 @@ def test_query_workers_passes_all_parameters_to_formatter( handler: CeleryWorkerHandler instance. mock_logical_workers: Mock logical worker entities. mock_formatter: Mock formatter instance. + mocker: Pytest mocker fixture. """ handler.merlin_db.get_all.return_value = mock_logical_workers + + # Mock the validation method to avoid Celery inspection + mocker.patch.object(handler, "_validate_worker_status") queues = ["[merlin]_queue1", "[merlin]_queue2"] workers = ["worker1"] @@ -317,3 +338,297 @@ def test_query_workers_passes_all_parameters_to_formatter( # Verify all parameters are passed correctly mock_formatter.format_and_display.assert_called_once_with(mock_logical_workers, expected_filters, handler.merlin_db) + + def test_get_active_workers_returns_worker_queue_mapping(self, handler: CeleryWorkerHandler): + """ + Test that `get_active_workers` correctly maps workers to their queues. + + Args: + handler: CeleryWorkerHandler instance. + """ + # Mock Celery app and inspection + mock_inspect = MagicMock() + handler.app.control.inspect.return_value = mock_inspect + + # Mock active queues response + mock_inspect.active_queues.return_value = { + "celery@worker1": [ + {"name": "[merlin]_queue1"}, + {"name": "[merlin]_queue2"} + ], + "celery@worker2": [ + {"name": "[merlin]_queue1"} + ] + } + + result = handler.get_active_workers() + + expected = { + "celery@worker1": ["[merlin]_queue1", "[merlin]_queue2"], + "celery@worker2": ["[merlin]_queue1"] + } + assert result == expected + + def test_get_active_workers_handles_no_active_workers(self, handler: CeleryWorkerHandler): + """ + Test that `get_active_workers` handles case when no workers are active. + + Args: + handler: CeleryWorkerHandler instance. + """ + # Mock Celery app and inspection + mock_inspect = MagicMock() + handler.app.control.inspect.return_value = mock_inspect + + # Mock empty response + mock_inspect.active_queues.return_value = None + + result = handler.get_active_workers() + + assert result == {} + + def test_get_active_workers_handles_empty_worker_dict(self, handler: CeleryWorkerHandler): + """ + Test that `get_active_workers` handles empty worker dictionary. + + Args: + handler: CeleryWorkerHandler instance. + """ + # Mock Celery app and inspection + mock_inspect = MagicMock() + handler.app.control.inspect.return_value = mock_inspect + + # Mock empty dictionary response + mock_inspect.active_queues.return_value = {} + + result = handler.get_active_workers() + + assert result == {} + + def test_validate_worker_status_marks_dead_workers_as_stalled( + self, handler: CeleryWorkerHandler, mocker: MockerFixture + ): + """ + Test that `_validate_worker_status` marks workers as stalled when not found in Celery. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + # # Mock Celery app import + # mock_app = MagicMock() + # mocker.patch("merlin.workers.handlers.celery_handler.app", mock_app) + + # Mock get_active_workers to return empty dict (no live workers) + mocker.patch.object(handler, "get_active_workers", return_value={}) + + # Create mock physical worker that's marked as RUNNING + mock_physical = MagicMock() + mock_physical.get_status.return_value = WorkerStatus.RUNNING + mock_physical.get_name.return_value = "celery@dead_worker" + + # Create mock logical worker + mock_logical = MagicMock() + mock_logical.get_physical_workers.return_value = ["physical_id_1"] + + # Mock database get method + handler.merlin_db.get.return_value = mock_physical + + handler._validate_worker_status([mock_logical]) + + # Verify status was set to STALLED + mock_physical.set_status.assert_called_once() + + def test_validate_worker_status_leaves_running_workers_unchanged( + self, handler: CeleryWorkerHandler, mocker: MockerFixture + ): + """ + Test that `_validate_worker_status` doesn't change status of workers found in Celery. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + # # Mock Celery app import + # mock_app = MagicMock() + # mocker.patch("merlin.workers.handlers.celery_handler.app", mock_app) + + # Mock get_active_workers to return live worker + mocker.patch.object(handler, "get_active_workers", return_value={"celery@live_worker": ["[merlin]_queue1"]}) + + # Create mock physical worker that's marked as RUNNING + mock_physical = MagicMock() + mock_physical.get_status.return_value = WorkerStatus.RUNNING + mock_physical.get_name.return_value = "celery@live_worker" + + # Create mock logical worker + mock_logical = MagicMock() + mock_logical.get_physical_workers.return_value = ["physical_id_1"] + + # Mock database get method + handler.merlin_db.get.return_value = mock_physical + + handler._validate_worker_status([mock_logical]) + + # Verify status was NOT changed + mock_physical.set_status.assert_not_called() + + def test_validate_worker_status_ignores_stopped_workers( + self, handler: CeleryWorkerHandler, mocker: MockerFixture + ): + """ + Test that `_validate_worker_status` doesn't check workers already marked as stopped. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + # # Mock Celery app import + # mock_app = MagicMock() + # mocker.patch("merlin.workers.handlers.celery_handler.app", mock_app) + + # Mock get_active_workers (doesn't matter what it returns) + mocker.patch.object(handler, "get_active_workers", return_value={}) + + # Create mock physical worker that's marked as STOPPED + mock_physical = MagicMock() + mock_physical.get_status.return_value = WorkerStatus.STOPPED + mock_physical.get_name.return_value = "celery@stopped_worker" + + # Create mock logical worker + mock_logical = MagicMock() + mock_logical.get_physical_workers.return_value = ["physical_id_1"] + + # Mock database get method + handler.merlin_db.get.return_value = mock_physical + + handler._validate_worker_status([mock_logical]) + + # Verify status was NOT changed (worker already stopped) + mock_physical.set_status.assert_not_called() + + def test_validate_worker_status_handles_multiple_physical_workers( + self, handler: CeleryWorkerHandler, mocker: MockerFixture + ): + """ + Test that `_validate_worker_status` validates all physical workers for a logical worker. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + # # Mock Celery app import + # mock_app = MagicMock() + # mocker.patch("merlin.workers.handlers.celery_handler.app", mock_app) + + # Mock get_active_workers - only worker1 is live + mocker.patch.object(handler, "get_active_workers", return_value={"celery@worker1": ["[merlin]_queue1"]}) + + # Create mock physical workers + mock_physical1 = MagicMock() + mock_physical1.get_status.return_value = WorkerStatus.RUNNING + mock_physical1.get_name.return_value = "celery@worker1" + + mock_physical2 = MagicMock() + mock_physical2.get_status.return_value = WorkerStatus.RUNNING + mock_physical2.get_name.return_value = "celery@worker2" + + # Create mock logical worker with multiple physical workers + mock_logical = MagicMock() + mock_logical.get_physical_workers.return_value = ["physical_id_1", "physical_id_2"] + + # Mock database get method to return different workers + handler.merlin_db.get.side_effect = [mock_physical1, mock_physical2] + + handler._validate_worker_status([mock_logical]) + + # Verify worker1 status was NOT changed (it's live) + mock_physical1.set_status.assert_not_called() + + # Verify worker2 status WAS changed (it's not live) + mock_physical2.set_status.assert_called_once() + + def test_get_workers_from_app_returns_worker_list(self, handler: CeleryWorkerHandler): + """ + Test that `get_workers_from_app` returns a list of connected workers. + + Args: + handler: CeleryWorkerHandler instance. + """ + # Mock Celery app inspection + mock_inspect = MagicMock() + handler.app.control.inspect.return_value = mock_inspect + + # Mock ping response with worker names as dict keys + mock_inspect.ping.return_value = { + "celery@worker1": {"ok": "pong"}, + "celery@worker2": {"ok": "pong"}, + "celery@worker3": {"ok": "pong"} + } + + result = handler.get_workers_from_app() + + expected = ["celery@worker1", "celery@worker2", "celery@worker3"] + assert sorted(result) == sorted(expected) + + def test_get_workers_from_app_handles_no_workers(self, handler: CeleryWorkerHandler): + """ + Test that `get_workers_from_app` returns empty list when no workers are connected. + + Args: + handler: CeleryWorkerHandler instance. + """ + # Mock Celery app inspection + mock_inspect = MagicMock() + handler.app.control.inspect.return_value = mock_inspect + + # Mock ping returning None (no workers) + mock_inspect.ping.return_value = None + + result = handler.get_workers_from_app() + + assert result == [] + + def test_get_workers_from_app_handles_empty_worker_dict(self, handler: CeleryWorkerHandler): + """ + Test that `get_workers_from_app` returns empty list when ping returns empty dict. + + Args: + handler: CeleryWorkerHandler instance. + """ + # Mock Celery app inspection + mock_inspect = MagicMock() + handler.app.control.inspect.return_value = mock_inspect + + # Mock ping returning empty dict + mock_inspect.ping.return_value = {} + + result = handler.get_workers_from_app() + + assert result == [] + + def test_get_workers_from_app_preserves_worker_names(self, handler: CeleryWorkerHandler): + """ + Test that `get_workers_from_app` preserves exact worker names from Celery. + + Args: + handler: CeleryWorkerHandler instance. + """ + # Mock Celery app inspection + mock_inspect = MagicMock() + handler.app.control.inspect.return_value = mock_inspect + + # Mock ping response with various worker name formats + mock_inspect.ping.return_value = { + "celery@worker1.hostname.com": {"ok": "pong"}, + "celery@worker2": {"ok": "pong"}, + "worker3@localhost": {"ok": "pong"} + } + + result = handler.get_workers_from_app() + + # Verify all names are preserved exactly + assert "celery@worker1.hostname.com" in result + assert "celery@worker2" in result + assert "worker3@localhost" in result + assert len(result) == 3 diff --git a/tests/unit/workers/handlers/test_worker_handler.py b/tests/unit/workers/handlers/test_worker_handler.py index b89b2bc6..7bd56607 100644 --- a/tests/unit/workers/handlers/test_worker_handler.py +++ b/tests/unit/workers/handlers/test_worker_handler.py @@ -9,6 +9,7 @@ """ from typing import Any, Dict, List +from unittest.mock import MagicMock import pytest @@ -28,8 +29,8 @@ def get_metadata(self) -> Dict: class DummyWorkerHandler(MerlinWorkerHandler): - def __init__(self): - super().__init__() + def __init__(self, merlin_db: MagicMock): + super().__init__(merlin_db=merlin_db) self.started = False self.stopped = False self.queried = False @@ -69,11 +70,11 @@ class IncompleteHandler(MerlinWorkerHandler): IncompleteHandler() -def test_launch_workers_calls_worker_launch(): +def test_launch_workers_calls_worker_launch(mock_db_instance: MagicMock): """ Test that `start_workers` calls each worker's `start` method. """ - handler = DummyWorkerHandler() + handler = DummyWorkerHandler(merlin_db=mock_db_instance) workers = [DummyWorker("w1", {}, {}), DummyWorker("w2", {}, {})] result = handler.start_workers(workers) @@ -82,22 +83,22 @@ def test_launch_workers_calls_worker_launch(): assert result == ["launched", "launched"] -def test_stop_workers_sets_flag(): +def test_stop_workers_sets_flag(mock_db_instance: MagicMock): """ Test that `stop_workers` sets the internal state and returns expected value. """ - handler = DummyWorkerHandler() + handler = DummyWorkerHandler(merlin_db=mock_db_instance) response = handler.stop_workers() assert handler.stopped assert response == "Stopped all workers" -def test_query_workers_returns_summary(): +def test_query_workers_returns_summary(mock_db_instance: MagicMock): """ Test that `query_workers` returns a valid summary of current worker state. """ - handler = DummyWorkerHandler() + handler = DummyWorkerHandler(merlin_db=mock_db_instance) workers = [DummyWorker("a", {}, {}), DummyWorker("b", {}, {})] handler.start_workers(workers) From 3a42d0a31ba0726845fff3dd6c3d58205b972875 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 23 Oct 2025 12:37:27 -0700 Subject: [PATCH 85/91] run fix-style --- merlin/study/celeryadapter.py | 1 - merlin/workers/handlers/worker_handler.py | 2 +- tests/conftest.py | 12 +- tests/unit/cli/commands/test_run_workers.py | 9 +- tests/unit/common/test_encryption.py | 12 +- .../workers/handlers/test_celery_handler.py | 124 +++++++++--------- 6 files changed, 83 insertions(+), 77 deletions(-) diff --git a/merlin/study/celeryadapter.py b/merlin/study/celeryadapter.py index c46df29f..c1c1ea1e 100644 --- a/merlin/study/celeryadapter.py +++ b/merlin/study/celeryadapter.py @@ -15,7 +15,6 @@ from amqp.exceptions import ChannelError from celery import Celery -from tabulate import tabulate from merlin.common.dumper import dump_handler from merlin.config import Config diff --git a/merlin/workers/handlers/worker_handler.py b/merlin/workers/handlers/worker_handler.py index 9ec9484e..c9ce4929 100644 --- a/merlin/workers/handlers/worker_handler.py +++ b/merlin/workers/handlers/worker_handler.py @@ -38,7 +38,7 @@ class MerlinWorkerHandler(ABC): def __init__(self, merlin_db: MerlinDatabase = None): """ Initialize the worker handler. - + Args: merlin_db: The database instance used for worker management or None. """ diff --git a/tests/conftest.py b/tests/conftest.py index dee20bea..e6131154 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -217,13 +217,13 @@ def merlin_server_dir(temp_output_dir: FixtureStr) -> FixtureStr: def mock_db_class(mocker: MockerFixture) -> MagicMock: """ Mock MerlinDatabase globally for all tests. - + This fixture mocks MerlinDatabase at its source, so all imports across the codebase will use this mock. - + Args: mocker: Pytest mocker fixture. - + Returns: A mocked MerlinDatabase class. """ @@ -235,12 +235,12 @@ def mock_db_class(mocker: MockerFixture) -> MagicMock: def mock_db_instance(mock_db_class: MagicMock) -> MagicMock: """ Returns a mocked instance of MerlinDatabase. - + Use this when you need an instance rather than the class itself. - + Args: mock_db_class: The mocked MerlinDatabase class. - + Returns: A mocked MerlinDatabase instance. """ diff --git a/tests/unit/cli/commands/test_run_workers.py b/tests/unit/cli/commands/test_run_workers.py index 33cbe22d..9d3d8b93 100644 --- a/tests/unit/cli/commands/test_run_workers.py +++ b/tests/unit/cli/commands/test_run_workers.py @@ -79,7 +79,9 @@ def test_process_command_launches_workers(mocker: MockerFixture): mock_log.info.assert_called_once_with("Launching workers from 'workflow.yaml'") -def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, capsys: CaptureFixture, mock_db_instance: MagicMock): +def test_process_command_echo_only_mode_prints_command( + mocker: MockerFixture, capsys: CaptureFixture, mock_db_instance: MagicMock +): """ Test `process_command` prints the launch command and initializes config in echo-only mode. @@ -100,7 +102,10 @@ def test_process_command_echo_only_mode_prints_command(mocker: MockerFixture, ca mocker.patch("merlin.cli.commands.run_workers.get_merlin_spec_with_override", return_value=(mock_spec, "file.yaml")) mocker.patch("merlin.cli.commands.run_workers.initialize_config") mock_app = mocker.patch("merlin.celery.app") - mocker.patch("merlin.cli.commands.run_workers.worker_handler_factory.create", wraps=lambda _: CeleryWorkerHandler(merlin_db=mock_db_instance, app=mock_app)) + mocker.patch( + "merlin.cli.commands.run_workers.worker_handler_factory.create", + wraps=lambda _: CeleryWorkerHandler(merlin_db=mock_db_instance, app=mock_app), + ) args = Namespace( specification="spec.yaml", diff --git a/tests/unit/common/test_encryption.py b/tests/unit/common/test_encryption.py index 843672e0..6ac8d329 100644 --- a/tests/unit/common/test_encryption.py +++ b/tests/unit/common/test_encryption.py @@ -127,28 +127,28 @@ def test_get_key( def test_set_backend_funcs(self, mocker: MockerFixture): """ Test the `set_backend_funcs` function. - + Args: mocker: Pytest mocker fixture. """ # Mock the Backend class to ensure clean state mock_backend = mocker.patch("celery.backends.base.Backend") - + # Set up mock encode/decode attributes mock_backend.encode = mocker.MagicMock() mock_backend.decode = mocker.MagicMock() - + # Store original values orig_encode = mock_backend.encode orig_decode = mock_backend.decode - + # Call the function set_backend_funcs() - + # Verify the functions were replaced assert mock_backend.encode == _encrypt_encode assert mock_backend.decode == _decrypt_decode - + # Verify they're different from the originals assert mock_backend.encode != orig_encode assert mock_backend.decode != orig_decode diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py index 364a65c2..7c13f082 100644 --- a/tests/unit/workers/handlers/test_celery_handler.py +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -216,7 +216,11 @@ def test_build_filters_with_no_parameters(self, handler: CeleryWorkerHandler): assert filters == {} def test_query_workers_calls_database_and_formatter( - self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock, mocker: MockerFixture + self, + handler: CeleryWorkerHandler, + mock_logical_workers: List[MagicMock], + mock_formatter: MagicMock, + mocker: MockerFixture, ): """ Test that `query_workers` retrieves data from database and calls formatter. @@ -229,7 +233,7 @@ def test_query_workers_calls_database_and_formatter( """ # Mock the database get_all method handler.merlin_db.get_all.return_value = mock_logical_workers - + # Mock the validation method to avoid Celery inspection mocker.patch.object(handler, "_validate_worker_status") @@ -243,7 +247,11 @@ def test_query_workers_calls_database_and_formatter( mock_formatter.format_and_display.assert_called_once_with(mock_logical_workers, expected_filters, handler.merlin_db) def test_query_workers_with_no_filters( - self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock, mocker: MockerFixture + self, + handler: CeleryWorkerHandler, + mock_logical_workers: List[MagicMock], + mock_formatter: MagicMock, + mocker: MockerFixture, ): """ Test that `query_workers` works correctly when no filters are provided. @@ -255,7 +263,7 @@ def test_query_workers_with_no_filters( mocker: Pytest mocker fixture. """ handler.merlin_db.get_all.return_value = mock_logical_workers - + # Mock the validation method to avoid Celery inspection mocker.patch.object(handler, "_validate_worker_status") @@ -279,7 +287,7 @@ def test_query_workers_uses_correct_formatter( mocker: Pytest mocker fixture. """ handler.merlin_db.get_all.return_value = mock_logical_workers - + # Mock the validation method to avoid Celery inspection mocker.patch.object(handler, "_validate_worker_status") @@ -293,7 +301,9 @@ def test_query_workers_uses_correct_formatter( mock_factory.create.assert_called_once_with("json") mock_formatter.format_and_display.assert_called_once() - def test_query_workers_handles_empty_results(self, handler: CeleryWorkerHandler, mock_formatter: MagicMock, mocker: MockerFixture): + def test_query_workers_handles_empty_results( + self, handler: CeleryWorkerHandler, mock_formatter: MagicMock, mocker: MockerFixture + ): """ Test that `query_workers` handles empty database results gracefully. @@ -303,7 +313,7 @@ def test_query_workers_handles_empty_results(self, handler: CeleryWorkerHandler, mocker: Pytest mocker fixture. """ handler.merlin_db.get_all.return_value = [] - + # Mock the validation method to avoid Celery inspection mocker.patch.object(handler, "_validate_worker_status") @@ -313,7 +323,11 @@ def test_query_workers_handles_empty_results(self, handler: CeleryWorkerHandler, mock_formatter.format_and_display.assert_called_once_with([], {}, handler.merlin_db) def test_query_workers_passes_all_parameters_to_formatter( - self, handler: CeleryWorkerHandler, mock_logical_workers: List[MagicMock], mock_formatter: MagicMock, mocker: MockerFixture + self, + handler: CeleryWorkerHandler, + mock_logical_workers: List[MagicMock], + mock_formatter: MagicMock, + mocker: MockerFixture, ): """ Test that `query_workers` passes all necessary parameters to formatter. @@ -325,7 +339,7 @@ def test_query_workers_passes_all_parameters_to_formatter( mocker: Pytest mocker fixture. """ handler.merlin_db.get_all.return_value = mock_logical_workers - + # Mock the validation method to avoid Celery inspection mocker.patch.object(handler, "_validate_worker_status") @@ -352,21 +366,13 @@ def test_get_active_workers_returns_worker_queue_mapping(self, handler: CeleryWo # Mock active queues response mock_inspect.active_queues.return_value = { - "celery@worker1": [ - {"name": "[merlin]_queue1"}, - {"name": "[merlin]_queue2"} - ], - "celery@worker2": [ - {"name": "[merlin]_queue1"} - ] + "celery@worker1": [{"name": "[merlin]_queue1"}, {"name": "[merlin]_queue2"}], + "celery@worker2": [{"name": "[merlin]_queue1"}], } result = handler.get_active_workers() - expected = { - "celery@worker1": ["[merlin]_queue1", "[merlin]_queue2"], - "celery@worker2": ["[merlin]_queue1"] - } + expected = {"celery@worker1": ["[merlin]_queue1", "[merlin]_queue2"], "celery@worker2": ["[merlin]_queue1"]} assert result == expected def test_get_active_workers_handles_no_active_workers(self, handler: CeleryWorkerHandler): @@ -405,9 +411,7 @@ def test_get_active_workers_handles_empty_worker_dict(self, handler: CeleryWorke assert result == {} - def test_validate_worker_status_marks_dead_workers_as_stalled( - self, handler: CeleryWorkerHandler, mocker: MockerFixture - ): + def test_validate_worker_status_marks_dead_workers_as_stalled(self, handler: CeleryWorkerHandler, mocker: MockerFixture): """ Test that `_validate_worker_status` marks workers as stalled when not found in Celery. @@ -418,24 +422,24 @@ def test_validate_worker_status_marks_dead_workers_as_stalled( # # Mock Celery app import # mock_app = MagicMock() # mocker.patch("merlin.workers.handlers.celery_handler.app", mock_app) - + # Mock get_active_workers to return empty dict (no live workers) mocker.patch.object(handler, "get_active_workers", return_value={}) - + # Create mock physical worker that's marked as RUNNING mock_physical = MagicMock() mock_physical.get_status.return_value = WorkerStatus.RUNNING mock_physical.get_name.return_value = "celery@dead_worker" - + # Create mock logical worker mock_logical = MagicMock() mock_logical.get_physical_workers.return_value = ["physical_id_1"] - + # Mock database get method handler.merlin_db.get.return_value = mock_physical - + handler._validate_worker_status([mock_logical]) - + # Verify status was set to STALLED mock_physical.set_status.assert_called_once() @@ -448,62 +452,60 @@ def test_validate_worker_status_leaves_running_workers_unchanged( Args: handler: CeleryWorkerHandler instance. mocker: Pytest mocker fixture. - """ + """ # # Mock Celery app import # mock_app = MagicMock() # mocker.patch("merlin.workers.handlers.celery_handler.app", mock_app) - + # Mock get_active_workers to return live worker mocker.patch.object(handler, "get_active_workers", return_value={"celery@live_worker": ["[merlin]_queue1"]}) - + # Create mock physical worker that's marked as RUNNING mock_physical = MagicMock() mock_physical.get_status.return_value = WorkerStatus.RUNNING mock_physical.get_name.return_value = "celery@live_worker" - + # Create mock logical worker mock_logical = MagicMock() mock_logical.get_physical_workers.return_value = ["physical_id_1"] - + # Mock database get method handler.merlin_db.get.return_value = mock_physical - + handler._validate_worker_status([mock_logical]) - + # Verify status was NOT changed mock_physical.set_status.assert_not_called() - def test_validate_worker_status_ignores_stopped_workers( - self, handler: CeleryWorkerHandler, mocker: MockerFixture - ): + def test_validate_worker_status_ignores_stopped_workers(self, handler: CeleryWorkerHandler, mocker: MockerFixture): """ Test that `_validate_worker_status` doesn't check workers already marked as stopped. Args: handler: CeleryWorkerHandler instance. mocker: Pytest mocker fixture. - """ + """ # # Mock Celery app import # mock_app = MagicMock() # mocker.patch("merlin.workers.handlers.celery_handler.app", mock_app) - + # Mock get_active_workers (doesn't matter what it returns) mocker.patch.object(handler, "get_active_workers", return_value={}) - + # Create mock physical worker that's marked as STOPPED mock_physical = MagicMock() mock_physical.get_status.return_value = WorkerStatus.STOPPED mock_physical.get_name.return_value = "celery@stopped_worker" - + # Create mock logical worker mock_logical = MagicMock() mock_logical.get_physical_workers.return_value = ["physical_id_1"] - + # Mock database get method handler.merlin_db.get.return_value = mock_physical - + handler._validate_worker_status([mock_logical]) - + # Verify status was NOT changed (worker already stopped) mock_physical.set_status.assert_not_called() @@ -516,35 +518,35 @@ def test_validate_worker_status_handles_multiple_physical_workers( Args: handler: CeleryWorkerHandler instance. mocker: Pytest mocker fixture. - """ + """ # # Mock Celery app import # mock_app = MagicMock() # mocker.patch("merlin.workers.handlers.celery_handler.app", mock_app) - + # Mock get_active_workers - only worker1 is live mocker.patch.object(handler, "get_active_workers", return_value={"celery@worker1": ["[merlin]_queue1"]}) - + # Create mock physical workers mock_physical1 = MagicMock() mock_physical1.get_status.return_value = WorkerStatus.RUNNING mock_physical1.get_name.return_value = "celery@worker1" - + mock_physical2 = MagicMock() mock_physical2.get_status.return_value = WorkerStatus.RUNNING mock_physical2.get_name.return_value = "celery@worker2" - + # Create mock logical worker with multiple physical workers mock_logical = MagicMock() mock_logical.get_physical_workers.return_value = ["physical_id_1", "physical_id_2"] - + # Mock database get method to return different workers handler.merlin_db.get.side_effect = [mock_physical1, mock_physical2] - + handler._validate_worker_status([mock_logical]) - + # Verify worker1 status was NOT changed (it's live) mock_physical1.set_status.assert_not_called() - + # Verify worker2 status WAS changed (it's not live) mock_physical2.set_status.assert_called_once() @@ -558,12 +560,12 @@ def test_get_workers_from_app_returns_worker_list(self, handler: CeleryWorkerHan # Mock Celery app inspection mock_inspect = MagicMock() handler.app.control.inspect.return_value = mock_inspect - + # Mock ping response with worker names as dict keys mock_inspect.ping.return_value = { "celery@worker1": {"ok": "pong"}, "celery@worker2": {"ok": "pong"}, - "celery@worker3": {"ok": "pong"} + "celery@worker3": {"ok": "pong"}, } result = handler.get_workers_from_app() @@ -581,7 +583,7 @@ def test_get_workers_from_app_handles_no_workers(self, handler: CeleryWorkerHand # Mock Celery app inspection mock_inspect = MagicMock() handler.app.control.inspect.return_value = mock_inspect - + # Mock ping returning None (no workers) mock_inspect.ping.return_value = None @@ -599,7 +601,7 @@ def test_get_workers_from_app_handles_empty_worker_dict(self, handler: CeleryWor # Mock Celery app inspection mock_inspect = MagicMock() handler.app.control.inspect.return_value = mock_inspect - + # Mock ping returning empty dict mock_inspect.ping.return_value = {} @@ -617,12 +619,12 @@ def test_get_workers_from_app_preserves_worker_names(self, handler: CeleryWorker # Mock Celery app inspection mock_inspect = MagicMock() handler.app.control.inspect.return_value = mock_inspect - + # Mock ping response with various worker name formats mock_inspect.ping.return_value = { "celery@worker1.hostname.com": {"ok": "pong"}, "celery@worker2": {"ok": "pong"}, - "worker3@localhost": {"ok": "pong"} + "worker3@localhost": {"ok": "pong"}, } result = handler.get_workers_from_app() From c8f8616bd0ac504c691afee73187b50b8b98ef84 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 11 Dec 2025 12:47:20 -0800 Subject: [PATCH 86/91] move stop-workers functionality to CeleryWorkerHandler class --- merlin/cli/commands/stop_workers.py | 26 ++++- merlin/workers/celery_worker.py | 21 +++- merlin/workers/handlers/celery_handler.py | 123 ++++++++++++++++++++-- merlin/workers/handlers/worker_handler.py | 39 ++++++- 4 files changed, 193 insertions(+), 16 deletions(-) diff --git a/merlin/cli/commands/stop_workers.py b/merlin/cli/commands/stop_workers.py index 3d5ef6d8..564072ab 100644 --- a/merlin/cli/commands/stop_workers.py +++ b/merlin/cli/commands/stop_workers.py @@ -19,9 +19,9 @@ from merlin.ascii_art import banner_small from merlin.cli.commands.command_entry_point import CommandEntryPoint -from merlin.router import stop_workers from merlin.spec.specification import MerlinSpec from merlin.utils import verify_filepath +from merlin.workers.handlers.handler_factory import worker_handler_factory LOG = logging.getLogger("merlin") @@ -67,6 +67,12 @@ def add_parser(self, subparsers: ArgumentParser): default=None, help="regex match for specific workers to stop", ) + stop.add_argument( + "-d", + "--dry-run", + action="store_true", + help="Display which workers would be stopped without actually stopping them" + ) def process_command(self, args: Namespace): """ @@ -87,6 +93,7 @@ def process_command(self, args: Namespace): worker_names = [] # Load in the spec if one was provided via the CLI + spec = None if args.spec: spec_path = verify_filepath(args.spec) spec = MerlinSpec.load_specification(spec_path) @@ -94,6 +101,19 @@ def process_command(self, args: Namespace): for worker_name in worker_names: if "$" in worker_name: LOG.warning(f"Worker '{worker_name}' is unexpanded. Target provenance spec instead?") + LOG.debug(f"Searching for the following workers to stop based on the spec {args.spec}: {worker_names}") + + # If we have workers from --workers flag, add them to the list + if args.workers: + worker_names.extend(args.workers) - # Send stop command to router - stop_workers(args.task_server, worker_names, args.queues, args.workers) + # Get the task server from spec or CLI argument + task_server = spec.merlin["resources"]["task_server"] if spec else args.task_server + + # Create the handler and send stop command + worker_handler = worker_handler_factory.create(task_server) + worker_handler.stop_workers( + queues=args.queues, + workers=worker_names if worker_names else None, + dry_run=args.dry_run, + ) diff --git a/merlin/workers/celery_worker.py b/merlin/workers/celery_worker.py index 7bea76bc..0df44ec8 100644 --- a/merlin/workers/celery_worker.py +++ b/merlin/workers/celery_worker.py @@ -85,6 +85,7 @@ def __init__( self.batch = self.config.get("batch", {}) self.machines = self.config.get("machines", []) self.overlap = overlap + self.pid = None # Set when the worker is launched # Add this worker to the database merlin_db = MerlinDatabase() @@ -189,12 +190,30 @@ def start(self, override_args: str = "", disable_logs: bool = False): if self.should_launch(): launch_cmd = self.get_launch_command(override_args=override_args, disable_logs=disable_logs) try: - subprocess.Popen(launch_cmd, env=self.env, shell=True, universal_newlines=True) # pylint: disable=R1732 + worker_proc = subprocess.Popen(launch_cmd, env=self.env, shell=True, universal_newlines=True) # pylint: disable=R1732 + self.pid = worker_proc.pid LOG.debug(f"Launched worker '{self.name}' with command: {launch_cmd}.") except Exception as e: # pylint: disable=C0103 LOG.error(f"Cannot start celery workers, {e}") raise MerlinWorkerLaunchError from e + def stop(self): + """ + Stop the worker process. + + If the worker has a known PID, sends a SIGTERM to terminate it. + Otherwise, logs a warning that the worker cannot be stopped. + """ + if self.pid: + try: + os.kill(self.pid, 15) # Send SIGTERM + LOG.debug(f"Stopped worker '{self.name}' with PID {self.pid}.") + self.pid = None # Reset PID after stopping + except Exception as e: # pylint: disable=C0103 + LOG.error(f"Cannot stop celery worker '{self.name}', {e}") + else: + LOG.warning(f"Worker '{self.name}' is not running or PID is unknown; cannot stop.") + def get_metadata(self) -> Dict: """ Return metadata about this worker instance. diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index aaba14cb..d191c1c9 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -21,6 +21,7 @@ from merlin.common.enums import WorkerStatus from merlin.db_scripts.entities.logical_worker_entity import LogicalWorkerEntity from merlin.db_scripts.merlin_db import MerlinDatabase +from merlin.utils import apply_list_of_regex from merlin.workers import CeleryWorker from merlin.workers.formatters.formatter_factory import worker_formatter_factory from merlin.workers.handlers.worker_handler import MerlinWorkerHandler @@ -78,11 +79,6 @@ def start_workers(self, workers: List[CeleryWorker], **kwargs): LOG.debug(f"Launching worker '{worker.name}'.") worker.start(override_args=override_args, disable_logs=disable_logs) - def stop_workers(self): - """ - Attempt to stop Celery workers. - """ - def get_workers_from_app(self) -> List[str]: """ Retrieve a list of all workers connected to the Celery application. @@ -108,10 +104,8 @@ def get_active_workers(self) -> Dict[str, List[str]]: """ Retrieve a mapping of active workers to their associated queues for a Celery application. - This function serves as the inverse of - [`get_active_celery_queues()`][study.celeryadapter.get_active_celery_queues]. It constructs - a dictionary where each key is a worker's name and the corresponding value is a - list of queues that the worker is connected to. This allows for easy identification + This method constructs a dictionary where each key is a worker's name and the corresponding + value is a list of queues that the worker is connected to. This allows for easy identification of which queues are being handled by each worker. Returns: @@ -206,3 +200,114 @@ def query_workers( # Use formatter to display the results formatter = worker_formatter_factory.create(formatter) formatter.format_and_display(logical_workers, filters, self.merlin_db) + + def normalize_queue_names(self, queues: List[str]) -> List[str]: + """ + Normalize queue names to conform to Celery's naming conventions. + + Args: + queues (List[str]): List of queue names to normalize. + + Returns: + List[str]: Normalized queue names. + """ + from merlin.config.configfile import CONFIG # Importing configuration for queue tag + return [f"{CONFIG.celery.queue_tag}{queue}" for queue in queues] + + def get_workers_from_queues(self, queues: List[str]) -> List[str]: + """ + Given a list of queue names, retrieve the Celery workers associated with those queues. + + Args: + queues (List[str]): The list of queue names to filter workers by. + + Returns: + List[str]: A list of Celery worker names associated with the specified queues. + """ + live_workers = self.get_active_workers() + return [worker for worker, live_queues in live_workers.items() if set(queues) & set(live_queues)] + + def filter_workers(self, all_workers: List[str], filters: List[str]) -> List[str]: + """ + Filter workers based on regex patterns or specific names. + + Args: + all_workers (List[str]): List of all available workers. + filters (List[str]): List of regex patterns or specific names to filter workers. + + Returns: + List[str]: Filtered list of workers. + """ + filtered_workers = [] + apply_list_of_regex(filters, all_workers, filtered_workers) + return list(set(filtered_workers)) + + def send_shutdown_signal(self, workers_to_stop: List[str]): + """ + Send a shutdown signal to the specified workers. + + Args: + workers_to_stop (List[str]): List of worker names to send the shutdown signal to. + """ + if workers_to_stop: + LOG.info(f"Sending shutdown signal to workers: {workers_to_stop}") + self.app.control.broadcast("shutdown", destination=workers_to_stop) + else: + LOG.warning("No workers found to stop.") + + def stop_workers(self, queues: List[str] = None, workers: List[str] = None, dry_run: bool = False): + """ + Stop worker processes, optionally filtered by queue or worker name. + + This method terminates active worker processes based on the provided filters. + The behavior varies by implementation: + + - If both `queues` and `workers` are None, all active workers are stopped. + - If `queues` is provided, only workers attached to those queues are stopped. + - If `workers` is provided, only workers matching those names/patterns are stopped. + - If both are provided, workers must match both criteria (intersection). + + Args: + queues: Optional list of queue names to filter workers by. + workers: Optional list of worker names or patterns to match. For Celery, + these can be logical worker names from the spec or regex patterns + matching physical worker names (e.g., "celery@worker1.*"). + dry_run: If True, just print out the names of the workers that will be stopped. + + Example: + ```python + handler = CeleryWorkerHandler() + + # Stop all workers + handler.stop_workers() + + # Stop workers on specific queues + handler.stop_workers(queues=['hello_queue', 'world_queue']) + + # Stop specific workers by name + handler.stop_workers(workers=['worker1', 'worker2']) + + # Stop workers matching both criteria + handler.stop_workers(queues=['hello_queue'], workers=['worker1.*']) + ``` + """ + LOG.debug(f"Stopping workers with queues: {queues}, workers: {workers}") + + # Step 1: Normalize queue names + if queues: + queues = self.normalize_queue_names(queues) + + # Step 2: Get workers from queues + all_workers = self.get_workers_from_queues(queues) if queues else self.get_workers_from_app() + + # Step 3: Filter workers + workers_to_stop = self.filter_workers(all_workers, workers) if workers else all_workers + + # Step 4: Send shutdown signal + if len(workers_to_stop) == 0: + LOG.warning("No workers found to stop.") + else: + if dry_run: + print(f"Would send shutdown signal to workers: {workers_to_stop}.") + else: + self.send_shutdown_signal(workers_to_stop) diff --git a/merlin/workers/handlers/worker_handler.py b/merlin/workers/handlers/worker_handler.py index 03df36ad..a1d3023c 100644 --- a/merlin/workers/handlers/worker_handler.py +++ b/merlin/workers/handlers/worker_handler.py @@ -56,11 +56,44 @@ def start_workers(self, workers: List[MerlinWorker], **kwargs): raise NotImplementedError("Subclasses of `MerlinWorkerHandler` must implement a `start_workers` method.") @abstractmethod - def stop_workers(self): + def stop_workers(self, queues: List[str] = None, workers: List[str] = None): """ - Stop worker processes. + Stop worker processes, optionally filtered by queue or worker name. - This method should terminate any active worker sessions that were previously launched. + This method terminates active worker processes based on the provided filters. + The behavior varies by implementation: + + - If both `queues` and `workers` are None, all active workers are stopped. + - If `queues` is provided, only workers attached to those queues are stopped. + - If `workers` is provided, only workers matching those names/patterns are stopped. + - If both are provided, workers must match both criteria (intersection). + + Args: + queues: Optional list of queue names to filter workers by. Queue names + will be normalized with the appropriate task server prefix if needed. + workers: Optional list of worker names or patterns to match. For Celery, + these can be logical worker names from the spec or regex patterns + matching physical worker names (e.g., "celery@worker1.*"). + + Example: + ```python + handler = CeleryWorkerHandler() + + # Stop all workers + handler.stop_workers() + + # Stop workers on specific queues + handler.stop_workers(queues=['hello_queue', 'world_queue']) + + # Stop specific workers by name + handler.stop_workers(workers=['worker1', 'worker2']) + + # Stop workers matching both criteria + handler.stop_workers(queues=['hello_queue'], workers=['worker1.*']) + ``` + + Raises: + May raise task-server-specific exceptions if connection fails. """ raise NotImplementedError("Subclasses of `MerlinWorkerHandler` must implement a `stop_workers` method.") From 231e9588f070b52f59be7108718cb13dfc37e21a Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 11 Dec 2025 12:48:05 -0800 Subject: [PATCH 87/91] remove unused functions from router/celeradapter --- merlin/common/tasks.py | 15 ++++--- merlin/router.py | 22 ---------- merlin/study/celeryadapter.py | 79 ----------------------------------- merlin/study/manager.py | 8 ++-- 4 files changed, 12 insertions(+), 112 deletions(-) diff --git a/merlin/common/tasks.py b/merlin/common/tasks.py index 6db4dbd7..c05a2f05 100644 --- a/merlin/common/tasks.py +++ b/merlin/common/tasks.py @@ -48,6 +48,7 @@ from merlin.study.step import Step from merlin.study.study import MerlinStudy from merlin.utils import dict_deep_merge +from merlin.workers.handlers.celery_handler import CeleryWorkerHandler retry_exceptions = ( @@ -894,12 +895,13 @@ def expand_tasks_with_samples( # pylint: disable=R0913,R0914 name="merlin:shutdown_workers", priority=get_priority(Priority.HIGH), ) -def shutdown_workers(self: Task, shutdown_queues: List[str]): # pylint: disable=W0613 +def shutdown_workers(self: Task, shutdown_queues: List[str] = None): # pylint: disable=W0613 """ Initiates the shutdown of Celery workers. - This task wraps the [`stop_celery_workers`][study.celeryadapter.stop_celery_workers] - function, allowing for the graceful shutdown of specified Celery worker queues. It is + This task wraps the [`stop_workers`][workers.handlers.celery_handler.CeleryWorkerHandler.stop_workers] + method of the [`CeleryWorkerHandler`][workers.handlers.celery_handler.CeleryWorkerHandler] + class, allowing for the graceful shutdown of specified Celery worker queues. It is acknowledged immediately upon execution, ensuring that it will not be requeued, even if executed by a worker. @@ -908,11 +910,8 @@ def shutdown_workers(self: Task, shutdown_queues: List[str]): # pylint: disable shutdown_queues: A list of specific queues to shut down. If None, all queues will be shut down. """ - if shutdown_queues is not None: - LOG.warning(f"Shutting down workers in queues {shutdown_queues}!") - else: - LOG.warning("Shutting down workers in all queues!") - return stop_workers("celery", None, shutdown_queues, None) + worker_handler = CeleryWorkerHandler() + worker_handler.stop_workers(queues=shutdown_queues) # Pylint complains that these args are unused but celery passes args diff --git a/merlin/router.py b/merlin/router.py index 2af92435..fbf21fb6 100644 --- a/merlin/router.py +++ b/merlin/router.py @@ -21,7 +21,6 @@ purge_celery_tasks, query_celery_queues, run_celery, - stop_celery_workers, ) from merlin.study.study import MerlinStudy @@ -150,24 +149,3 @@ def query_queues( else: LOG.error("Celery is not specified as the task server!") return {} - - -def stop_workers(task_server: str, spec_worker_names: List[str], queues: List[str], workers_regex: str): - """ - This function sends a command to stop workers that match the specified - criteria from the designated task server. - - Args: - task_server: The task server from which to stop workers. - spec_worker_names: A list of worker names to stop, as defined - in a specification. - queues: A list of queues from which to stop associated workers. - workers_regex: A regex pattern used to filter the workers to stop. - """ - LOG.info("Stopping workers...") - - if task_server == "celery": # pylint: disable=R1705 - # Stop workers - stop_celery_workers(queues, spec_worker_names, workers_regex) - else: - LOG.error("Celery is not specified as the task server!") diff --git a/merlin/study/celeryadapter.py b/merlin/study/celeryadapter.py index 789891e0..6fb9e1d3 100644 --- a/merlin/study/celeryadapter.py +++ b/merlin/study/celeryadapter.py @@ -458,82 +458,3 @@ def purge_celery_tasks(queues: str, force: bool) -> int: purge_command = " ".join(["celery -A merlin purge", force_com, "-Q", queues]) LOG.debug(purge_command) return subprocess.run(purge_command, shell=True).returncode - - -def stop_celery_workers( - queues: List[str] = None, spec_worker_names: List[str] = None, worker_regex: List[str] = None -): # pylint: disable=R0912 - """ - Send a stop command to Celery workers. - - This function sends a shutdown command to Celery workers associated with - specified queues. By default, it stops all connected workers, but it can - be configured to target specific workers based on queue names or regular - expression patterns. - - Args: - queues: A list of queue names to which the stop command will be sent. - If None, all connected workers across all queues will be stopped. - spec_worker_names: A list of specific worker names to stop, in addition - to those matching the `worker_regex`. - worker_regex: A regular expression string used to match worker names. - If None, no regex filtering will be applied. - - Side Effects: - - Broadcasts a shutdown signal to Celery workers - - Example: - ```python - stop_celery_workers(queues=['hello'], worker_regex='celery@*my_machine*') - stop_celery_workers() - ``` - """ - from merlin.celery import app # pylint: disable=C0415 - - LOG.debug(f"Sending stop to queues: {queues}, worker_regex: {worker_regex}, spec_worker_names: {spec_worker_names}") - active_queues, _ = get_active_celery_queues(app) - - # If not specified, get all the queues - if queues is None: - queues = [*active_queues] - # Celery adds the queue tag in front of each queue so we add that here - else: - celerize_queues(queues) - - # Find the set of all workers attached to all of those queues - all_workers = set() - for queue in queues: - try: - all_workers.update(active_queues[queue]) - LOG.debug(f"Workers attached to queue {queue}: {active_queues[queue]}") - except KeyError: - LOG.warning(f"No workers are connected to queue {queue}") - - all_workers = list(all_workers) - - LOG.debug(f"Pre-filter worker stop list: {all_workers}") - - # Stop workers with no flags - if (spec_worker_names is None or len(spec_worker_names) == 0) and worker_regex is None: - workers_to_stop = list(all_workers) - # Flag handling - else: - workers_to_stop = [] - # --spec flag - if (spec_worker_names is not None) and len(spec_worker_names) > 0: - apply_list_of_regex(spec_worker_names, all_workers, workers_to_stop) - # --workers flag - if worker_regex is not None: - LOG.debug(f"Searching for workers to stop based on the following regex's: {worker_regex}") - apply_list_of_regex(worker_regex, all_workers, workers_to_stop) - - # Remove duplicates - workers_to_stop = list(set(workers_to_stop)) - LOG.debug(f"Post-filter worker stop list: {workers_to_stop}") - - if workers_to_stop: - LOG.info(f"Sending stop to these workers: {workers_to_stop}") - # Send the shutdown signal - app.control.broadcast("shutdown", destination=workers_to_stop) - else: - LOG.warning("No workers found to stop") diff --git a/merlin/study/manager.py b/merlin/study/manager.py index 3157b278..434d3881 100644 --- a/merlin/study/manager.py +++ b/merlin/study/manager.py @@ -21,7 +21,8 @@ from merlin.db_scripts.merlin_db import MerlinDatabase from merlin.exceptions import RunNotFoundError, StudyNotFoundError from merlin.spec.specification import MerlinSpec -from merlin.study.celeryadapter import purge_celery_tasks, stop_celery_workers +from merlin.study.celeryadapter import purge_celery_tasks +from merlin.workers.handlers.celery_handler import CeleryWorkerHandler LOG = logging.getLogger(__name__) @@ -97,12 +98,13 @@ def cancel( # Step 1: Stop the workers if stop_workers: - # TODO when we refactor `stop-workers`, update this worker_names = spec.get_worker_names() for worker_name in worker_names: if "$" in worker_name: LOG.warning(f"Worker '{worker_name}' is unexpanded. Target provenance spec instead?") - stop_celery_workers(spec_worker_names=worker_names) + + worker_handler = CeleryWorkerHandler() + worker_handler.stop_workers(workers=worker_names) # TODO when we refactor `stop-workers`, may want to do some extra validation here to ensure # all of these workers have actually been stopped From 1bfd5370d0d091e9b7d833b5f478bff8b083ad9a Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 11 Dec 2025 12:48:29 -0800 Subject: [PATCH 88/91] add/update tests for the refactored stop-workers functionality --- tests/unit/cli/commands/test_stop_workers.py | 24 +- tests/unit/study/test_study_manager.py | 15 +- .../workers/handlers/test_celery_handler.py | 246 ++++++++++++++++++ tests/unit/workers/test_celery_worker.py | 74 ++++++ 4 files changed, 345 insertions(+), 14 deletions(-) diff --git a/tests/unit/cli/commands/test_stop_workers.py b/tests/unit/cli/commands/test_stop_workers.py index 970a0219..132d438f 100644 --- a/tests/unit/cli/commands/test_stop_workers.py +++ b/tests/unit/cli/commands/test_stop_workers.py @@ -33,6 +33,7 @@ def test_add_parser_sets_up_stop_workers_command(create_parser: FixtureCallable) assert args.queues == ["queue1", "queue2"] assert args.workers is None assert args.spec is None + assert args.dry_run is False def test_process_command_calls_stop_workers_no_spec(mocker: MockerFixture): @@ -43,12 +44,14 @@ def test_process_command_calls_stop_workers_no_spec(mocker: MockerFixture): mocker: PyTest mocker fixture. """ mocker.patch("merlin.cli.commands.stop_workers.banner_small", "BANNER") - mock_stop = mocker.patch("merlin.cli.commands.stop_workers.stop_workers") + mock_handler_factory = mocker.patch("merlin.cli.commands.stop_workers.worker_handler_factory.create") + mock_handler = mock_handler_factory.return_value + mock_handler.stop_workers = mocker.MagicMock() - args = Namespace(spec=None, task_server="celery", queues=["q1"], workers=["worker1"]) + args = Namespace(spec=None, task_server="celery", queues=["q1"], workers=["worker1"], dry_run=False) StopWorkersCommand().process_command(args) - mock_stop.assert_called_once_with("celery", [], ["q1"], ["worker1"]) + mock_handler.stop_workers.assert_called_once_with(queues=["q1"], workers=["worker1"], dry_run=False) def test_process_command_with_spec_and_worker_names(mocker: MockerFixture): @@ -59,7 +62,9 @@ def test_process_command_with_spec_and_worker_names(mocker: MockerFixture): mocker: PyTest mocker fixture. """ mocker.patch("merlin.cli.commands.stop_workers.banner_small", "BANNER") - mock_stop = mocker.patch("merlin.cli.commands.stop_workers.stop_workers") + mock_handler_factory = mocker.patch("merlin.cli.commands.stop_workers.worker_handler_factory.create") + mock_handler = mock_handler_factory.return_value + mock_handler.stop_workers = mocker.MagicMock() mock_verify = mocker.patch("merlin.cli.commands.stop_workers.verify_filepath", return_value="study.yaml") mock_spec = mocker.patch("merlin.cli.commands.stop_workers.MerlinSpec") @@ -70,12 +75,13 @@ def test_process_command_with_spec_and_worker_names(mocker: MockerFixture): task_server="celery", queues=None, workers=None, + dry_run=False, ) StopWorkersCommand().process_command(args) mock_verify.assert_called_once_with("study.yaml") mock_spec.load_specification.assert_called_once_with("study.yaml") - mock_stop.assert_called_once_with("celery", ["worker.alpha", "worker.beta"], None, None) + mock_handler.stop_workers.assert_called_once_with(queues=None, workers=["worker.alpha", "worker.beta"], dry_run=False) def test_process_command_logs_warning_on_unexpanded_worker(mocker: MockerFixture, caplog: CaptureFixture): @@ -89,14 +95,16 @@ def test_process_command_logs_warning_on_unexpanded_worker(mocker: MockerFixture caplog.set_level("WARNING", logger="merlin") mocker.patch("merlin.cli.commands.stop_workers.banner_small", "BANNER") - mock_stop = mocker.patch("merlin.cli.commands.stop_workers.stop_workers") + mock_handler_factory = mocker.patch("merlin.cli.commands.stop_workers.worker_handler_factory.create") + mock_handler = mock_handler_factory.return_value + mock_handler.stop_workers = mocker.MagicMock() mocker.patch("merlin.cli.commands.stop_workers.verify_filepath", return_value="spec.yaml") mock_spec = mocker.patch("merlin.cli.commands.stop_workers.MerlinSpec") mock_spec.load_specification.return_value.get_worker_names.return_value = ["worker.1", "worker.$step"] - args = Namespace(spec="spec.yaml", task_server="celery", queues=None, workers=None) + args = Namespace(spec="spec.yaml", task_server="celery", queues=None, workers=None, dry_run=False) StopWorkersCommand().process_command(args) assert any("is unexpanded" in record.message for record in caplog.records) - mock_stop.assert_called_once_with("celery", ["worker.1", "worker.$step"], None, None) + mock_handler.stop_workers.assert_called_once_with(queues=None, workers=["worker.1", "worker.$step"], dry_run=False) diff --git a/tests/unit/study/test_study_manager.py b/tests/unit/study/test_study_manager.py index d893231e..623a1415 100644 --- a/tests/unit/study/test_study_manager.py +++ b/tests/unit/study/test_study_manager.py @@ -55,15 +55,18 @@ def mock_spec(mocker: MockerFixture) -> MagicMock: @pytest.fixture def mock_stop_workers(mocker: MockerFixture) -> MagicMock: """ - Fixture that mocks the stop_celery_workers function. + Fixture that mocks the CeleryWorkerHandler class and its stop_workers method. Args: mocker: PyTest mocker fixture. Returns: - The mocked stop_celery_workers function. + A mocked CeleryWorkerHandler instance with the stop_workers method mocked. """ - return mocker.patch("merlin.study.manager.stop_celery_workers") + mock_handler = mocker.MagicMock() + mock_handler.stop_workers = mocker.MagicMock() + mocker.patch("merlin.study.manager.CeleryWorkerHandler", return_value=mock_handler) + return mock_handler.stop_workers @pytest.fixture @@ -129,7 +132,7 @@ def test_cancel_full_cancellation_with_defaults( result = manager.cancel(mock_spec) # Verify workers were stopped - mock_stop_workers.assert_called_once_with(spec_worker_names=["worker1", "worker2"]) + mock_stop_workers.assert_called_once_with(workers=["worker1", "worker2"]) # Verify queues were purged mock_purge_tasks.assert_called_once_with("queue1,queue2,queue3", True) @@ -451,7 +454,7 @@ def test_cancel_with_unexpanded_worker_names( assert "Target provenance spec instead?" in caplog.text # Verify workers were still stopped (including unexpanded one) - mock_stop_workers.assert_called_once_with(spec_worker_names=["worker1", "$(UNEXPANDED_WORKER)", "worker2"]) + mock_stop_workers.assert_called_once_with(workers=["worker1", "$(UNEXPANDED_WORKER)", "worker2"]) def test_cancel_queue_formatting( self, @@ -519,7 +522,7 @@ def test_cancel_with_empty_worker_list( result = manager.cancel(mock_spec) # Verify stop_workers was still called with empty list - mock_stop_workers.assert_called_once_with(spec_worker_names=[]) + mock_stop_workers.assert_called_once_with(workers=[]) # Verify result reflects empty worker list assert result["workers_stopped"] == [] diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py index b73398c6..73b81fac 100644 --- a/tests/unit/workers/handlers/test_celery_handler.py +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -634,3 +634,249 @@ def test_get_workers_from_app_preserves_worker_names(self, handler: CeleryWorker assert "celery@worker2" in result assert "worker3@localhost" in result assert len(result) == 3 + + def test_normalize_queue_names_with_valid_queues(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `normalize_queue_names` correctly normalizes valid queue names. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mock_config = mocker.patch("merlin.config.configfile.CONFIG") + mock_config.celery.queue_tag = "[merlin]_" + queues = ["queue1", "queue2"] + + result = handler.normalize_queue_names(queues) + + assert result == ["[merlin]_queue1", "[merlin]_queue2"] + + def test_normalize_queue_names_with_empty_list(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `normalize_queue_names` handles an empty list of queues. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mock_config = mocker.patch("merlin.config.configfile.CONFIG") + mock_config.celery.queue_tag = "[merlin]_" + queues = [] + + result = handler.normalize_queue_names(queues) + + assert result == [] + + def test_normalize_queue_names_with_special_characters(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `normalize_queue_names` handles queue names with special characters. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mock_config = mocker.patch("merlin.config.configfile.CONFIG") + mock_config.celery.queue_tag = "[merlin]_" + queues = ["queue@1", "queue#2"] + + result = handler.normalize_queue_names(queues) + + assert result == ["[merlin]_queue@1", "[merlin]_queue#2"] + + def test_get_workers_from_queues_with_matching_queues(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `get_workers_from_queues` retrieves workers associated with specified queues. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mocker.patch.object(handler, "get_active_workers", return_value={ + "worker1": ["queue1", "queue2"], + "worker2": ["queue2", "queue3"], + }) + queues = ["queue1", "queue3"] + + result = handler.get_workers_from_queues(queues) + + assert result == ["worker1", "worker2"] + + def test_get_workers_from_queues_with_no_matching_queues(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `get_workers_from_queues` returns an empty list when no queues match. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mocker.patch.object(handler, "get_active_workers", return_value={ + "worker1": ["queue1", "queue2"], + "worker2": ["queue2", "queue3"], + }) + queues = ["queue4"] + + result = handler.get_workers_from_queues(queues) + + assert result == [] + + def test_get_workers_from_queues_with_empty_queues(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `get_workers_from_queues` returns an empty list when the queues list is empty. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mocker.patch.object(handler, "get_active_workers", return_value={ + "worker1": ["queue1", "queue2"], + "worker2": ["queue2", "queue3"], + }) + queues = [] + + result = handler.get_workers_from_queues(queues) + + assert result == [] + + def test_filter_workers_with_matching_filters(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `filter_workers` filters workers based on matching filters. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mock_apply_list_of_regex = mocker.patch("merlin.workers.handlers.celery_handler.apply_list_of_regex") + all_workers = ["worker1", "worker2", "worker3"] + filters = ["worker1", "worker3"] + + handler.filter_workers(all_workers, filters) + + mock_apply_list_of_regex.assert_called_once_with(filters, all_workers, []) + + def test_filter_workers_with_no_matching_filters(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `filter_workers` returns an empty list when no filters match. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mock_apply_list_of_regex = mocker.patch("merlin.workers.handlers.celery_handler.apply_list_of_regex") + all_workers = ["worker1", "worker2", "worker3"] + filters = ["worker4"] + + handler.filter_workers(all_workers, filters) + + mock_apply_list_of_regex.assert_called_once_with(filters, all_workers, []) + + def test_filter_workers_with_empty_filters(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `filter_workers` returns all workers when filters are empty. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mock_apply_list_of_regex = mocker.patch("merlin.workers.handlers.celery_handler.apply_list_of_regex") + all_workers = ["worker1", "worker2", "worker3"] + filters = [] + + handler.filter_workers(all_workers, filters) + + mock_apply_list_of_regex.assert_called_once_with(filters, all_workers, []) + + def test_send_shutdown_signal_with_workers(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `send_shutdown_signal` sends a shutdown signal to specified workers. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mock_broadcast = mocker.patch.object(handler.app.control, "broadcast") + workers_to_stop = ["worker1", "worker2"] + + handler.send_shutdown_signal(workers_to_stop) + + mock_broadcast.assert_called_once_with("shutdown", destination=workers_to_stop) + + def test_send_shutdown_signal_with_no_workers(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `send_shutdown_signal` logs a warning when no workers are provided. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mock_broadcast = mocker.patch.object(handler.app.control, "broadcast") + mock_logger = mocker.patch("merlin.workers.handlers.celery_handler.LOG") + workers_to_stop = [] + + handler.send_shutdown_signal(workers_to_stop) + + mock_broadcast.assert_not_called() + mock_logger.warning.assert_called_once_with("No workers found to stop.") + + def test_stop_workers_with_matching_queues_and_workers(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `stop_workers` stops workers matching both queues and worker names. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mock_normalize_queue_names = mocker.patch.object(handler, "normalize_queue_names", return_value=["[merlin]_queue1"]) + mock_get_workers_from_queues = mocker.patch.object(handler, "get_workers_from_queues", return_value=["worker1", "worker2"]) + mock_filter_workers = mocker.patch.object(handler, "filter_workers", return_value=["worker1"]) + mock_send_shutdown_signal = mocker.patch.object(handler, "send_shutdown_signal") + + handler.stop_workers(queues=["queue1"], workers=["worker1"], dry_run=False) + + mock_normalize_queue_names.assert_called_once_with(["queue1"]) + mock_get_workers_from_queues.assert_called_once_with(["[merlin]_queue1"]) + mock_filter_workers.assert_called_once_with(["worker1", "worker2"], ["worker1"]) + mock_send_shutdown_signal.assert_called_once_with(["worker1"]) + + def test_stop_workers_with_dry_run(self, handler: CeleryWorkerHandler, mocker: MockerFixture, capsys: pytest.CaptureFixture): + """ + Test that `stop_workers` performs a dry run and prints the workers to be stopped. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + capsys: Pytest system output capture fixture. + """ + mock_normalize_queue_names = mocker.patch.object(handler, "normalize_queue_names", return_value=["[merlin]_queue1"]) + mock_get_workers_from_queues = mocker.patch.object(handler, "get_workers_from_queues", return_value=["worker1", "worker2"]) + mock_filter_workers = mocker.patch.object(handler, "filter_workers", return_value=["worker1"]) + mock_send_shutdown_signal = mocker.patch.object(handler, "send_shutdown_signal") + + handler.stop_workers(queues=["queue1"], workers=["worker1"], dry_run=True) + + mock_normalize_queue_names.assert_called_once_with(["queue1"]) + mock_get_workers_from_queues.assert_called_once_with(["[merlin]_queue1"]) + mock_filter_workers.assert_called_once_with(["worker1", "worker2"], ["worker1"]) + mock_send_shutdown_signal.assert_not_called() + + captured = capsys.readouterr() + assert "Would send shutdown signal to workers: ['worker1']." in captured.out + + def test_stop_workers_with_no_workers_found(self, handler: CeleryWorkerHandler, mocker: MockerFixture): + """ + Test that `stop_workers` logs a warning when no workers are found to stop. + + Args: + handler: CeleryWorkerHandler instance. + mocker: Pytest mocker fixture. + """ + mock_normalize_queue_names = mocker.patch.object(handler, "normalize_queue_names", return_value=["[merlin]_queue1"]) + mock_get_workers_from_queues = mocker.patch.object(handler, "get_workers_from_queues", return_value=[]) + mock_filter_workers = mocker.patch.object(handler, "filter_workers", return_value=[]) + mock_logger = mocker.patch("merlin.workers.handlers.celery_handler.LOG") + + handler.stop_workers(queues=["queue1"], workers=["worker1"], dry_run=False) + + mock_normalize_queue_names.assert_called_once_with(["queue1"]) + mock_get_workers_from_queues.assert_called_once_with(["[merlin]_queue1"]) + mock_filter_workers.assert_called_once_with([], ["worker1"]) + mock_logger.warning.assert_called_once_with("No workers found to stop.") diff --git a/tests/unit/workers/test_celery_worker.py b/tests/unit/workers/test_celery_worker.py index 00918139..4f13b413 100644 --- a/tests/unit/workers/test_celery_worker.py +++ b/tests/unit/workers/test_celery_worker.py @@ -81,6 +81,80 @@ def mock_db(mocker: MockerFixture) -> MagicMock: return mocker.patch("merlin.workers.celery_worker.MerlinDatabase") +def test_stop_worker_with_valid_pid( + mocker: MockerFixture, + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MagicMock, +): + """ + Test that `stop` successfully terminates a worker with a valid PID. + + Args: + mocker: Pytest mocker fixture. + basic_config: Basic configuration dictionary fixture. + dummy_env: Dummy environment dictionary fixture. + mock_db: Mocked MerlinDatabase object. + """ + mock_kill = mocker.patch("os.kill") + worker = CeleryWorker("worker1", basic_config, dummy_env) + worker.pid = 12345 + + worker.stop() + + mock_kill.assert_called_once_with(12345, 15) + assert worker.pid is None + + +def test_stop_worker_handles_exception( + mocker: MockerFixture, + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MagicMock, +): + """ + Test that `stop` logs an error if `os.kill` raises an exception. + + Args: + mocker: Pytest mocker fixture. + basic_config: Basic configuration dictionary fixture. + dummy_env: Dummy environment dictionary fixture. + mock_db: Mocked MerlinDatabase object. + """ + mock_kill = mocker.patch("os.kill", side_effect=OSError("Failed to stop process")) + mock_logger = mocker.patch("merlin.workers.celery_worker.LOG") + worker = CeleryWorker("worker2", basic_config, dummy_env) + worker.pid = 12345 + + worker.stop() + + mock_kill.assert_called_once_with(12345, 15) + mock_logger.error.assert_called_once_with("Cannot stop celery worker 'worker2', Failed to stop process") + + +def test_stop_worker_without_pid( + mocker: MockerFixture, + basic_config: FixtureDict[str, Any], + dummy_env: FixtureDict[str, str], + mock_db: MagicMock, +): + """ + Test that `stop` logs a warning if the worker has no PID. + + Args: + mocker: Pytest mocker fixture. + basic_config: Basic configuration dictionary fixture. + dummy_env: Dummy environment dictionary fixture. + mock_db: Mocked MerlinDatabase object. + """ + mock_logger = mocker.patch("merlin.workers.celery_worker.LOG") + worker = CeleryWorker("worker3", basic_config, dummy_env) + + worker.stop() + + mock_logger.warning.assert_called_once_with("Worker 'worker3' is not running or PID is unknown; cannot stop.") + + def test_constructor_sets_fields_and_calls_db_create( basic_config: FixtureDict[str, Any], dummy_env: FixtureDict[str, str], From 501883e40e4c0ad1b3712868a3fb6c41edcf6415 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 11 Dec 2025 13:04:54 -0800 Subject: [PATCH 89/91] remove unused import --- merlin/common/tasks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/merlin/common/tasks.py b/merlin/common/tasks.py index c05a2f05..841c6182 100644 --- a/merlin/common/tasks.py +++ b/merlin/common/tasks.py @@ -41,7 +41,6 @@ RestartException, RetryException, ) -from merlin.router import stop_workers from merlin.spec.expansion import parameter_substitutions_for_cmd, parameter_substitutions_for_sample from merlin.study.dag import DAG from merlin.study.status import read_status, status_conflict_handler From 3e130fe86b786a5ffb016864ee4477e3a464ce27 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 11 Dec 2025 13:16:09 -0800 Subject: [PATCH 90/91] fix style --- merlin/cli/commands/stop_workers.py | 2 +- merlin/study/celeryadapter.py | 2 +- merlin/workers/celery_worker.py | 4 +- merlin/workers/handlers/celery_handler.py | 1 + merlin/workers/handlers/worker_handler.py | 10 ++-- .../workers/handlers/test_celery_handler.py | 48 +++++++++++++------ tests/unit/workers/test_celery_worker.py | 4 +- 7 files changed, 46 insertions(+), 25 deletions(-) diff --git a/merlin/cli/commands/stop_workers.py b/merlin/cli/commands/stop_workers.py index 564072ab..07083057 100644 --- a/merlin/cli/commands/stop_workers.py +++ b/merlin/cli/commands/stop_workers.py @@ -71,7 +71,7 @@ def add_parser(self, subparsers: ArgumentParser): "-d", "--dry-run", action="store_true", - help="Display which workers would be stopped without actually stopping them" + help="Display which workers would be stopped without actually stopping them", ) def process_command(self, args: Namespace): diff --git a/merlin/study/celeryadapter.py b/merlin/study/celeryadapter.py index 6fb9e1d3..fb0cca63 100644 --- a/merlin/study/celeryadapter.py +++ b/merlin/study/celeryadapter.py @@ -20,7 +20,7 @@ from merlin.config import Config from merlin.spec.specification import MerlinSpec from merlin.study.study import MerlinStudy -from merlin.utils import apply_list_of_regex, get_procs, is_running +from merlin.utils import get_procs, is_running LOG = logging.getLogger(__name__) diff --git a/merlin/workers/celery_worker.py b/merlin/workers/celery_worker.py index 0df44ec8..0b3fcafd 100644 --- a/merlin/workers/celery_worker.py +++ b/merlin/workers/celery_worker.py @@ -190,7 +190,9 @@ def start(self, override_args: str = "", disable_logs: bool = False): if self.should_launch(): launch_cmd = self.get_launch_command(override_args=override_args, disable_logs=disable_logs) try: - worker_proc = subprocess.Popen(launch_cmd, env=self.env, shell=True, universal_newlines=True) # pylint: disable=R1732 + worker_proc = subprocess.Popen( + launch_cmd, env=self.env, shell=True, universal_newlines=True + ) # pylint: disable=R1732 self.pid = worker_proc.pid LOG.debug(f"Launched worker '{self.name}' with command: {launch_cmd}.") except Exception as e: # pylint: disable=C0103 diff --git a/merlin/workers/handlers/celery_handler.py b/merlin/workers/handlers/celery_handler.py index d191c1c9..b5b3a902 100644 --- a/merlin/workers/handlers/celery_handler.py +++ b/merlin/workers/handlers/celery_handler.py @@ -212,6 +212,7 @@ def normalize_queue_names(self, queues: List[str]) -> List[str]: List[str]: Normalized queue names. """ from merlin.config.configfile import CONFIG # Importing configuration for queue tag + return [f"{CONFIG.celery.queue_tag}{queue}" for queue in queues] def get_workers_from_queues(self, queues: List[str]) -> List[str]: diff --git a/merlin/workers/handlers/worker_handler.py b/merlin/workers/handlers/worker_handler.py index a1d3023c..91799be1 100644 --- a/merlin/workers/handlers/worker_handler.py +++ b/merlin/workers/handlers/worker_handler.py @@ -62,7 +62,7 @@ def stop_workers(self, queues: List[str] = None, workers: List[str] = None): This method terminates active worker processes based on the provided filters. The behavior varies by implementation: - + - If both `queues` and `workers` are None, all active workers are stopped. - If `queues` is provided, only workers attached to those queues are stopped. - If `workers` is provided, only workers matching those names/patterns are stopped. @@ -78,16 +78,16 @@ def stop_workers(self, queues: List[str] = None, workers: List[str] = None): Example: ```python handler = CeleryWorkerHandler() - + # Stop all workers handler.stop_workers() - + # Stop workers on specific queues handler.stop_workers(queues=['hello_queue', 'world_queue']) - + # Stop specific workers by name handler.stop_workers(workers=['worker1', 'worker2']) - + # Stop workers matching both criteria handler.stop_workers(queues=['hello_queue'], workers=['worker1.*']) ``` diff --git a/tests/unit/workers/handlers/test_celery_handler.py b/tests/unit/workers/handlers/test_celery_handler.py index 73b81fac..2daf2640 100644 --- a/tests/unit/workers/handlers/test_celery_handler.py +++ b/tests/unit/workers/handlers/test_celery_handler.py @@ -691,10 +691,14 @@ def test_get_workers_from_queues_with_matching_queues(self, handler: CeleryWorke handler: CeleryWorkerHandler instance. mocker: Pytest mocker fixture. """ - mocker.patch.object(handler, "get_active_workers", return_value={ - "worker1": ["queue1", "queue2"], - "worker2": ["queue2", "queue3"], - }) + mocker.patch.object( + handler, + "get_active_workers", + return_value={ + "worker1": ["queue1", "queue2"], + "worker2": ["queue2", "queue3"], + }, + ) queues = ["queue1", "queue3"] result = handler.get_workers_from_queues(queues) @@ -709,10 +713,14 @@ def test_get_workers_from_queues_with_no_matching_queues(self, handler: CeleryWo handler: CeleryWorkerHandler instance. mocker: Pytest mocker fixture. """ - mocker.patch.object(handler, "get_active_workers", return_value={ - "worker1": ["queue1", "queue2"], - "worker2": ["queue2", "queue3"], - }) + mocker.patch.object( + handler, + "get_active_workers", + return_value={ + "worker1": ["queue1", "queue2"], + "worker2": ["queue2", "queue3"], + }, + ) queues = ["queue4"] result = handler.get_workers_from_queues(queues) @@ -727,10 +735,14 @@ def test_get_workers_from_queues_with_empty_queues(self, handler: CeleryWorkerHa handler: CeleryWorkerHandler instance. mocker: Pytest mocker fixture. """ - mocker.patch.object(handler, "get_active_workers", return_value={ - "worker1": ["queue1", "queue2"], - "worker2": ["queue2", "queue3"], - }) + mocker.patch.object( + handler, + "get_active_workers", + return_value={ + "worker1": ["queue1", "queue2"], + "worker2": ["queue2", "queue3"], + }, + ) queues = [] result = handler.get_workers_from_queues(queues) @@ -826,7 +838,9 @@ def test_stop_workers_with_matching_queues_and_workers(self, handler: CeleryWork mocker: Pytest mocker fixture. """ mock_normalize_queue_names = mocker.patch.object(handler, "normalize_queue_names", return_value=["[merlin]_queue1"]) - mock_get_workers_from_queues = mocker.patch.object(handler, "get_workers_from_queues", return_value=["worker1", "worker2"]) + mock_get_workers_from_queues = mocker.patch.object( + handler, "get_workers_from_queues", return_value=["worker1", "worker2"] + ) mock_filter_workers = mocker.patch.object(handler, "filter_workers", return_value=["worker1"]) mock_send_shutdown_signal = mocker.patch.object(handler, "send_shutdown_signal") @@ -837,7 +851,9 @@ def test_stop_workers_with_matching_queues_and_workers(self, handler: CeleryWork mock_filter_workers.assert_called_once_with(["worker1", "worker2"], ["worker1"]) mock_send_shutdown_signal.assert_called_once_with(["worker1"]) - def test_stop_workers_with_dry_run(self, handler: CeleryWorkerHandler, mocker: MockerFixture, capsys: pytest.CaptureFixture): + def test_stop_workers_with_dry_run( + self, handler: CeleryWorkerHandler, mocker: MockerFixture, capsys: pytest.CaptureFixture + ): """ Test that `stop_workers` performs a dry run and prints the workers to be stopped. @@ -847,7 +863,9 @@ def test_stop_workers_with_dry_run(self, handler: CeleryWorkerHandler, mocker: M capsys: Pytest system output capture fixture. """ mock_normalize_queue_names = mocker.patch.object(handler, "normalize_queue_names", return_value=["[merlin]_queue1"]) - mock_get_workers_from_queues = mocker.patch.object(handler, "get_workers_from_queues", return_value=["worker1", "worker2"]) + mock_get_workers_from_queues = mocker.patch.object( + handler, "get_workers_from_queues", return_value=["worker1", "worker2"] + ) mock_filter_workers = mocker.patch.object(handler, "filter_workers", return_value=["worker1"]) mock_send_shutdown_signal = mocker.patch.object(handler, "send_shutdown_signal") diff --git a/tests/unit/workers/test_celery_worker.py b/tests/unit/workers/test_celery_worker.py index 4f13b413..3f49c2ce 100644 --- a/tests/unit/workers/test_celery_worker.py +++ b/tests/unit/workers/test_celery_worker.py @@ -153,8 +153,8 @@ def test_stop_worker_without_pid( worker.stop() mock_logger.warning.assert_called_once_with("Worker 'worker3' is not running or PID is unknown; cannot stop.") - - + + def test_constructor_sets_fields_and_calls_db_create( basic_config: FixtureDict[str, Any], dummy_env: FixtureDict[str, str], From 5adc67f060637d09b218b4b191772d86d2539f37 Mon Sep 17 00:00:00 2001 From: Brian Gunnarson Date: Thu, 11 Dec 2025 13:17:08 -0800 Subject: [PATCH 91/91] update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7670113b..ca4bc670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Removed old monitor code from v1.0 +- Moved `stop-workers` functionality to the `CeleryWorkerHandlers` class ## [2.0.0b4]