Spaces:
Running
Running
deploy: tests/test_codegen.py
Browse files- tests/test_codegen.py +154 -0
tests/test_codegen.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Codegen must be deterministic and produce code that is (a) valid Python and (b) a
|
| 2 |
+
working daggr graph. Both are asserted here - offline, with no network calls."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import ast
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
from daggrstudio.codegen import (
|
| 10 |
+
build_graph,
|
| 11 |
+
infer_postprocess_hint,
|
| 12 |
+
node_name_for,
|
| 13 |
+
render_app_py,
|
| 14 |
+
resolve_postprocess,
|
| 15 |
+
)
|
| 16 |
+
from daggrstudio.registry.bricks import get_registry
|
| 17 |
+
from daggrstudio.spec import Binding, Step, WorkflowSpec
|
| 18 |
+
from tests.conftest import concept_spec
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_build_graph_creates_nodes_and_edges():
|
| 22 |
+
graph = build_graph(concept_spec())
|
| 23 |
+
names = set(graph.nodes)
|
| 24 |
+
assert {"concept", "cutout"}.issubset(names) # space steps keep their id
|
| 25 |
+
assert any(n.startswith("organize_outputs") for n in names) # fn step named after fn
|
| 26 |
+
|
| 27 |
+
connections = graph.get_connections()
|
| 28 |
+
endpoints = {(c[0], c[1], c[2], c[3]) for c in connections} if connections else set()
|
| 29 |
+
assert connections, "expected at least one edge"
|
| 30 |
+
assert any("concept" in str(c) and "cutout" in str(c) for c in connections), endpoints
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_graph_has_a_stable_name_and_persist_key():
|
| 34 |
+
graph = build_graph(concept_spec())
|
| 35 |
+
assert graph.name == "Concept to sprite"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_build_graph_rejects_unknown_brick():
|
| 39 |
+
spec = concept_spec()
|
| 40 |
+
spec.steps[0].brick_id = "not-a-real-brick"
|
| 41 |
+
spec.steps[0].source = "owner/does-not-exist"
|
| 42 |
+
import pytest
|
| 43 |
+
|
| 44 |
+
with pytest.raises(KeyError, match="unknown brick"):
|
| 45 |
+
build_graph(spec)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_seed_registry_entries_carry_provenance():
|
| 49 |
+
reg = get_registry()
|
| 50 |
+
seed = reg.by_id("flux1-schnell")
|
| 51 |
+
assert seed is not None and seed.verified_at # introspected in-session
|
| 52 |
+
unverified = reg.by_id("wan21")
|
| 53 |
+
assert unverified is not None
|
| 54 |
+
assert not unverified.is_live_verified or unverified.verified_at
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_node_names_are_unique_and_url_safe():
|
| 58 |
+
used: set[str] = set()
|
| 59 |
+
a = node_name_for(Step(id="Concept Art", kind="space"), used)
|
| 60 |
+
b = node_name_for(Step(id="concept art", kind="space"), used)
|
| 61 |
+
assert a == "concept_art" and b == "concept_art_2"
|
| 62 |
+
assert node_name_for(Step(id="x", kind="fn"), used) is None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_resolve_postprocess_hints():
|
| 66 |
+
first = resolve_postprocess("first")
|
| 67 |
+
assert first("img", 42) == "img"
|
| 68 |
+
second = resolve_postprocess("tuple_index:1")
|
| 69 |
+
assert second("original", "processed") == "processed"
|
| 70 |
+
assert second("only") == "only" # tolerant of single-value returns
|
| 71 |
+
dp = resolve_postprocess("dict_path:path")
|
| 72 |
+
assert dp({"path": "/tmp/a.png"}) == "/tmp/a.png"
|
| 73 |
+
assert dp("/tmp/a.png") == "/tmp/a.png"
|
| 74 |
+
assert resolve_postprocess(None) is None
|
| 75 |
+
assert resolve_postprocess("nonsense") is None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_postprocess_hint_comes_from_the_registry():
|
| 79 |
+
spec = concept_spec()
|
| 80 |
+
registry = get_registry()
|
| 81 |
+
cutout = spec.steps[1]
|
| 82 |
+
brick = registry.by_id(cutout.brick_id)
|
| 83 |
+
# whatever the registry verified for this brick is what codegen must use
|
| 84 |
+
assert infer_postprocess_hint(brick, cutout) == (brick.postprocess_hint
|
| 85 |
+
if brick.postprocess_hint else None)
|
| 86 |
+
# an explicit step-level hint always overrides the registry
|
| 87 |
+
cutout.postprocess = "tuple_index:1"
|
| 88 |
+
assert infer_postprocess_hint(brick, cutout) == "tuple_index:1"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_multi_value_bricks_get_a_postprocess_hint():
|
| 92 |
+
"""A brick that returns (value, extra) must never be used without unpacking."""
|
| 93 |
+
registry = get_registry()
|
| 94 |
+
multi = [b for b in registry.all() if b.postprocess_hint and b.status == "running"]
|
| 95 |
+
assert multi, "registry should expose at least one multi-return brick"
|
| 96 |
+
for brick in multi:
|
| 97 |
+
hint = brick.postprocess_hint
|
| 98 |
+
assert hint.startswith("tuple_index:") or hint.startswith("dict_path:")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_render_app_py_is_valid_python_and_self_contained():
|
| 102 |
+
spec = concept_spec()
|
| 103 |
+
registry = get_registry()
|
| 104 |
+
source = render_app_py(spec, registry)
|
| 105 |
+
ast.parse(source) # raises SyntaxError if codegen emits broken code
|
| 106 |
+
assert "from daggr import FnNode, GradioNode, InferenceNode, InputNode, Graph" in source
|
| 107 |
+
gen = registry.by_id(spec.steps[0].brick_id)
|
| 108 |
+
cut = registry.by_id(spec.steps[1].brick_id)
|
| 109 |
+
assert gen.source in source
|
| 110 |
+
assert f'api_name="{gen.api_name}"' in source
|
| 111 |
+
assert cut.source in source
|
| 112 |
+
assert "def organize_outputs(" in source # helper inlined
|
| 113 |
+
assert "daggrstudio" not in source # no dependency on this package
|
| 114 |
+
assert "graph.launch()" in source
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_render_is_deterministic_and_tracks_the_spec():
|
| 118 |
+
spec = concept_spec()
|
| 119 |
+
one = render_app_py(spec)
|
| 120 |
+
two = render_app_py(spec)
|
| 121 |
+
# identical except for the generated timestamp line
|
| 122 |
+
assert "\n".join(l for l in one.splitlines() if "Generated:" not in l) == \
|
| 123 |
+
"\n".join(l for l in two.splitlines() if "Generated:" not in l)
|
| 124 |
+
|
| 125 |
+
spec.name = "Renamed"
|
| 126 |
+
assert "Renamed" in render_app_py(spec)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_rendered_app_reparses_into_an_equivalent_graph():
|
| 130 |
+
"""The downloaded code and the in-Space graph share one spec - prove the spec survives
|
| 131 |
+
a JSON round trip through the render path."""
|
| 132 |
+
spec = concept_spec()
|
| 133 |
+
again = WorkflowSpec.from_dict(json.loads(json.dumps(spec.to_dict())))
|
| 134 |
+
assert render_app_py(again).replace("Generated: ", "").split("\n")[0:3] == \
|
| 135 |
+
render_app_py(spec).replace("Generated: ", "").split("\n")[0:3]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_fn_step_uses_the_real_function_signature():
|
| 139 |
+
graph = build_graph(concept_spec())
|
| 140 |
+
fn_nodes = [n for name, n in graph.nodes.items() if name.startswith("organize_outputs")]
|
| 141 |
+
assert fn_nodes, "fn step should produce a node"
|
| 142 |
+
node = fn_nodes[0]
|
| 143 |
+
assert "source" in node._input_ports
|
| 144 |
+
assert "output_dir" in node._input_ports
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_build_graph_handles_a_spec_with_no_inputs():
|
| 148 |
+
spec = WorkflowSpec(name="No inputs", steps=[
|
| 149 |
+
Step(id="bg", kind="space", brick_id="background-removal",
|
| 150 |
+
inputs={"image": Binding(value="/tmp/x.png")},
|
| 151 |
+
outputs={"image": {"component": "image"}}),
|
| 152 |
+
])
|
| 153 |
+
graph = build_graph(spec)
|
| 154 |
+
assert graph is not None and len(graph.nodes) == 1
|