diff --git a/cylc/flow/option_parsers.py b/cylc/flow/option_parsers.py index 8a4245a6a94..f14e757aa3e 100644 --- a/cylc/flow/option_parsers.py +++ b/cylc/flow/option_parsers.py @@ -369,6 +369,8 @@ class CylcOptionParser(OptionParser): ) ] + LINEBREAK = ('\n', '') + def __init__( self, usage: str, @@ -407,7 +409,6 @@ def __init__( if multitask: usage += self.MULTITASK_USAGE - args = "" self.n_compulsory_args = 0 self.n_optional_args = 0 self.unlimited_args = False @@ -419,21 +420,7 @@ def __init__( self.segregated_log = segregated_log if argdoc: - maxlen = max(len(arg) for arg, _ in argdoc) - usage += "\n\nArguments:" - for arg, descr in argdoc: - if arg.startswith('['): - self.n_optional_args += 1 - else: - self.n_compulsory_args += 1 - if arg.rstrip(']').endswith('...'): - self.unlimited_args = True - - args += arg + " " - - pad = (maxlen - len(arg)) * ' ' + ' ' - usage += "\n " + arg + pad + descr - usage = usage.replace('ARGS', args) + usage = self.argdoc_parser(argdoc, usage) OptionParser.__init__( self, @@ -442,6 +429,43 @@ def __init__( formatter=CylcHelpFormatter() ) + def argdoc_parser(self, argdoc, usage) -> str: + """Convert argdoc list into a string to be passed to + OptionParser.__init__ as usage argument. + """ + args = "" + maxlen = max(len(arg) for arg, _ in argdoc) + usage += "\n\nArguments:" + if 'ARGS' in usage: + script_name, usage = usage.split('ARGS', 1) + else: + return usage + used_args = [self.LINEBREAK] + if self.LINEBREAK in argdoc: + args += ' One of:\n' + args += script_name + for arg, descr in argdoc: + if arg.startswith('['): + self.n_optional_args += 1 + else: + self.n_compulsory_args += 1 + if arg.rstrip(']').endswith('...'): + self.unlimited_args = True + + if arg == '\n': + args += arg + args += script_name + else: + args += arg + " " + + if (arg, descr) not in used_args: + pad = (maxlen - len(arg)) * ' ' + ' ' + usage += "\n " + arg + pad + descr + used_args.append((arg, descr)) + + usage = args + usage + return usage + def get_std_options(self): """Get a data-structure of standard options""" opts = [] diff --git a/cylc/flow/scripts/message.py b/cylc/flow/scripts/message.py index 0727d51f387..1a8616741d6 100755 --- a/cylc/flow/scripts/message.py +++ b/cylc/flow/scripts/message.py @@ -117,13 +117,14 @@ def get_option_parser() -> COP: __doc__, comms=True, argdoc=[ - COP.optional(WORKFLOW_ID_ARG_DOC), + WORKFLOW_ID_ARG_DOC, + ('JOB', 'Job ID - CYCLE/TASK_NAME/SUBMIT_NUM'), COP.optional( - ('JOB', 'Job ID - CYCLE/TASK_NAME/SUBMIT_NUM') + ('[SEVERITY:]MESSAGE ...', 'Severity Level:Message') ), - COP.optional( - ('[SEVERITY:]MESSAGE ...', 'Messages') - ) + COP.LINEBREAK, + COP.optional(('[SEVERITY:]MESSAGE', 'Severity Level:Message')), + COP.optional(('[SEVERITY:]MESSAGE', 'Severity Level:Message')), ] ) diff --git a/tests/unit/test_option_parsers.py b/tests/unit/test_option_parsers.py index 5a37d0bdc3d..26210f6608e 100644 --- a/tests/unit/test_option_parsers.py +++ b/tests/unit/test_option_parsers.py @@ -30,13 +30,20 @@ ) -USAGE_WITH_COMMENT = "usage \n # comment" +USAGE_WITH_COMMENT = "ARGS usage \n # comment" ARGS = 'args' KWARGS = 'kwargs' SOURCES = 'sources' USEIF = 'useif' +class MockCylcOptionParser(COP): + """A stub subclass to allow unit-like testing of methods.""" + def __init__(self, **kwargs): + for key, value in kwargs.items(): + self.__dict__[key] = value + + @pytest.fixture(scope='module') def parser(): return COP( @@ -94,7 +101,9 @@ def test_help_nocolor(monkeypatch: pytest.MonkeyPatch, parser: COP): f = io.StringIO() with redirect_stdout(f): parser.print_help() - assert (f.getvalue()).startswith("Usage: " + USAGE_WITH_COMMENT) + str_ = f.getvalue() + assert str_.startswith( + "Usage: " + USAGE_WITH_COMMENT.replace('ARGS', 'SOME_ARG ')) def test_Options_std_opts(): @@ -580,3 +589,61 @@ def test__in_list(): second = OptionSettings(['--foo']) third = OptionSettings(['--bar']) assert first._in_list([second, third]) is True + + @pytest.mark.parametrize( + 'argdoc, usage, expect', + ( + param( + [('WORKFLOW', 'Workflow ID')], + 'cylc play [OPTIONS] ARGS\n\nStart, resume...', + enumerate(('cylc play [OPTIONS] WORKFLOW',)), + id='basic' + ), + param( + [ + ('WORKFLOW', 'Workflow ID'), + COP.LINEBREAK, + ('[TASK]', 'Task ID'), + ], + 'cylc total-perspective [OPTIONS] ARGS\n\nThe most savage...', + enumerate([ + 'One of:', + 'cylc total-perspective [OPTIONS] WORKFLOW', + 'cylc total-perspective [OPTIONS] [TASK]' + ]), + id='has-line-break' + ), + param( + [('[TASK]', 'Task ID')], + 'cylc infinite-improbability [OPTIONS] ARGS\n\nEvery point...', + enumerate(['cylc infinite-improbability [OPTIONS] [TASK]',]), + id='startswith-sq-bracket', + ), + param( + [ + ('WORKFLOW', 'Workflow ID'), + COP.LINEBREAK, + ('WORKFLOW', 'Workflow ID'), + ], + 'cylc skin-cat [OPTIONS] ARGS\n\nThere are many ways.', + { + -2: 'Arguments:', + -1: 'WORKFLOW Workflow ID' + }.items(), + id='no-repeated-arg-descriptions' + ) + ) + ) + def test_argdoc_parser(self, argdoc, usage, expect): + """Tests for standalone argdoc_parser method when argdoc + offers multiple ways to call a command. + """ + parser = MockCylcOptionParser( + n_compulsory_args=0, + n_optional_args=0, + unlimited_args=False) + + result = parser.argdoc_parser(argdoc, usage) + results = [r for r in result.split('\n') if r] + for i, line in expect: + assert results[i].strip() == line