fix(python): fan out only on JSON-array output, not any IEnumerable

The old code iterated every IEnumerable, so `output = "hello"` emitted one
item per character (a string is IEnumerable-of-char) and `output = {"a":1}`
emitted the keys as items. Now the script's output is serialized first and
the ValueKind decides: JSON array → per-element items; anything else →
single item. `output = None` remains an empty emission.

New tests cover string, dict, number, and None shapes.
This commit is contained in:
Claude
2026-08-31 11:21:55 +00:00
parent e4e55ea01a
commit 6615c5f7d0
2 changed files with 55 additions and 3 deletions

View File

@@ -59,12 +59,19 @@ public sealed class PythonTransformNode : IActionNode
string.IsNullOrEmpty(formatted) ? ex.Message : formatted);
}
if (!scope.TryGetVariable("output", out object? outputObj))
if (!scope.TryGetVariable("output", out object? outputObj) || outputObj is null)
return Task.FromResult<IReadOnlyList<IReadOnlyList<Item>>>(new[] { Array.Empty<Item>() });
// Fan out only when `output` serializes to a JSON array. A naive
// `IEnumerable` check would treat a bare string as an iterable of chars
// (`output = "hello"` → 5 items) and a bare dict as an iterable of keys.
// Serializing first and dispatching on ValueKind gives one predictable rule.
var element = JsonToPython.ToJson(outputObj);
var outItems = new List<Item>();
if (outputObj is System.Collections.IEnumerable ie)
foreach (var v in ie) outItems.Add(new Item(JsonToPython.ToJson(v)));
if (element.ValueKind == JsonValueKind.Array)
foreach (var el in element.EnumerateArray()) outItems.Add(new Item(el.Clone()));
else
outItems.Add(new Item(element));
return Task.FromResult<IReadOnlyList<IReadOnlyList<Item>>>(new[] { outItems });
}

View File

@@ -73,6 +73,51 @@ public class PythonTransformNodeTests
Assert.Contains("line 2", ex.Message);
}
[Fact]
public async Task Bare_string_output_is_a_single_item_not_one_char_per_item()
{
var node = new PythonTransformNode();
var outs = await node.ExecuteAsync(Ctx("output = 'hello'"),
new IReadOnlyList<Item>[] { Array.Empty<Item>() }, CancellationToken.None);
Assert.Single(outs);
Assert.Single(outs[0]);
Assert.Equal("hello", outs[0][0].Json.GetString());
}
[Fact]
public async Task Bare_dict_output_is_a_single_item_not_one_key_per_item()
{
var node = new PythonTransformNode();
var outs = await node.ExecuteAsync(Ctx("output = {'test': 'hi', 'n': 3}"),
new IReadOnlyList<Item>[] { Array.Empty<Item>() }, CancellationToken.None);
Assert.Single(outs);
Assert.Single(outs[0]);
Assert.Equal(JsonValueKind.Object, outs[0][0].Json.ValueKind);
Assert.Equal("hi", outs[0][0].Json.GetProperty("test").GetString());
Assert.Equal(3, outs[0][0].Json.GetProperty("n").GetInt32());
}
[Fact]
public async Task Bare_number_output_is_a_single_item()
{
var node = new PythonTransformNode();
var outs = await node.ExecuteAsync(Ctx("output = 42"),
new IReadOnlyList<Item>[] { Array.Empty<Item>() }, CancellationToken.None);
Assert.Single(outs);
Assert.Single(outs[0]);
Assert.Equal(42, outs[0][0].Json.GetInt64());
}
[Fact]
public async Task None_output_yields_empty_list()
{
var node = new PythonTransformNode();
var outs = await node.ExecuteAsync(Ctx("output = None"),
new IReadOnlyList<Item>[] { Array.Empty<Item>() }, CancellationToken.None);
Assert.Single(outs);
Assert.Empty(outs[0]);
}
[Fact]
public async Task Syntax_error_message_contains_python_traceback()
{