Skip to content

Commit f819b97

Browse files
authored
Add typ option to load_yaml_files (#49)
1 parent 1fdfb78 commit f819b97

5 files changed

Lines changed: 83 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
- BREAKING CHANGE: Rework list merge logic — within-file duplicates are now preserved; if any file contains duplicate dict items in a list, merging is disabled for that entire list and items are concatenated instead
44
- BREAKING CHANGE: Two dict items in a list now merge when all shared primitive fields match, even if both sides have additional unique primitive fields (previously this was blocked)
55
- Add support for Python 3.14
6+
- Add `typ` parameter to `load_yaml_files()` to select ruamel.yaml loader mode (e.g. `"safe"` for native dict/list)
67

78
# 1.1.1
89

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,29 @@
55

66
A Python library with common YAML utility functions supporting `Network as Code`.
77

8+
## Usage
9+
10+
### Load and merge YAML files
11+
12+
```python
13+
from pathlib import Path
14+
from nac_yaml.yaml import load_yaml_files
15+
16+
# Default is ruamel's round-trip loader (preserves formatting internally).
17+
data = load_yaml_files([
18+
Path("path/to/file1.yaml"),
19+
Path("path/to/file2.yaml"),
20+
])
21+
22+
# Use the safe loader to get native dict/list containers.
23+
data_safe = load_yaml_files([
24+
Path("path/to/file1.yaml"),
25+
Path("path/to/file2.yaml"),
26+
], typ="safe")
27+
```
28+
29+
Note: when `typ` is not round-trip (e.g. `"safe"`), formatting features (quotes/comments/style) are not preserved.
30+
831
## Installation
932

1033
### Using uv (recommended)

nac_yaml/yaml.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,11 @@ def from_yaml(cls, loader: Any, node: Any) -> str:
142142
return str(cls(node.value))
143143

144144

145-
def load_yaml_files(paths: list[Path], deduplicate: bool = True) -> dict[str, Any]:
145+
def load_yaml_files(
146+
paths: list[Path],
147+
deduplicate: bool = True,
148+
typ: str | None = None,
149+
) -> dict[str, Any]:
146150
"""Load and merge YAML files from provided paths.
147151
148152
Args:
@@ -151,6 +155,13 @@ def load_yaml_files(paths: list[Path], deduplicate: bool = True) -> dict[str, An
151155
- If ANY file has duplicates in a list, that list is concatenated (no merging)
152156
- If NO duplicates exist, matching dict items are merged across files
153157
When False, simply concatenates all list items.
158+
typ: Optional ruamel.yaml parser type passed to ``ruamel.yaml.YAML(typ=...)``.
159+
Use ``"safe"`` to load native Python types (dict/list) instead of round-trip
160+
containers.
161+
162+
Caveat: when ``typ`` is not round-trip (e.g. ``"safe"``), formatting-related
163+
options like ``preserve_quotes`` do not apply and comments/quoting/style are not
164+
preserved.
154165
155166
Returns:
156167
Merged dictionary structure
@@ -188,7 +199,7 @@ def load_yaml_files(paths: list[Path], deduplicate: bool = True) -> dict[str, An
188199
Result: devices: [{name: switch1}, {name: switch1}, {name: switch1, port: 1/0/1}] # all preserved
189200
"""
190201
# Create YAML parser once and reuse for all files
191-
y = yaml.YAML()
202+
y = yaml.YAML(typ=typ) if typ is not None else yaml.YAML()
192203
y.preserve_quotes = True
193204
y.register_class(VaultTag)
194205
y.register_class(EnvTag)

scripts/compare_merge.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def make_serializable(obj):
8080
return str(obj)
8181
8282
paths = [Path(p) for p in sys.argv[1:]]
83-
result = load_yaml_files(paths, deduplicate=True)
83+
result = load_yaml_files(paths, deduplicate=True{typ_arg})
8484
json.dump(make_serializable(result), sys.stdout, indent=2, sort_keys=True)
8585
""")
8686

@@ -123,12 +123,15 @@ def resolve_paths(raw_paths: list[str]) -> list[Path]:
123123
return resolved
124124

125125

126-
def run_version(version: str, paths: list[Path]) -> dict[str, Any]:
126+
def run_version(
127+
version: str, paths: list[Path], typ: str | None = None
128+
) -> dict[str, Any]:
127129
"""Run load_yaml_files() with a specific nac-yaml version in an isolated uv env.
128130
129131
Args:
130132
version: nac-yaml PyPI version string (e.g. "1.1.1").
131133
paths: Absolute paths to YAML files/directories.
134+
typ: Optional ruamel.yaml parser type to pass to load_yaml_files() for the NEW version.
132135
133136
Returns:
134137
Parsed JSON dict of the merged YAML output.
@@ -137,6 +140,12 @@ def run_version(version: str, paths: list[Path]) -> dict[str, Any]:
137140
SystemExit: On subprocess failure.
138141
"""
139142
str_paths = [str(p) for p in paths]
143+
144+
if version == NEW_VERSION and typ is not None:
145+
typ_arg = f", typ={typ!r}"
146+
else:
147+
typ_arg = ""
148+
140149
cmd = [
141150
"uv",
142151
"run",
@@ -145,7 +154,7 @@ def run_version(version: str, paths: list[Path]) -> dict[str, Any]:
145154
f"nac-yaml=={version}",
146155
"python",
147156
"-c",
148-
_LOADER_SCRIPT_TEMPLATE,
157+
_LOADER_SCRIPT_TEMPLATE.format(typ_arg=typ_arg),
149158
*str_paths,
150159
]
151160
try:
@@ -1017,6 +1026,14 @@ def main() -> int:
10171026
action="store_true",
10181027
help="Render both merge results as YAML and show unified diff (like diff -u)",
10191028
)
1029+
parser.add_argument(
1030+
"--typ",
1031+
metavar="TYP",
1032+
help=(
1033+
"Pass ruamel.yaml typ to load_yaml_files() for the NEW version only "
1034+
'(e.g. "safe" for native dict/list)'
1035+
),
1036+
)
10201037
args = parser.parse_args()
10211038

10221039
if args.diff and (args.json_output or args.raw):
@@ -1035,7 +1052,7 @@ def main() -> int:
10351052
)
10361053
with ThreadPoolExecutor(max_workers=2) as pool:
10371054
future_old = pool.submit(run_version, OLD_VERSION, resolved)
1038-
future_new = pool.submit(run_version, NEW_VERSION, resolved)
1055+
future_new = pool.submit(run_version, NEW_VERSION, resolved, args.typ)
10391056
old_data = future_old.result()
10401057
new_data = future_new.result()
10411058

tests/unit/test_yaml.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@
1313
pytestmark = pytest.mark.unit
1414

1515

16-
def test_load_yaml_files(tmpdir: Path) -> None:
16+
def _is_only_dict_list_tree(value: Any) -> bool:
17+
if isinstance(value, dict):
18+
return all(_is_only_dict_list_tree(v) for v in value.values())
19+
if isinstance(value, list):
20+
return all(_is_only_dict_list_tree(v) for v in value)
21+
return True
22+
23+
24+
@pytest.mark.parametrize("typ", [None, "rt", "safe"])
25+
def test_load_yaml_files(tmpdir: Path, typ: str | None) -> None:
1726
input_path_1 = Path("tests/unit/fixtures/data_merge/file1.yaml")
1827
input_path_2 = Path("tests/unit/fixtures/data_merge/file2.yaml")
1928
output_path = Path(tmpdir, "output.yaml")
@@ -22,22 +31,33 @@ def test_load_yaml_files(tmpdir: Path) -> None:
2231
"tests/unit/fixtures/data_merge/result_no_deduplicate.yaml"
2332
)
2433

25-
data = yaml.load_yaml_files([input_path_1, input_path_2])
34+
data = yaml.load_yaml_files([input_path_1, input_path_2], typ=typ)
35+
if typ == "safe":
36+
assert _is_only_dict_list_tree(data)
2637
yaml.write_yaml_file(data, output_path)
2738
assert filecmp.cmp(output_path, result_path, shallow=False)
2839

29-
data = yaml.load_yaml_files([input_path_1, input_path_2], deduplicate=False)
40+
data = yaml.load_yaml_files(
41+
[input_path_1, input_path_2], deduplicate=False, typ=typ
42+
)
43+
if typ == "safe":
44+
assert _is_only_dict_list_tree(data)
3045
yaml.write_yaml_file(data, output_path)
3146
assert filecmp.cmp(output_path, result_no_deduplicate_path, shallow=False)
3247

3348
input_path = Path("tests/unit/fixtures/data_vault/")
3449
os.environ["ANSIBLE_VAULT_ID"] = "dev"
3550
os.environ["ANSIBLE_VAULT_PASSWORD"] = "Password123"
36-
data = yaml.load_yaml_files([input_path])
51+
data = yaml.load_yaml_files([input_path], typ=typ)
52+
if typ == "safe":
53+
assert _is_only_dict_list_tree(data)
54+
assert data["root"]["children"][0]["name"] == "ABC\n"
3755

3856
input_path = Path("tests/unit/fixtures/data_env/")
3957
os.environ["ABC"] = "DEF"
40-
data = yaml.load_yaml_files([input_path])
58+
data = yaml.load_yaml_files([input_path], typ=typ)
59+
if typ == "safe":
60+
assert _is_only_dict_list_tree(data)
4161
assert data["root"]["children"][0]["name"] == "DEF"
4262

4363

0 commit comments

Comments
 (0)