diff --git a/cylc/flow/scripts/install.py b/cylc/flow/scripts/install.py index bdf339fcc38..4bce90fabb2 100755 --- a/cylc/flow/scripts/install.py +++ b/cylc/flow/scripts/install.py @@ -89,11 +89,16 @@ from cylc.flow.scripts.scan import ( get_pipe, _format_plain, + FLOW_STATE_SYMBOLS, + FLOW_STATE_CMAP ) from cylc.flow import iter_entry_points from cylc.flow.exceptions import PluginError, InputError from cylc.flow.loggingutil import CylcLogFormatter -from cylc.flow.option_parsers import CylcOptionParser as COP +from cylc.flow.option_parsers import ( + CylcOptionParser as COP, + Options +) from cylc.flow.pathutil import EXPLICIT_RELATIVE_PATH_REGEX, expand_path from cylc.flow.workflow_files import ( install_workflow, search_install_source_dirs, parse_cli_sym_dirs @@ -167,7 +172,7 @@ def get_source_location(path: Optional[str]) -> Path: """ if path is None: return Path.cwd() - path = path.strip() + path = str(path).strip() expanded_path = Path(expand_path(path)) if expanded_path.is_absolute(): return expanded_path @@ -182,33 +187,68 @@ async def scan(wf_name: str) -> None: 'name': [f'{wf_name}/*'], 'states': {'running', 'paused', 'stopping'}, 'source': False, - 'ping': False, + 'ping': True, # get status of scanned workflows }) active = [ item async for item in get_pipe(opts, None, scan_dir=None) ] if active: + n = len(active) + grammar = ( + ["s", "are", "them"] + if n > 1 else + ["", "is", "it"] + ) print( CylcLogFormatter.COLORS['WARNING'].format( - f'Instance(s) of "{wf_name}" are already active:' + f'WARNING: {n} run%s of "{wf_name}"' + ' %s already active:' % tuple(grammar[:2]) ) ) for item in active: + status = item['status'] + tag = FLOW_STATE_CMAP[status] + symbol = f"<{tag}>{FLOW_STATE_SYMBOLS[status]}" cprint( - _format_plain(item, opts) + " ", symbol, _format_plain(item, opts) ) + pattern = ( + f"'{wf_name}/*'" + if n > 1 else + f"{item['name']}" + ) + print( + f'You can stop %s with "cylc stop [options] {pattern}".\n' + 'See "cylc stop --help" for options.' % grammar[-1] + ) + + +InstallOptions = Options(get_option_parser()) @cli_function(get_option_parser) -def main(parser, opts, reg=None): - wf_name = install(parser, opts, reg) +def main( + _parser: COP, + opts: 'Values', + reg: Optional[str] = None +) -> None: + """CLI wrapper.""" + install_cli(opts, reg) + + +def install_cli( + opts: 'Values', + reg: Optional[str] = None +) -> None: + """Install workflow and scan for already-running instances.""" + wf_name = install(opts, reg) asyncio.run( scan(wf_name) ) def install( - parser: COP, opts: 'Values', reg: Optional[str] = None + opts: 'Values', reg: Optional[str] = None ) -> str: if opts.no_run_name and opts.run_name: raise InputError( diff --git a/tests/integration/test_install.py b/tests/integration/test_install.py new file mode 100644 index 00000000000..4c9023347e0 --- /dev/null +++ b/tests/integration/test_install.py @@ -0,0 +1,84 @@ +# 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 . + +"""Test cylc install.""" + +from pathlib import Path +from shutil import rmtree +from tempfile import TemporaryDirectory + +import pytest + +from .test_scan import init_flows + +from cylc.flow.workflow_files import WorkflowFiles +from cylc.flow.scripts.install import ( + InstallOptions, + install_cli +) + + +SRV_DIR = Path(WorkflowFiles.Service.DIRNAME) +CONTACT = Path(WorkflowFiles.Service.CONTACT) +RUN_N = Path(WorkflowFiles.RUN_N) +INSTALL = Path(WorkflowFiles.Install.DIRNAME) + + +@pytest.fixture() +def src_run_dirs(mock_glbl_cfg, monkeypatch): + """Create some workflow source and run dirs for testing. + + Source dirs: + /w1 + /w2 + + Run dir: + /w1/run1 + + """ + tmp_src_path = Path(TemporaryDirectory().name) + tmp_run_path = Path(TemporaryDirectory().name) + tmp_src_path.mkdir() + tmp_run_path.mkdir() + + init_flows( + tmp_run_path=tmp_run_path, + running=('w1/run1',), + tmp_src_path=tmp_src_path, + src=('w1', 'w2') + ) + mock_glbl_cfg( + 'cylc.flow.workflow_files.glbl_cfg', + f''' + [install] + source dirs = {tmp_src_path} + ''' + ) + monkeypatch.setattr('cylc.flow.pathutil._CYLC_RUN_DIR', tmp_run_path) + + yield tmp_src_path, tmp_run_path + rmtree(tmp_src_path) + rmtree(tmp_run_path) + + +def test_install_scan(src_run_dirs, capsys): + """At install, any running intances should be reported.""" + + install_cli(opts=InstallOptions(), reg='w1') + assert '1 run of "w1" is already active:' in capsys.readouterr().out + + install_cli(opts=InstallOptions(), reg='w2') + assert '1 run of "w2" is already active:' not in capsys.readouterr().out diff --git a/tests/integration/test_scan.py b/tests/integration/test_scan.py index 94edbfc9b29..6cb405cea69 100644 --- a/tests/integration/test_scan.py +++ b/tests/integration/test_scan.py @@ -42,17 +42,20 @@ INSTALL = Path(WorkflowFiles.Install.DIRNAME) -def init_flows(tmp_path, running=None, registered=None, un_registered=None): +def init_flows(tmp_run_path=None, running=None, registered=None, + un_registered=None, tmp_src_path=None, src=None): """Create some dummy workflows for scan to discover. Assume "run1, run2, ..., runN" structure if flow name constains "run". + Optionally create workflow source dirs in a give location too. + """ def make_registered(name, running=False): - run_d = Path(tmp_path, name) + run_d = Path(tmp_run_path, name) run_d.mkdir(parents=True, exist_ok=True) (run_d / "flow.cylc").touch() if "run" in name: - root = Path(tmp_path, name).parent + root = Path(tmp_run_path, name).parent with suppress(FileExistsError): (root / "runN").symlink_to(run_d, target_is_directory=True) else: @@ -63,12 +66,19 @@ def make_registered(name, running=False): if running: (srv_d / CONTACT).touch() + def make_src(name): + src_d = Path(tmp_src_path, name) + src_d.mkdir(parents=True, exist_ok=True) + (src_d / "flow.cylc").touch() + for name in (running or []): make_registered(name, running=True) for name in (registered or []): make_registered(name) for name in (un_registered or []): - Path(tmp_path, name).mkdir(parents=True, exist_ok=True) + Path(tmp_run_path, name).mkdir(parents=True, exist_ok=True) + for name in (src or []): + make_src(name) @pytest.fixture(scope='session') @@ -157,14 +167,14 @@ def source_dirs(mock_glbl_cfg): src1 = src / '1' src1.mkdir() init_flows( - src1, - registered=('a', 'b/c') + tmp_src_path=src1, + src=('a', 'b/c') ) src2 = src / '2' src2.mkdir() init_flows( - src2, - registered=('d', 'e/f') + tmp_src_path=src2, + src=('d', 'e/f') ) mock_glbl_cfg( 'cylc.flow.scripts.scan.glbl_cfg',