Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add gui workflow arg #370

Merged
merged 8 commits into from
Jan 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ ones in. -->

### Enhancements

[#370](https://github.com/cylc/cylc-uiserver/pull/370) - Gui command adapted:
`cylc gui workflow_id` is now supported and will open gui at that workflow.

[#376](https://github.com/cylc/cylc-uiserver/pull/376) -
UIServer logs are now archived. The five most recent logs are retained (located
in `~/.cylc/uiserver/log/`). A new log is created with each UIServer instance.
Expand Down
25 changes: 19 additions & 6 deletions cylc/uiserver/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@

from concurrent.futures import ProcessPoolExecutor
import getpass
import os
from pathlib import Path, PurePath
import sys
from textwrap import dedent
from typing import List

from pkg_resources import parse_version
Expand Down Expand Up @@ -104,6 +106,8 @@
from cylc.uiserver.websockets.tornado import TornadoSubscriptionServer
from cylc.uiserver.workflows_mgr import WorkflowsManager

INFO_FILES_DIR = Path(USER_CONF_ROOT / "info_files")


class PathType(TraitType):
"""A pathlib traitlet type which allows string and undefined values."""
Expand All @@ -126,14 +130,17 @@ class CylcUIServer(ExtensionApp):

name = 'cylc'
app_name = 'cylc-gui'
default_url = "/cylc"
load_other_extensions = True
description = '''
Cylc - A user interface for monitoring and controlling Cylc workflows.
''' # type: ignore[assignment]
examples = '''
cylc gui # start the cylc GUI
Cylc gui - A user interface for monitoring and controlling Cylc workflows.
''' # type: ignore[assignment]
examples = dedent('''
cylc gui # Start the Cylc GUI (At the dashboard page)
cylc gui [workflow] # Start the Cylc GUI (at the workflow page)
cylc gui --new [workflow] # Start the Cylc GUI (at the workflow page), with
a new instance.

''') # type: ignore[assignment]
config_file_paths = get_conf_dir_hierarchy(
[
SITE_CONF_ROOT, # site configuration
Expand Down Expand Up @@ -506,12 +513,18 @@ def initialize_templates(self):
"""Change the jinja templating environment."""

@classmethod
def launch_instance(cls, argv=None, **kwargs):
def launch_instance(cls, argv=None, workflow_id=None, **kwargs):
if workflow_id:
cls.default_url = f"/cylc/#/workflows/{workflow_id}"
else:
cls.default_url = "/cylc"
if argv is None:
# jupyter server isn't expecting to be launched by a Cylc command
# this patches some internal logic
argv = sys.argv[2:]
os.environ["JUPYTER_RUNTIME_DIR"] = str(INFO_FILES_DIR)
super().launch_instance(argv=argv, **kwargs)
del os.environ["JUPYTER_RUNTIME_DIR"]

async def stop_extension(self):
# stop the async scan task
Expand Down
1 change: 0 additions & 1 deletion cylc/uiserver/jupyter_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
# may be used by Cylc UI developers to use a development UI build
'CYLC_DEV',
)

# this auto-spawns uiservers without user interaction
c.JupyterHub.implicit_spawn_seconds = 0.01

Expand Down
152 changes: 150 additions & 2 deletions cylc/uiserver/scripts/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,158 @@
For a multi-user system see `cylc hub`.
"""

from ansimarkup import parse as cparse
import argparse
import asyncio
from contextlib import suppress
from glob import glob
import os
from pathlib import Path
import random
import re
import sys
import webbrowser


from cylc.flow.id_cli import parse_id_async
from cylc.flow.exceptions import (
InputError,
WorkflowFilesError
)

from cylc.uiserver import init_log
from cylc.uiserver.app import CylcUIServer
from cylc.uiserver.app import (
CylcUIServer,
INFO_FILES_DIR
)

CLI_OPT_NEW = "--new"


def main(*argv):
init_log()
return CylcUIServer.launch_instance(argv or None)
jp_server_opts, new_gui, workflow_id = parse_args_opts()
if '--help' not in sys.argv:
# get existing jpserver-<pid>-open.html files
# assume if this exists then the server is still running
# these files are cleaned by jpserver on shutdown
existing_guis = glob(os.path.join(INFO_FILES_DIR, "*open.html"))
if existing_guis and not new_gui:
gui_file = random.choice(existing_guis)
print(
"Opening with existing gui." +
f" Use {CLI_OPT_NEW} option for a new gui.",
file=sys.stderr
)
update_html_file(gui_file, workflow_id)
if '--no-browser' not in sys.argv:
webbrowser.open(gui_file, autoraise=True)
return
return CylcUIServer.launch_instance(
jp_server_opts or None, workflow_id=workflow_id
)


def print_error(error: str, msg: str):
"""Print formatted error with message"""
print(cparse(
f'<red><bold>{error}: </bold>{msg}</red>'
), file=sys.stderr
)


def parse_args_opts():
"""Parse cli args

Separate JPserver args from Cylc specific ones.
Parse workflow id.
Raise error if invalid workflow provided
"""
cylc_opts, jp_server_opts = get_arg_parser().parse_known_args()
# gui arg is stripped cylc end but need to re-strip after arg parsing here
with suppress(ValueError):
jp_server_opts.remove('gui')
new_gui = cylc_opts.new
if new_gui:
sys.argv.remove(CLI_OPT_NEW)
workflow_id = None
workflow_arg = [
arg for arg in jp_server_opts
if not arg.startswith('-')
]
if len(workflow_arg) > 1:
msg = "Wrong number of arguments (too many)"
print_error("CylcError", msg)
sys.exit(1)
if len(workflow_arg) == 1:
try:
loop = asyncio.new_event_loop()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should only need one loop here. Suggest doing something like this:

def main(*argv):
    ... = asyncio.run(parse_args())

async def parse_args():
    ...
    for arg in jp_server_opts:
        ...
        workflow_id, *_ = await parse_id_async(...)

task = loop.create_task(parse_id_async(
workflow_arg[0], constraint='workflows'))
loop.run_until_complete(task)
loop.close()
workflow_id, _, _ = task.result()
except (InputError, WorkflowFilesError) as exc:
# workflow not found
print_error(exc.__class__.__name__, exc)
sys.exit(1)

# Remove args which are workflow ids from jp_server_opts.
jp_server_opts = tuple(
arg for arg in jp_server_opts if arg not in workflow_arg)
[sys.argv.remove(arg) for arg in workflow_arg]
return jp_server_opts, new_gui, workflow_id


def get_arg_parser():
"""Cylc specific options"""
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument(
CLI_OPT_NEW,
action='store_true',
default=False,
dest='new'
)
return parser


def update_html_file(gui_file, workflow_id):
""" Update the html file to open at the correct workflow in the gui.
"""
with open(gui_file, "r") as f:
file_content = f.read()
url_extract_regex = re.compile('url=(.*?)\"')
url_string = url_extract_regex.search(file_content)
if not url_string:
return
url = url_string.group(1)
split_url = url.split('/workflows/')
if not workflow_id:
# new url should point to dashboard
if len(split_url) == 1:
# no update required
return
else:
# previous url points to workflow page and needs updating
# replace with base url (including token)
replacement_url_string = split_url[0]
else:
if len(split_url) > 1:
old_workflow = split_url[1]
if workflow_id == old_workflow:
# same workflow page requested, no update needed
return
else:
replacement_url_string = url.replace(old_workflow, workflow_id)
else:
# current url points to dashboard, update to point to workflow
replacement_url_string = f"{url}/workflows/{workflow_id}"
update_url_string(gui_file, url, replacement_url_string)


def update_url_string(gui_file: str, url: str, replacement_url_string: str):
"""Updates the url string in the given gui file."""
file = Path(gui_file)
current_text = file.read_text()
updated_text = current_text.replace(url, replacement_url_string)
file.write_text(updated_text)
71 changes: 71 additions & 0 deletions cylc/uiserver/tests/test_gui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 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 <http://www.gnu.org/licenses/>.

import json
from pathlib import Path
import pytest
import os

from cylc.uiserver.scripts.gui import update_html_file

@pytest.mark.parametrize(
'existing_content,workflow_id,expected_updated_content',
[
pytest.param(
'content="1;url=http://localhost:8892/cylc/?token=1234567890some_big_long_token1234567890#" /> ',
None,
'content="1;url=http://localhost:8892/cylc/?token=1234567890some_big_long_token1234567890#" /> ',
id='existing_no_workflow_new_no_workflow'
),
pytest.param(
'content="1;url=http://localhost:8892/cylc/?token=1234567890some_big_long_token1234567890#" /> ',
'some/workflow',
'content="1;url=http://localhost:8892/cylc/?token=1234567890some_big_long_token1234567890#/workflows/some/workflow" /> ',
id='existing_no_workflow_new_workflow'
),
pytest.param(
'content="1;url=http://localhost:8892/cylc/?token=1234567890some_big_long_token1234567890#/workflows/some/workflow" /> ',
'another/flow',
'content="1;url=http://localhost:8892/cylc/?token=1234567890some_big_long_token1234567890#/workflows/another/flow" /> ',
id='existing_workflow_new_workflow'
),
pytest.param(
'content="1;url=http://localhost:8892/cylc/?token=1234567890some_big_long_token1234567890#/workflows/some/workflow" /> ',
None,
'content="1;url=http://localhost:8892/cylc/?token=1234567890some_big_long_token1234567890#" /> ',
id='existing_workflow_no_new_workflow'
),
pytest.param(
'content="1;no url in this file "',
'another/flow',
'content="1;no url in this file "',
id='no_url_no_change'
),
]
)
def test_update_html_file_updates_gui_file(
existing_content,
workflow_id,
expected_updated_content,
tmp_path):
"""Tests html file is updated correctly"""
Path(tmp_path).mkdir(exist_ok=True)
tmp_gui_file = Path(tmp_path / "gui")
tmp_gui_file.touch()
tmp_gui_file.write_text(existing_content)
update_html_file(tmp_gui_file, workflow_id)
updated_file_content = tmp_gui_file.read_text()

assert updated_file_content == expected_updated_content
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ install_requires =
# NB: no graphene version specified; we only make light use of it in our
# own code, so graphene-tornado's transitive version should do.
cylc-flow==8.1.*
ansimarkup>=1.0.0
graphene
graphene-tornado==2.6.*
graphql-ws==0.4.4
Expand Down