Source code for ramble.expander

# Copyright 2022-2025 The Ramble Authors
#
# Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
# https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
# <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
# option. This file may not be copied, modified, or distributed
# except according to those terms.

import string
import ast
import operator
import math
import random

from typing import Dict

import ramble.error
import ramble.keywords
from ramble.util.logger import logger

import spack.util.naming


def _and(a, b):
    return a and b


def _or(a, b):
    return a or b


def _re_search(regex, s):
    import re

    return re.search(regex, s) is not None


def _safe_str_node_check(node):
    # ast.Str was deprecated. short-circuit the test for it to avoid issues with newer python.
    return hasattr(ast, "Str") and isinstance(node, ast.Str)


supported_math_operators = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
    ast.FloorDiv: operator.floordiv,
    ast.Pow: operator.pow,
    ast.BitXor: operator.xor,
    ast.USub: operator.neg,
    ast.Eq: operator.eq,
    ast.NotEq: operator.ne,
    ast.Gt: operator.gt,
    ast.GtE: operator.ge,
    ast.Lt: operator.lt,
    ast.LtE: operator.le,
    ast.And: _and,
    ast.Or: _or,
    ast.Mod: operator.mod,
}

supported_scalar_function_pointers = {
    "str": str,
    "int": int,
    "float": float,
    "max": max,
    "min": min,
    "ceil": math.ceil,
    "floor": math.floor,
    "randrange": random.randrange,
    "randint": random.randint,
    "simplify_str": spack.util.naming.simplify_name,
    "re_search": _re_search,
}


supported_list_function_pointers = {
    "range": range,
}


formatter = string.Formatter()


[docs] class ExpansionDelimiter: """Class representing the delimiters for ramble expansion strings""" left = "{" right = "}" escape = "\\"
[docs] class VformatDelimiter: """Class representing the delimiters for the string.Formatter class""" left = "{" right = "}"
[docs] class ExpansionNode: """Class representing a node in a ramble expansion graph""" def __init__(self, left_idx, right_idx): self.left = left_idx self.right = right_idx self.children = [] self.idx = None self.contents = None self.value = None self.root = None def __str__(self): lines = [] lines.append(" Node:") lines.append(f" Indices: ({self.left}, {self.right})") lines.append(f" Num Children: ({len(self.children)})") lines.append(f' Contents: "{self.contents}"') lines.append(f' Value: "{self.value}"') lines.append(f' Is root: "{self is self.root}"') return "\n".join(lines)
[docs] def relative_indices(self, relative_to): """Compute node indices relative to another node Args: relative_to (node): node to shift current node's indices relative to Returns: (tuple) indices of shifted match set """ return (self.left - relative_to.left, self.right - relative_to.left)
[docs] def add_children(self, children): """Add children to this node Args: children (node, or list): nodes to adds as children of self """ if isinstance(children, list): self.children.extend(children) else: self.children.append(children)
[docs] def define_value( self, expansion_dict, allow_passthrough=True, expansion_func=str, evaluation_func=eval, no_expand_vars=None, used_vars=None, ): """Define the value for this node. Construct the value of self. This builds up a string representation of self, and performs evaluation and formatting of the resulting string. This includes extracting the values of the children nodes, and replacing their values in the proper positions in self's string. Stores the resulting value in self.value Args: expansion_dict (dict): variable definitions to use for expanding detected matches allow_passthrough (bool): if true, expansion is allowed to fail. if false, failed expansion raises an error. expansion_func (func): function to use for expansion of nested variable definitions evaluation_func (func): function to use for evaluating math of strings no_expand_vars (set): set of variable names that should never be expanded """ if no_expand_vars is None: no_expand_vars = set() if used_vars is None: used_vars = set() if self.contents is not None: parts = [] last_idx = 0 for child in self.children: child_indices = child.relative_indices(self) parts.append(self.contents[last_idx : child_indices[0]]) parts.append(str(child.value)) last_idx = child_indices[1] + 1 if last_idx != len(self.contents): parts.append(self.contents[last_idx:]) if self != self.root: replaced_contents = "".join(parts) # Special case '{}' if len(replaced_contents) == 2: self.value = "{}" return format_kw = replaced_contents[1:-1] kw_parts = format_kw.split(":") required_passthrough = False if kw_parts[0] in expansion_dict: used_vars.add(kw_parts[0]) # Exit expansion for variables defined as no_expand if kw_parts[0] in no_expand_vars: self.value = expansion_dict[kw_parts[0]] return else: self.value = expansion_func( expansion_dict, expansion_dict[kw_parts[0]], allow_passthrough=allow_passthrough, ) else: self.value = kw_parts[0] required_passthrough = True # Evaluation should go here try: old_value = self.value self.value = evaluation_func(self.value) if old_value != self.value: required_passthrough = False except SyntaxError: pass # If we had a format spec, add it if len(kw_parts) > 1: kw_dict = {"value": self.value} format_str = f"value:{kw_parts[1]}" try: self.value = formatter.vformat( VformatDelimiter.left + format_str + VformatDelimiter.right, [], kw_dict, ) required_passthrough = False except ValueError: self.value += f":{kw_parts[1]}" except KeyError: self.value += f":{kw_parts[1]}" if required_passthrough: self.value = f"{{{self.value}}}" if not allow_passthrough: raise_passthrough_error(self.contents, self.value) else: replaced_contents = "".join(parts) try: self.value = evaluation_func(replaced_contents) except SyntaxError: self.value = replaced_contents # Replace escaped curly braces with curly braces if isinstance(self.value, str): self.value = self.value.replace("\\{", "{").replace("\\}", "}")
[docs] class ExpansionGraph: """Class representing a graph of ExpansionNodes""" def __init__(self, in_str): self.str = in_str self.root = ExpansionNode(0, len(in_str) - 1) self.root.contents = in_str self.root.root = self.root opened = [] children = [] escaped = False for i, c in enumerate(self.str): if c == ExpansionDelimiter.left and not escaped: opened.append(i) children.append([]) elif c == ExpansionDelimiter.right and len(opened) > 0 and not escaped: left_idx = opened.pop() right_idx = i cur_match = ExpansionNode(left_idx, right_idx) cur_match.add_children(children.pop()) cur_match.contents = self.str[left_idx : right_idx + 1] # Define contents cur_match.root = self.root if len(opened) > 0: children[-1].append(cur_match) else: self.root.add_children(cur_match) elif c == "\n": # Don't expand across new lines opened = [] if c == ExpansionDelimiter.escape: escaped = True elif escaped: escaped = False if len(opened) > 0: self.root.add_children(children.pop())
[docs] def walk(self, in_node=None): """Perform a DFS walk of the nodes in the graph Args: in_node (ExpansionNode): node to begin the walk from, if not set uses self.root Yields: (ExpansionNode): nodes following a DFS traversal of the graph """ cur_node = in_node if cur_node is None: cur_node = self.root for child in cur_node.children: yield from self.walk(in_node=child) yield cur_node
def __str__(self): lines = [] lines.append(f"Processing string: {self.str}") for node in self.walk(): lines.append(f"{node}") return "\n".join(lines)
[docs] class ExpansionDict(dict): def __missing__(self, key): return "{" + key + "}"
[docs] class Expander: """A class that will track and expand keyword arguments This class will track variables and their definitions, to allow for expansion within string. The variables can come from workspace variables, software stack variables, and experiment variables. Additionally, math will be evaluated as part of expansion. """ _ast_dbg_prefix = "EXPANDER AST:" def __init__(self, variables, experiment_set, no_expand_vars=None): if no_expand_vars is None: no_expand_vars = set() self._keywords = ramble.keywords.keywords self._variables = variables self._no_expand_vars = no_expand_vars self._used_variables = set() self._used_variable_stage = set() self._experiment_set = experiment_set self._application_name = None self._workload_name = None self._experiment_name = None self._application_namespace = None self._workload_namespace = None self._experiment_namespace = None self._env_path = None self._application_input_dir = None self._workload_input_dir = None self._license_input_dir = None self._application_run_dir = None self._workload_run_dir = None self._experiment_run_dir = None
[docs] def add_no_expand_var(self, var: str): """Add a new variable to the no expand set Args: var (str): Variable that should not expand """ self._no_expand_vars.add(var)
[docs] def set_no_expand_vars(self, no_expand_vars): self._no_expand_vars = no_expand_vars.copy()
[docs] def flush_used_variable_stage(self): self._used_variable_stage = set()
[docs] def merge_used_variable_stage(self): self._used_variables = self._used_variables.union(self._used_variable_stage) self.flush_used_variable_stage()
[docs] def copy(self): return Expander(self._variables.copy(), self._experiment_set)
@property def application_name(self): if not self._application_name: self._application_name = self.expand_var_name(self._keywords.application_name) return self._application_name @property def workload_name(self): if not self._workload_name: self._workload_name = self.expand_var_name(self._keywords.workload_name) return self._workload_name @property def experiment_name(self): if not self._experiment_name: self._experiment_name = self.expand_var_name(self._keywords.experiment_name) return self._experiment_name @property def application_namespace(self): if not self._application_namespace: self._application_namespace = self.application_name return self._application_namespace @property def workload_namespace(self): if not self._workload_namespace: self._workload_namespace = f"{self.application_name}.{self.workload_name}" return self._workload_namespace @property def experiment_namespace(self): if not self._experiment_namespace: self._experiment_namespace = "{}.{}.{}".format( self.application_name, self.workload_name, self.experiment_name, ) return self._experiment_namespace @property def env_path(self): if not self._env_path: var = self.expansion_str(self._keywords.env_path) self._env_path = self.expand_var(var) return self._env_path @property def application_input_dir(self): if not self._application_input_dir: self._application_input_dir = self.expand_var_name( self._keywords.application_input_dir ) return self._application_input_dir @property def workload_input_dir(self): if not self._workload_input_dir: self._workload_input_dir = self.expand_var_name(self._keywords.workload_input_dir) return self._workload_input_dir @property def license_input_dir(self): if not self._license_input_dir: self._license_input_dir = self.expand_var_name(self._keywords.license_input_dir) return self._license_input_dir @property def application_run_dir(self): if not self._application_run_dir: self._application_run_dir = self.expand_var_name(self._keywords.application_run_dir) return self._application_run_dir @property def workload_run_dir(self): if not self._workload_run_dir: self._workload_run_dir = self.expand_var_name(self._keywords.workload_run_dir) return self._workload_run_dir @property def experiment_run_dir(self): if not self._experiment_run_dir: self._experiment_run_dir = self.expand_var_name(self._keywords.experiment_run_dir) return self._experiment_run_dir
[docs] def expand_lists(self, var): """Expand a variable into a list if possible If expanding a variable would generate a list, this function will return a list. If any error case happens, this function will return the unmodified input value. NOTE: This function is generally called early in the expansion. This allows lists to be generated before rendering experiments, but does not support pulling a list from a different experiment. """ try: math_ast = ast.parse(str(var), mode="eval") value = self.eval_math(math_ast.body) if isinstance(value, list): return value return var except MathEvaluationError: return var except AttributeError: return var except ValueError: return var except SyntaxError: return var
[docs] def expand_var_name( self, var_name: str, extra_vars: Dict = None, allow_passthrough: bool = True, typed: bool = False, merge_used_stage: bool = True, ): """Convert a variable name to an expansion string, and expand it Take a variable name (var) and convert it to an expansion string by calling the expansion_str function. Pass the expansion string into expand_var, and return the result. Args: var_name (str): String name of variable to expand extra_vars (dict): Variable definitions to use with highest precedence allow_passthrough (bool): Whether the string is allowed to have keywords after expansion typed (bool): Whether the return type should be typed or not merge_used_stage (bool): Whether tracked variables are merged into the used variable set or not. """ return self.expand_var( self.expansion_str(var_name), extra_vars=extra_vars, allow_passthrough=allow_passthrough, typed=typed, merge_used_stage=merge_used_stage, )
[docs] def expand_var( self, var: str, extra_vars: Dict = None, allow_passthrough: bool = True, typed: bool = False, merge_used_stage: bool = True, ): """Perform expansion of a string Expand a string by building up a dict of all expansion variables. Args: var (str): String variable to expand extra_vars (dict): Variable definitions to use with highest precedence allow_passthrough (bool): Whether the string is allowed to have keywords after expansion typed (bool): Whether the return type should be typed or not merge_used_stage (bool): Whether tracked variables are merged into the used variable set or not. """ passthrough_setting = allow_passthrough # If disable_passthrough is set, override allow_passthrough from caller if ramble.config.get("config:disable_passthrough"): passthrough_setting = False logger.debug(f"BEGINNING OF EXPAND_VAR STACK ON {var}") expansions = self._variables if extra_vars: expansions = self._variables.copy() expansions.update(extra_vars) try: value = self._partial_expand( expansions, str(var), allow_passthrough=passthrough_setting ).lstrip() except RamblePassthroughError as e: if not passthrough_setting: raise RambleSyntaxError( f"Encountered a passthrough error while expanding {var}\n" f"{e}" ) logger.debug(f"END OF EXPAND_VAR STACK {value}") if typed: logger.debug(f"BEGINNING OF TYPING ON {value}") try: value = ast.literal_eval(value) logger.debug(f"END OF TYPING {value}") except ValueError: logger.debug("END OF TYPING Failed with ValueError") except SyntaxError: logger.debug("END OF TYPING Failed with SyntaxError") if merge_used_stage: self.merge_used_variable_stage() return value
[docs] def evaluate_predicate(self, in_str, extra_vars=None, merge_used_stage: bool = True): """Evaluate a predicate by expanding and evaluating math contained in a string Args: in_str: String representing predicate that should be evaluated extra_vars: Variable definitions to use with highest precedence Returns: boolean: True or False, based on the evaluation of in_str """ evaluated = self.expand_var( in_str, extra_vars=extra_vars, allow_passthrough=False, merge_used_stage=merge_used_stage, ) if not isinstance(evaluated, str): logger.die("Logical compute failed to return a string") if evaluated == "True": return True elif evaluated == "False": return False else: logger.die( f"When evaluating {in_str}, evaluate_predicate returned " f'a non-boolean string: "{evaluated}"' )
[docs] @staticmethod def expansion_str(in_str): return f"{ExpansionDelimiter.left}{in_str}{ExpansionDelimiter.right}"
def _partial_expand(self, expansion_vars, in_str, allow_passthrough=True): """Perform expansion of a string with some variables args: expansion_vars (dict): Variables to perform expansion with in_str (str): Input template string to expand allow_passthrough (bool): Define if variables are allowed to passthrough without being expanded. returns: in_str (str): Expanded version of input string """ if isinstance(in_str, str): str_graph = ExpansionGraph(in_str) for node in str_graph.walk(): node.define_value( expansion_vars, allow_passthrough=allow_passthrough, expansion_func=self._partial_expand, evaluation_func=self.perform_math_eval, no_expand_vars=self._no_expand_vars, used_vars=self._used_variable_stage, ) return str(str_graph.root.value) return str(in_str)
[docs] def perform_math_eval(self, in_str): """Attempt to evaluate in_str Args: in_str (str): string representing math to attempt to evaluate Returns: (str) either the evaluation of in_str (if successful) or in_str unmodified (if unsuccessful) """ try: math_ast = ast.parse(in_str, mode="eval") out_str = self.eval_math(math_ast.body) return out_str except MathEvaluationError as e: logger.debug(f' Math input is: "{in_str}"') logger.debug(e) except RambleSyntaxError as e: raise RambleSyntaxError(f'{str(e)} in "{in_str}"') return in_str
[docs] def eval_math(self, node): """Evaluate math from parsing the AST Does not assume a specific type of operands. Some operators will generate floating point, while others will generate integers (if the inputs are integers). """ try: if isinstance(node, ast.Num): return self._ast_num(node) elif isinstance(node, ast.Constant): return self._ast_constant(node) elif isinstance(node, ast.Name): return self._ast_name(node) # TODO: Remove when we drop support for 3.6 # DEPRECATED: Remove due to python 3.8 # See: https://docs.python.org/3/library/ast.html#node-classes elif isinstance(node, ast.Str): return node.s elif isinstance(node, ast.Attribute): return self._ast_attr(node) elif isinstance(node, ast.Compare): return self._eval_comparisons(node) elif isinstance(node, ast.BoolOp): return self._eval_bool_op(node) elif isinstance(node, ast.BinOp): return self._eval_binary_ops(node) elif isinstance(node, ast.UnaryOp): return self._eval_unary_ops(node) elif isinstance(node, ast.Call): return self._eval_function_call(node) elif isinstance(node, ast.Subscript): return self._eval_subscript_op(node) else: node_type = str(type(node)) raise MathEvaluationError( f"Unsupported math AST node {node_type}:\n" + f"\t{node.__dict__}" ) except SyntaxError as e: logger.debug(str(e)) raise e
# Ast logic helper methods def __raise_syntax_error(self, node): node_type = str(type(node)) raise RambleSyntaxError( f"Syntax error while processing {node_type} node:\n" + f"{node.__dict__}" ) def __dbg_syntax_error(self, msg, node): node_type = str(type(node)) raise SyntaxError( self._ast_dbg_prefix + f" {msg}\n" + f"Occurred while processing {node_type} node:\n" + f"{node.__dict__}" ) def _ast_num(self, node): """Handle a number node in the ast""" return node.n def _ast_constant(self, node): """Handle a constant node in the ast""" return node.value def _ast_name(self, node): """Handle a name node in the ast""" return node.id def _ast_attr(self, node): """Handle an attribute node in the ast""" if isinstance(node.value, ast.Attribute): base = self._ast_attr(node.value) elif isinstance(node.value, ast.Name): base = self._ast_name(node.value) else: self.__raise_syntax_error(node) val = f"{base}.{node.attr}" return val def _eval_function_call(self, node): """Handle a subset of function call nodes in the ast""" args = [] kwargs = {} for arg in node.args: args.append(self.eval_math(arg)) for kw in node.keywords: kwargs[self.eval_math(kw.arg)] = self.eval_math(kw.value) if node.func.id in supported_scalar_function_pointers.keys(): func = supported_scalar_function_pointers[node.func.id] return func(*args, **kwargs) elif node.func.id in supported_list_function_pointers.keys(): func = supported_list_function_pointers[node.func.id] return list(func(*args, **kwargs)) elif node.func.id == "replace": return str(args[0]).replace(*args[1:], **kwargs) else: raise MathEvaluationError( f"Undefined function {node.func.id} used.\n" "returning unexapanded string" ) def _eval_bool_op(self, node): """Handle a boolean operator node in the ast""" try: op = supported_math_operators[type(node.op)] result = self.eval_math(node.values[0]) for value in node.values[1:]: result = op(result, self.eval_math(value)) return result except TypeError: self.__dbg_syntax_error("Unsupported operand type in boolean operator", node) except KeyError: self.__dbg_syntax_error("Unsupported boolean operator", node) def _eval_comparisons(self, node): """Handle a comparison node in the ast""" # Extract In or NotIn nodes, and call their helper if len(node.ops) == 1 and isinstance(node.ops[0], (ast.In, ast.NotIn)): is_in = self._eval_comp_in(node) if isinstance(node.ops[0], ast.NotIn): return not is_in return is_in if len(node.ops) == 1 and isinstance(node.ops[0], ast.Is): raise RambleSyntaxError("Encountered unsupported operator `is`") # Try to evaluate the comparison logic, if not return the node as is. try: cur_left = self.eval_math(node.left) op = supported_math_operators[type(node.ops[0])] cur_right = self.eval_math(node.comparators[0]) result = op(cur_left, cur_right) if len(node.ops) > 1: cur_left = cur_right for comp, right in zip(node.ops, node.comparators)[1:]: op = supported_math_operators[type(comp)] cur_right = self.eval_math(right) result = result and op(cur_left, cur_right) cur_left = cur_right return result except TypeError: self.__dbg_syntax_error("Unsupported operand type in binary comparison operator", node) except KeyError: self.__dbg_syntax_error("Unsupported binary comparison operator", node) def _eval_comp_in(self, node): """Handle in node in the ast Perform extraction of `<variable> in <experiment>` syntax. Raises an exception if the experiment does not exist. Also, evaluated `<value> in [list, of, values]` and `<value> in "str"` syntaxes. """ if isinstance(node.left, ast.Name): var_name = self._ast_name(node.left) if isinstance(node.comparators[0], ast.Attribute): namespace = self.eval_math(node.comparators[0]) val = self._experiment_set.get_var_from_experiment( namespace, self.expansion_str(var_name) ) if not val: raise RambleSyntaxError( f"{namespace} does not exist in: " + f'"{var_name} in {namespace}"' ) self.__raise_syntax_error(node) return val # TODO: Remove `or` logic after 3.6 & 3.7 series python are unsupported elif isinstance(node.left, ast.Constant) or _safe_str_node_check(node.left): lhs_value = self.eval_math(node.left) found = False for comp in node.comparators: if isinstance(comp, ast.List): for elt in comp.elts: rhs_value = self.eval_math(elt) if lhs_value == rhs_value: found = True elif isinstance(comp, ast.Constant) or _safe_str_node_check(comp): # Attempt evaluating `"str" in "string"` rhs_value = self.eval_math(comp) if isinstance(rhs_value, str) and lhs_value in rhs_value: found = True return found self.__raise_syntax_error(node) def _eval_binary_ops(self, node): """Evaluate binary operators in the ast Extract the binary operator, and evaluate it. """ try: left_eval = self.eval_math(node.left) right_eval = self.eval_math(node.right) op = supported_math_operators[type(node.op)] if isinstance(left_eval, str) or isinstance(right_eval, str): self.__dbg_syntax_error("Unsupported operand type in binary operator", node) return op(left_eval, right_eval) except TypeError: self.__dbg_syntax_error("Unsupported operand type in binary operator", node) except KeyError: self.__dbg_syntax_error("Unsupported binary operator", node) def _eval_unary_ops(self, node): """Evaluate unary operators in the ast Extract the unary operator, and evaluate it. """ try: operand = self.eval_math(node.operand) if isinstance(operand, str): self.__dbg_syntax_error("Unsupported operand type in unary operator", node) op = supported_math_operators[type(node.op)] return op(operand) except TypeError: self.__dbg_syntax_error("Unsupported operand type in unary operator", node) except KeyError: self.__dbg_syntax_error("Unsupported unary operator", node) def _eval_subscript_op(self, node): """Evaluate subscript operation in the ast""" try: operand = self.eval_math(node.value) slice_node = node.slice if isinstance(operand, str): if isinstance(slice_node, ast.Slice): def _get_with_default(s_node, attr, default): v_node = getattr(s_node, attr) if v_node is None: return default return self.eval_math(v_node) lower = _get_with_default(slice_node, "lower", 0) upper = _get_with_default(slice_node, "upper", len(operand)) step = _get_with_default(slice_node, "step", 1) return operand[slice(lower, upper, step)] elif operand in self._variables and isinstance(self._variables[operand], dict): op_dict = self.expand_var_name(operand, typed=True) key = None # TODO: Remove after support for python 3.9 is dropped # DEPRECATED: ast.Index was dropped in python 3.9 if hasattr(ast, "Index") and isinstance(slice_node, ast.Index): key = self.eval_math(slice_node.value) elif isinstance(slice_node, ast.Constant) or _safe_str_node_check(slice_node): key = self.eval_math(slice_node) if key is None: msg = ( "During dictionary extraction, key is None. " + "Skipping extraction." ) self.__dbg_syntax_error(msg, node) if key not in op_dict: msg = ( f"Key {key} is not in dictionary {operand}. " + "Cannot extract value." ) self.__dbg_syntax_error(msg, node) return op_dict[key] msg = ( "Currently subscripts are only support " + "for string slicing, and key extraction from dictionaries" ) self.__dbg_syntax_error(msg, node) except TypeError: msg = "Unsupported operand type in subscript operator" self.__dbg_syntax_error(msg, node)
[docs] def raise_passthrough_error(in_str, out_str): """Raise an error when passthrough is disabled but variables are not all expanded""" logger.debug(f"Expansion stack errors: attempted to expand " f'"{in_str}"') logger.debug(f" As: {out_str}") raise RamblePassthroughError("Error Stack:\n" f'Input: "{in_str}"\n' f'Output: "{out_str}"\n')
[docs] class ExpanderError(ramble.error.RambleError): """Raised when an error happens within an expander"""
[docs] class MathEvaluationError(ExpanderError): """Raised when an error happens while evaluating math during expansion """
[docs] class RambleSyntaxError(ExpanderError): """Raised when a syntax error happens within variable definitions"""
[docs] class RamblePassthroughError(ExpanderError): """Raised when passthrough is disabled and variables fail to expand"""
[docs] class ApplicationNotDefinedError(ExpanderError): """Raised when an application is not defined properly"""
[docs] class WorkloadNotDefinedError(ExpanderError): """Raised when a workload is not defined properly"""
[docs] class ExperimentNotDefinedError(ExpanderError): """Raised when an experiment is not defined properly"""