diff --git a/cylc/flow/scripts/reinstall.py b/cylc/flow/scripts/reinstall.py index d81d3e6949e..bb471cc05e9 100644 --- a/cylc/flow/scripts/reinstall.py +++ b/cylc/flow/scripts/reinstall.py @@ -30,18 +30,56 @@ # Or, to reinstall a specific run: $ cylc reinstall myflow/run2 - # View the changes reinstall would make: - $ cylc reinstall myflow --dry-run + # If the workflow is running: + $ cylc reinstall myflow # reinstall as usual + $ cylc reload myflow # pick up changes in the workflow config + +What reinstall does: + Reinstall synchronises files between the workflow source and the specified + run directory. + + Any files which have been added, updated or removed in the source directory + will be added, updated or removed in the run directory. Cylc uses "rsync" + to do this (run in "--debug" mode to see the exact command used). + +How changes are displayed: + Reinstall will first perform a dry run showing the files it would change. + This is displayed in "rsync" format e.g: + + send foo # this means the file "foo" would be added/updated + del. bar # this means the file "bar" would be deleted + +How to prevent reinstall deleting files: + Reinstall will delete any files which are not present in the source directory + (i.e. if you delete a file from the source directory, a reinstall would + remove the file from the run directory too). The "work/" and "share/" + directory are excluded from this. These are the recommended locations for any + files created at runtime. + + You can extend the list of "excluded" paths by creating a ".cylcignore" file. + For example the following file would exclude "data/" and any ".csv" files + from being overwritten by a reinstallation: + + $ cat .cylcignore + data + *.csv + + Note any paths listed in ".cylcignore" will not be installed by + "cylc install" even if present in the source directory. """ from pathlib import Path import sys -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, List from ansimarkup import parse as cparse from cylc.flow import iter_entry_points -from cylc.flow.exceptions import PluginError, WorkflowFilesError +from cylc.flow.exceptions import ( + PluginError, + ServiceFileError, + WorkflowFilesError, +) from cylc.flow.id_cli import parse_id from cylc.flow.option_parsers import ( WORKFLOW_ID_ARG_DOC, @@ -50,9 +88,10 @@ from cylc.flow.pathutil import get_workflow_run_dir from cylc.flow.workflow_files import ( get_workflow_source_dir, + load_contact_file, reinstall_workflow, ) -from cylc.flow.terminal import cli_function +from cylc.flow.terminal import cli_function, DIM, is_terminal if TYPE_CHECKING: from optparse import Values @@ -78,48 +117,19 @@ def get_option_parser() -> COP: dest="clear_rose_install_opts" ) - parser.add_option( - '--dry', '--dry-run', - action='store_true', - help='Show the changes reinstallation would make.' - ) - return parser -def format_rsync_out(out): - """Format rsync stdout for presenting to users. - - Note: Output formats of different rsync implementations may differ so keep - this code simple and robust. - - """ - lines = [] - for line in out.splitlines(): - if line[0:4] == 'send': - # file added or updated - lines.append(cparse(f'{line}')) - elif line[0:4] == 'del.': - # file deleted - lines.append(cparse(f'{line}')) - elif line == 'cannot delete non-empty directory: opt': - # These "cannot delete non-empty directory" messages can arise - # as a result of excluding files within sub-directories. - # This opt dir message is likely to occur when a rose-suit.conf - # file is present. - continue - else: - # other uncategorised log line - lines.append(line) - return lines - - @cli_function(get_option_parser) def main( parser: COP, opts: 'Values', args: Optional[str] = None ) -> None: + """Implement cylc reinstall. + + This is the bit which contains all the CLI logic. + """ run_dir: Optional[Path] workflow_id: str workflow_id, *_ = parse_id( @@ -141,7 +151,62 @@ def main( ' symlink to continue.' ) - if not opts.dry: + try: + usr: str = '' + if is_terminal(): + # interactive mode - perform dry-run and prompt + reinstall(opts, workflow_id, source, run_dir, dry_run=True) + print( + cparse( + f'\n<{DIM}>TIP: You can "exclude" files/dirs to prevent' + ' Cylc from installing or overwriting\n them by adding' + 'them to the .cylcignore file. See cylc reinstall --help.' + f'\n' + '\nReinstall would make the above changes.' + ) + ) + # prompt for permission to continue + while usr not in ['y', 'n']: + usr = input( + cparse('Continue [y/n]: ') + ).lower() + else: + # non interactive-mode - no dry-run, no prompt + usr = 'y' + except KeyboardInterrupt: + # ensure the "reinstall canceled" message shows for ctrl+c + usr = 'n' # cancel the reinstall + print() # clear the traceback line + + if usr == 'y': + reinstall(opts, workflow_id, source, run_dir, dry_run=False) + print(cparse('Successfully reinstalled.')) + if is_workflow_running(workflow_id): + print(cparse( + '\n' + 'Run "cylc reload {workflow_id}" to pick up changes.' + '' + )) + else: + print( + cparse('Reinstall canceled, no changes made.') + ) + + +def reinstall( + opts: 'Values', + workflow_id: str, + source: str, + run_dir: Path, + dry_run: bool = False, +) -> None: + """Perform reinstallation. + + This is the purely functional bit without any CLI logic. + """ + # run pre_configure plugins + if not dry_run: + # don't run plugins in dry-mode for entry_point in iter_entry_points( 'cylc.pre_configure' ): @@ -156,28 +221,36 @@ def main( exc ) from None - stdout = reinstall_workflow( + # reinstall from source + stdout: str = reinstall_workflow( source=Path(source), named_run=workflow_id, rundir=run_dir, - dry_run=opts.dry, + dry_run=dry_run, ) - if Path(source, 'rose-suite.conf').is_file(): - print( - cparse( - '' - 'NOTE: Files created by Rose file installation will show as' - ' deleted.' - '\n They will be re-created during the reinstall' - ' process.' - '', - ), - file=sys.stderr, - ) - print('\n'.join(format_rsync_out(stdout)), file=sys.stderr) + # display changes + if dry_run: + # only write rsync output in dry-mode + if Path(source, 'rose-suite.conf').is_file(): + # TODO: remove this in combination with + # https://github.com/cylc/cylc-rose/issues/149 + print( + cparse( + f'\n<{DIM}>' + 'NOTE: Files created by Rose file installation will show' + ' as deleted.' + '\n They will be re-created during the reinstall' + ' process.' + f'\n', + ), + file=sys.stderr, + ) + print('\n'.join(format_rsync_out(stdout)), file=sys.stderr) - if not opts.dry: + # run post_install plugins + if not dry_run: + # don't run plugins in dry-mode for entry_point in iter_entry_points( 'cylc.post_install' ): @@ -195,3 +268,53 @@ def main( entry_point.name, exc ) from None + + +def format_rsync_out(out: str) -> List[str]: + r"""Format rsync stdout for presenting to users. + + Note: Output formats of different rsync implementations may differ so keep + this code simple and robust. + + Example: + >>> format_rsync_out( + ... 'send foo\ndel. bar\nbaz' + ... ) == [ + ... cparse('send foo'), + ... cparse('del. bar'), + ... 'baz', + ... ] + True + + """ + lines = [] + for line in out.splitlines(): + if line[0:4] == 'send': + # file added or updated + lines.append(cparse(f'{line}')) + elif line[0:4] == 'del.': + # file deleted + lines.append(cparse(f'{line}')) + elif line == 'cannot delete non-empty directory: opt': + # These "cannot delete non-empty directory" messages can arise + # as a result of excluding files within sub-directories. + # This opt dir message is likely to occur when a rose-suit.conf + # file is present. + continue + else: + # other uncategorised log line + lines.append(line) + return lines + + +def is_workflow_running(workflow_id: str) -> bool: + """Quick and simple way to tell if a workflow is running. + + It would be better to "ping" the workflow, however, this is sufficient + for our purposes. + """ + try: + load_contact_file(workflow_id) + except ServiceFileError: + return False + return True diff --git a/cylc/flow/workflow_files.py b/cylc/flow/workflow_files.py index 26e63992faf..76e3570ea49 100644 --- a/cylc/flow/workflow_files.py +++ b/cylc/flow/workflow_files.py @@ -1514,7 +1514,7 @@ def reinstall_workflow( dry_run=dry_run, ) reinstall_log.info(cli_format(rsync_cmd)) - # print(cli_format(rsync_cmd)) + LOG.debug(cli_format(rsync_cmd)) proc = Popen(rsync_cmd, stdout=PIPE, stderr=PIPE, text=True) # nosec # * command is constructed via internal interface stdout, stderr = proc.communicate()