dataface 0.3.1.dev132__py3-none-any.whl → 0.3.2.dev22__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 (138) hide show
  1. d3_format/spec.py +45 -0
  2. dataface/agent_api/__init__.py +6 -2
  3. dataface/agent_api/board_artifact.py +11 -1
  4. dataface/agent_api/docs/yaml-reference.md +6 -0
  5. dataface/cli/commands/render.py +1 -4
  6. dataface/cli/commands/serve.py +6 -0
  7. dataface/cli/main.py +4 -4
  8. dataface/core/attribution.py +191 -0
  9. dataface/core/compile/channel.py +232 -119
  10. dataface/core/compile/jinja.py +2 -1
  11. dataface/core/compile/label_data.py +154 -0
  12. dataface/core/compile/link_keys.py +127 -0
  13. dataface/core/compile/migrations.py +29 -13
  14. dataface/core/compile/models/chart/normalized/_base.py +2 -2
  15. dataface/core/compile/models/chart/normalized/callout.py +2 -2
  16. dataface/core/compile/models/chart/resolved/_base.py +2 -2
  17. dataface/core/compile/models/chart/resolved/callout.py +1 -1
  18. dataface/core/compile/models/chart/resolved/pie.py +55 -1
  19. dataface/core/compile/models/config.py +4 -0
  20. dataface/core/compile/models/query/normalized.py +2 -2
  21. dataface/core/compile/models/source.py +91 -8
  22. dataface/core/compile/models/style/resolved/__init__.py +1 -1
  23. dataface/core/compile/models/style/resolved/_base.py +1 -1
  24. dataface/core/compile/models/style/resolved/_cascade.py +12 -4
  25. dataface/core/compile/models/style/resolved/table.py +8 -3
  26. dataface/core/compile/models/style/theme/marks.py +1 -1
  27. dataface/core/compile/models/variable/authored.py +2 -2
  28. dataface/core/compile/normalize_charts.py +1 -1
  29. dataface/core/compile/normalize_queries.py +1 -1
  30. dataface/core/compile/normalize_variables.py +2 -2
  31. dataface/core/compile/palette.py +1 -1
  32. dataface/core/compile/resolve/__init__.py +47 -7
  33. dataface/core/compile/resolve/_axes.py +313 -0
  34. dataface/core/compile/resolve/_channels.py +120 -0
  35. dataface/core/compile/resolve/_chart_rows.py +29 -0
  36. dataface/core/compile/resolve/_dispatch.py +316 -0
  37. dataface/core/compile/resolve/_domain.py +465 -0
  38. dataface/core/compile/resolve/_kwargs.py +238 -0
  39. dataface/core/compile/resolve/_layers.py +241 -0
  40. dataface/core/compile/resolve/_links.py +66 -0
  41. dataface/core/compile/resolve/_marks.py +141 -0
  42. dataface/core/compile/resolve/_palette.py +187 -0
  43. dataface/core/compile/resolve/_table.py +131 -0
  44. dataface/core/compile/resolve/area.py +502 -0
  45. dataface/core/compile/resolve/bar.py +412 -0
  46. dataface/core/compile/resolve/geo.py +416 -0
  47. dataface/core/compile/resolve/heatmap.py +113 -0
  48. dataface/core/compile/resolve/line.py +314 -0
  49. dataface/core/compile/resolve/pie.py +287 -0
  50. dataface/core/compile/resolve/pie_attachment.py +170 -0
  51. dataface/core/compile/resolve/scatter.py +222 -0
  52. dataface/core/compile/resolve/simple.py +158 -0
  53. dataface/core/compile/single_series_allocation.py +1 -1
  54. dataface/core/compile/style_cascade.py +1 -1
  55. dataface/core/compile/variables.py +82 -2
  56. dataface/core/defaults/default_config.yml +6 -0
  57. dataface/core/diagnostics/__init__.py +5 -1
  58. dataface/core/diagnostics/codes_execute.py +23 -0
  59. dataface/core/diagnostics/codes_render.py +36 -23
  60. dataface/core/execute/adapters/adapter_registry.py +27 -5
  61. dataface/core/execute/adapters/base.py +9 -5
  62. dataface/core/execute/adapters/dbt_adapter.py +89 -4
  63. dataface/core/execute/adapters/dbt_adapter_factory.py +36 -6
  64. dataface/core/execute/adapters/duckdb_adapter.py +5 -3
  65. dataface/core/execute/adapters/http_adapter.py +2 -2
  66. dataface/core/execute/adapters/native_attribution.py +66 -0
  67. dataface/core/execute/adapters/query_header.py +96 -0
  68. dataface/core/execute/adapters/schema_adapter.py +2 -2
  69. dataface/core/execute/adapters/sql_adapter.py +36 -9
  70. dataface/core/execute/adapters/sqlite_adapter.py +5 -3
  71. dataface/core/execute/adapters/values_adapter.py +2 -2
  72. dataface/core/execute/cache_keys.py +10 -4
  73. dataface/core/execute/chart_data_provider.py +1 -1
  74. dataface/core/execute/chart_resolution.py +278 -0
  75. dataface/core/execute/recording.py +6 -4
  76. dataface/core/execute/source_resolver.py +23 -12
  77. dataface/core/font_measure.py +108 -0
  78. dataface/core/fonts.py +218 -49
  79. dataface/core/registered_views/variable_planner.py +7 -74
  80. dataface/core/render/board_replay.py +15 -0
  81. dataface/core/render/chart/arc_attached_table.py +19 -368
  82. dataface/core/render/chart/auto_link.py +3 -3
  83. dataface/core/render/chart/callout.py +1 -1
  84. dataface/core/render/chart/data_table_attachment.py +1 -1
  85. dataface/core/render/chart/emitters/_channels.py +48 -8
  86. dataface/core/render/chart/emitters/_label_overlap.py +1 -1
  87. dataface/core/render/chart/emitters/_measured_label_padding.py +1 -1
  88. dataface/core/render/chart/emitters/area.py +23 -10
  89. dataface/core/render/chart/emitters/bar.py +11 -1
  90. dataface/core/render/chart/emitters/line.py +26 -10
  91. dataface/core/render/chart/emitters/pie.py +61 -52
  92. dataface/core/render/chart/features/endpoint_labels.py +1 -1
  93. dataface/core/render/chart/features/value_labels.py +1 -1
  94. dataface/core/render/chart/kpi.py +2 -2
  95. dataface/core/render/chart/table.py +48 -88
  96. dataface/core/render/chart/table_support.py +7 -22
  97. dataface/core/render/chart/time_unit_detect.py +66 -1
  98. dataface/core/render/chart/title_overflow.py +1 -1
  99. dataface/core/render/chart/type_inference.py +36 -8
  100. dataface/core/render/chart/vega_lite.py +47 -245
  101. dataface/core/render/controls.py +5 -5
  102. dataface/core/render/face_resolve.py +64 -17
  103. dataface/core/render/face_to_dict.py +1 -1
  104. dataface/core/render/faces.py +87 -43
  105. dataface/core/render/font_support.py +64 -103
  106. dataface/core/render/fonts/SourceSerif4Variable.ttf +0 -0
  107. dataface/core/render/fonts/{_emoji_font_face.css → _font_face.css} +1 -0
  108. dataface/core/render/layout_sizing.py +147 -125
  109. dataface/core/render/renderer.py +22 -2
  110. dataface/core/render/sizing.py +26 -57
  111. dataface/core/render/svg_utils.py +2 -3
  112. dataface/core/render/template_loader.py +7 -9
  113. dataface/core/render/templates/page.css +2 -2
  114. dataface/core/render/templates/svg/styles.css +1 -1
  115. dataface/core/render/terminal.py +5 -14
  116. dataface/core/render/terminal_charts.py +45 -46
  117. dataface/core/render/variables_strip.py +7 -6
  118. dataface/core/render/warnings/value_labels_crowd_width.py +1 -1
  119. dataface/core/serve/server.py +2 -2
  120. dataface/data/schemas/board-resolved/board-resolved.schema.json +149 -116
  121. dataface/data/schemas/yaml/0.3.1.json +1 -0
  122. dataface/data/schemas/yaml/manifest.json +7 -0
  123. {dataface-0.3.1.dev132.dist-info → dataface-0.3.2.dev22.dist-info}/METADATA +1 -1
  124. {dataface-0.3.1.dev132.dist-info → dataface-0.3.2.dev22.dist-info}/RECORD +131 -109
  125. mdsvg/__init__.py +5 -3
  126. mdsvg/fonts.py +130 -35
  127. mdsvg/renderer.py +258 -37
  128. mdsvg/style.py +18 -3
  129. dataface/core/compile/resolve/_resolve.py +0 -3320
  130. dataface/core/registered_views/link_keys.py +0 -45
  131. dataface/core/render/chart/labels.py +0 -76
  132. dataface/core/render/font_measurement.py +0 -35
  133. dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
  134. dataface/core/render/shared_y_datasets.py +0 -33
  135. dataface/core/render/stable_domain.py +0 -365
  136. {dataface-0.3.1.dev132.dist-info → dataface-0.3.2.dev22.dist-info}/WHEEL +0 -0
  137. {dataface-0.3.1.dev132.dist-info → dataface-0.3.2.dev22.dist-info}/entry_points.txt +0 -0
  138. {dataface-0.3.1.dev132.dist-info → dataface-0.3.2.dev22.dist-info}/licenses/LICENSE +0 -0
d3_format/spec.py CHANGED
@@ -49,6 +49,51 @@ class FormatSpec:
49
49
  trim: bool = False # ~ flag
50
50
  type: str = "" # "" means default (g-ish) behavior
51
51
 
52
+ def __str__(self) -> str:
53
+ """Serialize back to a d3-format spec string, in grammar order.
54
+
55
+ Omits every default-valued token, so a parsed spec serializes back to
56
+ something an author would write rather than a fully-explicit
57
+ expansion. ``zero`` implies ``fill == "0"`` and ``align == "="`` (the
58
+ collapse in ``parse``), so the zero-pad flag is emitted alone rather
59
+ than alongside a redundant ``0=`` fill and align.
60
+
61
+ Raises:
62
+ ValueError: If ``zero`` is set but ``fill``/``align`` have been
63
+ mutated away from ``"0"``/``"="``. The d3 grammar cannot
64
+ express that combination -- ``parse`` collapses ``zero`` back
65
+ to ``fill="0"``, ``align="="`` unconditionally -- so silently
66
+ keeping the flag and dropping the fill would misrepresent the
67
+ spec. Clear ``zero`` first if an explicit fill/align is wanted.
68
+ """
69
+ if self.zero and (self.fill != "0" or self.align != "="):
70
+ raise ValueError(
71
+ "FormatSpec has zero=True with fill="
72
+ f"{self.fill!r}, align={self.align!r}; the d3 grammar can "
73
+ "only express zero-pad as fill='0', align='='. Clear zero "
74
+ "if you want this fill/align combination."
75
+ )
76
+ tokens: list[str] = []
77
+ if not self.zero and (self.fill != " " or self.align != ">"):
78
+ tokens.append(self.fill)
79
+ tokens.append(self.align)
80
+ if self.sign != "-":
81
+ tokens.append(self.sign)
82
+ if self.symbol:
83
+ tokens.append(self.symbol)
84
+ if self.zero:
85
+ tokens.append("0")
86
+ if self.width is not None:
87
+ tokens.append(str(self.width))
88
+ if self.comma:
89
+ tokens.append(",")
90
+ if self.precision is not None:
91
+ tokens.append(f".{self.precision}")
92
+ if self.trim:
93
+ tokens.append("~")
94
+ tokens.append(self.type)
95
+ return "".join(tokens)
96
+
52
97
 
53
98
  def parse(spec: str) -> FormatSpec:
54
99
  """Parse a d3-format spec string into a FormatSpec.
@@ -69,6 +69,7 @@ from dataface.agent_api.validate_query import (
69
69
  QueryDiagnostic as QueryDiagnostic,
70
70
  validate_query as validate_query,
71
71
  )
72
+ from dataface.core.attribution import set_surface as set_surface
72
73
  from dataface.core.compile.compiler import (
73
74
  CompileResult as CompileResult,
74
75
  compile as compile,
@@ -76,6 +77,7 @@ from dataface.core.compile.compiler import (
76
77
  from dataface.core.compile.config import ProjectSourcesConfig as ProjectSourcesConfig
77
78
  from dataface.core.dashboard import (
78
79
  RenderedDashboard as RenderedDashboard,
80
+ RenderFormat as RenderFormat,
79
81
  render_dashboard as render_dashboard,
80
82
  )
81
83
  from dataface.core.diagnostics import Diagnostic as Diagnostic
@@ -92,7 +94,7 @@ from dataface.core.execute.source_resolver import (
92
94
  DefaultSourceResolver as DefaultSourceResolver,
93
95
  SourceResolver as SourceResolver,
94
96
  )
95
- from dataface.core.fonts import get_inter_font_path as get_inter_font_path
97
+ from dataface.core.fonts import get_fonts_dir as get_fonts_dir
96
98
  from dataface.core.project import (
97
99
  FaceFile as FaceFile,
98
100
  InMemoryFace as InMemoryFace,
@@ -118,11 +120,13 @@ __all__ = [
118
120
  "MigrateSummary",
119
121
  "Project",
120
122
  "ProjectSession",
123
+ "set_surface",
121
124
  "ProjectSourcesConfig",
122
125
  "QueryDiagnostic",
123
126
  "QueryFaceResult",
124
127
  "RenderedDashboard",
125
128
  "RenderError",
129
+ "RenderFormat",
126
130
  "SchemaHints",
127
131
  "Topic",
128
132
  "ValidateDashboardArgs",
@@ -134,7 +138,7 @@ __all__ = [
134
138
  "SourceResolver",
135
139
  "WarehouseObserver",
136
140
  "describe_face",
137
- "get_inter_font_path",
141
+ "get_fonts_dir",
138
142
  "init_project",
139
143
  "migrate_paths",
140
144
  "query_face",
@@ -22,6 +22,7 @@ from pydantic import BaseModel, ConfigDict
22
22
 
23
23
  from dataface.agent_api._paths import resolve_face_or_error
24
24
  from dataface.core.compile import compile_file
25
+ from dataface.core.compile.variables import normalize_multiselect_values
25
26
  from dataface.core.diagnostics import ERR_FILE_NOT_FOUND, Diagnostic
26
27
  from dataface.core.diagnostics.base import DatafaceError
27
28
  from dataface.core.execute.executor import Executor
@@ -105,7 +106,16 @@ def emit_board_artifact(
105
106
  face = compile_result.face
106
107
  assert face is not None # compile_result.success guarantees this
107
108
 
108
- merged_variables: dict[str, Any] = {**face.variable_defaults, **(variables or {})}
109
+ # `emit-board` merges its own variables — it never goes through `render()`,
110
+ # whose prologue does this — and this dict is both rendered from and stored
111
+ # verbatim in the recording that `render_board_from_files` replays. `--var`
112
+ # values are always strings, so a scalar is the only shape a caller can
113
+ # supply for a multiselect here; unnarrowed, a bare `| join` would iterate
114
+ # it character by character.
115
+ merged_variables: dict[str, Any] = normalize_multiselect_values(
116
+ {**face.variable_defaults, **(variables or {})},
117
+ face.variable_registry or {},
118
+ )
109
119
 
110
120
  executor = Executor(
111
121
  face,
@@ -2852,6 +2852,7 @@ Postgres source configuration.
2852
2852
  | `max_query_duration_seconds` | int | ✓ | Maximum execution time for one query in seconds. Overrides execution.max_query_duration_seconds for this source. |
2853
2853
  | `lookml_path` | str | ✓ | Relative path to the directory of .lkml files backing this source's models. |
2854
2854
  | `models` | dict[str, [LookMLModelConfig](#lookmlmodelconfig)] | ✓ | Named LookML model collections nested under this source. |
2855
+ | `attribution` | dict[str, str] | ✓ | Cost-attribution pairs sent with every query against this source, e.g. attribution: {team: analytics}. Emitted as BigQuery job labels and as a query comment elsewhere. Keys and values must match BigQuery's label rules ([a-z][a-z0-9_-]{0,62} / [a-z0-9_-]{0,63}). The dft_ prefix and the app key are reserved for the engine's own identity. |
2855
2856
  | `port` | int | ✓ | Database port number. |
2856
2857
  | `schema` | str | ✓ | Default schema for queries. |
2857
2858
  | `connect_timeout` | int | ✓ | Connection timeout in seconds. |
@@ -2878,6 +2879,7 @@ Snowflake source configuration.
2878
2879
  | `max_query_duration_seconds` | int | ✓ | Maximum execution time for one query in seconds. Overrides execution.max_query_duration_seconds for this source. |
2879
2880
  | `lookml_path` | str | ✓ | Relative path to the directory of .lkml files backing this source's models. |
2880
2881
  | `models` | dict[str, [LookMLModelConfig](#lookmlmodelconfig)] | ✓ | Named LookML model collections nested under this source. |
2882
+ | `attribution` | dict[str, str] | ✓ | Cost-attribution pairs sent with every query against this source, e.g. attribution: {team: analytics}. Emitted as BigQuery job labels and as a query comment elsewhere. Keys and values must match BigQuery's label rules ([a-z][a-z0-9_-]{0,62} / [a-z0-9_-]{0,63}). The dft_ prefix and the app key are reserved for the engine's own identity. |
2881
2883
  | `user` | str | ✓ | Snowflake user name. Omit for token-based authentication when supported. |
2882
2884
  | `password` | str | ✓ | Snowflake password. Omit when using OAuth or key-pair auth. |
2883
2885
  | `warehouse` | str | ✓ | Snowflake virtual warehouse name. |
@@ -2919,6 +2921,7 @@ BigQuery source configuration.
2919
2921
  | `max_query_duration_seconds` | int | ✓ | Maximum execution time for one query in seconds. Overrides execution.max_query_duration_seconds for this source. |
2920
2922
  | `lookml_path` | str | ✓ | Relative path to the directory of .lkml files backing this source's models. |
2921
2923
  | `models` | dict[str, [LookMLModelConfig](#lookmlmodelconfig)] | ✓ | Named LookML model collections nested under this source. |
2924
+ | `attribution` | dict[str, str] | ✓ | Cost-attribution pairs sent with every query against this source, e.g. attribution: {team: analytics}. Emitted as BigQuery job labels and as a query comment elsewhere. Keys and values must match BigQuery's label rules ([a-z][a-z0-9_-]{0,62} / [a-z0-9_-]{0,63}). The dft_ prefix and the app key are reserved for the engine's own identity. |
2922
2925
  | `keyfile` | str | ✓ | Path to service account JSON key file. |
2923
2926
  | `keyfile_json` | dict[str, str \| int \| float \| bool] | ✓ | Inline service account JSON dict. |
2924
2927
  | `location` | str | ✓ | Dataset location (e.g. US, EU). |
@@ -2961,6 +2964,7 @@ Redshift source configuration.
2961
2964
  | `max_query_duration_seconds` | int | ✓ | Maximum execution time for one query in seconds. Overrides execution.max_query_duration_seconds for this source. |
2962
2965
  | `lookml_path` | str | ✓ | Relative path to the directory of .lkml files backing this source's models. |
2963
2966
  | `models` | dict[str, [LookMLModelConfig](#lookmlmodelconfig)] | ✓ | Named LookML model collections nested under this source. |
2967
+ | `attribution` | dict[str, str] | ✓ | Cost-attribution pairs sent with every query against this source, e.g. attribution: {team: analytics}. Emitted as BigQuery job labels and as a query comment elsewhere. Keys and values must match BigQuery's label rules ([a-z][a-z0-9_-]{0,62} / [a-z0-9_-]{0,63}). The dft_ prefix and the app key are reserved for the engine's own identity. |
2964
2968
  | `port` | int | ✓ | Redshift port number. |
2965
2969
  | `schema` | str | ✓ | Default schema for queries. |
2966
2970
  | `user` | str | ✓ | Redshift user name. Omit for IAM-based authentication when supported. |
@@ -3009,6 +3013,7 @@ MySQL source configuration.
3009
3013
  | `max_query_duration_seconds` | int | ✓ | Maximum execution time for one query in seconds. Overrides execution.max_query_duration_seconds for this source. |
3010
3014
  | `lookml_path` | str | ✓ | Relative path to the directory of .lkml files backing this source's models. |
3011
3015
  | `models` | dict[str, [LookMLModelConfig](#lookmlmodelconfig)] | ✓ | Named LookML model collections nested under this source. |
3016
+ | `attribution` | dict[str, str] | ✓ | Cost-attribution pairs sent with every query against this source, e.g. attribution: {team: analytics}. Emitted as BigQuery job labels and as a query comment elsewhere. Keys and values must match BigQuery's label rules ([a-z][a-z0-9_-]{0,62} / [a-z0-9_-]{0,63}). The dft_ prefix and the app key are reserved for the engine's own identity. |
3012
3017
  | `port` | int | ✓ | MySQL port number. |
3013
3018
  | `schema` | str | ✓ | Default schema for queries. |
3014
3019
 
@@ -3105,6 +3110,7 @@ Reference to a dbt profile.
3105
3110
  | `type` | enum: "dbt_profile" | | Source type identifier. |
3106
3111
  | `profile` | str | | dbt profile name from profiles.yml. |
3107
3112
  | `cache` | [Cache](#cache) | ✓ | Cache policy default for every query against this source, e.g. cache: 1h — queries inherit it and may refine it; cache: false opts them out. |
3113
+ | `attribution` | dict[str, str] | ✓ | Cost-attribution pairs sent with every query against this source, e.g. attribution: {team: analytics}. Emitted as BigQuery job labels and as a query comment elsewhere. Keys and values must match BigQuery's label rules ([a-z][a-z0-9_-]{0,62} / [a-z0-9_-]{0,63}). The dft_ prefix and the app key are reserved for the engine's own identity. |
3108
3114
  | `target` | str | ✓ | dbt target to use; defaults to the profile's default target. |
3109
3115
  | `profiles_dir` | str | ✓ | Directory containing profiles.yml, relative to the dataface project root. Use when profiles.yml is in a subdirectory (e.g. services/dbt). Resolution order: profiles_dir → $DBT_PROFILES_DIR → project root → ~/.dbt. |
3110
3116
 
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
 
7
7
  import typer
8
8
 
9
- from dataface.agent_api import Diagnostic, InMemoryFace, ProjectSession
9
+ from dataface.agent_api import Diagnostic, InMemoryFace, ProjectSession, RenderFormat
10
10
  from dataface.agent_api._paths import (
11
11
  build_face_render_context,
12
12
  build_yaml_render_context,
@@ -19,9 +19,6 @@ from dataface.cli._error_format import print_diagnostics
19
19
  from dataface.cli._project import with_project
20
20
  from dataface.cli.filesystem_project import FilesystemProject
21
21
 
22
- # tach-ignore(cli->core debt: route via agent_api; burn-down task)
23
- from dataface.core.dashboard import RenderFormat
24
-
25
22
  # Maps common file extensions to render formats for format inference.
26
23
  _EXT_TO_FORMAT: dict[str, RenderFormat] = {
27
24
  "svg": "svg",
@@ -67,6 +67,8 @@ def serve_command(
67
67
  Returns:
68
68
  None (exits with code 0 on success, 1 on errors)
69
69
  """
70
+ from dataface.agent_api import set_surface
71
+
70
72
  # tach-ignore(cli->core debt: route via agent_api; burn-down task)
71
73
  from dataface.core.compile.config import list_built_in_themes, load_config
72
74
 
@@ -82,6 +84,10 @@ def serve_command(
82
84
  # tach-ignore(cli->core debt: route via agent_api; burn-down task)
83
85
  from dataface.core.serve.server import create_server
84
86
 
87
+ # Narrows the root callback's "cli": a served dashboard's queries are a
88
+ # different cost story from a one-shot `dft render`.
89
+ set_surface("serve")
90
+
85
91
  project_dir = project.root
86
92
 
87
93
  # Validate DFT_DEFAULT_THEME at startup — fail fast before serving any request.
dataface/cli/main.py CHANGED
@@ -12,6 +12,7 @@ from rich.console import Console
12
12
  from rich.panel import Panel
13
13
  from typer.core import TyperGroup
14
14
 
15
+ from dataface.agent_api import RenderFormat, set_surface
15
16
  from dataface.cli._console import is_plain_output
16
17
  from dataface.cli._extras import require_extras
17
18
  from dataface.cli._parsing import parse_kv_pairs
@@ -34,9 +35,6 @@ from dataface.cli.commands import (
34
35
  from dataface.cli.commands.mcp_init import run_init as _mcp_run_init
35
36
  from dataface.cli.commands.skills_init import run_init_skills as _skills_run_init
36
37
 
37
- # tach-ignore(cli->core debt: route via agent_api; burn-down task)
38
- from dataface.core.dashboard import RenderFormat
39
-
40
38
  # tach-ignore(cli->core debt: route via agent_api; burn-down task)
41
39
  from dataface.core.project_roots import find_dft_root
42
40
 
@@ -1499,7 +1497,9 @@ def main(
1499
1497
  ] = None,
1500
1498
  ) -> None:
1501
1499
  """dft - Declarative, dbt-native dashboards in YAML."""
1502
- pass
1500
+ # Runs before any subcommand, so every warehouse query this process sends is
1501
+ # attributed to the CLI. `dft serve` narrows it to "serve" in its own command.
1502
+ set_surface("cli")
1503
1503
 
1504
1504
 
1505
1505
  if __name__ == "__main__":
@@ -0,0 +1,191 @@
1
+ """Warehouse query attribution — what ran this query, and whose budget it belongs to.
2
+
3
+ Stage: leaf (no compile/execute/render dependencies).
4
+
5
+ Every query Dataface sends carries an attribution payload so a warehouse cost model
6
+ can tell a Dataface query apart from ad-hoc human querying. Without it a dashboard
7
+ render is indistinguishable from someone typing SQL into a console, and cost
8
+ attribution has to guess.
9
+
10
+ Two halves, with different owners:
11
+
12
+ - **Identity** is engine-owned and derived — the app, the surface, the face and query
13
+ that caused the call. Authoring it would create a second source of truth that drifts
14
+ on the first rename, so authors cannot set it. Most of it sits under the ``dft_``
15
+ namespace; ``app`` deliberately does not, because dbt emits ``app=dbt`` and sharing
16
+ that one key lets a cost model ask "which tool ran this" once instead of per tool.
17
+ - **Ownership** (``team``, ``cost_center``, …) cannot be derived from anything the
18
+ engine knows. It is authored per source via ``attribution:``.
19
+
20
+ The payload reaches the warehouse through ``execute/adapters/query_header.py``; this
21
+ module stays free of any dbt import so the compile layer can share its vocabulary.
22
+
23
+ Engine-owned values are *normalized* to the label charset (we generate them, so a face
24
+ slug of ``gtm/weekly-revenue`` becoming ``gtm-weekly-revenue`` is our own bookkeeping).
25
+ Authored values are *validated* and rejected — silently rewriting what an author typed
26
+ is the failure mode where a cost model quietly attributes spend to the wrong team.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from collections.abc import Generator, Mapping
33
+ from contextlib import contextmanager
34
+ from contextvars import ContextVar
35
+ from types import MappingProxyType
36
+
37
+ # The engine's namespace. Authors cannot write keys under it, so a cost model can
38
+ # trust that a `dft_` key means the engine said so.
39
+ RESERVED_PREFIX = "dft_"
40
+
41
+ # `app` is deliberately NOT prefixed: it is the cross-tool discriminator dbt also
42
+ # emits (`app=dbt`), so one predicate answers "which tool ran this" across both. That
43
+ # leaves it inside the space an author could otherwise write, so it is reserved by
44
+ # name — without this, `attribution: {app: looker}` would shadow the engine's value,
45
+ # since authored pairs merge over engine ones.
46
+ RESERVED_KEYS = frozenset({"app"})
47
+
48
+ # BigQuery's job-label rules are the strictest of any warehouse we emit to, so every
49
+ # key and value is held to them regardless of dialect: one rule set means the same
50
+ # source config behaves identically everywhere, instead of a value that works on
51
+ # Snowflake and is silently truncated on BigQuery.
52
+ _KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{0,62}$")
53
+ _VALUE_PATTERN = re.compile(r"^[a-z0-9_-]{0,63}$")
54
+ _NON_LABEL_CHARS = re.compile(r"[^a-z0-9_-]")
55
+ _MAX_VALUE_LENGTH = 63
56
+
57
+ # A frozen empty mapping, not `{}`: a mutable ContextVar default is shared across
58
+ # every context that never sets one, so a stray write would leak between them.
59
+ _NO_ATTRIBUTION: Mapping[str, str] = MappingProxyType({})
60
+
61
+ _runtime: ContextVar[Mapping[str, str]] = ContextVar(
62
+ "dft_attribution", default=_NO_ATTRIBUTION
63
+ )
64
+
65
+ # Set once by the entry point (see set_surface); empty until one declares itself.
66
+ _process_surface: dict[str, str] = {}
67
+
68
+ # The dimensions a scope may establish. `surface` is here too so a narrower scope
69
+ # (the inspector, say) can override what the entry point declared.
70
+ SCOPE_DIMENSIONS = frozenset({"surface", "face", "query", "target"})
71
+
72
+
73
+ def validate_attribution(attribution: Mapping[str, str]) -> None:
74
+ """Raise ``ValueError`` on a reserved or malformed authored attribution pair.
75
+
76
+ Runs at the compile boundary, on values already rendered through dbt Jinja, so an
77
+ ``{{ env_var(...) }}`` result is held to the same rules as a literal.
78
+ """
79
+ for key, value in attribution.items():
80
+ if key.startswith(RESERVED_PREFIX) or key in RESERVED_KEYS:
81
+ raise ValueError(
82
+ f"attribution key {key!r} is reserved: the engine sets it. Keys "
83
+ f"beginning with {RESERVED_PREFIX!r}, and "
84
+ f"{', '.join(sorted(repr(k) for k in RESERVED_KEYS))}, are engine-owned"
85
+ )
86
+ if not _KEY_PATTERN.match(key):
87
+ raise ValueError(
88
+ f"attribution key {key!r} is invalid: keys must match "
89
+ f"[a-z][a-z0-9_-]{{0,62}}"
90
+ )
91
+ if not _VALUE_PATTERN.match(value):
92
+ raise ValueError(
93
+ f"attribution value for {key!r} is invalid: values must match "
94
+ f"[a-z0-9_-]{{0,63}}"
95
+ )
96
+
97
+
98
+ def engine_attribution() -> dict[str, str]:
99
+ """The constant half of the engine's identity: what produced this query."""
100
+ from dataface import __version__
101
+
102
+ return {
103
+ # `app` matches dbt's own default query comment, which emits `app=dbt` — so a
104
+ # cost model can filter one key across both tools instead of two.
105
+ "app": "dataface",
106
+ "dft_version": _normalize(__version__),
107
+ }
108
+
109
+
110
+ def connection_identity() -> dict[str, str]:
111
+ """The attribution stable enough to ride a pooled connection.
112
+
113
+ Engine constants plus the process surface — deliberately *not*
114
+ :func:`current_attribution`, which also carries the per-query scope. A pooled
115
+ connection is shared by every source with the same connection identity and a
116
+ worker holds it across renders, so anything written at connect time must be true
117
+ for every source and every query that will use it. The face, the query, and the
118
+ source's authored ``attribution:`` are none of those things; they ride the query
119
+ comment, which is rebuilt per call.
120
+ """
121
+ return {**engine_attribution(), **_process_surface}
122
+
123
+
124
+ def current_attribution() -> dict[str, str]:
125
+ """The engine identity in effect: the process surface plus enclosing scopes."""
126
+ return {**_process_surface, **_runtime.get()}
127
+
128
+
129
+ def set_surface(surface: str) -> None:
130
+ """Declare which Dataface entry point this process is: ``cli``, ``serve``, ``cloud``…
131
+
132
+ The surface is a property of the running process, not of a request or a session,
133
+ so it is set once at the entry point rather than threaded through every registry
134
+ construction. Nothing is assumed when no entry point declares one — the payload
135
+ omits ``dft_surface`` rather than inventing a value, so a cost model never reads a
136
+ surface nobody set.
137
+
138
+ Deliberately process-wide: unlike a tenant or a user, every session in a given
139
+ process shares the same entry point, so there is no cross-tenant leak here.
140
+ """
141
+ _process_surface[f"{RESERVED_PREFIX}surface"] = _normalize(surface)
142
+
143
+
144
+ @contextmanager
145
+ def attribute(pairs: Mapping[str, str], authored: Mapping[str, str]) -> Generator[None]:
146
+ """Establish attribution for every warehouse call made inside the scope.
147
+
148
+ *pairs* holds the engine dimensions this scope actually knows — a composition root
149
+ knows the surface but not yet the face; the execute boundary knows the face and
150
+ query. Scopes nest and merge, innermost wins, and a dimension nobody set is simply
151
+ absent from the payload rather than emitted empty. Keys are bare dimension names
152
+ (``face``, not ``dft_face``); the prefix is added here, which is what makes the
153
+ reserved namespace unreachable from anywhere else. An unknown name is a caller
154
+ bug, so it raises rather than silently vanishing.
155
+
156
+ *authored* is the resolved source's own ``attribution:`` block, already validated
157
+ at the compile boundary, merged in unprefixed. It rides this per-call scope rather
158
+ than being captured when the adapter is built: connection pools are shared by
159
+ every source with the same *connection* identity, so an adapter-captured value
160
+ would label one source's queries with a different source's team.
161
+
162
+ Values are normalized to the label charset here rather than at emission, so the
163
+ payload is already warehouse-safe wherever it is read.
164
+
165
+ Note for callers dispatching onto their own threads: ``ContextVar`` does not cross
166
+ ``ThreadPoolExecutor.submit`` on its own — pass ``contextvars.copy_context().run``
167
+ or the scope is invisible to the worker.
168
+ """
169
+ unknown = sorted(set(pairs) - SCOPE_DIMENSIONS)
170
+ if unknown:
171
+ raise ValueError(
172
+ f"unknown attribution dimension(s) {unknown}: "
173
+ f"expected any of {sorted(SCOPE_DIMENSIONS)}"
174
+ )
175
+ established = {
176
+ **dict(authored),
177
+ **{
178
+ f"{RESERVED_PREFIX}{name}": _normalize(value)
179
+ for name, value in pairs.items()
180
+ },
181
+ }
182
+ token = _runtime.set({**_runtime.get(), **established})
183
+ try:
184
+ yield
185
+ finally:
186
+ _runtime.reset(token)
187
+
188
+
189
+ def _normalize(value: str) -> str:
190
+ """Coerce an engine-generated value into the label charset."""
191
+ return _NON_LABEL_CHARS.sub("-", value.strip().lower())[:_MAX_VALUE_LENGTH]