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__