dataface 0.1.4.dev6__py3-none-any.whl → 0.1.5.dev48__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 (65) hide show
  1. dataface/DATAFACE_SYNTAX.md +14 -1
  2. dataface/agent_api/_init_templates/{index.md → README.md} +2 -2
  3. dataface/agent_api/_project_agents_md.py +1 -1
  4. dataface/agent_api/_session_store.py +3 -3
  5. dataface/agent_api/dashboards.py +2 -2
  6. dataface/agent_api/docs/yaml-reference.md +40 -3
  7. dataface/agent_api/init.py +24 -11
  8. dataface/agent_api/inspect.py +2 -2
  9. dataface/agent_api/mcp_install.py +4 -4
  10. dataface/agent_api/pack.py +4 -2
  11. dataface/agent_api/schema.py +1 -1
  12. dataface/agent_api/search.py +1 -1
  13. dataface/agent_api/surface_aliases.yaml +1 -1
  14. dataface/ai/external_mcp.py +2 -2
  15. dataface/ai/memories.py +1 -1
  16. dataface/ai/prompts.py +3 -3
  17. dataface/ai/skills/dataface-troubleshooting/SKILL.md +4 -1
  18. dataface/cli/commands/chat.py +1 -1
  19. dataface/cli/commands/init.py +14 -4
  20. dataface/cli/commands/query.py +1 -1
  21. dataface/cli/main.py +10 -3
  22. dataface/core/compile/compiler.py +8 -9
  23. dataface/core/compile/markdown.py +29 -35
  24. dataface/core/compile/models/face/authored.py +6 -2
  25. dataface/core/compile/models/face/compiled.py +18 -0
  26. dataface/core/compile/models/primitives.py +7 -7
  27. dataface/core/compile/models/source.py +10 -2
  28. dataface/core/compile/models/style/compiled.py +218 -116
  29. dataface/core/compile/models/style/merged.py +85 -16
  30. dataface/core/compile/normalize_layout.py +22 -3
  31. dataface/core/compile/normalize_queries.py +6 -5
  32. dataface/core/compile/normalizer.py +9 -5
  33. dataface/core/compile/sources.py +35 -26
  34. dataface/core/dashboard.py +3 -0
  35. dataface/core/defaults/themes/_base.yaml +26 -1
  36. dataface/core/defaults/themes/cream.yaml +11 -0
  37. dataface/core/defaults/themes/editorial.yaml +11 -0
  38. dataface/core/defaults/themes/stark.yaml +12 -0
  39. dataface/core/errors/codes_compile.py +16 -0
  40. dataface/core/execute/adapters/dbt_adapter.py +39 -22
  41. dataface/core/execute/adapters/sql_adapter.py +62 -9
  42. dataface/core/execute/duckdb_cache.py +32 -68
  43. dataface/core/inspect/dbt_schema.py +1 -1
  44. dataface/core/inspect/manifest_utils.py +4 -2
  45. dataface/core/pack/proposal_store.py +6 -3
  46. dataface/core/project_roots.py +86 -12
  47. dataface/core/render/chart_interactivity.py +39 -1
  48. dataface/core/render/control_registry.py +6 -4
  49. dataface/core/render/converters/html.py +1 -1
  50. dataface/core/render/dir_context.py +267 -0
  51. dataface/core/render/face_api.py +8 -5
  52. dataface/core/render/renderer.py +22 -4
  53. dataface/core/render/template_loader.py +1 -1
  54. dataface/core/render/templates/controls/_styles.css +4 -0
  55. dataface/core/render/templates/scripts/chart_interactivity.js +15 -16
  56. dataface/core/render/variable_controls.py +1 -1
  57. dataface/core/resolve_face.py +10 -10
  58. dataface/core/serve/port.py +1 -1
  59. dataface/core/serve/server.py +26 -16
  60. {dataface-0.1.4.dev6.dist-info → dataface-0.1.5.dev48.dist-info}/METADATA +50 -18
  61. {dataface-0.1.4.dev6.dist-info → dataface-0.1.5.dev48.dist-info}/RECORD +65 -64
  62. {dataface-0.1.4.dev6.dist-info → dataface-0.1.5.dev48.dist-info}/WHEEL +1 -1
  63. /dataface/agent_api/_init_templates/{faces-dataface.yml → guide.yml} +0 -0
  64. {dataface-0.1.4.dev6.dist-info → dataface-0.1.5.dev48.dist-info}/entry_points.txt +0 -0
  65. {dataface-0.1.4.dev6.dist-info → dataface-0.1.5.dev48.dist-info}/licenses/LICENSE +0 -0
@@ -1113,17 +1113,30 @@ Active code categories (see `dataface/core/errors/codes_*.py` for the authoritat
1113
1113
 
1114
1114
  | Prefix | Meaning |
1115
1115
  |--------|---------|
1116
+ | `DF-COMPILE-*` | Compile-time errors (missing fields, unknown references, malformed YAML) |
1116
1117
  | `DF-RENDER-*` | Chart rendering errors (wrong row counts, unknown chart types, format issues) |
1117
1118
  | `DF-EXECUTE-*` | Query execution errors (missing sources, inline-source policy, source typing) |
1118
1119
  | `DF-UNKNOWN-*` | Fallback codes for legacy string-message errors not yet migrated |
1119
1120
 
1120
- The registry is being filled out incrementally — many compile-time and variable-resolution errors still raise as plain `DatafaceError` without a structured code. Today's typed codes:
1121
+ The registry is being filled out incrementally — some compile-time and variable-resolution errors still raise as plain `DatafaceError` without a structured code. Today's typed codes:
1122
+
1123
+ ### Compile errors
1124
+
1125
+ - **`DF-COMPILE-SOURCE-REQUIRED`** — a SQL query has no `source:` and no default is configured. Set the source on the query (`source: my_db`), at the face level (`source:` or `sources.default`), or project-wide via `sources.default` in `dataface.yml`.
1126
+ - **`DF-COMPILE-UNKNOWN-QUERY`** — a chart's `query:` references a name that does not appear under `queries:`. Check for a typo or add the missing query.
1127
+ - **`DF-COMPILE-EXTRA-FIELD`** — the face YAML contains a field not recognised by the schema. Remove it or check the schema for supported keys.
1128
+ - **`DF-COMPILE-SQL-LITERAL-NEWLINES`** — a SQL field contains a literal `\n` (backslash + n) from single-quoted YAML. Use a YAML block scalar (`sql: |`) to write multiline SQL.
1129
+
1130
+ ### Render errors
1121
1131
 
1122
1132
  - **`DF-RENDER-KPI-MULTIROW`** — a KPI chart's query returned more than one row but `value` is a column reference. Add `LIMIT 1` or aggregate down to one row.
1123
1133
  - **`DF-RENDER-UNKNOWN-CHART-TYPE`** — `type:` is not a recognized chart type. See the [Charts](#charts) section for the list.
1124
1134
  - **`DF-RENDER-FORMAT-UNSUPPORTED`** — the render `format` argument is not one of `svg`/`html`/`png`.
1125
1135
  - **`DF-RENDER-NO-LAYOUT`** — the face has no `rows`/`cols`/`grid`/`tabs` and nothing to render.
1126
1136
  - **`DF-RENDER-INPUT-INVALID`** — the render call received structurally invalid input (e.g. both `path` and `yaml_content`).
1137
+
1138
+ ### Execute errors
1139
+
1127
1140
  - **`DF-EXECUTE-SOURCE-NOT-FOUND`** / **`-NOT-FOUND-EMPTY`** — the query's `source:` (or default source) is not configured in `dataface.yml`.
1128
1141
  - **`DF-EXECUTE-NO-DEFAULT-SOURCE`** — multiple sources are defined and no `default_source` was set.
1129
1142
  - **`DF-EXECUTE-SOURCE-INLINE-FORBIDDEN`** / **`-CROSS-FILE-FORBIDDEN`** — inline source definitions are restricted by project policy.
@@ -11,14 +11,14 @@ for your data project.
11
11
  ## Authoring modes
12
12
 
13
13
  **YAML (`.yml`)** — structured dashboards with queries, charts, and layout.
14
- See the [dataface guide](dataface) for a working example covering queries, charts, and variables.
14
+ See the [dataface guide](guide) for a working example covering queries, charts, and variables.
15
15
 
16
16
  **Markdown (`.md`)** — prose pages like this one. Add YAML frontmatter for
17
17
  queries and charts, then embed them inline with `{% raw %}{{ chart my_chart }}{% endraw %}`.
18
18
 
19
19
  ## Next steps
20
20
 
21
- 1. Open `faces/dataface.yml` and tweak the content.
21
+ 1. Open `faces/guide.yml` and tweak the content.
22
22
  2. Run `dft serve` and open the URL it prints.
23
23
  3. Add new `.yml` or `.md` files under `faces/` — they appear automatically.
24
24
  4. Run `dft validate` to validate your face YAML for errors.
@@ -15,7 +15,7 @@ _TEMPLATES = importlib.resources.files("dataface.agent_api._init_templates")
15
15
 
16
16
  def load_agents_snippet() -> str:
17
17
  """Return the packaged markdown blurb (includes marker comments)."""
18
- return _TEMPLATES.joinpath("agents_dft_snippet.md").read_text()
18
+ return _TEMPLATES.joinpath("agents_dft_snippet.md").read_text(encoding="utf-8")
19
19
 
20
20
 
21
21
  def has_dataface_markers(text: str) -> bool:
@@ -198,7 +198,7 @@ class SessionIndex:
198
198
  def _load(self) -> dict[str, list[SessionMeta]]:
199
199
  if self._path.exists():
200
200
  try:
201
- raw = json.loads(self._path.read_text())
201
+ raw = json.loads(self._path.read_text(encoding="utf-8"))
202
202
  if isinstance(raw, dict):
203
203
  return raw
204
204
  logger.warning("sessions/index.json has unexpected shape; rebuilding")
@@ -208,7 +208,7 @@ class SessionIndex:
208
208
 
209
209
  def _save(self) -> None:
210
210
  self._path.parent.mkdir(parents=True, exist_ok=True)
211
- self._path.write_text(json.dumps(self._data, indent=2))
211
+ self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
212
212
 
213
213
  def _rebuild(self) -> dict[str, list[SessionMeta]]:
214
214
  """Scan all JSONL files under ~/.dft/sessions/ and reconstruct the index."""
@@ -234,7 +234,7 @@ class SessionIndex:
234
234
  data.setdefault(cwd, []).append(entry)
235
235
  # Persist the rebuilt index
236
236
  self._path.parent.mkdir(parents=True, exist_ok=True)
237
- self._path.write_text(json.dumps(data, indent=2))
237
+ self._path.write_text(json.dumps(data, indent=2), encoding="utf-8")
238
238
  return data
239
239
 
240
240
 
@@ -148,7 +148,7 @@ def list_dashboards(
148
148
  if yaml_file.name.startswith("_"):
149
149
  continue
150
150
  try:
151
- content = yaml.safe_load(yaml_file.read_text())
151
+ content = yaml.safe_load(yaml_file.read_text(encoding="utf-8"))
152
152
  if not isinstance(content, dict):
153
153
  skipped.append(
154
154
  SkippedFile(
@@ -235,7 +235,7 @@ def get_dashboard(
235
235
  )
236
236
 
237
237
  try:
238
- raw_yaml = file_path.read_text()
238
+ raw_yaml = file_path.read_text(encoding="utf-8")
239
239
  except OSError as e:
240
240
  return CompiledDashboard(
241
241
  success=False,
@@ -15,7 +15,7 @@ AuthoredFace (dataface) definition from YAML.
15
15
  | `source` | str | ✓ | Default source name shorthand (equivalent to sources.default). Sets the connection for all queries. |
16
16
  | `variables` | dict[str, [Variable](#variable) \| VariableRef] | ✓ | Variable definitions for dynamic filtering and UI controls. |
17
17
  | `queries` | dict[str, [Query](#query) \| QueryRef] | ✓ | Named query definitions (SQL, CSV, MetricFlow, HTTP, etc.). |
18
- | `charts` | dict[str, [BarChart](#barchart) \| [LineChart](#linechart) \| [AreaChart](#areachart) \| [ScatterChart](#scatterchart) \| [HeatmapChart](#heatmapchart) \| [PieChart](#piechart) \| [KpiChart](#kpichart) \| [TableChart](#tablechart) \| [PointMapChart](#pointmapchart) \| [GeoshapeChart](#geoshapechart) \| [LayeredChart](#layeredchart) \| [CalloutChart](#calloutchart) \| [SparkBarChart](#sparkbarchart) \| ChartRef] | ✓ | Named chart definitions referenced in the layout. |
18
+ | `charts` | dict[str, [BarChart](#barchart) \| [LineChart](#linechart) \| [AreaChart](#areachart) \| [ScatterChart](#scatterchart) \| [HeatmapChart](#heatmapchart) \| [PieChart](#piechart) \| [KpiChart](#kpichart) \| [TableChart](#tablechart) \| [PointMapChart](#pointmapchart) \| [GeoshapeChart](#geoshapechart) \| [LayeredChart](#layeredchart) \| [CalloutChart](#calloutchart) \| [SparkBarChart](#sparkbarchart) \| ChartRef] | ✓ | Named chart definitions. When no explicit layout is present, charts render as an implicit row layout in authored order. |
19
19
  | `rows` | list[str \| [Face](#face) \| [BarChart](#barchart) \| [LineChart](#linechart) \| [AreaChart](#areachart) \| [ScatterChart](#scatterchart) \| [HeatmapChart](#heatmapchart) \| [PieChart](#piechart) \| [KpiChart](#kpichart) \| [TableChart](#tablechart) \| [PointMapChart](#pointmapchart) \| [GeoshapeChart](#geoshapechart) \| [LayeredChart](#layeredchart) \| [CalloutChart](#calloutchart) \| [SparkBarChart](#sparkbarchart) \| [ForeachItem](#foreachitem) \| dict[str, [BarChart](#barchart) \| [LineChart](#linechart) \| [AreaChart](#areachart) \| [ScatterChart](#scatterchart) \| [HeatmapChart](#heatmapchart) \| [PieChart](#piechart) \| [KpiChart](#kpichart) \| [TableChart](#tablechart) \| [PointMapChart](#pointmapchart) \| [GeoshapeChart](#geoshapechart) \| [LayeredChart](#layeredchart) \| [CalloutChart](#calloutchart) \| [SparkBarChart](#sparkbarchart)]] | ✓ | Vertical stack layout: list of chart names or inline chart/face definitions. |
20
20
  | `cols` | list[str \| [Face](#face) \| [BarChart](#barchart) \| [LineChart](#linechart) \| [AreaChart](#areachart) \| [ScatterChart](#scatterchart) \| [HeatmapChart](#heatmapchart) \| [PieChart](#piechart) \| [KpiChart](#kpichart) \| [TableChart](#tablechart) \| [PointMapChart](#pointmapchart) \| [GeoshapeChart](#geoshapechart) \| [LayeredChart](#layeredchart) \| [CalloutChart](#calloutchart) \| [SparkBarChart](#sparkbarchart) \| [ForeachItem](#foreachitem) \| dict[str, [BarChart](#barchart) \| [LineChart](#linechart) \| [AreaChart](#areachart) \| [ScatterChart](#scatterchart) \| [HeatmapChart](#heatmapchart) \| [PieChart](#piechart) \| [KpiChart](#kpichart) \| [TableChart](#tablechart) \| [PointMapChart](#pointmapchart) \| [GeoshapeChart](#geoshapechart) \| [LayeredChart](#layeredchart) \| [CalloutChart](#calloutchart) \| [SparkBarChart](#sparkbarchart)]] | ✓ | Horizontal layout: list of chart names or inline chart/face definitions. |
21
21
  | `grid` | [GridLayout](#gridlayout) | ✓ | CSS-grid style layout with explicit row/column placement. |
@@ -646,13 +646,14 @@ HTTP/REST API source configuration.
646
646
 
647
647
  <a id="dbtprofilesourceconfig"></a>
648
648
  ## DbtProfileSourceConfig
649
- Reference to a dbt profile (for backward compatibility).
649
+ Reference to a dbt profile.
650
650
 
651
651
  | Field | Type | Optional | Description |
652
652
  |-------|------|:--------:|-------------|
653
653
  | `type` | enum: "dbt_profile" | | Source type identifier. |
654
654
  | `profile` | str | | dbt profile name from profiles.yml. |
655
655
  | `target` | str | ✓ | dbt target to use; defaults to the profile's default target. |
656
+ | `profiles_dir` | str | ✓ | Directory containing profiles.yml, relative to the dataface project root. Use when profiles.yml is in a subdirectory (e.g. services/dbt). Resolution order: profiles_dir → $DBT_PROFILES_DIR → project root → ~/.dbt. |
656
657
 
657
658
  <a id="variableoptions"></a>
658
659
  ## VariableOptions
@@ -1485,11 +1486,21 @@ Authored overlay for LegendStyle.
1485
1486
 
1486
1487
  <a id="tooltipstyle"></a>
1487
1488
  ## TooltipStyle
1488
- Authored overlay for TooltipStyle. Tooltip rendering defaults.
1489
+ Authored overlay for TooltipStyle. Tooltip box style — all cascade keys for the hover bubble.
1489
1490
 
1490
1491
  | Field | Type | Optional | Description |
1491
1492
  |-------|------|:--------:|-------------|
1492
1493
  | `format` | str | ✓ | Default tooltip value format string; theme always provides this. |
1494
+ | `background` | str | ✓ | Tooltip background color (CSS color string); theme always provides this. |
1495
+ | `line_height` | float | ✓ | Tooltip line-height multiplier; theme always provides this. |
1496
+ | `max_width` | float | ✓ | Maximum tooltip width in pixels; theme always provides this. |
1497
+ | `gap` | float | ✓ | Gap in pixels between label and value columns; theme always provides this. |
1498
+ | `font` | [FontStyle](#fontstyle) | ✓ | Tooltip font overrides (size etc.); cascade fills missing fields. |
1499
+ | `padding` | [PaddingStyle](#paddingstyle) | ✓ | Tooltip inner padding (4 sides in pixels); theme always provides this. |
1500
+ | `label` | [TooltipSlotStyle](#tooltipslotstyle) | ✓ | Label-column font overrides (color, weight). |
1501
+ | `value` | [TooltipSlotStyle](#tooltipslotstyle) | ✓ | Value-column font overrides (color, weight). |
1502
+ | `border` | [TooltipBorderStyle](#tooltipborderstyle) | ✓ | Tooltip border style; theme always provides this. |
1503
+ | `shadow` | [TooltipShadowStyle](#tooltipshadowstyle) | ✓ | Tooltip drop-shadow config; theme always provides this. |
1493
1504
 
1494
1505
  <a id="stylecolorconfig"></a>
1495
1506
  ## StyleColorConfig
@@ -2266,6 +2277,32 @@ Authored overlay for LegendElementStyle.
2266
2277
  | `font` | [FontStyle](#fontstyle) | ✓ | Legend element font style overrides. |
2267
2278
  | `padding` | float | ✓ | Padding between legend symbol and element text in pixels. |
2268
2279
 
2280
+ <a id="tooltipslotstyle"></a>
2281
+ ## TooltipSlotStyle
2282
+ Authored overlay for TooltipSlotStyle. Typography for a single tooltip slot (label or value).
2283
+
2284
+ | Field | Type | Optional | Description |
2285
+ |-------|------|:--------:|-------------|
2286
+ | `font` | [FontStyle](#fontstyle) | ✓ | Font overrides for this tooltip slot (color, weight). |
2287
+
2288
+ <a id="tooltipborderstyle"></a>
2289
+ ## TooltipBorderStyle
2290
+ Authored overlay for TooltipBorderStyle. Tooltip box border — all fields required; theme YAML supplies defaults.
2291
+
2292
+ | Field | Type | Optional | Description |
2293
+ |-------|------|:--------:|-------------|
2294
+ | `color` | str | ✓ | Border color as a CSS color string. |
2295
+ | `width` | float | ✓ | Border width in pixels. |
2296
+ | `radius` | float | ✓ | Border corner radius in pixels. |
2297
+
2298
+ <a id="tooltipshadowstyle"></a>
2299
+ ## TooltipShadowStyle
2300
+ Authored overlay for TooltipShadowStyle. Tooltip drop-shadow toggle. JS applies the shadow expression when visible=true.
2301
+
2302
+ | Field | Type | Optional | Description |
2303
+ |-------|------|:--------:|-------------|
2304
+ | `visible` | bool | ✓ | Show a drop-shadow on the tooltip box; theme always provides this. |
2305
+
2269
2306
  <a id="scaletargetconfig"></a>
2270
2307
  ## ScaleTargetConfig
2271
2308
  Scale configuration for a single style target (background or color).
@@ -51,9 +51,18 @@ def init_project(
51
51
  (root / "faces" / "partials").mkdir(exist_ok=True)
52
52
 
53
53
  scaffolds: list[tuple[str, str]] = [
54
- ("dataface.yml", _TEMPLATES.joinpath("dataface.yml").read_text()),
55
- ("faces/index.md", _TEMPLATES.joinpath("index.md").read_text()),
56
- ("faces/dataface.yml", _TEMPLATES.joinpath("faces-dataface.yml").read_text()),
54
+ (
55
+ "dataface.yml",
56
+ _TEMPLATES.joinpath("dataface.yml").read_text(encoding="utf-8"),
57
+ ),
58
+ (
59
+ "faces/README.md",
60
+ _TEMPLATES.joinpath("README.md").read_text(encoding="utf-8"),
61
+ ),
62
+ (
63
+ "faces/guide.yml",
64
+ _TEMPLATES.joinpath("guide.yml").read_text(encoding="utf-8"),
65
+ ),
57
66
  ("faces/partials/.gitkeep", ""),
58
67
  ]
59
68
 
@@ -62,10 +71,10 @@ def init_project(
62
71
  if target.exists() and not force:
63
72
  result.skipped_files.append(Path(rel))
64
73
  elif target.exists():
65
- target.write_text(content)
74
+ target.write_text(content, encoding="utf-8")
66
75
  result.refreshed_files.append(Path(rel))
67
76
  else:
68
- target.write_text(content)
77
+ target.write_text(content, encoding="utf-8")
69
78
  result.created_files.append(Path(rel))
70
79
 
71
80
  _ensure_gitignore_entries(root, result)
@@ -74,10 +83,12 @@ def init_project(
74
83
  snippet = load_agents_snippet()
75
84
  agents_md_path = root / "AGENTS.md"
76
85
  existing: str | None = (
77
- agents_md_path.read_text() if agents_md_path.exists() else None
86
+ agents_md_path.read_text(encoding="utf-8")
87
+ if agents_md_path.exists()
88
+ else None
78
89
  )
79
90
  final_text, action = merge_agents_snippet(existing, snippet)
80
- agents_md_path.write_text(final_text)
91
+ agents_md_path.write_text(final_text, encoding="utf-8")
81
92
  if action == "created":
82
93
  result.created_files.append(Path("AGENTS.md"))
83
94
  elif action == "refreshed":
@@ -88,7 +99,7 @@ def init_project(
88
99
  if write_claude_md:
89
100
  claude_md_path = root / "CLAUDE.md"
90
101
  if not claude_md_path.exists():
91
- claude_md_path.write_text("@AGENTS.md\n")
102
+ claude_md_path.write_text("@AGENTS.md\n", encoding="utf-8")
92
103
  result.created_files.append(Path("CLAUDE.md"))
93
104
 
94
105
  if eject_inspect:
@@ -111,16 +122,18 @@ def init_project(
111
122
  def _ensure_gitignore_entries(root: Path, result: InitResult) -> None:
112
123
  gitignore = root / ".gitignore"
113
124
  if not gitignore.exists():
114
- gitignore.write_text("\n".join(GITIGNORE_ENTRIES) + "\n")
125
+ gitignore.write_text("\n".join(GITIGNORE_ENTRIES) + "\n", encoding="utf-8")
115
126
  result.created_files.append(Path(".gitignore"))
116
127
  return
117
128
 
118
- text = gitignore.read_text()
129
+ text = gitignore.read_text(encoding="utf-8")
119
130
  existing = set(text.splitlines())
120
131
  missing = [e for e in GITIGNORE_ENTRIES if e not in existing]
121
132
  if not missing:
122
133
  return
123
134
 
124
135
  separator = "" if not text or text.endswith("\n") else "\n"
125
- gitignore.write_text(f"{text}{separator}" + "\n".join(missing) + "\n")
136
+ gitignore.write_text(
137
+ f"{text}{separator}" + "\n".join(missing) + "\n", encoding="utf-8"
138
+ )
126
139
  result.refreshed_files.append(Path(".gitignore"))
@@ -78,9 +78,9 @@ def eject_templates(
78
78
  if target_file.exists() and not force:
79
79
  continue
80
80
 
81
- content = source_file.read_text()
81
+ content = source_file.read_text(encoding="utf-8")
82
82
  source_hash = sha256(content.encode("utf-8")).hexdigest()
83
- target_file.write_text(content)
83
+ target_file.write_text(content, encoding="utf-8")
84
84
 
85
85
  manifest_templates[name] = {
86
86
  "filename": f"{name}.yml",
@@ -126,7 +126,7 @@ def _upsert_mcp_config(
126
126
  existing: dict[str, Any] = {}
127
127
  if config_path.exists():
128
128
  try:
129
- existing = json.loads(config_path.read_text())
129
+ existing = json.loads(config_path.read_text(encoding="utf-8"))
130
130
  if not isinstance(existing, dict):
131
131
  existing = {}
132
132
  except (json.JSONDecodeError, OSError):
@@ -140,7 +140,7 @@ def _upsert_mcp_config(
140
140
 
141
141
  existing.setdefault(servers_key, {})
142
142
  existing[servers_key]["dataface"] = server_entry
143
- config_path.write_text(json.dumps(existing, indent=2) + "\n")
143
+ config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
144
144
  return f" {'Updated' if already_has else 'Added dataface to'} {config_path}"
145
145
 
146
146
 
@@ -158,7 +158,7 @@ def _upsert_toml_mcp_config(
158
158
 
159
159
  existing: dict[str, Any] = {}
160
160
  if config_path.exists():
161
- existing = tomllib.loads(config_path.read_text())
161
+ existing = tomllib.loads(config_path.read_text(encoding="utf-8"))
162
162
 
163
163
  already_has = "dataface" in existing.get(servers_key, {})
164
164
  if already_has and not force:
@@ -166,5 +166,5 @@ def _upsert_toml_mcp_config(
166
166
 
167
167
  existing.setdefault(servers_key, {})
168
168
  existing[servers_key]["dataface"] = server_entry
169
- config_path.write_text(tomli_w.dumps(existing))
169
+ config_path.write_text(tomli_w.dumps(existing), encoding="utf-8")
170
170
  return f" {'Updated' if already_has else 'Added dataface to'} {config_path}"
@@ -291,7 +291,8 @@ def _write_face(path: Path, face_dict: dict) -> None:
291
291
  path.write_text(
292
292
  yaml.dump(
293
293
  face_dict, default_flow_style=False, sort_keys=False, allow_unicode=True
294
- )
294
+ ),
295
+ encoding="utf-8",
295
296
  )
296
297
 
297
298
 
@@ -417,7 +418,8 @@ def apply_proposal(
417
418
  # Write a minimal partial YAML comment block
418
419
  partial_path.write_text(
419
420
  f"# Shared partial: {partial_filename}\n"
420
- f"# Add shared query fragments, variables, or chart definitions here.\n"
421
+ f"# Add shared query fragments, variables, or chart definitions here.\n",
422
+ encoding="utf-8",
421
423
  )
422
424
  result.created_files.append(rel)
423
425
 
@@ -646,7 +646,7 @@ def _project_uses_direct_file_queries(project_root: Path) -> bool:
646
646
 
647
647
  for dashboard in list_dashboards(directory=project_root, recursive=True).dashboards:
648
648
  try:
649
- content = dashboard.absolute_path.read_text()
649
+ content = dashboard.absolute_path.read_text(encoding="utf-8")
650
650
  except OSError:
651
651
  continue
652
652
  if (
@@ -124,7 +124,7 @@ def _build_index(directory: Path) -> list[dict[str, Any]]:
124
124
  for dash in listing.dashboards:
125
125
  abs_path = dash.absolute_path
126
126
  try:
127
- content = yaml.safe_load(abs_path.read_text())
127
+ content = yaml.safe_load(abs_path.read_text(encoding="utf-8"))
128
128
  except (yaml.YAMLError, OSError):
129
129
  continue
130
130
 
@@ -39,7 +39,7 @@ render_dashboard:
39
39
 
40
40
  query_face:
41
41
  tool: "query_face"
42
- dft: "dft query --face"
42
+ dft: "dft query --in"
43
43
 
44
44
  describe_query:
45
45
  tool: "describe_query"
@@ -234,7 +234,7 @@ def _parse_json_config(
234
234
  if not path.exists():
235
235
  return
236
236
  try:
237
- data = json.loads(path.read_text())
237
+ data = json.loads(path.read_text(encoding="utf-8"))
238
238
  except json.JSONDecodeError:
239
239
  _console.print(f"[yellow]MCP: {path} has invalid JSON; skipping[/yellow]")
240
240
  return
@@ -271,7 +271,7 @@ def _parse_toml_config(
271
271
  if not path.exists():
272
272
  return
273
273
  try:
274
- data = tomllib.loads(path.read_text())
274
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
275
275
  except (tomllib.TOMLDecodeError, OSError) as exc:
276
276
  _console.print(
277
277
  f"[yellow]MCP: {path} has invalid TOML: {exc}; skipping[/yellow]"
dataface/ai/memories.py CHANGED
@@ -17,7 +17,7 @@ def load_memories(base_dir: Path | None = None) -> list[dict[str, Any]]:
17
17
  if not path.exists():
18
18
  return []
19
19
 
20
- payload = yaml.safe_load(path.read_text()) or {}
20
+ payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
21
21
  rows = payload.get("memories") if isinstance(payload, dict) else None
22
22
  if not isinstance(rows, list):
23
23
  return []
dataface/ai/prompts.py CHANGED
@@ -41,7 +41,7 @@ def load_prompt(prompt_name: str, prompts_dir: Path) -> str:
41
41
  """
42
42
  prompt_file = prompts_dir / f"{prompt_name}.md"
43
43
  if prompt_file.exists():
44
- return prompt_file.read_text()
44
+ return prompt_file.read_text(encoding="utf-8")
45
45
  return ""
46
46
 
47
47
 
@@ -94,7 +94,7 @@ class PromptLoader:
94
94
  prompt_file = self.prompts_dir / f"{prompt_name}.md"
95
95
  if not prompt_file.exists():
96
96
  raise FileNotFoundError(f"Prompt file not found: {prompt_file}")
97
- return prompt_file.read_text()
97
+ return prompt_file.read_text(encoding="utf-8")
98
98
 
99
99
  def exists(self, prompt_name: str) -> bool:
100
100
  """Check if a prompt file exists.
@@ -129,7 +129,7 @@ def load_shared_prompt(skill_name: str, *, surface: SkillSurface = "tool") -> st
129
129
  """
130
130
  skill_file = SKILLS_DIR / skill_name / "SKILL.md"
131
131
  if skill_file.exists():
132
- body = _strip_frontmatter(skill_file.read_text())
132
+ body = _strip_frontmatter(skill_file.read_text(encoding="utf-8"))
133
133
  return render_skill_body(body, surface=surface)
134
134
  return ""
135
135
 
@@ -38,7 +38,10 @@ These come from `{{ s_validate_dashboard }}`. The error message tells you exactl
38
38
 
39
39
  ### "must have at least one layout"
40
40
 
41
- Every face needs a layout element. Add `rows: []`, `cols: []`, `grid: {...}`, `tabs: {...}`, or `text: "..."`.
41
+ Every face needs visible content. Add a chart under `charts:`, an explicit
42
+ layout (`rows:`, `cols:`, `grid:`, or `tabs:`), or text/title content. If
43
+ `charts:` is present and no layout key is present, Dataface renders those
44
+ charts as implicit rows.
42
45
 
43
46
  ### "references unknown chart 'X'"
44
47
 
@@ -236,7 +236,7 @@ def _render_tool_result(name: str, result: Any, console: Console, tmpdir: Path)
236
236
  # Sanitize name to avoid path separators from LLM-provided tool names.
237
237
  safe_name = re.sub(r"[^\w-]", "_", name)
238
238
  tmp_file = tmpdir / f"dft_{safe_name}_{int(time.monotonic() * 1000)}.json"
239
- tmp_file.write_text(body)
239
+ tmp_file.write_text(body, encoding="utf-8")
240
240
  summary = f"[dim]... ({extra} more lines, full result in {tmp_file})[/dim]"
241
241
  console.print(
242
242
  Panel(
@@ -80,7 +80,7 @@ def run_wizard(
80
80
  agents_md_prompt = (
81
81
  "Create AGENTS.md at the project root with Dataface instructions?"
82
82
  )
83
- elif has_dataface_markers(agents_md_path.read_text()):
83
+ elif has_dataface_markers(agents_md_path.read_text(encoding="utf-8")):
84
84
  agents_md_prompt = "Refresh the Dataface section in AGENTS.md?"
85
85
  else:
86
86
  agents_md_prompt = "Append Dataface instructions to your existing AGENTS.md?"
@@ -137,7 +137,10 @@ def run_wizard(
137
137
  default=True,
138
138
  )
139
139
 
140
- # Playground extra — skip prompt if already installed
140
+ # Playground extra — skip prompt if already installed.
141
+ # Default is False: dataface-playground is not on public PyPI yet, so an
142
+ # unprompted install attempt would always fail for OSS users. Explicit
143
+ # --with-playground still works; failure degrades to a warning (see below).
141
144
  if not _missing_packages("playground"):
142
145
  do_with_playground = False
143
146
  else:
@@ -145,7 +148,7 @@ def run_wizard(
145
148
  with_playground,
146
149
  yes=yes,
147
150
  prompt="Install the dataface[playground] extra now (so 'dft playground' works without prompting later)?",
148
- default=True,
151
+ default=False,
149
152
  )
150
153
 
151
154
  # IDE detection — only prompt for IDEs found on PATH
@@ -238,7 +241,14 @@ def run_wizard(
238
241
  if do_with_playground:
239
242
  from dataface.cli._extras import install_extras
240
243
 
241
- install_extras("playground", interactive=False)
244
+ try:
245
+ install_extras("playground", interactive=False)
246
+ except typer.Exit:
247
+ install_warnings.append(
248
+ "playground skipped — dataface-playground is not on public PyPI yet. "
249
+ "Install it from the private registry or run `dft playground` later "
250
+ "to install interactively."
251
+ )
242
252
 
243
253
  if do_vscode:
244
254
  from dataface.cli.commands import extension as extension_cmd
@@ -186,7 +186,7 @@ def _resolve_sql_text(
186
186
  if not file.exists():
187
187
  typer.echo(f"Error: file not found: {file}", err=True)
188
188
  raise typer.Exit(1)
189
- return file.read_text(), False, None
189
+ return file.read_text(encoding="utf-8"), False, None
190
190
 
191
191
  if not target:
192
192
  typer.echo(
dataface/cli/main.py CHANGED
@@ -903,9 +903,16 @@ def serve(
903
903
  Renders your dashboards in the browser. Face file paths map
904
904
  to URLs (faces/sales.yml → /sales/). Query params become variables.
905
905
 
906
- Auto-discovers the project root by walking up from the current directory
907
- to find dataface.yml or dbt_project.yml. When a dbt project is found,
908
- the SQL dialect is inferred from the profile target.
906
+ Auto-discovers the project root by walking UP from the current directory
907
+ to find dataface.yml or dbt_project.yml. Subdirectories are not searched.
908
+ When a dbt project is found, the SQL dialect is inferred from the profile
909
+ target.
910
+
911
+ dbt profile location is resolved in this order:
912
+ 1. profiles_dir field in the dbt_profile source config (dataface.yml)
913
+ 2. DBT_PROFILES_DIR environment variable
914
+ 3. Project root (next to dataface.yml / dbt_project.yml)
915
+ 4. ~/.dbt/profiles.yml
909
916
 
910
917
  Port is auto-resolved: --port flag > DFT_PORT env var > dataface.yml
911
918
  port field > deterministic hash of project directory. If the chosen port
@@ -478,20 +478,19 @@ def compile_file(
478
478
 
479
479
  # Markdown report files: translate to YAML before compiling
480
480
  if file_path.suffix.lower() == ".md":
481
- from dataface.core.compile.markdown import is_markdown_face, markdown_to_yaml
481
+ from dataface.core.compile.markdown import (
482
+ MARKDOWN_NOT_FACE_MESSAGE,
483
+ is_markdown_face,
484
+ markdown_to_yaml,
485
+ )
482
486
 
483
487
  if not is_markdown_face(file_path):
484
488
  return CompileResult(
485
- errors=[
486
- CompilationError(
487
- "Not a Dataface face file: .md files require YAML frontmatter "
488
- "with at least one of: queries, charts, variables, source, sources"
489
- ).to_structured()
490
- ]
489
+ errors=[CompilationError(MARKDOWN_NOT_FACE_MESSAGE).to_structured()]
491
490
  )
492
491
 
493
492
  try:
494
- raw_text = file_path.read_text()
493
+ raw_text = file_path.read_text(encoding="utf-8")
495
494
  yaml_content = markdown_to_yaml(raw_text)
496
495
  except (OSError, ValueError) as e:
497
496
  return CompileResult(
@@ -499,7 +498,7 @@ def compile_file(
499
498
  )
500
499
  else:
501
500
  try:
502
- yaml_content = file_path.read_text()
501
+ yaml_content = file_path.read_text(encoding="utf-8")
503
502
  except OSError as e:
504
503
  return CompileResult(
505
504
  errors=[CompilationError(f"Failed to read file: {e}").to_structured()]