From 2e7e28ab324c0ed64954e2cdbee87b23236cd1dc Mon Sep 17 00:00:00 2001 From: Yyunozor Date: Thu, 8 Oct 2026 13:46:07 +0200 Subject: [PATCH] fix(export): strip control characters from the id drawn for an unlabelled node A node without a label is drawn under its id (`G.nodes[n].get("label", n)`), a third path from text to graph.svg. The previous commit cleaned the labels and left that fallback as it was, so an unlabelled node whose id holds a C0 control character still produced a graph.svg that a strict parser rejects. Run the fallback through the same strip: one expression, `_strip_xml_illegal(str(G.nodes[n].get("label", n)))`, now covers the label and the id. A clean id is drawn as before. Tests: for each control character the module already lists, an unlabelled node whose id carries it; the file must parse and still show the cleaned id. Assisted-by: assayer harness on Claude Code (https://github.com/Yyunozor/assayer-memory-mcp) --- graphify/export.py | 3 +-- tests/test_export_control_characters.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/graphify/export.py b/graphify/export.py index f5f8e3fb0..9894000b6 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -1393,8 +1393,7 @@ def to_svg( # svg.fonttype) and escapes only &, <, > and --, so a control character in a label # would leave the file malformed XML while the export reports success (same hazard as #2897). nx.draw_networkx_labels(G, pos, ax=ax, - labels={n: (_strip_xml_illegal(str(G.nodes[n]["label"])) - if "label" in G.nodes[n] else n) + labels={n: _strip_xml_illegal(str(G.nodes[n].get("label", n))) for n in G.nodes()}, font_size=7, font_color="white") diff --git a/tests/test_export_control_characters.py b/tests/test_export_control_characters.py index 8988bc880..5103fb7ca 100644 --- a/tests/test_export_control_characters.py +++ b/tests/test_export_control_characters.py @@ -138,10 +138,10 @@ def test_a_label_that_is_only_control_characters_still_yields_a_name(): # SVG # --------------------------------------------------------------------------- -def _svg(tmp_path, G, community_labels=None): +def _svg(tmp_path, G, community_labels=None, communities=COMMUNITIES): pytest.importorskip("matplotlib") out = tmp_path / "g.svg" - to_svg(G, COMMUNITIES, str(out), community_labels=community_labels) + to_svg(G, communities, str(out), community_labels=community_labels) return out.read_text(encoding="utf-8") @@ -179,6 +179,18 @@ def test_svg_clean_labels_are_unchanged(tmp_path): assert "Ordinary Community (2)" in svg +@pytest.mark.parametrize("ch", BREAK) +def test_svg_survives_a_control_character_in_the_id_of_an_unlabelled_node(tmp_path, ch): + """A node with no label is drawn under its id: a third path from text to the file.""" + G = build_from_json({ + "nodes": [{"id": f"we{ch}ird", "file_type": "code", "source_file": "a.py"}], + "edges": [], "hyperedges": [], + }) + svg = _svg(tmp_path, G, communities={0: [f"we{ch}ird"]}) + ET.fromstring(svg) + assert "weird" in svg + + def test_svg_draws_a_label_that_is_not_a_string(tmp_path): """nx.draw_networkx_labels str()s a non-string label on its own; the strip must not turn that into a TypeError, nor into an empty label."""