dataface 0.1.2__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 (455) hide show
  1. d3_format/__init__.py +14 -0
  2. d3_format/errors.py +19 -0
  3. d3_format/format.py +551 -0
  4. d3_format/spec.py +159 -0
  5. dataface/DATAFACE_SYNTAX.md +1135 -0
  6. dataface/__init__.py +93 -0
  7. dataface/_docs_site.py +20 -0
  8. dataface/_install_hint.py +26 -0
  9. dataface/agent_api/__init__.py +79 -0
  10. dataface/agent_api/_init_templates/__init__.py +0 -0
  11. dataface/agent_api/_init_templates/agents_dft_snippet.md +26 -0
  12. dataface/agent_api/_init_templates/dataface.yml +15 -0
  13. dataface/agent_api/_init_templates/faces-dataface.yml +144 -0
  14. dataface/agent_api/_init_templates/index.md +24 -0
  15. dataface/agent_api/_paths.py +118 -0
  16. dataface/agent_api/_project_agents_md.py +43 -0
  17. dataface/agent_api/_session_store.py +486 -0
  18. dataface/agent_api/_state.py +28 -0
  19. dataface/agent_api/chat.py +221 -0
  20. dataface/agent_api/dashboards.py +257 -0
  21. dataface/agent_api/describe.py +366 -0
  22. dataface/agent_api/describe_query.py +120 -0
  23. dataface/agent_api/docs/__init__.py +25 -0
  24. dataface/agent_api/docs/_loader.py +292 -0
  25. dataface/agent_api/docs/yaml-reference.md +2757 -0
  26. dataface/agent_api/file_refs.py +118 -0
  27. dataface/agent_api/init.py +126 -0
  28. dataface/agent_api/inspect.py +128 -0
  29. dataface/agent_api/mcp_install.py +170 -0
  30. dataface/agent_api/query.py +274 -0
  31. dataface/agent_api/schema.py +658 -0
  32. dataface/agent_api/schema_search.py +284 -0
  33. dataface/agent_api/search.py +270 -0
  34. dataface/agent_api/skill_install.py +141 -0
  35. dataface/agent_api/skill_render.py +90 -0
  36. dataface/agent_api/skills.py +293 -0
  37. dataface/agent_api/surface_aliases.yaml +128 -0
  38. dataface/agent_api/validate.py +175 -0
  39. dataface/agent_api/validate_query.py +84 -0
  40. dataface/ai/__init__.py +39 -0
  41. dataface/ai/agent.py +139 -0
  42. dataface/ai/context.py +45 -0
  43. dataface/ai/events.py +62 -0
  44. dataface/ai/external_mcp.py +610 -0
  45. dataface/ai/generate_sql.py +96 -0
  46. dataface/ai/llm.py +403 -0
  47. dataface/ai/mcp/__init__.py +51 -0
  48. dataface/ai/mcp/server.py +289 -0
  49. dataface/ai/memories.py +85 -0
  50. dataface/ai/prompts.py +177 -0
  51. dataface/ai/schema_context.py +138 -0
  52. dataface/ai/skills/before-after-comparison/SKILL.md +102 -0
  53. dataface/ai/skills/before-after-comparison/examples/before-after-comparison.yml +24 -0
  54. dataface/ai/skills/dashboard-build/SKILL.md +212 -0
  55. dataface/ai/skills/dashboard-build/examples/_smoke.yml +15 -0
  56. dataface/ai/skills/dashboard-design/SKILL.md +182 -0
  57. dataface/ai/skills/dashboard-review/SKILL.md +113 -0
  58. dataface/ai/skills/dashboard-structural-review/SKILL.md +173 -0
  59. dataface/ai/skills/dashboard-visual-review/SKILL.md +139 -0
  60. dataface/ai/skills/dataface-mcp-setup/SKILL.md +177 -0
  61. dataface/ai/skills/dataface-troubleshooting/SKILL.md +225 -0
  62. dataface/ai/skills/drill-down-link/SKILL.md +112 -0
  63. dataface/ai/skills/drill-down-link/examples/drill-down-link.yml +27 -0
  64. dataface/ai/skills/faceted-small-multiples/SKILL.md +116 -0
  65. dataface/ai/skills/faceted-small-multiples/examples/faceted-small-multiples.yml +33 -0
  66. dataface/ai/skills/filter-bar-with-variables/SKILL.md +105 -0
  67. dataface/ai/skills/filter-bar-with-variables/examples/filter-bar-with-variables.yml +49 -0
  68. dataface/ai/skills/kpi-row/SKILL.md +101 -0
  69. dataface/ai/skills/kpi-row/examples/kpi-row.yml +55 -0
  70. dataface/ai/skills/report-design/SKILL.md +184 -0
  71. dataface/ai/skills/single-metric-bignum/SKILL.md +90 -0
  72. dataface/ai/skills/single-metric-bignum/examples/single-metric-bignum.yml +27 -0
  73. dataface/ai/skills/table-heavy-ops-dashboard/SKILL.md +114 -0
  74. dataface/ai/skills/table-heavy-ops-dashboard/examples/table-heavy-ops-dashboard.yml +48 -0
  75. dataface/ai/skills/time-series-trend/SKILL.md +93 -0
  76. dataface/ai/skills/time-series-trend/examples/time-series-trend.yml +26 -0
  77. dataface/ai/skills/top-n-with-detail/SKILL.md +98 -0
  78. dataface/ai/skills/top-n-with-detail/examples/top-n-with-detail.yml +45 -0
  79. dataface/ai/skills/two-by-two-grid-overview/SKILL.md +78 -0
  80. dataface/ai/skills/two-by-two-grid-overview/examples/two-by-two-grid-overview.yml +64 -0
  81. dataface/ai/tool_schemas.py +132 -0
  82. dataface/ai/tools/__init__.py +312 -0
  83. dataface/ai/yaml_utils.py +57 -0
  84. dataface/cli/__init__.py +3 -0
  85. dataface/cli/_console.py +48 -0
  86. dataface/cli/_error_format.py +83 -0
  87. dataface/cli/_extras.py +190 -0
  88. dataface/cli/_json_output.py +8 -0
  89. dataface/cli/_parsing.py +17 -0
  90. dataface/cli/_version_info.py +56 -0
  91. dataface/cli/commands/__init__.py +3 -0
  92. dataface/cli/commands/_agent_input.py +205 -0
  93. dataface/cli/commands/_agent_server.py +115 -0
  94. dataface/cli/commands/chat.py +645 -0
  95. dataface/cli/commands/describe.py +107 -0
  96. dataface/cli/commands/docs.py +131 -0
  97. dataface/cli/commands/extension.py +179 -0
  98. dataface/cli/commands/init.py +240 -0
  99. dataface/cli/commands/inspect.py +94 -0
  100. dataface/cli/commands/mcp_init.py +167 -0
  101. dataface/cli/commands/query.py +386 -0
  102. dataface/cli/commands/render.py +291 -0
  103. dataface/cli/commands/schema.py +411 -0
  104. dataface/cli/commands/search.py +49 -0
  105. dataface/cli/commands/serve.py +114 -0
  106. dataface/cli/commands/skills.py +133 -0
  107. dataface/cli/commands/skills_init.py +161 -0
  108. dataface/cli/commands/validate.py +63 -0
  109. dataface/cli/main.py +1501 -0
  110. dataface/core/__init__.py +75 -0
  111. dataface/core/compile/__init__.py +244 -0
  112. dataface/core/compile/_jinja_helpers.py +78 -0
  113. dataface/core/compile/channel.py +222 -0
  114. dataface/core/compile/chart_focus.py +101 -0
  115. dataface/core/compile/chart_resolved.py +169 -0
  116. dataface/core/compile/chart_type_detection.py +489 -0
  117. dataface/core/compile/chart_update.py +261 -0
  118. dataface/core/compile/colors.py +64 -0
  119. dataface/core/compile/compiler.py +904 -0
  120. dataface/core/compile/config.py +823 -0
  121. dataface/core/compile/custom_chart_types.py +208 -0
  122. dataface/core/compile/data_table_attachment.py +1287 -0
  123. dataface/core/compile/detect.py +110 -0
  124. dataface/core/compile/errors.py +302 -0
  125. dataface/core/compile/filter_injection.py +319 -0
  126. dataface/core/compile/introspection.py +527 -0
  127. dataface/core/compile/jinja.py +511 -0
  128. dataface/core/compile/labels_env.py +52 -0
  129. dataface/core/compile/markdown.py +154 -0
  130. dataface/core/compile/meta.py +388 -0
  131. dataface/core/compile/models/__init__.py +0 -0
  132. dataface/core/compile/models/chart/__init__.py +0 -0
  133. dataface/core/compile/models/chart/authored.py +2137 -0
  134. dataface/core/compile/models/chart/compiled.py +398 -0
  135. dataface/core/compile/models/config.py +347 -0
  136. dataface/core/compile/models/face/__init__.py +0 -0
  137. dataface/core/compile/models/face/authored.py +659 -0
  138. dataface/core/compile/models/face/compiled.py +522 -0
  139. dataface/core/compile/models/factories.py +201 -0
  140. dataface/core/compile/models/markers.py +40 -0
  141. dataface/core/compile/models/palette.py +36 -0
  142. dataface/core/compile/models/primitives.py +415 -0
  143. dataface/core/compile/models/query/__init__.py +0 -0
  144. dataface/core/compile/models/query/authored.py +246 -0
  145. dataface/core/compile/models/query/compiled.py +710 -0
  146. dataface/core/compile/models/refs.py +137 -0
  147. dataface/core/compile/models/source.py +611 -0
  148. dataface/core/compile/models/style/__init__.py +0 -0
  149. dataface/core/compile/models/style/authored.py +481 -0
  150. dataface/core/compile/models/style/compiled.py +3399 -0
  151. dataface/core/compile/models/style/merged.py +1682 -0
  152. dataface/core/compile/models/theme.py +362 -0
  153. dataface/core/compile/models/variable/__init__.py +0 -0
  154. dataface/core/compile/models/variable/authored.py +254 -0
  155. dataface/core/compile/models/vega_lite/__init__.py +0 -0
  156. dataface/core/compile/models/vega_lite/config.py +510 -0
  157. dataface/core/compile/models/vega_lite/contracts.py +171 -0
  158. dataface/core/compile/normalize_charts.py +494 -0
  159. dataface/core/compile/normalize_layout.py +1000 -0
  160. dataface/core/compile/normalize_queries.py +297 -0
  161. dataface/core/compile/normalize_variables.py +489 -0
  162. dataface/core/compile/normalizer.py +543 -0
  163. dataface/core/compile/palette.py +1100 -0
  164. dataface/core/compile/parameterized.py +658 -0
  165. dataface/core/compile/parser.py +228 -0
  166. dataface/core/compile/schema.py +20 -0
  167. dataface/core/compile/schema_renderers/__init__.py +0 -0
  168. dataface/core/compile/schema_renderers/json_schema.py +163 -0
  169. dataface/core/compile/schema_renderers/prompt.py +152 -0
  170. dataface/core/compile/schema_renderers/vscode_schema.py +301 -0
  171. dataface/core/compile/sizing.py +2126 -0
  172. dataface/core/compile/sources.py +518 -0
  173. dataface/core/compile/sql_authoring_lint.py +56 -0
  174. dataface/core/compile/style_cascade.py +471 -0
  175. dataface/core/compile/typography.py +299 -0
  176. dataface/core/compile/validator.py +301 -0
  177. dataface/core/compile/variables.py +53 -0
  178. dataface/core/compile/vega_config.py +98 -0
  179. dataface/core/compile/vega_lite/__init__.py +6 -0
  180. dataface/core/compile/vega_lite/validation.py +95 -0
  181. dataface/core/compile/yaml_error_formatter.py +838 -0
  182. dataface/core/connections.py +38 -0
  183. dataface/core/dashboard.py +358 -0
  184. dataface/core/defaults/default_config.yml +101 -0
  185. dataface/core/defaults/palettes/categorical/category-10-dark.yml +32 -0
  186. dataface/core/defaults/palettes/categorical/category-10-light.yml +43 -0
  187. dataface/core/defaults/palettes/categorical/category-10.yml +31 -0
  188. dataface/core/defaults/palettes/categorical/category-6-tonal-blue.yml +22 -0
  189. dataface/core/defaults/palettes/categorical/category-6-tonal-brown.yml +29 -0
  190. dataface/core/defaults/palettes/categorical/category-6-tonal-green.yml +20 -0
  191. dataface/core/defaults/palettes/categorical/category-6-tonal-orange.yml +21 -0
  192. dataface/core/defaults/palettes/categorical/category-6-tonal-purple.yml +20 -0
  193. dataface/core/defaults/palettes/categorical/editorial-10-dark.yml +32 -0
  194. dataface/core/defaults/palettes/categorical/editorial-10.yml +40 -0
  195. dataface/core/defaults/palettes/categorical/hero-6.yml +17 -0
  196. dataface/core/defaults/palettes/categorical/single-blue.yml +11 -0
  197. dataface/core/defaults/palettes/categorical/tableau.yml +20 -0
  198. dataface/core/defaults/palettes/data/xkcd_colors.json +3803 -0
  199. dataface/core/defaults/palettes/diverging/blue-red.yml +25 -0
  200. dataface/core/defaults/palettes/diverging/coolwarm.yml +24 -0
  201. dataface/core/defaults/palettes/diverging/crimson-green.yml +23 -0
  202. dataface/core/defaults/palettes/diverging/orange-teal.yml +23 -0
  203. dataface/core/defaults/palettes/diverging/sunset.yml +24 -0
  204. dataface/core/defaults/palettes/scaffold/dft-creams.yml +38 -0
  205. dataface/core/defaults/palettes/scaffold/dft-grays.yml +53 -0
  206. dataface/core/defaults/palettes/sequential/amber.yml +22 -0
  207. dataface/core/defaults/palettes/sequential/blue.yml +22 -0
  208. dataface/core/defaults/palettes/sequential/brown.yml +22 -0
  209. dataface/core/defaults/palettes/sequential/gray.yml +22 -0
  210. dataface/core/defaults/palettes/sequential/green.yml +22 -0
  211. dataface/core/defaults/palettes/sequential/purple.yml +22 -0
  212. dataface/core/defaults/palettes/sequential/rust.yml +22 -0
  213. dataface/core/defaults/palettes/sequential/teal.yml +22 -0
  214. dataface/core/defaults/palettes/tone/negative.yml +32 -0
  215. dataface/core/defaults/palettes/tone/positive.yml +22 -0
  216. dataface/core/defaults/palettes/tone/warning.yml +22 -0
  217. dataface/core/defaults/themes/_base.yaml +786 -0
  218. dataface/core/defaults/themes/bi.yaml +16 -0
  219. dataface/core/defaults/themes/carbong100.yaml +41 -0
  220. dataface/core/defaults/themes/cream.yaml +122 -0
  221. dataface/core/defaults/themes/dark.yaml +40 -0
  222. dataface/core/defaults/themes/diagnostics-title-angle-extreme.yaml +9 -0
  223. dataface/core/defaults/themes/diagnostics-title-baseline-extreme.yaml +9 -0
  224. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +24 -0
  225. dataface/core/defaults/themes/diagnostics-title-center.yaml +8 -0
  226. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +24 -0
  227. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +25 -0
  228. dataface/core/defaults/themes/diagnostics-title-left.yaml +8 -0
  229. dataface/core/defaults/themes/diagnostics-title-offset-extreme.yaml +9 -0
  230. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +24 -0
  231. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +24 -0
  232. dataface/core/defaults/themes/editorial.yaml +147 -0
  233. dataface/core/defaults/themes/light.yaml +30 -0
  234. dataface/core/defaults/themes/looker.yaml +17 -0
  235. dataface/core/defaults/themes/stark.yaml +134 -0
  236. dataface/core/errors/__init__.py +67 -0
  237. dataface/core/errors/codes_compile.py +56 -0
  238. dataface/core/errors/codes_execute.py +177 -0
  239. dataface/core/errors/codes_render.py +106 -0
  240. dataface/core/errors/codes_unknown.py +15 -0
  241. dataface/core/errors/hints.py +74 -0
  242. dataface/core/errors/registry.py +42 -0
  243. dataface/core/errors/structured.py +92 -0
  244. dataface/core/execute/__init__.py +91 -0
  245. dataface/core/execute/adapters/__init__.py +49 -0
  246. dataface/core/execute/adapters/adapter_registry.py +400 -0
  247. dataface/core/execute/adapters/base.py +245 -0
  248. dataface/core/execute/adapters/csv_adapter.py +239 -0
  249. dataface/core/execute/adapters/dbt_adapter.py +283 -0
  250. dataface/core/execute/adapters/dbt_adapter_factory.py +212 -0
  251. dataface/core/execute/adapters/dbt_macro_loader.py +95 -0
  252. dataface/core/execute/adapters/dbt_utils.py +150 -0
  253. dataface/core/execute/adapters/http_adapter.py +224 -0
  254. dataface/core/execute/adapters/metricflow_adapter.py +94 -0
  255. dataface/core/execute/adapters/schema_resolver_adapter.py +144 -0
  256. dataface/core/execute/adapters/sql_adapter.py +710 -0
  257. dataface/core/execute/adapters/values_adapter.py +58 -0
  258. dataface/core/execute/batch.py +744 -0
  259. dataface/core/execute/cache_backend.py +135 -0
  260. dataface/core/execute/cache_keys.py +66 -0
  261. dataface/core/execute/dbt_jinja.py +21 -0
  262. dataface/core/execute/dialects/__init__.py +121 -0
  263. dataface/core/execute/dialects/athena.py +75 -0
  264. dataface/core/execute/dialects/base.py +302 -0
  265. dataface/core/execute/dialects/bigquery.py +38 -0
  266. dataface/core/execute/dialects/databricks.py +68 -0
  267. dataface/core/execute/dialects/duckdb.py +35 -0
  268. dataface/core/execute/dialects/mysql.py +68 -0
  269. dataface/core/execute/dialects/postgres.py +39 -0
  270. dataface/core/execute/dialects/redshift.py +12 -0
  271. dataface/core/execute/dialects/snowflake.py +51 -0
  272. dataface/core/execute/dialects/sqlserver.py +92 -0
  273. dataface/core/execute/duckdb_cache.py +712 -0
  274. dataface/core/execute/duckdb_config.py +26 -0
  275. dataface/core/execute/errors.py +213 -0
  276. dataface/core/execute/executor.py +1249 -0
  277. dataface/core/execute/parallel.py +162 -0
  278. dataface/core/execute/setup_sql.py +58 -0
  279. dataface/core/execute/source_registry.py +72 -0
  280. dataface/core/execute/source_resolver.py +255 -0
  281. dataface/core/execute/sql_guard.py +387 -0
  282. dataface/core/execute/sql_literals.py +199 -0
  283. dataface/core/fonts.py +52 -0
  284. dataface/core/inspect/__init__.py +32 -0
  285. dataface/core/inspect/cache_factory.py +98 -0
  286. dataface/core/inspect/db_types.py +162 -0
  287. dataface/core/inspect/dbt_schema.py +96 -0
  288. dataface/core/inspect/defaults.yml +37 -0
  289. dataface/core/inspect/fanout_risk.py +109 -0
  290. dataface/core/inspect/manifest_utils.py +77 -0
  291. dataface/core/inspect/partials/categorical.yml +40 -0
  292. dataface/core/inspect/partials/date.yml +40 -0
  293. dataface/core/inspect/partials/numeric.yml +55 -0
  294. dataface/core/inspect/partition_types.py +38 -0
  295. dataface/core/inspect/query_validator.py +975 -0
  296. dataface/core/inspect/renderer.py +354 -0
  297. dataface/core/inspect/resolver.py +808 -0
  298. dataface/core/inspect/search.py +461 -0
  299. dataface/core/inspect/sources/__init__.py +32 -0
  300. dataface/core/inspect/sources/dbt.py +738 -0
  301. dataface/core/inspect/sources/duckdb_utils.py +66 -0
  302. dataface/core/inspect/templates/__init__.py +1 -0
  303. dataface/core/inspect/templates/categorical_column.yml +196 -0
  304. dataface/core/inspect/templates/charts.yml +109 -0
  305. dataface/core/inspect/templates/date_column.yml +248 -0
  306. dataface/core/inspect/templates/model.yml +138 -0
  307. dataface/core/inspect/templates/numeric_column.yml +261 -0
  308. dataface/core/inspect/templates/quality.yml +80 -0
  309. dataface/core/inspect/templates/string_column.yml +263 -0
  310. dataface/core/project_roots.py +165 -0
  311. dataface/core/render/__init__.py +87 -0
  312. dataface/core/render/board_links.py +176 -0
  313. dataface/core/render/chart/__init__.py +27 -0
  314. dataface/core/render/chart/arc_attached_table.py +251 -0
  315. dataface/core/render/chart/artifacts.py +16 -0
  316. dataface/core/render/chart/callout.py +225 -0
  317. dataface/core/render/chart/decisions.py +358 -0
  318. dataface/core/render/chart/geo.py +700 -0
  319. dataface/core/render/chart/kpi.py +916 -0
  320. dataface/core/render/chart/labels.py +76 -0
  321. dataface/core/render/chart/pipeline.py +818 -0
  322. dataface/core/render/chart/presentation.py +36 -0
  323. dataface/core/render/chart/profile.py +3438 -0
  324. dataface/core/render/chart/render_single.py +347 -0
  325. dataface/core/render/chart/renderers.py +193 -0
  326. dataface/core/render/chart/rendering.py +565 -0
  327. dataface/core/render/chart/serialization.py +90 -0
  328. dataface/core/render/chart/spark.py +496 -0
  329. dataface/core/render/chart/spark_bar.py +370 -0
  330. dataface/core/render/chart/spec_builders.py +154 -0
  331. dataface/core/render/chart/standard_renderer.py +2645 -0
  332. dataface/core/render/chart/table.py +2957 -0
  333. dataface/core/render/chart/table_support.py +1452 -0
  334. dataface/core/render/chart/tick_values.py +66 -0
  335. dataface/core/render/chart/time_unit_detect.py +809 -0
  336. dataface/core/render/chart/title_overflow.py +157 -0
  337. dataface/core/render/chart/type_inference.py +122 -0
  338. dataface/core/render/chart/validation.py +99 -0
  339. dataface/core/render/chart/vega_lite.py +125 -0
  340. dataface/core/render/chart/vega_lite_types.py +268 -0
  341. dataface/core/render/chart/vl_field_maps.py +346 -0
  342. dataface/core/render/chart_interactivity.py +24 -0
  343. dataface/core/render/control_registry.py +287 -0
  344. dataface/core/render/converters/__init__.py +24 -0
  345. dataface/core/render/converters/chart.py +276 -0
  346. dataface/core/render/converters/html.py +98 -0
  347. dataface/core/render/converters/pdf.py +40 -0
  348. dataface/core/render/converters/png.py +41 -0
  349. dataface/core/render/errors.py +144 -0
  350. dataface/core/render/face_api.py +160 -0
  351. dataface/core/render/faces.py +1194 -0
  352. dataface/core/render/font_measurement.py +48 -0
  353. dataface/core/render/font_support.py +197 -0
  354. dataface/core/render/fonts/DFTSansTabular-Regular.ttf +0 -0
  355. dataface/core/render/fonts/DFTSansTabular-Regular.woff2 +0 -0
  356. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.ttf +0 -0
  357. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.ttf +0 -0
  358. dataface/core/render/fonts/InterVariable.ttf +0 -0
  359. dataface/core/render/fonts/InterVariable.woff2 +0 -0
  360. dataface/core/render/fonts/NOTO_COLOR_EMOJI_LICENSE.txt +93 -0
  361. dataface/core/render/fonts/NOTO_EMOJI_LICENSE.txt +93 -0
  362. dataface/core/render/fonts/NotoColorEmoji-Regular.ttf +0 -0
  363. dataface/core/render/fonts/NotoColorEmoji-Regular.woff2 +0 -0
  364. dataface/core/render/fonts/NotoEmoji-Regular.ttf +0 -0
  365. dataface/core/render/fonts/NotoEmoji-Regular.woff2 +0 -0
  366. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -0
  367. dataface/core/render/fonts/SOURCE_SERIF_4_LICENSE.txt +98 -0
  368. dataface/core/render/fonts/SourceCodePro-Regular.ttf +0 -0
  369. dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
  370. dataface/core/render/fonts/_emoji_font_face.css +43 -0
  371. dataface/core/render/fonts/source-serif-4-variable-latin.woff2 +0 -0
  372. dataface/core/render/format_utils.py +329 -0
  373. dataface/core/render/geo_defaults.yml +28 -0
  374. dataface/core/render/json_format.py +146 -0
  375. dataface/core/render/layout_sizing.py +865 -0
  376. dataface/core/render/layouts.py +541 -0
  377. dataface/core/render/markdown_defaults.yml +16 -0
  378. dataface/core/render/missing_vars_prompt.py +79 -0
  379. dataface/core/render/placeholder.py +389 -0
  380. dataface/core/render/render_result.py +14 -0
  381. dataface/core/render/renderer.py +467 -0
  382. dataface/core/render/script_embedding.py +16 -0
  383. dataface/core/render/svg_utils.py +212 -0
  384. dataface/core/render/template_loader.py +69 -0
  385. dataface/core/render/templates/controls/_styles.css +606 -0
  386. dataface/core/render/templates/controls/checkbox.html +16 -0
  387. dataface/core/render/templates/controls/date.html +16 -0
  388. dataface/core/render/templates/controls/number.html +19 -0
  389. dataface/core/render/templates/controls/readonly.html +9 -0
  390. dataface/core/render/templates/controls/select.html +21 -0
  391. dataface/core/render/templates/controls/slider.html +22 -0
  392. dataface/core/render/templates/controls/text.html +16 -0
  393. dataface/core/render/templates/scripts/chart_interactivity.js +191 -0
  394. dataface/core/render/templates/scripts/variables.js +976 -0
  395. dataface/core/render/templates/svg/grid_pattern.svg +3 -0
  396. dataface/core/render/templates/svg/styles.css +51 -0
  397. dataface/core/render/terminal.py +311 -0
  398. dataface/core/render/terminal_charts.py +563 -0
  399. dataface/core/render/terminal_defaults.yml +2 -0
  400. dataface/core/render/terminal_layouts.py +299 -0
  401. dataface/core/render/terminal_text.py +31 -0
  402. dataface/core/render/text/__init__.py +1 -0
  403. dataface/core/render/text/case.py +113 -0
  404. dataface/core/render/text_format.py +129 -0
  405. dataface/core/render/utils.py +106 -0
  406. dataface/core/render/variable_controls.py +946 -0
  407. dataface/core/render/variable_input_refinement.py +140 -0
  408. dataface/core/render/warnings/__init__.py +15 -0
  409. dataface/core/render/warnings/bar_color_1_to_1_with_x.py +80 -0
  410. dataface/core/render/warnings/base.py +44 -0
  411. dataface/core/render/warnings/fanout_risk.py +15 -0
  412. dataface/core/render/warnings/from_query_diagnostic.py +56 -0
  413. dataface/core/render/warnings/missing_join_predicate.py +13 -0
  414. dataface/core/render/warnings/query_parse_error.py +14 -0
  415. dataface/core/render/warnings/query_returned_zero_rows.py +42 -0
  416. dataface/core/render/warnings/reaggregation.py +14 -0
  417. dataface/core/render/warnings/registry.py +45 -0
  418. dataface/core/render/warnings/suppression.py +46 -0
  419. dataface/core/render/warnings/temporal_single_point.py +63 -0
  420. dataface/core/render/warnings/unreferenced_chart.py +15 -0
  421. dataface/core/render/warnings/y_encoding_mostly_null.py +76 -0
  422. dataface/core/render/yaml_format.py +167 -0
  423. dataface/core/resolve_face.py +195 -0
  424. dataface/core/schema/__init__.py +0 -0
  425. dataface/core/schema/guidance.py +151 -0
  426. dataface/core/scoped_paths.py +59 -0
  427. dataface/core/serve/__init__.py +14 -0
  428. dataface/core/serve/bootstrap.py +39 -0
  429. dataface/core/serve/embedded.py +57 -0
  430. dataface/core/serve/port.py +129 -0
  431. dataface/core/serve/server.py +938 -0
  432. dataface/core/serve/templates/__init__.py +0 -0
  433. dataface/core/serve/templates/directory.yml +6 -0
  434. dataface/core/serve/templates/error.html.j2 +217 -0
  435. dataface/core/utils.py +121 -0
  436. dataface/core/validate.py +64 -0
  437. dataface/integrations/__init__.py +0 -0
  438. dataface/integrations/highlighting.py +351 -0
  439. dataface/integrations/markdown.py +537 -0
  440. dataface/py.typed +0 -0
  441. dataface-0.1.2.dist-info/METADATA +375 -0
  442. dataface-0.1.2.dist-info/RECORD +455 -0
  443. dataface-0.1.2.dist-info/WHEEL +4 -0
  444. dataface-0.1.2.dist-info/entry_points.txt +2 -0
  445. dataface-0.1.2.dist-info/licenses/LICENSE +202 -0
  446. mdsvg/__init__.py +168 -0
  447. mdsvg/fonts.py +656 -0
  448. mdsvg/images.py +299 -0
  449. mdsvg/parser.py +629 -0
  450. mdsvg/playground.py +284 -0
  451. mdsvg/py.typed +2 -0
  452. mdsvg/renderer.py +1623 -0
  453. mdsvg/style.py +355 -0
  454. mdsvg/types.py +200 -0
  455. mdsvg/utils.py +86 -0
@@ -0,0 +1,2137 @@
1
+ """Authored chart models: per-family discriminated union for AuthoredChart.
2
+
3
+ Stage: COMPILE (Input)
4
+ Purpose: Define all chart-specific authored types that map directly to the YAML schema.
5
+ Families: BarChart, LineChart, AreaChart, ScatterChart, HeatmapChart, PieChart, KpiChart,
6
+ TableChart, PointMapChart, GeoshapeChart, LayeredChart, CalloutChart, SparkBarChart.
7
+ AuthoredChart is a type alias over a discriminated union. type: is mandatory — missing
8
+ or unknown type raises a ValidationError at the authored-model level.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from enum import Enum
15
+ from typing import Annotated, Any, Literal
16
+
17
+ from pydantic import (
18
+ BaseModel,
19
+ ConfigDict,
20
+ Discriminator,
21
+ Field,
22
+ StrictBool,
23
+ Tag,
24
+ field_validator,
25
+ model_validator,
26
+ )
27
+
28
+ from dataface.core.compile.models.primitives import (
29
+ FontStyle,
30
+ FormatConfig,
31
+ ScaleTargetConfig,
32
+ )
33
+ from dataface.core.compile.models.style.authored import (
34
+ AreaChartStylePatch,
35
+ BarChartStylePatch,
36
+ CalloutChartStylePatch,
37
+ GeoshapeChartStylePatch,
38
+ HeatmapChartStylePatch,
39
+ KpiChartStylePatch,
40
+ LayeredChartStyle,
41
+ LineChartStylePatch,
42
+ PieChartStylePatch,
43
+ PointMapChartStylePatch,
44
+ ScatterChartStylePatch,
45
+ SparkBarChartStylePatch,
46
+ )
47
+ from dataface.core.compile.models.style.compiled import (
48
+ VALID_FONT_WEIGHTS,
49
+ TableChartStylePatch,
50
+ _normalize_overflow_value,
51
+ font_weight_as_css,
52
+ )
53
+ from dataface.core.compile.models.variable.authored import SingleRowBoolProbe
54
+ from dataface.core.compile.models.vega_lite.contracts import (
55
+ Projection,
56
+ )
57
+ from dataface.core.compile.vega_lite.validation import (
58
+ validate_projection_definition,
59
+ )
60
+
61
+ # ============================================================================
62
+ # KPI: SEMANTIC TONE + SUPPORT BLOCK
63
+ # ============================================================================
64
+
65
+ ToneLiteral = Literal["positive", "negative", "warning"]
66
+ """Semantic styling hint shared by KPI value/glyph and (future) table emphasis.
67
+
68
+ v1: three tones only — positive, negative, warning.
69
+ neutral removed; KPIs without tone use default chrome colors.
70
+ """
71
+
72
+
73
+ class KpiSupportConfig(BaseModel):
74
+ """Support-line block authored alongside a KPI's main value."""
75
+
76
+ model_config = ConfigDict(extra="forbid")
77
+
78
+ value: str | None = Field(
79
+ default=None,
80
+ description="Column reference (string column name) for the support number/text.",
81
+ )
82
+ label: str | None = Field(
83
+ default=None,
84
+ description="Trailing explainer text rendered beside the support value.",
85
+ )
86
+ format: str | FormatConfig | None = Field(
87
+ default=None,
88
+ description="Number format (D3 spec, preset, or FormatConfig).",
89
+ )
90
+ glyph: str | None = Field(
91
+ default=None,
92
+ description="Optional prefix glyph (e.g. '▲', '▼', '●').",
93
+ )
94
+ tone: ToneLiteral | None = Field(
95
+ default=None,
96
+ description="Semantic styling for the support value/glyph.",
97
+ )
98
+
99
+ @model_validator(mode="before")
100
+ @classmethod
101
+ def _reject_removed_color_shortcuts(cls, data: Any) -> Any:
102
+ if isinstance(data, dict):
103
+ if "value_color" in data:
104
+ raise ValueError(
105
+ "value_color no longer accepted on support. "
106
+ "Use style.kpi.value.font.color instead."
107
+ )
108
+ if "glyph_color" in data:
109
+ raise ValueError(
110
+ "glyph_color no longer accepted on support. "
111
+ "Use style.kpi.glyph.font.color instead."
112
+ )
113
+ return data
114
+
115
+ @model_validator(mode="before")
116
+ @classmethod
117
+ def reject_literal_support_value(cls, data: Any) -> Any:
118
+ if isinstance(data, dict) and not isinstance(data.get("value"), bool):
119
+ v = data.get("value")
120
+ if isinstance(v, (int, float)):
121
+ raise ValueError(
122
+ f"support.value must be a column reference (string column name).\n"
123
+ f"Got numeric literal: {v}.\n"
124
+ "Channels are always column references; data values come from the query.\n"
125
+ "Update to:\n"
126
+ f' query: {{ sql: "select revenue, {v} as delta_pct from revenue_q" }}\n'
127
+ " value: revenue\n"
128
+ " support:\n"
129
+ " value: delta_pct"
130
+ )
131
+ return data
132
+
133
+ @model_validator(mode="after")
134
+ def _require_non_empty(self) -> KpiSupportConfig:
135
+ if self.value is None and self.label is None and self.glyph is None:
136
+ raise ValueError(
137
+ "Empty `support:` block. Set at least one of `value`, "
138
+ "`label`, or `glyph`, or omit the block entirely."
139
+ )
140
+ return self
141
+
142
+
143
+ # ============================================================================
144
+ # ENUMS & LITERALS
145
+ # ============================================================================
146
+
147
+
148
+ class ChartType(str, Enum):
149
+ """Supported chart types."""
150
+
151
+ # Basic Vega-Lite marks
152
+ BAR = "bar"
153
+ LINE = "line"
154
+ AREA = "area"
155
+ CIRCLE = "circle"
156
+ SQUARE = "square"
157
+ TICK = "tick"
158
+ RULE = "rule"
159
+ TRAIL = "trail"
160
+ RECT = "rect"
161
+ ARC = "arc"
162
+
163
+ # Composite marks
164
+ BOXPLOT = "boxplot"
165
+ ERRORBAR = "errorbar"
166
+ ERRORBAND = "errorband"
167
+
168
+ # Special marks
169
+ GEOSHAPE = "geoshape"
170
+ IMAGE = "image"
171
+
172
+ # Dataface-specific
173
+ TABLE = "table"
174
+ KPI = "kpi"
175
+ CALLOUT = "callout"
176
+
177
+ # Aliases (map to underlying marks)
178
+ SCATTER = "scatter" # VL mark: point
179
+ HEATMAP = "heatmap" # -> rect
180
+ PIE = "pie" # -> arc
181
+ DONUT = "donut" # -> pie with style.inner_radius=0.6 (normalized alias)
182
+ HISTOGRAM = "histogram" # -> bar + binning
183
+ MAP = "map" # -> geoshape (generic map)
184
+ POINT_MAP = "point_map" # -> circle marks with lat/lng
185
+ BUBBLE_MAP = "bubble_map" # -> circle marks with size encoding
186
+
187
+ # Spark charts (compact inline charts)
188
+ SPARK_BAR = "spark_bar" # -> compact horizontal bars for profiler cards
189
+
190
+ # Composition
191
+ LAYERED = "layered" # explicit multi-mark layered chart
192
+
193
+ # Internal/auto-detection (not shown in UI dropdowns)
194
+ AUTO = "auto" # auto-detect chart type from data
195
+
196
+
197
+ # Display metadata for UI dropdowns
198
+ CHART_TYPE_DISPLAY: dict[ChartType, dict[str, str]] = {
199
+ # Basic Vega-Lite marks
200
+ ChartType.LINE: {"label": "Line", "icon": "📈"},
201
+ ChartType.BAR: {"label": "Bar", "icon": "📊"},
202
+ ChartType.AREA: {"label": "Area", "icon": "📉"},
203
+ ChartType.CIRCLE: {"label": "Circle", "icon": "⭕"},
204
+ ChartType.SQUARE: {"label": "Square", "icon": "⬜"},
205
+ ChartType.TICK: {"label": "Tick", "icon": "➖"},
206
+ ChartType.RULE: {"label": "Rule", "icon": "📏"},
207
+ ChartType.TRAIL: {"label": "Trail", "icon": "〰️"},
208
+ ChartType.RECT: {"label": "Rect", "icon": "⬛"},
209
+ ChartType.ARC: {"label": "Arc", "icon": "🌙"},
210
+ # Composite marks
211
+ ChartType.BOXPLOT: {"label": "Boxplot", "icon": "📦"},
212
+ ChartType.ERRORBAR: {"label": "Error Bar", "icon": "📊"},
213
+ ChartType.ERRORBAND: {"label": "Error Band", "icon": "📊"},
214
+ # Special marks
215
+ ChartType.GEOSHAPE: {"label": "Geoshape", "icon": "🗺️"},
216
+ ChartType.IMAGE: {"label": "Image", "icon": "🖼️"},
217
+ # Dataface-specific
218
+ ChartType.TABLE: {"label": "Table", "icon": "📋"},
219
+ ChartType.KPI: {"label": "KPI", "icon": "#️⃣"},
220
+ ChartType.CALLOUT: {"label": "Callout", "icon": "🚨"},
221
+ # Aliases
222
+ ChartType.SCATTER: {"label": "Scatter", "icon": "⬡"},
223
+ ChartType.HEATMAP: {"label": "Heatmap", "icon": "🟧"},
224
+ ChartType.PIE: {"label": "Pie", "icon": "🥧"},
225
+ ChartType.DONUT: {"label": "Donut", "icon": "🍩"},
226
+ ChartType.HISTOGRAM: {"label": "Histogram", "icon": "📊"},
227
+ # Maps
228
+ ChartType.MAP: {"label": "Map", "icon": "🗺️"},
229
+ ChartType.POINT_MAP: {"label": "Point Map", "icon": "📍"},
230
+ ChartType.BUBBLE_MAP: {"label": "Bubble Map", "icon": "🫧"},
231
+ # Spark charts
232
+ ChartType.SPARK_BAR: {"label": "Spark Bar", "icon": "📊"},
233
+ # Composition
234
+ ChartType.LAYERED: {"label": "Layered", "icon": "📊"},
235
+ # Internal
236
+ ChartType.AUTO: {"label": "Auto", "icon": "🔮"},
237
+ }
238
+
239
+ # Internal chart types that should not be shown in UI dropdowns by default
240
+ _INTERNAL_CHART_TYPES: set[ChartType] = {ChartType.AUTO, ChartType.DONUT}
241
+
242
+
243
+ def get_chart_type_options(include_internal: bool = False) -> list[dict]:
244
+ """Get chart types for UI dropdowns. Single source of truth.
245
+
246
+ Args:
247
+ include_internal: Whether to include internal types like AUTO
248
+
249
+ Returns:
250
+ List of dicts with name, label, icon for each chart type
251
+ """
252
+ return [
253
+ {
254
+ "name": ct.value,
255
+ **CHART_TYPE_DISPLAY.get(
256
+ ct, {"label": ct.value.replace("_", " ").title(), "icon": "📊"}
257
+ ),
258
+ }
259
+ for ct in ChartType
260
+ if include_internal or ct not in _INTERNAL_CHART_TYPES
261
+ ]
262
+
263
+
264
+ # ============================================================================
265
+ # SPARK CHARTS (SPARKLINES)
266
+ # ============================================================================
267
+
268
+ SparkTypeLiteral = Literal[
269
+ "line",
270
+ "area",
271
+ "bar",
272
+ "bar-normalize",
273
+ "columns",
274
+ ]
275
+
276
+ SPARK_SUPPORTED_TYPES: frozenset[str] = frozenset(
277
+ {"line", "area", "bar", "bar-normalize", "columns"}
278
+ )
279
+
280
+ # Renamed-away variant names → guidance for authors hitting the old vocab.
281
+ # Pre-launch repo — no aliases, no silent fallback. The validator below
282
+ # surfaces the rename as a clear ValueError instead of a generic Literal
283
+ # mismatch.
284
+ _SPARK_RENAMED_AWAY: dict[str, str] = {
285
+ "progress": (
286
+ "spark.type 'progress' renamed to 'bar' (no max, no track) or "
287
+ "'bar-normalize' (with max, with background track)"
288
+ ),
289
+ "bars": "spark.type 'bars' renamed to 'columns' (multi-value vertical bars)",
290
+ "histogram": (
291
+ "spark.type 'histogram' renamed to 'columns' — pre-bin in SQL "
292
+ "(width_bucket / histogram_continuous) and render as columns"
293
+ ),
294
+ }
295
+
296
+
297
+ class SparkConfig(BaseModel):
298
+ """Configuration for spark charts (inline sparklines) in table columns."""
299
+
300
+ model_config = ConfigDict(extra="forbid")
301
+
302
+ type: SparkTypeLiteral = Field(
303
+ default="line",
304
+ description="Spark chart type (line, area, bar, bar-normalize, columns).",
305
+ )
306
+ color: str | None = Field(default=None, description="Color for the spark mark.")
307
+ height: int | None = Field(
308
+ default=None, description="Spark chart height in pixels."
309
+ )
310
+ width: int | None = Field(default=None, description="Spark chart width in pixels.")
311
+
312
+ # Line/area specific
313
+ last_visible: bool | None = Field(
314
+ default=None,
315
+ description="Highlight the last data point (line/area spark charts).",
316
+ )
317
+ min_max_visible: bool | None = Field(
318
+ default=None,
319
+ description="Annotate the min and max data points (line/area spark charts).",
320
+ )
321
+ fill_opacity: float | None = Field(
322
+ default=None, description="Fill opacity for area spark charts (0–1)."
323
+ )
324
+
325
+ # Bar / bar-normalize specific
326
+ max: float | None = Field(
327
+ default=None, description="Maximum value for bar-normalize range scaling."
328
+ )
329
+ thresholds: dict[int | float, str] | None = Field(
330
+ default=None,
331
+ description="Color thresholds for bar / bar-normalize: {value: CSS color string}.",
332
+ )
333
+ background: str | None = Field(
334
+ default=None, description="Background track color for bar-normalize chart."
335
+ )
336
+ border_radius: float | None = Field(
337
+ default=None, description="Border radius for bar-normalize track in pixels."
338
+ )
339
+ value_visible: bool | None = Field(
340
+ default=None, description="Show numeric value label alongside the bar."
341
+ )
342
+ value_suffix: str | None = Field(
343
+ default=None,
344
+ description="Suffix appended to the displayed value label (e.g., '%').",
345
+ )
346
+
347
+ @field_validator("type", mode="before")
348
+ @classmethod
349
+ def _reject_renamed_away_type(cls, value: Any) -> Any:
350
+ if isinstance(value, str) and value in _SPARK_RENAMED_AWAY:
351
+ raise ValueError(_SPARK_RENAMED_AWAY[value])
352
+ return value
353
+
354
+
355
+ # ============================================================================
356
+ # CONDITIONAL FORMATTING
357
+ # ============================================================================
358
+
359
+ _PREDICATE_OPS: tuple[str, ...] = (
360
+ "eq",
361
+ "ne",
362
+ "lt",
363
+ "lte",
364
+ "gt",
365
+ "gte",
366
+ "between",
367
+ "in_",
368
+ "is_null",
369
+ "default",
370
+ )
371
+
372
+
373
+ def _validate_exactly_one_predicate(obj: Any) -> None:
374
+ """Raise ValueError if obj does not have exactly one condition operator set."""
375
+ ops = [f for f in _PREDICATE_OPS if getattr(obj, f) is not None]
376
+ if len(ops) == 0:
377
+ raise ValueError(
378
+ f"{type(obj).__name__} requires exactly one condition operator"
379
+ )
380
+ if len(ops) > 1:
381
+ raise ValueError(
382
+ f"{type(obj).__name__} requires exactly one condition operator, got: {ops}"
383
+ )
384
+
385
+
386
+ class _PredicateBase(BaseModel):
387
+ """Shared predicate fields and helpers for conditional rules."""
388
+
389
+ # extra="forbid": rejects unknown fields like all authored models.
390
+ # populate_by_name=True: 'in' is a Python keyword, so the field is `in_`
391
+ # in Python; YAML authors write `in:` and the alias bridges the two.
392
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
393
+
394
+ eq: Any = Field(
395
+ default=None, description="Match rows where the column value equals this value."
396
+ )
397
+ ne: Any = Field(
398
+ default=None,
399
+ description="Match rows where the column value does not equal this value.",
400
+ )
401
+ lt: int | float | None = Field(
402
+ default=None,
403
+ description="Match rows where the column value is less than this number.",
404
+ )
405
+ lte: int | float | None = Field(
406
+ default=None,
407
+ description="Match rows where the column value is less than or equal to this number.",
408
+ )
409
+ gt: int | float | None = Field(
410
+ default=None,
411
+ description="Match rows where the column value is greater than this number.",
412
+ )
413
+ gte: int | float | None = Field(
414
+ default=None,
415
+ description="Match rows where the column value is greater than or equal to this number.",
416
+ )
417
+ between: list[int | float] | None = Field(
418
+ default=None,
419
+ description="Match rows where the column value falls in [low, high] (inclusive).",
420
+ )
421
+ in_: list[Any] | None = Field(
422
+ default=None,
423
+ alias="in",
424
+ description="Match rows where the column value is in this list.",
425
+ )
426
+ is_null: StrictBool | None = Field(
427
+ default=None, description="Match null rows (true) or non-null rows (false)."
428
+ )
429
+ default: Literal[True] | None = Field(
430
+ default=None,
431
+ description="Catch-all rule that matches any row not matched by earlier rules. Must be the last entry.",
432
+ )
433
+
434
+ @field_validator("between")
435
+ @classmethod
436
+ def _validate_between(cls, v: list[int | float] | None) -> list[int | float] | None:
437
+ if v is None:
438
+ return v
439
+ if len(v) != 2:
440
+ raise ValueError(
441
+ f"between expects exactly 2 values [low, high], got {len(v)}"
442
+ )
443
+ low, high = v
444
+ if low > high:
445
+ raise ValueError(f"between requires low <= high, got [{low}, {high}]")
446
+ return v
447
+
448
+ @field_validator("in_")
449
+ @classmethod
450
+ def _validate_in(cls, v: list[Any] | None) -> list[Any] | None:
451
+ if v is None:
452
+ return v
453
+ if len(v) == 0:
454
+ raise ValueError("in must be a non-empty list")
455
+ return v
456
+
457
+ def active_predicate(self) -> tuple[str, Any]:
458
+ """Return the (op, value) pair for the single active predicate."""
459
+ for op in _PREDICATE_OPS:
460
+ val = getattr(self, op)
461
+ if val is not None:
462
+ return op, val
463
+ raise AssertionError("No active predicate — should be caught by validator")
464
+
465
+
466
+ def coerce_numeric(v: Any) -> float | None:
467
+ """Return float(v) for finite numeric-compatible values; None otherwise."""
468
+ if v is None or isinstance(v, bool):
469
+ return None
470
+ if isinstance(v, (int, float)):
471
+ result = float(v)
472
+ return result if math.isfinite(result) else None
473
+ if isinstance(v, str):
474
+ try:
475
+ result = float(v)
476
+ return result if math.isfinite(result) else None
477
+ except (ValueError, TypeError):
478
+ return None
479
+ return None
480
+
481
+
482
+ def _in_value_matches(candidate: Any, value: Any) -> bool:
483
+ """Equality check for a single ``in`` candidate, with the bool/int guard."""
484
+ if isinstance(value, bool) != isinstance(candidate, bool):
485
+ return False
486
+ return value == candidate
487
+
488
+
489
+ def match_predicate(rule: _PredicateBase, value: Any) -> bool:
490
+ """Return True if the rule's single predicate matches value."""
491
+ if rule.default is True:
492
+ return True
493
+ if rule.is_null is not None:
494
+ return (value is None) == rule.is_null
495
+ if rule.eq is not None:
496
+ if isinstance(value, bool) != isinstance(rule.eq, bool):
497
+ return False
498
+ return value == rule.eq
499
+ if rule.ne is not None:
500
+ if isinstance(value, bool) != isinstance(rule.ne, bool):
501
+ return True
502
+ return value != rule.ne
503
+ if rule.in_ is not None:
504
+ return any(_in_value_matches(candidate, value) for candidate in rule.in_)
505
+ v_coerced = coerce_numeric(value)
506
+ if v_coerced is None:
507
+ return False
508
+ v = v_coerced
509
+ if rule.lt is not None:
510
+ return v < float(rule.lt)
511
+ if rule.lte is not None:
512
+ return v <= float(rule.lte)
513
+ if rule.gt is not None:
514
+ return v > float(rule.gt)
515
+ if rule.gte is not None:
516
+ return v >= float(rule.gte)
517
+ if rule.between is not None:
518
+ low, high = rule.between
519
+ return float(low) <= v <= float(high)
520
+ return False
521
+
522
+
523
+ def _validate_glyph_pair(
524
+ glyph: str | None, glyph_color: str | None, label: str
525
+ ) -> None:
526
+ """Shared check for the (glyph, glyph_color) authoring contract."""
527
+ if glyph is not None and not glyph.strip():
528
+ raise ValueError(f"{label} glyph must be a non-empty string")
529
+ if glyph_color is not None and glyph is None:
530
+ raise ValueError(f"{label} glyph_color requires glyph to be set")
531
+
532
+
533
+ class ConditionalRule(_PredicateBase):
534
+ """A single conditional formatting rule."""
535
+
536
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
537
+
538
+ # Style overrides — at least one must be set
539
+ background: str | None = Field(
540
+ default=None, description="Cell background color applied when the rule matches."
541
+ )
542
+ font: FontStyle | None = Field(
543
+ default=None,
544
+ description="Font style overrides (color, weight, style, decoration) applied when the rule matches.",
545
+ )
546
+ glyph: str | None = Field(
547
+ default=None,
548
+ description="Glyph character prepended to the cell value when the rule matches.",
549
+ )
550
+ glyph_color: str | None = Field(
551
+ default=None,
552
+ description="Color for the glyph when the rule matches. Requires glyph to be set.",
553
+ )
554
+
555
+ @model_validator(mode="after")
556
+ def _validate_rule(self) -> ConditionalRule:
557
+ _validate_exactly_one_predicate(self)
558
+ _font_effective = self.font is not None and (
559
+ self.font.color is not None
560
+ or self.font.weight is not None
561
+ or self.font.style is not None
562
+ or self.font.decoration is not None
563
+ )
564
+ _validate_glyph_pair(self.glyph, self.glyph_color, label="ConditionalRule")
565
+ if self.background is None and not _font_effective and self.glyph is None:
566
+ raise ValueError(
567
+ "ConditionalRule requires at least one style override "
568
+ "(background, font.color, font.weight, font.style, "
569
+ "font.decoration, or glyph)"
570
+ )
571
+ if self.font is not None and self.font.weight is not None:
572
+ if font_weight_as_css(self.font.weight) not in VALID_FONT_WEIGHTS:
573
+ raise ValueError(
574
+ f"ConditionalRule font.weight {self.font.weight!r} is not a valid "
575
+ "CSS font-weight; use a numeric string (e.g. '700') or 'normal'/'bold'."
576
+ )
577
+ return self
578
+
579
+
580
+ class FieldConditionalFormatting(BaseModel):
581
+ """Conditional formatting rules scoped to a single column."""
582
+
583
+ model_config = ConfigDict(extra="forbid")
584
+
585
+ when: list[ConditionalRule] = Field(
586
+ description="Ordered list of conditional rules. The first matching rule applies; a 'default: true' rule must be last."
587
+ )
588
+
589
+ @model_validator(mode="after")
590
+ def _validate_default_position(self) -> FieldConditionalFormatting:
591
+ for i, rule in enumerate(self.when):
592
+ if rule.default is True and i != len(self.when) - 1:
593
+ raise ValueError(
594
+ "ConditionalRule with default: true must be the last entry "
595
+ f"in a when list (found at position {i} of {len(self.when)})"
596
+ )
597
+ return self
598
+
599
+
600
+ # ============================================================================
601
+ # CHART.DATA_TABLE — attached mini-table primitive
602
+ # ============================================================================
603
+
604
+ ChartDataTableAggregateOp = Literal[
605
+ "sum", "avg", "min", "max", "median", "count", "count_distinct"
606
+ ]
607
+
608
+
609
+ class ChartDataTableSource(BaseModel):
610
+ """A data_table row that reads a column's raw per-x value."""
611
+
612
+ model_config = ConfigDict(extra="forbid")
613
+
614
+ source: str = Field(
615
+ description="Query column the row reads from (per-x raw value)."
616
+ )
617
+ format: str | None = Field(
618
+ default=None,
619
+ description="D3-style format string (Vega-Lite format parity). Optional.",
620
+ )
621
+ label: str | None = Field(
622
+ default=None,
623
+ description="Left-stub row label. Optional.",
624
+ )
625
+
626
+
627
+ class ChartDataTableAggregate(BaseModel):
628
+ """A data_table row that reads an aggregate of a column grouped by x."""
629
+
630
+ model_config = ConfigDict(extra="forbid")
631
+
632
+ aggregate: ChartDataTableAggregateOp = Field(
633
+ description=(
634
+ "Aggregate operation applied per x-group. One of: "
635
+ "sum, avg, min, max, median, count, count_distinct. "
636
+ "Exact names only — no aliases (spec G4)."
637
+ ),
638
+ )
639
+ source: str = Field(
640
+ description=(
641
+ "Query column being aggregated. Always required alongside "
642
+ "`aggregate:` (spec G2)."
643
+ ),
644
+ )
645
+ format: str | None = Field(
646
+ default=None, description="D3-style format string for the aggregated value."
647
+ )
648
+ label: str | None = Field(
649
+ default=None, description="Column header label override for this row."
650
+ )
651
+
652
+
653
+ class ChartDataTablePerSeries(BaseModel):
654
+ """A data_table entry that expands into one row per color: series.
655
+
656
+ When ``by_measure=True`` the entry expands into a single row reading the
657
+ named y-field directly, without a color: channel. This is the expansion
658
+ mode for multi-y charts (``y: [revenue, margin]``) where each measure is
659
+ its own y-axis series rather than a color-encoded pivot.
660
+ """
661
+
662
+ model_config = ConfigDict(extra="forbid")
663
+
664
+ per_series: str = Field(
665
+ description="Query column the row reads from (per-x, per-series value)."
666
+ )
667
+ by_measure: bool = Field(
668
+ default=False,
669
+ description=(
670
+ "When True, expand one row reading the named measure field directly "
671
+ "(no color: groupby). Required for multi-y charts where each measure "
672
+ "is its own y-field rather than a color-encoded series."
673
+ ),
674
+ )
675
+ label: str | None = Field(
676
+ default=None,
677
+ description=(
678
+ "Row label displayed in the strip's label gutter. "
679
+ "When None and by_measure=True, the per_series field name is used. "
680
+ "Has no effect when by_measure=False (series name is the label)."
681
+ ),
682
+ )
683
+ format: str | None = Field(
684
+ default=None,
685
+ description="D3-style format string (Vega-Lite format parity). Optional.",
686
+ )
687
+
688
+
689
+ def _discriminate_entry(entry: Any) -> str | None:
690
+ """Tag-selector for the ChartDataTableEntry tagged union."""
691
+ if isinstance(entry, ChartDataTableAggregate):
692
+ return "aggregate"
693
+ if isinstance(entry, ChartDataTablePerSeries):
694
+ return "per_series"
695
+ if isinstance(entry, ChartDataTableSource):
696
+ return "source"
697
+ if isinstance(entry, dict):
698
+ if "aggregate" in entry:
699
+ return "aggregate"
700
+ if "per_series" in entry:
701
+ return "per_series"
702
+ return "source"
703
+ return None
704
+
705
+
706
+ ChartDataTableEntry = Annotated[
707
+ Annotated[ChartDataTableSource, Tag("source")]
708
+ | Annotated[ChartDataTableAggregate, Tag("aggregate")]
709
+ | Annotated[ChartDataTablePerSeries, Tag("per_series")],
710
+ Discriminator(_discriminate_entry),
711
+ ]
712
+
713
+ CHART_DATA_TABLE_MAX_X_TICKS = 40
714
+
715
+
716
+ class ChartDataTable(BaseModel):
717
+ """Container for a chart's data_table block."""
718
+
719
+ model_config = ConfigDict(extra="forbid")
720
+
721
+ entries: list[ChartDataTableEntry] = Field(
722
+ min_length=1,
723
+ description="List of data-table entries (source, aggregate, or per-series rows).",
724
+ )
725
+
726
+ @model_validator(mode="before")
727
+ @classmethod
728
+ def _accept_bare_list(cls, data: Any) -> Any:
729
+ if isinstance(data, list):
730
+ return {"entries": data}
731
+ return data
732
+
733
+
734
+ CHART_DATA_TABLE_SUPPORTED_TYPES: frozenset[str] = frozenset(
735
+ {"bar", "line", "area", "layered"}
736
+ )
737
+
738
+
739
+ def validate_data_table_shape(entries: list[ChartDataTableEntry]) -> None:
740
+ """Data-free validation of a data_table entry list (spec §3.2)."""
741
+ seen: set[tuple[str, str | None, bool]] = set()
742
+ for entry in entries:
743
+ if isinstance(entry, ChartDataTablePerSeries):
744
+ key: tuple[str, str | None, bool] = (entry.per_series, None, True)
745
+ if key in seen:
746
+ raise ValueError(
747
+ f"chart.data_table has duplicate per_series entries for "
748
+ f"{{per_series: {entry.per_series!r}}}. "
749
+ "Remove the duplicate."
750
+ )
751
+ seen.add(key)
752
+ continue
753
+ agg = entry.aggregate if isinstance(entry, ChartDataTableAggregate) else None
754
+ source = entry.source
755
+ normal_key: tuple[str, str | None, bool] = (source, agg, False)
756
+ if normal_key in seen:
757
+ label = (
758
+ f"{{aggregate: {agg}, source: {source}}}"
759
+ if agg is not None
760
+ else f"{{source: {source}}}"
761
+ )
762
+ raise ValueError(
763
+ f"chart.data_table has duplicate entries for {label}. "
764
+ "Remove the duplicate or differentiate by aggregate operation."
765
+ )
766
+ seen.add(normal_key)
767
+
768
+
769
+ # ============================================================================
770
+ # TABLE COLUMN CONFIG
771
+ # ============================================================================
772
+
773
+
774
+ class ColumnScaleConfig(BaseModel):
775
+ """Scale-based continuous color mapping for a table column."""
776
+
777
+ model_config = ConfigDict(extra="forbid")
778
+
779
+ background: ScaleTargetConfig | None = Field(
780
+ default=None, description="Continuous background color mapping for this column."
781
+ )
782
+ color: ScaleTargetConfig | None = Field(
783
+ default=None, description="Continuous text color mapping for this column."
784
+ )
785
+
786
+ @model_validator(mode="after")
787
+ def _validate_color_palettes(self) -> ColumnScaleConfig:
788
+ for key, target in (("background", self.background), ("color", self.color)):
789
+ if target is not None and not all(
790
+ isinstance(c, str) for c in target.palette
791
+ ):
792
+ raise ValueError(
793
+ f"ColumnScaleConfig.{key}.palette must be CSS color strings, not numbers."
794
+ )
795
+ return self
796
+
797
+
798
+ class TableColumnConfig(BaseModel):
799
+ """Configuration for a single table column."""
800
+
801
+ model_config = ConfigDict(extra="forbid")
802
+
803
+ label: str | None = Field(
804
+ default=None, description="Display header label (defaults to column name)."
805
+ )
806
+ format: str | FormatConfig | None = Field(
807
+ default=None,
808
+ description="Number format (D3 string, preset name, or FormatConfig).",
809
+ )
810
+ spark: SparkConfig | SparkTypeLiteral | None = Field(
811
+ default=None, description="Inline spark chart config or spark type name."
812
+ )
813
+ swatch: bool | None = Field(
814
+ default=None,
815
+ description=(
816
+ "When True, render this column's cells as small rounded color squares "
817
+ "instead of text. Cell value must be a CSS color string (e.g. '#3164a3'). "
818
+ "Useful for series-keyed tables — e.g. a 'Series' column where each row "
819
+ "is identified by its color in the parent chart's palette."
820
+ ),
821
+ )
822
+ width: int | str | None = Field(
823
+ default=None,
824
+ description="Column width (integer pixels or CSS string like '10%').",
825
+ )
826
+ max_width: int | str | None = Field(
827
+ default=None,
828
+ description=(
829
+ "Maximum column width for auto-sized text columns "
830
+ "(integer pixels or CSS string like '30%'). "
831
+ "Cannot be set together with width:."
832
+ ),
833
+ )
834
+ align: Literal["left", "center", "right"] | None = Field(
835
+ default=None, description="Text alignment in cells (left, center, right)."
836
+ )
837
+ header_overflow: Literal["clip", "truncate", "wrap-two", "wrap"] | None = Field(
838
+ default=None,
839
+ description="Header text overflow mode (clip, truncate, wrap-two, wrap).",
840
+ )
841
+ header_link: str | None = Field(
842
+ default=None, description="URL template for the column header link."
843
+ )
844
+ link: str | None = Field(
845
+ default=None,
846
+ description="URL template for cell values (Jinja template with row fields available).",
847
+ )
848
+ background: str | None = Field(
849
+ default=None, description="Cell background color (CSS color string)."
850
+ )
851
+ font: FontStyle | None = Field(
852
+ default=None, description="Cell font style overrides."
853
+ )
854
+ scale: ColumnScaleConfig | None = Field(
855
+ default=None,
856
+ description="Continuous color mapping configuration for this column.",
857
+ )
858
+ glyph: str | None = Field(
859
+ default=None, description="Glyph character prepended to cell values."
860
+ )
861
+ glyph_color: str | None = Field(
862
+ default=None, description="Color for the glyph. Requires glyph to be set."
863
+ )
864
+
865
+ @field_validator("header_overflow", mode="before")
866
+ @classmethod
867
+ def _normalize_header_overflow(cls, value: Any) -> Any:
868
+ return _normalize_overflow_value(value)
869
+
870
+ @field_validator("spark", mode="before")
871
+ @classmethod
872
+ def _reject_renamed_away_spark_shorthand(cls, value: Any) -> Any:
873
+ if isinstance(value, str) and value in _SPARK_RENAMED_AWAY:
874
+ raise ValueError(_SPARK_RENAMED_AWAY[value])
875
+ return value
876
+
877
+ @model_validator(mode="after")
878
+ def _validate_glyph(self) -> TableColumnConfig:
879
+ _validate_glyph_pair(self.glyph, self.glyph_color, label="TableColumnConfig")
880
+ return self
881
+
882
+ @model_validator(mode="after")
883
+ def _validate_width_max_width_exclusive(self) -> TableColumnConfig:
884
+ if self.width is not None and self.max_width is not None:
885
+ raise ValueError(
886
+ "TableColumnConfig: 'width' and 'max_width' are mutually exclusive. "
887
+ "Use 'width' for a hard pin, or 'max_width' to cap auto-sizing."
888
+ )
889
+ return self
890
+
891
+
892
+ # ============================================================================
893
+ # CHART SORT
894
+ # ============================================================================
895
+
896
+
897
+ class ChartSort(BaseModel):
898
+ """Chart-level sort configuration for categorical axes."""
899
+
900
+ model_config = ConfigDict(extra="forbid")
901
+
902
+ by: str = Field(description="Field name to sort by.")
903
+ order: Literal["asc", "desc"] = Field(
904
+ default="asc", description="Sort direction (asc or desc)."
905
+ )
906
+
907
+
908
+ # ============================================================================
909
+ # CHART SURFACE VALIDATION
910
+ # ============================================================================
911
+
912
+ _KPI_ONLY_FIELDS: dict[str, str] = {
913
+ "support": "structured support row beneath the headline value",
914
+ "label": "text rendered above the headline value",
915
+ }
916
+
917
+ # ============================================================================
918
+ # LAYER PATCH — PER-LAYER AUTHORED INPUT
919
+ # ============================================================================
920
+
921
+ VALID_LAYER_TYPES: frozenset[str] = frozenset(
922
+ {
923
+ "bar",
924
+ "line",
925
+ "area",
926
+ "circle",
927
+ "square",
928
+ "tick",
929
+ "rule",
930
+ "trail",
931
+ "rect",
932
+ "image",
933
+ "scatter",
934
+ }
935
+ )
936
+
937
+
938
+ class ChartTotal(BaseModel):
939
+ """Donut center total — opt-in summary at the center of a pie chart."""
940
+
941
+ model_config = ConfigDict(extra="forbid")
942
+
943
+ label: str | None = Field(
944
+ default=None, description="Caption text displayed below the center total value."
945
+ )
946
+ format: str | FormatConfig | None = Field(
947
+ default=None, description="Number format (D3 spec, preset, or FormatConfig)."
948
+ )
949
+
950
+
951
+ class ChartLabels(BaseModel):
952
+ """Per-row text annotations rendered near each data anchor."""
953
+
954
+ model_config = ConfigDict(extra="forbid")
955
+
956
+ template: str = Field(
957
+ description="Jinja2 template for the label text. Row fields are available as variables."
958
+ )
959
+ where: str | None = Field(
960
+ default=None,
961
+ description="Jinja2 boolean filter expression. Labels only render on rows where this is truthy.",
962
+ )
963
+
964
+ @field_validator("template")
965
+ @classmethod
966
+ def _validate_template(cls, value: str | None) -> str | None:
967
+ from dataface.core.compile.labels_env import label_jinja_env
968
+
969
+ if value is None:
970
+ return value
971
+ try:
972
+ label_jinja_env().parse(value)
973
+ except Exception as exc:
974
+ raise ValueError(f"Invalid Jinja template: {exc}") from exc
975
+ return value
976
+
977
+ @field_validator("where")
978
+ @classmethod
979
+ def _validate_where(cls, value: str | None) -> str | None:
980
+ from dataface.core.compile.labels_env import (
981
+ label_jinja_env,
982
+ strip_jinja_braces,
983
+ )
984
+
985
+ if value is None:
986
+ return value
987
+ try:
988
+ label_jinja_env().compile_expression(strip_jinja_braces(value))
989
+ except Exception as exc:
990
+ raise ValueError(f"Invalid Jinja expression: {exc}") from exc
991
+ return value
992
+
993
+
994
+ class LayerAxisYStyle(BaseModel):
995
+ """Per-layer y-axis settings on a layered chart."""
996
+
997
+ model_config = ConfigDict(extra="forbid")
998
+
999
+ orient: Literal["left", "right"] | None = Field(
1000
+ default=None, description="Y-axis side for this layer (left or right)."
1001
+ )
1002
+ title: str | None = Field(
1003
+ default=None, description="Y-axis title override for this layer."
1004
+ )
1005
+
1006
+
1007
+ class Layer(BaseModel):
1008
+ """A single layer in a layered chart authored YAML."""
1009
+
1010
+ model_config = ConfigDict(extra="forbid")
1011
+
1012
+ type: str = Field(
1013
+ description="Mark type for this layer (bar, line, area, circle, square, tick, rule, trail, rect, image, scatter)."
1014
+ )
1015
+ query: str | None = Field(
1016
+ default=None,
1017
+ description="Query name for this layer's data (overrides chart-level query).",
1018
+ )
1019
+ x: str | None = Field(default=None, description="X-axis field name for this layer.")
1020
+ y: str | None = Field(default=None, description="Y-axis field name for this layer.")
1021
+ label: str | None = Field(
1022
+ default=None, description="Label field name for this layer."
1023
+ )
1024
+ color: str | None = Field(
1025
+ default=None,
1026
+ description="Color data channel for this layer: bare field name only.",
1027
+ )
1028
+ fill: str | None = Field(
1029
+ default=None,
1030
+ description="Static fill color for this layer (hex string). Use for fixed-color marks; `color` is for data channels.",
1031
+ )
1032
+ size: str | None = Field(
1033
+ default=None, description="Size channel field name for this layer."
1034
+ )
1035
+ shape: str | None = Field(
1036
+ default=None, description="Shape channel field name for this layer."
1037
+ )
1038
+ axis_y: LayerAxisYStyle | None = Field(
1039
+ default=None, description="Y-axis settings for this layer (orientation, title)."
1040
+ )
1041
+
1042
+ @field_validator("color", mode="before")
1043
+ @classmethod
1044
+ def _reject_non_column_color(cls, v: Any) -> Any:
1045
+ if isinstance(v, str) and v.startswith("#"):
1046
+ raise ValueError(
1047
+ f"literal color '{v}' belongs in style:\n"
1048
+ " style:\n"
1049
+ " color: '#...'\n"
1050
+ "Layer.color is a data channel (bare field name only)."
1051
+ )
1052
+ if isinstance(v, dict):
1053
+ raise ValueError(
1054
+ "Layer.color only accepts a bare field name (string). "
1055
+ "Dicts (value, scale, when forms) are not supported on layers."
1056
+ )
1057
+ return v
1058
+
1059
+ @model_validator(mode="before")
1060
+ @classmethod
1061
+ def reject_vl_passthrough_fields(cls, data: Any) -> Any:
1062
+ if isinstance(data, dict) and "encoding" in data:
1063
+ raise ValueError(
1064
+ "`encoding` is not part of the authored Dataface layer surface. "
1065
+ "Use typed layer channels (`color`, `size`, `shape`) instead."
1066
+ )
1067
+ return data
1068
+
1069
+
1070
+ # ============================================================================
1071
+ # FILTER BINDING
1072
+ # ============================================================================
1073
+
1074
+ # Canonical set of recognized filter operator keys.
1075
+ # filter_injection._OP_MAP holds the sqlglot-class mapping for SQL injection;
1076
+ # this set is the authoritative list for compile-time validation.
1077
+ FILTER_OPS: frozenset[str] = frozenset(
1078
+ {"eq", "neq", "gt", "gte", "lt", "lte", "like", "ilike", "in", "not_in", "between"}
1079
+ )
1080
+
1081
+
1082
+ class FilterDef(BaseModel):
1083
+ """One column→variable binding in chart.filters.
1084
+
1085
+ Authored in YAML as a plain variable name (implicit eq), a Jinja template
1086
+ (implicit eq, resolved at execute time), or a single-key operator dict:
1087
+
1088
+ filters:
1089
+ region: selected_region → FilterDef(op="eq", var="selected_region")
1090
+ region: "{{ selected_region }}" → FilterDef(op="eq", template="{{ selected_region }}")
1091
+ revenue:
1092
+ gte: min_revenue → FilterDef(op="gte", var="min_revenue")
1093
+
1094
+ Exactly one of ``var`` or ``template`` must be set. Operator dicts require
1095
+ plain variable names; Jinja templates are not accepted in operator-dict values.
1096
+ """
1097
+
1098
+ model_config = ConfigDict(extra="forbid")
1099
+
1100
+ op: Literal[
1101
+ "eq",
1102
+ "neq",
1103
+ "gt",
1104
+ "gte",
1105
+ "lt",
1106
+ "lte",
1107
+ "like",
1108
+ "ilike",
1109
+ "in",
1110
+ "not_in",
1111
+ "between",
1112
+ ] = Field(default="eq", description="Comparison operator. Defaults to 'eq'.")
1113
+ var: str | None = Field(
1114
+ default=None,
1115
+ description="Plain variable name (no Jinja). Set when the filter value is a dashboard variable reference.",
1116
+ )
1117
+ template: str | None = Field(
1118
+ default=None,
1119
+ description="Jinja template resolved at execute time. Only valid with op='eq'. Set when conditional-disable or complex expressions are needed.",
1120
+ )
1121
+
1122
+ @model_validator(mode="before")
1123
+ @classmethod
1124
+ def _from_yaml(cls, v: Any) -> dict[str, Any]:
1125
+ """Coerce authored YAML shapes into normalized dict form.
1126
+
1127
+ Accepts four input forms:
1128
+ - plain variable name: "region_var" → {op: "eq", var: "region_var"}
1129
+ - Jinja template: "{{ region_var }}" → {op: "eq", template: "{{ region_var }}"}
1130
+ - single-key op dict: {"gte": "min_rev"} → {op: "gte", var: "min_rev"}
1131
+ - already-normalized: {"op": X, "var": Y} — passed through as-is
1132
+ {"op": X, "template": Y} — passed through as-is
1133
+ """
1134
+ if isinstance(v, str):
1135
+ if not v:
1136
+ raise ValueError("filter value must be a non-empty string")
1137
+ if "{{" in v or "}}" in v:
1138
+ return {"op": "eq", "template": v}
1139
+ return {"op": "eq", "var": v}
1140
+ if isinstance(v, dict):
1141
+ # Already-normalized form from programmatic construction — pass through.
1142
+ if set(v.keys()) <= {"op", "var", "template"}:
1143
+ return v
1144
+ # YAML authored shape: single-key {operator: var_name} — plain var only.
1145
+ if len(v) != 1:
1146
+ raise ValueError(
1147
+ f"operator dict must have exactly one key, got {sorted(v.keys())!r}"
1148
+ )
1149
+ op, var = next(iter(v.items()))
1150
+ if op not in FILTER_OPS:
1151
+ raise ValueError(
1152
+ f"unknown operator {op!r}; must be one of {sorted(FILTER_OPS)}"
1153
+ )
1154
+ if not isinstance(var, str) or not var:
1155
+ raise ValueError(
1156
+ f"filters.{op}: variable name must be a non-empty string"
1157
+ )
1158
+ if "{{" in var or "}}" in var:
1159
+ raise ValueError(
1160
+ f"filters.{op}: Jinja templates are not allowed in operator-dict "
1161
+ f"filter values; use a plain variable name instead of {var!r}"
1162
+ )
1163
+ return {"op": op, "var": var}
1164
+ raise ValueError(
1165
+ f"filter binding must be a variable name string or single-key operator dict, "
1166
+ f"got {type(v).__name__}"
1167
+ )
1168
+
1169
+ @model_validator(mode="after")
1170
+ def _exactly_one(self) -> FilterDef:
1171
+ """Exactly one of var or template must be set; template requires op='eq'."""
1172
+ if (self.var is None) == (self.template is None):
1173
+ raise ValueError(
1174
+ "FilterDef must set exactly one of var or template, "
1175
+ f"got var={self.var!r}, template={self.template!r}"
1176
+ )
1177
+ if self.template is not None and self.op != "eq":
1178
+ raise ValueError(
1179
+ f"FilterDef.template is only valid with op='eq', got op={self.op!r}"
1180
+ )
1181
+ return self
1182
+
1183
+ def to_yaml_form(self) -> str | dict[str, str]:
1184
+ """Return the canonical YAML-authored representation.
1185
+
1186
+ Template bindings and implicit-eq bindings round-trip as a plain string;
1187
+ all other operators round-trip as a single-key operator dict.
1188
+ """
1189
+ if self.template is not None:
1190
+ return self.template
1191
+ assert self.var is not None # enforced by _exactly_one
1192
+ if self.op == "eq":
1193
+ return self.var
1194
+ return {self.op: self.var}
1195
+
1196
+
1197
+ # ============================================================================
1198
+ # CHART FIELD BASES
1199
+ # ============================================================================
1200
+
1201
+
1202
+ def _reject_color_dict(v: Any) -> Any:
1203
+ """Shared validator: reject non-string chart.color values."""
1204
+ if isinstance(v, str) and v.startswith("#"):
1205
+ raise ValueError(
1206
+ f"literal color '{v}' belongs in style:\n"
1207
+ " style:\n"
1208
+ " color: '#...'\n"
1209
+ "chart.color is a data channel (bare field name only)."
1210
+ )
1211
+ if isinstance(v, dict):
1212
+ if "value" in v:
1213
+ raise ValueError(
1214
+ "color: {value: ...} no longer accepted at chart root. "
1215
+ "Move literal colors to style:\n"
1216
+ " style:\n color: '#...'"
1217
+ )
1218
+ if "scale" in v:
1219
+ raise ValueError(
1220
+ "Inline scale config no longer accepted at chart.color. "
1221
+ "Use style:\n"
1222
+ " style:\n color: {scale: {palette: [...]}}"
1223
+ )
1224
+ if "when" in v:
1225
+ raise ValueError(
1226
+ "Inline conditional no longer accepted at chart.color. "
1227
+ "Use conditional_formatting: for per-cell rules."
1228
+ )
1229
+ raise ValueError(
1230
+ "chart.color only accepts a bare field name (string). "
1231
+ "Use style.color for static paint or gradient scale."
1232
+ )
1233
+ return v
1234
+
1235
+
1236
+ class _SharedChartFields(BaseModel):
1237
+ """Private base shared by ALL chart families (authored patches).
1238
+
1239
+ Contains only fields that every chart type meaningfully uses.
1240
+ Per-family fields (x/y, theta, value, layers, message) are declared
1241
+ on the family-specific *Patch classes, not here.
1242
+ """
1243
+
1244
+ model_config = ConfigDict(extra="forbid")
1245
+
1246
+ # None = auto-generated chart id from YAML key when omitted.
1247
+ id: Annotated[
1248
+ str | None,
1249
+ Field(
1250
+ default=None,
1251
+ description="Explicit chart ID (auto-generated from the chart's YAML key if omitted).",
1252
+ ),
1253
+ ]
1254
+ # None = no chart title (KPI charts use label: on KpiChart, not title:).
1255
+ title: Annotated[
1256
+ str | None,
1257
+ Field(
1258
+ default=None,
1259
+ description="Chart title displayed above the chart (not used on type: kpi).",
1260
+ ),
1261
+ ]
1262
+ # None = no subtitle.
1263
+ subtitle: Annotated[
1264
+ str | None,
1265
+ Field(default=None, description="Chart subtitle displayed below the title."),
1266
+ ]
1267
+ description: Annotated[
1268
+ str | None,
1269
+ Field(
1270
+ default=None,
1271
+ description="Human-readable description used by AI search and context tooltips.",
1272
+ ),
1273
+ ]
1274
+ # None = author omitted query; normalizer supplies or errors at compile.
1275
+ query: Annotated[
1276
+ str | dict[str, Any] | None,
1277
+ Field(
1278
+ default=None,
1279
+ description="Named query reference, inline AuthoredQuery, or SQL string shorthand.",
1280
+ ),
1281
+ ]
1282
+ link: Annotated[
1283
+ str | None,
1284
+ Field(
1285
+ default=None, description="Click-through URL template for drill-down links."
1286
+ ),
1287
+ ]
1288
+
1289
+ @model_validator(mode="before")
1290
+ @classmethod
1291
+ def _reject_href(cls, data: Any) -> Any:
1292
+ if isinstance(data, dict) and "href" in data:
1293
+ raise ValueError(
1294
+ "'href:' was renamed to 'link:'. Use `link:` for click-through URLs."
1295
+ )
1296
+ return data
1297
+
1298
+ filters: Annotated[
1299
+ dict[str, FilterDef] | None,
1300
+ Field(
1301
+ default=None,
1302
+ description="Declarative column filters applied to chart data after query execution.",
1303
+ ),
1304
+ ]
1305
+ conditional_formatting: Annotated[
1306
+ dict[str, FieldConditionalFormatting] | None,
1307
+ Field(
1308
+ default=None,
1309
+ description="Discrete rule-driven style overrides indexed by column name.",
1310
+ ),
1311
+ ]
1312
+ # Excluded from compiled dict — layout hint only when patch is inline in rows/cols.
1313
+ visible: Annotated[
1314
+ bool | str | SingleRowBoolProbe | None,
1315
+ Field(
1316
+ default=None,
1317
+ exclude=True,
1318
+ description="Controls whether this layout item is rendered.",
1319
+ ),
1320
+ ]
1321
+ # Widest base declaration; concrete subclasses narrow via Literal.
1322
+ # Required — missing or unknown type raises a discriminator ValidationError.
1323
+ type: str
1324
+ warnings_ignore: Annotated[
1325
+ list[str] | None,
1326
+ Field(
1327
+ default=None,
1328
+ description="Codes of render warnings to suppress for this chart.",
1329
+ ),
1330
+ ]
1331
+
1332
+
1333
+ class _CartesianChartFields(_SharedChartFields):
1334
+ """Private base for cartesian chart families (bar, line, area, scatter, heatmap).
1335
+
1336
+ Holds channel fields that only make sense on charts with x/y axes.
1337
+ """
1338
+
1339
+ # None = field not encoded.
1340
+ x: Annotated[
1341
+ str | None,
1342
+ Field(default=None, description="X-axis field name from the query result."),
1343
+ ]
1344
+ y: Annotated[
1345
+ str | list[str] | None,
1346
+ Field(
1347
+ default=None,
1348
+ description="Y-axis field name(s). Accepts a single field or list for multi-series charts.",
1349
+ ),
1350
+ ]
1351
+ x_label: Annotated[
1352
+ str | None, Field(default=None, description="Custom label for the X axis.")
1353
+ ]
1354
+ y_label: Annotated[
1355
+ str | None, Field(default=None, description="Custom label for the Y axis.")
1356
+ ]
1357
+ # None = no color encoding (solid fill from theme).
1358
+ color: Annotated[
1359
+ str | None,
1360
+ Field(default=None, description="Color data channel: bare field name only."),
1361
+ ]
1362
+ sort: Annotated[
1363
+ ChartSort | None,
1364
+ Field(
1365
+ default=None,
1366
+ description="Sort configuration: field to sort by and direction (asc/desc).",
1367
+ ),
1368
+ ]
1369
+ labels: Annotated[
1370
+ ChartLabels | None,
1371
+ Field(
1372
+ default=None, description="Per-row text annotations near each data anchor."
1373
+ ),
1374
+ ]
1375
+ data_table: Annotated[
1376
+ ChartDataTable | None,
1377
+ Field(
1378
+ default=None,
1379
+ description="Optional mini data-grid attached below/above the chart.",
1380
+ ),
1381
+ ]
1382
+ # None = no format override; axis/tooltip format falls back to theme.
1383
+ format: Annotated[
1384
+ str | FormatConfig | None,
1385
+ Field(
1386
+ default=None,
1387
+ description="Number format: D3 format string, preset name, or FormatConfig object.",
1388
+ ),
1389
+ ]
1390
+ # Box geometry — height and width at chart root, not in style:.
1391
+ # aspect_ratio, min_height, max_height belong in style: (promoted to compiled Chart root).
1392
+ # Renderer-owned types (kpi, table, callout, spark_bar) reject these via model_validator.
1393
+ height: Annotated[
1394
+ int | float | None,
1395
+ Field(
1396
+ default=None,
1397
+ gt=0,
1398
+ description=(
1399
+ "Explicit chart height in pixels. Positive number only. When set, "
1400
+ "overrides aspect_ratio and theme cascade. Not valid on kpi, table, "
1401
+ "callout, or spark_bar \u2014 those renderers own their own sizing contracts."
1402
+ ),
1403
+ ),
1404
+ ]
1405
+ width: Annotated[
1406
+ int | float | None,
1407
+ Field(
1408
+ default=None,
1409
+ gt=0,
1410
+ description=(
1411
+ "Chart width hint in pixels. Positive number only. Used by the "
1412
+ "label-overlap heuristic to determine whether x-axis labels need "
1413
+ "tilting. Does not override the layout slot width — the chart "
1414
+ "still fills its allocated container. Not valid on kpi, table, "
1415
+ "callout, or spark_bar \u2014 those renderers own their own sizing contracts."
1416
+ ),
1417
+ ),
1418
+ ]
1419
+
1420
+ @field_validator("color", mode="before")
1421
+ @classmethod
1422
+ def _reject_non_column_color(cls, v: Any) -> Any:
1423
+ """Color is a data channel — only bare field names accepted at chart root."""
1424
+ return _reject_color_dict(v)
1425
+
1426
+ @model_validator(mode="after")
1427
+ def _validate_data_table(self) -> _CartesianChartFields:
1428
+ if self.data_table is None:
1429
+ return self
1430
+ if self.type not in CHART_DATA_TABLE_SUPPORTED_TYPES:
1431
+ supported = ", ".join(sorted(CHART_DATA_TABLE_SUPPORTED_TYPES))
1432
+ raise ValueError(
1433
+ f"chart.data_table is not supported for chart type "
1434
+ f"{self.type!r} in v1. Supported chart types: {supported}."
1435
+ )
1436
+ if isinstance(self.y, list) and len(self.y) > 1:
1437
+ all_by_measure = all(
1438
+ isinstance(e, ChartDataTablePerSeries) and e.by_measure
1439
+ for e in self.data_table.entries
1440
+ )
1441
+ if not all_by_measure:
1442
+ raise ValueError(
1443
+ "chart.data_table is not supported on charts with a "
1444
+ "multi-field `y:` list unless every entry is a "
1445
+ "`per_series:` entry with `by_measure: true`. "
1446
+ "Collapse `y:` to one field, or use "
1447
+ "`{per_series: <alias>, by_measure: true}` entries."
1448
+ )
1449
+ has_per_series_needing_color = any(
1450
+ isinstance(e, ChartDataTablePerSeries) and not e.by_measure
1451
+ for e in self.data_table.entries
1452
+ )
1453
+ if has_per_series_needing_color:
1454
+ color = self.color
1455
+ has_color = color is not None and color != ""
1456
+ if not has_color:
1457
+ raise ValueError(
1458
+ "chart.data_table per_series: entries require the chart to have "
1459
+ "a color: channel. Add `color: <field>` to the chart."
1460
+ )
1461
+ validate_data_table_shape(self.data_table.entries)
1462
+ return self
1463
+
1464
+
1465
+ class BasemapConfig(BaseModel):
1466
+ """Tile-layer configuration for point_map and bubble_map charts.
1467
+
1468
+ extra="allow" so authors can pass arbitrary provider-specific keys;
1469
+ typed fields here are the Dataface-documented surface.
1470
+ """
1471
+
1472
+ model_config = ConfigDict(extra="allow")
1473
+
1474
+ type: str | None = Field(
1475
+ default=None, description="Tile provider type (e.g. 'mapbox', 'raster')."
1476
+ )
1477
+ style: str | None = Field(
1478
+ default=None, description="Tile style URL (e.g. Mapbox style URI)."
1479
+ )
1480
+ source: str | None = Field(
1481
+ default=None,
1482
+ description="Named geographic boundary source (e.g. 'us-states') for overlay rendering.",
1483
+ )
1484
+ attribution: str | None = Field(
1485
+ default=None, description="Attribution text for the tile provider."
1486
+ )
1487
+ fill: str | None = Field(
1488
+ default=None, description="Fill color for geographic boundary overlay."
1489
+ )
1490
+ stroke: str | None = Field(
1491
+ default=None, description="Stroke color for geographic boundary overlay."
1492
+ )
1493
+
1494
+
1495
+ class _GeoChartFields(_SharedChartFields):
1496
+ """Private base for geographic chart families (map/geoshape, point_map/bubble_map)."""
1497
+
1498
+ # None = no projection specified; renderer uses default.
1499
+ projection: Annotated[
1500
+ str | Projection | None,
1501
+ Field(
1502
+ default=None,
1503
+ description="Map projection name or Vega-Lite projection config.",
1504
+ ),
1505
+ ]
1506
+ # None = no color encoding.
1507
+ color: Annotated[
1508
+ str | None,
1509
+ Field(default=None, description="Color data channel: bare field name only."),
1510
+ ]
1511
+ geo: Annotated[
1512
+ str | dict[str, Any] | None,
1513
+ Field(
1514
+ default=None,
1515
+ description="GeoJSON field name or inline GeoJSON spec for geoshape charts.",
1516
+ ),
1517
+ ]
1518
+ geo_source: Annotated[
1519
+ str | None,
1520
+ Field(
1521
+ default=None,
1522
+ description="Named geographic data source for loading GeoJSON boundaries.",
1523
+ ),
1524
+ ]
1525
+ lookup: Annotated[
1526
+ str | None,
1527
+ Field(
1528
+ default=None,
1529
+ description="Data field to join against geographic data (map join key).",
1530
+ ),
1531
+ ]
1532
+ # None = no value encoding (fill uses static color).
1533
+ value: Annotated[
1534
+ str | None,
1535
+ Field(default=None, description="Data field mapped to the fill color."),
1536
+ ]
1537
+ # None = lat/lon not specified.
1538
+ latitude: Annotated[
1539
+ str | None,
1540
+ Field(
1541
+ default=None,
1542
+ description="Field containing latitude values for point/bubble maps.",
1543
+ ),
1544
+ ]
1545
+ longitude: Annotated[
1546
+ str | None,
1547
+ Field(
1548
+ default=None,
1549
+ description="Field containing longitude values for point/bubble maps.",
1550
+ ),
1551
+ ]
1552
+
1553
+ @field_validator("color", mode="before")
1554
+ @classmethod
1555
+ def _reject_non_column_color(cls, v: Any) -> Any:
1556
+ if isinstance(v, str) and v.startswith("#"):
1557
+ raise ValueError(
1558
+ f"literal color '{v}' belongs in style:\n"
1559
+ " style:\n"
1560
+ " color: '#...'\n"
1561
+ "chart.color is a data channel (bare field name only)."
1562
+ )
1563
+ if isinstance(v, dict):
1564
+ raise ValueError("chart.color only accepts a bare field name (string).")
1565
+ return v
1566
+
1567
+ @field_validator("projection", mode="before")
1568
+ @classmethod
1569
+ def _validate_projection(cls, value: Any) -> str | Projection | None:
1570
+ return validate_projection_definition(value)
1571
+
1572
+
1573
+ class _RadialChartFields(_SharedChartFields):
1574
+ """Private base for radial/arc chart families (pie, donut).
1575
+
1576
+ Holds channel fields that only make sense on arc charts with angular encoding.
1577
+ """
1578
+
1579
+ theta: Annotated[
1580
+ str, Field(description="Field for angular encoding in pie (arc) charts.")
1581
+ ]
1582
+ # None = no color encoding.
1583
+ color: Annotated[
1584
+ str | None,
1585
+ Field(default=None, description="Color data channel: bare field name only."),
1586
+ ]
1587
+ total: Annotated[
1588
+ ChartTotal | None, Field(default=None, description="Donut center total.")
1589
+ ]
1590
+ labels: Annotated[
1591
+ ChartLabels | None,
1592
+ Field(
1593
+ default=None, description="Per-row text annotations near each data anchor."
1594
+ ),
1595
+ ]
1596
+
1597
+ @field_validator("color", mode="before")
1598
+ @classmethod
1599
+ def _reject_non_column_color(cls, v: Any) -> Any:
1600
+ if isinstance(v, str) and v.startswith("#"):
1601
+ raise ValueError(
1602
+ f"literal color '{v}' belongs in style. chart.color is a data channel."
1603
+ )
1604
+ return v
1605
+
1606
+
1607
+ # ============================================================================
1608
+ # PER-FAMILY AUTHORED PATCHES
1609
+ # ============================================================================
1610
+
1611
+ # --- Cartesian family ---
1612
+
1613
+
1614
+ class BarChart(_CartesianChartFields):
1615
+ """Authored patch for bar and histogram charts."""
1616
+
1617
+ model_config = ConfigDict(extra="forbid")
1618
+
1619
+ type: Annotated[
1620
+ Literal["bar", "histogram"], Field(description="Bar or histogram chart type.")
1621
+ ]
1622
+ size: Annotated[
1623
+ str | None,
1624
+ Field(
1625
+ default=None,
1626
+ description="Field used to size-encode data points (quantitative).",
1627
+ ),
1628
+ ]
1629
+ shape: Annotated[
1630
+ str | None,
1631
+ Field(
1632
+ default=None,
1633
+ description="Field used to shape-encode data points (categorical).",
1634
+ ),
1635
+ ]
1636
+ style: Annotated[
1637
+ BarChartStylePatch | None,
1638
+ Field(default=None, description="Chart-local style overrides."),
1639
+ ]
1640
+
1641
+
1642
+ class LineChart(_CartesianChartFields):
1643
+ """Authored patch for line charts.
1644
+
1645
+ Intentionally excludes size, and shape — these channels are structurally
1646
+ impossible on line charts. An attempt to set them raises extra_forbidden.
1647
+ """
1648
+
1649
+ model_config = ConfigDict(extra="forbid")
1650
+
1651
+ type: Annotated[Literal["line"], Field(description="Line chart type.")]
1652
+ style: Annotated[
1653
+ LineChartStylePatch | None,
1654
+ Field(default=None, description="Chart-local style overrides."),
1655
+ ]
1656
+
1657
+
1658
+ class AreaChart(_CartesianChartFields):
1659
+ """Authored patch for area charts."""
1660
+
1661
+ model_config = ConfigDict(extra="forbid")
1662
+
1663
+ type: Annotated[Literal["area"], Field(description="Area chart type.")]
1664
+ size: Annotated[
1665
+ str | None,
1666
+ Field(
1667
+ default=None,
1668
+ description="Field used to size-encode data points (quantitative).",
1669
+ ),
1670
+ ]
1671
+ shape: Annotated[
1672
+ str | None,
1673
+ Field(
1674
+ default=None,
1675
+ description="Field used to shape-encode data points (categorical).",
1676
+ ),
1677
+ ]
1678
+ style: Annotated[
1679
+ AreaChartStylePatch | None,
1680
+ Field(default=None, description="Chart-local style overrides."),
1681
+ ]
1682
+
1683
+
1684
+ class ScatterChart(_CartesianChartFields):
1685
+ """Authored patch for scatter charts."""
1686
+
1687
+ model_config = ConfigDict(extra="forbid")
1688
+
1689
+ type: Annotated[Literal["scatter"], Field(description="Scatter chart type.")]
1690
+ size: Annotated[
1691
+ str | None,
1692
+ Field(
1693
+ default=None,
1694
+ description="Field used to size-encode data points (quantitative).",
1695
+ ),
1696
+ ]
1697
+ shape: Annotated[
1698
+ str | None,
1699
+ Field(
1700
+ default=None,
1701
+ description="Field used to shape-encode data points (categorical).",
1702
+ ),
1703
+ ]
1704
+ style: Annotated[
1705
+ ScatterChartStylePatch | None,
1706
+ Field(default=None, description="Chart-local style overrides."),
1707
+ ]
1708
+
1709
+
1710
+ class HeatmapChart(_CartesianChartFields):
1711
+ """Authored patch for heatmap charts."""
1712
+
1713
+ model_config = ConfigDict(extra="forbid")
1714
+
1715
+ type: Annotated[Literal["heatmap"], Field(description="Heatmap chart type.")]
1716
+ size: Annotated[
1717
+ str | None,
1718
+ Field(
1719
+ default=None,
1720
+ description="Field used to size-encode data points (quantitative).",
1721
+ ),
1722
+ ]
1723
+ shape: Annotated[
1724
+ str | None,
1725
+ Field(
1726
+ default=None,
1727
+ description="Field used to shape-encode data points (categorical).",
1728
+ ),
1729
+ ]
1730
+ style: Annotated[
1731
+ HeatmapChartStylePatch | None,
1732
+ Field(default=None, description="Chart-local style overrides."),
1733
+ ]
1734
+
1735
+
1736
+ # --- Arc family ---
1737
+
1738
+
1739
+ class PieChart(_RadialChartFields):
1740
+ """Authored patch for pie and donut charts.
1741
+
1742
+ Pie charts use theta (angular) and color (segment) channels.
1743
+ x/y/format/sort and other cartesian fields are not valid here.
1744
+ """
1745
+
1746
+ model_config = ConfigDict(extra="forbid")
1747
+
1748
+ type: Annotated[
1749
+ Literal["pie", "donut"], Field(description="Pie or donut chart type.")
1750
+ ]
1751
+ style: Annotated[
1752
+ PieChartStylePatch | None,
1753
+ Field(default=None, description="Chart-local style overrides."),
1754
+ ]
1755
+
1756
+
1757
+ # --- KPI family ---
1758
+
1759
+
1760
+ class KpiChart(_SharedChartFields):
1761
+ """Authored patch for KPI (key performance indicator) charts.
1762
+
1763
+ KPI charts do not use title: (use label: instead) and require value:.
1764
+ """
1765
+
1766
+ model_config = ConfigDict(extra="forbid")
1767
+
1768
+ type: Annotated[Literal["kpi"], Field(description="KPI chart type.")]
1769
+ value: Annotated[
1770
+ str,
1771
+ Field(
1772
+ description="Column reference (string column name) for the headline number/text."
1773
+ ),
1774
+ ]
1775
+ # None = no label above the headline value (slug auto-generated).
1776
+ label: Annotated[
1777
+ str | None,
1778
+ Field(default=None, description="KPI label rendered above the headline value."),
1779
+ ]
1780
+ # None = no format override; value is rendered as-is.
1781
+ format: Annotated[
1782
+ str | FormatConfig | None,
1783
+ Field(
1784
+ default=None,
1785
+ description="Number format applied to the headline value: D3 format string, preset name, or FormatConfig object.",
1786
+ ),
1787
+ ]
1788
+ support: Annotated[
1789
+ KpiSupportConfig | None,
1790
+ Field(default=None, description="Optional support line beneath the KPI value."),
1791
+ ]
1792
+ style: Annotated[
1793
+ KpiChartStylePatch | None,
1794
+ Field(default=None, description="Chart-local style overrides."),
1795
+ ]
1796
+
1797
+ @field_validator("value", mode="before")
1798
+ @classmethod
1799
+ def _reject_literal_value(cls, v: Any) -> Any:
1800
+ if not isinstance(v, bool) and isinstance(v, (int, float)):
1801
+ raise ValueError(
1802
+ "value at chart root must be a column reference (string column name).\n"
1803
+ f"Got numeric literal: {v}.\n"
1804
+ "Channels are always column references; data values come from the query.\n"
1805
+ "Update to:\n"
1806
+ f" query:\n rows:\n - count: {v}\n"
1807
+ " value: count\n"
1808
+ "or write a SQL query:\n"
1809
+ f' query: "select {v} as count"\n'
1810
+ " value: count"
1811
+ )
1812
+ return v
1813
+
1814
+ @model_validator(mode="before")
1815
+ @classmethod
1816
+ def _reject_kpi_title_and_subtitle(cls, data: Any) -> Any:
1817
+ if isinstance(data, dict):
1818
+ if data.get("title"):
1819
+ raise ValueError(
1820
+ "`title:` is not used on `type: kpi`. Use `label:` instead."
1821
+ )
1822
+ if data.get("subtitle"):
1823
+ from dataface.core.compile.normalize_charts import ( # noqa: PLC0415
1824
+ _kpi_subtitle_error,
1825
+ )
1826
+
1827
+ raise ValueError(_kpi_subtitle_error(data.get("id")))
1828
+ return data
1829
+
1830
+ @model_validator(mode="before")
1831
+ @classmethod
1832
+ def _reject_glyph_and_tone_at_chart_root(cls, data: Any) -> Any:
1833
+ if isinstance(data, dict):
1834
+ if "glyph" in data:
1835
+ raise ValueError(
1836
+ "'glyph:' has moved from the KPI chart root into the style namespace.\n"
1837
+ "Use style.glyph.character instead:\n\n"
1838
+ " style:\n"
1839
+ " glyph:\n"
1840
+ " character: '▲'\n"
1841
+ )
1842
+ if "tone" in data:
1843
+ raise ValueError(
1844
+ "'tone:' has moved from the KPI chart root into the style namespace.\n"
1845
+ "Use style.tone instead:\n\n"
1846
+ " style:\n"
1847
+ " tone: positive\n"
1848
+ )
1849
+ return data
1850
+
1851
+
1852
+ # --- Table family ---
1853
+
1854
+
1855
+ class TableChart(_SharedChartFields):
1856
+ """Authored patch for table charts."""
1857
+
1858
+ model_config = ConfigDict(extra="forbid")
1859
+
1860
+ type: Annotated[Literal["table"], Field(description="Table chart type.")]
1861
+ style: Annotated[
1862
+ TableChartStylePatch | None,
1863
+ Field(default=None, description="Chart-local style overrides."),
1864
+ ]
1865
+
1866
+
1867
+ # --- Geo family ---
1868
+
1869
+
1870
+ class PointMapChart(_GeoChartFields):
1871
+ """Authored patch for point_map and bubble_map charts."""
1872
+
1873
+ model_config = ConfigDict(extra="forbid")
1874
+
1875
+ type: Annotated[
1876
+ Literal["point_map", "bubble_map"],
1877
+ Field(description="Point map or bubble map chart type."),
1878
+ ]
1879
+ # None = no size encoding; bubbles use a fixed radius.
1880
+ size: Annotated[
1881
+ str | None,
1882
+ Field(
1883
+ default=None,
1884
+ description="Data field used to scale bubble radius (quantitative). Only meaningful on bubble_map.",
1885
+ ),
1886
+ ]
1887
+ basemap: Annotated[
1888
+ BasemapConfig | None,
1889
+ Field(
1890
+ default=None, description="Tile-layer configuration for the map background."
1891
+ ),
1892
+ ]
1893
+ style: Annotated[
1894
+ PointMapChartStylePatch | None,
1895
+ Field(default=None, description="Chart-local style overrides."),
1896
+ ]
1897
+
1898
+
1899
+ class GeoshapeChart(_GeoChartFields):
1900
+ """Authored patch for map and geoshape charts."""
1901
+
1902
+ model_config = ConfigDict(extra="forbid")
1903
+
1904
+ type: Annotated[
1905
+ Literal["map", "geoshape"], Field(description="Map or geoshape chart type.")
1906
+ ]
1907
+ style: Annotated[
1908
+ GeoshapeChartStylePatch | None,
1909
+ Field(default=None, description="Chart-local style overrides."),
1910
+ ]
1911
+
1912
+
1913
+ # --- Layered family ---
1914
+
1915
+
1916
+ class LayeredChart(_SharedChartFields):
1917
+ """Authored patch for layered multi-mark charts."""
1918
+
1919
+ model_config = ConfigDict(extra="forbid")
1920
+
1921
+ type: Annotated[Literal["layered"], Field(description="Layered chart type.")]
1922
+ layers: Annotated[
1923
+ list[Layer],
1924
+ Field(
1925
+ min_length=1,
1926
+ description="Layers for multi-mark charts. Each layer is a chart definition.",
1927
+ ),
1928
+ ]
1929
+ x: Annotated[
1930
+ str | None,
1931
+ Field(default=None, description="Shared X-axis field name for all layers."),
1932
+ ]
1933
+ # None = no sort; layers render in query order.
1934
+ sort: Annotated[
1935
+ ChartSort | None,
1936
+ Field(default=None, description="Sort configuration for the shared X axis."),
1937
+ ]
1938
+ x_domain: Annotated[
1939
+ Literal["union", "primary"] | None,
1940
+ Field(
1941
+ default=None,
1942
+ description="Controls how x-values from multiple layer queries are combined.",
1943
+ ),
1944
+ ]
1945
+ data_table: Annotated[
1946
+ ChartDataTable | None,
1947
+ Field(
1948
+ default=None, description="Optional mini data-grid attached to the chart."
1949
+ ),
1950
+ ]
1951
+ # None = no format override.
1952
+ format: Annotated[
1953
+ str | FormatConfig | None,
1954
+ Field(default=None, description="Number format override."),
1955
+ ]
1956
+ # Layered uses a flat bespoke style surface (LayeredChartStyle).
1957
+ style: Annotated[
1958
+ LayeredChartStyle | None,
1959
+ Field(default=None, description="Chart-local style overrides."),
1960
+ ]
1961
+
1962
+ @model_validator(mode="after")
1963
+ def _validate_layered_constraints(self) -> LayeredChart:
1964
+ if self.x_domain is not None:
1965
+ if self.layers and self.layers[0].query is None:
1966
+ raise ValueError(
1967
+ "x_domain requires the first layer to specify its own `query:`. "
1968
+ "The x-domain anchor is applied to the first layer's dataset; "
1969
+ "a first layer without a per-layer query has no dataset to anchor to."
1970
+ )
1971
+ if self.data_table is not None:
1972
+ for i, layer in enumerate(self.layers):
1973
+ if layer.query is not None:
1974
+ raise ValueError(
1975
+ f"chart.data_table is not supported on layered "
1976
+ f"charts with per-layer `query:` (layer {i} sets "
1977
+ f"query={layer.query!r})."
1978
+ )
1979
+ if layer.x is not None and layer.x != self.x:
1980
+ if self.x is None:
1981
+ raise ValueError(
1982
+ f"chart.data_table requires a chart-level `x:` "
1983
+ f"on layered charts (layer {i} sets x={layer.x!r} "
1984
+ f"but the chart itself has no x)."
1985
+ )
1986
+ raise ValueError(
1987
+ f"chart.data_table is not supported on layered "
1988
+ f"charts with per-layer `x:` that differs from the "
1989
+ f"chart-level x (layer {i} sets x={layer.x!r}, "
1990
+ f"chart.x={self.x!r})."
1991
+ )
1992
+ validate_data_table_shape(self.data_table.entries)
1993
+ return self
1994
+
1995
+
1996
+ # --- Callout family ---
1997
+
1998
+
1999
+ class CalloutChart(BaseModel):
2000
+ """Static callout/message chart. Minimal — no chrome, no styling, no query."""
2001
+
2002
+ model_config = ConfigDict(extra="forbid")
2003
+
2004
+ type: Annotated[Literal["callout"], Field(description="Callout chart type.")]
2005
+ message: Annotated[str, Field(description="Static message content.")]
2006
+ title: Annotated[
2007
+ str | None,
2008
+ Field(
2009
+ default=None, description="Optional chart title shown above the message."
2010
+ ),
2011
+ ] = None
2012
+ style: Annotated[
2013
+ CalloutChartStylePatch | None,
2014
+ Field(default=None, description="Chart-local style overrides (tone)."),
2015
+ ] = None
2016
+ warnings_ignore: Annotated[
2017
+ list[str] | None,
2018
+ Field(
2019
+ default=None,
2020
+ description="Codes of render warnings to suppress for this chart.",
2021
+ ),
2022
+ ] = None
2023
+
2024
+ @model_validator(mode="before")
2025
+ @classmethod
2026
+ def _reject_tone_at_chart_root(cls, data: Any) -> Any:
2027
+ if isinstance(data, dict) and "tone" in data:
2028
+ raise ValueError(
2029
+ "'tone:' has moved from the callout chart root into the style namespace.\n"
2030
+ "Use style.tone instead:\n\n"
2031
+ " style:\n"
2032
+ " tone: negative\n"
2033
+ )
2034
+ return data
2035
+
2036
+
2037
+ # --- SparkBar family ---
2038
+
2039
+
2040
+ class SparkBarChart(_SharedChartFields):
2041
+ """Authored patch for spark_bar charts (compact horizontal bars)."""
2042
+
2043
+ model_config = ConfigDict(extra="forbid")
2044
+
2045
+ type: Annotated[Literal["spark_bar"], Field(description="SparkBar chart type.")]
2046
+ # None = auto-detected from query columns.
2047
+ x: Annotated[
2048
+ str | None, Field(default=None, description="X-axis (label) field name.")
2049
+ ]
2050
+ y: Annotated[
2051
+ str | list[str] | None,
2052
+ Field(default=None, description="Y-axis (value) field name(s)."),
2053
+ ]
2054
+ style: Annotated[
2055
+ SparkBarChartStylePatch | None,
2056
+ Field(default=None, description="Chart-local style overrides."),
2057
+ ]
2058
+
2059
+
2060
+ # ============================================================================
2061
+ # AuthoredChart DISCRIMINATED UNION ALIAS
2062
+ # ============================================================================
2063
+
2064
+ # Set of type values that have a dedicated family patch class.
2065
+ _DISCRIMINATED_AUTHORED_TYPES: frozenset[str] = frozenset(
2066
+ {
2067
+ "bar",
2068
+ "histogram",
2069
+ "line",
2070
+ "area",
2071
+ "scatter",
2072
+ "heatmap",
2073
+ "pie",
2074
+ "donut",
2075
+ "kpi",
2076
+ "table",
2077
+ "point_map",
2078
+ "bubble_map",
2079
+ "map",
2080
+ "geoshape",
2081
+ "layered",
2082
+ "callout",
2083
+ "spark_bar",
2084
+ }
2085
+ )
2086
+
2087
+
2088
+ def _discriminate_authored_chart(v: Any) -> str | None:
2089
+ """Custom discriminator: return type tag for the AuthoredChart union.
2090
+
2091
+ Returns None for unknown or missing type, which triggers Pydantic's
2092
+ union_tag_not_found ValidationError — explicit and loud.
2093
+ """
2094
+ if isinstance(v, dict):
2095
+ t = v.get("type")
2096
+ elif isinstance(v, _SharedChartFields):
2097
+ t = v.type
2098
+ else:
2099
+ return None
2100
+ return t if isinstance(t, str) and t in _DISCRIMINATED_AUTHORED_TYPES else None
2101
+
2102
+
2103
+ AuthoredChart = Annotated[
2104
+ Annotated[BarChart, Tag("bar")]
2105
+ | Annotated[BarChart, Tag("histogram")]
2106
+ | Annotated[LineChart, Tag("line")]
2107
+ | Annotated[AreaChart, Tag("area")]
2108
+ | Annotated[ScatterChart, Tag("scatter")]
2109
+ | Annotated[HeatmapChart, Tag("heatmap")]
2110
+ | Annotated[PieChart, Tag("pie")]
2111
+ | Annotated[PieChart, Tag("donut")]
2112
+ | Annotated[KpiChart, Tag("kpi")]
2113
+ | Annotated[TableChart, Tag("table")]
2114
+ | Annotated[PointMapChart, Tag("point_map")]
2115
+ | Annotated[PointMapChart, Tag("bubble_map")]
2116
+ | Annotated[GeoshapeChart, Tag("map")]
2117
+ | Annotated[GeoshapeChart, Tag("geoshape")]
2118
+ | Annotated[LayeredChart, Tag("layered")]
2119
+ | Annotated[CalloutChart, Tag("callout")]
2120
+ | Annotated[SparkBarChart, Tag("spark_bar")],
2121
+ Discriminator(_discriminate_authored_chart),
2122
+ ]
2123
+ """Discriminated union of per-family authored chart patches.
2124
+
2125
+ AuthoredChart is a type alias, not a BaseModel. Use TypeAdapter(AuthoredChart) for
2126
+ validation. Use isinstance(item, _SharedChartFields) to check if an object
2127
+ is any kind of chart patch. type: is mandatory; missing or unknown type raises
2128
+ a union_tag_not_found ValidationError.
2129
+ """
2130
+
2131
+ # ============================================================================
2132
+ # GEO CHART TYPES
2133
+ # ============================================================================
2134
+
2135
+ GEO_CHART_TYPES: frozenset[str] = frozenset(
2136
+ {"map", "geoshape", "point_map", "bubble_map"}
2137
+ )