dataface 0.1.5.dev215__py3-none-any.whl → 0.1.5.dev237__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.
- dataface/DATAFACE_SYNTAX.md +27 -1
- dataface/agent_api/__init__.py +20 -0
- dataface/agent_api/docs/yaml-reference.md +1 -0
- dataface/agent_api/entity_paths.py +221 -0
- dataface/agent_api/project.py +23 -8
- dataface/agent_api/schema_hints.py +49 -0
- dataface/agent_api/validate.py +48 -0
- dataface/agent_api/validate_query.py +40 -62
- dataface/ai/mcp/server.py +1 -1
- dataface/ai/tools/__init__.py +4 -4
- dataface/cli/commands/_agent_server.py +10 -4
- dataface/cli/commands/query.py +54 -36
- dataface/cli/commands/schema.py +29 -0
- dataface/cli/main.py +13 -0
- dataface/core/compile/__init__.py +6 -2
- dataface/core/compile/compiler.py +111 -85
- dataface/core/compile/config.py +62 -39
- dataface/core/compile/models/face/authored.py +9 -0
- dataface/core/compile/models/face/normalized.py +8 -0
- dataface/core/compile/normalizer.py +1 -0
- dataface/core/dashboard.py +2 -2
- dataface/core/execute/adapters/adapter_registry.py +2 -2
- dataface/core/inspect/renderer.py +2 -2
- dataface/core/render/face_api.py +2 -2
- dataface/core/serve/alias_index.py +284 -0
- dataface/core/serve/server.py +207 -20
- dataface/core/system_views/__init__.py +0 -0
- dataface/core/system_views/expander.py +383 -0
- dataface/core/system_views/loader.py +105 -0
- dataface/core/system_views/models.py +67 -0
- dataface/core/system_views/query_runner.py +248 -0
- dataface/core/system_views/registry.yaml +78 -0
- dataface/core/system_views/router.py +127 -0
- dataface/core/system_views/templates/entity/schema-index.yaml +21 -0
- dataface/core/system_views/templates/entity/source-index.yaml +20 -0
- dataface/core/system_views/templates/entity/table-detail.yaml +30 -0
- dataface/core/system_views/templates/entity/table-index.yaml +41 -0
- dataface/core/system_views/templates/inspector/column.yaml +24 -0
- dataface/core/system_views/templates/inspector/schema.yaml +20 -0
- dataface/core/system_views/templates/inspector/source.yaml +19 -0
- dataface/core/system_views/templates/inspector/table.yaml +23 -0
- dataface/core/system_views/variable_planner.py +327 -0
- {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev237.dist-info}/METADATA +1 -1
- {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev237.dist-info}/RECORD +47 -28
- {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev237.dist-info}/WHEEL +0 -0
- {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev237.dist-info}/entry_points.txt +0 -0
- {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev237.dist-info}/licenses/LICENSE +0 -0
dataface/DATAFACE_SYNTAX.md
CHANGED
|
@@ -206,15 +206,20 @@ chart_focus: revenue_trend # Render only one chart with its dependent variab
|
|
|
206
206
|
details: "Click to expand" # Collapsible section
|
|
207
207
|
expanded_title: "Hide details"
|
|
208
208
|
expanded: false
|
|
209
|
+
|
|
210
|
+
aliases: # Additional URLs that 302-redirect to this face
|
|
211
|
+
- /old-reports/
|
|
212
|
+
- /legacy/sales/
|
|
209
213
|
```
|
|
210
214
|
|
|
211
|
-
Top-level fields (
|
|
215
|
+
Top-level fields (24 total):
|
|
212
216
|
|
|
213
217
|
| Field | Type | Notes |
|
|
214
218
|
|-------|------|-------|
|
|
215
219
|
| `title` | string | Display title |
|
|
216
220
|
| `description` | string | Description text |
|
|
217
221
|
| `tags` | list[string] | Tags for categorization/search |
|
|
222
|
+
| `aliases` | list[string] | See [Aliases](#aliases) below |
|
|
218
223
|
| `text` | string | Markdown body for text-only faces |
|
|
219
224
|
| `source` | string | Default source shorthand (equivalent to `sources.default`) |
|
|
220
225
|
| `sources` | object | `{default: name, <name>: {type: ...}}` |
|
|
@@ -238,6 +243,27 @@ Top-level fields (23 total):
|
|
|
238
243
|
|
|
239
244
|
`face:` as a top-level key is rejected. Put face properties (title, rows, queries, …) directly at the YAML root.
|
|
240
245
|
|
|
246
|
+
### Aliases
|
|
247
|
+
|
|
248
|
+
`aliases:` is a list of absolute URL paths that 302-redirect to this face's canonical file-path URL. Each entry must start with `/`; trailing slashes are normalized automatically.
|
|
249
|
+
|
|
250
|
+
```yaml
|
|
251
|
+
aliases:
|
|
252
|
+
- /old-reports/
|
|
253
|
+
- /legacy/sales/
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Rules:
|
|
257
|
+
- An alias must not collide with a real face file path — the server raises an error at startup if it does.
|
|
258
|
+
- Each alias must be unique across the project — two faces claiming the same alias is a startup error.
|
|
259
|
+
- Aliasing a generated system route (entity or inspector view) is allowed: the redirect takes precedence, letting you override what that URL serves.
|
|
260
|
+
|
|
261
|
+
There are two ways to override an entity/inspector route for a given path:
|
|
262
|
+
- **Face file at that path** — create `faces/entity/warehouse/schema/table.yml` and the server renders it directly (no redirect).
|
|
263
|
+
- **`aliases:` entry** — any face can declare `/entity/…/` as an alias; the server issues a 302 to the declaring face's canonical URL.
|
|
264
|
+
|
|
265
|
+
Both approaches work. A face file wins without a redirect round-trip; an alias lets a face live anywhere and still capture a system-view URL.
|
|
266
|
+
|
|
241
267
|
### Board style
|
|
242
268
|
|
|
243
269
|
`style:` on a face, nested face, or layout section accepts a fixed set of keys — not arbitrary CSS:
|
dataface/agent_api/__init__.py
CHANGED
|
@@ -53,8 +53,20 @@ from dataface.agent_api.init import (
|
|
|
53
53
|
)
|
|
54
54
|
from dataface.agent_api.project import Project as Project
|
|
55
55
|
from dataface.agent_api.query import QueryFaceResult, query_face
|
|
56
|
+
from dataface.agent_api.schema_hints import (
|
|
57
|
+
SchemaHints as SchemaHints,
|
|
58
|
+
schema_hints as schema_hints,
|
|
59
|
+
)
|
|
56
60
|
from dataface.agent_api.validate import ValidateDashboardArgs, ValidateResult
|
|
61
|
+
from dataface.agent_api.validate_query import (
|
|
62
|
+
QueryDiagnostic as QueryDiagnostic,
|
|
63
|
+
validate_query as validate_query,
|
|
64
|
+
)
|
|
57
65
|
from dataface.core.compile.config import ProjectSourcesConfig as ProjectSourcesConfig
|
|
66
|
+
from dataface.core.errors.structured import StructuredError as StructuredError
|
|
67
|
+
from dataface.core.fonts import get_inter_font_path as get_inter_font_path
|
|
68
|
+
from dataface.core.render.board_links import LinkContext as LinkContext
|
|
69
|
+
from dataface.core.render.errors import RenderError as RenderError
|
|
58
70
|
|
|
59
71
|
__all__ = [
|
|
60
72
|
"DescribeFaceArgs",
|
|
@@ -63,15 +75,23 @@ __all__ = [
|
|
|
63
75
|
"DocsResult",
|
|
64
76
|
"DocsSearchHit",
|
|
65
77
|
"InitResult",
|
|
78
|
+
"LinkContext",
|
|
66
79
|
"Project",
|
|
67
80
|
"ProjectSourcesConfig",
|
|
81
|
+
"QueryDiagnostic",
|
|
68
82
|
"QueryFaceResult",
|
|
69
83
|
"RenderedDashboard",
|
|
84
|
+
"RenderError",
|
|
85
|
+
"SchemaHints",
|
|
86
|
+
"StructuredError",
|
|
70
87
|
"Topic",
|
|
71
88
|
"ValidateDashboardArgs",
|
|
72
89
|
"ValidateResult",
|
|
73
90
|
"describe_face",
|
|
91
|
+
"get_inter_font_path",
|
|
74
92
|
"init_project",
|
|
75
93
|
"query_face",
|
|
76
94
|
"render_dashboard",
|
|
95
|
+
"schema_hints",
|
|
96
|
+
"validate_query",
|
|
77
97
|
]
|
|
@@ -9,6 +9,7 @@ AuthoredFace (dataface) definition from YAML.
|
|
|
9
9
|
| `title` | str | ✓ | Dashboard title displayed at the top. |
|
|
10
10
|
| `description` | str | ✓ | Description text for the dashboard. |
|
|
11
11
|
| `tags` | list[str] | ✓ | Tags for categorization and search. |
|
|
12
|
+
| `aliases` | list[str] | ✓ | Additional URLs that redirect to this face's canonical file-path URL. Each entry must be absolute (leading /). Requests to these URLs are redirected (302) to the face's real path, query string preserved. Valid on .yml, .yaml, .md, and folder index.* faces. |
|
|
12
13
|
| `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
14
|
| `text` | str | ✓ | Markdown text content for text-only sections. |
|
|
14
15
|
| `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. |
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Entity URL surfacing and alias typo lint.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- EntityPathInfo: the agent-facing wire shape for a canonical entity URL
|
|
5
|
+
(url, resolves_to: "generic" | "authored", face)
|
|
6
|
+
- entity_paths_for_source/schema/table: compute entity URLs and check the
|
|
7
|
+
alias index to determine whether a face overrides the generic system view
|
|
8
|
+
- entity_paths_list: flat list of EntityPathInfo (source + schema + table level)
|
|
9
|
+
from a SchemaResponse — used for --entity-paths CLI flag and agent discovery
|
|
10
|
+
- validate_entity_aliases: typo lint for /entity/ prefixed aliases — checks
|
|
11
|
+
source names only (config only, no DB connection required).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from dataface.agent_api.schema import SchemaResponse
|
|
22
|
+
from dataface.core.serve.alias_index import AliasIndex
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class EntityPathInfo:
|
|
27
|
+
"""Canonical entity URL with resolution status.
|
|
28
|
+
|
|
29
|
+
Wire shape (pinned by contract test — any change is a breaking API change):
|
|
30
|
+
{"url": "/entity/src/schema/table/", "resolves_to": "generic"}
|
|
31
|
+
{"url": "...", "resolves_to": "authored", "face": "/sales/"}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
url: str
|
|
35
|
+
resolves_to: Literal["generic", "authored"]
|
|
36
|
+
face: str | None = None
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
"""Serialize to the agent wire shape, omitting face when generic."""
|
|
40
|
+
d: dict[str, Any] = {"url": self.url, "resolves_to": self.resolves_to}
|
|
41
|
+
if self.face is not None:
|
|
42
|
+
d["face"] = self.face
|
|
43
|
+
return d
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def entity_paths_for_source(
|
|
47
|
+
source: str,
|
|
48
|
+
*,
|
|
49
|
+
alias_index: AliasIndex,
|
|
50
|
+
) -> EntityPathInfo:
|
|
51
|
+
"""Return the entity URL info for a source.
|
|
52
|
+
|
|
53
|
+
The canonical URL is /entity/<source>/. When a face aliases it the
|
|
54
|
+
resolves_to is 'authored' and face is the canonical face URL.
|
|
55
|
+
"""
|
|
56
|
+
url = f"/entity/{source}/"
|
|
57
|
+
face = alias_index.lookup(url)
|
|
58
|
+
if face is not None:
|
|
59
|
+
return EntityPathInfo(url=url, resolves_to="authored", face=face)
|
|
60
|
+
return EntityPathInfo(url=url, resolves_to="generic")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def entity_paths_for_schema(
|
|
64
|
+
source: str,
|
|
65
|
+
schema: str,
|
|
66
|
+
*,
|
|
67
|
+
alias_index: AliasIndex,
|
|
68
|
+
) -> EntityPathInfo:
|
|
69
|
+
"""Return the entity URL info for a schema.
|
|
70
|
+
|
|
71
|
+
The canonical URL is /entity/<source>/<schema>/. When a face aliases it
|
|
72
|
+
the resolves_to is 'authored' and face is the canonical face URL.
|
|
73
|
+
"""
|
|
74
|
+
url = f"/entity/{source}/{schema}/"
|
|
75
|
+
face = alias_index.lookup(url)
|
|
76
|
+
if face is not None:
|
|
77
|
+
return EntityPathInfo(url=url, resolves_to="authored", face=face)
|
|
78
|
+
return EntityPathInfo(url=url, resolves_to="generic")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def entity_paths_for_table(
|
|
82
|
+
source: str,
|
|
83
|
+
schema: str,
|
|
84
|
+
table: str,
|
|
85
|
+
*,
|
|
86
|
+
alias_index: AliasIndex,
|
|
87
|
+
) -> EntityPathInfo:
|
|
88
|
+
"""Return the entity URL info for a table.
|
|
89
|
+
|
|
90
|
+
The canonical URL is /entity/<source>/<schema>/<table>/. When a face
|
|
91
|
+
aliases it the resolves_to is 'authored' and face is the canonical face URL.
|
|
92
|
+
"""
|
|
93
|
+
url = f"/entity/{source}/{schema}/{table}/"
|
|
94
|
+
face = alias_index.lookup(url)
|
|
95
|
+
if face is not None:
|
|
96
|
+
return EntityPathInfo(url=url, resolves_to="authored", face=face)
|
|
97
|
+
return EntityPathInfo(url=url, resolves_to="generic")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_entity_aliases(
|
|
101
|
+
aliases: list[str],
|
|
102
|
+
*,
|
|
103
|
+
source_names: frozenset[str],
|
|
104
|
+
) -> list[str]:
|
|
105
|
+
"""Lint /entity/ prefixed aliases against configured source names.
|
|
106
|
+
|
|
107
|
+
Called at dft-validate time (connection-free). Checks that every
|
|
108
|
+
/entity/ URL references a known source, and errors with available
|
|
109
|
+
sources as a hint when not.
|
|
110
|
+
|
|
111
|
+
Returns a list of error message strings (empty = all valid).
|
|
112
|
+
Aliases not prefixed with /entity/ are silently skipped.
|
|
113
|
+
"""
|
|
114
|
+
errors: list[str] = []
|
|
115
|
+
for raw_alias in aliases:
|
|
116
|
+
# Normalize: trailing-slash canonical, strip percent-encoding
|
|
117
|
+
from urllib.parse import unquote
|
|
118
|
+
|
|
119
|
+
alias = unquote(raw_alias)
|
|
120
|
+
if not alias.endswith("/"):
|
|
121
|
+
alias = alias + "/"
|
|
122
|
+
|
|
123
|
+
if not alias.startswith("/entity/"):
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Strip leading "/entity/" and trailing "/" to get segments
|
|
127
|
+
inner = alias[len("/entity/") :]
|
|
128
|
+
if inner.endswith("/"):
|
|
129
|
+
inner = inner[:-1]
|
|
130
|
+
segments = [s for s in inner.split("/") if s]
|
|
131
|
+
|
|
132
|
+
if not segments:
|
|
133
|
+
# Bare /entity/ — no validation needed
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# Segment 0: source
|
|
137
|
+
source = segments[0]
|
|
138
|
+
if source not in source_names:
|
|
139
|
+
available = sorted(source_names)
|
|
140
|
+
hint = (
|
|
141
|
+
f"available sources: {', '.join(available)}"
|
|
142
|
+
if available
|
|
143
|
+
else "no sources configured"
|
|
144
|
+
)
|
|
145
|
+
errors.append(
|
|
146
|
+
f"Entity alias {raw_alias!r} references unknown source {source!r}. "
|
|
147
|
+
f"Did you mean one of: {hint}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return errors
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def entity_paths_list(
|
|
154
|
+
schema_response: SchemaResponse,
|
|
155
|
+
*,
|
|
156
|
+
alias_index: AliasIndex,
|
|
157
|
+
) -> list[EntityPathInfo]:
|
|
158
|
+
"""Build a flat list of EntityPathInfo from a SchemaResponse.
|
|
159
|
+
|
|
160
|
+
Iterates all sources → schemas → tables in the response and returns one
|
|
161
|
+
EntityPathInfo per source, per schema, and per table, checking the alias
|
|
162
|
+
index for author overrides at each level.
|
|
163
|
+
"""
|
|
164
|
+
result: list[EntityPathInfo] = []
|
|
165
|
+
for source_name, source_data in schema_response.sources.items():
|
|
166
|
+
result.append(entity_paths_for_source(source_name, alias_index=alias_index))
|
|
167
|
+
for schema_name, schema_data in (source_data.get("schemas") or {}).items():
|
|
168
|
+
result.append(
|
|
169
|
+
entity_paths_for_schema(
|
|
170
|
+
source_name, schema_name, alias_index=alias_index
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
for table_name in schema_data.get("tables") or {}:
|
|
174
|
+
result.append(
|
|
175
|
+
entity_paths_for_table(
|
|
176
|
+
source_name,
|
|
177
|
+
schema_name,
|
|
178
|
+
table_name,
|
|
179
|
+
alias_index=alias_index,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def build_alias_index_for_project(project_dir: Path) -> AliasIndex:
|
|
186
|
+
"""Build an AliasIndex from a project directory.
|
|
187
|
+
|
|
188
|
+
Uses the same faces_at_root detection logic as the server. Intended for
|
|
189
|
+
CLI commands (dft schema --entity-paths) that need alias lookup without
|
|
190
|
+
starting a server.
|
|
191
|
+
"""
|
|
192
|
+
from dataface.core.serve.alias_index import AliasIndex
|
|
193
|
+
|
|
194
|
+
faces_dir = project_dir / "faces"
|
|
195
|
+
faces_at_root = faces_dir.is_dir()
|
|
196
|
+
return AliasIndex.build(
|
|
197
|
+
project_dir=project_dir,
|
|
198
|
+
faces_dir=faces_dir,
|
|
199
|
+
faces_at_root=faces_at_root,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def entity_alias_errors_for_file(
|
|
204
|
+
face_file: Path,
|
|
205
|
+
source_names: frozenset[str],
|
|
206
|
+
) -> list[str]:
|
|
207
|
+
"""Read aliases from a face file and lint any /entity/ prefixed ones.
|
|
208
|
+
|
|
209
|
+
Source-name check only — no DB connection, no resolver. Called from the
|
|
210
|
+
validate path which must stay connection-free.
|
|
211
|
+
|
|
212
|
+
Returns a list of error message strings; empty means no entity alias issues.
|
|
213
|
+
Silently returns [] when the file cannot be parsed (compile catches that).
|
|
214
|
+
"""
|
|
215
|
+
from dataface.core.serve.alias_index import _read_aliases_from_file
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
aliases = _read_aliases_from_file(face_file)
|
|
219
|
+
except ValueError:
|
|
220
|
+
return [] # compile path reports alias parse errors with better context
|
|
221
|
+
return validate_entity_aliases(aliases, source_names=source_names)
|
dataface/agent_api/project.py
CHANGED
|
@@ -28,8 +28,8 @@ from dataface.agent_api.validate import (
|
|
|
28
28
|
)
|
|
29
29
|
from dataface.core.compile.config import (
|
|
30
30
|
ProjectSourcesConfig,
|
|
31
|
-
get_project_sources,
|
|
32
31
|
get_project_warnings_ignore,
|
|
32
|
+
load_project_sources,
|
|
33
33
|
)
|
|
34
34
|
from dataface.core.execute.adapters.adapter_registry import (
|
|
35
35
|
AdapterRegistry,
|
|
@@ -40,6 +40,7 @@ from dataface.core.execute.duckdb_cache import DuckDBCache
|
|
|
40
40
|
if TYPE_CHECKING:
|
|
41
41
|
from dataface.core.dashboard import RenderedDashboard, RenderFormat
|
|
42
42
|
from dataface.core.execute.source_resolver import SourceResolver
|
|
43
|
+
from dataface.core.render.board_links import LinkContext
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
@dataclass(init=False)
|
|
@@ -72,6 +73,7 @@ class Project:
|
|
|
72
73
|
project_root: Path,
|
|
73
74
|
cache: DuckDBCache | None = None,
|
|
74
75
|
adapter_registry: AdapterRegistry | None = None,
|
|
76
|
+
read_only: bool = True,
|
|
75
77
|
) -> None:
|
|
76
78
|
self.project_root = project_root
|
|
77
79
|
self.cache = cache
|
|
@@ -82,7 +84,7 @@ class Project:
|
|
|
82
84
|
# cached_property stores in __dict__; pre-populating makes the descriptor
|
|
83
85
|
# short-circuit the build on first access.
|
|
84
86
|
self.__dict__["adapter_registry"] = adapter_registry
|
|
85
|
-
self._read_only =
|
|
87
|
+
self._read_only = read_only
|
|
86
88
|
self._dbt_project_path = None
|
|
87
89
|
self._connection_string = None
|
|
88
90
|
self._dialect = "duckdb"
|
|
@@ -90,7 +92,7 @@ class Project:
|
|
|
90
92
|
self._duckdb_config = None
|
|
91
93
|
self._allow_external_access_in_readonly = False
|
|
92
94
|
self._resolver = None
|
|
93
|
-
self._sources =
|
|
95
|
+
self._sources = load_project_sources(self.project_root)
|
|
94
96
|
self._warnings_ignore = get_project_warnings_ignore(self.project_root)
|
|
95
97
|
|
|
96
98
|
@classmethod
|
|
@@ -119,8 +121,9 @@ class Project:
|
|
|
119
121
|
Call ``refresh()`` to close the current registry and force a rebuild on the
|
|
120
122
|
next access.
|
|
121
123
|
"""
|
|
122
|
-
project = cls(
|
|
123
|
-
|
|
124
|
+
project = cls(
|
|
125
|
+
project_root=Path(project_dir).resolve(), cache=cache, read_only=read_only
|
|
126
|
+
)
|
|
124
127
|
project._dbt_project_path = dbt_project_path
|
|
125
128
|
project._connection_string = connection_string
|
|
126
129
|
project._dialect = dialect
|
|
@@ -170,7 +173,7 @@ class Project:
|
|
|
170
173
|
if self._owns_registry and "adapter_registry" in self.__dict__:
|
|
171
174
|
self.adapter_registry.close()
|
|
172
175
|
del self.__dict__["adapter_registry"]
|
|
173
|
-
self._sources =
|
|
176
|
+
self._sources = load_project_sources(self.project_root)
|
|
174
177
|
self._warnings_ignore = get_project_warnings_ignore(self.project_root)
|
|
175
178
|
|
|
176
179
|
def close(self) -> None:
|
|
@@ -191,10 +194,20 @@ class Project:
|
|
|
191
194
|
# ── Verb forwarders ──────────────────────────────────────────────────────
|
|
192
195
|
|
|
193
196
|
def validate(self, face_path: Path) -> ValidateResult:
|
|
194
|
-
|
|
197
|
+
result = _validate.validate(face_path, project_dir=self.project_root)
|
|
198
|
+
annotated = _validate.annotate_with_entity_lint([result], project=self)
|
|
199
|
+
return annotated[0]
|
|
195
200
|
|
|
196
201
|
def validate_paths(self, paths: list[Path] | None) -> list[ValidateResult]:
|
|
197
|
-
|
|
202
|
+
results = _validate.validate_paths(paths, project_dir=self.project_root)
|
|
203
|
+
return _validate.annotate_with_entity_lint(results, project=self)
|
|
204
|
+
|
|
205
|
+
def _source_names(self) -> frozenset[str]:
|
|
206
|
+
"""Return the configured source names for entity alias validation.
|
|
207
|
+
|
|
208
|
+
Reads from the project config file — no adapter registry or DB connection needed.
|
|
209
|
+
"""
|
|
210
|
+
return frozenset(self._sources.sources.keys())
|
|
198
211
|
|
|
199
212
|
def describe_face(self, path: Path) -> _describe.DescribeFaceResult:
|
|
200
213
|
return _describe.describe_face(path, project_dir=self.project_root)
|
|
@@ -288,6 +301,7 @@ class Project:
|
|
|
288
301
|
scale: float | None = None,
|
|
289
302
|
ignore_codes: set[str] | None = None,
|
|
290
303
|
max_workers: int | None = None,
|
|
304
|
+
link_context: LinkContext | None = None,
|
|
291
305
|
) -> RenderedDashboard:
|
|
292
306
|
return _dashboards.render_dashboard(
|
|
293
307
|
path=path,
|
|
@@ -305,4 +319,5 @@ class Project:
|
|
|
305
319
|
ignore_codes=ignore_codes,
|
|
306
320
|
max_workers=max_workers,
|
|
307
321
|
warnings_ignore=self.warnings_ignore,
|
|
322
|
+
link_context=link_context,
|
|
308
323
|
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Stateless view of compile-time schema constants for authoring agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from dataface.core.compile.config import list_built_in_themes
|
|
10
|
+
from dataface.core.compile.models.chart.authored import (
|
|
11
|
+
_INTERNAL_CHART_TYPES,
|
|
12
|
+
CHART_TYPE_DISPLAY,
|
|
13
|
+
ChartType,
|
|
14
|
+
)
|
|
15
|
+
from dataface.core.compile.models.variable.authored import VariableInputType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChartTypeDisplay(BaseModel):
|
|
19
|
+
label: str
|
|
20
|
+
icon: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SchemaHints(BaseModel):
|
|
24
|
+
chart_types: list[str]
|
|
25
|
+
input_types: list[str]
|
|
26
|
+
theme_names: list[str]
|
|
27
|
+
chart_type_display: dict[str, ChartTypeDisplay]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def schema_hints() -> SchemaHints:
|
|
31
|
+
"""Return compile-time schema constants for Dataface authoring agents.
|
|
32
|
+
|
|
33
|
+
Takes no arguments and reads no process state. Two consecutive calls
|
|
34
|
+
return equal results.
|
|
35
|
+
"""
|
|
36
|
+
public_chart_types = [t for t in ChartType if t not in _INTERNAL_CHART_TYPES]
|
|
37
|
+
return SchemaHints(
|
|
38
|
+
chart_types=[t.value for t in public_chart_types],
|
|
39
|
+
input_types=list(typing.get_args(VariableInputType)),
|
|
40
|
+
theme_names=list_built_in_themes(),
|
|
41
|
+
chart_type_display={
|
|
42
|
+
t.value: ChartTypeDisplay(
|
|
43
|
+
label=CHART_TYPE_DISPLAY[t]["label"],
|
|
44
|
+
icon=CHART_TYPE_DISPLAY[t]["icon"],
|
|
45
|
+
)
|
|
46
|
+
for t in public_chart_types
|
|
47
|
+
if t in CHART_TYPE_DISPLAY
|
|
48
|
+
},
|
|
49
|
+
)
|
dataface/agent_api/validate.py
CHANGED
|
@@ -6,6 +6,7 @@ No warehouse connection, no query execution.
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
9
10
|
|
|
10
11
|
from pydantic import BaseModel, Field
|
|
11
12
|
|
|
@@ -18,6 +19,9 @@ from dataface.core.compile.errors import DatafaceError
|
|
|
18
19
|
from dataface.core.errors import DF_UNKNOWN_INTERNAL, StructuredError
|
|
19
20
|
from dataface.core.render.warnings.base import RenderWarning
|
|
20
21
|
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from dataface.agent_api.project import Project
|
|
24
|
+
|
|
21
25
|
|
|
22
26
|
class ValidateDashboardArgs(BaseModel):
|
|
23
27
|
"""Validate a face YAML file without executing queries.
|
|
@@ -178,3 +182,47 @@ def validate(path: Path, *, project_dir: Path) -> ValidateResult:
|
|
|
178
182
|
errors=errors,
|
|
179
183
|
warnings=list(result.warnings),
|
|
180
184
|
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def annotate_with_entity_lint(
|
|
188
|
+
results: list[ValidateResult],
|
|
189
|
+
*,
|
|
190
|
+
project: Project,
|
|
191
|
+
) -> list[ValidateResult]:
|
|
192
|
+
"""Augment validate results with entity alias typo lint.
|
|
193
|
+
|
|
194
|
+
For each result that compiled successfully, reads its aliases and checks
|
|
195
|
+
any /entity/ prefixed ones against the configured source names (config only,
|
|
196
|
+
no DB connection). Appends StructuredErrors to results in place and updates
|
|
197
|
+
success=False when errors are found.
|
|
198
|
+
"""
|
|
199
|
+
from dataface.agent_api.entity_paths import entity_alias_errors_for_file
|
|
200
|
+
|
|
201
|
+
source_names = project._source_names()
|
|
202
|
+
|
|
203
|
+
annotated: list[ValidateResult] = []
|
|
204
|
+
for result in results:
|
|
205
|
+
if not result.path.exists() or not result.path.is_file():
|
|
206
|
+
annotated.append(result)
|
|
207
|
+
continue
|
|
208
|
+
alias_msgs = entity_alias_errors_for_file(
|
|
209
|
+
result.path, source_names=source_names
|
|
210
|
+
)
|
|
211
|
+
if not alias_msgs:
|
|
212
|
+
annotated.append(result)
|
|
213
|
+
continue
|
|
214
|
+
new_errors = list(result.errors) + [
|
|
215
|
+
DatafaceError.from_code(DF_UNKNOWN_INTERNAL, message=msg).to_structured(
|
|
216
|
+
file=str(result.path)
|
|
217
|
+
)
|
|
218
|
+
for msg in alias_msgs
|
|
219
|
+
]
|
|
220
|
+
annotated.append(
|
|
221
|
+
ValidateResult(
|
|
222
|
+
success=False,
|
|
223
|
+
path=result.path,
|
|
224
|
+
errors=new_errors,
|
|
225
|
+
warnings=result.warnings,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
return annotated
|
|
@@ -1,84 +1,62 @@
|
|
|
1
|
-
"""validate_query verb —
|
|
1
|
+
"""validate_query verb — stateless SQL lint, returns typed QueryDiagnostic list."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
from pydantic import BaseModel, Field
|
|
5
|
+
from typing import Literal, overload
|
|
9
6
|
|
|
10
7
|
from dataface.core.inspect.query_validator import (
|
|
11
|
-
|
|
8
|
+
QueryDiagnostic,
|
|
12
9
|
validate_query as _core_validate,
|
|
13
10
|
)
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
__all__ = ["QueryDiagnostic", "validate_query"]
|
|
17
13
|
|
|
18
|
-
def _load_relationship_context() -> RelationshipContext | None:
|
|
19
|
-
"""Load relationship context from the private package when available."""
|
|
20
|
-
if not _SUPER_SCHEMA_AVAILABLE:
|
|
21
|
-
return None
|
|
22
|
-
from dataface_super_schema.inspect.relationship_context import ( # noqa: PLC0415
|
|
23
|
-
load_relationship_context,
|
|
24
|
-
)
|
|
25
14
|
|
|
26
|
-
|
|
15
|
+
@overload
|
|
16
|
+
def validate_query(
|
|
17
|
+
sql: str,
|
|
18
|
+
*,
|
|
19
|
+
dialect: str | None = ...,
|
|
20
|
+
suppress: set[str] | None = ...,
|
|
21
|
+
return_suppressed: Literal[False] = ...,
|
|
22
|
+
) -> list[QueryDiagnostic]: ...
|
|
27
23
|
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
suppressed: list[dict[str, Any]] | None = Field(
|
|
39
|
-
None,
|
|
40
|
-
description="Suppressed diagnostics (only present when return_suppressed=True).",
|
|
41
|
-
)
|
|
25
|
+
@overload
|
|
26
|
+
def validate_query(
|
|
27
|
+
sql: str,
|
|
28
|
+
*,
|
|
29
|
+
dialect: str | None = ...,
|
|
30
|
+
suppress: set[str] | None = ...,
|
|
31
|
+
return_suppressed: Literal[True],
|
|
32
|
+
) -> tuple[list[QueryDiagnostic], list[QueryDiagnostic]]: ...
|
|
42
33
|
|
|
43
34
|
|
|
44
35
|
def validate_query(
|
|
45
36
|
sql: str,
|
|
46
37
|
*,
|
|
47
38
|
dialect: str | None = None,
|
|
39
|
+
suppress: set[str] | None = None,
|
|
48
40
|
return_suppressed: bool = False,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
41
|
+
) -> list[QueryDiagnostic] | tuple[list[QueryDiagnostic], list[QueryDiagnostic]]:
|
|
42
|
+
"""Run static SQL validation and return typed diagnostics.
|
|
43
|
+
|
|
44
|
+
Stateless — requires no project, adapter, or warehouse connection.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
sql: SQL query string to validate.
|
|
48
|
+
dialect: Optional SQLGlot dialect name (e.g. "duckdb", "bigquery").
|
|
49
|
+
suppress: Optional set of diagnostic codes to suppress from the active list.
|
|
50
|
+
return_suppressed: If True, return (active, suppressed) tuple instead of
|
|
51
|
+
just the active list. Used by CLI --show-suppressed.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
list[QueryDiagnostic] — active diagnostics (default).
|
|
55
|
+
tuple[list[QueryDiagnostic], list[QueryDiagnostic]] — (active, suppressed)
|
|
56
|
+
when return_suppressed=True.
|
|
55
57
|
"""
|
|
56
|
-
if relationship_context is None:
|
|
57
|
-
relationship_context = _load_relationship_context()
|
|
58
|
-
|
|
59
58
|
if return_suppressed:
|
|
60
|
-
|
|
61
|
-
sql,
|
|
62
|
-
dialect=dialect,
|
|
63
|
-
relationship_context=relationship_context,
|
|
64
|
-
return_suppressed=True,
|
|
59
|
+
return _core_validate(
|
|
60
|
+
sql, dialect=dialect, suppress=suppress, return_suppressed=True
|
|
65
61
|
)
|
|
66
|
-
|
|
67
|
-
d.to_dict() for d in suppressed
|
|
68
|
-
]
|
|
69
|
-
else:
|
|
70
|
-
diags = _core_validate(
|
|
71
|
-
sql,
|
|
72
|
-
dialect=dialect,
|
|
73
|
-
relationship_context=relationship_context,
|
|
74
|
-
)
|
|
75
|
-
suppressed_dicts = None
|
|
76
|
-
|
|
77
|
-
diag_dicts = [d.to_dict() for d in diags]
|
|
78
|
-
has_errors = any(d.get("severity") == "error" for d in diag_dicts)
|
|
79
|
-
|
|
80
|
-
return ValidateQueryResult(
|
|
81
|
-
has_errors=has_errors,
|
|
82
|
-
diagnostics=diag_dicts,
|
|
83
|
-
suppressed=suppressed_dicts,
|
|
84
|
-
)
|
|
62
|
+
return _core_validate(sql, dialect=dialect, suppress=suppress)
|
dataface/ai/mcp/server.py
CHANGED
|
@@ -257,8 +257,8 @@ async def run_server() -> None:
|
|
|
257
257
|
) from e
|
|
258
258
|
|
|
259
259
|
from dataface.agent_api import Project
|
|
260
|
+
from dataface.agent_api.cache import open_cache_from_env
|
|
260
261
|
from dataface.core.execute.adapters import LOCAL_AUTHORING_REGISTRY_KWARGS
|
|
261
|
-
from dataface.core.execute.duckdb_cache import open_cache_from_env
|
|
262
262
|
from dataface.core.serve.embedded import build_embedded_server
|
|
263
263
|
|
|
264
264
|
http_server, resolved_http_port = build_embedded_server()
|