From c14041289fdb0708366851d8c6b4817724b25a89 Mon Sep 17 00:00:00 2001 From: Maxime RIO Date: Wed, 24 Apr 2024 10:57:11 +1200 Subject: [PATCH 01/12] fix bug when job runner submit stdout doesn't end with newline When the stdout returned by the job runner submit method doesn't end with a newline, the `sys.stdout.write` call in `jobs_submit` do not add it and this cases submission to fails (at least to be recorded as failed by the calling process when running a workflow). Looking at how newlines are handled in the JobRunnerManager.job_kill method, it looks like this a typo in the jobs_submit method. --- cylc/flow/job_runner_mgr.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cylc/flow/job_runner_mgr.py b/cylc/flow/job_runner_mgr.py index f23c6c70104..ba367e753be 100644 --- a/cylc/flow/job_runner_mgr.py +++ b/cylc/flow/job_runner_mgr.py @@ -306,8 +306,8 @@ def jobs_submit(self, job_log_root, job_log_dirs, remote_mode=False, if value is None or not value.strip(): continue for line in value.splitlines(True): - if not value.endswith("\n"): - value += "\n" + if not line.endswith("\n"): + line += "\n" sys.stdout.write("%s%s|%s|[%s] %s" % ( self.OUT_PREFIX_COMMAND, now, job_log_dir, key, line)) From a00fcfac4845371cb3802ca489ad994e50c8496e Mon Sep 17 00:00:00 2001 From: Maxime RIO Date: Wed, 24 Apr 2024 11:02:56 +1200 Subject: [PATCH 02/12] add name to contributors --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4e77fd82a7..fb0b362e4a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,6 +96,7 @@ requests_). - Diquan Jabbour - Shixian Sheng - Utheri Wagura + - Maxime Rio (All contributors are identifiable with email addresses in the git version From adbcdfcf5be3b5d65ec9bfad7cc3dabb1fc3828a Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Wed, 23 Oct 2024 13:46:46 +1300 Subject: [PATCH 03/12] Tweak as per code review. --- cylc/flow/job_runner_handlers/background.py | 2 +- cylc/flow/job_runner_handlers/documentation.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cylc/flow/job_runner_handlers/background.py b/cylc/flow/job_runner_handlers/background.py index 2866a839dfb..729b9b74d28 100644 --- a/cylc/flow/job_runner_handlers/background.py +++ b/cylc/flow/job_runner_handlers/background.py @@ -89,7 +89,7 @@ def submit(cls, job_file_path, submit_opts): exc.filename = "nohup" return (1, None, str(exc)) else: - return (0, "%d\n" % (proc.pid), None) + return (0, str(proc.pid), None) JOB_RUNNER_HANDLER = BgCommandHandler() diff --git a/cylc/flow/job_runner_handlers/documentation.py b/cylc/flow/job_runner_handlers/documentation.py index 3b546ab4209..415f031684e 100644 --- a/cylc/flow/job_runner_handlers/documentation.py +++ b/cylc/flow/job_runner_handlers/documentation.py @@ -424,8 +424,7 @@ def submit( ret_code: Subprocess return code. out: - Subprocess standard output, note this should be newline - terminated. + Subprocess standard output. err: Subprocess standard error. From 2a43d247424dc2fed1a8f8facfb1943bb7604492 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Wed, 23 Oct 2024 14:29:35 +1300 Subject: [PATCH 04/12] Simplify some job_runner code. --- cylc/flow/job_runner_mgr.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/cylc/flow/job_runner_mgr.py b/cylc/flow/job_runner_mgr.py index ba367e753be..b8ddaaa0538 100644 --- a/cylc/flow/job_runner_mgr.py +++ b/cylc/flow/job_runner_mgr.py @@ -210,12 +210,10 @@ def jobs_kill(self, job_log_root, job_log_dirs): self.OUT_PREFIX_SUMMARY, now, job_log_dir, ret_code)) # Note: Print STDERR to STDOUT may look a bit strange, but it # requires less logic for the workflow to parse the output. - if err.strip(): - for line in err.splitlines(True): - if not line.endswith("\n"): - line += "\n" - sys.stdout.write("%s%s|%s|%s" % ( - self.OUT_PREFIX_CMD_ERR, now, job_log_dir, line)) + for line in err.strip().splitlines(): + sys.stdout.write( + f"{self.OUT_PREFIX_CMD_ERR}{now}|{job_log_dir}|{line}\n" + ) def jobs_poll(self, job_log_root, job_log_dirs): """Poll multiple jobs. @@ -303,13 +301,13 @@ def jobs_submit(self, job_log_root, job_log_dirs, remote_mode=False, sys.stdout.write("%s%s|%s|%d|%s\n" % ( self.OUT_PREFIX_SUMMARY, now, job_log_dir, ret_code, job_id)) for key, value in [("STDERR", err), ("STDOUT", out)]: - if value is None or not value.strip(): + if value is None: continue - for line in value.splitlines(True): - if not line.endswith("\n"): - line += "\n" - sys.stdout.write("%s%s|%s|[%s] %s" % ( - self.OUT_PREFIX_COMMAND, now, job_log_dir, key, line)) + for line in value.strip().splitlines(): + sys.stdout.write( + f"{self.OUT_PREFIX_COMMAND}{now}" + f"|{job_log_dir}|[{key}] {line}\n" + ) def job_kill(self, st_file_path): """Ask job runner to terminate the job specified in "st_file_path". From 32aed6af599df963ee01e8cd0500ec6bb3617605 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Wed, 23 Oct 2024 14:42:07 +1300 Subject: [PATCH 05/12] Update change log. --- changes.d/6081.fix.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changes.d/6081.fix.md diff --git a/changes.d/6081.fix.md b/changes.d/6081.fix.md new file mode 100644 index 00000000000..714321a9311 --- /dev/null +++ b/changes.d/6081.fix.md @@ -0,0 +1,2 @@ +Fix job submission when a batch of jobs is submitted to a runner that does +not return a newline with the job ID (did not affect built-in job runners). From f1fb504801f962a6dc83cae28b9c86522eba4b92 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Wed, 23 Oct 2024 14:48:14 +1300 Subject: [PATCH 06/12] Update mailmap. --- .mailmap | 1 + 1 file changed, 1 insertion(+) diff --git a/.mailmap b/.mailmap index 71a84fdefc6..7bbf47d2375 100644 --- a/.mailmap +++ b/.mailmap @@ -57,3 +57,4 @@ Utheri Wagura <36386988+uwagura@users.noreply.github.com> github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> github-actions[bot] GitHub Action Diquan Jabbour <165976689+Diquan-BOM@users.noreply.github.com> +Maxime Rio From 4f3f8f1ea369833a70e67ac9c0f22a1617d62536 Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Tue, 5 Nov 2024 13:41:23 +0000 Subject: [PATCH 07/12] Lint: Tell users that backslashes are no longer necessary after => & | --- changes.d/6459.feat.md | 1 + cylc/flow/scripts/lint.py | 14 +++++++++++++- tests/unit/scripts/test_lint.py | 21 ++++++++++++++++----- 3 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 changes.d/6459.feat.md diff --git a/changes.d/6459.feat.md b/changes.d/6459.feat.md new file mode 100644 index 00000000000..19fddbbd10f --- /dev/null +++ b/changes.d/6459.feat.md @@ -0,0 +1 @@ +Cylc lint now checks for unecessary continuation characters in graph section. diff --git a/cylc/flow/scripts/lint.py b/cylc/flow/scripts/lint.py index e9ef1db742e..83983c7dbce 100755 --- a/cylc/flow/scripts/lint.py +++ b/cylc/flow/scripts/lint.py @@ -547,7 +547,13 @@ 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': ( + '`=>` is a line continuation without `\\`' + ), + FUNCTION: re.compile(r'=>\s*\\').findall + }, } # Subset of deprecations which are tricky (impossible?) to scrape from the # upgrader. @@ -715,6 +721,12 @@ def list_wrapper(line: str, check: Callable) -> Optional[Dict[str, str]]: FUNCTION: functools.partial( list_wrapper, check=CHECK_FOR_OLD_VARS.findall), }, + 'U017': { + 'short': ( + '`&` and `|` are line continuations without `\\`' + ), + FUNCTION: re.compile(r'[&|]\s*\\').findall + }, } ALL_RULESETS = ['728', 'style', 'all'] EXTRA_TOML_VALIDATION = { diff --git a/tests/unit/scripts/test_lint.py b/tests/unit/scripts/test_lint.py index 0f5f02648bd..68536d919ca 100644 --- a/tests/unit/scripts/test_lint.py +++ b/tests/unit/scripts/test_lint.py @@ -44,7 +44,7 @@ STYLE_CHECKS = parse_checks(['style']) UPG_CHECKS = parse_checks(['728']) -TEST_FILE = """ +TEST_FILE = ''' [visualization] [cylc] @@ -97,7 +97,13 @@ hold after point = 20220101T0000Z [[dependencies]] [[[R1]]] - graph = MyFaM:finish-all => remote => !mash_theme + graph = """ + MyFaM:finish-all => remote => !mash_theme + a & \\ + b => c + c | \\ + d => e + """ [runtime] [[root]] @@ -154,10 +160,10 @@ host = `rose host-select thingy` %include foo.cylc -""" +''' -LINT_TEST_FILE = """ +LINT_TEST_FILE = ''' \t[scheduler] [scheduler] @@ -167,6 +173,11 @@ {% foo %} {{foo}} # {{quix}} + R1 = """ + foo & \\ + bar => \\ + baz + """ [runtime] [[this_is_ok]] @@ -180,7 +191,7 @@ platform = $(some-script foo) [[baz]] platform = `no backticks` -""" + ( +''' + ( '\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.' ) From a094cadce3831d01a563e41f9462b9c1fc7fab93 Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Tue, 19 Nov 2024 09:53:46 +0000 Subject: [PATCH 08/12] Update changes.d/6459.feat.md Co-authored-by: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> --- changes.d/6459.feat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes.d/6459.feat.md b/changes.d/6459.feat.md index 19fddbbd10f..3263081511f 100644 --- a/changes.d/6459.feat.md +++ b/changes.d/6459.feat.md @@ -1 +1 @@ -Cylc lint now checks for unecessary continuation characters in graph section. +`cylc lint` now checks for unecessary continuation characters in graph section. From e7a82ec37154dfe3c1e390a42d0b2c0831b708bb Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 21 Nov 2024 14:21:16 +0000 Subject: [PATCH 09/12] job_runner_mgr: test job kill error --- tests/integration/test_job_runner_mgr.py | 85 ++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/integration/test_job_runner_mgr.py diff --git a/tests/integration/test_job_runner_mgr.py b/tests/integration/test_job_runner_mgr.py new file mode 100644 index 00000000000..93663aec892 --- /dev/null +++ b/tests/integration/test_job_runner_mgr.py @@ -0,0 +1,85 @@ +# 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 . + +import errno +import logging +from pathlib import Path +import re +from textwrap import dedent + +from cylc.flow.job_runner_mgr import JobRunnerManager +from cylc.flow.pathutil import get_workflow_run_job_dir +from cylc.flow.task_state import TASK_STATUS_RUNNING +from cylc.flow.subprocctx import SubProcContext + + +async def test_kill_error(one, start, test_dir, capsys, log_filter): + """It should report the failure to kill a job.""" + async with start(one) as log: + # make it look like the task is running + itask = one.pool.get_tasks()[0] + itask.submit_num += 1 + itask.state_reset(TASK_STATUS_RUNNING) + + # fake job details + workflow_job_log_dir = Path(get_workflow_run_job_dir(one.workflow)) + job_id = itask.tokens.duplicate(job='01').relative_id + job_log_dir = Path(workflow_job_log_dir, job_id) + + # create job status file (give it a fake pid) + job_log_dir.mkdir(parents=True) + (job_log_dir / 'job.status').write_text(dedent(''' + CYLC_JOB_RUNNER_NAME=background + CYLC_JOB_ID=99999999 + CYLC_JOB_PID=99999999 + ''')) + + # attempt to kill the job using the jobs-kill script + # (note this is normally run via a subprocess) + capsys.readouterr() + JobRunnerManager().jobs_kill(str(workflow_job_log_dir), [job_id]) + + # the kill should fail, the failure should be written to stdout + # (the jobs-kill callback will read this in and handle it) + out, err = capsys.readouterr() + assert re.search( + # # NOTE: ESRCH = no such process + rf'TASK JOB ERROR.*{job_id}.*Errno {errno.ESRCH}', + out, + ) + + # feed this jobs-kill output into the scheduler + # (as if we had run the jobs-kill script as a subprocess) + one.task_job_mgr._kill_task_jobs_callback( + # mock the subprocess + SubProcContext( + one.task_job_mgr.JOBS_KILL, + ['mock-cmd'], + # provide it with the out/err the script produced + out=out, + err=err, + ), + one.workflow, + [itask], + ) + + # a warning should be logged + assert log_filter( + log, + regex=r'1/one/01:running.*job kill failed', + level=logging.WARNING, + ) + assert itask.state(TASK_STATUS_RUNNING) From d844dfd6fce2c6cd002aa3d710f3a0fdf38d93d3 Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Fri, 22 Nov 2024 08:33:22 +0000 Subject: [PATCH 10/12] Apply suggestions from code review Co-authored-by: Hilary James Oliver --- changes.d/6459.feat.md | 2 +- cylc/flow/scripts/lint.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/changes.d/6459.feat.md b/changes.d/6459.feat.md index 3263081511f..6ca3c71a2a4 100644 --- a/changes.d/6459.feat.md +++ b/changes.d/6459.feat.md @@ -1 +1 @@ -`cylc lint` now checks for unecessary continuation characters in graph section. +`cylc lint` now checks for unnecessary continuation characters in the graph section. diff --git a/cylc/flow/scripts/lint.py b/cylc/flow/scripts/lint.py index 83983c7dbce..79535903ec3 100755 --- a/cylc/flow/scripts/lint.py +++ b/cylc/flow/scripts/lint.py @@ -550,7 +550,7 @@ def list_wrapper(line: str, check: Callable) -> Optional[Dict[str, str]]: }, 'S014': { 'short': ( - '`=>` is a line continuation without `\\`' + '`=>` implies line continuation without `\\`' ), FUNCTION: re.compile(r'=>\s*\\').findall }, @@ -723,7 +723,7 @@ def list_wrapper(line: str, check: Callable) -> Optional[Dict[str, str]]: }, 'U017': { 'short': ( - '`&` and `|` are line continuations without `\\`' + '`&` and `|` imply line continuation without `\\`' ), FUNCTION: re.compile(r'[&|]\s*\\').findall }, From 5bf2b1ca051beae7912fe180460807a69a107e89 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Mon, 25 Nov 2024 11:11:37 +0000 Subject: [PATCH 11/12] Fix workflow state docs (#6477) Co-authored-by: Hilary James Oliver --- cylc/flow/scripts/workflow_state.py | 29 +++++++++++++-------------- cylc/flow/xtriggers/workflow_state.py | 10 ++++----- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/cylc/flow/scripts/workflow_state.py b/cylc/flow/scripts/workflow_state.py index c444c6d1f65..a3bc3b1c695 100755 --- a/cylc/flow/scripts/workflow_state.py +++ b/cylc/flow/scripts/workflow_state.py @@ -20,10 +20,13 @@ Check or poll a workflow database for task statuses or completed outputs. -The ID argument can target a workflow, or a cycle point, or a specific -task, with an optional selector on cycle or task to match task status, -output trigger (if not a status, or with --trigger) or output message -(with --message). All matching results will be printed. +The ID argument can target a workflow, or a cycle point, or a specific task, +with an optional selector on cycle or task to match final task statuses, +output trigger (with --triggers), or output message (with --messages). +You cannot poll for transient states such as "submitted" and "running"; +poll for the corresponding output triggers instead ("submitted", "started"). + +All matching results will be printed. If no results match, the command will repeatedly check (poll) until a match is found or polling is exhausted (see --max-polls and --interval). For a @@ -35,14 +38,12 @@ Legacy (pre-8.3.0) options are supported, but deprecated, for existing scripts: cylc workflow-state --task=NAME --point=CYCLE --status=STATUS --output=MESSAGE --message=MESSAGE --task-point WORKFLOW -(Note from 8.0 until 8.3.0 --output and --message both match task messages). +(Note from 8.0 until 8.3.0 --output and --message both matched task messages). In "cycle/task:selector" the selector will match task statuses, unless: - - if it is not a known status, it will match task output triggers - (Cylc 8 DB) or task ouput messages (Cylc 7 DB) - - with --triggers, it will only match task output triggers - - with --messages (deprecated), it will only match task output messages. - Triggers are more robust - they match manually and naturally set outputs. + - with --triggers, it will only match task output triggers. + - with --messages, it will only match task output messages. It is recommended + to use triggers instead - they match both naturally & manually set outputs. Selector does not default to "succeeded". If omitted, any status will match. @@ -64,8 +65,6 @@ Warnings: - Typos in the workflow or task ID will result in fruitless polling. - - To avoid missing transient states ("submitted", "running") poll for the - corresponding output trigger instead ("submitted", "started"). - Cycle points are auto-converted to the DB point format (and UTC mode). - Task outputs manually completed by "cylc set" have "(force-completed)" recorded as the task message in the DB, so it is best to query trigger @@ -296,8 +295,8 @@ def get_option_parser() -> COP: parser.add_option( "--triggers", - help="Task selector should match output triggers rather than status." - " (Note this is not needed for custom outputs).", + help="Task selector should match output trigger names rather than " + "status.", action="store_true", dest="is_trigger", default=False) parser.add_option( @@ -370,7 +369,7 @@ def main(parser: COP, options: 'Values', *ids: str) -> None: [ options.depr_task, options.depr_status, - options.depr_msg, # --message and --trigger + options.depr_msg, options.depr_point, options.depr_env_point ] diff --git a/cylc/flow/xtriggers/workflow_state.py b/cylc/flow/xtriggers/workflow_state.py index 14948a43390..c9729844aa0 100644 --- a/cylc/flow/xtriggers/workflow_state.py +++ b/cylc/flow/xtriggers/workflow_state.py @@ -48,12 +48,12 @@ def workflow_state( e.g. PT1H (1 hour) or P1 (1 integer cycle) flow_num: Flow number of the target task. - is_message: - Interpret the task:selector as a task output message - (the default is a task status or trigger) is_trigger: - Interpret the task:selector as a task trigger name - (only needed if it is also a valid status name) + Interpret the task:selector as a task trigger name rather than a + task status. + is_message: + Interpret the task:selector as a task output message rather than a + task status. alt_cylc_run_dir: Alternate cylc-run directory, e.g. for another user. From dc962a10c24de39b4d3c403d0376726a61f32317 Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Mon, 25 Nov 2024 12:36:38 +0000 Subject: [PATCH 12/12] Revert "Lint: Tell users that backslashes are no longer necessary after => & |" (#6492) --- changes.d/6459.feat.md | 1 - cylc/flow/scripts/lint.py | 14 +------------- tests/unit/scripts/test_lint.py | 21 +++++---------------- 3 files changed, 6 insertions(+), 30 deletions(-) delete mode 100644 changes.d/6459.feat.md diff --git a/changes.d/6459.feat.md b/changes.d/6459.feat.md deleted file mode 100644 index 6ca3c71a2a4..00000000000 --- a/changes.d/6459.feat.md +++ /dev/null @@ -1 +0,0 @@ -`cylc lint` now checks for unnecessary continuation characters in the graph section. diff --git a/cylc/flow/scripts/lint.py b/cylc/flow/scripts/lint.py index 79535903ec3..e9ef1db742e 100755 --- a/cylc/flow/scripts/lint.py +++ b/cylc/flow/scripts/lint.py @@ -547,13 +547,7 @@ 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': ( - '`=>` implies line continuation without `\\`' - ), - FUNCTION: re.compile(r'=>\s*\\').findall - }, + } } # Subset of deprecations which are tricky (impossible?) to scrape from the # upgrader. @@ -721,12 +715,6 @@ def list_wrapper(line: str, check: Callable) -> Optional[Dict[str, str]]: FUNCTION: functools.partial( list_wrapper, check=CHECK_FOR_OLD_VARS.findall), }, - 'U017': { - 'short': ( - '`&` and `|` imply line continuation without `\\`' - ), - FUNCTION: re.compile(r'[&|]\s*\\').findall - }, } ALL_RULESETS = ['728', 'style', 'all'] EXTRA_TOML_VALIDATION = { diff --git a/tests/unit/scripts/test_lint.py b/tests/unit/scripts/test_lint.py index 68536d919ca..0f5f02648bd 100644 --- a/tests/unit/scripts/test_lint.py +++ b/tests/unit/scripts/test_lint.py @@ -44,7 +44,7 @@ STYLE_CHECKS = parse_checks(['style']) UPG_CHECKS = parse_checks(['728']) -TEST_FILE = ''' +TEST_FILE = """ [visualization] [cylc] @@ -97,13 +97,7 @@ hold after point = 20220101T0000Z [[dependencies]] [[[R1]]] - graph = """ - MyFaM:finish-all => remote => !mash_theme - a & \\ - b => c - c | \\ - d => e - """ + graph = MyFaM:finish-all => remote => !mash_theme [runtime] [[root]] @@ -160,10 +154,10 @@ host = `rose host-select thingy` %include foo.cylc -''' +""" -LINT_TEST_FILE = ''' +LINT_TEST_FILE = """ \t[scheduler] [scheduler] @@ -173,11 +167,6 @@ {% foo %} {{foo}} # {{quix}} - R1 = """ - foo & \\ - bar => \\ - baz - """ [runtime] [[this_is_ok]] @@ -191,7 +180,7 @@ platform = $(some-script foo) [[baz]] platform = `no backticks` -''' + ( +""" + ( '\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.' )