Skip to content

Commit c7c122a

Browse files
feat: type-aware variable coercion using BRUIN_VARS_SCHEMA (#4)
* feat: type-aware variable coercion using BRUIN_VARS_SCHEMA When Go injects BRUIN_VARS_SCHEMA alongside BRUIN_VARS, the SDK now coerces string values to their declared JSON Schema types (integer, number, boolean, array, object). Falls back gracefully when the schema is missing or malformed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add complex data structure tests for var coercion Cover nested objects, array of objects, object with array properties, deeply nested 3-level structures, array of arrays, mixed-type vars, and schema-less passthrough for arrays/objects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: include variable name in coercion error messages Wrap per-variable errors with the key name, value, and target type so users can identify which variable failed. Add test for nested object coercion failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add e2e test for type-aware variable coercion Exercises the full round-trip: pipeline variables with types, CLI --var overrides as strings, Go injects BRUIN_VARS_SCHEMA, Python SDK coerces to int/bool/float/str. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: line too long in _coerce_vars error message Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: format _context.py and test_context.py with ruff Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b4686a9 commit c7c122a

3 files changed

Lines changed: 462 additions & 0 deletions

File tree

src/bruin/_context.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,58 @@ def _parse_datetime(env_var: str) -> "datetime.datetime | None":
3232
)
3333

3434

35+
def _coerce_value(value, type_def: dict):
36+
"""Coerce a single value to match a JSON Schema type definition."""
37+
if value is None:
38+
return None
39+
schema_type = type_def.get("type")
40+
if schema_type == "string":
41+
return str(value)
42+
if schema_type == "integer":
43+
return int(value)
44+
if schema_type == "number":
45+
return float(value)
46+
if schema_type == "boolean":
47+
if isinstance(value, str):
48+
if value.lower() in ("true", "1"):
49+
return True
50+
if value.lower() in ("false", "0"):
51+
return False
52+
raise ValueError(f"Cannot convert '{value}' to boolean")
53+
return bool(value)
54+
if schema_type == "array":
55+
if not isinstance(value, list):
56+
return value
57+
items_def = type_def.get("items")
58+
if items_def:
59+
return [_coerce_value(item, items_def) for item in value]
60+
return value
61+
if schema_type == "object":
62+
if not isinstance(value, dict):
63+
return value
64+
props = type_def.get("properties", {})
65+
return {k: _coerce_value(v, props[k]) if k in props else v for k, v in value.items()}
66+
return value # unknown type → passthrough
67+
68+
69+
def _coerce_vars(values: dict, schema: dict) -> dict:
70+
"""Apply schema-based type coercion to all variables."""
71+
result = {}
72+
for key, val in values.items():
73+
type_def = schema.get(key)
74+
if type_def:
75+
try:
76+
result[key] = _coerce_value(val, type_def)
77+
except (ValueError, TypeError) as exc:
78+
target = type_def.get("type")
79+
raise ValueError(
80+
f"Cannot coerce variable '{key}' (value={val!r}) to {target}: {exc}"
81+
) from exc
82+
else:
83+
result[key] = val
84+
return result
85+
86+
3587
class _BruinContext:
3688
"""Lazy accessor for BRUIN_* environment variables injected by ``bruin run``.
3789
@@ -92,6 +144,16 @@ def vars(self) -> dict:
92144
raise BruinError(
93145
f"Invalid BRUIN_VARS value: expected a JSON object, got {type(parsed).__name__}."
94146
)
147+
schema_raw = os.environ.get("BRUIN_VARS_SCHEMA")
148+
if schema_raw:
149+
try:
150+
schema = json.loads(schema_raw)
151+
except (json.JSONDecodeError, TypeError):
152+
return parsed # bad schema → return raw values
153+
try:
154+
return _coerce_vars(parsed, schema)
155+
except (ValueError, TypeError) as exc:
156+
raise BruinError(f"Cannot coerce BRUIN_VARS: {exc}") from exc
95157
return parsed
96158

97159

tests/e2e/test_bruin_run.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,102 @@ def test_context_available_in_asset(self, bruin_bin, pipeline_dir):
136136
assert output["has_bruin_this"] is True
137137

138138

139+
class TestVarsCoercion:
140+
"""Verify that BRUIN_VARS_SCHEMA is injected and the SDK coerces types."""
141+
142+
def test_vars_coerced_to_schema_types(self, bruin_bin, pipeline_dir):
143+
root, pipe_dir, assets_dir = pipeline_dir
144+
145+
# Pipeline with typed variables
146+
(pipe_dir / "pipeline.yml").write_text(
147+
textwrap.dedent("""\
148+
name: test_pipeline
149+
schedule: daily
150+
variables:
151+
env:
152+
type: string
153+
default: dev
154+
count:
155+
type: integer
156+
default: 10
157+
rate:
158+
type: number
159+
default: 1.5
160+
debug:
161+
type: boolean
162+
default: false
163+
""")
164+
)
165+
166+
output_file = root / "vars_output.json"
167+
asset_code = textwrap.dedent(f'''\
168+
""" @bruin
169+
170+
name: test_vars
171+
type: python
172+
173+
@bruin """
174+
175+
import json
176+
import os
177+
from bruin import context
178+
179+
v = context.vars
180+
result = {{
181+
"vars": v,
182+
"types": {{k: type(val).__name__ for k, val in v.items()}},
183+
"has_schema": "BRUIN_VARS_SCHEMA" in os.environ,
184+
}}
185+
186+
with open("{output_file}", "w") as f:
187+
json.dump(result, f)
188+
''')
189+
(assets_dir / "test_vars.py").write_text(asset_code)
190+
191+
result = subprocess.run(
192+
[
193+
bruin_bin,
194+
"run",
195+
"--start-date",
196+
"2024-06-01",
197+
"--end-date",
198+
"2024-06-02",
199+
"--var",
200+
"count=42",
201+
"--var",
202+
"debug=true",
203+
str(pipe_dir / "assets" / "test_vars.py"),
204+
],
205+
capture_output=True,
206+
text=True,
207+
cwd=str(root),
208+
timeout=120,
209+
)
210+
211+
assert result.returncode == 0, (
212+
f"bruin run failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
213+
)
214+
assert output_file.exists(), (
215+
f"Asset did not produce output file.\nstdout: {result.stdout}\nstderr: {result.stderr}"
216+
)
217+
218+
output = json.loads(output_file.read_text())
219+
220+
if not output["has_schema"]:
221+
pytest.skip("bruin binary does not inject BRUIN_VARS_SCHEMA yet")
222+
223+
# CLI overrides were strings — SDK should have coerced them
224+
assert output["vars"]["count"] == 42
225+
assert output["types"]["count"] == "int"
226+
assert output["vars"]["debug"] is True
227+
assert output["types"]["debug"] == "bool"
228+
# Defaults should keep their types
229+
assert output["vars"]["env"] == "dev"
230+
assert output["types"]["env"] == "str"
231+
assert output["vars"]["rate"] == 1.5
232+
assert output["types"]["rate"] == "float"
233+
234+
139235
class TestConnectionEnvVar:
140236
"""Verify BRUIN_CONNECTION is injected when asset has a connection field.
141237

0 commit comments

Comments
 (0)