diff --git a/.github/workflows/2_auto_publish_release.yml b/.github/workflows/2_auto_publish_release.yml index 02ea52d678c..9223bd2106c 100644 --- a/.github/workflows/2_auto_publish_release.yml +++ b/.github/workflows/2_auto_publish_release.yml @@ -38,7 +38,7 @@ jobs: uses: cylc/release-actions/build-python-package@v1 - name: Publish distribution to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.10 + uses: pypa/gh-action-pypi-publish@v1.8.11 with: user: __token__ # uses the API token feature of PyPI - least permissions possible password: ${{ secrets.PYPI_TOKEN }} diff --git a/changes.d/5731.feat.md b/changes.d/5731.feat.md new file mode 100644 index 00000000000..b0c28a01ac1 --- /dev/null +++ b/changes.d/5731.feat.md @@ -0,0 +1 @@ +Major upgrade to `cylc tui` which now supports larger workflows and can browse installed workflows. diff --git a/changes.d/5772.feat.md b/changes.d/5772.feat.md index 9d47d528622..da0984a82ec 100644 --- a/changes.d/5772.feat.md +++ b/changes.d/5772.feat.md @@ -1 +1 @@ -Add a check for indentation being 4N spaces. \ No newline at end of file +`cylc lint`: added a check for indentation being 4N spaces. diff --git a/changes.d/5838.feat.md b/changes.d/5838.feat.md new file mode 100644 index 00000000000..8e9919d3a0f --- /dev/null +++ b/changes.d/5838.feat.md @@ -0,0 +1 @@ +`cylc lint`: added rule to check for `rose date` usage (should be replaced with `isodatetime`). diff --git a/changes.d/5841.fix.md b/changes.d/5841.fix.md new file mode 100644 index 00000000000..4bc41462fca --- /dev/null +++ b/changes.d/5841.fix.md @@ -0,0 +1 @@ +`cylc lint`: improved handling of S011 to not warn if the `#` is `#$` (e.g. shell base arithmetic). diff --git a/cylc/flow/async_util.py b/cylc/flow/async_util.py index 73826ffe3ce..1e103615b33 100644 --- a/cylc/flow/async_util.py +++ b/cylc/flow/async_util.py @@ -17,6 +17,7 @@ import asyncio from functools import partial, wraps +from inspect import signature import os from pathlib import Path from typing import List, Union @@ -262,10 +263,22 @@ def __str__(self): def __repr__(self): return _AsyncPipe(self.func).__repr__() + @property + def __name__(self): + return self.func.__name__ + @property def __doc__(self): return self.func.__doc__ + @property + def __signature__(self): + return signature(self.func) + + @property + def __annotations__(self): + return self.func.__annotations__ + def pipe(func=None, preproc=None): """An asynchronous pipe implementation in pure Python. diff --git a/cylc/flow/cfgspec/workflow.py b/cylc/flow/cfgspec/workflow.py index d75013fd621..de919c27c0f 100644 --- a/cylc/flow/cfgspec/workflow.py +++ b/cylc/flow/cfgspec/workflow.py @@ -1794,7 +1794,7 @@ def upg(cfg, descr): ['cylc', 'simulation', 'disable suite event handlers']) u.obsolete('8.0.0', ['cylc', 'simulation'], is_section=True) u.obsolete('8.0.0', ['visualization'], is_section=True) - u.obsolete('8.0.0', ['scheduling', 'spawn to max active cycle points']), + u.obsolete('8.0.0', ['scheduling', 'spawn to max active cycle points']) u.deprecate( '8.0.0', ['cylc', 'task event mail interval'], diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index ab9b3124352..ef77105b3a4 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -1587,7 +1587,7 @@ def insert_job(self, name, cycle_point, status, job_conf): name=tproxy.name, cycle_point=tproxy.cycle_point, execution_time_limit=job_conf.get('execution_time_limit'), - platform=job_conf.get('platform')['name'], + platform=job_conf['platform']['name'], job_runner_name=job_conf.get('job_runner_name'), ) # Not all fields are populated with some submit-failures, diff --git a/cylc/flow/option_parsers.py b/cylc/flow/option_parsers.py index ee84a477da7..b83ff45aab9 100644 --- a/cylc/flow/option_parsers.py +++ b/cylc/flow/option_parsers.py @@ -48,6 +48,7 @@ ) WORKFLOW_ID_ARG_DOC = ('WORKFLOW', 'Workflow ID') +OPT_WORKFLOW_ID_ARG_DOC = ('[WORKFLOW]', 'Workflow ID') WORKFLOW_ID_MULTI_ARG_DOC = ('WORKFLOW ...', 'Workflow ID(s)') WORKFLOW_ID_OR_PATH_ARG_DOC = ('WORKFLOW | PATH', 'Workflow ID or path') ID_MULTI_ARG_DOC = ('ID ...', 'Workflow/Cycle/Family/Task ID(s)') diff --git a/cylc/flow/scripts/cylc.py b/cylc/flow/scripts/cylc.py index 46c8127585a..700a70f8d7b 100644 --- a/cylc/flow/scripts/cylc.py +++ b/cylc/flow/scripts/cylc.py @@ -31,9 +31,12 @@ def pythonpath_manip(): https://github.com/cylc/cylc-flow/issues/5124 """ if 'CYLC_PYTHONPATH' in os.environ: - for item in os.environ['CYLC_PYTHONPATH'].split(os.pathsep): - abspath = os.path.abspath(item) - sys.path.insert(0, abspath) + paths = [ + os.path.abspath(item) + for item in os.environ['CYLC_PYTHONPATH'].split(os.pathsep) + ] + paths.extend(sys.path) + sys.path = paths if 'PYTHONPATH' in os.environ: for item in os.environ['PYTHONPATH'].split(os.pathsep): abspath = os.path.abspath(item) diff --git a/cylc/flow/scripts/graph.py b/cylc/flow/scripts/graph.py index a4721bb6920..56a60b6cced 100644 --- a/cylc/flow/scripts/graph.py +++ b/cylc/flow/scripts/graph.py @@ -414,9 +414,21 @@ async def graph_diff( graph_a: List[str] = [] graph_b: List[str] = [] graph_reference( - opts, workflow_a, start, stop, flow_file, write=graph_a.append), + opts, + workflow_a, + start, + stop, + flow_file, + write=graph_a.append, + ) graph_reference( - opts, workflow_b, start, stop, flow_file_b, write=graph_b.append), + opts, + workflow_b, + start, + stop, + flow_file_b, + write=graph_b.append, + ) # compare graphs diff_lines = list( diff --git a/cylc/flow/scripts/lint.py b/cylc/flow/scripts/lint.py index c215436387d..cc06512988c 100755 --- a/cylc/flow/scripts/lint.py +++ b/cylc/flow/scripts/lint.py @@ -40,7 +40,7 @@ TOMLDOC = """ pyproject.toml configuration:{} [cylc-lint] # any of {} - ignore = ['S001', 'S002] # List of rules to ignore + ignore = ['S001', 'S002'] # List of rules to ignore exclude = ['etc/foo.cylc'] # List of files to ignore rulesets = ['style', '728'] # Sets default rulesets to check max-line-length = 130 # Max line length for linting @@ -437,7 +437,7 @@ def check_indentation(line: str) -> bool: 'evaluate commented lines': True, FUNCTION: functools.partial( check_if_jinja2, - function=re.compile(r'(? bool: 'job-script-vars/index.html' ), FUNCTION: check_for_obsolete_environment_variables, - } + }, + 'U014': { + 'short': 'Use "isodatetime [ref]" instead of "rose date [-c]"', + 'rst': ( + 'For datetime operations in task scripts:\n\n' + ' * Use ``isodatetime`` instead of ``rose date``\n' + ' * Use ``isodatetime ref`` instead of ``rose date -c`` for ' + 'the current cycle point\n' + ), + 'url': ( + 'https://cylc.github.io/cylc-doc/stable/html/7-to-8/' + 'cheat-sheet.html#datetime-operations' + ), + FUNCTION: re.compile(r'rose +date').findall, + }, } RULESETS = ['728', 'style', 'all'] EXTRA_TOML_VALIDATION = { diff --git a/cylc/flow/scripts/tui.py b/cylc/flow/scripts/tui.py index 86970052b46..f8f0879a1e6 100644 --- a/cylc/flow/scripts/tui.py +++ b/cylc/flow/scripts/tui.py @@ -15,34 +15,35 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""cylc tui WORKFLOW +"""cylc tui [WORKFLOW] View and control running workflows in the terminal. (Tui = Terminal User Interface) -WARNING: Tui is experimental and may break with large flows. -An upcoming change to the way Tui receives data from the scheduler will make it -much more efficient in the future. +Tui allows you to monitor and interact with workflows in a manner similar +to the GUI. + +Press "h" whilst running Tui to bring up the help screen, use the arrow +keys to navigage. + """ -# TODO: remove this warning once Tui is delta-driven -# https://github.com/cylc/cylc-flow/issues/3527 +from getpass import getuser from textwrap import indent -from typing import TYPE_CHECKING -from urwid import html_fragment +from typing import TYPE_CHECKING, Optional +from cylc.flow.id import Tokens from cylc.flow.id_cli import parse_id from cylc.flow.option_parsers import ( - WORKFLOW_ID_ARG_DOC, + OPT_WORKFLOW_ID_ARG_DOC, CylcOptionParser as COP, ) from cylc.flow.terminal import cli_function from cylc.flow.tui import TUI +from cylc.flow.tui.util import suppress_logging from cylc.flow.tui.app import ( TuiApp, - TREE_EXPAND_DEPTH - # ^ a nasty solution ) if TYPE_CHECKING: @@ -55,57 +56,25 @@ def get_option_parser() -> COP: parser = COP( __doc__, - argdoc=[WORKFLOW_ID_ARG_DOC], + argdoc=[OPT_WORKFLOW_ID_ARG_DOC], # auto_add=False, NOTE: at present auto_add can not be turned off color=False ) - parser.add_option( - '--display', - help=( - 'Specify the display technology to use.' - ' "raw" for interactive in-terminal display.' - ' "html" for non-interactive html output.' - ), - action='store', - choices=['raw', 'html'], - default='raw', - ) - parser.add_option( - '--v-term-size', - help=( - 'The virtual terminal size for non-interactive' - '--display options.' - ), - action='store', - default='80,24' - ) - return parser @cli_function(get_option_parser) -def main(_, options: 'Values', workflow_id: str) -> None: - workflow_id, *_ = parse_id( - workflow_id, - constraint='workflows', - ) - screen = None - if options.display == 'html': - TREE_EXPAND_DEPTH[0] = -1 # expand tree fully - screen = html_fragment.HtmlGenerator() - screen.set_terminal_properties(256) - screen.register_palette(TuiApp.palette) - html_fragment.screenshot_init( - [tuple(map(int, options.v_term_size.split(',')))], - [] +def main(_, options: 'Values', workflow_id: Optional[str] = None) -> None: + # get workflow ID if specified + if workflow_id: + workflow_id, *_ = parse_id( + workflow_id, + constraint='workflows', ) + tokens = Tokens(workflow_id) + workflow_id = tokens.duplicate(user=getuser()).id - try: - TuiApp(workflow_id, screen=screen).main() - - if options.display == 'html': - for fragment in html_fragment.screenshot_collect(): - print(fragment) - except KeyboardInterrupt: + # start Tui + with suppress_logging(), TuiApp().main(workflow_id): pass diff --git a/cylc/flow/tui/__init__.py b/cylc/flow/tui/__init__.py index 92e3bce0268..6beaeae059e 100644 --- a/cylc/flow/tui/__init__.py +++ b/cylc/flow/tui/__init__.py @@ -106,12 +106,26 @@ class Bindings: + """Represets key bindings for the Tui app.""" def __init__(self): self.bindings = [] self.groups = {} def bind(self, keys, group, desc, callback): + """Register a key binding. + + Args: + keys: + The keys to bind. + group: + The group to which this binding should belong. + desc: + Description for this binding, used to generate help. + callback: + The thing to call when this binding is pressed. + + """ if group not in self.groups: raise ValueError(f'Group {group} not registered.') binding = { @@ -124,6 +138,15 @@ def bind(self, keys, group, desc, callback): self.groups[group]['bindings'].append(binding) def add_group(self, group, desc): + """Add a new binding group. + + Args: + group: + The name of the group. + desc: + A description of the group, used to generate help. + + """ self.groups[group] = { 'name': group, 'desc': desc, @@ -134,6 +157,12 @@ def __iter__(self): return iter(self.bindings) def list_groups(self): + """List groups and the bindings in them. + + Yields: + (group_name, [binding, ...]) + + """ for name, group in self.groups.items(): yield ( group, @@ -143,6 +172,3 @@ def list_groups(self): if binding['group'] == name ] ) - - -BINDINGS = Bindings() diff --git a/cylc/flow/tui/app.py b/cylc/flow/tui/app.py index cf09f75db0e..fff634ea894 100644 --- a/cylc/flow/tui/app.py +++ b/cylc/flow/tui/app.py @@ -16,32 +16,29 @@ # along with this program. If not, see . """The application control logic for Tui.""" -import sys +from copy import deepcopy +from contextlib import contextmanager +from multiprocessing import Process +import re import urwid -from urwid import html_fragment -from urwid.wimp import SelectableIcon -from pathlib import Path - -from cylc.flow.network.client_factory import get_client -from cylc.flow.exceptions import ( - ClientError, - ClientTimeout, - WorkflowStopped -) -from cylc.flow.pathutil import get_workflow_run_dir +try: + from urwid.widget import SelectableIcon +except ImportError: + # BACK COMPAT: urwid.wimp + # From: urwid 2.0 + # To: urwid 2.2 + from urwid.wimp import SelectableIcon + +from cylc.flow.id import Tokens from cylc.flow.task_state import ( - TASK_STATUSES_ORDERED, TASK_STATUS_SUBMITTED, TASK_STATUS_RUNNING, TASK_STATUS_FAILED, ) -from cylc.flow.tui.data import ( - QUERY -) import cylc.flow.tui.overlay as overlay from cylc.flow.tui import ( - BINDINGS, + Bindings, FORE, BACK, JOB_COLOURS, @@ -49,32 +46,38 @@ ) from cylc.flow.tui.tree import ( find_closest_focus, - translate_collapsing + translate_collapsing, + expand_tree, +) +from cylc.flow.tui.updater import ( + Updater, + get_default_filters, ) from cylc.flow.tui.util import ( - compute_tree, dummy_flow, - get_task_status_summary, - get_workflow_status_str, render_node ) -from cylc.flow.workflow_files import WorkflowFiles +from cylc.flow.workflow_status import ( + WorkflowStatus, +) -urwid.set_encoding('utf8') # required for unicode task icons +# default workflow / task filters +# (i.e. show everything) +DEFAULT_FILTERS = get_default_filters() + -TREE_EXPAND_DEPTH = [2] +urwid.set_encoding('utf8') # required for unicode task icons class TuiWidget(urwid.TreeWidget): """Display widget for tree nodes. Arguments: + app (TuiApp): + Reference to the application. node (TuiNode): The root tree node. - max_depth (int): - Determines which nodes are unfolded by default. - The maximum tree depth to unfold. """ @@ -82,16 +85,12 @@ class TuiWidget(urwid.TreeWidget): # will skip rows when the user navigates unexpandable_icon = SelectableIcon(' ', 0) - def __init__(self, node, max_depth=None): - if not max_depth: - max_depth = TREE_EXPAND_DEPTH[0] + def __init__(self, app, node): + self.app = app self._node = node self._innerwidget = None self.is_leaf = not node.get_child_keys() - if max_depth > 0: - self.expanded = node.get_depth() < max_depth - else: - self.expanded = True + self.expanded = False widget = self.get_indented_widget() urwid.WidgetWrap.__init__(self, widget) @@ -103,18 +102,6 @@ def selectable(self): """ return self.get_node().get_value()['type_'] != 'job_info' - def _is_leaf(self): - """Return True if this node has no children - - Note: the `is_leaf` attribute doesn't seem to give the right - answer. - - """ - return ( - not hasattr(self, 'git_first_child') - or not self.get_first_child() - ) - def get_display_text(self): """Compute the text to display for a given node. @@ -147,8 +134,8 @@ def keypress(self, size, key): return key def get_indented_widget(self): + """Override the Urwid method to handle leaf nodes differently.""" if self.is_leaf: - self._innerwidget = urwid.Columns( [ ('fixed', 1, self.unexpandable_icon), @@ -158,40 +145,80 @@ def get_indented_widget(self): ) return self.__super.get_indented_widget() + def update_expanded_icon(self, subscribe=True): + """Update the +/- icon. -class TuiNode(urwid.TreeNode): - """Data storage object for leaf nodes.""" - - def load_widget(self): - return TuiWidget(self) + This method overrides the built-in urwid update_expanded_icon method + in order to add logic for subscribing and unsubscribing to workflows. + Args: + subscribe: + If True, then we will [un]subscribe to workflows when workflow + nodes are expanded/collapsed. If False, then these events will + be ignored. Note we set subscribe=False when we are translating + the expand/collapse status when rebuilding the tree after an + update. -class TuiParentNode(urwid.ParentNode): - """Data storage object for interior/parent nodes.""" + """ + if subscribe: + node = self.get_node() + value = node.get_value() + data = value['data'] + type_ = value['type_'] + if type_ == 'workflow': + if self.expanded: + self.app.updater.subscribe(data['id']) + self.app.expand_on_load.add(data['id']) + else: + self.app.updater.unsubscribe(data['id']) + return urwid.TreeWidget.update_expanded_icon(self) + + +class TuiNode(urwid.ParentNode): + """Data storage object for Tui tree nodes.""" + + def __init__(self, app, *args, **kwargs): + self.app = app + urwid.ParentNode.__init__(self, *args, **kwargs) def load_widget(self): - return TuiWidget(self) + return TuiWidget(self.app, self) def load_child_keys(self): # Note: keys are really indices. - data = self.get_value() - return range(len(data['children'])) + return range(len(self.get_value()['children'])) def load_child_node(self, key): - """Return either an TuiNode or TuiParentNode""" - childdata = self.get_value()['children'][key] - if 'children' in childdata: - childclass = TuiParentNode - else: - childclass = TuiNode - return childclass( - childdata, + """Return a TuiNode instance for child "key".""" + return TuiNode( + self.app, + self.get_value()['children'][key], parent=self, key=key, depth=self.get_depth() + 1 ) +@contextmanager +def updater_subproc(filters): + """Runs the Updater in its own process. + + The updater provides the data for Tui to render. Running the updater + in its own process removes its CPU load from the Tui app allowing + it to remain responsive whilst updates are being gathered as well as + decoupling the application update logic from the data update logic. + """ + # start the updater + updater = Updater() + p = Process(target=updater.start, args=(filters,)) + try: + p.start() + yield updater + finally: + updater.terminate() + p.join() + + class TuiApp: """An application to display a single Cylc workflow. @@ -206,16 +233,25 @@ class TuiApp: """ - UPDATE_INTERVAL = 1 - CLIENT_TIMEOUT = 1 + # the UI update interval + # NOTE: this is different from the data update interval + UPDATE_INTERVAL = 0.1 + # colours to be used throughout the application palette = [ ('head', FORE, BACK), ('body', FORE, BACK), ('foot', 'white', 'dark blue'), ('key', 'light cyan', 'dark blue'), - ('title', FORE, BACK, 'bold'), + ('title', 'default, bold', BACK), + ('header', 'dark gray', BACK), + ('header_key', 'dark gray, bold', BACK), ('overlay', 'black', 'light gray'), + # cylc logo colours + ('R', 'light red, bold', BACK), + ('Y', 'yellow, bold', BACK), + ('G', 'light green, bold', BACK), + ('B', 'light blue, bold', BACK), ] + [ # job colours (f'job_{status}', colour, BACK) for status, colour in JOB_COLOURS.items() @@ -227,49 +263,137 @@ class TuiApp: for status, spec in WORKFLOW_COLOURS.items() ] - def __init__(self, id_, screen=None): - self.id_ = id_ - self.client = None + def __init__(self, screen=None): self.loop = None - self.screen = None + self.screen = screen self.stack = 0 self.tree_walker = None + # store a reference to the bindings on the app to avoid cicular import + self.bindings = BINDINGS + # create the template - topnode = TuiParentNode(dummy_flow({'id': 'Loading...'})) + topnode = TuiNode(self, dummy_flow({'id': 'Loading...'})) self.listbox = urwid.TreeListBox(urwid.TreeWalker(topnode)) header = urwid.Text('\n') - footer = urwid.AttrWrap( - # urwid.Text(self.FOOTER_TEXT), - urwid.Text(list_bindings()), - 'foot' - ) + footer = urwid.AttrWrap(urwid.Text(list_bindings()), 'foot') self.view = urwid.Frame( urwid.AttrWrap(self.listbox, 'body'), header=urwid.AttrWrap(header, 'head'), footer=footer ) - self.filter_states = { - state: True - for state in TASK_STATUSES_ORDERED - } - if isinstance(screen, html_fragment.HtmlGenerator): - # the HtmlGenerator only captures one frame - # so we need to pre-populate the GUI before - # starting the event loop - self.update() - - def main(self): - """Start the event loop.""" - self.loop = urwid.MainLoop( - self.view, - self.palette, - unhandled_input=self.unhandled_input, - screen=self.screen - ) - # schedule the first update - self.loop.set_alarm_in(0, self._update) - self.loop.run() + self.filters = get_default_filters() + + @contextmanager + def main(self, w_id=None, id_filter=None, interactive=True): + """Start the Tui app. + + With interactive=False, this does not start the urwid event loop to + make testing more deterministic. If you want Tui to update (i.e. + display the latest data), you must call the update method manually. + + Note, we still run the updater asynchronously in a subprocess so that + we can test the interactions between the Tui application and the + updater processes. + + """ + self.set_initial_filters(w_id, id_filter) + + with updater_subproc(self.filters) as updater: + self.updater = updater + + # pre-subscribe to the provided workflow if requested + self.expand_on_load = {w_id or 'root'} + if w_id: + self.updater.subscribe(w_id) + + # configure the urwid main loop + self.loop = urwid.MainLoop( + self.view, + self.palette, + unhandled_input=self.unhandled_input, + screen=self.screen + ) + + if interactive: + # Tui is being run normally as an interactive application + # schedule the first update + self.loop.set_alarm_in(0, self.update) + + # start the urwid main loop + try: + self.loop.run() + except KeyboardInterrupt: + yield + return + else: + # wait for the first full update + self.wait_until_loaded(w_id or 'root') + + yield self + + def set_initial_filters(self, w_id, id_filter): + """Set the default workflow/task filters on startup.""" + if w_id: + # Tui has been configured to look at a single workflow + # => filter everything else out + workflow = str(Tokens(w_id)['workflow']) + self.filters['workflows']['id'] = rf'^{re.escape(workflow)}$' + elif id_filter: + # a custom workflow ID filter has been provided + self.filters['workflows']['id'] = id_filter + + def wait_until_loaded(self, *ids, retries=None, max_fails=50): + """Wait for any requested nodes to be created. + + Warning: + This method is blocking! It's for HTML / testing purposes only! + + Args: + ids: + Iterable containing the node IDs you want to wait for. + Note, these should be full IDs i.e. they should include the + user. + To wait for the root node to load, use "root". + retries: + The maximum number of updates to perform whilst waiting + for the specified IDs to appear in the tree. + max_fails: + If there is no update received from the updater, then we call + it a failed attempt. This isn't necessarily an issue, the + updater might be running a little slow. But if there are a + large number of fails, it likely means the condition won't be + satisfied. + + Returns: + A list of the IDs which NOT not appear in the store. + + Raises: + Exception: + If the "max_fails" are exhausted. + + """ + from time import sleep + ids = set(ids) + self.expand_on_load.update(ids) + successful_updates = 0 + failed_updates = 0 + while ids & self.expand_on_load: + if self.update(): + successful_updates += 1 + if retries is not None and successful_updates > retries: + return list(self.expand_on_load) + else: + failed_updates += 1 + if failed_updates > max_fails: + raise Exception( + f'No update was received after {max_fails} attempts.' + f'\nThere were {successful_updates} successful' + ' updates.' + ) + + sleep(0.15) # blocking to Tui but not to the updater process + return None def unhandled_input(self, key): """Catch key presses, uncaught events are passed down the chain.""" @@ -285,74 +409,6 @@ def unhandled_input(self, key): meth(self, *args) return - def get_snapshot(self): - """Contact the workflow, return a tree structure - - In the event of error contacting the workflow the - message is written to this Widget's header. - - Returns: - dict if successful, else False - - """ - try: - if not self.client: - self.client = get_client(self.id_, timeout=self.CLIENT_TIMEOUT) - data = self.client( - 'graphql', - { - 'request_string': QUERY, - 'variables': { - # list of task states we want to see - 'taskStates': [ - state - for state, is_on in self.filter_states.items() - if is_on - ] - } - } - ) - except WorkflowStopped: - # Distinguish stopped flow from non-existent flow. - self.client = None - full_path = Path(get_workflow_run_dir(self.id_)) - if ( - (full_path / WorkflowFiles.SUITE_RC).is_file() - or (full_path / WorkflowFiles.FLOW_FILE).is_file() - ): - message = "stopped" - else: - message = ( - f"No {WorkflowFiles.SUITE_RC} or {WorkflowFiles.FLOW_FILE}" - f"found in {self.id_}." - ) - - return dummy_flow({ - 'name': self.id_, - 'id': self.id_, - 'status': message, - 'stateTotals': {} - }) - except (ClientError, ClientTimeout) as exc: - # catch network / client errors - self.set_header([('workflow_error', str(exc))]) - return False - - if isinstance(data, list): - # catch GraphQL errors - try: - message = data[0]['error']['message'] - except (IndexError, KeyError): - message = str(data) - self.set_header([('workflow_error', message)]) - return False - - if len(data['workflows']) != 1: - # multiple workflows in returned data - shouldn't happen - raise ValueError() - - return compute_tree(data['workflows'][0]) - @staticmethod def get_node_id(node): """Return a unique identifier for a node. @@ -366,62 +422,82 @@ def get_node_id(node): """ return node.get_value()['id_'] - def set_header(self, message: list): - """Set the header message for this widget. + def update_header(self): + """Update the application header.""" + header = [ + # the Cylc Tui logo + ('R', 'C'), + ('Y', 'y'), + ('G', 'l'), + ('B', 'c'), + ('title', ' Tui') + ] + if self.filters['tasks'] != DEFAULT_FILTERS['tasks']: + # if task filters are active, display short help + header.extend([ + ('header', ' tasks filtered ('), + ('header_key', 'F'), + ('header', ' - edit, '), + ('header_key', 'R'), + ('header', ' - reset)'), + ]) + if self.filters['workflows'] != DEFAULT_FILTERS['workflows']: + # if workflow filters are active, display short help + header.extend([ + ('header', ' workflows filtered ('), + ('header_key', 'W'), + ('header', ' - edit, '), + ('header_key', 'E'), + ('header', ' - reset)'), + ]) + elif self.filters == DEFAULT_FILTERS: + # if not filters are available show application help + header.extend([ + ('header', ' '), + ('header_key', 'h'), + ('header', ' to show help, '), + ('header_key', 'q'), + ('header', ' to quit'), + ]) - Arguments: - message (object): - Text content for the urwid.Text widget, - may be a string, tuple or list, see urwid docs. - - """ # put in a one line gap - message.append('\n') - - # TODO: remove once Tui is delta-driven - # https://github.com/cylc/cylc-flow/issues/3527 - message.extend([ - ( - 'workflow_error', - 'TUI is experimental and may break with large flows' - ), - '\n' - ]) + header.append('\n') - self.view.header = urwid.Text(message) + # replace the previous header + self.view.header = urwid.Text(header) - def _update(self, *_): - try: - self.update() - except Exception as exc: - sys.exit(exc) + def get_update(self): + """Fetch the most recent update. + + Returns the update, or False if there is no update queued. + """ + update = False + while not self.updater.update_queue.empty(): + # fetch the most recent update + update = self.updater.update_queue.get() + return update - def update(self): + def update(self, *_): """Refresh the data and redraw this widget. Preserves the current focus and collapse/expand state. """ - # update the data store - # TODO: this can be done incrementally using deltas - # once this interface is available - snapshot = self.get_snapshot() - if snapshot is False: + # attempt to fetch an update + update = self.get_update() + if update is False: + # there was no update, try again later + if self.loop: + self.loop.set_alarm_in(self.UPDATE_INTERVAL, self.update) return False - # update the workflow status message - header = [get_workflow_status_str(snapshot['data'])] - status_summary = get_task_status_summary(snapshot['data']) - if status_summary: - header.extend([' ('] + status_summary + [' )']) - if not all(self.filter_states.values()): - header.extend([' ', '*filtered* "R" to reset', ' ']) - self.set_header(header) + # update the application header + self.update_header() # global update - the nuclear option - slow but simple # TODO: this can be done incrementally by adding and # removing nodes from the existing tree - topnode = TuiParentNode(snapshot) + topnode = TuiNode(self, update) # NOTE: because we are nuking the tree we need to manually # preserve the focus and collapse status of tree nodes @@ -443,9 +519,15 @@ def update(self): # preserve the collapse/expand status of all nodes translate_collapsing(self, old_node, new_node) + # expand any nodes which have been requested + for id_ in list(self.expand_on_load): + depth = 1 if id_ == 'root' else None + if expand_tree(self, new_node, id_, depth): + self.expand_on_load.remove(id_) + # schedule the next run of this update method if self.loop: - self.loop.set_alarm_in(self.UPDATE_INTERVAL, self._update) + self.loop.set_alarm_in(self.UPDATE_INTERVAL, self.update) return True @@ -457,14 +539,40 @@ def filter_by_task_state(self, filtered_state=None): A task state to filter by or None. """ - self.filter_states = { + self.filters['tasks'] = { state: (state == filtered_state) or not filtered_state - for state in self.filter_states + for state in self.filters['tasks'] } - return + self.updater.update_filters(self.filters) + + def reset_workflow_filters(self): + """Reset workflow state/id filters.""" + self.filters['workflows'] = deepcopy(DEFAULT_FILTERS['workflows']) + self.updater.update_filters(self.filters) + + def filter_by_workflow_state(self, *filtered_states): + """Filter workflows. + + Args: + filtered_state (str): + A task state to filter by or None. + + """ + for state in self.filters['workflows']: + if state != 'id': + self.filters['workflows'][state] = ( + (state in filtered_states) or not filtered_states + ) + self.updater.update_filters(self.filters) def open_overlay(self, fcn): - self.create_overlay(*fcn(self)) + """Open an overlay over the application. + + Args: + fcn: A function which returns an urwid widget to overlay. + + """ + self.create_overlay(*fcn(app=self)) def create_overlay(self, widget, kwargs): """Open an overlay over the monitor. @@ -521,6 +629,10 @@ def close_topmost(self): self.stack -= 1 +# register key bindings +# * all bindings must belong to a group +# * all keys are auto-documented in the help screen and application footer +BINDINGS = Bindings() BINDINGS.add_group( '', 'Application Controls' @@ -603,40 +715,68 @@ def close_topmost(self): ) BINDINGS.add_group( - 'filter', + 'filter tasks', 'Filter by task state' ) BINDINGS.bind( - ('F',), - 'filter', + ('T',), + 'filter tasks', 'Select task states to filter by', (TuiApp.open_overlay, overlay.filter_task_state) ) BINDINGS.bind( ('f',), - 'filter', + 'filter tasks', 'Show only failed tasks', (TuiApp.filter_by_task_state, TASK_STATUS_FAILED) ) BINDINGS.bind( ('s',), - 'filter', + 'filter tasks', 'Show only submitted tasks', (TuiApp.filter_by_task_state, TASK_STATUS_SUBMITTED) ) BINDINGS.bind( ('r',), - 'filter', + 'filter tasks', 'Show only running tasks', (TuiApp.filter_by_task_state, TASK_STATUS_RUNNING) ) BINDINGS.bind( ('R',), - 'filter', + 'filter tasks', 'Reset task state filtering', (TuiApp.filter_by_task_state,) ) +BINDINGS.add_group( + 'filter workflows', + 'Filter by workflow state' +) +BINDINGS.bind( + ('W',), + 'filter workflows', + 'Select workflow states to filter by', + (TuiApp.open_overlay, overlay.filter_workflow_state) +) +BINDINGS.bind( + ('E',), + 'filter workflows', + 'Reset workflow filtering', + (TuiApp.reset_workflow_filters,) +) +BINDINGS.bind( + ('p',), + 'filter workflows', + 'Show only running workflows', + ( + TuiApp.filter_by_workflow_state, + WorkflowStatus.RUNNING.value, + WorkflowStatus.PAUSED.value, + WorkflowStatus.STOPPING.value + ) +) + def list_bindings(): """Write out an in-line list of the key bindings.""" diff --git a/cylc/flow/tui/data.py b/cylc/flow/tui/data.py index 4fd538d8783..04c3d7220b1 100644 --- a/cylc/flow/tui/data.py +++ b/cylc/flow/tui/data.py @@ -16,19 +16,27 @@ from functools import partial from subprocess import Popen, PIPE -import sys -from cylc.flow.exceptions import ClientError +from cylc.flow.exceptions import ( + ClientError, + ClientTimeout, + WorkflowStopped, +) +from cylc.flow.network.client_factory import get_client +from cylc.flow.id import Tokens from cylc.flow.tui.util import ( extract_context ) +# the GraphQL query which Tui runs against each of the workflows +# is is subscribed to QUERY = ''' query cli($taskStates: [String]){ workflows { id name + port status stateTotals taskProxies(states: $taskStates) { @@ -82,6 +90,7 @@ } ''' +# the list of mutations we can call on a running scheduler MUTATIONS = { 'workflow': [ 'pause', @@ -107,35 +116,23 @@ ] } +# mapping of Tui's node types (e.g. workflow) onto GraphQL argument types +# (e.g. WorkflowID) ARGUMENT_TYPES = { + # : 'workflow': '[WorkflowID]!', 'task': '[NamespaceIDGlob]!', } -MUTATION_TEMPLATES = { - 'workflow': ''' - mutation($workflow: [WorkflowID]!) { - pause (workflows: $workflow) { - result - } - } - ''', - 'task': ''' - mutation($workflow: [WorkflowID]!, $task: [NamespaceIDGlob]!) { - trigger (workflows: $workflow, tasks: $task) { - result - } - } - ''' -} - -def cli_cmd(*cmd): +def cli_cmd(*cmd, ret=False): """Issue a CLI command. Args: cmd: The command without the 'cylc' prefix'. + ret: + If True, the stdout will be returned. Rasies: ClientError: @@ -149,28 +146,136 @@ def cli_cmd(*cmd): stdout=PIPE, text=True, ) - out, err = proc.communicate() + _out, err = proc.communicate() if proc.returncode != 0: - raise ClientError(f'Error in command {" ".join(cmd)}\n{err}') + raise ClientError(f'Error in command cylc {" ".join(cmd)}\n{err}') + if ret: + return _out + + +def _show(id_): + """Special mutation to display cylc show output.""" + # dynamic import to avoid circular import issues + from cylc.flow.tui.overlay import text_box + return partial( + text_box, + text=cli_cmd('show', id_, '--color=never', ret=True), + ) + + +def _log(id_): + """Special mutation to open the log view.""" + # dynamic import to avoid circular import issues + from cylc.flow.tui.overlay import log + return partial( + log, + id_=id_, + list_files=partial(_list_log_files, id_), + get_log=partial(_get_log, id_), + ) + + +def _parse_log_header(contents): + """Parse the cat-log header. + + The "--prepend-path" option to "cat-log" adds a line containing the host + and path to the file being viewed in the form: + + # : + + Args: + contents: + The raw log file contents as returned by "cat-log". + Returns: + tuple - (host, path, text) -def _clean(workflow): - # for now we will exit tui when the workflow is cleaned - # this will change when tui supports multiple workflows - cli_cmd('clean', workflow) - sys.exit(0) + host: + The host where the file was retrieved from. + path: + The absolute path to the log file. + text: + The log file contents with the header removed. + + """ + contents, text = contents.split('\n', 1) + contents = contents.replace('# ', '') + host, path = contents.split(':') + return host, path, text + + +def _get_log(id_, filename=None): + """Retrieve the contents of a log file. + + Args: + id_: + The Cylc universal ID of the thing you want to fetch the log file + for. + filename: + The file name to retrieve (note name not path). + If "None", then the default log file will be retrieved. + + """ + cmd = [ + 'cat-log', + '--mode=cat', + '--prepend-path', + ] + if filename: + cmd.append(f'--file={filename}') + text = cli_cmd( + *cmd, + id_, + ret=True, + ) + return _parse_log_header(text) + + +def _list_log_files(id_): + """Return a list of available log files. + + Args: + id_: + The Cylc universal ID of the thing you want to fetch the log file + for. + + """ + text = cli_cmd('cat-log', '--mode=list-dir', id_, ret=True) + return text.splitlines() +# the mutations we have to go through the CLI to perform OFFLINE_MUTATIONS = { + 'user': { + 'stop-all': partial(cli_cmd, 'stop', '*'), + }, 'workflow': { 'play': partial(cli_cmd, 'play'), - 'clean': _clean, + 'clean': partial(cli_cmd, 'clean', '--yes'), 'reinstall-reload': partial(cli_cmd, 'vr', '--yes'), - } + 'log': _log, + }, + 'task': { + 'log': _log, + 'show': _show, + }, + 'job': { + 'log': _log, + }, } def generate_mutation(mutation, arguments): + """Return a GraphQL mutation string. + + Args: + mutation: + The mutation name. + Arguments: + The arguments to provide to it. + + """ + arguments.pop('user') graphql_args = ', '.join([ f'${argument}: {ARGUMENT_TYPES[argument]}' for argument in arguments @@ -189,11 +294,25 @@ def generate_mutation(mutation, arguments): ''' -def list_mutations(client, selection): +def list_mutations(selection, is_running=True): + """List mutations relevant to the provided selection. + + Args: + selection: + The user selection. + is_running: + If False, then mutations which require the scheduler to be + running will be omitted. + + Note, this is only relevant for workflow nodes because if a + workflow is stopped, then any tasks within it will be removed + anyway. + + """ context = extract_context(selection) selection_type = list(context)[-1] ret = [] - if client: + if is_running: # add the online mutations ret.extend(MUTATIONS.get(selection_type, [])) # add the offline mutations @@ -201,47 +320,99 @@ def list_mutations(client, selection): return sorted(ret) -def context_to_variables(context): +def context_to_variables(context, jobs=False): """Derive multiple selection out of single selection. + Note, this interface exists with the aim of facilitating the addition of + multiple selection at a later date. + Examples: >>> context_to_variables(extract_context(['~a/b//c/d'])) - {'workflow': ['b'], 'task': ['c/d']} + {'user': ['a'], 'workflow': ['b'], 'task': ['c/d']} >>> context_to_variables(extract_context(['~a/b//c'])) - {'workflow': ['b'], 'task': ['c/*']} + {'user': ['a'], 'workflow': ['b'], 'task': ['c/*']} >>> context_to_variables(extract_context(['~a/b'])) - {'workflow': ['b']} + {'user': ['a'], 'workflow': ['b']} + + # Note, jobs are omitted by default + >>> context_to_variables(extract_context(['~a/b//c/d/01'])) + {'user': ['a'], 'workflow': ['b'], 'task': ['c/d']} + + # This is because Cylc commands cannot generally operate on jobs only + # tasks. + # To let jobs slide through: + >>> context_to_variables(extract_context(['~a/b//c/d/01']), jobs=True) + {'user': ['a'], 'workflow': ['b'], 'job': ['c/d/01']} """ # context_to_variables because it can only handle single-selection ATM - variables = {'workflow': context['workflow']} - if 'task' in context: + variables = {'user': context['user']} + + if 'workflow' in context: + variables['workflow'] = context['workflow'] + if jobs and 'job' in context: + variables['job'] = [ + Tokens( + cycle=context['cycle'][0], + task=context['task'][0], + job=context['job'][0], + ).relative_id + ] + elif 'task' in context: variables['task'] = [ - f'{context["cycle"][0]}/{context["task"][0]}' + Tokens( + cycle=context['cycle'][0], + task=context['task'][0] + ).relative_id ] elif 'cycle' in context: - variables['task'] = [f'{context["cycle"][0]}/*'] + variables['task'] = [ + Tokens(cycle=context['cycle'][0], task='*').relative_id + ] return variables -def mutate(client, mutation, selection): - if mutation in OFFLINE_MUTATIONS['workflow']: - offline_mutate(mutation, selection) - elif client: - online_mutate(client, mutation, selection) +def mutate(mutation, selection): + """Call a mutation. + + Args: + mutation: + The mutation name (e.g. stop). + selection: + The Tui selection (i.e. the row(s) selected in Tui). + + """ + if mutation in { + _mutation + for section in OFFLINE_MUTATIONS.values() + for _mutation in section + }: + return offline_mutate(mutation, selection) else: - raise Exception( - f'Cannot peform command {mutation} on a stopped workflow' - ' or invalid command.' - ) + online_mutate(mutation, selection) + return None -def online_mutate(client, mutation, selection): +def online_mutate(mutation, selection): """Issue a mutation over a network interface.""" context = extract_context(selection) variables = context_to_variables(context) + + # note this only supports single workflow mutations at present + workflow = variables['workflow'][0] + try: + client = get_client(workflow) + except WorkflowStopped: + raise Exception( + f'Cannot peform command {mutation} on a stopped workflow' + ) + except (ClientError, ClientTimeout) as exc: + raise Exception( + f'Error connecting to workflow: {exc}' + ) + request_string = generate_mutation(mutation, variables) client( 'graphql', @@ -255,7 +426,21 @@ def online_mutate(client, mutation, selection): def offline_mutate(mutation, selection): """Issue a mutation over the CLI or other offline interface.""" context = extract_context(selection) - variables = context_to_variables(context) - for workflow in variables['workflow']: - # NOTE: this currently only supports workflow mutations - OFFLINE_MUTATIONS['workflow'][mutation](workflow) + variables = context_to_variables(context, jobs=True) + if 'job' in variables: + for job in variables['job']: + id_ = Tokens(job, relative=True).duplicate( + workflow=variables['workflow'][0] + ) + return OFFLINE_MUTATIONS['job'][mutation](id_.id) + if 'task' in variables: + for task in variables['task']: + id_ = Tokens(task, relative=True).duplicate( + workflow=variables['workflow'][0] + ) + return OFFLINE_MUTATIONS['task'][mutation](id_.id) + if 'workflow' in variables: + for workflow in variables['workflow']: + return OFFLINE_MUTATIONS['workflow'][mutation](workflow) + else: + return OFFLINE_MUTATIONS['user'][mutation]() diff --git a/cylc/flow/tui/overlay.py b/cylc/flow/tui/overlay.py index cddcc8d5093..94c1a8b2ef5 100644 --- a/cylc/flow/tui/overlay.py +++ b/cylc/flow/tui/overlay.py @@ -38,19 +38,17 @@ """ from functools import partial +import re import sys import urwid -from cylc.flow.exceptions import ( - ClientError, -) +from cylc.flow.id import Tokens from cylc.flow.task_state import ( TASK_STATUSES_ORDERED, TASK_STATUS_WAITING ) from cylc.flow.tui import ( - BINDINGS, JOB_COLOURS, JOB_ICON, TUI @@ -60,32 +58,109 @@ mutate, ) from cylc.flow.tui.util import ( - get_task_icon + get_task_icon, + get_text_dimensions, ) +def _get_display_id(id_): + """Return an ID for display in context menus. + + * Display the full ID for users/workflows + * Display the relative ID for everything else + + """ + tokens = Tokens(id_) + if tokens.is_task_like: + # if it's a cycle/task/job, then use the relative id + return tokens.relative_id + else: + # otherwise use the full id + return tokens.id + + +def _toggle_filter(app, filter_group, status, *_): + """Toggle a filter state.""" + app.filters[filter_group][status] = not app.filters[filter_group][status] + app.updater.update_filters(app.filters) + + +def _invert_filter(checkboxes, *_): + """Invert the state of all filters.""" + for checkbox in checkboxes: + checkbox.set_state(not checkbox.state) + + +def filter_workflow_state(app): + """Return a widget for adjusting the workflow filter options.""" + checkboxes = [ + urwid.CheckBox( + [status], + state=is_on, + on_state_change=partial(_toggle_filter, app, 'workflows', status) + ) + for status, is_on in app.filters['workflows'].items() + if status != 'id' + ] + + workflow_id_prompt = 'id (regex)' + + def update_id_filter(widget, value): + nonlocal app + try: + # ensure the filter is value before updating the filter + re.compile(value) + except re.error: + # error in the regex -> inform the user + widget.set_caption(f'{workflow_id_prompt} - error: \n') + else: + # valid regex -> update the filter + widget.set_caption(f'{workflow_id_prompt}: \n') + app.filters['workflows']['id'] = value + app.updater.update_filters(app.filters) + + id_filter_widget = urwid.Edit( + caption=f'{workflow_id_prompt}: \n', + edit_text=app.filters['workflows']['id'], + ) + urwid.connect_signal(id_filter_widget, 'change', update_id_filter) + + widget = urwid.ListBox( + urwid.SimpleFocusListWalker([ + urwid.Text('Filter Workflow States'), + urwid.Divider(), + urwid.Padding( + urwid.Button( + 'Invert', + on_press=partial(_invert_filter, checkboxes) + ), + right=19 + ) + ] + checkboxes + [ + urwid.Divider(), + id_filter_widget, + ]) + ) + + return ( + widget, + {'width': 35, 'height': 23} + ) + + def filter_task_state(app): """Return a widget for adjusting the task state filter.""" - def toggle(state, *_): - """Toggle a filter state.""" - app.filter_states[state] = not app.filter_states[state] - checkboxes = [ urwid.CheckBox( get_task_icon(state) + [' ' + state], state=is_on, - on_state_change=partial(toggle, state) + on_state_change=partial(_toggle_filter, app, 'tasks', state) ) - for state, is_on in app.filter_states.items() + for state, is_on in app.filters['tasks'].items() ] - def invert(*_): - """Invert the state of all filters.""" - for checkbox in checkboxes: - checkbox.set_state(not checkbox.state) - widget = urwid.ListBox( urwid.SimpleFocusListWalker([ urwid.Text('Filter Task States'), @@ -93,7 +168,7 @@ def invert(*_): urwid.Padding( urwid.Button( 'Invert', - on_press=invert + on_press=partial(_invert_filter, checkboxes) ), right=19 ) @@ -127,7 +202,7 @@ def help_info(app): ] # list key bindings - for group, bindings in BINDINGS.list_groups(): + for group, bindings in app.bindings.list_groups(): items.append( urwid.Text([ f'{group["desc"]}:' @@ -215,21 +290,37 @@ def context(app): value = app.tree_walker.get_focus()[0].get_node().get_value() selection = [value['id_']] # single selection ATM + is_running = True + if ( + value['type_'] == 'workflow' + and value['data']['status'] not in {'running', 'paused'} + ): + # this is a stopped workflow + # => don't display mutations only valid for a running workflow + is_running = False + def _mutate(mutation, _): - nonlocal app + nonlocal app, selection + app.open_overlay(partial(progress, text='Running Command')) + overlay_fcn = None try: - mutate(app.client, mutation, selection) - except ClientError as exc: + overlay_fcn = mutate(mutation, selection) + except Exception as exc: app.open_overlay(partial(error, text=str(exc))) else: app.close_topmost() app.close_topmost() + if overlay_fcn: + app.open_overlay(overlay_fcn) + + # determine the ID to display for the context menu + display_id = _get_display_id(value['id_']) widget = urwid.ListBox( urwid.SimpleFocusListWalker( [ - urwid.Text(f'id: {value["id_"]}'), + urwid.Text(f'id: {display_id}'), urwid.Divider(), urwid.Text('Action'), urwid.Button( @@ -242,14 +333,17 @@ def _mutate(mutation, _): mutation, on_press=partial(_mutate, mutation) ) - for mutation in list_mutations(app.client, selection) + for mutation in list_mutations( + selection, + is_running, + ) ] ) ) return ( widget, - {'width': 30, 'height': 20} + {'width': 50, 'height': 20} ) @@ -273,3 +367,105 @@ def progress(app, text='Working'): ]), {'width': 30, 'height': 10} ) + + +def log(app, id_=None, list_files=None, get_log=None): + """An overlay for displaying log files.""" + # display the host name where the file is coming from + host_widget = urwid.Text('loading...') + # display the log filepath + file_widget = urwid.Text('') + # display the actual log file itself + text_widget = urwid.Text('') + + def open_menu(*_args, **_kwargs): + """Open an overlay for selecting a log file.""" + nonlocal app, id_ + app.open_overlay(select_log) + + def select_log(*_args, **_kwargs): + """Create an overlay for selecting a log file.""" + nonlocal list_files, id_ + try: + files = list_files() + except Exception as exc: + return error(app, text=str(exc)) + return ( + urwid.ListBox([ + *[ + urwid.Text('Select File'), + urwid.Divider(), + ], + *[ + urwid.Button( + filename, + on_press=partial( + open_log, + filename=filename, + close=True, + ), + ) + for filename in files + ], + ]), + # NOTE: the "+6" avoids the need for scrolling + {'width': 40, 'height': len(files) + 6} + ) + + def open_log(*_, filename=None, close=False): + """View the provided log file. + + Args: + filename: + The name of the file to open (note name not path). + close: + If True, then the topmost overlay will be closed when a file is + selected. Use this to close the "select_log" overlay. + + """ + + nonlocal host_widget, file_widget, text_widget + try: + host, path, text = get_log(filename) + except Exception as exc: + host_widget.set_text(f'Error: {exc}') + file_widget.set_text('') + text_widget.set_text('') + else: + host_widget.set_text(f'Host: {host}') + file_widget.set_text(f'Path: {path}') + text_widget.set_text(text) + if close: + app.close_topmost() + + # load the default log file + if id_: + # NOTE: the kwargs are not provided in the overlay unit tests + open_log() + + return ( + urwid.ListBox([ + host_widget, + file_widget, + urwid.Button( + 'Select File', + on_press=open_menu, + ), + urwid.Divider(), + text_widget, + ]), + # open full screen + {'width': 9999, 'height': 9999} + ) + + +def text_box(app, text=''): + """A simple text box overlay.""" + width, height = get_text_dimensions(text) + return ( + urwid.ListBox([ + urwid.Text(text), + ]), + # NOTE: those fudge factors account for the overlay border & padding + {'width': width + 4, 'height': height + 6} + ) diff --git a/cylc/flow/tui/tree.py b/cylc/flow/tui/tree.py index 84ed55ab53a..02cecee61ed 100644 --- a/cylc/flow/tui/tree.py +++ b/cylc/flow/tui/tree.py @@ -56,6 +56,78 @@ def find_closest_focus(app, old_node, new_node): ) +def expand_tree(app, tree_node, id_, depth=5, node_types=None): + """Expand the Tui tree to the desired level. + + Arguments: + app: + The Tui application instance. + tree_node: + The Tui widget representing the tree view. + id_: + If specified, we will look within the tree for a node matching + this ID and the tree below this node will be expanded. + depth: + The max depth to expand nodes too. + node_types: + Whitelist of node types to expand, note "task", "job" and "spring" + nodes are excluded by default. + + Returns: + True, if the node was found in the tree, is loaded and has been + expanded. + + Examples: + # expand the top three levels of the tree + compute_tree(app, node, None, 3) + + # expand the "root" node AND the top five levels of the tree under + # ~user/workflow + compute_tree(app, node, '~user/workflow') + + """ + if not node_types: + # don't auto-expand job nodes by default + node_types = {'root', 'workflow', 'cycle', 'family'} + + root_node = tree_node.get_root() + requested_node = root_node + + # locate the "id_" within the tree if specified + if id_: + for node in walk_tree(root_node): + key = app.get_node_id(node) + if key == id_: + requested_node = node + child_keys = node.get_child_keys() + if ( + # if the node only has one child + len(child_keys) == 1 + # and that child is a "#spring" node (i.e. a loading node) + and ( + node.get_child_node(0).get_value()['type_'] + ) == '#spring' + ): + # then the content hasn't loaded yet so the node cannot be + # expanded + return False + break + else: + # the requested node does not exist yet + # it might still be loading + return False + + # expand the specified nodes + for node in (*walk_tree(requested_node, depth), root_node): + if node.get_value()['type_'] not in node_types: + continue + widget = node.get_widget() + widget.expanded = True + widget.update_expanded_icon(False) + + return True + + def translate_collapsing(app, old_node, new_node): """Transfer the collapse state from one tree to another. @@ -81,29 +153,46 @@ def translate_collapsing(app, old_node, new_node): for node in walk_tree(new_root): key = app.get_node_id(node) if key in old_tree: + # this node was present before + # => translate its expansion to the new tree expanded = old_tree.get(key) widget = node.get_widget() if widget.expanded != expanded: widget.expanded = expanded - widget.update_expanded_icon() - - -def walk_tree(node): + widget.update_expanded_icon(False) + else: + # this node was not present before + # => apply the standard expansion logic + expand_tree( + app, + node, + key, + 3, + # don't auto-expand workflows, only cycles/families + # and the root node to help expand the tree on startup + node_types={'root', 'cycle', 'family'} + ) + + +def walk_tree(node, depth=None): """Yield nodes in order. Arguments: node (urwid.TreeNode): Yield this node and all nodes beneath it. + depth: + The maximum depth to walk to or None to walk all children. Yields: urwid.TreeNode """ - stack = [node] + stack = [(node, 1)] while stack: - node = stack.pop() + node, _depth = stack.pop() yield node - stack.extend([ - node.get_child_node(index) - for index in node.get_child_keys() - ]) + if depth is None or _depth < depth: + stack.extend([ + (node.get_child_node(index), _depth + 1) + for index in node.get_child_keys() + ]) diff --git a/cylc/flow/tui/updater.py b/cylc/flow/tui/updater.py new file mode 100644 index 00000000000..f9bd0cf2c00 --- /dev/null +++ b/cylc/flow/tui/updater.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Contains the logic for updating the Tui app.""" + +from asyncio import ( + run, + sleep, + gather, +) +from contextlib import suppress +from copy import deepcopy +from getpass import getuser +from multiprocessing import Queue +from time import time + +from zmq.error import ZMQError + +from cylc.flow.exceptions import ( + ClientError, + ClientTimeout, + CylcError, + WorkflowStopped, +) +from cylc.flow.id import Tokens +from cylc.flow.network.client_factory import get_client +from cylc.flow.network.scan import ( + filter_name, + graphql_query, + is_active, + scan, +) +from cylc.flow.task_state import ( + TASK_STATUSES_ORDERED, +) +from cylc.flow.tui.data import ( + QUERY +) +from cylc.flow.tui.util import ( + compute_tree, + suppress_logging, +) +from cylc.flow.workflow_status import ( + WorkflowStatus, +) + + +ME = getuser() + + +def get_default_filters(): + """Return default task/workflow filters. + + These filters show everything. + """ + return { + 'tasks': { + # filtered task statuses + state: True + for state in TASK_STATUSES_ORDERED + }, + 'workflows': { + # filtered workflow statuses + **{ + state.value: True + for state in WorkflowStatus + }, + # filtered workflow ids + 'id': '.*', + } + } + + +class Updater(): + """The bit of Tui which provides the data. + + It lists workflows using the "scan" interface, and provides detail using + the "GraphQL" interface. + + """ + + # the maximum time to wait for a workflow update + CLIENT_TIMEOUT = 2 + + # the interval between workflow listing scans + BASE_SCAN_INTERVAL = 20 + + # the interval between workflow data updates + BASE_UPDATE_INTERVAL = 1 + + # the command signal used to tell the updater to shut down + SIGNAL_TERMINATE = 'terminate' + + def __init__(self): + # Cylc comms clients for each workflow we're connected to + self._clients = {} + + # iterate over this to get a list of workflows + self._scan_pipe = None + # the new pipe if the workflow filter options are changed + self.__scan_pipe = None + + # task/workflow filters + self.filters = None # note set on self.run() + # queue for pushing out updates + self.update_queue = Queue( + # block the updater if it gets too far ahead of the application + maxsize=10 + ) + # queue for commands to the updater + self._command_queue = Queue() + + def subscribe(self, w_id): + """Subscribe to updates from a workflow.""" + self._command_queue.put((self._subscribe.__name__, w_id)) + + def unsubscribe(self, w_id): + """Unsubscribe to updates from a workflow.""" + self._command_queue.put((self._unsubscribe.__name__, w_id)) + + def update_filters(self, filters): + """Update the task state filter.""" + self._command_queue.put((self._update_filters.__name__, filters)) + + def terminate(self): + """Stop the updater.""" + self._command_queue.put((self.SIGNAL_TERMINATE, None)) + + def start(self, filters): + """Start the updater in a new asyncio.loop. + + The Tui app will call this within a dedicated process. + """ + with suppress(KeyboardInterrupt): + run(self.run(filters)) + + async def run(self, filters): + """Start the updater in an existing asyncio.loop. + + The tests call this within the same process. + """ + with suppress_logging(): + self._update_filters(filters) + while True: + ret = await self._update() + if ret == self.SIGNAL_TERMINATE: + break + self.update_queue.put(ret) + + def _subscribe(self, w_id): + if w_id not in self._clients: + self._clients[w_id] = None + + def _unsubscribe(self, w_id): + if w_id in self._clients: + self._clients.pop(w_id) + + def _update_filters(self, filters): + if ( + not self.filters + or filters['workflows']['id'] != self.filters['workflows']['id'] + ): + # update the scan pipe + self.__scan_pipe = ( + # scan all workflows + scan + | filter_name(filters['workflows']['id']) + # if the workflow is active, retrieve its status + | is_active(True, filter_stop=False) + | graphql_query({'status': None}) + ) + + self.filters = filters + + async def _update(self): + """Run one iteration of the updater. + + Either returns the next update or "self.SIGNAL_TERMINATE". + """ + last_scan_time = 0 + # process any pending commands + while not self._command_queue.empty(): + (command, payload) = self._command_queue.get() + if command == self.SIGNAL_TERMINATE: + return command + getattr(self, command)(payload) + + # do a workflow scan if it's due + update_start_time = time() + if update_start_time - last_scan_time > self.BASE_SCAN_INTERVAL: + data = await self._scan() + + # get the next snapshot from workflows we are subscribed to + update = await self._run_update(data) + + # schedule the next update + update_time = time() - update_start_time + await sleep(self.BASE_UPDATE_INTERVAL - update_time) + return update + + async def _run_update(self, data): + # copy the scanned data so it can be reused for future updates + data = deepcopy(data) + + # connect to schedulers if needed + self._connect(data) + + # update data with the response from each workflow + # NOTE: Currently we're bunching these updates together so Tui will + # only update as fast as the slowest responding workflow. + # We could run these updates separately if this is an issue. + await gather( + *( + self._update_workflow(w_id, client, data) + for w_id, client in self._clients.items() + ) + ) + + return compute_tree(data) + + async def _update_workflow(self, w_id, client, data): + if not client: + # we could not connect to this workflow + # e.g. workflow is shut down + return + + try: + # fetch the data from the workflow + workflow_update = await client.async_request( + 'graphql', + { + 'request_string': QUERY, + 'variables': { + # list of task states we want to see + 'taskStates': [ + state + for state, is_on in self.filters['tasks'].items() + if is_on + ] + } + } + ) + except WorkflowStopped: + # remove the client on any error, we'll reconnect next time + self._clients[w_id] = None + for workflow in data['workflows']: + if workflow['id'] == w_id: + break + else: + # there's no entry here, create a stub + # NOTE: this handles the situation where we've connected to a + # workflow before it has appeared in a scan which matters to + # the tests as they use fine timings + data['workflows'].append({ + 'id': w_id, + 'status': 'stopped', + }) + except (CylcError, ZMQError): + # something went wrong :( + # remove the client on any error, we'll reconnect next time + self._clients[w_id] = None + else: + # the data arrived, add it to the update + workflow_data = workflow_update['workflows'][0] + for workflow in data['workflows']: + if workflow['id'] == workflow_data['id']: + workflow.update(workflow_data) + break + + def _connect(self, data): + """Connect to all subscribed workflows.""" + for w_id, client in self._clients.items(): + if not client: + try: + self._clients[w_id] = get_client( + Tokens(w_id)['workflow'], + timeout=self.CLIENT_TIMEOUT + ) + except WorkflowStopped: + for workflow in data['workflows']: + if workflow['id'] == w_id: + workflow['_tui_data'] = 'Workflow is not running' + except (ZMQError, ClientError, ClientTimeout) as exc: + for workflow in data['workflows']: + if workflow['id'] == w_id: + workflow['_tui_data'] = f'Error: {exc}' + break + + async def _scan(self): + """Scan for workflows on the filesystem.""" + data = {'workflows': []} + workflow_filter_statuses = { + status + for status, filtered in self.filters['workflows'].items() + if filtered + } + if self.__scan_pipe: + # switch to the new pipe if it has been changed + self._scan_pipe = self.__scan_pipe + async for workflow in self._scan_pipe: + status = workflow.get('status', WorkflowStatus.STOPPED.value) + if status not in workflow_filter_statuses: + # this workflow is filtered out + continue + data['workflows'].append({ + 'id': f'~{ME}/{workflow["name"]}', + 'name': workflow['name'], + 'status': status, + 'stateTotals': {}, + }) + + data['workflows'].sort(key=lambda x: x['id']) + return data diff --git a/cylc/flow/tui/util.py b/cylc/flow/tui/util.py index 575fc693437..88e960e249a 100644 --- a/cylc/flow/tui/util.py +++ b/cylc/flow/tui/util.py @@ -16,10 +16,14 @@ # along with this program. If not, see . """Common utilities for Tui.""" +from contextlib import contextmanager +from getpass import getuser from itertools import zip_longest import re from time import time +from typing import Tuple +from cylc.flow import LOG from cylc.flow.id import Tokens from cylc.flow.task_state import ( TASK_STATUS_RUNNING @@ -33,6 +37,26 @@ from cylc.flow.wallclock import get_unix_time_from_time_string +# the Tui user, note this is always the same as the workflow owner +# (Tui doesn't do multi-user stuff) +ME = getuser() + + +@contextmanager +def suppress_logging(): + """Suppress Cylc logging. + + Log goes to stdout/err which can pollute Urwid apps. + Patching sys.stdout/err is insufficient so we set the level to something + silly for the duration of this context manager then set it back again + afterwards. + """ + level = LOG.getEffectiveLevel() + LOG.setLevel(99999) + yield + LOG.setLevel(level) + + def get_task_icon( status, *, @@ -113,80 +137,90 @@ def idpop(id_): return tokens.id -def compute_tree(flow): - """Digest GraphQL data to produce a tree. +def compute_tree(data): + """Digest GraphQL data to produce a tree.""" + root_node = add_node('root', 'root', {}, data={}) - Arguments: - flow (dict): - A dictionary representing a single workflow. + for flow in data['workflows']: + nodes = {} + flow_node = add_node( + 'workflow', flow['id'], nodes, data=flow) + root_node['children'].append(flow_node) - Returns: - dict - A top-level workflow node. + # populate cycle nodes + for cycle in flow.get('cyclePoints', []): + cycle['id'] = idpop(cycle['id']) # strip the family off of the id + cycle_node = add_node('cycle', cycle['id'], nodes, data=cycle) + flow_node['children'].append(cycle_node) - """ - nodes = {} - flow_node = add_node( - 'workflow', flow['id'], nodes, data=flow) - - # populate cycle nodes - for cycle in flow['cyclePoints']: - cycle['id'] = idpop(cycle['id']) # strip the family off of the id - cycle_node = add_node('cycle', cycle['id'], nodes, data=cycle) - flow_node['children'].append(cycle_node) - - # populate family nodes - for family in flow['familyProxies']: - add_node('family', family['id'], nodes, data=family) - - # create cycle/family tree - for family in flow['familyProxies']: - family_node = add_node( - 'family', family['id'], nodes) - first_parent = family['firstParent'] - if ( - first_parent - and first_parent['name'] != 'root' - ): - parent_node = add_node( - 'family', first_parent['id'], nodes) - parent_node['children'].append(family_node) - else: - add_node( - 'cycle', idpop(family['id']), nodes - )['children'].append(family_node) - - # add leaves - for task in flow['taskProxies']: - # If there's no first parent, the child will have been deleted - # during/after API query resolution. So ignore. - if not task['firstParent']: - continue - task_node = add_node( - 'task', task['id'], nodes, data=task) - if task['firstParent']['name'] == 'root': - family_node = add_node( - 'cycle', idpop(task['id']), nodes) - else: + # populate family nodes + for family in flow.get('familyProxies', []): + add_node('family', family['id'], nodes, data=family) + + # create cycle/family tree + for family in flow.get('familyProxies', []): family_node = add_node( - 'family', task['firstParent']['id'], nodes) - family_node['children'].append(task_node) - for job in task['jobs']: - job_node = add_node( - 'job', job['id'], nodes, data=job) - job_info_node = add_node( - 'job_info', job['id'] + '_info', nodes, data=job) - job_node['children'] = [job_info_node] - task_node['children'].append(job_node) - - # sort - for (type_, _), node in nodes.items(): - if type_ != 'task': - # NOTE: jobs are sorted by submit-num in the GraphQL query - node['children'].sort( - key=lambda x: NaturalSort(x['id_']) + 'family', family['id'], nodes) + first_parent = family['firstParent'] + if ( + first_parent + and first_parent['name'] != 'root' + ): + parent_node = add_node( + 'family', first_parent['id'], nodes) + parent_node['children'].append(family_node) + else: + add_node( + 'cycle', idpop(family['id']), nodes + )['children'].append(family_node) + + # add leaves + for task in flow.get('taskProxies', []): + # If there's no first parent, the child will have been deleted + # during/after API query resolution. So ignore. + if not task['firstParent']: + continue + task_node = add_node( + 'task', task['id'], nodes, data=task) + if task['firstParent']['name'] == 'root': + family_node = add_node( + 'cycle', idpop(task['id']), nodes) + else: + family_node = add_node( + 'family', task['firstParent']['id'], nodes) + family_node['children'].append(task_node) + for job in task['jobs']: + job_node = add_node( + 'job', job['id'], nodes, data=job) + job_info_node = add_node( + 'job_info', job['id'] + '_info', nodes, data=job) + job_node['children'] = [job_info_node] + task_node['children'].append(job_node) + + # sort + for (type_, _), node in nodes.items(): + if type_ != 'task': + # NOTE: jobs are sorted by submit-num in the GraphQL query + node['children'].sort( + key=lambda x: NaturalSort(x['id_']) + ) + + # spring nodes + if 'port' not in flow: + # the "port" field is only available via GraphQL + # so we are not connected to this workflow yet + flow_node['children'].append( + add_node( + '#spring', + '#spring', + nodes, + data={ + 'id': flow.get('_tui_data', 'Loading ...'), + } + ) ) - return flow_node + return root_node class NaturalSort: @@ -340,7 +374,7 @@ def get_task_status_summary(flow): state_totals = flow['stateTotals'] return [ [ - ('', ' '), + ' ', (f'job_{state}', str(state_totals[state])), (f'job_{state}', JOB_ICON) ] @@ -361,103 +395,126 @@ def get_workflow_status_str(flow): list - Text list for the urwid.Text widget. """ - status = flow['status'] + + +def _render_user(node, data): + return f'~{ME}' + + +def _render_job_info(node, data): + key_len = max(len(key) for key in data) + ret = [ + f'{key} {" " * (key_len - len(key))} {value}\n' + for key, value in data.items() + ] + ret[-1] = ret[-1][:-1] # strip trailing newline + return ret + + +def _render_job(node, data): return [ - ( - 'title', - flow['name'], - ), - ' - ', - ( - f'workflow_{status}', - status - ) + f'#{data["submitNum"]:02d} ', + get_job_icon(data['state']) ] -def render_node(node, data, type_): - """Render a tree node as text. +def _render_task(node, data): + start_time = None + mean_time = None + try: + # due to sorting this is the most recent job + first_child = node.get_child_node(0) + except IndexError: + first_child = None + + # progress information + if data['state'] == TASK_STATUS_RUNNING and first_child: + start_time = first_child.get_value()['data']['startedTime'] + mean_time = data['task']['meanElapsedTime'] + + # the task icon + ret = get_task_icon( + data['state'], + is_held=data['isHeld'], + is_queued=data['isQueued'], + is_runahead=data['isRunahead'], + start_time=start_time, + mean_time=mean_time + ) - Args: - node (MonitorNode): - The node to render. - data (dict): - Data associated with that node. - type_ (str): - The node type (e.g. `task`, `job`, `family`). + # the most recent job status + ret.append(' ') + if first_child: + state = first_child.get_value()['data']['state'] + ret += [(f'job_{state}', f'{JOB_ICON}'), ' '] - """ - if type_ == 'job_info': - key_len = max(len(key) for key in data) - ret = [ - f'{key} {" " * (key_len - len(key))} {value}\n' - for key, value in data.items() - ] - ret[-1] = ret[-1][:-1] # strip trailing newline - return ret + # the task name + ret.append(f'{data["name"]}') + return ret - if type_ == 'job': - return [ - f'#{data["submitNum"]:02d} ', - get_job_icon(data['state']) - ] - if type_ == 'task': - start_time = None - mean_time = None - try: - # due to sorting this is the most recent job - first_child = node.get_child_node(0) - except IndexError: - first_child = None - - # progress information - if data['state'] == TASK_STATUS_RUNNING and first_child: - start_time = first_child.get_value()['data']['startedTime'] - mean_time = data['task']['meanElapsedTime'] - - # the task icon - ret = get_task_icon( +def _render_family(node, data): + return [ + get_task_icon( data['state'], is_held=data['isHeld'], is_queued=data['isQueued'], - is_runahead=data['isRunahead'], - start_time=start_time, - mean_time=mean_time - ) + is_runahead=data['isRunahead'] + ), + ' ', + Tokens(data['id']).pop_token()[1] + ] - # the most recent job status - ret.append(' ') - if first_child: - state = first_child.get_value()['data']['state'] - ret += [(f'job_{state}', f'{JOB_ICON}'), ' '] - - # the task name - ret.append(f'{data["name"]}') - return ret - - if type_ in ['family', 'cycle']: - return [ - get_task_icon( - data['state'], - is_held=data['isHeld'], - is_queued=data['isQueued'], - is_runahead=data['isRunahead'] + +def _render_unknown(node, data): + try: + state_totals = get_task_status_summary(data) + status = data['status'] + status_msg = [ + ( + 'title', + _display_workflow_id(data), ), - ' ', - Tokens(data['id']).pop_token()[1] + ' - ', + ( + f'workflow_{status}', + status + ) ] + except KeyError: + return Tokens(data['id']).pop_token()[1] + + return [*status_msg, *state_totals] + - return Tokens(data['id']).pop_token()[1] +def _display_workflow_id(data): + return data['name'] -PARTS = [ - 'user', - 'workflow', - 'cycle', - 'task', - 'job' -] +RENDER_FUNCTIONS = { + 'user': _render_user, + 'root': _render_user, + 'job_info': _render_job_info, + 'job': _render_job, + 'task': _render_task, + 'cycle': _render_family, + 'family': _render_family, +} + + +def render_node(node, data, type_): + """Render a tree node as text. + + Args: + node (MonitorNode): + The node to render. + data (dict): + Data associated with that node. + type_ (str): + The node type (e.g. `task`, `job`, `family`). + + """ + return RENDER_FUNCTIONS.get(type_, _render_unknown)(node, data) def extract_context(selection): @@ -476,9 +533,18 @@ def extract_context(selection): {'user': ['a'], 'workflow': ['b'], 'cycle': ['c'], 'task': ['d'], 'job': ['e']} + >>> list(extract_context(['root']).keys()) + ['user'] + """ ret = {} for item in selection: + if item == 'root': + # special handling for the Tui "root" node + # (this represents the workflow owner which is always the same as + # user for Tui) + ret['user'] = ME + continue tokens = Tokens(item) for key, value in tokens.items(): if ( @@ -489,3 +555,24 @@ def extract_context(selection): if value not in lst: lst.append(value) return ret + + +def get_text_dimensions(text: str) -> Tuple[int, int]: + """Return the monospace size of a block of multiline text. + + Examples: + >>> get_text_dimensions('foo') + (3, 1) + + >>> get_text_dimensions(''' + ... foo bar + ... baz + ... ''') + (11, 3) + + >>> get_text_dimensions('') + (0, 0) + + """ + lines = text.splitlines() + return max((0, *(len(line) for line in lines))), len(lines) diff --git a/setup.cfg b/setup.cfg index 6b0ae3e1cc8..53b2024a9a3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -117,8 +117,8 @@ tests = flake8-type-checking; python_version > "3.7" flake8>=3.0.0 mypy>=0.910 - # https://github.com/pytest-dev/pytest-asyncio/issues/655 - pytest-asyncio>=0.17,!=0.22.0 + # https://github.com/pytest-dev/pytest-asyncio/issues/706 + pytest-asyncio>=0.17,!=0.23.* pytest-cov>=2.8.0 pytest-xdist>=2 pytest-env>=0.6.2 diff --git a/tests/conftest.py b/tests/conftest.py index 8f07a4f3441..8ed3e210fc6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,15 @@ from cylc.flow.parsec.validate import cylc_config_validate +@pytest.fixture(scope='module') +def mod_monkeypatch(): + """A module-scoped version of the monkeypatch fixture.""" + from _pytest.monkeypatch import MonkeyPatch + mpatch = MonkeyPatch() + yield mpatch + mpatch.undo() + + @pytest.fixture def mock_glbl_cfg(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """A Pytest fixture for fiddling global config values. diff --git a/tests/integration/tui/__init__.py b/tests/integration/tui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/tui/conftest.py b/tests/integration/tui/conftest.py new file mode 100644 index 00000000000..813d81ad4f4 --- /dev/null +++ b/tests/integration/tui/conftest.py @@ -0,0 +1,307 @@ +from contextlib import contextmanager +from difflib import unified_diff +import os +from pathlib import Path +import re +from time import sleep +from uuid import uuid1 + +import pytest +from urwid import html_fragment + +from cylc.flow.id import Tokens +from cylc.flow.tui.app import TuiApp +from cylc.flow.tui.overlay import _get_display_id + + +SCREENSHOT_DIR = Path(__file__).parent / 'screenshots' + + +def configure_screenshot(v_term_size): + """Configure Urwid HTML screenshots.""" + screen = html_fragment.HtmlGenerator() + screen.set_terminal_properties(256) + screen.register_palette(TuiApp.palette) + html_fragment.screenshot_init( + [tuple(map(int, v_term_size.split(',')))], + [] + ) + return screen, html_fragment + + +def format_test_failure(expected, got, description): + """Return HTML to represent a screenshot test failure. + + Args: + expected: + HTML fragment for the expected screenshot. + got: + HTML fragment for the test screenshot. + description: + Test description. + + """ + diff = '\n'.join(unified_diff( + expected.splitlines(), + got.splitlines(), + fromfile='expected', + tofile='got', + )) + return f''' + + +

{description}

+ + + + + + + + + +
ExpectedGot
{expected}{got}
+
+

Diff:

+
{ diff }
+ + ''' + + +class RaikuraSession: + """Convenience class for accessing Raikura functionality.""" + + def __init__(self, app, html_fragment, test_dir, test_name): + self.app = app + self.html_fragment = html_fragment + self.test_dir = test_dir + self.test_name = test_name + + def user_input(self, *keys): + """Simulate a user pressing keys. + + Each "key" is a keyboard button e.g. "x" or "enter". + + If you provide more than one key, each one will be pressed, one + after another. + + You can combine keys in a single string, e.g. "ctrl d". + """ + return self.app.loop.process_input(keys) + + def compare_screenshot( + self, + name, + description, + retries=3, + delay=0.1, + force_update=True, + ): + """Take a screenshot and compare it to one taken previously. + + To update the screenshot, set the environment variable + "CYLC_UPDATE_SCREENSHOTS" to "true". + + Note, if the comparison fails, "force_update" is called and the + test is retried. + + Arguments: + name: + The name to use for the screenshot, this is used in the + filename for the generated HTML fragment. + description: + A description of the test to be used on test failure. + retries: + The maximum number of retries for this test before failing. + delay: + The delay between retries. This helps overcome timing issues + with data provision. + + Raises: + Exception: + If the screenshot does not match the reference. + + """ + filename = SCREENSHOT_DIR / f'{self.test_name}.{name}.html' + + exc = None + for _try in range(retries): + # load the expected result + expected = '' + if filename.exists(): + with open(filename, 'r') as expected_file: + expected = expected_file.read() + # update to pick up latest data + if force_update: + self.force_update() + # force urwid to draw the screen + # (the main loop isn't runing so this doesn't happen automatically) + self.app.loop.draw_screen() + # take a screenshot + screenshot = self.html_fragment.screenshot_collect()[-1] + + try: + if expected != screenshot: + # screenshot does not match + # => write an html file with the visual diff + out = self.test_dir / filename.name + with open(out, 'w+') as out_file: + out_file.write( + format_test_failure( + expected, + screenshot, + description, + ) + ) + raise Exception( + 'Screenshot differs:' + '\n* Set "CYLC_UPDATE_SCREENSHOTS=true" to update' + f'\n* To debug see: file:////{out}' + ) + + break + except Exception as exc_: + exc = exc_ + # wait a while to allow the updater to do its job + sleep(delay) + else: + if os.environ.get('CYLC_UPDATE_SCREENSHOTS', '').lower() == 'true': + with open(filename, 'w+') as expected_file: + expected_file.write(screenshot) + else: + raise exc + + def force_update(self): + """Run Tui's update method. + + This is done automatically by compare_screenshot but you may want to + call it in a test, e.g. before pressing navigation keys. + + With Raikura, the Tui event loop is not running so the data is never + refreshed. + + You do NOT need to call this method for key presses, but you do need to + call this if the data has changed (e.g. if you've changed a task state) + OR if you've changed any filters (because filters are handled by the + update code). + """ + # flush any prior updates + self.app.get_update() + # wait for the next update + while not self.app.update(): + pass + + def wait_until_loaded(self, *ids, retries=20): + """Wait until the given ID appears in the Tui tree, then expand them. + + Useful for waiting whilst Tui loads a workflow. + + Note, this is a blocking wait with no timeout! + """ + exc = None + try: + ids = self.app.wait_until_loaded(*ids, retries=retries) + except Exception as _exc: + exc = _exc + if ids: + msg = ( + 'Requested nodes did not appear in Tui after' + f' {retries} retries: ' + + ', '.join(ids) + ) + if exc: + msg += f'\n{exc}' + self.compare_screenshot(f'fail-{uuid1()}', msg, 1) + + +@pytest.fixture +def raikura(test_dir, request, monkeypatch): + """Visual regression test framework for Urwid apps. + + Like Cypress but for Tui so named after a NZ island with lots of Tuis. + + When called this yields a RaikuraSession object loaded with test + utilities. All tests have default retries to avoid flaky tests. + + Similar to the "start" fixture, which starts a Scheduler without running + the main loop, raikura starts Tui without running the main loop. + + Arguments: + workflow_id: + The "WORKFLOW" argument of the "cylc tui" command line. + size: + The virtual terminal size for screenshots as a comma + separated string e.g. "80,50" for 80 cols wide by 50 rows tall. + + Returns: + A RaikuraSession context manager which provides useful utilities for + testing. + + """ + return _raikura(test_dir, request, monkeypatch) + + +@pytest.fixture +def mod_raikura(test_dir, request, monkeypatch): + """Same as raikura but configured to view module-scoped workflows. + + Note: This is *not* a module-scoped fixture (no need, creating Tui sessions + is not especially slow), it is configured to display module-scoped + "scheduler" fixtures (which may be more expensive to create/destroy). + """ + return _raikura(test_dir.parent, request, monkeypatch) + + +def _raikura(test_dir, request, monkeypatch): + # make the workflow and scan update intervals match (more reliable) + # and speed things up a little whilst we're at it + monkeypatch.setattr( + 'cylc.flow.tui.updater.Updater.BASE_UPDATE_INTERVAL', + 0.1, + ) + monkeypatch.setattr( + 'cylc.flow.tui.updater.Updater.BASE_SCAN_INTERVAL', + 0.1, + ) + + # the user name and the prefix of workflow IDs are both variable + # so we patch the render functions to make test output stable + def get_display_id(id_): + tokens = Tokens(id_) + return _get_display_id( + tokens.duplicate( + user='cylc', + workflow=tokens.get('workflow', '').rsplit('/', 1)[-1], + ).id + ) + monkeypatch.setattr('cylc.flow.tui.util.ME', 'cylc') + monkeypatch.setattr( + 'cylc.flow.tui.util._display_workflow_id', + lambda data: data['name'].rsplit('/', 1)[-1] + ) + monkeypatch.setattr( + 'cylc.flow.tui.overlay._get_display_id', + get_display_id, + ) + + # filter Tui so that only workflows created within our test show up + id_base = str(test_dir.relative_to(Path("~/cylc-run").expanduser())) + workflow_filter = re.escape(id_base) + r'/.*' + + @contextmanager + def _raikura(workflow_id=None, size='80,50'): + screen, html_fragment = configure_screenshot(size) + app = TuiApp(screen=screen) + with app.main( + workflow_id, + id_filter=workflow_filter, + interactive=False, + ): + yield RaikuraSession( + app, + html_fragment, + test_dir, + request.function.__name__, + ) + + return _raikura diff --git a/tests/integration/tui/screenshots/test_auto_expansion.later-time.html b/tests/integration/tui/screenshots/test_auto_expansion.later-time.html new file mode 100644 index 00000000000..f5a19fd428d --- /dev/null +++ b/tests/integration/tui/screenshots/test_auto_expansion.later-time.html @@ -0,0 +1,21 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   - one - paused                                                               
+      - ̿○ 1                                                                     
+           ̿○ b                                                                  
+      - ̿○ 2                                                                     
+         - ̿○ A                                                                  
+              ̿○ a                                                               
+           ○ b                                                                  
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_auto_expansion.on-load.html b/tests/integration/tui/screenshots/test_auto_expansion.on-load.html new file mode 100644 index 00000000000..df3c9f5c41b --- /dev/null +++ b/tests/integration/tui/screenshots/test_auto_expansion.on-load.html @@ -0,0 +1,21 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   - one - paused                                                               
+      - ̿○ 1                                                                     
+         - ̿○ A                                                                  
+              ̿○ a                                                               
+           ○ b                                                                  
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_errors.list-error.html b/tests/integration/tui/screenshots/test_errors.list-error.html new file mode 100644 index 00000000000..02448aa0267 --- /dev/null +++ b/tests/integration/tui/screenshots/test_errors.list-error.html @@ -0,0 +1,31 @@ +
────────────────────────────────────────────────────────────────────────────
+  Error: Somethi  Error                                                     
+                                                                            
+  < Select File   Something went wrong :(                                >  
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+                                                                            
+ q to close      q to close                                                 
+────────────────────────────────────────────────────────────────────────────
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_errors.open-error.html b/tests/integration/tui/screenshots/test_errors.open-error.html new file mode 100644 index 00000000000..142d0d88c72 --- /dev/null +++ b/tests/integration/tui/screenshots/test_errors.open-error.html @@ -0,0 +1,31 @@ +
──────────────────────────────────────────────────────────────────────────────
+  Error: Something went wrong :(                                              
+                                                                              
+  < Select File                                                            >  
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+ q to close                                                                   
+──────────────────────────────────────────────────────────────────────────────
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_job_logs.01-job.out.html b/tests/integration/tui/screenshots/test_job_logs.01-job.out.html new file mode 100644 index 00000000000..c1c767b98cf --- /dev/null +++ b/tests/integration/tui/screenshots/test_job_logs.01-job.out.html @@ -0,0 +1,31 @@ +
──────────────────────────────────────────────────────────────────────────────
+  Host: myhost                                                                
+  Path: mypath                                                                
+  < Select File                                                            >  
+                                                                              
+  job: 1/a/01                                                                 
+  this is a job log                                                           
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+ q to close                                                                   
+──────────────────────────────────────────────────────────────────────────────
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_job_logs.02-job.out.html b/tests/integration/tui/screenshots/test_job_logs.02-job.out.html new file mode 100644 index 00000000000..0eb94051201 --- /dev/null +++ b/tests/integration/tui/screenshots/test_job_logs.02-job.out.html @@ -0,0 +1,31 @@ +
──────────────────────────────────────────────────────────────────────────────
+  Host: myhost                                                                
+  Path: mypath                                                                
+  < Select File                                                            >  
+                                                                              
+  job: 1/a/02                                                                 
+  this is a job log                                                           
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+ q to close                                                                   
+──────────────────────────────────────────────────────────────────────────────
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_navigation.cursor-at-bottom-of-screen.html b/tests/integration/tui/screenshots/test_navigation.cursor-at-bottom-of-screen.html new file mode 100644 index 00000000000..bf5e3812008 --- /dev/null +++ b/tests/integration/tui/screenshots/test_navigation.cursor-at-bottom-of-screen.html @@ -0,0 +1,31 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   - one - paused                                                               
+      - ̿○ 1                                                                     
+         + ̿○ A                                                                  
+         - ̿○ B                                                                  
+            - ̿○ B1                                                              
+                 ̿○ b11                                                          
+                 ̿○ b12                                                          
+            - ̿○ B2                                                              
+                 ̿○ b21                                                          
+                 ̿○ b22                                                          
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_navigation.family-A-collapsed.html b/tests/integration/tui/screenshots/test_navigation.family-A-collapsed.html new file mode 100644 index 00000000000..cefab5264f4 --- /dev/null +++ b/tests/integration/tui/screenshots/test_navigation.family-A-collapsed.html @@ -0,0 +1,31 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   - one - paused                                                               
+      - ̿○ 1                                                                     
+         + ̿○ A                                                                  
+         - ̿○ B                                                                  
+            - ̿○ B1                                                              
+                 ̿○ b11                                                          
+                 ̿○ b12                                                          
+            - ̿○ B2                                                              
+                 ̿○ b21                                                          
+                 ̿○ b22                                                          
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_navigation.on-load.html b/tests/integration/tui/screenshots/test_navigation.on-load.html new file mode 100644 index 00000000000..a0bd107742b --- /dev/null +++ b/tests/integration/tui/screenshots/test_navigation.on-load.html @@ -0,0 +1,31 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   + one - paused                                                               
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_navigation.workflow-expanded.html b/tests/integration/tui/screenshots/test_navigation.workflow-expanded.html new file mode 100644 index 00000000000..6b26ced563e --- /dev/null +++ b/tests/integration/tui/screenshots/test_navigation.workflow-expanded.html @@ -0,0 +1,31 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   - one - paused                                                               
+      - ̿○ 1                                                                     
+         - ̿○ A                                                                  
+              ̿○ a1                                                              
+              ̿○ a2                                                              
+         - ̿○ B                                                                  
+            - ̿○ B1                                                              
+                 ̿○ b11                                                          
+                 ̿○ b12                                                          
+            - ̿○ B2                                                              
+                 ̿○ b21                                                          
+                 ̿○ b22                                                          
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_offline_mutation.clean-command-error.html b/tests/integration/tui/screenshots/test_offline_mutation.clean-command-error.html new file mode 100644 index 00000000000..88defab9486 --- /dev/null +++ b/tests/integration/tui/screenshots/test_offline_mutation.clean-command-error.html @@ -0,0 +1,16 @@ +
Cylc Tui   work────────────────────────────────────────────────────          
+                 id  Error                                                   
+- ~cylc                                                                      
+   + one - stop  Ac  Error in command cylc clean --yes one                   
+                 <   mock-stderr                                             
+                                                                             
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                                                                             
+                                                                             
+                                                                             
+quit: q  help:  q t q to close                                     ome End   
+filter tasks: T────────────────────────────────────────────────────          
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_offline_mutation.clean-mutation-selected.html b/tests/integration/tui/screenshots/test_offline_mutation.clean-mutation-selected.html new file mode 100644 index 00000000000..f28cced0714 --- /dev/null +++ b/tests/integration/tui/screenshots/test_offline_mutation.clean-mutation-selected.html @@ -0,0 +1,16 @@ +
Cylc Tui   work────────────────────────────────────────────────               
+                 id: ~cylc/one                                                
+- ~cylc                                                                       
+   + one - stop  Action                                                       
+                 < (cancel)                                 >                 
+                                                                              
+                 < clean                                    >                 
+                 < log                                      >                 
+                 < play                                     >                 
+                 < reinstall-reload                         >                 
+                                                                              
+                                                                              
+                                                                              
+quit: q  help:  q to close                                     ↥ ↧ Home End   
+filter tasks: T────────────────────────────────────────────────               
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_offline_mutation.stop-all-mutation-selected.html b/tests/integration/tui/screenshots/test_offline_mutation.stop-all-mutation-selected.html new file mode 100644 index 00000000000..c2355597f78 --- /dev/null +++ b/tests/integration/tui/screenshots/test_offline_mutation.stop-all-mutation-selected.html @@ -0,0 +1,16 @@ +
Cylc Tui   work────────────────────────────────────────────────               
+                 id: ~cylc/root                                               
+- ~cylc                                                                       
+   + one - stop  Action                                                       
+                 < (cancel)                                 >                 
+                                                                              
+                 < stop-all                                 >                 
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+quit: q  help:  q to close                                     ↥ ↧ Home End   
+filter tasks: T────────────────────────────────────────────────               
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_online_mutation.command-failed-client-error.html b/tests/integration/tui/screenshots/test_online_mutation.command-failed-client-error.html new file mode 100644 index 00000000000..895856c6ea2 --- /dev/null +++ b/tests/integration/tui/screenshots/test_online_mutation.command-failed-client-error.html @@ -0,0 +1,16 @@ +
Cylc Tui   work────────────────────────────────────────────────────          
+                 id  Error                                                   
+- ~cylc                                                                      
+   - one - paus  Ac  Error connecting to workflow: mock error                
+      - ̿○ 1      <                                                           
+           ̿○ on                                                              
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                                                                             
+quit: q  help:  q t q to close                                     ome End   
+filter tasks: T────────────────────────────────────────────────────          
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_online_mutation.command-failed-workflow-stopped.html b/tests/integration/tui/screenshots/test_online_mutation.command-failed-workflow-stopped.html new file mode 100644 index 00000000000..6f9954926ef --- /dev/null +++ b/tests/integration/tui/screenshots/test_online_mutation.command-failed-workflow-stopped.html @@ -0,0 +1,16 @@ +
Cylc Tui   work────────────────────────────────────────────────────          
+                 id  Error                                                   
+- ~cylc                                                                      
+   - one - paus  Ac  Cannot peform command hold on a stopped                 
+      - ̿○ 1      <   workflow                                                
+           ̿○ on                                                              
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                                                                             
+quit: q  help:  q t q to close                                     ome End   
+filter tasks: T────────────────────────────────────────────────────          
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_online_mutation.command-failed.html b/tests/integration/tui/screenshots/test_online_mutation.command-failed.html new file mode 100644 index 00000000000..fae4a429cc6 --- /dev/null +++ b/tests/integration/tui/screenshots/test_online_mutation.command-failed.html @@ -0,0 +1,16 @@ +
Cylc Tui   work────────────────────────────────────────────────────          
+                 id  Error                                                   
+- ~cylc                                                                      
+   - one - paus  Ac  Cannot peform command hold on a stopped                 
+      - ̿○ 1      <   workflow                                                
+           ̿○ on                                                              
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                                                                             
+                                                                             
+quit: q  help:  q t q to close                                     ome End   
+filter tasks: T────────────────────────────────────────────────────          
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_online_mutation.hold-mutation-selected.html b/tests/integration/tui/screenshots/test_online_mutation.hold-mutation-selected.html new file mode 100644 index 00000000000..34be2ffa0ce --- /dev/null +++ b/tests/integration/tui/screenshots/test_online_mutation.hold-mutation-selected.html @@ -0,0 +1,16 @@ +
Cylc Tui   work────────────────────────────────────────────────               
+                 id: 1/one                                                    
+- ~cylc                                                                       
+   - one - paus  Action                                                       
+      - ̿○ 1      < (cancel)                                 >                 
+           ̿○ on                                                               
+                 < hold                                     >                 
+                 < kill                                     >                 
+                 < log                                      >                 
+                 < poll                                     >                 
+                 < release                                  >                 
+                 < show                                     >                 
+                                                                              
+quit: q  help:  q to close                                     ↥ ↧ Home End   
+filter tasks: T────────────────────────────────────────────────               
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_online_mutation.task-selected.html b/tests/integration/tui/screenshots/test_online_mutation.task-selected.html new file mode 100644 index 00000000000..7d94d5e43dd --- /dev/null +++ b/tests/integration/tui/screenshots/test_online_mutation.task-selected.html @@ -0,0 +1,16 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   - one - paused                                                               
+      - ̿○ 1                                                                     
+           ̿○ one                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_restart_reconnect.1-workflow-running.html b/tests/integration/tui/screenshots/test_restart_reconnect.1-workflow-running.html new file mode 100644 index 00000000000..74c02508239 --- /dev/null +++ b/tests/integration/tui/screenshots/test_restart_reconnect.1-workflow-running.html @@ -0,0 +1,21 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   - one - paused                                                               
+      - ̿○ 1                                                                     
+           ̿○ one                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_restart_reconnect.2-workflow-stopped.html b/tests/integration/tui/screenshots/test_restart_reconnect.2-workflow-stopped.html new file mode 100644 index 00000000000..09c3bbd7fb0 --- /dev/null +++ b/tests/integration/tui/screenshots/test_restart_reconnect.2-workflow-stopped.html @@ -0,0 +1,21 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   - one - stopped                                                              
+        Workflow is not running                                                 
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_restart_reconnect.3-workflow-restarted.html b/tests/integration/tui/screenshots/test_restart_reconnect.3-workflow-restarted.html new file mode 100644 index 00000000000..74c02508239 --- /dev/null +++ b/tests/integration/tui/screenshots/test_restart_reconnect.3-workflow-restarted.html @@ -0,0 +1,21 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   - one - paused                                                               
+      - ̿○ 1                                                                     
+           ̿○ one                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_scheduler_logs.log-file-selection.html b/tests/integration/tui/screenshots/test_scheduler_logs.log-file-selection.html new file mode 100644 index 00000000000..f88e1b0124d --- /dev/null +++ b/tests/integration/tui/screenshots/test_scheduler_logs.log-file-selection.html @@ -0,0 +1,31 @@ +
──────────────────────────────────────────────────────────────────────────────
+  Host: myhost                                                                
+  Path: mypath                                                                
+  < Select File                                                            >  
+                                                                              
+  this is the                                                                 
+  scheduler log file                                                          
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                     ──────────────────────────────────────                 
+                       Select File                                          
+                                                                            
+                       < config/01-start-01.cylc        >                   
+                       < config/flow-processed.cylc     >                   
+                       < scheduler/01-start-01.log      >                   
+                                                                            
+                      q to close                                            
+                     ──────────────────────────────────────                 
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+ q to close                                                                   
+──────────────────────────────────────────────────────────────────────────────
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_scheduler_logs.scheduler-log-file.html b/tests/integration/tui/screenshots/test_scheduler_logs.scheduler-log-file.html new file mode 100644 index 00000000000..68dbcc10f9c --- /dev/null +++ b/tests/integration/tui/screenshots/test_scheduler_logs.scheduler-log-file.html @@ -0,0 +1,31 @@ +
──────────────────────────────────────────────────────────────────────────────
+  Host: myhost                                                                
+  Path: mypath                                                                
+  < Select File                                                            >  
+                                                                              
+  this is the                                                                 
+  scheduler log file                                                          
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+ q to close                                                                   
+──────────────────────────────────────────────────────────────────────────────
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_scheduler_logs.workflow-configuration-file.html b/tests/integration/tui/screenshots/test_scheduler_logs.workflow-configuration-file.html new file mode 100644 index 00000000000..e3fcdfbab22 --- /dev/null +++ b/tests/integration/tui/screenshots/test_scheduler_logs.workflow-configuration-file.html @@ -0,0 +1,31 @@ +
──────────────────────────────────────────────────────────────────────────────
+  Host: myhost                                                                
+  Path: mypath                                                                
+  < Select File                                                            >  
+                                                                              
+  [scheduling]                                                                
+      [[graph]]                                                               
+          R1 = a                                                              
+  [runtime]                                                                   
+      [[a]]                                                                   
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+ q to close                                                                   
+──────────────────────────────────────────────────────────────────────────────
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_show.fail.html b/tests/integration/tui/screenshots/test_show.fail.html new file mode 100644 index 00000000000..f788e5b3a55 --- /dev/null +++ b/tests/integration/tui/screenshots/test_show.fail.html @@ -0,0 +1,41 @@ +
Cylc Tui   workflows────────────────────────────────────────────────          
+                      Error                                                   
+- ~cylc                                                                       
+   - one - paused     :(                                                      
+      - ̿○ 1                                                                   
+           ̿○ foo                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+               ────                                                          
+                 id                                                          
+                                                                             
+                 Ac                                                          
+                 <                                                           
+                                                                             
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                 <                                                           
+                                                                             
+                                                                             
+                                                                             
+                                                                             
+                                                                             
+                q t                                                          
+               ────                                                          
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+quit: q  help: h  co q to close                                     ome End   
+filter tasks: T f s ────────────────────────────────────────────────          
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_show.success.html b/tests/integration/tui/screenshots/test_show.success.html new file mode 100644 index 00000000000..afdcd1a73b4 --- /dev/null +++ b/tests/integration/tui/screenshots/test_show.success.html @@ -0,0 +1,41 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   - one - paused                                                               
+      - ̿○ 1                                                                     
+           ̿○ foo                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+               ────────────────────────────────────────────────               
+                 title: Foo                                                   
+                 description: The first metasyntactic                         
+                 variable.                                                    
+                 URL: (not given)                                             
+                 state: waiting                                               
+                 prerequisites: (None)                                        
+                 outputs: ('-': not completed)                                
+                   - 1/foo expired                                            
+                   - 1/foo submitted                                          
+                   - 1/foo submit-failed                                      
+                   - 1/foo started                                            
+                   - 1/foo succeeded                                          
+                   - 1/foo failed                                             
+                                                                              
+                                                                              
+                q to close                                                    
+               ────────────────────────────────────────────────               
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_subscribe_unsubscribe.subscribed.html b/tests/integration/tui/screenshots/test_subscribe_unsubscribe.subscribed.html new file mode 100644 index 00000000000..019184ec897 --- /dev/null +++ b/tests/integration/tui/screenshots/test_subscribe_unsubscribe.subscribed.html @@ -0,0 +1,16 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   - one - paused                                                               
+      - ̿○ 1                                                                     
+           ̿○ one                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_subscribe_unsubscribe.unsubscribed.html b/tests/integration/tui/screenshots/test_subscribe_unsubscribe.unsubscribed.html new file mode 100644 index 00000000000..8fa0f4329a1 --- /dev/null +++ b/tests/integration/tui/screenshots/test_subscribe_unsubscribe.unsubscribed.html @@ -0,0 +1,16 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   + one - paused                                                               
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_task_logs.latest-job.err.html b/tests/integration/tui/screenshots/test_task_logs.latest-job.err.html new file mode 100644 index 00000000000..4814892df7a --- /dev/null +++ b/tests/integration/tui/screenshots/test_task_logs.latest-job.err.html @@ -0,0 +1,31 @@ +
──────────────────────────────────────────────────────────────────────────────
+  Host: myhost                                                                
+  Path: mypath                                                                
+  < Select File                                                            >  
+                                                                              
+  job: 1/a/02                                                                 
+  this is a job error                                                         
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+ q to close                                                                   
+──────────────────────────────────────────────────────────────────────────────
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_task_logs.latest-job.out.html b/tests/integration/tui/screenshots/test_task_logs.latest-job.out.html new file mode 100644 index 00000000000..0eb94051201 --- /dev/null +++ b/tests/integration/tui/screenshots/test_task_logs.latest-job.out.html @@ -0,0 +1,31 @@ +
──────────────────────────────────────────────────────────────────────────────
+  Host: myhost                                                                
+  Path: mypath                                                                
+  < Select File                                                            >  
+                                                                              
+  job: 1/a/02                                                                 
+  this is a job log                                                           
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+ q to close                                                                   
+──────────────────────────────────────────────────────────────────────────────
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_tui_basics.test-raikura-enter.html b/tests/integration/tui/screenshots/test_tui_basics.test-raikura-enter.html new file mode 100644 index 00000000000..d54d9538d26 --- /dev/null +++ b/tests/integration/tui/screenshots/test_tui_basics.test-raikura-enter.html @@ -0,0 +1,41 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+               ────────────────────────────────────────────────               
+                 id: ~cylc/root                                               
+                                                                              
+                 Action                                                       
+                 < (cancel)                                 >                 
+                                                                              
+                 < stop-all                                 >                 
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                                                                              
+                q to close                                                    
+               ────────────────────────────────────────────────               
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_tui_basics.test-raikura-help.html b/tests/integration/tui/screenshots/test_tui_basics.test-raikura-help.html new file mode 100644 index 00000000000..1795c586d9a --- /dev/null +++ b/tests/integration/tui/screenshots/test_tui_basics.test-raikura-help.html @@ -0,0 +1,41 @@ +
Cylc Tui  ──────────────────────────────────────────────────────────          
+                                                                              
+- ~cylc                        _        _         _                           
+                              | |      | |       (_)                          
+                     ___ _   _| | ___  | |_ _   _ _                           
+                    / __| | | | |/ __| | __| | | | |                          
+                   | (__| |_| | | (__  | |_| |_| | |                          
+                    \___|\__, |_|\___|  \__|\__,_|_|                          
+                          __/ |                                               
+                         |___/                                                
+                                                                              
+                      ( scroll using arrow keys )                             
+                                                                              
+                                                                              
+                                                                              
+                                       _,@@@@@@.                              
+                                     <=@@@, `@@@@@.                           
+                                        `-@@@@@@@@@@@'                        
+                                           :@@@@@@@@@@.                       
+                                          (.@@@@@@@@@@@                       
+                                         ( '@@@@@@@@@@@@.                     
+                                        ;.@@@@@@@@@@@@@@@                     
+                                      '@@@@@@@@@@@@@@@@@@,                    
+                                    ,@@@@@@@@@@@@@@@@@@@@'                    
+                                  :.@@@@@@@@@@@@@@@@@@@@@.                    
+                                .@@@@@@@@@@@@@@@@@@@@@@@@.                    
+                              '@@@@@@@@@@@@@@@@@@@@@@@@@.                     
+                            ;@@@@@@@@@@@@@@@@@@@@@@@@@@@                      
+                           .@@@@@@@@@@@@@@@@@@@@@@@@@@.                       
+                          .@@@@@@@@@@@@@@@@@@@@@@@@@@,                        
+                         .@@@@@@@@@@@@@@@@@@@@@@@@@'                          
+                        .@@@@@@@@@@@@@@@@@@@@@@@@'     ,                      
+                      :@@@@@@@@@@@@@@@@@@@@@..''';,,,;::-                     
+                     '@@@@@@@@@@@@@@@@@@@.        `.   `                      
+                    .@@@@@@.: ,.@@@@@@@.            `                         
+                  :@@@@@@@,         ;.@,                                      
+                 '@@@@@@.              `@'                                    
+                                                                              
+quit: q  h q to close                                               ome End   
+filter tas──────────────────────────────────────────────────────────          
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_tui_basics.test-raikura.html b/tests/integration/tui/screenshots/test_tui_basics.test-raikura.html new file mode 100644 index 00000000000..7f80031804b --- /dev/null +++ b/tests/integration/tui/screenshots/test_tui_basics.test-raikura.html @@ -0,0 +1,41 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_workflow_states.filter-active.html b/tests/integration/tui/screenshots/test_workflow_states.filter-active.html new file mode 100644 index 00000000000..282f76735ed --- /dev/null +++ b/tests/integration/tui/screenshots/test_workflow_states.filter-active.html @@ -0,0 +1,16 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   + one - stopping                                                             
+   + two - paused                                                               
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_workflow_states.filter-starts-with-t.html b/tests/integration/tui/screenshots/test_workflow_states.filter-starts-with-t.html new file mode 100644 index 00000000000..8c26ce6ccc9 --- /dev/null +++ b/tests/integration/tui/screenshots/test_workflow_states.filter-starts-with-t.html @@ -0,0 +1,16 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   + tre - stopped                                                              
+   + two - paused                                                               
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_workflow_states.filter-stopped-or-paused.html b/tests/integration/tui/screenshots/test_workflow_states.filter-stopped-or-paused.html new file mode 100644 index 00000000000..8c26ce6ccc9 --- /dev/null +++ b/tests/integration/tui/screenshots/test_workflow_states.filter-stopped-or-paused.html @@ -0,0 +1,16 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   + tre - stopped                                                              
+   + two - paused                                                               
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_workflow_states.filter-stopped.html b/tests/integration/tui/screenshots/test_workflow_states.filter-stopped.html new file mode 100644 index 00000000000..1ff602df101 --- /dev/null +++ b/tests/integration/tui/screenshots/test_workflow_states.filter-stopped.html @@ -0,0 +1,16 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   + tre - stopped                                                              
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/screenshots/test_workflow_states.unfiltered.html b/tests/integration/tui/screenshots/test_workflow_states.unfiltered.html new file mode 100644 index 00000000000..0651eedec30 --- /dev/null +++ b/tests/integration/tui/screenshots/test_workflow_states.unfiltered.html @@ -0,0 +1,16 @@ +
Cylc Tui   workflows filtered (W - edit, E - reset)                             
+                                                                                
+- ~cylc                                                                         
+   + one - stopping                                                             
+   + tre - stopped                                                              
+   + two - paused                                                               
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+                                                                                
+quit: q  help: h  context: enter  tree: - ← + →  navigation: ↑ ↓ ↥ ↧ Home End   
+filter tasks: T f s r R  filter workflows: W E p                                
+
\ No newline at end of file diff --git a/tests/integration/tui/test_app.py b/tests/integration/tui/test_app.py new file mode 100644 index 00000000000..e6ed8c6e24d --- /dev/null +++ b/tests/integration/tui/test_app.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest +import urwid + +from cylc.flow.cycling.integer import IntegerPoint +from cylc.flow.task_state import ( +# TASK_STATUS_RUNNING, + TASK_STATUS_SUCCEEDED, +# TASK_STATUS_FAILED, +# TASK_STATUS_WAITING, +) +from cylc.flow.workflow_status import StopMode + + +def set_task_state(schd, task_states): + """Force tasks into the desired states. + + Task states should be of the format (cycle, task, state, is_held). + """ + for cycle, task, state, is_held in task_states: + itask = schd.pool.get_task(cycle, task) + if not itask: + itask = schd.pool.spawn_task(task, cycle, {1}) + itask.state_reset(state, is_held=is_held) + schd.data_store_mgr.delta_task_state(itask) + schd.data_store_mgr.increment_graph_window( + itask.tokens, + cycle, + {1}, + ) + + +async def test_tui_basics(raikura): + """Test basic Tui interaction with no workflows.""" + with raikura(size='80,40') as rk: + # the app should open + rk.compare_screenshot('test-raikura', 'the app should have loaded') + + # "h" should bring up the onscreen help + rk.user_input('h') + rk.compare_screenshot( + 'test-raikura-help', + 'the help screen should be visible' + ) + + # "q" should close the popup + rk.user_input('q') + rk.compare_screenshot( + 'test-raikura', + 'the help screen should have closed', + ) + + # "enter" should bring up the context menu + rk.user_input('enter') + rk.compare_screenshot( + 'test-raikura-enter', + 'the context menu should have opened', + ) + + # "enter" again should close it via the "cancel" button + rk.user_input('enter') + rk.compare_screenshot( + 'test-raikura', + 'the context menu should have closed', + ) + + # "ctrl d" should exit Tui + with pytest.raises(urwid.ExitMainLoop): + rk.user_input('ctrl d') + + # "q" should exit Tui + with pytest.raises(urwid.ExitMainLoop): + rk.user_input('q') + + +async def test_subscribe_unsubscribe(one_conf, flow, scheduler, start, raikura): + """Test a simple workflow with one task.""" + id_ = flow(one_conf, name='one') + schd = scheduler(id_) + async with start(schd): + await schd.update_data_structure() + with raikura(size='80,15') as rk: + rk.compare_screenshot( + 'unsubscribed', + 'the workflow should be collapsed' + ' (no subscription no state totals)', + ) + + # expand the workflow to subscribe to it + rk.user_input('down', 'right') + rk.wait_until_loaded() + rk.compare_screenshot( + 'subscribed', + 'the workflow should be expanded', + ) + + # collapse the workflow to unsubscribe from it + rk.user_input('left', 'up') + rk.force_update() + rk.compare_screenshot( + 'unsubscribed', + 'the workflow should be collapsed' + ' (no subscription no state totals)', + ) + + +async def test_workflow_states(one_conf, flow, scheduler, start, raikura): + """Test viewing multiple workflows in different states.""" + # one => stopping + id_1 = flow(one_conf, name='one') + schd_1 = scheduler(id_1) + # two => paused + id_2 = flow(one_conf, name='two') + schd_2 = scheduler(id_2) + # tre => stopped + flow(one_conf, name='tre') + + async with start(schd_1): + schd_1.stop_mode = StopMode.AUTO # make it look like we're stopping + await schd_1.update_data_structure() + + async with start(schd_2): + await schd_2.update_data_structure() + with raikura(size='80,15') as rk: + rk.compare_screenshot( + 'unfiltered', + 'All workflows should be visible (one, two, tree)', + ) + + # filter for active workflows (i.e. paused, running, stopping) + rk.user_input('p') + rk.compare_screenshot( + 'filter-active', + 'Only active workflows should be visible (one, two)' + ) + + # invert the filter so we are filtering for stopped workflows + rk.user_input('W', 'enter', 'q') + rk.compare_screenshot( + 'filter-stopped', + 'Only stopped workflow should be visible (tre)' + ) + + # filter in paused workflows + rk.user_input('W', 'down', 'enter', 'q') + rk.force_update() + rk.compare_screenshot( + 'filter-stopped-or-paused', + 'Only stopped or paused workflows should be visible' + ' (two, tre)', + ) + + # reset the state filters + rk.user_input('W', 'down', 'down', 'enter', 'down', 'enter') + + # scroll to the id filter text box + rk.user_input('down', 'down', 'down', 'down') + + # scroll to the end of the ID + rk.user_input(*['right'] * ( + len(schd_1.tokens['workflow'].rsplit('/', 1)[0]) + 1) + ) + + # type the letter "t" + # (this should filter for workflows starting with "t") + rk.user_input('t') + rk.force_update() # this is required for the tests + rk.user_input('page up', 'q') # close the dialogue + + rk.compare_screenshot( + 'filter-starts-with-t', + 'Only workflows starting with the letter "t" should be' + ' visible (two, tre)', + ) + + +# TODO: Task state filtering is currently broken +# see: https://github.com/cylc/cylc-flow/issues/5716 +# +# async def test_task_states(flow, scheduler, start, raikura): +# id_ = flow({ +# 'scheduler': { +# 'allow implicit tasks': 'true', +# }, +# 'scheduling': { +# 'initial cycle point': '1', +# 'cycling mode': 'integer', +# 'runahead limit': 'P1', +# 'graph': { +# 'P1': ''' +# a => b => c +# b[-P1] => b +# ''' +# } +# } +# }, name='test_task_states') +# schd = scheduler(id_) +# async with start(schd): +# set_task_state( +# schd, +# [ +# (IntegerPoint('1'), 'a', TASK_STATUS_SUCCEEDED, False), +# # (IntegerPoint('1'), 'b', TASK_STATUS_FAILED, False), +# (IntegerPoint('1'), 'c', TASK_STATUS_RUNNING, False), +# # (IntegerPoint('2'), 'a', TASK_STATUS_RUNNING, False), +# (IntegerPoint('2'), 'b', TASK_STATUS_WAITING, True), +# ] +# ) +# await schd.update_data_structure() +# +# with raikura(schd.tokens.id, size='80,20') as rk: +# rk.compare_screenshot('unfiltered') +# +# # filter out waiting tasks +# rk.user_input('T', 'down', 'enter', 'q') +# rk.compare_screenshot('filter-not-waiting') + + +async def test_navigation(flow, scheduler, start, raikura): + """Test navigating with the arrow keys.""" + id_ = flow({ + 'scheduling': { + 'graph': { + 'R1': 'A & B1 & B2', + } + }, + 'runtime': { + 'A': {}, + 'B': {}, + 'B1': {'inherit': 'B'}, + 'B2': {'inherit': 'B'}, + 'a1': {'inherit': 'A'}, + 'a2': {'inherit': 'A'}, + 'b11': {'inherit': 'B1'}, + 'b12': {'inherit': 'B1'}, + 'b21': {'inherit': 'B2'}, + 'b22': {'inherit': 'B2'}, + } + }, name='one') + schd = scheduler(id_) + async with start(schd): + await schd.update_data_structure() + + with raikura(size='80,30') as rk: + # wait for the workflow to appear (collapsed) + rk.wait_until_loaded('#spring') + + rk.compare_screenshot( + 'on-load', + 'the workflow should be collapsed when Tui is loaded', + ) + + # pressing "right" should connect to the workflow + # and expand it once the data arrives + rk.user_input('down', 'right') + rk.wait_until_loaded(schd.tokens.id) + rk.compare_screenshot( + 'workflow-expanded', + 'the workflow should be expanded', + ) + + # pressing "left" should collapse the node + rk.user_input('down', 'down', 'left') + rk.compare_screenshot( + 'family-A-collapsed', + 'the family "1/A" should be collapsed', + ) + + # the "page up" and "page down" buttons should navigate to the top + # and bottom of the screen + rk.user_input('page down') + rk.compare_screenshot( + 'cursor-at-bottom-of-screen', + 'the cursor should be at the bottom of the screen', + ) + + +async def test_auto_expansion(flow, scheduler, start, raikura): + """It should automatically expand cycles and top-level families. + + When a workflow is expanded, Tui should auto expand cycles and top-level + families. Any new cycles and top-level families should be auto-expanded + when added. + """ + id_ = flow({ + 'scheduling': { + 'runahead limit': 'P1', + 'initial cycle point': '1', + 'cycling mode': 'integer', + 'graph': { + 'P1': 'b[-P1] => a => b' + }, + }, + 'runtime': { + 'A': {}, + 'a': {'inherit': 'A'}, + 'b': {}, + }, + }, name='one') + schd = scheduler(id_) + with raikura(size='80,20') as rk: + async with start(schd): + await schd.update_data_structure() + # wait for the workflow to appear (collapsed) + rk.wait_until_loaded('#spring') + + # open the workflow + rk.force_update() + rk.user_input('down', 'right') + rk.wait_until_loaded(schd.tokens.id) + + rk.compare_screenshot( + 'on-load', + 'cycle "1" and top-level family "1/A" should be expanded', + ) + + for task in ('a', 'b'): + itask = schd.pool.get_task(IntegerPoint('1'), task) + itask.state_reset(TASK_STATUS_SUCCEEDED) + schd.pool.spawn_on_output(itask, TASK_STATUS_SUCCEEDED) + await schd.update_data_structure() + + rk.compare_screenshot( + 'later-time', + 'cycle "2" and top-level family "2/A" should be expanded', + ) + + +async def test_restart_reconnect(one_conf, flow, scheduler, start, raikura): + """It should handle workflow shutdown and restart. + + The Cylc client can raise exceptions e.g. WorkflowStopped. Any text written + to stdout/err will mess with Tui. The purpose of this test is to ensure Tui + can handle shutdown / restart without any errors occuring and any spurious + text appearing on the screen. + """ + with raikura(size='80,20') as rk: + schd = scheduler(flow(one_conf, name='one')) + + # 1- start the workflow + async with start(schd): + await schd.update_data_structure() + # wait for the workflow to appear (collapsed) + rk.wait_until_loaded('#spring') + + # expand the workflow (subscribes to updates from it) + rk.force_update() + rk.user_input('down', 'right') + + # wait for workflow to appear (expanded) + rk.wait_until_loaded(schd.tokens.id) + rk.compare_screenshot( + '1-workflow-running', + 'the workflow should appear in tui and be expanded', + ) + + # 2 - stop the worlflow + rk.compare_screenshot( + '2-workflow-stopped', + 'the stopped workflow should be collapsed with a message saying' + ' workflow stopped', + ) + + # 3- restart the workflow + schd = scheduler(flow(one_conf, name='one')) + async with start(schd): + await schd.update_data_structure() + rk.wait_until_loaded(schd.tokens.id) + rk.compare_screenshot( + '3-workflow-restarted', + 'the restarted workflow should be expanded', + ) diff --git a/tests/integration/tui/test_logs.py b/tests/integration/tui/test_logs.py new file mode 100644 index 00000000000..66531a1e42d --- /dev/null +++ b/tests/integration/tui/test_logs.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio +from pathlib import Path +from typing import TYPE_CHECKING + +from cylc.flow.cycling.integer import IntegerPoint +from cylc.flow.exceptions import ClientError +from cylc.flow.task_job_logs import get_task_job_log +from cylc.flow.task_state import ( + TASK_STATUS_FAILED, + TASK_STATUS_SUCCEEDED, +) +from cylc.flow.tui.data import _get_log + +import pytest + +if TYPE_CHECKING: + from cylc.flow.id import Tokens + + +def get_job_log(tokens: 'Tokens', suffix: str) -> Path: + """Return the path to a job log file. + + Args: + tokens: Job tokens. + suffix: Filename. + + """ + return Path(get_task_job_log( + tokens['workflow'], + tokens['cycle'], + tokens['task'], + tokens['job'], + suffix=suffix, + )) + + +@pytest.fixture(scope='module') +def standarise_host_and_path(mod_monkeypatch): + """Replace variable content in the log view. + + The log view displays the "Host" and "Path" of the log file. These will + differer from user to user, so we mock away the difference to produce + stable results. + """ + def _parse_log_header(contents): + _header, text = contents.split('\n', 1) + return 'myhost', 'mypath', text + + mod_monkeypatch.setattr( + 'cylc.flow.tui.data._parse_log_header', + _parse_log_header, + ) + + +@pytest.fixture +def wait_log_loaded(monkeypatch): + """Wait for Tui to successfully open a log file.""" + # previous log open count + before = 0 + # live log open count + count = 0 + + # wrap the Tui "_get_log" method to count the number of times it has + # returned + def __get_log(*args, **kwargs): + nonlocal count + try: + ret = _get_log(*args, **kwargs) + except ClientError as exc: + count += 1 + raise exc + count += 1 + return ret + monkeypatch.setattr( + 'cylc.flow.tui.data._get_log', + __get_log, + ) + + async def _wait_log_loaded(tries: int = 25, delay: float = 0.1): + """Wait for the log file to be loaded. + + Args: + tries: The number of (re)tries to attempt before failing. + delay: The delay between retries. + + """ + nonlocal before, count + for _try in range(tries): + if count > before: + await asyncio.sleep(0) + before += 1 + return + await asyncio.sleep(delay) + raise Exception(f'Log file was not loaded within {delay * tries}s') + + return _wait_log_loaded + + +@pytest.fixture(scope='module') +async def workflow(mod_flow, mod_scheduler, mod_start, standarise_host_and_path): + """Test fixture providing a workflow with some log files to poke at.""" + id_ = mod_flow({ + 'scheduling': { + 'graph': { + 'R1': 'a', + } + }, + 'runtime': { + 'a': {}, + } + }, name='one') + schd = mod_scheduler(id_) + async with mod_start(schd): + # create some log files for tests to inspect + + # create a scheduler log + # (note the scheduler log doesn't get created in integration tests) + scheduler_log = Path(schd.workflow_log_dir, '01-start-01.log') + with open(scheduler_log, 'w+') as logfile: + logfile.write('this is the\nscheduler log file') + + # task 1/a + itask = schd.pool.get_task(IntegerPoint('1'), 'a') + itask.submit_num = 2 + + # mark 1/a/01 as failed + job_1 = schd.tokens.duplicate(cycle='1', task='a', job='01') + schd.data_store_mgr.insert_job( + 'a', + IntegerPoint('1'), + TASK_STATUS_SUCCEEDED, + {'submit_num': 1, 'platform': {'name': 'x'}} + ) + schd.data_store_mgr.delta_job_state(job_1, TASK_STATUS_FAILED) + + # mark 1/a/02 as succeeded + job_2 = schd.tokens.duplicate(cycle='1', task='a', job='02') + schd.data_store_mgr.insert_job( + 'a', + IntegerPoint('1'), + TASK_STATUS_SUCCEEDED, + {'submit_num': 2, 'platform': {'name': 'x'}} + ) + schd.data_store_mgr.delta_job_state(job_1, TASK_STATUS_SUCCEEDED) + schd.data_store_mgr.delta_task_state(itask) + + # mark 1/a as succeeded + itask.state_reset(TASK_STATUS_SUCCEEDED) + schd.data_store_mgr.delta_task_state(itask) + + # 1/a/01 - job.out + job_1_out = get_job_log(job_1, 'job.out') + job_1_out.parent.mkdir(parents=True) + with open(job_1_out, 'w+') as log: + log.write(f'job: {job_1.relative_id}\nthis is a job log\n') + + # 1/a/02 - job.out + job_2_out = get_job_log(job_2, 'job.out') + job_2_out.parent.mkdir(parents=True) + with open(job_2_out, 'w+') as log: + log.write(f'job: {job_2.relative_id}\nthis is a job log\n') + + # 1/a/02 - job.err + job_2_err = get_job_log(job_2, 'job.err') + with open(job_2_err, 'w+') as log: + log.write(f'job: {job_2.relative_id}\nthis is a job error\n') + + # 1/a/NN -> 1/a/02 + (job_2_out.parent.parent / 'NN').symlink_to( + (job_2_out.parent.parent / '02'), + target_is_directory=True, + ) + + # populate the data store + await schd.update_data_structure() + + yield schd + + +async def test_scheduler_logs( + workflow, + mod_raikura, + wait_log_loaded, +): + """Test viewing the scheduler log files.""" + with mod_raikura(size='80,30') as rk: + # wait for the workflow to appear (collapsed) + rk.wait_until_loaded('#spring') + + # open the workflow in Tui + rk.user_input('down', 'right') + rk.wait_until_loaded(workflow.tokens.id) + + # open the log view for the workflow + rk.user_input('enter') + rk.user_input('down', 'down', 'enter') + + # wait for the default log file to load + await wait_log_loaded() + rk.compare_screenshot( + 'scheduler-log-file', + 'the scheduler log file should be open', + ) + + # open the list of log files + rk.user_input('enter') + rk.compare_screenshot( + 'log-file-selection', + 'the list of available log files should be displayed' + ) + + # select the processed workflow configuration file + rk.user_input('down', 'enter') + + # wait for the file to load + await wait_log_loaded() + rk.compare_screenshot( + 'workflow-configuration-file', + 'the workflow configuration file should be open' + ) + + +async def test_task_logs( + workflow, + mod_raikura, + wait_log_loaded, +): + """Test viewing task log files. + + I.E. Test viewing job log files by opening the log view on a task. + """ + with mod_raikura(size='80,30') as rk: + # wait for the workflow to appear (collapsed) + rk.wait_until_loaded('#spring') + + # open the workflow in Tui + rk.user_input('down', 'right') + rk.wait_until_loaded(workflow.tokens.id) + + # open the context menu for the task 1/a + rk.user_input('down', 'down', 'enter') + + # open the log view for the task 1/a + rk.user_input('down', 'down', 'down', 'enter') + + # wait for the default log file to load + await wait_log_loaded() + rk.compare_screenshot( + 'latest-job.out', + 'the job.out file for the second job should be open', + ) + + rk.user_input('enter') + rk.user_input('enter') + + # wait for the job.err file to load + await wait_log_loaded() + rk.compare_screenshot( + 'latest-job.err', + 'the job.out file for the second job should be open', + ) + + +async def test_job_logs( + workflow, + mod_raikura, + wait_log_loaded, +): + """Test viewing the job log files. + + I.E. Test viewing job log files by opening the log view on a job. + """ + with mod_raikura(size='80,30') as rk: + # wait for the workflow to appear (collapsed) + rk.wait_until_loaded('#spring') + + # open the workflow in Tui + rk.user_input('down', 'right') + rk.wait_until_loaded(workflow.tokens.id) + + # open the context menu for the job 1/a/02 + rk.user_input('down', 'down', 'right', 'down', 'enter') + + # open the log view for the job 1/a/02 + rk.user_input('down', 'down', 'down', 'enter') + + # wait for the default log file to load + await wait_log_loaded() + rk.compare_screenshot( + '02-job.out', + 'the job.out file for the *second* job should be open', + ) + + # close log view + rk.user_input('q') + + # open the log view for the job 1/a/01 + rk.user_input('down', 'enter') + rk.user_input('down', 'down', 'down', 'enter') + + # wait for the default log file to load + await wait_log_loaded() + rk.compare_screenshot( + '01-job.out', + 'the job.out file for the *first* job should be open', + ) + + +async def test_errors( + workflow, + mod_raikura, + wait_log_loaded, + monkeypatch, +): + """Test error handing of cat-log commands.""" + # make it look like cat-log commands are failing + def cli_cmd_fail(*args, **kwargs): + raise ClientError('Something went wrong :(') + + monkeypatch.setattr( + 'cylc.flow.tui.data.cli_cmd', + cli_cmd_fail, + ) + + with mod_raikura(size='80,30') as rk: + # wait for the workflow to appear (collapsed) + rk.wait_until_loaded('#spring') + + # open the log view on scheduler + rk.user_input('down', 'enter', 'down', 'down', 'enter') + + # it will fail to open + await wait_log_loaded() + rk.compare_screenshot( + 'open-error', + 'the error message should be displayed in the log view header', + ) + + # open the file selector + rk.user_input('enter') + + # it will fail to list avialable log files + rk.compare_screenshot( + 'list-error', + 'the error message should be displayed in a pop up', + ) diff --git a/tests/integration/tui/test_mutations.py b/tests/integration/tui/test_mutations.py new file mode 100644 index 00000000000..659c74ac1d0 --- /dev/null +++ b/tests/integration/tui/test_mutations.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio + +import pytest + +from cylc.flow.exceptions import ClientError + + +async def gen_commands(schd): + """Yield commands from the scheduler's command queue.""" + while True: + await asyncio.sleep(0.1) + if not schd.command_queue.empty(): + yield schd.command_queue.get() + + +async def test_online_mutation( + one_conf, + flow, + scheduler, + start, + raikura, + monkeypatch, +): + """Test a simple workflow with one task.""" + id_ = flow(one_conf, name='one') + schd = scheduler(id_) + with raikura(size='80,15') as rk: + async with start(schd): + await schd.update_data_structure() + assert schd.command_queue.empty() + + # open the workflow + rk.force_update() + rk.user_input('down', 'right') + rk.wait_until_loaded(schd.tokens.id) + + # focus on a task + rk.user_input('down', 'right', 'down', 'right') + rk.compare_screenshot( + # take a screenshot to ensure we have focused on the task + # successfully + 'task-selected', + 'the cursor should be on the task 1/foo', + ) + + # focus on the hold mutation for a task + rk.user_input('enter', 'down') + rk.compare_screenshot( + # take a screenshot to ensure we have focused on the mutation + # successfully + 'hold-mutation-selected', + 'the cursor should be on the "hold" mutation', + ) + + # run the hold mutation + rk.user_input('enter') + + # the mutation should be in the scheduler's command_queue + command = None + async for command in gen_commands(schd): + break + assert command == ('hold', (['1/one'],), {}) + + # close the dialogue and re-run the hold mutation + rk.user_input('q', 'q', 'enter') + rk.compare_screenshot( + 'command-failed-workflow-stopped', + 'an error should be visible explaining that the operation' + ' cannot be performed on a stopped workflow', + # NOTE: don't update so Tui still thinks the workflow is running + force_update=False, + ) + + # force mutations to raise ClientError + def _get_client(*args, **kwargs): + raise ClientError('mock error') + monkeypatch.setattr( + 'cylc.flow.tui.data.get_client', + _get_client, + ) + + # close the dialogue and re-run the hold mutation + rk.user_input('q', 'q', 'enter') + rk.compare_screenshot( + 'command-failed-client-error', + 'an error should be visible explaining that the operation' + ' failed due to a client error', + # NOTE: don't update so Tui still thinks the workflow is running + force_update=False, + ) + + +@pytest.fixture +def standardise_cli_cmds(monkeypatch): + """This remove the variable bit of the workflow ID from CLI commands. + + The workflow ID changes from run to run. In order to make screenshots + stable, this + """ + from cylc.flow.tui.data import extract_context + def _extract_context(selection): + context = extract_context(selection) + if 'workflow' in context: + context['workflow'] = [ + workflow.rsplit('/', 1)[-1] + for workflow in context.get('workflow', []) + ] + return context + monkeypatch.setattr( + 'cylc.flow.tui.data.extract_context', + _extract_context, + ) + +@pytest.fixture +def capture_commands(monkeypatch): + ret = [] + returncode = [0] + + class _Popen: + def __init__(self, *args, **kwargs): + nonlocal ret + ret.append(args) + + def communicate(self): + return 'mock-stdout', 'mock-stderr' + + @property + def returncode(self): + nonlocal returncode + return returncode[0] + + monkeypatch.setattr( + 'cylc.flow.tui.data.Popen', + _Popen, + ) + + return ret, returncode + + +async def test_offline_mutation( + one_conf, + flow, + raikura, + capture_commands, + standardise_cli_cmds, +): + id_ = flow(one_conf, name='one') + commands, returncode = capture_commands + + with raikura(size='80,15') as rk: + # run the stop-all mutation + rk.wait_until_loaded('root') + rk.user_input('enter', 'down') + rk.compare_screenshot( + # take a screenshot to ensure we have focused on the task + # successfully + 'stop-all-mutation-selected', + 'the stop-all mutation should be selected', + ) + rk.user_input('enter') + + # the command "cylc stop '*'" should have been run + assert commands == [(['cylc', 'stop', '*'],)] + commands.clear() + + # run the clean command on the workflow + rk.user_input('down', 'enter', 'down') + rk.compare_screenshot( + # take a screenshot to ensure we have focused on the mutation + # successfully + 'clean-mutation-selected', + 'the clean mutation should be selected', + ) + rk.user_input('enter') + + # the command "cylc clean " should have been run + assert commands == [(['cylc', 'clean', '--yes', 'one'],)] + commands.clear() + + # make commands fail + returncode[:] = [1] + rk.user_input('enter', 'down') + rk.compare_screenshot( + # take a screenshot to ensure we have focused on the mutation + # successfully + 'clean-mutation-selected', + 'the clean mutation should be selected', + ) + rk.user_input('enter') + + assert commands == [(['cylc', 'clean', '--yes', 'one'],)] + + rk.compare_screenshot( + # take a screenshot to ensure we have focused on the mutation + # successfully + 'clean-command-error', + 'there should be a box displaying the error containing the stderr' + ' returned by the command', + ) diff --git a/tests/integration/tui/test_show.py b/tests/integration/tui/test_show.py new file mode 100644 index 00000000000..ac6aa8532a6 --- /dev/null +++ b/tests/integration/tui/test_show.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from cylc.flow.exceptions import ClientError +from cylc.flow.tui.data import _show + + +async def test_show(flow, scheduler, start, raikura, monkeypatch): + """Test "cylc show" support in Tui.""" + id_ = flow({ + 'scheduling': { + 'graph': { + 'R1': 'foo' + }, + }, + 'runtime': { + 'foo': { + 'meta': { + 'title': 'Foo', + 'description': 'The first metasyntactic variable.' + }, + }, + }, + }, name='one') + schd = scheduler(id_) + async with start(schd): + await schd.update_data_structure() + + with raikura(size='80,40') as rk: + rk.user_input('down', 'right') + rk.wait_until_loaded(schd.tokens.id) + + # select a task + rk.user_input('down', 'down', 'enter') + + # select the "show" context option + rk.user_input(*(['down'] * 6), 'enter') + rk.compare_screenshot( + 'success', + 'the show output should be displayed', + ) + + # make it look like "cylc show" failed + def cli_cmd_fail(*args, **kwargs): + raise ClientError(':(') + monkeypatch.setattr( + 'cylc.flow.tui.data.cli_cmd', + cli_cmd_fail, + ) + + # select the "show" context option + rk.user_input('q', 'enter', *(['down'] * 6), 'enter') + rk.compare_screenshot( + 'fail', + 'the error should be displayed', + ) diff --git a/tests/integration/tui/test_updater.py b/tests/integration/tui/test_updater.py new file mode 100644 index 00000000000..b3daac5a328 --- /dev/null +++ b/tests/integration/tui/test_updater.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from copy import deepcopy +from pathlib import Path +from queue import Queue +import re + +from async_timeout import timeout +import pytest + +from cylc.flow.cycling.integer import IntegerPoint +from cylc.flow.id import Tokens +from cylc.flow.tui.updater import ( + Updater, + get_default_filters, +) +from cylc.flow.workflow_status import WorkflowStatus + + +@pytest.fixture +def updater(monkeypatch, test_dir): + """Return an updater ready for testing.""" + # patch the update intervals so that everything runs for every update + monkeypatch.setattr( + 'cylc.flow.tui.updater.Updater.BASE_UPDATE_INTERVAL', + 0, + ) + monkeypatch.setattr( + 'cylc.flow.tui.updater.Updater.BASE_SCAN_INTERVAL', + 0, + ) + + # create the updater + updater = Updater() + + # swap multiprocessing.Queue for queue.Queue + # (this means queued operations are instant making tests more stable) + updater.update_queue = Queue() + updater._command_queue = Queue() + + # set up the filters + # (these filter for the workflows created in this test only) + filters = get_default_filters() + id_base = str(test_dir.relative_to(Path("~/cylc-run").expanduser())) + filters['workflows']['id'] = f'^{re.escape(id_base)}/.*' + updater._update_filters(filters) + + return updater + + +def get_child_tokens(root_node, types, relative=False): + """Return all ID of the specified types contained within the provided tree. + + Args: + root_node: + The Tui tree you want to look for IDs in. + types: + The Tui types (e.g. 'workflow' or 'task') you want to extract. + relative: + If True, the relative IDs will be returned. + + """ + ret = set() + stack = [root_node] + while stack: + node = stack.pop() + stack.extend(node['children']) + if node['type_'] in types: + + tokens = Tokens(node['id_']) + if relative: + ret.add(tokens.relative_id) + else: + ret.add(tokens.id) + return ret + + +async def test_subscribe(one_conf, flow, scheduler, run, updater): + """It should subscribe and unsubscribe from workflows.""" + id_ = flow(one_conf) + schd = scheduler(id_) + + async with run(schd): + # run the updater and the test + async with timeout(10): + # wait for the first update + root_node = await updater._update() + + # there should be a root root_node + assert root_node['id_'] == 'root' + # a single root_node representing the workflow + assert root_node['children'][0]['id_'] == schd.tokens.id + # and a "spring" root_node used to active the subscription + # mechanism + assert root_node['children'][0]['children'][0]['id_'] == '#spring' + + # subscribe to the workflow + updater.subscribe(schd.tokens.id) + root_node = await updater._update() + + # check the workflow contains one cycle with one task in it + workflow_node = root_node['children'][0] + assert len(workflow_node['children']) == 1 + cycle_node = workflow_node['children'][0] + assert Tokens(cycle_node['id_']).relative_id == '1' # cycle ID + assert len(cycle_node['children']) == 1 + task_node = cycle_node['children'][0] + assert Tokens(task_node['id_']).relative_id == '1/one' # task ID + + # unsubscribe from the workflow + updater.unsubscribe(schd.tokens.id) + root_node = await updater._update() + + # the workflow should be replaced by a "spring" node again + assert root_node['children'][0]['children'][0]['id_'] == '#spring' + + +async def test_filters(one_conf, flow, scheduler, run, updater): + """It should filter workflow and task states. + + Note: + The workflow ID filter is not explicitly tested here, but it is + indirectly tested, otherwise other workflows would show up in the + updater results. + + """ + one = scheduler(flow({ + 'scheduler': { + 'allow implicit tasks': 'True', + }, + 'scheduling': { + 'graph': { + 'R1': 'a & b & c', + } + } + }, name='one'), paused_start=True) + two = scheduler(flow(one_conf, name='two')) + tre = scheduler(flow(one_conf, name='tre')) + + # start workflow "one" + async with run(one): + # mark "1/a" as running and "1/b" as succeeded + one_a = one.pool.get_task(IntegerPoint('1'), 'a') + one_a.state_reset('running') + one.data_store_mgr.delta_task_state(one_a) + one.pool.get_task(IntegerPoint('1'), 'b').state_reset('succeeded') + + # start workflow "two" + async with run(two): + # run the updater and the test + filters = deepcopy(updater.filters) + + root_node = await updater._update() + assert {child['id_'] for child in root_node['children']} == { + one.tokens.id, + two.tokens.id, + tre.tokens.id, + } + + # filter out paused workflows + filters = deepcopy(filters) + filters['workflows'][WorkflowStatus.STOPPED.value] = True + filters['workflows'][WorkflowStatus.PAUSED.value] = False + updater.update_filters(filters) + + # "one" and "two" should now be filtered out + root_node = await updater._update() + assert {child['id_'] for child in root_node['children']} == { + tre.tokens.id, + } + + # filter out stopped workflows + filters = deepcopy(filters) + filters['workflows'][WorkflowStatus.STOPPED.value] = False + filters['workflows'][WorkflowStatus.PAUSED.value] = True + updater.update_filters(filters) + + # "tre" should now be filtered out + root_node = await updater._update() + assert {child['id_'] for child in root_node['children']} == { + one.tokens.id, + two.tokens.id, + } + + # subscribe to "one" + updater._subscribe(one.tokens.id) + root_node = await updater._update() + assert get_child_tokens( + root_node, types={'task'}, relative=True + ) == { + '1/a', + '1/b', + '1/c', + } + + # filter out running tasks + # TODO: see https://github.com/cylc/cylc-flow/issues/5716 + # filters = deepcopy(filters) + # filters['tasks'][TASK_STATUS_RUNNING] = False + # updater.update_filters(filters) + + # root_node = await updater._update() + # assert get_child_tokens( + # root_node, + # types={'task'}, + # relative=True + # ) == { + # '1/b', + # '1/c', + # } diff --git a/tests/unit/scripts/test_cylc.py b/tests/unit/scripts/test_cylc.py index 819583a296c..9928024bb66 100644 --- a/tests/unit/scripts/test_cylc.py +++ b/tests/unit/scripts/test_cylc.py @@ -133,18 +133,16 @@ def test_pythonpath_manip(monkeypatch): and adds items from CYLC_PYTHONPATH """ - # If PYTHONPATH is set... - monkeypatch.setenv('PYTHONPATH', '/remove-from-sys.path') - monkeypatch.setattr('sys.path', ['/leave-alone', '/remove-from-sys.path']) + monkeypatch.setenv('PYTHONPATH', '/remove1:/remove2') + monkeypatch.setattr('sys.path', ['/leave-alone', '/remove1', '/remove2']) pythonpath_manip() # ... we don't change PYTHONPATH - assert os.environ['PYTHONPATH'] == '/remove-from-sys.path' + assert os.environ['PYTHONPATH'] == '/remove1:/remove2' # ... but we do remove PYTHONPATH items from sys.path, and don't remove # items there not in PYTHONPATH assert sys.path == ['/leave-alone'] - # If CYLC_PYTHONPATH is set we retrieve its contents and # add them to the sys.path: - monkeypatch.setenv('CYLC_PYTHONPATH', '/add-to-sys.path') + monkeypatch.setenv('CYLC_PYTHONPATH', '/add1:/add2') pythonpath_manip() - assert sys.path == ['/add-to-sys.path', '/leave-alone'] + assert sys.path == ['/add1', '/add2', '/leave-alone'] diff --git a/tests/unit/scripts/test_lint.py b/tests/unit/scripts/test_lint.py index d33fca7efc1..6ee1018cf96 100644 --- a/tests/unit/scripts/test_lint.py +++ b/tests/unit/scripts/test_lint.py @@ -102,6 +102,7 @@ pre-script = "echo ${CYLC_SUITE_DEF_PATH}" script = {{HELLOWORLD}} post-script = "echo ${CYLC_SUITE_INITIAL_CYCLE_TIME}" + env-script = POINT=$(rose date 2059 --offset P1M) [[[suite state polling]]] template = and [[[remote]]] @@ -333,6 +334,12 @@ def test_check_cylc_file_jinja2_comments(): assert not any('S011' in msg for msg in lint.messages) +def test_check_cylc_file_jinja2_comments_shell_arithmetic_not_warned(): + """Jinja2 after a $((10#$variable)) should not warn""" + lint = lint_text('#!jinja2\na = b$((10#$foo+5)) {{ BAR }}', ['style']) + assert not any('S011' in msg for msg in lint.messages) + + @pytest.mark.parametrize( # 11 won't be tested because there is no jinja2 shebang 'number', set(range(1, len(MANUAL_DEPRECATIONS) + 1)) - {11} diff --git a/tests/unit/test_async_util.py b/tests/unit/test_async_util.py index 17823817dbe..56373e7185d 100644 --- a/tests/unit/test_async_util.py +++ b/tests/unit/test_async_util.py @@ -15,6 +15,7 @@ # along with this program. If not, see . import asyncio +from inspect import signature import logging from pathlib import Path from random import random @@ -209,14 +210,17 @@ def test_pipe_brackets(): @pipe -async def documented(x): +async def documented(x: str, y: int = 0): """The docstring for the pipe function.""" pass def test_documentation(): - """It should preserve the docstring of pipe functions.""" + """It should preserve the docstring, signature & annotations of + the wrapped function.""" assert documented.__doc__ == 'The docstring for the pipe function.' + assert documented.__annotations__ == {'x': str, 'y': int} + assert str(signature(documented)) == '(x: str, y: int = 0)' def test_rewind(): diff --git a/tests/unit/tui/test_data.py b/tests/unit/tui/test_data.py index a2d17bf2e76..85805a5d1ea 100644 --- a/tests/unit/tui/test_data.py +++ b/tests/unit/tui/test_data.py @@ -28,7 +28,7 @@ def test_generate_mutation(monkeypatch): monkeypatch.setattr(cylc.flow.tui.data, 'ARGUMENT_TYPES', arg_types) assert generate_mutation( 'my_mutation', - ['foo', 'bar'] + {'foo': 'foo', 'bar': 'bar', 'user': 'user'} ) == ''' mutation($foo: String!, $bar: [Int]) { my_mutation (foos: $foo, bars: $bar) { diff --git a/tests/unit/tui/test_overlay.py b/tests/unit/tui/test_overlay.py index 42334aac009..013e8480c21 100644 --- a/tests/unit/tui/test_overlay.py +++ b/tests/unit/tui/test_overlay.py @@ -21,7 +21,9 @@ import pytest import urwid +from cylc.flow.tui.app import BINDINGS import cylc.flow.tui.overlay +from cylc.flow.workflow_status import WorkflowStatus @pytest.fixture @@ -39,6 +41,7 @@ def overlay_functions(): getattr(cylc.flow.tui.overlay, obj.name) for obj in tree.body if isinstance(obj, ast.FunctionDef) + and not obj.name.startswith('_') ] @@ -47,14 +50,21 @@ def test_interface(overlay_functions): for function in overlay_functions: # mock up an app object to keep things working app = Mock( - filter_states={}, + filters={'tasks': {}, 'workflows': {'id': '.*'}}, + bindings=BINDINGS, tree_walker=Mock( get_focus=Mock( return_value=[ Mock( get_node=Mock( return_value=Mock( - get_value=lambda: {'id_': 'a'} + get_value=lambda: { + 'id_': '~u/a', + 'type_': 'workflow', + 'data': { + 'status': WorkflowStatus.RUNNING, + }, + } ) ) ) diff --git a/tests/unit/tui/test_util.py b/tests/unit/tui/test_util.py index 00ac9fa95be..2b3231e0f7e 100644 --- a/tests/unit/tui/test_util.py +++ b/tests/unit/tui/test_util.py @@ -189,77 +189,87 @@ def test_compute_tree(): """ tree = compute_tree({ - 'id': 'workflow id', - 'cyclePoints': [ - { - 'id': '1/family-suffix', - 'cyclePoint': '1' - } - ], - 'familyProxies': [ - { # top level family - 'name': 'FOO', - 'id': '1/FOO', - 'cyclePoint': '1', - 'firstParent': {'name': 'root', 'id': '1/root'} - }, - { # nested family - 'name': 'FOOT', - 'id': '1/FOOT', - 'cyclePoint': '1', - 'firstParent': {'name': 'FOO', 'id': '1/FOO'} - }, - ], - 'taskProxies': [ - { # top level task - 'name': 'pub', - 'id': '1/pub', - 'firstParent': {'name': 'root', 'id': '1/root'}, - 'cyclePoint': '1', - 'jobs': [] - }, - { # child task (belongs to family) - 'name': 'fan', - 'id': '1/fan', - 'firstParent': {'name': 'fan', 'id': '1/fan'}, - 'cyclePoint': '1', - 'jobs': [] - }, - { # nested child task (belongs to incestuous family) - 'name': 'fool', - 'id': '1/fool', - 'firstParent': {'name': 'FOOT', 'id': '1/FOOT'}, - 'cyclePoint': '1', - 'jobs': [] - }, - { # a task which has jobs - 'name': 'worker', - 'id': '1/worker', - 'firstParent': {'name': 'root', 'id': '1/root'}, - 'cyclePoint': '1', - 'jobs': [ - {'id': '1/worker/03', 'submitNum': '3'}, - {'id': '1/worker/02', 'submitNum': '2'}, - {'id': '1/worker/01', 'submitNum': '1'} - ] - } - ] + 'workflows': [{ + 'id': 'workflow id', + 'port': 1234, + 'cyclePoints': [ + { + 'id': '1/family-suffix', + 'cyclePoint': '1' + } + ], + 'familyProxies': [ + { # top level family + 'name': 'FOO', + 'id': '1/FOO', + 'cyclePoint': '1', + 'firstParent': {'name': 'root', 'id': '1/root'} + }, + { # nested family + 'name': 'FOOT', + 'id': '1/FOOT', + 'cyclePoint': '1', + 'firstParent': {'name': 'FOO', 'id': '1/FOO'} + }, + ], + 'taskProxies': [ + { # top level task + 'name': 'pub', + 'id': '1/pub', + 'firstParent': {'name': 'root', 'id': '1/root'}, + 'cyclePoint': '1', + 'jobs': [] + }, + { # child task (belongs to family) + 'name': 'fan', + 'id': '1/fan', + 'firstParent': {'name': 'fan', 'id': '1/fan'}, + 'cyclePoint': '1', + 'jobs': [] + }, + { # nested child task (belongs to incestuous family) + 'name': 'fool', + 'id': '1/fool', + 'firstParent': {'name': 'FOOT', 'id': '1/FOOT'}, + 'cyclePoint': '1', + 'jobs': [] + }, + { # a task which has jobs + 'name': 'worker', + 'id': '1/worker', + 'firstParent': {'name': 'root', 'id': '1/root'}, + 'cyclePoint': '1', + 'jobs': [ + {'id': '1/worker/03', 'submitNum': '3'}, + {'id': '1/worker/02', 'submitNum': '2'}, + {'id': '1/worker/01', 'submitNum': '1'} + ] + } + ] + }] }) + # the root node + assert tree['type_'] == 'root' + assert tree['id_'] == 'root' + assert len(tree['children']) == 1 + # the workflow node - assert tree['type_'] == 'workflow' - assert tree['id_'] == 'workflow id' - assert list(tree['data']) == [ + workflow = tree['children'][0] + assert workflow['type_'] == 'workflow' + assert workflow['id_'] == 'workflow id' + assert set(workflow['data']) == { # whatever if present on the node should end up in data - 'id', 'cyclePoints', 'familyProxies', + 'id', + 'port', 'taskProxies' - ] - assert len(tree['children']) == 1 + } + assert len(workflow['children']) == 1 # the cycle point node - cycle = tree['children'][0] + cycle = workflow['children'][0] assert cycle['type_'] == 'cycle' assert cycle['id_'] == '//1' assert list(cycle['data']) == [