dataface 0.1.5.dev206__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 (66) hide show
  1. dataface/DATAFACE_SYNTAX.md +28 -2
  2. dataface/agent_api/__init__.py +20 -0
  3. dataface/agent_api/docs/yaml-reference.md +5 -4
  4. dataface/agent_api/entity_paths.py +221 -0
  5. dataface/agent_api/project.py +61 -50
  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 +5 -5
  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/commands/serve.py +9 -4
  15. dataface/cli/main.py +13 -0
  16. dataface/core/compile/__init__.py +11 -3
  17. dataface/core/compile/compiler.py +128 -126
  18. dataface/core/compile/config.py +86 -34
  19. dataface/core/compile/models/face/authored.py +9 -0
  20. dataface/core/compile/models/face/normalized.py +8 -0
  21. dataface/core/compile/models/query/authored.py +6 -8
  22. dataface/core/compile/models/query/normalized.py +13 -13
  23. dataface/core/compile/normalize_queries.py +6 -6
  24. dataface/core/compile/normalizer.py +1 -0
  25. dataface/core/dashboard.py +13 -4
  26. dataface/core/errors/__init__.py +6 -0
  27. dataface/core/errors/codes_serve.py +36 -0
  28. dataface/core/errors/hints.py +13 -0
  29. dataface/core/execute/adapters/__init__.py +2 -2
  30. dataface/core/execute/adapters/adapter_registry.py +9 -11
  31. dataface/core/execute/adapters/{schema_resolver_adapter.py → schema_adapter.py} +8 -8
  32. dataface/core/inspect/renderer.py +23 -16
  33. dataface/core/inspect/templates/categorical_column.yml +1 -1
  34. dataface/core/inspect/templates/date_column.yml +1 -1
  35. dataface/core/inspect/templates/model.yml +1 -1
  36. dataface/core/inspect/templates/numeric_column.yml +1 -1
  37. dataface/core/inspect/templates/quality.yml +1 -1
  38. dataface/core/inspect/templates/string_column.yml +1 -1
  39. dataface/core/pack/models.py +1 -1
  40. dataface/core/render/face_api.py +2 -2
  41. dataface/core/render/nav.py +9 -5
  42. dataface/core/render/renderer.py +1 -1
  43. dataface/core/serve/alias_index.py +284 -0
  44. dataface/core/serve/bootstrap.py +22 -8
  45. dataface/core/serve/server.py +211 -20
  46. dataface/core/system_views/__init__.py +0 -0
  47. dataface/core/system_views/expander.py +383 -0
  48. dataface/core/system_views/loader.py +105 -0
  49. dataface/core/system_views/models.py +67 -0
  50. dataface/core/system_views/query_runner.py +248 -0
  51. dataface/core/system_views/registry.yaml +78 -0
  52. dataface/core/system_views/router.py +127 -0
  53. dataface/core/system_views/templates/entity/schema-index.yaml +21 -0
  54. dataface/core/system_views/templates/entity/source-index.yaml +20 -0
  55. dataface/core/system_views/templates/entity/table-detail.yaml +30 -0
  56. dataface/core/system_views/templates/entity/table-index.yaml +41 -0
  57. dataface/core/system_views/templates/inspector/column.yaml +24 -0
  58. dataface/core/system_views/templates/inspector/schema.yaml +20 -0
  59. dataface/core/system_views/templates/inspector/source.yaml +19 -0
  60. dataface/core/system_views/templates/inspector/table.yaml +23 -0
  61. dataface/core/system_views/variable_planner.py +327 -0
  62. {dataface-0.1.5.dev206.dist-info → dataface-0.1.5.dev237.dist-info}/METADATA +1 -1
  63. {dataface-0.1.5.dev206.dist-info → dataface-0.1.5.dev237.dist-info}/RECORD +66 -46
  64. {dataface-0.1.5.dev206.dist-info → dataface-0.1.5.dev237.dist-info}/WHEEL +0 -0
  65. {dataface-0.1.5.dev206.dist-info → dataface-0.1.5.dev237.dist-info}/entry_points.txt +0 -0
  66. {dataface-0.1.5.dev206.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:
@@ -346,7 +372,7 @@ queries:
346
372
  - [Bob, 87.1]
347
373
  ```
348
374
 
349
- Query types (`type:` literals): `sql`, `csv`, `http`, `dbt_model`, `metricflow`, `values`. `schema_resolver` is internal-only and not part of the authored surface.
375
+ Query types (`type:` literals): `sql`, `csv`, `http`, `dbt_model`, `metricflow`, `values`. `schema` is internal-only and not part of the authored surface.
350
376
 
351
377
  Common fields (all query types):
352
378
 
@@ -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. |
@@ -72,7 +73,7 @@ AuthoredQuery definition from YAML.
72
73
 
73
74
  | Field | Type | Optional | Description |
74
75
  |-------|------|:--------:|-------------|
75
- | `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. |
76
+ | `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. |
76
77
  | `source` | str \| dict[str, Any] | ✓ | Database source reference (name string) or inline source config dict. |
77
78
  | `target` | str | ✓ | dbt target name for dbt_model queries (defaults to 'dev'). |
78
79
  | `sql` | str | ✓ | SQL query string. Supports Jinja2 templates referencing variables. |
@@ -92,9 +93,9 @@ AuthoredQuery definition from YAML.
92
93
  | `filter` | dict[str, Any] | ✓ | Row-level filter applied to CSV or values data. |
93
94
  | `delimiter` | str | ✓ | Column delimiter for CSV queries. Default: comma (','). |
94
95
  | `encoding` | str | ✓ | File encoding for CSV queries. Default: UTF-8. |
95
- | `schema` | str | ✓ | Schema name for schema_resolver queries (YAML key: schema). |
96
- | `table` | str | ✓ | Table name for schema_resolver queries. |
97
- | `column` | str | ✓ | Column name for schema_resolver queries. |
96
+ | `schema` | str | ✓ | Schema name for schema queries (YAML key: schema). |
97
+ | `table` | str | ✓ | Table name for schema queries. |
98
+ | `column` | str | ✓ | Column name for schema queries. |
98
99
  | `rows` | list[dict[str, Any]] | ✓ | Inline data rows for values-type queries (list of row dicts). |
99
100
  | `values` | list[list[Any]] | ✓ | Inline column-oriented data for values-type queries (list of lists). |
100
101
  | `description` | str | ✓ | Human-readable description of the query. Used by AI search and tooling. |
@@ -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)
@@ -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
 
@@ -27,8 +28,8 @@ from dataface.agent_api.validate import (
27
28
  )
28
29
  from dataface.core.compile.config import (
29
30
  ProjectSourcesConfig,
30
- get_project_sources,
31
31
  get_project_warnings_ignore,
32
+ load_project_sources,
32
33
  )
33
34
  from dataface.core.execute.adapters.adapter_registry import (
34
35
  AdapterRegistry,
@@ -39,6 +40,7 @@ from dataface.core.execute.duckdb_cache import DuckDBCache
39
40
  if TYPE_CHECKING:
40
41
  from dataface.core.dashboard import RenderedDashboard, RenderFormat
41
42
  from dataface.core.execute.source_resolver import SourceResolver
43
+ from dataface.core.render.board_links import LinkContext
42
44
 
43
45
 
44
46
  @dataclass(init=False)
@@ -55,7 +57,6 @@ class Project:
55
57
  project_root: Path
56
58
  cache: DuckDBCache | None
57
59
  _owns_registry: bool
58
- _adapter_registry: AdapterRegistry | None
59
60
  _read_only: bool
60
61
  _dbt_project_path: Path | None
61
62
  _connection_string: str | None
@@ -64,22 +65,26 @@ class Project:
64
65
  _duckdb_config: dict[str, Any] | None
65
66
  _allow_external_access_in_readonly: bool
66
67
  _resolver: SourceResolver | None
67
- _sources_cache: ProjectSourcesConfig | None
68
- _warnings_ignore_cache: frozenset[str] | None
68
+ _sources: ProjectSourcesConfig
69
+ _warnings_ignore: frozenset[str]
69
70
 
70
71
  def __init__(
71
72
  self,
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
78
80
  # We own the registry only when we'll lazy-build it ourselves. An injected
79
81
  # registry belongs to the caller; we use it but don't close it on their behalf.
80
82
  self._owns_registry = adapter_registry is None
81
- self._adapter_registry = adapter_registry
82
- self._read_only = True
83
+ if adapter_registry is not None:
84
+ # cached_property stores in __dict__; pre-populating makes the descriptor
85
+ # short-circuit the build on first access.
86
+ self.__dict__["adapter_registry"] = adapter_registry
87
+ self._read_only = read_only
83
88
  self._dbt_project_path = None
84
89
  self._connection_string = None
85
90
  self._dialect = "duckdb"
@@ -87,8 +92,8 @@ class Project:
87
92
  self._duckdb_config = None
88
93
  self._allow_external_access_in_readonly = False
89
94
  self._resolver = None
90
- self._sources_cache = None
91
- self._warnings_ignore_cache = None
95
+ self._sources = load_project_sources(self.project_root)
96
+ self._warnings_ignore = get_project_warnings_ignore(self.project_root)
92
97
 
93
98
  @classmethod
94
99
  def open(
@@ -116,8 +121,9 @@ class Project:
116
121
  Call ``refresh()`` to close the current registry and force a rebuild on the
117
122
  next access.
118
123
  """
119
- project = cls(project_root=Path(project_dir).resolve(), cache=cache)
120
- project._read_only = read_only
124
+ project = cls(
125
+ project_root=Path(project_dir).resolve(), cache=cache, read_only=read_only
126
+ )
121
127
  project._dbt_project_path = dbt_project_path
122
128
  project._connection_string = connection_string
123
129
  project._dialect = dialect
@@ -133,7 +139,7 @@ class Project:
133
139
  def __exit__(self, *exc_info: object) -> None:
134
140
  self.close()
135
141
 
136
- @property
142
+ @cached_property
137
143
  def adapter_registry(self) -> AdapterRegistry:
138
144
  """Lazily build the registry on first access; cached thereafter.
139
145
 
@@ -141,65 +147,67 @@ class Project:
141
147
  own it (i.e. it was not injected at construction time).
142
148
  When constructed with an injected registry, returns it directly.
143
149
  """
144
- if self._adapter_registry is None:
145
- self._adapter_registry = build_adapter_registry(
146
- self.project_root,
147
- read_only=self._read_only,
148
- dbt_project_path=self._dbt_project_path,
149
- connection_string=self._connection_string,
150
- profile_type=self._dialect,
151
- target=self._target,
152
- duckdb_config=self._duckdb_config,
153
- allow_external_access_in_readonly=self._allow_external_access_in_readonly,
154
- resolver=self._resolver,
155
- )
156
- return self._adapter_registry
150
+ return build_adapter_registry(
151
+ self.project_root,
152
+ read_only=self._read_only,
153
+ dbt_project_path=self._dbt_project_path,
154
+ connection_string=self._connection_string,
155
+ profile_type=self._dialect,
156
+ target=self._target,
157
+ duckdb_config=self._duckdb_config,
158
+ allow_external_access_in_readonly=self._allow_external_access_in_readonly,
159
+ resolver=self._resolver,
160
+ )
157
161
 
158
162
  def refresh(self) -> None:
159
163
  """Policy-free rebuild primitive.
160
164
 
161
165
  For projects opened via ``open()``: closes the current registry (if built)
162
- and clears it so the next access rebuilds from disk. Also clears the
163
- sources and warnings_ignore caches so the next call re-reads from disk.
166
+ and clears it so the next access rebuilds from disk. Also re-reads
167
+ sources and warnings_ignore from disk.
164
168
 
165
169
  For projects constructed with an injected ``adapter_registry``: skips the
166
- registry rebuild (build arguments are not available), but still clears the
167
- config caches.
170
+ registry rebuild (build arguments are not available), but still re-reads
171
+ the config fields.
168
172
  """
169
- if self._owns_registry:
170
- if self._adapter_registry is not None:
171
- self._adapter_registry.close()
172
- self._adapter_registry = None
173
- self._sources_cache = None
174
- self._warnings_ignore_cache = None
173
+ if self._owns_registry and "adapter_registry" in self.__dict__:
174
+ self.adapter_registry.close()
175
+ del self.__dict__["adapter_registry"]
176
+ self._sources = load_project_sources(self.project_root)
177
+ self._warnings_ignore = get_project_warnings_ignore(self.project_root)
175
178
 
176
179
  def close(self) -> None:
177
180
  """Close the adapter registry iff we own it. The cache is the caller's to close."""
178
- if self._owns_registry and self._adapter_registry is not None:
179
- self._adapter_registry.close()
180
- self._adapter_registry = None
181
-
182
- # ── Config accessors ─────────────────────────────────────────────────────
181
+ if self._owns_registry and "adapter_registry" in self.__dict__:
182
+ self.adapter_registry.close()
183
+ del self.__dict__["adapter_registry"]
183
184
 
185
+ # Read-only views: project lifecycle owns these; external writers go through refresh().
186
+ @property
184
187
  def sources(self) -> ProjectSourcesConfig:
185
- """Project-level sources config. Cached on the instance; refresh() invalidates."""
186
- if self._sources_cache is None:
187
- self._sources_cache = get_project_sources(self.project_root)
188
- return self._sources_cache
188
+ return self._sources
189
189
 
190
+ @property
190
191
  def warnings_ignore(self) -> frozenset[str]:
191
- """Warning codes to suppress. Cached on the instance; refresh() invalidates."""
192
- if self._warnings_ignore_cache is None:
193
- self._warnings_ignore_cache = get_project_warnings_ignore(self.project_root)
194
- return self._warnings_ignore_cache
192
+ return self._warnings_ignore
195
193
 
196
194
  # ── Verb forwarders ──────────────────────────────────────────────────────
197
195
 
198
196
  def validate(self, face_path: Path) -> ValidateResult:
199
- 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]
200
200
 
201
201
  def validate_paths(self, paths: list[Path] | None) -> list[ValidateResult]:
202
- 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())
203
211
 
204
212
  def describe_face(self, path: Path) -> _describe.DescribeFaceResult:
205
213
  return _describe.describe_face(path, project_dir=self.project_root)
@@ -293,6 +301,7 @@ class Project:
293
301
  scale: float | None = None,
294
302
  ignore_codes: set[str] | None = None,
295
303
  max_workers: int | None = None,
304
+ link_context: LinkContext | None = None,
296
305
  ) -> RenderedDashboard:
297
306
  return _dashboards.render_dashboard(
298
307
  path=path,
@@ -300,6 +309,7 @@ class Project:
300
309
  variables=variables,
301
310
  adapter_registry=self.adapter_registry,
302
311
  project_dir=self.project_root,
312
+ project_sources=self.sources,
303
313
  duckdb_cache=self.cache,
304
314
  format=format,
305
315
  use_cache=use_cache,
@@ -308,5 +318,6 @@ class Project:
308
318
  scale=scale,
309
319
  ignore_codes=ignore_codes,
310
320
  max_workers=max_workers,
311
- warnings_ignore=self.warnings_ignore(),
321
+ warnings_ignore=self.warnings_ignore,
322
+ link_context=link_context,
312
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
+ )