From 407a7f142de1ed68ab9c92d8d9cc3dc0943734c8 Mon Sep 17 00:00:00 2001 From: antonioscarinci <96891485+antonioscarinci@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:54:08 +0100 Subject: [PATCH] fix(export,cli): GraphML null-attr coercion + save-result --answer-file (#1502) Two cross-platform fixes salvaged from #1502: - to_graphml: nx.write_graphml raises ValueError on None attribute values, so a node/edge carrying a null field crashed the export. Coerce None -> "" for node and edge attributes before writing. - save-result: add --answer-file as an alternative to --answer so long or multiline answers can be passed via a file instead of a fragile inline shell arg (notably Windows/PowerShell quoting). Exactly one of --answer / --answer-file is required. The rest of #1502 (a version downgrade and a hand-edited generated skill-windows.md that fails skillgen --check, plus duplicated windows-scripts) is left for rework on the PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/__main__.py | 7 ++++++- graphify/export.py | 9 +++++++++ tests/test_export.py | 17 +++++++++++++++++ tests/test_reflect.py | 21 +++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index 2bad90a..f7903c4 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -2970,13 +2970,18 @@ def main() -> None: p = _ap.ArgumentParser(prog="graphify save-result") p.add_argument("--question", required=True) - p.add_argument("--answer", required=True) + p.add_argument("--answer", default=None) + p.add_argument("--answer-file", dest="answer_file", default=None) p.add_argument("--type", dest="query_type", default="query") p.add_argument("--nodes", nargs="*", default=[]) p.add_argument("--outcome", choices=("useful", "dead_end", "corrected"), default=None) p.add_argument("--correction", default=None) p.add_argument("--memory-dir", default=str(Path(_GRAPHIFY_OUT) / "memory")) opts = p.parse_args(sys.argv[2:]) + if opts.answer_file: + opts.answer = Path(opts.answer_file).read_text(encoding="utf-8").strip() + elif not opts.answer: + p.error("--answer or --answer-file is required") from graphify.ingest import save_query_result as _sqr out = _sqr( diff --git a/graphify/export.py b/graphify/export.py index 052dbcc..2967603 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -1535,6 +1535,15 @@ def to_graphml( for _, _, attrs in H.edges(data=True): for k in [k for k in attrs if k.startswith("_")]: del attrs[k] + # nx.write_graphml raises ValueError on None attribute values; replace with "". + for node_id in H.nodes(): + for key, val in list(H.nodes[node_id].items()): + if val is None: + H.nodes[node_id][key] = "" + for u, v in H.edges(): + for key, val in list(H.edges[u, v].items()): + if val is None: + H.edges[u, v][key] = "" nx.write_graphml(H, output_path) diff --git a/tests/test_export.py b/tests/test_export.py index 6a1a439..fb5481f 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -82,6 +82,23 @@ def test_to_graphml_has_community_attribute(): content = out.read_text() assert "community" in content +def test_to_graphml_tolerates_none_attribute_values(): + """nx.write_graphml raises ValueError on a None attribute value; to_graphml + must coerce None -> "" so a node/edge with a null field still exports (#1502).""" + G = make_graph() + communities = cluster(G) + # Inject a None-valued attribute on one node and one edge. + a_node = next(iter(G.nodes())) + G.nodes[a_node]["nullable_field"] = None + if G.number_of_edges(): + u, v = next(iter(G.edges())) + G.edges[u, v]["nullable_field"] = None + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "graph.graphml" + to_graphml(G, communities, str(out)) # must not raise + content = out.read_text() + assert " clean argparse error, not a crash.""" + r = _run(["save-result", "--question", "q", "--outcome", "useful"], tmp_path) + assert r.returncode != 0 + assert "--answer" in (r.stderr + r.stdout) + + def test_cli_reflect_cold_start_writes_empty_lessons(tmp_path): """First run with no graphify-out/memory/ still succeeds and writes a valid doc.""" r = _run(["reflect"], tmp_path)