fix(export): coerce non-scalar attrs in graphml; harden pipe-close flush

#1831 — `graphify export graphml` crashed on any dict/list-valued
attribute (per-node metadata dict, graph-level hyperedges list) because
nx.write_graphml only accepts scalars; a real ~2,300-node graph failed
every export and left a 0-byte .graphml behind. to_graphml now coerces
None->"" and JSON-serializes non-scalars across graph/node/edge scopes
(int/float/bool/str pass through), and writes atomically via a temp file
so a failed export can't leave a partial file. Closes #1830.

#1807 followup — adopt @varuntej07's explicit in-guard sys.stdout.flush()
from #1811: piped stdout is block-buffered, so a small fully-buffered
output would only flush at interpreter shutdown (outside the guard),
where a closed-pipe reader escapes as a noisy shutdown error and nonzero
exit. Flushing inside the try closes that gap. Closes #1811.

Reported by @hofmockel (#1831) and @varuntej07 (#1807/#1811).

Co-Authored-By: hofmockel <hofmockel@users.noreply.github.com>
Co-Authored-By: varuntej07 <varuntej07@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-12 23:57:16 +01:00
co-authored by hofmockel varuntej07 Claude Opus 4.8
parent 5f57c46b92
commit a46eee49ef
5 changed files with 102 additions and 7 deletions
+5
View File
@@ -464,6 +464,11 @@ def main() -> None:
agent harnesses read a successful query as a command failure (#1807)."""
try:
_run_cli()
# Flush explicitly, inside the guard. Piped stdout is block-buffered, so a
# small fully-buffered output would otherwise only flush at interpreter
# shutdown — outside this try — where a reader that closed the pipe surfaces
# as a noisy "Exception ignored on flushing sys.stdout" and a nonzero exit.
sys.stdout.flush()
except BrokenPipeError:
_silence_broken_pipe()
except OSError as exc:
+34 -6
View File
@@ -911,16 +911,44 @@ 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 "".
# nx.write_graphml only accepts scalar attribute values: None raises, and a
# dict/list value (e.g. a per-node `metadata` dict, or the graph-level
# `hyperedges` list set by attach_hyperedges()) raises
# "GraphML does not support type <class 'dict'/'list'> as data values" (#1831).
# Coerce None -> "" and non-scalars -> a JSON string, across all three scopes.
def _graphml_safe(val):
if val is None:
return ""
if isinstance(val, bool) or isinstance(val, (int, float, str)):
return val # GraphML-native scalars pass through unchanged
try:
return json.dumps(val, default=str, sort_keys=True)
except (TypeError, ValueError):
return str(val)
for key, val in list(H.graph.items()):
H.graph[key] = _graphml_safe(val)
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] = ""
H.nodes[node_id][key] = _graphml_safe(val)
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)
H.edges[u, v][key] = _graphml_safe(val)
# Write atomically: a mid-serialization error otherwise leaves a 0-byte
# .graphml on disk that downstream tooling mistakes for a completed export
# (#1831). Write to a sibling temp file, then replace on success.
out = Path(output_path)
tmp = out.with_name(out.name + ".tmp")
try:
nx.write_graphml(H, str(tmp))
os.replace(str(tmp), str(out))
finally:
if tmp.exists():
try:
tmp.unlink()
except OSError:
pass
def to_svg(