dataface 0.3.0__py3-none-any.whl → 0.3.1.dev22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. dataface/core/compile/introspection.py +27 -3
  2. dataface/core/compile/models/style/theme/variables.py +1 -1
  3. dataface/core/compile/resolve/_resolve.py +58 -30
  4. dataface/core/compile/schema_renderers/json_schema.py +36 -9
  5. dataface/core/compile/schema_renderers/vscode_schema.py +5 -5
  6. dataface/core/compile/schema_renderers/yaml_schema_catalog.py +167 -0
  7. dataface/core/compile/tick_values.py +21 -0
  8. dataface/core/dashboard.py +5 -0
  9. dataface/core/render/__init__.py +11 -11
  10. dataface/core/render/board_links.py +70 -5
  11. dataface/core/render/chart/emitters/_wide.py +116 -0
  12. dataface/core/render/chart/emitters/area.py +44 -71
  13. dataface/core/render/chart/emitters/bar.py +112 -61
  14. dataface/core/render/chart/features/baseline.py +42 -25
  15. dataface/core/render/chart/features/endpoint_labels.py +225 -5
  16. dataface/core/render/chart/rendering.py +1 -1
  17. dataface/core/render/chart/time_unit_detect.py +5 -1
  18. dataface/core/render/chart/validation.py +16 -9
  19. dataface/core/render/controls.py +604 -0
  20. dataface/core/render/converters/html.py +19 -4
  21. dataface/core/render/faces.py +42 -29
  22. dataface/core/render/render_result.py +3 -0
  23. dataface/core/render/renderer.py +29 -1
  24. dataface/core/render/sizing.py +1 -1
  25. dataface/core/render/templates/controls/_styles.css +32 -28
  26. dataface/core/render/templates/page.css +6 -0
  27. dataface/core/render/templates/page.html +7 -2
  28. dataface/core/render/templates/scripts/variables.js +171 -160
  29. dataface/core/render/templates/svg/styles.css +0 -1
  30. dataface/core/render/templates/variable_controls/layer.html +28 -0
  31. dataface/core/render/terminal.py +1 -1
  32. dataface/core/render/variables_strip.py +412 -0
  33. dataface/core/serve/server.py +3 -0
  34. dataface/data/schemas/yaml/0.3.0.json +1 -0
  35. dataface/data/schemas/yaml/manifest.json +11 -0
  36. dataface/schema_release.py +133 -0
  37. {dataface-0.3.0.dist-info → dataface-0.3.1.dev22.dist-info}/METADATA +1 -1
  38. {dataface-0.3.0.dist-info → dataface-0.3.1.dev22.dist-info}/RECORD +41 -35
  39. dataface/core/render/templates/variable_controls/container.html +0 -21
  40. dataface/core/render/variable_controls.py +0 -1002
  41. {dataface-0.3.0.dist-info → dataface-0.3.1.dev22.dist-info}/WHEEL +0 -0
  42. {dataface-0.3.0.dist-info → dataface-0.3.1.dev22.dist-info}/entry_points.txt +0 -0
  43. {dataface-0.3.0.dist-info → dataface-0.3.1.dev22.dist-info}/licenses/LICENSE +0 -0
@@ -66,6 +66,7 @@ class SchemaField:
66
66
  extra_union_types: list[str] = dataclasses.field(
67
67
  default_factory=list
68
68
  ) # primitive type names alongside models or enums in a union
69
+ container_mapping_models: list[str] = dataclasses.field(default_factory=list)
69
70
  is_extra_key: bool = (
70
71
  False # True for synthetic '<name>' fields from __pydantic_extra__
71
72
  )
@@ -328,14 +329,18 @@ def _extra_union_types(annotation: Any) -> list[str]:
328
329
 
329
330
  Returns the names (e.g. ["str", "bool"]) so the renderer can include
330
331
  them in anyOf alongside $ref or enum branches.
331
- Only meaningful for bare Union types list/dict containers return [].
332
+ Container values recurse because ``list[str | AuthoredChart]`` needs the
333
+ same scalar branch on its item schema as a bare union does.
332
334
  """
333
335
  annotation = _unwrap_annotated(annotation)
334
336
  origin = get_origin(annotation)
335
337
  args = get_args(annotation)
336
- if not args or origin in (list, dict):
338
+ if not args:
337
339
  return []
338
340
 
341
+ if origin in (list, dict):
342
+ return _extra_union_types(args[-1])
343
+
339
344
  result: list[str] = []
340
345
  for arg in args:
341
346
  # Unwrap per-arm: a validated primitive alias (Duration =
@@ -348,6 +353,9 @@ def _extra_union_types(annotation: Any) -> list[str]:
348
353
  if _is_authored_model(arg):
349
354
  continue
350
355
  arg_origin = get_origin(arg)
356
+ if arg_origin in (list, dict):
357
+ result.extend(_extra_union_types(arg))
358
+ continue
351
359
  if arg_origin is Literal:
352
360
  continue
353
361
  if isinstance(arg, type) and issubclass(arg, Enum):
@@ -358,7 +366,22 @@ def _extra_union_types(annotation: Any) -> list[str]:
358
366
  and arg.__name__ in _PRIMITIVE_NAMES
359
367
  ):
360
368
  result.append(arg.__name__)
361
- return result
369
+ return list(dict.fromkeys(result))
370
+
371
+
372
+ def _container_mapping_models(annotation: type) -> list[str]:
373
+ """Return named-map value models nested in a list item union."""
374
+ annotation = _unwrap_annotated(annotation)
375
+ origin = get_origin(annotation)
376
+ args = get_args(annotation)
377
+ if origin is dict and len(args) == 2:
378
+ return _nested_model_names(args[1])
379
+ if origin is list and args:
380
+ return _container_mapping_models(args[0])
381
+ result: list[str] = []
382
+ for arg in args:
383
+ result.extend(_container_mapping_models(arg))
384
+ return list(dict.fromkeys(result))
362
385
 
363
386
 
364
387
  def _build_schema_field(
@@ -383,6 +406,7 @@ def _build_schema_field(
383
406
  nested_models=_nested_model_names(annotation),
384
407
  container=_get_container(annotation),
385
408
  extra_union_types=_extra_union_types(annotation),
409
+ container_mapping_models=_container_mapping_models(annotation),
386
410
  inherit_from=(inherit.from_path,) if inherit is not None else (),
387
411
  inherit_slot=inherit_slot.from_path if inherit_slot is not None else None,
388
412
  )
@@ -69,7 +69,7 @@ class VariablesStyle(BaseModel):
69
69
 
70
70
  Note: `input.background` styles the *inputs only*. The variables strip/
71
71
  container itself is transparent (no background band) and sits directly on the
72
- board canvas — see `render/variable_controls.py`. This matters on dark themes:
72
+ board canvas — see `render/variables_strip.py`. This matters on dark themes:
73
73
  an opaque container bg that differs from the canvas (e.g. neon input bg
74
74
  #222222 over canvas #161616) renders as a visible band; a transparent
75
75
  container has none, on any theme. The old chart_themes system had a separate
@@ -155,6 +155,7 @@ from dataface.core.compile.tick_values import (
155
155
  apply_headroom,
156
156
  nice_tick_values,
157
157
  numeric_domain_bounds,
158
+ stacked_bar_multi_measure_totals_max,
158
159
  stacked_bar_totals_max,
159
160
  )
160
161
  from dataface.core.compile.typography import resolve_title_font, width_tier
@@ -530,7 +531,7 @@ def _resolve_cartesian_ticks(
530
531
  def _resolve_stacked_bar_ticks(
531
532
  ay: ResolvedAxisStyle,
532
533
  data: list[dict[str, Any]],
533
- y_field: str,
534
+ y_fields: list[str],
534
535
  cat_field: str,
535
536
  ) -> tuple[float, ...]:
536
537
  """Compute nice tick values for a stacked bar using the stacked domain max.
@@ -549,14 +550,20 @@ def _resolve_stacked_bar_ticks(
549
550
  """
550
551
  if ay.ticks.count is None or not data:
551
552
  return ()
552
- y_floats = numeric_column_values(data, y_field)
553
+ y_floats = [
554
+ value for y_field in y_fields for value in numeric_column_values(data, y_field)
555
+ ]
553
556
  if not y_floats:
554
557
  return ()
555
558
  authored = numeric_domain_bounds(ay.scale.domain if ay.scale is not None else None)
556
559
  if authored is not None:
557
560
  tick_min, tick_max = authored
558
561
  else:
559
- sm = stacked_bar_totals_max(data, cat_field, y_field)
562
+ if len(y_fields) == 1:
563
+ sm = stacked_bar_totals_max(data, cat_field, y_fields[0])
564
+ else:
565
+ totals = stacked_bar_multi_measure_totals_max(data, cat_field, y_fields)
566
+ sm = totals[0] if totals else None
560
567
  raw_max = sm if sm is not None else max(y_floats)
561
568
  tick_max = apply_headroom(raw_max, _axis_headroom(ay))
562
569
  tick_min = min(0.0, min(y_floats)) # stacked bars always zero-anchor
@@ -787,15 +794,17 @@ def _edge_or_none(value: str | None) -> Literal["left", "right"] | None:
787
794
  def _suppress_legend_for_endpoint_labels(
788
795
  channels: dict[str, Any],
789
796
  endpoint_labels_visible: bool,
797
+ *,
798
+ wide_measure_series: bool = False,
790
799
  ) -> bool:
791
800
  """Return True when endpoint labels will replace the colour legend.
792
801
 
793
- Fires when a series colour channel is present AND the author opted in via
794
- endpoint_labels.visible.
802
+ Fires when a series colour channel (or a folded wide area measure series)
803
+ is present AND the author opted in via endpoint_labels.visible.
795
804
  """
796
805
  color_ch = channels.get("color")
797
- return (
798
- color_ch is not None and color_ch.mode == "series" and endpoint_labels_visible
806
+ return endpoint_labels_visible and (
807
+ wide_measure_series or (color_ch is not None and color_ch.mode == "series")
799
808
  )
800
809
 
801
810
 
@@ -816,6 +825,8 @@ def _bake_ay_orient(
816
825
  ay: AxisStyle,
817
826
  channels: dict[str, Any],
818
827
  endpoint_labels_visible: bool,
828
+ *,
829
+ wide_measure_series: bool = False,
819
830
  ) -> AxisStyle:
820
831
  """Resolve 'auto' y-axis position for line/area/bar at compile time.
821
832
 
@@ -830,7 +841,9 @@ def _bake_ay_orient(
830
841
  """
831
842
  if ay.position != "auto":
832
843
  return ay
833
- fires = _suppress_legend_for_endpoint_labels(channels, endpoint_labels_visible)
844
+ fires = _suppress_legend_for_endpoint_labels(
845
+ channels, endpoint_labels_visible, wide_measure_series=wide_measure_series
846
+ )
834
847
  return ay.model_copy(update={"position": "left" if fires else "right"})
835
848
 
836
849
 
@@ -1838,25 +1851,28 @@ def _resolve_bar(
1838
1851
  # Stacked bars only anchor when scale.zero is explicitly True.
1839
1852
  bar_zero = (not is_stacked and ay_scale_zero is not False) or ay_scale_zero is True
1840
1853
  y_field = normalized.y if isinstance(normalized.y, str) else None
1854
+ y_fields = (
1855
+ [y_field] if y_field else normalized.y if isinstance(normalized.y, list) else []
1856
+ )
1841
1857
  x_field = normalized.x if isinstance(normalized.x, str) else None
1842
1858
  bar_domain_max: float | None = None
1843
1859
  bar_domain_min: float | None = None
1844
- if resolved_stack == "normalize" or not y_field:
1860
+ if resolved_stack == "normalize" or not y_fields:
1845
1861
  # normalize-stack domain is always [0,1]; no y_field means no ticks.
1846
1862
  tick_values: tuple[float, ...] = ()
1847
1863
  elif is_stacked and x_field:
1848
- tick_values = _resolve_stacked_bar_ticks(ay, data, y_field, x_field)
1864
+ tick_values = _resolve_stacked_bar_ticks(ay, data, y_fields, x_field)
1849
1865
  # stacked domainMax baked separately below; domain_min stays None
1850
1866
  else:
1867
+ bar_y_values = (
1868
+ _shared_y_values(data, y_field, normalized, datasets)
1869
+ if y_field
1870
+ else _numeric_y_values(data, tuple(y_fields))
1871
+ )
1851
1872
  _bar_ticks = _resolve_cartesian_ticks(
1852
1873
  normalized.id,
1853
1874
  ay,
1854
- _shared_y_values(
1855
- data,
1856
- y_field,
1857
- normalized,
1858
- datasets,
1859
- ),
1875
+ bar_y_values,
1860
1876
  zero_anchor=bar_zero,
1861
1877
  authored_ticks_count=_authored_axis_y_ticks_count(normalized.style),
1862
1878
  )
@@ -1878,8 +1894,12 @@ def _resolve_bar(
1878
1894
  # A non-positive total (all-negative stacks) is skipped: domainMax 0 on a
1879
1895
  # zero-anchored scale is a degenerate [0, 0] domain; VL auto-fits instead.
1880
1896
  stacked_domain_max: float | None = None
1881
- if is_stacked and resolved_stack != "normalize" and y_field and x_field:
1882
- _raw_stacked_max = stacked_bar_totals_max(data, x_field, y_field)
1897
+ if is_stacked and resolved_stack != "normalize" and y_fields and x_field:
1898
+ if len(y_fields) == 1:
1899
+ _raw_stacked_max = stacked_bar_totals_max(data, x_field, y_fields[0])
1900
+ else:
1901
+ totals = stacked_bar_multi_measure_totals_max(data, x_field, y_fields)
1902
+ _raw_stacked_max = totals[0] if totals else None
1883
1903
  if _raw_stacked_max is not None and _raw_stacked_max > 0:
1884
1904
  stacked_domain_max = apply_headroom(_raw_stacked_max, _axis_headroom(ay))
1885
1905
  style_tail = _cartesian_style_tail(board_rcs, board_style, ax, ay)
@@ -2250,6 +2270,7 @@ def _resolve_area(
2250
2270
  primary = _with_color_tokens(normalized.style, board_style)
2251
2271
  channels = _channels_for(normalized, data)
2252
2272
  _project_cf_channels(normalized, data, channels)
2273
+ wide_measure_series = isinstance(normalized.y, list)
2253
2274
  x_ch_type = _classify_to_channel_type(normalized.x, data, is_dimension=True)
2254
2275
  resolved_stack = normalized.stack if normalized.stack is not None else area.stack
2255
2276
  # Area semantics fix x=dimension, y=value — unlike bar there is no
@@ -2352,7 +2373,12 @@ def _resolve_area(
2352
2373
  )
2353
2374
  y_field_area = normalized.y if isinstance(normalized.y, str) else None
2354
2375
  _area_floats = _numeric_y_values(data, (y_field_area,)) if y_field_area else []
2355
- ay_merged = _bake_ay_orient(ay_merged, channels, endpoint_labels.visible)
2376
+ ay_merged = _bake_ay_orient(
2377
+ ay_merged,
2378
+ channels,
2379
+ endpoint_labels.visible,
2380
+ wide_measure_series=wide_measure_series,
2381
+ )
2356
2382
  # Area's x is always a bottom-orient temporal/ordinal axis — no left/right edge.
2357
2383
  ax = build_resolved_axis(ax_merged, edge=None)
2358
2384
  ay = build_resolved_axis(ay_merged, edge=_edge_or_none(ay_merged.position))
@@ -2415,10 +2441,10 @@ def _resolve_area(
2415
2441
  else:
2416
2442
  zero_anchored_area = True
2417
2443
  x_field_area = normalized.x if isinstance(normalized.x, str) else None
2418
- if resolved_stack == "normalize" or not y_field_area:
2419
- # normalize-stack domain is always [0,1]; no y_field means no ticks.
2444
+ if resolved_stack == "normalize":
2445
+ # normalize-stack domain is always [0,1].
2420
2446
  area_ticks = _CartesianTickResolution((), None)
2421
- elif resolved_stack not in (None, "none") and x_field_area:
2447
+ elif resolved_stack not in (None, "none") and x_field_area and _log_y_fields:
2422
2448
  # Stacked area ladders against the cumulative column total, not the raw
2423
2449
  # per-series range. Without this a chart whose columns sum to 154 labels
2424
2450
  # an axis that stops at 70, leaving two thirds of the plot unlabelled —
@@ -2431,18 +2457,18 @@ def _resolve_area(
2431
2457
  # [0, max_total] and no column exceeds that ceiling — the same
2432
2458
  # zero-anchored domain a plain zero-stack renders on.
2433
2459
  area_ticks = _CartesianTickResolution(
2434
- _resolve_stacked_bar_ticks(ay, data, y_field_area, x_field_area), None
2460
+ _resolve_stacked_bar_ticks(ay, data, _log_y_fields, x_field_area), None
2435
2461
  )
2436
2462
  else:
2463
+ area_y_values = (
2464
+ _shared_y_values(data, y_field_area, normalized, datasets)
2465
+ if y_field_area
2466
+ else _numeric_y_values(data, tuple(_log_y_fields))
2467
+ )
2437
2468
  area_ticks = _resolve_cartesian_ticks(
2438
2469
  normalized.id,
2439
2470
  ay,
2440
- _shared_y_values(
2441
- data,
2442
- y_field_area,
2443
- normalized,
2444
- datasets,
2445
- ),
2471
+ area_y_values,
2446
2472
  zero_anchor=zero_anchored_area,
2447
2473
  authored_ticks_count=_authored_axis_y_ticks_count(normalized.style),
2448
2474
  )
@@ -2568,7 +2594,9 @@ def _resolve_area(
2568
2594
  _effective_palette(board_rcs, primary),
2569
2595
  requested_alias_palette=_effective_requested_alias_palette(board_rcs, primary),
2570
2596
  suppress_legend=_suppress_legend_for_endpoint_labels(
2571
- channels, endpoint_labels.visible
2597
+ channels,
2598
+ endpoint_labels.visible,
2599
+ wide_measure_series=wide_measure_series,
2572
2600
  ),
2573
2601
  show_top_legend=show_top_legend,
2574
2602
  )
@@ -3,7 +3,7 @@
3
3
  Converts an AuthorableSchema IR to a draft-07 JSON Schema dict.
4
4
  Callers: IDE schema generation, validation tooling.
5
5
 
6
- Entry point: render_json_schema(schema: AuthorableSchema) -> dict
6
+ Entry point: render_yaml_schema(schema: AuthorableSchema) -> dict
7
7
  """
8
8
 
9
9
  from __future__ import annotations
@@ -64,7 +64,7 @@ def _split_union(s: str) -> list[str]:
64
64
  def _ref(model_name: str, root: str) -> dict[str, str]:
65
65
  """Build a $ref to a model's $defs entry, or to the document root.
66
66
 
67
- render_json_schema inlines the root model's properties at the top level and
67
+ render_yaml_schema inlines the root model's properties at the top level and
68
68
  never emits a $defs entry for it. A field that recurses back to the root
69
69
  (a nested face) must therefore $ref the document root ("#") rather than a
70
70
  "#/$defs/<root>" entry that will never exist.
@@ -95,20 +95,39 @@ def _type_schema(field: SchemaField, root: str) -> dict[str, Any]:
95
95
 
96
96
  if field.nested_models:
97
97
  refs: list[dict[str, Any]] = [_ref(n, root) for n in field.nested_models]
98
- inner: dict[str, Any] = refs[0] if len(refs) == 1 else {"anyOf": refs}
98
+ branches = refs + extra
99
+ inner: dict[str, Any] = (
100
+ branches[0] if len(branches) == 1 else {"anyOf": branches}
101
+ )
99
102
  if field.container == "list":
103
+ if field.container_mapping_models:
104
+ map_refs = [_ref(n, root) for n in field.container_mapping_models]
105
+ if len(map_refs) == 1:
106
+ item_branches = [
107
+ inner,
108
+ {"type": "object", "additionalProperties": map_refs[0]},
109
+ ]
110
+ else:
111
+ item_branches = [
112
+ inner,
113
+ {
114
+ "type": "object",
115
+ "additionalProperties": {"anyOf": map_refs},
116
+ },
117
+ ]
118
+ inner = {"anyOf": item_branches}
100
119
  arr: dict[str, Any] = {"type": "array", "items": inner}
101
- parts: list[dict[str, Any]] = [arr] + extra
120
+ parts: list[dict[str, Any]] = [arr]
102
121
  if nullable:
103
122
  parts.append({"type": "null"})
104
123
  return {"anyOf": parts} if len(parts) > 1 else parts[0]
105
124
  if field.container == "dict":
106
125
  obj: dict[str, Any] = {"type": "object", "additionalProperties": inner}
107
- parts = [obj] + extra
126
+ parts = [obj]
108
127
  if nullable:
109
128
  parts.append({"type": "null"})
110
129
  return {"anyOf": parts} if len(parts) > 1 else parts[0]
111
- all_branches: list[dict[str, Any]] = refs + extra
130
+ all_branches: list[dict[str, Any]] = branches
112
131
  if nullable:
113
132
  all_branches.append({"type": "null"})
114
133
  return {"anyOf": all_branches} if len(all_branches) > 1 else all_branches[0]
@@ -236,14 +255,18 @@ def _model_to_def(model_name: str, ir: AuthorableSchema) -> dict[str, Any]:
236
255
  defn["required"] = required
237
256
  if additional_properties is not None:
238
257
  defn["additionalProperties"] = additional_properties
258
+ else:
259
+ defn["additionalProperties"] = False
239
260
  return defn
240
261
 
241
262
 
242
- def render_json_schema(schema: AuthorableSchema) -> dict[str, Any]:
243
- """Render an AuthorableSchema IR as a draft-07 JSON Schema.
263
+ def render_yaml_schema(schema: AuthorableSchema) -> dict[str, Any]:
264
+ """Render the strict draft-07 Dataface YAML schema.
244
265
 
245
266
  Produces a self-contained schema with the root model's properties
246
- inlined and all reachable authored models in $defs.
267
+ inlined and all reachable authored models in $defs. Author-defined model
268
+ objects are closed; only declared keyed collections use typed additional
269
+ properties.
247
270
  """
248
271
  root = _model_to_def(schema.root, schema)
249
272
  defs = {
@@ -278,9 +301,13 @@ def render_json_schema(schema: AuthorableSchema) -> dict[str, Any]:
278
301
  result["description"] = root["description"]
279
302
  result["type"] = "object"
280
303
  result["properties"] = root.get("properties", {})
304
+ result["additionalProperties"] = root["additionalProperties"]
281
305
  if root.get("required"):
282
306
  result["required"] = root["required"]
283
307
  if defs:
284
308
  result["$defs"] = defs
285
309
 
286
310
  return result
311
+
312
+
313
+ render_json_schema = render_yaml_schema
@@ -1,6 +1,6 @@
1
- """VS Code JSON Schema renderer — wraps render_json_schema with IDE-specific decoration.
1
+ """VS Code JSON Schema renderer — decorates the strict Dataface YAML schema.
2
2
 
3
- Calls render_json_schema(ir) for the IR-driven core, then adds the IDE-specific
3
+ Calls render_yaml_schema(ir) for the IR-driven core, then adds the IDE-specific
4
4
  extras that have no Pydantic counterpart (fileMatch, layout anyOf constraint,
5
5
  theme enum, LayoutItem definition).
6
6
 
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
  from typing import Any
13
13
 
14
14
  from dataface.core.compile.introspection import AuthorableSchema
15
- from dataface.core.compile.schema_renderers.json_schema import render_json_schema
15
+ from dataface.core.compile.schema_renderers.json_schema import render_yaml_schema
16
16
 
17
17
 
18
18
  def _rename_defs(obj: Any) -> Any:
@@ -253,8 +253,8 @@ def render_vscode_schema(
253
253
  ) -> dict[str, Any]:
254
254
  """Render an AuthorableSchema as a VS Code draft-07 JSON Schema.
255
255
 
256
- Calls render_json_schema for the IR-driven core and decorates with
256
+ Calls render_yaml_schema for the IR-driven core and decorates with
257
257
  IDE-specific extras (fileMatch, layout constraints, theme enum, etc.).
258
258
  """
259
- base = render_json_schema(schema)
259
+ base = render_yaml_schema(schema)
260
260
  return _decorate_for_vscode(base, themes=themes)
@@ -0,0 +1,167 @@
1
+ """Load immutable Dataface YAML schemas packaged with Dataface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass
8
+ from datetime import date
9
+ from types import NoneType
10
+ from typing import Literal, Protocol, TypeAlias
11
+
12
+ from importlib_resources import files
13
+
14
+ JsonValue: TypeAlias = (
15
+ str
16
+ | int
17
+ | float
18
+ | bool
19
+ | Literal[None]
20
+ | list["JsonValue"]
21
+ | dict[str, "JsonValue"]
22
+ )
23
+ JsonObject: TypeAlias = dict[str, JsonValue]
24
+
25
+
26
+ class _SchemaResource(Protocol):
27
+ def joinpath(self, *_: str) -> _SchemaResource: ...
28
+
29
+ def read_bytes(self) -> bytes: ...
30
+
31
+ def read_text(self) -> str: ...
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class YamlSchemaEntry:
36
+ """One immutable face-YAML grammar snapshot."""
37
+
38
+ version: str
39
+ released_at: date
40
+ filename: str
41
+ sha256: str
42
+ predecessor: str | Literal[None]
43
+
44
+ @classmethod
45
+ def from_json(cls, value: JsonObject) -> YamlSchemaEntry:
46
+ predecessor = value["predecessor"]
47
+ if not isinstance(predecessor, (str, NoneType)):
48
+ raise ValueError(
49
+ "Dataface YAML schema manifest predecessor must be a string or null."
50
+ )
51
+ return cls(
52
+ version=_manifest_string(value, "version"),
53
+ released_at=_manifest_date(value, "released_at"),
54
+ filename=_manifest_string(value, "file"),
55
+ sha256=_manifest_string(value, "sha256"),
56
+ predecessor=predecessor,
57
+ )
58
+
59
+ def as_json(self) -> JsonObject:
60
+ return {
61
+ "version": self.version,
62
+ "released_at": self.released_at.isoformat(),
63
+ "file": self.filename,
64
+ "sha256": self.sha256,
65
+ "predecessor": self.predecessor,
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class YamlSchemaCatalog:
71
+ """Frozen Dataface YAML schemas indexed by version, newest to oldest."""
72
+
73
+ entries: tuple[YamlSchemaEntry, ...]
74
+ _schemas: dict[str, JsonObject]
75
+
76
+ @property
77
+ def latest(self) -> YamlSchemaEntry:
78
+ return self.entries[0]
79
+
80
+ @property
81
+ def versions(self) -> tuple[str, ...]:
82
+ return tuple(entry.version for entry in self.entries)
83
+
84
+ def schema_for(self, version: str) -> JsonObject:
85
+ return self._schemas[version]
86
+
87
+
88
+ def _manifest_string(value: JsonObject, key: str) -> str:
89
+ result = value[key]
90
+ if not isinstance(result, str):
91
+ raise ValueError(f"Dataface YAML schema manifest {key} must be a string.")
92
+ return result
93
+
94
+
95
+ def _manifest_date(value: JsonObject, key: str) -> date:
96
+ try:
97
+ return date.fromisoformat(_manifest_string(value, key))
98
+ except ValueError as error:
99
+ raise ValueError(
100
+ f"Dataface YAML schema manifest {key} must be an ISO date."
101
+ ) from error
102
+
103
+
104
+ def canonical_schema_bytes(schema: JsonObject) -> bytes:
105
+ """Serialize a schema once so release comparisons are byte-for-byte stable."""
106
+ return (json.dumps(schema, separators=(",", ":"), sort_keys=True) + "\n").encode()
107
+
108
+
109
+ def load_yaml_schema_catalog() -> YamlSchemaCatalog:
110
+ """Load the package's immutable Dataface YAML schemas."""
111
+ return load_yaml_schema_catalog_from(
112
+ files("dataface") / "data" / "schemas" / "yaml"
113
+ )
114
+
115
+
116
+ def load_yaml_schema_catalog_from(
117
+ directory: _SchemaResource,
118
+ ) -> YamlSchemaCatalog:
119
+ """Load and verify a catalog directory; intended for package data and tests."""
120
+ manifest: JsonValue = json.loads(directory.joinpath("manifest.json").read_text())
121
+ if not isinstance(manifest, dict) or "schemas" not in manifest:
122
+ raise ValueError("Dataface YAML schema manifest has no schemas.")
123
+ raw_entries = manifest["schemas"]
124
+ if not isinstance(raw_entries, list):
125
+ raise ValueError("Dataface YAML schema manifest schemas must be a list.")
126
+ if not raw_entries:
127
+ raise ValueError("Dataface YAML schema manifest has no schemas.")
128
+
129
+ entries = tuple(
130
+ YamlSchemaEntry.from_json(value)
131
+ for value in raw_entries
132
+ if isinstance(value, dict)
133
+ )
134
+ if len(entries) != len(raw_entries):
135
+ raise ValueError("Dataface YAML schema manifest entries must be objects.")
136
+ if len({entry.version for entry in entries}) != len(entries):
137
+ raise ValueError("Dataface YAML schema manifest has duplicate versions.")
138
+
139
+ schemas: dict[str, JsonObject] = {}
140
+ predecessor: str | Literal[None] = None
141
+ previous_date: date | Literal[None] = None
142
+ for entry in entries:
143
+ if entry.predecessor != predecessor:
144
+ raise ValueError(
145
+ f"Dataface YAML schema {entry.version} has predecessor "
146
+ f"{entry.predecessor!r}, expected {predecessor!r}."
147
+ )
148
+ if previous_date is not None and entry.released_at < previous_date:
149
+ raise ValueError(
150
+ "Dataface YAML schema manifest release dates must be chronological."
151
+ )
152
+ contents = directory.joinpath(entry.filename).read_bytes()
153
+ digest = hashlib.sha256(contents).hexdigest()
154
+ if digest != entry.sha256:
155
+ raise ValueError(
156
+ f"Dataface YAML schema {entry.filename} sha256 does not match its manifest."
157
+ )
158
+ schema: JsonValue = json.loads(contents)
159
+ if not isinstance(schema, dict):
160
+ raise ValueError(
161
+ f"Dataface YAML schema {entry.filename} must be an object."
162
+ )
163
+ schemas[entry.version] = schema
164
+ predecessor = entry.version
165
+ previous_date = entry.released_at
166
+
167
+ return YamlSchemaCatalog(tuple(reversed(entries)), schemas)
@@ -8,8 +8,12 @@ dependency direction.
8
8
  from __future__ import annotations
9
9
 
10
10
  import math
11
+ from collections import defaultdict
12
+ from decimal import Decimal
11
13
  from typing import Any
12
14
 
15
+ ChartValue = str | int | float | Decimal
16
+
13
17
 
14
18
  def nice_tick_values(
15
19
  domain_min: float,
@@ -148,3 +152,20 @@ def stacked_bar_totals_max(
148
152
  continue
149
153
  totals[x] = totals.get(x, 0.0) + max(0.0, float(y))
150
154
  return max(totals.values()) if totals else None
155
+
156
+
157
+ def stacked_bar_multi_measure_totals_max(
158
+ data: list[dict[str, ChartValue]],
159
+ x_field: str,
160
+ y_fields: list[str],
161
+ ) -> tuple[float, ...]:
162
+ """Return the max positive stacked total for wide-form measures."""
163
+ totals: defaultdict[str | int | float | Decimal, float] = defaultdict(float)
164
+ for row in data:
165
+ x = row.get(x_field)
166
+ if x is None:
167
+ continue
168
+ totals[x] += sum(
169
+ max(0.0, float(row[y])) for y in y_fields if row.get(y) is not None
170
+ )
171
+ return (max(totals.values()),) if totals else ()
@@ -102,6 +102,10 @@ class RenderedDashboard(BaseModel):
102
102
  # `json.dumps()` in the MCP dispatcher will fail on bytes at the wire
103
103
  # boundary, which is correct behaviour.
104
104
  data: dict[str, Any] | str | bytes | None = None
105
+ # The interactive variable-control layer, when the caller passed
106
+ # ``controls=True``. Empty otherwise: a control that cannot re-run its
107
+ # queries is worse than no control, so hosts opt in (render/controls.py).
108
+ controls_html: str = ""
105
109
  validation_errors: list[Diagnostic] = []
106
110
  warnings: list[Diagnostic] = []
107
111
  suppressed_warnings: list[Diagnostic] = []
@@ -472,6 +476,7 @@ def render_dashboard(
472
476
  return RenderedDashboard(
473
477
  status="partial" if render_result.chart_errors else "ok",
474
478
  data=data,
479
+ controls_html=render_result.controls_html,
475
480
  chart_errors=render_result.chart_errors,
476
481
  warnings=[*compile_active, *render_result.warnings],
477
482
  suppressed_warnings=[
@@ -30,6 +30,11 @@ from dataface.core.render.chart import (
30
30
  render_table_svg,
31
31
  slug_to_text,
32
32
  )
33
+ from dataface.core.render.controls import (
34
+ controls_runtime_source,
35
+ controls_stylesheet,
36
+ render_controls_layer,
37
+ )
33
38
  from dataface.core.render.errors import (
34
39
  MissingRequiredVariablesError,
35
40
  MissingVariable,
@@ -49,12 +54,7 @@ from dataface.core.render.terminal_charts import (
49
54
  render_kpi_terminal,
50
55
  render_table_terminal,
51
56
  )
52
- from dataface.core.render.variable_controls import (
53
- generate_svg_variable_icons,
54
- generate_svg_variable_script,
55
- render_interactive_variables_svg,
56
- render_variables_svg,
57
- )
57
+ from dataface.core.render.variables_strip import render_variables_strip_svg
58
58
 
59
59
  __all__ = [
60
60
  # Main functions
@@ -65,11 +65,11 @@ __all__ = [
65
65
  "MissingRequiredVariablesError",
66
66
  "MissingVariable",
67
67
  "RenderError",
68
- # Variable controls
69
- "render_variables_svg",
70
- "render_interactive_variables_svg",
71
- "generate_svg_variable_script",
72
- "generate_svg_variable_icons",
68
+ # Variables: read-only strip (artifact) + control layer (hosts)
69
+ "render_variables_strip_svg",
70
+ "render_controls_layer",
71
+ "controls_runtime_source",
72
+ "controls_stylesheet",
73
73
  # Vega-Lite
74
74
  "generate_vega_lite_spec",
75
75
  "render_table_svg",