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.
Files changed (185) hide show
  1. dataface/__init__.py +70 -23
  2. dataface/agent_api/__init__.py +162 -68
  3. dataface/agent_api/_paths.py +9 -3
  4. dataface/agent_api/docs/yaml-reference.md +240 -106
  5. dataface/agent_api/init.py +1 -1
  6. dataface/agent_api/project_session.py +2 -1
  7. dataface/agent_api/serve.py +115 -0
  8. dataface/ai/agent.py +28 -2
  9. dataface/cli/_project.py +100 -1
  10. dataface/cli/commands/board_artifact.py +5 -2
  11. dataface/cli/commands/describe.py +7 -3
  12. dataface/cli/commands/docs.py +12 -3
  13. dataface/cli/commands/init.py +7 -14
  14. dataface/cli/commands/inspect.py +6 -1
  15. dataface/cli/commands/mcp_init.py +23 -43
  16. dataface/cli/commands/migrate.py +8 -3
  17. dataface/cli/commands/query.py +11 -8
  18. dataface/cli/commands/render.py +20 -9
  19. dataface/cli/commands/search.py +8 -2
  20. dataface/cli/commands/serve.py +26 -76
  21. dataface/cli/commands/skills_init.py +2 -19
  22. dataface/cli/commands/validate.py +7 -2
  23. dataface/cli/filesystem_project.py +5 -4
  24. dataface/cli/main.py +2 -19
  25. dataface/core/__init__.py +73 -28
  26. dataface/core/{compile/colors.py → colors.py} +20 -5
  27. dataface/core/compile/compiler.py +97 -34
  28. dataface/core/compile/data_table.py +7 -2
  29. dataface/core/compile/enrich.py +18 -44
  30. dataface/core/compile/face_warnings.py +46 -8
  31. dataface/core/compile/format.py +47 -1
  32. dataface/core/compile/inherit_graph.py +3 -3
  33. dataface/core/compile/inherit_registry.yaml +1 -1
  34. dataface/core/compile/migrations.py +112 -9
  35. dataface/core/compile/models/chart/normalized/kpi.py +0 -4
  36. dataface/core/compile/models/chart/resolved/_base.py +28 -10
  37. dataface/core/compile/models/chart/resolved/callout.py +2 -3
  38. dataface/core/compile/models/chart/resolved/kpi.py +7 -3
  39. dataface/core/compile/models/chart/resolved/table.py +16 -2
  40. dataface/core/compile/models/markers.py +1 -1
  41. dataface/core/compile/models/primitives.py +175 -2
  42. dataface/core/compile/models/source.py +5 -5
  43. dataface/core/compile/models/style/authored/__init__.py +32 -8
  44. dataface/core/compile/models/style/authored/_base.py +126 -69
  45. dataface/core/compile/models/style/authored/table.py +61 -3
  46. dataface/core/compile/models/style/resolved/__init__.py +12 -2
  47. dataface/core/compile/models/style/resolved/_base.py +142 -57
  48. dataface/core/compile/models/style/resolved/_cartesian.py +6 -11
  49. dataface/core/compile/models/style/resolved/_cascade.py +141 -54
  50. dataface/core/compile/models/style/resolved/area.py +1 -1
  51. dataface/core/compile/models/style/resolved/bar.py +1 -1
  52. dataface/core/compile/models/style/resolved/line.py +1 -1
  53. dataface/core/compile/models/style/resolved/scatter.py +3 -2
  54. dataface/core/compile/models/style/theme/__init__.py +36 -14
  55. dataface/core/compile/models/style/theme/_chart_base.py +17 -9
  56. dataface/core/compile/models/style/theme/axis.py +432 -312
  57. dataface/core/compile/models/style/theme/charts.py +10 -6
  58. dataface/core/compile/models/style/theme/kpi.py +2 -1
  59. dataface/core/compile/models/style/theme/page.py +5 -2
  60. dataface/core/compile/models/style/theme/table.py +2 -2
  61. dataface/core/compile/models/vega_lite/config.py +1 -1
  62. dataface/core/compile/normalize_charts.py +6 -2
  63. dataface/core/compile/normalize_queries.py +21 -7
  64. dataface/core/compile/resolve/__init__.py +2 -0
  65. dataface/core/compile/resolve/_axes.py +147 -44
  66. dataface/core/compile/resolve/_channels.py +1 -1
  67. dataface/core/compile/resolve/_dispatch.py +1 -1
  68. dataface/core/compile/resolve/_domain.py +49 -32
  69. dataface/core/compile/resolve/_kwargs.py +39 -12
  70. dataface/core/compile/resolve/_marks.py +8 -3
  71. dataface/core/compile/resolve/_palette.py +7 -3
  72. dataface/core/compile/resolve/_table.py +124 -33
  73. dataface/core/compile/resolve/area.py +48 -18
  74. dataface/core/compile/resolve/bar.py +245 -64
  75. dataface/core/compile/resolve/geo.py +10 -3
  76. dataface/core/compile/resolve/heatmap.py +16 -8
  77. dataface/core/compile/resolve/line.py +35 -12
  78. dataface/core/compile/resolve/pie.py +4 -1
  79. dataface/core/compile/resolve/pie_attachment.py +1 -1
  80. dataface/core/compile/resolve/scatter.py +25 -13
  81. dataface/core/compile/resolve/simple.py +25 -2
  82. dataface/core/compile/schema_renderers/prompt.py +1 -1
  83. dataface/core/compile/schema_renderers/textmate_grammar.py +8 -1
  84. dataface/core/compile/source_map.py +211 -28
  85. dataface/core/compile/style_cascade.py +126 -55
  86. dataface/core/compile/yaml_error_formatter.py +1 -1
  87. dataface/core/connections.py +10 -3
  88. dataface/core/dashboard.py +24 -18
  89. dataface/core/defaults/themes/_base.yaml +49 -39
  90. dataface/core/defaults/themes/cream.yaml +4 -4
  91. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +1 -1
  92. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +1 -1
  93. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +1 -1
  94. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +1 -1
  95. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +1 -1
  96. dataface/core/defaults/themes/editorial.yaml +6 -6
  97. dataface/core/defaults/themes/neon.yaml +15 -8
  98. dataface/core/defaults/themes/plain.yaml +6 -4
  99. dataface/core/defaults/themes/stark.yaml +16 -7
  100. dataface/core/defaults/themes/vivid.yaml +15 -8
  101. dataface/core/diagnostics/__init__.py +2 -2
  102. dataface/core/diagnostics/codes_compile.py +13 -31
  103. dataface/core/diagnostics/codes_execute.py +79 -0
  104. dataface/core/diagnostics/codes_render.py +87 -28
  105. dataface/core/diagnostics/diagnostic.py +30 -0
  106. dataface/core/diagnostics/hints.py +19 -0
  107. dataface/core/dialects/base.py +17 -0
  108. dataface/core/dialects/postgres.py +14 -0
  109. dataface/core/dialects/redshift.py +5 -0
  110. dataface/core/execute/__init__.py +59 -14
  111. dataface/core/execute/adapters/dbt_adapter.py +4 -1
  112. dataface/core/execute/adapters/dbt_adapter_factory.py +19 -0
  113. dataface/core/execute/adapters/dbt_utils.py +84 -20
  114. dataface/core/execute/adapters/postgres_connection_manager.py +48 -0
  115. dataface/core/execute/adapters/sql_adapter.py +72 -4
  116. dataface/core/execute/dbt_jinja.py +65 -10
  117. dataface/core/execute/executor.py +69 -16
  118. dataface/core/font_measure.py +28 -0
  119. dataface/core/fonts.py +8 -5
  120. dataface/core/inspect/__init__.py +46 -6
  121. dataface/core/project.py +13 -1
  122. dataface/core/render/chart/data_table_attachment.py +14 -12
  123. dataface/core/render/chart/emitters/_cartesian.py +21 -18
  124. dataface/core/render/chart/emitters/_channels.py +56 -8
  125. dataface/core/render/chart/emitters/_label_overlap.py +18 -15
  126. dataface/core/render/chart/emitters/_measured_label_padding.py +4 -4
  127. dataface/core/render/chart/emitters/area.py +3 -3
  128. dataface/core/render/chart/emitters/bar.py +60 -35
  129. dataface/core/render/chart/emitters/geo.py +8 -13
  130. dataface/core/render/chart/emitters/heatmap.py +20 -26
  131. dataface/core/render/chart/emitters/line.py +2 -2
  132. dataface/core/render/chart/emitters/scatter.py +7 -30
  133. dataface/core/render/chart/features/baseline.py +22 -9
  134. dataface/core/render/chart/features/endpoint_labels.py +55 -32
  135. dataface/core/render/chart/features/mirror_axis.py +12 -9
  136. dataface/core/render/chart/features/value_labels.py +1 -1
  137. dataface/core/render/chart/kpi.py +11 -90
  138. dataface/core/render/chart/presentation.py +6 -8
  139. dataface/core/render/chart/rendering.py +3 -12
  140. dataface/core/render/chart/session.py +10 -0
  141. dataface/core/render/chart/spec.py +3 -0
  142. dataface/core/render/chart/spec_builders.py +18 -18
  143. dataface/core/render/chart/table.py +81 -36
  144. dataface/core/render/chart/table_support.py +2 -53
  145. dataface/core/render/chart/time_unit_detect.py +3 -2
  146. dataface/core/render/chart/translate.py +82 -44
  147. dataface/core/render/chart/type_inference.py +45 -25
  148. dataface/core/render/chart/validation.py +91 -0
  149. dataface/core/render/chart/vega_lite.py +30 -11
  150. dataface/core/render/chart/vl_field_maps.py +111 -76
  151. dataface/core/render/converters/chart.py +16 -6
  152. dataface/core/render/faces.py +3 -2
  153. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.woff2 +0 -0
  154. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.woff2 +0 -0
  155. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -93
  156. dataface/core/render/fonts/_font_face.css +10 -0
  157. dataface/core/render/layout_sizing.py +267 -238
  158. dataface/core/render/numeral_expr.py +80 -0
  159. dataface/core/render/warnings/bar_band_width_too_narrow.py +64 -6
  160. dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +25 -3
  161. dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +12 -6
  162. dataface/core/render/warnings/palette_unsupported.py +6 -0
  163. dataface/core/render/warnings/pie_dominant_segment.py +1 -0
  164. dataface/core/render/warnings/pie_too_many_segments.py +1 -0
  165. dataface/core/render/warnings/point_map_out_of_projection.py +1 -0
  166. dataface/core/render/warnings/query_returned_zero_rows.py +1 -0
  167. dataface/core/render/warnings/redundant_encoding.py +20 -1
  168. dataface/core/render/warnings/registry.py +7 -3
  169. dataface/core/render/warnings/temporal_single_point.py +1 -0
  170. dataface/core/render/warnings/too_many_color_categories.py +1 -0
  171. dataface/core/render/warnings/too_many_x_categories.py +18 -2
  172. dataface/core/render/warnings/value_labels_crowd_width.py +9 -0
  173. dataface/core/render/warnings/y_encoding_mostly_null.py +1 -0
  174. dataface/core/render_format.py +18 -0
  175. dataface/core/text/format_d3.py +56 -0
  176. dataface/core/text/numeral_scale.py +218 -0
  177. dataface/core/utils.py +103 -10
  178. dataface/data/schemas/board-resolved/board-resolved.schema.json +9487 -7978
  179. dataface/data/schemas/yaml/0.3.2.json +1 -0
  180. dataface/data/schemas/yaml/manifest.json +7 -0
  181. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/METADATA +1 -1
  182. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/RECORD +185 -177
  183. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/WHEEL +0 -0
  184. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/entry_points.txt +0 -0
  185. {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 find_lookml_model_source
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
- stamp_diagnostics(result.errors, source_map, sql_blocks)
217
- stamp_diagnostics(result.warnings, source_map, sql_blocks)
218
- stamp_diagnostics(result.suppressed_warnings, source_map, sql_blocks)
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 is a bare name
1527
- that should resolve against the source face's own `queries:` section,
1528
- rather than: inline SQL (self-contained), a `#`-external ref (separate,
1529
- pre-existing mechanism — resolving it correctly for imported charts is a
1530
- tracked follow-up, not this task), or a full `<file>.queries.<name>`
1531
- cross-file ref written directly on the chart itself (an already-
1532
- ambiguous, unrelated edge case for local charts too — left untouched).
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
- "#" not in stripped
1537
- and ".queries." not in stripped
1538
- and not looks_like_sql(stripped)
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
- try:
1597
- target_path = file_dir / target_file_str
1598
- except ValueError as e:
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(bare_name: str) -> str:
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
- bare_name.strip(),
1719
- file_dir,
1720
- file_relpath,
1721
- queries_section,
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.label.padding + merged.label.font.size * style.label_max_lines
58
- if merged.label.visible is not False and merged.label.font.size > 0
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
@@ -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
- existing_scale is not None and isinstance(existing_scale.zero, bool)
465
+ _existing_scale_cont is not None and isinstance(_existing_scale_cont.zero, bool)
494
466
  ) or (
495
- existing_axis_y_scale is not None
496
- and isinstance(existing_axis_y_scale.zero, bool)
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
- scale_data.update(scale)
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
- # Dotted authoring path, not the face id — this is the source-map key
72
- # that gives the diagnostic its editor position.
73
- path="title",
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) when the face's body opens with a markdown heading.
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
- body = face.text or (_bare_text_block_body(first) if first is not None else None)
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
- return first_block.level, "".join(span.text for span in first_block.spans)
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:
@@ -1,8 +1,11 @@
1
- """Format-alias resolution — a compile-layer concern.
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.label.font.color": "Style.charts.axis.label.font.color", ...}
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.label.font.color": ("Style.charts.axis.label.font.color",), ...}
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.label.font.color → axis.label.font.color rather than
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.label.font:
23
+ Style.charts.axis.labels.font:
24
24
  - Style.charts.font
25
25
  - Style.font
26
26
  Style.charts.axis.title.font: