dataface 0.4.1.dev32__py3-none-any.whl → 0.4.1.dev134__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 (149) hide show
  1. dataface/DATAFACE_SYNTAX.md +4 -2
  2. dataface/agent_api/board_artifact.py +15 -1
  3. dataface/agent_api/diagnostics.py +25 -7
  4. dataface/agent_api/docs/yaml-reference.md +17 -8
  5. dataface/agent_api/project_session.py +4 -1
  6. dataface/agent_api/query.py +53 -7
  7. dataface/ai/skills/dashboard-build/SKILL.md +2 -0
  8. dataface/ai/tools/__init__.py +10 -3
  9. dataface/cli/commands/query.py +1 -1
  10. dataface/core/compile/chart_update.py +23 -19
  11. dataface/core/compile/data_table.py +8 -8
  12. dataface/core/compile/format.py +12 -2
  13. dataface/core/compile/inherit_graph.py +22 -1
  14. dataface/core/compile/inherit_resolver.py +1 -1
  15. dataface/core/compile/introspection.py +6 -0
  16. dataface/core/compile/migrations/versions/v0_4_0.py +5 -5
  17. dataface/core/compile/models/chart/normalized/callout.py +7 -0
  18. dataface/core/compile/models/chart/resolved/_base.py +13 -0
  19. dataface/core/compile/models/face/authored.py +12 -7
  20. dataface/core/compile/models/face/normalized.py +14 -7
  21. dataface/core/compile/models/face/resolved.py +67 -1
  22. dataface/core/compile/models/markers.py +20 -0
  23. dataface/core/compile/models/primitives.py +2 -1
  24. dataface/core/compile/models/style/authored/table.py +22 -3
  25. dataface/core/compile/models/style/context.py +225 -0
  26. dataface/core/compile/models/style/resolved/__init__.py +19 -8
  27. dataface/core/compile/models/style/resolved/_base.py +63 -125
  28. dataface/core/compile/models/style/resolved/_cascade.py +94 -52
  29. dataface/core/compile/models/style/resolved/point_map.py +3 -0
  30. dataface/core/compile/models/style/resolved/table.py +1 -1
  31. dataface/core/compile/models/style/theme/__init__.py +2 -6
  32. dataface/core/compile/models/style/theme/axis.py +0 -11
  33. dataface/core/compile/models/style/theme/board.py +32 -105
  34. dataface/core/compile/models/style/theme/charts.py +1 -1
  35. dataface/core/compile/models/style/theme/marks.py +20 -7
  36. dataface/core/compile/normalize_layout.py +6 -0
  37. dataface/core/compile/normalizer.py +48 -19
  38. dataface/core/compile/parameterized.py +16 -2
  39. dataface/core/compile/resolve/__init__.py +8 -8
  40. dataface/core/compile/resolve/_axes.py +28 -19
  41. dataface/core/compile/resolve/_dispatch.py +20 -115
  42. dataface/core/compile/resolve/_domain.py +1 -68
  43. dataface/core/compile/resolve/_kwargs.py +90 -46
  44. dataface/core/compile/resolve/_layers.py +36 -12
  45. dataface/core/compile/resolve/_marks.py +21 -5
  46. dataface/core/compile/resolve/_palette.py +25 -18
  47. dataface/core/compile/resolve/_table.py +42 -25
  48. dataface/core/compile/resolve/area.py +33 -40
  49. dataface/core/compile/resolve/bar.py +69 -62
  50. dataface/core/compile/resolve/geo.py +40 -28
  51. dataface/core/compile/resolve/heatmap.py +23 -19
  52. dataface/core/compile/resolve/line.py +36 -42
  53. dataface/core/compile/resolve/pie.py +76 -70
  54. dataface/core/compile/resolve/pie_attachment.py +56 -1
  55. dataface/core/compile/resolve/scatter.py +56 -56
  56. dataface/core/compile/resolve/simple.py +23 -20
  57. dataface/core/compile/schema_renderers/json_schema.py +5 -1
  58. dataface/core/compile/schema_renderers/prompt.py +7 -1
  59. dataface/core/compile/single_series_allocation.py +1 -1
  60. dataface/core/compile/sizing.py +8 -2
  61. dataface/core/compile/style_cascade.py +88 -54
  62. dataface/core/compile/typography.py +18 -18
  63. dataface/core/compile/validate_formats.py +2 -2
  64. dataface/core/compile/vega_config.py +3 -3
  65. dataface/core/compile/vega_lite/mapping.py +23 -23
  66. dataface/core/dashboard.py +6 -5
  67. dataface/core/defaults/themes/_base.yaml +12 -10
  68. dataface/core/defaults/themes/solid.yaml +245 -0
  69. dataface/core/defaults/themes/stark.yaml +1 -1
  70. dataface/core/diagnostics/__init__.py +0 -2
  71. dataface/core/diagnostics/codes_execute.py +0 -23
  72. dataface/core/diagnostics/codes_render.py +108 -1
  73. dataface/core/dialects/athena.py +0 -2
  74. dataface/core/dialects/base.py +0 -40
  75. dataface/core/dialects/bigquery.py +0 -2
  76. dataface/core/dialects/databricks.py +0 -2
  77. dataface/core/dialects/duckdb.py +0 -2
  78. dataface/core/dialects/mysql.py +0 -2
  79. dataface/core/dialects/postgres.py +0 -2
  80. dataface/core/dialects/snowflake.py +0 -2
  81. dataface/core/dialects/sqlite.py +0 -2
  82. dataface/core/dialects/sqlserver.py +0 -2
  83. dataface/core/execute/adapters/adapter_registry.py +31 -0
  84. dataface/core/execute/chart_resolution.py +8 -202
  85. dataface/core/font_measure.py +20 -0
  86. dataface/core/fonts.py +5 -5
  87. dataface/core/render/board_links.py +1 -1
  88. dataface/core/render/chart/data_table_attachment.py +83 -53
  89. dataface/core/render/chart/emitters/_cartesian.py +87 -12
  90. dataface/core/render/chart/emitters/_layers.py +90 -43
  91. dataface/core/render/chart/emitters/_overlay.py +9 -3
  92. dataface/core/render/chart/emitters/area.py +41 -16
  93. dataface/core/render/chart/emitters/bar.py +50 -34
  94. dataface/core/render/chart/emitters/geo.py +2 -0
  95. dataface/core/render/chart/emitters/line.py +17 -13
  96. dataface/core/render/chart/emitters/pie.py +8 -6
  97. dataface/core/render/chart/emitters/scatter.py +11 -7
  98. dataface/core/render/chart/features/endpoint_labels.py +3 -14
  99. dataface/core/render/chart/features/facet.py +12 -10
  100. dataface/core/render/chart/features/mirror_axis.py +20 -6
  101. dataface/core/render/chart/kpi.py +2 -2
  102. dataface/core/render/chart/rendering.py +96 -30
  103. dataface/core/render/chart/spark.py +7 -7
  104. dataface/core/render/chart/spec.py +14 -2
  105. dataface/core/render/chart/spec_builders.py +1 -1
  106. dataface/core/render/chart/table.py +106 -27
  107. dataface/core/render/chart/table_support.py +3 -41
  108. dataface/core/render/chart/time_unit_detect.py +16 -5
  109. dataface/core/render/chart/type_inference.py +14 -0
  110. dataface/core/render/chart/vega_lite.py +64 -19
  111. dataface/core/render/chart/vl_field_maps.py +5 -0
  112. dataface/core/render/chart_diagnostics.py +27 -0
  113. dataface/core/render/chart_interactivity.py +4 -2
  114. dataface/core/render/chrome_css.py +2 -2
  115. dataface/core/render/column_packer.py +272 -0
  116. dataface/core/render/converters/chart.py +11 -7
  117. dataface/core/render/face_resolve.py +57 -23
  118. dataface/core/render/face_to_dict.py +8 -9
  119. dataface/core/render/faces.py +19 -192
  120. dataface/core/render/font_support.py +0 -70
  121. dataface/core/render/format_utils.py +44 -19
  122. dataface/core/render/layout_sizing.py +261 -164
  123. dataface/core/render/layouts.py +6 -6
  124. dataface/core/render/numeral_expr.py +27 -17
  125. dataface/core/render/prose.py +299 -0
  126. dataface/core/render/renderer.py +3 -0
  127. dataface/core/render/sizing.py +53 -182
  128. dataface/core/render/svg_utils.py +6 -0
  129. dataface/core/render/templates/controls/_styles.css +187 -8
  130. dataface/core/render/templates/scripts/chart_interactivity.js +1 -1
  131. dataface/core/render/templates/scripts/variables.js +345 -50
  132. dataface/core/render/terminal.py +8 -2
  133. dataface/core/render/terminal_charts.py +2 -1
  134. dataface/core/render/variables_strip.py +6 -0
  135. dataface/core/render/warnings/value_labels_crowd_width.py +1 -1
  136. dataface/core/text/format_d3.py +115 -9
  137. dataface/core/text/numeral_scale.py +8 -7
  138. dataface/core/utils.py +84 -2
  139. dataface/data/highlighting/face.json +1 -1
  140. dataface/data/schemas/board-resolved/board-resolved.schema.json +740 -2772
  141. {dataface-0.4.1.dev32.dist-info → dataface-0.4.1.dev134.dist-info}/METADATA +3 -3
  142. {dataface-0.4.1.dev32.dist-info → dataface-0.4.1.dev134.dist-info}/RECORD +149 -144
  143. {dataface-0.4.1.dev32.dist-info → dataface-0.4.1.dev134.dist-info}/WHEEL +1 -1
  144. mdsvg/fonts.py +28 -2
  145. mdsvg/parser.py +17 -0
  146. mdsvg/renderer.py +307 -14
  147. mdsvg/style.py +6 -0
  148. {dataface-0.4.1.dev32.dist-info → dataface-0.4.1.dev134.dist-info}/entry_points.txt +0 -0
  149. {dataface-0.4.1.dev32.dist-info → dataface-0.4.1.dev134.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.
@@ -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)
@@ -36,9 +36,10 @@ __all__ = [
36
36
  "DiagnosticCodeDetail",
37
37
  "DiagnosticCodesListResult",
38
38
  "DiagnosticCodeDetailResult",
39
+ "DiagnosticDisplay",
39
40
  "list_diagnostic_codes",
40
41
  "get_diagnostic_code",
41
- "doc_url_map",
42
+ "display_map",
42
43
  ]
43
44
 
44
45
  # ----- Args types ------------------------------------------------------------
@@ -77,6 +78,20 @@ class DiagnosticCodeSummary(BaseModel):
77
78
  model_config = ConfigDict(extra="forbid", frozen=True)
78
79
 
79
80
 
81
+ class DiagnosticDisplay(BaseModel):
82
+ """Registry-derived presentation data for one code, shipped per page.
83
+
84
+ Both fields are derived DiagnosticCode properties rather than serialized
85
+ Diagnostic fields, so a display surface resolves them from the code it
86
+ already has instead of receiving them on every payload.
87
+ """
88
+
89
+ title: str
90
+ doc_url: str
91
+
92
+ model_config = ConfigDict(extra="forbid", frozen=True)
93
+
94
+
80
95
  class DiagnosticCodeDetail(BaseModel):
81
96
  """Full details for a single diagnostic code."""
82
97
 
@@ -207,11 +222,14 @@ def get_diagnostic_code(code: str) -> DiagnosticCodeDetailResult:
207
222
  )
208
223
 
209
224
 
210
- def doc_url_map() -> dict[str, str]:
211
- """The registry's code -> doc_url map, built once for embedding alongside a page.
225
+ def display_map() -> dict[str, DiagnosticDisplay]:
226
+ """The registry's code -> display map, built once for embedding alongside a page.
212
227
 
213
- doc_url is a derived DiagnosticCode property, never an authored/serialized
214
- field — Cloud and Playground each ship this map once rather than
215
- re-deriving or hand-formatting a URL per diagnostic.
228
+ `title` and `doc_url` are derived DiagnosticCode properties, never
229
+ authored/serialized Diagnostic fields — Cloud and Playground each ship this
230
+ map once rather than re-deriving a URL or restating a title per diagnostic.
216
231
  """
217
- return {dc.code: dc.doc_url for dc in REGISTRY.all()}
232
+ return {
233
+ dc.code: DiagnosticDisplay(title=dc.title, doc_url=dc.doc_url)
234
+ for dc in REGISTRY.all()
235
+ }
@@ -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. |
@@ -1894,14 +1894,14 @@ Authored overlay for BlockMarginStyle. Top and bottom margin for a prose block r
1894
1894
 
1895
1895
  <a id="textcolumnstyle"></a>
1896
1896
  ## TextColumnStyle
1897
- Authored overlay for TextColumnStyle. Multi-column layout for face body text (CSS column-count/gap/rule/max_chars).
1897
+ Authored overlay for TextColumnStyle. Author overrides for the column layout of face body text.
1898
1898
 
1899
1899
  | Field | Type | Optional | Description |
1900
1900
  |-------|------|:--------:|-------------|
1901
- | `number` | int | ✓ | Fixed column count. None = single column. |
1902
- | `gap` | float | ✓ | Gap between columns in pixels. None = use layout gap token. |
1903
- | `rule` | str | ✓ | CSS column-rule shorthand, e.g. '1px solid #e5e7eb'. None = no rule. |
1904
- | `max_chars` | int | ✓ | Target column width as a character count. The pixel width is derived at render time via fonttools OS/2.xAvgCharWidth × max_chars; column count auto-derives as ceil(available / col_width_px). |
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
+ | `gap` | float | ✓ | Gap between columns in pixels. None = 1.5 line boxes, so the gutter scales with the type it separates. |
1903
+ | `rule` | [ColumnRuleStyle](#columnrulestyle) | ✓ | Vertical rule drawn between columns. None = no rule. |
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>
1907
1907
  ## TextCodeStyle
@@ -2335,7 +2335,6 @@ Authored overlay for ScaleContinuousStyle. Continuous-scale-only config: type, d
2335
2335
  | `zero` | bool \| enum: "auto" | ✓ | Force scale zero-baseline: True/False pins it; "auto" runs the Dataface smart-zero heuristic; None passes through to Vega-Lite. |
2336
2336
  | `type` | enum: "linear", "log", "pow", "sqrt", "symlog", "temporal" | ✓ | Scale type override. 'linear'/'log'/'pow'/'sqrt'/'symlog' pass straight through to Vega-Lite's quantitative scale.type. 'temporal' is a Dataface escape hatch for a cartesian x-axis: it forces a continuous temporal scale instead of the auto-inferred ordinal/nominal bucketed type, which is required before an authored ``domain`` of ISO dates can extend the visible range past the data extent. None lets Dataface/Vega-Lite infer from the field type. |
2337
2337
  | `domain` | list[Any] | ✓ | Explicit [low, high] scale domain; None lets Vega-Lite auto-determine from data. Must have exactly 2 elements — Dataface only wires a continuous range override through to Vega-Lite, not an explicit category enumeration. When ``type: temporal`` is set, both elements must be ISO-8601 date/datetime strings. |
2338
- | `domain_strategy` | enum: "stable", "auto" | ✓ | How the numeric domain responds to filter-variable changes when no explicit `domain` is authored. 'stable' (the default when None) pins the domain to the chart query's full unfiltered extent — the same value lands at the same pixel across variable changes. 'auto' rescales the domain to the current filtered subset on every variable change. An explicit `domain` always wins over either strategy. |
2339
2338
  | `log` | [ScaleLogStyle](#scalelogstyle) | ✓ | Log-scale param (base); only meaningful with type: log. |
2340
2339
  | `pow` | [ScalePowStyle](#scalepowstyle) | ✓ | Power-scale param (exponent); only meaningful with type: pow. |
2341
2340
  | `symlog` | [ScaleSymlogStyle](#scalesymlogstyle) | ✓ | Symlog-scale param (constant); only meaningful with type: symlog. |
@@ -2621,6 +2620,16 @@ Authored overlay for GeoshapeMarkStyle. Geoshape (choropleth) mark fill and boun
2621
2620
  | `fill` | str | ✓ | Neutral geoshape fill color. |
2622
2621
  | `stroke` | [StrokeStyle](#strokestyle) | ✓ | Geoshape boundary stroke style. |
2623
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
+
2624
2633
  <a id="circlemarkstyle"></a>
2625
2634
  ## CircleMarkStyle
2626
2635
  Authored overlay for CircleMarkStyle. Circle mark opacity and stroke.
@@ -2848,7 +2857,7 @@ Authored overlay for SliceLabelsStyle. Pie labels: typography + positioning offs
2848
2857
  |-------|------|:--------:|-------------|
2849
2858
  | `offset` | float | ✓ | Radial offset of slice labels from the arc in pixels. |
2850
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. |
2851
- | `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`). |
2852
2861
  | `default_template` | [LabelsDefaultTemplate](#labelsdefaulttemplate) | ✓ | Default Jinja templates for per-slice labels when template is not authored. |
2853
2862
  | `template` | str | ✓ | Jinja2 label template. Overrides default_template when authored. |
2854
2863
  | `where` | str | ✓ | Jinja2 boolean filter — labels only render on rows where this is truthy. |
@@ -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,
@@ -33,12 +33,17 @@ def lookup_face_query_sql(
33
33
  path: Path,
34
34
  *,
35
35
  project: Project,
36
+ vars: dict[str, Any] | None = None,
36
37
  ) -> FaceQueryLookupResult:
37
- """Compile a face file and extract the SQL + source for one named SQL query.
38
+ """Compile a face file, render the SQL template, and return it for offline use.
38
39
 
39
- Used by validate and describe paths that need the raw SQL text without
40
- executing it against a warehouse.
40
+ Expands ``{{ queries.X }}`` and substitutes variables so callers receive
41
+ valid SQL rather than a raw Jinja template. Used by validate and describe
42
+ paths that need the rendered SQL text without executing it against a
43
+ warehouse.
41
44
  """
45
+ from dataface.core.compile.parameterized import render_parameterized_with_queries
46
+
42
47
  try:
43
48
  file_path = resolve_face_path(path, project)
44
49
  except ValueError as e:
@@ -74,7 +79,18 @@ def lookup_face_query_sql(
74
79
  ],
75
80
  )
76
81
 
77
- return FaceQueryLookupResult(success=True, sql=query.sql, source=query.source)
82
+ merged_vars = {**face.variable_defaults, **(vars or {})}
83
+ try:
84
+ rendered = render_parameterized_with_queries(
85
+ query.sql,
86
+ merged_vars,
87
+ queries=face.queries,
88
+ strict=not query.lenient_variables,
89
+ )
90
+ except (ValueError, KeyError, TypeError) as exc:
91
+ return FaceQueryLookupResult(success=False, errors=[str(exc)])
92
+
93
+ return FaceQueryLookupResult(success=True, sql=rendered.sql, source=query.source)
78
94
 
79
95
 
80
96
  class ExecuteQueryArgs(BaseModel):
@@ -93,6 +109,14 @@ class ExecuteQueryArgs(BaseModel):
93
109
  False,
94
110
  description="When True, undefined Jinja variables degrade gracefully (useful for iterative chart editing where not all variables are set).",
95
111
  )
112
+ # Display only — never reaches execute_query(). Ad-hoc SQL is the one query
113
+ # surface with no author to describe it: a named face query carries the
114
+ # `description:` its YAML already declares, and this is the equivalent for
115
+ # a query that exists only for the length of one tool call.
116
+ description: str | None = Field(
117
+ None,
118
+ description="Short phrase naming what this query is for (e.g. 'Leads by industry'), shown to the user in place of the raw SQL.",
119
+ )
96
120
 
97
121
 
98
122
  class ExecuteQueryResult(BaseModel):
@@ -236,6 +260,9 @@ class QueryFaceResult(BaseModel):
236
260
  path: PurePosixPath
237
261
  query_type: str | None = None
238
262
  sql: str | None = None
263
+ #: The query's authored `description:`, when its YAML declares one. Lets a
264
+ #: caller label the run with what it is for rather than its identifier.
265
+ description: str | None = None
239
266
  columns: list[str] = []
240
267
  data: list[dict[str, Any]] = []
241
268
  row_count: int = 0
@@ -249,11 +276,19 @@ def _fail(
249
276
  path: PurePosixPath,
250
277
  errors: list[str],
251
278
  available: list[str] | None = None,
279
+ sql: str | None = None,
280
+ description: str | None = None,
252
281
  ) -> QueryFaceResult:
282
+ """A failure reports the query it was running whenever it got far enough to
283
+ know it: an undefined-variable error is unreadable without the SQL holding
284
+ the `{{ }}` that went unbound. Failures before the query is looked up
285
+ (unresolvable path, compile error, unknown name) have neither to report."""
253
286
  return QueryFaceResult(
254
287
  success=False,
255
288
  name=name,
256
289
  path=path,
290
+ sql=sql,
291
+ description=description,
257
292
  errors=errors,
258
293
  available_queries=available or [],
259
294
  )
@@ -298,6 +333,8 @@ def query_face(
298
333
 
299
334
  merged_vars = {**face.variable_defaults, **(vars or {})}
300
335
  query = face.queries[name].model_copy(update={"limit": limit + 1})
336
+ sql = query.sql if isinstance(query, SqlQuery) else None
337
+ described = query.description
301
338
 
302
339
  # Detect file sources before dispatching — the adapter path has no materializer
303
340
  # and would fall through to SqlAdapter with a cryptic dbt error. File-source
@@ -324,6 +361,8 @@ def query_face(
324
361
  "(dft render / dft serve). "
325
362
  "Use dft render to preview this dashboard."
326
363
  ],
364
+ sql=sql,
365
+ description=described,
327
366
  )
328
367
 
329
368
  try:
@@ -331,10 +370,16 @@ def query_face(
331
370
  query, variables=merged_vars or None, face=face, query_name=name
332
371
  )
333
372
  except Exception as e: # noqa: BLE001
334
- return _fail(name, resolved_display, [str(e)])
373
+ return _fail(name, resolved_display, [str(e)], sql=sql, description=described)
335
374
 
336
375
  if exec_result.error:
337
- return _fail(name, resolved_display, [exec_result.error])
376
+ return _fail(
377
+ name,
378
+ resolved_display,
379
+ [exec_result.error],
380
+ sql=sql,
381
+ description=described,
382
+ )
338
383
 
339
384
  data = normalize_data_for_json(exec_result.data)
340
385
  truncated = len(data) > limit
@@ -345,7 +390,8 @@ def query_face(
345
390
  name=name,
346
391
  path=resolved_display,
347
392
  query_type=query.query_type,
348
- sql=query.sql if isinstance(query, SqlQuery) else None,
393
+ sql=sql,
394
+ description=described,
349
395
  columns=list(data[0].keys()) if data else [],
350
396
  data=data,
351
397
  row_count=len(data),
@@ -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
 
@@ -206,9 +206,16 @@ def _handle_get_skill(args: dict[str, Any], ctx: DatafaceAIContext) -> dict[str,
206
206
  )
207
207
  except _skills.SkillNotFound as exc:
208
208
  return {"success": False, "errors": [str(exc)]}
209
- return skill.model_dump(
210
- mode="json", exclude_none=True, exclude=_skills.SKILL_WIRE_EXCLUDE_FIELDS
211
- )
209
+ # `Skill` is a domain model, not a result envelope — it carries no verdict
210
+ # of its own, and it must not grow one (it nests inside `SkillList.skills`,
211
+ # where a per-item `success` means nothing). The envelope is added here, so
212
+ # this payload states its outcome like every other tool's.
213
+ return {
214
+ "success": True,
215
+ **skill.model_dump(
216
+ mode="json", exclude_none=True, exclude=_skills.SKILL_WIRE_EXCLUDE_FIELDS
217
+ ),
218
+ }
212
219
 
213
220
 
214
221
  def _handle_search_skills(
@@ -344,7 +344,7 @@ def query_command(
344
344
  if mode_is_named:
345
345
  assert query_name is not None
346
346
  assert face is not None
347
- lr = project_session.lookup_face_query_sql(query_name, face)
347
+ lr = project_session.lookup_face_query_sql(query_name, face, vars=vars)
348
348
  if not lr.success:
349
349
  _handle_lookup_failure(lr, query_name)
350
350
  sql_text = lr.sql
@@ -36,13 +36,10 @@ Usage:
36
36
  )
37
37
  """
38
38
 
39
- import logging
40
39
  from typing import Any
41
40
 
42
41
  import yaml
43
42
 
44
- logger = logging.getLogger(__name__)
45
-
46
43
 
47
44
  def update_chart_in_yaml(
48
45
  yaml_content: str,
@@ -147,6 +144,25 @@ def update_chart_in_yaml(
147
144
  if field in edits and edits[field]:
148
145
  chart_def[field] = edits[field]
149
146
 
147
+ # Validate rewritability before any mutation.
148
+ # A rows/cols source_path must be exactly two segments (layout_key.N) where N
149
+ # is a digit — that is the only shape _update_layout_reference can rewrite.
150
+ # Deeper paths (rows.0.cols.1) name a chart inside a nested face; the nested
151
+ # face's own file must name the chart instead.
152
+ if source_path and source_path.split(".")[0] in ("rows", "cols"):
153
+ _segs = source_path.split(".")
154
+ if len(_segs) != 2:
155
+ raise ValueError(
156
+ f"Cannot promote inline chart at '{source_path}': only top-level "
157
+ "layout positions (rows.N / cols.N) are supported; name the chart "
158
+ "in the nested face's own file instead"
159
+ )
160
+ if not _segs[1].isdigit():
161
+ raise ValueError(
162
+ f"Cannot promote inline chart at '{source_path}': "
163
+ f"'{_segs[1]}' is not a valid integer index"
164
+ )
165
+
150
166
  # Update the charts section
151
167
  # If chart already exists, merge with existing values
152
168
  if chart_id in data["charts"]:
@@ -171,31 +187,19 @@ def update_chart_in_yaml(
171
187
  def _update_layout_reference(
172
188
  data: dict[str, Any], source_path: str, chart_id: str
173
189
  ) -> None:
174
- """Update a layout position to reference a named chart instead of inline.
190
+ """Replace an inline chart at a top-level layout position with a named reference.
175
191
 
176
192
  Args:
177
193
  data: Parsed YAML data (modified in place)
178
- source_path: Path like "rows.0" or "rows.0.cols.1"
194
+ source_path: Validated path of the form "rows.N" or "cols.N".
195
+ Caller guarantees exactly two segments and a digit index.
179
196
  chart_id: ID of the chart to reference
180
197
  """
181
- # Only a top-level position is rewritable here: this replaces an entry in
182
- # `data[layout_key]`, and a deeper path (`rows.0.cols.1`) names an entry in a
183
- # nested face's own list, which this function never walks into.
184
- segments = source_path.split(".")
185
- if len(segments) != 2 or segments[0] not in ("rows", "cols"):
186
- logger.debug(f"Unrecognized source_path format: {source_path}")
187
- return
188
- layout_key, raw_index = segments
189
- if not raw_index.isdigit():
190
- logger.warning(f"Failed to parse index from source_path: {source_path}")
191
- return
198
+ layout_key, raw_index = source_path.split(".")
192
199
  index = int(raw_index)
193
-
194
- # Update the layout
195
200
  if (
196
201
  layout_key in data
197
202
  and isinstance(data[layout_key], list)
198
203
  and 0 <= index < len(data[layout_key])
199
204
  ):
200
- # Replace inline chart with named reference
201
205
  data[layout_key][index] = chart_id
@@ -16,7 +16,7 @@ helpers) live there too.
16
16
  from __future__ import annotations
17
17
 
18
18
  from dataface.core.compile.merge import merge_onto_base
19
- from dataface.core.compile.models.style.resolved import ResolvedChartsStyle
19
+ from dataface.core.compile.models.style.context import ChartStyleContext
20
20
  from dataface.core.compile.models.style.theme import DataTableStyle
21
21
  from dataface.core.compile.style_cascade import resolved_axis_style
22
22
 
@@ -31,7 +31,7 @@ def row_height(style: DataTableStyle) -> float:
31
31
  return _ROW_HEIGHT_BASE + padding.vertical * 2.0
32
32
 
33
33
 
34
- def axis_offset(charts_style: ResolvedChartsStyle, style: DataTableStyle) -> float:
34
+ def axis_offset(chart_style_context: ChartStyleContext, style: DataTableStyle) -> float:
35
35
  """Pixel offset from plot bottom edge to the bottom of the x-axis block.
36
36
 
37
37
  Sums whichever axis components are actually visible: a chart with the
@@ -45,14 +45,14 @@ def axis_offset(charts_style: ResolvedChartsStyle, style: DataTableStyle) -> flo
45
45
 
46
46
  MUST use the fully-merged axis (resolved_axis_style) rather than reading
47
47
  charts_style.axis_x directly. The authored x_label visibility flip is
48
- stored on axis_overrides_x (Layer 6) by build_resolved_style; reading
48
+ stored on axis_overrides_x (Layer 6) by build_chart_style_context; reading
49
49
  the base axis_x (Layers 1+2) misses it, under-reserving title space and
50
50
  causing the strip to collide with the rendered axis title.
51
51
  """
52
52
  # Font metrics come from the base ResolvedAxisStyle (non-None guaranteed).
53
53
  # Visibility is read from the fully-merged cascade (includes Layer-6 authored-
54
54
  # label flips) so both label and title heights reflect the actual rendered state.
55
- merged = resolved_axis_style(charts_style, "axis_x", "band")
55
+ merged = resolved_axis_style(chart_style_context, "axis_x", "band")
56
56
  # labels.padding is always required by build_resolved_axis (the shared
57
57
  # ResolvedAxisElementStyle dataclass types it Optional only to let title
58
58
  # leave it unset — see AxisTitleStyle).
@@ -72,7 +72,7 @@ def axis_offset(charts_style: ResolvedChartsStyle, style: DataTableStyle) -> flo
72
72
 
73
73
 
74
74
  def resolve_effective_data_table_style(
75
- charts_style: ResolvedChartsStyle, chart_type: str
75
+ chart_style_context: ChartStyleContext, chart_type: str
76
76
  ) -> DataTableStyle:
77
77
  """Resolve the effective data_table style for a chart.
78
78
 
@@ -81,7 +81,7 @@ def resolve_effective_data_table_style(
81
81
  fields from charts.data_table, so this function only needs to apply the
82
82
  per-family theme values (tier-2) on top.
83
83
  """
84
- per_type = getattr(charts_style, chart_type, None)
84
+ per_type = getattr(chart_style_context, chart_type, None)
85
85
  if per_type is None or not hasattr(per_type, "data_table"):
86
- return charts_style.data_table
87
- return merge_onto_base(charts_style.data_table, per_type.data_table)
86
+ return chart_style_context.data_table
87
+ return merge_onto_base(chart_style_context.data_table, per_type.data_table)
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
  from typing import Any
14
14
 
15
15
  from dataface.core.compile.models.primitives import FormatConfig
16
+ from dataface.core.text.format_d3 import round_aware_format
16
17
 
17
18
 
18
19
  def resolve_format(
@@ -35,6 +36,15 @@ def resolve_format(
35
36
  rendering. That pass keys slots off field name, so it is not a guarantee
36
37
  for every value reaching this function.
37
38
 
39
+ The resolved spec also carries ``round_aware_format``'s ``~``-trim
40
+ injection (a no-op for a non-SI spec, a native-formatter key, or a
41
+ strftime directive). This is the single point nearly every consumer of a
42
+ resolved format string is baked from — axes and mark labels at compile,
43
+ KPI/table/tooltip at render — so every one of them, including a spec
44
+ handed straight to Vega-Lite (``axis.format``, a value label's
45
+ ``text.format``), agrees on digits by construction instead of each
46
+ render leaf re-applying the trim itself.
47
+
38
48
  Args:
39
49
  format_input: Format specification (string, FormatConfig, dict, or None)
40
50
  formats: The resolved format alias dict from the theme cascade
@@ -59,10 +69,10 @@ def resolve_format(
59
69
 
60
70
  # Theme/face alias lookup (case-sensitive, key-exact)
61
71
  if formats and format_str in formats:
62
- return formats[format_str]
72
+ return round_aware_format(formats[format_str])
63
73
 
64
74
  # Raw d3 passthrough — let d3 parse or raise
65
- return format_str
75
+ return round_aware_format(format_str)
66
76
 
67
77
 
68
78
  # d3's first SI prefix ("k") engages at 1000. Below it, ".2s" doesn't compact —
@@ -97,11 +97,16 @@ def _build_graph(
97
97
  slot_at: StylePath | None = None,
98
98
  slot_from: StylePath | None = None,
99
99
  skip_slots: bool = False,
100
+ slot_exclude: frozenset[str] = frozenset(),
100
101
  ) -> None:
101
102
  """Walk *model_cls* recursively and populate *graph* from Inherit/InheritSlot markers.
102
103
 
103
104
  ``skip_slots=True`` suppresses InheritSlot expansion for the entire subtree —
104
105
  used when a shared model's slot annotations must not apply at a particular path.
106
+
107
+ ``slot_exclude`` carries the active slot's ``InheritSlot.exclude`` leaf names
108
+ down through the recursion; a leaf whose field name is in this set gets no
109
+ graph entry, leaving it a genuine cascade-managed sentinel.
105
110
  """
106
111
  if model_cls in seen:
107
112
  return
@@ -160,6 +165,7 @@ def _build_graph(
160
165
  slot_at=slot_at if skip_marker.cascade else None,
161
166
  slot_from=slot_from if skip_marker.cascade else None,
162
167
  skip_slots=not skip_marker.cascade,
168
+ slot_exclude=slot_exclude if skip_marker.cascade else frozenset(),
163
169
  )
164
170
  elif slot_marker is not None and not skip_slots:
165
171
  if slot_at is not None:
@@ -176,6 +182,7 @@ def _build_graph(
176
182
  graph,
177
183
  slot_at=slot_at,
178
184
  slot_from=slot_from,
185
+ slot_exclude=slot_exclude,
179
186
  )
180
187
  else:
181
188
  from_path = slot_marker.from_path
@@ -184,6 +191,12 @@ def _build_graph(
184
191
  raise ValueError(
185
192
  f"InheritSlot from_path {from_path!r} does not match any path in the model"
186
193
  )
194
+ unknown_exclude = slot_marker.exclude - nested.model_fields.keys()
195
+ if unknown_exclude:
196
+ raise ValueError(
197
+ f"InheritSlot exclude {sorted(unknown_exclude)!r} on "
198
+ f"{_path_str(current)!r} not a field of {nested.__name__}"
199
+ )
187
200
  # Nullable InheritSlot (T | None): emit a container-level copy
188
201
  # link so apply_inherit can copy the whole parent object when
189
202
  # this field is None. apply_inherit._set() skips writes through
@@ -200,6 +213,7 @@ def _build_graph(
200
213
  graph,
201
214
  slot_at=current,
202
215
  slot_from=from_tuple,
216
+ slot_exclude=slot_marker.exclude,
203
217
  )
204
218
  else:
205
219
  _build_graph(
@@ -211,6 +225,7 @@ def _build_graph(
211
225
  slot_at=slot_at,
212
226
  slot_from=slot_from,
213
227
  skip_slots=skip_slots,
228
+ slot_exclude=slot_exclude,
214
229
  )
215
230
  else:
216
231
  # Leaf node.
@@ -226,7 +241,13 @@ def _build_graph(
226
241
  # slot (which authors may override independently).
227
242
  # 2. Explicit Inherit: fires at canonical (non-slot) positions only,
228
243
  # establishing the "ultimate fallback" for the entire slot family.
229
- if slot_at is not None and slot_from is not None and skip_marker is None:
244
+ #
245
+ # A leaf named in the active slot's exclude set gets no link at
246
+ # all: it stays a genuine cascade-managed sentinel (None unless
247
+ # some tier's raw YAML sets it) rather than being backfilled.
248
+ if field_name in slot_exclude:
249
+ pass
250
+ elif slot_at is not None and slot_from is not None and skip_marker is None:
230
251
  leaf_suffix: StylePath = current[len(slot_at) :]
231
252
  fallback_path = _path_str(slot_from + leaf_suffix)
232
253
  if fallback_path not in valid_paths:
@@ -4,7 +4,7 @@ apply_inherit fills None-sentinel leaves by walking the single-link
4
4
  parent chains declared via Inherit/InheritSlot markers on the compiled Style.
5
5
  It runs after merge_onto_base, replacing the former hard-coded push loop approach.
6
6
 
7
- Not in scope: _build_resolved_style, resolved_axis_style.
7
+ Not in scope: _build_chart_style_context, resolved_axis_style.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -72,6 +72,9 @@ class SchemaField:
72
72
  )
73
73
  inherit_from: tuple[str, ...] = () # ordered fallback dot-paths (Inherit marker)
74
74
  inherit_slot: str | None = None # source subtree path (InheritSlot marker)
75
+ inherit_slot_exclude: frozenset[str] = (
76
+ frozenset()
77
+ ) # leaf names InheritSlot excludes
75
78
 
76
79
 
77
80
  @dataclasses.dataclass
@@ -411,6 +414,9 @@ def _build_schema_field(
411
414
  container_mapping_models=_container_mapping_models(annotation),
412
415
  inherit_from=(inherit.from_path,) if inherit is not None else (),
413
416
  inherit_slot=inherit_slot.from_path if inherit_slot is not None else None,
417
+ inherit_slot_exclude=(
418
+ inherit_slot.exclude if inherit_slot is not None else frozenset()
419
+ ),
414
420
  )
415
421
 
416
422