dataface 0.2.1.dev301__py3-none-any.whl → 0.2.1.dev323__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/ai/llm.py +60 -3
- dataface/core/compile/normalize_variables.py +42 -3
- dataface/core/compile/resolve/_resolve.py +19 -11
- dataface/core/compile/variables.py +174 -1
- dataface/core/execute/executor.py +18 -1
- dataface/core/render/chart/emitters/bar.py +12 -69
- dataface/core/render/chart/features/grouped_bar.py +2 -2
- dataface/core/render/chart/vega_lite.py +7 -1
- dataface/core/render/converters/chart.py +31 -27
- dataface/core/render/renderer.py +30 -4
- dataface/core/serve/server.py +129 -269
- {dataface-0.2.1.dev301.dist-info → dataface-0.2.1.dev323.dist-info}/METADATA +1 -1
- {dataface-0.2.1.dev301.dist-info → dataface-0.2.1.dev323.dist-info}/RECORD +16 -16
- {dataface-0.2.1.dev301.dist-info → dataface-0.2.1.dev323.dist-info}/WHEEL +0 -0
- {dataface-0.2.1.dev301.dist-info → dataface-0.2.1.dev323.dist-info}/entry_points.txt +0 -0
- {dataface-0.2.1.dev301.dist-info → dataface-0.2.1.dev323.dist-info}/licenses/LICENSE +0 -0
dataface/ai/llm.py
CHANGED
|
@@ -35,6 +35,44 @@ def _llm_timeout() -> httpx.Timeout:
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
def _parse_data_uri(uri: str) -> tuple[str, str]:
|
|
39
|
+
"""Split a base64 ``data:`` URI into its media type and payload.
|
|
40
|
+
|
|
41
|
+
Raises ``LLMClientError`` on anything that isn't a base64 data URI — the
|
|
42
|
+
frontend and view already validate the shape, so a miss here is a bug, not
|
|
43
|
+
user input to tolerate.
|
|
44
|
+
"""
|
|
45
|
+
prefix, _, data = uri.partition(",")
|
|
46
|
+
if not (prefix.startswith("data:") and prefix.endswith(";base64") and data):
|
|
47
|
+
raise LLMClientError(f"Attachment is not a base64 data URI: {uri[:32]!r}...")
|
|
48
|
+
media_type = prefix[len("data:") : -len(";base64")]
|
|
49
|
+
return media_type, data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _anthropic_content_block(block: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
"""Convert one provider-neutral content block to an Anthropic block."""
|
|
54
|
+
block_type = block.get("type")
|
|
55
|
+
if block_type == "text" and isinstance(block.get("text"), str):
|
|
56
|
+
return {"type": "text", "text": block["text"]}
|
|
57
|
+
if block_type == "image_url":
|
|
58
|
+
image_url = block.get("image_url")
|
|
59
|
+
if isinstance(image_url, dict) and isinstance(image_url.get("url"), str):
|
|
60
|
+
media_type, data = _parse_data_uri(image_url["url"])
|
|
61
|
+
return {
|
|
62
|
+
"type": "image",
|
|
63
|
+
"source": {"type": "base64", "media_type": media_type, "data": data},
|
|
64
|
+
}
|
|
65
|
+
if block_type == "document" and isinstance(block.get("url"), str):
|
|
66
|
+
media_type, data = _parse_data_uri(block["url"])
|
|
67
|
+
return {
|
|
68
|
+
"type": "document",
|
|
69
|
+
"source": {"type": "base64", "media_type": media_type, "data": data},
|
|
70
|
+
}
|
|
71
|
+
raise LLMClientError(
|
|
72
|
+
f"Unsupported Anthropic message content block: {block_type!r}."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
38
76
|
def infer_provider(model: str | None) -> str:
|
|
39
77
|
if model and model.startswith("anthropic:"):
|
|
40
78
|
return "anthropic"
|
|
@@ -314,6 +352,19 @@ class OpenAIClient:
|
|
|
314
352
|
}
|
|
315
353
|
)
|
|
316
354
|
continue
|
|
355
|
+
if block_type == "document" and isinstance(
|
|
356
|
+
block.get("url"), str
|
|
357
|
+
):
|
|
358
|
+
media_type, _ = _parse_data_uri(block["url"])
|
|
359
|
+
subtype = media_type.rpartition("/")[2] or "bin"
|
|
360
|
+
responses_content.append(
|
|
361
|
+
{
|
|
362
|
+
"type": "input_file",
|
|
363
|
+
"filename": f"attachment.{subtype}",
|
|
364
|
+
"file_data": block["url"],
|
|
365
|
+
}
|
|
366
|
+
)
|
|
367
|
+
continue
|
|
317
368
|
raise LLMClientError(
|
|
318
369
|
f"Unsupported OpenAI message content block: {block_type!r}."
|
|
319
370
|
)
|
|
@@ -523,9 +574,15 @@ class AnthropicClient:
|
|
|
523
574
|
|
|
524
575
|
content = message.get("content")
|
|
525
576
|
if content:
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
577
|
+
if isinstance(content, list):
|
|
578
|
+
if role != "user":
|
|
579
|
+
raise LLMClientError(
|
|
580
|
+
"Only user messages may contain multimodal content."
|
|
581
|
+
)
|
|
582
|
+
blocks = [_anthropic_content_block(block) for block in content]
|
|
583
|
+
else:
|
|
584
|
+
blocks = [{"type": "text", "text": content}]
|
|
585
|
+
converted.append({"role": role, "content": blocks})
|
|
529
586
|
|
|
530
587
|
flush_tool_results()
|
|
531
588
|
return converted
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
+
from datetime import date, datetime
|
|
7
8
|
from typing import TYPE_CHECKING, Any
|
|
8
9
|
|
|
9
10
|
from dataface.core.compile.errors import CompilationError
|
|
@@ -28,6 +29,7 @@ from dataface.core.compile.models.variable.authored import (
|
|
|
28
29
|
Variable,
|
|
29
30
|
VariableInputType,
|
|
30
31
|
)
|
|
32
|
+
from dataface.core.compile.variables import parse_iso_date
|
|
31
33
|
from dataface.core.errors.codes_compile import (
|
|
32
34
|
DF_COMPILE_SOURCE_REQUIRED,
|
|
33
35
|
DF_COMPILE_UNKNOWN_VARIABLE,
|
|
@@ -505,11 +507,24 @@ _TYPE_VALIDATORS: dict[str, tuple[type | tuple[type, ...], str]] = {
|
|
|
505
507
|
"range": ((int, float), "a number"),
|
|
506
508
|
"checkbox": (bool, "a boolean"),
|
|
507
509
|
"multiselect": (list, "a list"),
|
|
508
|
-
|
|
509
|
-
|
|
510
|
+
# A date default may be an ISO string ("2024-01-01") or a native date object
|
|
511
|
+
# (unquoted YAML `2024-01-01`, which safe_load types as datetime.date).
|
|
512
|
+
"date": ((str, date), "a string or ISO date"),
|
|
513
|
+
"datepicker": ((str, date), "a string or ISO date"),
|
|
510
514
|
}
|
|
511
515
|
|
|
512
516
|
|
|
517
|
+
def _validate_date_string(var_name: str, label: str, value: str) -> None:
|
|
518
|
+
"""Raise CompilationError if *value* is not a parseable ISO date."""
|
|
519
|
+
try:
|
|
520
|
+
parse_iso_date(value)
|
|
521
|
+
except ValueError:
|
|
522
|
+
raise CompilationError(
|
|
523
|
+
f"Variable '{var_name}': {label} must be an ISO date (YYYY-MM-DD), "
|
|
524
|
+
f"got {value!r}"
|
|
525
|
+
) from None
|
|
526
|
+
|
|
527
|
+
|
|
513
528
|
def validate_variable_value(var_name: str, var: Variable, value: Any) -> None:
|
|
514
529
|
"""Validate a variable value against its input type.
|
|
515
530
|
|
|
@@ -537,11 +552,21 @@ def validate_variable_value(var_name: str, var: Variable, value: Any) -> None:
|
|
|
537
552
|
f"got {type(value).__name__}"
|
|
538
553
|
)
|
|
539
554
|
for i, v in enumerate(value):
|
|
540
|
-
if v is
|
|
555
|
+
if v is None:
|
|
556
|
+
continue
|
|
557
|
+
if isinstance(v, datetime):
|
|
558
|
+
raise CompilationError(
|
|
559
|
+
f"Variable '{var_name}': daterange[{i}] must be a calendar date, "
|
|
560
|
+
f"not a datetime, got {v!r}"
|
|
561
|
+
)
|
|
562
|
+
if isinstance(v, date):
|
|
563
|
+
continue
|
|
564
|
+
if not isinstance(v, str):
|
|
541
565
|
raise CompilationError(
|
|
542
566
|
f"Variable '{var_name}': daterange[{i}] must be string, "
|
|
543
567
|
f"got {type(v).__name__}"
|
|
544
568
|
)
|
|
569
|
+
_validate_date_string(var_name, f"daterange[{i}]", v)
|
|
545
570
|
return
|
|
546
571
|
|
|
547
572
|
# Standard type validation from dict
|
|
@@ -564,6 +589,20 @@ def validate_variable_value(var_name: str, var: Variable, value: Any) -> None:
|
|
|
564
589
|
f"Variable '{var_name}': {input_type} default {value} > max {var.max}"
|
|
565
590
|
)
|
|
566
591
|
|
|
592
|
+
# date/datepicker defaults: reject a datetime (a date must not carry a time),
|
|
593
|
+
# and parse-validate string forms as ISO so a bad default fails at `dft validate`
|
|
594
|
+
# instead of at query-execution time. Placed after the slider check so the
|
|
595
|
+
# isinstance narrowing here cannot leak into that numeric comparison (date and
|
|
596
|
+
# slider inputs are disjoint).
|
|
597
|
+
if input_type in ("date", "datepicker"):
|
|
598
|
+
if isinstance(value, datetime):
|
|
599
|
+
raise CompilationError(
|
|
600
|
+
f"Variable '{var_name}': {input_type} default must be a calendar "
|
|
601
|
+
f"date, not a datetime, got {value!r}"
|
|
602
|
+
)
|
|
603
|
+
if isinstance(value, str) and value.strip():
|
|
604
|
+
_validate_date_string(var_name, f"{input_type} default", value)
|
|
605
|
+
|
|
567
606
|
|
|
568
607
|
def compute_variable_dependencies(
|
|
569
608
|
variables: dict[str, Variable],
|
|
@@ -1167,7 +1167,7 @@ def _build_resolved_area_line(merged: AreaLineStyle) -> ResolvedAreaLineStyle:
|
|
|
1167
1167
|
|
|
1168
1168
|
def _resolve_one_layer(
|
|
1169
1169
|
layer: CartesianLayer,
|
|
1170
|
-
|
|
1170
|
+
board_style: ResolvedStyle,
|
|
1171
1171
|
base_type: str,
|
|
1172
1172
|
base_family_style: Any,
|
|
1173
1173
|
base_query_name: str | None,
|
|
@@ -1191,11 +1191,19 @@ def _resolve_one_layer(
|
|
|
1191
1191
|
base chart's query, so this is never an ambiguous ``None`` at render
|
|
1192
1192
|
time (barring the rare inline-unnamed-query chart, whose own
|
|
1193
1193
|
``query_name`` is itself None).
|
|
1194
|
+
|
|
1195
|
+
The layer's own ``style`` patch is run through ``_with_color_tokens``
|
|
1196
|
+
exactly like the chart-root style patch — otherwise a palette token
|
|
1197
|
+
authored on a per-layer style (e.g. ``layers[].style.marks.line.stroke.
|
|
1198
|
+
color``) leaks through to the renderer as a literal token string instead
|
|
1199
|
+
of the resolved hex value.
|
|
1194
1200
|
"""
|
|
1201
|
+
board_rcs = board_style.charts
|
|
1195
1202
|
axis_y = layer.axis_y if layer.axis_y is not None else LayerAxisYStyle()
|
|
1196
1203
|
query_name = layer.query if layer.query is not None else base_query_name
|
|
1204
|
+
layer_style = _with_color_tokens(layer.style, board_style)
|
|
1197
1205
|
if isinstance(layer, LineLayer):
|
|
1198
|
-
line_marks =
|
|
1206
|
+
line_marks = layer_style.marks if layer_style is not None else None
|
|
1199
1207
|
line_parent = base_family_style if base_type == "line" else board_rcs.line
|
|
1200
1208
|
return ResolvedLineLayer(
|
|
1201
1209
|
type="line",
|
|
@@ -1217,7 +1225,7 @@ def _resolve_one_layer(
|
|
|
1217
1225
|
query_name=query_name,
|
|
1218
1226
|
)
|
|
1219
1227
|
if isinstance(layer, AreaLayer):
|
|
1220
|
-
area_marks =
|
|
1228
|
+
area_marks = layer_style.marks if layer_style is not None else None
|
|
1221
1229
|
area_parent = base_family_style if base_type == "area" else board_rcs.area
|
|
1222
1230
|
return ResolvedAreaLayer(
|
|
1223
1231
|
type="area",
|
|
@@ -1245,7 +1253,7 @@ def _resolve_one_layer(
|
|
|
1245
1253
|
query_name=query_name,
|
|
1246
1254
|
)
|
|
1247
1255
|
if isinstance(layer, BarLayer):
|
|
1248
|
-
bar_marks =
|
|
1256
|
+
bar_marks = layer_style.marks if layer_style is not None else None
|
|
1249
1257
|
bar_parent = base_family_style if base_type == "bar" else board_rcs.bar
|
|
1250
1258
|
return ResolvedBarLayer(
|
|
1251
1259
|
type="bar",
|
|
@@ -1261,7 +1269,7 @@ def _resolve_one_layer(
|
|
|
1261
1269
|
query_name=query_name,
|
|
1262
1270
|
)
|
|
1263
1271
|
# ScatterLayer
|
|
1264
|
-
scatter_marks =
|
|
1272
|
+
scatter_marks = layer_style.marks if layer_style is not None else None
|
|
1265
1273
|
scatter_parent = base_family_style if base_type == "scatter" else board_rcs.scatter
|
|
1266
1274
|
return ResolvedScatterLayer(
|
|
1267
1275
|
type="scatter",
|
|
@@ -1307,7 +1315,7 @@ def _check_layers_y_domain(
|
|
|
1307
1315
|
|
|
1308
1316
|
def _resolve_layer_list(
|
|
1309
1317
|
layers: list[CartesianLayer],
|
|
1310
|
-
|
|
1318
|
+
board_style: ResolvedStyle,
|
|
1311
1319
|
base_type: str,
|
|
1312
1320
|
base_family_style: Any,
|
|
1313
1321
|
base_query_name: str | None,
|
|
@@ -1323,7 +1331,7 @@ def _resolve_layer_list(
|
|
|
1323
1331
|
return ()
|
|
1324
1332
|
return tuple(
|
|
1325
1333
|
_resolve_one_layer(
|
|
1326
|
-
layer,
|
|
1334
|
+
layer, board_style, base_type, base_family_style, base_query_name
|
|
1327
1335
|
)
|
|
1328
1336
|
for layer in layers
|
|
1329
1337
|
)
|
|
@@ -1475,7 +1483,7 @@ def _resolve_bar(
|
|
|
1475
1483
|
),
|
|
1476
1484
|
)
|
|
1477
1485
|
resolved_layers = _resolve_layer_list(
|
|
1478
|
-
normalized.layers,
|
|
1486
|
+
normalized.layers, board_style, "bar", bar, normalized.query_name
|
|
1479
1487
|
)
|
|
1480
1488
|
_check_layers_y_domain(normalized.id, resolved_layers, ay)
|
|
1481
1489
|
# Value labels track the effective measure format (bar_user_fmt), not just the
|
|
@@ -1699,7 +1707,7 @@ def _resolve_line(
|
|
|
1699
1707
|
),
|
|
1700
1708
|
)
|
|
1701
1709
|
resolved_layers = _resolve_layer_list(
|
|
1702
|
-
normalized.layers,
|
|
1710
|
+
normalized.layers, board_style, "line", line, normalized.query_name
|
|
1703
1711
|
)
|
|
1704
1712
|
_check_layers_y_domain(normalized.id, resolved_layers, ay)
|
|
1705
1713
|
return ResolvedLineChart(
|
|
@@ -1941,7 +1949,7 @@ def _resolve_area(
|
|
|
1941
1949
|
),
|
|
1942
1950
|
)
|
|
1943
1951
|
resolved_layers = _resolve_layer_list(
|
|
1944
|
-
normalized.layers,
|
|
1952
|
+
normalized.layers, board_style, "area", area, normalized.query_name
|
|
1945
1953
|
)
|
|
1946
1954
|
_check_layers_y_domain(normalized.id, resolved_layers, ay)
|
|
1947
1955
|
return ResolvedAreaChart(
|
|
@@ -2034,7 +2042,7 @@ def _resolve_scatter(
|
|
|
2034
2042
|
style_tail = _cartesian_style_tail(board_rcs, board_style, ax, ay)
|
|
2035
2043
|
style_tail["tooltip_format"] = tooltip_fmt
|
|
2036
2044
|
resolved_layers = _resolve_layer_list(
|
|
2037
|
-
normalized.layers,
|
|
2045
|
+
normalized.layers, board_style, "scatter", scatter, normalized.query_name
|
|
2038
2046
|
)
|
|
2039
2047
|
_check_layers_y_domain(normalized.id, resolved_layers, ay)
|
|
2040
2048
|
_s = normalized.style
|
|
@@ -8,9 +8,13 @@ by both the execute and render stages.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
+
import math
|
|
12
|
+
from datetime import date, datetime
|
|
11
13
|
from typing import Any
|
|
12
14
|
|
|
13
15
|
from dataface.core.compile.models.face.normalized import VariableValues
|
|
16
|
+
from dataface.core.compile.models.variable.authored import Variable
|
|
17
|
+
from dataface.core.errors.execution import ExecutionError
|
|
14
18
|
|
|
15
19
|
|
|
16
20
|
def parse_variable_json_strings(variables: VariableValues) -> VariableValues:
|
|
@@ -50,4 +54,173 @@ def parse_variable_json_strings(variables: VariableValues) -> VariableValues:
|
|
|
50
54
|
return parsed
|
|
51
55
|
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
# Input kinds that carry a non-string SQL type. Everything else (text, select,
|
|
58
|
+
# multiselect, radio, …) stays a string/list and needs no coercion.
|
|
59
|
+
_DATE_INPUTS = frozenset({"date", "datepicker"})
|
|
60
|
+
_NUMBER_INPUTS = frozenset({"number", "slider", "range"})
|
|
61
|
+
# Scalar typed inputs where an empty string means "unset" (→ None), like the
|
|
62
|
+
# renderer's absent-check. daterange is excluded — it handles endpoints itself.
|
|
63
|
+
_SCALAR_TYPED_INPUTS = _DATE_INPUTS | _NUMBER_INPUTS | frozenset({"checkbox"})
|
|
64
|
+
_BOOL_TRUE = frozenset({"true", "1", "yes", "on"})
|
|
65
|
+
_BOOL_FALSE = frozenset({"false", "0", "no", "off"})
|
|
66
|
+
|
|
67
|
+
# The scalar runtime domain a single-value coercer may receive. Spelled out (never
|
|
68
|
+
# `Any`/`object`/`| None`) so the coercers stay typed for mypy without tripping the
|
|
69
|
+
# dataface/core type-state freeze gate; broad enough that every isinstance below is
|
|
70
|
+
# meaningful and the trailing type-error raise stays reachable. Container inputs
|
|
71
|
+
# (daterange lists) are dispatched inline in the loop, not through these helpers.
|
|
72
|
+
_Coercible = str | int | float | bool | date
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_iso_date(value: str) -> date:
|
|
76
|
+
"""Parse an ISO ``YYYY-MM-DD`` string to a ``date``, raising ``ValueError``.
|
|
77
|
+
|
|
78
|
+
The shared date parser for both boundaries: the compile-time default check
|
|
79
|
+
(wraps ValueError as CompilationError) and runtime coercion (wraps it as
|
|
80
|
+
ExecutionError). Keeping one parser means both agree on what a date is.
|
|
81
|
+
"""
|
|
82
|
+
return date.fromisoformat(value.strip())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def coerce_variable_values(
|
|
86
|
+
values: VariableValues, registry: dict[str, Variable]
|
|
87
|
+
) -> VariableValues:
|
|
88
|
+
"""Coerce runtime variable values to their declared semantic Python types.
|
|
89
|
+
|
|
90
|
+
Runs at the runtime boundary (query execution), after defaults and user
|
|
91
|
+
values have merged. URL params, CLI, and API values arrive as strings; a
|
|
92
|
+
``date`` variable's ``"2024-01-01"`` must reach SQL as a ``date`` object so
|
|
93
|
+
strict engines get ``DATE '2024-01-01'`` (inline path) or a typed bound
|
|
94
|
+
parameter — not a varchar that Trino/BigQuery refuse to compare to a date
|
|
95
|
+
column. The semantic type comes from the variable's ``input`` kind; values
|
|
96
|
+
that don't match it raise ``ExecutionError`` (fail fast, no silent fixups).
|
|
97
|
+
|
|
98
|
+
Names absent from ``registry`` (builtins, dir-navigation context) have no
|
|
99
|
+
declared type and pass through untouched. An empty string is treated as
|
|
100
|
+
unset (``None``), matching the renderer's absent-check.
|
|
101
|
+
"""
|
|
102
|
+
result: VariableValues = {}
|
|
103
|
+
for name, value in values.items():
|
|
104
|
+
var = registry.get(name)
|
|
105
|
+
if var is None or value is None:
|
|
106
|
+
result[name] = value
|
|
107
|
+
continue
|
|
108
|
+
kind = var.input
|
|
109
|
+
if (
|
|
110
|
+
isinstance(value, str)
|
|
111
|
+
and not value.strip()
|
|
112
|
+
and kind in _SCALAR_TYPED_INPUTS
|
|
113
|
+
):
|
|
114
|
+
result[name] = None # empty scalar typed input = unset
|
|
115
|
+
elif kind in _DATE_INPUTS:
|
|
116
|
+
result[name] = _coerce_date(name, value)
|
|
117
|
+
elif kind == "daterange":
|
|
118
|
+
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
|
119
|
+
raise ExecutionError(
|
|
120
|
+
f"Variable '{name}': daterange value must be [start, end], "
|
|
121
|
+
f"got {value!r}"
|
|
122
|
+
)
|
|
123
|
+
# Each endpoint is None or an empty string (both unset), or a
|
|
124
|
+
# date/ISO-string. Inlined so the None-carrying list needs no written
|
|
125
|
+
# ``| None`` annotation (type-state freeze gate); the element type is
|
|
126
|
+
# inferred from the comprehension.
|
|
127
|
+
result[name] = [
|
|
128
|
+
(
|
|
129
|
+
None
|
|
130
|
+
if endpoint is None
|
|
131
|
+
or (isinstance(endpoint, str) and not endpoint.strip())
|
|
132
|
+
else _coerce_date(name, endpoint)
|
|
133
|
+
)
|
|
134
|
+
for endpoint in value
|
|
135
|
+
]
|
|
136
|
+
elif kind in _NUMBER_INPUTS:
|
|
137
|
+
result[name] = _coerce_number(name, value)
|
|
138
|
+
elif kind == "checkbox":
|
|
139
|
+
result[name] = _coerce_bool(name, value)
|
|
140
|
+
else:
|
|
141
|
+
result[name] = value
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _coerce_date(name: str, value: _Coercible) -> date:
|
|
146
|
+
"""Narrow one date/datepicker (or daterange endpoint) value to a ``date``.
|
|
147
|
+
|
|
148
|
+
Accepts what the runtime boundary produces — an ISO string or a native
|
|
149
|
+
``date`` (from a compile default) — and rejects everything else, notably a
|
|
150
|
+
``datetime``: it is a ``date`` subclass, so ``_to_sql_literal`` would emit
|
|
151
|
+
``TIMESTAMP`` for it, the exact varchar↔date mismatch this coercion prevents.
|
|
152
|
+
Callers handle the empty-string-is-unset case before reaching here.
|
|
153
|
+
"""
|
|
154
|
+
if isinstance(value, datetime):
|
|
155
|
+
raise ExecutionError(
|
|
156
|
+
f"Variable '{name}': date value must be a calendar date, not a "
|
|
157
|
+
f"datetime with a time component, got {value!r}"
|
|
158
|
+
)
|
|
159
|
+
if isinstance(value, date):
|
|
160
|
+
return value
|
|
161
|
+
if isinstance(value, str):
|
|
162
|
+
try:
|
|
163
|
+
return parse_iso_date(value)
|
|
164
|
+
except ValueError:
|
|
165
|
+
raise ExecutionError(
|
|
166
|
+
f"Variable '{name}': date value must be an ISO date "
|
|
167
|
+
f"(YYYY-MM-DD), got {value!r}"
|
|
168
|
+
) from None
|
|
169
|
+
raise ExecutionError(
|
|
170
|
+
f"Variable '{name}': date value must be a string, got {type(value).__name__}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _coerce_number(name: str, value: _Coercible) -> int | float:
|
|
175
|
+
if isinstance(value, bool):
|
|
176
|
+
raise ExecutionError(
|
|
177
|
+
f"Variable '{name}': number value must be numeric, got a boolean"
|
|
178
|
+
)
|
|
179
|
+
if isinstance(value, int):
|
|
180
|
+
return value
|
|
181
|
+
if isinstance(value, float):
|
|
182
|
+
return _require_finite(name, value)
|
|
183
|
+
if isinstance(value, str):
|
|
184
|
+
return _parse_number(name, value.strip())
|
|
185
|
+
raise ExecutionError(
|
|
186
|
+
f"Variable '{name}': number value must be numeric, got {type(value).__name__}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _parse_number(name: str, text: str) -> int | float:
|
|
191
|
+
try:
|
|
192
|
+
return int(text)
|
|
193
|
+
except ValueError:
|
|
194
|
+
pass
|
|
195
|
+
try:
|
|
196
|
+
parsed = float(text)
|
|
197
|
+
except ValueError:
|
|
198
|
+
raise ExecutionError(
|
|
199
|
+
f"Variable '{name}': number value must be numeric, got {text!r}"
|
|
200
|
+
) from None
|
|
201
|
+
return _require_finite(name, parsed)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _require_finite(name: str, value: float) -> float:
|
|
205
|
+
if not math.isfinite(value):
|
|
206
|
+
raise ExecutionError(
|
|
207
|
+
f"Variable '{name}': number value must be finite, got {value!r}"
|
|
208
|
+
)
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _coerce_bool(name: str, value: _Coercible) -> bool:
|
|
213
|
+
if isinstance(value, bool):
|
|
214
|
+
return value
|
|
215
|
+
if isinstance(value, str):
|
|
216
|
+
text = value.strip().lower()
|
|
217
|
+
if text in _BOOL_TRUE:
|
|
218
|
+
return True
|
|
219
|
+
if text in _BOOL_FALSE:
|
|
220
|
+
return False
|
|
221
|
+
raise ExecutionError(
|
|
222
|
+
f"Variable '{name}': checkbox value must be a boolean, got {value!r}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
__all__ = ["coerce_variable_values", "parse_iso_date", "parse_variable_json_strings"]
|
|
@@ -56,7 +56,10 @@ from dataface.core.compile.models.query.normalized import (
|
|
|
56
56
|
SqlQuery,
|
|
57
57
|
is_sql_query,
|
|
58
58
|
)
|
|
59
|
-
from dataface.core.compile.variables import
|
|
59
|
+
from dataface.core.compile.variables import (
|
|
60
|
+
coerce_variable_values,
|
|
61
|
+
parse_variable_json_strings,
|
|
62
|
+
)
|
|
60
63
|
from dataface.core.errors.execution import ExecutionError, QueryError
|
|
61
64
|
from dataface.core.execute.duckdb_cache import (
|
|
62
65
|
compute_cache_key,
|
|
@@ -515,6 +518,14 @@ class Executor:
|
|
|
515
518
|
"Please use a different variable name."
|
|
516
519
|
)
|
|
517
520
|
|
|
521
|
+
# Runtime boundary: coerce to declared semantic types, same as the
|
|
522
|
+
# per-query path (_prepare_call), so batch-bound params are typed too.
|
|
523
|
+
registry = self.face.variable_registry
|
|
524
|
+
if registry and variables:
|
|
525
|
+
variables = coerce_variable_values(
|
|
526
|
+
parse_variable_json_strings(variables), registry
|
|
527
|
+
)
|
|
528
|
+
|
|
518
529
|
# Generate batch execution plan
|
|
519
530
|
plan = create_batch_execution_plan(self.face)
|
|
520
531
|
|
|
@@ -754,6 +765,12 @@ class Executor:
|
|
|
754
765
|
all_variables.update(self.face.variable_defaults)
|
|
755
766
|
parsed_variables = parse_variable_json_strings(variables or {})
|
|
756
767
|
merged = {**all_variables, **parsed_variables}
|
|
768
|
+
# Runtime boundary: turn string inputs into their declared semantic types
|
|
769
|
+
# (date/number/bool) so the SQL layer binds typed literals/params. This is
|
|
770
|
+
# the per-query boundary execute_query and is_cached both funnel through, so
|
|
771
|
+
# the coerced values also key the cache identically (execute_face_batch
|
|
772
|
+
# coerces at its own entry).
|
|
773
|
+
merged = coerce_variable_values(merged, variable_registry)
|
|
757
774
|
return query_name, self._get_query(query_name), merged
|
|
758
775
|
|
|
759
776
|
def _lookup_cached(
|
|
@@ -123,44 +123,6 @@ def _resolve_overlap_fraction(
|
|
|
123
123
|
return float(overlap)
|
|
124
124
|
|
|
125
125
|
|
|
126
|
-
def _grouped_bar_chrome_px(chart: ResolvedBarChart) -> float:
|
|
127
|
-
"""Estimate pixels consumed by chrome along the horizontal bar's category axis.
|
|
128
|
-
|
|
129
|
-
Used only for horizontal overlap pixel-size computation. Approximates axis
|
|
130
|
-
labels + optional title/subtitle + legend rows so we can estimate the plot box.
|
|
131
|
-
The estimate is intentionally rough — forgiving for 2-series thick bars.
|
|
132
|
-
"""
|
|
133
|
-
chrome = chart.style.axis_x.label.font.size * 2.2 # axis labels + ticks
|
|
134
|
-
legend = chart.legend
|
|
135
|
-
if legend.visible is not False and legend.position in ("top", "bottom"):
|
|
136
|
-
chrome += legend.label.font.size * 2.8
|
|
137
|
-
return chrome
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def _grouped_bar_slot_px(
|
|
141
|
-
extent: float,
|
|
142
|
-
chrome_px: float,
|
|
143
|
-
n_categories: int,
|
|
144
|
-
n_series: int,
|
|
145
|
-
padding_inner: float,
|
|
146
|
-
padding_outer: float,
|
|
147
|
-
) -> float:
|
|
148
|
-
"""Per-series pixel slot within a category group for horizontal overlap.
|
|
149
|
-
|
|
150
|
-
Mirrors Vega's band-scale step math, then divides evenly across series.
|
|
151
|
-
Returns 0.0 when inputs can't yield a positive slot.
|
|
152
|
-
"""
|
|
153
|
-
if extent <= 0 or n_categories <= 0 or n_series <= 0:
|
|
154
|
-
return 0.0
|
|
155
|
-
usable = extent - chrome_px
|
|
156
|
-
if usable <= 0:
|
|
157
|
-
return 0.0
|
|
158
|
-
step = usable / (n_categories - padding_inner + 2 * padding_outer)
|
|
159
|
-
bandwidth = step * (1.0 - padding_inner)
|
|
160
|
-
slot = bandwidth / n_series
|
|
161
|
-
return slot if slot > 0 else 0.0
|
|
162
|
-
|
|
163
|
-
|
|
164
126
|
def _data_order_expression(color_field: str, data: list[VLDict]) -> str:
|
|
165
127
|
"""Build a VL calculate expression assigning each color value its first-encounter index.
|
|
166
128
|
|
|
@@ -186,8 +148,6 @@ def apply_grouped_bar_overlap(
|
|
|
186
148
|
mark_props: VLDict,
|
|
187
149
|
encoding: VLDict,
|
|
188
150
|
data: list[VLDict],
|
|
189
|
-
*,
|
|
190
|
-
height: float | None = None,
|
|
191
151
|
) -> None:
|
|
192
152
|
"""Apply within-group bar spacing for grouped bars.
|
|
193
153
|
|
|
@@ -196,9 +156,11 @@ def apply_grouped_bar_overlap(
|
|
|
196
156
|
* Full (≥1): drop the offset channel so series coincide.
|
|
197
157
|
* Positive overlap (0 < f < 1): restricted to exactly 2 series.
|
|
198
158
|
- Vertical: ``width: {band: 1+f}`` — band > 1 overflows in Vega.
|
|
199
|
-
- Horizontal:
|
|
200
|
-
|
|
201
|
-
(
|
|
159
|
+
- Horizontal: ``size: {expr: "(1+f) * bandwidth('yOffset')"}`` — the
|
|
160
|
+
``band`` shorthand clamps height at 1 so it can't overlap, but the
|
|
161
|
+
``bandwidth(scaleName)`` expression form isn't clamped and is
|
|
162
|
+
resolved by Vega at its own layout time, so it needs no panel
|
|
163
|
+
dimension and no chrome estimate.
|
|
202
164
|
"""
|
|
203
165
|
is_horizontal = chart.orientation == "horizontal"
|
|
204
166
|
offset_ch = "yOffset" if is_horizontal else "xOffset"
|
|
@@ -233,32 +195,13 @@ def apply_grouped_bar_overlap(
|
|
|
233
195
|
mark_props["width"] = {"band": 1.0 + fraction}
|
|
234
196
|
return
|
|
235
197
|
|
|
236
|
-
# Horizontal: band clamps, so size
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
height,
|
|
244
|
-
chrome_px,
|
|
245
|
-
n_categories,
|
|
246
|
-
n_series,
|
|
247
|
-
GROUPED_BAR_PADDING_INNER,
|
|
248
|
-
GROUPED_BAR_PADDING_OUTER,
|
|
249
|
-
)
|
|
250
|
-
if height is not None
|
|
251
|
-
else 0.0
|
|
252
|
-
)
|
|
253
|
-
if slot <= 0:
|
|
254
|
-
# No panel dimension (CLI/test callers) — best-effort band (→ touching).
|
|
255
|
-
mark_props["height"] = {"band": 1.0 + fraction}
|
|
256
|
-
return
|
|
257
|
-
# Clamp so the group footprint can't exceed the category step (no spill).
|
|
258
|
-
max_size = slot * (
|
|
259
|
-
1.0 + GROUPED_BAR_PADDING_INNER / (1.0 - GROUPED_BAR_PADDING_INNER) * n_series
|
|
260
|
-
)
|
|
261
|
-
mark_props["size"] = min(slot * (1.0 + fraction), max_size)
|
|
198
|
+
# Horizontal: band clamps, so size comes from Vega's own resolved
|
|
199
|
+
# per-series offset-scale width. paddingInner=0 on the offset scale
|
|
200
|
+
# (set above) makes bandwidth('yOffset') == total_step / n_series, and
|
|
201
|
+
# fraction < 1 here keeps (1+fraction)*bandwidth under 2*bandwidth ==
|
|
202
|
+
# total_step — the group footprint can't spill into a neighboring
|
|
203
|
+
# category by construction, no clamp needed.
|
|
204
|
+
mark_props["size"] = {"expr": f"{1.0 + fraction} * bandwidth('yOffset')"}
|
|
262
205
|
# Drop the band-relative height (from bar_mark_to_vl / theme band_width) so
|
|
263
206
|
# the explicit pixel size is the sole height control — band and size conflict.
|
|
264
207
|
mark_props.pop("height", None)
|
|
@@ -26,7 +26,8 @@ class GroupedBarOverlapFeature:
|
|
|
26
26
|
|
|
27
27
|
The emitter sets up the offset encoding; this feature:
|
|
28
28
|
1. Sets the outer categorical axis paddingInner/paddingOuter (group gaps).
|
|
29
|
-
2. Applies mark sizing
|
|
29
|
+
2. Applies mark sizing (band shorthand, or a bandwidth() expression for
|
|
30
|
+
the horizontal-overlap case — resolved by Vega itself, not box geometry).
|
|
30
31
|
"""
|
|
31
32
|
|
|
32
33
|
def applies_to(self, chart: ResolvedChart) -> bool:
|
|
@@ -64,6 +65,5 @@ class GroupedBarOverlapFeature:
|
|
|
64
65
|
spec.mark_props,
|
|
65
66
|
spec.encoding,
|
|
66
67
|
chart_rows(chart, datasets),
|
|
67
|
-
height=box.height,
|
|
68
68
|
)
|
|
69
69
|
return spec
|
|
@@ -172,7 +172,13 @@ def _render_vl_artifact(
|
|
|
172
172
|
vl["$df_target_height"] = effective_height
|
|
173
173
|
if len(vl["hconcat"]) > 1:
|
|
174
174
|
vl["hconcat"][1]["height"] = effective_height
|
|
175
|
-
|
|
175
|
+
if "hconcat" not in vl:
|
|
176
|
+
# hconcat's title wrap is deferred to render_vega_spec: vl-convert
|
|
177
|
+
# ignores autosize:fit on concat children, so pane[0].width here is
|
|
178
|
+
# still the full column width — wrapping against it now overstates
|
|
179
|
+
# the room the title actually has. render_vega_spec wraps it after
|
|
180
|
+
# _correct_concat_overshoot shrinks pane[0] to its real width.
|
|
181
|
+
apply_title_overflow_to_spec(size_target, resolved_style.charts.title)
|
|
176
182
|
_trace_vl_spec(resolved.id, "v2", vl)
|
|
177
183
|
return RenderArtifact(kind="vega_spec", payload=vl)
|
|
178
184
|
|