dataface 0.3.2.dev22__py3-none-any.whl → 0.3.2.dev111__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.
- dataface/__init__.py +70 -23
- dataface/agent_api/__init__.py +162 -68
- dataface/agent_api/_paths.py +9 -3
- dataface/agent_api/docs/yaml-reference.md +240 -106
- dataface/agent_api/init.py +1 -1
- dataface/agent_api/project_session.py +2 -1
- dataface/agent_api/serve.py +115 -0
- dataface/ai/agent.py +28 -2
- dataface/cli/_project.py +100 -1
- dataface/cli/commands/board_artifact.py +5 -2
- dataface/cli/commands/describe.py +7 -3
- dataface/cli/commands/docs.py +12 -3
- dataface/cli/commands/init.py +7 -14
- dataface/cli/commands/inspect.py +6 -1
- dataface/cli/commands/mcp_init.py +23 -43
- dataface/cli/commands/migrate.py +8 -3
- dataface/cli/commands/query.py +11 -8
- dataface/cli/commands/render.py +20 -9
- dataface/cli/commands/search.py +8 -2
- dataface/cli/commands/serve.py +26 -76
- dataface/cli/commands/skills_init.py +2 -19
- dataface/cli/commands/validate.py +7 -2
- dataface/cli/filesystem_project.py +5 -4
- dataface/cli/main.py +2 -19
- dataface/core/__init__.py +73 -28
- dataface/core/{compile/colors.py → colors.py} +20 -5
- dataface/core/compile/compiler.py +97 -34
- dataface/core/compile/data_table.py +7 -2
- dataface/core/compile/enrich.py +18 -44
- dataface/core/compile/face_warnings.py +46 -8
- dataface/core/compile/format.py +47 -1
- dataface/core/compile/inherit_graph.py +3 -3
- dataface/core/compile/inherit_registry.yaml +1 -1
- dataface/core/compile/migrations.py +112 -9
- dataface/core/compile/models/chart/normalized/kpi.py +0 -4
- dataface/core/compile/models/chart/resolved/_base.py +28 -10
- dataface/core/compile/models/chart/resolved/callout.py +2 -3
- dataface/core/compile/models/chart/resolved/kpi.py +7 -3
- dataface/core/compile/models/chart/resolved/table.py +16 -2
- dataface/core/compile/models/markers.py +1 -1
- dataface/core/compile/models/primitives.py +175 -2
- dataface/core/compile/models/source.py +5 -5
- dataface/core/compile/models/style/authored/__init__.py +32 -8
- dataface/core/compile/models/style/authored/_base.py +126 -69
- dataface/core/compile/models/style/authored/table.py +61 -3
- dataface/core/compile/models/style/resolved/__init__.py +12 -2
- dataface/core/compile/models/style/resolved/_base.py +142 -57
- dataface/core/compile/models/style/resolved/_cartesian.py +6 -11
- dataface/core/compile/models/style/resolved/_cascade.py +141 -54
- dataface/core/compile/models/style/resolved/area.py +1 -1
- dataface/core/compile/models/style/resolved/bar.py +1 -1
- dataface/core/compile/models/style/resolved/line.py +1 -1
- dataface/core/compile/models/style/resolved/scatter.py +3 -2
- dataface/core/compile/models/style/theme/__init__.py +36 -14
- dataface/core/compile/models/style/theme/_chart_base.py +17 -9
- dataface/core/compile/models/style/theme/axis.py +432 -312
- dataface/core/compile/models/style/theme/charts.py +10 -6
- dataface/core/compile/models/style/theme/kpi.py +2 -1
- dataface/core/compile/models/style/theme/page.py +5 -2
- dataface/core/compile/models/style/theme/table.py +2 -2
- dataface/core/compile/models/vega_lite/config.py +1 -1
- dataface/core/compile/normalize_charts.py +6 -2
- dataface/core/compile/normalize_queries.py +21 -7
- dataface/core/compile/resolve/__init__.py +2 -0
- dataface/core/compile/resolve/_axes.py +147 -44
- dataface/core/compile/resolve/_channels.py +1 -1
- dataface/core/compile/resolve/_dispatch.py +1 -1
- dataface/core/compile/resolve/_domain.py +49 -32
- dataface/core/compile/resolve/_kwargs.py +39 -12
- dataface/core/compile/resolve/_marks.py +8 -3
- dataface/core/compile/resolve/_palette.py +7 -3
- dataface/core/compile/resolve/_table.py +124 -33
- dataface/core/compile/resolve/area.py +48 -18
- dataface/core/compile/resolve/bar.py +245 -64
- dataface/core/compile/resolve/geo.py +10 -3
- dataface/core/compile/resolve/heatmap.py +16 -8
- dataface/core/compile/resolve/line.py +35 -12
- dataface/core/compile/resolve/pie.py +4 -1
- dataface/core/compile/resolve/pie_attachment.py +1 -1
- dataface/core/compile/resolve/scatter.py +25 -13
- dataface/core/compile/resolve/simple.py +25 -2
- dataface/core/compile/schema_renderers/prompt.py +1 -1
- dataface/core/compile/schema_renderers/textmate_grammar.py +8 -1
- dataface/core/compile/source_map.py +211 -28
- dataface/core/compile/style_cascade.py +126 -55
- dataface/core/compile/yaml_error_formatter.py +1 -1
- dataface/core/connections.py +10 -3
- dataface/core/dashboard.py +24 -18
- dataface/core/defaults/themes/_base.yaml +49 -39
- dataface/core/defaults/themes/cream.yaml +4 -4
- dataface/core/defaults/themes/diagnostics-title-baseline.yaml +1 -1
- dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +1 -1
- dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +1 -1
- dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +1 -1
- dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +1 -1
- dataface/core/defaults/themes/editorial.yaml +6 -6
- dataface/core/defaults/themes/neon.yaml +15 -8
- dataface/core/defaults/themes/plain.yaml +6 -4
- dataface/core/defaults/themes/stark.yaml +16 -7
- dataface/core/defaults/themes/vivid.yaml +15 -8
- dataface/core/diagnostics/__init__.py +2 -2
- dataface/core/diagnostics/codes_compile.py +13 -31
- dataface/core/diagnostics/codes_execute.py +79 -0
- dataface/core/diagnostics/codes_render.py +87 -28
- dataface/core/diagnostics/diagnostic.py +30 -0
- dataface/core/diagnostics/hints.py +19 -0
- dataface/core/dialects/base.py +17 -0
- dataface/core/dialects/postgres.py +14 -0
- dataface/core/dialects/redshift.py +5 -0
- dataface/core/execute/__init__.py +59 -14
- dataface/core/execute/adapters/dbt_adapter.py +4 -1
- dataface/core/execute/adapters/dbt_adapter_factory.py +19 -0
- dataface/core/execute/adapters/dbt_utils.py +84 -20
- dataface/core/execute/adapters/postgres_connection_manager.py +48 -0
- dataface/core/execute/adapters/sql_adapter.py +72 -4
- dataface/core/execute/dbt_jinja.py +65 -10
- dataface/core/execute/executor.py +69 -16
- dataface/core/font_measure.py +28 -0
- dataface/core/fonts.py +8 -5
- dataface/core/inspect/__init__.py +46 -6
- dataface/core/project.py +13 -1
- dataface/core/render/chart/data_table_attachment.py +14 -12
- dataface/core/render/chart/emitters/_cartesian.py +21 -18
- dataface/core/render/chart/emitters/_channels.py +56 -8
- dataface/core/render/chart/emitters/_label_overlap.py +18 -15
- dataface/core/render/chart/emitters/_measured_label_padding.py +4 -4
- dataface/core/render/chart/emitters/area.py +3 -3
- dataface/core/render/chart/emitters/bar.py +60 -35
- dataface/core/render/chart/emitters/geo.py +8 -13
- dataface/core/render/chart/emitters/heatmap.py +20 -26
- dataface/core/render/chart/emitters/line.py +2 -2
- dataface/core/render/chart/emitters/scatter.py +7 -30
- dataface/core/render/chart/features/baseline.py +22 -9
- dataface/core/render/chart/features/endpoint_labels.py +55 -32
- dataface/core/render/chart/features/mirror_axis.py +12 -9
- dataface/core/render/chart/features/value_labels.py +1 -1
- dataface/core/render/chart/kpi.py +11 -90
- dataface/core/render/chart/presentation.py +6 -8
- dataface/core/render/chart/rendering.py +3 -12
- dataface/core/render/chart/session.py +10 -0
- dataface/core/render/chart/spec.py +3 -0
- dataface/core/render/chart/spec_builders.py +18 -18
- dataface/core/render/chart/table.py +81 -36
- dataface/core/render/chart/table_support.py +2 -53
- dataface/core/render/chart/time_unit_detect.py +3 -2
- dataface/core/render/chart/translate.py +82 -44
- dataface/core/render/chart/type_inference.py +45 -25
- dataface/core/render/chart/validation.py +91 -0
- dataface/core/render/chart/vega_lite.py +30 -11
- dataface/core/render/chart/vl_field_maps.py +111 -76
- dataface/core/render/converters/chart.py +16 -6
- dataface/core/render/faces.py +3 -2
- dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.woff2 +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.woff2 +0 -0
- dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -93
- dataface/core/render/fonts/_font_face.css +10 -0
- dataface/core/render/layout_sizing.py +267 -238
- dataface/core/render/numeral_expr.py +80 -0
- dataface/core/render/warnings/bar_band_width_too_narrow.py +64 -6
- dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +25 -3
- dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +12 -6
- dataface/core/render/warnings/palette_unsupported.py +6 -0
- dataface/core/render/warnings/pie_dominant_segment.py +1 -0
- dataface/core/render/warnings/pie_too_many_segments.py +1 -0
- dataface/core/render/warnings/point_map_out_of_projection.py +1 -0
- dataface/core/render/warnings/query_returned_zero_rows.py +1 -0
- dataface/core/render/warnings/redundant_encoding.py +20 -1
- dataface/core/render/warnings/registry.py +7 -3
- dataface/core/render/warnings/temporal_single_point.py +1 -0
- dataface/core/render/warnings/too_many_color_categories.py +1 -0
- dataface/core/render/warnings/too_many_x_categories.py +18 -2
- dataface/core/render/warnings/value_labels_crowd_width.py +9 -0
- dataface/core/render/warnings/y_encoding_mostly_null.py +1 -0
- dataface/core/render_format.py +18 -0
- dataface/core/text/format_d3.py +56 -0
- dataface/core/text/numeral_scale.py +218 -0
- dataface/core/utils.py +103 -10
- dataface/data/schemas/board-resolved/board-resolved.schema.json +9487 -7978
- dataface/data/schemas/yaml/0.3.2.json +1 -0
- dataface/data/schemas/yaml/manifest.json +7 -0
- {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/METADATA +1 -1
- {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/RECORD +185 -177
- {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/WHEEL +0 -0
- {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/entry_points.txt +0 -0
- {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/licenses/LICENSE +0 -0
|
@@ -86,7 +86,10 @@ from dataface.core.compile.normalize_charts import (
|
|
|
86
86
|
collect_lookml_channel_refs,
|
|
87
87
|
collect_metricflow_channel_refs,
|
|
88
88
|
)
|
|
89
|
-
from dataface.core.compile.normalize_queries import
|
|
89
|
+
from dataface.core.compile.normalize_queries import (
|
|
90
|
+
find_lookml_model_source,
|
|
91
|
+
split_external_query_ref,
|
|
92
|
+
)
|
|
90
93
|
from dataface.core.compile.normalize_variables import (
|
|
91
94
|
VariableReferenceErrors,
|
|
92
95
|
synthetic_query_name,
|
|
@@ -100,6 +103,7 @@ from dataface.core.compile.parser import (
|
|
|
100
103
|
)
|
|
101
104
|
from dataface.core.compile.source_map import (
|
|
102
105
|
LiteralBlock,
|
|
106
|
+
build_container_paths,
|
|
103
107
|
build_literal_blocks,
|
|
104
108
|
build_source_map,
|
|
105
109
|
stamp_diagnostics,
|
|
@@ -163,6 +167,12 @@ class CompileResult:
|
|
|
163
167
|
diagnostics (chart-render errors, render warnings, query
|
|
164
168
|
diagnostics) reuse this same map via ``stamp_diagnostics`` instead
|
|
165
169
|
of each doing its own position resolution.
|
|
170
|
+
container_paths: Dotted paths addressing a mapping/sequence rather
|
|
171
|
+
than a scalar, built alongside ``source_map``. Lets the stamping
|
|
172
|
+
pass collapse a container's whole-body range to its key line, so a
|
|
173
|
+
diagnostic about a chart marks the line naming it instead of every
|
|
174
|
+
line inside. Carried on the result for the same reason as
|
|
175
|
+
``sql_blocks``: render-side stamping reuses it without recompiling.
|
|
166
176
|
sql_blocks: Dotted-path -> LiteralBlock for every literal (``|``)
|
|
167
177
|
block scalar, built alongside ``source_map``. Render-side
|
|
168
178
|
diagnostics pass this to ``stamp_diagnostics`` too, so a query
|
|
@@ -188,6 +198,7 @@ class CompileResult:
|
|
|
188
198
|
meta_lint: MetaLintConfig | None = None
|
|
189
199
|
source_map: dict[str, SourceRange] = field(default_factory=dict)
|
|
190
200
|
sql_blocks: dict[str, LiteralBlock] = field(default_factory=dict)
|
|
201
|
+
container_paths: frozenset[str] = frozenset()
|
|
191
202
|
|
|
192
203
|
@property
|
|
193
204
|
def success(self) -> bool:
|
|
@@ -211,11 +222,13 @@ def _stamp_compile_result(
|
|
|
211
222
|
return result
|
|
212
223
|
source_map = build_source_map(yaml_content, file)
|
|
213
224
|
sql_blocks = build_literal_blocks(yaml_content)
|
|
225
|
+
containers = build_container_paths(yaml_content)
|
|
214
226
|
result.source_map = source_map
|
|
215
227
|
result.sql_blocks = sql_blocks
|
|
216
|
-
|
|
217
|
-
stamp_diagnostics(result.
|
|
218
|
-
stamp_diagnostics(result.
|
|
228
|
+
result.container_paths = containers
|
|
229
|
+
stamp_diagnostics(result.errors, source_map, sql_blocks, containers)
|
|
230
|
+
stamp_diagnostics(result.warnings, source_map, sql_blocks, containers)
|
|
231
|
+
stamp_diagnostics(result.suppressed_warnings, source_map, sql_blocks, containers)
|
|
219
232
|
return result
|
|
220
233
|
|
|
221
234
|
|
|
@@ -1523,20 +1536,53 @@ def pull_cross_face_query_chain(
|
|
|
1523
1536
|
|
|
1524
1537
|
|
|
1525
1538
|
def _is_lexically_scoped_query_name(value: str) -> bool:
|
|
1526
|
-
"""True when a chart's `query:`/`layers[].query` string
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1539
|
+
"""True when a chart's `query:`/`layers[].query` string must resolve
|
|
1540
|
+
against the *source* face rather than the importer: a bare name declared
|
|
1541
|
+
in the source face's own `queries:` section, or a `<file>#<name>`
|
|
1542
|
+
external ref anchored at the source face's own directory. Excluded is
|
|
1543
|
+
inline SQL (self-contained) and a full `<file>.queries.<name>` cross-file
|
|
1544
|
+
ref written directly on the chart itself (an already-ambiguous, unrelated
|
|
1545
|
+
edge case for local charts too — left untouched).
|
|
1533
1546
|
"""
|
|
1534
1547
|
stripped = value.strip()
|
|
1535
|
-
return (
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1548
|
+
return ".queries." not in stripped and not looks_like_sql(stripped)
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _read_queries_section(
|
|
1552
|
+
file_dir: ProjectDirectory, target_file_str: str, ref: str, chart_ref: str
|
|
1553
|
+
) -> tuple[ProjectPath, dict[str, YamlValue]]:
|
|
1554
|
+
"""The `queries:` section of `target_file_str`, resolved relative to
|
|
1555
|
+
`file_dir` — the *declaring* file's own directory, never the importer's.
|
|
1556
|
+
|
|
1557
|
+
`ref` is the authored ref string this file part came from and `chart_ref`
|
|
1558
|
+
the chart import that pulled it in; both are named in every failure, since
|
|
1559
|
+
the author is looking at neither file when the resolution breaks.
|
|
1560
|
+
"""
|
|
1561
|
+
where = f"Chart ref '{chart_ref}' requires '{ref}'"
|
|
1562
|
+
try:
|
|
1563
|
+
target_path = file_dir / target_file_str
|
|
1564
|
+
except ValueError as e:
|
|
1565
|
+
raise CompilationError(f"{where}: {e}") from e
|
|
1566
|
+
if not target_path.exists():
|
|
1567
|
+
raise CompilationError(
|
|
1568
|
+
f"{where}, but {target_file_str} does not exist "
|
|
1569
|
+
f"(resolved relative to {file_dir.relpath})."
|
|
1570
|
+
)
|
|
1571
|
+
try:
|
|
1572
|
+
target_content = target_path.read_yaml()
|
|
1573
|
+
except (OSError, yaml.YAMLError) as e:
|
|
1574
|
+
raise CompilationError(f"{where}: failed to load {target_file_str}: {e}") from e
|
|
1575
|
+
# read_yaml is -> Any: a file whose top level is a list or scalar parses
|
|
1576
|
+
# fine and would only fail at the .get below, as an AttributeError escaping
|
|
1577
|
+
# compile() rather than a diagnostic.
|
|
1578
|
+
if target_content is not None and not isinstance(target_content, dict):
|
|
1579
|
+
raise CompilationError(f"{where}, but {target_file_str} is not a YAML mapping.")
|
|
1580
|
+
target_queries = target_content.get("queries") if target_content else None
|
|
1581
|
+
if not isinstance(target_queries, dict):
|
|
1582
|
+
raise CompilationError(
|
|
1583
|
+
f"{where}, but {target_file_str} has no queries: section."
|
|
1584
|
+
)
|
|
1585
|
+
return target_path, target_queries
|
|
1540
1586
|
|
|
1541
1587
|
|
|
1542
1588
|
def _resolve_cross_face_query(
|
|
@@ -1593,19 +1639,9 @@ def _resolve_cross_face_query(
|
|
|
1593
1639
|
target_file_str, target_name = raw_def["ref"].rsplit(".queries.", 1)
|
|
1594
1640
|
if not (target_file_str.endswith(".yml") or target_file_str.endswith(".yaml")):
|
|
1595
1641
|
target_file_str += ".yml"
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
raise CompilationError(str(e)) from e
|
|
1600
|
-
if not target_path.exists():
|
|
1601
|
-
raise CompilationError(f"Referenced file not found: {target_file_str}")
|
|
1602
|
-
try:
|
|
1603
|
-
target_content = target_path.read_yaml()
|
|
1604
|
-
except (OSError, yaml.YAMLError) as e:
|
|
1605
|
-
raise CompilationError(f"Failed to load {target_file_str}: {e}") from e
|
|
1606
|
-
target_queries = target_content.get("queries") if target_content else None
|
|
1607
|
-
if not isinstance(target_queries, dict):
|
|
1608
|
-
raise CompilationError(f"No queries found in {target_file_str}")
|
|
1642
|
+
target_path, target_queries = _read_queries_section(
|
|
1643
|
+
file_dir, target_file_str, raw_def["ref"], chart_ref
|
|
1644
|
+
)
|
|
1609
1645
|
target_key = _resolve_cross_face_query(
|
|
1610
1646
|
target_name,
|
|
1611
1647
|
target_path.parent,
|
|
@@ -1713,12 +1749,39 @@ def _load_chart_ref_with_lexical_queries(
|
|
|
1713
1749
|
key_cache: dict[tuple[str, str], str] = {}
|
|
1714
1750
|
resolving: set[tuple[str, str]] = set()
|
|
1715
1751
|
|
|
1716
|
-
def resolve_name(
|
|
1752
|
+
def resolve_name(name: str) -> str:
|
|
1753
|
+
"""A bare name resolves in the source face's own `queries:`; a
|
|
1754
|
+
`<file>#<name>` external ref resolves in that file's, reached relative
|
|
1755
|
+
to the source face's directory. Either way the resulting query lands
|
|
1756
|
+
in `cross_face_queries` under a key qualified by the file it was
|
|
1757
|
+
actually declared in — so an importer `#`-ref spelled identically to
|
|
1758
|
+
the imported chart's, but pointing at a different file, stays its own
|
|
1759
|
+
query.
|
|
1760
|
+
"""
|
|
1761
|
+
stripped = name.strip()
|
|
1762
|
+
if "#" not in stripped:
|
|
1763
|
+
return _resolve_cross_face_query(
|
|
1764
|
+
stripped,
|
|
1765
|
+
file_dir,
|
|
1766
|
+
file_relpath,
|
|
1767
|
+
queries_section,
|
|
1768
|
+
cross_face_queries,
|
|
1769
|
+
key_cache,
|
|
1770
|
+
resolving,
|
|
1771
|
+
chart_ref.ref,
|
|
1772
|
+
sources=sources,
|
|
1773
|
+
cache_root=cache_root,
|
|
1774
|
+
face_cache=face_cache,
|
|
1775
|
+
)
|
|
1776
|
+
target_file_str, target_name = split_external_query_ref(stripped)
|
|
1777
|
+
target_path, target_queries = _read_queries_section(
|
|
1778
|
+
file_dir, target_file_str, stripped, chart_ref.ref
|
|
1779
|
+
)
|
|
1717
1780
|
return _resolve_cross_face_query(
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1781
|
+
target_name,
|
|
1782
|
+
target_path.parent,
|
|
1783
|
+
target_path.relpath,
|
|
1784
|
+
target_queries,
|
|
1722
1785
|
cross_face_queries,
|
|
1723
1786
|
key_cache,
|
|
1724
1787
|
resolving,
|
|
@@ -53,11 +53,16 @@ def axis_offset(charts_style: ResolvedChartsStyle, style: DataTableStyle) -> flo
|
|
|
53
53
|
# Visibility is read from the fully-merged cascade (includes Layer-6 authored-
|
|
54
54
|
# label flips) so both label and title heights reflect the actual rendered state.
|
|
55
55
|
merged = resolved_axis_style(charts_style, "axis_x", "band")
|
|
56
|
+
# labels.padding is always required by build_resolved_axis (the shared
|
|
57
|
+
# ResolvedAxisElementStyle dataclass types it Optional only to let title
|
|
58
|
+
# leave it unset — see AxisTitleStyle).
|
|
59
|
+
assert merged.labels.padding is not None, "axis_x.labels.padding unset"
|
|
56
60
|
label_h = (
|
|
57
|
-
merged.
|
|
58
|
-
if merged.
|
|
61
|
+
merged.labels.padding + merged.labels.font.size * style.label_max_lines
|
|
62
|
+
if merged.labels.visible is not False and merged.labels.font.size > 0
|
|
59
63
|
else 0.0
|
|
60
64
|
)
|
|
65
|
+
assert merged.title.padding is not None, "axis_x.title.padding unset"
|
|
61
66
|
title_h = (
|
|
62
67
|
merged.title.padding + merged.title.font.size
|
|
63
68
|
if merged.title.visible is not False and merged.title.font.size > 0
|
dataface/core/compile/enrich.py
CHANGED
|
@@ -13,9 +13,9 @@ Pipeline position:
|
|
|
13
13
|
Key principle: every auto-decision is overridable by explicit YAML.
|
|
14
14
|
If the user sets scale/fields, those win over any auto-detection.
|
|
15
15
|
|
|
16
|
-
Format defaults come from the theme cascade (axis_quantitative.format in _base.yaml,
|
|
16
|
+
Format defaults come from the theme cascade (axis_quantitative.labels.format in _base.yaml,
|
|
17
17
|
which references the number_default alias → .3~s), not from column-name inference.
|
|
18
|
-
Authors override per-chart via chart.format or style.axis_y.format.
|
|
18
|
+
Authors override per-chart via chart.format or style.axis_y.labels.format.
|
|
19
19
|
|
|
20
20
|
Note on database driver compatibility:
|
|
21
21
|
The type_code mapping in db_type_to_dtype works for DuckDB (which uses
|
|
@@ -33,6 +33,7 @@ from decimal import Decimal
|
|
|
33
33
|
from typing import Any
|
|
34
34
|
|
|
35
35
|
from dataface.core.compile.models.chart.normalized import Chart, _CartesianChartFields
|
|
36
|
+
from dataface.core.utils import is_year_shaped
|
|
36
37
|
|
|
37
38
|
# Chart types that use x/y axes (not KPI, table, pie)
|
|
38
39
|
_AXIS_CHART_TYPES = frozenset({"bar", "line", "area", "scatter", "heatmap"})
|
|
@@ -137,8 +138,8 @@ def enrich_chart(
|
|
|
137
138
|
fall back to value-based heuristics)
|
|
138
139
|
3. User YAML annotations (always win — never overridden)
|
|
139
140
|
|
|
140
|
-
Format defaults come from the theme cascade (axis_quantitative.format) —
|
|
141
|
-
not from inference here. Authors override via chart.format or style.axis_y.format.
|
|
141
|
+
Format defaults come from the theme cascade (axis_quantitative.labels.format) —
|
|
142
|
+
not from inference here. Authors override via chart.format or style.axis_y.labels.format.
|
|
142
143
|
"""
|
|
143
144
|
if not data:
|
|
144
145
|
return chart
|
|
@@ -282,41 +283,6 @@ def classify_column_type(
|
|
|
282
283
|
return "categorical"
|
|
283
284
|
|
|
284
285
|
|
|
285
|
-
_YEAR_MIN = 1900
|
|
286
|
-
_YEAR_MAX = 2100
|
|
287
|
-
_YEAR_STRING_PATTERN = re.compile(r"^\d{4}$")
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def is_year_shaped(samples: list[Any]) -> bool:
|
|
291
|
-
"""True iff every non-null sample is a year-range value.
|
|
292
|
-
|
|
293
|
-
Value-only heuristic (no column-name signal): an ``int`` in
|
|
294
|
-
``[1900, 2100]``, a ``str`` matching ``^\\d{4}$`` in that range, or an
|
|
295
|
-
integral ``Decimal`` (``v == v.to_integral_value()``) in range — warehouses
|
|
296
|
-
(BigQuery NUMERIC, Snowflake NUMBER) return year integers as ``Decimal``.
|
|
297
|
-
A non-integral ``Decimal`` (e.g. ``2024.5``) is not a year. ``bool`` and
|
|
298
|
-
``float`` are excluded — bool is never a year, and float years don't occur
|
|
299
|
-
in practice. Empty input is not year-shaped.
|
|
300
|
-
"""
|
|
301
|
-
if not samples:
|
|
302
|
-
return False
|
|
303
|
-
for v in samples:
|
|
304
|
-
if isinstance(v, bool):
|
|
305
|
-
return False
|
|
306
|
-
if isinstance(v, int):
|
|
307
|
-
if not (_YEAR_MIN <= v <= _YEAR_MAX):
|
|
308
|
-
return False
|
|
309
|
-
elif isinstance(v, Decimal):
|
|
310
|
-
if v != v.to_integral_value() or not (_YEAR_MIN <= v <= _YEAR_MAX):
|
|
311
|
-
return False
|
|
312
|
-
elif isinstance(v, str) and _YEAR_STRING_PATTERN.match(v):
|
|
313
|
-
if not (_YEAR_MIN <= int(v) <= _YEAR_MAX):
|
|
314
|
-
return False
|
|
315
|
-
else:
|
|
316
|
-
return False
|
|
317
|
-
return True
|
|
318
|
-
|
|
319
|
-
|
|
320
286
|
def is_column_discrete_for_bar_orientation(
|
|
321
287
|
samples: list[int | float | Decimal | str | bool | date | datetime],
|
|
322
288
|
) -> bool:
|
|
@@ -470,7 +436,7 @@ def _pick_axis_style(
|
|
|
470
436
|
) -> dict[str, Any]:
|
|
471
437
|
"""Pick y-axis scale style updates.
|
|
472
438
|
|
|
473
|
-
Format defaults come from the theme cascade (axis_quantitative.format in _base.yaml).
|
|
439
|
+
Format defaults come from the theme cascade (axis_quantitative.labels.format in _base.yaml).
|
|
474
440
|
This function only handles the zero-baseline heuristic.
|
|
475
441
|
|
|
476
442
|
Returns flat dict with a ``scale`` key when a scale override is needed — the caller
|
|
@@ -489,11 +455,17 @@ def _pick_axis_style(
|
|
|
489
455
|
)
|
|
490
456
|
|
|
491
457
|
# "auto" means "run the picker" — not a pinned value, so treat as absent.
|
|
458
|
+
_existing_scale_cont = (
|
|
459
|
+
existing_scale.continuous if existing_scale is not None else None
|
|
460
|
+
)
|
|
461
|
+
_existing_ay_scale_cont = (
|
|
462
|
+
existing_axis_y_scale.continuous if existing_axis_y_scale is not None else None
|
|
463
|
+
)
|
|
492
464
|
has_zero = (
|
|
493
|
-
|
|
465
|
+
_existing_scale_cont is not None and isinstance(_existing_scale_cont.zero, bool)
|
|
494
466
|
) or (
|
|
495
|
-
|
|
496
|
-
and isinstance(
|
|
467
|
+
_existing_ay_scale_cont is not None
|
|
468
|
+
and isinstance(_existing_ay_scale_cont.zero, bool)
|
|
497
469
|
)
|
|
498
470
|
if not has_zero:
|
|
499
471
|
scale = _pick_scale(y_profile, chart_type=chart.type)
|
|
@@ -501,7 +473,9 @@ def _pick_axis_style(
|
|
|
501
473
|
scale_data = (
|
|
502
474
|
existing_scale.model_dump(exclude_none=True) if existing_scale else {}
|
|
503
475
|
)
|
|
504
|
-
|
|
476
|
+
# zero is nested under continuous; merge to preserve any existing continuous fields
|
|
477
|
+
existing_continuous = scale_data.get("continuous", {})
|
|
478
|
+
scale_data["continuous"] = {**existing_continuous, **scale}
|
|
505
479
|
updates["scale"] = scale_data
|
|
506
480
|
|
|
507
481
|
return updates
|
|
@@ -16,6 +16,7 @@ from dataface.core.diagnostics import (
|
|
|
16
16
|
WARN_SINGLE_CHART_REDUNDANT_TITLE,
|
|
17
17
|
WARN_UNREFERENCED_CHART,
|
|
18
18
|
Diagnostic,
|
|
19
|
+
RelatedLocation,
|
|
19
20
|
)
|
|
20
21
|
|
|
21
22
|
|
|
@@ -45,7 +46,15 @@ def _detect_double_headers(face: Face, warnings: list[Diagnostic]) -> None:
|
|
|
45
46
|
heading = _body_opening_heading(face)
|
|
46
47
|
if heading is None:
|
|
47
48
|
return
|
|
48
|
-
level, text = heading
|
|
49
|
+
level, text, heading_line = heading
|
|
50
|
+
# Prose reaching here from a layout slot came through a normalized nested
|
|
51
|
+
# face, which no longer carries the authored path it was written at — only
|
|
52
|
+
# the face's own `text:` can be marked inside.
|
|
53
|
+
body_is_own_text = bool(face.text)
|
|
54
|
+
# The heading line, verbatim, lets stamping narrow a `text:` block range
|
|
55
|
+
# down to just it. Reconstructing it from `level` + `text` would guess wrong
|
|
56
|
+
# on `# Sales` and on setext headings.
|
|
57
|
+
needle = {"source_needle": heading_line}
|
|
49
58
|
if not face.title:
|
|
50
59
|
# No title to compare a lower-level heading against — only a literal
|
|
51
60
|
# level 1 stands in for a missing document title.
|
|
@@ -55,6 +64,7 @@ def _detect_double_headers(face: Face, warnings: list[Diagnostic]) -> None:
|
|
|
55
64
|
Diagnostic.from_code(
|
|
56
65
|
WARN_H1_BODY_NO_TITLE,
|
|
57
66
|
path="text",
|
|
67
|
+
fields=needle,
|
|
58
68
|
message=WARN_H1_BODY_NO_TITLE.message_template.format(heading=text),
|
|
59
69
|
fix=WARN_H1_BODY_NO_TITLE.fix_template.format(heading=text),
|
|
60
70
|
)
|
|
@@ -65,12 +75,29 @@ def _detect_double_headers(face: Face, warnings: list[Diagnostic]) -> None:
|
|
|
65
75
|
# the title is; `## Overview` under `title: Sales` is section structure.
|
|
66
76
|
if level != 1 and _comparable(text) != _comparable(face.title):
|
|
67
77
|
return
|
|
78
|
+
# The heading is the line to delete, so it gets the mark; `title:` is the
|
|
79
|
+
# other half of the pair and rides along as a related location. When the
|
|
80
|
+
# prose lives in a layout slot rather than the face's own `text:`, the body
|
|
81
|
+
# path is not recoverable from the normalized tree, so the two swap roles
|
|
82
|
+
# and `title:` carries the mark — still one line, never the whole face.
|
|
83
|
+
related: tuple[RelatedLocation, ...]
|
|
84
|
+
if body_is_own_text:
|
|
85
|
+
path = "text"
|
|
86
|
+
related = (
|
|
87
|
+
RelatedLocation(
|
|
88
|
+
path="title",
|
|
89
|
+
message=f"the face title {face.title!r} already heads the board",
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
path = "title"
|
|
94
|
+
related = ()
|
|
68
95
|
warnings.append(
|
|
69
96
|
Diagnostic.from_code(
|
|
70
97
|
WARN_DOUBLE_HEADER,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
path=path,
|
|
99
|
+
related=related,
|
|
100
|
+
fields=needle,
|
|
74
101
|
message=WARN_DOUBLE_HEADER.message_template.format(
|
|
75
102
|
title=face.title, level=level, heading=text
|
|
76
103
|
),
|
|
@@ -79,17 +106,27 @@ def _detect_double_headers(face: Face, warnings: list[Diagnostic]) -> None:
|
|
|
79
106
|
)
|
|
80
107
|
|
|
81
108
|
|
|
82
|
-
def _body_opening_heading(face: Face) -> tuple[int, str] | None:
|
|
83
|
-
"""Return (level, text)
|
|
109
|
+
def _body_opening_heading(face: Face) -> tuple[int, str, str] | None:
|
|
110
|
+
"""Return (level, text, heading_line) for a body that opens with a markdown
|
|
111
|
+
heading.
|
|
84
112
|
|
|
85
113
|
The body is either the face's own ``text`` (text-only face) or, for a face
|
|
86
114
|
with a layout, a bare text block in the first slot — the two ways prose lands
|
|
87
115
|
directly under the board header.
|
|
116
|
+
|
|
117
|
+
``heading_line`` is the body's first non-blank line — the heading exactly as
|
|
118
|
+
authored, whatever its spacing or style — so stamping can narrow a ``text:``
|
|
119
|
+
block range down to it.
|
|
88
120
|
"""
|
|
89
121
|
first = next(iter(face.layout.items), None)
|
|
90
|
-
|
|
122
|
+
own_text = face.text
|
|
123
|
+
body = own_text or (_bare_text_block_body(first) if first is not None else None)
|
|
91
124
|
if not body:
|
|
92
125
|
return None
|
|
126
|
+
heading_line = next(
|
|
127
|
+
(line for line in body.splitlines() if line.strip()),
|
|
128
|
+
"",
|
|
129
|
+
)
|
|
93
130
|
|
|
94
131
|
# mdsvg is the parser that renders face text, so asking it what a heading is
|
|
95
132
|
# keeps the warning from firing on something that renders as prose (a fenced
|
|
@@ -100,7 +137,8 @@ def _body_opening_heading(face: Face) -> tuple[int, str] | None:
|
|
|
100
137
|
first_block = next(iter(blocks), None)
|
|
101
138
|
if not isinstance(first_block, Heading):
|
|
102
139
|
return None
|
|
103
|
-
|
|
140
|
+
text = "".join(span.text for span in first_block.spans)
|
|
141
|
+
return first_block.level, text, heading_line
|
|
104
142
|
|
|
105
143
|
|
|
106
144
|
def _bare_text_block_body(item: LayoutItem) -> str | None:
|
dataface/core/compile/format.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
"""Format
|
|
1
|
+
"""Format resolution and finalization — a compile-layer concern.
|
|
2
2
|
|
|
3
3
|
Resolving a face/theme format alias to a concrete d3 spec is part of the style
|
|
4
4
|
cascade, so it lives in the compile layer where the cascade is baked. The render
|
|
5
5
|
layer's d3 formatters (format_utils.py) import ``resolve_format`` from here.
|
|
6
|
+
This module also owns ``finalize_kpi_value_format()``, the KPI headline's
|
|
7
|
+
data-aware format defaulting rule — baked into ``ResolvedKpiChart.format`` at
|
|
8
|
+
resolve, consumed as-is by render.
|
|
6
9
|
"""
|
|
7
10
|
|
|
8
11
|
from __future__ import annotations
|
|
@@ -60,3 +63,46 @@ def resolve_format(
|
|
|
60
63
|
|
|
61
64
|
# Raw d3 passthrough — let d3 parse or raise
|
|
62
65
|
return format_str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# d3's first SI prefix ("k") engages at 1000. Below it, ".2s" doesn't compact —
|
|
69
|
+
# it rounds to 2 significant figures on plain digits instead (131 -> "130"),
|
|
70
|
+
# which drops information the author never asked to lose. Digit-integrity
|
|
71
|
+
# rule: rounding is acceptable under SI compaction, never on plain digits —
|
|
72
|
+
# so the default only compacts once compaction is real.
|
|
73
|
+
_KPI_SI_COMPACT_THRESHOLD = 1000.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def finalize_kpi_value_format(
|
|
77
|
+
format_input: str | FormatConfig | None,
|
|
78
|
+
value: float | None,
|
|
79
|
+
) -> FormatConfig | None:
|
|
80
|
+
"""Finalize the KPI headline's number format from its cascaded style and value.
|
|
81
|
+
|
|
82
|
+
KPI is a hero number that the reader pauses on, so it defaults to the
|
|
83
|
+
narrative notation register (``1.5mn``) instead of the analytic register
|
|
84
|
+
(``1.5 M``) used on axis ticks and table cells. When neither spec nor
|
|
85
|
+
notation is authored, and ``value`` is large enough to actually compact
|
|
86
|
+
(``|value| >= 1000``), it also defaults to compact SI form (``".2s"``) so
|
|
87
|
+
narrative notation shows up — notation alone is a no-op against a non-SI
|
|
88
|
+
spec. Below that threshold there is nothing to compact, so the value
|
|
89
|
+
renders its exact digits (no spec at all) rather than a misleadingly
|
|
90
|
+
rounded plain number. An explicit spec always wins over the SI default.
|
|
91
|
+
"""
|
|
92
|
+
compact_eligible = value is not None and abs(value) >= _KPI_SI_COMPACT_THRESHOLD
|
|
93
|
+
if isinstance(format_input, str):
|
|
94
|
+
return FormatConfig(spec=format_input, notation="narrative")
|
|
95
|
+
spec = format_input.spec if format_input is not None else None
|
|
96
|
+
notation = format_input.notation if format_input is not None else None
|
|
97
|
+
if notation is None:
|
|
98
|
+
notation = "narrative"
|
|
99
|
+
if spec is None and notation in ("narrative", "analytic") and compact_eligible:
|
|
100
|
+
spec = ".2s"
|
|
101
|
+
if format_input is None and spec is None:
|
|
102
|
+
return None
|
|
103
|
+
return FormatConfig(
|
|
104
|
+
spec=spec,
|
|
105
|
+
prefix=format_input.prefix if format_input is not None else None,
|
|
106
|
+
suffix=format_input.suffix if format_input is not None else None,
|
|
107
|
+
notation=notation,
|
|
108
|
+
)
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
Usage::
|
|
4
4
|
|
|
5
5
|
links = build_inherit_graph(Style)
|
|
6
|
-
# {"Style.charts.axis_x.
|
|
6
|
+
# {"Style.charts.axis_x.labels.font.color": "Style.charts.axis.labels.font.color", ...}
|
|
7
7
|
|
|
8
8
|
chains = flatten_inherit_chains(links)
|
|
9
|
-
# {"Style.charts.axis_x.
|
|
9
|
+
# {"Style.charts.axis_x.labels.font.color": ("Style.charts.axis.labels.font.color",), ...}
|
|
10
10
|
|
|
11
11
|
Production entry point::
|
|
12
12
|
|
|
@@ -166,7 +166,7 @@ def _build_graph(
|
|
|
166
166
|
# Already inside an outer slot expansion. The inner InheritSlot
|
|
167
167
|
# applies at the canonical source path, not at this derived-axis
|
|
168
168
|
# path. Preserve the outer slot so leaves still expand as
|
|
169
|
-
# axis_x.
|
|
169
|
+
# axis_x.labels.font.color → axis.labels.font.color rather than
|
|
170
170
|
# jumping directly to charts.font.color.
|
|
171
171
|
_build_graph(
|
|
172
172
|
nested,
|
|
@@ -20,7 +20,7 @@ Style.charts.area.padding:
|
|
|
20
20
|
- Style.charts.padding
|
|
21
21
|
Style.charts.area.preferred_width:
|
|
22
22
|
- Style.charts.preferred_width
|
|
23
|
-
Style.charts.axis.
|
|
23
|
+
Style.charts.axis.labels.font:
|
|
24
24
|
- Style.charts.font
|
|
25
25
|
- Style.font
|
|
26
26
|
Style.charts.axis.title.font:
|