Skip to content

Commit 5468e3c

Browse files
authored
fix: use nac-yaml typ=safe to return plain dict/list (#788) (#796)
* fix: strip ruamel types at merge boundary to prevent Jinja2 attr collisions (#788) Convert CommentedMap/CommentedSeq to plain dict/list via recursive _to_builtin_types() in DataMerger.merge_data_files(), so Jinja2 templates never see ruamel-internal attributes (tag, anchor, ca, etc.) via dot-notation. This replaces the KeyFirstEnvironment approach from #789 with a simpler solution — no blocklists, no custom Jinja2 Environment, no edge cases. - Add _to_builtin_types() in data_merger.py (single-pass, no JSON overhead) - Revert robot_writer.py to standard jinja2.Environment - Add unit tests enforcing the no-ruamel-types contract - Add integration test fixtures for attr collision regression * fix: use nac-yaml typ=safe to return plain types, remove _to_builtin_types Leverage nac-yaml's new typ='safe' parameter in load_yaml_files() to get plain dict/list directly from the YAML parser, removing the need for _to_builtin_types() post-processing in DataMerger. - Pin nac-yaml to oboehmer/nac-yaml@load-typ branch with typ support - Remove _to_builtin_types() and its unit tests from data_merger - Keep contract tests and integration tests as regression guard * build: allow direct references in hatch for nac-yaml git dep * remove redundant method test * change Any -> object typing * update nac-yaml==2.0.0b2
1 parent 6d5972d commit 5468e3c

12 files changed

Lines changed: 155 additions & 38 deletions

File tree

nac_test/data_merger.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def merge_data_files(data_paths: list[Path]) -> dict[str, Any]:
3535
logger.info(
3636
"Loading yaml files from %s", ", ".join([str(path) for path in data_paths])
3737
)
38-
data = yaml.load_yaml_files(data_paths)
38+
data = yaml.load_yaml_files(data_paths, typ="safe")
3939
# Ensure we always return a dict, even if yaml returns None
4040
return data if isinstance(data, dict) else {}
4141

nac_test/robot/robot_writer.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import re
1010
import shutil
1111
import sys
12-
from collections.abc import Mapping
1312
from pathlib import Path
1413
from typing import Any
1514

@@ -31,14 +30,6 @@ class StrictChainableUndefined(ChainableUndefined):
3130
__contains__ = Undefined._fail_with_undefined_error
3231

3332

34-
class KeyFirstEnvironment(Environment):
35-
# Prefer Mapping keys over attributes to avoid ruamel/Jinja dot-notation collisions (e.g. `tag`).
36-
def getattr(self, obj: Any, attribute: str) -> Any:
37-
if isinstance(obj, Mapping) and attribute in obj:
38-
return obj[attribute]
39-
return super().getattr(obj, attribute)
40-
41-
4233
class TestCollector(SuiteVisitor): # type: ignore[misc]
4334
"""Visitor to collect test or suite names to construct the pabot ordering file.
4435
@@ -303,7 +294,7 @@ def write(
303294
self, templates_path: Path, output_path: Path, ordering_file: Path | None = None
304295
) -> None:
305296
"""Render Robot test suites."""
306-
env = KeyFirstEnvironment( # nosec B701
297+
env = Environment( # nosec B701
307298
loader=FileSystemLoader(templates_path),
308299
undefined=StrictChainableUndefined,
309300
lstrip_blocks=True,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ dependencies = [
3232
"Jinja2>=3.1.6",
3333
"jmespath>=1.0.1",
3434
"nac-test-pyats-common>=0.3.0",
35-
"nac-yaml==2.0.0a0",
35+
"nac-yaml==2.0.0b2",
3636
"robotframework>=7.3.2",
3737
"robotframework-jmespath",
3838
"robotframework-jsonlibrary>=0.5",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
root:
3+
children:
4+
- name: abc
5+
param: value
6+
tag: 100
7+
items: foo
8+
keys: bar
9+
- name: def
10+
param: value
11+
items: foo
12+
keys: bar
13+
14+
# Nested structure with collision keys at different levels (also tests CommentedSeq)
15+
fabric:
16+
spine:
17+
node_id: 101
18+
interfaces:
19+
- name: Ethernet1/1
20+
tag: trunk
21+
- name: Ethernet1/2
22+
tag: access
23+
24+
defaults:
25+
tag: fallback
26+
description: "default desc"

tests/integration/fixtures/data_attr_collision_dir/root.yaml

Lines changed: 0 additions & 8 deletions
This file was deleted.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
*** Settings ***
2+
Documentation Test collision key access via bracket notation and non-collision dot access
3+
4+
*** Test Cases ***
5+
{% for child in root.children | default([]) %}
6+
7+
Test {{ child.name }}
8+
Should Be Equal {{ child.param }} value msg=param_{{ child.name }}
9+
Should Be Equal {{ child.tag | default(defaults.tag) }} {% if child.name == 'abc' %}100{% else %}fallback{% endif %} msg=tag_{{ child.name }}
10+
# 'items' and 'keys' collide with dict method names — dot access (child.items)
11+
# would resolve to the dict method, not the YAML key value. Use bracket notation.
12+
Should Be Equal {{ child['items'] }} foo msg=items_key_{{ child.name }}
13+
Should Be Equal {{ child['keys'] }} bar msg=keys_key_{{ child.name }}
14+
{% endfor %}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
*** Settings ***
2+
Documentation Test selectattr/rejectattr/map(attribute=) with collision keys like 'tag'
3+
4+
*** Test Cases ***
5+
Test selectattr with tag key
6+
{% set trunk_ports = root.fabric.spine.interfaces | selectattr('tag', 'equalto', 'trunk') | list %}
7+
Should Be Equal {{ trunk_ports | length }} 1 msg=selectattr_count
8+
Should Be Equal {{ trunk_ports[0].name }} Ethernet1/1 msg=selectattr_name
9+
10+
Test rejectattr with tag key
11+
{% set non_trunk = root.fabric.spine.interfaces | rejectattr('tag', 'equalto', 'trunk') | list %}
12+
Should Be Equal {{ non_trunk | length }} 1 msg=rejectattr_count
13+
Should Be Equal {{ non_trunk[0].name }} Ethernet1/2 msg=rejectattr_name
14+
15+
Test map attribute extraction with collision key
16+
{% set all_tags = root.fabric.spine.interfaces | map(attribute='tag') | list %}
17+
Should Be Equal {{ all_tags | join(',') }} trunk,access msg=map_tags

tests/integration/fixtures/templates_test/test1.robot

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,5 @@ Documentation Test1
55
{% for child in root.children | default([]) %}
66

77
Test {{ child.name }}
8-
Should Be Equal {{ child.param }} value
9-
Log tag={{ child.tag }}
10-
Log items={{ child.items }}
11-
Log keys={{ child.keys }}
8+
Should Be Equal {{ child.param is test1 child.param }} True
129
{% endfor %}

tests/integration/test_cli_rendering.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"""
1010

1111
import filecmp
12+
import re
1213
from pathlib import Path
1314

1415
import pytest
@@ -338,9 +339,16 @@ def test_render_only_without_controller_credentials(tmp_path: Path) -> None:
338339

339340

340341
def test_dict_key_attribute_collision_renders_key_values(tmp_path: Path) -> None:
342+
"""Comprehensive test for key-attribute collision handling.
343+
344+
Tests three categories with a single data dir + template dir:
345+
1. test.robot — collision keys (items, keys) via bracket notation; non-collision (tag) via dot access
346+
2. test_methods.robot — .items()/.get()/.keys()/.values() method calls on clean mappings
347+
3. test_selectattr.robot — selectattr/rejectattr/map(attribute=) with collision key 'tag'
348+
"""
341349
runner = CliRunner()
342-
data_path = "tests/integration/fixtures/data_attr_collision_dir"
343-
templates_path = "tests/integration/fixtures/templates_test"
350+
data_path = "tests/integration/fixtures/data_attr_collision"
351+
templates_path = "tests/integration/fixtures/templates_attr_collision"
344352

345353
result = runner.invoke(
346354
nac_test.cli.main.app,
@@ -351,12 +359,39 @@ def test_dict_key_attribute_collision_renders_key_values(tmp_path: Path) -> None
351359
templates_path,
352360
"-o",
353361
str(tmp_path),
354-
"--render-only",
355362
],
356363
)
357-
assert result.exit_code == 0, result.output
364+
assert result.exit_code == 0, f"CLI rendering failed:\n{result.output}"
358365

359-
output = (tmp_path / ROBOT_RESULTS_DIRNAME / "test1.robot").read_text()
360-
assert "tag=100" in output
361-
assert "items=foo" in output
362-
assert "keys=bar" in output
366+
output_dir = tmp_path / ROBOT_RESULTS_DIRNAME
367+
368+
# --- test.robot: collision keys via bracket notation ---
369+
output_collision = (output_dir / "test.robot").read_text()
370+
assert re.search(r"Should Be Equal\s+100\s+100\s+msg=tag_abc", output_collision), (
371+
"tag key dot-access did not render '100' for child abc"
372+
)
373+
assert re.search(
374+
r"Should Be Equal\s+foo\s+foo\s+msg=items_key_abc", output_collision
375+
), "items collision key did not render 'foo' via bracket notation"
376+
assert re.search(
377+
r"Should Be Equal\s+bar\s+bar\s+msg=keys_key_abc", output_collision
378+
), "keys collision key did not render 'bar' via bracket notation"
379+
assert "{{" not in output_collision, "Unresolved Jinja2 expression in test.robot"
380+
381+
# --- test_selectattr.robot: selectattr/rejectattr/map ---
382+
output_select = (output_dir / "test_selectattr.robot").read_text()
383+
assert re.search(
384+
r"Should Be Equal\s+Ethernet1/1\s+Ethernet1/1\s+msg=selectattr_name",
385+
output_select,
386+
), "selectattr didn't find trunk port Ethernet1/1"
387+
assert re.search(
388+
r"Should Be Equal\s+Ethernet1/2\s+Ethernet1/2\s+msg=rejectattr_name",
389+
output_select,
390+
), "rejectattr didn't find non-trunk port Ethernet1/2"
391+
assert re.search(
392+
r"Should Be Equal\s+trunk,access\s+trunk,access\s+msg=map_tags",
393+
output_select,
394+
), "map(attribute='tag') didn't produce 'trunk,access'"
395+
assert "{{" not in output_select, (
396+
"Unresolved Jinja2 expression in test_selectattr.robot"
397+
)

tests/unit/robot/test_robot_writer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,4 @@ def test_creates_output_file_and_parent_directories(
9797
)
9898
assert output.exists()
9999
assert output.parent.is_dir()
100+
assert output.read_text().strip() # non-empty content

0 commit comments

Comments
 (0)