dataface 0.4.1.dev53__py3-none-any.whl → 0.4.1.dev224__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 (215) hide show
  1. dataface/DATAFACE_SYNTAX.md +6 -2
  2. dataface/agent_api/_init_templates/meta.yaml +3 -3
  3. dataface/agent_api/board_artifact.py +15 -1
  4. dataface/agent_api/dashboards.py +21 -4
  5. dataface/agent_api/docs/yaml-reference.md +22 -12
  6. dataface/agent_api/pack.py +1 -1
  7. dataface/agent_api/project_session.py +4 -1
  8. dataface/agent_api/query.py +112 -12
  9. dataface/agent_api/serve.py +1 -3
  10. dataface/ai/llm.py +59 -13
  11. dataface/ai/skills/dashboard-build/SKILL.md +2 -0
  12. dataface/ai/tools/__init__.py +21 -7
  13. dataface/cli/commands/board_artifact.py +1 -1
  14. dataface/cli/commands/extension.py +1 -0
  15. dataface/cli/commands/query.py +1 -1
  16. dataface/cli/commands/serve.py +5 -14
  17. dataface/cli/commands/skills_init.py +1 -2
  18. dataface/cli/main.py +29 -13
  19. dataface/core/compile/compiler.py +51 -27
  20. dataface/core/compile/config.py +71 -5
  21. dataface/core/compile/data_table.py +8 -8
  22. dataface/core/compile/format.py +12 -2
  23. dataface/core/compile/inherit_graph.py +22 -1
  24. dataface/core/compile/inherit_resolver.py +4 -4
  25. dataface/core/compile/introspection.py +6 -0
  26. dataface/core/compile/label_data.py +3 -3
  27. dataface/core/compile/labels_env.py +14 -2
  28. dataface/core/compile/migrations/migrations.py +3 -1
  29. dataface/core/compile/migrations/versions/__init__.py +16 -0
  30. dataface/core/compile/models/chart/normalized/callout.py +7 -0
  31. dataface/core/compile/models/chart/resolved/_base.py +13 -0
  32. dataface/core/compile/models/chart/resolved/pie.py +10 -0
  33. dataface/core/compile/models/config.py +20 -19
  34. dataface/core/compile/models/face/authored.py +12 -8
  35. dataface/core/compile/models/face/normalized.py +15 -7
  36. dataface/core/compile/models/face/resolved.py +68 -2
  37. dataface/core/compile/models/markers.py +20 -0
  38. dataface/core/compile/models/primitives.py +9 -1
  39. dataface/core/compile/models/style/authored/table.py +22 -3
  40. dataface/core/compile/models/style/context.py +225 -0
  41. dataface/core/compile/models/style/resolved/__init__.py +19 -8
  42. dataface/core/compile/models/style/resolved/_base.py +131 -129
  43. dataface/core/compile/models/style/resolved/_cascade.py +433 -104
  44. dataface/core/compile/models/style/resolved/point_map.py +3 -0
  45. dataface/core/compile/models/style/resolved/table.py +1 -1
  46. dataface/core/compile/models/style/theme/__init__.py +5 -7
  47. dataface/core/compile/models/style/theme/board.py +16 -43
  48. dataface/core/compile/models/style/theme/charts.py +1 -1
  49. dataface/core/compile/models/style/theme/marks.py +20 -7
  50. dataface/core/compile/models/style/theme/style.py +2 -2
  51. dataface/core/compile/models/style/theme/table.py +7 -1
  52. dataface/core/compile/models/variable/authored.py +5 -1
  53. dataface/core/compile/normalize_layout.py +7 -1
  54. dataface/core/compile/normalize_queries.py +1 -3
  55. dataface/core/compile/normalize_variables.py +10 -3
  56. dataface/core/compile/normalizer.py +136 -33
  57. dataface/core/compile/parameterized.py +25 -12
  58. dataface/core/compile/resolve/__init__.py +6 -4
  59. dataface/core/compile/resolve/_axes.py +99 -43
  60. dataface/core/compile/resolve/_dispatch.py +18 -20
  61. dataface/core/compile/resolve/_domain.py +34 -1
  62. dataface/core/compile/resolve/_kwargs.py +90 -46
  63. dataface/core/compile/resolve/_layers.py +36 -12
  64. dataface/core/compile/resolve/_marks.py +21 -5
  65. dataface/core/compile/resolve/_palette.py +25 -18
  66. dataface/core/compile/resolve/_table.py +42 -25
  67. dataface/core/compile/resolve/adaptive_stroke.py +7 -3
  68. dataface/core/compile/resolve/area.py +60 -27
  69. dataface/core/compile/resolve/bar.py +236 -64
  70. dataface/core/compile/resolve/geo.py +40 -28
  71. dataface/core/compile/resolve/heatmap.py +33 -21
  72. dataface/core/compile/resolve/line.py +61 -28
  73. dataface/core/compile/resolve/pie.py +48 -24
  74. dataface/core/compile/resolve/pie_attachment.py +25 -2
  75. dataface/core/compile/resolve/scatter.py +54 -27
  76. dataface/core/compile/resolve/simple.py +23 -20
  77. dataface/core/compile/schema_renderers/json_schema.py +5 -1
  78. dataface/core/compile/schema_renderers/prompt.py +7 -1
  79. dataface/core/compile/schema_renderers/yaml_schema_catalog.py +4 -2
  80. dataface/core/compile/single_series_allocation.py +1 -1
  81. dataface/core/compile/sizing.py +16 -10
  82. dataface/core/compile/source_map.py +56 -76
  83. dataface/core/compile/sources.py +2 -4
  84. dataface/core/compile/style_cascade.py +166 -66
  85. dataface/core/compile/typography.py +27 -27
  86. dataface/core/compile/validate_formats.py +35 -3
  87. dataface/core/compile/vega_config.py +3 -3
  88. dataface/core/compile/vega_lite/mapping.py +23 -23
  89. dataface/core/compile/yaml_error_formatter.py +1 -2
  90. dataface/core/compile/yaml_patch.py +1 -1
  91. dataface/core/dashboard.py +6 -5
  92. dataface/core/defaults/default_config.yml +42 -42
  93. dataface/core/defaults/themes/_base.yaml +4 -1
  94. dataface/core/defaults/themes/solid.yaml +245 -0
  95. dataface/core/defaults/themes/stark.yaml +7 -5
  96. dataface/core/diagnostics/__init__.py +18 -0
  97. dataface/core/diagnostics/codes_compile.py +34 -0
  98. dataface/core/diagnostics/codes_render.py +303 -6
  99. dataface/core/dialects/athena.py +0 -2
  100. dataface/core/dialects/base.py +0 -40
  101. dataface/core/dialects/bigquery.py +0 -2
  102. dataface/core/dialects/databricks.py +0 -2
  103. dataface/core/dialects/duckdb.py +0 -2
  104. dataface/core/dialects/mysql.py +0 -2
  105. dataface/core/dialects/postgres.py +0 -2
  106. dataface/core/dialects/snowflake.py +0 -2
  107. dataface/core/dialects/sqlite.py +0 -2
  108. dataface/core/dialects/sqlserver.py +0 -2
  109. dataface/core/execute/_duckdb_cache_base.py +1 -3
  110. dataface/core/execute/adapters/adapter_registry.py +31 -0
  111. dataface/core/execute/adapters/sql_adapter.py +1 -3
  112. dataface/core/execute/cache_composition.py +1 -3
  113. dataface/core/execute/chart_resolution.py +3 -3
  114. dataface/core/execute/sql_literals.py +1 -2
  115. dataface/core/execute/trivial_local_cache.py +3 -1
  116. dataface/core/font_measure.py +155 -1
  117. dataface/core/fonts.py +77 -5
  118. dataface/core/inspect/resolver.py +3 -1
  119. dataface/core/numeric.py +15 -0
  120. dataface/core/registered_views/expander.py +95 -24
  121. dataface/core/registered_views/render_pipeline.py +36 -1
  122. dataface/core/render/board_links.py +1 -1
  123. dataface/core/render/chart/callout.py +44 -30
  124. dataface/core/render/chart/data_table_attachment.py +88 -60
  125. dataface/core/render/chart/emitters/_cartesian.py +125 -22
  126. dataface/core/render/chart/emitters/_channels.py +3 -3
  127. dataface/core/render/chart/emitters/_label_overlap.py +2 -2
  128. dataface/core/render/chart/emitters/_layers.py +90 -43
  129. dataface/core/render/chart/emitters/_measured_label_padding.py +26 -11
  130. dataface/core/render/chart/emitters/_overlay.py +42 -9
  131. dataface/core/render/chart/emitters/_tooltip.py +6 -6
  132. dataface/core/render/chart/emitters/area.py +47 -19
  133. dataface/core/render/chart/emitters/bar.py +92 -47
  134. dataface/core/render/chart/emitters/geo.py +2 -0
  135. dataface/core/render/chart/emitters/line.py +21 -19
  136. dataface/core/render/chart/emitters/pie.py +9 -8
  137. dataface/core/render/chart/emitters/scatter.py +21 -10
  138. dataface/core/render/chart/features/baseline.py +9 -9
  139. dataface/core/render/chart/features/endpoint_labels.py +39 -28
  140. dataface/core/render/chart/features/facet.py +12 -10
  141. dataface/core/render/chart/features/mirror_axis.py +59 -11
  142. dataface/core/render/chart/features/structured_tooltip.py +5 -3
  143. dataface/core/render/chart/kpi.py +51 -48
  144. dataface/core/render/chart/rendering.py +136 -36
  145. dataface/core/render/chart/series_label_truncation.py +81 -0
  146. dataface/core/render/chart/spark.py +8 -8
  147. dataface/core/render/chart/spark_bar.py +15 -10
  148. dataface/core/render/chart/spec.py +16 -4
  149. dataface/core/render/chart/spec_builders.py +1 -1
  150. dataface/core/render/chart/table.py +187 -90
  151. dataface/core/render/chart/table_support.py +11 -46
  152. dataface/core/render/chart/text_truncation.py +96 -0
  153. dataface/core/render/chart/time_unit_detect.py +134 -41
  154. dataface/core/render/chart/title_overflow.py +39 -62
  155. dataface/core/render/chart/translate.py +4 -10
  156. dataface/core/render/chart/type_inference.py +14 -0
  157. dataface/core/render/chart/vega_lite.py +77 -23
  158. dataface/core/render/chart/vl_field_maps.py +178 -37
  159. dataface/core/render/chart_diagnostics.py +27 -0
  160. dataface/core/render/chart_interactivity.py +4 -2
  161. dataface/core/render/chrome_css.py +2 -2
  162. dataface/core/render/column_packer.py +65 -29
  163. dataface/core/render/converters/chart.py +40 -25
  164. dataface/core/render/face_resolve.py +67 -33
  165. dataface/core/render/face_to_dict.py +8 -9
  166. dataface/core/render/faces.py +49 -23
  167. dataface/core/render/font_support.py +71 -83
  168. dataface/core/render/fonts/DFTSansTabular-Medium.ttf +0 -0
  169. dataface/core/render/fonts/DFTSansTabular-SemiBold.ttf +0 -0
  170. dataface/core/render/fonts/InterVariable-Medium.ttf +0 -0
  171. dataface/core/render/fonts/InterVariable-SemiBold.ttf +0 -0
  172. dataface/core/render/fonts/SourceSerif4-Medium.ttf +0 -0
  173. dataface/core/render/fonts/SourceSerif4-SemiBold.ttf +0 -0
  174. dataface/core/render/format_utils.py +44 -19
  175. dataface/core/render/layout_sizing.py +294 -183
  176. dataface/core/render/layouts.py +6 -6
  177. dataface/core/render/numeral_expr.py +27 -17
  178. dataface/core/render/prose.py +29 -25
  179. dataface/core/render/renderer.py +112 -69
  180. dataface/core/render/sizing.py +191 -153
  181. dataface/core/render/svg_utils.py +7 -10
  182. dataface/core/render/templates/scripts/chart_interactivity.js +1 -1
  183. dataface/core/render/terminal.py +8 -2
  184. dataface/core/render/terminal_charts.py +2 -1
  185. dataface/core/render/variables_layout.py +5 -3
  186. dataface/core/render/variables_resolve.py +4 -7
  187. dataface/core/render/variables_strip.py +9 -2
  188. dataface/core/render/warnings/axis_title_truncated.py +43 -0
  189. dataface/core/render/warnings/base.py +18 -0
  190. dataface/core/render/warnings/callout_text_truncated.py +41 -0
  191. dataface/core/render/warnings/chart_title_truncated.py +36 -0
  192. dataface/core/render/warnings/facet_panel_width_below_minimum.py +71 -0
  193. dataface/core/render/warnings/kpi_label_truncated.py +34 -0
  194. dataface/core/render/warnings/registry.py +16 -0
  195. dataface/core/render/warnings/series_label_truncated.py +64 -0
  196. dataface/core/render/warnings/spark_label_truncated.py +37 -0
  197. dataface/core/render/warnings/table_text_truncated.py +42 -0
  198. dataface/core/render/warnings/value_labels_crowd_width.py +1 -1
  199. dataface/core/render/yaml_format.py +15 -0
  200. dataface/core/serve/server.py +3 -5
  201. dataface/core/text/format_d3.py +241 -28
  202. dataface/core/text/numeral_scale.py +47 -7
  203. dataface/core/utils.py +84 -2
  204. dataface/core/validate.py +0 -1
  205. dataface/data/highlighting/face.json +1 -1
  206. dataface/data/schemas/board-resolved/board-resolved.schema.json +1644 -3554
  207. dataface/schema_release.py +4 -2
  208. {dataface-0.4.1.dev53.dist-info → dataface-0.4.1.dev224.dist-info}/METADATA +3 -3
  209. {dataface-0.4.1.dev53.dist-info → dataface-0.4.1.dev224.dist-info}/RECORD +215 -196
  210. {dataface-0.4.1.dev53.dist-info → dataface-0.4.1.dev224.dist-info}/WHEEL +1 -1
  211. mdsvg/fonts.py +13 -6
  212. mdsvg/parser.py +17 -0
  213. mdsvg/renderer.py +175 -12
  214. {dataface-0.4.1.dev53.dist-info → dataface-0.4.1.dev224.dist-info}/entry_points.txt +0 -0
  215. {dataface-0.4.1.dev53.dist-info → dataface-0.4.1.dev224.dist-info}/licenses/LICENSE +0 -0
@@ -307,9 +307,11 @@ style:
307
307
  text:
308
308
  align: left
309
309
  column:
310
- number: 2
310
+ max_number: 2
311
311
  gap: 24
312
- rule: "1px solid #cbd5e1"
312
+ rule:
313
+ width: 1
314
+ color: "#cbd5e1"
313
315
  ```
314
316
 
315
317
  CSS-only keys like `border-radius`, `border-left`, `margin-top` are not board-style fields and are rejected.
@@ -926,6 +928,8 @@ variables:
926
928
 
927
929
  Top-level option-source binding (alternative to `options:`): `column`, `query`, `dimension` (MetricFlow), `measure` (MetricFlow), `model` (dbt).
928
930
 
931
+ Top-level `column` is `table.column`, and the table may be schema-qualified when it is not in the connection's default schema — `column: gis.fact_sales.property_type`. `filter()` accepts the same qualified form.
932
+
929
933
  Enabled/disabled forms (`enabled: false` means the control is inactive):
930
934
 
931
935
  ```yaml
@@ -6,8 +6,8 @@
6
6
  # directory and its subdirectories; each face overrides what it sets.
7
7
  #
8
8
  # A meta.yaml deeper in the tree layers on top of the ones above it, and a face
9
- # always wins over meta. Nested style keys merge (a face setting style.board.margin
10
- # keeps a meta-supplied style.board.width).
9
+ # always wins over meta. Nested style keys merge (a face setting style.frame.margin
10
+ # keeps a meta-supplied style.frame.width).
11
11
  #
12
12
  # Uncomment and edit to set project-wide defaults:
13
13
  #
@@ -15,5 +15,5 @@
15
15
  # source: my_warehouse # default data source for faces that don't name one
16
16
  #
17
17
  # style:
18
- # board:
18
+ # frame:
19
19
  # width: 1000
@@ -22,6 +22,7 @@ from pydantic import BaseModel, ConfigDict
22
22
 
23
23
  from dataface.agent_api._paths import resolve_face_or_error
24
24
  from dataface.core.compile import compile_file
25
+ from dataface.core.compile.models.face.resolved import ChartResolveFailure
25
26
  from dataface.core.compile.variables import normalize_multiselect_values
26
27
  from dataface.core.diagnostics import ERR_FILE_NOT_FOUND, Diagnostic
27
28
  from dataface.core.diagnostics.base import DatafaceError
@@ -133,14 +134,27 @@ def emit_board_artifact(
133
134
  set_filter_variables_context(
134
135
  frozenset(face.variable_registry) if face.variable_registry else frozenset()
135
136
  )
137
+ resolve_errors: dict[str, ChartResolveFailure] = {}
136
138
  try:
137
- resolved, _render_cache = build_resolved_face(face, executor, merged_variables)
139
+ resolved, _render_cache = build_resolved_face(
140
+ face, executor, merged_variables, resolve_errors=resolve_errors
141
+ )
138
142
  except DatafaceError as exc:
139
143
  return EmitBoardArtifactResult(success=False, errors=[exc.to_diagnostic()])
140
144
  finally:
141
145
  set_auto_link_context(False)
142
146
  set_filter_variables_context(frozenset())
143
147
 
148
+ # A chart-level resolve failure no longer raises — it leaves a hole in the
149
+ # face instead. Publishing that hole silently would be worse than the fatal
150
+ # it replaced, since a live render at least draws an error tile where an
151
+ # artifact just has nothing. Artifacts stay all-or-nothing.
152
+ if resolve_errors:
153
+ return EmitBoardArtifactResult(
154
+ success=False,
155
+ errors=[failure.diagnostic for failure in resolve_errors.values()],
156
+ )
157
+
144
158
  recording = record_board(resolved, executor, merged_variables)
145
159
 
146
160
  artifact_path.parent.mkdir(parents=True, exist_ok=True)
@@ -7,13 +7,20 @@ values. No bare dict[str, Any] in any return position. Tool/CLI callers use
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import sys
10
11
  from pathlib import Path
11
- from typing import Any, Literal
12
+ from typing import Literal
12
13
 
13
14
  import yaml
14
- from pydantic import BaseModel, ConfigDict, Field, field_serializer
15
+ from pydantic import BaseModel, ConfigDict, Field, field_serializer, model_validator
16
+
17
+ if sys.version_info >= (3, 11):
18
+ from typing import Self
19
+ else:
20
+ from typing_extensions import Self
15
21
 
16
22
  from dataface.agent_api._paths import resolve_face_or_error
23
+ from dataface.agent_api.query import VariableBinding, variables_to_dict as _vars_to_dict
17
24
  from dataface.core.compile import Face, compile_file
18
25
  from dataface.core.dashboard import RenderedDashboard as RenderedDashboard
19
26
  from dataface.core.diagnostics import Diagnostic
@@ -112,8 +119,12 @@ class RenderDashboardArgs(BaseModel):
112
119
  "the list of available chart ids."
113
120
  ),
114
121
  )
115
- variables: dict[str, Any] | None = Field(
116
- None, description="Variable values to apply to the dashboard"
122
+ variables: list[VariableBinding] | None = Field(
123
+ None,
124
+ description=(
125
+ "Variable values to apply to the dashboard, as an array of {name, value} pairs. "
126
+ 'Example: [{"name": "region", "value": "US"}, {"name": "year", "value": 2024}]'
127
+ ),
117
128
  )
118
129
  format: Literal["json", "text", "yaml", "svg", "terminal", "data"] | None = Field(
119
130
  None,
@@ -148,6 +159,12 @@ class RenderDashboardArgs(BaseModel):
148
159
  ),
149
160
  )
150
161
 
162
+ @model_validator(mode="after")
163
+ def _no_duplicate_variable_names(self) -> Self:
164
+ if self.variables is not None:
165
+ _vars_to_dict(self.variables)
166
+ return self
167
+
151
168
 
152
169
  # ---------------------------------------------------------------------------
153
170
  # Public verbs
@@ -11,7 +11,7 @@ AuthoredFace (dataface) definition from YAML.
11
11
  | `tags` | list[str] | ✓ | Tags for categorization and search. |
12
12
  | `aliases` | list[str] | ✓ | Additional URLs that redirect to this face's canonical file-path URL. Each entry must be absolute (leading /). Requests to these URLs are redirected (302) to the face's real path, query string preserved. Valid on .yml, .yaml, .md, and folder index.* faces. |
13
13
  | `text` | str | ✓ | Markdown text content for text-only sections. |
14
- | `allow_html` | bool | ✓ | Render the face's body text as raw HTML via foreignObject instead of markdown. TRUSTED-CONTENT ONLY: the HTML (including any Jinja-interpolated values) is rendered as-authored — this is NOT a security sandbox. mdsvg strips <script>/event-handlers as a best-effort guard, not a guarantee. Enable only on first-party faces you fully control. |
14
+ | `html_policy` | enum: "none", "safe-subset", "trusted-raw" | ✓ | HTML rendering policy for the face's body text. One of: "none" (default) — HTML is escaped and rendered as plain markdown; "safe-subset" — reserved for a parser-checked allowlist (not yet enforced — currently renders as none); "trusted-raw" — raw HTML via foreignObject. TRUSTED-CONTENT ONLY: this is NOT a security sandbox. <script>/event-handlers are stripped as a best-effort guard, not a guarantee. Enable only on first-party faces you fully control. |
15
15
  | `source` | str | ✓ | Default source name for all queries in this face. Inheritable via meta.yaml cascade. |
16
16
  | `cache` | [Cache](#cache) | ✓ | Cache policy for every query in this dashboard, e.g. cache: 1h — queries inherit it and may refine it; cache: false opts the whole dashboard out. Inheritable via the meta.yaml cascade. |
17
17
  | `variables` | dict[str, [Variable](#variable) \| VariableRef] | ✓ | Variable definitions for dynamic filtering and UI controls. |
@@ -26,7 +26,7 @@ AuthoredFace (dataface) definition from YAML.
26
26
  | `details` | [FaceDetails](#facedetails) | ✓ | Collapsible section metadata. String shorthand: details: 'text' → FaceDetails(summary='text'). Block form: details: {summary: ..., expanded_title: ..., expanded: false}. |
27
27
  | `id` | str | ✓ | Explicit ID for this face. Auto-generated from filename if omitted. |
28
28
  | `style` | [Style](#style) | ✓ | Style patch (background, padding, border, etc.). Background and semantic color tokens (accent, muted) cascade to nested child faces. |
29
- | `width` | str \| int | ✓ | Width when nested (e.g., '50%', '400px', or an integer in pixels). On the root face there is no parent to place it into, so it instead sets the board's own width (equivalent to 'style.board.width') — percentages are rejected there since there's nothing to size relative to. |
29
+ | `width` | str \| int | ✓ | Width when nested (e.g., '50%', '400px', or an integer in pixels). On the root face there is no parent to place it into, so it instead sets the board's own width (equivalent to 'style.frame.width') — percentages are rejected there since there's nothing to size relative to. |
30
30
  | `height` | str \| int | ✓ | Height when nested (e.g., '300px' or an integer in pixels). |
31
31
  | `visible` | bool \| str \| [SingleRowBoolProbe](#singlerowboolprobe) | ✓ | Controls whether this layout item is rendered. Accepts a bool, variable name, Jinja expression, or {query, column} probe. |
32
32
  | `extends` | str \| list[str] | ✓ | Face name(s) or relative path(s) this face inherits from, low to high priority. |
@@ -54,7 +54,7 @@ Variable definition from YAML.
54
54
  | `required` | bool | ✓ | When True, a value must be provided before queries execute. |
55
55
  | `visible` | bool | ✓ | When False, the variable is not rendered in the UI but can still be set via URL params. |
56
56
  | `enabled` | bool \| str \| [SingleRowBoolProbe](#singlerowboolprobe) | ✓ | Enable this control. Accepts: static bool; a variable name or Jinja boolean expression string (no {{ }} required — bare names auto-wrap); or a {query, column} form that reads a single boolean cell from a named query. None = enabled. Absent variable in a string expression raises (use a default). |
57
- | `column` | str | ✓ | Column name in the query result to use as option values. |
57
+ | `column` | str | ✓ | Table column to draw option values from, as 'table.column'. The table may be schema-qualified ('schema.table.column') when it is not in the connection's default schema. |
58
58
  | `query` | str \| [SqlQuery](#sqlquery) \| [MetricflowQuery](#metricflowquery) \| [HttpQuery](#httpquery) \| [ValuesQuery](#valuesquery) \| [CompactValuesQuery](#compactvaluesquery) \| [SchemaQuery](#schemaquery) | ✓ | Query name or inline query definition for populating options. |
59
59
  | `dimension` | str | ✓ | MetricFlow dimension name for populating options. |
60
60
  | `measure` | str | ✓ | MetricFlow measure name for populating options. |
@@ -496,7 +496,7 @@ Authored overlay for Style — all fields optional. Adds CSS shorthand coercers.
496
496
 
497
497
  | Field | Type | Optional | Description |
498
498
  |-------|------|:--------:|-------------|
499
- | `board` | [BoardStyle](#boardstyle) | ✓ | Face-level structural board dimensions. |
499
+ | `frame` | [FrameStyle](#framestyle) | ✓ | Face-level structural frame dimensions. |
500
500
  | `background` | str | ✓ | Working-surface background color (board and card fills). |
501
501
  | `accent` | str | ✓ | Accent color token cascaded to sparklines, bars, and focus rings. |
502
502
  | `muted` | str | ✓ | Muted secondary-text color token (KPI support rows, table and spark subtitles). |
@@ -1023,13 +1023,13 @@ Tab layout item.
1023
1023
  | `grid` | [GridLayout](#gridlayout) | ✓ | CSS-grid layout for this tab's content. |
1024
1024
  | `tabs` | [TabLayout](#tablayout) | ✓ | Nested tab layout (tabs within tabs). |
1025
1025
 
1026
- <a id="boardstyle"></a>
1027
- ## BoardStyle
1028
- Authored overlay for BoardStyle. Face-level structural dimensions. Do NOT cascade to child faces.
1026
+ <a id="framestyle"></a>
1027
+ ## FrameStyle
1028
+ Authored overlay for FrameStyle. Face-level structural dimensions. Do NOT cascade to child faces.
1029
1029
 
1030
1030
  | Field | Type | Optional | Description |
1031
1031
  |-------|------|:--------:|-------------|
1032
- | `width` | float | ✓ | Maximum dashboard width in pixels. Caps the measured layout width, setting the board's proportions. Rendered as an on-screen pixel maximum everywhere except the dft HTML page (dft serve, dft render --format html), which scales the board to its container. |
1032
+ | `width` | float | ✓ | Maximum board width in pixels. Caps the measured layout width, setting the board's proportions. Rendered as an on-screen pixel maximum everywhere except the dft HTML page (dft serve, dft render --format html), which scales the board to its container. |
1033
1033
  | `min_height` | float | ✓ | Minimum board height in pixels. |
1034
1034
  | `margin` | float | ✓ | Board outer margin in pixels. |
1035
1035
  | `card_padding` | float | ✓ | Padding added to each card side in pixels. |
@@ -1689,7 +1689,7 @@ Authored overlay for TableTitleStyle.
1689
1689
 
1690
1690
  | Field | Type | Optional | Description |
1691
1691
  |-------|------|:--------:|-------------|
1692
- | `height` | float | ✓ | Table title row height in pixels. |
1692
+ | `height` | float | ✓ | Minimum title-block height in pixels. Applies as a floor only when a subtitle is present; a title-only block sizes to its natural content height instead. |
1693
1693
  | `font` | [FontStyle](#fontstyle) | ✓ | Table title font style overrides. Unset fields fall back to [`style.charts.table.font`](#tablechartstyle). |
1694
1694
 
1695
1695
  <a id="paginationconfig"></a>
@@ -1898,9 +1898,9 @@ Authored overlay for TextColumnStyle. Author overrides for the column layout of
1898
1898
 
1899
1899
  | Field | Type | Optional | Description |
1900
1900
  |-------|------|:--------:|-------------|
1901
- | `number` | int | ✓ | Ceiling on the column count. The renderer may choose fewer when there is not enough text to fill them. None = no ceiling. |
1901
+ | `max_number` | int | ✓ | Ceiling on the column count. The renderer may choose fewer when there is not enough text to fill them. None = no ceiling. |
1902
1902
  | `gap` | float | ✓ | Gap between columns in pixels. None = 1.5 line boxes, so the gutter scales with the type it separates. |
1903
- | `rule` | str | ✓ | CSS column-rule shorthand, e.g. '1px solid #e5e7eb'. None = no rule. |
1903
+ | `rule` | [ColumnRuleStyle](#columnrulestyle) | ✓ | Vertical rule drawn between columns. None = no rule. |
1904
1904
  | `max_chars` | int | ✓ | Column width as a character count, overriding the shipped measure. The width is used exactly and the column count follows from it. |
1905
1905
 
1906
1906
  <a id="textcodestyle"></a>
@@ -2620,6 +2620,16 @@ Authored overlay for GeoshapeMarkStyle. Geoshape (choropleth) mark fill and boun
2620
2620
  | `fill` | str | ✓ | Neutral geoshape fill color. |
2621
2621
  | `stroke` | [StrokeStyle](#strokestyle) | ✓ | Geoshape boundary stroke style. |
2622
2622
 
2623
+ <a id="columnrulestyle"></a>
2624
+ ## ColumnRuleStyle
2625
+ Authored overlay for ColumnRuleStyle. Vertical rule drawn between prose columns, structured like BorderStyle.
2626
+
2627
+ | Field | Type | Optional | Description |
2628
+ |-------|------|:--------:|-------------|
2629
+ | `width` | float | ✓ | Rule line width in pixels. |
2630
+ | `color` | str | ✓ | Rule color as a CSS color string. |
2631
+ | `style` | enum: "solid", "dashed", "dotted" | ✓ | Rule line style. |
2632
+
2623
2633
  <a id="circlemarkstyle"></a>
2624
2634
  ## CircleMarkStyle
2625
2635
  Authored overlay for CircleMarkStyle. Circle mark opacity and stroke.
@@ -2847,7 +2857,7 @@ Authored overlay for SliceLabelsStyle. Pie labels: typography + positioning offs
2847
2857
  |-------|------|:--------:|-------------|
2848
2858
  | `offset` | float | ✓ | Radial offset of slice labels from the arc in pixels. |
2849
2859
  | `line_height` | float | ✓ | Line height for slice labels in pixels. Reserved vertical space above the disk is ``line_height × &lt;rendered lines&gt;`` per row, so the same value handles 1-line, 2-line, and multi-line templates. |
2850
- | `font` | [FontStyle](#fontstyle) | ✓ | Slice label font style overrides. Unset fields fall back to [`style.charts.font`](#chartsstyle). |
2860
+ | `font` | [FontStyle](#fontstyle) | ✓ | Slice label font style overrides. ``color`` only takes effect on single-series pies (no ``color:`` channel authored); multi-series pies always paint each label the dark companion of its own wedge color and ignore an authored ``color`` here. Unset fields fall back to [`style.charts.font`](#chartsstyle) (except `color`). |
2851
2861
  | `default_template` | [LabelsDefaultTemplate](#labelsdefaulttemplate) | ✓ | Default Jinja templates for per-slice labels when template is not authored. |
2852
2862
  | `template` | str | ✓ | Jinja2 label template. Overrides default_template when authored. |
2853
2863
  | `where` | str | ✓ | Jinja2 boolean filter — labels only render on rows where this is truthy. |
@@ -274,7 +274,7 @@ def _build_face_dict(dashboard: ProposedDashboard) -> dict[str, Any]:
274
274
  "title": dashboard.title,
275
275
  "description": dashboard.purpose,
276
276
  "text": (
277
- f"Dashboard for {dashboard.primary_entity}. " "Add queries and charts here."
277
+ f"Dashboard for {dashboard.primary_entity}. Add queries and charts here."
278
278
  ),
279
279
  }
280
280
  if dashboard.canonical_data_url:
@@ -365,8 +365,11 @@ class ProjectSession:
365
365
  self,
366
366
  name: str,
367
367
  path: Path,
368
+ vars: dict[str, Any] | None = None,
368
369
  ) -> _query.FaceQueryLookupResult:
369
- return _query.lookup_face_query_sql(name=name, path=path, project=self.project)
370
+ return _query.lookup_face_query_sql(
371
+ name=name, path=path, project=self.project, vars=vars
372
+ )
370
373
 
371
374
  def query_face(
372
375
  self,
@@ -1,9 +1,15 @@
1
1
  """Typed query verbs for the agent API."""
2
2
 
3
+ import sys
3
4
  from pathlib import Path, PurePosixPath
4
5
  from typing import Any
5
6
 
6
- from pydantic import BaseModel, ConfigDict, Field
7
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
8
+
9
+ if sys.version_info >= (3, 11):
10
+ from typing import Self
11
+ else:
12
+ from typing_extensions import Self
7
13
 
8
14
  from dataface.agent_api._paths import resolve_face_path
9
15
  from dataface.core.compile import compile_file
@@ -16,6 +22,34 @@ from dataface.core.validate import normalize_data_for_json
16
22
  MAX_QUERY_LIMIT = 1000
17
23
 
18
24
 
25
+ class VariableBinding(BaseModel):
26
+ """One variable binding in a tool call: a name–value pair.
27
+
28
+ Used in the ``variables`` / ``vars`` fields of ``render_dashboard``,
29
+ ``execute_query``, and ``query_face`` instead of an open-keyed dict, so
30
+ that OpenAI strict mode can validate the full tool-call structure.
31
+ """
32
+
33
+ model_config = ConfigDict(extra="forbid", frozen=True)
34
+
35
+ name: str = Field(..., description="Variable name, e.g. 'region'")
36
+ value: str | int | float | bool = Field(
37
+ ..., description="Scalar value for the variable"
38
+ )
39
+
40
+
41
+ def variables_to_dict(
42
+ bindings: list[VariableBinding],
43
+ ) -> dict[str, str | int | float | bool]:
44
+ """Convert a VariableBinding list to a dict, raising on duplicate names."""
45
+ result: dict[str, str | int | float | bool] = {}
46
+ for binding in bindings:
47
+ if binding.name in result:
48
+ raise ValueError(f"Duplicate variable name: {binding.name!r}")
49
+ result[binding.name] = binding.value
50
+ return result
51
+
52
+
19
53
  class FaceQueryLookupResult(BaseModel):
20
54
  """Result of extracting SQL from a named face query for offline use (validate/describe)."""
21
55
 
@@ -33,12 +67,17 @@ def lookup_face_query_sql(
33
67
  path: Path,
34
68
  *,
35
69
  project: Project,
70
+ vars: dict[str, Any] | None = None,
36
71
  ) -> FaceQueryLookupResult:
37
- """Compile a face file and extract the SQL + source for one named SQL query.
72
+ """Compile a face file, render the SQL template, and return it for offline use.
38
73
 
39
- Used by validate and describe paths that need the raw SQL text without
40
- executing it against a warehouse.
74
+ Expands ``{{ queries.X }}`` and substitutes variables so callers receive
75
+ valid SQL rather than a raw Jinja template. Used by validate and describe
76
+ paths that need the rendered SQL text without executing it against a
77
+ warehouse.
41
78
  """
79
+ from dataface.core.compile.parameterized import render_parameterized_with_queries
80
+
42
81
  try:
43
82
  file_path = resolve_face_path(path, project)
44
83
  except ValueError as e:
@@ -74,7 +113,18 @@ def lookup_face_query_sql(
74
113
  ],
75
114
  )
76
115
 
77
- return FaceQueryLookupResult(success=True, sql=query.sql, source=query.source)
116
+ merged_vars = {**face.variable_defaults, **(vars or {})}
117
+ try:
118
+ rendered = render_parameterized_with_queries(
119
+ query.sql,
120
+ merged_vars,
121
+ queries=face.queries,
122
+ strict=not query.lenient_variables,
123
+ )
124
+ except (ValueError, KeyError, TypeError) as exc:
125
+ return FaceQueryLookupResult(success=False, errors=[str(exc)])
126
+
127
+ return FaceQueryLookupResult(success=True, sql=rendered.sql, source=query.source)
78
128
 
79
129
 
80
130
  class ExecuteQueryArgs(BaseModel):
@@ -84,8 +134,12 @@ class ExecuteQueryArgs(BaseModel):
84
134
  ...,
85
135
  description="SQL query to execute. Use {{ variable_name }} for parameterized values — these work identically in dashboard YAML, so queries you test here will be cached when reused in dashboards.",
86
136
  )
87
- variables: dict[str, Any] | None = Field(
88
- None, description='Variable values to substitute (e.g. {"region": "US"})'
137
+ variables: list[VariableBinding] | None = Field(
138
+ None,
139
+ description=(
140
+ "Variable values to substitute, as an array of {name, value} pairs. "
141
+ 'Example: [{"name": "region", "value": "US"}, {"name": "year", "value": 2024}]'
142
+ ),
89
143
  )
90
144
  source: str | None = Field(None, description="Data source name to execute against")
91
145
  limit: int | None = Field(None, description="Maximum rows to return (default 50)")
@@ -93,6 +147,20 @@ class ExecuteQueryArgs(BaseModel):
93
147
  False,
94
148
  description="When True, undefined Jinja variables degrade gracefully (useful for iterative chart editing where not all variables are set).",
95
149
  )
150
+ # Display only — never reaches execute_query(). Ad-hoc SQL is the one query
151
+ # surface with no author to describe it: a named face query carries the
152
+ # `description:` its YAML already declares, and this is the equivalent for
153
+ # a query that exists only for the length of one tool call.
154
+ description: str | None = Field(
155
+ None,
156
+ description="Short phrase naming what this query is for (e.g. 'Leads by industry'), shown to the user in place of the raw SQL.",
157
+ )
158
+
159
+ @model_validator(mode="after")
160
+ def _no_duplicate_variable_names(self) -> Self:
161
+ if self.variables is not None:
162
+ variables_to_dict(self.variables)
163
+ return self
96
164
 
97
165
 
98
166
  class ExecuteQueryResult(BaseModel):
@@ -218,8 +286,12 @@ class QueryFaceArgs(BaseModel):
218
286
  ..., description="Name of the query inside the face's `queries:` block"
219
287
  )
220
288
  path: Path = Field(..., description="Path to the face YAML file")
221
- vars: dict[str, Any] | None = Field(
222
- None, description='Variable overrides (e.g. {"country": "FR"})'
289
+ vars: list[VariableBinding] | None = Field(
290
+ None,
291
+ description=(
292
+ "Variable overrides as an array of {name, value} pairs. "
293
+ 'Example: [{"name": "country", "value": "FR"}]'
294
+ ),
223
295
  )
224
296
  limit: int = Field(
225
297
  20, ge=1, le=1000, description="Max rows to return (default 20, max 1000)"
@@ -227,6 +299,12 @@ class QueryFaceArgs(BaseModel):
227
299
 
228
300
  model_config = ConfigDict(extra="forbid")
229
301
 
302
+ @model_validator(mode="after")
303
+ def _no_duplicate_variable_names(self) -> Self:
304
+ if self.vars is not None:
305
+ variables_to_dict(self.vars)
306
+ return self
307
+
230
308
 
231
309
  class QueryFaceResult(BaseModel):
232
310
  model_config = ConfigDict(frozen=True)
@@ -236,6 +314,9 @@ class QueryFaceResult(BaseModel):
236
314
  path: PurePosixPath
237
315
  query_type: str | None = None
238
316
  sql: str | None = None
317
+ #: The query's authored `description:`, when its YAML declares one. Lets a
318
+ #: caller label the run with what it is for rather than its identifier.
319
+ description: str | None = None
239
320
  columns: list[str] = []
240
321
  data: list[dict[str, Any]] = []
241
322
  row_count: int = 0
@@ -249,11 +330,19 @@ def _fail(
249
330
  path: PurePosixPath,
250
331
  errors: list[str],
251
332
  available: list[str] | None = None,
333
+ sql: str | None = None,
334
+ description: str | None = None,
252
335
  ) -> QueryFaceResult:
336
+ """A failure reports the query it was running whenever it got far enough to
337
+ know it: an undefined-variable error is unreadable without the SQL holding
338
+ the `{{ }}` that went unbound. Failures before the query is looked up
339
+ (unresolvable path, compile error, unknown name) have neither to report."""
253
340
  return QueryFaceResult(
254
341
  success=False,
255
342
  name=name,
256
343
  path=path,
344
+ sql=sql,
345
+ description=description,
257
346
  errors=errors,
258
347
  available_queries=available or [],
259
348
  )
@@ -298,6 +387,8 @@ def query_face(
298
387
 
299
388
  merged_vars = {**face.variable_defaults, **(vars or {})}
300
389
  query = face.queries[name].model_copy(update={"limit": limit + 1})
390
+ sql = query.sql if isinstance(query, SqlQuery) else None
391
+ described = query.description
301
392
 
302
393
  # Detect file sources before dispatching — the adapter path has no materializer
303
394
  # and would fall through to SqlAdapter with a cryptic dbt error. File-source
@@ -324,6 +415,8 @@ def query_face(
324
415
  "(dft render / dft serve). "
325
416
  "Use dft render to preview this dashboard."
326
417
  ],
418
+ sql=sql,
419
+ description=described,
327
420
  )
328
421
 
329
422
  try:
@@ -331,10 +424,16 @@ def query_face(
331
424
  query, variables=merged_vars or None, face=face, query_name=name
332
425
  )
333
426
  except Exception as e: # noqa: BLE001
334
- return _fail(name, resolved_display, [str(e)])
427
+ return _fail(name, resolved_display, [str(e)], sql=sql, description=described)
335
428
 
336
429
  if exec_result.error:
337
- return _fail(name, resolved_display, [exec_result.error])
430
+ return _fail(
431
+ name,
432
+ resolved_display,
433
+ [exec_result.error],
434
+ sql=sql,
435
+ description=described,
436
+ )
338
437
 
339
438
  data = normalize_data_for_json(exec_result.data)
340
439
  truncated = len(data) > limit
@@ -345,7 +444,8 @@ def query_face(
345
444
  name=name,
346
445
  path=resolved_display,
347
446
  query_type=query.query_type,
348
- sql=query.sql if isinstance(query, SqlQuery) else None,
447
+ sql=sql,
448
+ description=described,
349
449
  columns=list(data[0].keys()) if data else [],
350
450
  data=data,
351
451
  row_count=len(data),
@@ -58,9 +58,7 @@ def prepare_serve(
58
58
  from dataface.core.serve.port import resolve_port
59
59
  from dataface.core.serve.server import create_server
60
60
 
61
- env_theme = os.environ.get(
62
- "DFT_DEFAULT_THEME"
63
- ) # noqa: TID251 — DFT_DEFAULT_THEME knob
61
+ env_theme = os.environ.get("DFT_DEFAULT_THEME") # noqa: TID251 — DFT_DEFAULT_THEME knob
64
62
  # Empty string is treated as unset (matches get_default_theme_name, which
65
63
  # falls back to the shipped default) — only a non-empty unknown name errors.
66
64
  if env_theme:
dataface/ai/llm.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import copy
5
6
  import json
6
7
  import logging
7
8
  import os
@@ -93,24 +94,69 @@ def _resolve_model(
93
94
  return explicit_model or os.getenv(env_var) or default_model
94
95
 
95
96
 
97
+ def _to_strict_json_schema(schema: dict[str, Any]) -> dict[str, Any]:
98
+ """Transform a JSON Schema dict in-place to conform to OpenAI strict-mode rules.
99
+
100
+ Mirrors openai/lib/_pydantic.py::_ensure_strict_json_schema:
101
+ - Every object gets additionalProperties: false.
102
+ - required covers all property keys whenever properties is present.
103
+ - 'default: null' entries are stripped (non-null defaults are left intact).
104
+ Recurses into $defs, definitions, properties, items, anyOf, allOf, oneOf.
105
+ """
106
+ if schema.get("type") == "object":
107
+ schema.setdefault("additionalProperties", False)
108
+ props = schema.get("properties", {})
109
+ if props:
110
+ schema["required"] = list(props.keys())
111
+
112
+ if schema.get("default") is None and "default" in schema:
113
+ del schema["default"]
114
+
115
+ for defs_key in ("$defs", "definitions"):
116
+ for sub in schema.get(defs_key, {}).values():
117
+ _to_strict_json_schema(sub)
118
+
119
+ for sub in schema.get("properties", {}).values():
120
+ _to_strict_json_schema(sub)
121
+
122
+ for key in ("anyOf", "allOf", "oneOf"):
123
+ for sub in schema.get(key, []):
124
+ _to_strict_json_schema(sub)
125
+
126
+ if "items" in schema:
127
+ _to_strict_json_schema(schema["items"])
128
+
129
+ return schema
130
+
131
+
96
132
  def _normalize_openai_tools(
97
133
  tools: list[dict[str, Any]] | None,
98
134
  ) -> list[dict[str, Any]]:
99
135
  # None means "use default agent tools"; [] means "no tools, text-only completion".
100
136
  source_tools = ALL_TOOLS if tools is None else tools
101
- if not source_tools:
102
- return []
103
- if "input_schema" in source_tools[0]:
104
- return [
105
- {
106
- "type": "function",
107
- "name": tool["name"],
108
- "description": tool["description"],
109
- "parameters": tool["input_schema"],
110
- }
111
- for tool in source_tools
112
- ]
113
- return source_tools
137
+ result: list[dict[str, Any]] = []
138
+ for tool in source_tools:
139
+ if "input_schema" in tool:
140
+ # First-party tool in MCP/input_schema format — strict-transform and
141
+ # convert to OpenAI Responses API format. Strict mode is a property of
142
+ # schemas we authored, not of the transport; MCP passthrough schemas
143
+ # (already in OpenAI function format) are appended below unchanged.
144
+ result.append(
145
+ {
146
+ "type": "function",
147
+ "strict": True,
148
+ "name": tool["name"],
149
+ "description": tool["description"],
150
+ "parameters": _to_strict_json_schema(
151
+ copy.deepcopy(tool["input_schema"])
152
+ ),
153
+ }
154
+ )
155
+ else:
156
+ # Already in OpenAI function format (e.g. MCP passthrough) — pass through
157
+ # untouched. Caller-supplied schemas must not be strict-marked or rewritten.
158
+ result.append(tool)
159
+ return result
114
160
 
115
161
 
116
162
  class LLMClientError(RuntimeError):
@@ -260,12 +260,14 @@ Named presets (prefer these over raw D3 strings):
260
260
  | Preset | Example output | Notes |
261
261
  |--------|---------------|-------|
262
262
  | `integer` | `1,234` | |
263
+ | `year` | `2025` — no grouping, no SI scaling | an identifier, not a quantity |
263
264
  | `currency_whole` | `$1,234` | |
264
265
  | `currency_compact` | `$1.2M` | |
265
266
  | `percent` | `2.3%` — input is decimal fraction (0.023) | line/bar/area/scatter via `style.number_format` |
266
267
  | `percent_number` | `2.3%` — input is whole-number percent (2.3) | **KPI only** (`style.value.format`) |
267
268
  | `percent_delta` | `+2.3%` — input is decimal fraction | |
268
269
  | `percent_number_delta` | `+2.3%` — input is whole-number (2.3) | **KPI only** |
270
+ | `percentage_points_delta` | `+3.2 pts` — the difference of two percentages | for a point delta, not a ratio delta |
269
271
 
270
272
  **`percent_number` and `percent_number_delta` only work on `type: kpi`.** Using them on line/bar/area/scatter causes a render error. For those chart types use `percent` or `percent_delta` (decimal-fraction input) or omit formatting and accept axis defaults.
271
273