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.
Files changed (108) hide show
  1. dataface/agent_api/docs/yaml-reference.md +12 -11
  2. dataface/ai/llm.py +1 -1
  3. dataface/ai/skills/kpi-row/SKILL.md +1 -1
  4. dataface/ai/skills/kpi-row/examples/kpi-row.yml +1 -0
  5. dataface/core/compile/channel.py +5 -1
  6. dataface/core/compile/chart_update.py +13 -23
  7. dataface/core/compile/merge.py +19 -5
  8. dataface/core/compile/migrations/__init__.py +27 -0
  9. dataface/core/compile/{migrations.py → migrations/migrations.py} +140 -155
  10. dataface/core/compile/migrations/versions/__init__.py +0 -0
  11. dataface/core/compile/migrations/versions/v0_4_0.py +51 -0
  12. dataface/core/compile/models/chart/resolved/_base.py +24 -1
  13. dataface/core/compile/models/chart/resolved/callout.py +15 -0
  14. dataface/core/compile/models/chart/resolved/pie.py +7 -3
  15. dataface/core/compile/models/face/normalized.py +7 -1
  16. dataface/core/compile/models/primitives.py +103 -3
  17. dataface/core/compile/models/style/resolved/__init__.py +4 -0
  18. dataface/core/compile/models/style/resolved/_base.py +112 -1
  19. dataface/core/compile/models/style/resolved/_cascade.py +158 -14
  20. dataface/core/compile/models/style/resolved/table.py +10 -0
  21. dataface/core/compile/models/style/theme/table.py +14 -8
  22. dataface/core/compile/normalize_layout.py +70 -9
  23. dataface/core/compile/normalize_variables.py +12 -0
  24. dataface/core/compile/normalizer.py +16 -2
  25. dataface/core/compile/resolve/_domain.py +1 -1
  26. dataface/core/compile/resolve/_kwargs.py +73 -2
  27. dataface/core/compile/resolve/_table.py +74 -8
  28. dataface/core/compile/resolve/area.py +5 -2
  29. dataface/core/compile/resolve/bar.py +76 -28
  30. dataface/core/compile/resolve/geo.py +20 -1
  31. dataface/core/compile/resolve/heatmap.py +10 -4
  32. dataface/core/compile/resolve/line.py +5 -2
  33. dataface/core/compile/resolve/pie.py +26 -10
  34. dataface/core/compile/resolve/pie_attachment.py +51 -1
  35. dataface/core/compile/resolve/scatter.py +5 -2
  36. dataface/core/compile/resolve/simple.py +9 -1
  37. dataface/core/compile/schema_renderers/board_resolved.py +16 -12
  38. dataface/core/compile/style_cascade.py +7 -1
  39. dataface/core/compile/tick_values.py +5 -86
  40. dataface/core/compile/typography.py +2 -0
  41. dataface/core/compile/validator.py +1 -17
  42. dataface/core/compile/yaml_patch.py +62 -0
  43. dataface/core/dashboard.py +7 -3
  44. dataface/core/defaults/themes/_base.yaml +13 -5
  45. dataface/core/defaults/themes/stark.yaml +8 -6
  46. dataface/core/diagnostics/codes_compile.py +29 -0
  47. dataface/core/diagnostics/codes_render.py +45 -1
  48. dataface/core/font_measure.py +135 -22
  49. dataface/core/fonts.py +27 -0
  50. dataface/core/numeric.py +92 -0
  51. dataface/core/registered_views/variable_planner.py +0 -10
  52. dataface/core/render/chart/__init__.py +0 -2
  53. dataface/core/render/chart/auto_link.py +9 -23
  54. dataface/core/render/chart/callout.py +11 -13
  55. dataface/core/render/chart/data_table_attachment.py +84 -126
  56. dataface/core/render/chart/emitters/_cartesian.py +11 -18
  57. dataface/core/render/chart/emitters/_channels.py +1 -1
  58. dataface/core/render/chart/emitters/_label_overlap.py +58 -25
  59. dataface/core/render/chart/emitters/_layers.py +48 -21
  60. dataface/core/render/chart/emitters/_measured_label_padding.py +66 -8
  61. dataface/core/render/chart/emitters/_overlay.py +9 -0
  62. dataface/core/render/chart/emitters/area.py +3 -0
  63. dataface/core/render/chart/emitters/bar.py +14 -1
  64. dataface/core/render/chart/emitters/line.py +3 -0
  65. dataface/core/render/chart/emitters/pie.py +14 -3
  66. dataface/core/render/chart/emitters/scatter.py +2 -0
  67. dataface/core/render/chart/features/__init__.py +8 -0
  68. dataface/core/render/chart/features/click_interactivity.py +35 -3
  69. dataface/core/render/chart/features/endpoint_labels.py +24 -7
  70. dataface/core/render/chart/features/mirror_axis.py +48 -6
  71. dataface/core/render/chart/features/value_labels.py +14 -12
  72. dataface/core/render/chart/features/zero_value_label.py +139 -0
  73. dataface/core/render/chart/kpi.py +7 -3
  74. dataface/core/render/chart/rendering.py +13 -25
  75. dataface/core/render/chart/table.py +53 -17
  76. dataface/core/render/chart/table_support.py +22 -29
  77. dataface/core/render/chart/time_unit_detect.py +80 -34
  78. dataface/core/render/chart/type_inference.py +4 -0
  79. dataface/core/render/chart/vega_lite.py +3 -1
  80. dataface/core/render/chart/vl_field_maps.py +91 -2
  81. dataface/core/render/chrome_css.py +5 -0
  82. dataface/core/render/controls.py +60 -10
  83. dataface/core/render/face_to_dict.py +2 -3
  84. dataface/core/render/faces.py +3 -7
  85. dataface/core/render/layout_sizing.py +32 -26
  86. dataface/core/render/layouts.py +10 -19
  87. dataface/core/render/renderer.py +74 -47
  88. dataface/core/render/sizing.py +67 -32
  89. dataface/core/render/svg_utils.py +18 -0
  90. dataface/core/render/templates/controls/_styles.css +19 -4
  91. dataface/core/render/templates/scripts/chart_interactivity.js +5 -0
  92. dataface/core/render/templates/scripts/variables.js +286 -69
  93. dataface/core/render/templates/variable_controls/multiselect.html +19 -0
  94. dataface/core/render/variables_resolve.py +18 -0
  95. dataface/core/render/warnings/layout_min_exceeds_height.py +3 -1
  96. dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +1 -1
  97. dataface/core/render/warnings/palette_unsupported.py +9 -3
  98. dataface/core/text/numeral_scale.py +55 -4
  99. dataface/data/schemas/board-resolved/board-resolved.schema.json +439 -20
  100. dataface/data/schemas/yaml/0.4.0.json +1 -0
  101. dataface/data/schemas/yaml/manifest.json +4 -4
  102. {dataface-0.3.2.dev150.dist-info → dataface-0.4.1.dev5.dist-info}/METADATA +1 -1
  103. {dataface-0.3.2.dev150.dist-info → dataface-0.4.1.dev5.dist-info}/RECORD +107 -101
  104. mdsvg/fonts.py +16 -0
  105. dataface/data/schemas/yaml/0.3.2.json +0 -1
  106. {dataface-0.3.2.dev150.dist-info → dataface-0.4.1.dev5.dist-info}/WHEEL +0 -0
  107. {dataface-0.3.2.dev150.dist-info → dataface-0.4.1.dev5.dist-info}/entry_points.txt +0 -0
  108. {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
- | `title_baseline_offset` | float | ✓ | Vertical offset to align SVG title baseline in pixels. |
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
@@ -28,7 +28,7 @@ else:
28
28
 
29
29
  logger = logging.getLogger(__name__)
30
30
 
31
- DEFAULT_OPENAI_MODEL = "gpt-5.6"
31
+ DEFAULT_OPENAI_MODEL = "gpt-5.6-terra"
32
32
 
33
33
 
34
34
  #: Connect budget for a caller-supplied read timeout. Named so the callers that
@@ -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
- | Fixed row height | `height: 120` on the row | Prevent KPIs growing taller than charts below |
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
 
@@ -52,6 +52,7 @@ charts:
52
52
  tone: positive
53
53
 
54
54
  rows:
55
+ # Ceiling, not a reservation: the row shrinks to the KPIs if they need less.
55
56
  - height: 120
56
57
  cols:
57
58
  - revenue_kpi
@@ -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
- return ScaleTargetConfig.model_validate(scale_dict)
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[0]")
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[0]" or "cols[1]"
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
- # Parse the source path to get the layout type and index
184
- if source_path.startswith("rows["):
185
- layout_key = "rows"
186
- try:
187
- index = int(source_path.split("[")[1].rstrip("]"))
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 (
@@ -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(content: str, relpath: str) -> BaseModel:
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(prepare_face_mapping(data))
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(prepare_face_mapping(meta_data))
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 model metadata."""
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], *, allow_expired: bool = False
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
- AuthoredFace.model_validate(mapping)
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
- from dataface.core.compile.models.face.authored import AuthoredFace
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
- marker_registry = MigrationRegistry.from_model(AuthoredFace, catalog=catalog)
269
- axis_reshape_registry = MigrationRegistry.from_suffix_renames(
270
- AuthoredFace,
271
- "0.3.1",
272
- "0.3.2",
273
- _AXIS_RESHAPE_RENAMES,
274
- catalog=catalog,
275
- )
276
- registry = MigrationRegistry(
277
- marker_registry.moves + axis_reshape_registry.moves, catalog=catalog
278
- )
279
- return catalog, registry
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 — see MovedFrom's
326
- # docstring). Recognizing an older schema and exhausting its transition
327
- # chain is not proof the result is current-compatible; check explicitly
328
- # rather than trust silently reaching the current identifier.
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 scalar moves while preserving unrelated YAML text.
350
-
351
- The general in-memory transformer supports any JSON-shaped value. The file
352
- writer is deliberately narrower: its surgical YAML editor only moves scalar
353
- block-mapping leaves. Unsupported shapes fail instead of reformatting a face
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 ScalarLeaf, set_face_values
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
- updates[".".join(destination)] = value
390
- removals.add(".".join(source))
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[tuple[str, ...], FieldInfo]]:
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, field
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