dataface 0.4.1.dev134__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 (146) hide show
  1. dataface/DATAFACE_SYNTAX.md +2 -0
  2. dataface/agent_api/_init_templates/meta.yaml +3 -3
  3. dataface/agent_api/dashboards.py +21 -4
  4. dataface/agent_api/docs/yaml-reference.md +8 -8
  5. dataface/agent_api/pack.py +1 -1
  6. dataface/agent_api/query.py +59 -5
  7. dataface/agent_api/serve.py +1 -3
  8. dataface/ai/llm.py +59 -13
  9. dataface/ai/tools/__init__.py +11 -4
  10. dataface/cli/commands/board_artifact.py +1 -1
  11. dataface/cli/commands/extension.py +1 -0
  12. dataface/cli/commands/serve.py +5 -14
  13. dataface/cli/commands/skills_init.py +1 -2
  14. dataface/cli/main.py +29 -13
  15. dataface/core/compile/compiler.py +51 -27
  16. dataface/core/compile/config.py +71 -5
  17. dataface/core/compile/inherit_resolver.py +3 -3
  18. dataface/core/compile/label_data.py +3 -3
  19. dataface/core/compile/labels_env.py +14 -2
  20. dataface/core/compile/migrations/migrations.py +3 -1
  21. dataface/core/compile/migrations/versions/__init__.py +16 -0
  22. dataface/core/compile/models/chart/resolved/pie.py +10 -0
  23. dataface/core/compile/models/config.py +20 -2
  24. dataface/core/compile/models/face/authored.py +3 -4
  25. dataface/core/compile/models/face/normalized.py +2 -1
  26. dataface/core/compile/models/face/resolved.py +3 -3
  27. dataface/core/compile/models/primitives.py +7 -0
  28. dataface/core/compile/models/style/resolved/_base.py +68 -5
  29. dataface/core/compile/models/style/resolved/_cascade.py +341 -55
  30. dataface/core/compile/models/style/theme/__init__.py +3 -3
  31. dataface/core/compile/models/style/theme/board.py +3 -3
  32. dataface/core/compile/models/style/theme/style.py +2 -2
  33. dataface/core/compile/models/style/theme/table.py +7 -1
  34. dataface/core/compile/models/variable/authored.py +5 -1
  35. dataface/core/compile/normalize_layout.py +1 -1
  36. dataface/core/compile/normalize_queries.py +1 -3
  37. dataface/core/compile/normalize_variables.py +10 -3
  38. dataface/core/compile/normalizer.py +93 -19
  39. dataface/core/compile/parameterized.py +9 -10
  40. dataface/core/compile/resolve/_axes.py +71 -24
  41. dataface/core/compile/resolve/_domain.py +34 -1
  42. dataface/core/compile/resolve/adaptive_stroke.py +7 -3
  43. dataface/core/compile/resolve/area.py +32 -6
  44. dataface/core/compile/resolve/bar.py +167 -17
  45. dataface/core/compile/resolve/heatmap.py +10 -2
  46. dataface/core/compile/resolve/line.py +30 -6
  47. dataface/core/compile/resolve/pie.py +23 -6
  48. dataface/core/compile/resolve/pie_attachment.py +25 -2
  49. dataface/core/compile/resolve/scatter.py +16 -2
  50. dataface/core/compile/schema_renderers/yaml_schema_catalog.py +4 -2
  51. dataface/core/compile/sizing.py +8 -8
  52. dataface/core/compile/source_map.py +56 -76
  53. dataface/core/compile/sources.py +2 -4
  54. dataface/core/compile/style_cascade.py +79 -13
  55. dataface/core/compile/typography.py +9 -9
  56. dataface/core/compile/validate_formats.py +33 -1
  57. dataface/core/compile/yaml_error_formatter.py +1 -2
  58. dataface/core/compile/yaml_patch.py +1 -1
  59. dataface/core/defaults/default_config.yml +42 -3
  60. dataface/core/defaults/themes/_base.yaml +1 -1
  61. dataface/core/defaults/themes/stark.yaml +6 -4
  62. dataface/core/diagnostics/__init__.py +18 -0
  63. dataface/core/diagnostics/codes_compile.py +34 -0
  64. dataface/core/diagnostics/codes_render.py +199 -9
  65. dataface/core/execute/_duckdb_cache_base.py +1 -3
  66. dataface/core/execute/adapters/sql_adapter.py +1 -3
  67. dataface/core/execute/cache_composition.py +1 -3
  68. dataface/core/execute/sql_literals.py +1 -2
  69. dataface/core/execute/trivial_local_cache.py +3 -1
  70. dataface/core/font_measure.py +155 -1
  71. dataface/core/fonts.py +72 -0
  72. dataface/core/inspect/resolver.py +3 -1
  73. dataface/core/numeric.py +15 -0
  74. dataface/core/registered_views/expander.py +95 -24
  75. dataface/core/registered_views/render_pipeline.py +36 -1
  76. dataface/core/render/chart/callout.py +44 -30
  77. dataface/core/render/chart/data_table_attachment.py +11 -13
  78. dataface/core/render/chart/emitters/_cartesian.py +48 -20
  79. dataface/core/render/chart/emitters/_channels.py +3 -3
  80. dataface/core/render/chart/emitters/_label_overlap.py +2 -2
  81. dataface/core/render/chart/emitters/_measured_label_padding.py +26 -11
  82. dataface/core/render/chart/emitters/_overlay.py +33 -6
  83. dataface/core/render/chart/emitters/_tooltip.py +6 -6
  84. dataface/core/render/chart/emitters/area.py +8 -5
  85. dataface/core/render/chart/emitters/bar.py +42 -13
  86. dataface/core/render/chart/emitters/line.py +5 -7
  87. dataface/core/render/chart/emitters/pie.py +1 -2
  88. dataface/core/render/chart/emitters/scatter.py +10 -3
  89. dataface/core/render/chart/features/baseline.py +9 -9
  90. dataface/core/render/chart/features/endpoint_labels.py +36 -14
  91. dataface/core/render/chart/features/mirror_axis.py +39 -5
  92. dataface/core/render/chart/features/structured_tooltip.py +5 -3
  93. dataface/core/render/chart/kpi.py +49 -46
  94. dataface/core/render/chart/rendering.py +40 -6
  95. dataface/core/render/chart/series_label_truncation.py +81 -0
  96. dataface/core/render/chart/spark.py +1 -1
  97. dataface/core/render/chart/spark_bar.py +15 -10
  98. dataface/core/render/chart/spec.py +2 -2
  99. dataface/core/render/chart/table.py +83 -65
  100. dataface/core/render/chart/table_support.py +8 -5
  101. dataface/core/render/chart/text_truncation.py +96 -0
  102. dataface/core/render/chart/time_unit_detect.py +118 -36
  103. dataface/core/render/chart/title_overflow.py +39 -62
  104. dataface/core/render/chart/translate.py +4 -10
  105. dataface/core/render/chart/vega_lite.py +14 -5
  106. dataface/core/render/chart/vl_field_maps.py +173 -37
  107. dataface/core/render/converters/chart.py +29 -18
  108. dataface/core/render/face_resolve.py +10 -10
  109. dataface/core/render/faces.py +45 -19
  110. dataface/core/render/font_support.py +71 -13
  111. dataface/core/render/fonts/DFTSansTabular-Medium.ttf +0 -0
  112. dataface/core/render/fonts/DFTSansTabular-SemiBold.ttf +0 -0
  113. dataface/core/render/fonts/InterVariable-Medium.ttf +0 -0
  114. dataface/core/render/fonts/InterVariable-SemiBold.ttf +0 -0
  115. dataface/core/render/fonts/SourceSerif4-Medium.ttf +0 -0
  116. dataface/core/render/fonts/SourceSerif4-SemiBold.ttf +0 -0
  117. dataface/core/render/layout_sizing.py +33 -19
  118. dataface/core/render/renderer.py +109 -69
  119. dataface/core/render/sizing.py +170 -132
  120. dataface/core/render/svg_utils.py +7 -10
  121. dataface/core/render/variables_layout.py +5 -3
  122. dataface/core/render/variables_resolve.py +4 -7
  123. dataface/core/render/variables_strip.py +9 -2
  124. dataface/core/render/warnings/axis_title_truncated.py +43 -0
  125. dataface/core/render/warnings/base.py +18 -0
  126. dataface/core/render/warnings/callout_text_truncated.py +41 -0
  127. dataface/core/render/warnings/chart_title_truncated.py +36 -0
  128. dataface/core/render/warnings/facet_panel_width_below_minimum.py +71 -0
  129. dataface/core/render/warnings/kpi_label_truncated.py +34 -0
  130. dataface/core/render/warnings/registry.py +16 -0
  131. dataface/core/render/warnings/series_label_truncated.py +64 -0
  132. dataface/core/render/warnings/spark_label_truncated.py +37 -0
  133. dataface/core/render/warnings/table_text_truncated.py +42 -0
  134. dataface/core/render/yaml_format.py +15 -0
  135. dataface/core/serve/server.py +3 -5
  136. dataface/core/text/format_d3.py +126 -19
  137. dataface/core/text/numeral_scale.py +39 -0
  138. dataface/core/validate.py +0 -1
  139. dataface/data/schemas/board-resolved/board-resolved.schema.json +118 -46
  140. dataface/schema_release.py +4 -2
  141. {dataface-0.4.1.dev134.dist-info → dataface-0.4.1.dev224.dist-info}/METADATA +1 -1
  142. {dataface-0.4.1.dev134.dist-info → dataface-0.4.1.dev224.dist-info}/RECORD +146 -130
  143. mdsvg/fonts.py +13 -6
  144. {dataface-0.4.1.dev134.dist-info → dataface-0.4.1.dev224.dist-info}/WHEEL +0 -0
  145. {dataface-0.4.1.dev134.dist-info → dataface-0.4.1.dev224.dist-info}/entry_points.txt +0 -0
  146. {dataface-0.4.1.dev134.dist-info → dataface-0.4.1.dev224.dist-info}/licenses/LICENSE +0 -0
@@ -928,6 +928,8 @@ variables:
928
928
 
929
929
  Top-level option-source binding (alternative to `options:`): `column`, `query`, `dimension` (MetricFlow), `measure` (MetricFlow), `model` (dbt).
930
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
+
931
933
  Enabled/disabled forms (`enabled: false` means the control is inactive):
932
934
 
933
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
@@ -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
@@ -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>
@@ -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:
@@ -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
 
@@ -100,8 +134,12 @@ class ExecuteQueryArgs(BaseModel):
100
134
  ...,
101
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.",
102
136
  )
103
- variables: dict[str, Any] | None = Field(
104
- 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
+ ),
105
143
  )
106
144
  source: str | None = Field(None, description="Data source name to execute against")
107
145
  limit: int | None = Field(None, description="Maximum rows to return (default 50)")
@@ -118,6 +156,12 @@ class ExecuteQueryArgs(BaseModel):
118
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.",
119
157
  )
120
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
164
+
121
165
 
122
166
  class ExecuteQueryResult(BaseModel):
123
167
  model_config = ConfigDict(frozen=True)
@@ -242,8 +286,12 @@ class QueryFaceArgs(BaseModel):
242
286
  ..., description="Name of the query inside the face's `queries:` block"
243
287
  )
244
288
  path: Path = Field(..., description="Path to the face YAML file")
245
- vars: dict[str, Any] | None = Field(
246
- 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
+ ),
247
295
  )
248
296
  limit: int = Field(
249
297
  20, ge=1, le=1000, description="Max rows to return (default 20, max 1000)"
@@ -251,6 +299,12 @@ class QueryFaceArgs(BaseModel):
251
299
 
252
300
  model_config = ConfigDict(extra="forbid")
253
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
+
254
308
 
255
309
  class QueryFaceResult(BaseModel):
256
310
  model_config = ConfigDict(frozen=True)
@@ -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):
@@ -18,7 +18,10 @@ from dataface.agent_api import (
18
18
  skills as _skills,
19
19
  )
20
20
  from dataface.agent_api._paths import resolve_face_or_error
21
- from dataface.agent_api.query import ExecuteQueryArgs as _ExecuteQueryArgs
21
+ from dataface.agent_api.query import (
22
+ ExecuteQueryArgs as _ExecuteQueryArgs,
23
+ variables_to_dict as _vars_to_dict,
24
+ )
22
25
  from dataface.agent_api.validate import (
23
26
  ValidateDashboardArgs as _ValidateDashboardArgs,
24
27
  validate_dashboard as _validate_dashboard_func,
@@ -105,7 +108,9 @@ def _handle_render(args: dict[str, Any], ctx: DatafaceAIContext) -> dict[str, An
105
108
  return ctx.project_session.render_dashboard(
106
109
  face=face,
107
110
  chart=parsed.chart,
108
- variables=parsed.variables,
111
+ variables=(
112
+ _vars_to_dict(parsed.variables) if parsed.variables is not None else None
113
+ ),
109
114
  format=parsed.format or "json",
110
115
  as_link=parsed.as_link,
111
116
  server_port=ctx.server_port,
@@ -117,7 +122,9 @@ def _handle_query(args: dict[str, Any], ctx: DatafaceAIContext) -> dict[str, Any
117
122
  parsed = _ExecuteQueryArgs.model_validate(args)
118
123
  return _query.execute_query(
119
124
  sql=parsed.sql,
120
- variables=parsed.variables,
125
+ variables=(
126
+ _vars_to_dict(parsed.variables) if parsed.variables is not None else None
127
+ ),
121
128
  source=parsed.source or ctx.default_source,
122
129
  limit=parsed.limit or 50,
123
130
  adapter_registry=ctx.project_session.adapter_registry,
@@ -141,7 +148,7 @@ def _handle_query_face(args: dict[str, Any], ctx: DatafaceAIContext) -> dict[str
141
148
  name=parsed.name,
142
149
  path=parsed.path,
143
150
  project=ctx.project_session.project,
144
- vars=parsed.vars,
151
+ vars=_vars_to_dict(parsed.vars) if parsed.vars is not None else None,
145
152
  limit=parsed.limit,
146
153
  adapter_registry=ctx.project_session.adapter_registry,
147
154
  ).model_dump(mode="json", exclude_none=True)
@@ -1,4 +1,4 @@
1
- """board emit / board render commands — thin wrappers over dataface.agent_api.board_artifact."""
1
+ """artifact emit / artifact render commands — thin wrappers over dataface.agent_api.board_artifact."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -70,6 +70,7 @@ def install_extension(editor: str, emit: Callable[[str], None] = print) -> int:
70
70
  [binary, "--install-extension", str(vsix_path)],
71
71
  capture_output=True,
72
72
  text=True,
73
+ encoding="utf-8",
73
74
  timeout=60,
74
75
  )
75
76
  except subprocess.TimeoutExpired:
@@ -32,14 +32,11 @@ def serve_command(
32
32
  """Start unified Dataface server.
33
33
 
34
34
  This command starts a FastAPI server that serves:
35
- - Profile templates at /profile/{template}/?model=X&column=Y
36
35
  - Any face file at /{path}/?var=value (path.yml → /path/)
37
36
  - Health check at /health
38
37
 
39
38
  Example URLs:
40
- http://localhost:9876/health
41
- http://localhost:9876/profile/model/?model=fct_orders&theme=dark
42
- http://localhost:9876/profile/numeric_column/?model=fct_orders&column=revenue
39
+ http://localhost:9876/
43
40
  http://localhost:9876/sales/?region=West
44
41
 
45
42
  Args:
@@ -94,17 +91,11 @@ def serve_command(
94
91
  port = setup.port
95
92
  app = setup.app
96
93
 
97
- # Start server
98
- typer.echo("🚀 Starting dashboard server")
94
+ # Start server. The URL is the whole banner: it lists every face in the
95
+ # project, so any other route printed here is a second way to say the same
96
+ # thing — or, once a route is renamed, a link that 404s.
97
+ typer.echo("📈 Starting dashboard server")
99
98
  typer.echo(f" URL: http://{host}:{port}")
100
- typer.echo(f" Health: http://{host}:{port}/health")
101
- typer.echo("")
102
- typer.echo(" Routes:")
103
- typer.echo(f" http://{host}:{port}/profile/model/?model=<table>")
104
- typer.echo(
105
- f" http://{host}:{port}/profile/numeric_column/?model=<table>&column=<col>"
106
- )
107
- typer.echo(f" http://{host}:{port}/<name>/?var=value")
108
99
  typer.echo("")
109
100
  typer.echo(" Press CTRL+C to stop")
110
101
 
@@ -122,8 +122,7 @@ def run_init_skills(
122
122
  typer.echo(f" ✓ {name}")
123
123
  elif result.skipped_existing:
124
124
  typer.echo(
125
- f" Workflow skills unchanged ({len(result.skipped_existing)})"
126
- f" → {rel}/"
125
+ f" Workflow skills unchanged ({len(result.skipped_existing)}) → {rel}/"
127
126
  )
128
127
  if result.retired_removed:
129
128
  typer.echo(f" Removed retired: {', '.join(result.retired_removed)}")
dataface/cli/main.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Main CLI entry point."""
2
2
 
3
3
  import importlib.util
4
+ import io
4
5
  import logging
5
6
  import sys
6
7
  from pathlib import Path
@@ -71,9 +72,9 @@ inspect_app = typer.Typer(
71
72
  rich_markup_mode=_RICH_MARKUP_MODE,
72
73
  )
73
74
 
74
- # Board subcommand app — `dft board emit` / `dft board render`
75
- board_app = typer.Typer(
76
- name="board",
75
+ # Artifact subcommand app — `dft artifact emit` / `dft artifact render`
76
+ artifact_app = typer.Typer(
77
+ name="artifact",
77
78
  help="Emit a resolved-board artifact + recording, and render one back.",
78
79
  no_args_is_help=True,
79
80
  pretty_exceptions_show_locals=False,
@@ -632,7 +633,7 @@ def mcp_serve(
632
633
  # --- Dashboards -------------------------------------------------------------
633
634
 
634
635
 
635
- app.add_typer(board_app, name="board", rich_help_panel="Dashboards")
636
+ app.add_typer(artifact_app, name="artifact", rich_help_panel="Dashboards")
636
637
 
637
638
 
638
639
  @app.command("describe", rich_help_panel="Dashboards")
@@ -920,7 +921,7 @@ def render(
920
921
  )
921
922
 
922
923
 
923
- @board_app.command("emit")
924
+ @artifact_app.command("emit")
924
925
  def emit_board(
925
926
  face: Annotated[
926
927
  Path,
@@ -965,14 +966,14 @@ def emit_board(
965
966
 
966
967
  The artifact is the resolved board's published, data-free contract; the
967
968
  recording is a separate sidecar carrying the rows it was resolved
968
- against. Render the pair back with `dft board render` — no compile step,
969
+ against. Render the pair back with `dft artifact render` — no compile step,
969
970
  no warehouse connection.
970
971
 
971
972
  \b
972
973
  Examples:
973
- dft board emit faces/sales.yml
974
- dft board emit faces/sales.yml --artifact out/sales.board.json --recording out/sales.recording.json
975
- dft board emit faces/sales.yml --var region=West
974
+ dft artifact emit faces/sales.yml
975
+ dft artifact emit faces/sales.yml --artifact out/sales.board.json --recording out/sales.recording.json
976
+ dft artifact emit faces/sales.yml --var region=West
976
977
  """
977
978
  board_artifact_cmd.emit_board_command(
978
979
  face,
@@ -985,7 +986,7 @@ def emit_board(
985
986
  )
986
987
 
987
988
 
988
- @board_app.command("render")
989
+ @artifact_app.command("render")
989
990
  def render_board(
990
991
  artifact: Annotated[Path, typer.Argument(help="Path to a board artifact file.")],
991
992
  recording: Annotated[
@@ -1001,14 +1002,14 @@ def render_board(
1001
1002
  ) -> None:
1002
1003
  """Render a board from its artifact + recording — no compile, no warehouse.
1003
1004
 
1004
- Both files come from `dft board emit`. A recording missing a query, or
1005
+ Both files come from `dft artifact emit`. A recording missing a query, or
1005
1006
  captured under different variable values than the artifact expects,
1006
1007
  fails loudly rather than rendering an empty or wrong chart.
1007
1008
 
1008
1009
  \b
1009
1010
  Examples:
1010
- dft board render out/sales.board.json out/sales.recording.json
1011
- dft board render out/sales.board.json out/sales.recording.json --output sales.svg
1011
+ dft artifact render out/sales.board.json out/sales.recording.json
1012
+ dft artifact render out/sales.board.json out/sales.recording.json --output sales.svg
1012
1013
  """
1013
1014
  board_artifact_cmd.render_board_command(
1014
1015
  artifact,
@@ -1464,6 +1465,20 @@ for _ep in _entry_points(group="dataface.cli_plugins"):
1464
1465
  _plugin()
1465
1466
 
1466
1467
 
1468
+ def _reconfigure_console_encoding() -> None:
1469
+ """Force UTF-8 on stdout/stderr so non-ASCII diagnostics never crash.
1470
+
1471
+ Windows consoles default to the legacy cp1252 codepage; printing
1472
+ non-ASCII text (e.g. an arrow in an error message) would otherwise raise
1473
+ UnicodeEncodeError while reporting the original error. No-op on
1474
+ Linux/macOS, where the streams are already UTF-8. Streams without
1475
+ reconfigure() — e.g. one swapped in under test capture — are left alone.
1476
+ """
1477
+ for stream in (sys.stdout, sys.stderr):
1478
+ if isinstance(stream, io.TextIOWrapper):
1479
+ stream.reconfigure(encoding="utf-8")
1480
+
1481
+
1467
1482
  def version_callback(value: bool) -> None:
1468
1483
  """Print version and exit."""
1469
1484
  if value:
@@ -1486,6 +1501,7 @@ def main(
1486
1501
  ] = None,
1487
1502
  ) -> None:
1488
1503
  """dft - Declarative, dbt-native dashboards in YAML."""
1504
+ _reconfigure_console_encoding()
1489
1505
  # Runs before any subcommand, so every warehouse query this process sends is
1490
1506
  # attributed to the CLI. `dft serve` narrows it to "serve" in its own command.
1491
1507
  set_surface("cli")