From 3eb8ac6ee7ff924d0ec72303e04d06494a640cce Mon Sep 17 00:00:00 2001 From: Jeremy White Date: Tue, 17 Sep 2024 18:36:52 -0700 Subject: [PATCH 01/16] Initial prototype for updating study configuration during execution --- maestrowf/conductor.py | 77 +++++++++++++++++++ .../datastructures/core/executiongraph.py | 29 +++++++ maestrowf/maestro.py | 71 +++++++++++++++++ 3 files changed, 177 insertions(+) diff --git a/maestrowf/conductor.py b/maestrowf/conductor.py index 10fc32a02..46d0bf0da 100644 --- a/maestrowf/conductor.py +++ b/maestrowf/conductor.py @@ -34,6 +34,7 @@ import inspect import logging import os +import shutil import sys from time import sleep import dill @@ -140,6 +141,7 @@ class Conductor: _pkl_extension = ".study.pkl" _cancel_lock = ".cancel.lock" + _study_update = ".study.update.lock" _batch_info = "batch.info" def __init__(self, study): @@ -289,6 +291,60 @@ def mark_cancelled(cls, output_path): with open(lock_path, 'a'): os.utime(lock_path, None) + @classmethod + def update_study_exec(cls, output_path, updated_config): + """ + Mark the study rooted at 'out_path'. + + :param out_path: A string containing the patht to a study root. + :returns: A dictionary containing the status of the study. + """ + study_update_path = make_safe_path(output_path, cls._study_update) + # NOTE: should we timestamp these, or append separate timestamped docs + # to the yaml? + print(f"Writing updated config to %s", study_update_path) + with open(study_update_path, 'wb') as study_update: + study_update.write(yaml.dump(updated_config).encode("utf-8")) + + @classmethod + def load_updated_study_exec(cls, output_path): + """ + Load the updated study config for the study rooted in 'out_path'. + + :param out_path: A string containing the path to a study root. + :returns: A dict containing the updated config for the study. + """ + study_update_path = os.path.join(output_path, cls._study_update) + + if not os.path.exists(study_update_path): + # No update record found + return {} + + with open(study_update_path, 'r') as data: + try: + updated_study_config = yaml.load(data, yaml.FullLoader) + except AttributeError: + LOGGER.warning( + "*** PyYAML is using an unsafe version with a known " + "load vulnerability. Please upgrade your installation " + "to a more recent version! ***") + updated_study_config = yaml.load(data) + + if updated_study_config: + LOGGER.debug("Successfully read updated study config; removing record at %s", + study_update_path) + os.remove(study_update_path) + + return updated_study_config + + @property + def sleep_time(self): + return self._sleep_time + + @sleep_time.setter + def sleep_time(self, new_sleep_time): + self._sleep_time = new_sleep_time + def initialize(self, batch_info, sleeptime=60): """ Initializes the Conductor instance based on the stored study. @@ -318,6 +374,7 @@ def monitor_study(self): # Set some fixed variables that monitor will use. cancel_lock_path = make_safe_path(self.output_path, self._cancel_lock) + study_update_path = make_safe_path(self.output_path, self._study_update) dag = self._exec_dag pkl_path = \ os.path.join(self._pkl_path, "{}.pkl".format(self._study.name)) @@ -346,6 +403,26 @@ def monitor_study(self): LOGGER.error("Failed to acquire cancellation lock.") pass + if os.path.exists(study_update_path): + updated_study_config = self.load_updated_study_exec(self.output_path) + + if "throttle" in updated_study_config: + LOGGER.info("Updating throttle from %d to %d", + dag._submission_throttle, # NOTE: make a property? + updated_study_config["throttle"]) + dag.update_throttle(updated_study_config["throttle"]) + + if "rlimit" in updated_study_config: + LOGGER.info("Updating restart limit to %d", + updated_study_config["rlimit"]) + dag.update_rlimit(updated_study_config["rlimit"]) + + if "sleep" in updated_study_config: + LOGGER.info("Updating conductor sleep time from %s to %s", + str(self.sleep_time), + str(updated_study_config["sleep"])) + self.sleep_time = updated_study_config["sleep"] + LOGGER.info("Checking DAG status at %s", str(datetime.now())) # Execute steps that are ready # Receives StudyStatus enum diff --git a/maestrowf/datastructures/core/executiongraph.py b/maestrowf/datastructures/core/executiongraph.py index 0cc0dcbd8..aec0a0ace 100644 --- a/maestrowf/datastructures/core/executiongraph.py +++ b/maestrowf/datastructures/core/executiongraph.py @@ -84,6 +84,14 @@ def params(self): self._params = {} return self._params + @property + def restart_limit(self): + return self._restart_limit + + @restart_limit.setter + def restart_limit(self, new_restart_limit): + self._restart_limit = new_restart_limit + def setup_workspace(self): """Initialize the record's workspace.""" create_parentdir(self.workspace.value) @@ -399,6 +407,27 @@ def _check_tmp_dir(self): if self._tmp_dir and not os.path.exists(self._tmp_dir): self._tmp_dir = tempfile.mkdtemp() + def update_throttle(self, new_submission_throttle): + self._submission_throttle = new_submission_throttle + + def update_rlimit(self, new_restart_limit): + # subtree, _ = self.bfs_subtree(SOURCE) + + # update_subtree = [key for key in subtree + # if key != '_source'] + + for key in self.values.keys(): + if key == SOURCE: + continue + + # Get the step record to update + step_record = self.values[key] + LOGGER.debug("Updating restart limit from %d to %d for step '%s'", + step_record.restart_limit, + new_restart_limit, + key) + step_record.restart_limit = new_restart_limit + def add_step(self, name, step, workspace, restart_limit, params=None): """ Add a StepRecord to the ExecutionGraph. diff --git a/maestrowf/maestro.py b/maestrowf/maestro.py index 40818dcc7..ab8c83c28 100644 --- a/maestrowf/maestro.py +++ b/maestrowf/maestro.py @@ -47,6 +47,7 @@ create_parentdir, create_dictionary, LoggerUtility, make_safe_path, \ start_process +from rich.prompt import FloatPrompt, IntPrompt, Prompt # Program Globals LOGGER = logging.getLogger(__name__) @@ -145,6 +146,68 @@ def cancel_study(args): return ret_code +def update_study_exec(args): + """ + Update a running study with new restart limits, throttle, other study + metadata. + """ + # Force logging to Warning and above + LOG_UTIL.configure(LFORMAT, log_lvl=3) + + directory_list = args.directory + + ret_code = 0 + to_update = [] + if directory_list: + for directory in directory_list: + abs_path = os.path.abspath(directory) + if not os.path.isdir(abs_path): + print( + f"Attempted to update '{abs_path}' " + "-- study directory not found.") + ret_code = 1 + else: + print(f"Study in '{abs_path}' to be updated.") + to_update.append(abs_path) + + if to_update: + + for directory in directory_list: + new_limits = {} + still_updating = True + while still_updating: + + update_menu_choice = Prompt.ask( + f"Choose study config to update, or quit\nStudy: {directory}", + choices=["rlimit", "throttle", "sleep", "quit"] + ) + print(f"{update_menu_choice=}") + if update_menu_choice == "rlimit": + print("Updating restart limit") + new_limits["rlimit"] = IntPrompt.ask("Enter new restart limit") + elif update_menu_choice == "throttle": + new_limits["throttle"] = IntPrompt.ask("Enter new throttle limit") + elif update_menu_choice == "sleep": + new_limits["sleep"] = IntPrompt.ask("Enter new sleep duration for Conductor") + else: + # Quit + still_updating = False + + try: + Conductor.update_study_exec(directory, new_limits) + except Exception as excpt: + print(f"Error:\n{excpt}") + print("Error updating study config. Aborting.") + return -1 + else: + print("Study update aborted.") + else: + print("Path(s) or glob(s) did not resolve to a directory(ies).") + ret_code = 1 + + return ret_code + + def load_parameter_generator(path, env, kwargs): """ Import and load custom parameter Python files. @@ -383,6 +446,14 @@ def setup_argparser(): help="Directory containing a launched study.") cancel.set_defaults(func=cancel_study) + update = subparsers.add_parser( + 'update', + help="Update config of a running study (throttle, rlimit, sleep).") + update.add_argument( + "directory", type=str, nargs="+", + help="Directory containing a launched study.") + update.set_defaults(func=update_study_exec) + # subparser for a run subcommand run = subparsers.add_parser('run', help="Launch a study based on a specification") From 2474e8980f487243dad8383a40308e60a0f49b02 Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Tue, 19 Nov 2024 14:05:21 -0800 Subject: [PATCH 02/16] Fix broken fstring --- maestrowf/conductor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maestrowf/conductor.py b/maestrowf/conductor.py index 46d0bf0da..9735650a0 100644 --- a/maestrowf/conductor.py +++ b/maestrowf/conductor.py @@ -302,7 +302,7 @@ def update_study_exec(cls, output_path, updated_config): study_update_path = make_safe_path(output_path, cls._study_update) # NOTE: should we timestamp these, or append separate timestamped docs # to the yaml? - print(f"Writing updated config to %s", study_update_path) + print(f"Writing updated study config to '{study_update_path}'") with open(study_update_path, 'wb') as study_update: study_update.write(yaml.dump(updated_config).encode("utf-8")) From bea77bbdcda5da13a9a151a67d8b5a649f1b1ec3 Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Tue, 19 Nov 2024 14:05:43 -0800 Subject: [PATCH 03/16] Guard against null/empty values in study update lock file --- maestrowf/conductor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/maestrowf/conductor.py b/maestrowf/conductor.py index 9735650a0..64711ffe3 100644 --- a/maestrowf/conductor.py +++ b/maestrowf/conductor.py @@ -406,18 +406,18 @@ def monitor_study(self): if os.path.exists(study_update_path): updated_study_config = self.load_updated_study_exec(self.output_path) - if "throttle" in updated_study_config: + if "throttle" in updated_study_config and updated_study_config["throttle"]: LOGGER.info("Updating throttle from %d to %d", dag._submission_throttle, # NOTE: make a property? updated_study_config["throttle"]) dag.update_throttle(updated_study_config["throttle"]) - if "rlimit" in updated_study_config: + if "rlimit" in updated_study_config and updated_study_config["rlimit"]: LOGGER.info("Updating restart limit to %d", updated_study_config["rlimit"]) dag.update_rlimit(updated_study_config["rlimit"]) - if "sleep" in updated_study_config: + if "sleep" in updated_study_config and updated_study_config["sleep"]: LOGGER.info("Updating conductor sleep time from %s to %s", str(self.sleep_time), str(updated_study_config["sleep"])) From 34166d4257ae243a8dc6de766b5574eb8b116620 Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Tue, 19 Nov 2024 14:06:21 -0800 Subject: [PATCH 04/16] Add explicit arguments to bypass prompt, fefactor to reduce nesting, and add argument validation and expansion --- maestrowf/maestro.py | 207 +++++++++++++++++++++++++++++++++---------- 1 file changed, 158 insertions(+), 49 deletions(-) diff --git a/maestrowf/maestro.py b/maestrowf/maestro.py index ab8c83c28..1c926c4cb 100644 --- a/maestrowf/maestro.py +++ b/maestrowf/maestro.py @@ -29,6 +29,7 @@ """A script for launching a YAML study specification.""" from argparse import ArgumentParser, ArgumentError, RawTextHelpFormatter +import itertools import jsonschema import logging import os @@ -146,66 +147,150 @@ def cancel_study(args): return ret_code +def validate_update_args(args, directory_list): + """ + Ensure any study config update fields are consistent with the list of study + directories. If any config field has > 1 value, it must be of the same + length as number of study directories. + """ + def validate_attr(namespace_obj, attr_name, dir_list): + attr_tmp = getattr(namespace_obj, attr_name) + + # If no override, skip this one + if not attr_tmp: + return True + + return len(attr_tmp) == 1 or len(attr_tmp) == len(dir_list) + + # Add rich formatting, or spin up logger here with stdout only handler? + err_msg_template = "ERROR: {var_name} is incompatible with directory: "\ + + "{var_name} have either 1 value (same value for all directories) " \ + + "or the same number of values as study directories." + vars_are_valid = True + for update_config_var in ['rlimit', 'throttle', 'sleep']: + var_is_valid = validate_attr(args, update_config_var, directory_list) + if not var_is_valid: + print(err_msg_template.format(var_name=update_config_var)) + vars_are_valid = False + + return vars_are_valid + + +def expand_update_args(args, directory_list): + """ + Take any length one args and replicate for each directory in directory list + """ + config_args = ["rlimit", "throttle", "sleep"] + update_args = {} + for update_config_var in config_args: + cvar_input = getattr(args, update_config_var) + if not cvar_input: + continue + + if len(cvar_input) == 1 and len(cvar_input) != len(directory_list): + cvar_input = list(itertools.repeat(cvar_input[0], len(directory_list))) + + update_args[update_config_var] = cvar_input + + # Invert it for easier paired iterating later + inverted_update_args = [] + for idx, _ in enumerate(directory_list): + inverted_update_args.append( + # { + # cvar: update_args[cvar][idx] + # for cvar in config_args + # if cvar in update_args and update_args[cvar] + # } + { + cvar: update_args[cvar][idx] if cvar in update_args and update_args[cvar] else None + for cvar in config_args + } + ) + + return inverted_update_args + + def update_study_exec(args): """ Update a running study with new restart limits, throttle, other study - metadata. + metadata. If inputs are valid, write new limits in yaml format to + '.study.update.lock' at the top level of a running study workspace + for conductor to read in when it wakes up. + + :param args: argparse Namespace containing cli arguments from 'update' command + :returns: 1 if update failes, -1 if update succeeds """ # Force logging to Warning and above LOG_UTIL.configure(LFORMAT, log_lvl=3) directory_list = args.directory - ret_code = 0 + if not directory_list: + print("Path(s) or glob(s) did not resolve to a directory(ies).") + return 1 # ret_code = 1 + to_update = [] - if directory_list: - for directory in directory_list: - abs_path = os.path.abspath(directory) - if not os.path.isdir(abs_path): - print( - f"Attempted to update '{abs_path}' " - "-- study directory not found.") - ret_code = 1 - else: - print(f"Study in '{abs_path}' to be updated.") - to_update.append(abs_path) - - if to_update: - - for directory in directory_list: - new_limits = {} - still_updating = True - while still_updating: - - update_menu_choice = Prompt.ask( - f"Choose study config to update, or quit\nStudy: {directory}", - choices=["rlimit", "throttle", "sleep", "quit"] - ) - print(f"{update_menu_choice=}") - if update_menu_choice == "rlimit": - print("Updating restart limit") - new_limits["rlimit"] = IntPrompt.ask("Enter new restart limit") - elif update_menu_choice == "throttle": - new_limits["throttle"] = IntPrompt.ask("Enter new throttle limit") - elif update_menu_choice == "sleep": - new_limits["sleep"] = IntPrompt.ask("Enter new sleep duration for Conductor") - else: - # Quit - still_updating = False + for directory in directory_list: + abs_path = os.path.abspath(directory) + if not os.path.isdir(abs_path): + print( + f"Attempted to update '{abs_path}' " + "-- study directory not found.") + else: + print(f"Study in '{abs_path}' to be updated.") + to_update.append(abs_path) + + # Validate the update args + update_args_are_valid = validate_update_args(args, directory_list) + expanded_update_args = expand_update_args(args, directory_list) + + if not to_update or not update_args_are_valid: + print("Study update aborted.") + if not to_update: + print( + "Found no studies to update in specified study workspace directories: {}".format( + ','.join(directory_list) + ) + ) + + if not update_args_are_valid: + print("Incompatible numbers of values for study config and study directories") + return 1 - try: - Conductor.update_study_exec(directory, new_limits) - except Exception as excpt: - print(f"Error:\n{excpt}") - print("Error updating study config. Aborting.") - return -1 + # Apply updated study config, prompting for values interactively if needed + for new_limits, directory in zip(expanded_update_args, directory_list): + # new_limits = {} + # Skip interactive mode if explicit args passed in + if new_limits: + still_updating = False else: - print("Study update aborted.") - else: - print("Path(s) or glob(s) did not resolve to a directory(ies).") - ret_code = 1 + still_updating = True + + while still_updating: + + update_menu_choice = Prompt.ask( + f"Choose study config to update, or quit\nStudy: {directory}", + choices=["rlimit", "throttle", "sleep", "quit"] + ) + print(f"{update_menu_choice=}") + if update_menu_choice == "rlimit": + print("Updating restart limit") + new_limits["rlimit"] = IntPrompt.ask("Enter new restart limit") + elif update_menu_choice == "throttle": + new_limits["throttle"] = IntPrompt.ask("Enter new throttle limit") + elif update_menu_choice == "sleep": + new_limits["sleep"] = IntPrompt.ask("Enter new sleep duration for Conductor") + else: + # Quit + still_updating = False - return ret_code + try: + Conductor.update_study_exec(directory, new_limits) + except Exception as excpt: + print(f"Error:\n{excpt}") + print("Error updating study config. Aborting.") + + return -1 def load_parameter_generator(path, env, kwargs): @@ -443,7 +528,7 @@ def setup_argparser(): help="Cancel all running jobs.") cancel.add_argument( "directory", type=str, nargs="+", - help="Directory containing a launched study.") + help="Directory or list of directories containing running studies.") cancel.set_defaults(func=cancel_study) update = subparsers.add_parser( @@ -452,6 +537,30 @@ def setup_argparser(): update.add_argument( "directory", type=str, nargs="+", help="Directory containing a launched study.") + update.add_argument( + "--throttle", + action="append", + type=str, + default=[], + help="Update the maximum number of inflight jobs allowed to execute" + " simultaneously (0 denotes no throttling)." + ) + update.add_argument( + "--rlimit", + action="append", + type=str, + default=[], + help="Update the maximum number of restarts allowed when steps " + "specify a restart command (0 denotes no limit)." + ) + update.add_argument( + "--sleep", + action="append", + type=str, + default=[], + help="Update the time (in seconds) that the manager will " + "wait between job status checks.", + ) update.set_defaults(func=update_study_exec) # subparser for a run subcommand @@ -461,7 +570,7 @@ def setup_argparser(): help="Maximum number of submission attempts before a " "step is marked as failed. [Default: %(default)d]") run.add_argument("-r", "--rlimit", type=int, default=1, - help="Maximum number of restarts allowed when steps. " + help="Maximum number of restarts allowed when steps " "specify a restart command (0 denotes no limit). " "[Default: %(default)d]") run.add_argument("-t", "--throttle", type=int, default=0, From eb049631598e14b9ea52805fdfc96d56a640b43d Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:21:08 -0800 Subject: [PATCH 05/16] Add initial documentation for update command --- docs/Maestro/cli.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/Maestro/cli.md b/docs/Maestro/cli.md index 93b6c3916..cf62550db 100644 --- a/docs/Maestro/cli.md +++ b/docs/Maestro/cli.md @@ -38,6 +38,7 @@ maestro [OPTIONS] COMMAND [ARGS]... - [*cancel*](#cancel): Cancel all running jobs. - [*run*](#run): Launch a study based on a specification - [*status*](#status): Check the status of a running study. +- [*update*](#update): Update a running study ### **cancel** @@ -106,6 +107,40 @@ maestro status [OPTIONS] DIRECTORY [DIRECTORY ...] | `--disable-pager` | boolean | Turn off the pager for the status display. See [Status Pager](monitoring.md#status-pager) for more information on this option. | `False` | +### **update** + +Update the config of a running study. Currently limited to three settings: throttle, restart limit (rlimit), and sleep. Explicitly set each argument via keyword args, interactively set for each study, or a mix of the two. Supports updating multiple studies at once. + +!!! note + + This command will drop a hidden file in your study workspace '.study.update.lock' which conductor reads asynchronously and removes upon successful reading. Applying this command to a finished study will currently leave this file in your workspace. Similarly, this file will also not be cleaned up if conductor crashes before reading. + +**Usage:** + +``` console +maestro update [-h] [--rlimit RLIMIT] [--sleep SLEEP] [--throttle THROTTLE] DIRECTORY [DIRECTORY ...] +``` + +**Options:** + +| Name | Type | Description | Default | +| ---- | ---- | ----------- | ------- | +| `-h`, `--help` | boolean | Show this help message and exit. | `False` | +| `--rlimit` | integer | Update maximum number of restarts when steps specify a restart command (0 denotes no limit) | None | +| `--sleep` | integer | Update the time (in seconds) that the manager (conductor) will wait between job status checks. | None | +| `--throttle` | integer | Update the maximum number of inflight jobs allowed to execute simultaneously (0 denotes no throttling). | None | + + +**Examples:** + +``` console title="Update 1 study" +maestro update --rlimit 4 /path/to/my/timestamped/study/workspace/ +``` + +``` console title="Update 2 studies" +maestro update --rlimit 4 --rlimit 2 /path/to/my/timestamped/study/workspace_1/ /path/to/my/timestamped/study/workspace_2/ +``` + ## **conductor** A application for checking and managing and ExecutionDAG within an executing study. From 5d35aeac4a59630367df3e366272202d65122d9c Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:36:50 -0800 Subject: [PATCH 06/16] Fix broken test for triggering interactive mode --- maestrowf/maestro.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maestrowf/maestro.py b/maestrowf/maestro.py index 1c926c4cb..8d1a58921 100644 --- a/maestrowf/maestro.py +++ b/maestrowf/maestro.py @@ -261,7 +261,7 @@ def update_study_exec(args): for new_limits, directory in zip(expanded_update_args, directory_list): # new_limits = {} # Skip interactive mode if explicit args passed in - if new_limits: + if any([update_val for update_arg, update_val in new_limits.items()]): still_updating = False else: still_updating = True From 27d265930e33b39d5f624d8912a8a44f6abb610f Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Tue, 19 Nov 2024 17:37:05 -0800 Subject: [PATCH 07/16] Add example for interactive update command usage --- docs/Maestro/cli.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/Maestro/cli.md b/docs/Maestro/cli.md index cf62550db..148775ce2 100644 --- a/docs/Maestro/cli.md +++ b/docs/Maestro/cli.md @@ -141,6 +141,24 @@ maestro update --rlimit 4 /path/to/my/timestamped/study/workspace/ maestro update --rlimit 4 --rlimit 2 /path/to/my/timestamped/study/workspace_1/ /path/to/my/timestamped/study/workspace_2/ ``` +``` console title="Interactively update 1 study" +$ maestro update samples/hello_world/sample_output/hello_world_restart/hello_bye_world_20241119-173122 +Study in '/path/to/maestro_repo/samples/hello_world/sample_output/hello_world_restart/hello_bye_world_20241119-173122' to be updated. +Choose study config to update, or quit +Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 [rlimit/throttle/sleep/quit]: rlimit +update_menu_choice='rlimit' +Updating restart limit +Enter new restart limit: 4 +Choose study config to update, or quit +Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 [rlimit/throttle/sleep/quit]: sleep +update_menu_choice='sleep' +Enter new sleep duration for Conductor: 30 +Choose study config to update, or quit +Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 [rlimit/throttle/sleep/quit]: quit +update_menu_choice='quit' +Writing updated study config to 'sample_output/hello_world_restart/hello_bye_world_20241119-173122/.study.update.lock' +``` + ## **conductor** A application for checking and managing and ExecutionDAG within an executing study. From db81b892a5838dfe61bb89575ce8a4c4de6e4daa Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:51:49 -0800 Subject: [PATCH 08/16] Tweak messaging and formatting of interative update and handle case of quitting without any updates --- maestrowf/maestro.py | 47 +++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/maestrowf/maestro.py b/maestrowf/maestro.py index 8d1a58921..906244ce6 100644 --- a/maestrowf/maestro.py +++ b/maestrowf/maestro.py @@ -48,7 +48,14 @@ create_parentdir, create_dictionary, LoggerUtility, make_safe_path, \ start_process -from rich.prompt import FloatPrompt, IntPrompt, Prompt +from rich.console import Console +from rich.prompt import IntPrompt, Prompt + +# Ensure actual input is on it's own line +Prompt.prompt_suffix = "\n> " +IntPrompt.prompt_suffix = "\n> " + +console = Console() # Program Globals LOGGER = logging.getLogger(__name__) @@ -226,18 +233,17 @@ def update_study_exec(args): directory_list = args.directory if not directory_list: - print("Path(s) or glob(s) did not resolve to a directory(ies).") - return 1 # ret_code = 1 + console.print("Path(s) or glob(s) did not resolve to a directory(ies).") + console.print("Aborting study update") + return 1 to_update = [] for directory in directory_list: abs_path = os.path.abspath(directory) if not os.path.isdir(abs_path): - print( - f"Attempted to update '{abs_path}' " - "-- study directory not found.") + console.print( + f"'{abs_path}' is not a directory; skipping update for this study.") else: - print(f"Study in '{abs_path}' to be updated.") to_update.append(abs_path) # Validate the update args @@ -245,16 +251,18 @@ def update_study_exec(args): expanded_update_args = expand_update_args(args, directory_list) if not to_update or not update_args_are_valid: - print("Study update aborted.") if not to_update: - print( - "Found no studies to update in specified study workspace directories: {}".format( + console.print( + "[bold red]ERROR:[/] Found no studies to update in specified study workspace directories: {}".format( ','.join(directory_list) ) ) if not update_args_are_valid: - print("Incompatible numbers of values for study config and study directories") + console.print("[bold red]ERROR:[/] Incompatible numbers of values for study config and study directories") + + console.print("Aborting study update.") + return 1 # Apply updated study config, prompting for values interactively if needed @@ -266,15 +274,14 @@ def update_study_exec(args): else: still_updating = True + console.print(f"Updating study at [blue]'{directory}'[/]") while still_updating: - update_menu_choice = Prompt.ask( - f"Choose study config to update, or quit\nStudy: {directory}", + f"Choose study configuration to update, or quit to abort\n", choices=["rlimit", "throttle", "sleep", "quit"] ) - print(f"{update_menu_choice=}") if update_menu_choice == "rlimit": - print("Updating restart limit") + console.print("Updating restart limit") new_limits["rlimit"] = IntPrompt.ask("Enter new restart limit") elif update_menu_choice == "throttle": new_limits["throttle"] = IntPrompt.ask("Enter new throttle limit") @@ -285,10 +292,14 @@ def update_study_exec(args): still_updating = False try: - Conductor.update_study_exec(directory, new_limits) + if any([update_val for update_arg, update_val in new_limits.items()]): + Conductor.update_study_exec(directory, new_limits) + else: + console.print("No new study config settings provided; exiting.") except Exception as excpt: - print(f"Error:\n{excpt}") - print("Error updating study config. Aborting.") + console.print(f"[bold red]ERROR:[/]\n{excpt}") + console.print("[bold red]ERROR:[/] Encountered unexpected exception while " + "updating study config. Aborting.") return -1 From 41440c3a78912724b616c3fd9369ee59dbf92c3a Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:52:42 -0800 Subject: [PATCH 09/16] Add new dependency for docs; split out docs dependencies into group and gate that group to python >3.8 due to termynal's requirements --- poetry.lock | 161 ++++++------------------------------------------- pyproject.toml | 26 ++++---- 2 files changed, 32 insertions(+), 155 deletions(-) diff --git a/poetry.lock b/poetry.lock index 45fc87249..c2c1ce6db 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,21 +14,6 @@ files = [ [package.dependencies] typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} -[[package]] -name = "astunparse" -version = "1.6.3" -description = "An AST unparser for Python" -optional = false -python-versions = "*" -files = [ - {file = "astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8"}, - {file = "astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872"}, -] - -[package.dependencies] -six = ">=1.6.1,<2.0" -wheel = ">=0.23.0,<1.0" - [[package]] name = "attrs" version = "24.2.0" @@ -59,33 +44,9 @@ files = [ {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, ] -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] -[[package]] -name = "beautifulsoup4" -version = "4.12.3" -description = "Screen-scraping library" -optional = false -python-versions = ">=3.6.0" -files = [ - {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, - {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, -] - -[package.dependencies] -soupsieve = ">1.2" - -[package.extras] -cchardet = ["cchardet"] -chardet = ["chardet"] -charset-normalizer = ["charset-normalizer"] -html5lib = ["html5lib"] -lxml = ["lxml"] - [[package]] name = "cachetools" version = "5.5.0" @@ -399,16 +360,6 @@ files = [ {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, ] -[[package]] -name = "editorconfig" -version = "0.12.4" -description = "EditorConfig File Locator and Interpreter for Python" -optional = false -python-versions = "*" -files = [ - {file = "EditorConfig-0.12.4.tar.gz", hash = "sha256:24857fa1793917dd9ccf0c7810a07e05404ce9b823521c7dce22a4fb5d125f80"}, -] - [[package]] name = "exceptiongroup" version = "1.2.2" @@ -484,7 +435,6 @@ files = [ ] [package.dependencies] -astunparse = {version = ">=1.6", markers = "python_version < \"3.9\""} colorama = ">=0.4" [[package]] @@ -616,20 +566,6 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] -[[package]] -name = "jsbeautifier" -version = "1.15.1" -description = "JavaScript unobfuscator and beautifier." -optional = false -python-versions = "*" -files = [ - {file = "jsbeautifier-1.15.1.tar.gz", hash = "sha256:ebd733b560704c602d744eafc839db60a1ee9326e30a2a80c4adb8718adc1b24"}, -] - -[package.dependencies] -editorconfig = ">=0.12.2" -six = ">=1.13.0" - [[package]] name = "jsonschema" version = "4.23.0" @@ -955,28 +891,6 @@ files = [ {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, ] -[[package]] -name = "mkdocs-mermaid2-plugin" -version = "1.1.1" -description = "A MkDocs plugin for including mermaid graphs in markdown sources" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mkdocs-mermaid2-plugin-1.1.1.tar.gz", hash = "sha256:bea5f3cbe6cb76bad21b81e49a01e074427ed466666c5d404e62fe8698bc2d7c"}, - {file = "mkdocs_mermaid2_plugin-1.1.1-py3-none-any.whl", hash = "sha256:4e25876b59d1e151ca33a467207b346404b4a246f4f24af5e44c32408e175882"}, -] - -[package.dependencies] -beautifulsoup4 = ">=4.6.3" -jsbeautifier = "*" -mkdocs = ">=1.0.4" -pymdown-extensions = ">=8.0" -requests = "*" -setuptools = ">=18.5" - -[package.extras] -test = ["mkdocs-material"] - [[package]] name = "mkdocstrings" version = "0.26.1" @@ -1317,17 +1231,6 @@ files = [ [package.dependencies] six = ">=1.5" -[[package]] -name = "pytz" -version = "2024.2" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, - {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, -] - [[package]] name = "pyyaml" version = "6.0.2" @@ -1674,26 +1577,6 @@ files = [ {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, ] -[[package]] -name = "setuptools" -version = "75.2.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"}, - {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] - [[package]] name = "six" version = "1.16.0" @@ -1716,17 +1599,6 @@ files = [ {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] -[[package]] -name = "soupsieve" -version = "2.6" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.8" -files = [ - {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, - {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, -] - [[package]] name = "tabulate" version = "0.9.0" @@ -1741,6 +1613,23 @@ files = [ [package.extras] widechars = ["wcwidth"] +[[package]] +name = "termynal" +version = "0.12.2" +description = "A lightweight and modern animated terminal window" +optional = false +python-versions = ">=3.9" +files = [ + {file = "termynal-0.12.2-py3-none-any.whl", hash = "sha256:62314dac6e77f1b7b64a251c2c90702eb6e6910f72ea9c6e5ef68d715b272709"}, + {file = "termynal-0.12.2.tar.gz", hash = "sha256:cc2356bbf8650c16abd0558786251fabdde6b25e1125c1f091607ed3e422f7f2"}, +] + +[package.dependencies] +markdown = ">=3" + +[package.extras] +mkdocs = ["mkdocs (>=1.4)"] + [[package]] name = "tomli" version = "2.0.2" @@ -1913,20 +1802,6 @@ files = [ [package.extras] watchmedo = ["PyYAML (>=3.10)"] -[[package]] -name = "wheel" -version = "0.44.0" -description = "A built-package format for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "wheel-0.44.0-py3-none-any.whl", hash = "sha256:2376a90c98cc337d18623527a97c31797bd02bad0033d41547043a1cbfbe448f"}, - {file = "wheel-0.44.0.tar.gz", hash = "sha256:a29c3f2817e95ab89aa4660681ad547c0e9547f20e75b0562fe7723c9a2a9d49"}, -] - -[package.extras] -test = ["pytest (>=6.0.0)", "setuptools (>=65)"] - [[package]] name = "zipp" version = "3.20.2" @@ -1949,4 +1824,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<4.0" -content-hash = "8e169dd2881da44de4f78d8072b3345bad152b584e7501035661ab3a526e8d85" +content-hash = "b3de4bca516cf38559f448a39142800371cd3df2f33910af3ea57180a6b88dd7" diff --git a/pyproject.toml b/pyproject.toml index 5f685ab32..f92f04c7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,20 +44,10 @@ pyyaml = ">=4.2b1" rich = "*" six = "*" tabulate = "*" -mkdocs-mermaid2-plugin = "^1.1.1" +#mkdocs-mermaid2-plugin = "^1.1.1" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] coverage = "*" -mkdocs = "<2.0" -mkdocs-material = "*" -mkdocs-material-extensions = "*" -# mkdocs-mermaid2-plugin = "*" -mkdocstrings = "*" -mkdocstrings-python = "*" -mkdocs-gen-files = "*" -mkdocs-literate-nav = "*" # Make sub groups here for docs, formatting, etc? -mkdocs-glightbox = "*" -pymdown-extensions = "*" flake8 = "*" pydocstyle = "*" pylint = "*" @@ -68,6 +58,18 @@ pre-commit = "*" tox-travis = "*" tox-pyenv = "*" +[tool.poetry.group.docs.dependencies] +mkdocs = { version = "<2.0", python = "^3.9" } +mkdocs-material = { version = "*", python = "^3.9" } +mkdocs-material-extensions = { version = "*", python = "^3.9" } +mkdocstrings = { version = "*", python = "^3.9" } +mkdocstrings-python = { version = "*", python = "^3.9" } +mkdocs-gen-files = { version = "*", python = "^3.9" } +mkdocs-literate-nav = { version = "*", python = "^3.9" } +mkdocs-glightbox = { version = "*", python = "^3.9" } +pymdown-extensions = { version = "*", python = "^3.9" } +termynal = { version = "*", python = "^3.9" } + [tool.poetry.plugins."console_scripts"] "maestro" = "maestrowf.maestro:main" "conductor" = "maestrowf.conductor:main" From 29f312ef7d186307f3924d883b467fe1521d8abf Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:53:40 -0800 Subject: [PATCH 10/16] Add initial animated example for interactive update command --- docs/Maestro/cli.md | 48 ++++++++++++++++++++++++++++++++++----------- mkdocs.yml | 5 +++++ 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/docs/Maestro/cli.md b/docs/Maestro/cli.md index 148775ce2..498841a0a 100644 --- a/docs/Maestro/cli.md +++ b/docs/Maestro/cli.md @@ -131,30 +131,56 @@ maestro update [-h] [--rlimit RLIMIT] [--sleep SLEEP] [--throttle THROTTLE] DIRE | `--throttle` | integer | Update the maximum number of inflight jobs allowed to execute simultaneously (0 denotes no throttling). | None | -**Examples:** +#### **Examples** -``` console title="Update 1 study" +**Update a single study configuration value for a single study:** + +``` console title="Change single config value for a single study" maestro update --rlimit 4 /path/to/my/timestamped/study/workspace/ ``` -``` console title="Update 2 studies" +**Update multiple study configuration values for a single study:** + +``` console title="Change multiple config values for a single study" +maestro update --rlimit 4 --throttle 2 /path/to/my/timestamped/study/workspace/ +``` +**Update single study configuration value for multiple studies:** + +``` console title="Single config value, two studies" +maestro update --rlimit 4 --rlimit 2 /path/to/my/timestamped/study/workspace_1/ /path/to/my/timestamped/study/workspace_2/ +``` + +**Update multiple study configuration values for multiple studies:** + +``` console title="Multiple config values, two studies" maestro update --rlimit 4 --rlimit 2 /path/to/my/timestamped/study/workspace_1/ /path/to/my/timestamped/study/workspace_2/ ``` -``` console title="Interactively update 1 study" -$ maestro update samples/hello_world/sample_output/hello_world_restart/hello_bye_world_20241119-173122 -Study in '/path/to/maestro_repo/samples/hello_world/sample_output/hello_world_restart/hello_bye_world_20241119-173122' to be updated. +**Interactively update study configuration for one study:** + + +``` +$ maestro update ./sample_output/hello_world_restart/hello_bye_world_20241119-173122 +Study in '/path/to/sample_output/hello_world_restart/hello_bye_world_20241119-173122' to be updated. Choose study config to update, or quit -Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 [rlimit/throttle/sleep/quit]: rlimit +Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 +[rlimit/throttle/sleep/quit] +> rlimit update_menu_choice='rlimit' Updating restart limit -Enter new restart limit: 4 +Enter new restart limit +> 4 Choose study config to update, or quit -Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 [rlimit/throttle/sleep/quit]: sleep +Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 + [rlimit/throttle/sleep/quit] +> sleep update_menu_choice='sleep' -Enter new sleep duration for Conductor: 30 +Enter new sleep duration for Conductor +> 30 Choose study config to update, or quit -Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 [rlimit/throttle/sleep/quit]: quit +Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 + [rlimit/throttle/sleep/quit] +> quit update_menu_choice='quit' Writing updated study config to 'sample_output/hello_world_restart/hello_bye_world_20241119-173122/.study.update.lock' ``` diff --git a/mkdocs.yml b/mkdocs.yml index 6bbf89006..1d0ba116f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,6 +37,11 @@ plugins: - literate-nav: nav_file: SUMMARY.md - glightbox + - termynal: + title: bash + prompt_literal_start: + - "$" + - ">" # - section-index extra_javascript: From 0869b76d1f50e98df5cb930d1639cb8b6aae0b9b Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Thu, 21 Nov 2024 15:38:19 -0800 Subject: [PATCH 11/16] Tweak interactive update command to be more clear, make quit act like an abort and add a done option to save current updates. Update examples to reflect new ui --- docs/Maestro/cli.md | 32 ++++++++++++++++++-------------- maestrowf/maestro.py | 36 ++++++++++++++++++++++-------------- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/docs/Maestro/cli.md b/docs/Maestro/cli.md index 498841a0a..8df6c3b70 100644 --- a/docs/Maestro/cli.md +++ b/docs/Maestro/cli.md @@ -161,27 +161,31 @@ maestro update --rlimit 4 --rlimit 2 /path/to/my/timestamped/study/workspace_1/ ``` $ maestro update ./sample_output/hello_world_restart/hello_bye_world_20241119-173122 -Study in '/path/to/sample_output/hello_world_restart/hello_bye_world_20241119-173122' to be updated. -Choose study config to update, or quit -Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 -[rlimit/throttle/sleep/quit] +Updating study at '/path/to/sample_output/hello_world_restart/hello_bye_world_20241119-173122' +Choose study config to update, or done/quit to finish/abort +[rlimit/throttle/sleep/done/quit] > rlimit -update_menu_choice='rlimit' -Updating restart limit -Enter new restart limit +Enter new restart limit [Integer, 0 = unlimited] > 4 Choose study config to update, or quit -Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 - [rlimit/throttle/sleep/quit] + [rlimit/throttle/sleep/done/quit] > sleep -update_menu_choice='sleep' -Enter new sleep duration for Conductor +Enter new sleep duration for Conductor [Integer, seconds] > 30 Choose study config to update, or quit -Study: sample_output/hello_world_restart/hello_bye_world_20241119-173122 - [rlimit/throttle/sleep/quit] + [rlimit/throttle/sleep/done/quit] > quit -update_menu_choice='quit' +Discarding updates to 'sample_output/hello_world_restart/hello_bye_world_20241119-173122/' +$ maestro update ./sample_output/hello_world_restart/hello_bye_world_20241119-173122 +Updating study at '/path/to/sample_output/hello_world_restart/hello_bye_world_20241119-173122' +Choose study config to update, or done/quit to finish/abort +[rlimit/throttle/sleep/done/quit] +> rlimit +Enter new restart limit [Integer, 0 = unlimited] +> 4 +Choose study config to update, or quit + [rlimit/throttle/sleep/done/quit] +> done Writing updated study config to 'sample_output/hello_world_restart/hello_bye_world_20241119-173122/.study.update.lock' ``` diff --git a/maestrowf/maestro.py b/maestrowf/maestro.py index 906244ce6..1ca66806a 100644 --- a/maestrowf/maestro.py +++ b/maestrowf/maestro.py @@ -159,6 +159,9 @@ def validate_update_args(args, directory_list): Ensure any study config update fields are consistent with the list of study directories. If any config field has > 1 value, it must be of the same length as number of study directories. + + :param args: argparse Namespace containing cli arguments from 'update' command + :returns: True if study update args are valid, False if not """ def validate_attr(namespace_obj, attr_name, dir_list): attr_tmp = getattr(namespace_obj, attr_name) @@ -185,7 +188,11 @@ def validate_attr(namespace_obj, attr_name, dir_list): def expand_update_args(args, directory_list): """ - Take any length one args and replicate for each directory in directory list + Take any length one study update args and replicate for each directory + in directory list + + :param args: argparse Namespace containing cli arguments from 'update' command + :returns: List of dictionaries containing study update args for each directory """ config_args = ["rlimit", "throttle", "sleep"] update_args = {} @@ -203,13 +210,9 @@ def expand_update_args(args, directory_list): inverted_update_args = [] for idx, _ in enumerate(directory_list): inverted_update_args.append( - # { - # cvar: update_args[cvar][idx] - # for cvar in config_args - # if cvar in update_args and update_args[cvar] - # } { - cvar: update_args[cvar][idx] if cvar in update_args and update_args[cvar] else None + cvar: update_args[cvar][idx] + if cvar in update_args and update_args[cvar] else None for cvar in config_args } ) @@ -276,26 +279,31 @@ def update_study_exec(args): console.print(f"Updating study at [blue]'{directory}'[/]") while still_updating: + finish_message = f"No new study config settings provided, skipping update of study at [blue]'{directory}'[/]" update_menu_choice = Prompt.ask( - f"Choose study configuration to update, or quit to abort\n", - choices=["rlimit", "throttle", "sleep", "quit"] + "Choose study configuration to update, or done/quit to finish/abort\n", + choices=["rlimit", "throttle", "sleep", "done", "quit"] ) if update_menu_choice == "rlimit": - console.print("Updating restart limit") - new_limits["rlimit"] = IntPrompt.ask("Enter new restart limit") + new_limits["rlimit"] = IntPrompt.ask("Enter new restart limit [Integer, 0 = unlimited]") elif update_menu_choice == "throttle": - new_limits["throttle"] = IntPrompt.ask("Enter new throttle limit") + new_limits["throttle"] = IntPrompt.ask("Enter new throttle limit [Integer, 0 = no throttling]") elif update_menu_choice == "sleep": - new_limits["sleep"] = IntPrompt.ask("Enter new sleep duration for Conductor") + new_limits["sleep"] = IntPrompt.ask("Enter new sleep duration for Conductor [Integer, seconds]") + elif update_menu_choice == "done": + still_updating = False else: # Quit + finish_message = f"Discarding updates to study at [blue]'{directory}'[/]" + #console.print(f"Discarding updates to study at [blue]'{directory}'[/]") + new_limits = {} # Abandon changes and abort still_updating = False try: if any([update_val for update_arg, update_val in new_limits.items()]): Conductor.update_study_exec(directory, new_limits) else: - console.print("No new study config settings provided; exiting.") + console.print(finish_message) except Exception as excpt: console.print(f"[bold red]ERROR:[/]\n{excpt}") console.print("[bold red]ERROR:[/] Encountered unexpected exception while " From b3d539a0ba91be37052b80f9d2bb764ebf50ad7f Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Thu, 21 Nov 2024 17:10:56 -0800 Subject: [PATCH 12/16] Add unit test for update cli args validation --- maestrowf/maestro.py | 4 ++-- tests/test_cli.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli.py diff --git a/maestrowf/maestro.py b/maestrowf/maestro.py index 1ca66806a..2ee2b6a4b 100644 --- a/maestrowf/maestro.py +++ b/maestrowf/maestro.py @@ -174,8 +174,8 @@ def validate_attr(namespace_obj, attr_name, dir_list): # Add rich formatting, or spin up logger here with stdout only handler? err_msg_template = "ERROR: {var_name} is incompatible with directory: "\ - + "{var_name} have either 1 value (same value for all directories) " \ - + "or the same number of values as study directories." + + "{var_name} must have either 1 value (same value for all " \ + + "directories) or the same number of values as study directories." vars_are_valid = True for update_config_var in ['rlimit', 'throttle', 'sleep']: var_is_valid = validate_attr(args, update_config_var, directory_list) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 000000000..443165cfe --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,30 @@ +import argparse +from maestrowf import maestro +import pytest + +# NOTE: good place for property testing with hypothesis? +@pytest.mark.parametrize( + "cli_args, args_are_valid", + [ + (["--rlimit", "2", 'study_workspace_1'], True), + (["--rlimit", "2", 'study_workspace_1', 'study_workspace_2'], True), + (["--rlimit", "2", "--rlimit", "3", 'study_workspace_1'], False), + (["--rlimit", "2", "--sleep", "30", 'study_workspace_1'], True), + (["--rlimit", "2", "--sleep", "30", 'study_workspace_1', 'study_workspace_2'], True), + (["--rlimit", "2", "--sleep", "30", "--sleep", "32", 'study_workspace_1', 'study_workspace_2'], True), + (["--rlimit", "2", "--sleep", "30", "--sleep", "32", "--sleep", "33", 'study_workspace_1', 'study_workspace_2'], False), + (["--rlimit", "2", "--sleep", "30", "--sleep", "32", 'study_workspace_1', 'study_workspace_2', 'study_workspace_3'], True), + ] +) +def test_validate_update_args(cli_args, args_are_valid): + """ + Test validation of arguments passed to the 'maestro update' cli command + """ + parser = maestro.setup_argparser() + maestro_cli = ["update"] + + maestro_cli.extend(cli_args) + print(f"{maestro_cli=}") + args = parser.parse_args(maestro_cli) + # assert args is None + assert maestro.validate_update_args(args, args.directory) is args_are_valid From 63220bbad393b468cc286212775f29186efc5b9a Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Thu, 21 Nov 2024 17:27:44 -0800 Subject: [PATCH 13/16] Add test of update argument expansion, fix incorrect types found via the test --- maestrowf/maestro.py | 6 ++--- tests/test_cli.py | 54 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/maestrowf/maestro.py b/maestrowf/maestro.py index 2ee2b6a4b..6d02b0705 100644 --- a/maestrowf/maestro.py +++ b/maestrowf/maestro.py @@ -559,7 +559,7 @@ def setup_argparser(): update.add_argument( "--throttle", action="append", - type=str, + type=int, default=[], help="Update the maximum number of inflight jobs allowed to execute" " simultaneously (0 denotes no throttling)." @@ -567,7 +567,7 @@ def setup_argparser(): update.add_argument( "--rlimit", action="append", - type=str, + type=int, default=[], help="Update the maximum number of restarts allowed when steps " "specify a restart command (0 denotes no limit)." @@ -575,7 +575,7 @@ def setup_argparser(): update.add_argument( "--sleep", action="append", - type=str, + type=int, default=[], help="Update the time (in seconds) that the manager will " "wait between job status checks.", diff --git a/tests/test_cli.py b/tests/test_cli.py index 443165cfe..dc0f6337a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,7 +13,7 @@ (["--rlimit", "2", "--sleep", "30", 'study_workspace_1', 'study_workspace_2'], True), (["--rlimit", "2", "--sleep", "30", "--sleep", "32", 'study_workspace_1', 'study_workspace_2'], True), (["--rlimit", "2", "--sleep", "30", "--sleep", "32", "--sleep", "33", 'study_workspace_1', 'study_workspace_2'], False), - (["--rlimit", "2", "--sleep", "30", "--sleep", "32", 'study_workspace_1', 'study_workspace_2', 'study_workspace_3'], True), + (["--rlimit", "2", "--sleep", "30", "--sleep", "32", 'study_workspace_1', 'study_workspace_2', 'study_workspace_3'], False), ] ) def test_validate_update_args(cli_args, args_are_valid): @@ -28,3 +28,55 @@ def test_validate_update_args(cli_args, args_are_valid): args = parser.parse_args(maestro_cli) # assert args is None assert maestro.validate_update_args(args, args.directory) is args_are_valid + + +@pytest.mark.parametrize( + "cli_args, expected_expanded_args", + [ + ( + ["--rlimit", "2", 'study_workspace_1'], + [{'rlimit': 2, 'throttle': None, 'sleep': None}, ] + ), + ( + ["--rlimit", "2", 'study_workspace_1', 'study_workspace_2'], + [ + {'rlimit': 2, 'throttle': None, 'sleep': None}, + {'rlimit': 2, 'throttle': None, 'sleep': None} + ] + ), + ( + ["--rlimit", "2", "--sleep", "30", 'study_workspace_1'], + [{'rlimit': 2, 'sleep': 30, 'throttle': None}, ] + ), + ( + ["--rlimit", "2", "--sleep", "30", 'study_workspace_1', 'study_workspace_2'], + [ + {'rlimit': 2, 'sleep': 30, 'throttle': None}, + {'rlimit': 2, 'sleep': 30, 'throttle': None} + ] + ), + ( + ["--rlimit", "2", "--sleep", "30", "--sleep", "32", 'study_workspace_1', 'study_workspace_2'], + [ + {'rlimit': 2, 'sleep': 30, 'throttle': None}, + {'rlimit': 2, 'sleep': 32, 'throttle': None} + ], + ), + ] +) +def test_expand_update_args(cli_args, expected_expanded_args): + """ + Test expansion of arguments passed to the 'maestro update' cli command, + i.e. repeating single values for all study workspaces being updated + """ + parser = maestro.setup_argparser() + maestro_cli = ["update"] + + maestro_cli.extend(cli_args) + print(f"{maestro_cli=}") + args = parser.parse_args(maestro_cli) + expanded_args = maestro.expand_update_args(args, args.directory) + print(f"{args=}") + print(f"{expanded_args=}") + print(f"{expected_expanded_args=}") + assert expanded_args == expected_expanded_args From 0993a030a66f8da9dda401cb59fcbdf638048f8e Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Thu, 21 Nov 2024 18:31:01 -0800 Subject: [PATCH 14/16] Add test for writing and reading study update lock files --- tests/test_cli.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index dc0f6337a..b3f2c8a45 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,10 @@ -import argparse from maestrowf import maestro +from maestrowf.conductor import Conductor + import pytest +from rich.pretty import pprint + # NOTE: good place for property testing with hypothesis? @pytest.mark.parametrize( "cli_args, args_are_valid", @@ -80,3 +83,34 @@ def test_expand_update_args(cli_args, expected_expanded_args): print(f"{expanded_args=}") print(f"{expected_expanded_args=}") assert expanded_args == expected_expanded_args + + +@pytest.mark.parametrize( + "cli_args, expected_lock_dict", + [ + ( + ["--rlimit", "2"], + {'rlimit': 2, 'throttle': None, 'sleep': None}, + ), + ] +) +def test_write_update_args(cli_args, expected_lock_dict, tmp_path): + """ + Test writing and reading of study config updates, + """ + parser = maestro.setup_argparser() + maestro_cli = ["update"] + + maestro_cli.extend(cli_args) + pprint(f"{tmp_path=}") + maestro_cli.append(str(tmp_path)) + print(f"{maestro_cli=}") + args = parser.parse_args(maestro_cli) + print(f"{args=}") + print(f"{expected_lock_dict=}") + + maestro.update_study_exec(args) + + update_dict = Conductor.load_updated_study_exec(tmp_path) + + assert update_dict == expected_lock_dict From 6602cb34a152425743db029dff9268649b72782e Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Thu, 12 Dec 2024 11:16:55 -0800 Subject: [PATCH 15/16] Remove commented out dependencies --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f92f04c7b..4944b9b51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,14 +37,12 @@ python = ">=3.8,<4.0" coloredlogs = "*" dill = "*" filelock = "*" -# importlib_metadata = {version = "*", python = "<3.8"} jsonschema = ">=3.2.0" packaging = ">=22.0" pyyaml = ">=4.2b1" rich = "*" six = "*" tabulate = "*" -#mkdocs-mermaid2-plugin = "^1.1.1" [tool.poetry.group.dev.dependencies] coverage = "*" From a5acdcbcff0422ecc63e8a2905579cd1ab79af01 Mon Sep 17 00:00:00 2001 From: Jeremy White <44277022+jwhite242@users.noreply.github.com> Date: Thu, 12 Dec 2024 11:23:09 -0800 Subject: [PATCH 16/16] tick dev version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4944b9b51..6508ee229 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool] [tool.poetry] name = "maestrowf" -version = "1.1.11dev1" +version = "1.1.11dev2" description = "A tool to easily orchestrate general computational workflows both locally and on supercomputers." license = "MIT License" classifiers = [