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.
- dataface/core/compile/introspection.py +27 -3
- dataface/core/compile/models/style/theme/variables.py +1 -1
- dataface/core/compile/resolve/_resolve.py +58 -30
- dataface/core/compile/schema_renderers/json_schema.py +36 -9
- dataface/core/compile/schema_renderers/vscode_schema.py +5 -5
- dataface/core/compile/schema_renderers/yaml_schema_catalog.py +167 -0
- dataface/core/compile/tick_values.py +21 -0
- dataface/core/dashboard.py +5 -0
- dataface/core/render/__init__.py +11 -11
- dataface/core/render/board_links.py +70 -5
- dataface/core/render/chart/emitters/_wide.py +116 -0
- dataface/core/render/chart/emitters/area.py +44 -71
- dataface/core/render/chart/emitters/bar.py +112 -61
- dataface/core/render/chart/features/baseline.py +42 -25
- dataface/core/render/chart/features/endpoint_labels.py +225 -5
- dataface/core/render/chart/rendering.py +1 -1
- dataface/core/render/chart/time_unit_detect.py +5 -1
- dataface/core/render/chart/validation.py +16 -9
- dataface/core/render/controls.py +604 -0
- dataface/core/render/converters/html.py +19 -4
- dataface/core/render/faces.py +42 -29
- dataface/core/render/render_result.py +3 -0
- dataface/core/render/renderer.py +29 -1
- dataface/core/render/sizing.py +1 -1
- dataface/core/render/templates/controls/_styles.css +32 -28
- dataface/core/render/templates/page.css +6 -0
- dataface/core/render/templates/page.html +7 -2
- dataface/core/render/templates/scripts/variables.js +171 -160
- dataface/core/render/templates/svg/styles.css +0 -1
- dataface/core/render/templates/variable_controls/layer.html +28 -0
- dataface/core/render/terminal.py +1 -1
- dataface/core/render/variables_strip.py +412 -0
- dataface/core/serve/server.py +3 -0
- dataface/data/schemas/yaml/0.3.0.json +1 -0
- dataface/data/schemas/yaml/manifest.json +11 -0
- dataface/schema_release.py +133 -0
- {dataface-0.3.0.dist-info → dataface-0.3.1.dev22.dist-info}/METADATA +1 -1
- {dataface-0.3.0.dist-info → dataface-0.3.1.dev22.dist-info}/RECORD +41 -35
- dataface/core/render/templates/variable_controls/container.html +0 -21
- dataface/core/render/variable_controls.py +0 -1002
- {dataface-0.3.0.dist-info → dataface-0.3.1.dev22.dist-info}/WHEEL +0 -0
- {dataface-0.3.0.dist-info → dataface-0.3.1.dev22.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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"
|
|
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(
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
1882
|
-
|
|
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(
|
|
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"
|
|
2419
|
-
# normalize-stack domain is always [0,1]
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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]
|
|
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]] =
|
|
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
|
|
243
|
-
"""Render
|
|
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 —
|
|
1
|
+
"""VS Code JSON Schema renderer — decorates the strict Dataface YAML schema.
|
|
2
2
|
|
|
3
|
-
Calls
|
|
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
|
|
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
|
|
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 =
|
|
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 ()
|
dataface/core/dashboard.py
CHANGED
|
@@ -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=[
|
dataface/core/render/__init__.py
CHANGED
|
@@ -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.
|
|
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
|
-
#
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
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",
|