dataface 0.1.6.dev321__py3-none-any.whl → 0.1.6.dev360__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 (61) hide show
  1. dataface/agent_api/describe_query.py +3 -1
  2. dataface/agent_api/docs/yaml-reference.md +4 -2
  3. dataface/agent_api/inspect.py +2 -1
  4. dataface/agent_api/pack.py +7 -4
  5. dataface/agent_api/query.py +2 -2
  6. dataface/agent_api/render_face.py +5 -7
  7. dataface/ai/prompts/sql-guidance.md +2 -2
  8. dataface/cli/commands/_agent_input.py +1 -1
  9. dataface/cli/commands/docs.py +5 -5
  10. dataface/core/compile/compiler.py +47 -35
  11. dataface/core/compile/errors.py +2 -2
  12. dataface/core/compile/filter_injection.py +1 -1
  13. dataface/core/compile/jinja.py +3 -3
  14. dataface/core/compile/markdown.py +7 -6
  15. dataface/core/compile/models/chart/authored.py +1 -1
  16. dataface/core/compile/models/face/authored.py +13 -0
  17. dataface/core/compile/models/face/normalized.py +9 -0
  18. dataface/core/compile/models/source.py +7 -0
  19. dataface/core/compile/models/style/resolved.py +1 -1
  20. dataface/core/compile/models/style/theme.py +2 -2
  21. dataface/core/compile/models/vega_lite/config.py +4 -2
  22. dataface/core/compile/normalize_charts.py +10 -6
  23. dataface/core/compile/normalize_layout.py +58 -46
  24. dataface/core/compile/normalize_queries.py +28 -19
  25. dataface/core/compile/normalize_variables.py +10 -10
  26. dataface/core/compile/normalizer.py +16 -11
  27. dataface/core/compile/parameterized.py +2 -2
  28. dataface/core/compile/sizing.py +3 -3
  29. dataface/core/compile/yaml_error_formatter.py +1 -1
  30. dataface/core/dashboard.py +1 -2
  31. dataface/core/execute/adapters/base.py +1 -1
  32. dataface/core/execute/adapters/dbt_adapter_factory.py +1 -1
  33. dataface/core/execute/dialects/base.py +3 -2
  34. dataface/core/execute/duckdb_cache.py +1 -1
  35. dataface/core/execute/executor.py +5 -3
  36. dataface/core/execute/parallel.py +2 -2
  37. dataface/core/inspect/manifest_utils.py +3 -3
  38. dataface/core/project.py +93 -0
  39. dataface/core/registered_views/expander.py +0 -38
  40. dataface/core/registered_views/link_keys.py +42 -0
  41. dataface/core/registered_views/render_pipeline.py +3 -1
  42. dataface/core/registered_views/templates/data/table-index.yaml +4 -11
  43. dataface/core/render/board_links.py +35 -13
  44. dataface/core/render/chart/auto_link.py +187 -0
  45. dataface/core/render/chart/decisions.py +2 -2
  46. dataface/core/render/chart/pipeline.py +2 -2
  47. dataface/core/render/chart/render_single.py +32 -0
  48. dataface/core/render/chart/table.py +2 -2
  49. dataface/core/render/chart_interactivity.py +2 -2
  50. dataface/core/render/face_api.py +6 -4
  51. dataface/core/render/renderer.py +5 -0
  52. dataface/core/render/terminal_charts.py +1 -1
  53. dataface/core/render/text_format.py +1 -1
  54. dataface/core/utils.py +1 -1
  55. dataface/core/validate.py +9 -6
  56. dataface/integrations/markdown.py +5 -2
  57. {dataface-0.1.6.dev321.dist-info → dataface-0.1.6.dev360.dist-info}/METADATA +1 -1
  58. {dataface-0.1.6.dev321.dist-info → dataface-0.1.6.dev360.dist-info}/RECORD +61 -59
  59. {dataface-0.1.6.dev321.dist-info → dataface-0.1.6.dev360.dist-info}/WHEEL +0 -0
  60. {dataface-0.1.6.dev321.dist-info → dataface-0.1.6.dev360.dist-info}/entry_points.txt +0 -0
  61. {dataface-0.1.6.dev321.dist-info → dataface-0.1.6.dev360.dist-info}/licenses/LICENSE +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import Any
6
+
5
7
  from pydantic import BaseModel, Field
6
8
 
7
9
  from dataface.core.execute.adapters import AdapterRegistry
@@ -38,7 +40,7 @@ class DescribeQueryColumn(BaseModel):
38
40
  class DescribeQueryResult(BaseModel):
39
41
  success: bool
40
42
  columns: list[DescribeQueryColumn] | None = None
41
- diagnostics: list[dict] = Field(
43
+ diagnostics: list[dict[str, Any]] = Field(
42
44
  default_factory=list,
43
45
  description="Dialect-specific diagnostic messages from the query runner.",
44
46
  )
@@ -31,6 +31,7 @@ AuthoredFace (dataface) definition from YAML.
31
31
  | `height` | str \| int | ✓ | Height when nested (e.g., '300px' or an integer in pixels). |
32
32
  | `visible` | bool \| str \| [SingleRowBoolProbe](#singlerowboolprobe) | ✓ | Controls whether this layout item is rendered. Accepts a bool, variable name, Jinja expression, or {query, column} probe. |
33
33
  | `theme` | str | ✓ | Theme name (e.g., 'editorial', 'cream', 'stark'). |
34
+ | `auto_link` | bool | ✓ | When True, table charts with no explicit link: automatically link each row to its canonical /data/<source>/<schema>/<table>/detail/ page. Default off. Explicit link: always wins; set link: ~ to suppress per chart. |
34
35
 
35
36
  <a id="sourcessection"></a>
36
37
  ## SourcesSection
@@ -1390,7 +1391,7 @@ Authored overlay for AxisStyle.
1390
1391
  | `band_position` | float | ✓ | Band position within the step (0–1); None uses Vega-Lite's default. |
1391
1392
  | `offset` | float | ✓ | Pixel offset of the axis from its default position; None means no offset. |
1392
1393
  | `format` | str | ✓ | Tick value format string; None uses auto-format. |
1393
- | `values` | list | ✓ | Explicit tick values; None uses Vega-Lite's auto tick values. |
1394
+ | `values` | list[Any] | ✓ | Explicit tick values; None uses Vega-Lite's auto tick values. |
1394
1395
  | `time_unit` | enum: "auto", "year", "yearquarter", "yearmonth", "yearweek", "yearmonthdate", "monthofyear", "dayofweek", "dayofmonth", "dayofyear", "hourofday", "none" | ✓ | Time-unit bucketing for temporal x-axes; None or 'auto' auto-detects from data. |
1395
1396
  | `scale` | [ScaleStyle](#scalestyle) | ✓ | Per-axis scale overrides; None means no override. |
1396
1397
  | `type` | enum: "auto", "ordinal", "temporal" | ✓ | Scale type for bucketed-time x-axes; None/'auto' infers from time_unit grain. |
@@ -1416,7 +1417,7 @@ Authored overlay for ScaleStyle. Scale configuration primitive.
1416
1417
  | `use_unaggregated_domain` | bool | ✓ | Use unaggregated domain for scale extent; None uses Vega-Lite's default. |
1417
1418
  | `x_reverse` | bool | ✓ | Reverse the x-axis scale direction; None means no reversal. |
1418
1419
  | `type` | str | ✓ | Scale type override; None lets Vega-Lite infer from field type. |
1419
- | `domain` | list | ✓ | Explicit scale domain values; None lets Vega-Lite auto-determine from data. |
1420
+ | `domain` | list[Any] | ✓ | Explicit scale domain values; None lets Vega-Lite auto-determine from data. |
1420
1421
  | `nice` | bool | ✓ | Round scale domain to nice values; None uses Vega-Lite's default. |
1421
1422
  | `padding` | float | ✓ | Unified scale padding shortcut; dispatches to band, point, or continuous padding per scale type. |
1422
1423
 
@@ -2608,6 +2609,7 @@ Postgres source configuration.
2608
2609
  | `password` | str | | Database password. |
2609
2610
  | `port` | int | ✓ | Database port number. |
2610
2611
  | `schema` | str | ✓ | Default schema for queries. |
2612
+ | `sslmode` | enum: "disable", "allow", "prefer", "require", "verify-ca", "verify-full" | ✓ | libpq SSL mode forwarded to psycopg2. None lets libpq decide its default. |
2611
2613
 
2612
2614
  <a id="snowflakesourceconfig"></a>
2613
2615
  ## SnowflakeSourceConfig
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
  from datetime import datetime, timezone
12
12
  from hashlib import sha256
13
13
  from pathlib import Path
14
+ from typing import Any
14
15
 
15
16
  from pydantic import BaseModel
16
17
 
@@ -109,7 +110,7 @@ def validate_ejected_templates(target_dir: Path) -> ValidateTemplatesResult:
109
110
  )
110
111
 
111
112
  manifest = load_manifest(target_dir, dataface_version=__version__)
112
- manifest_templates: dict[str, dict] = manifest.get("templates", {})
113
+ manifest_templates: dict[str, dict[str, Any]] = manifest.get("templates", {})
113
114
  templates_pkg = _pkg_files("dataface.core.inspect.templates")
114
115
 
115
116
  missing, upstream_changed, custom_safe, unchanged = compare_templates(
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import re
16
16
  from pathlib import Path
17
+ from typing import Any
17
18
 
18
19
  import yaml
19
20
  from pydantic import BaseModel
@@ -254,7 +255,7 @@ def propose_pack(
254
255
  return proposal, out_path
255
256
 
256
257
 
257
- def _build_face_dict(dashboard: ProposedDashboard) -> dict:
258
+ def _build_face_dict(dashboard: ProposedDashboard) -> dict[str, Any]:
258
259
  """Build a minimal valid face dict from a ProposedDashboard.
259
260
 
260
261
  Sparse + valid + no TODO placeholders. Emits title + description + text only.
@@ -266,7 +267,7 @@ def _build_face_dict(dashboard: ProposedDashboard) -> dict:
266
267
  is included so the data URL redirects to this authored face. The alias
267
268
  lives on the face file — no central mapping needed.
268
269
  """
269
- face: dict = {
270
+ face: dict[str, Any] = {
270
271
  "title": dashboard.title,
271
272
  "description": dashboard.purpose,
272
273
  "text": (
@@ -278,7 +279,9 @@ def _build_face_dict(dashboard: ProposedDashboard) -> dict:
278
279
  return face
279
280
 
280
281
 
281
- def _build_index_dict(folder_path: str, dashboards: list[ProposedDashboard]) -> dict:
282
+ def _build_index_dict(
283
+ folder_path: str, dashboards: list[ProposedDashboard]
284
+ ) -> dict[str, Any]:
282
285
  """Build the index.yml landing face for a folder.
283
286
 
284
287
  Generates a simple text-only landing that lists the dashboards in the folder.
@@ -302,7 +305,7 @@ def _build_index_dict(folder_path: str, dashboards: list[ProposedDashboard]) ->
302
305
  }
303
306
 
304
307
 
305
- def _write_face(path: Path, face_dict: dict) -> None:
308
+ def _write_face(path: Path, face_dict: dict[str, Any]) -> None:
306
309
  """Write a face dict to a YAML file in block style."""
307
310
  path.parent.mkdir(parents=True, exist_ok=True)
308
311
  path.write_text(
@@ -102,7 +102,7 @@ class ExecuteQueryResult(BaseModel):
102
102
  errors: list[str]
103
103
  row_count: int
104
104
  truncated: bool
105
- diagnostics: list[dict] = Field(
105
+ diagnostics: list[dict[str, Any]] = Field(
106
106
  default_factory=list,
107
107
  description=(
108
108
  "Deterministic validate_query findings (fanout_risk, "
@@ -114,7 +114,7 @@ class ExecuteQueryResult(BaseModel):
114
114
 
115
115
  def _query_diagnostics(
116
116
  sql: str, source: str | None, adapter_registry: AdapterRegistry
117
- ) -> list[dict]:
117
+ ) -> list[dict[str, Any]]:
118
118
  """Run the structural validator over *sql*; never raise on a real query.
119
119
 
120
120
  Resolves the source dialect when possible so dialect-specific parsing is
@@ -24,7 +24,6 @@ def render_face(
24
24
  warnings_ignore: frozenset[str] | None = None,
25
25
  format: str = "svg",
26
26
  project: Project | None = None,
27
- base_dir: str | Path | None = None,
28
27
  variables: dict[str, str] | None = None,
29
28
  use_cache: bool = True,
30
29
  cache: DuckDBCache | None = None,
@@ -40,8 +39,6 @@ def render_face(
40
39
  format: Output format — "svg", "html", "png", "pdf", etc.
41
40
  project: Explicit project. When omitted, auto-discovered upward from
42
41
  the face file's directory via ``ProjectSession.from_face``.
43
- base_dir: Base directory for resolving relative paths in the face.
44
- Defaults to the face file's parent directory.
45
42
  variables: Runtime variable overrides.
46
43
  use_cache: Whether to use the in-memory Executor result cache.
47
44
  cache: Optional DuckDB query-result cache. When provided, the caller
@@ -63,8 +60,6 @@ def render_face(
63
60
  else:
64
61
  yaml_content = raw
65
62
 
66
- resolved_base_dir = Path(base_dir).resolve() if base_dir else face_file.parent
67
-
68
63
  if project is not None:
69
64
  if warnings_ignore is None:
70
65
  warnings_ignore = project.warnings_ignore
@@ -74,13 +69,16 @@ def render_face(
74
69
  warnings_ignore = frozenset()
75
70
  project_session = ProjectSession.from_face(face_file, cache=cache)
76
71
  try:
72
+ p = project_session.project
73
+ base_file = p.file_for_path(face_file)
77
74
  return compile_and_render(
78
75
  yaml_content,
79
- project_session.project,
76
+ p,
80
77
  adapter_registry=project_session.adapter_registry,
81
78
  cache=cache,
82
79
  warnings_ignore=warnings_ignore,
83
- base_dir=resolved_base_dir,
80
+ base_file=base_file,
81
+ face_dir=face_file.parent,
84
82
  format=format,
85
83
  variables=variables,
86
84
  use_cache=use_cache,
@@ -4,11 +4,11 @@ Before writing SQL, write a short query plan:
4
4
 
5
5
  - **OUTPUT**: the columns to return, with clear aliases that describe the value
6
6
  - **FROM/JOIN**: the tables and the join keys between them
7
- - **GRAIN**: what one row represents after the joins; guard against fan-out / double-counting on the non-unique side of a join — if a join multiplies rows, aggregate before joining or add a GROUP BY
7
+ - **GRAIN**: what one row represents after the joins; guard against fan-out / double-counting on the non-unique side of a join — if a join multiplies rows, aggregate before joining or add a GROUP BY. If the schema context already states a join's multiplicity (e.g. one-to-many) or a column's uniqueness, use that to decide grain — don't spend a query probing for what the schema already tells you
8
8
  - **FILTERS**: each WHERE condition; verify the exact stored value before filtering (stored values may differ from display labels — e.g. `'LA'` not `'Los Angeles'`)
9
9
  - **SORT/LIMIT**: include if the question asks to rank, sort, or return a top-N
10
10
 
11
- Then translate the plan faithfully into SQL. Verify by executing the query and checking that shape and values match the plan.
11
+ Then translate the plan faithfully into SQL. Verify by executing the query and checking that shape and values match the plan. When you run a query, heed any diagnostics it returns — a `fanout_risk`, `missing_join_predicate`, or `reaggregation` warning means the join or aggregate is probably double-counting even though the query ran; fix the grain before trusting the numbers.
12
12
 
13
13
  Additional rules:
14
14
 
@@ -147,7 +147,7 @@ class PromptToolkitInput:
147
147
  def _newline_meta(event: Any) -> None: # pyright: ignore[reportUnusedFunction] # decorator-registered — pyright cannot model runtime registration # fmt: skip
148
148
  event.current_buffer.insert_text("\n")
149
149
 
150
- self._session: PromptSession = PromptSession(
150
+ self._session: PromptSession[str] = PromptSession(
151
151
  history=FileHistory(str(hist_path)),
152
152
  # DftCompleter is duck-typed against the Completer protocol; we
153
153
  # don't inherit because prompt_toolkit imports are deferred to
@@ -6,7 +6,7 @@ from rich.padding import Padding
6
6
  from rich.table import Table
7
7
 
8
8
  from dataface._docs_site import docs_site_url
9
- from dataface.agent_api.docs import docs as _docs
9
+ from dataface.agent_api.docs import TopicEntry, docs as _docs
10
10
  from dataface.agent_api.docs.warnings import get_warning_code, list_warning_codes
11
11
  from dataface.cli._console import dft_console, is_plain_output
12
12
  from dataface.cli._json_output import print_json_result
@@ -119,7 +119,7 @@ def docs_command(
119
119
  _emit_markdown(result.topic.content)
120
120
 
121
121
 
122
- def _emit_topic_index(topics: list) -> None:
122
+ def _emit_topic_index(topics: list[TopicEntry]) -> None:
123
123
  if not topics:
124
124
  typer.echo("No topics found.")
125
125
  return
@@ -131,7 +131,7 @@ def _emit_topic_index(topics: list) -> None:
131
131
  _emit_topic_index_plain(topics, web_docs)
132
132
 
133
133
 
134
- def _emit_topic_index_rich(topics: list, web_docs: str) -> None:
134
+ def _emit_topic_index_rich(topics: list[TopicEntry], web_docs: str) -> None:
135
135
  typer.echo(
136
136
  "Dataface (`dft`) is a dbt-native dashboard layer. You author dashboards as "
137
137
  'YAML "faces" — queries, charts, variables, and layout — and `dft` compiles '
@@ -158,7 +158,7 @@ def _emit_topic_index_rich(topics: list, web_docs: str) -> None:
158
158
  typer.echo(" dft skills Agent workflows and layout patterns")
159
159
 
160
160
 
161
- def _emit_topic_index_plain(topics: list, web_docs: str) -> None:
161
+ def _emit_topic_index_plain(topics: list[TopicEntry], web_docs: str) -> None:
162
162
  typer.echo(
163
163
  "Dataface (`dft`) is a dbt-native dashboard layer. You author dashboards as "
164
164
  'YAML "faces" — queries, charts, variables, and layout — and `dft` compiles '
@@ -185,7 +185,7 @@ def _emit_topic_index_plain(topics: list, web_docs: str) -> None:
185
185
  typer.echo(" dft skills Agent workflows and layout patterns")
186
186
 
187
187
 
188
- def _topic_table(topics: list) -> Table:
188
+ def _topic_table(topics: list[TopicEntry]) -> Table:
189
189
  name_width = max(len(entry.id) for entry in topics)
190
190
  table = Table(show_header=False, box=None, padding=(0, 2, 0, 0))
191
191
  table.add_column("Topic", style="bold", no_wrap=True, width=name_width)
@@ -68,7 +68,7 @@ from dataface.core.compile.yaml_error_formatter import (
68
68
  format_validation_errors_structured,
69
69
  )
70
70
  from dataface.core.errors.structured import StructuredError
71
- from dataface.core.project import Project
71
+ from dataface.core.project import Project, ProjectFile
72
72
  from dataface.core.render.warnings import unreferenced_chart
73
73
  from dataface.core.render.warnings.base import RenderWarning
74
74
  from dataface.core.render.warnings.from_query_diagnostic import from_query_diagnostic
@@ -140,7 +140,7 @@ class CompileResult:
140
140
 
141
141
  def compile_authored_face(
142
142
  authored: AuthoredFace,
143
- base_dir: Path | None = None,
143
+ base_file: ProjectFile | None = None,
144
144
  project_sources: ProjectSourcesConfig | None = None,
145
145
  _yaml_content: str = "",
146
146
  ) -> CompileResult:
@@ -152,7 +152,7 @@ def compile_authored_face(
152
152
 
153
153
  Args:
154
154
  authored: Parsed face from the registered-view expander.
155
- base_dir: Base directory for resolving file references.
155
+ base_file: ProjectFile handle for the face file (used to resolve sub-file refs).
156
156
  project_sources: Project-level sources config.
157
157
  _yaml_content: Original YAML text, used only to produce richer
158
158
  ``format_validation_errors_structured`` output on PydanticValidationError.
@@ -188,7 +188,7 @@ def compile_authored_face(
188
188
  # ════════════════════════════════════════════════════════════════════
189
189
  try:
190
190
  query_registry = build_query_registry(
191
- authored, base_dir, default_source=default_source
191
+ authored, base_file, default_source=default_source
192
192
  )
193
193
  except CompilationError as e:
194
194
  return CompileResult(errors=[e.to_structured()])
@@ -206,7 +206,7 @@ def compile_authored_face(
206
206
  # STEP 5b: Build Chart Registry
207
207
  # ════════════════════════════════════════════════════════════════════
208
208
  try:
209
- chart_registry = build_chart_registry(authored, base_dir=base_dir)
209
+ chart_registry = build_chart_registry(authored, base_file=base_file)
210
210
  except CompilationError as e:
211
211
  return CompileResult(errors=[e.to_structured()])
212
212
 
@@ -218,7 +218,7 @@ def compile_authored_face(
218
218
  authored,
219
219
  query_registry=query_registry,
220
220
  chart_registry=chart_registry,
221
- base_path=base_dir,
221
+ base_file=base_file,
222
222
  )
223
223
  except ReferenceError as e:
224
224
  return CompileResult(errors=[e.to_structured()])
@@ -262,7 +262,7 @@ def compile_authored_face(
262
262
  def compile(
263
263
  yaml_content: str,
264
264
  options: dict[str, Any] | None = None,
265
- base_dir: Path | None = None,
265
+ base_file: ProjectFile | None = None,
266
266
  project_sources: ProjectSourcesConfig | None = None,
267
267
  ) -> CompileResult:
268
268
  """Compile YAML content to a Face.
@@ -278,7 +278,7 @@ def compile(
278
278
  Args:
279
279
  yaml_content: YAML string to compile
280
280
  options: Optional compilation options
281
- base_dir: Base directory for resolving file references
281
+ base_file: ProjectFile handle for the face file (used to resolve sub-file refs).
282
282
  project_sources: Project-level sources.
283
283
 
284
284
  Returns:
@@ -319,7 +319,7 @@ def compile(
319
319
  return _compile_with_text(
320
320
  face,
321
321
  yaml_content,
322
- base_dir=base_dir,
322
+ base_file=base_file,
323
323
  project_sources=project_sources,
324
324
  meta_lint=options.get("meta_lint"),
325
325
  )
@@ -342,7 +342,7 @@ def _parse_error_to_structured(
342
342
  def _compile_with_text(
343
343
  face: AuthoredFace,
344
344
  yaml_content: str,
345
- base_dir: Path | None,
345
+ base_file: ProjectFile | None,
346
346
  project_sources: ProjectSourcesConfig | None,
347
347
  meta_lint: MetaLintConfig | None,
348
348
  ) -> CompileResult:
@@ -355,7 +355,7 @@ def _compile_with_text(
355
355
  authoring_warnings = detect_authoring_warnings(yaml_content)
356
356
  result = compile_authored_face(
357
357
  face,
358
- base_dir=base_dir,
358
+ base_file=base_file,
359
359
  project_sources=project_sources,
360
360
  _yaml_content=yaml_content,
361
361
  )
@@ -498,7 +498,10 @@ def compile_file(
498
498
  errors=[CompilationError(f"File not found: {file_path}").to_structured()]
499
499
  )
500
500
 
501
- # Markdown report files: translate to YAML before compiling
501
+ face_file = project.file_for_path(file_path)
502
+
503
+ # Markdown report files: translate to YAML before compiling.
504
+ # is_markdown_face stays path-based (upward directory walk, out of scope).
502
505
  from dataface.core.compile.markdown import (
503
506
  MARKDOWN_NOT_FACE_MESSAGE,
504
507
  MARKDOWN_SUFFIXES,
@@ -523,7 +526,7 @@ def compile_file(
523
526
  )
524
527
  else:
525
528
  try:
526
- yaml_content = file_path.read_text(encoding="utf-8")
529
+ yaml_content = face_file.read_text()
527
530
  except OSError as e:
528
531
  return CompileResult(
529
532
  errors=[CompilationError(f"Failed to read file: {e}").to_structured()]
@@ -556,7 +559,7 @@ def compile_file(
556
559
  result = _compile_with_text(
557
560
  face,
558
561
  yaml_content,
559
- base_dir=file_path.parent,
562
+ base_file=face_file,
560
563
  project_sources=project.sources,
561
564
  meta_lint=meta_lint,
562
565
  )
@@ -654,8 +657,8 @@ def _build_registry(
654
657
  registry_type: Type of registry to build ("queries" or "charts")
655
658
  registry: Existing registry to add to (will be created if None)
656
659
  **kwargs: Additional arguments specific to registry type:
657
- - For "queries": base_dir, default_source
658
- - For "charts": base_dir
660
+ - For "queries": base_file, default_source
661
+ - For "charts": base_file
659
662
 
660
663
  Returns:
661
664
  Complete registry dictionary
@@ -685,7 +688,7 @@ def _build_registry(
685
688
  def _process_queries_for_registry(
686
689
  face: AuthoredFace,
687
690
  registry: dict[str, AnyQuery],
688
- base_dir: Path | None = None,
691
+ base_file: ProjectFile | None = None,
689
692
  default_source: str | None = None,
690
693
  ) -> None:
691
694
  """Process queries from a face and add to registry.
@@ -693,7 +696,7 @@ def _process_queries_for_registry(
693
696
  Args:
694
697
  face: AuthoredFace to process queries from
695
698
  registry: Registry to add queries to
696
- base_dir: Base directory for cross-file references
699
+ base_file: ProjectFile handle for the face file (used to resolve sub-file refs).
697
700
  default_source: Default source to apply to queries without explicit source
698
701
 
699
702
  Raises:
@@ -713,7 +716,7 @@ def _process_queries_for_registry(
713
716
 
714
717
  # Handle cross-file references
715
718
  if isinstance(query_def, QueryRef):
716
- registry[name] = load_from_reference(query_def, base_dir=base_dir)
719
+ registry[name] = load_from_reference(query_def, base_file=base_file)
717
720
  else:
718
721
  registry[name] = normalize_query(
719
722
  name, query_def, default_source=effective_default_source
@@ -723,7 +726,7 @@ def _process_queries_for_registry(
723
726
  def _process_charts_for_registry(
724
727
  face: AuthoredFace,
725
728
  registry: dict[str, Any],
726
- base_dir: Path | None = None,
729
+ base_file: ProjectFile | None = None,
727
730
  **kwargs: Any,
728
731
  ) -> None:
729
732
  """Process charts from a face and add to registry.
@@ -731,7 +734,7 @@ def _process_charts_for_registry(
731
734
  Args:
732
735
  face: AuthoredFace to process charts from
733
736
  registry: Registry to add charts to
734
- base_dir: Base directory for cross-file references
737
+ base_file: ProjectFile handle for the face file (used to resolve sub-file refs).
735
738
  **kwargs: Additional arguments (e.g., query_registry for future use)
736
739
 
737
740
  Raises:
@@ -747,14 +750,14 @@ def _process_charts_for_registry(
747
750
 
748
751
  # Handle cross-file references
749
752
  if isinstance(chart_def, ChartRef):
750
- registry[name] = load_from_reference(chart_def, base_dir=base_dir)
753
+ registry[name] = load_from_reference(chart_def, base_file=base_file)
751
754
  else:
752
755
  registry[name] = chart_def
753
756
 
754
757
 
755
758
  def build_query_registry(
756
759
  face: AuthoredFace,
757
- base_dir: Path | None = None,
760
+ base_file: ProjectFile | None = None,
758
761
  registry: dict[str, AnyQuery] | None = None,
759
762
  default_source: str | None = None,
760
763
  ) -> dict[str, AnyQuery]:
@@ -765,7 +768,7 @@ def build_query_registry(
765
768
 
766
769
  Args:
767
770
  face: AuthoredFace to process
768
- base_dir: Base directory for cross-file references
771
+ base_file: ProjectFile handle for the face file (used to resolve sub-file refs).
769
772
  registry: Existing registry to add to
770
773
  default_source: Default source to apply to queries without explicit source
771
774
 
@@ -779,14 +782,14 @@ def build_query_registry(
779
782
  face,
780
783
  "queries",
781
784
  registry=registry,
782
- base_dir=base_dir,
785
+ base_file=base_file,
783
786
  default_source=default_source,
784
787
  )
785
788
 
786
789
 
787
790
  def build_chart_registry(
788
791
  face: AuthoredFace,
789
- base_dir: Path | None = None,
792
+ base_file: ProjectFile | None = None,
790
793
  registry: dict[str, Any] | None = None,
791
794
  ) -> dict[str, Any]:
792
795
  """Build complete chart registry from face and nested faces.
@@ -796,7 +799,7 @@ def build_chart_registry(
796
799
 
797
800
  Args:
798
801
  face: AuthoredFace to process
799
- base_dir: Base directory for cross-file references
802
+ base_file: ProjectFile handle for the face file (used to resolve sub-file refs).
800
803
  registry: Existing registry to add to
801
804
 
802
805
  Returns:
@@ -805,19 +808,20 @@ def build_chart_registry(
805
808
  Raises:
806
809
  CompilationError: If duplicate chart names found
807
810
  """
808
- return _build_registry(face, "charts", registry=registry, base_dir=base_dir)
811
+ return _build_registry(face, "charts", registry=registry, base_file=base_file)
809
812
 
810
813
 
811
814
  def load_from_reference(
812
815
  reference: VariableRef | QueryRef | ChartRef,
813
- base_dir: Path | None = None,
816
+ base_file: ProjectFile | None = None,
814
817
  ) -> Any:
815
818
  """Load an item from a typed cross-file reference.
816
819
 
817
820
  Args:
818
821
  reference: A typed ref model (VariableRef, QueryRef, or ChartRef).
819
822
  The grammar has already been validated by the Pydantic model.
820
- base_dir: Base directory for resolving paths
823
+ base_file: ProjectFile handle for the face file (used to resolve paths).
824
+ If None and a sub-file ref is encountered, raises CompilationError.
821
825
 
822
826
  Returns:
823
827
  - QueryRef → AnyQuery
@@ -841,14 +845,22 @@ def load_from_reference(
841
845
  if not (file_path_str.endswith(".yml") or file_path_str.endswith(".yaml")):
842
846
  file_path_str += ".yml"
843
847
 
844
- full_path = base_dir / file_path_str if base_dir else Path(file_path_str)
848
+ if base_file is None:
849
+ raise CompilationError(
850
+ f"Cannot resolve cross-file reference '{reference.ref}': "
851
+ "no base file context (face was compiled without a ProjectFile)"
852
+ )
853
+
854
+ try:
855
+ ref_file = base_file.sibling(file_path_str)
856
+ except ValueError as e:
857
+ raise CompilationError(str(e)) from e
845
858
 
846
- if not full_path.exists():
859
+ if not ref_file.exists():
847
860
  raise CompilationError(f"Referenced file not found: {file_path_str}")
848
861
 
849
862
  try:
850
- with open(full_path) as f:
851
- content = yaml.safe_load(f)
863
+ content = ref_file.read_yaml()
852
864
  except (OSError, yaml.YAMLError) as e:
853
865
  raise CompilationError(f"Failed to load {file_path_str}: {e}") from e
854
866
 
@@ -881,7 +893,7 @@ def load_from_reference(
881
893
  )
882
894
 
883
895
 
884
- def _extract_sources(face: AuthoredFace) -> dict[str, dict]:
896
+ def _extract_sources(face: AuthoredFace) -> dict[str, dict[str, Any]]:
885
897
  """Extract named source configurations from AuthoredFace.
886
898
 
887
899
  Extracts named source definitions (excluding 'default') into a dict.
@@ -213,7 +213,7 @@ class ValidationError(CompilationError):
213
213
 
214
214
  field_path: str | None = None
215
215
  invalid_value: str | None = None
216
- valid_values: list | None = None
216
+ valid_values: list[str] | None = None
217
217
 
218
218
  def __init__(
219
219
  self,
@@ -224,7 +224,7 @@ class ValidationError(CompilationError):
224
224
  suggestion: str | None = None,
225
225
  field_path: str | None = None,
226
226
  invalid_value: str | None = None,
227
- valid_values: list | None = None,
227
+ valid_values: list[str] | None = None,
228
228
  ):
229
229
  self.field_path = field_path
230
230
  self.invalid_value = invalid_value
@@ -178,7 +178,7 @@ def inject_filters(
178
178
  return tree.sql(dialect=sg_dialect), all_params
179
179
 
180
180
 
181
- def _is_operator_dict(d: dict) -> bool:
181
+ def _is_operator_dict(d: dict[str, Any]) -> bool:
182
182
  """Check if a dict's keys are all recognized filter operators."""
183
183
  operators = set(_OP_MAP) | {"in", "not_in", "between"}
184
184
  return all(k in operators for k in d)
@@ -267,8 +267,8 @@ def _detect_circular(dependencies: dict[str, list[str]]) -> None:
267
267
  Raises:
268
268
  JinjaError: If circular dependency found
269
269
  """
270
- visited: set = set()
271
- rec_stack: set = set()
270
+ visited: set[str] = set()
271
+ rec_stack: set[str] = set()
272
272
 
273
273
  def dfs(node: str, path: list[str]) -> None:
274
274
  if node in rec_stack:
@@ -340,7 +340,7 @@ def _substitute_query_refs(sql: str, resolved: dict[str, str]) -> str:
340
340
  """
341
341
  _QUERY_REF = re.compile(r"\{\{\s*queries\.(\w+)\s*\}\}")
342
342
 
343
- def _replacer(m: re.Match) -> str:
343
+ def _replacer(m: re.Match[str]) -> str:
344
344
  name = m.group(1)
345
345
  if name in resolved:
346
346
  return resolved[name]
@@ -19,6 +19,7 @@ validator.
19
19
 
20
20
  import re
21
21
  from pathlib import Path
22
+ from typing import Any
22
23
 
23
24
  import yaml
24
25
 
@@ -66,7 +67,7 @@ def parse_chart_embeds(body: str) -> list[tuple[str, str]]:
66
67
  return blocks
67
68
 
68
69
 
69
- def _extract_frontmatter(md_text: str) -> tuple[dict, str]:
70
+ def _extract_frontmatter(md_text: str) -> tuple[dict[str, Any], str]:
70
71
  """Extract optional YAML frontmatter and markdown body from text."""
71
72
  lines = md_text.splitlines(keepends=True)
72
73
  if not lines or lines[0].strip() != "---":
@@ -95,7 +96,7 @@ def _extract_frontmatter(md_text: str) -> tuple[dict, str]:
95
96
  return fm, body
96
97
 
97
98
 
98
- def _metadata_to_markdown_table(metadata: dict) -> str:
99
+ def _metadata_to_markdown_table(metadata: dict[str, Any]) -> str:
99
100
  """Render a flat metadata dict as a two-column markdown table.
100
101
 
101
102
  Produces:
@@ -116,7 +117,7 @@ def _metadata_to_markdown_table(metadata: dict) -> str:
116
117
  return "\n".join(lines)
117
118
 
118
119
 
119
- def parse_markdown_face(md_text: str) -> tuple[str, dict]:
120
+ def parse_markdown_face(md_text: str) -> tuple[str, dict[str, Any]]:
120
121
  """Parse a markdown face file and return (yaml_str, metadata_dict).
121
122
 
122
123
  ``yaml_str`` is the YAML string ready to feed to compile().
@@ -131,8 +132,8 @@ def parse_markdown_face(md_text: str) -> tuple[str, dict]:
131
132
  """
132
133
  fm, body = _extract_frontmatter(md_text)
133
134
 
134
- face_config: dict = {}
135
- metadata: dict = {}
135
+ face_config: dict[str, Any] = {}
136
+ metadata: dict[str, Any] = {}
136
137
 
137
138
  if "face" in fm:
138
139
  face_cfg = fm["face"]
@@ -146,7 +147,7 @@ def parse_markdown_face(md_text: str) -> tuple[str, dict]:
146
147
 
147
148
  # Build rows from markdown body
148
149
  blocks = parse_chart_embeds(body)
149
- rows: list = []
150
+ rows: list[dict[str, Any] | str] = []
150
151
  for block_type, value in blocks:
151
152
  if block_type == "text":
152
153
  rows.append({"text": value})
@@ -252,7 +252,7 @@ CHART_TYPE_DISPLAY: dict[ChartType, dict[str, str]] = {
252
252
  _INTERNAL_CHART_TYPES: set[ChartType] = {ChartType.DONUT}
253
253
 
254
254
 
255
- def get_chart_type_options(include_internal: bool = False) -> list[dict]:
255
+ def get_chart_type_options(include_internal: bool = False) -> list[dict[str, Any]]:
256
256
  """Get chart types for UI dropdowns. Single source of truth.
257
257
 
258
258
  Args: