dataface 0.1.5.dev215__py3-none-any.whl → 0.1.5.dev278__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 (112) hide show
  1. dataface/DATAFACE_SYNTAX.md +38 -20
  2. dataface/__init__.py +2 -1
  3. dataface/agent_api/__init__.py +24 -4
  4. dataface/agent_api/_init_templates/guide.yaml +6 -2
  5. dataface/agent_api/dashboards.py +1 -8
  6. dataface/agent_api/data_paths.py +225 -0
  7. dataface/agent_api/docs/yaml-reference.md +42 -156
  8. dataface/agent_api/pack.py +45 -3
  9. dataface/agent_api/project.py +112 -10
  10. dataface/agent_api/render_face.py +17 -18
  11. dataface/agent_api/schema_hints.py +59 -0
  12. dataface/agent_api/search.py +32 -1
  13. dataface/agent_api/validate.py +46 -0
  14. dataface/agent_api/validate_query.py +40 -62
  15. dataface/ai/mcp/server.py +1 -1
  16. dataface/ai/skills/dashboard-pack-scaffolding/SKILL.md +98 -10
  17. dataface/ai/skills/kpi-row/SKILL.md +3 -1
  18. dataface/ai/skills/kpi-row/examples/kpi-row.yml +6 -2
  19. dataface/ai/skills/single-metric-bignum/SKILL.md +3 -1
  20. dataface/ai/skills/single-metric-bignum/examples/single-metric-bignum.yml +3 -1
  21. dataface/ai/tools/__init__.py +6 -5
  22. dataface/cli/commands/_agent_server.py +10 -4
  23. dataface/cli/commands/query.py +54 -36
  24. dataface/cli/commands/schema.py +73 -42
  25. dataface/cli/commands/search.py +3 -3
  26. dataface/cli/main.py +13 -0
  27. dataface/core/__init__.py +2 -1
  28. dataface/core/compile/__init__.py +6 -2
  29. dataface/core/compile/compiler.py +111 -85
  30. dataface/core/compile/config.py +62 -39
  31. dataface/core/compile/meta.py +3 -3
  32. dataface/core/compile/models/chart/authored.py +47 -83
  33. dataface/core/compile/models/chart/resolved.py +1 -1
  34. dataface/core/compile/models/face/authored.py +9 -0
  35. dataface/core/compile/models/face/normalized.py +8 -0
  36. dataface/core/compile/models/query/normalized.py +13 -5
  37. dataface/core/compile/models/style/authored.py +1 -7
  38. dataface/core/compile/models/style/resolved.py +13 -24
  39. dataface/core/compile/models/style/theme.py +13 -72
  40. dataface/core/compile/models/variable/authored.py +7 -7
  41. dataface/core/compile/normalize_layout.py +2 -2
  42. dataface/core/compile/normalize_variables.py +5 -5
  43. dataface/core/compile/normalizer.py +1 -0
  44. dataface/core/compile/sizing.py +42 -16
  45. dataface/core/compile/style_cascade.py +7 -7
  46. dataface/core/compile/typography.py +18 -5
  47. dataface/core/compile/validator.py +3 -3
  48. dataface/core/compile/yaml_error_formatter.py +0 -13
  49. dataface/core/dashboard.py +3 -3
  50. dataface/core/defaults/themes/_base.yaml +2 -7
  51. dataface/core/defaults/themes/editorial.yaml +3 -3
  52. dataface/core/errors/__init__.py +6 -0
  53. dataface/core/errors/codes_search.py +36 -0
  54. dataface/core/execute/adapters/adapter_registry.py +19 -12
  55. dataface/core/execute/adapters/dbt_adapter.py +5 -6
  56. dataface/core/execute/adapters/schema_adapter.py +15 -5
  57. dataface/core/execute/duckdb_cache.py +10 -5
  58. dataface/core/execute/executor.py +2 -1
  59. dataface/core/inspect/db_types.py +29 -18
  60. dataface/core/inspect/renderer.py +8 -3
  61. dataface/core/inspect/templates/categorical_column.yml +9 -3
  62. dataface/core/inspect/templates/date_column.yml +9 -3
  63. dataface/core/inspect/templates/numeric_column.yml +18 -6
  64. dataface/core/inspect/templates/string_column.yml +18 -6
  65. dataface/core/pack/models.py +14 -0
  66. dataface/core/pack/planner.py +43 -13
  67. dataface/core/registered_views/__init__.py +0 -0
  68. dataface/core/registered_views/data_urls.py +31 -0
  69. dataface/core/registered_views/expander.py +392 -0
  70. dataface/core/registered_views/loader.py +104 -0
  71. dataface/core/registered_views/models.py +51 -0
  72. dataface/core/registered_views/query_runner.py +247 -0
  73. dataface/core/registered_views/registry.yaml +72 -0
  74. dataface/core/registered_views/render_pipeline.py +221 -0
  75. dataface/core/registered_views/router.py +126 -0
  76. dataface/core/registered_views/templates/data/root.yaml +19 -0
  77. dataface/core/registered_views/templates/data/schema-index.yaml +21 -0
  78. dataface/core/registered_views/templates/data/source-index.yaml +20 -0
  79. dataface/core/registered_views/templates/data/table-detail.yaml +30 -0
  80. dataface/core/registered_views/templates/data/table-index.yaml +41 -0
  81. dataface/core/registered_views/templates/inspector/column.yaml +24 -0
  82. dataface/core/registered_views/templates/inspector/schema.yaml +20 -0
  83. dataface/core/registered_views/templates/inspector/source.yaml +19 -0
  84. dataface/core/registered_views/templates/inspector/table.yaml +23 -0
  85. dataface/core/registered_views/variable_planner.py +243 -0
  86. dataface/core/render/chart/decisions.py +11 -1
  87. dataface/core/render/chart/kpi.py +2 -1
  88. dataface/core/render/chart/pipeline.py +11 -5
  89. dataface/core/render/chart/profile.py +18 -18
  90. dataface/core/render/chart/standard_renderer.py +5 -5
  91. dataface/core/render/chart/vl_field_maps.py +4 -4
  92. dataface/core/render/face_api.py +2 -2
  93. dataface/core/render/face_to_dict.py +109 -0
  94. dataface/core/render/faces.py +9 -3
  95. dataface/core/render/json_format.py +3 -98
  96. dataface/core/render/layout_sizing.py +4 -1
  97. dataface/core/render/nav.py +13 -4
  98. dataface/core/render/svg_utils.py +1 -1
  99. dataface/core/render/text_format.py +2 -3
  100. dataface/core/render/variable_controls.py +12 -12
  101. dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +29 -2
  102. dataface/core/render/yaml_format.py +2 -3
  103. dataface/core/serve/alias_index.py +284 -0
  104. dataface/core/serve/server.py +155 -21
  105. dataface/integrations/markdown.py +26 -47
  106. {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev278.dist-info}/METADATA +1 -1
  107. {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev278.dist-info}/RECORD +112 -88
  108. mdsvg/renderer.py +6 -1
  109. mdsvg/style.py +3 -0
  110. {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev278.dist-info}/WHEEL +0 -0
  111. {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev278.dist-info}/entry_points.txt +0 -0
  112. {dataface-0.1.5.dev215.dist-info → dataface-0.1.5.dev278.dist-info}/licenses/LICENSE +0 -0
@@ -19,7 +19,7 @@ dft validate faces/my_dashboard.yml
19
19
  To verify that your database connection works, run a simple query:
20
20
 
21
21
  ```bash
22
- dft query <your_source> 'SELECT 1'
22
+ dft query 'SELECT 1' --source <your_source>
23
23
  ```
24
24
 
25
25
  A successful result means your data source is reachable.
@@ -138,7 +138,7 @@ variables:
138
138
  region:
139
139
  input: select # See `dft docs variables` for all 14 input types
140
140
  options: { static: [US, EU, APAC] }
141
- # No default: starts on All regions
141
+ default: US
142
142
  ```
143
143
 
144
144
  Reference variables inside queries with bare `{{ region }}` — no `variables.` prefix.
@@ -238,6 +238,27 @@ Top-level fields (23 total):
238
238
 
239
239
  `face:` as a top-level key is rejected. Put face properties (title, rows, queries, …) directly at the YAML root.
240
240
 
241
+ ### Aliases
242
+
243
+ `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.
244
+
245
+ ```yaml
246
+ aliases:
247
+ - /old-reports/
248
+ - /legacy/sales/
249
+ ```
250
+
251
+ Rules:
252
+ - An alias must not collide with a real face file path — the server raises an error at startup if it does.
253
+ - Each alias must be unique across the project — two faces claiming the same alias is a startup error.
254
+ - Aliasing a generated system route (data or inspector view) is allowed: the redirect takes precedence, letting you override what that URL serves.
255
+
256
+ There are two ways to override a data/inspector route for a given path:
257
+ - **Face file at that path** — create `faces/data/warehouse/schema/table.yml` and the server renders it directly (no redirect).
258
+ - **`aliases:` entry** — any face can declare `/data/…/` as an alias; the server issues a 302 to the declaring face's canonical URL.
259
+
260
+ 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.
261
+
241
262
  ### Board style
242
263
 
243
264
  `style:` on a face, nested face, or layout section accepts a fixed set of keys — not arbitrary CSS:
@@ -346,7 +367,7 @@ queries:
346
367
  - [Bob, 87.1]
347
368
  ```
348
369
 
349
- Query types (`type:` literals): `sql`, `csv`, `http`, `dbt_model`, `metricflow`, `values`. `schema` is internal-only and not part of the authored surface.
370
+ Query types (`type:` literals): `sql`, `csv`, `http`, `dbt_model`, `metricflow`, `values`. `schema_resolver` is internal-only and not part of the authored surface.
350
371
 
351
372
  Common fields (all query types):
352
373
 
@@ -461,7 +482,6 @@ charts:
461
482
 
462
483
  # Style + behavior
463
484
  sort: { by: total, order: desc }
464
- format: integer # named alias or raw D3 spec (e.g. ",.0f")
465
485
  x_label: "Month"
466
486
  y_label: "Revenue (USD)"
467
487
  link: "/orders?month={{ month }}" # Click-through URL template (drill-down)
@@ -469,6 +489,7 @@ charts:
469
489
 
470
490
  style: # Chart-local style patch (typed; not raw CSS) — paint only
471
491
  orientation: vertical # style: does NOT accept height or aspect_ratio
492
+ number_format: ",.0f" # D3 format string or named alias for axis/tooltip format
472
493
  stack: zero # false | true | zero | normalize | center
473
494
  ```
474
495
 
@@ -597,14 +618,12 @@ support: # Optional support line (same shape: value/label/format/
597
618
  glyph: "▲"
598
619
  tone: positive
599
620
 
600
- # table — renders all query columns unless `style.columns` selects a subset.
601
- # `columns` is a MAPPING keyed by column name (NOT a list). Omit it to show
602
- # every query column; include it to choose a subset and/or style columns.
621
+ # table — renders all query columns unless `style.columns` selects a subset
603
622
  type: table
604
623
  style:
605
624
  columns:
606
- order_id: {} # include with default styling
607
- amount: # the key is the column name
625
+ - column: order_id
626
+ - column: amount
608
627
  label: Amount
609
628
  format: currency_whole
610
629
  align: right # left | center | right
@@ -700,7 +719,7 @@ layers:
700
719
  y: target
701
720
  label: Target
702
721
  axis_y:
703
- orient: right # left | right
722
+ position: right # left | right
704
723
  title: "Target"
705
724
  ```
706
725
 
@@ -846,7 +865,7 @@ variables:
846
865
  description: "Restrict every query to one region."
847
866
  options:
848
867
  static: [US, EU, APAC]
849
- # No default: starts on All regions
868
+ default: US
850
869
  ```
851
870
 
852
871
  Input types (14 total):
@@ -878,6 +897,7 @@ Common variable fields:
878
897
  | `default` | any | Default value when no URL param is set |
879
898
  | `placeholder` | string | Placeholder text |
880
899
  | `required` | bool | Block rendering until a value exists |
900
+ | `allow_null` | bool | `null` is a valid selection |
881
901
  | `visible` | bool | Hidden when `false`; still settable via URL param |
882
902
  | `disabled` | bool \| string \| `{query, column}` | Static, Jinja expr, or query-backed disable |
883
903
  | `data_type` | string | Upstream type hint (informational; preserved through migrations) |
@@ -893,7 +913,7 @@ variables:
893
913
  product:
894
914
  input: select
895
915
  options:
896
- static: [Electronics, Clothing] # Hardcoded list; blank -- All -- is implicit
916
+ static: [All, Electronics, Clothing] # Hardcoded list
897
917
  # OR
898
918
  query: products_list # Query whose first column is the option list
899
919
  column: product_name # Optional: which column in that query
@@ -906,25 +926,23 @@ variables:
906
926
 
907
927
  Top-level option-source binding (alternative to `options:`): `column`, `query`, `dimension` (MetricFlow), `measure` (MetricFlow), `model` (dbt).
908
928
 
909
- Variables are optional by default. For `select` and `multiselect`, omitting `default` starts the variable unset. The renderer adds a blank `-- All --` option for that unset state; `{{ filter('column', variable) }}` emits `1=1` unless the SQL call site opts into `none='deny'`. Add `required: true` only when the dashboard cannot render without a value.
910
-
911
- Disabled forms:
929
+ Enabled/disabled forms (`enabled: false` means the control is inactive):
912
930
 
913
931
  ```yaml
914
932
  variables:
915
- # Static bool
916
- closed: { input: checkbox, disabled: true }
933
+ # Static bool — enabled: false disables the control
934
+ closed: { input: checkbox, enabled: false }
917
935
 
918
936
  # Jinja expression against current variable values
919
- q4_only: { input: select, options: { static: [Q4] }, disabled: "{{ year < 2024 }}" }
937
+ q4_only: { input: select, options: { static: [Q4] }, enabled: "{{ year >= 2024 }}" }
920
938
 
921
939
  # Query-backed (must return exactly 1 row with the named boolean column)
922
940
  territory:
923
941
  input: select
924
942
  options: { query: territory_options }
925
- disabled:
943
+ enabled:
926
944
  query: control_state
927
- column: territory_disabled
945
+ column: territory_enabled
928
946
  ```
929
947
 
930
948
  **Multiselect SQL antipattern** — the first instinct for filtering a multiselect variable in SQL is `IN ({{ plans | map('tojson') | join(', ') }})`. This is wrong: `tojson` produces double-quoted strings (`"trial"`), which most SQL dialects (including DuckDB) treat as *column references*, not string literals. The query silently returns an empty result and `dft render` exits 0.
dataface/__init__.py CHANGED
@@ -22,7 +22,8 @@ Quick Start:
22
22
  ... face = result.face
23
23
  ...
24
24
  ... # Create executor and render
25
- ... registry = build_adapter_registry(Path.cwd())
25
+ ... from dataface.core.compile.config import load_project_sources
26
+ ... registry = build_adapter_registry(Path.cwd(), project_sources=load_project_sources(Path.cwd()))
26
27
  ... executor = Executor(face, registry, query_registry=result.query_registry)
27
28
  ... svg = render(face, executor, format="svg")
28
29
  """
@@ -37,10 +37,6 @@ CLI/MCP files that contain logic beyond parse-and-dispatch.
37
37
  """
38
38
 
39
39
  from dataface.agent_api import skills as skills
40
- from dataface.agent_api.dashboards import (
41
- RenderedDashboard as RenderedDashboard,
42
- render_dashboard as render_dashboard,
43
- )
44
40
  from dataface.agent_api.describe import (
45
41
  DescribeFaceArgs,
46
42
  DescribeFaceResult,
@@ -53,8 +49,24 @@ from dataface.agent_api.init import (
53
49
  )
54
50
  from dataface.agent_api.project import Project as Project
55
51
  from dataface.agent_api.query import QueryFaceResult, query_face
52
+ from dataface.agent_api.schema_hints import (
53
+ SchemaHints as SchemaHints,
54
+ schema_hints as schema_hints,
55
+ )
56
56
  from dataface.agent_api.validate import ValidateDashboardArgs, ValidateResult
57
+ from dataface.agent_api.validate_query import (
58
+ QueryDiagnostic as QueryDiagnostic,
59
+ validate_query as validate_query,
60
+ )
57
61
  from dataface.core.compile.config import ProjectSourcesConfig as ProjectSourcesConfig
62
+ from dataface.core.dashboard import (
63
+ RenderedDashboard as RenderedDashboard,
64
+ render_dashboard as render_dashboard,
65
+ )
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
  ]
@@ -82,7 +82,9 @@ charts:
82
82
  query: kpi_summary
83
83
  label: Total Revenue (H1)
84
84
  value: total_revenue
85
- format: currency_compact
85
+ style:
86
+ value:
87
+ format: currency_compact
86
88
  support:
87
89
  value: revenue_delta
88
90
  label: vs H1 last year
@@ -95,7 +97,9 @@ charts:
95
97
  query: kpi_summary
96
98
  label: Avg Deal Size
97
99
  value: avg_deal
98
- format: currency_compact
100
+ style:
101
+ value:
102
+ format: currency_compact
99
103
 
100
104
  data_table:
101
105
  type: table
@@ -3,10 +3,6 @@
3
3
  All public functions have concrete typed arguments and typed Pydantic return
4
4
  values. No bare dict[str, Any] in any return position. Tool/CLI callers use
5
5
  .model_dump() at the wire boundary.
6
-
7
- `RenderedDashboard` and `render_dashboard` live in `dataface.core.dashboard`
8
- so the embedded HTTP server can call them without inverting the layer stack; they
9
- are re-exported here so the agent_api thin-wrapper rule still holds.
10
6
  """
11
7
 
12
8
  from __future__ import annotations
@@ -20,10 +16,7 @@ from pydantic import BaseModel, Field
20
16
  from dataface.agent_api._paths import resolve_scoped_path
21
17
  from dataface.core.compile import Face, compile_file
22
18
  from dataface.core.compile.errors import DatafaceError
23
- from dataface.core.dashboard import (
24
- RenderedDashboard as RenderedDashboard,
25
- render_dashboard as render_dashboard,
26
- )
19
+ from dataface.core.dashboard import RenderedDashboard as RenderedDashboard
27
20
  from dataface.core.errors import DF_UNKNOWN_INTERNAL, StructuredError
28
21
  from dataface.core.render.warnings.base import RenderWarning
29
22
 
@@ -0,0 +1,225 @@
1
+ """Data URL surfacing and alias typo lint.
2
+
3
+ Provides:
4
+ - DataPathInfo: the agent-facing wire shape for a canonical data URL
5
+ (url, resolves_to: "generic" | "authored", face)
6
+ - data_paths_for_source/schema/table: compute data URLs and check the
7
+ alias index to determine whether a face overrides the generic system view
8
+ - data_paths_list: flat list of DataPathInfo (source + schema + table level)
9
+ from a SchemaResponse — used for --data-paths CLI flag and agent discovery
10
+ - validate_data_aliases: typo lint for /data/ 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
+ from dataface.core.registered_views.data_urls import (
21
+ data_schema_url,
22
+ data_source_url,
23
+ data_table_url,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from dataface.agent_api.schema import SchemaResponse
28
+ from dataface.core.serve.alias_index import AliasIndex
29
+
30
+
31
+ @dataclass
32
+ class DataPathInfo:
33
+ """Canonical data URL with resolution status.
34
+
35
+ Wire shape (pinned by contract test — any change is a breaking API change):
36
+ {"url": "/data/src/schema/table/", "resolves_to": "generic"}
37
+ {"url": "...", "resolves_to": "authored", "face": "/sales/"}
38
+ """
39
+
40
+ url: str
41
+ resolves_to: Literal["generic", "authored"]
42
+ face: str | None = None
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ """Serialize to the agent wire shape, omitting face when generic."""
46
+ d: dict[str, Any] = {"url": self.url, "resolves_to": self.resolves_to}
47
+ if self.face is not None:
48
+ d["face"] = self.face
49
+ return d
50
+
51
+
52
+ def data_paths_for_source(
53
+ source: str,
54
+ *,
55
+ alias_index: AliasIndex,
56
+ ) -> DataPathInfo:
57
+ """Return the data URL info for a source.
58
+
59
+ The canonical URL is /data/<source>/. When a face aliases it the
60
+ resolves_to is 'authored' and face is the canonical face URL.
61
+ """
62
+ url = data_source_url(source)
63
+ face = alias_index.lookup(url)
64
+ if face is not None:
65
+ return DataPathInfo(url=url, resolves_to="authored", face=face)
66
+ return DataPathInfo(url=url, resolves_to="generic")
67
+
68
+
69
+ def data_paths_for_schema(
70
+ source: str,
71
+ schema: str,
72
+ *,
73
+ alias_index: AliasIndex,
74
+ ) -> DataPathInfo:
75
+ """Return the data URL info for a schema.
76
+
77
+ The canonical URL is /data/<source>/<schema>/. When a face aliases it
78
+ the resolves_to is 'authored' and face is the canonical face URL.
79
+ """
80
+ url = data_schema_url(source, schema)
81
+ face = alias_index.lookup(url)
82
+ if face is not None:
83
+ return DataPathInfo(url=url, resolves_to="authored", face=face)
84
+ return DataPathInfo(url=url, resolves_to="generic")
85
+
86
+
87
+ def data_paths_for_table(
88
+ source: str,
89
+ schema: str,
90
+ table: str,
91
+ *,
92
+ alias_index: AliasIndex,
93
+ ) -> DataPathInfo:
94
+ """Return the data URL info for a table.
95
+
96
+ The canonical URL is /data/<source>/<schema>/<table>/. When a face
97
+ aliases it the resolves_to is 'authored' and face is the canonical face URL.
98
+ """
99
+ url = data_table_url(source, schema, table)
100
+ face = alias_index.lookup(url)
101
+ if face is not None:
102
+ return DataPathInfo(url=url, resolves_to="authored", face=face)
103
+ return DataPathInfo(url=url, resolves_to="generic")
104
+
105
+
106
+ def validate_data_aliases(
107
+ aliases: list[str],
108
+ *,
109
+ source_names: frozenset[str],
110
+ ) -> list[str]:
111
+ """Lint /data/ prefixed aliases against configured source names.
112
+
113
+ Called at dft-validate time (connection-free). Checks that every
114
+ /data/ URL references a known source, and errors with available
115
+ sources as a hint when not.
116
+
117
+ Returns a list of error message strings (empty = all valid).
118
+ Aliases not prefixed with /data/ are silently skipped.
119
+ """
120
+ errors: list[str] = []
121
+ for raw_alias in aliases:
122
+ # Normalize: trailing-slash canonical, strip percent-encoding
123
+ from urllib.parse import unquote
124
+
125
+ alias = unquote(raw_alias)
126
+ if not alias.endswith("/"):
127
+ alias = alias + "/"
128
+
129
+ if not alias.startswith("/data/"):
130
+ continue
131
+
132
+ # Strip leading "/data/" and trailing "/" to get segments
133
+ inner = alias[len("/data/") :]
134
+ if inner.endswith("/"):
135
+ inner = inner[:-1]
136
+ segments = [s for s in inner.split("/") if s]
137
+
138
+ if not segments:
139
+ # Bare /data/ — no validation needed
140
+ continue
141
+
142
+ # Segment 0: source
143
+ source = segments[0]
144
+ if source not in source_names:
145
+ available = sorted(source_names)
146
+ hint = (
147
+ f"available sources: {', '.join(available)}"
148
+ if available
149
+ else "no sources configured"
150
+ )
151
+ errors.append(
152
+ f"Data alias {raw_alias!r} references unknown source {source!r}. "
153
+ f"Did you mean one of: {hint}"
154
+ )
155
+
156
+ return errors
157
+
158
+
159
+ def data_paths_list(
160
+ schema_response: SchemaResponse,
161
+ *,
162
+ alias_index: AliasIndex,
163
+ ) -> list[DataPathInfo]:
164
+ """Build a flat list of DataPathInfo from a SchemaResponse.
165
+
166
+ Iterates all sources → schemas → tables in the response and returns one
167
+ DataPathInfo per source, per schema, and per table, checking the alias
168
+ index for author overrides at each level.
169
+ """
170
+ result: list[DataPathInfo] = []
171
+ for source_name, source_data in schema_response.sources.items():
172
+ result.append(data_paths_for_source(source_name, alias_index=alias_index))
173
+ for schema_name, schema_data in (source_data.get("schemas") or {}).items():
174
+ result.append(
175
+ data_paths_for_schema(source_name, schema_name, alias_index=alias_index)
176
+ )
177
+ for table_name in schema_data.get("tables") or {}:
178
+ result.append(
179
+ data_paths_for_table(
180
+ source_name,
181
+ schema_name,
182
+ table_name,
183
+ alias_index=alias_index,
184
+ )
185
+ )
186
+ return result
187
+
188
+
189
+ def build_alias_index_for_project(project_dir: Path) -> AliasIndex:
190
+ """Build an AliasIndex from a project directory.
191
+
192
+ Uses the same faces_at_root detection logic as the server. Intended for
193
+ CLI commands (dft schema --data-paths) that need alias lookup without
194
+ starting a server.
195
+ """
196
+ from dataface.core.serve.alias_index import AliasIndex
197
+
198
+ faces_dir = project_dir / "faces"
199
+ faces_at_root = faces_dir.is_dir()
200
+ return AliasIndex.build(
201
+ project_dir=project_dir,
202
+ faces_dir=faces_dir,
203
+ faces_at_root=faces_at_root,
204
+ )
205
+
206
+
207
+ def data_alias_errors_for_file(
208
+ face_file: Path,
209
+ source_names: frozenset[str],
210
+ ) -> list[str]:
211
+ """Read aliases from a face file and lint any /data/ prefixed ones.
212
+
213
+ Source-name check only — no DB connection, no resolver. Called from the
214
+ validate path which must stay connection-free.
215
+
216
+ Returns a list of error message strings; empty means no data alias issues.
217
+ Silently returns [] when the file cannot be parsed (compile catches that).
218
+ """
219
+ from dataface.core.serve.alias_index import read_aliases_from_file
220
+
221
+ try:
222
+ aliases = read_aliases_from_file(face_file)
223
+ except ValueError:
224
+ return [] # compile path reports alias parse errors with better context
225
+ return validate_data_aliases(aliases, source_names=source_names)