Merge "Add support for conditional execution"
This commit is contained in:
		
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 22 KiB | 
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 20 KiB | 
| @@ -124,6 +124,11 @@ or if needed will wait for all of the atoms it depends on to complete. | |||||||
|   An engine running a task also transitions the task to the ``PENDING`` state |   An engine running a task also transitions the task to the ``PENDING`` state | ||||||
|   after it was reverted and its containing flow was restarted or retried. |   after it was reverted and its containing flow was restarted or retried. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | **IGNORE** - When a conditional decision has been made to skip (not | ||||||
|  | execute) the task the engine will transition the task to | ||||||
|  | the ``IGNORE`` state. | ||||||
|  |  | ||||||
| **RUNNING** - When an engine running the task starts to execute the task, the | **RUNNING** - When an engine running the task starts to execute the task, the | ||||||
| engine will transition the task to the ``RUNNING`` state, and the task will | engine will transition the task to the ``RUNNING`` state, and the task will | ||||||
| stay in this state until the tasks :py:meth:`~taskflow.task.BaseTask.execute` | stay in this state until the tasks :py:meth:`~taskflow.task.BaseTask.execute` | ||||||
| @@ -171,6 +176,10 @@ flow that the retry is associated with by consulting its | |||||||
|   An engine running a retry also transitions the retry to the ``PENDING`` state |   An engine running a retry also transitions the retry to the ``PENDING`` state | ||||||
|   after it was reverted and its associated flow was restarted or retried. |   after it was reverted and its associated flow was restarted or retried. | ||||||
|  |  | ||||||
|  | **IGNORE** - When a conditional decision has been made to skip (not | ||||||
|  | execute) the retry the engine will transition the retry to | ||||||
|  | the ``IGNORE`` state. | ||||||
|  |  | ||||||
| **RUNNING** - When an engine starts to execute the retry, the engine | **RUNNING** - When an engine starts to execute the retry, the engine | ||||||
| transitions the retry to the ``RUNNING`` state, and the retry stays in this | transitions the retry to the ``RUNNING`` state, and the retry stays in this | ||||||
| state until its :py:meth:`~taskflow.retry.Retry.execute` method returns. | state until its :py:meth:`~taskflow.retry.Retry.execute` method returns. | ||||||
|   | |||||||
| @@ -14,6 +14,8 @@ | |||||||
| #    License for the specific language governing permissions and limitations | #    License for the specific language governing permissions and limitations | ||||||
| #    under the License. | #    under the License. | ||||||
|  |  | ||||||
|  | import itertools | ||||||
|  |  | ||||||
| from networkx.algorithms import traversal | from networkx.algorithms import traversal | ||||||
| import six | import six | ||||||
|  |  | ||||||
| @@ -21,6 +23,60 @@ from taskflow import retry as retry_atom | |||||||
| from taskflow import states as st | from taskflow import states as st | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IgnoreDecider(object): | ||||||
|  |     """Checks any provided edge-deciders and determines if ok to run.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, atom, edge_deciders): | ||||||
|  |         self._atom = atom | ||||||
|  |         self._edge_deciders = edge_deciders | ||||||
|  |  | ||||||
|  |     def check(self, runtime): | ||||||
|  |         """Returns bool of whether this decider should allow running.""" | ||||||
|  |         results = {} | ||||||
|  |         for name in six.iterkeys(self._edge_deciders): | ||||||
|  |             results[name] = runtime.storage.get(name) | ||||||
|  |         for local_decider in six.itervalues(self._edge_deciders): | ||||||
|  |             if not local_decider(history=results): | ||||||
|  |                 return False | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def affect(self, runtime): | ||||||
|  |         """If the :py:func:`~.check` returns false, affects associated atoms. | ||||||
|  |  | ||||||
|  |         This will alter the associated atom + successor atoms by setting there | ||||||
|  |         state to ``IGNORE`` so that they are ignored in future runtime | ||||||
|  |         activities. | ||||||
|  |         """ | ||||||
|  |         successors_iter = runtime.analyzer.iterate_subgraph(self._atom) | ||||||
|  |         runtime.reset_nodes(itertools.chain([self._atom], successors_iter), | ||||||
|  |                             state=st.IGNORE, intention=st.IGNORE) | ||||||
|  |  | ||||||
|  |     def check_and_affect(self, runtime): | ||||||
|  |         """Handles :py:func:`~.check` + :py:func:`~.affect` in right order.""" | ||||||
|  |         proceed = self.check(runtime) | ||||||
|  |         if not proceed: | ||||||
|  |             self.affect(runtime) | ||||||
|  |         return proceed | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NoOpDecider(object): | ||||||
|  |     """No-op decider that says it is always ok to run & has no effect(s).""" | ||||||
|  |  | ||||||
|  |     def check(self, runtime): | ||||||
|  |         """Always good to go.""" | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def affect(self, runtime): | ||||||
|  |         """Does nothing.""" | ||||||
|  |  | ||||||
|  |     def check_and_affect(self, runtime): | ||||||
|  |         """Handles :py:func:`~.check` + :py:func:`~.affect` in right order. | ||||||
|  |  | ||||||
|  |         Does nothing. | ||||||
|  |         """ | ||||||
|  |         return self.check(runtime) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Analyzer(object): | class Analyzer(object): | ||||||
|     """Analyzes a compilation and aids in execution processes. |     """Analyzes a compilation and aids in execution processes. | ||||||
|  |  | ||||||
| @@ -35,18 +91,21 @@ class Analyzer(object): | |||||||
|         self._storage = runtime.storage |         self._storage = runtime.storage | ||||||
|         self._execution_graph = runtime.compilation.execution_graph |         self._execution_graph = runtime.compilation.execution_graph | ||||||
|         self._check_atom_transition = runtime.check_atom_transition |         self._check_atom_transition = runtime.check_atom_transition | ||||||
|  |         self._fetch_edge_deciders = runtime.fetch_edge_deciders | ||||||
|  |  | ||||||
|     def get_next_nodes(self, node=None): |     def get_next_nodes(self, node=None): | ||||||
|  |         """Get next nodes to run (originating from node or all nodes).""" | ||||||
|         if node is None: |         if node is None: | ||||||
|             execute = self.browse_nodes_for_execute() |             execute = self.browse_nodes_for_execute() | ||||||
|             revert = self.browse_nodes_for_revert() |             revert = self.browse_nodes_for_revert() | ||||||
|             return execute + revert |             return execute + revert | ||||||
|  |  | ||||||
|         state = self.get_state(node) |         state = self.get_state(node) | ||||||
|         intention = self._storage.get_atom_intention(node.name) |         intention = self._storage.get_atom_intention(node.name) | ||||||
|         if state == st.SUCCESS: |         if state == st.SUCCESS: | ||||||
|             if intention == st.REVERT: |             if intention == st.REVERT: | ||||||
|                 return [node] |                 return [ | ||||||
|  |                     (node, NoOpDecider()), | ||||||
|  |                 ] | ||||||
|             elif intention == st.EXECUTE: |             elif intention == st.EXECUTE: | ||||||
|                 return self.browse_nodes_for_execute(node) |                 return self.browse_nodes_for_execute(node) | ||||||
|             else: |             else: | ||||||
| @@ -61,70 +120,86 @@ class Analyzer(object): | |||||||
|     def browse_nodes_for_execute(self, node=None): |     def browse_nodes_for_execute(self, node=None): | ||||||
|         """Browse next nodes to execute. |         """Browse next nodes to execute. | ||||||
|  |  | ||||||
|         This returns a collection of nodes that are ready to be executed, if |         This returns a collection of nodes that *may* be ready to be | ||||||
|         given a specific node it will only examine the successors of that node, |         executed, if given a specific node it will only examine the successors | ||||||
|         otherwise it will examine the whole graph. |         of that node, otherwise it will examine the whole graph. | ||||||
|         """ |         """ | ||||||
|         if node: |         if node is not None: | ||||||
|             nodes = self._execution_graph.successors(node) |             nodes = self._execution_graph.successors(node) | ||||||
|         else: |         else: | ||||||
|             nodes = self._execution_graph.nodes_iter() |             nodes = self._execution_graph.nodes_iter() | ||||||
|  |         ready_nodes = [] | ||||||
|         available_nodes = [] |  | ||||||
|         for node in nodes: |         for node in nodes: | ||||||
|             if self._is_ready_for_execute(node): |             is_ready, late_decider = self._get_maybe_ready_for_execute(node) | ||||||
|                 available_nodes.append(node) |             if is_ready: | ||||||
|         return available_nodes |                 ready_nodes.append((node, late_decider)) | ||||||
|  |         return ready_nodes | ||||||
|  |  | ||||||
|     def browse_nodes_for_revert(self, node=None): |     def browse_nodes_for_revert(self, node=None): | ||||||
|         """Browse next nodes to revert. |         """Browse next nodes to revert. | ||||||
|  |  | ||||||
|         This returns a collection of nodes that are ready to be be reverted, if |         This returns a collection of nodes that *may* be ready to be be | ||||||
|         given a specific node it will only examine the predecessors of that |         reverted, if given a specific node it will only examine the | ||||||
|         node, otherwise it will examine the whole graph. |         predecessors of that node, otherwise it will examine the whole | ||||||
|  |         graph. | ||||||
|         """ |         """ | ||||||
|         if node: |         if node is not None: | ||||||
|             nodes = self._execution_graph.predecessors(node) |             nodes = self._execution_graph.predecessors(node) | ||||||
|         else: |         else: | ||||||
|             nodes = self._execution_graph.nodes_iter() |             nodes = self._execution_graph.nodes_iter() | ||||||
|  |         ready_nodes = [] | ||||||
|         available_nodes = [] |  | ||||||
|         for node in nodes: |         for node in nodes: | ||||||
|             if self._is_ready_for_revert(node): |             is_ready, late_decider = self._get_maybe_ready_for_revert(node) | ||||||
|                 available_nodes.append(node) |             if is_ready: | ||||||
|         return available_nodes |                 ready_nodes.append((node, late_decider)) | ||||||
|  |         return ready_nodes | ||||||
|  |  | ||||||
|  |     def _get_maybe_ready_for_execute(self, atom): | ||||||
|  |         """Returns if an atom is *likely* ready to be executed.""" | ||||||
|  |  | ||||||
|     def _is_ready_for_execute(self, atom): |  | ||||||
|         """Checks if atom is ready to be executed.""" |  | ||||||
|         state = self.get_state(atom) |         state = self.get_state(atom) | ||||||
|         intention = self._storage.get_atom_intention(atom.name) |         intention = self._storage.get_atom_intention(atom.name) | ||||||
|         transition = self._check_atom_transition(atom, state, st.RUNNING) |         transition = self._check_atom_transition(atom, state, st.RUNNING) | ||||||
|         if not transition or intention != st.EXECUTE: |         if not transition or intention != st.EXECUTE: | ||||||
|             return False |             return (False, None) | ||||||
|  |  | ||||||
|         atom_names = [] |         predecessor_names = [] | ||||||
|         for prev_atom in self._execution_graph.predecessors(atom): |         for previous_atom in self._execution_graph.predecessors(atom): | ||||||
|             atom_names.append(prev_atom.name) |             predecessor_names.append(previous_atom.name) | ||||||
|  |  | ||||||
|         atom_states = self._storage.get_atoms_states(atom_names) |         predecessor_states = self._storage.get_atoms_states(predecessor_names) | ||||||
|         return all(state == st.SUCCESS and intention == st.EXECUTE |         predecessor_states_iter = six.itervalues(predecessor_states) | ||||||
|                    for state, intention in six.itervalues(atom_states)) |         ok_to_run = all(state == st.SUCCESS and intention == st.EXECUTE | ||||||
|  |                         for state, intention in predecessor_states_iter) | ||||||
|  |  | ||||||
|  |         if not ok_to_run: | ||||||
|  |             return (False, None) | ||||||
|  |         else: | ||||||
|  |             edge_deciders = self._fetch_edge_deciders(atom) | ||||||
|  |             return (True, IgnoreDecider(atom, edge_deciders)) | ||||||
|  |  | ||||||
|  |     def _get_maybe_ready_for_revert(self, atom): | ||||||
|  |         """Returns if an atom is *likely* ready to be reverted.""" | ||||||
|  |  | ||||||
|     def _is_ready_for_revert(self, atom): |  | ||||||
|         """Checks if atom is ready to be reverted.""" |  | ||||||
|         state = self.get_state(atom) |         state = self.get_state(atom) | ||||||
|         intention = self._storage.get_atom_intention(atom.name) |         intention = self._storage.get_atom_intention(atom.name) | ||||||
|         transition = self._check_atom_transition(atom, state, st.REVERTING) |         transition = self._check_atom_transition(atom, state, st.REVERTING) | ||||||
|         if not transition or intention not in (st.REVERT, st.RETRY): |         if not transition or intention not in (st.REVERT, st.RETRY): | ||||||
|             return False |             return (False, None) | ||||||
|  |  | ||||||
|         atom_names = [] |         predecessor_names = [] | ||||||
|         for prev_atom in self._execution_graph.successors(atom): |         for previous_atom in self._execution_graph.successors(atom): | ||||||
|             atom_names.append(prev_atom.name) |             predecessor_names.append(previous_atom.name) | ||||||
|  |  | ||||||
|         atom_states = self._storage.get_atoms_states(atom_names) |         predecessor_states = self._storage.get_atoms_states(predecessor_names) | ||||||
|         return all(state in (st.PENDING, st.REVERTED) |         predecessor_states_iter = six.itervalues(predecessor_states) | ||||||
|                    for state, intention in six.itervalues(atom_states)) |         ok_to_run = all(state in (st.PENDING, st.REVERTED) | ||||||
|  |                         for state, intention in predecessor_states_iter) | ||||||
|  |  | ||||||
|  |         if not ok_to_run: | ||||||
|  |             return (False, None) | ||||||
|  |         else: | ||||||
|  |             return (True, NoOpDecider()) | ||||||
|  |  | ||||||
|     def iterate_subgraph(self, atom): |     def iterate_subgraph(self, atom): | ||||||
|         """Iterates a subgraph connected to given atom.""" |         """Iterates a subgraph connected to given atom.""" | ||||||
| @@ -142,17 +217,24 @@ class Analyzer(object): | |||||||
|                     yield node |                     yield node | ||||||
|  |  | ||||||
|     def iterate_all_nodes(self): |     def iterate_all_nodes(self): | ||||||
|  |         """Yields back all nodes in the execution graph.""" | ||||||
|         for node in self._execution_graph.nodes_iter(): |         for node in self._execution_graph.nodes_iter(): | ||||||
|             yield node |             yield node | ||||||
|  |  | ||||||
|     def find_atom_retry(self, atom): |     def find_atom_retry(self, atom): | ||||||
|  |         """Returns the retry atom associated to the given atom (or none).""" | ||||||
|         return self._execution_graph.node[atom].get('retry') |         return self._execution_graph.node[atom].get('retry') | ||||||
|  |  | ||||||
|     def is_success(self): |     def is_success(self): | ||||||
|  |         """Checks if all nodes in the execution graph are in 'happy' state.""" | ||||||
|         for atom in self.iterate_all_nodes(): |         for atom in self.iterate_all_nodes(): | ||||||
|             if self.get_state(atom) != st.SUCCESS: |             atom_state = self.get_state(atom) | ||||||
|  |             if atom_state == st.IGNORE: | ||||||
|  |                 continue | ||||||
|  |             if atom_state != st.SUCCESS: | ||||||
|                 return False |                 return False | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|     def get_state(self, atom): |     def get_state(self, atom): | ||||||
|  |         """Gets the state of a given atom (from the backend storage unit).""" | ||||||
|         return self._storage.get_atom_state(atom.name) |         return self._storage.get_atom_state(atom.name) | ||||||
|   | |||||||
| @@ -94,6 +94,7 @@ class Runner(object): | |||||||
|     ignorable_states = (st.SCHEDULING, st.WAITING, st.RESUMING, st.ANALYZING) |     ignorable_states = (st.SCHEDULING, st.WAITING, st.RESUMING, st.ANALYZING) | ||||||
|  |  | ||||||
|     def __init__(self, runtime, waiter): |     def __init__(self, runtime, waiter): | ||||||
|  |         self._runtime = runtime | ||||||
|         self._analyzer = runtime.analyzer |         self._analyzer = runtime.analyzer | ||||||
|         self._completer = runtime.completer |         self._completer = runtime.completer | ||||||
|         self._scheduler = runtime.scheduler |         self._scheduler = runtime.scheduler | ||||||
| @@ -111,13 +112,26 @@ class Runner(object): | |||||||
|         if timeout is None: |         if timeout is None: | ||||||
|             timeout = _WAITING_TIMEOUT |             timeout = _WAITING_TIMEOUT | ||||||
|  |  | ||||||
|  |         # Cache some local functions/methods... | ||||||
|  |         do_schedule = self._scheduler.schedule | ||||||
|  |         wait_for_any = self._waiter.wait_for_any | ||||||
|  |         do_complete = self._completer.complete | ||||||
|  |  | ||||||
|  |         def iter_next_nodes(target_node=None): | ||||||
|  |             # Yields and filters and tweaks the next nodes to execute... | ||||||
|  |             maybe_nodes = self._analyzer.get_next_nodes(node=target_node) | ||||||
|  |             for node, late_decider in maybe_nodes: | ||||||
|  |                 proceed = late_decider.check_and_affect(self._runtime) | ||||||
|  |                 if proceed: | ||||||
|  |                     yield node | ||||||
|  |  | ||||||
|         def resume(old_state, new_state, event): |         def resume(old_state, new_state, event): | ||||||
|             # This reaction function just updates the state machines memory |             # This reaction function just updates the state machines memory | ||||||
|             # to include any nodes that need to be executed (from a previous |             # to include any nodes that need to be executed (from a previous | ||||||
|             # attempt, which may be empty if never ran before) and any nodes |             # attempt, which may be empty if never ran before) and any nodes | ||||||
|             # that are now ready to be ran. |             # that are now ready to be ran. | ||||||
|             memory.next_nodes.update(self._completer.resume()) |             memory.next_nodes.update(self._completer.resume()) | ||||||
|             memory.next_nodes.update(self._analyzer.get_next_nodes()) |             memory.next_nodes.update(iter_next_nodes()) | ||||||
|             return _SCHEDULE |             return _SCHEDULE | ||||||
|  |  | ||||||
|         def game_over(old_state, new_state, event): |         def game_over(old_state, new_state, event): | ||||||
| @@ -127,7 +141,7 @@ class Runner(object): | |||||||
|             # it is *always* called before the final state is entered. |             # it is *always* called before the final state is entered. | ||||||
|             if memory.failures: |             if memory.failures: | ||||||
|                 return _FAILED |                 return _FAILED | ||||||
|             if self._analyzer.get_next_nodes(): |             if any(1 for node in iter_next_nodes()): | ||||||
|                 return _SUSPENDED |                 return _SUSPENDED | ||||||
|             elif self._analyzer.is_success(): |             elif self._analyzer.is_success(): | ||||||
|                 return _SUCCESS |                 return _SUCCESS | ||||||
| @@ -141,8 +155,7 @@ class Runner(object): | |||||||
|             # that holds this information to stop or suspend); handles failures |             # that holds this information to stop or suspend); handles failures | ||||||
|             # that occur during this process safely... |             # that occur during this process safely... | ||||||
|             if self.runnable() and memory.next_nodes: |             if self.runnable() and memory.next_nodes: | ||||||
|                 not_done, failures = self._scheduler.schedule( |                 not_done, failures = do_schedule(memory.next_nodes) | ||||||
|                     memory.next_nodes) |  | ||||||
|                 if not_done: |                 if not_done: | ||||||
|                     memory.not_done.update(not_done) |                     memory.not_done.update(not_done) | ||||||
|                 if failures: |                 if failures: | ||||||
| @@ -155,8 +168,7 @@ class Runner(object): | |||||||
|             # call sometime in the future, or equivalent that will work in |             # call sometime in the future, or equivalent that will work in | ||||||
|             # py2 and py3. |             # py2 and py3. | ||||||
|             if memory.not_done: |             if memory.not_done: | ||||||
|                 done, not_done = self._waiter.wait_for_any(memory.not_done, |                 done, not_done = wait_for_any(memory.not_done, timeout) | ||||||
|                                                            timeout) |  | ||||||
|                 memory.done.update(done) |                 memory.done.update(done) | ||||||
|                 memory.not_done = not_done |                 memory.not_done = not_done | ||||||
|             return _ANALYZE |             return _ANALYZE | ||||||
| @@ -173,7 +185,7 @@ class Runner(object): | |||||||
|                 node = fut.atom |                 node = fut.atom | ||||||
|                 try: |                 try: | ||||||
|                     event, result = fut.result() |                     event, result = fut.result() | ||||||
|                     retain = self._completer.complete(node, event, result) |                     retain = do_complete(node, event, result) | ||||||
|                     if isinstance(result, failure.Failure): |                     if isinstance(result, failure.Failure): | ||||||
|                         if retain: |                         if retain: | ||||||
|                             memory.failures.append(result) |                             memory.failures.append(result) | ||||||
| @@ -196,7 +208,7 @@ class Runner(object): | |||||||
|                     memory.failures.append(failure.Failure()) |                     memory.failures.append(failure.Failure()) | ||||||
|                 else: |                 else: | ||||||
|                     try: |                     try: | ||||||
|                         more_nodes = self._analyzer.get_next_nodes(node) |                         more_nodes = set(iter_next_nodes(target_node=node)) | ||||||
|                     except Exception: |                     except Exception: | ||||||
|                         memory.failures.append(failure.Failure()) |                         memory.failures.append(failure.Failure()) | ||||||
|                     else: |                     else: | ||||||
|   | |||||||
| @@ -23,6 +23,7 @@ from taskflow.engines.action_engine import completer as co | |||||||
| from taskflow.engines.action_engine import runner as ru | from taskflow.engines.action_engine import runner as ru | ||||||
| from taskflow.engines.action_engine import scheduler as sched | from taskflow.engines.action_engine import scheduler as sched | ||||||
| from taskflow.engines.action_engine import scopes as sc | from taskflow.engines.action_engine import scopes as sc | ||||||
|  | from taskflow import flow as flow_type | ||||||
| from taskflow import states as st | from taskflow import states as st | ||||||
| from taskflow import task | from taskflow import task | ||||||
| from taskflow.utils import misc | from taskflow.utils import misc | ||||||
| @@ -61,6 +62,7 @@ class Runtime(object): | |||||||
|             'retry': self.retry_scheduler, |             'retry': self.retry_scheduler, | ||||||
|             'task': self.task_scheduler, |             'task': self.task_scheduler, | ||||||
|         } |         } | ||||||
|  |         execution_graph = self._compilation.execution_graph | ||||||
|         for atom in self.analyzer.iterate_all_nodes(): |         for atom in self.analyzer.iterate_all_nodes(): | ||||||
|             metadata = {} |             metadata = {} | ||||||
|             walker = sc.ScopeWalker(self.compilation, atom, names_only=True) |             walker = sc.ScopeWalker(self.compilation, atom, names_only=True) | ||||||
| @@ -72,10 +74,20 @@ class Runtime(object): | |||||||
|                 check_transition_handler = st.check_retry_transition |                 check_transition_handler = st.check_retry_transition | ||||||
|                 change_state_handler = change_state_handlers['retry'] |                 change_state_handler = change_state_handlers['retry'] | ||||||
|                 scheduler = schedulers['retry'] |                 scheduler = schedulers['retry'] | ||||||
|  |             edge_deciders = {} | ||||||
|  |             for previous_atom in execution_graph.predecessors(atom): | ||||||
|  |                 # If there is any link function that says if this connection | ||||||
|  |                 # is able to run (or should not) ensure we retain it and use | ||||||
|  |                 # it later as needed. | ||||||
|  |                 u_v_data = execution_graph.adj[previous_atom][atom] | ||||||
|  |                 u_v_decider = u_v_data.get(flow_type.LINK_DECIDER) | ||||||
|  |                 if u_v_decider is not None: | ||||||
|  |                     edge_deciders[previous_atom.name] = u_v_decider | ||||||
|             metadata['scope_walker'] = walker |             metadata['scope_walker'] = walker | ||||||
|             metadata['check_transition_handler'] = check_transition_handler |             metadata['check_transition_handler'] = check_transition_handler | ||||||
|             metadata['change_state_handler'] = change_state_handler |             metadata['change_state_handler'] = change_state_handler | ||||||
|             metadata['scheduler'] = scheduler |             metadata['scheduler'] = scheduler | ||||||
|  |             metadata['edge_deciders'] = edge_deciders | ||||||
|             self._atom_cache[atom.name] = metadata |             self._atom_cache[atom.name] = metadata | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @@ -130,6 +142,14 @@ class Runtime(object): | |||||||
|         check_transition_handler = metadata['check_transition_handler'] |         check_transition_handler = metadata['check_transition_handler'] | ||||||
|         return check_transition_handler(current_state, target_state) |         return check_transition_handler(current_state, target_state) | ||||||
|  |  | ||||||
|  |     def fetch_edge_deciders(self, atom): | ||||||
|  |         """Fetches the edge deciders for the given atom.""" | ||||||
|  |         # This does not check if the name exists (since this is only used | ||||||
|  |         # internally to the engine, and is not exposed to atoms that will | ||||||
|  |         # not exist and therefore doesn't need to handle that case). | ||||||
|  |         metadata = self._atom_cache[atom.name] | ||||||
|  |         return metadata['edge_deciders'] | ||||||
|  |  | ||||||
|     def fetch_scheduler(self, atom): |     def fetch_scheduler(self, atom): | ||||||
|         """Fetches the cached specific scheduler for the given atom.""" |         """Fetches the cached specific scheduler for the given atom.""" | ||||||
|         # This does not check if the name exists (since this is only used |         # This does not check if the name exists (since this is only used | ||||||
|   | |||||||
							
								
								
									
										75
									
								
								taskflow/examples/switch_graph_flow.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								taskflow/examples/switch_graph_flow.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | |||||||
|  | # -*- coding: utf-8 -*- | ||||||
|  |  | ||||||
|  | #    Copyright (C) 2014 Yahoo! Inc. All Rights Reserved. | ||||||
|  | # | ||||||
|  | #    Licensed under the Apache License, Version 2.0 (the "License"); you may | ||||||
|  | #    not use this file except in compliance with the License. You may obtain | ||||||
|  | #    a copy of the License at | ||||||
|  | # | ||||||
|  | #         http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #    Unless required by applicable law or agreed to in writing, software | ||||||
|  | #    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||||
|  | #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||||
|  | #    License for the specific language governing permissions and limitations | ||||||
|  | #    under the License. | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  | import os | ||||||
|  | import sys | ||||||
|  |  | ||||||
|  | logging.basicConfig(level=logging.ERROR) | ||||||
|  |  | ||||||
|  | top_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), | ||||||
|  |                                        os.pardir, | ||||||
|  |                                        os.pardir)) | ||||||
|  | sys.path.insert(0, top_dir) | ||||||
|  |  | ||||||
|  | from taskflow import engines | ||||||
|  | from taskflow.patterns import graph_flow as gf | ||||||
|  | from taskflow.persistence import backends | ||||||
|  | from taskflow import task | ||||||
|  | from taskflow.utils import persistence_utils as pu | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DummyTask(task.Task): | ||||||
|  |     def execute(self): | ||||||
|  |         print("Running %s" % self.name) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def allow(history): | ||||||
|  |     print(history) | ||||||
|  |     return False | ||||||
|  |  | ||||||
|  |  | ||||||
|  | r = gf.Flow("root") | ||||||
|  | r_a = DummyTask('r-a') | ||||||
|  | r_b = DummyTask('r-b') | ||||||
|  | r.add(r_a, r_b) | ||||||
|  | r.link(r_a, r_b, decider=allow) | ||||||
|  |  | ||||||
|  | backend = backends.fetch({ | ||||||
|  |     'connection': 'memory://', | ||||||
|  | }) | ||||||
|  | book, flow_detail = pu.temporary_flow_detail(backend=backend) | ||||||
|  |  | ||||||
|  | e = engines.load(r, flow_detail=flow_detail, book=book, backend=backend) | ||||||
|  | e.compile() | ||||||
|  | e.prepare() | ||||||
|  | e.run() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | print("---------") | ||||||
|  | print("After run") | ||||||
|  | print("---------") | ||||||
|  | entries = [os.path.join(backend.memory.root_path, child) | ||||||
|  |            for child in backend.memory.ls(backend.memory.root_path)] | ||||||
|  | while entries: | ||||||
|  |     path = entries.pop() | ||||||
|  |     value = backend.memory[path] | ||||||
|  |     if value: | ||||||
|  |         print("%s -> %s" % (path, value)) | ||||||
|  |     else: | ||||||
|  |         print("%s" % (path)) | ||||||
|  |     entries.extend(os.path.join(path, child) | ||||||
|  |                    for child in backend.memory.ls(path)) | ||||||
| @@ -31,6 +31,9 @@ LINK_RETRY = 'retry' | |||||||
| # This key denotes the link was created due to symbol constraints and the | # This key denotes the link was created due to symbol constraints and the | ||||||
| # value will be a set of names that the constraint ensures are satisfied. | # value will be a set of names that the constraint ensures are satisfied. | ||||||
| LINK_REASONS = 'reasons' | LINK_REASONS = 'reasons' | ||||||
|  | # | ||||||
|  | # This key denotes a callable that will determine if the target is visited. | ||||||
|  | LINK_DECIDER = 'decider' | ||||||
|  |  | ||||||
|  |  | ||||||
| @six.add_metaclass(abc.ABCMeta) | @six.add_metaclass(abc.ABCMeta) | ||||||
|   | |||||||
| @@ -16,6 +16,8 @@ | |||||||
|  |  | ||||||
| import collections | import collections | ||||||
|  |  | ||||||
|  | import six | ||||||
|  |  | ||||||
| from taskflow import exceptions as exc | from taskflow import exceptions as exc | ||||||
| from taskflow import flow | from taskflow import flow | ||||||
| from taskflow.types import graph as gr | from taskflow.types import graph as gr | ||||||
| @@ -66,16 +68,20 @@ class Flow(flow.Flow): | |||||||
|     #: Extracts the unsatisified symbol requirements of a single node. |     #: Extracts the unsatisified symbol requirements of a single node. | ||||||
|     _unsatisfied_requires = staticmethod(_unsatisfied_requires) |     _unsatisfied_requires = staticmethod(_unsatisfied_requires) | ||||||
|  |  | ||||||
|     def link(self, u, v): |     def link(self, u, v, decider=None): | ||||||
|         """Link existing node u as a runtime dependency of existing node v.""" |         """Link existing node u as a runtime dependency of existing node v.""" | ||||||
|         if not self._graph.has_node(u): |         if not self._graph.has_node(u): | ||||||
|             raise ValueError("Node '%s' not found to link from" % (u)) |             raise ValueError("Node '%s' not found to link from" % (u)) | ||||||
|         if not self._graph.has_node(v): |         if not self._graph.has_node(v): | ||||||
|             raise ValueError("Node '%s' not found to link to" % (v)) |             raise ValueError("Node '%s' not found to link to" % (v)) | ||||||
|         self._swap(self._link(u, v, manual=True)) |         if decider is not None: | ||||||
|  |             if not six.callable(decider): | ||||||
|  |                 raise ValueError("Decider boolean callback must be callable") | ||||||
|  |         self._swap(self._link(u, v, manual=True, decider=decider)) | ||||||
|         return self |         return self | ||||||
|  |  | ||||||
|     def _link(self, u, v, graph=None, reason=None, manual=False): |     def _link(self, u, v, graph=None, | ||||||
|  |               reason=None, manual=False, decider=None): | ||||||
|         mutable_graph = True |         mutable_graph = True | ||||||
|         if graph is None: |         if graph is None: | ||||||
|             graph = self._graph |             graph = self._graph | ||||||
| @@ -85,6 +91,8 @@ class Flow(flow.Flow): | |||||||
|         attrs = graph.get_edge_data(u, v) |         attrs = graph.get_edge_data(u, v) | ||||||
|         if not attrs: |         if not attrs: | ||||||
|             attrs = {} |             attrs = {} | ||||||
|  |         if decider is not None: | ||||||
|  |             attrs[flow.LINK_DECIDER] = decider | ||||||
|         if manual: |         if manual: | ||||||
|             attrs[flow.LINK_MANUAL] = True |             attrs[flow.LINK_MANUAL] = True | ||||||
|         if reason is not None: |         if reason is not None: | ||||||
| @@ -281,9 +289,9 @@ class TargetedFlow(Flow): | |||||||
|         self._subgraph = None |         self._subgraph = None | ||||||
|         return self |         return self | ||||||
|  |  | ||||||
|     def link(self, u, v): |     def link(self, u, v, decider=None): | ||||||
|         """Link existing node u as a runtime dependency of existing node v.""" |         """Link existing node u as a runtime dependency of existing node v.""" | ||||||
|         super(TargetedFlow, self).link(u, v) |         super(TargetedFlow, self).link(u, v, decider=decider) | ||||||
|         # reset cached subgraph, in case it was affected |         # reset cached subgraph, in case it was affected | ||||||
|         self._subgraph = None |         self._subgraph = None | ||||||
|         return self |         return self | ||||||
|   | |||||||
| @@ -40,10 +40,11 @@ REVERTING = REVERTING | |||||||
| SUCCESS = SUCCESS | SUCCESS = SUCCESS | ||||||
| RUNNING = RUNNING | RUNNING = RUNNING | ||||||
| RETRYING = 'RETRYING' | RETRYING = 'RETRYING' | ||||||
|  | IGNORE = 'IGNORE' | ||||||
|  |  | ||||||
| # Atom intentions. | # Atom intentions. | ||||||
| EXECUTE = 'EXECUTE' | EXECUTE = 'EXECUTE' | ||||||
| IGNORE = 'IGNORE' | IGNORE = IGNORE | ||||||
| REVERT = 'REVERT' | REVERT = 'REVERT' | ||||||
| RETRY = 'RETRY' | RETRY = 'RETRY' | ||||||
| INTENTIONS = (EXECUTE, IGNORE, REVERT, RETRY) | INTENTIONS = (EXECUTE, IGNORE, REVERT, RETRY) | ||||||
| @@ -160,6 +161,7 @@ def check_flow_transition(old_state, new_state): | |||||||
|  |  | ||||||
| _ALLOWED_TASK_TRANSITIONS = frozenset(( | _ALLOWED_TASK_TRANSITIONS = frozenset(( | ||||||
|     (PENDING, RUNNING),       # run it! |     (PENDING, RUNNING),       # run it! | ||||||
|  |     (PENDING, IGNORE),        # skip it! | ||||||
|  |  | ||||||
|     (RUNNING, SUCCESS),       # the task finished successfully |     (RUNNING, SUCCESS),       # the task finished successfully | ||||||
|     (RUNNING, FAILURE),       # the task failed |     (RUNNING, FAILURE),       # the task failed | ||||||
| @@ -171,6 +173,7 @@ _ALLOWED_TASK_TRANSITIONS = frozenset(( | |||||||
|     (REVERTING, FAILURE),     # revert failed |     (REVERTING, FAILURE),     # revert failed | ||||||
|  |  | ||||||
|     (REVERTED, PENDING),      # try again |     (REVERTED, PENDING),      # try again | ||||||
|  |     (IGNORE, PENDING),        # try again | ||||||
| )) | )) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -15,7 +15,9 @@ | |||||||
| #    under the License. | #    under the License. | ||||||
|  |  | ||||||
| import contextlib | import contextlib | ||||||
|  | import functools | ||||||
|  |  | ||||||
|  | import six | ||||||
| import testtools | import testtools | ||||||
|  |  | ||||||
| import taskflow.engines | import taskflow.engines | ||||||
| @@ -772,6 +774,126 @@ class EngineMissingDepsTest(utils.EngineTestBase): | |||||||
|         self.assertIsNotNone(c_e.cause) |         self.assertIsNotNone(c_e.cause) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EngineGraphConditionalFlowTest(utils.EngineTestBase): | ||||||
|  |  | ||||||
|  |     def test_graph_flow_conditional(self): | ||||||
|  |         flow = gf.Flow('root') | ||||||
|  |  | ||||||
|  |         task1 = utils.ProgressingTask(name='task1') | ||||||
|  |         task2 = utils.ProgressingTask(name='task2') | ||||||
|  |         task2_2 = utils.ProgressingTask(name='task2_2') | ||||||
|  |         task3 = utils.ProgressingTask(name='task3') | ||||||
|  |  | ||||||
|  |         flow.add(task1, task2, task2_2, task3) | ||||||
|  |         flow.link(task1, task2, decider=lambda history: False) | ||||||
|  |         flow.link(task2, task2_2) | ||||||
|  |         flow.link(task1, task3, decider=lambda history: True) | ||||||
|  |  | ||||||
|  |         engine = self._make_engine(flow) | ||||||
|  |         with utils.CaptureListener(engine, capture_flow=False) as capturer: | ||||||
|  |             engine.run() | ||||||
|  |  | ||||||
|  |         expected = set([ | ||||||
|  |             'task1.t RUNNING', | ||||||
|  |             'task1.t SUCCESS(5)', | ||||||
|  |  | ||||||
|  |             'task2.t IGNORE', | ||||||
|  |             'task2_2.t IGNORE', | ||||||
|  |  | ||||||
|  |             'task3.t RUNNING', | ||||||
|  |             'task3.t SUCCESS(5)', | ||||||
|  |         ]) | ||||||
|  |         self.assertEqual(expected, set(capturer.values)) | ||||||
|  |  | ||||||
|  |     def test_graph_flow_diamond_ignored(self): | ||||||
|  |         flow = gf.Flow('root') | ||||||
|  |  | ||||||
|  |         task1 = utils.ProgressingTask(name='task1') | ||||||
|  |         task2 = utils.ProgressingTask(name='task2') | ||||||
|  |         task3 = utils.ProgressingTask(name='task3') | ||||||
|  |         task4 = utils.ProgressingTask(name='task4') | ||||||
|  |  | ||||||
|  |         flow.add(task1, task2, task3, task4) | ||||||
|  |         flow.link(task1, task2) | ||||||
|  |         flow.link(task2, task4, decider=lambda history: False) | ||||||
|  |         flow.link(task1, task3) | ||||||
|  |         flow.link(task3, task4, decider=lambda history: True) | ||||||
|  |  | ||||||
|  |         engine = self._make_engine(flow) | ||||||
|  |         with utils.CaptureListener(engine, capture_flow=False) as capturer: | ||||||
|  |             engine.run() | ||||||
|  |  | ||||||
|  |         expected = set([ | ||||||
|  |             'task1.t RUNNING', | ||||||
|  |             'task1.t SUCCESS(5)', | ||||||
|  |  | ||||||
|  |             'task2.t RUNNING', | ||||||
|  |             'task2.t SUCCESS(5)', | ||||||
|  |  | ||||||
|  |             'task3.t RUNNING', | ||||||
|  |             'task3.t SUCCESS(5)', | ||||||
|  |  | ||||||
|  |             'task4.t IGNORE', | ||||||
|  |         ]) | ||||||
|  |         self.assertEqual(expected, set(capturer.values)) | ||||||
|  |         self.assertEqual(states.IGNORE, | ||||||
|  |                          engine.storage.get_atom_state('task4')) | ||||||
|  |         self.assertEqual(states.IGNORE, | ||||||
|  |                          engine.storage.get_atom_intention('task4')) | ||||||
|  |  | ||||||
|  |     def test_graph_flow_conditional_history(self): | ||||||
|  |  | ||||||
|  |         def even_odd_decider(history, allowed): | ||||||
|  |             total = sum(six.itervalues(history)) | ||||||
|  |             if total == allowed: | ||||||
|  |                 return True | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |         flow = gf.Flow('root') | ||||||
|  |  | ||||||
|  |         task1 = utils.TaskMultiArgOneReturn(name='task1') | ||||||
|  |         task2 = utils.ProgressingTask(name='task2') | ||||||
|  |         task2_2 = utils.ProgressingTask(name='task2_2') | ||||||
|  |         task3 = utils.ProgressingTask(name='task3') | ||||||
|  |         task3_3 = utils.ProgressingTask(name='task3_3') | ||||||
|  |  | ||||||
|  |         flow.add(task1, task2, task2_2, task3, task3_3) | ||||||
|  |         flow.link(task1, task2, | ||||||
|  |                   decider=functools.partial(even_odd_decider, allowed=2)) | ||||||
|  |         flow.link(task2, task2_2) | ||||||
|  |  | ||||||
|  |         flow.link(task1, task3, | ||||||
|  |                   decider=functools.partial(even_odd_decider, allowed=1)) | ||||||
|  |         flow.link(task3, task3_3) | ||||||
|  |  | ||||||
|  |         engine = self._make_engine(flow) | ||||||
|  |         engine.storage.inject({'x': 0, 'y': 1, 'z': 1}) | ||||||
|  |  | ||||||
|  |         with utils.CaptureListener(engine, capture_flow=False) as capturer: | ||||||
|  |             engine.run() | ||||||
|  |  | ||||||
|  |         expected = set([ | ||||||
|  |             'task1.t RUNNING', 'task1.t SUCCESS(2)', | ||||||
|  |             'task3.t IGNORE', 'task3_3.t IGNORE', | ||||||
|  |             'task2.t RUNNING', 'task2.t SUCCESS(5)', | ||||||
|  |             'task2_2.t RUNNING', 'task2_2.t SUCCESS(5)', | ||||||
|  |         ]) | ||||||
|  |         self.assertEqual(expected, set(capturer.values)) | ||||||
|  |  | ||||||
|  |         engine = self._make_engine(flow) | ||||||
|  |         engine.storage.inject({'x': 0, 'y': 0, 'z': 1}) | ||||||
|  |         with utils.CaptureListener(engine, capture_flow=False) as capturer: | ||||||
|  |             engine.run() | ||||||
|  |  | ||||||
|  |         expected = set([ | ||||||
|  |             'task1.t RUNNING', 'task1.t SUCCESS(1)', | ||||||
|  |             'task2.t IGNORE', 'task2_2.t IGNORE', | ||||||
|  |             'task3.t RUNNING', 'task3.t SUCCESS(5)', | ||||||
|  |             'task3_3.t RUNNING', 'task3_3.t SUCCESS(5)', | ||||||
|  |         ]) | ||||||
|  |         self.assertEqual(expected, set(capturer.values)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class EngineCheckingTaskTest(utils.EngineTestBase): | class EngineCheckingTaskTest(utils.EngineTestBase): | ||||||
|     # FIXME: this test uses a inner class that workers/process engines can't |     # FIXME: this test uses a inner class that workers/process engines can't | ||||||
|     # get to, so we need to do something better to make this test useful for |     # get to, so we need to do something better to make this test useful for | ||||||
| @@ -805,6 +927,7 @@ class SerialEngineTest(EngineTaskTest, | |||||||
|                        EngineOptionalRequirementsTest, |                        EngineOptionalRequirementsTest, | ||||||
|                        EngineGraphFlowTest, |                        EngineGraphFlowTest, | ||||||
|                        EngineMissingDepsTest, |                        EngineMissingDepsTest, | ||||||
|  |                        EngineGraphConditionalFlowTest, | ||||||
|                        EngineCheckingTaskTest, |                        EngineCheckingTaskTest, | ||||||
|                        test.TestCase): |                        test.TestCase): | ||||||
|     def _make_engine(self, flow, |     def _make_engine(self, flow, | ||||||
| @@ -832,6 +955,7 @@ class ParallelEngineWithThreadsTest(EngineTaskTest, | |||||||
|                                     EngineOptionalRequirementsTest, |                                     EngineOptionalRequirementsTest, | ||||||
|                                     EngineGraphFlowTest, |                                     EngineGraphFlowTest, | ||||||
|                                     EngineMissingDepsTest, |                                     EngineMissingDepsTest, | ||||||
|  |                                     EngineGraphConditionalFlowTest, | ||||||
|                                     EngineCheckingTaskTest, |                                     EngineCheckingTaskTest, | ||||||
|                                     test.TestCase): |                                     test.TestCase): | ||||||
|     _EXECUTOR_WORKERS = 2 |     _EXECUTOR_WORKERS = 2 | ||||||
| @@ -871,6 +995,7 @@ class ParallelEngineWithEventletTest(EngineTaskTest, | |||||||
|                                      EngineOptionalRequirementsTest, |                                      EngineOptionalRequirementsTest, | ||||||
|                                      EngineGraphFlowTest, |                                      EngineGraphFlowTest, | ||||||
|                                      EngineMissingDepsTest, |                                      EngineMissingDepsTest, | ||||||
|  |                                      EngineGraphConditionalFlowTest, | ||||||
|                                      EngineCheckingTaskTest, |                                      EngineCheckingTaskTest, | ||||||
|                                      test.TestCase): |                                      test.TestCase): | ||||||
|  |  | ||||||
| @@ -893,6 +1018,7 @@ class ParallelEngineWithProcessTest(EngineTaskTest, | |||||||
|                                     EngineOptionalRequirementsTest, |                                     EngineOptionalRequirementsTest, | ||||||
|                                     EngineGraphFlowTest, |                                     EngineGraphFlowTest, | ||||||
|                                     EngineMissingDepsTest, |                                     EngineMissingDepsTest, | ||||||
|  |                                     EngineGraphConditionalFlowTest, | ||||||
|                                     test.TestCase): |                                     test.TestCase): | ||||||
|     _EXECUTOR_WORKERS = 2 |     _EXECUTOR_WORKERS = 2 | ||||||
|  |  | ||||||
| @@ -920,6 +1046,7 @@ class WorkerBasedEngineTest(EngineTaskTest, | |||||||
|                             EngineOptionalRequirementsTest, |                             EngineOptionalRequirementsTest, | ||||||
|                             EngineGraphFlowTest, |                             EngineGraphFlowTest, | ||||||
|                             EngineMissingDepsTest, |                             EngineMissingDepsTest, | ||||||
|  |                             EngineGraphConditionalFlowTest, | ||||||
|                             test.TestCase): |                             test.TestCase): | ||||||
|     def setUp(self): |     def setUp(self): | ||||||
|         super(WorkerBasedEngineTest, self).setUp() |         super(WorkerBasedEngineTest, self).setUp() | ||||||
|   | |||||||
| @@ -14,6 +14,8 @@ | |||||||
| #    License for the specific language governing permissions and limitations | #    License for the specific language governing permissions and limitations | ||||||
| #    under the License. | #    under the License. | ||||||
|  |  | ||||||
|  | import mock | ||||||
|  |  | ||||||
| import optparse | import optparse | ||||||
| import os | import os | ||||||
| import sys | import sys | ||||||
| @@ -37,10 +39,10 @@ from taskflow.types import fsm | |||||||
| # actually be running it...). | # actually be running it...). | ||||||
| class DummyRuntime(object): | class DummyRuntime(object): | ||||||
|     def __init__(self): |     def __init__(self): | ||||||
|         self.analyzer = None |         self.analyzer = mock.MagicMock() | ||||||
|         self.completer = None |         self.completer = mock.MagicMock() | ||||||
|         self.scheduler = None |         self.scheduler = mock.MagicMock() | ||||||
|         self.storage = None |         self.storage = mock.MagicMock() | ||||||
|  |  | ||||||
|  |  | ||||||
| def clean_event(name): | def clean_event(name): | ||||||
| @@ -130,7 +132,7 @@ def main(): | |||||||
|                               list(states._ALLOWED_RETRY_TRANSITIONS)) |                               list(states._ALLOWED_RETRY_TRANSITIONS)) | ||||||
|     elif options.engines: |     elif options.engines: | ||||||
|         source_type = "Engines" |         source_type = "Engines" | ||||||
|         r = runner.Runner(DummyRuntime(), None) |         r = runner.Runner(DummyRuntime(), mock.MagicMock()) | ||||||
|         source, memory = r.build() |         source, memory = r.build() | ||||||
|         internal_states.extend(runner._META_STATES) |         internal_states.extend(runner._META_STATES) | ||||||
|         ordering = 'out' |         ordering = 'out' | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Jenkins
					Jenkins