11from __future__ import annotations
22
3+ import json
34import tempfile
45import unittest
56from pathlib import Path
7+ from typing import Any
68from unittest .mock import patch
79
810import base_cli
@@ -15,6 +17,17 @@ def _write_yaml(path: Path, contents: str) -> None:
1517 path .write_text (contents , encoding = "utf-8" )
1618
1719
20+ def _combined_output (result : Any ) -> str :
21+ """Return human-readable output across Click's split/combined result APIs."""
22+
23+ output = result .output
24+ try :
25+ stderr = result .stderr
26+ except ValueError :
27+ stderr = ""
28+ return output if not stderr or stderr in output else output + stderr
29+
30+
1831class BatteriesIncludedConfigTests (unittest .TestCase ):
1932 def test_nested_merge_provenance_keeps_repeated_leaf_names_scoped (self ) -> None :
2033 values : dict [str , object ] = {}
@@ -29,6 +42,96 @@ def test_nested_merge_provenance_keeps_repeated_leaf_names_scoped(self) -> None:
2942 {"host" : "user" , "db.host" : "project" , "db.tls.enabled" : "project" },
3043 )
3144
45+ def test_nested_non_string_keys_are_rejected_before_first_insert_or_overlay (self ) -> None :
46+ for initial in ({}, {"nested" : {"keep" : True }}):
47+ with self .subTest (initial = initial ):
48+ values = dict (initial )
49+ provenance : dict [str , str ] = {"existing" : "prior" }
50+ original_values = dict (values )
51+ original_provenance = dict (provenance )
52+ with self .assertRaisesRegex (base_cli .ConfigurationError , "nested" ):
53+ _merge_mapping (values , provenance , {"nested" : {2 : "invalid" }}, "explicit" )
54+ self .assertEqual (values , original_values )
55+ self .assertEqual (provenance , original_provenance )
56+
57+ def test_shared_mapping_alias_is_valid_but_recursive_alias_is_rejected_with_path (self ) -> None :
58+ shared = {"answer" : 42 }
59+ valid = {"left" : shared , "right" : shared }
60+ values : dict [str , object ] = {}
61+ provenance : dict [str , str ] = {}
62+ _merge_mapping (values , provenance , valid , "user" )
63+ self .assertEqual (values , {"left" : shared , "right" : shared })
64+ self .assertEqual (provenance , {"left.answer" : "user" , "right.answer" : "user" })
65+
66+ with tempfile .TemporaryDirectory () as tmpdir :
67+ path = Path (tmpdir ) / "recursive.yaml"
68+ _write_yaml (path , "nested: &node\n child: *node\n " )
69+ with self .assertRaisesRegex (base_cli .ConfigurationError , "recursive.yaml.*recursive value.*nested.child" ):
70+ BatteriesIncludedConfigLoader (user_config_dir = Path (tmpdir ) / "user" ).load (None , path )
71+
72+ def test_mapping_depth_is_bounded_before_recursive_merge_or_provenance (self ) -> None :
73+ nested : dict [str , object ] = {"value" : 1 }
74+ for index in range (65 ):
75+ nested = {f"level{ index } " : nested }
76+
77+ with self .assertRaisesRegex (base_cli .ConfigurationError , "maximum nesting depth of 64" ):
78+ _merge_mapping ({}, {}, nested , "explicit" )
79+
80+ def test_scalar_nodes_count_toward_configuration_graph_limit (self ) -> None :
81+ with self .assertRaisesRegex (base_cli .ConfigurationError , "maximum of 100000 nested values" ):
82+ _merge_mapping ({}, {}, {"values" : [0 ] * 100_000 }, "explicit" )
83+
84+ def test_nested_invalid_yaml_shape_is_usage_error_in_human_and_json_modes (self ) -> None :
85+ import click
86+
87+ with tempfile .TemporaryDirectory () as tmpdir :
88+ root = Path (tmpdir )
89+ config_path = root / "invalid.yaml"
90+ _write_yaml (config_path , "nested:\n 2: invalid\n " )
91+ profile = base_cli .CliProfile .batteries_included (
92+ "invalid-nested-config" ,
93+ user_config_dir = root / "user" ,
94+ )
95+ app = base_cli .App (
96+ name = "invalid-nested-config" ,
97+ profile = profile ,
98+ log_to_file = False ,
99+ lifecycle_options = base_cli .LifecycleOptions (json = base_cli .LifecycleOption ("--json" )),
100+ )
101+
102+ @app .command ()
103+ def main (ctx : base_cli .Context ) -> None :
104+ del ctx
105+
106+ @click .command (name = "invalid-nested-config" )
107+ def attached_command () -> None :
108+ pass
109+
110+ attached_app = base_cli .App (
111+ name = "invalid-nested-config" ,
112+ profile = base_cli .CliProfile .batteries_included (
113+ "invalid-nested-config" ,
114+ user_config_dir = root / "user" ,
115+ ),
116+ log_to_file = False ,
117+ lifecycle_options = base_cli .LifecycleOptions (json = base_cli .LifecycleOption ("--json" )),
118+ )
119+ attached = attached_app .attach (attached_command )
120+ targets = (("native" , app ), ("attached" , attached ))
121+ for target_name , target in targets :
122+ for args in (["--config" , str (config_path )], ["--json" , "--config" , str (config_path )]):
123+ with self .subTest (target = target_name , json = "--json" in args ):
124+ result = invoke (target , list (args ), home = root / f"home-{ target_name } -{ len (args )} " )
125+ output = _combined_output (result )
126+ self .assertEqual (result .exit_code , 2 , output )
127+ self .assertNotIn ("RecursionError" , output )
128+ if "--json" in args :
129+ payload = json .loads (result .stdout )
130+ self .assertEqual (payload ["code" ], "usage_error" )
131+ self .assertIn (str (config_path ), payload ["message" ])
132+ else :
133+ self .assertIn (str (config_path ), output )
134+
32135 def test_layered_loader_merges_in_documented_order_and_records_provenance (self ) -> None :
33136 with tempfile .TemporaryDirectory () as tmpdir :
34137 root = Path (tmpdir )
0 commit comments