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.
- d3_format/spec.py +45 -0
- dataface/agent_api/__init__.py +6 -2
- dataface/agent_api/board_artifact.py +11 -1
- dataface/agent_api/docs/yaml-reference.md +6 -0
- dataface/cli/commands/render.py +1 -4
- dataface/cli/commands/serve.py +6 -0
- dataface/cli/main.py +4 -4
- dataface/core/attribution.py +191 -0
- dataface/core/compile/channel.py +232 -119
- dataface/core/compile/jinja.py +2 -1
- dataface/core/compile/label_data.py +154 -0
- dataface/core/compile/link_keys.py +127 -0
- dataface/core/compile/migrations.py +29 -13
- dataface/core/compile/models/chart/normalized/_base.py +2 -2
- dataface/core/compile/models/chart/normalized/callout.py +2 -2
- dataface/core/compile/models/chart/resolved/_base.py +2 -2
- dataface/core/compile/models/chart/resolved/callout.py +1 -1
- dataface/core/compile/models/chart/resolved/pie.py +55 -1
- dataface/core/compile/models/config.py +4 -0
- dataface/core/compile/models/query/normalized.py +2 -2
- dataface/core/compile/models/source.py +91 -8
- dataface/core/compile/models/style/resolved/__init__.py +1 -1
- dataface/core/compile/models/style/resolved/_base.py +1 -1
- dataface/core/compile/models/style/resolved/_cascade.py +12 -4
- dataface/core/compile/models/style/resolved/table.py +8 -3
- dataface/core/compile/models/style/theme/marks.py +1 -1
- dataface/core/compile/models/variable/authored.py +2 -2
- dataface/core/compile/normalize_charts.py +1 -1
- dataface/core/compile/normalize_queries.py +1 -1
- dataface/core/compile/normalize_variables.py +2 -2
- dataface/core/compile/palette.py +1 -1
- dataface/core/compile/resolve/__init__.py +47 -7
- dataface/core/compile/resolve/_axes.py +313 -0
- dataface/core/compile/resolve/_channels.py +120 -0
- dataface/core/compile/resolve/_chart_rows.py +29 -0
- dataface/core/compile/resolve/_dispatch.py +316 -0
- dataface/core/compile/resolve/_domain.py +465 -0
- dataface/core/compile/resolve/_kwargs.py +238 -0
- dataface/core/compile/resolve/_layers.py +241 -0
- dataface/core/compile/resolve/_links.py +66 -0
- dataface/core/compile/resolve/_marks.py +141 -0
- dataface/core/compile/resolve/_palette.py +187 -0
- dataface/core/compile/resolve/_table.py +131 -0
- dataface/core/compile/resolve/area.py +502 -0
- dataface/core/compile/resolve/bar.py +412 -0
- dataface/core/compile/resolve/geo.py +416 -0
- dataface/core/compile/resolve/heatmap.py +113 -0
- dataface/core/compile/resolve/line.py +314 -0
- dataface/core/compile/resolve/pie.py +287 -0
- dataface/core/compile/resolve/pie_attachment.py +170 -0
- dataface/core/compile/resolve/scatter.py +222 -0
- dataface/core/compile/resolve/simple.py +158 -0
- dataface/core/compile/single_series_allocation.py +1 -1
- dataface/core/compile/style_cascade.py +1 -1
- dataface/core/compile/variables.py +82 -2
- dataface/core/defaults/default_config.yml +6 -0
- dataface/core/diagnostics/__init__.py +5 -1
- dataface/core/diagnostics/codes_execute.py +23 -0
- dataface/core/diagnostics/codes_render.py +36 -23
- dataface/core/execute/adapters/adapter_registry.py +27 -5
- dataface/core/execute/adapters/base.py +9 -5
- dataface/core/execute/adapters/dbt_adapter.py +89 -4
- dataface/core/execute/adapters/dbt_adapter_factory.py +36 -6
- dataface/core/execute/adapters/duckdb_adapter.py +5 -3
- dataface/core/execute/adapters/http_adapter.py +2 -2
- dataface/core/execute/adapters/native_attribution.py +66 -0
- dataface/core/execute/adapters/query_header.py +96 -0
- dataface/core/execute/adapters/schema_adapter.py +2 -2
- dataface/core/execute/adapters/sql_adapter.py +36 -9
- dataface/core/execute/adapters/sqlite_adapter.py +5 -3
- dataface/core/execute/adapters/values_adapter.py +2 -2
- dataface/core/execute/cache_keys.py +10 -4
- dataface/core/execute/chart_data_provider.py +1 -1
- dataface/core/execute/chart_resolution.py +278 -0
- dataface/core/execute/recording.py +6 -4
- dataface/core/execute/source_resolver.py +23 -12
- dataface/core/font_measure.py +108 -0
- dataface/core/fonts.py +218 -49
- dataface/core/registered_views/variable_planner.py +7 -74
- dataface/core/render/board_replay.py +15 -0
- dataface/core/render/chart/arc_attached_table.py +19 -368
- dataface/core/render/chart/auto_link.py +3 -3
- dataface/core/render/chart/callout.py +1 -1
- dataface/core/render/chart/data_table_attachment.py +1 -1
- dataface/core/render/chart/emitters/_channels.py +48 -8
- dataface/core/render/chart/emitters/_label_overlap.py +1 -1
- dataface/core/render/chart/emitters/_measured_label_padding.py +1 -1
- dataface/core/render/chart/emitters/area.py +23 -10
- dataface/core/render/chart/emitters/bar.py +11 -1
- dataface/core/render/chart/emitters/line.py +26 -10
- dataface/core/render/chart/emitters/pie.py +61 -52
- dataface/core/render/chart/features/endpoint_labels.py +1 -1
- dataface/core/render/chart/features/value_labels.py +1 -1
- dataface/core/render/chart/kpi.py +2 -2
- dataface/core/render/chart/table.py +48 -88
- dataface/core/render/chart/table_support.py +7 -22
- dataface/core/render/chart/time_unit_detect.py +66 -1
- dataface/core/render/chart/title_overflow.py +1 -1
- dataface/core/render/chart/type_inference.py +36 -8
- dataface/core/render/chart/vega_lite.py +47 -245
- dataface/core/render/controls.py +5 -5
- dataface/core/render/face_resolve.py +64 -17
- dataface/core/render/face_to_dict.py +1 -1
- dataface/core/render/faces.py +87 -43
- dataface/core/render/font_support.py +64 -103
- dataface/core/render/fonts/SourceSerif4Variable.ttf +0 -0
- dataface/core/render/fonts/{_emoji_font_face.css → _font_face.css} +1 -0
- dataface/core/render/layout_sizing.py +147 -125
- dataface/core/render/renderer.py +22 -2
- dataface/core/render/sizing.py +26 -57
- dataface/core/render/svg_utils.py +2 -3
- dataface/core/render/template_loader.py +7 -9
- dataface/core/render/templates/page.css +2 -2
- dataface/core/render/templates/svg/styles.css +1 -1
- dataface/core/render/terminal.py +5 -14
- dataface/core/render/terminal_charts.py +45 -46
- dataface/core/render/variables_strip.py +7 -6
- dataface/core/render/warnings/value_labels_crowd_width.py +1 -1
- dataface/core/serve/server.py +2 -2
- dataface/data/schemas/board-resolved/board-resolved.schema.json +149 -116
- dataface/data/schemas/yaml/0.3.1.json +1 -0
- dataface/data/schemas/yaml/manifest.json +7 -0
- {dataface-0.3.1.dev132.dist-info → dataface-0.3.2.dev22.dist-info}/METADATA +1 -1
- {dataface-0.3.1.dev132.dist-info → dataface-0.3.2.dev22.dist-info}/RECORD +131 -109
- mdsvg/__init__.py +5 -3
- mdsvg/fonts.py +130 -35
- mdsvg/renderer.py +258 -37
- mdsvg/style.py +18 -3
- dataface/core/compile/resolve/_resolve.py +0 -3320
- dataface/core/registered_views/link_keys.py +0 -45
- dataface/core/render/chart/labels.py +0 -76
- dataface/core/render/font_measurement.py +0 -35
- dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
- dataface/core/render/shared_y_datasets.py +0 -33
- dataface/core/render/stable_domain.py +0 -365
- {dataface-0.3.1.dev132.dist-info → dataface-0.3.2.dev22.dist-info}/WHEEL +0 -0
- {dataface-0.3.1.dev132.dist-info → dataface-0.3.2.dev22.dist-info}/entry_points.txt +0 -0
- {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.
|
dataface/agent_api/__init__.py
CHANGED
|
@@ -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
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
dataface/cli/commands/render.py
CHANGED
|
@@ -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",
|
dataface/cli/commands/serve.py
CHANGED
|
@@ -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
|
-
|
|
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]
|