dataface 0.1.5.dev177__py3-none-any.whl → 0.1.5.dev215__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 (73) hide show
  1. dataface/DATAFACE_SYNTAX.md +6 -4
  2. dataface/agent_api/cache.py +21 -2
  3. dataface/agent_api/dashboards.py +4 -2
  4. dataface/agent_api/docs/yaml-reference.md +26 -6
  5. dataface/agent_api/files.py +262 -0
  6. dataface/agent_api/project.py +46 -39
  7. dataface/agent_api/render_face.py +7 -0
  8. dataface/agent_api/surface_aliases.yaml +4 -0
  9. dataface/ai/__init__.py +1 -2
  10. dataface/ai/agent.py +30 -10
  11. dataface/ai/generate_sql.py +9 -24
  12. dataface/ai/prompts/sql-guidance.md +8 -0
  13. dataface/ai/prompts/sql-system.md +13 -0
  14. dataface/ai/prompts/system.md +19 -0
  15. dataface/ai/prompts.py +77 -64
  16. dataface/ai/schema_context.py +39 -14
  17. dataface/ai/skills/dashboard-build/SKILL.md +5 -0
  18. dataface/ai/skills/data-exploration/SKILL.md +75 -0
  19. dataface/ai/tool_schemas.py +28 -0
  20. dataface/ai/tools/__init__.py +48 -0
  21. dataface/cli/commands/chat.py +144 -3
  22. dataface/cli/commands/query.py +92 -100
  23. dataface/cli/commands/render.py +30 -34
  24. dataface/cli/commands/serve.py +9 -4
  25. dataface/core/compile/__init__.py +5 -1
  26. dataface/core/compile/compiler.py +25 -49
  27. dataface/core/compile/config.py +39 -92
  28. dataface/core/compile/models/config.py +2 -17
  29. dataface/core/compile/models/face/authored.py +10 -0
  30. dataface/core/compile/models/face/normalized.py +10 -0
  31. dataface/core/compile/models/face/resolved.py +1 -0
  32. dataface/core/compile/models/query/authored.py +6 -8
  33. dataface/core/compile/models/query/normalized.py +13 -13
  34. dataface/core/compile/models/style/resolved.py +4 -0
  35. dataface/core/compile/models/style/theme.py +45 -2
  36. dataface/core/compile/normalize_queries.py +6 -6
  37. dataface/core/compile/normalizer.py +1 -0
  38. dataface/core/compile/sizing.py +10 -12
  39. dataface/core/dashboard.py +18 -16
  40. dataface/core/defaults/default_config.yml +2 -7
  41. dataface/core/defaults/themes/_base.yaml +10 -1
  42. dataface/core/defaults/themes/cream.yaml +3 -1
  43. dataface/core/defaults/themes/editorial.yaml +4 -1
  44. dataface/core/errors/__init__.py +6 -0
  45. dataface/core/errors/codes_serve.py +36 -0
  46. dataface/core/errors/hints.py +13 -0
  47. dataface/core/execute/adapters/__init__.py +2 -2
  48. dataface/core/execute/adapters/adapter_registry.py +23 -6
  49. dataface/core/execute/adapters/{schema_resolver_adapter.py → schema_adapter.py} +8 -8
  50. dataface/core/execute/source_registry.py +17 -43
  51. dataface/core/inspect/renderer.py +28 -21
  52. dataface/core/inspect/resolver.py +28 -1
  53. dataface/core/inspect/templates/categorical_column.yml +1 -1
  54. dataface/core/inspect/templates/date_column.yml +1 -1
  55. dataface/core/inspect/templates/model.yml +1 -1
  56. dataface/core/inspect/templates/numeric_column.yml +1 -1
  57. dataface/core/inspect/templates/quality.yml +1 -1
  58. dataface/core/inspect/templates/string_column.yml +1 -1
  59. dataface/core/pack/models.py +1 -1
  60. dataface/core/render/face_api.py +4 -2
  61. dataface/core/render/faces.py +17 -14
  62. dataface/core/render/nav.py +185 -0
  63. dataface/core/render/renderer.py +7 -4
  64. dataface/core/resolve_face.py +7 -2
  65. dataface/core/serve/bootstrap.py +22 -8
  66. dataface/core/serve/server.py +213 -10
  67. dataface/core/serve/templates/nav.yml +12 -0
  68. dataface/integrations/markdown.py +31 -9
  69. {dataface-0.1.5.dev177.dist-info → dataface-0.1.5.dev215.dist-info}/METADATA +1 -1
  70. {dataface-0.1.5.dev177.dist-info → dataface-0.1.5.dev215.dist-info}/RECORD +73 -65
  71. {dataface-0.1.5.dev177.dist-info → dataface-0.1.5.dev215.dist-info}/WHEEL +0 -0
  72. {dataface-0.1.5.dev177.dist-info → dataface-0.1.5.dev215.dist-info}/entry_points.txt +0 -0
  73. {dataface-0.1.5.dev177.dist-info → dataface-0.1.5.dev215.dist-info}/licenses/LICENSE +0 -0
@@ -346,7 +346,7 @@ queries:
346
346
  - [Bob, 87.1]
347
347
  ```
348
348
 
349
- Query types (`type:` literals): `sql`, `csv`, `http`, `dbt_model`, `metricflow`, `values`. `schema_resolver` is internal-only and not part of the authored surface.
349
+ Query types (`type:` literals): `sql`, `csv`, `http`, `dbt_model`, `metricflow`, `values`. `schema` is internal-only and not part of the authored surface.
350
350
 
351
351
  Common fields (all query types):
352
352
 
@@ -597,12 +597,14 @@ support: # Optional support line (same shape: value/label/format/
597
597
  glyph: "▲"
598
598
  tone: positive
599
599
 
600
- # table — renders all query columns unless `style.columns` selects a subset
600
+ # table — renders all query columns unless `style.columns` selects a subset.
601
+ # `columns` is a MAPPING keyed by column name (NOT a list). Omit it to show
602
+ # every query column; include it to choose a subset and/or style columns.
601
603
  type: table
602
604
  style:
603
605
  columns:
604
- - column: order_id
605
- - column: amount
606
+ order_id: {} # include with default styling
607
+ amount: # the key is the column name
606
608
  label: Amount
607
609
  format: currency_whole
608
610
  align: right # left | center | right
@@ -1,5 +1,24 @@
1
1
  """Cache constructors at the agent_api boundary."""
2
2
 
3
- from dataface.core.execute.duckdb_cache import open_cache_from_env
3
+ from collections.abc import Generator
4
+ from contextlib import contextmanager
4
5
 
5
- __all__ = ["open_cache_from_env"]
6
+ from dataface.core.execute.duckdb_cache import DuckDBCache, open_cache_from_env
7
+
8
+ __all__ = ["cache_from_env", "open_cache_from_env"]
9
+
10
+
11
+ @contextmanager
12
+ def cache_from_env() -> Generator[DuckDBCache | None]:
13
+ """Open DFT_CACHE_PATH-backed cache (or None) and close it on exit.
14
+
15
+ Use for CLI verbs and composition roots that need a cache scoped to
16
+ a single command invocation. Long-lived processes (server boot, MCP
17
+ server) use ``open_cache_from_env()`` directly and own the lifecycle.
18
+ """
19
+ cache = open_cache_from_env()
20
+ try:
21
+ yield cache
22
+ finally:
23
+ if cache is not None:
24
+ cache.close()
@@ -79,7 +79,7 @@ class RenderDashboardArgs(BaseModel):
79
79
  variables: dict[str, Any] | None = Field(
80
80
  None, description="Variable values to apply to the dashboard"
81
81
  )
82
- format: Literal["json", "text", "yaml", "svg"] | None = Field(
82
+ format: Literal["json", "text", "yaml", "svg", "terminal"] | None = Field(
83
83
  None,
84
84
  description=(
85
85
  "Output format. 'json' (default) returns resolved chart "
@@ -89,7 +89,9 @@ class RenderDashboardArgs(BaseModel):
89
89
  "YAML with inline data — valid input for re-compilation, "
90
90
  "ideal for round-trip editing. 'svg' returns the rendered "
91
91
  "dashboard as inline SVG (under result['data']) for hosts "
92
- "that embed the rendered output directly."
92
+ "that embed the rendered output directly. 'terminal' returns "
93
+ "the charts as ANSI text (under result['data']) for display "
94
+ "in a terminal."
93
95
  ),
94
96
  )
95
97
  as_link: bool = Field(
@@ -11,6 +11,7 @@ AuthoredFace (dataface) definition from YAML.
11
11
  | `tags` | list[str] | ✓ | Tags for categorization and search. |
12
12
  | `docs` | str | ✓ | Relative path under the docs site for the canonical doc page that explains this face. Surfaced as a 'Docs →' link in the playground gallery when DFT_DOCS_URL is set. |
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
15
  | `sources` | [SourcesSection](#sourcessection) | ✓ | Database source configuration. Use 'default:' to set the default source for all queries. |
15
16
  | `source` | str | ✓ | Default source name shorthand (equivalent to sources.default). Sets the connection for all queries. |
16
17
  | `variables` | dict[str, [Variable](#variable) \| VariableRef] | ✓ | Variable definitions for dynamic filtering and UI controls. |
@@ -71,7 +72,7 @@ AuthoredQuery definition from YAML.
71
72
 
72
73
  | Field | Type | Optional | Description |
73
74
  |-------|------|:--------:|-------------|
74
- | `type` | enum: "sql", "metricflow", "dbt_model", "http", "csv", "values", "schema_resolver" | ✓ | Query adapter type. Defaults to 'sql'. Options: sql, metricflow, dbt_model, http, csv, values, schema_resolver. |
75
+ | `type` | enum: "sql", "metricflow", "dbt_model", "http", "csv", "values", "schema" | ✓ | Query adapter type. Defaults to 'sql'. Options: sql, metricflow, dbt_model, http, csv, values, schema. |
75
76
  | `source` | str \| dict[str, Any] | ✓ | Database source reference (name string) or inline source config dict. |
76
77
  | `target` | str | ✓ | dbt target name for dbt_model queries (defaults to 'dev'). |
77
78
  | `sql` | str | ✓ | SQL query string. Supports Jinja2 templates referencing variables. |
@@ -91,9 +92,9 @@ AuthoredQuery definition from YAML.
91
92
  | `filter` | dict[str, Any] | ✓ | Row-level filter applied to CSV or values data. |
92
93
  | `delimiter` | str | ✓ | Column delimiter for CSV queries. Default: comma (','). |
93
94
  | `encoding` | str | ✓ | File encoding for CSV queries. Default: UTF-8. |
94
- | `schema` | str | ✓ | Schema name for schema_resolver queries (YAML key: schema). |
95
- | `table` | str | ✓ | Table name for schema_resolver queries. |
96
- | `column` | str | ✓ | Column name for schema_resolver queries. |
95
+ | `schema` | str | ✓ | Schema name for schema queries (YAML key: schema). |
96
+ | `table` | str | ✓ | Table name for schema queries. |
97
+ | `column` | str | ✓ | Column name for schema queries. |
97
98
  | `rows` | list[dict[str, Any]] | ✓ | Inline data rows for values-type queries (list of row dicts). |
98
99
  | `values` | list[list[Any]] | ✓ | Inline column-oriented data for values-type queries (list of lists). |
99
100
  | `description` | str | ✓ | Human-readable description of the query. Used by AI search and tooling. |
@@ -490,6 +491,8 @@ Authored overlay for Style — all fields optional. Adds CSS shorthand coercers.
490
491
  | `layout` | [LayoutStyle](#layoutstyle) | ✓ | Layout container styles (rows, cols, grid, tabs, details). |
491
492
  | `variables` | [VariablesStyle](#variablesstyle) | ✓ | Variable controls chrome style. |
492
493
  | `page` | [PageStyle](#pagestyle) | ✓ | Page-level canvas style (behind the board). |
494
+ | `footer` | [FooterStyle](#footerstyle) | ✓ | Page footer chrome visibility. |
495
+ | `timestamp` | [TimestampStyle](#timestampstyle) | ✓ | Render-timestamp chrome: visibility, format, and font. |
493
496
  | `formats` | dict[str, str] | ✓ | Format alias map; None means no aliases at this cascade level. |
494
497
  | `palettes` | dict[str, str] | ✓ | Theme palette role assignments: open dict mapping role name to palette file name. Default seed: chrome, negative, positive, warning, category, sequence, diverge. |
495
498
  | `roles` | dict[str, str] | ✓ | Optional top-level theme role aliases: bare name → role.alias. e.g. ink: chrome.heading |
@@ -1063,8 +1066,7 @@ Authored overlay for BoardStyle. Face-level structural dimensions. Do NOT cascad
1063
1066
  | Field | Type | Optional | Description |
1064
1067
  |-------|------|:--------:|-------------|
1065
1068
  | `width` | float | ✓ | Board width in pixels. |
1066
- | `default_height` | float | ✓ | Default row height in pixels. |
1067
- | `min_height` | float | ✓ | Minimum row height in pixels. |
1069
+ | `min_height` | float | ✓ | Minimum board height in pixels. |
1068
1070
  | `margin` | float | ✓ | Board outer margin in pixels. |
1069
1071
  | `card_padding` | float | ✓ | Padding added to each card side in pixels. |
1070
1072
  | `card_gap` | float | ✓ | Gap between cards in pixels. |
@@ -1221,6 +1223,24 @@ Authored overlay for PageStyle. Page-level (outer HTML canvas) styling.
1221
1223
  |-------|------|:--------:|-------------|
1222
1224
  | `background` | str | ✓ | Page canvas background color (behind the face board). |
1223
1225
 
1226
+ <a id="footerstyle"></a>
1227
+ ## FooterStyle
1228
+ Authored overlay for FooterStyle. Authored visibility toggle for the page footer chrome.
1229
+
1230
+ | Field | Type | Optional | Description |
1231
+ |-------|------|:--------:|-------------|
1232
+ | `visible` | bool | ✓ | Show the footer attribution line. |
1233
+
1234
+ <a id="timestampstyle"></a>
1235
+ ## TimestampStyle
1236
+ Authored overlay for TimestampStyle. Authored timestamp chrome: visibility, strftime format, and font.
1237
+
1238
+ | Field | Type | Optional | Description |
1239
+ |-------|------|:--------:|-------------|
1240
+ | `visible` | bool | ✓ | Show the render-timestamp line. |
1241
+ | `format` | str | ✓ | strftime format string for the render timestamp. |
1242
+ | `font` | [FontStyle](#fontstyle) | ✓ | Timestamp font style overrides (size, color, weight, ...). |
1243
+
1224
1244
  <a id="spacingvalues"></a>
1225
1245
  ## SpacingValues
1226
1246
  Pre-parsed CSS spacing (margin/padding).
@@ -0,0 +1,262 @@
1
+ """General project-file tools for the chat agent.
2
+
3
+ Claude-Code-style primitives — read, write, edit, glob, grep — that let an
4
+ agent author and save faces directly, rather than via a domain-specific
5
+ "write a dashboard" verb. The agent composes these with the typed dft tools
6
+ (``validate_dashboard``, ``render_dashboard``, ``schema``, ``execute_query``).
7
+
8
+ Every path is resolved against and confined to the project root. A path that
9
+ escapes the root (``..`` traversal, absolute path outside, symlink out) is
10
+ refused — these tools never touch the wider filesystem.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ # Cap grep/glob payloads — read_file returns full content (model must handle size).
20
+ _MAX_GREP_MATCHES = 200
21
+ _MAX_GLOB_MATCHES = 500
22
+
23
+ # Directory names to skip on whole-tree scans — avoids traversing .venv,
24
+ # .git, node_modules, target, etc. which can be huge and contain no user code.
25
+ _SKIP_DIRS = frozenset(
26
+ {".git", ".venv", "venv", "node_modules", "target", "__pycache__", ".tox"}
27
+ )
28
+
29
+
30
+ class ReadFileArgs(BaseModel):
31
+ """Read a UTF-8 text file from the project. Path is relative to the project root."""
32
+
33
+ path: str = Field(description="File path relative to the project root.")
34
+
35
+
36
+ class WriteFileArgs(BaseModel):
37
+ """Write a UTF-8 text file in the project, creating parent directories.
38
+
39
+ Overwrites an existing file. To change part of an existing file, prefer
40
+ edit_file so you don't clobber content you haven't read. Path is relative
41
+ to the project root and may not escape it.
42
+ """
43
+
44
+ path: str = Field(description="File path relative to the project root.")
45
+ content: str = Field(description="Full file contents to write.")
46
+
47
+
48
+ class EditFileArgs(BaseModel):
49
+ """Replace an exact string in a project file. old_string must occur exactly once.
50
+
51
+ Fails (writing nothing) if old_string is absent or appears more than once —
52
+ include enough surrounding context to make it unique. Path is relative to
53
+ the project root and may not escape it.
54
+ """
55
+
56
+ path: str = Field(description="File path relative to the project root.")
57
+ old_string: str = Field(description="Exact text to replace (must be unique).")
58
+ new_string: str = Field(description="Replacement text.")
59
+
60
+
61
+ class GlobFilesArgs(BaseModel):
62
+ """List files matching a glob pattern under the project root (e.g. 'faces/*.yml')."""
63
+
64
+ pattern: str = Field(description="Glob pattern relative to the project root.")
65
+
66
+
67
+ class GrepFilesArgs(BaseModel):
68
+ """Search file contents for a substring under the project root.
69
+
70
+ Optionally restrict to files matching a glob (e.g. only 'faces/**/*.yml').
71
+ Skips .git, .venv, node_modules, target, and other large non-user directories.
72
+ """
73
+
74
+ pattern: str = Field(description="Substring to search for (case-sensitive).")
75
+ glob: str | None = Field(
76
+ default=None, description="Optional glob to restrict which files are searched."
77
+ )
78
+
79
+
80
+ class ReadFileResult(BaseModel):
81
+ success: bool
82
+ path: str
83
+ content: str | None = None
84
+ error: str | None = None
85
+
86
+
87
+ class WriteFileResult(BaseModel):
88
+ success: bool
89
+ path: str
90
+ bytes_written: int = 0
91
+ error: str | None = None
92
+
93
+
94
+ class EditFileResult(BaseModel):
95
+ success: bool
96
+ path: str
97
+ replacements: int = 0
98
+ error: str | None = None
99
+
100
+
101
+ class GlobResult(BaseModel):
102
+ success: bool
103
+ matches: list[str] = Field(default_factory=list)
104
+ truncated: bool = False
105
+ error: str | None = None
106
+
107
+
108
+ class GrepMatch(BaseModel):
109
+ path: str
110
+ line_number: int
111
+ line: str
112
+
113
+
114
+ class GrepResult(BaseModel):
115
+ success: bool
116
+ matches: list[GrepMatch] = Field(default_factory=list)
117
+ truncated: bool = False
118
+ error: str | None = None
119
+
120
+
121
+ def _resolve_within(project_dir: Path, raw: str) -> Path:
122
+ """Resolve ``raw`` against the project root, refusing anything that escapes it."""
123
+ root = project_dir.resolve()
124
+ candidate = Path(raw)
125
+ candidate = candidate if candidate.is_absolute() else root / candidate
126
+ resolved = candidate.resolve()
127
+ if resolved != root and root not in resolved.parents:
128
+ raise ValueError(f"path {raw!r} escapes the project root")
129
+ return resolved
130
+
131
+
132
+ def _rel(project_dir: Path, p: Path) -> str:
133
+ return str(p.resolve().relative_to(project_dir.resolve()))
134
+
135
+
136
+ def _walk_project(root: Path) -> list[Path]:
137
+ """Return files under root, skipping known large/irrelevant directories."""
138
+ result: list[Path] = []
139
+ for item in sorted(root.iterdir()):
140
+ if item.is_dir():
141
+ if item.name in _SKIP_DIRS or item.name.startswith("."):
142
+ continue
143
+ result.extend(_walk_project(item))
144
+ elif item.is_file():
145
+ result.append(item)
146
+ return result
147
+
148
+
149
+ def read_file(path: str, project_dir: Path) -> ReadFileResult:
150
+ try:
151
+ target = _resolve_within(project_dir, path)
152
+ except ValueError as exc:
153
+ return ReadFileResult(success=False, path=path, error=str(exc))
154
+ if not target.is_file():
155
+ return ReadFileResult(success=False, path=path, error=f"file not found: {path}")
156
+ try:
157
+ content = target.read_text(encoding="utf-8")
158
+ except (OSError, UnicodeDecodeError) as exc:
159
+ return ReadFileResult(success=False, path=path, error=str(exc))
160
+ return ReadFileResult(success=True, path=_rel(project_dir, target), content=content)
161
+
162
+
163
+ def write_file(path: str, content: str, project_dir: Path) -> WriteFileResult:
164
+ try:
165
+ target = _resolve_within(project_dir, path)
166
+ except ValueError as exc:
167
+ return WriteFileResult(success=False, path=path, error=str(exc))
168
+ try:
169
+ target.parent.mkdir(parents=True, exist_ok=True)
170
+ target.write_text(content, encoding="utf-8")
171
+ except OSError as exc:
172
+ return WriteFileResult(success=False, path=path, error=str(exc))
173
+ return WriteFileResult(
174
+ success=True,
175
+ path=_rel(project_dir, target),
176
+ bytes_written=len(content.encode("utf-8")),
177
+ )
178
+
179
+
180
+ def edit_file(
181
+ path: str, old_string: str, new_string: str, project_dir: Path
182
+ ) -> EditFileResult:
183
+ try:
184
+ target = _resolve_within(project_dir, path)
185
+ except ValueError as exc:
186
+ return EditFileResult(success=False, path=path, error=str(exc))
187
+ if not target.is_file():
188
+ return EditFileResult(success=False, path=path, error=f"file not found: {path}")
189
+ try:
190
+ original = target.read_text(encoding="utf-8")
191
+ except (OSError, UnicodeDecodeError) as exc:
192
+ return EditFileResult(success=False, path=path, error=str(exc))
193
+ count = original.count(old_string)
194
+ if count == 0:
195
+ return EditFileResult(
196
+ success=False, path=path, error="old_string not found in file"
197
+ )
198
+ if count > 1:
199
+ return EditFileResult(
200
+ success=False,
201
+ path=path,
202
+ error=f"old_string is not unique ({count} occurrences) — add surrounding context",
203
+ )
204
+ try:
205
+ target.write_text(original.replace(old_string, new_string), encoding="utf-8")
206
+ except OSError as exc:
207
+ return EditFileResult(success=False, path=path, error=str(exc))
208
+ return EditFileResult(success=True, path=_rel(project_dir, target), replacements=1)
209
+
210
+
211
+ def glob_files(pattern: str, project_dir: Path) -> GlobResult:
212
+ root = project_dir.resolve()
213
+ matches: list[str] = []
214
+ try:
215
+ candidates = root.glob(pattern)
216
+ except (ValueError, OSError, NotImplementedError) as exc:
217
+ return GlobResult(success=False, error=str(exc))
218
+ for p in sorted(candidates):
219
+ if not p.is_file():
220
+ continue
221
+ try:
222
+ rel = p.resolve().relative_to(root)
223
+ except ValueError:
224
+ continue # symlink escaping the root
225
+ matches.append(str(rel))
226
+ if len(matches) >= _MAX_GLOB_MATCHES:
227
+ return GlobResult(success=True, matches=matches, truncated=True)
228
+ return GlobResult(success=True, matches=matches)
229
+
230
+
231
+ def grep_files(pattern: str, project_dir: Path, glob: str | None = None) -> GrepResult:
232
+ root = project_dir.resolve()
233
+ matches: list[GrepMatch] = []
234
+ try:
235
+ candidates: list[Path] = (
236
+ sorted(root.glob(glob)) if glob else _walk_project(root)
237
+ )
238
+ except (ValueError, OSError, NotImplementedError) as exc:
239
+ return GrepResult(success=False, error=str(exc))
240
+ for p in candidates:
241
+ if not p.is_file():
242
+ continue
243
+ try:
244
+ rel = p.resolve().relative_to(root)
245
+ except ValueError:
246
+ continue
247
+ try:
248
+ with p.open(encoding="utf-8") as fh:
249
+ for i, line in enumerate(fh, start=1):
250
+ if pattern in line:
251
+ matches.append(
252
+ GrepMatch(
253
+ path=str(rel), line_number=i, line=line.rstrip("\n")
254
+ )
255
+ )
256
+ if len(matches) >= _MAX_GREP_MATCHES:
257
+ return GrepResult(
258
+ success=True, matches=matches, truncated=True
259
+ )
260
+ except (OSError, UnicodeDecodeError):
261
+ continue # skip binary / unreadable files
262
+ return GrepResult(success=True, matches=matches)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import sys
6
6
  from dataclasses import dataclass
7
+ from functools import cached_property
7
8
  from pathlib import Path
8
9
  from typing import TYPE_CHECKING, Any
9
10
 
@@ -38,6 +39,7 @@ from dataface.core.execute.duckdb_cache import DuckDBCache
38
39
 
39
40
  if TYPE_CHECKING:
40
41
  from dataface.core.dashboard import RenderedDashboard, RenderFormat
42
+ from dataface.core.execute.source_resolver import SourceResolver
41
43
 
42
44
 
43
45
  @dataclass(init=False)
@@ -54,7 +56,6 @@ class Project:
54
56
  project_root: Path
55
57
  cache: DuckDBCache | None
56
58
  _owns_registry: bool
57
- _adapter_registry: AdapterRegistry | None
58
59
  _read_only: bool
59
60
  _dbt_project_path: Path | None
60
61
  _connection_string: str | None
@@ -62,6 +63,9 @@ class Project:
62
63
  _target: str
63
64
  _duckdb_config: dict[str, Any] | None
64
65
  _allow_external_access_in_readonly: bool
66
+ _resolver: SourceResolver | None
67
+ _sources: ProjectSourcesConfig
68
+ _warnings_ignore: frozenset[str]
65
69
 
66
70
  def __init__(
67
71
  self,
@@ -74,7 +78,10 @@ class Project:
74
78
  # We own the registry only when we'll lazy-build it ourselves. An injected
75
79
  # registry belongs to the caller; we use it but don't close it on their behalf.
76
80
  self._owns_registry = adapter_registry is None
77
- self._adapter_registry = adapter_registry
81
+ if adapter_registry is not None:
82
+ # cached_property stores in __dict__; pre-populating makes the descriptor
83
+ # short-circuit the build on first access.
84
+ self.__dict__["adapter_registry"] = adapter_registry
78
85
  self._read_only = True
79
86
  self._dbt_project_path = None
80
87
  self._connection_string = None
@@ -82,6 +89,9 @@ class Project:
82
89
  self._target = "dev"
83
90
  self._duckdb_config = None
84
91
  self._allow_external_access_in_readonly = False
92
+ self._resolver = None
93
+ self._sources = get_project_sources(self.project_root)
94
+ self._warnings_ignore = get_project_warnings_ignore(self.project_root)
85
95
 
86
96
  @classmethod
87
97
  def open(
@@ -96,6 +106,7 @@ class Project:
96
106
  target: str = "dev",
97
107
  duckdb_config: dict[str, Any] | None = None,
98
108
  allow_external_access_in_readonly: bool = False,
109
+ resolver: SourceResolver | None = None,
99
110
  ) -> Self:
100
111
  """Construct a Project rooted at *project_dir*.
101
112
 
@@ -116,6 +127,7 @@ class Project:
116
127
  project._target = target
117
128
  project._duckdb_config = duckdb_config
118
129
  project._allow_external_access_in_readonly = allow_external_access_in_readonly
130
+ project._resolver = resolver
119
131
  return project
120
132
 
121
133
  def __enter__(self) -> Self:
@@ -124,7 +136,7 @@ class Project:
124
136
  def __exit__(self, *exc_info: object) -> None:
125
137
  self.close()
126
138
 
127
- @property
139
+ @cached_property
128
140
  def adapter_registry(self) -> AdapterRegistry:
129
141
  """Lazily build the registry on first access; cached thereafter.
130
142
 
@@ -132,56 +144,49 @@ class Project:
132
144
  own it (i.e. it was not injected at construction time).
133
145
  When constructed with an injected registry, returns it directly.
134
146
  """
135
- if self._adapter_registry is None:
136
- self._adapter_registry = build_adapter_registry(
137
- self.project_root,
138
- read_only=self._read_only,
139
- dbt_project_path=self._dbt_project_path,
140
- connection_string=self._connection_string,
141
- profile_type=self._dialect,
142
- target=self._target,
143
- duckdb_config=self._duckdb_config,
144
- allow_external_access_in_readonly=self._allow_external_access_in_readonly,
145
- )
146
- return self._adapter_registry
147
+ return build_adapter_registry(
148
+ self.project_root,
149
+ read_only=self._read_only,
150
+ dbt_project_path=self._dbt_project_path,
151
+ connection_string=self._connection_string,
152
+ profile_type=self._dialect,
153
+ target=self._target,
154
+ duckdb_config=self._duckdb_config,
155
+ allow_external_access_in_readonly=self._allow_external_access_in_readonly,
156
+ resolver=self._resolver,
157
+ )
147
158
 
148
159
  def refresh(self) -> None:
149
160
  """Policy-free rebuild primitive.
150
161
 
151
162
  For projects opened via ``open()``: closes the current registry (if built)
152
- and clears it so the next access rebuilds from disk.
163
+ and clears it so the next access rebuilds from disk. Also re-reads
164
+ sources and warnings_ignore from disk.
153
165
 
154
- For projects constructed with an injected ``adapter_registry``: no-op,
155
- because the build arguments are not available to rebuild.
166
+ For projects constructed with an injected ``adapter_registry``: skips the
167
+ registry rebuild (build arguments are not available), but still re-reads
168
+ the config fields.
156
169
  """
157
- if not self._owns_registry:
158
- return
159
- if self._adapter_registry is not None:
160
- self._adapter_registry.close()
161
- self._adapter_registry = None
170
+ if self._owns_registry and "adapter_registry" in self.__dict__:
171
+ self.adapter_registry.close()
172
+ del self.__dict__["adapter_registry"]
173
+ self._sources = get_project_sources(self.project_root)
174
+ self._warnings_ignore = get_project_warnings_ignore(self.project_root)
162
175
 
163
176
  def close(self) -> None:
164
177
  """Close the adapter registry iff we own it. The cache is the caller's to close."""
165
- if self._owns_registry and self._adapter_registry is not None:
166
- self._adapter_registry.close()
167
- self._adapter_registry = None
168
-
169
- # ── Config accessors ─────────────────────────────────────────────────────
178
+ if self._owns_registry and "adapter_registry" in self.__dict__:
179
+ self.adapter_registry.close()
180
+ del self.__dict__["adapter_registry"]
170
181
 
182
+ # Read-only views: project lifecycle owns these; external writers go through refresh().
183
+ @property
171
184
  def sources(self) -> ProjectSourcesConfig:
172
- """Fresh read of _sources.yaml + dataface.yml sources section.
173
-
174
- No instance-level cache: every call routes through the loader. The underlying
175
- loader in `core/compile/config.py` currently warms a module-level cache keyed
176
- by `project_dir`, so end-to-end disk freshness is gated on that cache being
177
- deleted in a follow-up; this method's contract (fresh-per-call invocation
178
- of the loader) is independent of that work.
179
- """
180
- return get_project_sources(self.project_root)
185
+ return self._sources
181
186
 
187
+ @property
182
188
  def warnings_ignore(self) -> frozenset[str]:
183
- """Fresh read of dataface.yml warnings.ignore section. Forwards to the loader on every call."""
184
- return get_project_warnings_ignore(self.project_root)
189
+ return self._warnings_ignore
185
190
 
186
191
  # ── Verb forwarders ──────────────────────────────────────────────────────
187
192
 
@@ -290,6 +295,7 @@ class Project:
290
295
  variables=variables,
291
296
  adapter_registry=self.adapter_registry,
292
297
  project_dir=self.project_root,
298
+ project_sources=self.sources,
293
299
  duckdb_cache=self.cache,
294
300
  format=format,
295
301
  use_cache=use_cache,
@@ -298,4 +304,5 @@ class Project:
298
304
  scale=scale,
299
305
  ignore_codes=ignore_codes,
300
306
  max_workers=max_workers,
307
+ warnings_ignore=self.warnings_ignore,
301
308
  )
@@ -23,6 +23,8 @@ from dataface.core.render.face_api import compile_and_render
23
23
 
24
24
  def render_face(
25
25
  face_path: str | Path,
26
+ *,
27
+ warnings_ignore: frozenset[str],
26
28
  format: str = "svg",
27
29
  project_dir: str | Path | None = None,
28
30
  base_dir: str | Path | None = None,
@@ -35,6 +37,10 @@ def render_face(
35
37
 
36
38
  Args:
37
39
  face_path: Path to the .yml or .md face file to render.
40
+ warnings_ignore: Project-level warning codes to suppress. Caller
41
+ resolves this (typically via
42
+ ``get_project_warnings_ignore(project_root)``); render_face
43
+ does no disk I/O for warnings config.
38
44
  format: Output format — "svg", "html", "png", "pdf", etc.
39
45
  project_dir: Explicit project root. When omitted, auto-discovered
40
46
  upward from the face file's directory.
@@ -83,6 +89,7 @@ def render_face(
83
89
  project_root=project_root,
84
90
  adapter_registry=adapter_registry,
85
91
  cache=cache,
92
+ warnings_ignore=warnings_ignore,
86
93
  base_dir=resolved_base_dir,
87
94
  format=format,
88
95
  variables=variables,
@@ -53,6 +53,10 @@ schema_search:
53
53
  tool: "schema_search"
54
54
  dft: "dft schema -s"
55
55
 
56
+ search_dashboards:
57
+ tool: "search_dashboards"
58
+ dft: "dft search"
59
+
56
60
  # ---- Inline skill-pointer references --------------------------------------
57
61
 
58
62
  skill_build:
dataface/ai/__init__.py CHANGED
@@ -9,7 +9,7 @@ Modules:
9
9
  yaml_utils: YAML extraction from AI responses
10
10
  """
11
11
 
12
- from dataface.ai.prompts import PromptLoader, load_prompt
12
+ from dataface.ai.prompts import load_prompt
13
13
  from dataface.ai.schema_context import get_schema_context
14
14
  from dataface.ai.tools import (
15
15
  TOOL_EXECUTE_QUERY,
@@ -24,7 +24,6 @@ from dataface.ai.yaml_utils import extract_yaml
24
24
  __all__ = [
25
25
  # Prompts
26
26
  "load_prompt",
27
- "PromptLoader",
28
27
  # Tools
29
28
  "get_tools",
30
29
  "handle_tool_call",