diff --git a/cylc/flow/parsec/OrderedDict.py b/cylc/flow/parsec/OrderedDict.py index ed5b85c20f4..8e727719b40 100644 --- a/cylc/flow/parsec/OrderedDict.py +++ b/cylc/flow/parsec/OrderedDict.py @@ -100,6 +100,19 @@ def prepend(self, key, value): 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( + val, replace, replacement) + elif val == replace: + target[key] = replacement + class DictTree: """An object providing a single point of access to a tree of dicts. diff --git a/cylc/flow/parsec/config.py b/cylc/flow/parsec/config.py index 29944c03b30..794ee9eea03 100644 --- a/cylc/flow/parsec/config.py +++ b/cylc/flow/parsec/config.py @@ -15,9 +15,11 @@ # along with this program. If not, see . 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 ( @@ -33,10 +35,12 @@ if TYPE_CHECKING: from optparse import Values + from typing_extensions import Literal class ParsecConfig: """Object wrapper for parsec functions.""" + META: "Literal['meta']" = 'meta' def __init__( self, @@ -162,7 +166,7 @@ def get(self, keys: Optional[Iterable[str]] = None, sparse: bool = False): 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'. @@ -178,7 +182,40 @@ def idump(self, items=None, sparse=False, prefix='', 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) + 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 + + for keys in mkeys or []: + if not keys: + keys = [] + cfg = self.get(keys, sparse) + if none_str: + OrderedDictWithDefaults.repl_val(cfg, None, none_str) + data = json.dumps(cfg, indent=indent) + + print(data, file=handle or sys.stdout) def mdump(self, mkeys=None, sparse=False, prefix='', oneline=False, none_str='', handle=None): diff --git a/cylc/flow/scripts/config.py b/cylc/flow/scripts/config.py index aa7144a934d..b5826f46d07 100755 --- a/cylc/flow/scripts/config.py +++ b/cylc/flow/scripts/config.py @@ -110,6 +110,15 @@ def get_option_parser() -> COP: "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( @@ -139,6 +148,28 @@ def get_option_parser() -> COP: 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( + 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] @@ -163,6 +194,7 @@ async def _main( options: 'Values', *ids, ) -> None: + json_opt_check(parser, options) if options.print_platform_names and options.print_platforms: options.print_platform_names = False @@ -188,7 +220,8 @@ async def _main( options.item, not options.defaults, oneline=options.oneline, - none_str=options.none_str + none_str=options.none_str, + json=options.json, ) return @@ -213,5 +246,6 @@ async def _main( options.item, not options.defaults, oneline=options.oneline, - none_str=options.none_str + none_str=options.none_str, + json=options.json ) diff --git a/tests/functional/cylc-config/11-json-dump.t b/tests/functional/cylc-config/11-json-dump.t new file mode 100644 index 00000000000..5e2af26aa33 --- /dev/null +++ b/tests/functional/cylc-config/11-json-dump.t @@ -0,0 +1,113 @@ +#!/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 cylc config can dump json files. +# n.b. not heavily tested because most of this functionality +# is from Standard library json. +. "$(dirname "$0")/test_header" +#------------------------------------------------------------------------------- +set_test_number 9 +#------------------------------------------------------------------------------- + +# Test that option parser errors if incompat options given: +cylc config --json --platforms 2> err +named_grep_ok "${TEST_NAME_BASE}.CLI-one-incompat-item" \ + "--json incompatible with --platforms" \ + err + +cylc config --json --platforms --platform-names 2> err +named_grep_ok "${TEST_NAME_BASE}.CLI-two-incompat-items" \ + "--json incompatible with --platform-names or --platforms" \ + err + +cylc config --json --platforms --platform-names --print-hierarchy 2> err +named_grep_ok "${TEST_NAME_BASE}.CLI-three-incompat-items" \ + "--json incompatible with --print-hierarchy or --platform-names or --platforms" \ + err + + +# Test the global.cylc +TEST_NAME="${TEST_NAME_BASE}-global" + +cat > "global.cylc" <<__HEREDOC__ +[platforms] + [[golders_green]] + [[[meta]]] + can = "Test lots of things" + because = metadata, is, not, fussy + number = 99 +__HEREDOC__ + +export CYLC_CONF_PATH="${PWD}" +run_ok "${TEST_NAME}" cylc config --json --one-line +cmp_ok "${TEST_NAME}.stdout" <<__HERE__ +{"platforms": {"golders_green": {"meta": {"can": "Test lots of things", "because": "metadata, is, not, fussy", "number": "99"}}}} +__HERE__ + +# Test a flow.cylc +TEST_NAME="${TEST_NAME_BASE}-workflow" + +cat > "flow.cylc" <<__HERE__ +[scheduling] + [[graph]] + P1D = foo + +[runtime] + [[foo]] +__HERE__ + +run_ok "${TEST_NAME}" cylc config . --json --icp 1000 +cmp_ok "${TEST_NAME}.stdout" <<__HERE__ +{ + "scheduling": { + "graph": { + "P1D": "foo" + }, + "initial cycle point": "1000" + }, + "runtime": { + "root": {}, + "foo": { + "completion": "succeeded" + } + } +} +__HERE__ + +# Test an empty global.cylc to check: +# * item selection +# * null value setting +# * showing defaults +TEST_NAME="${TEST_NAME_BASE}-defaults-item-null-value" +echo "" > global.cylc +export CYLC_CONF_PATH="${PWD}" + +run_ok "${TEST_NAME}" cylc config \ + -i '[scheduler][mail]' \ + --json \ + --defaults \ + --null-value='zilch' + +cmp_ok "${TEST_NAME}.stdout" <<__HERE__ +{ + "from": "zilch", + "smtp": "zilch", + "to": "zilch", + "footer": "zilch", + "task event batch interval": 300.0 +} +__HERE__