From 3d002ee8653c8205ee32521d43f0a8a197639465 Mon Sep 17 00:00:00 2001 From: WXTIM <26465611+wxtim@users.noreply.github.com> Date: Thu, 25 Apr 2024 13:53:46 +0100 Subject: [PATCH] Implement Skip Mode * Add `[runtime][]run mode` and `[runtime][][skip]`. * Spin run mode functionality into separate modules. * Run sim mode check with every main loop - we don't know if any tasks are in sim mode from the scheduler, but it doesn't cost much to check if none are. * Implemented separate job "submission" pathway switching. * Implemented skip mode, including output control logic. * Add a linter and a validation check for tasks in nonlive modes, and for combinations of outputs * Enabled setting outputs as if task ran in skip mode using `cylc set --out skip`. * Testing for the above. --- changes.d/6039.feat.md | 1 + cylc/flow/cfgspec/workflow.py | 53 +- cylc/flow/config.py | 10 +- cylc/flow/run_modes/dummy.py | 121 + cylc/flow/run_modes/nonlive.py | 59 + cylc/flow/{ => run_modes}/simulation.py | 185 +- cylc/flow/run_modes/skip.py | 172 + cylc/flow/scheduler.py | 25 +- cylc/flow/scheduler_cli.py | 12 +- cylc/flow/scripts/lint.py | 47 + cylc/flow/scripts/set.py | 7 +- cylc/flow/scripts/validate.py | 2 +- cylc/flow/task_events_mgr.py | 16 +- cylc/flow/task_job_mgr.py | 108 +- cylc/flow/task_pool.py | 5 +- cylc/flow/task_proxy.py | 2 +- cylc/flow/task_state.py | 72 + cylc/flow/unicode_rules.py | 4 +- cylc/flow/workflow_status.py | 19 - diff | 2782 +++++++++++++++++ .../cylc-config/00-simple/section2.stdout | 58 +- tests/functional/cylc-set/09-set-skip.t | 28 + .../functional/cylc-set/09-set-skip/flow.cylc | 50 + .../cylc-set/09-set-skip/reference.log | 8 + .../{modes => run_modes}/01-dummy.t | 0 .../{modes => run_modes}/01-dummy/flow.cylc | 0 .../01-dummy/reference.log | 0 .../02-dummy-message-outputs.t | 0 .../02-dummy-message-outputs/flow.cylc | 0 .../02-dummy-message-outputs/reference.log | 0 .../{modes => run_modes}/03-simulation.t | 0 .../03-simulation/flow.cylc | 0 .../03-simulation/reference.log | 0 .../04-simulation-runtime.t | 0 .../04-simulation-runtime/flow.cylc | 0 .../04-simulation-runtime/reference.log | 0 .../{modes => run_modes}/05-sim-trigger.t | 0 .../05-sim-trigger/flow.cylc | 0 .../05-sim-trigger/reference.log | 0 .../run_modes/06-run-mode-overrides.t | 72 + .../run_modes/06-run-mode-overrides/flow.cylc | 29 + .../{modes => run_modes}/test_header | 0 .../run_modes/test_mode_overrides.py | 109 + .../{ => run_modes}/test_simulation.py | 55 +- tests/integration/test_config.py | 53 + tests/unit/run_modes/test_dummy.py | 40 + tests/unit/run_modes/test_nonlive.py | 45 + tests/unit/{ => run_modes}/test_simulation.py | 26 +- tests/unit/run_modes/test_skip.py | 119 + tests/unit/scripts/test_lint.py | 3 + tests/unit/test_config.py | 34 +- tests/unit/test_task_state.py | 28 + 52 files changed, 4221 insertions(+), 238 deletions(-) create mode 100644 changes.d/6039.feat.md create mode 100644 cylc/flow/run_modes/dummy.py create mode 100644 cylc/flow/run_modes/nonlive.py rename cylc/flow/{ => run_modes}/simulation.py (66%) create mode 100644 cylc/flow/run_modes/skip.py create mode 100644 diff create mode 100644 tests/functional/cylc-set/09-set-skip.t create mode 100644 tests/functional/cylc-set/09-set-skip/flow.cylc create mode 100644 tests/functional/cylc-set/09-set-skip/reference.log rename tests/functional/{modes => run_modes}/01-dummy.t (100%) rename tests/functional/{modes => run_modes}/01-dummy/flow.cylc (100%) rename tests/functional/{modes => run_modes}/01-dummy/reference.log (100%) rename tests/functional/{modes => run_modes}/02-dummy-message-outputs.t (100%) rename tests/functional/{modes => run_modes}/02-dummy-message-outputs/flow.cylc (100%) rename tests/functional/{modes => run_modes}/02-dummy-message-outputs/reference.log (100%) rename tests/functional/{modes => run_modes}/03-simulation.t (100%) rename tests/functional/{modes => run_modes}/03-simulation/flow.cylc (100%) rename tests/functional/{modes => run_modes}/03-simulation/reference.log (100%) rename tests/functional/{modes => run_modes}/04-simulation-runtime.t (100%) rename tests/functional/{modes => run_modes}/04-simulation-runtime/flow.cylc (100%) rename tests/functional/{modes => run_modes}/04-simulation-runtime/reference.log (100%) rename tests/functional/{modes => run_modes}/05-sim-trigger.t (100%) rename tests/functional/{modes => run_modes}/05-sim-trigger/flow.cylc (100%) rename tests/functional/{modes => run_modes}/05-sim-trigger/reference.log (100%) create mode 100644 tests/functional/run_modes/06-run-mode-overrides.t create mode 100644 tests/functional/run_modes/06-run-mode-overrides/flow.cylc rename tests/functional/{modes => run_modes}/test_header (100%) create mode 100644 tests/integration/run_modes/test_mode_overrides.py rename tests/integration/{ => run_modes}/test_simulation.py (90%) create mode 100644 tests/unit/run_modes/test_dummy.py create mode 100644 tests/unit/run_modes/test_nonlive.py rename tests/unit/{ => run_modes}/test_simulation.py (86%) create mode 100644 tests/unit/run_modes/test_skip.py diff --git a/changes.d/6039.feat.md b/changes.d/6039.feat.md new file mode 100644 index 00000000000..6b951fd7076 --- /dev/null +++ b/changes.d/6039.feat.md @@ -0,0 +1 @@ +Allow setting of run mode on a task by task basis. Add a new mode "skip". \ No newline at end of file diff --git a/cylc/flow/cfgspec/workflow.py b/cylc/flow/cfgspec/workflow.py index d22f0f415bb..9dbc67bcb8b 100644 --- a/cylc/flow/cfgspec/workflow.py +++ b/cylc/flow/cfgspec/workflow.py @@ -1175,6 +1175,22 @@ def get_script_common_text(this: str, example: Optional[str] = None): "[platforms][]submission retry delays" ) ) + Conf( + 'run mode', VDR.V_STRING, + options=['workflow', 'simulation', 'dummy', 'live', 'skip'], + default='workflow', + desc=''' + Override the workflow's run mode. + + By default workflows run in "live mode" - tasks run + in the way defined by the runtime config. + This setting allows individual tasks to be run using + a different run mode. + + .. TODO: Reference updated documention. + + .. versionadded:: 8.4.0 + ''') with Conf('meta', desc=r''' Metadata for the task or task family. @@ -1247,9 +1263,44 @@ def get_script_common_text(this: str, example: Optional[str] = None): determine how an event handler responds to task failure events. ''') + with Conf('skip', desc=''' + Task configuration for task :ref:`SkipMode`. + + For a full description of skip run mode see + :ref:`SkipMode`. + + .. versionadded:: 8.4.0 + '''): + Conf( + 'outputs', + VDR.V_STRING_LIST, + desc=''' + Outputs to be emitted by a task in skip mode. + + By default started, submitted, succeeded and all + required outputs will be emitted. + + If outputs are specified, but neither succeeded or + failed are specified, succeeded will automatically be + emitted. + .. versionadded:: 8.4.0 + ''' + ) + Conf( + 'disable task event handlers', + VDR.V_BOOLEAN, + default=True, + desc=''' + Task event handlers are turned off by default for + skip mode tasks. Changing this setting to ``False`` + will re-enable task event handlers. + + .. versionadded:: 8.4.0 + ''' + ) with Conf('simulation', desc=''' - Task configuration for workflow *simulation* and *dummy* run + Task configuration for *simulation* and *dummy* run modes. For a full description of simulation and dummy run modes see diff --git a/cylc/flow/config.py b/cylc/flow/config.py index 4739dd0ffc9..aa502685b35 100644 --- a/cylc/flow/config.py +++ b/cylc/flow/config.py @@ -81,7 +81,7 @@ is_relative_to, ) from cylc.flow.print_tree import print_tree -from cylc.flow.simulation import configure_sim_modes +from cylc.flow.run_modes.nonlive import mode_validate_checks from cylc.flow.subprocctx import SubFuncContext from cylc.flow.task_events_mgr import ( EventData, @@ -107,7 +107,7 @@ WorkflowFiles, check_deprecation, ) -from cylc.flow.workflow_status import RunMode +from cylc.flow.task_state import RunMode from cylc.flow.xtrigger_mgr import XtriggerManager if TYPE_CHECKING: @@ -499,10 +499,6 @@ def __init__( self.process_runahead_limit() - run_mode = self.run_mode() - if run_mode in {RunMode.SIMULATION, RunMode.DUMMY}: - configure_sim_modes(self.taskdefs.values(), run_mode) - self.configure_workflow_state_polling_tasks() self._check_task_event_handlers() @@ -553,6 +549,8 @@ def __init__( self.mem_log("config.py: end init config") + mode_validate_checks(self.taskdefs) + @staticmethod def _warn_if_queues_have_implicit_tasks( config, taskdefs, max_warning_lines diff --git a/cylc/flow/run_modes/dummy.py b/cylc/flow/run_modes/dummy.py new file mode 100644 index 00000000000..56d99b2c626 --- /dev/null +++ b/cylc/flow/run_modes/dummy.py @@ -0,0 +1,121 @@ +# 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 . +"""Utilities supporting dummy mode. +""" + +from logging import INFO +from typing import TYPE_CHECKING, Any, Dict, Tuple + +from cylc.flow.task_outputs import TASK_OUTPUT_SUBMITTED +from cylc.flow.run_modes.simulation import ( + ModeSettings, + disable_platforms, + get_simulated_run_len, + parse_fail_cycle_points +) +from cylc.flow.task_state import RunMode +from cylc.flow.platforms import get_platform + + +if TYPE_CHECKING: + from cylc.flow.task_job_mgr import TaskJobManager + from cylc.flow.task_proxy import TaskProxy + from typing_extensions import Literal + + +def submit_task_job( + task_job_mgr: 'TaskJobManager', + itask: 'TaskProxy', + rtconfig: Dict[str, Any], + workflow: str, + now: Tuple[float, str] +) -> 'Literal[False]': + """Submit a task in dummy mode. + + Returns: + False - indicating that TaskJobManager needs to continue running the + live mode path. + """ + configure_dummy_mode( + rtconfig, itask.tdef.rtconfig['simulation']['fail cycle points']) + + itask.summary['started_time'] = now[0] + task_job_mgr._set_retry_timers(itask, rtconfig) + itask.mode_settings = ModeSettings( + itask, + task_job_mgr.workflow_db_mgr, + rtconfig + ) + + itask.waiting_on_job_prep = False + itask.submit_num += 1 + + itask.platform = get_platform() + itask.platform['name'] = RunMode.DUMMY + itask.summary['job_runner_name'] = RunMode.DUMMY + itask.summary[task_job_mgr.KEY_EXECUTE_TIME_LIMIT] = ( + itask.mode_settings.simulated_run_length) + itask.jobs.append( + task_job_mgr.get_simulation_job_conf(itask, workflow)) + task_job_mgr.task_events_mgr.process_message( + itask, INFO, TASK_OUTPUT_SUBMITTED) + task_job_mgr.workflow_db_mgr.put_insert_task_jobs( + itask, { + 'time_submit': now[1], + 'try_num': itask.get_try_num(), + } + ) + return False + + +def configure_dummy_mode(rtc, fallback): + """Adjust task defs for simulation and dummy mode. + + """ + rtc['submission retry delays'] = [1] + # Generate dummy scripting. + rtc['init-script'] = "" + rtc['env-script'] = "" + rtc['pre-script'] = "" + rtc['post-script'] = "" + rtc['script'] = build_dummy_script( + rtc, get_simulated_run_len(rtc)) + disable_platforms(rtc) + # Disable environment, in case it depends on env-script. + rtc['environment'] = {} + rtc["simulation"][ + "fail cycle points" + ] = parse_fail_cycle_points( + rtc["simulation"]["fail cycle points"], fallback + ) + + +def build_dummy_script(rtc: Dict[str, Any], sleep_sec: int) -> str: + """Create fake scripting for dummy mode. + + This is for Dummy mode only. + """ + script = "sleep %d" % sleep_sec + # Dummy message outputs. + for msg in rtc['outputs'].values(): + script += "\ncylc message '%s'" % msg + if rtc['simulation']['fail try 1 only']: + arg1 = "true" + else: + arg1 = "false" + arg2 = " ".join(rtc['simulation']['fail cycle points']) + script += "\ncylc__job__dummy_result %s %s || exit 1" % (arg1, arg2) + return script diff --git a/cylc/flow/run_modes/nonlive.py b/cylc/flow/run_modes/nonlive.py new file mode 100644 index 00000000000..58217bbeb38 --- /dev/null +++ b/cylc/flow/run_modes/nonlive.py @@ -0,0 +1,59 @@ +# 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 . +"""Utilities supporting all nonlive modes +""" +from typing import TYPE_CHECKING, Dict, List + +from cylc.flow import LOG +from cylc.flow.run_modes.skip import check_task_skip_config +from cylc.flow.task_state import RunMode + +if TYPE_CHECKING: + from cylc.flow.taskdefs import TaskDefs + + +def mode_validate_checks(taskdefs: 'Dict[str, TaskDefs]'): + """Warn user if any tasks has "run mode" set to a non-live value. + + Additionally, run specific checks for each mode's config settings. + """ + warn_nonlive: Dict[str, List[str]] = { + RunMode.SKIP: [], + RunMode.SIMULATION: [], + RunMode.DUMMY: [], + } + + # Run through taskdefs looking for those with nonlive modes + for taskname, taskdef in taskdefs.items(): + # Add to list of tasks to be run in non-live modes: + if ( + taskdef.rtconfig.get('run mode', 'workflow') + not in [RunMode.LIVE, 'workflow'] + ): + warn_nonlive[taskdef.rtconfig['run mode']].append(taskname) + + # Run any mode specific validation checks: + check_task_skip_config(taskname, taskdef) + + # Assemble warning message about any tasks in nonlive mode. + if any(warn_nonlive.values()): + message = 'The following tasks are set to run in non-live mode:' + for mode, tasknames in warn_nonlive.items(): + if tasknames: + message += f'\n{mode} mode:' + for taskname in tasknames: + message += f'\n * {taskname}' + LOG.warning(message) diff --git a/cylc/flow/simulation.py b/cylc/flow/run_modes/simulation.py similarity index 66% rename from cylc/flow/simulation.py rename to cylc/flow/run_modes/simulation.py index 8ec4d279cb9..7b1594b5df3 100644 --- a/cylc/flow/simulation.py +++ b/cylc/flow/run_modes/simulation.py @@ -13,33 +13,85 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Utilities supporting simulation and skip modes +"""Utilities supporting simulation mode """ from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from logging import INFO +from typing import ( + TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union) from time import time from metomi.isodatetime.parsers import DurationParser from cylc.flow import LOG +from cylc.flow.cycling import PointBase from cylc.flow.cycling.loader import get_point from cylc.flow.exceptions import PointParsingError -from cylc.flow.platforms import FORBIDDEN_WITH_PLATFORM +from cylc.flow.platforms import FORBIDDEN_WITH_PLATFORM, get_platform +from cylc.flow.task_outputs import TASK_OUTPUT_SUBMITTED from cylc.flow.task_state import ( TASK_STATUS_RUNNING, TASK_STATUS_FAILED, TASK_STATUS_SUCCEEDED, ) from cylc.flow.wallclock import get_unix_time_from_time_string -from cylc.flow.workflow_status import RunMode +from cylc.flow.task_state import RunMode if TYPE_CHECKING: from cylc.flow.task_events_mgr import TaskEventsManager + from cylc.flow.task_job_mgr import TaskJobManager from cylc.flow.task_proxy import TaskProxy from cylc.flow.workflow_db_mgr import WorkflowDatabaseManager - from cylc.flow.cycling import PointBase + from typing_extensions import Literal + + +def submit_task_job( + task_job_mgr: 'TaskJobManager', + itask: 'TaskProxy', + rtconfig: Dict[str, Any], + workflow: str, + now: Tuple[float, str] +) -> 'Literal[True]': + """Submit a task in simulation mode. + + Returns: + True - indicating that TaskJobManager need take no further action. + """ + configure_sim_mode( + rtconfig, + itask.tdef.rtconfig['simulation']['fail cycle points']) + itask.summary['started_time'] = now[0] + task_job_mgr._set_retry_timers(itask, rtconfig) + itask.mode_settings = ModeSettings( + itask, + task_job_mgr.workflow_db_mgr, + rtconfig + ) + itask.waiting_on_job_prep = False + itask.submit_num += 1 + + itask.platform = get_platform() + itask.platform['name'] = 'SIMULATION' + itask.summary['job_runner_name'] = 'SIMULATION' + itask.summary[task_job_mgr.KEY_EXECUTE_TIME_LIMIT] = ( + itask.mode_settings.simulated_run_length + ) + itask.jobs.append( + task_job_mgr.get_simulation_job_conf(itask, workflow) + ) + task_job_mgr.task_events_mgr.process_message( + itask, INFO, TASK_OUTPUT_SUBMITTED, + ) + task_job_mgr.workflow_db_mgr.put_insert_task_jobs( + itask, { + 'time_submit': now[1], + 'try_num': itask.get_try_num(), + } + ) + itask.state.status = TASK_STATUS_RUNNING + return True @dataclass @@ -79,7 +131,6 @@ def __init__( db_mgr: 'WorkflowDatabaseManager', rtconfig: Dict[str, Any] ): - # itask.summary['started_time'] and mode_settings.timeout need # repopulating from the DB on workflow restart: started_time = itask.summary['started_time'] @@ -104,22 +155,15 @@ def __init__( try_num = db_info["try_num"] # Parse fail cycle points: - if rtconfig != itask.tdef.rtconfig: - try: - rtconfig["simulation"][ - "fail cycle points" - ] = parse_fail_cycle_points( - rtconfig["simulation"]["fail cycle points"] - ) - except PointParsingError as exc: - # Broadcast Fail CP didn't parse - LOG.warning( - 'Broadcast fail cycle point was invalid:\n' - f' {exc.args[0]}' - ) - rtconfig['simulation'][ - 'fail cycle points' - ] = itask.tdef.rtconfig['simulation']['fail cycle points'] + if not rtconfig: + rtconfig = itask.tdef.rtconfig + if rtconfig and rtconfig != itask.tdef.rtconfig: + rtconfig["simulation"][ + "fail cycle points" + ] = parse_fail_cycle_points( + rtconfig["simulation"]["fail cycle points"], + itask.tdef.rtconfig['simulation']['fail cycle points'] + ) # Calculate simulation info: self.simulated_run_length = ( @@ -132,37 +176,39 @@ def __init__( self.timeout = started_time + self.simulated_run_length -def configure_sim_modes(taskdefs, sim_mode): +def configure_sim_mode(rtc, fallback): """Adjust task defs for simulation and dummy mode. + Example: + >>> this = configure_sim_mode + >>> rtc = { + ... 'submission retry delays': [42, 24, 23], + ... 'environment': {'DoNot': '"WantThis"'}, + ... 'simulation': {'fail cycle points': ['all']} + ... } + >>> this(rtc, [53]) + >>> rtc['submission retry delays'] + [1] + >>> rtc['environment'] + {} + >>> rtc['simulation'] + {'fail cycle points': None} + >>> rtc['platform'] + 'localhost' """ - dummy_mode = (sim_mode == RunMode.DUMMY) - - for tdef in taskdefs: - # Compute simulated run time by scaling the execution limit. - rtc = tdef.rtconfig - - rtc['submission retry delays'] = [1] + rtc['submission retry delays'] = [1] - if dummy_mode: - # Generate dummy scripting. - rtc['init-script'] = "" - rtc['env-script'] = "" - rtc['pre-script'] = "" - rtc['post-script'] = "" - rtc['script'] = build_dummy_script( - rtc, get_simulated_run_len(rtc)) + disable_platforms(rtc) - disable_platforms(rtc) + # Disable environment, in case it depends on env-script. + rtc['environment'] = {} - # Disable environment, in case it depends on env-script. - rtc['environment'] = {} - - rtc["simulation"][ - "fail cycle points" - ] = parse_fail_cycle_points( - rtc["simulation"]["fail cycle points"] - ) + rtc["simulation"][ + "fail cycle points" + ] = parse_fail_cycle_points( + rtc["simulation"]["fail cycle points"], + fallback + ) def get_simulated_run_len(rtc: Dict[str, Any]) -> int: @@ -184,24 +230,6 @@ def get_simulated_run_len(rtc: Dict[str, Any]) -> int: return sleep_sec -def build_dummy_script(rtc: Dict[str, Any], sleep_sec: int) -> str: - """Create fake scripting for dummy mode. - - This is for Dummy mode only. - """ - script = "sleep %d" % sleep_sec - # Dummy message outputs. - for msg in rtc['outputs'].values(): - script += "\ncylc message '%s'" % msg - if rtc['simulation']['fail try 1 only']: - arg1 = "true" - else: - arg1 = "false" - arg2 = " ".join(rtc['simulation']['fail cycle points']) - script += "\ncylc__job__dummy_result %s %s || exit 1" % (arg1, arg2) - return script - - def disable_platforms( rtc: Dict[str, Any] ) -> None: @@ -222,7 +250,7 @@ def disable_platforms( def parse_fail_cycle_points( - f_pts_orig: List[str] + f_pts_orig: List[str], fallback ) -> 'Union[None, List[PointBase]]': """Parse `[simulation][fail cycle points]`. @@ -231,11 +259,11 @@ def parse_fail_cycle_points( Examples: >>> this = parse_fail_cycle_points - >>> this(['all']) is None + >>> this(['all'], ['42']) is None True - >>> this([]) + >>> this([], ['42']) [] - >>> this(None) is None + >>> this(None, ['42']) is None True """ f_pts: 'Optional[List[PointBase]]' = [] @@ -247,7 +275,16 @@ def parse_fail_cycle_points( elif f_pts_orig: f_pts = [] for point_str in f_pts_orig: - f_pts.append(get_point(point_str).standardise()) + if isinstance(point_str, PointBase): + f_pts.append(point_str) + else: + try: + f_pts.append(get_point(point_str).standardise()) + except PointParsingError: + LOG.warning( + f'Invalid ISO 8601 date representation: {point_str}' + ) + return fallback return f_pts @@ -266,13 +303,19 @@ def sim_time_check( now = time() sim_task_state_changed: bool = False for itask in itasks: - if itask.state.status != TASK_STATUS_RUNNING: + if ( + itask.state.status != TASK_STATUS_RUNNING + or itask.tdef.run_mode != RunMode.SIMULATION + ): continue # This occurs if the workflow has been restarted. if itask.mode_settings is None: rtconfig = task_events_manager.broadcast_mgr.get_updated_rtconfig( itask) + rtconfig = configure_sim_mode( + rtconfig, + itask.tdef.rtconfig['simulation']['fail cycle points']) itask.mode_settings = ModeSettings( itask, db_mgr, diff --git a/cylc/flow/run_modes/skip.py b/cylc/flow/run_modes/skip.py new file mode 100644 index 00000000000..b336e05e578 --- /dev/null +++ b/cylc/flow/run_modes/skip.py @@ -0,0 +1,172 @@ +# 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 . +"""Utilities supporting skip modes +""" +from ansimarkup import parse as cparse +from logging import INFO +from typing import ( + TYPE_CHECKING, Any, Dict, List, Set, Tuple) + +from cylc.flow import LOG +from cylc.flow.exceptions import WorkflowConfigError +from cylc.flow.platforms import get_platform +from cylc.flow.task_outputs import ( + TASK_OUTPUT_SUBMITTED, + TASK_OUTPUT_SUCCEEDED, + TASK_OUTPUT_FAILED, + TASK_OUTPUT_STARTED +) +from cylc.flow.task_state import RunMode + +if TYPE_CHECKING: + from cylc.flow.taskdef import TaskDef + from cylc.flow.task_job_mgr import TaskJobManager + from cylc.flow.task_proxy import TaskProxy + from typing_extensions import Literal + + +def submit_task_job( + task_job_mgr: 'TaskJobManager', + itask: 'TaskProxy', + rtconfig: Dict[str, Any], + workflow: str, + now: Tuple[float, str] +) -> 'Literal[True]': + """Submit a task in skip mode. + + Returns: + True - indicating that TaskJobManager need take no further action. + """ + itask.summary['started_time'] = now[0] + # TODO - do we need this? I don't thing so? + task_job_mgr._set_retry_timers(itask, rtconfig) + itask.waiting_on_job_prep = False + itask.submit_num += 1 + + itask.platform = get_platform() + itask.platform['name'] = RunMode.SKIP + itask.summary['job_runner_name'] = RunMode.SKIP + itask.tdef.run_mode = RunMode.SKIP + task_job_mgr.task_events_mgr.process_message( + itask, INFO, TASK_OUTPUT_SUBMITTED, + ) + task_job_mgr.workflow_db_mgr.put_insert_task_jobs( + itask, { + 'time_submit': now[1], + 'try_num': itask.get_try_num(), + } + ) + for output in process_outputs(itask): + task_job_mgr.task_events_mgr.process_message(itask, INFO, output) + + return True + + +def process_outputs(itask: 'TaskProxy') -> List[str]: + """Process Skip Mode Outputs: + + * By default, all required outputs will be generated plus succeeded + if success is optional. + * The outputs submitted and started are always produced and do not + need to be defined in outputs. + * If outputs is specified and does not include either + succeeded or failed then succeeded will be produced. + + Return: + A list of outputs to emit. + """ + result: List = [] + conf_outputs = itask.tdef.rtconfig['skip']['outputs'] + + # Remove started or submitted from our list of outputs: + for out in get_unecessary_outputs(conf_outputs): + conf_outputs.remove(out) + + # Submitted will always be produced: + # (Task events manager handles started automatically for ghost modes) + result.append(TASK_OUTPUT_SUBMITTED) + + # Send the rest of our outputs, unless they are succeed or failed, + # which we hold back, to prevent warnings about pre-requisites being + # unmet being shown because a "finished" output happens to come first. + for output, message in itask.state.outputs._required.items(): + # Send message unless it be succeeded/failed. + if output in [TASK_OUTPUT_SUCCEEDED, TASK_OUTPUT_FAILED]: + continue + if not conf_outputs or output in conf_outputs: + result.append(message) + + # Send succeeded/failed last. + if TASK_OUTPUT_FAILED in conf_outputs: + result.append(TASK_OUTPUT_FAILED) + else: + result.append(TASK_OUTPUT_SUCCEEDED) + + return result + + +def check_task_skip_config(name: str, tdef: 'TaskDef') -> None: + """Ensure that skip mode configurations are sane at validation time: + + Args: + name: of task + tdef: of task + + Logs: + * Warn that outputs need not include started and submitted as these + are always emitted. + + Raises: + * Error if outputs include succeeded and failed. + """ + skip_config = tdef.rtconfig.get('skip', {}) + if not skip_config: + return + skip_outputs = skip_config.get('outputs', {}) + if not skip_outputs: + return + + # Error if outputs include succeded and failed: + if ( + TASK_OUTPUT_SUCCEEDED in skip_outputs + and TASK_OUTPUT_FAILED in skip_outputs + ): + raise WorkflowConfigError( + cparse( + f'Skip mode settings for task {name} has' + ' mutually exclusive outputs: succeeded AND failed.')) + + # Warn if started or submitted set: + unecessary_outs = get_unecessary_outputs(skip_outputs) + if unecessary_outs: + LOG.warning( + f'Task {name} has output(s) {unecessary_outs} which will' + ' always be run in skip mode and need not be set.') + + +def get_unecessary_outputs(skip_outputs: List[str]) -> Set[str]: + """Get a list of outputs which we will always run, and don't need + setting config. + + Examples: + >>> this = get_unecessary_outputs + >>> this(['foo', 'started', 'succeeded']) + {'started'} + """ + return { + o for o in skip_outputs + if o in {TASK_OUTPUT_SUBMITTED, TASK_OUTPUT_STARTED} + } diff --git a/cylc/flow/scheduler.py b/cylc/flow/scheduler.py index 7e082f08f2b..5bde0e9676a 100644 --- a/cylc/flow/scheduler.py +++ b/cylc/flow/scheduler.py @@ -113,12 +113,12 @@ ) from cylc.flow.profiler import Profiler from cylc.flow.resources import get_resources -from cylc.flow.simulation import sim_time_check +from cylc.flow.run_modes.simulation import sim_time_check from cylc.flow.subprocpool import SubProcPool from cylc.flow.templatevars import eval_var from cylc.flow.workflow_db_mgr import WorkflowDatabaseManager from cylc.flow.workflow_events import WorkflowEventHandler -from cylc.flow.workflow_status import RunMode, StopMode, AutoRestartMode +from cylc.flow.workflow_status import StopMode, AutoRestartMode from cylc.flow import workflow_files from cylc.flow.taskdef import TaskDef from cylc.flow.task_events_mgr import TaskEventsManager @@ -140,7 +140,8 @@ TASK_STATUS_SUBMITTED, TASK_STATUS_RUNNING, TASK_STATUS_WAITING, - TASK_STATUS_FAILED) + TASK_STATUS_FAILED, + RunMode) from cylc.flow.templatevars import get_template_vars from cylc.flow.util import cli_format from cylc.flow.wallclock import ( @@ -1499,7 +1500,7 @@ def release_queued_tasks(self) -> bool: pre_prep_tasks, self.server.curve_auth, self.server.client_pub_key_dir, - is_simulation=(self.get_run_mode() == RunMode.SIMULATION) + run_mode=self.config.run_mode() ): if itask.flow_nums: flow = ','.join(str(i) for i in itask.flow_nums) @@ -1745,19 +1746,15 @@ async def _main_loop(self) -> None: if self.xtrigger_mgr.do_housekeeping: self.xtrigger_mgr.housekeep(self.pool.get_tasks()) - self.pool.clock_expire_tasks() - self.release_queued_tasks() - - if ( - self.get_run_mode() == RunMode.SIMULATION - and sim_time_check( - self.task_events_mgr, - self.pool.get_tasks(), - self.workflow_db_mgr, - ) + if sim_time_check( + self.task_events_mgr, + self.pool.get_tasks(), + self.workflow_db_mgr, ): # A simulated task state change occurred. self.reset_inactivity_timer() + self.pool.clock_expire_tasks() + self.release_queued_tasks() self.broadcast_mgr.expire_broadcast(self.pool.get_min_point()) self.late_tasks_check() diff --git a/cylc/flow/scheduler_cli.py b/cylc/flow/scheduler_cli.py index d25be809325..5ed69284d1a 100644 --- a/cylc/flow/scheduler_cli.py +++ b/cylc/flow/scheduler_cli.py @@ -54,6 +54,7 @@ from cylc.flow.remote import cylc_server_cmd from cylc.flow.scheduler import Scheduler, SchedulerError from cylc.flow.scripts.common import cylc_header +from cylc.flow.task_state import RunMode from cylc.flow.workflow_db_mgr import WorkflowDatabaseManager from cylc.flow.workflow_files import ( SUITERC_DEPR_MSG, @@ -65,7 +66,6 @@ is_terminal, prompt, ) -from cylc.flow.workflow_status import RunMode if TYPE_CHECKING: from optparse import Values @@ -129,9 +129,15 @@ RUN_MODE = OptionSettings( ["-m", "--mode"], - help="Run mode: live, dummy, simulation (default live).", + help=( + f"Run mode: {RunMode.WORKFLOW_MODES} (default live)." + " Live mode executes the tasks as defined in the runtime section." + " Simulation, Skip and Dummy partially or wholly ignore" + " the task defined in runtime configuration. Simulation and" + " dummy are designed for testing and Skip for flow control." + ), metavar="STRING", action='store', dest="run_mode", - choices=[RunMode.LIVE, RunMode.DUMMY, RunMode.SIMULATION], + choices=list(RunMode.WORKFLOW_MODES), ) PLAY_RUN_MODE = deepcopy(RUN_MODE) diff --git a/cylc/flow/scripts/lint.py b/cylc/flow/scripts/lint.py index d045ab70838..6b3ed660f99 100755 --- a/cylc/flow/scripts/lint.py +++ b/cylc/flow/scripts/lint.py @@ -86,9 +86,15 @@ from cylc.flow.cfgspec.workflow import upg, SPEC from cylc.flow.id_cli import parse_id from cylc.flow.parsec.config import ParsecConfig +from cylc.flow.run_modes.skip import get_unecessary_outputs from cylc.flow.scripts.cylc import DEAD_ENDS +from cylc.flow.task_outputs import ( + TASK_OUTPUT_SUCCEEDED, + TASK_OUTPUT_FAILED, +) from cylc.flow.terminal import cli_function + if TYPE_CHECKING: # BACK COMPAT: typing_extensions.Literal # FROM: Python 3.7 @@ -319,6 +325,39 @@ def check_for_deprecated_task_event_template_vars( return None +BAD_SKIP_OUTS = re.compile(r'outputs\s*=\s*(.*)') + + +def check_skip_mode_outputs(line: str) -> Dict: + """Ensure skip mode output setting doesn't include: + + * succeeded _and_ failed: Mutually exclusive. + * submitted and started: These are emitted by skip mode anyway. + + n.b. + + This should be separable from ``[[outputs]]`` because it's a key + value pair not a section heading. + """ + + outputs = BAD_SKIP_OUTS.findall(line) + if outputs: + outputs = [i.strip() for i in outputs[0].split(',')] + if TASK_OUTPUT_FAILED in outputs and TASK_OUTPUT_SUCCEEDED in outputs: + return { + 'description': + 'are mutually exclusive and cannot be used together', + 'outputs': f'{TASK_OUTPUT_FAILED} and {TASK_OUTPUT_SUCCEEDED}' + } + pointless_outputs = get_unecessary_outputs(outputs) + if pointless_outputs: + return { + 'description': 'are not required, they will be emitted anyway', + 'outputs': f'{pointless_outputs}' + } + return {} + + INDENTATION = re.compile(r'^(\s*)(.*)') @@ -533,6 +572,14 @@ def list_wrapper(line: str, check: Callable) -> Optional[Dict[str, str]]: 'S013': { 'short': 'Items should be indented in 4 space blocks.', FUNCTION: check_indentation + }, + 'S014': { + 'short': 'Run mode is not live: This task will only appear to run.', + FUNCTION: re.compile(r'run mode\s*=\s*[^l][^i][^v][^e]$').findall + }, + 'S015': { + 'short': 'Task outputs {outputs}: {description}.', + FUNCTION: check_skip_mode_outputs } } # Subset of deprecations which are tricky (impossible?) to scrape from the diff --git a/cylc/flow/scripts/set.py b/cylc/flow/scripts/set.py index 4f0cd19522f..73dea491249 100755 --- a/cylc/flow/scripts/set.py +++ b/cylc/flow/scripts/set.py @@ -326,9 +326,12 @@ def get_output_opts(output_options: List[str]): # If "required" is explicit just ditch it (same as the default) if not outputs or outputs == ["required"]: return [] + elif outputs == ['skip']: + return ['skip'] - if "required" in outputs: - raise InputError("--out=required must be used alone") + for val in ["required", 'skip']: + if val in outputs: + raise InputError(f"--out={val} must be used alone") if "waiting" in outputs: raise InputError( "Tasks cannot be set to waiting, use a new flow to re-run" diff --git a/cylc/flow/scripts/validate.py b/cylc/flow/scripts/validate.py index a224302c1b0..1be36cb3df4 100755 --- a/cylc/flow/scripts/validate.py +++ b/cylc/flow/scripts/validate.py @@ -55,7 +55,7 @@ from cylc.flow.templatevars import get_template_vars from cylc.flow.terminal import cli_function from cylc.flow.scheduler_cli import RUN_MODE -from cylc.flow.workflow_status import RunMode +from cylc.flow.task_state import RunMode if TYPE_CHECKING: from cylc.flow.option_parsers import Values diff --git a/cylc/flow/task_events_mgr.py b/cylc/flow/task_events_mgr.py index 14f376dfb74..57409c037ef 100644 --- a/cylc/flow/task_events_mgr.py +++ b/cylc/flow/task_events_mgr.py @@ -78,7 +78,8 @@ TASK_STATUS_FAILED, TASK_STATUS_EXPIRED, TASK_STATUS_SUCCEEDED, - TASK_STATUS_WAITING + TASK_STATUS_WAITING, + RunMode, ) from cylc.flow.task_outputs import ( TASK_OUTPUT_EXPIRED, @@ -98,7 +99,6 @@ get_template_variables as get_workflow_template_variables, process_mail_footer, ) -from cylc.flow.workflow_status import RunMode if TYPE_CHECKING: @@ -768,7 +768,7 @@ def process_message( # ... but either way update the job ID in the job proxy (it only # comes in via the submission message). - if itask.tdef.run_mode != RunMode.SIMULATION: + if not RunMode.is_ghostly(itask.tdef.run_mode): job_tokens = itask.tokens.duplicate( job=str(itask.submit_num) ) @@ -886,7 +886,7 @@ def _process_message_check( if ( itask.state(TASK_STATUS_WAITING) - # Polling in live mode only: + # Polling in live mode only. and itask.tdef.run_mode == RunMode.LIVE and ( ( @@ -932,7 +932,7 @@ def _process_message_check( def setup_event_handlers(self, itask, event, message): """Set up handlers for a task event.""" - if itask.tdef.run_mode != RunMode.LIVE: + if RunMode.disable_task_event_handlers(itask): return msg = "" if message != f"job {event}": @@ -1457,7 +1457,7 @@ def _process_message_submitted( ) itask.set_summary_time('submitted', event_time) - if itask.tdef.run_mode == RunMode.SIMULATION: + if RunMode.is_ghostly(itask.tdef.run_mode): # Simulate job started as well. itask.set_summary_time('started', event_time) if itask.state_reset(TASK_STATUS_RUNNING, forced=forced): @@ -1494,7 +1494,7 @@ def _process_message_submitted( 'submitted', event_time, ) - if itask.tdef.run_mode == RunMode.SIMULATION: + if RunMode.is_ghostly(itask.tdef.run_mode): # Simulate job started as well. self.data_store_mgr.delta_job_time( job_tokens, @@ -1527,7 +1527,7 @@ def _insert_task_job( # not see previous submissions (so can't use itask.jobs[submit_num-1]). # And transient tasks, used for setting outputs and spawning children, # do not submit jobs. - if (itask.tdef.run_mode == RunMode.SIMULATION) or forced: + if RunMode.is_ghostly(itask.tdef.run_mode) or forced: job_conf = {"submit_num": itask.submit_num} else: job_conf = itask.jobs[-1] diff --git a/cylc/flow/task_job_mgr.py b/cylc/flow/task_job_mgr.py index 69b8a22bb97..de1a29336c7 100644 --- a/cylc/flow/task_job_mgr.py +++ b/cylc/flow/task_job_mgr.py @@ -35,7 +35,7 @@ ) from shutil import rmtree from time import time -from typing import TYPE_CHECKING, Any, Union, Optional +from typing import TYPE_CHECKING, Any, List, Tuple, Union, Optional from cylc.flow import LOG from cylc.flow.job_runner_mgr import JobPollContext @@ -63,7 +63,12 @@ get_platform, ) from cylc.flow.remote import construct_ssh_cmd -from cylc.flow.simulation import ModeSettings +from cylc.flow.run_modes.simulation import ( + submit_task_job as simulation_submit_task_job) +from cylc.flow.run_modes.skip import ( + submit_task_job as skip_submit_task_job) +from cylc.flow.run_modes.dummy import ( + submit_task_job as dummy_submit_task_job) from cylc.flow.subprocctx import SubProcContext from cylc.flow.subprocpool import SubProcPool from cylc.flow.task_action_timer import ( @@ -103,7 +108,8 @@ TASK_STATUS_SUBMITTED, TASK_STATUS_RUNNING, TASK_STATUS_WAITING, - TASK_STATUSES_ACTIVE + TASK_STATUSES_ACTIVE, + RunMode ) from cylc.flow.wallclock import ( get_current_time_string, @@ -247,7 +253,7 @@ def prep_submit_task_jobs(self, workflow, itasks, check_syntax=True): return [prepared_tasks, bad_tasks] def submit_task_jobs(self, workflow, itasks, curve_auth, - client_pub_key_dir, is_simulation=False): + client_pub_key_dir, run_mode='live'): """Prepare for job submission and submit task jobs. Preparation (host selection, remote host init, and remote install) @@ -262,8 +268,8 @@ def submit_task_jobs(self, workflow, itasks, curve_auth, Return (list): list of tasks that attempted submission. """ - if is_simulation: - return self._simulation_submit_task_jobs(itasks, workflow) + itasks, ghost_tasks = self._nonlive_submit_task_jobs( + itasks, workflow, run_mode) # Prepare tasks for job submission prepared_tasks, bad_tasks = self.prep_submit_task_jobs( @@ -272,8 +278,10 @@ def submit_task_jobs(self, workflow, itasks, curve_auth, # Reset consumed host selection results self.task_remote_mgr.subshell_eval_reset() - if not prepared_tasks: + if not prepared_tasks and not ghost_tasks: return bad_tasks + elif not prepared_tasks: + return ghost_tasks auth_itasks = {} # {platform: [itask, ...], ...} for itask in prepared_tasks: @@ -281,7 +289,7 @@ def submit_task_jobs(self, workflow, itasks, curve_auth, auth_itasks.setdefault(platform_name, []) auth_itasks[platform_name].append(itask) # Submit task jobs for each platform - done_tasks = bad_tasks + done_tasks = bad_tasks + ghost_tasks for _, itasks in sorted(auth_itasks.items()): # Find the first platform where >1 host has not been tried and @@ -995,44 +1003,64 @@ def _set_retry_timers( except KeyError: itask.try_timers[key] = TaskActionTimer(delays=delays) - def _simulation_submit_task_jobs(self, itasks, workflow): - """Simulation mode task jobs submission.""" + def _nonlive_submit_task_jobs( + self: 'TaskJobManager', + itasks: 'List[TaskProxy]', + workflow: str, + workflow_run_mode: str, + ) -> 'Tuple[List[TaskProxy], List[TaskProxy]]': + """Simulation mode task jobs submission. + + Returns: + lively_tasks: + A list of tasks which require subsequent + processing **as if** they were live mode tasks. + (This includes live and dummy mode tasks) + ghostly_tasks: + A list of tasks which require no further processing + because their apparent execution is done entirely inside + the scheduler. (This includes skip and simulation mode tasks). + """ + lively_tasks: 'List[TaskProxy]' = [] + ghost_tasks: 'List[TaskProxy]' = [] now = time() - now_str = get_time_string_from_unix_time(now) + now = (now, get_time_string_from_unix_time(now)) + for itask in itasks: - # Handle broadcasts + # Handle broadcasts: rtconfig = self.task_events_mgr.broadcast_mgr.get_updated_rtconfig( itask) - itask.summary['started_time'] = now - self._set_retry_timers(itask, rtconfig) - itask.mode_settings = ModeSettings( - itask, - self.workflow_db_mgr, - rtconfig - ) - - itask.waiting_on_job_prep = False - itask.submit_num += 1 + # Apply task run mode - itask.platform = {'name': 'SIMULATION'} - itask.summary['job_runner_name'] = 'SIMULATION' - itask.summary[self.KEY_EXECUTE_TIME_LIMIT] = ( - itask.mode_settings.simulated_run_length - ) - itask.jobs.append( - self.get_simulation_job_conf(itask, workflow) - ) - self.task_events_mgr.process_message( - itask, INFO, TASK_OUTPUT_SUBMITTED, - ) - self.workflow_db_mgr.put_insert_task_jobs( - itask, { - 'time_submit': now_str, - 'try_num': itask.get_try_num(), - } - ) - return itasks + if rtconfig['run mode'] == 'workflow': + run_mode = workflow_run_mode + else: + if rtconfig['run mode'] != workflow_run_mode: + LOG.info( + f'[{itask.identity}] run mode set by task settings' + f' to: {rtconfig["run mode"]} mode.') + run_mode = rtconfig['run mode'] + itask.tdef.run_mode = run_mode + + # Submit ghost tasks, or add live-like tasks to list + # of tasks to put through live submission pipeline: + is_done = False + if run_mode == RunMode.DUMMY: + is_done = dummy_submit_task_job( + self, itask, rtconfig, workflow, now) + elif run_mode == RunMode.SIMULATION: + is_done = simulation_submit_task_job( + self, itask, rtconfig, workflow, now) + elif run_mode == RunMode.SKIP: + is_done = skip_submit_task_job( + self, itask, rtconfig, workflow, now) + # Assign task to list: + if is_done: + ghost_tasks.append(itask) + else: + lively_tasks.append(itask) + return lively_tasks, ghost_tasks def _submit_task_jobs_callback(self, ctx, workflow, itasks): """Callback when submit task jobs command exits.""" diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 2b214d70943..b3a9ed8e902 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -69,6 +69,8 @@ ) from cylc.flow.wallclock import get_current_time_string from cylc.flow.platforms import get_platform +from cylc.flow.run_modes.skip import ( + process_outputs as get_skip_mode_outputs) from cylc.flow.task_outputs import ( TASK_OUTPUT_SUCCEEDED, TASK_OUTPUT_EXPIRED, @@ -1887,9 +1889,10 @@ def _set_outputs_itask( outputs: List[str], ) -> None: """Set requested outputs on a task proxy and spawn children.""" - if not outputs: outputs = itask.tdef.get_required_output_messages() + elif outputs == ['skip']: + outputs = get_skip_mode_outputs(itask) else: outputs = self._standardise_outputs( itask.point, itask.tdef, outputs) diff --git a/cylc/flow/task_proxy.py b/cylc/flow/task_proxy.py index 898017c8da1..af9f6328835 100644 --- a/cylc/flow/task_proxy.py +++ b/cylc/flow/task_proxy.py @@ -62,7 +62,7 @@ if TYPE_CHECKING: from cylc.flow.cycling import PointBase - from cylc.flow.simulation import ModeSettings + from cylc.flow.run_modes.simulation import ModeSettings from cylc.flow.task_action_timer import TaskActionTimer from cylc.flow.taskdef import TaskDef from cylc.flow.id import Tokens diff --git a/cylc/flow/task_state.py b/cylc/flow/task_state.py index ebb3dbc985b..0fd09a6640f 100644 --- a/cylc/flow/task_state.py +++ b/cylc/flow/task_state.py @@ -23,6 +23,7 @@ from cylc.flow.wallclock import get_current_time_string if TYPE_CHECKING: + from cylc.flow.option_parsers import Values from cylc.flow.id import Tokens @@ -154,6 +155,77 @@ } +class RunMode: + """The possible run modes of a task/workflow.""" + + LIVE = 'live' + """Task will run normally.""" + + SIMULATION = 'simulation' + """Task will run in simulation mode.""" + + DUMMY = 'dummy' + """Task will run in dummy mode.""" + + SKIP = 'skip' + """Task will run in skip mode.""" + + WORKFLOW = 'workflow' + """Default to workflow run mode""" + + MODES = {LIVE, SIMULATION, DUMMY, SKIP, WORKFLOW} + + WORKFLOW_MODES = sorted(MODES - {WORKFLOW}) + """Workflow mode not sensible mode for workflow. + + n.b. converted to a list to ensure ordering doesn't change in + CLI + """ + + LIVELY_MODES = {LIVE, DUMMY} + """Modes which need to have real jobs submitted.""" + + GHOSTLY_MODES = {SKIP, SIMULATION} + """Modes which completely ignore the standard submission path.""" + + @staticmethod + def get(options: 'Values') -> str: + """Return the run mode from the options.""" + return getattr(options, 'run_mode', None) or RunMode.LIVE + + @staticmethod + def is_lively(mode: str) -> bool: + """Task should be treated as live, mode setting mess with scripts only. + """ + return bool(mode in RunMode.LIVELY_MODES) + + @staticmethod + def is_ghostly(mode: str) -> bool: + """Task has no reality outside the scheduler and needs no further + processing after run_mode.submit_task_job method finishes. + """ + return bool(mode in RunMode.GHOSTLY_MODES) + + @staticmethod + def disable_task_event_handlers(itask): + """Should we disable event handlers for this task? + + No event handlers in simulation mode, or in skip mode + if we don't deliberately enable them: + """ + mode = itask.tdef.run_mode + if ( + mode == RunMode.SIMULATION + or ( + mode == RunMode.SKIP + and itask.tdef.rtconfig['skip'][ + 'disable task event handlers'] is True + ) + ): + return True + return False + + def status_leq(status_a, status_b): """"Return True if status_a <= status_b""" return (TASK_STATUSES_ORDERED.index(status_a) <= diff --git a/cylc/flow/unicode_rules.py b/cylc/flow/unicode_rules.py index 4608559798d..47ca55c7399 100644 --- a/cylc/flow/unicode_rules.py +++ b/cylc/flow/unicode_rules.py @@ -23,7 +23,7 @@ _TASK_NAME_PREFIX, ) from cylc.flow.task_qualifiers import TASK_QUALIFIERS -from cylc.flow.task_state import TASK_STATUSES_ORDERED +from cylc.flow.task_state import TASK_STATUSES_ORDERED, RunMode ENGLISH_REGEX_MAP = { r'\w': 'alphanumeric', @@ -351,6 +351,8 @@ class TaskOutputValidator(UnicodeRuleChecker): not_starts_with('_cylc'), # blacklist keywords not_equals('required', 'optional', 'all'), + # blacklist Run Modes: + not_equals(*RunMode.MODES), # blacklist built-in task qualifiers and statuses (e.g. "waiting") not_equals(*sorted({*TASK_QUALIFIERS, *TASK_STATUSES_ORDERED})), ] diff --git a/cylc/flow/workflow_status.py b/cylc/flow/workflow_status.py index 02f42717ed3..95f25e39358 100644 --- a/cylc/flow/workflow_status.py +++ b/cylc/flow/workflow_status.py @@ -21,7 +21,6 @@ from cylc.flow.wallclock import get_time_string_from_unix_time as time2str if TYPE_CHECKING: - from optparse import Values from cylc.flow.scheduler import Scheduler # Keys for identify API call @@ -199,21 +198,3 @@ def get_workflow_status(schd: 'Scheduler') -> Tuple[str, str]: status_msg = 'running' return (status.value, status_msg) - - -class RunMode: - """The possible run modes of a workflow.""" - - LIVE = 'live' - """Workflow will run normally.""" - - SIMULATION = 'simulation' - """Workflow will run in simulation mode.""" - - DUMMY = 'dummy' - """Workflow will run in dummy mode.""" - - @staticmethod - def get(options: 'Values') -> str: - """Return the run mode from the options.""" - return getattr(options, 'run_mode', None) or RunMode.LIVE diff --git a/diff b/diff new file mode 100644 index 00000000000..d6411db2dd8 --- /dev/null +++ b/diff @@ -0,0 +1,2782 @@ +diff --git a/changes.d/6039.feat.md b/changes.d/6039.feat.md +new file mode 100644 +index 000000000..6b951fd70 +--- /dev/null ++++ b/changes.d/6039.feat.md +@@ -0,0 +1 @@ ++Allow setting of run mode on a task by task basis. Add a new mode "skip". +\ No newline at end of file +diff --git a/cylc/flow/cfgspec/workflow.py b/cylc/flow/cfgspec/workflow.py +index d22f0f415..9dbc67bcb 100644 +--- a/cylc/flow/cfgspec/workflow.py ++++ b/cylc/flow/cfgspec/workflow.py +@@ -1175,6 +1175,22 @@ with Conf( + "[platforms][]submission retry delays" + ) + ) ++ Conf( ++ 'run mode', VDR.V_STRING, ++ options=['workflow', 'simulation', 'dummy', 'live', 'skip'], ++ default='workflow', ++ desc=''' ++ Override the workflow's run mode. ++ ++ By default workflows run in "live mode" - tasks run ++ in the way defined by the runtime config. ++ This setting allows individual tasks to be run using ++ a different run mode. ++ ++ .. TODO: Reference updated documention. ++ ++ .. versionadded:: 8.4.0 ++ ''') + with Conf('meta', desc=r''' + Metadata for the task or task family. + +@@ -1247,9 +1263,44 @@ with Conf( + determine how an event handler responds to task failure + events. + ''') ++ with Conf('skip', desc=''' ++ Task configuration for task :ref:`SkipMode`. ++ ++ For a full description of skip run mode see ++ :ref:`SkipMode`. ++ ++ .. versionadded:: 8.4.0 ++ '''): ++ Conf( ++ 'outputs', ++ VDR.V_STRING_LIST, ++ desc=''' ++ Outputs to be emitted by a task in skip mode. ++ ++ By default started, submitted, succeeded and all ++ required outputs will be emitted. ++ ++ If outputs are specified, but neither succeeded or ++ failed are specified, succeeded will automatically be ++ emitted. + ++ .. versionadded:: 8.4.0 ++ ''' ++ ) ++ Conf( ++ 'disable task event handlers', ++ VDR.V_BOOLEAN, ++ default=True, ++ desc=''' ++ Task event handlers are turned off by default for ++ skip mode tasks. Changing this setting to ``False`` ++ will re-enable task event handlers. ++ ++ .. versionadded:: 8.4.0 ++ ''' ++ ) + with Conf('simulation', desc=''' +- Task configuration for workflow *simulation* and *dummy* run ++ Task configuration for *simulation* and *dummy* run + modes. + + For a full description of simulation and dummy run modes see +diff --git a/cylc/flow/config.py b/cylc/flow/config.py +index 4739dd0ff..aa502685b 100644 +--- a/cylc/flow/config.py ++++ b/cylc/flow/config.py +@@ -81,7 +81,7 @@ from cylc.flow.pathutil import ( + is_relative_to, + ) + from cylc.flow.print_tree import print_tree +-from cylc.flow.simulation import configure_sim_modes ++from cylc.flow.run_modes.nonlive import mode_validate_checks + from cylc.flow.subprocctx import SubFuncContext + from cylc.flow.task_events_mgr import ( + EventData, +@@ -107,7 +107,7 @@ from cylc.flow.workflow_files import ( + WorkflowFiles, + check_deprecation, + ) +-from cylc.flow.workflow_status import RunMode ++from cylc.flow.task_state import RunMode + from cylc.flow.xtrigger_mgr import XtriggerManager + + if TYPE_CHECKING: +@@ -499,10 +499,6 @@ class WorkflowConfig: + + self.process_runahead_limit() + +- run_mode = self.run_mode() +- if run_mode in {RunMode.SIMULATION, RunMode.DUMMY}: +- configure_sim_modes(self.taskdefs.values(), run_mode) +- + self.configure_workflow_state_polling_tasks() + + self._check_task_event_handlers() +@@ -553,6 +549,8 @@ class WorkflowConfig: + + self.mem_log("config.py: end init config") + ++ mode_validate_checks(self.taskdefs) ++ + @staticmethod + def _warn_if_queues_have_implicit_tasks( + config, taskdefs, max_warning_lines +diff --git a/cylc/flow/run_modes/dummy.py b/cylc/flow/run_modes/dummy.py +new file mode 100644 +index 000000000..56d99b2c6 +--- /dev/null ++++ b/cylc/flow/run_modes/dummy.py +@@ -0,0 +1,121 @@ ++# 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 . ++"""Utilities supporting dummy mode. ++""" ++ ++from logging import INFO ++from typing import TYPE_CHECKING, Any, Dict, Tuple ++ ++from cylc.flow.task_outputs import TASK_OUTPUT_SUBMITTED ++from cylc.flow.run_modes.simulation import ( ++ ModeSettings, ++ disable_platforms, ++ get_simulated_run_len, ++ parse_fail_cycle_points ++) ++from cylc.flow.task_state import RunMode ++from cylc.flow.platforms import get_platform ++ ++ ++if TYPE_CHECKING: ++ from cylc.flow.task_job_mgr import TaskJobManager ++ from cylc.flow.task_proxy import TaskProxy ++ from typing_extensions import Literal ++ ++ ++def submit_task_job( ++ task_job_mgr: 'TaskJobManager', ++ itask: 'TaskProxy', ++ rtconfig: Dict[str, Any], ++ workflow: str, ++ now: Tuple[float, str] ++) -> 'Literal[False]': ++ """Submit a task in dummy mode. ++ ++ Returns: ++ False - indicating that TaskJobManager needs to continue running the ++ live mode path. ++ """ ++ configure_dummy_mode( ++ rtconfig, itask.tdef.rtconfig['simulation']['fail cycle points']) ++ ++ itask.summary['started_time'] = now[0] ++ task_job_mgr._set_retry_timers(itask, rtconfig) ++ itask.mode_settings = ModeSettings( ++ itask, ++ task_job_mgr.workflow_db_mgr, ++ rtconfig ++ ) ++ ++ itask.waiting_on_job_prep = False ++ itask.submit_num += 1 ++ ++ itask.platform = get_platform() ++ itask.platform['name'] = RunMode.DUMMY ++ itask.summary['job_runner_name'] = RunMode.DUMMY ++ itask.summary[task_job_mgr.KEY_EXECUTE_TIME_LIMIT] = ( ++ itask.mode_settings.simulated_run_length) ++ itask.jobs.append( ++ task_job_mgr.get_simulation_job_conf(itask, workflow)) ++ task_job_mgr.task_events_mgr.process_message( ++ itask, INFO, TASK_OUTPUT_SUBMITTED) ++ task_job_mgr.workflow_db_mgr.put_insert_task_jobs( ++ itask, { ++ 'time_submit': now[1], ++ 'try_num': itask.get_try_num(), ++ } ++ ) ++ return False ++ ++ ++def configure_dummy_mode(rtc, fallback): ++ """Adjust task defs for simulation and dummy mode. ++ ++ """ ++ rtc['submission retry delays'] = [1] ++ # Generate dummy scripting. ++ rtc['init-script'] = "" ++ rtc['env-script'] = "" ++ rtc['pre-script'] = "" ++ rtc['post-script'] = "" ++ rtc['script'] = build_dummy_script( ++ rtc, get_simulated_run_len(rtc)) ++ disable_platforms(rtc) ++ # Disable environment, in case it depends on env-script. ++ rtc['environment'] = {} ++ rtc["simulation"][ ++ "fail cycle points" ++ ] = parse_fail_cycle_points( ++ rtc["simulation"]["fail cycle points"], fallback ++ ) ++ ++ ++def build_dummy_script(rtc: Dict[str, Any], sleep_sec: int) -> str: ++ """Create fake scripting for dummy mode. ++ ++ This is for Dummy mode only. ++ """ ++ script = "sleep %d" % sleep_sec ++ # Dummy message outputs. ++ for msg in rtc['outputs'].values(): ++ script += "\ncylc message '%s'" % msg ++ if rtc['simulation']['fail try 1 only']: ++ arg1 = "true" ++ else: ++ arg1 = "false" ++ arg2 = " ".join(rtc['simulation']['fail cycle points']) ++ script += "\ncylc__job__dummy_result %s %s || exit 1" % (arg1, arg2) ++ return script +diff --git a/cylc/flow/run_modes/nonlive.py b/cylc/flow/run_modes/nonlive.py +new file mode 100644 +index 000000000..58217bbeb +--- /dev/null ++++ b/cylc/flow/run_modes/nonlive.py +@@ -0,0 +1,59 @@ ++# 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 . ++"""Utilities supporting all nonlive modes ++""" ++from typing import TYPE_CHECKING, Dict, List ++ ++from cylc.flow import LOG ++from cylc.flow.run_modes.skip import check_task_skip_config ++from cylc.flow.task_state import RunMode ++ ++if TYPE_CHECKING: ++ from cylc.flow.taskdefs import TaskDefs ++ ++ ++def mode_validate_checks(taskdefs: 'Dict[str, TaskDefs]'): ++ """Warn user if any tasks has "run mode" set to a non-live value. ++ ++ Additionally, run specific checks for each mode's config settings. ++ """ ++ warn_nonlive: Dict[str, List[str]] = { ++ RunMode.SKIP: [], ++ RunMode.SIMULATION: [], ++ RunMode.DUMMY: [], ++ } ++ ++ # Run through taskdefs looking for those with nonlive modes ++ for taskname, taskdef in taskdefs.items(): ++ # Add to list of tasks to be run in non-live modes: ++ if ( ++ taskdef.rtconfig.get('run mode', 'workflow') ++ not in [RunMode.LIVE, 'workflow'] ++ ): ++ warn_nonlive[taskdef.rtconfig['run mode']].append(taskname) ++ ++ # Run any mode specific validation checks: ++ check_task_skip_config(taskname, taskdef) ++ ++ # Assemble warning message about any tasks in nonlive mode. ++ if any(warn_nonlive.values()): ++ message = 'The following tasks are set to run in non-live mode:' ++ for mode, tasknames in warn_nonlive.items(): ++ if tasknames: ++ message += f'\n{mode} mode:' ++ for taskname in tasknames: ++ message += f'\n * {taskname}' ++ LOG.warning(message) +diff --git a/cylc/flow/simulation.py b/cylc/flow/run_modes/simulation.py +similarity index 66% +rename from cylc/flow/simulation.py +rename to cylc/flow/run_modes/simulation.py +index 8ec4d279c..7b1594b5d 100644 +--- a/cylc/flow/simulation.py ++++ b/cylc/flow/run_modes/simulation.py +@@ -13,33 +13,85 @@ + # + # You should have received a copy of the GNU General Public License + # along with this program. If not, see . +-"""Utilities supporting simulation and skip modes ++"""Utilities supporting simulation mode + """ + + from dataclasses import dataclass +-from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union ++from logging import INFO ++from typing import ( ++ TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union) + from time import time + + from metomi.isodatetime.parsers import DurationParser + + from cylc.flow import LOG ++from cylc.flow.cycling import PointBase + from cylc.flow.cycling.loader import get_point + from cylc.flow.exceptions import PointParsingError +-from cylc.flow.platforms import FORBIDDEN_WITH_PLATFORM ++from cylc.flow.platforms import FORBIDDEN_WITH_PLATFORM, get_platform ++from cylc.flow.task_outputs import TASK_OUTPUT_SUBMITTED + from cylc.flow.task_state import ( + TASK_STATUS_RUNNING, + TASK_STATUS_FAILED, + TASK_STATUS_SUCCEEDED, + ) + from cylc.flow.wallclock import get_unix_time_from_time_string +-from cylc.flow.workflow_status import RunMode ++from cylc.flow.task_state import RunMode + + + if TYPE_CHECKING: + from cylc.flow.task_events_mgr import TaskEventsManager ++ from cylc.flow.task_job_mgr import TaskJobManager + from cylc.flow.task_proxy import TaskProxy + from cylc.flow.workflow_db_mgr import WorkflowDatabaseManager +- from cylc.flow.cycling import PointBase ++ from typing_extensions import Literal ++ ++ ++def submit_task_job( ++ task_job_mgr: 'TaskJobManager', ++ itask: 'TaskProxy', ++ rtconfig: Dict[str, Any], ++ workflow: str, ++ now: Tuple[float, str] ++) -> 'Literal[True]': ++ """Submit a task in simulation mode. ++ ++ Returns: ++ True - indicating that TaskJobManager need take no further action. ++ """ ++ configure_sim_mode( ++ rtconfig, ++ itask.tdef.rtconfig['simulation']['fail cycle points']) ++ itask.summary['started_time'] = now[0] ++ task_job_mgr._set_retry_timers(itask, rtconfig) ++ itask.mode_settings = ModeSettings( ++ itask, ++ task_job_mgr.workflow_db_mgr, ++ rtconfig ++ ) ++ itask.waiting_on_job_prep = False ++ itask.submit_num += 1 ++ ++ itask.platform = get_platform() ++ itask.platform['name'] = 'SIMULATION' ++ itask.summary['job_runner_name'] = 'SIMULATION' ++ itask.summary[task_job_mgr.KEY_EXECUTE_TIME_LIMIT] = ( ++ itask.mode_settings.simulated_run_length ++ ) ++ itask.jobs.append( ++ task_job_mgr.get_simulation_job_conf(itask, workflow) ++ ) ++ task_job_mgr.task_events_mgr.process_message( ++ itask, INFO, TASK_OUTPUT_SUBMITTED, ++ ) ++ task_job_mgr.workflow_db_mgr.put_insert_task_jobs( ++ itask, { ++ 'time_submit': now[1], ++ 'try_num': itask.get_try_num(), ++ } ++ ) ++ itask.state.status = TASK_STATUS_RUNNING ++ return True + + + @dataclass +@@ -79,7 +131,6 @@ class ModeSettings: + db_mgr: 'WorkflowDatabaseManager', + rtconfig: Dict[str, Any] + ): +- + # itask.summary['started_time'] and mode_settings.timeout need + # repopulating from the DB on workflow restart: + started_time = itask.summary['started_time'] +@@ -104,22 +155,15 @@ class ModeSettings: + try_num = db_info["try_num"] + + # Parse fail cycle points: +- if rtconfig != itask.tdef.rtconfig: +- try: +- rtconfig["simulation"][ +- "fail cycle points" +- ] = parse_fail_cycle_points( +- rtconfig["simulation"]["fail cycle points"] +- ) +- except PointParsingError as exc: +- # Broadcast Fail CP didn't parse +- LOG.warning( +- 'Broadcast fail cycle point was invalid:\n' +- f' {exc.args[0]}' +- ) +- rtconfig['simulation'][ +- 'fail cycle points' +- ] = itask.tdef.rtconfig['simulation']['fail cycle points'] ++ if not rtconfig: ++ rtconfig = itask.tdef.rtconfig ++ if rtconfig and rtconfig != itask.tdef.rtconfig: ++ rtconfig["simulation"][ ++ "fail cycle points" ++ ] = parse_fail_cycle_points( ++ rtconfig["simulation"]["fail cycle points"], ++ itask.tdef.rtconfig['simulation']['fail cycle points'] ++ ) + + # Calculate simulation info: + self.simulated_run_length = ( +@@ -132,37 +176,39 @@ class ModeSettings: + self.timeout = started_time + self.simulated_run_length + + +-def configure_sim_modes(taskdefs, sim_mode): ++def configure_sim_mode(rtc, fallback): + """Adjust task defs for simulation and dummy mode. + ++ Example: ++ >>> this = configure_sim_mode ++ >>> rtc = { ++ ... 'submission retry delays': [42, 24, 23], ++ ... 'environment': {'DoNot': '"WantThis"'}, ++ ... 'simulation': {'fail cycle points': ['all']} ++ ... } ++ >>> this(rtc, [53]) ++ >>> rtc['submission retry delays'] ++ [1] ++ >>> rtc['environment'] ++ {} ++ >>> rtc['simulation'] ++ {'fail cycle points': None} ++ >>> rtc['platform'] ++ 'localhost' + """ +- dummy_mode = (sim_mode == RunMode.DUMMY) +- +- for tdef in taskdefs: +- # Compute simulated run time by scaling the execution limit. +- rtc = tdef.rtconfig +- +- rtc['submission retry delays'] = [1] ++ rtc['submission retry delays'] = [1] + +- if dummy_mode: +- # Generate dummy scripting. +- rtc['init-script'] = "" +- rtc['env-script'] = "" +- rtc['pre-script'] = "" +- rtc['post-script'] = "" +- rtc['script'] = build_dummy_script( +- rtc, get_simulated_run_len(rtc)) ++ disable_platforms(rtc) + +- disable_platforms(rtc) ++ # Disable environment, in case it depends on env-script. ++ rtc['environment'] = {} + +- # Disable environment, in case it depends on env-script. +- rtc['environment'] = {} +- +- rtc["simulation"][ +- "fail cycle points" +- ] = parse_fail_cycle_points( +- rtc["simulation"]["fail cycle points"] +- ) ++ rtc["simulation"][ ++ "fail cycle points" ++ ] = parse_fail_cycle_points( ++ rtc["simulation"]["fail cycle points"], ++ fallback ++ ) + + + def get_simulated_run_len(rtc: Dict[str, Any]) -> int: +@@ -184,24 +230,6 @@ def get_simulated_run_len(rtc: Dict[str, Any]) -> int: + return sleep_sec + + +-def build_dummy_script(rtc: Dict[str, Any], sleep_sec: int) -> str: +- """Create fake scripting for dummy mode. +- +- This is for Dummy mode only. +- """ +- script = "sleep %d" % sleep_sec +- # Dummy message outputs. +- for msg in rtc['outputs'].values(): +- script += "\ncylc message '%s'" % msg +- if rtc['simulation']['fail try 1 only']: +- arg1 = "true" +- else: +- arg1 = "false" +- arg2 = " ".join(rtc['simulation']['fail cycle points']) +- script += "\ncylc__job__dummy_result %s %s || exit 1" % (arg1, arg2) +- return script +- +- + def disable_platforms( + rtc: Dict[str, Any] + ) -> None: +@@ -222,7 +250,7 @@ def disable_platforms( + + + def parse_fail_cycle_points( +- f_pts_orig: List[str] ++ f_pts_orig: List[str], fallback + ) -> 'Union[None, List[PointBase]]': + """Parse `[simulation][fail cycle points]`. + +@@ -231,11 +259,11 @@ def parse_fail_cycle_points( + + Examples: + >>> this = parse_fail_cycle_points +- >>> this(['all']) is None ++ >>> this(['all'], ['42']) is None + True +- >>> this([]) ++ >>> this([], ['42']) + [] +- >>> this(None) is None ++ >>> this(None, ['42']) is None + True + """ + f_pts: 'Optional[List[PointBase]]' = [] +@@ -247,7 +275,16 @@ def parse_fail_cycle_points( + elif f_pts_orig: + f_pts = [] + for point_str in f_pts_orig: +- f_pts.append(get_point(point_str).standardise()) ++ if isinstance(point_str, PointBase): ++ f_pts.append(point_str) ++ else: ++ try: ++ f_pts.append(get_point(point_str).standardise()) ++ except PointParsingError: ++ LOG.warning( ++ f'Invalid ISO 8601 date representation: {point_str}' ++ ) ++ return fallback + return f_pts + + +@@ -266,13 +303,19 @@ def sim_time_check( + now = time() + sim_task_state_changed: bool = False + for itask in itasks: +- if itask.state.status != TASK_STATUS_RUNNING: ++ if ( ++ itask.state.status != TASK_STATUS_RUNNING ++ or itask.tdef.run_mode != RunMode.SIMULATION ++ ): + continue + + # This occurs if the workflow has been restarted. + if itask.mode_settings is None: + rtconfig = task_events_manager.broadcast_mgr.get_updated_rtconfig( + itask) ++ rtconfig = configure_sim_mode( ++ rtconfig, ++ itask.tdef.rtconfig['simulation']['fail cycle points']) + itask.mode_settings = ModeSettings( + itask, + db_mgr, +diff --git a/cylc/flow/run_modes/skip.py b/cylc/flow/run_modes/skip.py +new file mode 100644 +index 000000000..b336e05e5 +--- /dev/null ++++ b/cylc/flow/run_modes/skip.py +@@ -0,0 +1,172 @@ ++# 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 . ++"""Utilities supporting skip modes ++""" ++from ansimarkup import parse as cparse ++from logging import INFO ++from typing import ( ++ TYPE_CHECKING, Any, Dict, List, Set, Tuple) ++ ++from cylc.flow import LOG ++from cylc.flow.exceptions import WorkflowConfigError ++from cylc.flow.platforms import get_platform ++from cylc.flow.task_outputs import ( ++ TASK_OUTPUT_SUBMITTED, ++ TASK_OUTPUT_SUCCEEDED, ++ TASK_OUTPUT_FAILED, ++ TASK_OUTPUT_STARTED ++) ++from cylc.flow.task_state import RunMode ++ ++if TYPE_CHECKING: ++ from cylc.flow.taskdef import TaskDef ++ from cylc.flow.task_job_mgr import TaskJobManager ++ from cylc.flow.task_proxy import TaskProxy ++ from typing_extensions import Literal ++ ++ ++def submit_task_job( ++ task_job_mgr: 'TaskJobManager', ++ itask: 'TaskProxy', ++ rtconfig: Dict[str, Any], ++ workflow: str, ++ now: Tuple[float, str] ++) -> 'Literal[True]': ++ """Submit a task in skip mode. ++ ++ Returns: ++ True - indicating that TaskJobManager need take no further action. ++ """ ++ itask.summary['started_time'] = now[0] ++ # TODO - do we need this? I don't thing so? ++ task_job_mgr._set_retry_timers(itask, rtconfig) ++ itask.waiting_on_job_prep = False ++ itask.submit_num += 1 ++ ++ itask.platform = get_platform() ++ itask.platform['name'] = RunMode.SKIP ++ itask.summary['job_runner_name'] = RunMode.SKIP ++ itask.tdef.run_mode = RunMode.SKIP ++ task_job_mgr.task_events_mgr.process_message( ++ itask, INFO, TASK_OUTPUT_SUBMITTED, ++ ) ++ task_job_mgr.workflow_db_mgr.put_insert_task_jobs( ++ itask, { ++ 'time_submit': now[1], ++ 'try_num': itask.get_try_num(), ++ } ++ ) ++ for output in process_outputs(itask): ++ task_job_mgr.task_events_mgr.process_message(itask, INFO, output) ++ ++ return True ++ ++ ++def process_outputs(itask: 'TaskProxy') -> List[str]: ++ """Process Skip Mode Outputs: ++ ++ * By default, all required outputs will be generated plus succeeded ++ if success is optional. ++ * The outputs submitted and started are always produced and do not ++ need to be defined in outputs. ++ * If outputs is specified and does not include either ++ succeeded or failed then succeeded will be produced. ++ ++ Return: ++ A list of outputs to emit. ++ """ ++ result: List = [] ++ conf_outputs = itask.tdef.rtconfig['skip']['outputs'] ++ ++ # Remove started or submitted from our list of outputs: ++ for out in get_unecessary_outputs(conf_outputs): ++ conf_outputs.remove(out) ++ ++ # Submitted will always be produced: ++ # (Task events manager handles started automatically for ghost modes) ++ result.append(TASK_OUTPUT_SUBMITTED) ++ ++ # Send the rest of our outputs, unless they are succeed or failed, ++ # which we hold back, to prevent warnings about pre-requisites being ++ # unmet being shown because a "finished" output happens to come first. ++ for output, message in itask.state.outputs._required.items(): ++ # Send message unless it be succeeded/failed. ++ if output in [TASK_OUTPUT_SUCCEEDED, TASK_OUTPUT_FAILED]: ++ continue ++ if not conf_outputs or output in conf_outputs: ++ result.append(message) ++ ++ # Send succeeded/failed last. ++ if TASK_OUTPUT_FAILED in conf_outputs: ++ result.append(TASK_OUTPUT_FAILED) ++ else: ++ result.append(TASK_OUTPUT_SUCCEEDED) ++ ++ return result ++ ++ ++def check_task_skip_config(name: str, tdef: 'TaskDef') -> None: ++ """Ensure that skip mode configurations are sane at validation time: ++ ++ Args: ++ name: of task ++ tdef: of task ++ ++ Logs: ++ * Warn that outputs need not include started and submitted as these ++ are always emitted. ++ ++ Raises: ++ * Error if outputs include succeeded and failed. ++ """ ++ skip_config = tdef.rtconfig.get('skip', {}) ++ if not skip_config: ++ return ++ skip_outputs = skip_config.get('outputs', {}) ++ if not skip_outputs: ++ return ++ ++ # Error if outputs include succeded and failed: ++ if ( ++ TASK_OUTPUT_SUCCEEDED in skip_outputs ++ and TASK_OUTPUT_FAILED in skip_outputs ++ ): ++ raise WorkflowConfigError( ++ cparse( ++ f'Skip mode settings for task {name} has' ++ ' mutually exclusive outputs: succeeded AND failed.')) ++ ++ # Warn if started or submitted set: ++ unecessary_outs = get_unecessary_outputs(skip_outputs) ++ if unecessary_outs: ++ LOG.warning( ++ f'Task {name} has output(s) {unecessary_outs} which will' ++ ' always be run in skip mode and need not be set.') ++ ++ ++def get_unecessary_outputs(skip_outputs: List[str]) -> Set[str]: ++ """Get a list of outputs which we will always run, and don't need ++ setting config. ++ ++ Examples: ++ >>> this = get_unecessary_outputs ++ >>> this(['foo', 'started', 'succeeded']) ++ {'started'} ++ """ ++ return { ++ o for o in skip_outputs ++ if o in {TASK_OUTPUT_SUBMITTED, TASK_OUTPUT_STARTED} ++ } +diff --git a/cylc/flow/scheduler.py b/cylc/flow/scheduler.py +index 7e082f08f..5bde0e967 100644 +--- a/cylc/flow/scheduler.py ++++ b/cylc/flow/scheduler.py +@@ -113,12 +113,12 @@ from cylc.flow.platforms import ( + ) + from cylc.flow.profiler import Profiler + from cylc.flow.resources import get_resources +-from cylc.flow.simulation import sim_time_check ++from cylc.flow.run_modes.simulation import sim_time_check + from cylc.flow.subprocpool import SubProcPool + from cylc.flow.templatevars import eval_var + from cylc.flow.workflow_db_mgr import WorkflowDatabaseManager + from cylc.flow.workflow_events import WorkflowEventHandler +-from cylc.flow.workflow_status import RunMode, StopMode, AutoRestartMode ++from cylc.flow.workflow_status import StopMode, AutoRestartMode + from cylc.flow import workflow_files + from cylc.flow.taskdef import TaskDef + from cylc.flow.task_events_mgr import TaskEventsManager +@@ -140,7 +140,8 @@ from cylc.flow.task_state import ( + TASK_STATUS_SUBMITTED, + TASK_STATUS_RUNNING, + TASK_STATUS_WAITING, +- TASK_STATUS_FAILED) ++ TASK_STATUS_FAILED, ++ RunMode) + from cylc.flow.templatevars import get_template_vars + from cylc.flow.util import cli_format + from cylc.flow.wallclock import ( +@@ -1499,7 +1500,7 @@ class Scheduler: + pre_prep_tasks, + self.server.curve_auth, + self.server.client_pub_key_dir, +- is_simulation=(self.get_run_mode() == RunMode.SIMULATION) ++ run_mode=self.config.run_mode() + ): + if itask.flow_nums: + flow = ','.join(str(i) for i in itask.flow_nums) +@@ -1745,19 +1746,15 @@ class Scheduler: + if self.xtrigger_mgr.do_housekeeping: + self.xtrigger_mgr.housekeep(self.pool.get_tasks()) + +- self.pool.clock_expire_tasks() +- self.release_queued_tasks() +- +- if ( +- self.get_run_mode() == RunMode.SIMULATION +- and sim_time_check( +- self.task_events_mgr, +- self.pool.get_tasks(), +- self.workflow_db_mgr, +- ) ++ if sim_time_check( ++ self.task_events_mgr, ++ self.pool.get_tasks(), ++ self.workflow_db_mgr, + ): + # A simulated task state change occurred. + self.reset_inactivity_timer() ++ self.pool.clock_expire_tasks() ++ self.release_queued_tasks() + + self.broadcast_mgr.expire_broadcast(self.pool.get_min_point()) + self.late_tasks_check() +diff --git a/cylc/flow/scheduler_cli.py b/cylc/flow/scheduler_cli.py +index d25be8093..5ed69284d 100644 +--- a/cylc/flow/scheduler_cli.py ++++ b/cylc/flow/scheduler_cli.py +@@ -54,6 +54,7 @@ from cylc.flow.pathutil import get_workflow_run_scheduler_log_path + from cylc.flow.remote import cylc_server_cmd + from cylc.flow.scheduler import Scheduler, SchedulerError + from cylc.flow.scripts.common import cylc_header ++from cylc.flow.task_state import RunMode + from cylc.flow.workflow_db_mgr import WorkflowDatabaseManager + from cylc.flow.workflow_files import ( + SUITERC_DEPR_MSG, +@@ -65,7 +66,6 @@ from cylc.flow.terminal import ( + is_terminal, + prompt, + ) +-from cylc.flow.workflow_status import RunMode + + if TYPE_CHECKING: + from optparse import Values +@@ -129,9 +129,15 @@ PLAY_ICP_OPTION.sources = {'play'} + + RUN_MODE = OptionSettings( + ["-m", "--mode"], +- help="Run mode: live, dummy, simulation (default live).", ++ help=( ++ f"Run mode: {RunMode.WORKFLOW_MODES} (default live)." ++ " Live mode executes the tasks as defined in the runtime section." ++ " Simulation, Skip and Dummy partially or wholly ignore" ++ " the task defined in runtime configuration. Simulation and" ++ " dummy are designed for testing and Skip for flow control." ++ ), + metavar="STRING", action='store', dest="run_mode", +- choices=[RunMode.LIVE, RunMode.DUMMY, RunMode.SIMULATION], ++ choices=list(RunMode.WORKFLOW_MODES), + ) + + PLAY_RUN_MODE = deepcopy(RUN_MODE) +diff --git a/cylc/flow/scripts/lint.py b/cylc/flow/scripts/lint.py +index d045ab708..6b3ed660f 100755 +--- a/cylc/flow/scripts/lint.py ++++ b/cylc/flow/scripts/lint.py +@@ -86,9 +86,15 @@ from cylc.flow.option_parsers import ( + from cylc.flow.cfgspec.workflow import upg, SPEC + from cylc.flow.id_cli import parse_id + from cylc.flow.parsec.config import ParsecConfig ++from cylc.flow.run_modes.skip import get_unecessary_outputs + from cylc.flow.scripts.cylc import DEAD_ENDS ++from cylc.flow.task_outputs import ( ++ TASK_OUTPUT_SUCCEEDED, ++ TASK_OUTPUT_FAILED, ++) + from cylc.flow.terminal import cli_function + ++ + if TYPE_CHECKING: + # BACK COMPAT: typing_extensions.Literal + # FROM: Python 3.7 +@@ -319,6 +325,39 @@ def check_for_deprecated_task_event_template_vars( + return None + + ++BAD_SKIP_OUTS = re.compile(r'outputs\s*=\s*(.*)') ++ ++ ++def check_skip_mode_outputs(line: str) -> Dict: ++ """Ensure skip mode output setting doesn't include: ++ ++ * succeeded _and_ failed: Mutually exclusive. ++ * submitted and started: These are emitted by skip mode anyway. ++ ++ n.b. ++ ++ This should be separable from ``[[outputs]]`` because it's a key ++ value pair not a section heading. ++ """ ++ ++ outputs = BAD_SKIP_OUTS.findall(line) ++ if outputs: ++ outputs = [i.strip() for i in outputs[0].split(',')] ++ if TASK_OUTPUT_FAILED in outputs and TASK_OUTPUT_SUCCEEDED in outputs: ++ return { ++ 'description': ++ 'are mutually exclusive and cannot be used together', ++ 'outputs': f'{TASK_OUTPUT_FAILED} and {TASK_OUTPUT_SUCCEEDED}' ++ } ++ pointless_outputs = get_unecessary_outputs(outputs) ++ if pointless_outputs: ++ return { ++ 'description': 'are not required, they will be emitted anyway', ++ 'outputs': f'{pointless_outputs}' ++ } ++ return {} ++ ++ + INDENTATION = re.compile(r'^(\s*)(.*)') + + +@@ -533,6 +572,14 @@ STYLE_CHECKS = { + 'S013': { + 'short': 'Items should be indented in 4 space blocks.', + FUNCTION: check_indentation ++ }, ++ 'S014': { ++ 'short': 'Run mode is not live: This task will only appear to run.', ++ FUNCTION: re.compile(r'run mode\s*=\s*[^l][^i][^v][^e]$').findall ++ }, ++ 'S015': { ++ 'short': 'Task outputs {outputs}: {description}.', ++ FUNCTION: check_skip_mode_outputs + } + } + # Subset of deprecations which are tricky (impossible?) to scrape from the +diff --git a/cylc/flow/scripts/set.py b/cylc/flow/scripts/set.py +index 4f0cd1952..73dea4912 100755 +--- a/cylc/flow/scripts/set.py ++++ b/cylc/flow/scripts/set.py +@@ -326,9 +326,12 @@ def get_output_opts(output_options: List[str]): + # If "required" is explicit just ditch it (same as the default) + if not outputs or outputs == ["required"]: + return [] ++ elif outputs == ['skip']: ++ return ['skip'] + +- if "required" in outputs: +- raise InputError("--out=required must be used alone") ++ for val in ["required", 'skip']: ++ if val in outputs: ++ raise InputError(f"--out={val} must be used alone") + if "waiting" in outputs: + raise InputError( + "Tasks cannot be set to waiting, use a new flow to re-run" +diff --git a/cylc/flow/scripts/validate.py b/cylc/flow/scripts/validate.py +index a224302c1..1be36cb3d 100755 +--- a/cylc/flow/scripts/validate.py ++++ b/cylc/flow/scripts/validate.py +@@ -55,7 +55,7 @@ from cylc.flow.task_proxy import TaskProxy + from cylc.flow.templatevars import get_template_vars + from cylc.flow.terminal import cli_function + from cylc.flow.scheduler_cli import RUN_MODE +-from cylc.flow.workflow_status import RunMode ++from cylc.flow.task_state import RunMode + + if TYPE_CHECKING: + from cylc.flow.option_parsers import Values +diff --git a/cylc/flow/task_events_mgr.py b/cylc/flow/task_events_mgr.py +index 14f376dfb..57409c037 100644 +--- a/cylc/flow/task_events_mgr.py ++++ b/cylc/flow/task_events_mgr.py +@@ -78,7 +78,8 @@ from cylc.flow.task_state import ( + TASK_STATUS_FAILED, + TASK_STATUS_EXPIRED, + TASK_STATUS_SUCCEEDED, +- TASK_STATUS_WAITING ++ TASK_STATUS_WAITING, ++ RunMode, + ) + from cylc.flow.task_outputs import ( + TASK_OUTPUT_EXPIRED, +@@ -98,7 +99,6 @@ from cylc.flow.workflow_events import ( + get_template_variables as get_workflow_template_variables, + process_mail_footer, + ) +-from cylc.flow.workflow_status import RunMode + + + if TYPE_CHECKING: +@@ -768,7 +768,7 @@ class TaskEventsManager(): + + # ... but either way update the job ID in the job proxy (it only + # comes in via the submission message). +- if itask.tdef.run_mode != RunMode.SIMULATION: ++ if not RunMode.is_ghostly(itask.tdef.run_mode): + job_tokens = itask.tokens.duplicate( + job=str(itask.submit_num) + ) +@@ -886,7 +886,7 @@ class TaskEventsManager(): + + if ( + itask.state(TASK_STATUS_WAITING) +- # Polling in live mode only: ++ # Polling in live mode only. + and itask.tdef.run_mode == RunMode.LIVE + and ( + ( +@@ -932,7 +932,7 @@ class TaskEventsManager(): + + def setup_event_handlers(self, itask, event, message): + """Set up handlers for a task event.""" +- if itask.tdef.run_mode != RunMode.LIVE: ++ if RunMode.disable_task_event_handlers(itask): + return + msg = "" + if message != f"job {event}": +@@ -1457,7 +1457,7 @@ class TaskEventsManager(): + ) + + itask.set_summary_time('submitted', event_time) +- if itask.tdef.run_mode == RunMode.SIMULATION: ++ if RunMode.is_ghostly(itask.tdef.run_mode): + # Simulate job started as well. + itask.set_summary_time('started', event_time) + if itask.state_reset(TASK_STATUS_RUNNING, forced=forced): +@@ -1494,7 +1494,7 @@ class TaskEventsManager(): + 'submitted', + event_time, + ) +- if itask.tdef.run_mode == RunMode.SIMULATION: ++ if RunMode.is_ghostly(itask.tdef.run_mode): + # Simulate job started as well. + self.data_store_mgr.delta_job_time( + job_tokens, +@@ -1527,7 +1527,7 @@ class TaskEventsManager(): + # not see previous submissions (so can't use itask.jobs[submit_num-1]). + # And transient tasks, used for setting outputs and spawning children, + # do not submit jobs. +- if (itask.tdef.run_mode == RunMode.SIMULATION) or forced: ++ if RunMode.is_ghostly(itask.tdef.run_mode) or forced: + job_conf = {"submit_num": itask.submit_num} + else: + job_conf = itask.jobs[-1] +diff --git a/cylc/flow/task_job_mgr.py b/cylc/flow/task_job_mgr.py +index 69b8a22bb..de1a29336 100644 +--- a/cylc/flow/task_job_mgr.py ++++ b/cylc/flow/task_job_mgr.py +@@ -35,7 +35,7 @@ from logging import ( + ) + from shutil import rmtree + from time import time +-from typing import TYPE_CHECKING, Any, Union, Optional ++from typing import TYPE_CHECKING, Any, List, Tuple, Union, Optional + + from cylc.flow import LOG + from cylc.flow.job_runner_mgr import JobPollContext +@@ -63,7 +63,12 @@ from cylc.flow.platforms import ( + get_platform, + ) + from cylc.flow.remote import construct_ssh_cmd +-from cylc.flow.simulation import ModeSettings ++from cylc.flow.run_modes.simulation import ( ++ submit_task_job as simulation_submit_task_job) ++from cylc.flow.run_modes.skip import ( ++ submit_task_job as skip_submit_task_job) ++from cylc.flow.run_modes.dummy import ( ++ submit_task_job as dummy_submit_task_job) + from cylc.flow.subprocctx import SubProcContext + from cylc.flow.subprocpool import SubProcPool + from cylc.flow.task_action_timer import ( +@@ -103,7 +108,8 @@ from cylc.flow.task_state import ( + TASK_STATUS_SUBMITTED, + TASK_STATUS_RUNNING, + TASK_STATUS_WAITING, +- TASK_STATUSES_ACTIVE ++ TASK_STATUSES_ACTIVE, ++ RunMode + ) + from cylc.flow.wallclock import ( + get_current_time_string, +@@ -247,7 +253,7 @@ class TaskJobManager: + return [prepared_tasks, bad_tasks] + + def submit_task_jobs(self, workflow, itasks, curve_auth, +- client_pub_key_dir, is_simulation=False): ++ client_pub_key_dir, run_mode='live'): + """Prepare for job submission and submit task jobs. + + Preparation (host selection, remote host init, and remote install) +@@ -262,8 +268,8 @@ class TaskJobManager: + + Return (list): list of tasks that attempted submission. + """ +- if is_simulation: +- return self._simulation_submit_task_jobs(itasks, workflow) ++ itasks, ghost_tasks = self._nonlive_submit_task_jobs( ++ itasks, workflow, run_mode) + + # Prepare tasks for job submission + prepared_tasks, bad_tasks = self.prep_submit_task_jobs( +@@ -272,8 +278,10 @@ class TaskJobManager: + # Reset consumed host selection results + self.task_remote_mgr.subshell_eval_reset() + +- if not prepared_tasks: ++ if not prepared_tasks and not ghost_tasks: + return bad_tasks ++ elif not prepared_tasks: ++ return ghost_tasks + auth_itasks = {} # {platform: [itask, ...], ...} + + for itask in prepared_tasks: +@@ -281,7 +289,7 @@ class TaskJobManager: + auth_itasks.setdefault(platform_name, []) + auth_itasks[platform_name].append(itask) + # Submit task jobs for each platform +- done_tasks = bad_tasks ++ done_tasks = bad_tasks + ghost_tasks + + for _, itasks in sorted(auth_itasks.items()): + # Find the first platform where >1 host has not been tried and +@@ -995,44 +1003,64 @@ class TaskJobManager: + except KeyError: + itask.try_timers[key] = TaskActionTimer(delays=delays) + +- def _simulation_submit_task_jobs(self, itasks, workflow): +- """Simulation mode task jobs submission.""" ++ def _nonlive_submit_task_jobs( ++ self: 'TaskJobManager', ++ itasks: 'List[TaskProxy]', ++ workflow: str, ++ workflow_run_mode: str, ++ ) -> 'Tuple[List[TaskProxy], List[TaskProxy]]': ++ """Simulation mode task jobs submission. ++ ++ Returns: ++ lively_tasks: ++ A list of tasks which require subsequent ++ processing **as if** they were live mode tasks. ++ (This includes live and dummy mode tasks) ++ ghostly_tasks: ++ A list of tasks which require no further processing ++ because their apparent execution is done entirely inside ++ the scheduler. (This includes skip and simulation mode tasks). ++ """ ++ lively_tasks: 'List[TaskProxy]' = [] ++ ghost_tasks: 'List[TaskProxy]' = [] + now = time() +- now_str = get_time_string_from_unix_time(now) ++ now = (now, get_time_string_from_unix_time(now)) ++ + for itask in itasks: +- # Handle broadcasts ++ # Handle broadcasts: + rtconfig = self.task_events_mgr.broadcast_mgr.get_updated_rtconfig( + itask) + +- itask.summary['started_time'] = now +- self._set_retry_timers(itask, rtconfig) +- itask.mode_settings = ModeSettings( +- itask, +- self.workflow_db_mgr, +- rtconfig +- ) +- +- itask.waiting_on_job_prep = False +- itask.submit_num += 1 ++ # Apply task run mode + +- itask.platform = {'name': 'SIMULATION'} +- itask.summary['job_runner_name'] = 'SIMULATION' +- itask.summary[self.KEY_EXECUTE_TIME_LIMIT] = ( +- itask.mode_settings.simulated_run_length +- ) +- itask.jobs.append( +- self.get_simulation_job_conf(itask, workflow) +- ) +- self.task_events_mgr.process_message( +- itask, INFO, TASK_OUTPUT_SUBMITTED, +- ) +- self.workflow_db_mgr.put_insert_task_jobs( +- itask, { +- 'time_submit': now_str, +- 'try_num': itask.get_try_num(), +- } +- ) +- return itasks ++ if rtconfig['run mode'] == 'workflow': ++ run_mode = workflow_run_mode ++ else: ++ if rtconfig['run mode'] != workflow_run_mode: ++ LOG.info( ++ f'[{itask.identity}] run mode set by task settings' ++ f' to: {rtconfig["run mode"]} mode.') ++ run_mode = rtconfig['run mode'] ++ itask.tdef.run_mode = run_mode ++ ++ # Submit ghost tasks, or add live-like tasks to list ++ # of tasks to put through live submission pipeline: ++ is_done = False ++ if run_mode == RunMode.DUMMY: ++ is_done = dummy_submit_task_job( ++ self, itask, rtconfig, workflow, now) ++ elif run_mode == RunMode.SIMULATION: ++ is_done = simulation_submit_task_job( ++ self, itask, rtconfig, workflow, now) ++ elif run_mode == RunMode.SKIP: ++ is_done = skip_submit_task_job( ++ self, itask, rtconfig, workflow, now) ++ # Assign task to list: ++ if is_done: ++ ghost_tasks.append(itask) ++ else: ++ lively_tasks.append(itask) ++ return lively_tasks, ghost_tasks + + def _submit_task_jobs_callback(self, ctx, workflow, itasks): + """Callback when submit task jobs command exits.""" +diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py +index 2b214d709..b3a9ed8e9 100644 +--- a/cylc/flow/task_pool.py ++++ b/cylc/flow/task_pool.py +@@ -69,6 +69,8 @@ from cylc.flow.util import ( + ) + from cylc.flow.wallclock import get_current_time_string + from cylc.flow.platforms import get_platform ++from cylc.flow.run_modes.skip import ( ++ process_outputs as get_skip_mode_outputs) + from cylc.flow.task_outputs import ( + TASK_OUTPUT_SUCCEEDED, + TASK_OUTPUT_EXPIRED, +@@ -1887,9 +1889,10 @@ class TaskPool: + outputs: List[str], + ) -> None: + """Set requested outputs on a task proxy and spawn children.""" +- + if not outputs: + outputs = itask.tdef.get_required_output_messages() ++ elif outputs == ['skip']: ++ outputs = get_skip_mode_outputs(itask) + else: + outputs = self._standardise_outputs( + itask.point, itask.tdef, outputs) +diff --git a/cylc/flow/task_proxy.py b/cylc/flow/task_proxy.py +index 898017c8d..af9f63288 100644 +--- a/cylc/flow/task_proxy.py ++++ b/cylc/flow/task_proxy.py +@@ -62,7 +62,7 @@ from cylc.flow.cycling.iso8601 import ( + + if TYPE_CHECKING: + from cylc.flow.cycling import PointBase +- from cylc.flow.simulation import ModeSettings ++ from cylc.flow.run_modes.simulation import ModeSettings + from cylc.flow.task_action_timer import TaskActionTimer + from cylc.flow.taskdef import TaskDef + from cylc.flow.id import Tokens +diff --git a/cylc/flow/task_state.py b/cylc/flow/task_state.py +index ebb3dbc98..0fd09a664 100644 +--- a/cylc/flow/task_state.py ++++ b/cylc/flow/task_state.py +@@ -23,6 +23,7 @@ from cylc.flow.task_outputs import TaskOutputs + from cylc.flow.wallclock import get_current_time_string + + if TYPE_CHECKING: ++ from cylc.flow.option_parsers import Values + from cylc.flow.id import Tokens + + +@@ -154,6 +155,77 @@ TASK_STATUSES_TRIGGERABLE = { + } + + ++class RunMode: ++ """The possible run modes of a task/workflow.""" ++ ++ LIVE = 'live' ++ """Task will run normally.""" ++ ++ SIMULATION = 'simulation' ++ """Task will run in simulation mode.""" ++ ++ DUMMY = 'dummy' ++ """Task will run in dummy mode.""" ++ ++ SKIP = 'skip' ++ """Task will run in skip mode.""" ++ ++ WORKFLOW = 'workflow' ++ """Default to workflow run mode""" ++ ++ MODES = {LIVE, SIMULATION, DUMMY, SKIP, WORKFLOW} ++ ++ WORKFLOW_MODES = sorted(MODES - {WORKFLOW}) ++ """Workflow mode not sensible mode for workflow. ++ ++ n.b. converted to a list to ensure ordering doesn't change in ++ CLI ++ """ ++ ++ LIVELY_MODES = {LIVE, DUMMY} ++ """Modes which need to have real jobs submitted.""" ++ ++ GHOSTLY_MODES = {SKIP, SIMULATION} ++ """Modes which completely ignore the standard submission path.""" ++ ++ @staticmethod ++ def get(options: 'Values') -> str: ++ """Return the run mode from the options.""" ++ return getattr(options, 'run_mode', None) or RunMode.LIVE ++ ++ @staticmethod ++ def is_lively(mode: str) -> bool: ++ """Task should be treated as live, mode setting mess with scripts only. ++ """ ++ return bool(mode in RunMode.LIVELY_MODES) ++ ++ @staticmethod ++ def is_ghostly(mode: str) -> bool: ++ """Task has no reality outside the scheduler and needs no further ++ processing after run_mode.submit_task_job method finishes. ++ """ ++ return bool(mode in RunMode.GHOSTLY_MODES) ++ ++ @staticmethod ++ def disable_task_event_handlers(itask): ++ """Should we disable event handlers for this task? ++ ++ No event handlers in simulation mode, or in skip mode ++ if we don't deliberately enable them: ++ """ ++ mode = itask.tdef.run_mode ++ if ( ++ mode == RunMode.SIMULATION ++ or ( ++ mode == RunMode.SKIP ++ and itask.tdef.rtconfig['skip'][ ++ 'disable task event handlers'] is True ++ ) ++ ): ++ return True ++ return False ++ ++ + def status_leq(status_a, status_b): + """"Return True if status_a <= status_b""" + return (TASK_STATUSES_ORDERED.index(status_a) <= +diff --git a/cylc/flow/unicode_rules.py b/cylc/flow/unicode_rules.py +index 460855979..47ca55c73 100644 +--- a/cylc/flow/unicode_rules.py ++++ b/cylc/flow/unicode_rules.py +@@ -23,7 +23,7 @@ from cylc.flow.task_id import ( + _TASK_NAME_PREFIX, + ) + from cylc.flow.task_qualifiers import TASK_QUALIFIERS +-from cylc.flow.task_state import TASK_STATUSES_ORDERED ++from cylc.flow.task_state import TASK_STATUSES_ORDERED, RunMode + + ENGLISH_REGEX_MAP = { + r'\w': 'alphanumeric', +@@ -351,6 +351,8 @@ class TaskOutputValidator(UnicodeRuleChecker): + not_starts_with('_cylc'), + # blacklist keywords + not_equals('required', 'optional', 'all'), ++ # blacklist Run Modes: ++ not_equals(*RunMode.MODES), + # blacklist built-in task qualifiers and statuses (e.g. "waiting") + not_equals(*sorted({*TASK_QUALIFIERS, *TASK_STATUSES_ORDERED})), + ] +diff --git a/cylc/flow/workflow_status.py b/cylc/flow/workflow_status.py +index 02f42717e..95f25e393 100644 +--- a/cylc/flow/workflow_status.py ++++ b/cylc/flow/workflow_status.py +@@ -21,7 +21,6 @@ from typing import Tuple, TYPE_CHECKING + from cylc.flow.wallclock import get_time_string_from_unix_time as time2str + + if TYPE_CHECKING: +- from optparse import Values + from cylc.flow.scheduler import Scheduler + + # Keys for identify API call +@@ -199,21 +198,3 @@ def get_workflow_status(schd: 'Scheduler') -> Tuple[str, str]: + status_msg = 'running' + + return (status.value, status_msg) +- +- +-class RunMode: +- """The possible run modes of a workflow.""" +- +- LIVE = 'live' +- """Workflow will run normally.""" +- +- SIMULATION = 'simulation' +- """Workflow will run in simulation mode.""" +- +- DUMMY = 'dummy' +- """Workflow will run in dummy mode.""" +- +- @staticmethod +- def get(options: 'Values') -> str: +- """Return the run mode from the options.""" +- return getattr(options, 'run_mode', None) or RunMode.LIVE +diff --git a/tests/functional/cylc-config/00-simple/section2.stdout b/tests/functional/cylc-config/00-simple/section2.stdout +index 4d3989c83..77cbe5cff 100644 +--- a/tests/functional/cylc-config/00-simple/section2.stdout ++++ b/tests/functional/cylc-config/00-simple/section2.stdout +@@ -1,12 +1,12 @@ + [[root]] + platform = + inherit = ++ script = + init-script = + env-script = + err-script = + exit-script = + pre-script = +- script = + post-script = + work sub-directory = + execution polling intervals = +@@ -14,10 +14,14 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -90,10 +94,14 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -166,10 +174,14 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -229,12 +241,12 @@ + [[SERIAL]] + platform = + inherit = ++ script = + init-script = + env-script = + err-script = + exit-script = + pre-script = +- script = + post-script = + work sub-directory = + execution polling intervals = +@@ -242,12 +254,16 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[directives]]] + job_type = serial + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -306,12 +322,12 @@ + [[PARALLEL]] + platform = + inherit = ++ script = + init-script = + env-script = + err-script = + exit-script = + pre-script = +- script = + post-script = + work sub-directory = + execution polling intervals = +@@ -319,12 +335,16 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[directives]]] + job_type = parallel + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -396,12 +416,16 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[directives]]] + job_type = serial + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -473,12 +497,16 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[directives]]] + job_type = serial + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -550,12 +578,16 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[directives]]] + job_type = parallel + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -627,12 +659,16 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[directives]]] + job_type = parallel + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -704,12 +740,16 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[directives]]] + job_type = serial + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -781,12 +821,16 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[directives]]] + job_type = serial + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -858,12 +902,16 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[directives]]] + job_type = parallel + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +@@ -935,12 +983,16 @@ + execution time limit = + submission polling intervals = + submission retry delays = ++ run mode = workflow + [[[directives]]] + job_type = parallel + [[[meta]]] + title = + description = + URL = ++ [[[skip]]] ++ outputs = ++ disable task event handlers = True + [[[simulation]]] + default run length = PT10S + speedup factor = +diff --git a/tests/functional/cylc-set/09-set-skip.t b/tests/functional/cylc-set/09-set-skip.t +new file mode 100644 +index 000000000..dd3142837 +--- /dev/null ++++ b/tests/functional/cylc-set/09-set-skip.t +@@ -0,0 +1,28 @@ ++#!/usr/bin/env bash ++# 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 . ++#------------------------------------------------------------------------------- ++# ++# Skip Mode proposal example: ++# https://github.com/cylc/cylc-admin/blob/master/docs/proposal-skip-mode.md ++# The cylc set --out option should accept the skip value ++# which should set the outputs defined in ++# [runtime][][skip]outputs. ++ ++. "$(dirname "$0")/test_header" ++set_test_number 2 ++reftest ++exit +diff --git a/tests/functional/cylc-set/09-set-skip/flow.cylc b/tests/functional/cylc-set/09-set-skip/flow.cylc +new file mode 100644 +index 000000000..ef74c3627 +--- /dev/null ++++ b/tests/functional/cylc-set/09-set-skip/flow.cylc +@@ -0,0 +1,50 @@ ++[meta] ++ test_description = """ ++ Test that cylc set --out skip satisfies ++ all outputs which are required by the graph. ++ """ ++ proposal url = https://github.com/cylc/cylc-admin/blob/master/docs/proposal-skip-mode.md ++ ++[scheduler] ++ allow implicit tasks = true ++ [[events]] ++ expected task failures = 1/bar ++ ++[scheduling] ++ [[graph]] ++ R1 = """ ++ # Optional out not created by set --out skip ++ foo:no? => not_this_task? ++ ++ # set --out skip creates required, started, submitted ++ # and succeeded (unless failed is set): ++ foo:yes => require_this_task ++ foo:submitted => submitted_emitted ++ foo:succeeded => succeeded_emitted ++ foo:started => skip_foo ++ ++ # set --out skip creates failed if that is required ++ # by skip mode settings: ++ bar:started => skip_bar ++ bar:failed? => bar_failed ++ """ ++ ++[runtime] ++ [[foo]] ++ script = sleep 100 ++ [[[skip]]] ++ outputs = yes ++ [[[outputs]]] ++ no = 'Don\'t require this task' ++ yes = 'Require this task' ++ ++ [[bar]] ++ script = sleep 100 ++ [[[skip]]] ++ outputs = failed ++ ++ [[skip_foo]] ++ script = cylc set ${CYLC_WORKFLOW_ID}//1/foo --out skip ++ ++ [[skip_bar]] ++ script = cylc set ${CYLC_WORKFLOW_ID}//1/bar --out skip +diff --git a/tests/functional/cylc-set/09-set-skip/reference.log b/tests/functional/cylc-set/09-set-skip/reference.log +new file mode 100644 +index 000000000..6e7b636f5 +--- /dev/null ++++ b/tests/functional/cylc-set/09-set-skip/reference.log +@@ -0,0 +1,8 @@ ++1/bar -triggered off [] in flow 1 ++1/foo -triggered off [] in flow 1 ++1/submitted_emitted -triggered off ['1/foo'] in flow 1 ++1/skip_bar -triggered off ['1/bar'] in flow 1 ++1/skip_foo -triggered off ['1/foo'] in flow 1 ++1/succeeded_emitted -triggered off ['1/foo'] in flow 1 ++1/bar_failed -triggered off ['1/bar'] in flow 1 ++1/require_this_task -triggered off ['1/foo'] in flow 1 +diff --git a/tests/functional/modes/01-dummy.t b/tests/functional/run_modes/01-dummy.t +similarity index 100% +rename from tests/functional/modes/01-dummy.t +rename to tests/functional/run_modes/01-dummy.t +diff --git a/tests/functional/modes/01-dummy/flow.cylc b/tests/functional/run_modes/01-dummy/flow.cylc +similarity index 100% +rename from tests/functional/modes/01-dummy/flow.cylc +rename to tests/functional/run_modes/01-dummy/flow.cylc +diff --git a/tests/functional/modes/01-dummy/reference.log b/tests/functional/run_modes/01-dummy/reference.log +similarity index 100% +rename from tests/functional/modes/01-dummy/reference.log +rename to tests/functional/run_modes/01-dummy/reference.log +diff --git a/tests/functional/modes/02-dummy-message-outputs.t b/tests/functional/run_modes/02-dummy-message-outputs.t +similarity index 100% +rename from tests/functional/modes/02-dummy-message-outputs.t +rename to tests/functional/run_modes/02-dummy-message-outputs.t +diff --git a/tests/functional/modes/02-dummy-message-outputs/flow.cylc b/tests/functional/run_modes/02-dummy-message-outputs/flow.cylc +similarity index 100% +rename from tests/functional/modes/02-dummy-message-outputs/flow.cylc +rename to tests/functional/run_modes/02-dummy-message-outputs/flow.cylc +diff --git a/tests/functional/modes/02-dummy-message-outputs/reference.log b/tests/functional/run_modes/02-dummy-message-outputs/reference.log +similarity index 100% +rename from tests/functional/modes/02-dummy-message-outputs/reference.log +rename to tests/functional/run_modes/02-dummy-message-outputs/reference.log +diff --git a/tests/functional/modes/03-simulation.t b/tests/functional/run_modes/03-simulation.t +similarity index 100% +rename from tests/functional/modes/03-simulation.t +rename to tests/functional/run_modes/03-simulation.t +diff --git a/tests/functional/modes/03-simulation/flow.cylc b/tests/functional/run_modes/03-simulation/flow.cylc +similarity index 100% +rename from tests/functional/modes/03-simulation/flow.cylc +rename to tests/functional/run_modes/03-simulation/flow.cylc +diff --git a/tests/functional/modes/03-simulation/reference.log b/tests/functional/run_modes/03-simulation/reference.log +similarity index 100% +rename from tests/functional/modes/03-simulation/reference.log +rename to tests/functional/run_modes/03-simulation/reference.log +diff --git a/tests/functional/modes/04-simulation-runtime.t b/tests/functional/run_modes/04-simulation-runtime.t +similarity index 100% +rename from tests/functional/modes/04-simulation-runtime.t +rename to tests/functional/run_modes/04-simulation-runtime.t +diff --git a/tests/functional/modes/04-simulation-runtime/flow.cylc b/tests/functional/run_modes/04-simulation-runtime/flow.cylc +similarity index 100% +rename from tests/functional/modes/04-simulation-runtime/flow.cylc +rename to tests/functional/run_modes/04-simulation-runtime/flow.cylc +diff --git a/tests/functional/modes/04-simulation-runtime/reference.log b/tests/functional/run_modes/04-simulation-runtime/reference.log +similarity index 100% +rename from tests/functional/modes/04-simulation-runtime/reference.log +rename to tests/functional/run_modes/04-simulation-runtime/reference.log +diff --git a/tests/functional/modes/05-sim-trigger.t b/tests/functional/run_modes/05-sim-trigger.t +similarity index 100% +rename from tests/functional/modes/05-sim-trigger.t +rename to tests/functional/run_modes/05-sim-trigger.t +diff --git a/tests/functional/modes/05-sim-trigger/flow.cylc b/tests/functional/run_modes/05-sim-trigger/flow.cylc +similarity index 100% +rename from tests/functional/modes/05-sim-trigger/flow.cylc +rename to tests/functional/run_modes/05-sim-trigger/flow.cylc +diff --git a/tests/functional/modes/05-sim-trigger/reference.log b/tests/functional/run_modes/05-sim-trigger/reference.log +similarity index 100% +rename from tests/functional/modes/05-sim-trigger/reference.log +rename to tests/functional/run_modes/05-sim-trigger/reference.log +diff --git a/tests/functional/run_modes/06-run-mode-overrides.t b/tests/functional/run_modes/06-run-mode-overrides.t +new file mode 100644 +index 000000000..2e90deaa4 +--- /dev/null ++++ b/tests/functional/run_modes/06-run-mode-overrides.t +@@ -0,0 +1,72 @@ ++#!/usr/bin/env bash ++# 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 that broadcasting a change in [runtime][]run mode ++# Leads to the next submission from that task to be in the updated ++# mode. ++ ++. "$(dirname "$0")/test_header" ++set_test_number 15 ++ ++install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" ++run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" ++workflow_run_ok "${TEST_NAME_BASE}-run" \ ++ cylc play "${WORKFLOW_NAME}" \ ++ --no-detach ++ ++JOB_LOGS="${WORKFLOW_RUN_DIR}/log/job/1000" ++ ++# Ghost modes do not leave log folders: ++for MODE in simulation skip; do ++ run_fail "${TEST_NAME_BASE}-no-${MODE}-task-folder" ls "${JOB_LOGS}/${MODE}_" ++done ++ ++# Live modes leave log folders: ++for MODE in default live dummy; do ++ run_ok "${TEST_NAME_BASE}-${MODE}-task-folder" ls "${JOB_LOGS}/${MODE}_" ++done ++ ++# Default defaults to live, and live is live: ++for MODE in default live; do ++ named_grep_ok "${TEST_NAME_BASE}-default-task-live" "===.*===" "${JOB_LOGS}/${MODE}_/NN/job.out" ++done ++ ++# Dummy produces a job.out, containing dummy message: ++named_grep_ok "${TEST_NAME_BASE}-default-task-live" "dummy job succeed" "${JOB_LOGS}/dummy_/NN/job.out" ++ ++purge ++ ++# Do it again with a workflow in simulation. ++install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" ++run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" ++workflow_run_ok "${TEST_NAME_BASE}-run" \ ++ cylc play "${WORKFLOW_NAME}" \ ++ --no-detach \ ++ --mode simulation ++ ++JOB_LOGS="${WORKFLOW_RUN_DIR}/log/job/1000" ++ ++# Live modes leave log folders: ++for MODE in live dummy; do ++ run_ok "${TEST_NAME_BASE}-${MODE}-task-folder" ls "${JOB_LOGS}/${MODE}_" ++done ++ ++# Ghost modes do not leave log folders: ++run_fail "${TEST_NAME_BASE}-no-default-task-folder" ls "${JOB_LOGS}/default_" ++ ++purge ++exit 0 +diff --git a/tests/functional/run_modes/06-run-mode-overrides/flow.cylc b/tests/functional/run_modes/06-run-mode-overrides/flow.cylc +new file mode 100644 +index 000000000..b76932321 +--- /dev/null ++++ b/tests/functional/run_modes/06-run-mode-overrides/flow.cylc +@@ -0,0 +1,29 @@ ++[scheduler] ++ cycle point format = %Y ++ ++[scheduling] ++ initial cycle point = 1000 ++ final cycle point = 1000 ++ [[graph]] ++ P1Y = """ ++ default_ ++ live_ ++ dummy_ ++ simulation_ ++ skip_ ++ """ ++ ++[runtime] ++ [[root]] ++ script = echo "=== this task ran in live mode ===" ++ [[[simulation]]] ++ default run length = PT0S ++ [[default_]] ++ [[live_]] ++ run mode = live ++ [[dummy_]] ++ run mode = dummy ++ [[simulation_]] ++ run mode = simulation ++ [[skip_]] ++ run mode = skip +diff --git a/tests/functional/modes/test_header b/tests/functional/run_modes/test_header +similarity index 100% +rename from tests/functional/modes/test_header +rename to tests/functional/run_modes/test_header +diff --git a/tests/integration/run_modes/test_mode_overrides.py b/tests/integration/run_modes/test_mode_overrides.py +new file mode 100644 +index 000000000..4eb133a82 +--- /dev/null ++++ b/tests/integration/run_modes/test_mode_overrides.py +@@ -0,0 +1,109 @@ ++# 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 that using [runtime][TASK]run mode works in each mode. ++ ++TODO: This is pretty much a functional test and ++probably ought to be labelled as such, but uses the ++integration test framework. ++""" ++ ++import pytest ++ ++ ++@pytest.mark.parametrize( ++ 'workflow_run_mode', [('live'), ('simulation'), ('dummy')]) #, ('skip')]) ++async def test_run_mode_override( ++ workflow_run_mode, flow, scheduler, run, complete, log_filter ++): ++ """Test that ``[runtime][TASK]run mode`` overrides workflow modes. ++ ++ Can only be run for tasks which run in ghost modes. ++ """ ++ default_ = ( ++ '\ndefault_' if workflow_run_mode in ['simulation', 'skip'] else '') ++ ++ cfg = { ++ "scheduler": {"cycle point format": "%Y"}, ++ "scheduling": { ++ "initial cycle point": "1000", ++ "final cycle point": "1000", ++ "graph": {"P1Y": f"sim_\nskip_{default_}"}}, ++ "runtime": { ++ "sim_": { ++ "run mode": "simulation", ++ 'simulation': {'default run length': 'PT0S'} ++ }, ++ "skip_": {"run mode": "skip"}, ++ } ++ } ++ id_ = flow(cfg) ++ schd = scheduler(id_, run_mode=workflow_run_mode, paused_start=False) ++ expect = ('[1000/sim_] run mode set by task settings to: simulation mode.') ++ ++ async with run(schd) as log: ++ await complete(schd) ++ if workflow_run_mode == 'simulation': ++ # No message in simulation mode. ++ assert not log_filter(log, contains=expect) ++ else: ++ assert log_filter(log, contains=expect) ++ ++ ++@pytest.mark.parametrize('mode', (('skip'), ('simulation'), ('dummy'))) ++async def test_force_trigger_does_not_override_run_mode( ++ flow, ++ scheduler, ++ start, ++ mode, ++): ++ """Force-triggering a task will not override the run mode. ++ ++ Tasks with run mode = skip will continue to abide by ++ the is_held flag as normal. ++ ++ Taken from spec at ++ https://github.com/cylc/cylc-admin/blob/master/ ++ docs/proposal-skip-mode.md#proposal ++ """ ++ wid = flow({ ++ 'scheduling': {'graph': {'R1': 'foo'}}, ++ 'runtime': {'foo': {'run mode': mode}} ++ }) ++ schd = scheduler(wid) ++ async with start(schd): ++ # Check that task isn't held at first ++ foo = schd.pool.get_tasks()[0] ++ assert foo.state.is_held is False ++ ++ # Hold task, check that it's held: ++ schd.pool.hold_tasks('1/foo') ++ assert foo.state.is_held is True ++ ++ # Trigger task, check that it's _still_ held: ++ schd.command_force_trigger_tasks('1/foo', [1]) ++ assert foo.state.is_held is True ++ ++ # run_mode will always be simulation from test ++ # workflow before submit routine... ++ assert foo.tdef.run_mode == 'simulation' ++ ++ # ... but job submission will always change this to the correct mode: ++ schd.task_job_mgr.submit_task_jobs( ++ schd.workflow, ++ [foo], ++ schd.server.curve_auth, ++ schd.server.client_pub_key_dir) ++ assert foo.tdef.run_mode == mode +diff --git a/tests/integration/test_simulation.py b/tests/integration/run_modes/test_simulation.py +similarity index 90% +rename from tests/integration/test_simulation.py +rename to tests/integration/run_modes/test_simulation.py +index 72cf23996..dd38a0eed 100644 +--- a/tests/integration/test_simulation.py ++++ b/tests/integration/run_modes/test_simulation.py +@@ -19,7 +19,7 @@ import pytest + from pytest import param + + from cylc.flow.cycling.iso8601 import ISO8601Point +-from cylc.flow.simulation import sim_time_check ++from cylc.flow.run_modes.simulation import sim_time_check + + + @pytest.fixture +@@ -27,7 +27,8 @@ def monkeytime(monkeypatch): + """Convenience function monkeypatching time.""" + def _inner(time_: int): + monkeypatch.setattr('cylc.flow.task_job_mgr.time', lambda: time_) +- monkeypatch.setattr('cylc.flow.simulation.time', lambda: time_) ++ monkeypatch.setattr( ++ 'cylc.flow.run_modes.simulation.time', lambda: time_) + return _inner + + +@@ -41,8 +42,8 @@ def run_simjob(monkeytime): + itask = schd.pool.get_task(point, task) + itask.state.is_queued = False + monkeytime(0) +- schd.task_job_mgr._simulation_submit_task_jobs( +- [itask], schd.workflow) ++ schd.task_job_mgr._nonlive_submit_task_jobs( ++ [itask], schd.workflow, 'simulation') + monkeytime(itask.mode_settings.timeout + 1) + + # Run Time Check +@@ -149,8 +150,8 @@ def test_fail_once(sim_time_check_setup, itask, point, results, monkeypatch): + + for i, result in enumerate(results): + itask.try_timers['execution-retry'].num = i +- schd.task_job_mgr._simulation_submit_task_jobs( +- [itask], schd.workflow) ++ schd.task_job_mgr._nonlive_submit_task_jobs( ++ [itask], schd.workflow, 'simulation') + assert itask.mode_settings.sim_task_fails is result + + +@@ -169,11 +170,11 @@ def test_task_finishes(sim_time_check_setup, monkeytime, caplog): + fail_all_1066 = schd.pool.get_task(ISO8601Point('1066'), 'fail_all') + fail_all_1066.state.status = 'running' + fail_all_1066.state.is_queued = False +- schd.task_job_mgr._simulation_submit_task_jobs( +- [fail_all_1066], schd.workflow) ++ schd.task_job_mgr._nonlive_submit_task_jobs( ++ [fail_all_1066], schd.workflow, 'simulation') + + # For the purpose of the test delete the started time set by +- # _simulation_submit_task_jobs. ++ # _nonlive_submit_task_jobs. + fail_all_1066.summary['started_time'] = 0 + + # Before simulation time is up: +@@ -200,8 +201,8 @@ def test_task_sped_up(sim_time_check_setup, monkeytime): + + # Run the job submission method: + monkeytime(0) +- schd.task_job_mgr._simulation_submit_task_jobs( +- [fast_forward_1066], schd.workflow) ++ schd.task_job_mgr._nonlive_submit_task_jobs( ++ [fast_forward_1066], schd.workflow, 'simulation') + fast_forward_1066.state.is_queued = False + + result = sim_time_check(schd.task_events_mgr, [fast_forward_1066], '') +@@ -254,8 +255,8 @@ async def test_settings_restart( + async with start(schd): + og_timeouts = {} + for itask in schd.pool.get_tasks(): +- schd.task_job_mgr._simulation_submit_task_jobs( +- [itask], schd.workflow) ++ schd.task_job_mgr._nonlive_submit_task_jobs( ++ [itask], schd.workflow, 'simulation') + + og_timeouts[itask.identity] = itask.mode_settings.timeout + +@@ -380,8 +381,8 @@ async def test_settings_broadcast( + itask.state.is_queued = False + + # Submit the first - the sim task will fail: +- schd.task_job_mgr._simulation_submit_task_jobs( +- [itask], schd.workflow) ++ schd.task_job_mgr._nonlive_submit_task_jobs( ++ [itask], schd.workflow, 'simulation') + assert itask.mode_settings.sim_task_fails is True + + # Let task finish. +@@ -399,14 +400,14 @@ async def test_settings_broadcast( + 'simulation': {'fail cycle points': ''} + }]) + # Submit again - result is different: +- schd.task_job_mgr._simulation_submit_task_jobs( +- [itask], schd.workflow) ++ schd.task_job_mgr._nonlive_submit_task_jobs( ++ [itask], schd.workflow, 'simulation') + assert itask.mode_settings.sim_task_fails is False + + # Assert Clearing the broadcast works + schd.broadcast_mgr.clear_broadcast() +- schd.task_job_mgr._simulation_submit_task_jobs( +- [itask], schd.workflow) ++ schd.task_job_mgr._nonlive_submit_task_jobs( ++ [itask], schd.workflow, 'simulation') + assert itask.mode_settings.sim_task_fails is True + + # Assert that list of broadcasts doesn't change if we submit +@@ -416,18 +417,22 @@ async def test_settings_broadcast( + ['1066'], ['one'], [{ + 'simulation': {'fail cycle points': 'higadfuhasgiurguj'} + }]) +- schd.task_job_mgr._simulation_submit_task_jobs( +- [itask], schd.workflow) ++ schd.task_job_mgr._nonlive_submit_task_jobs( ++ [itask], schd.workflow, 'simulation') + assert ( + 'Invalid ISO 8601 date representation: higadfuhasgiurguj' + in log.messages[-1]) + ++ # Check that the invalid broadcast hasn't ++ # changed the itask sim mode settings: ++ assert itask.mode_settings.sim_task_fails is True ++ + schd.broadcast_mgr.put_broadcast( + ['1066'], ['one'], [{ + 'simulation': {'fail cycle points': '1'} + }]) +- schd.task_job_mgr._simulation_submit_task_jobs( +- [itask], schd.workflow) ++ schd.task_job_mgr._nonlive_submit_task_jobs( ++ [itask], schd.workflow, 'simulation') + assert ( + 'Invalid ISO 8601 date representation: 1' + in log.messages[-1]) +@@ -438,8 +443,8 @@ async def test_settings_broadcast( + 'simulation': {'fail cycle points': '1945, 1977, 1066'}, + 'execution retry delays': '3*PT2S' + }]) +- schd.task_job_mgr._simulation_submit_task_jobs( +- [itask], schd.workflow) ++ schd.task_job_mgr._nonlive_submit_task_jobs( ++ [itask], schd.workflow, 'simulation') + assert itask.mode_settings.sim_task_fails is True + assert itask.try_timers['execution-retry'].delays == [2.0, 2.0, 2.0] + # n.b. rtconfig should remain unchanged, lest we cancel broadcasts: +diff --git a/tests/integration/test_config.py b/tests/integration/test_config.py +index 0a186aa41..a66d3741a 100644 +--- a/tests/integration/test_config.py ++++ b/tests/integration/test_config.py +@@ -15,7 +15,9 @@ + # along with this program. If not, see . + + from pathlib import Path ++import re + import sqlite3 ++from textwrap import dedent + from typing import Any + import pytest + +@@ -503,3 +505,54 @@ def test_special_task_non_word_names(flow: Fixture, validate: Fixture): + }, + }) + validate(wid) ++ ++ ++def test_nonlive_mode_validation(flow, validate, caplog): ++ """Nonlive tasks return a warning at validation. ++ """ ++ msg1 = dedent( ++ ''' The following tasks are set to run in non-live mode: ++ skip mode: ++ * skip ++ simulation mode: ++ * simulation ++ dummy mode: ++ * dummy''') ++ ++ msg2 = re.compile("Task skip has output") ++ ++ wid = flow({ ++ 'scheduling': { ++ 'graph': { ++ 'R1': 'live => skip => simulation => dummy => default' ++ } ++ }, ++ 'runtime': { ++ 'default': {}, ++ 'live': {'run mode': 'live'}, ++ 'simulation': {'run mode': 'simulation'}, ++ 'dummy': {'run mode': 'dummy'}, ++ 'skip': { ++ 'run mode': 'skip', ++ 'skip': {'outputs': 'started, submitted'} ++ }, ++ }, ++ }) ++ ++ validate(wid) ++ assert msg1 in caplog.messages ++ ++ messages = [msg for msg in caplog.messages if msg2.findall(msg)] ++ assert 'started' in messages[0] ++ assert 'submitted' in messages[0] ++ ++ ++@pytest.mark.parametrize('mode', (('simulation'), ('skip'), ('dummy'))) ++def test_nonlive_mode_forbidden_as_outputs(flow, validate, mode): ++ """Run mode names are forbidden as task output names.""" ++ wid = flow({ ++ 'scheduling': {'graph': {'R1': 'task'}}, ++ 'runtime': {'task': {'outputs': {mode: f'message for {mode}'}}} ++ }) ++ with pytest.raises(WorkflowConfigError, match=f'message for {mode}'): ++ validate(wid) +diff --git a/tests/unit/run_modes/test_dummy.py b/tests/unit/run_modes/test_dummy.py +new file mode 100644 +index 000000000..998c13767 +--- /dev/null ++++ b/tests/unit/run_modes/test_dummy.py +@@ -0,0 +1,40 @@ ++# 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 . ++"""Tests for utilities supporting dummy mode. ++""" ++import pytest ++from cylc.flow.run_modes.dummy import build_dummy_script ++ ++ ++@pytest.mark.parametrize( ++ 'fail_one_time_only', (True, False) ++) ++def test_build_dummy_script(fail_one_time_only): ++ rtc = { ++ 'outputs': {'foo': '1', 'bar': '2'}, ++ 'simulation': { ++ 'fail try 1 only': fail_one_time_only, ++ 'fail cycle points': '1', ++ } ++ } ++ result = build_dummy_script(rtc, 60) ++ assert result.split('\n') == [ ++ 'sleep 60', ++ "cylc message '1'", ++ "cylc message '2'", ++ f"cylc__job__dummy_result {str(fail_one_time_only).lower()}" ++ " 1 || exit 1" ++ ] +diff --git a/tests/unit/run_modes/test_nonlive.py b/tests/unit/run_modes/test_nonlive.py +new file mode 100644 +index 000000000..34c01151e +--- /dev/null ++++ b/tests/unit/run_modes/test_nonlive.py +@@ -0,0 +1,45 @@ ++# 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 . ++"""Unit tests for utilities supporting all nonlive modes ++""" ++ ++from types import SimpleNamespace ++ ++from cylc.flow.run_modes.nonlive import mode_validate_checks ++ ++ ++def test_mode_validate_checks(monkeypatch, caplog): ++ """It warns us if we've set a task config to nonlive mode. ++ ++ (And not otherwise) ++ """ ++ taskdefs = { ++ f'{run_mode}_task': SimpleNamespace( ++ rtconfig={'run mode': run_mode} ++ ) ++ for run_mode ++ in ['live', 'workflow', 'dummy', 'simulation', 'skip'] ++ } ++ ++ mode_validate_checks(taskdefs) ++ ++ message = caplog.messages[0] ++ ++ assert 'skip mode:\n * skip_task' in message ++ assert 'simulation mode:\n * simulation_task' in message ++ assert 'dummy mode:\n * dummy_task' in message ++ assert ' live mode' not in message # Avoid matching "non-live mode" ++ assert 'workflow mode' not in message +diff --git a/tests/unit/test_simulation.py b/tests/unit/run_modes/test_simulation.py +similarity index 86% +rename from tests/unit/test_simulation.py +rename to tests/unit/run_modes/test_simulation.py +index 920a87250..109174c8b 100644 +--- a/tests/unit/test_simulation.py ++++ b/tests/unit/run_modes/test_simulation.py +@@ -20,9 +20,8 @@ from pytest import param + + from cylc.flow.cycling.integer import IntegerPoint + from cylc.flow.cycling.iso8601 import ISO8601Point +-from cylc.flow.simulation import ( ++from cylc.flow.run_modes.simulation import ( + parse_fail_cycle_points, +- build_dummy_script, + disable_platforms, + get_simulated_run_len, + sim_task_failed, +@@ -56,27 +55,6 @@ def test_get_simulated_run_len( + assert get_simulated_run_len(rtc) == 3600 + + +-@pytest.mark.parametrize( +- 'fail_one_time_only', (True, False) +-) +-def test_set_simulation_script(fail_one_time_only): +- rtc = { +- 'outputs': {'foo': '1', 'bar': '2'}, +- 'simulation': { +- 'fail try 1 only': fail_one_time_only, +- 'fail cycle points': '1', +- } +- } +- result = build_dummy_script(rtc, 60) +- assert result.split('\n') == [ +- 'sleep 60', +- "cylc message '1'", +- "cylc message '2'", +- f"cylc__job__dummy_result {str(fail_one_time_only).lower()}" +- " 1 || exit 1" +- ] +- +- + @pytest.mark.parametrize( + 'rtc, expect', ( + ({'platform': 'skarloey'}, 'localhost'), +@@ -100,7 +78,7 @@ def test_disable_platforms(rtc, expect): + def test_parse_fail_cycle_points(set_cycling_type): + before = ['2', '4'] + set_cycling_type() +- assert parse_fail_cycle_points(before) == [ ++ assert parse_fail_cycle_points(before, ['']) == [ + IntegerPoint(i) for i in before + ] + +diff --git a/tests/unit/run_modes/test_skip.py b/tests/unit/run_modes/test_skip.py +new file mode 100644 +index 000000000..c2791220f +--- /dev/null ++++ b/tests/unit/run_modes/test_skip.py +@@ -0,0 +1,119 @@ ++# 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 . ++"""Unit tests for utilities supporting skip modes ++""" ++import pytest ++from pytest import param, raises ++from types import SimpleNamespace ++ ++from cylc.flow.exceptions import WorkflowConfigError ++from cylc.flow.run_modes.skip import check_task_skip_config, process_outputs ++ ++ ++@pytest.mark.parametrize( ++ 'conf', ++ ( ++ param({}, id='no-skip-config'), ++ param({'skip': {'outputs': []}}, id='no-skip-outputs'), ++ param({'skip': {'outputs': ['foo1', 'failed']}}, id='ok-skip-outputs'), ++ ) ++) ++def test_good_check_task_skip_config(conf): ++ """It returns none if the problems this function checks are not present. ++ """ ++ tdef = SimpleNamespace(rtconfig=conf) ++ assert check_task_skip_config('foo', tdef) is None ++ ++ ++def test_raises_check_task_skip_config(): ++ """It raises an error if succeeded and failed are set. ++ """ ++ tdef = SimpleNamespace( ++ rtconfig={'skip': {'outputs': ['foo1', 'failed', 'succeeded']}} ++ ) ++ with raises(WorkflowConfigError, match='succeeded AND failed'): ++ check_task_skip_config('foo', tdef) ++ ++ ++@pytest.mark.parametrize( ++ 'conf', ++ ( ++ param({'skip': {'outputs': ['foo1', 'started']}}, id='started'), ++ param({'skip': {'outputs': ['foo1', 'submitted']}}, id='started'), ++ param({'skip': {'outputs': ['submitted', 'started']}}, id='both'), ++ ) ++) ++def test_warns_check_task_skip_config(conf, caplog): ++ """It returns none but logs a warning if started or submitted are present ++ in the task outputs. ++ """ ++ tdef = SimpleNamespace(rtconfig=conf) ++ assert check_task_skip_config('foo', tdef) is None ++ assert 'need not be set' in caplog.messages[0] ++ ++ ++@pytest.mark.parametrize( ++ 'outputs, required, expect', ++ ( ++ param([], [], ['succeeded'], id='implicit-succeded'), ++ param( ++ ['succeeded'], ['succeeded'], ['succeeded'], ++ id='explicit-succeded' ++ ), ++ param(['submitted'], [], ['succeeded'], id='only-1-submit'), ++ param( ++ ['foo', 'bar', 'baz', 'qux'], ++ ['bar', 'qux'], ++ ['bar', 'qux', 'succeeded'], ++ id='required-only' ++ ), ++ param( ++ ['foo', 'baz'], ++ ['bar', 'qux'], ++ ['succeeded'], ++ id='no-required' ++ ), ++ param( ++ ['failed'], ++ [], ++ ['failed'], ++ id='explicit-failed' ++ ), ++ ) ++) ++def test_process_outputs(outputs, required, expect): ++ """Check that skip outputs: ++ ++ 1. Doesn't send submitted twice. ++ 2. Sends every required output. ++ 3. If failed is set send failed ++ 4. If failed in not set send succeeded. ++ ++ n.b: The real process message function sends the TASK_OUTPUT_STARTED ++ message for free, so there is no reference to that here. ++ """ ++ ++ ++ # Create a mocked up task-proxy: ++ itask = SimpleNamespace( ++ tdef=SimpleNamespace( ++ rtconfig={'skip': {'outputs': outputs}}), ++ state=SimpleNamespace( ++ outputs=SimpleNamespace( ++ _required={v: v for v in required} ++ ))) ++ ++ assert process_outputs(itask) == ['submitted'] + expect +diff --git a/tests/unit/scripts/test_lint.py b/tests/unit/scripts/test_lint.py +index 0f5f02648..2b0d87364 100644 +--- a/tests/unit/scripts/test_lint.py ++++ b/tests/unit/scripts/test_lint.py +@@ -179,7 +179,10 @@ something\t + [[bar]] + platform = $(some-script foo) + [[baz]] ++ run mode = skip + platform = `no backticks` ++ [[[skip]]] ++ outputs = succeeded, failed + """ + ( + '\nscript = the quick brown fox jumps over the lazy dog until it becomes ' + 'clear that this line is longer than the default 130 character limit.' +diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py +index 98d9fc2f4..62bc63de5 100644 +--- a/tests/unit/test_config.py ++++ b/tests/unit/test_config.py +@@ -14,16 +14,14 @@ + # You should have received a copy of the GNU General Public License + # along with this program. If not, see . + +-from copy import deepcopy + import os + import sys + from optparse import Values +-from typing import Any, Callable, Dict, List, Optional, Tuple, Type +-from pathlib import Path ++from typing import ( ++ TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Type) + import pytest + import logging + from types import SimpleNamespace +-from unittest.mock import Mock + from contextlib import suppress + + from cylc.flow import CYLC_LOG +@@ -39,7 +37,6 @@ from cylc.flow.exceptions import ( + from cylc.flow.parsec.exceptions import Jinja2Error, EmPyError + from cylc.flow.scheduler_cli import RunOptions + from cylc.flow.scripts.validate import ValidateOptions +-from cylc.flow.simulation import configure_sim_modes + from cylc.flow.workflow_files import WorkflowFiles + from cylc.flow.wallclock import get_utc_mode, set_utc_mode + from cylc.flow.xtrigger_mgr import XtriggerManager +@@ -50,8 +47,9 @@ from cylc.flow.task_outputs import ( + + from cylc.flow.cycling.iso8601 import ISO8601Point + +- +-Fixture = Any ++if TYPE_CHECKING: ++ from pathlib import Path ++ Fixture = Any + + + def _tmp_flow_config(tmp_run_dir: Callable): +@@ -63,8 +61,8 @@ def _tmp_flow_config(tmp_run_dir: Callable): + + Returns the path to the flow file. + """ +- def __tmp_flow_config(id_: str, config: str) -> Path: +- run_dir: Path = tmp_run_dir(id_) ++ def __tmp_flow_config(id_: str, config: str) -> 'Path': ++ run_dir: 'Path' = tmp_run_dir(id_) + flow_file = run_dir / WorkflowFiles.FLOW_FILE + flow_file.write_text(config) + return flow_file +@@ -85,7 +83,7 @@ class TestWorkflowConfig: + """Test class for the Cylc WorkflowConfig object.""" + + def test_xfunction_imports( +- self, mock_glbl_cfg: Fixture, tmp_path: Path, ++ self, mock_glbl_cfg: 'Fixture', tmp_path: 'Path', + xtrigger_mgr: XtriggerManager): + """Test for a workflow configuration with valid xtriggers""" + mock_glbl_cfg( +@@ -363,7 +361,7 @@ def test_process_icp( + expected_icp: Optional[str], + expected_opt_icp: Optional[str], + expected_err: Optional[Tuple[Type[Exception], str]], +- monkeypatch: pytest.MonkeyPatch, set_cycling_type: Fixture ++ monkeypatch: pytest.MonkeyPatch, set_cycling_type: 'Fixture' + ) -> None: + """Test WorkflowConfig.process_initial_cycle_point(). + +@@ -450,7 +448,7 @@ def test_process_startcp( + starttask: Optional[str], + expected: str, + expected_err: Optional[Tuple[Type[Exception], str]], +- monkeypatch: pytest.MonkeyPatch, set_cycling_type: Fixture ++ monkeypatch: pytest.MonkeyPatch, set_cycling_type: 'Fixture' + ) -> None: + """Test WorkflowConfig.process_start_cycle_point(). + +@@ -653,7 +651,7 @@ def test_process_fcp( + options_fcp: Optional[str], + expected_fcp: Optional[str], + expected_err: Optional[Tuple[Type[Exception], str]], +- set_cycling_type: Fixture ++ set_cycling_type: 'Fixture' + ) -> None: + """Test WorkflowConfig.process_final_cycle_point(). + +@@ -817,7 +815,7 @@ def test_stopcp_after_fcp( + cycle point is handled correctly.""" + caplog.set_level(logging.WARNING, CYLC_LOG) + id_ = 'cassini' +- flow_file: Path = tmp_flow_config(id_, f""" ++ flow_file: 'Path' = tmp_flow_config(id_, f""" + [scheduler] + allow implicit tasks = True + [scheduling] +@@ -1393,7 +1391,7 @@ def test_implicit_tasks( + """ + # Setup + id_ = 'rincewind' +- flow_file: Path = tmp_flow_config(id_, f""" ++ flow_file: 'Path' = tmp_flow_config(id_, f""" + [scheduler] + { + f'allow implicit tasks = {allow_implicit_tasks}' +@@ -1497,7 +1495,7 @@ def test_zero_interval( + """Test that a zero-duration recurrence with >1 repetition gets an + appropriate warning.""" + id_ = 'ordinary' +- flow_file: Path = tmp_flow_config(id_, f""" ++ flow_file: 'Path' = tmp_flow_config(id_, f""" + [scheduler] + UTC mode = True + allow implicit tasks = True +@@ -1541,7 +1539,7 @@ def test_chain_expr( + Note the order matters when "nominal" units (years, months) are used. + """ + id_ = 'osgiliath' +- flow_file: Path = tmp_flow_config(id_, f""" ++ flow_file: 'Path' = tmp_flow_config(id_, f""" + [scheduler] + UTC mode = True + allow implicit tasks = True +@@ -1720,7 +1718,7 @@ def test__warn_if_queues_have_implicit_tasks(caplog): + ] + ) + def test_cylc_env_at_parsing( +- tmp_path: Path, ++ tmp_path: 'Path', + monkeypatch: pytest.MonkeyPatch, + installed, + run_dir, +diff --git a/tests/unit/test_task_state.py b/tests/unit/test_task_state.py +index e655c74b7..106f4f8e2 100644 +--- a/tests/unit/test_task_state.py ++++ b/tests/unit/test_task_state.py +@@ -15,11 +15,13 @@ + # along with this program. If not, see . + + import pytest ++from types import SimpleNamespace + + from cylc.flow.taskdef import TaskDef + from cylc.flow.cycling.integer import IntegerSequence, IntegerPoint + from cylc.flow.task_trigger import Dependency, TaskTrigger + from cylc.flow.task_state import ( ++ RunMode, + TaskState, + TASK_STATUS_PREPARING, + TASK_STATUS_SUBMIT_FAILED, +@@ -119,3 +121,29 @@ def test_task_state_order(): + assert not tstate.is_gt(TASK_STATUS_RUNNING) + assert not tstate.is_gte(TASK_STATUS_RUNNING) + ++ ++@pytest.mark.parametrize( ++ 'itask_run_mode, disable_handlers, expect', ++ ( ++ ('live', True, False), ++ ('live', False, False), ++ ('dummy', True, False), ++ ('dummy', False, False), ++ ('simulation', True, True), ++ ('simulation', False, True), ++ ('skip', True, True), ++ ('skip', False, False), ++ ) ++) ++def test_disable_task_event_handlers(itask_run_mode, disable_handlers, expect): ++ """Conditions under which task event handlers should not be used. ++ """ ++ # Construct a fake itask object: ++ itask = SimpleNamespace( ++ tdef=SimpleNamespace( ++ run_mode=itask_run_mode, ++ rtconfig={ ++ 'skip': {'disable task event handlers': disable_handlers}}) ++ ) ++ # Check method: ++ assert RunMode.disable_task_event_handlers(itask) is expect diff --git a/tests/functional/cylc-config/00-simple/section2.stdout b/tests/functional/cylc-config/00-simple/section2.stdout index 4d3989c8387..77cbe5cffcb 100644 --- a/tests/functional/cylc-config/00-simple/section2.stdout +++ b/tests/functional/cylc-config/00-simple/section2.stdout @@ -1,12 +1,12 @@ [[root]] platform = inherit = + script = init-script = env-script = err-script = exit-script = pre-script = - script = post-script = work sub-directory = execution polling intervals = @@ -14,10 +14,14 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -90,10 +94,14 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -166,10 +174,14 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -229,12 +241,12 @@ [[SERIAL]] platform = inherit = + script = init-script = env-script = err-script = exit-script = pre-script = - script = post-script = work sub-directory = execution polling intervals = @@ -242,12 +254,16 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[directives]]] job_type = serial [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -306,12 +322,12 @@ [[PARALLEL]] platform = inherit = + script = init-script = env-script = err-script = exit-script = pre-script = - script = post-script = work sub-directory = execution polling intervals = @@ -319,12 +335,16 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[directives]]] job_type = parallel [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -396,12 +416,16 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[directives]]] job_type = serial [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -473,12 +497,16 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[directives]]] job_type = serial [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -550,12 +578,16 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[directives]]] job_type = parallel [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -627,12 +659,16 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[directives]]] job_type = parallel [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -704,12 +740,16 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[directives]]] job_type = serial [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -781,12 +821,16 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[directives]]] job_type = serial [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -858,12 +902,16 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[directives]]] job_type = parallel [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = @@ -935,12 +983,16 @@ execution time limit = submission polling intervals = submission retry delays = + run mode = workflow [[[directives]]] job_type = parallel [[[meta]]] title = description = URL = + [[[skip]]] + outputs = + disable task event handlers = True [[[simulation]]] default run length = PT10S speedup factor = diff --git a/tests/functional/cylc-set/09-set-skip.t b/tests/functional/cylc-set/09-set-skip.t new file mode 100644 index 00000000000..dd314283700 --- /dev/null +++ b/tests/functional/cylc-set/09-set-skip.t @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# 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 . +#------------------------------------------------------------------------------- +# +# Skip Mode proposal example: +# https://github.com/cylc/cylc-admin/blob/master/docs/proposal-skip-mode.md +# The cylc set --out option should accept the skip value +# which should set the outputs defined in +# [runtime][][skip]outputs. + +. "$(dirname "$0")/test_header" +set_test_number 2 +reftest +exit diff --git a/tests/functional/cylc-set/09-set-skip/flow.cylc b/tests/functional/cylc-set/09-set-skip/flow.cylc new file mode 100644 index 00000000000..ef74c362773 --- /dev/null +++ b/tests/functional/cylc-set/09-set-skip/flow.cylc @@ -0,0 +1,50 @@ +[meta] + test_description = """ + Test that cylc set --out skip satisfies + all outputs which are required by the graph. + """ + proposal url = https://github.com/cylc/cylc-admin/blob/master/docs/proposal-skip-mode.md + +[scheduler] + allow implicit tasks = true + [[events]] + expected task failures = 1/bar + +[scheduling] + [[graph]] + R1 = """ + # Optional out not created by set --out skip + foo:no? => not_this_task? + + # set --out skip creates required, started, submitted + # and succeeded (unless failed is set): + foo:yes => require_this_task + foo:submitted => submitted_emitted + foo:succeeded => succeeded_emitted + foo:started => skip_foo + + # set --out skip creates failed if that is required + # by skip mode settings: + bar:started => skip_bar + bar:failed? => bar_failed + """ + +[runtime] + [[foo]] + script = sleep 100 + [[[skip]]] + outputs = yes + [[[outputs]]] + no = 'Don\'t require this task' + yes = 'Require this task' + + [[bar]] + script = sleep 100 + [[[skip]]] + outputs = failed + + [[skip_foo]] + script = cylc set ${CYLC_WORKFLOW_ID}//1/foo --out skip + + [[skip_bar]] + script = cylc set ${CYLC_WORKFLOW_ID}//1/bar --out skip diff --git a/tests/functional/cylc-set/09-set-skip/reference.log b/tests/functional/cylc-set/09-set-skip/reference.log new file mode 100644 index 00000000000..6e7b636f540 --- /dev/null +++ b/tests/functional/cylc-set/09-set-skip/reference.log @@ -0,0 +1,8 @@ +1/bar -triggered off [] in flow 1 +1/foo -triggered off [] in flow 1 +1/submitted_emitted -triggered off ['1/foo'] in flow 1 +1/skip_bar -triggered off ['1/bar'] in flow 1 +1/skip_foo -triggered off ['1/foo'] in flow 1 +1/succeeded_emitted -triggered off ['1/foo'] in flow 1 +1/bar_failed -triggered off ['1/bar'] in flow 1 +1/require_this_task -triggered off ['1/foo'] in flow 1 diff --git a/tests/functional/modes/01-dummy.t b/tests/functional/run_modes/01-dummy.t similarity index 100% rename from tests/functional/modes/01-dummy.t rename to tests/functional/run_modes/01-dummy.t diff --git a/tests/functional/modes/01-dummy/flow.cylc b/tests/functional/run_modes/01-dummy/flow.cylc similarity index 100% rename from tests/functional/modes/01-dummy/flow.cylc rename to tests/functional/run_modes/01-dummy/flow.cylc diff --git a/tests/functional/modes/01-dummy/reference.log b/tests/functional/run_modes/01-dummy/reference.log similarity index 100% rename from tests/functional/modes/01-dummy/reference.log rename to tests/functional/run_modes/01-dummy/reference.log diff --git a/tests/functional/modes/02-dummy-message-outputs.t b/tests/functional/run_modes/02-dummy-message-outputs.t similarity index 100% rename from tests/functional/modes/02-dummy-message-outputs.t rename to tests/functional/run_modes/02-dummy-message-outputs.t diff --git a/tests/functional/modes/02-dummy-message-outputs/flow.cylc b/tests/functional/run_modes/02-dummy-message-outputs/flow.cylc similarity index 100% rename from tests/functional/modes/02-dummy-message-outputs/flow.cylc rename to tests/functional/run_modes/02-dummy-message-outputs/flow.cylc diff --git a/tests/functional/modes/02-dummy-message-outputs/reference.log b/tests/functional/run_modes/02-dummy-message-outputs/reference.log similarity index 100% rename from tests/functional/modes/02-dummy-message-outputs/reference.log rename to tests/functional/run_modes/02-dummy-message-outputs/reference.log diff --git a/tests/functional/modes/03-simulation.t b/tests/functional/run_modes/03-simulation.t similarity index 100% rename from tests/functional/modes/03-simulation.t rename to tests/functional/run_modes/03-simulation.t diff --git a/tests/functional/modes/03-simulation/flow.cylc b/tests/functional/run_modes/03-simulation/flow.cylc similarity index 100% rename from tests/functional/modes/03-simulation/flow.cylc rename to tests/functional/run_modes/03-simulation/flow.cylc diff --git a/tests/functional/modes/03-simulation/reference.log b/tests/functional/run_modes/03-simulation/reference.log similarity index 100% rename from tests/functional/modes/03-simulation/reference.log rename to tests/functional/run_modes/03-simulation/reference.log diff --git a/tests/functional/modes/04-simulation-runtime.t b/tests/functional/run_modes/04-simulation-runtime.t similarity index 100% rename from tests/functional/modes/04-simulation-runtime.t rename to tests/functional/run_modes/04-simulation-runtime.t diff --git a/tests/functional/modes/04-simulation-runtime/flow.cylc b/tests/functional/run_modes/04-simulation-runtime/flow.cylc similarity index 100% rename from tests/functional/modes/04-simulation-runtime/flow.cylc rename to tests/functional/run_modes/04-simulation-runtime/flow.cylc diff --git a/tests/functional/modes/04-simulation-runtime/reference.log b/tests/functional/run_modes/04-simulation-runtime/reference.log similarity index 100% rename from tests/functional/modes/04-simulation-runtime/reference.log rename to tests/functional/run_modes/04-simulation-runtime/reference.log diff --git a/tests/functional/modes/05-sim-trigger.t b/tests/functional/run_modes/05-sim-trigger.t similarity index 100% rename from tests/functional/modes/05-sim-trigger.t rename to tests/functional/run_modes/05-sim-trigger.t diff --git a/tests/functional/modes/05-sim-trigger/flow.cylc b/tests/functional/run_modes/05-sim-trigger/flow.cylc similarity index 100% rename from tests/functional/modes/05-sim-trigger/flow.cylc rename to tests/functional/run_modes/05-sim-trigger/flow.cylc diff --git a/tests/functional/modes/05-sim-trigger/reference.log b/tests/functional/run_modes/05-sim-trigger/reference.log similarity index 100% rename from tests/functional/modes/05-sim-trigger/reference.log rename to tests/functional/run_modes/05-sim-trigger/reference.log diff --git a/tests/functional/run_modes/06-run-mode-overrides.t b/tests/functional/run_modes/06-run-mode-overrides.t new file mode 100644 index 00000000000..2e90deaa460 --- /dev/null +++ b/tests/functional/run_modes/06-run-mode-overrides.t @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# 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 that broadcasting a change in [runtime][]run mode +# Leads to the next submission from that task to be in the updated +# mode. + +. "$(dirname "$0")/test_header" +set_test_number 15 + +install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" +run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" +workflow_run_ok "${TEST_NAME_BASE}-run" \ + cylc play "${WORKFLOW_NAME}" \ + --no-detach + +JOB_LOGS="${WORKFLOW_RUN_DIR}/log/job/1000" + +# Ghost modes do not leave log folders: +for MODE in simulation skip; do + run_fail "${TEST_NAME_BASE}-no-${MODE}-task-folder" ls "${JOB_LOGS}/${MODE}_" +done + +# Live modes leave log folders: +for MODE in default live dummy; do + run_ok "${TEST_NAME_BASE}-${MODE}-task-folder" ls "${JOB_LOGS}/${MODE}_" +done + +# Default defaults to live, and live is live: +for MODE in default live; do + named_grep_ok "${TEST_NAME_BASE}-default-task-live" "===.*===" "${JOB_LOGS}/${MODE}_/NN/job.out" +done + +# Dummy produces a job.out, containing dummy message: +named_grep_ok "${TEST_NAME_BASE}-default-task-live" "dummy job succeed" "${JOB_LOGS}/dummy_/NN/job.out" + +purge + +# Do it again with a workflow in simulation. +install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" +run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" +workflow_run_ok "${TEST_NAME_BASE}-run" \ + cylc play "${WORKFLOW_NAME}" \ + --no-detach \ + --mode simulation + +JOB_LOGS="${WORKFLOW_RUN_DIR}/log/job/1000" + +# Live modes leave log folders: +for MODE in live dummy; do + run_ok "${TEST_NAME_BASE}-${MODE}-task-folder" ls "${JOB_LOGS}/${MODE}_" +done + +# Ghost modes do not leave log folders: +run_fail "${TEST_NAME_BASE}-no-default-task-folder" ls "${JOB_LOGS}/default_" + +purge +exit 0 diff --git a/tests/functional/run_modes/06-run-mode-overrides/flow.cylc b/tests/functional/run_modes/06-run-mode-overrides/flow.cylc new file mode 100644 index 00000000000..b7693232149 --- /dev/null +++ b/tests/functional/run_modes/06-run-mode-overrides/flow.cylc @@ -0,0 +1,29 @@ +[scheduler] + cycle point format = %Y + +[scheduling] + initial cycle point = 1000 + final cycle point = 1000 + [[graph]] + P1Y = """ + default_ + live_ + dummy_ + simulation_ + skip_ + """ + +[runtime] + [[root]] + script = echo "=== this task ran in live mode ===" + [[[simulation]]] + default run length = PT0S + [[default_]] + [[live_]] + run mode = live + [[dummy_]] + run mode = dummy + [[simulation_]] + run mode = simulation + [[skip_]] + run mode = skip diff --git a/tests/functional/modes/test_header b/tests/functional/run_modes/test_header similarity index 100% rename from tests/functional/modes/test_header rename to tests/functional/run_modes/test_header diff --git a/tests/integration/run_modes/test_mode_overrides.py b/tests/integration/run_modes/test_mode_overrides.py new file mode 100644 index 00000000000..4eb133a82b9 --- /dev/null +++ b/tests/integration/run_modes/test_mode_overrides.py @@ -0,0 +1,109 @@ +# 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 that using [runtime][TASK]run mode works in each mode. + +TODO: This is pretty much a functional test and +probably ought to be labelled as such, but uses the +integration test framework. +""" + +import pytest + + +@pytest.mark.parametrize( + 'workflow_run_mode', [('live'), ('simulation'), ('dummy')]) #, ('skip')]) +async def test_run_mode_override( + workflow_run_mode, flow, scheduler, run, complete, log_filter +): + """Test that ``[runtime][TASK]run mode`` overrides workflow modes. + + Can only be run for tasks which run in ghost modes. + """ + default_ = ( + '\ndefault_' if workflow_run_mode in ['simulation', 'skip'] else '') + + cfg = { + "scheduler": {"cycle point format": "%Y"}, + "scheduling": { + "initial cycle point": "1000", + "final cycle point": "1000", + "graph": {"P1Y": f"sim_\nskip_{default_}"}}, + "runtime": { + "sim_": { + "run mode": "simulation", + 'simulation': {'default run length': 'PT0S'} + }, + "skip_": {"run mode": "skip"}, + } + } + id_ = flow(cfg) + schd = scheduler(id_, run_mode=workflow_run_mode, paused_start=False) + expect = ('[1000/sim_] run mode set by task settings to: simulation mode.') + + async with run(schd) as log: + await complete(schd) + if workflow_run_mode == 'simulation': + # No message in simulation mode. + assert not log_filter(log, contains=expect) + else: + assert log_filter(log, contains=expect) + + +@pytest.mark.parametrize('mode', (('skip'), ('simulation'), ('dummy'))) +async def test_force_trigger_does_not_override_run_mode( + flow, + scheduler, + start, + mode, +): + """Force-triggering a task will not override the run mode. + + Tasks with run mode = skip will continue to abide by + the is_held flag as normal. + + Taken from spec at + https://github.com/cylc/cylc-admin/blob/master/ + docs/proposal-skip-mode.md#proposal + """ + wid = flow({ + 'scheduling': {'graph': {'R1': 'foo'}}, + 'runtime': {'foo': {'run mode': mode}} + }) + schd = scheduler(wid) + async with start(schd): + # Check that task isn't held at first + foo = schd.pool.get_tasks()[0] + assert foo.state.is_held is False + + # Hold task, check that it's held: + schd.pool.hold_tasks('1/foo') + assert foo.state.is_held is True + + # Trigger task, check that it's _still_ held: + schd.command_force_trigger_tasks('1/foo', [1]) + assert foo.state.is_held is True + + # run_mode will always be simulation from test + # workflow before submit routine... + assert foo.tdef.run_mode == 'simulation' + + # ... but job submission will always change this to the correct mode: + schd.task_job_mgr.submit_task_jobs( + schd.workflow, + [foo], + schd.server.curve_auth, + schd.server.client_pub_key_dir) + assert foo.tdef.run_mode == mode diff --git a/tests/integration/test_simulation.py b/tests/integration/run_modes/test_simulation.py similarity index 90% rename from tests/integration/test_simulation.py rename to tests/integration/run_modes/test_simulation.py index 72cf23996a4..dd38a0eed59 100644 --- a/tests/integration/test_simulation.py +++ b/tests/integration/run_modes/test_simulation.py @@ -19,7 +19,7 @@ from pytest import param from cylc.flow.cycling.iso8601 import ISO8601Point -from cylc.flow.simulation import sim_time_check +from cylc.flow.run_modes.simulation import sim_time_check @pytest.fixture @@ -27,7 +27,8 @@ def monkeytime(monkeypatch): """Convenience function monkeypatching time.""" def _inner(time_: int): monkeypatch.setattr('cylc.flow.task_job_mgr.time', lambda: time_) - monkeypatch.setattr('cylc.flow.simulation.time', lambda: time_) + monkeypatch.setattr( + 'cylc.flow.run_modes.simulation.time', lambda: time_) return _inner @@ -41,8 +42,8 @@ def _run_simjob(schd, point, task): itask = schd.pool.get_task(point, task) itask.state.is_queued = False monkeytime(0) - schd.task_job_mgr._simulation_submit_task_jobs( - [itask], schd.workflow) + schd.task_job_mgr._nonlive_submit_task_jobs( + [itask], schd.workflow, 'simulation') monkeytime(itask.mode_settings.timeout + 1) # Run Time Check @@ -149,8 +150,8 @@ def test_fail_once(sim_time_check_setup, itask, point, results, monkeypatch): for i, result in enumerate(results): itask.try_timers['execution-retry'].num = i - schd.task_job_mgr._simulation_submit_task_jobs( - [itask], schd.workflow) + schd.task_job_mgr._nonlive_submit_task_jobs( + [itask], schd.workflow, 'simulation') assert itask.mode_settings.sim_task_fails is result @@ -169,11 +170,11 @@ def test_task_finishes(sim_time_check_setup, monkeytime, caplog): fail_all_1066 = schd.pool.get_task(ISO8601Point('1066'), 'fail_all') fail_all_1066.state.status = 'running' fail_all_1066.state.is_queued = False - schd.task_job_mgr._simulation_submit_task_jobs( - [fail_all_1066], schd.workflow) + schd.task_job_mgr._nonlive_submit_task_jobs( + [fail_all_1066], schd.workflow, 'simulation') # For the purpose of the test delete the started time set by - # _simulation_submit_task_jobs. + # _nonlive_submit_task_jobs. fail_all_1066.summary['started_time'] = 0 # Before simulation time is up: @@ -200,8 +201,8 @@ def test_task_sped_up(sim_time_check_setup, monkeytime): # Run the job submission method: monkeytime(0) - schd.task_job_mgr._simulation_submit_task_jobs( - [fast_forward_1066], schd.workflow) + schd.task_job_mgr._nonlive_submit_task_jobs( + [fast_forward_1066], schd.workflow, 'simulation') fast_forward_1066.state.is_queued = False result = sim_time_check(schd.task_events_mgr, [fast_forward_1066], '') @@ -254,8 +255,8 @@ async def test_settings_restart( async with start(schd): og_timeouts = {} for itask in schd.pool.get_tasks(): - schd.task_job_mgr._simulation_submit_task_jobs( - [itask], schd.workflow) + schd.task_job_mgr._nonlive_submit_task_jobs( + [itask], schd.workflow, 'simulation') og_timeouts[itask.identity] = itask.mode_settings.timeout @@ -380,8 +381,8 @@ async def test_settings_broadcast( itask.state.is_queued = False # Submit the first - the sim task will fail: - schd.task_job_mgr._simulation_submit_task_jobs( - [itask], schd.workflow) + schd.task_job_mgr._nonlive_submit_task_jobs( + [itask], schd.workflow, 'simulation') assert itask.mode_settings.sim_task_fails is True # Let task finish. @@ -399,14 +400,14 @@ async def test_settings_broadcast( 'simulation': {'fail cycle points': ''} }]) # Submit again - result is different: - schd.task_job_mgr._simulation_submit_task_jobs( - [itask], schd.workflow) + schd.task_job_mgr._nonlive_submit_task_jobs( + [itask], schd.workflow, 'simulation') assert itask.mode_settings.sim_task_fails is False # Assert Clearing the broadcast works schd.broadcast_mgr.clear_broadcast() - schd.task_job_mgr._simulation_submit_task_jobs( - [itask], schd.workflow) + schd.task_job_mgr._nonlive_submit_task_jobs( + [itask], schd.workflow, 'simulation') assert itask.mode_settings.sim_task_fails is True # Assert that list of broadcasts doesn't change if we submit @@ -416,18 +417,22 @@ async def test_settings_broadcast( ['1066'], ['one'], [{ 'simulation': {'fail cycle points': 'higadfuhasgiurguj'} }]) - schd.task_job_mgr._simulation_submit_task_jobs( - [itask], schd.workflow) + schd.task_job_mgr._nonlive_submit_task_jobs( + [itask], schd.workflow, 'simulation') assert ( 'Invalid ISO 8601 date representation: higadfuhasgiurguj' in log.messages[-1]) + # Check that the invalid broadcast hasn't + # changed the itask sim mode settings: + assert itask.mode_settings.sim_task_fails is True + schd.broadcast_mgr.put_broadcast( ['1066'], ['one'], [{ 'simulation': {'fail cycle points': '1'} }]) - schd.task_job_mgr._simulation_submit_task_jobs( - [itask], schd.workflow) + schd.task_job_mgr._nonlive_submit_task_jobs( + [itask], schd.workflow, 'simulation') assert ( 'Invalid ISO 8601 date representation: 1' in log.messages[-1]) @@ -438,8 +443,8 @@ async def test_settings_broadcast( 'simulation': {'fail cycle points': '1945, 1977, 1066'}, 'execution retry delays': '3*PT2S' }]) - schd.task_job_mgr._simulation_submit_task_jobs( - [itask], schd.workflow) + schd.task_job_mgr._nonlive_submit_task_jobs( + [itask], schd.workflow, 'simulation') assert itask.mode_settings.sim_task_fails is True assert itask.try_timers['execution-retry'].delays == [2.0, 2.0, 2.0] # n.b. rtconfig should remain unchanged, lest we cancel broadcasts: diff --git a/tests/integration/test_config.py b/tests/integration/test_config.py index 0a186aa41bd..a66d3741a80 100644 --- a/tests/integration/test_config.py +++ b/tests/integration/test_config.py @@ -15,7 +15,9 @@ # along with this program. If not, see . from pathlib import Path +import re import sqlite3 +from textwrap import dedent from typing import Any import pytest @@ -503,3 +505,54 @@ def test_special_task_non_word_names(flow: Fixture, validate: Fixture): }, }) validate(wid) + + +def test_nonlive_mode_validation(flow, validate, caplog): + """Nonlive tasks return a warning at validation. + """ + msg1 = dedent( + ''' The following tasks are set to run in non-live mode: + skip mode: + * skip + simulation mode: + * simulation + dummy mode: + * dummy''') + + msg2 = re.compile("Task skip has output") + + wid = flow({ + 'scheduling': { + 'graph': { + 'R1': 'live => skip => simulation => dummy => default' + } + }, + 'runtime': { + 'default': {}, + 'live': {'run mode': 'live'}, + 'simulation': {'run mode': 'simulation'}, + 'dummy': {'run mode': 'dummy'}, + 'skip': { + 'run mode': 'skip', + 'skip': {'outputs': 'started, submitted'} + }, + }, + }) + + validate(wid) + assert msg1 in caplog.messages + + messages = [msg for msg in caplog.messages if msg2.findall(msg)] + assert 'started' in messages[0] + assert 'submitted' in messages[0] + + +@pytest.mark.parametrize('mode', (('simulation'), ('skip'), ('dummy'))) +def test_nonlive_mode_forbidden_as_outputs(flow, validate, mode): + """Run mode names are forbidden as task output names.""" + wid = flow({ + 'scheduling': {'graph': {'R1': 'task'}}, + 'runtime': {'task': {'outputs': {mode: f'message for {mode}'}}} + }) + with pytest.raises(WorkflowConfigError, match=f'message for {mode}'): + validate(wid) diff --git a/tests/unit/run_modes/test_dummy.py b/tests/unit/run_modes/test_dummy.py new file mode 100644 index 00000000000..998c13767c9 --- /dev/null +++ b/tests/unit/run_modes/test_dummy.py @@ -0,0 +1,40 @@ +# 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 . +"""Tests for utilities supporting dummy mode. +""" +import pytest +from cylc.flow.run_modes.dummy import build_dummy_script + + +@pytest.mark.parametrize( + 'fail_one_time_only', (True, False) +) +def test_build_dummy_script(fail_one_time_only): + rtc = { + 'outputs': {'foo': '1', 'bar': '2'}, + 'simulation': { + 'fail try 1 only': fail_one_time_only, + 'fail cycle points': '1', + } + } + result = build_dummy_script(rtc, 60) + assert result.split('\n') == [ + 'sleep 60', + "cylc message '1'", + "cylc message '2'", + f"cylc__job__dummy_result {str(fail_one_time_only).lower()}" + " 1 || exit 1" + ] diff --git a/tests/unit/run_modes/test_nonlive.py b/tests/unit/run_modes/test_nonlive.py new file mode 100644 index 00000000000..34c01151e6f --- /dev/null +++ b/tests/unit/run_modes/test_nonlive.py @@ -0,0 +1,45 @@ +# 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 . +"""Unit tests for utilities supporting all nonlive modes +""" + +from types import SimpleNamespace + +from cylc.flow.run_modes.nonlive import mode_validate_checks + + +def test_mode_validate_checks(monkeypatch, caplog): + """It warns us if we've set a task config to nonlive mode. + + (And not otherwise) + """ + taskdefs = { + f'{run_mode}_task': SimpleNamespace( + rtconfig={'run mode': run_mode} + ) + for run_mode + in ['live', 'workflow', 'dummy', 'simulation', 'skip'] + } + + mode_validate_checks(taskdefs) + + message = caplog.messages[0] + + assert 'skip mode:\n * skip_task' in message + assert 'simulation mode:\n * simulation_task' in message + assert 'dummy mode:\n * dummy_task' in message + assert ' live mode' not in message # Avoid matching "non-live mode" + assert 'workflow mode' not in message diff --git a/tests/unit/test_simulation.py b/tests/unit/run_modes/test_simulation.py similarity index 86% rename from tests/unit/test_simulation.py rename to tests/unit/run_modes/test_simulation.py index 920a872503a..109174c8b43 100644 --- a/tests/unit/test_simulation.py +++ b/tests/unit/run_modes/test_simulation.py @@ -20,9 +20,8 @@ from cylc.flow.cycling.integer import IntegerPoint from cylc.flow.cycling.iso8601 import ISO8601Point -from cylc.flow.simulation import ( +from cylc.flow.run_modes.simulation import ( parse_fail_cycle_points, - build_dummy_script, disable_platforms, get_simulated_run_len, sim_task_failed, @@ -56,27 +55,6 @@ def test_get_simulated_run_len( assert get_simulated_run_len(rtc) == 3600 -@pytest.mark.parametrize( - 'fail_one_time_only', (True, False) -) -def test_set_simulation_script(fail_one_time_only): - rtc = { - 'outputs': {'foo': '1', 'bar': '2'}, - 'simulation': { - 'fail try 1 only': fail_one_time_only, - 'fail cycle points': '1', - } - } - result = build_dummy_script(rtc, 60) - assert result.split('\n') == [ - 'sleep 60', - "cylc message '1'", - "cylc message '2'", - f"cylc__job__dummy_result {str(fail_one_time_only).lower()}" - " 1 || exit 1" - ] - - @pytest.mark.parametrize( 'rtc, expect', ( ({'platform': 'skarloey'}, 'localhost'), @@ -100,7 +78,7 @@ def test_disable_platforms(rtc, expect): def test_parse_fail_cycle_points(set_cycling_type): before = ['2', '4'] set_cycling_type() - assert parse_fail_cycle_points(before) == [ + assert parse_fail_cycle_points(before, ['']) == [ IntegerPoint(i) for i in before ] diff --git a/tests/unit/run_modes/test_skip.py b/tests/unit/run_modes/test_skip.py new file mode 100644 index 00000000000..c2791220f78 --- /dev/null +++ b/tests/unit/run_modes/test_skip.py @@ -0,0 +1,119 @@ +# 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 . +"""Unit tests for utilities supporting skip modes +""" +import pytest +from pytest import param, raises +from types import SimpleNamespace + +from cylc.flow.exceptions import WorkflowConfigError +from cylc.flow.run_modes.skip import check_task_skip_config, process_outputs + + +@pytest.mark.parametrize( + 'conf', + ( + param({}, id='no-skip-config'), + param({'skip': {'outputs': []}}, id='no-skip-outputs'), + param({'skip': {'outputs': ['foo1', 'failed']}}, id='ok-skip-outputs'), + ) +) +def test_good_check_task_skip_config(conf): + """It returns none if the problems this function checks are not present. + """ + tdef = SimpleNamespace(rtconfig=conf) + assert check_task_skip_config('foo', tdef) is None + + +def test_raises_check_task_skip_config(): + """It raises an error if succeeded and failed are set. + """ + tdef = SimpleNamespace( + rtconfig={'skip': {'outputs': ['foo1', 'failed', 'succeeded']}} + ) + with raises(WorkflowConfigError, match='succeeded AND failed'): + check_task_skip_config('foo', tdef) + + +@pytest.mark.parametrize( + 'conf', + ( + param({'skip': {'outputs': ['foo1', 'started']}}, id='started'), + param({'skip': {'outputs': ['foo1', 'submitted']}}, id='started'), + param({'skip': {'outputs': ['submitted', 'started']}}, id='both'), + ) +) +def test_warns_check_task_skip_config(conf, caplog): + """It returns none but logs a warning if started or submitted are present + in the task outputs. + """ + tdef = SimpleNamespace(rtconfig=conf) + assert check_task_skip_config('foo', tdef) is None + assert 'need not be set' in caplog.messages[0] + + +@pytest.mark.parametrize( + 'outputs, required, expect', + ( + param([], [], ['succeeded'], id='implicit-succeded'), + param( + ['succeeded'], ['succeeded'], ['succeeded'], + id='explicit-succeded' + ), + param(['submitted'], [], ['succeeded'], id='only-1-submit'), + param( + ['foo', 'bar', 'baz', 'qux'], + ['bar', 'qux'], + ['bar', 'qux', 'succeeded'], + id='required-only' + ), + param( + ['foo', 'baz'], + ['bar', 'qux'], + ['succeeded'], + id='no-required' + ), + param( + ['failed'], + [], + ['failed'], + id='explicit-failed' + ), + ) +) +def test_process_outputs(outputs, required, expect): + """Check that skip outputs: + + 1. Doesn't send submitted twice. + 2. Sends every required output. + 3. If failed is set send failed + 4. If failed in not set send succeeded. + + n.b: The real process message function sends the TASK_OUTPUT_STARTED + message for free, so there is no reference to that here. + """ + + + # Create a mocked up task-proxy: + itask = SimpleNamespace( + tdef=SimpleNamespace( + rtconfig={'skip': {'outputs': outputs}}), + state=SimpleNamespace( + outputs=SimpleNamespace( + _required={v: v for v in required} + ))) + + assert process_outputs(itask) == ['submitted'] + expect diff --git a/tests/unit/scripts/test_lint.py b/tests/unit/scripts/test_lint.py index 0f5f02648bd..2b0d8736413 100644 --- a/tests/unit/scripts/test_lint.py +++ b/tests/unit/scripts/test_lint.py @@ -179,7 +179,10 @@ [[bar]] platform = $(some-script foo) [[baz]] + run mode = skip platform = `no backticks` + [[[skip]]] + outputs = succeeded, failed """ + ( '\nscript = the quick brown fox jumps over the lazy dog until it becomes ' 'clear that this line is longer than the default 130 character limit.' diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 98d9fc2f4ce..62bc63de548 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -14,16 +14,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from copy import deepcopy import os import sys from optparse import Values -from typing import Any, Callable, Dict, List, Optional, Tuple, Type -from pathlib import Path +from typing import ( + TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Type) import pytest import logging from types import SimpleNamespace -from unittest.mock import Mock from contextlib import suppress from cylc.flow import CYLC_LOG @@ -39,7 +37,6 @@ from cylc.flow.parsec.exceptions import Jinja2Error, EmPyError from cylc.flow.scheduler_cli import RunOptions from cylc.flow.scripts.validate import ValidateOptions -from cylc.flow.simulation import configure_sim_modes from cylc.flow.workflow_files import WorkflowFiles from cylc.flow.wallclock import get_utc_mode, set_utc_mode from cylc.flow.xtrigger_mgr import XtriggerManager @@ -50,8 +47,9 @@ from cylc.flow.cycling.iso8601 import ISO8601Point - -Fixture = Any +if TYPE_CHECKING: + from pathlib import Path + Fixture = Any def _tmp_flow_config(tmp_run_dir: Callable): @@ -63,8 +61,8 @@ def _tmp_flow_config(tmp_run_dir: Callable): Returns the path to the flow file. """ - def __tmp_flow_config(id_: str, config: str) -> Path: - run_dir: Path = tmp_run_dir(id_) + def __tmp_flow_config(id_: str, config: str) -> 'Path': + run_dir: 'Path' = tmp_run_dir(id_) flow_file = run_dir / WorkflowFiles.FLOW_FILE flow_file.write_text(config) return flow_file @@ -85,7 +83,7 @@ class TestWorkflowConfig: """Test class for the Cylc WorkflowConfig object.""" def test_xfunction_imports( - self, mock_glbl_cfg: Fixture, tmp_path: Path, + self, mock_glbl_cfg: 'Fixture', tmp_path: 'Path', xtrigger_mgr: XtriggerManager): """Test for a workflow configuration with valid xtriggers""" mock_glbl_cfg( @@ -363,7 +361,7 @@ def test_process_icp( expected_icp: Optional[str], expected_opt_icp: Optional[str], expected_err: Optional[Tuple[Type[Exception], str]], - monkeypatch: pytest.MonkeyPatch, set_cycling_type: Fixture + monkeypatch: pytest.MonkeyPatch, set_cycling_type: 'Fixture' ) -> None: """Test WorkflowConfig.process_initial_cycle_point(). @@ -450,7 +448,7 @@ def test_process_startcp( starttask: Optional[str], expected: str, expected_err: Optional[Tuple[Type[Exception], str]], - monkeypatch: pytest.MonkeyPatch, set_cycling_type: Fixture + monkeypatch: pytest.MonkeyPatch, set_cycling_type: 'Fixture' ) -> None: """Test WorkflowConfig.process_start_cycle_point(). @@ -653,7 +651,7 @@ def test_process_fcp( options_fcp: Optional[str], expected_fcp: Optional[str], expected_err: Optional[Tuple[Type[Exception], str]], - set_cycling_type: Fixture + set_cycling_type: 'Fixture' ) -> None: """Test WorkflowConfig.process_final_cycle_point(). @@ -817,7 +815,7 @@ def test_stopcp_after_fcp( cycle point is handled correctly.""" caplog.set_level(logging.WARNING, CYLC_LOG) id_ = 'cassini' - flow_file: Path = tmp_flow_config(id_, f""" + flow_file: 'Path' = tmp_flow_config(id_, f""" [scheduler] allow implicit tasks = True [scheduling] @@ -1393,7 +1391,7 @@ def test_implicit_tasks( """ # Setup id_ = 'rincewind' - flow_file: Path = tmp_flow_config(id_, f""" + flow_file: 'Path' = tmp_flow_config(id_, f""" [scheduler] { f'allow implicit tasks = {allow_implicit_tasks}' @@ -1497,7 +1495,7 @@ def test_zero_interval( """Test that a zero-duration recurrence with >1 repetition gets an appropriate warning.""" id_ = 'ordinary' - flow_file: Path = tmp_flow_config(id_, f""" + flow_file: 'Path' = tmp_flow_config(id_, f""" [scheduler] UTC mode = True allow implicit tasks = True @@ -1541,7 +1539,7 @@ def test_chain_expr( Note the order matters when "nominal" units (years, months) are used. """ id_ = 'osgiliath' - flow_file: Path = tmp_flow_config(id_, f""" + flow_file: 'Path' = tmp_flow_config(id_, f""" [scheduler] UTC mode = True allow implicit tasks = True @@ -1720,7 +1718,7 @@ def test__warn_if_queues_have_implicit_tasks(caplog): ] ) def test_cylc_env_at_parsing( - tmp_path: Path, + tmp_path: 'Path', monkeypatch: pytest.MonkeyPatch, installed, run_dir, diff --git a/tests/unit/test_task_state.py b/tests/unit/test_task_state.py index e655c74b7bb..106f4f8e2de 100644 --- a/tests/unit/test_task_state.py +++ b/tests/unit/test_task_state.py @@ -15,11 +15,13 @@ # along with this program. If not, see . import pytest +from types import SimpleNamespace from cylc.flow.taskdef import TaskDef from cylc.flow.cycling.integer import IntegerSequence, IntegerPoint from cylc.flow.task_trigger import Dependency, TaskTrigger from cylc.flow.task_state import ( + RunMode, TaskState, TASK_STATUS_PREPARING, TASK_STATUS_SUBMIT_FAILED, @@ -119,3 +121,29 @@ def test_task_state_order(): assert not tstate.is_gt(TASK_STATUS_RUNNING) assert not tstate.is_gte(TASK_STATUS_RUNNING) + +@pytest.mark.parametrize( + 'itask_run_mode, disable_handlers, expect', + ( + ('live', True, False), + ('live', False, False), + ('dummy', True, False), + ('dummy', False, False), + ('simulation', True, True), + ('simulation', False, True), + ('skip', True, True), + ('skip', False, False), + ) +) +def test_disable_task_event_handlers(itask_run_mode, disable_handlers, expect): + """Conditions under which task event handlers should not be used. + """ + # Construct a fake itask object: + itask = SimpleNamespace( + tdef=SimpleNamespace( + run_mode=itask_run_mode, + rtconfig={ + 'skip': {'disable task event handlers': disable_handlers}}) + ) + # Check method: + assert RunMode.disable_task_event_handlers(itask) is expect