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.
Files changed (47) hide show
  1. dataface/DATAFACE_SYNTAX.md +27 -1
  2. dataface/agent_api/__init__.py +20 -0
  3. dataface/agent_api/docs/yaml-reference.md +1 -0
  4. dataface/agent_api/entity_paths.py +221 -0
  5. dataface/agent_api/project.py +23 -8
  6. dataface/agent_api/schema_hints.py +49 -0
  7. dataface/agent_api/validate.py +48 -0
  8. dataface/agent_api/validate_query.py +40 -62
  9. dataface/ai/mcp/server.py +1 -1
  10. dataface/ai/tools/__init__.py +4 -4
  11. dataface/cli/commands/_agent_server.py +10 -4
  12. dataface/cli/commands/query.py +54 -36
  13. dataface/cli/commands/schema.py +29 -0
  14. dataface/cli/main.py +13 -0
  15. dataface/core/compile/__init__.py +6 -2
  16. dataface/core/compile/compiler.py +111 -85
  17. dataface/core/compile/config.py +62 -39
  18. dataface/core/compile/models/face/authored.py +9 -0
  19. dataface/core/compile/models/face/normalized.py +8 -0
  20. dataface/core/compile/normalizer.py +1 -0
  21. dataface/core/dashboard.py +2 -2
  22. dataface/core/execute/adapters/adapter_registry.py +2 -2
  23. dataface/core/inspect/renderer.py +2 -2
  24. dataface/core/render/face_api.py +2 -2
  25. dataface/core/serve/alias_index.py +284 -0
  26. dataface/core/serve/server.py +207 -20
  27. dataface/core/system_views/__init__.py +0 -0
  28. dataface/core/system_views/expander.py +383 -0
  29. dataface/core/system_views/loader.py +105 -0
  30. dataface/core/system_views/models.py +67 -0
  31. dataface/core/system_views/query_runner.py +248 -0
  32. dataface/core/system_views/registry.yaml +78 -0
  33. dataface/core/system_views/router.py +127 -0
  34. dataface/core/system_views/templates/entity/schema-index.yaml +21 -0
  35. dataface/core/system_views/templates/entity/source-index.yaml +20 -0
  36. dataface/core/system_views/templates/entity/table-detail.yaml +30 -0
  37. dataface/core/system_views/templates/entity/table-index.yaml +41 -0
  38. dataface/core/system_views/templates/inspector/column.yaml +24 -0
  39. dataface/core/system_views/templates/inspector/schema.yaml +20 -0
  40. dataface/core/system_views/templates/inspector/source.yaml +19 -0
  41. dataface/core/system_views/templates/inspector/table.yaml +23 -0
  42. dataface/core/system_views/variable_planner.py +327 -0
  43. {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev237.dist-info}/METADATA +1 -1
  44. {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev237.dist-info}/RECORD +47 -28
  45. {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev237.dist-info}/WHEEL +0 -0
  46. {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev237.dist-info}/entry_points.txt +0 -0
  47. {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev237.dist-info}/licenses/LICENSE +0 -0
@@ -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 (23 total):
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:
@@ -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)
@@ -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 = True
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 = get_project_sources(self.project_root)
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(project_root=Path(project_dir).resolve(), cache=cache)
123
- project._read_only = read_only
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 = get_project_sources(self.project_root)
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
- return _validate.validate(face_path, project_dir=self.project_root)
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
- return _validate.validate_paths(paths, project_dir=self.project_root)
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
+ )
@@ -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 — static SQL lint without warehouse access."""
1
+ """validate_query verb — stateless SQL lint, returns typed QueryDiagnostic list."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import importlib.util
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
- RelationshipContext,
8
+ QueryDiagnostic,
12
9
  validate_query as _core_validate,
13
10
  )
14
11
 
15
- _SUPER_SCHEMA_AVAILABLE = importlib.util.find_spec("dataface_super_schema") is not None
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
- return load_relationship_context()
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
- class ValidateQueryResult(BaseModel):
30
- has_errors: bool = Field(
31
- False,
32
- description="True when any active diagnostic has severity == 'error'.",
33
- )
34
- diagnostics: list[dict[str, Any]] = Field(
35
- default_factory=list,
36
- description="Active diagnostic messages (non-suppressed).",
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
- relationship_context: RelationshipContext | None = None,
50
- ) -> ValidateQueryResult:
51
- """Run static SQL validation and return a typed result.
52
-
53
- Loads relationship context from the inspection cache automatically (same
54
- as the CLI command). Pass relationship_context explicitly to override.
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
- diags, suppressed = _core_validate(
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
- suppressed_dicts: list[dict[str, Any]] | None = [
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()