dataface 0.1.6.dev129__py3-none-any.whl → 0.1.6.dev183__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. dataface/agent_api/docs/yaml-reference.md +2 -2
  2. dataface/agent_api/project_session.py +2 -0
  3. dataface/agent_api/query.py +6 -0
  4. dataface/ai/mcp/server.py +5 -5
  5. dataface/ai/skills/dashboard-build/SKILL.md +7 -0
  6. dataface/cli/commands/_agent_input.py +3 -3
  7. dataface/core/compile/__init__.py +0 -2
  8. dataface/core/compile/channel.py +1 -6
  9. dataface/core/compile/colors.py +1 -1
  10. dataface/core/compile/compiler.py +1 -3
  11. dataface/core/compile/config.py +3 -3
  12. dataface/core/compile/dbt_jinja.py +66 -0
  13. dataface/core/compile/inherit_graph.py +31 -4
  14. dataface/core/compile/inherit_resolver.py +13 -4
  15. dataface/core/compile/jinja.py +2 -2
  16. dataface/core/compile/models/chart/authored.py +8 -8
  17. dataface/core/compile/models/face/resolved.py +3 -3
  18. dataface/core/compile/models/markers.py +14 -3
  19. dataface/core/compile/models/query/normalized.py +4 -0
  20. dataface/core/compile/models/source.py +4 -86
  21. dataface/core/compile/models/style/authored.py +2 -2
  22. dataface/core/compile/models/style/resolved.py +15 -518
  23. dataface/core/compile/models/style/theme.py +61 -57
  24. dataface/core/compile/normalize_charts.py +3 -3
  25. dataface/core/compile/normalize_layout.py +6 -33
  26. dataface/core/compile/normalize_variables.py +10 -10
  27. dataface/core/compile/normalizer.py +24 -24
  28. dataface/core/compile/parameterized.py +1 -6
  29. dataface/core/compile/sizing.py +30 -8
  30. dataface/core/compile/sources.py +19 -6
  31. dataface/core/compile/style_cascade.py +202 -60
  32. dataface/core/compile/typography.py +92 -89
  33. dataface/core/defaults/themes/_base.yaml +2 -2
  34. dataface/core/execute/adapters/dbt_adapter_factory.py +7 -7
  35. dataface/core/execute/adapters/sql_adapter.py +10 -6
  36. dataface/core/execute/batch.py +4 -2
  37. dataface/core/execute/executor.py +3 -6
  38. dataface/core/inspect/query_validator.py +1 -1
  39. dataface/core/inspect/renderer.py +0 -1
  40. dataface/core/render/__init__.py +0 -3
  41. dataface/core/render/chart/callout.py +5 -9
  42. dataface/core/render/chart/geo.py +9 -30
  43. dataface/core/render/chart/kpi.py +18 -11
  44. dataface/core/render/chart/pipeline.py +15 -98
  45. dataface/core/render/chart/profile.py +25 -61
  46. dataface/core/render/chart/render_single.py +116 -67
  47. dataface/core/render/chart/renderers.py +45 -95
  48. dataface/core/render/chart/rendering.py +67 -64
  49. dataface/core/render/chart/spark.py +1 -1
  50. dataface/core/render/chart/spark_bar.py +34 -20
  51. dataface/core/render/chart/standard_renderer.py +102 -228
  52. dataface/core/render/chart/table.py +54 -50
  53. dataface/core/render/chart/table_support.py +2 -5
  54. dataface/core/render/chart/vega_lite.py +13 -23
  55. dataface/core/render/chart/vega_lite_types.py +5 -5
  56. dataface/core/render/converters/chart.py +5 -6
  57. dataface/core/render/dir_context.py +37 -35
  58. dataface/core/render/faces.py +34 -25
  59. dataface/core/render/font_support.py +8 -8
  60. dataface/core/render/layout_sizing.py +44 -53
  61. dataface/core/render/layouts.py +9 -17
  62. dataface/core/render/renderer.py +21 -18
  63. dataface/core/render/svg_utils.py +11 -8
  64. dataface/core/render/terminal_charts.py +2 -2
  65. dataface/core/render/utils.py +1 -1
  66. dataface/core/render/variable_controls.py +20 -22
  67. dataface/core/render/warnings/bar_color_1_to_1_with_x.py +4 -3
  68. dataface/core/render/warnings/base.py +2 -2
  69. dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +2 -2
  70. dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +2 -2
  71. dataface/core/render/warnings/redundant_encoding.py +10 -6
  72. dataface/core/render/warnings/temporal_single_point.py +3 -3
  73. dataface/core/resolve_face.py +21 -6
  74. dataface/core/serve/bootstrap.py +2 -2
  75. dataface/core/serve/server.py +9 -9
  76. dataface/core/validate.py +2 -2
  77. dataface/integrations/highlighting.py +4 -3
  78. dataface/integrations/markdown.py +2 -2
  79. {dataface-0.1.6.dev129.dist-info → dataface-0.1.6.dev183.dist-info}/METADATA +1 -1
  80. {dataface-0.1.6.dev129.dist-info → dataface-0.1.6.dev183.dist-info}/RECORD +85 -92
  81. mdsvg/renderer.py +76 -16
  82. mdsvg/style.py +16 -4
  83. dataface/core/render/control_registry.py +0 -287
  84. dataface/core/render/templates/controls/checkbox.html +0 -16
  85. dataface/core/render/templates/controls/date.html +0 -16
  86. dataface/core/render/templates/controls/number.html +0 -19
  87. dataface/core/render/templates/controls/readonly.html +0 -9
  88. dataface/core/render/templates/controls/select.html +0 -20
  89. dataface/core/render/templates/controls/slider.html +0 -22
  90. dataface/core/render/templates/controls/text.html +0 -16
  91. {dataface-0.1.6.dev129.dist-info → dataface-0.1.6.dev183.dist-info}/WHEEL +0 -0
  92. {dataface-0.1.6.dev129.dist-info → dataface-0.1.6.dev183.dist-info}/entry_points.txt +0 -0
  93. {dataface-0.1.6.dev129.dist-info → dataface-0.1.6.dev183.dist-info}/licenses/LICENSE +0 -0
@@ -1082,8 +1082,8 @@ Authored overlay for TitleStyle. Board and face titles.
1082
1082
  |-------|------|:--------:|-------------|
1083
1083
  | `font` | [FontStyle](#fontstyle) | ✓ | Title font style overrides. |
1084
1084
  | `line_height` | float | ✓ | Line height multiplier for titles and markdown headings. Headings typically want a tighter multiplier than body prose. |
1085
- | `sizes` | list[float] | ✓ | Font sizes for the H1–H6 heading ramp, indexed by ``face.level - 1``. Combined with ``width_offsets`` at render time to size titles responsively by card width. |
1086
- | `width_offsets` | [TitleWidthOffsetsStyle](#titlewidthoffsetsstyle) | ✓ | Additive level offsets by card width (tiny/narrow/medium/wide). Added to the title's base level before indexing ``sizes``. Consumed by chart_title_spec / face_title_spec. |
1085
+ | `sizes` | list[float] | ✓ | Font sizes for the H1–H6 heading ramp, indexed by ``face.level - 1``. Face/prose titles index this ramp directly (no width input). Object titles (chart/table/spark) combine ``width_offsets`` with a fixed object-title anchor to pick a slot. |
1086
+ | `width_offsets` | [TitleWidthOffsetsStyle](#titlewidthoffsetsstyle) | ✓ | Additive level offsets by card width (tiny/narrow/medium/wide). Consumed by ``chart_title_spec`` only — object titles add the tier offset to a fixed anchor to pick an H slot from ``sizes``. Face/prose titles are level-only and do not consult these. |
1087
1087
  | `min_height` | float | ✓ | Minimum title row height in pixels. |
1088
1088
  | `overflow` | str | ✓ | Text overflow mode (clip, truncate, wrap-two, wrap). |
1089
1089
  | `position` | [TitlePositionStyle](#titlepositionstyle) | ✓ | Vega-Lite title positioning: anchor, angle, offset, baseline. |
@@ -360,6 +360,7 @@ class ProjectSession:
360
360
  variables: dict[str, Any] | None = None,
361
361
  source: str | None = None,
362
362
  limit: int = 50,
363
+ lenient_variables: bool = False,
363
364
  ) -> _query.ExecuteQueryResult:
364
365
  return _query.execute_query(
365
366
  sql,
@@ -367,6 +368,7 @@ class ProjectSession:
367
368
  source=source,
368
369
  limit=limit,
369
370
  adapter_registry=self.adapter_registry,
371
+ lenient_variables=lenient_variables,
370
372
  )
371
373
 
372
374
  def describe_query(
@@ -88,6 +88,10 @@ class ExecuteQueryArgs(BaseModel):
88
88
  )
89
89
  source: str | None = Field(None, description="Data source name to execute against")
90
90
  limit: int | None = Field(None, description="Maximum rows to return (default 50)")
91
+ lenient_variables: bool = Field(
92
+ False,
93
+ description="When True, undefined Jinja variables degrade gracefully (useful for iterative chart editing where not all variables are set).",
94
+ )
91
95
 
92
96
 
93
97
  class ExecuteQueryResult(BaseModel):
@@ -135,6 +139,7 @@ def execute_query(
135
139
  limit: int = 50,
136
140
  *,
137
141
  adapter_registry: AdapterRegistry,
142
+ lenient_variables: bool = False,
138
143
  ) -> ExecuteQueryResult:
139
144
  """Execute a SQL query and return the results.
140
145
 
@@ -155,6 +160,7 @@ def execute_query(
155
160
  sql=sql,
156
161
  source=source,
157
162
  limit=fetch_limit,
163
+ lenient_variables=lenient_variables,
158
164
  )
159
165
 
160
166
  result = adapter_registry.execute(query, variables=variables)
dataface/ai/mcp/server.py CHANGED
@@ -180,14 +180,14 @@ def create_server(context: DatafaceAIContext) -> Any:
180
180
  return TypeAdapter(AnyUrl).validate_python(value)
181
181
 
182
182
  @server.list_resources() # type: ignore[no-untyped-call]
183
- async def handle_list_resources() -> list[Resource]:
183
+ async def handle_list_resources() -> list[Resource]: # pyright: ignore[reportUnusedFunction] # decorator-registered — pyright cannot model runtime registration # fmt: skip
184
184
  return [
185
185
  Resource(uri=_uri(u), mimeType=m, name=n, description=d)
186
186
  for u, m, n, d in (*_BASE_RESOURCES, *_docs_topic_resources())
187
187
  ]
188
188
 
189
189
  @server.list_resource_templates() # type: ignore[no-untyped-call]
190
- async def handle_list_resource_templates() -> list[ResourceTemplate]:
190
+ async def handle_list_resource_templates() -> list[ResourceTemplate]: # pyright: ignore[reportUnusedFunction] # decorator-registered — pyright cannot model runtime registration # fmt: skip
191
191
  return [
192
192
  ResourceTemplate(
193
193
  uriTemplate="dataface://dashboard/{path}",
@@ -204,11 +204,11 @@ def create_server(context: DatafaceAIContext) -> Any:
204
204
  ]
205
205
 
206
206
  @server.read_resource() # type: ignore[no-untyped-call]
207
- async def handle_read_resource(uri: AnyUrl) -> str:
207
+ async def handle_read_resource(uri: AnyUrl) -> str: # pyright: ignore[reportUnusedFunction] # decorator-registered — pyright cannot model runtime registration # fmt: skip
208
208
  return _read_resource_content(str(uri), context=context)
209
209
 
210
210
  @server.list_tools() # type: ignore[no-untyped-call]
211
- async def handle_list_tools() -> list[Tool]:
211
+ async def handle_list_tools() -> list[Tool]: # pyright: ignore[reportUnusedFunction] # decorator-registered — pyright cannot model runtime registration # fmt: skip
212
212
  return [
213
213
  Tool(
214
214
  name=cast(str, t["name"]),
@@ -234,7 +234,7 @@ def create_server(context: DatafaceAIContext) -> Any:
234
234
  ]
235
235
 
236
236
  @server.call_tool()
237
- async def handle_call_tool(
237
+ async def handle_call_tool( # pyright: ignore[reportUnusedFunction] # decorator-registered — pyright cannot model runtime registration
238
238
  name: str, arguments: dict[str, Any] | None
239
239
  ) -> CallToolResult:
240
240
  arguments = arguments or {}
@@ -19,6 +19,13 @@ metadata:
19
19
 
20
20
  **`{{ s_render_dashboard }}` is how you deliver a dashboard** — call it and return its output. Do not skip it.
21
21
 
22
+ ## Delivery discipline
23
+
24
+ - `{{ s_render_dashboard }}` delivers the dashboard — call it and let the result stand. Do not also paste the rendered output or the full YAML back as prose; that just duplicates the deliverable as an unreadable dump.
25
+ - Act, don't ask. Apply edits and render directly — do not ask for permission or offer optional follow-ups ("if you want, I can save this", "would you like me to…").
26
+ - Fail loud. If a tool returns an error, report it and either fix the input and call the tool again, or stop and say what's blocking. Never route around a failed tool by fabricating output, dumping query rows as a text table in place of a chart, or improvising a different format. A failed render is a failure to report, not to paper over.
27
+ - Never claim a render, query, or save succeeded unless the tool returned success. Never invent columns, tables, or data — if a schema or query call fails, fix it against the real schema.
28
+
22
29
  Build dashboards and reports **incrementally** — one chart at a time, validating at every step. Never one-shot an entire dashboard.
23
30
 
24
31
  ## Companion Skills
@@ -140,11 +140,11 @@ class PromptToolkitInput:
140
140
  kb = KeyBindings()
141
141
 
142
142
  @kb.add("enter")
143
- def _submit(event: Any) -> None:
143
+ def _submit(event: Any) -> None: # pyright: ignore[reportUnusedFunction] # decorator-registered — pyright cannot model runtime registration # fmt: skip
144
144
  event.current_buffer.validate_and_handle()
145
145
 
146
146
  @kb.add("escape", "enter")
147
- def _newline_meta(event: Any) -> None:
147
+ def _newline_meta(event: Any) -> None: # pyright: ignore[reportUnusedFunction] # decorator-registered — pyright cannot model runtime registration # fmt: skip
148
148
  event.current_buffer.insert_text("\n")
149
149
 
150
150
  self._session: PromptSession = PromptSession(
@@ -205,7 +205,7 @@ def select_input_layer(
205
205
  return StdlibInput(hist_path)
206
206
 
207
207
  try:
208
- import prompt_toolkit # noqa: PLC0415, F401
208
+ import prompt_toolkit # noqa: PLC0415, F401 # pyright: ignore[reportUnusedImport] — availability probe; ImportError selects StdlibInput
209
209
  except ImportError:
210
210
  return StdlibInput(hist_path)
211
211
 
@@ -120,7 +120,6 @@ from dataface.core.compile.models.source import (
120
120
  is_database_source,
121
121
  is_file_source,
122
122
  parse_source_config,
123
- resolve_env_var,
124
123
  )
125
124
  from dataface.core.compile.models.variable.authored import (
126
125
  Variable,
@@ -201,7 +200,6 @@ __all__ = [
201
200
  "is_database_source",
202
201
  "is_file_source",
203
202
  "is_api_source",
204
- "resolve_env_var",
205
203
  # Config
206
204
  "ProjectSourcesConfig",
207
205
  "get_config",
@@ -52,11 +52,6 @@ def parse_style_channel(
52
52
  if isinstance(raw, str):
53
53
  return ResolvedStyleChannel(channel=channel_name, mode="series", data_field=raw)
54
54
 
55
- if not isinstance(raw, dict):
56
- raise ValueError(
57
- f"Channel '{channel_name}' must be a string or dict, got {type(raw).__name__}"
58
- )
59
-
60
55
  has_column = "column" in raw
61
56
  has_value = "value" in raw
62
57
  has_scale = "scale" in raw
@@ -188,7 +183,7 @@ def normalize_chart_channels(
188
183
  channels["color"] = parse_style_channel(color_raw, "color")
189
184
 
190
185
  # Upgrade series channel to gradient when style.color carries a scale.
191
- if isinstance(style_color, StyleColorConfig) and style_color.scale is not None:
186
+ if isinstance(style_color, StyleColorConfig):
192
187
  if "color" not in channels:
193
188
  raise ValueError(
194
189
  "style.color: {scale: ...} requires chart.color to name a data field.\n"
@@ -43,7 +43,7 @@ def sanitize_color(color: str | None, fallback: str | None = None) -> str | None
43
43
  immediately — authored colors that are not valid are a configuration error,
44
44
  not a case for silent defaulting.
45
45
  """
46
- if not color or not isinstance(color, str):
46
+ if not color:
47
47
  return fallback
48
48
  if _CSS_HEX_COLOR_PATTERN.match(color):
49
49
  return color
@@ -824,10 +824,8 @@ def load_from_reference(
824
824
  section_name = "variables"
825
825
  elif isinstance(reference, QueryRef):
826
826
  section_name = "queries"
827
- elif isinstance(reference, ChartRef):
828
- section_name = "charts"
829
827
  else:
830
- raise CompilationError(f"Unexpected reference type: {type(reference)!r}")
828
+ section_name = "charts"
831
829
 
832
830
  # Grammar already validated by the typed model; split is deterministic.
833
831
  file_path_str, item_name = reference.ref.rsplit(f".{section_name}.", 1)
@@ -32,6 +32,7 @@ from typing import Any
32
32
 
33
33
  import yaml
34
34
 
35
+ from dataface.core.compile.dbt_jinja import render_dbt_jinja_in_dict
35
36
  from dataface.core.compile.models.config import (
36
37
  Config,
37
38
  ConfigNode,
@@ -43,7 +44,6 @@ from dataface.core.compile.models.config import (
43
44
  as_plain_mapping,
44
45
  is_mapping_like,
45
46
  )
46
- from dataface.core.compile.models.source import resolve_env_vars_in_dict
47
47
  from dataface.core.project import Project
48
48
 
49
49
  # ============================================================================
@@ -101,7 +101,7 @@ def _dataface_yml_mapping_section(
101
101
  return filename, _as_mapping(value, f"{filename} {key} section")
102
102
 
103
103
 
104
- def _dataface_yml_string_value(project: Project, key: str) -> str | None:
104
+ def dataface_yml_string_value(project: Project, key: str) -> str | None:
105
105
  filename, file_data = _load_dataface_yml(project)
106
106
  if key not in file_data:
107
107
  return None
@@ -781,4 +781,4 @@ class ProjectSourcesConfig:
781
781
  def _normalize_project_sources(
782
782
  sources: dict[str, dict[str, Any]],
783
783
  ) -> dict[str, dict[str, Any]]:
784
- return {name: resolve_env_vars_in_dict(config) for name, config in sources.items()}
784
+ return {name: render_dbt_jinja_in_dict(config) for name, config in sources.items()}
@@ -0,0 +1,66 @@
1
+ """Render dbt Jinja in source/profile config values.
2
+
3
+ dataface resolves `{{ env_var(...) }}` — and the rest of dbt's profile-rendering
4
+ Jinja — through dbt's own ``ProfileRenderer`` rather than a hand-rolled regex, so
5
+ these values resolve *exactly* as dbt would: env_var defaults, the
6
+ ``DBT_ENV_SECRET_``-prefixed secret form, and non-string value preservation
7
+ (`port: 5432` stays an int) all come for free.
8
+
9
+ Behaviour matches dbt's, deliberately:
10
+ - A missing ``env_var()`` with no default raises (surfaced as ``ValueError`` —
11
+ see :func:`_render`) for normal fields.
12
+ - dbt's ``SecretRenderer`` *defers* rendering for ``password`` keypaths (real
13
+ passwords may contain ``{{``/``%`` characters), so a missing password env_var
14
+ is left as a literal and fails at connect time rather than at parse. We accept
15
+ dbt's contract here rather than re-deriving a stricter one.
16
+
17
+ dbt reads env vars from a process-global *invocation context* (a ``ContextVar``
18
+ it sets at CLI startup). dataface embeds dbt as a library, so we establish that
19
+ context from the live environment per render.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ from typing import Any
26
+
27
+
28
+ def _refresh_invocation_context() -> None:
29
+ """(Re)build dbt's invocation context from the live ``os.environ``.
30
+
31
+ dbt's ``env_var()`` reads a process-global ``ContextVar``, not ``os.environ``
32
+ directly. The context holds nothing but env-derived state (public/private env
33
+ split + a secret cache), so rebuilding it per render is cheap, loses nothing,
34
+ and keeps ``env_var()`` reading the *current* environment — matching a direct
35
+ ``os.environ.get`` and keeping renders independent across env changes.
36
+
37
+ This is a compile-stage, single-threaded path. If dataface ever rendered
38
+ source configs concurrently with an active *embedded* dbt invocation, this
39
+ overwrite would clobber that invocation's context — not a concern today.
40
+ """
41
+ from dbt_common.context import set_invocation_context
42
+
43
+ # noqa rationale: dbt env_var() resolves YAML macros from the live process
44
+ # environment by contract — this is the composition root for that read.
45
+ set_invocation_context(dict(os.environ)) # noqa: TID251 — env_var() YAML macro
46
+
47
+
48
+ def render_dbt_jinja_in_dict(data: dict[str, Any]) -> dict[str, Any]:
49
+ """Render dbt Jinja in every (recursively nested) string value of a dict.
50
+
51
+ Non-string values pass through unchanged. ``password`` keypaths follow dbt's
52
+ secret-deferral (see module docstring); other fields raise ``ValueError`` on
53
+ an unset env var with no default or on malformed/unresolvable Jinja.
54
+
55
+ dbt raises its own hierarchy (``EnvVarMissingError`` / ``CompilationError``);
56
+ we adapt it to ``ValueError`` at this boundary so Pydantic field/model
57
+ validators surface a ``ValidationError``. The dbt message is preserved.
58
+ """
59
+ from dbt.config.renderer import ProfileRenderer
60
+ from dbt_common.exceptions.base import DbtBaseException
61
+
62
+ _refresh_invocation_context()
63
+ try:
64
+ return ProfileRenderer({}).render_data(data)
65
+ except DbtBaseException as e:
66
+ raise ValueError(str(e)) from e
@@ -125,14 +125,41 @@ def _build_graph(
125
125
 
126
126
  if nested is not None:
127
127
  if skip_marker is not None:
128
- # Recurse with slots suppressed; discard any inherited slot context.
128
+ # SkipInheritSlots suppresses slot expansion for this field.
129
+ # cascade=True: emit a container-level link so apply_inherit can
130
+ # copy the whole object when the child is None. Leaf writes
131
+ # through a None intermediate are not viable (_set skips them).
132
+ if (
133
+ skip_marker.cascade
134
+ and slot_at is not None
135
+ and slot_from is not None
136
+ and not skip_slots
137
+ ):
138
+ suffix: StylePath = current[len(slot_at) :]
139
+ fallback_path = _path_str(slot_from + suffix)
140
+ graph[_path_str(current)] = fallback_path
141
+ # Recurse into subtree.
142
+ # cascade=True inside a slot: keep the outer slot context so
143
+ # that individual leaf fields inside the subtree also get
144
+ # slot-derived links (e.g. charts.line.marks.line.stroke.width
145
+ # → charts.marks.line.stroke.width). The container link above
146
+ # handles the stroke=None case; leaf links handle the case where
147
+ # the container is set but individual fields are None. Inner
148
+ # InheritSlot markers with an absolute from_path still fire
149
+ # because skip_slots=False.
150
+ # cascade=True outside a slot (canonical path): both slot args
151
+ # are None already, so this is equivalent to clearing them.
152
+ # cascade=False: set skip_slots=True to fully suppress inner
153
+ # slot expansion (original SkipInheritSlots semantics).
129
154
  _build_graph(
130
155
  nested,
131
156
  current,
132
157
  seen,
133
158
  valid_paths,
134
159
  graph,
135
- skip_slots=True,
160
+ slot_at=slot_at if skip_marker.cascade else None,
161
+ slot_from=slot_from if skip_marker.cascade else None,
162
+ skip_slots=not skip_marker.cascade,
136
163
  )
137
164
  elif slot_marker is not None and not skip_slots:
138
165
  if slot_at is not None:
@@ -197,8 +224,8 @@ def _build_graph(
197
224
  else:
198
225
  graph[current_path] = p
199
226
  elif slot_at is not None and slot_from is not None and skip_marker is None:
200
- suffix: StylePath = current[len(slot_at) :]
201
- fallback_path = _path_str(slot_from + suffix)
227
+ leaf_suffix: StylePath = current[len(slot_at) :]
228
+ fallback_path = _path_str(slot_from + leaf_suffix)
202
229
  if fallback_path not in valid_paths:
203
230
  raise ValueError(
204
231
  f"InheritSlot expansion {fallback_path!r} not found in model paths"
@@ -1,8 +1,8 @@
1
1
  """Inherit resolver: fill style leaf fields from single-link InheritGraph chains.
2
2
 
3
- apply_inherit is the replacement for _apply_cascade's hard-coded push loops.
4
- It runs after deep_merge and fills None-sentinel leaves by walking the single-link
3
+ apply_inherit fills None-sentinel leaves by walking the single-link
5
4
  parent chains declared via Inherit/InheritSlot markers on the compiled Style.
5
+ It runs after deep_merge, replacing the former hard-coded push loop approach.
6
6
 
7
7
  Not in scope: _apply_token_cascade, _build_resolved_style, resolved_axis_style.
8
8
  """
@@ -68,8 +68,17 @@ def apply_inherit(merged: Style, links: InheritGraph) -> Style:
68
68
  if not updates:
69
69
  return merged
70
70
 
71
- for path, value in updates.items():
72
- _set(d, _obj_path(path), value)
71
+ # Write shorter paths before longer ones so container-level updates (from
72
+ # cascade=True SkipInheritSlots links) land before leaf writes into those
73
+ # containers. _set skips writes through a None intermediate, so the
74
+ # container dict must exist first.
75
+ # Re-read from d before each write: a container update may have already
76
+ # populated a field that a leaf link also targets (e.g. container copies
77
+ # marks.slice.labels.font.size=14 from the canonical path, while a separate
78
+ # leaf link would overwrite with the fallback charts.font.size=11).
79
+ for path in sorted(updates, key=lambda p: p.count(".")):
80
+ if _get(d, _obj_path(path)) is None:
81
+ _set(d, _obj_path(path), updates[path])
73
82
 
74
83
  return Style.model_validate(d)
75
84
 
@@ -87,7 +87,7 @@ def extract_variable_dependencies(template_str: str) -> set[str]:
87
87
  ... )
88
88
  {'region'}
89
89
  """
90
- if not template_str or not isinstance(template_str, str):
90
+ if not template_str:
91
91
  return set()
92
92
 
93
93
  # Quick check - no Jinja syntax
@@ -142,7 +142,7 @@ def resolve_jinja_template(
142
142
  ... )
143
143
  "SELECT * FROM users WHERE status = 'active'"
144
144
  """
145
- if not template or not isinstance(template, str):
145
+ if not template:
146
146
  return template
147
147
 
148
148
  # Quick check for Jinja syntax
@@ -29,16 +29,16 @@ from pydantic import (
29
29
  from dataface.core.compile.models.primitives import (
30
30
  FontStyle,
31
31
  FormatConfig,
32
- ScaleTargetConfig, # noqa: F401 — re-exported for external callers
32
+ ScaleTargetConfig as ScaleTargetConfig, # re-exported for external callers
33
33
  )
34
34
  from dataface.core.compile.models.query.authored import AuthoredQuery
35
35
  from dataface.core.compile.models.refs import QueryRef, normalize_query_value
36
36
  from dataface.core.compile.models.style.authored import (
37
- _SPARK_RENAMED_AWAY, # noqa: F401 — re-exported for external callers
37
+ _SPARK_RENAMED_AWAY as _SPARK_RENAMED_AWAY, # re-exported for external callers
38
38
  AreaChartStylePatch,
39
39
  BarChartStylePatch,
40
40
  CalloutChartStylePatch,
41
- ColumnScaleConfig, # noqa: F401 — re-exported for external callers
41
+ ColumnScaleConfig as ColumnScaleConfig, # re-exported for external callers
42
42
  GeoshapeChartStylePatch,
43
43
  HeatmapChartStylePatch,
44
44
  KpiChartStylePatch,
@@ -48,10 +48,10 @@ from dataface.core.compile.models.style.authored import (
48
48
  PointMapChartStylePatch,
49
49
  ScatterChartStylePatch,
50
50
  SparkBarChartStylePatch,
51
- SparkConfig, # noqa: F401 — re-exported for external callers
52
- SparkTypeLiteral, # noqa: F401 — re-exported for external callers
51
+ SparkConfig as SparkConfig, # re-exported for external callers
52
+ SparkTypeLiteral as SparkTypeLiteral, # re-exported for external callers
53
53
  TableChartStylePatch,
54
- TableColumnConfig, # noqa: F401 — re-exported for external callers
54
+ TableColumnConfig as TableColumnConfig, # re-exported for external callers
55
55
  _validate_glyph_pair,
56
56
  )
57
57
  from dataface.core.compile.models.style.theme import (
@@ -1554,10 +1554,10 @@ class KpiChart(_BaseChartFields):
1554
1554
  )
1555
1555
  if data.get("subtitle"):
1556
1556
  from dataface.core.compile.normalize_charts import ( # noqa: PLC0415
1557
- _kpi_subtitle_error,
1557
+ kpi_subtitle_error,
1558
1558
  )
1559
1559
 
1560
- raise ValueError(_kpi_subtitle_error(data.get("id")))
1560
+ raise ValueError(kpi_subtitle_error(data.get("id")))
1561
1561
  return data
1562
1562
 
1563
1563
  @model_validator(mode="before")
@@ -6,7 +6,7 @@
6
6
  from dataclasses import dataclass
7
7
  from typing import Any
8
8
 
9
- from dataface.core.compile.models.chart.normalized import Chart
9
+ from dataface.core.compile.models.chart.resolved import ResolvedChart
10
10
  from dataface.core.compile.models.face.normalized import VariableValues
11
11
  from dataface.core.compile.models.query.normalized import AnyQuery
12
12
  from dataface.core.compile.models.style.resolved import ResolvedStyle
@@ -18,7 +18,7 @@ class ResolvedLayoutItem:
18
18
  """Layout item with static dimension estimates — no executor, no data access."""
19
19
 
20
20
  type: str # "chart" | "face" | "text" | "markdown" | ...
21
- chart: Chart | None
21
+ chart: ResolvedChart | None
22
22
  # nested face is a forward reference — resolved recursively
23
23
  face: "ResolvedFace | None"
24
24
  x: float
@@ -101,7 +101,7 @@ class ResolvedFace:
101
101
  layout: ResolvedLayout
102
102
 
103
103
  # Data (unchanged from Face)
104
- charts: dict[str, Chart]
104
+ charts: dict[str, ResolvedChart]
105
105
  variables: dict[str, Variable]
106
106
  queries: dict[str, AnyQuery]
107
107
  variable_defaults: VariableValues
@@ -92,7 +92,18 @@ class SkipInheritSlots:
92
92
  ``_ChartStyleBase`` must not inherit from the root cascade.
93
93
 
94
94
  **On a leaf scalar field**: prevents the leaf from being added to the graph
95
- via slot expansion. Use on ``AxisStyle`` fields that ``_fill_axis`` does not
96
- cascade — e.g. ``grid.dash``, ``ticks.count``, ``scale``, ``position`` — so
97
- the inherit graph only declares what ``_apply_cascade`` actually does.
95
+ via slot expansion. Use on ``AxisStyle`` fields that the style resolver does
96
+ not cascade — e.g. ``grid.dash``, ``ticks.count``, ``scale``, ``position`` —
97
+ so the inherit graph only declares what the resolver actually does.
98
+
99
+ ``cascade=True`` — for optional nested-model fields that the resolver
100
+ cascades at container granularity (copies the whole object when the child is
101
+ None, rather than filling individual leaf fields). When set, the graph
102
+ emits a *container-level* link ``child_path → parent_path`` instead of
103
+ suppressing the field entirely. Leaf writes through a ``None`` intermediate
104
+ are not viable (``_set`` skips them), so container links are the only
105
+ mechanism. Only applicable on ``T | None`` nested-model fields inside an
106
+ active slot context.
98
107
  """
108
+
109
+ cascade: bool = False
@@ -188,6 +188,10 @@ class SqlQuery(Query):
188
188
  default=None,
189
189
  description="dbt target name (defaults to 'dev' if not specified).",
190
190
  )
191
+ lenient_variables: bool = Field(
192
+ default=False,
193
+ description="When True, undefined Jinja variables degrade gracefully (empty string) instead of raising. Intended for the iterative chart-editor flow where not all variables are set yet.",
194
+ )
191
195
 
192
196
  @field_validator("source", mode="before")
193
197
  @classmethod
@@ -44,12 +44,13 @@ See also:
44
44
 
45
45
  from __future__ import annotations
46
46
 
47
- import os
48
47
  import re
49
48
  from typing import Any, ClassVar, Literal, get_args, get_type_hints
50
49
 
51
50
  from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
52
51
 
52
+ from dataface.core.compile.dbt_jinja import render_dbt_jinja_in_dict
53
+
53
54
  # ============================================================================
54
55
  # BIGQUERY AUTH HELPERS
55
56
  # ============================================================================
@@ -66,65 +67,6 @@ def infer_bq_method(
66
67
  return "oauth"
67
68
 
68
69
 
69
- # ============================================================================
70
- # ENVIRONMENT VARIABLE RESOLUTION
71
- # ============================================================================
72
-
73
-
74
- def resolve_env_var(value: str) -> str:
75
- """Resolve {{ env_var('VAR') }} or {{ env_var('VAR', 'default') }} syntax.
76
-
77
- Matches dbt's env_var() function behavior.
78
-
79
- Args:
80
- value: String that may contain env_var() expressions
81
-
82
- Returns:
83
- String with env_var() resolved to actual values
84
-
85
- Raises:
86
- ValueError: If required env var is not set
87
- """
88
- # Pattern: {{ env_var('VAR') }} or {{ env_var('VAR', 'default') }}
89
- pattern = r"\{\{\s*env_var\(\s*['\"]([^'\"]+)['\"]\s*(?:,\s*['\"]([^'\"]*)['\"])?\s*\)\s*\}\}"
90
-
91
- def replace_env_var(match: re.Match[str]) -> str:
92
- var_name = match.group(1)
93
- default_value = match.group(2)
94
-
95
- env_value = os.environ.get(var_name) # noqa: TID251 — env_var() YAML macro
96
- if env_value is not None:
97
- return env_value
98
- elif default_value is not None:
99
- return default_value
100
- else:
101
- raise ValueError(
102
- f"Environment variable '{var_name}' is not set and no default provided"
103
- )
104
-
105
- return re.sub(pattern, replace_env_var, value)
106
-
107
-
108
- def resolve_env_vars_in_dict(data: dict[str, Any]) -> dict[str, Any]:
109
- """Recursively resolve env_var() expressions in a dictionary.
110
-
111
- Args:
112
- data: Dictionary that may contain env_var() expressions
113
-
114
- Returns:
115
- Dictionary with all env_var() expressions resolved
116
- """
117
- result: dict[str, Any] = {}
118
- for key, value in data.items():
119
- if isinstance(value, str):
120
- result[key] = resolve_env_var(value)
121
- elif isinstance(value, dict):
122
- result[key] = resolve_env_vars_in_dict(value)
123
- else:
124
- result[key] = value
125
- return result
126
-
127
-
128
70
  # ============================================================================
129
71
  # BASE SOURCE CONFIG
130
72
  # ============================================================================
@@ -143,9 +85,9 @@ class BaseSourceConfig(BaseModel):
143
85
  @model_validator(mode="before")
144
86
  @classmethod
145
87
  def _resolve_all_env_vars(cls, data: Any) -> Any:
146
- """Resolve {{ env_var('VAR') }} in all string fields before Pydantic validates."""
88
+ """Render dbt Jinja (env_var etc.) in all fields before Pydantic validates."""
147
89
  if isinstance(data, dict):
148
- return resolve_env_vars_in_dict(data)
90
+ return render_dbt_jinja_in_dict(data)
149
91
  return data
150
92
 
151
93
 
@@ -406,14 +348,6 @@ class CsvSourceConfig(BaseSourceConfig):
406
348
  raise ValueError("CsvSourceConfig requires either 'file' or 'url'")
407
349
  return self
408
350
 
409
- @field_validator("file", "url", mode="before")
410
- @classmethod
411
- def resolve_env_vars(cls, v: Any) -> Any:
412
- """Resolve env_var() syntax in string fields."""
413
- if isinstance(v, str):
414
- return resolve_env_var(v)
415
- return v
416
-
417
351
 
418
352
  class ParquetSourceConfig(BaseSourceConfig):
419
353
  """Parquet file source configuration."""
@@ -473,22 +407,6 @@ class HttpSourceConfig(BaseSourceConfig):
473
407
  default=None, description="Default HTTP headers (e.g. Authorization)."
474
408
  )
475
409
 
476
- @field_validator("url", mode="before")
477
- @classmethod
478
- def resolve_url_env_var(cls, v: Any) -> Any:
479
- """Resolve env_var() syntax in URL."""
480
- if isinstance(v, str):
481
- return resolve_env_var(v)
482
- return v
483
-
484
- @field_validator("headers", mode="before")
485
- @classmethod
486
- def resolve_headers_env_vars(cls, v: Any) -> Any:
487
- """Resolve env_var() syntax in headers."""
488
- if isinstance(v, dict):
489
- return resolve_env_vars_in_dict(v)
490
- return v
491
-
492
410
 
493
411
  # ============================================================================
494
412
  # DBT PROFILE SOURCE CONFIG