diff --git a/cylc/flow/scheduler_cli.py b/cylc/flow/scheduler_cli.py
index af55f66d2e..10d4035cbd 100644
--- a/cylc/flow/scheduler_cli.py
+++ b/cylc/flow/scheduler_cli.py
@@ -65,6 +65,7 @@
)
from cylc.flow.terminal import (
cli_function,
+ handle_sigint,
is_terminal,
prompt,
)
@@ -565,15 +566,16 @@ def _version_check(
))
if is_terminal():
# we are in interactive mode, ask the user if this is ok
- options.upgrade = prompt(
- cparse(
- 'Are you sure you want to upgrade from'
- f' {last_run_version}'
- f' to {__version__}?'
- ),
- {'y': True, 'n': False},
- process=str.lower,
- )
+ with handle_sigint():
+ options.upgrade = prompt(
+ cparse(
+ 'Are you sure you want to upgrade from'
+ f' {last_run_version}'
+ f' to {__version__}?'
+ ),
+ {'y': True, 'n': False},
+ process=str.lower,
+ )
return options.upgrade
# we are in non-interactive mode, abort abort abort
print('Use "--upgrade" to upgrade the workflow.', file=sys.stderr)
diff --git a/cylc/flow/scripts/clean.py b/cylc/flow/scripts/clean.py
index 95e241ed73..44d27a76f7 100644
--- a/cylc/flow/scripts/clean.py
+++ b/cylc/flow/scripts/clean.py
@@ -62,14 +62,25 @@
import asyncio
from optparse import SUPPRESS_HELP
import sys
-from typing import TYPE_CHECKING, Iterable, List, Tuple
+from typing import (
+ TYPE_CHECKING,
+ Iterable,
+ List,
+ Tuple,
+)
from metomi.isodatetime.exceptions import ISO8601SyntaxError
from metomi.isodatetime.parsers import DurationParser
from cylc.flow import LOG
-from cylc.flow.clean import init_clean, get_contained_workflows
-from cylc.flow.exceptions import CylcError, InputError
+from cylc.flow.clean import (
+ get_contained_workflows,
+ init_clean,
+)
+from cylc.flow.exceptions import (
+ CylcError,
+ InputError,
+)
import cylc.flow.flags
from cylc.flow.id_cli import parse_ids_async
from cylc.flow.loggingutil import set_timestamps
@@ -78,7 +89,12 @@
CylcOptionParser as COP,
Options,
)
-from cylc.flow.terminal import cli_function, is_terminal
+from cylc.flow.terminal import (
+ handle_sigint,
+ cli_function,
+ is_terminal,
+)
+
if TYPE_CHECKING:
from optparse import Values
@@ -168,12 +184,13 @@ def prompt(workflows: Iterable[str]) -> None:
print(f' {workflow}')
if is_terminal():
- while True:
- ret = input('Remove these workflows (y/n): ')
- if ret.lower() == 'y':
- return
- if ret.lower() == 'n':
- sys.exit(1)
+ with handle_sigint():
+ while True:
+ ret = input('Remove these workflows (y/n): ').lower()
+ if ret == 'y':
+ return
+ if ret == 'n':
+ sys.exit(1)
else:
print(
"Use --yes to remove multiple workflows in non-interactive mode.",
diff --git a/cylc/flow/scripts/reinstall.py b/cylc/flow/scripts/reinstall.py
index 1f574c212b..ed9c949242 100644
--- a/cylc/flow/scripts/reinstall.py
+++ b/cylc/flow/scripts/reinstall.py
@@ -98,6 +98,8 @@
from cylc.flow.terminal import (
DIM,
cli_function,
+ handle_sigint,
+ interrupt,
is_terminal,
)
from cylc.flow.workflow_files import (
@@ -226,10 +228,11 @@ async def reinstall_cli(
display_rose_warning(source)
display_cylcignore_tip()
# prompt for permission to continue
- while usr not in ['y', 'n']:
- usr = _input(
- cparse('Continue [y/n]: ')
- ).lower()
+ with handle_sigint(interrupt):
+ while usr not in ['y', 'n']:
+ usr = _input(
+ cparse('Continue [y/n]: ')
+ ).lower()
else: # non interactive-mode - no dry-run, no prompt
usr = 'y'
@@ -237,7 +240,6 @@ async def reinstall_cli(
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 for real
diff --git a/cylc/flow/terminal.py b/cylc/flow/terminal.py
index 0a9d30ac54..dee71327ce 100644
--- a/cylc/flow/terminal.py
+++ b/cylc/flow/terminal.py
@@ -16,13 +16,18 @@
"""Functionality to assist working with terminals"""
+from contextlib import contextmanager
+from functools import wraps
import inspect
import json
import logging
import os
+import signal
+from subprocess import ( # nosec
+ PIPE,
+ Popen,
+)
import sys
-from functools import wraps
-from subprocess import PIPE, Popen # nosec
from textwrap import wrap
from typing import (
TYPE_CHECKING,
@@ -30,6 +35,7 @@
Callable,
Dict,
List,
+ NoReturn,
Optional,
Sequence,
TypeVar,
@@ -41,15 +47,18 @@
from ansimarkup import parse as cparse
from colorama import init as color_init
-import cylc.flow.flags
from cylc.flow import CYLC_LOG
from cylc.flow.exceptions import CylcError
+import cylc.flow.flags
from cylc.flow.loggingutil import CylcLogFormatter
from cylc.flow.parsec.exceptions import ParsecError
if TYPE_CHECKING:
- from optparse import OptionParser, Values
+ from optparse import (
+ OptionParser,
+ Values,
+ )
T = TypeVar('T')
StrFunc = Callable[[str], str]
@@ -452,3 +461,29 @@ def flatten_cli_lists(lsts: List[str]) -> List[str]:
for lst in (lsts or [])
for item in lst.strip().split(',')
})
+
+
+@contextmanager
+def handle_sigint(handler: Callable | None = None):
+ """Context manager to handle if Ctrl+C happens while in input().
+
+ If no handler is specified, it will print "Aborted" and exit 1.
+
+ Sets the SIGINT handler inside the context and restores the previous
+ handler after.
+ """
+ prev_handler = signal.signal(signal.SIGINT, handler or abort)
+ try:
+ yield
+ finally:
+ signal.signal(signal.SIGINT, prev_handler)
+
+
+def abort(*args) -> NoReturn:
+ print("\nAborted")
+ sys.exit(1)
+
+
+def interrupt(*args) -> NoReturn:
+ print() # go to next line after `^C`
+ raise KeyboardInterrupt()