Skip to content

Commit

Permalink
reinstall: perform interactive --dry-run by default
Browse files Browse the repository at this point in the history
* In interactive mode:
  * Perform dry run.
  * Display proposed changes to the user.
  * Prompt for permission to continue.
  * Provide explanatory docs in the --help.
* In non-interactive mode (unlikely use case for reinstall):
  * Just do it.
  • Loading branch information
oliver-sanders committed Jul 6, 2022
1 parent 0fadcc4 commit 8be9957
Show file tree
Hide file tree
Showing 2 changed files with 179 additions and 56 deletions.
233 changes: 178 additions & 55 deletions cylc/flow/scripts/reinstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
<g>send foo</g> # this means the file "foo" would be added/updated
<r>del. bar</r> # 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,
Expand All @@ -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
Expand All @@ -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'<green>{line}</green>'))
elif line[0:4] == 'del.':
# file deleted
lines.append(cparse(f'<red>{line}</red>'))
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(
Expand All @@ -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'</{DIM}>\n'
'\n<bold>Reinstall would make the above changes.</bold>'
)
)
# prompt for permission to continue
while usr not in ['y', 'n']:
usr = input(
cparse('<bold>Continue [y/n]: </bold>')
).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('<green>Successfully reinstalled.</green>'))
if is_workflow_running(workflow_id):
print(cparse(
'\n<blue>'
'Run "cylc reload {workflow_id}" to pick up changes.'
'</blue>'
))
else:
print(
cparse('<magenta>Reinstall canceled, no changes made.</magenta>')
)


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'
):
Expand All @@ -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(
'<blue>'
'NOTE: Files created by Rose file installation will show as'
' deleted.'
'\n They will be re-created during the reinstall'
' process.'
'</blue>',
),
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'</{DIM}>\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'
):
Expand All @@ -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('<green>send foo</green>'),
... cparse('<red>del. bar</red>'),
... 'baz',
... ]
True
"""
lines = []
for line in out.splitlines():
if line[0:4] == 'send':
# file added or updated
lines.append(cparse(f'<green>{line}</green>'))
elif line[0:4] == 'del.':
# file deleted
lines.append(cparse(f'<red>{line}</red>'))
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
2 changes: 1 addition & 1 deletion cylc/flow/workflow_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down

0 comments on commit 8be9957

Please sign in to comment.