Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions cylc/flow/parsec/OrderedDict.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@
self[key] = value
self.move_to_end(key, last=False)

@staticmethod
def repl_val(target, replace, replacement):
"""Replace dictionary values with a string.

Designed to be used recursively.
"""
for key, val in target.items():
if isinstance(val, dict):
OrderedDictWithDefaults.repl_val(

Check warning on line 111 in cylc/flow/parsec/OrderedDict.py

View check run for this annotation

Codecov / codecov/patch

cylc/flow/parsec/OrderedDict.py#L111

Added line #L111 was not covered by tests
val, replace, replacement)
elif val == replace:
target[key] = replacement

Check warning on line 114 in cylc/flow/parsec/OrderedDict.py

View check run for this annotation

Codecov / codecov/patch

cylc/flow/parsec/OrderedDict.py#L114

Added line #L114 was not covered by tests


class DictTree:
"""An object providing a single point of access to a tree of dicts.
Expand Down
43 changes: 40 additions & 3 deletions cylc/flow/parsec/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from copy import deepcopy
import json
import re
import sys
from textwrap import dedent
from typing import TYPE_CHECKING, Callable, Iterable, List, Optional
from typing import TYPE_CHECKING, Callable, Iterable, List, Optional, TextIO

from cylc.flow.context_node import ContextNode
from cylc.flow.parsec.exceptions import (
Expand All @@ -33,6 +35,7 @@

if TYPE_CHECKING:
from optparse import Values
from typing_extensions import Literal


class DefaultList(list):
Expand All @@ -41,6 +44,7 @@

class ParsecConfig:
"""Object wrapper for parsec functions."""
META: "Literal['meta']" = 'meta'

def __init__(
self,
Expand Down Expand Up @@ -166,7 +170,7 @@
return cfg

def idump(self, items=None, sparse=False, prefix='',
oneline=False, none_str='', handle=None):
oneline=False, none_str='', handle=None, json=False):
"""
items is a list of --item style inputs:
'[runtime][foo]script'.
Expand All @@ -182,7 +186,40 @@
mkeys.append(j)
if null:
mkeys = [[]]
self.mdump(mkeys, sparse, prefix, oneline, none_str, handle=handle)
if json:
self.jdump(mkeys, sparse, oneline, none_str, handle=handle)

Check warning on line 190 in cylc/flow/parsec/config.py

View check run for this annotation

Codecov / codecov/patch

cylc/flow/parsec/config.py#L190

Added line #L190 was not covered by tests
else:
self.mdump(mkeys, sparse, prefix, oneline, none_str, handle=handle)

def jdump(
self,
mkeys: Optional[Iterable] = None,
sparse: bool = False,
oneline: bool = False,
none_str: Optional[str] = None,
handle: Optional[TextIO] = None
) -> None:
"""Dump a config to JSON format.

Args:
mkeys: Items to display.
sparse: Only display user set items, not defaults.
oneline: Output on a single line.
none_str: Value to give instead of null.
handle: Where to write the output.
"""
# Use json indent to control online output:
indent = None if oneline else 4

Check warning on line 212 in cylc/flow/parsec/config.py

View check run for this annotation

Codecov / codecov/patch

cylc/flow/parsec/config.py#L212

Added line #L212 was not covered by tests

for keys in mkeys or []:
if not keys:
keys = []
cfg = self.get(keys, sparse)

Check warning on line 217 in cylc/flow/parsec/config.py

View check run for this annotation

Codecov / codecov/patch

cylc/flow/parsec/config.py#L216-L217

Added lines #L216 - L217 were not covered by tests
if none_str:
OrderedDictWithDefaults.repl_val(cfg, None, none_str)
data = json.dumps(cfg, indent=indent)

Check warning on line 220 in cylc/flow/parsec/config.py

View check run for this annotation

Codecov / codecov/patch

cylc/flow/parsec/config.py#L219-L220

Added lines #L219 - L220 were not covered by tests

print(data, file=handle or sys.stdout)

Check warning on line 222 in cylc/flow/parsec/config.py

View check run for this annotation

Codecov / codecov/patch

cylc/flow/parsec/config.py#L222

Added line #L222 was not covered by tests

def mdump(self, mkeys=None, sparse=False, prefix='',
oneline=False, none_str='', handle=None):
Expand Down
38 changes: 36 additions & 2 deletions cylc/flow/scripts/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@
"overrides any settings it shares with those higher up."),
action="store_true", default=False, dest="print_hierarchy")

parser.add_option(
'--json',
help=(
'Returns config as JSON rather than Cylc Config format.'),
default=False,
action='store_true',
dest='json'
)

parser.add_option(icp_option)

platform_listing_options_group = parser.add_option_group(
Expand Down Expand Up @@ -140,6 +149,28 @@
return parser


def json_opt_check(parser, options):
"""Return an error if --json and incompatible options used.
"""
not_with_json = {
'--print-hierarchy': 'print_hierarchy',
'--platform-names': 'print_platform_names',
'--platforms': 'print_platforms'
}

if not options.json:
return

not_with_json = [
name for name, dest
in not_with_json.items()
if options.__dict__[dest]]

if not_with_json:
parser.error(

Check warning on line 170 in cylc/flow/scripts/config.py

View check run for this annotation

Codecov / codecov/patch

cylc/flow/scripts/config.py#L170

Added line #L170 was not covered by tests
f'--json incompatible with {" or ".join(not_with_json)}')


def get_config_file_hierarchy(workflow_id: Optional[str] = None) -> List[str]:
filepaths = [os.path.join(path, glbl_cfg().CONF_BASENAME)
for _, path in glbl_cfg().conf_dir_hierarchy]
Expand All @@ -164,6 +195,7 @@
options: 'Values',
*ids,
) -> None:
json_opt_check(parser, options)

if options.print_platform_names and options.print_platforms:
options.print_platform_names = False
Expand All @@ -189,7 +221,8 @@
options.item,
not options.defaults,
oneline=options.oneline,
none_str=options.none_str
none_str=options.none_str,
json=options.json,
)
return

Expand Down Expand Up @@ -219,5 +252,6 @@
options.item,
not options.defaults,
oneline=options.oneline,
none_str=options.none_str
none_str=options.none_str,
json=options.json
)
145 changes: 145 additions & 0 deletions tests/integration/scripts/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# 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 <http://www.gnu.org/licenses/>.

import json
import pytest

from cylc.flow.option_parsers import Options
from cylc.flow.scripts.config import _main, get_option_parser


@pytest.fixture(scope='module')
def setup(mod_one_conf, mod_flow):
parser = get_option_parser()
ConfigOptions = Options(parser)
opts = ConfigOptions()
opts.json = True
wid = mod_flow(mod_one_conf)
yield parser, opts, wid


async def test_json_basic(setup, capsys):
"""Test that the output is in JSON format."""
await _main(*setup)

result = capsys.readouterr()
assert result.err == ''
assert json.loads(result.out)['scheduling']['graph'] == {
'R1': 'one'
}


async def test_json_workflow_cfg(flow, capsys):
"""It fills in values from CLI."""
wid = flow(
{
'scheduling': {'graph': {'P1D': 'foo'}},
'runtime': {'foo': {}},
}
)
parser = get_option_parser()
ConfigOptions = Options(parser)
opts = ConfigOptions()
opts.json = True
opts.icp = ' '

await _main(parser, opts, wid)

returned_config = json.loads(capsys.readouterr().out)
assert returned_config['scheduling']['initial cycle point'] == '1000'
assert returned_config['runtime']['foo'] == {
'completion': 'succeeded',
'simulation': {'default run length': 0.0}
}


@pytest.mark.parametrize(
'not_with',
[
(['print_platforms']),
(['print_platforms', 'print_platform_names']),
(['print_platforms', 'print_platform_names', 'print_hierarchy']),
],
)
async def test_json_and_not_other_option(
setup, capsys, not_with
):
"""It fails if incompatible options provided."""
parser, opts, wid = setup
for key in not_with:
setattr(opts, key, True)

with pytest.raises(SystemExit):
await _main(parser, opts, wid)

result = capsys.readouterr()
assert result.out == ''
assert '--json incompatible with' in result.err
for key in not_with:
if 'platform' in key:
key = key.strip('print_')
assert key.replace('_', '-') in result.err

# Clean up, since setup object is shared:
for key in not_with:
setattr(opts, key, False)


async def test_json_global_cfg(setup, mock_glbl_cfg, capsys):
"""It returns the global configuration in JSON format."""
mock_glbl_cfg(
'cylc.flow.scripts.config.glbl_cfg',
'''
[platforms]
[[golders_green]]
[[[meta]]]
can = "Test lots of things"
because = metadata, is, not, fussy
number = 99
''',
)
parser, opts, _ = setup

await _main(parser, opts)

returned_config = json.loads(capsys.readouterr().out)
assert returned_config == {
'platforms': {
'golders_green': {
'meta': {
'can': 'Test lots of things',
'because': 'metadata, is, not, fussy',
'number': '99',
}
}
}
}


async def test_json_global_cfg_empty(setup, mock_glbl_cfg, capsys):
"""It returns an empty global configuration in JSON format."""
parser, opts, _ = setup
mock_glbl_cfg('cylc.flow.scripts.config.glbl_cfg', '')
opts.item = ['scheduler][mail]']
opts.json = True
opts.defaults = True
opts.none_str = 'zilch'

await _main(parser, opts)

returned_config = json.loads(capsys.readouterr().out)
for key in ['footer', 'from', 'smtp', 'to']:
assert returned_config[key] == 'zilch'
Loading