dataface 0.3.2.dev150__py3-none-any.whl → 0.4.1.dev5__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/agent_api/docs/yaml-reference.md +12 -11
- dataface/ai/llm.py +1 -1
- dataface/ai/skills/kpi-row/SKILL.md +1 -1
- dataface/ai/skills/kpi-row/examples/kpi-row.yml +1 -0
- dataface/core/compile/channel.py +5 -1
- dataface/core/compile/chart_update.py +13 -23
- dataface/core/compile/merge.py +19 -5
- dataface/core/compile/migrations/__init__.py +27 -0
- dataface/core/compile/{migrations.py → migrations/migrations.py} +140 -155
- dataface/core/compile/migrations/versions/__init__.py +0 -0
- dataface/core/compile/migrations/versions/v0_4_0.py +51 -0
- dataface/core/compile/models/chart/resolved/_base.py +24 -1
- dataface/core/compile/models/chart/resolved/callout.py +15 -0
- dataface/core/compile/models/chart/resolved/pie.py +7 -3
- dataface/core/compile/models/face/normalized.py +7 -1
- dataface/core/compile/models/primitives.py +103 -3
- dataface/core/compile/models/style/resolved/__init__.py +4 -0
- dataface/core/compile/models/style/resolved/_base.py +112 -1
- dataface/core/compile/models/style/resolved/_cascade.py +158 -14
- dataface/core/compile/models/style/resolved/table.py +10 -0
- dataface/core/compile/models/style/theme/table.py +14 -8
- dataface/core/compile/normalize_layout.py +70 -9
- dataface/core/compile/normalize_variables.py +12 -0
- dataface/core/compile/normalizer.py +16 -2
- dataface/core/compile/resolve/_domain.py +1 -1
- dataface/core/compile/resolve/_kwargs.py +73 -2
- dataface/core/compile/resolve/_table.py +74 -8
- dataface/core/compile/resolve/area.py +5 -2
- dataface/core/compile/resolve/bar.py +76 -28
- dataface/core/compile/resolve/geo.py +20 -1
- dataface/core/compile/resolve/heatmap.py +10 -4
- dataface/core/compile/resolve/line.py +5 -2
- dataface/core/compile/resolve/pie.py +26 -10
- dataface/core/compile/resolve/pie_attachment.py +51 -1
- dataface/core/compile/resolve/scatter.py +5 -2
- dataface/core/compile/resolve/simple.py +9 -1
- dataface/core/compile/schema_renderers/board_resolved.py +16 -12
- dataface/core/compile/style_cascade.py +7 -1
- dataface/core/compile/tick_values.py +5 -86
- dataface/core/compile/typography.py +2 -0
- dataface/core/compile/validator.py +1 -17
- dataface/core/compile/yaml_patch.py +62 -0
- dataface/core/dashboard.py +7 -3
- dataface/core/defaults/themes/_base.yaml +13 -5
- dataface/core/defaults/themes/stark.yaml +8 -6
- dataface/core/diagnostics/codes_compile.py +29 -0
- dataface/core/diagnostics/codes_render.py +45 -1
- dataface/core/font_measure.py +135 -22
- dataface/core/fonts.py +27 -0
- dataface/core/numeric.py +92 -0
- dataface/core/registered_views/variable_planner.py +0 -10
- dataface/core/render/chart/__init__.py +0 -2
- dataface/core/render/chart/auto_link.py +9 -23
- dataface/core/render/chart/callout.py +11 -13
- dataface/core/render/chart/data_table_attachment.py +84 -126
- dataface/core/render/chart/emitters/_cartesian.py +11 -18
- dataface/core/render/chart/emitters/_channels.py +1 -1
- dataface/core/render/chart/emitters/_label_overlap.py +58 -25
- dataface/core/render/chart/emitters/_layers.py +48 -21
- dataface/core/render/chart/emitters/_measured_label_padding.py +66 -8
- dataface/core/render/chart/emitters/_overlay.py +9 -0
- dataface/core/render/chart/emitters/area.py +3 -0
- dataface/core/render/chart/emitters/bar.py +14 -1
- dataface/core/render/chart/emitters/line.py +3 -0
- dataface/core/render/chart/emitters/pie.py +14 -3
- dataface/core/render/chart/emitters/scatter.py +2 -0
- dataface/core/render/chart/features/__init__.py +8 -0
- dataface/core/render/chart/features/click_interactivity.py +35 -3
- dataface/core/render/chart/features/endpoint_labels.py +24 -7
- dataface/core/render/chart/features/mirror_axis.py +48 -6
- dataface/core/render/chart/features/value_labels.py +14 -12
- dataface/core/render/chart/features/zero_value_label.py +139 -0
- dataface/core/render/chart/kpi.py +7 -3
- dataface/core/render/chart/rendering.py +13 -25
- dataface/core/render/chart/table.py +53 -17
- dataface/core/render/chart/table_support.py +22 -29
- dataface/core/render/chart/time_unit_detect.py +80 -34
- dataface/core/render/chart/type_inference.py +4 -0
- dataface/core/render/chart/vega_lite.py +3 -1
- dataface/core/render/chart/vl_field_maps.py +91 -2
- dataface/core/render/chrome_css.py +5 -0
- dataface/core/render/controls.py +60 -10
- dataface/core/render/face_to_dict.py +2 -3
- dataface/core/render/faces.py +3 -7
- dataface/core/render/layout_sizing.py +32 -26
- dataface/core/render/layouts.py +10 -19
- dataface/core/render/renderer.py +74 -47
- dataface/core/render/sizing.py +67 -32
- dataface/core/render/svg_utils.py +18 -0
- dataface/core/render/templates/controls/_styles.css +19 -4
- dataface/core/render/templates/scripts/chart_interactivity.js +5 -0
- dataface/core/render/templates/scripts/variables.js +286 -69
- dataface/core/render/templates/variable_controls/multiselect.html +19 -0
- dataface/core/render/variables_resolve.py +18 -0
- dataface/core/render/warnings/layout_min_exceeds_height.py +3 -1
- dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +1 -1
- dataface/core/render/warnings/palette_unsupported.py +9 -3
- dataface/core/text/numeral_scale.py +55 -4
- dataface/data/schemas/board-resolved/board-resolved.schema.json +439 -20
- dataface/data/schemas/yaml/0.4.0.json +1 -0
- dataface/data/schemas/yaml/manifest.json +4 -4
- {dataface-0.3.2.dev150.dist-info → dataface-0.4.1.dev5.dist-info}/METADATA +1 -1
- {dataface-0.3.2.dev150.dist-info → dataface-0.4.1.dev5.dist-info}/RECORD +107 -101
- mdsvg/fonts.py +16 -0
- dataface/data/schemas/yaml/0.3.2.json +0 -1
- {dataface-0.3.2.dev150.dist-info → dataface-0.4.1.dev5.dist-info}/WHEEL +0 -0
- {dataface-0.3.2.dev150.dist-info → dataface-0.4.1.dev5.dist-info}/entry_points.txt +0 -0
- {dataface-0.3.2.dev150.dist-info → dataface-0.4.1.dev5.dist-info}/licenses/LICENSE +0 -0
|
@@ -902,9 +902,7 @@ Authored overlay for TableChartStyle. Table chart style overrides layered on top
|
|
|
902
902
|
| `spark` | [SparkStyle](#sparkstyle) | ✓ | Inline sparkline defaults for table cells. |
|
|
903
903
|
| `transpose` | bool | ✓ | When True, render a single wide data row as N (label, value) rows — one per column. Raises ChartDataError when data has more than one row. Used for Looker single-value summary tiles with multiple measures. |
|
|
904
904
|
| `text_baseline_offset` | float | ✓ | Vertical offset to align SVG text baseline with cell grid in pixels. |
|
|
905
|
-
| `
|
|
906
|
-
| `title_subtitle_gap` | float | ✓ | Gap between table title and subtitle lines in pixels. |
|
|
907
|
-
| `subtitle` | [SubtitleStyle](#subtitlestyle) | ✓ | Table subtitle font-sizing constants. |
|
|
905
|
+
| `title_subtitle_gap` | float | ✓ | Pure whitespace between the title's descent and the subtitle's ascent, in pixels — not a baseline-to-baseline distance. Combined with the title and subtitle font sizes (via _TITLE_ASCENT_RATIO in render/chart/table.py) to reproduce Vega-Lite's title->subtitle spacing at any font size, not one calibrated pair. Font size and colour for the subtitle itself come from style.title.subtitle — the same source chart-family titles use — not a table-local constant. |
|
|
908
906
|
|
|
909
907
|
<a id="basemapconfig"></a>
|
|
910
908
|
## BasemapConfig
|
|
@@ -1062,6 +1060,9 @@ Box border. Does NOT cascade (ADR-003: box properties reset per level).
|
|
|
1062
1060
|
| `radius` | float | ✓ | Border corner radius in pixels. |
|
|
1063
1061
|
| `color` | str | ✓ | Border color (CSS color string). |
|
|
1064
1062
|
| `width` | float | ✓ | Border width in pixels. |
|
|
1063
|
+
| `dash_array` | list[float] | ✓ | SVG stroke-dasharray pattern in pixels (e.g. [4, 4]). None means a solid border. |
|
|
1064
|
+
| `line_cap` | enum: "butt", "round", "square" | ✓ | SVG stroke-linecap for a dashed border. None uses the renderer default (butt). |
|
|
1065
|
+
| `dash_offset` | float | ✓ | SVG stroke-dashoffset in pixels. None means 0. |
|
|
1065
1066
|
|
|
1066
1067
|
<a id="titlestyle"></a>
|
|
1067
1068
|
## TitleStyle
|
|
@@ -1776,14 +1777,6 @@ Authored overlay for SparkStyle. Inline sparkline defaults (inside table cells).
|
|
|
1776
1777
|
| `bar` | [SparkBarCellStyle](#sparkbarcellstyle) | ✓ | Style for bar/bar-normalize sparklines. |
|
|
1777
1778
|
| `area` | [SparkAreaStyle](#sparkareastyle) | ✓ | Style for area sparklines. |
|
|
1778
1779
|
|
|
1779
|
-
<a id="subtitlestyle"></a>
|
|
1780
|
-
## SubtitleStyle
|
|
1781
|
-
Authored overlay for SubtitleStyle. Subtitle font style for chart subtitles (Table, SparkBar).
|
|
1782
|
-
|
|
1783
|
-
| Field | Type | Optional | Description |
|
|
1784
|
-
|-------|------|:--------:|-------------|
|
|
1785
|
-
| `font` | [FontStyle](#fontstyle) | ✓ | Subtitle font style; theme populates font.size directly. |
|
|
1786
|
-
|
|
1787
1780
|
<a id="projectionstyle"></a>
|
|
1788
1781
|
## ProjectionStyle
|
|
1789
1782
|
Authored overlay for ProjectionStyle. Vega-Lite map projection configuration for geo chart families.
|
|
@@ -1854,6 +1847,14 @@ Authored overlay for SparkBarCountStyle. Count-value sub-block for SparkBarChart
|
|
|
1854
1847
|
| `visible` | bool | ✓ | Show count/value text next to bars. |
|
|
1855
1848
|
| `width` | float | ✓ | Reserved width for bar count text in pixels. |
|
|
1856
1849
|
|
|
1850
|
+
<a id="subtitlestyle"></a>
|
|
1851
|
+
## SubtitleStyle
|
|
1852
|
+
Authored overlay for SubtitleStyle. Subtitle font style for chart subtitles (Table, SparkBar).
|
|
1853
|
+
|
|
1854
|
+
| Field | Type | Optional | Description |
|
|
1855
|
+
|-------|------|:--------:|-------------|
|
|
1856
|
+
| `font` | [FontStyle](#fontstyle) | ✓ | Subtitle font style; theme populates font.size directly. |
|
|
1857
|
+
|
|
1857
1858
|
<a id="titlewidthoffsetsstyle"></a>
|
|
1858
1859
|
## TitleWidthOffsetsStyle
|
|
1859
1860
|
Authored overlay for TitleWidthOffsetsStyle. Additive level offsets applied to a face/chart title's heading level, by card pixel width.
|
dataface/ai/llm.py
CHANGED
|
@@ -82,7 +82,7 @@ See `examples/kpi-row.yml` for the full 3-KPI worked example.
|
|
|
82
82
|
| Currency | `style.value.format: currency_whole` | Revenue, spend, cost |
|
|
83
83
|
| Percent | `style.value.format: percent` | Rate, conversion, margin |
|
|
84
84
|
| Negative delta | `tone: negative`, `glyph: "▼"` | Unfavorable direction |
|
|
85
|
-
|
|
|
85
|
+
| Cap row height | `height: 120` on the row | Prevent KPIs growing taller than charts below. It is a ceiling, not a reservation — if the KPIs need less, the row shrinks to fit rather than leaving an empty band. |
|
|
86
86
|
|
|
87
87
|
## Common pitfalls
|
|
88
88
|
|
dataface/core/compile/channel.py
CHANGED
|
@@ -24,6 +24,7 @@ from dataface.core.compile.models.primitives import (
|
|
|
24
24
|
ColorStyle,
|
|
25
25
|
ScaleTargetConfig,
|
|
26
26
|
StaticGradientColorStyle,
|
|
27
|
+
bake_scale_target_stops,
|
|
27
28
|
)
|
|
28
29
|
|
|
29
30
|
_RawStyleChannel = str | dict[str, Any]
|
|
@@ -188,7 +189,10 @@ def _parse_channel_scale(raw: dict[str, Any], channel_name: str) -> ScaleTargetC
|
|
|
188
189
|
scale_dict = dict(scale)
|
|
189
190
|
if channel_name in ("opacity", "stroke_width") and "palette" not in scale_dict:
|
|
190
191
|
scale_dict["palette"] = [0.2, 1.0]
|
|
191
|
-
|
|
192
|
+
# A named Dataface palette resolves to hex stops via bake_scale_target_stops;
|
|
193
|
+
# a Vega scheme name (e.g. "viridis") is left alone and forwarded to VL as
|
|
194
|
+
# `scheme:` directly (gradient_scale_to_vl).
|
|
195
|
+
return bake_scale_target_stops(ScaleTargetConfig.model_validate(scale_dict))
|
|
192
196
|
|
|
193
197
|
|
|
194
198
|
def validate_channel_fields(
|
|
@@ -64,7 +64,7 @@ def update_chart_in_yaml(
|
|
|
64
64
|
yaml_content: Original dashboard YAML content
|
|
65
65
|
chart_id: ID of the chart to update
|
|
66
66
|
edits: Dict of fields to update (sql, type, x, y, title, etc.)
|
|
67
|
-
source_path: Original source path (e.g., "charts.revenue", "rows
|
|
67
|
+
source_path: Original source path (e.g., "charts.revenue", "rows.0")
|
|
68
68
|
query_is_inline: Whether the chart had an inline query
|
|
69
69
|
|
|
70
70
|
Returns:
|
|
@@ -159,9 +159,7 @@ def update_chart_in_yaml(
|
|
|
159
159
|
data["charts"][chart_id] = chart_def
|
|
160
160
|
|
|
161
161
|
# If chart was inline, we need to update the layout reference
|
|
162
|
-
if source_path and (
|
|
163
|
-
source_path.startswith("rows[") or source_path.startswith("cols[")
|
|
164
|
-
):
|
|
162
|
+
if source_path and source_path.split(".")[0] in ("rows", "cols"):
|
|
165
163
|
_update_layout_reference(data, source_path, chart_id)
|
|
166
164
|
|
|
167
165
|
# Serialize back to YAML
|
|
@@ -177,29 +175,21 @@ def _update_layout_reference(
|
|
|
177
175
|
|
|
178
176
|
Args:
|
|
179
177
|
data: Parsed YAML data (modified in place)
|
|
180
|
-
source_path: Path like "rows
|
|
178
|
+
source_path: Path like "rows.0" or "rows.0.cols.1"
|
|
181
179
|
chart_id: ID of the chart to reference
|
|
182
180
|
"""
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
except (ValueError, IndexError):
|
|
189
|
-
logger.warning(f"Failed to parse row index from source_path: {source_path}")
|
|
190
|
-
return
|
|
191
|
-
elif source_path.startswith("cols["):
|
|
192
|
-
layout_key = "cols"
|
|
193
|
-
try:
|
|
194
|
-
index = int(source_path.split("[")[1].rstrip("]"))
|
|
195
|
-
except (ValueError, IndexError):
|
|
196
|
-
logger.warning(
|
|
197
|
-
f"Failed to parse column index from source_path: {source_path}"
|
|
198
|
-
)
|
|
199
|
-
return
|
|
200
|
-
else:
|
|
181
|
+
# Only a top-level position is rewritable here: this replaces an entry in
|
|
182
|
+
# `data[layout_key]`, and a deeper path (`rows.0.cols.1`) names an entry in a
|
|
183
|
+
# nested face's own list, which this function never walks into.
|
|
184
|
+
segments = source_path.split(".")
|
|
185
|
+
if len(segments) != 2 or segments[0] not in ("rows", "cols"):
|
|
201
186
|
logger.debug(f"Unrecognized source_path format: {source_path}")
|
|
202
187
|
return
|
|
188
|
+
layout_key, raw_index = segments
|
|
189
|
+
if not raw_index.isdigit():
|
|
190
|
+
logger.warning(f"Failed to parse index from source_path: {source_path}")
|
|
191
|
+
return
|
|
192
|
+
index = int(raw_index)
|
|
203
193
|
|
|
204
194
|
# Update the layout
|
|
205
195
|
if (
|
dataface/core/compile/merge.py
CHANGED
|
@@ -423,7 +423,9 @@ class _ExtendCtx:
|
|
|
423
423
|
theme_names: frozenset[str] # built-in theme stems
|
|
424
424
|
|
|
425
425
|
|
|
426
|
-
def _fragment_from_yaml(
|
|
426
|
+
def _fragment_from_yaml(
|
|
427
|
+
content: str, relpath: str, *, is_builtin: bool = False
|
|
428
|
+
) -> BaseModel:
|
|
427
429
|
"""Parse extends-fragment YAML text into a FacePatch.
|
|
428
430
|
|
|
429
431
|
Fragments are partial content — they don't need the AuthoredFace
|
|
@@ -431,6 +433,13 @@ def _fragment_from_yaml(content: str, relpath: str) -> BaseModel:
|
|
|
431
433
|
FacePatch (all-optional, no content requirement). *relpath* is the
|
|
432
434
|
fragment's identity, used only in error messages.
|
|
433
435
|
|
|
436
|
+
*is_builtin* skips migration-currency detection entirely: built-in
|
|
437
|
+
theme YAML is package data we ship and keep current by construction,
|
|
438
|
+
so there is nothing to detect. A user-authored fragment (the default)
|
|
439
|
+
still gets checked, against FacePatch rather than AuthoredFace — the
|
|
440
|
+
latter's "must have layout/chart/etc" invariant would make every
|
|
441
|
+
current-schema patch look like a migration candidate.
|
|
442
|
+
|
|
434
443
|
Note: fragments must use canonical (non-shorthand) form. Chart
|
|
435
444
|
shorthand (``- chart_name`` without a type key) and certain source
|
|
436
445
|
shapes require the AuthoredFace normalizer and will fail here with a
|
|
@@ -443,7 +452,6 @@ def _fragment_from_yaml(content: str, relpath: str) -> BaseModel:
|
|
|
443
452
|
import yaml
|
|
444
453
|
from pydantic import ValidationError as PydanticValidationError
|
|
445
454
|
|
|
446
|
-
from dataface.core.compile.migrations import prepare_face_mapping
|
|
447
455
|
from dataface.core.compile.models.face.patch import FacePatch
|
|
448
456
|
from dataface.core.utils import UniqueKeyLoader
|
|
449
457
|
|
|
@@ -455,8 +463,12 @@ def _fragment_from_yaml(content: str, relpath: str) -> BaseModel:
|
|
|
455
463
|
raise CompilationError(
|
|
456
464
|
f"extends: {relpath} must be a YAML mapping, got {type(data).__name__}"
|
|
457
465
|
)
|
|
466
|
+
if not is_builtin:
|
|
467
|
+
from dataface.core.compile.migrations import prepare_face_mapping
|
|
468
|
+
|
|
469
|
+
data = prepare_face_mapping(data, model=FacePatch)
|
|
458
470
|
try:
|
|
459
|
-
return FacePatch.model_validate(
|
|
471
|
+
return FacePatch.model_validate(data)
|
|
460
472
|
except PydanticValidationError as e:
|
|
461
473
|
raise CompilationError(f"extends: schema error in {relpath}: {e}") from e
|
|
462
474
|
|
|
@@ -562,7 +574,7 @@ def _resolve_entry(
|
|
|
562
574
|
)
|
|
563
575
|
theme_file = _built_in_unified_theme_dir.joinpath(theme_relpath)
|
|
564
576
|
fragment = _fragment_from_yaml(
|
|
565
|
-
theme_file.read_text(encoding="utf-8"), theme_relpath
|
|
577
|
+
theme_file.read_text(encoding="utf-8"), theme_relpath, is_builtin=True
|
|
566
578
|
)
|
|
567
579
|
extends_patch = _merge_extends_inner(
|
|
568
580
|
fragment, ctx, seen | {theme_relpath}, theme_sink
|
|
@@ -721,7 +733,9 @@ def merge_metas(
|
|
|
721
733
|
try:
|
|
722
734
|
from dataface.core.compile.migrations import prepare_face_mapping
|
|
723
735
|
|
|
724
|
-
fragment = FacePatch.model_validate(
|
|
736
|
+
fragment = FacePatch.model_validate(
|
|
737
|
+
prepare_face_mapping(meta_data, model=FacePatch)
|
|
738
|
+
)
|
|
725
739
|
except PydanticValidationError as e:
|
|
726
740
|
raise CompilationError(
|
|
727
741
|
f"meta schema error in {meta_path.relpath}: {e}"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .migrations import (
|
|
2
|
+
MigrationConflictError,
|
|
3
|
+
MigrationError,
|
|
4
|
+
MigrationRegistry,
|
|
5
|
+
Move,
|
|
6
|
+
SchemaMigrationWarning,
|
|
7
|
+
SchemaVersionTooOldError,
|
|
8
|
+
UnsupportedSchemaError,
|
|
9
|
+
migrate_face_yaml_text,
|
|
10
|
+
migrate_mapping,
|
|
11
|
+
migrate_yaml_text,
|
|
12
|
+
prepare_face_mapping,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MigrationConflictError",
|
|
17
|
+
"MigrationError",
|
|
18
|
+
"MigrationRegistry",
|
|
19
|
+
"Move",
|
|
20
|
+
"SchemaVersionTooOldError",
|
|
21
|
+
"SchemaMigrationWarning",
|
|
22
|
+
"UnsupportedSchemaError",
|
|
23
|
+
"migrate_face_yaml_text",
|
|
24
|
+
"migrate_mapping",
|
|
25
|
+
"migrate_yaml_text",
|
|
26
|
+
"prepare_face_mapping",
|
|
27
|
+
]
|
|
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import copy
|
|
6
6
|
import dataclasses
|
|
7
|
+
import importlib
|
|
7
8
|
import json
|
|
9
|
+
import types
|
|
8
10
|
import warnings
|
|
9
11
|
from collections.abc import Iterable, Mapping
|
|
10
12
|
from datetime import date, timedelta
|
|
@@ -14,7 +16,6 @@ from typing import Annotated, TypeAlias, get_args, get_origin
|
|
|
14
16
|
from importlib_resources import files
|
|
15
17
|
from jsonschema import Draft7Validator
|
|
16
18
|
from pydantic import BaseModel, ValidationError
|
|
17
|
-
from pydantic.fields import FieldInfo
|
|
18
19
|
|
|
19
20
|
from dataface.core.compile.schema_renderers.yaml_schema_catalog import (
|
|
20
21
|
JsonObject,
|
|
@@ -45,20 +46,6 @@ class SchemaMigrationWarning(UserWarning):
|
|
|
45
46
|
"""A face was migrated in memory and should be rewritten with ``dft migrate``."""
|
|
46
47
|
|
|
47
48
|
|
|
48
|
-
@dataclasses.dataclass(frozen=True)
|
|
49
|
-
class MovedFrom:
|
|
50
|
-
"""Declare a version-scoped structural move on a current Pydantic field.
|
|
51
|
-
|
|
52
|
-
``old_path`` is explicit because it cannot safely be inferred. It is a YAML
|
|
53
|
-
mapping-key path, not a project file path. The registry
|
|
54
|
-
derives the destination from the annotated field's current model path.
|
|
55
|
-
"""
|
|
56
|
-
|
|
57
|
-
source_schema: str
|
|
58
|
-
target_schema: str
|
|
59
|
-
old_path: YamlKeyPath
|
|
60
|
-
|
|
61
|
-
|
|
62
49
|
@dataclasses.dataclass(frozen=True)
|
|
63
50
|
class Move:
|
|
64
51
|
"""One resolved adjacent structural rename or move."""
|
|
@@ -70,7 +57,7 @@ class Move:
|
|
|
70
57
|
|
|
71
58
|
|
|
72
59
|
class MigrationRegistry:
|
|
73
|
-
"""Validated adjacent transitions assembled from
|
|
60
|
+
"""Validated adjacent transitions assembled from tail-rename tables."""
|
|
74
61
|
|
|
75
62
|
def __init__(self, moves: Iterable[Move], *, catalog: YamlSchemaCatalog) -> None:
|
|
76
63
|
self.moves = tuple(moves)
|
|
@@ -81,77 +68,6 @@ class MigrationRegistry:
|
|
|
81
68
|
self._moves_by_source[move.source_schema] = (*moves_for_schema, move)
|
|
82
69
|
self._validate()
|
|
83
70
|
|
|
84
|
-
@classmethod
|
|
85
|
-
def from_model(
|
|
86
|
-
cls, root_model: type[BaseModel], *, catalog: YamlSchemaCatalog
|
|
87
|
-
) -> MigrationRegistry:
|
|
88
|
-
"""Build a move registry from ``MovedFrom`` field metadata."""
|
|
89
|
-
moves: list[Move] = []
|
|
90
|
-
for path, field in _walk_fields(root_model):
|
|
91
|
-
for marker in field.metadata:
|
|
92
|
-
if isinstance(marker, MovedFrom):
|
|
93
|
-
moves.append(
|
|
94
|
-
Move(
|
|
95
|
-
marker.source_schema,
|
|
96
|
-
marker.target_schema,
|
|
97
|
-
marker.old_path,
|
|
98
|
-
path,
|
|
99
|
-
)
|
|
100
|
-
)
|
|
101
|
-
return cls(moves, catalog=catalog)
|
|
102
|
-
|
|
103
|
-
@classmethod
|
|
104
|
-
def from_suffix_renames(
|
|
105
|
-
cls,
|
|
106
|
-
root_model: type[BaseModel],
|
|
107
|
-
source_schema: str,
|
|
108
|
-
target_schema: str,
|
|
109
|
-
renames: Iterable[tuple[YamlKeyPath, YamlKeyPath]],
|
|
110
|
-
*,
|
|
111
|
-
catalog: YamlSchemaCatalog,
|
|
112
|
-
) -> MigrationRegistry:
|
|
113
|
-
"""Build a move registry for tail-only path renames, one entry per
|
|
114
|
-
occurrence a shared style class is reused at.
|
|
115
|
-
|
|
116
|
-
``MovedFrom`` (``from_model``) declares one fixed ``old_path`` per
|
|
117
|
-
field *declaration*, which is correct only for a field that occurs at
|
|
118
|
-
a single position in the tree. A cascade style class like
|
|
119
|
-
``AxisLabelStyle`` is reused at hundreds of absolute positions (every
|
|
120
|
-
chart family's ``axis_x``/``axis_y``, every nested face, every theme
|
|
121
|
-
override) — one literal ``old_path`` cannot describe all of them.
|
|
122
|
-
``renames`` instead pairs a *new-path tail* with the corresponding
|
|
123
|
-
*old-path tail*; this walks every occurrence of the new tail and
|
|
124
|
-
substitutes the old tail at that same position, keeping the shared
|
|
125
|
-
prefix. Candidates that don't structurally exist in both frozen
|
|
126
|
-
schemas (a coincidental tail match on an unrelated field, e.g. a mark
|
|
127
|
-
family also named ``line``) are silently dropped rather than passed
|
|
128
|
-
to ``Move`` — that keeps this safe to run over the whole field tree
|
|
129
|
-
without hand-enumerating every real position.
|
|
130
|
-
"""
|
|
131
|
-
old_schema = catalog.schema_for(source_schema)
|
|
132
|
-
new_schema = catalog.schema_for(target_schema)
|
|
133
|
-
# Walk the field tree once and reuse it for every rename tuple —
|
|
134
|
-
# re-walking per rename is O(renames * tree_size) and the per-variant
|
|
135
|
-
# axis/scale models made tree_size large enough for that to matter.
|
|
136
|
-
paths = [path for path, _field in _walk_fields(root_model)]
|
|
137
|
-
seen: set[tuple[YamlKeyPath, YamlKeyPath]] = set()
|
|
138
|
-
moves: list[Move] = []
|
|
139
|
-
for new_tail, old_tail in renames:
|
|
140
|
-
width = len(new_tail)
|
|
141
|
-
for path in paths:
|
|
142
|
-
if len(path) < width or path[-width:] != new_tail:
|
|
143
|
-
continue
|
|
144
|
-
old_path = path[:-width] + old_tail
|
|
145
|
-
key = (old_path, path)
|
|
146
|
-
if key in seen:
|
|
147
|
-
continue
|
|
148
|
-
seen.add(key)
|
|
149
|
-
if _schema_path_exists(old_schema, old_path) and _schema_path_exists(
|
|
150
|
-
new_schema, path
|
|
151
|
-
):
|
|
152
|
-
moves.append(Move(source_schema, target_schema, old_path, path))
|
|
153
|
-
return cls(moves, catalog=catalog)
|
|
154
|
-
|
|
155
71
|
def transition_from(self, identifier: str) -> tuple[Move, ...]:
|
|
156
72
|
if identifier not in self._moves_by_source:
|
|
157
73
|
return ()
|
|
@@ -191,14 +107,72 @@ class MigrationRegistry:
|
|
|
191
107
|
)
|
|
192
108
|
|
|
193
109
|
|
|
110
|
+
def suffix_rename_moves(
|
|
111
|
+
root_model: type[BaseModel],
|
|
112
|
+
source_schema: str,
|
|
113
|
+
target_schema: str,
|
|
114
|
+
renames: Iterable[tuple[YamlKeyPath, YamlKeyPath]],
|
|
115
|
+
*,
|
|
116
|
+
catalog: YamlSchemaCatalog,
|
|
117
|
+
) -> tuple[Move, ...]:
|
|
118
|
+
"""Resolve tail-only path renames to Move objects without constructing a registry.
|
|
119
|
+
|
|
120
|
+
``renames`` pairs a *new-path tail* with the corresponding *old-path tail*;
|
|
121
|
+
this walks every occurrence of the new tail in the field tree and substitutes
|
|
122
|
+
the old tail at that same position, keeping the shared prefix. Candidates
|
|
123
|
+
that don't structurally exist in both frozen schemas are silently dropped —
|
|
124
|
+
that keeps this safe to run over the whole field tree without hand-enumerating
|
|
125
|
+
every real position.
|
|
126
|
+
|
|
127
|
+
Walk the field tree once and reuse it for every rename tuple — re-walking per
|
|
128
|
+
rename is O(renames * tree_size) and the per-variant axis/scale models made
|
|
129
|
+
tree_size large enough for that to matter.
|
|
130
|
+
"""
|
|
131
|
+
old_schema = catalog.schema_for(source_schema)
|
|
132
|
+
new_schema = catalog.schema_for(target_schema)
|
|
133
|
+
paths = list(_walk_fields(root_model))
|
|
134
|
+
seen: set[tuple[YamlKeyPath, YamlKeyPath]] = set()
|
|
135
|
+
result: list[Move] = []
|
|
136
|
+
for new_tail, old_tail in renames:
|
|
137
|
+
width = len(new_tail)
|
|
138
|
+
for path in paths:
|
|
139
|
+
if len(path) < width or path[-width:] != new_tail:
|
|
140
|
+
continue
|
|
141
|
+
old_path = path[:-width] + old_tail
|
|
142
|
+
key = (old_path, path)
|
|
143
|
+
if key in seen:
|
|
144
|
+
continue
|
|
145
|
+
seen.add(key)
|
|
146
|
+
if _schema_path_exists(old_schema, old_path) and _schema_path_exists(
|
|
147
|
+
new_schema, path
|
|
148
|
+
):
|
|
149
|
+
result.append(Move(source_schema, target_schema, old_path, path))
|
|
150
|
+
return tuple(result)
|
|
151
|
+
|
|
152
|
+
|
|
194
153
|
def prepare_face_mapping(
|
|
195
|
-
mapping: Mapping[str, JsonValue],
|
|
154
|
+
mapping: Mapping[str, JsonValue],
|
|
155
|
+
*,
|
|
156
|
+
model: type[BaseModel] | None = None,
|
|
157
|
+
allow_expired: bool = False,
|
|
196
158
|
) -> JsonObject:
|
|
197
|
-
"""Recognize and migrate a raw face-shaped mapping before model validation.
|
|
159
|
+
"""Recognize and migrate a raw face-shaped mapping before model validation.
|
|
160
|
+
|
|
161
|
+
``model`` is the currency check: does this mapping already validate
|
|
162
|
+
against the current schema, so migration can be skipped? Defaults to
|
|
163
|
+
``AuthoredFace`` for a real, standalone face. Callers handling a
|
|
164
|
+
patch-shaped fragment (a theme YAML's ``style:``-only content, a
|
|
165
|
+
meta.yaml override, an extends target) must pass ``FacePatch`` —
|
|
166
|
+
``AuthoredFace`` carries a "must have layout/chart/text/title/description"
|
|
167
|
+
invariant that no patch can ever satisfy, which would make every current
|
|
168
|
+
patch look like a migration candidate.
|
|
169
|
+
"""
|
|
198
170
|
from dataface.core.compile.models.face.authored import AuthoredFace
|
|
199
171
|
|
|
172
|
+
currency_model = model if model is not None else AuthoredFace
|
|
173
|
+
|
|
200
174
|
try:
|
|
201
|
-
|
|
175
|
+
currency_model.model_validate(mapping)
|
|
202
176
|
except ValidationError:
|
|
203
177
|
if not _face_has_historical_schemas():
|
|
204
178
|
return dict(mapping)
|
|
@@ -225,58 +199,51 @@ def migrate_face_yaml_text(yaml_text: str) -> str:
|
|
|
225
199
|
return migrate_yaml_text(yaml_text, catalog=catalog, registry=registry)
|
|
226
200
|
|
|
227
201
|
|
|
228
|
-
# 0.3.1 -> 0.3.2: the axis/scale model split (per-variant AxisXStyle/AxisYStyle,
|
|
229
|
-
# BaseScaleStyle.continuous nesting) renamed several theme-cascade fields.
|
|
230
|
-
# Every rename here is a pure tail substitution — same shared prefix, only the
|
|
231
|
-
# trailing segment(s) change — so from_suffix_renames derives every occurrence
|
|
232
|
-
# a rename applies at. Leaf renames that land *inside* the "labels"/"scale"
|
|
233
|
-
# containers run before the "labels"/"line" container renames themselves: a
|
|
234
|
-
# leaf move creates its destination container on demand, and the later
|
|
235
|
-
# container move must find that destination still empty (or absent) to avoid
|
|
236
|
-
# a spurious MigrationConflictError against an unrelated field that also sets
|
|
237
|
-
# the old container. See dataface/AGENTS.md's "Two validation boundaries" —
|
|
238
|
-
# these are structural moves; categorical_orient (deleted), ticks.interval
|
|
239
|
-
# (reinterpreted vocabulary, not renamed), and the band/point/quantize scale
|
|
240
|
-
# groups (deleted) are semantic changes with no lossless move and are left to
|
|
241
|
-
# raise UnsupportedSchemaError for manual migration.
|
|
242
|
-
_AXIS_RESHAPE_RENAMES: tuple[tuple[YamlKeyPath, YamlKeyPath], ...] = (
|
|
243
|
-
(("labels", "format"), ("format",)),
|
|
244
|
-
(("scale", "values"), ("values",)),
|
|
245
|
-
(("scale", "continuous", "domain"), ("scale", "domain")),
|
|
246
|
-
(("scale", "continuous", "zero"), ("scale", "zero")),
|
|
247
|
-
(("scale", "continuous", "type"), ("scale", "type")),
|
|
248
|
-
(("scale", "continuous", "domain_strategy"), ("scale", "domain_strategy")),
|
|
249
|
-
(("scale", "continuous", "log", "base"), ("scale", "base")),
|
|
250
|
-
(("scale", "continuous", "pow", "exponent"), ("scale", "exponent")),
|
|
251
|
-
(("scale", "continuous", "symlog", "constant"), ("scale", "constant")),
|
|
252
|
-
(("scale", "padding"), ("scale", "band_padding_inner")),
|
|
253
|
-
(("ticks", "length"), ("ticks", "size")),
|
|
254
|
-
(("labels",), ("label",)),
|
|
255
|
-
(("line",), ("domain",)),
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
|
|
259
202
|
@cache
|
|
260
203
|
def _face_migration_context() -> tuple[YamlSchemaCatalog, MigrationRegistry]:
|
|
261
|
-
"""Load and validate the package's immutable migration metadata once.
|
|
262
|
-
|
|
204
|
+
"""Load and validate the package's immutable migration metadata once.
|
|
205
|
+
|
|
206
|
+
Walks catalog.entries newest-to-oldest, skipping the oldest (predecessor
|
|
207
|
+
is None). For each remaining entry, imports the corresponding version module
|
|
208
|
+
from ``migrations.versions``. A missing module means no structural migration
|
|
209
|
+
was declared for that boundary — not an error. Each found module's
|
|
210
|
+
``moves()`` function owns the migration strategy; the collector stays
|
|
211
|
+
agnostic to the mechanism.
|
|
212
|
+
"""
|
|
263
213
|
from dataface.core.compile.schema_renderers.yaml_schema_catalog import (
|
|
264
214
|
load_yaml_schema_catalog,
|
|
265
215
|
)
|
|
266
216
|
|
|
267
217
|
catalog = load_yaml_schema_catalog()
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
)
|
|
279
|
-
|
|
218
|
+
all_moves: list[Move] = []
|
|
219
|
+
for entry in catalog.entries:
|
|
220
|
+
if entry.predecessor is None:
|
|
221
|
+
continue
|
|
222
|
+
module = _load_version_module(entry.version)
|
|
223
|
+
if module is None:
|
|
224
|
+
continue
|
|
225
|
+
all_moves.extend(
|
|
226
|
+
module.moves(entry.predecessor, entry.version, catalog=catalog)
|
|
227
|
+
)
|
|
228
|
+
return catalog, MigrationRegistry(all_moves, catalog=catalog)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _load_version_module(version: str) -> types.ModuleType | None:
|
|
232
|
+
"""Import the version-migration module for *version*, or return None if absent."""
|
|
233
|
+
module_name = "v" + version.replace(".", "_")
|
|
234
|
+
dotted = f"dataface.core.compile.migrations.versions.{module_name}"
|
|
235
|
+
try:
|
|
236
|
+
module = importlib.import_module(dotted)
|
|
237
|
+
except ModuleNotFoundError as exc:
|
|
238
|
+
if exc.name != dotted:
|
|
239
|
+
raise
|
|
240
|
+
return None
|
|
241
|
+
if not hasattr(module, "moves"):
|
|
242
|
+
raise MigrationError(
|
|
243
|
+
f"{dotted} was found but does not define "
|
|
244
|
+
"moves(source_schema, target_schema, *, catalog) -> tuple[Move, ...]"
|
|
245
|
+
)
|
|
246
|
+
return module
|
|
280
247
|
|
|
281
248
|
|
|
282
249
|
@cache
|
|
@@ -322,10 +289,10 @@ def migrate_mapping(
|
|
|
322
289
|
identifier = moves[0].target_schema
|
|
323
290
|
|
|
324
291
|
# A transition's declared moves may not cover every field a source schema
|
|
325
|
-
# allowed (a deleted field has no lossless Move
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
#
|
|
292
|
+
# allowed (a deleted field has no lossless Move). Recognizing an older
|
|
293
|
+
# schema and exhausting its transition chain is not proof the result is
|
|
294
|
+
# current-compatible; check explicitly rather than trust silently reaching
|
|
295
|
+
# the current identifier.
|
|
329
296
|
current_errors = _current_schema_rejections(result, catalog)
|
|
330
297
|
if current_errors:
|
|
331
298
|
raise _unsupported_schema_error(current_errors[0])
|
|
@@ -346,15 +313,24 @@ def migrate_yaml_text(
|
|
|
346
313
|
catalog: YamlSchemaCatalog,
|
|
347
314
|
registry: MigrationRegistry,
|
|
348
315
|
) -> str:
|
|
349
|
-
"""Rewrite declared
|
|
350
|
-
|
|
351
|
-
The general in-memory transformer supports any JSON-shaped value. The
|
|
352
|
-
writer is
|
|
353
|
-
block-mapping leaves
|
|
316
|
+
"""Rewrite declared moves while preserving unrelated YAML text.
|
|
317
|
+
|
|
318
|
+
The general in-memory transformer supports any JSON-shaped value. The
|
|
319
|
+
file writer is narrower, for two shapes only: it moves scalar
|
|
320
|
+
block-mapping leaves, and it renames a key in place when the move is a
|
|
321
|
+
pure rename (old and new path share the same parent -- only the final
|
|
322
|
+
segment's spelling differs). A rename never relocates content, so it
|
|
323
|
+
carries no restriction on the value's shape -- an entire nested block
|
|
324
|
+
renames as cleanly as a scalar. Any other non-scalar move (a genuine
|
|
325
|
+
relocation to a different parent) fails instead of reformatting a face
|
|
354
326
|
through a YAML dump/load round trip.
|
|
355
327
|
"""
|
|
356
328
|
from dataface.core.compile.parser import load_yaml_mapping
|
|
357
|
-
from dataface.core.compile.yaml_patch import
|
|
329
|
+
from dataface.core.compile.yaml_patch import (
|
|
330
|
+
ScalarLeaf,
|
|
331
|
+
rename_key_at_path,
|
|
332
|
+
set_face_values,
|
|
333
|
+
)
|
|
358
334
|
|
|
359
335
|
raw = load_yaml_mapping(yaml_text)
|
|
360
336
|
identifier = _recognize(raw, catalog)
|
|
@@ -378,16 +354,25 @@ def migrate_yaml_text(
|
|
|
378
354
|
)
|
|
379
355
|
assert isinstance(key, str)
|
|
380
356
|
value = parent[key]
|
|
381
|
-
if value is None or not isinstance(value, (str, int, float, bool)):
|
|
382
|
-
raise MigrationError(
|
|
383
|
-
f"Cannot rewrite {_format_path(move.old_path)!r}: "
|
|
384
|
-
"only scalar block-mapping "
|
|
385
|
-
"moves are supported; migrate this field manually."
|
|
386
|
-
)
|
|
387
357
|
source = _substitute_wildcards(move.old_path, bindings)
|
|
388
358
|
destination = _substitute_wildcards(move.new_path, bindings)
|
|
389
|
-
|
|
390
|
-
|
|
359
|
+
if value is None or not isinstance(value, (str, int, float, bool)):
|
|
360
|
+
if source[:-1] != destination[:-1]:
|
|
361
|
+
raise MigrationError(
|
|
362
|
+
f"Cannot rewrite {_format_path(move.old_path)!r}: "
|
|
363
|
+
"only scalar block-mapping moves or same-position "
|
|
364
|
+
"key renames are supported; migrate this field "
|
|
365
|
+
"manually."
|
|
366
|
+
)
|
|
367
|
+
# A rename never relocates content -- any value shape
|
|
368
|
+
# (including this nested mapping/list) is safe to
|
|
369
|
+
# rewrite in place; only the key token changes.
|
|
370
|
+
yaml_text = rename_key_at_path(
|
|
371
|
+
yaml_text, ".".join(source), destination[-1]
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
updates[".".join(destination)] = value
|
|
375
|
+
removals.add(".".join(source))
|
|
391
376
|
_apply_move(staged, move)
|
|
392
377
|
identifier = moves[0].target_schema
|
|
393
378
|
|
|
@@ -546,12 +531,12 @@ def _walk_fields(
|
|
|
546
531
|
model: type[BaseModel],
|
|
547
532
|
path: tuple[str, ...] = (),
|
|
548
533
|
seen: frozenset[type[BaseModel]] = frozenset(),
|
|
549
|
-
) -> Iterable[tuple[
|
|
534
|
+
) -> Iterable[tuple[str, ...]]:
|
|
550
535
|
if model in seen:
|
|
551
536
|
return
|
|
552
537
|
for name, field in model.model_fields.items():
|
|
553
538
|
field_path = path + (name,)
|
|
554
|
-
yield field_path
|
|
539
|
+
yield field_path
|
|
555
540
|
if field.annotation is not None:
|
|
556
541
|
for nested, suffix in _nested_models(field.annotation):
|
|
557
542
|
yield from _walk_fields(nested, field_path + suffix, seen | {model})
|
|
File without changes
|