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,818 @@
1
+ """Chart enrichment and resolution pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import dataclasses
7
+ import math
8
+ from typing import Any, Literal
9
+
10
+ from dataface.core.compile.channel import (
11
+ ResolvedStyleChannel,
12
+ normalize_chart_channels,
13
+ parse_style_channel,
14
+ )
15
+ from dataface.core.compile.chart_resolved import (
16
+ ChartLayer,
17
+ ResolvedChart,
18
+ )
19
+ from dataface.core.compile.chart_type_detection import detect_chart_type_full
20
+ from dataface.core.compile.config import get_chart_rendering
21
+ from dataface.core.compile.models.chart.authored import (
22
+ ColumnScaleConfig,
23
+ FieldConditionalFormatting,
24
+ Layer,
25
+ TableColumnConfig,
26
+ )
27
+ from dataface.core.compile.models.chart.compiled import Chart
28
+ from dataface.core.compile.models.style.authored import (
29
+ ChartStylePatch,
30
+ )
31
+ from dataface.core.compile.models.style.compiled import InferenceStyle
32
+ from dataface.core.compile.models.style.merged import (
33
+ MergedChartsStyle,
34
+ MergedStyle,
35
+ )
36
+ from dataface.core.compile.style_cascade import build_resolved_style
37
+ from dataface.core.render.chart.decisions import enrich_chart as enrich_chart_semantics
38
+ from dataface.core.render.chart.type_inference import infer_vega_type_from_data
39
+ from dataface.core.render.font_measurement import get_font_measurer
40
+
41
+ # Fraction of the chart's discrete-x width usable for axis label text. The
42
+ # remainder accounts for the other-axis label band, chart-area inset, and
43
+ # inter-bar gutters. Deliberately crude — catches the common long-vs-short
44
+ # cases cleanly. Used by `_pick_tilt_angle` to decide which tilt increment
45
+ # clears the per-band budget on a vertical bar's x-axis.
46
+ _USABLE_LABEL_AXIS_RATIO = 0.8
47
+
48
+
49
+ def _pick_tilt_angle(
50
+ x_field: str | None,
51
+ data: list[dict[str, Any]],
52
+ resolved_charts: MergedChartsStyle,
53
+ chart_width: float,
54
+ ) -> tuple[float, bool]:
55
+ """Walk axis_x.label.tilt_increments and pick the first angle that fits.
56
+
57
+ Returns ``(angle, fits)``. ``fits=False`` means every entry was too dense;
58
+ the caller should downgrade overlap to ``"parity"``.
59
+ """
60
+ increments = resolved_charts.axis_x.label.tilt_increments
61
+ # min_length=1 in the schema guarantees a non-empty list when the field is set.
62
+ # The fallback here covers the never-resolved sentinel case (e.g. axis_y).
63
+ if not increments:
64
+ return 0.0, True
65
+ if not x_field or not data:
66
+ return 0.0, True
67
+ labels = list({str(row.get(x_field, "")) for row in data[:100]})
68
+ labels = [label for label in labels if label]
69
+ n_labels = len(labels)
70
+ if n_labels == 0:
71
+ return 0.0, True
72
+ font = resolved_charts.axis_x.label.font
73
+ measurer = get_font_measurer(font.family)
74
+ max_width = max(measurer.measure(label, font.size) for label in labels)
75
+ usable_width = chart_width * _USABLE_LABEL_AXIS_RATIO
76
+ band = usable_width / n_labels
77
+ # Line height is per the rendered font; VL paints a single-line axis label.
78
+ line_height = font.size
79
+ for angle in increments:
80
+ radians = math.radians(abs(angle))
81
+ # Horizontal footprint of one label at this tilt: the label width
82
+ # projected onto x by cos(theta), plus the line-height projected by
83
+ # sin(theta) (a tilted label still bleeds half a line-height sideways).
84
+ footprint = max_width * math.cos(radians) + line_height * math.sin(radians)
85
+ if footprint <= band:
86
+ return angle, True
87
+ return increments[-1], False
88
+
89
+
90
+ def _is_discrete_axis(
91
+ x_field: str | None,
92
+ data: list[dict[str, Any]],
93
+ time_unit: str | None = None,
94
+ ) -> bool:
95
+ """True iff the x-axis field renders as discrete categorical bands.
96
+
97
+ Any non-None ``time_unit`` forces continuous — cyclical units (monthofyear,
98
+ dayofweek, weekofyear, hourofday) emit nominal-looking labels but ride a
99
+ temporal scale where parity-drop is the right reduction strategy.
100
+
101
+ Without a time_unit, classification falls back to the data:
102
+ * temporal (ISO dates), quantitative (numbers), ordinal (date-like
103
+ strings like "Q1 2024") → continuous
104
+ * nominal string categories → discrete
105
+ """
106
+ if time_unit is not None:
107
+ return False
108
+ if not x_field or not data or x_field not in data[0]:
109
+ return False
110
+ return infer_vega_type_from_data(data, x_field) == "nominal"
111
+
112
+
113
+ def _resolve_overlap_smart_x(
114
+ resolved_charts: MergedChartsStyle,
115
+ x_field: str | None,
116
+ data: list[dict[str, Any]],
117
+ chart_width: float,
118
+ time_unit: str | None = None,
119
+ authored_angle: float | None = None,
120
+ is_horizontal_bar: bool = False,
121
+ ) -> MergedChartsStyle:
122
+ """Resolve overlap='smart' on the x-axis cascade into a concrete value.
123
+
124
+ Dataface ``axis_x`` is always the categorical cascade. On horizontal bars
125
+ bar-flip routes axis_x to VL ``encoding["y"]``; on every other chart shape
126
+ it stays on VL ``encoding["x"]``.
127
+
128
+ Smart overlap contract:
129
+ * horizontal bar, no authored angle → ``angle=0`` + ``overlap='allow'``.
130
+ Horizontal bars carry category labels on VL y where tilt is wrong;
131
+ never parity-drop (every band must keep its label). Renderer grows
132
+ height from category count when the layout slot is too short.
133
+ * continuous x-axis, no authored angle → ``angle=0`` + ``overlap='parity'``.
134
+ Pins VL's adaptive daily-grain tilt and forces reliable parity-drop on
135
+ bucketed temporals.
136
+ * discrete vertical x-axis, no authored angle → walk ``tilt_increments``,
137
+ pick the first angle that fits, emit ``overlap='allow'``. If no angle
138
+ fits, fall through to the last increment + ``overlap='parity'``.
139
+ * authored angle (theme cascade or chart-local) → respect it, emit
140
+ ``overlap='allow'``. The author chose a tilt to avoid overlap.
141
+ * non-smart overlap values pass through unchanged.
142
+
143
+ ``authored_angle`` carries the chart-local angle if any; the theme-cascaded
144
+ angle lives on ``resolved_charts.axis_x.label.angle``. Either short-circuits
145
+ the picker so user-authored tilts are never overwritten.
146
+ """
147
+ label = resolved_charts.axis_x.label
148
+ if label.overlap != "smart":
149
+ return resolved_charts
150
+
151
+ effective_angle = authored_angle if authored_angle is not None else label.angle
152
+
153
+ if effective_angle is not None:
154
+ new_label = dataclasses.replace(label, overlap="allow")
155
+ elif is_horizontal_bar:
156
+ new_label = dataclasses.replace(label, overlap="allow", angle=0.0)
157
+ elif not _is_discrete_axis(x_field, data, time_unit=time_unit):
158
+ new_label = dataclasses.replace(label, overlap="parity", angle=0.0)
159
+ else:
160
+ angle, fits = _pick_tilt_angle(x_field, data, resolved_charts, chart_width)
161
+ new_label = dataclasses.replace(
162
+ label,
163
+ overlap="allow" if fits else "parity",
164
+ angle=float(angle),
165
+ )
166
+
167
+ new_axis_x = dataclasses.replace(resolved_charts.axis_x, label=new_label)
168
+ return dataclasses.replace(resolved_charts, axis_x=new_axis_x)
169
+
170
+
171
+ # Title block + view padding above/below the plot when sizing horizontal bars.
172
+ _HORIZONTAL_BAR_LAYOUT_CHROME_PX = 72.0
173
+
174
+
175
+ def count_horizontal_bar_categories(
176
+ category_field: str | None,
177
+ data: list[dict[str, Any]],
178
+ ) -> int:
179
+ """Distinct category values on the authored ``x`` field (VL y after flip)."""
180
+ if not category_field or not data:
181
+ return 0
182
+ return len(
183
+ {
184
+ str(row[category_field])
185
+ for row in data
186
+ if category_field in row and row[category_field] is not None
187
+ }
188
+ )
189
+
190
+
191
+ def min_height_for_horizontal_bar_categories(
192
+ n_categories: int,
193
+ resolved_charts: MergedChartsStyle,
194
+ ) -> float:
195
+ """Minimum chart height so every categorical band can show a flat y label."""
196
+ if n_categories <= 0:
197
+ return 0.0
198
+ label = resolved_charts.axis_x.label
199
+ bar_mark = resolved_charts.marks.bar
200
+ font_size = label.font.size
201
+ separation = label.separation
202
+ padding = label.padding
203
+ bar_size = bar_mark.size
204
+ assert (
205
+ font_size is not None
206
+ ), "axis_x.label.font.size unset — theme cascade must populate it"
207
+ assert (
208
+ separation is not None
209
+ ), "axis_x.label.separation unset — theme cascade must populate it"
210
+ assert (
211
+ padding is not None
212
+ ), "axis_x.label.padding unset — theme cascade must populate it"
213
+ assert bar_size is not None, "marks.bar.size unset — theme cascade must populate it"
214
+ label_line = font_size + 2 * padding
215
+ band_step = max(bar_size, label_line) + separation
216
+ return _HORIZONTAL_BAR_LAYOUT_CHROME_PX + n_categories * band_step
217
+
218
+
219
+ def _family_patch_for(style: ChartStylePatch | Any, chart_type: str) -> Any:
220
+ """Return the family sub-patch for chart_type, or None.
221
+
222
+ Uses the same chart-type → family-key mapping as _normalize_chart so
223
+ pipeline reads are consistent with how the patch was written.
224
+ """
225
+ from dataface.core.compile.normalize_charts import (
226
+ _AUTHORED_FAMILY_TO_MONOLITHIC_KEY,
227
+ )
228
+ from dataface.core.compile.style_cascade import _get_primary_patch
229
+
230
+ if not isinstance(style, ChartStylePatch):
231
+ return None
232
+ key = _AUTHORED_FAMILY_TO_MONOLITHIC_KEY.get(chart_type)
233
+ if key is not None:
234
+ return getattr(style, key, None)
235
+ # For types not in the mapping (layered, auto, …) fall back to the first
236
+ # non-null family patch.
237
+ return _get_primary_patch(style)
238
+
239
+
240
+ def _read_authored_format(chart: Chart | Any) -> Any:
241
+ """Extract authored y-axis format from chart.format or style.axis_y.format."""
242
+ if chart.format:
243
+ return chart.format
244
+ style = getattr(chart, "style", None)
245
+ patch = _family_patch_for(style, getattr(chart, "type", ""))
246
+ axis_y = getattr(patch, "axis_y", None) if patch is not None else None
247
+ return axis_y.format if axis_y is not None else None
248
+
249
+
250
+ def _read_authored_zero(chart: Chart | Any) -> Any:
251
+ """Extract authored scale zero from style.scale.zero."""
252
+ style = getattr(chart, "style", None)
253
+ patch = _family_patch_for(style, getattr(chart, "type", ""))
254
+ scale = getattr(patch, "scale", None) if patch is not None else None
255
+ return scale.zero if scale is not None else None
256
+
257
+
258
+ def _read_authored_stack(chart: Chart | Any) -> Any:
259
+ """Cascade stack: top-level chart.stack wins; fall back to style.stack."""
260
+ top = getattr(chart, "stack", None)
261
+ if top is not None:
262
+ return top
263
+ style = getattr(chart, "style", None)
264
+ patch = _family_patch_for(style, getattr(chart, "type", ""))
265
+ return getattr(patch, "stack", None) if patch is not None else None
266
+
267
+
268
+ def _convert_layers(
269
+ raw_layers: list[Layer] | None,
270
+ ) -> tuple[ChartLayer, ...]:
271
+ """Convert authored Layer objects to frozen ChartLayer tuples.
272
+
273
+ By the time this runs, Pydantic has already coerced dicts to Layer
274
+ (Chart.layers is ``list[Layer] | None``).
275
+ """
276
+ if not raw_layers:
277
+ return ()
278
+ return tuple(
279
+ ChartLayer(
280
+ chart_type=layer.type,
281
+ query_name=layer.query,
282
+ x=layer.x,
283
+ y=layer.y,
284
+ label=layer.label,
285
+ color=layer.color,
286
+ fill=layer.fill,
287
+ size=layer.size,
288
+ shape=layer.shape,
289
+ axis_y=(
290
+ layer.axis_y.model_dump(exclude_none=True)
291
+ if layer.axis_y is not None
292
+ else None
293
+ ),
294
+ )
295
+ for layer in raw_layers
296
+ )
297
+
298
+
299
+ def _authored_color_field(chart: Chart | Any) -> str | None:
300
+ """Return the authored color data field using the canonical parser."""
301
+ color = getattr(chart, "color", None)
302
+ if color is None:
303
+ return None
304
+ ch = parse_style_channel(color, "color")
305
+ return ch.data_field or None
306
+
307
+
308
+ def _chart_for_decisions(
309
+ chart: Chart | Any,
310
+ resolved_type: str,
311
+ ) -> Chart | Any:
312
+ """Return chart with resolved type for the decisions enrichment helper."""
313
+ if resolved_type == getattr(chart, "type", None):
314
+ return chart
315
+ if isinstance(chart, Chart):
316
+ return chart.model_copy(update={"type": resolved_type})
317
+ chart_copy = copy.copy(chart)
318
+ chart_copy.type = resolved_type
319
+ return chart_copy
320
+
321
+
322
+ def compute_enrichments(
323
+ chart: Chart | Any,
324
+ data: list[dict[str, Any]],
325
+ column_descriptions: dict[str, tuple] | None = None,
326
+ *,
327
+ inference: InferenceStyle,
328
+ ) -> dict[str, Any]:
329
+ """Compute data-driven field inferences. Returns sparse dict of inferred values only."""
330
+ if not data:
331
+ return {}
332
+
333
+ authored_zero = _read_authored_zero(chart)
334
+ infer_zero = inference.infer_zero_when_missing and authored_zero is None
335
+
336
+ chart_type = getattr(chart, "type", None) or "auto"
337
+ needs_type = chart_type == "auto"
338
+ needs_fields = (
339
+ any(getattr(chart, f, None) is None for f in ("x", "y", "theta"))
340
+ or getattr(chart, "color", None) is None
341
+ )
342
+
343
+ if not (needs_type or needs_fields or infer_zero):
344
+ return {}
345
+
346
+ inferred: dict[str, Any] = {}
347
+ has_authored_color = getattr(chart, "color", None) is not None
348
+ authored_color_field = _authored_color_field(chart)
349
+ value = getattr(chart, "value", None)
350
+ value_for_inference = value if isinstance(value, str) else None
351
+
352
+ if inference.infer_type_when_auto and needs_type:
353
+ y = getattr(chart, "y", None)
354
+ result = detect_chart_type_full(
355
+ data,
356
+ x_field=getattr(chart, "x", None),
357
+ y_field=(y[0] if isinstance(y, list) else y),
358
+ color_field=authored_color_field,
359
+ value_field=value_for_inference,
360
+ column_descriptions=column_descriptions,
361
+ )
362
+ inferred["chart_type"] = result.chart_type
363
+ if value_for_inference is None and result.value:
364
+ inferred["value"] = result.value
365
+ if getattr(chart, "theta", None) is None and result.theta:
366
+ inferred["theta"] = result.theta
367
+ if getattr(chart, "x", None) is None and result.x:
368
+ inferred["x"] = result.x
369
+ if getattr(chart, "y", None) is None and result.y:
370
+ inferred["y"] = result.y
371
+ if not has_authored_color and result.color:
372
+ inferred["color"] = result.color
373
+
374
+ resolved_type = inferred.get("chart_type", chart_type)
375
+ enriched = enrich_chart_semantics(
376
+ _chart_for_decisions(chart, resolved_type), data, column_descriptions
377
+ )
378
+
379
+ if inference.infer_fields_when_missing:
380
+ if getattr(chart, "x", None) is None and getattr(enriched, "x", None):
381
+ inferred.setdefault("x", enriched.x)
382
+ if getattr(chart, "y", None) is None and getattr(enriched, "y", None):
383
+ inferred.setdefault("y", enriched.y)
384
+ if not has_authored_color and getattr(enriched, "color", None):
385
+ inferred.setdefault("color", enriched.color)
386
+ enriched_value = getattr(enriched, "value", None)
387
+ if value_for_inference is None and isinstance(enriched_value, str):
388
+ inferred.setdefault("value", enriched_value)
389
+ if getattr(chart, "theta", None) is None and getattr(enriched, "theta", None):
390
+ inferred.setdefault("theta", enriched.theta)
391
+
392
+ if infer_zero:
393
+ enriched_style = getattr(enriched, "style", None)
394
+ if isinstance(enriched_style, ChartStylePatch):
395
+ enriched_patch = _family_patch_for(enriched_style, chart_type)
396
+ _ep_scale = (
397
+ getattr(enriched_patch, "scale", None)
398
+ if enriched_patch is not None
399
+ else None
400
+ )
401
+ if _ep_scale is not None and _ep_scale.zero is not None:
402
+ inferred["zero"] = _ep_scale.zero
403
+
404
+ return inferred
405
+
406
+
407
+ _TABLE_COLOR_CHANNELS = frozenset({"color", "background"})
408
+ _TABLE_LOWERABLE_MODES = frozenset({"gradient"})
409
+
410
+
411
+ def _build_col_map(
412
+ existing_cols: dict[str, Any],
413
+ lowerable: dict[str, Any],
414
+ ) -> dict[str, TableColumnConfig]:
415
+ """Build column→TableColumnConfig map from existing columns, updated with
416
+ per-channel gradients.
417
+ """
418
+ col_map: dict[str, TableColumnConfig] = {}
419
+ for field_name, col_cfg in existing_cols.items():
420
+ if isinstance(col_cfg, TableColumnConfig):
421
+ col_map[field_name] = col_cfg
422
+ elif isinstance(col_cfg, dict):
423
+ col_map[field_name] = TableColumnConfig.model_validate(col_cfg)
424
+
425
+ # Lower per-channel gradients (color.scale, background.scale) onto the
426
+ # target column's ColumnScaleConfig.
427
+ for ch_name, ch in lowerable.items():
428
+ field_name = ch.data_field
429
+ existing_cfg = col_map.get(field_name) or TableColumnConfig()
430
+ existing_scale = existing_cfg.scale or ColumnScaleConfig()
431
+ new_scale = existing_scale.model_copy(update={ch_name: ch.scale})
432
+ col_map[field_name] = existing_cfg.model_copy(update={"scale": new_scale})
433
+
434
+ return col_map
435
+
436
+
437
+ def _apply_col_map(
438
+ existing_cols: dict[str, Any],
439
+ col_map: dict[str, TableColumnConfig],
440
+ ) -> dict[str, TableColumnConfig]:
441
+ """Merge col_map back into the columns dict, preserving authoring order and appending new columns."""
442
+ result: dict[str, TableColumnConfig] = {}
443
+ for field_name, col_cfg in existing_cols.items():
444
+ if field_name in col_map:
445
+ result[field_name] = col_map[field_name]
446
+ elif isinstance(col_cfg, TableColumnConfig):
447
+ result[field_name] = col_cfg
448
+ elif isinstance(col_cfg, dict):
449
+ result[field_name] = TableColumnConfig.model_validate(col_cfg)
450
+ else:
451
+ result[field_name] = TableColumnConfig()
452
+ for field_name, cfg in col_map.items():
453
+ if field_name not in result:
454
+ result[field_name] = cfg
455
+ return result
456
+
457
+
458
+ def _lower_channels_to_table(
459
+ resolved: ResolvedChart,
460
+ resolved_channels: dict[str, Any],
461
+ ) -> tuple[ResolvedChart, dict[str, Any]]:
462
+ """Lower chart-level gradient channels onto ``style.columns[*].scale``.
463
+
464
+ Channels in gradient mode lower onto ``TableColumnConfig.scale`` so the
465
+ table renderer can interpolate a per-cell color from the column value.
466
+ Threshold rules (``conditional_formatting``) are read directly by the
467
+ table renderer from the chart-level block — no lowering required.
468
+ """
469
+ unsupported = [ch for ch in resolved_channels if ch not in _TABLE_COLOR_CHANNELS]
470
+ if unsupported:
471
+ raise ValueError(
472
+ f"Table charts do not support channel(s): {sorted(unsupported)}. "
473
+ "Meaningful table channels: color, background."
474
+ )
475
+
476
+ lowerable: dict[str, Any] = {}
477
+ for ch_name, ch in resolved_channels.items():
478
+ if ch_name not in _TABLE_COLOR_CHANNELS:
479
+ continue
480
+ if ch.mode not in _TABLE_LOWERABLE_MODES:
481
+ raise ValueError(
482
+ f"Table chart does not support chart.{ch_name} in '{ch.mode}' mode. "
483
+ f"Tables render one cell per row — use "
484
+ f"chart.conditional_formatting.<column>.when for threshold coloring or "
485
+ f"chart.{ch_name}: {{column: X, scale: {{palette: [...]}}}} "
486
+ "for gradient coloring."
487
+ )
488
+ lowerable[ch_name] = ch
489
+
490
+ if not lowerable:
491
+ return resolved, resolved_channels
492
+
493
+ existing_cols: dict[str, Any] = dict(resolved.columns or {})
494
+ col_map = _build_col_map(existing_cols, lowerable)
495
+ new_columns = _apply_col_map(existing_cols, col_map)
496
+ remaining = {k: v for k, v in resolved_channels.items() if k not in lowerable}
497
+ return dataclasses.replace(resolved, columns=new_columns), remaining
498
+
499
+
500
+ # Chart types where the `color` channel paints the mark (so rule.background
501
+ # lowers into a mark-fill VL condition). Other VL types (arc/pie/rect/etc.)
502
+ # also use color for the mark fill; this set is the intersection that the
503
+ # current migrator + tests exercise.
504
+ _MARK_FILL_CHART_TYPES: frozenset[str] = frozenset({"bar", "line", "area", "scatter"})
505
+
506
+
507
+ def _project_conditional_formatting(
508
+ chart: Any,
509
+ available_columns: set[str],
510
+ ) -> dict[str, ResolvedStyleChannel]:
511
+ """Project ``chart.conditional_formatting`` into per-channel conditional channels.
512
+
513
+ Projection rules:
514
+ * bar/line/area/scatter: rules with ``background`` lower into the
515
+ ``color`` channel (mark fill) as a VL condition list. Font-only rules
516
+ are ignored — marks have no text.
517
+ * kpi: rules with ``background`` lower into the ``background`` channel
518
+ (card fill). Rules with ``font.color`` lower into the ``color``
519
+ channel (number text).
520
+
521
+ Multi-column support is deferred; tests today exercise one metric column
522
+ per chart. Raises ValueError on unknown columns or multi-column usage.
523
+ """
524
+ cf: dict[str, FieldConditionalFormatting] | None = getattr(
525
+ chart, "conditional_formatting", None
526
+ )
527
+ if not cf:
528
+ return {}
529
+
530
+ chart_type = getattr(chart, "type", None)
531
+ for column_name in cf:
532
+ if available_columns and column_name not in available_columns:
533
+ raise ValueError(
534
+ f"conditional_formatting targets column '{column_name}' which is "
535
+ f"not in the query result. Available: {sorted(available_columns)}"
536
+ )
537
+
538
+ if chart_type in _MARK_FILL_CHART_TYPES:
539
+ outputs = {"color": lambda r: r.background}
540
+ elif chart_type == "kpi":
541
+ outputs = {
542
+ "background": lambda r: r.background,
543
+ "color": lambda r: r.font.color if r.font is not None else None,
544
+ }
545
+ else:
546
+ return {}
547
+
548
+ projected: dict[str, ResolvedStyleChannel] = {}
549
+ for channel_name, extractor in outputs.items():
550
+ per_column = [
551
+ (column, tuple(r for r in entry.when if extractor(r) is not None))
552
+ for column, entry in cf.items()
553
+ ]
554
+ per_column = [(c, rs) for c, rs in per_column if rs]
555
+ if not per_column:
556
+ continue
557
+ if len(per_column) > 1:
558
+ raise ValueError(
559
+ f"conditional_formatting on channel '{channel_name}' supports "
560
+ "one column's rules per chart in v1 — got rules for: "
561
+ f"{sorted(c for c, _ in per_column)}."
562
+ )
563
+ column, rules = per_column[0]
564
+ projected[channel_name] = ResolvedStyleChannel(
565
+ channel=channel_name,
566
+ mode="conditional",
567
+ data_field=column,
568
+ rules=rules,
569
+ )
570
+ return projected
571
+
572
+
573
+ def _available_columns_for_validation(
574
+ chart: Chart | Any,
575
+ data: list[dict[str, Any]],
576
+ ) -> set[str]:
577
+ """Return columns visible to chart-level validation.
578
+
579
+ Query-level pivot keeps SQL long-form but table rendering sees generated
580
+ wide columns named from the pivot column values. Validation must recognize
581
+ those generated columns or table conditional formatting cannot target them.
582
+ """
583
+ if not data:
584
+ return set()
585
+
586
+ columns = set(data[0].keys())
587
+ chart_pivot = getattr(getattr(chart, "query", None), "pivot", None)
588
+ if (
589
+ getattr(chart, "type", None) == "table"
590
+ and chart_pivot is not None
591
+ and chart_pivot.column in columns
592
+ and chart_pivot.value in columns
593
+ ):
594
+ pivot_columns = {str(row[chart_pivot.column]) for row in data}
595
+ return (columns - {chart_pivot.column, chart_pivot.value}) | pivot_columns
596
+
597
+ return columns
598
+
599
+
600
+ def resolve_chart(
601
+ chart: Chart | Any,
602
+ data: list[dict[str, Any]],
603
+ column_descriptions: dict[str, tuple] | None = None,
604
+ *,
605
+ # Intentionally optional: resolve_chart serves both visual rendering (where
606
+ # callers thread the face's MergedStyle) and non-visual resolution (type
607
+ # inference, axis analysis, label routing) where no board style is needed.
608
+ # Renderer callers that require visual fidelity must pass board_style;
609
+ # SvgRenderer and the geo branches of render_standard_vega_spec raise if
610
+ # resolved_style is absent rather than synthesising a silent default.
611
+ board_style: MergedStyle | None = None,
612
+ ) -> ResolvedChart:
613
+ """Resolve a chart into render-ready semantics."""
614
+ chart_style = getattr(chart, "style", None)
615
+ _authored_type = getattr(chart, "type", None) or "auto"
616
+ # Pass the authored chart type so build_resolved_style can apply per-family
617
+ # theme patches (e.g. editorial bar.legend.disable: false). Skip when type is
618
+ # "auto" — the concrete type isn't known until after compute_enrichments.
619
+ _build_chart_type = _authored_type if _authored_type != "auto" else None
620
+ resolved_style = build_resolved_style(
621
+ board_style, chart_style, chart_type=_build_chart_type
622
+ )
623
+ inference = resolved_style.inference
624
+
625
+ authored_format = _read_authored_format(chart)
626
+ authored_zero = _read_authored_zero(chart)
627
+ inferred = compute_enrichments(
628
+ chart, data, column_descriptions, inference=inference
629
+ )
630
+
631
+ chart_type = _authored_type
632
+ resolved_type = (
633
+ inferred.get("chart_type", chart_type) if chart_type == "auto" else chart_type
634
+ )
635
+
636
+ # When ``type: auto`` resolves to ``kpi`` at render time, the normalizer
637
+ # routed the chart-id slug into ``title`` (because chart_type was ``auto``
638
+ # at normalize time, not ``kpi``). Carry that auto-derived text into
639
+ # ``label`` so the KPI renderer reads the intended display string instead
640
+ # of falling through to the value-column name.
641
+ chart_label = getattr(chart, "label", "") or ""
642
+ chart_title = getattr(chart, "title", "") or ""
643
+ if resolved_type == "kpi" and not chart_label and chart_title:
644
+ resolved_label = chart_title
645
+ resolved_title = ""
646
+ else:
647
+ resolved_label = chart_label
648
+ resolved_title = chart_title
649
+
650
+ # Active family sub-patch for shared-field reads (axis_x, orientation, etc.)
651
+ family_patch = _family_patch_for(chart_style, chart_type)
652
+
653
+ x_field_for_overlap = getattr(chart, "x", None) or inferred.get("x")
654
+ chart_width_for_overlap = (
655
+ getattr(chart, "width", None) or get_chart_rendering().default_width
656
+ )
657
+ _fp_axis_x = (
658
+ getattr(family_patch, "axis_x", None) if family_patch is not None else None
659
+ )
660
+ chart_time_unit = _fp_axis_x.time_unit if _fp_axis_x is not None else None
661
+
662
+ # Bar orientation is type-driven. Discrete x (nominal categories)
663
+ # reads naturally horizontal; continuous x (temporal, quantitative,
664
+ # date-like ordinal, time_unit-bucketed) reads naturally vertical. Same
665
+ # `_is_discrete_axis` helper that drives the smart-tilt resolver.
666
+ authored_orientation = (
667
+ getattr(family_patch, "orientation", None) if family_patch is not None else None
668
+ )
669
+ if authored_orientation in ("horizontal", "vertical"):
670
+ orientation: Literal["horizontal", "vertical"] = authored_orientation
671
+ elif resolved_type == "bar" and _is_discrete_axis(
672
+ x_field_for_overlap, data, time_unit=chart_time_unit
673
+ ):
674
+ orientation = "horizontal"
675
+ else:
676
+ orientation = "vertical"
677
+ # Chart-local style.axis_x.label.angle lives on the chart Patch, not on the
678
+ # theme-cascaded resolved_style.axis_x — build_resolved_style routes it to
679
+ # axis_overrides_x for emit-time merging. Surface it here so the dispatcher
680
+ # can short-circuit the picker when an author specified a tilt directly.
681
+ _fp_axis_x_label = (
682
+ getattr(_fp_axis_x, "label", None) if _fp_axis_x is not None else None
683
+ )
684
+ chart_authored_angle = (
685
+ _fp_axis_x_label.angle if _fp_axis_x_label is not None else None
686
+ )
687
+ resolved_style = _resolve_overlap_smart_x(
688
+ resolved_style,
689
+ x_field_for_overlap,
690
+ data,
691
+ chart_width_for_overlap,
692
+ time_unit=chart_time_unit,
693
+ authored_angle=chart_authored_angle,
694
+ is_horizontal_bar=(resolved_type == "bar" and orientation == "horizontal"),
695
+ )
696
+
697
+ # Table-specific fields live on the table family sub-patch.
698
+ table_patch = (
699
+ getattr(chart_style, "table", None) if chart_style is not None else None
700
+ )
701
+ style_columns = table_patch.columns if table_patch is not None else None
702
+ header_overflow = table_patch.header_overflow if table_patch is not None else None
703
+ column_defaults = table_patch.column_defaults if table_patch is not None else None
704
+
705
+ resolved = ResolvedChart(
706
+ source_chart=chart,
707
+ id=getattr(chart, "id", "unknown"),
708
+ chart_type=resolved_type,
709
+ title=resolved_title,
710
+ subtitle=getattr(chart, "subtitle", "") or "",
711
+ description=getattr(chart, "description", "") or "",
712
+ label=resolved_label,
713
+ x=(
714
+ getattr(chart, "x", None)
715
+ if getattr(chart, "x", None) is not None
716
+ else inferred.get("x")
717
+ ),
718
+ y=(
719
+ getattr(chart, "y", None)
720
+ if getattr(chart, "y", None) is not None
721
+ else inferred.get("y")
722
+ ),
723
+ size=getattr(chart, "size", None),
724
+ shape=getattr(chart, "shape", None),
725
+ theta=(
726
+ getattr(chart, "theta", None)
727
+ if getattr(chart, "theta", None) is not None
728
+ else inferred.get("theta")
729
+ ),
730
+ total=getattr(chart, "total", None),
731
+ labels=getattr(chart, "labels", None),
732
+ format=authored_format,
733
+ message=getattr(chart, "message", None),
734
+ zero=authored_zero if authored_zero is not None else inferred.get("zero"),
735
+ x_label=getattr(chart, "x_label", None),
736
+ y_label=getattr(chart, "y_label", None),
737
+ geo=getattr(chart, "geo", None),
738
+ geo_source=getattr(chart, "geo_source", None),
739
+ lookup=getattr(chart, "lookup", None),
740
+ value=(
741
+ getattr(chart, "value", None)
742
+ if getattr(chart, "value", None) is not None
743
+ else inferred.get("value")
744
+ ),
745
+ support=getattr(chart, "support", None),
746
+ projection=getattr(chart, "projection", None),
747
+ latitude=getattr(chart, "latitude", None),
748
+ longitude=getattr(chart, "longitude", None),
749
+ basemap=getattr(chart, "basemap", None),
750
+ sort=getattr(chart, "sort", None),
751
+ stack=_read_authored_stack(chart),
752
+ orientation=orientation,
753
+ link=getattr(chart, "link", None),
754
+ query_name=getattr(chart, "query_name", None),
755
+ variable_dependencies=set(getattr(chart, "variable_dependencies", set())),
756
+ resolved_style=resolved_style,
757
+ columns=style_columns,
758
+ header_overflow=header_overflow,
759
+ column_defaults=column_defaults,
760
+ layers=_convert_layers(getattr(chart, "layers", None)),
761
+ x_domain=getattr(chart, "x_domain", None),
762
+ )
763
+
764
+ available_cols = _available_columns_for_validation(chart, data)
765
+ style_color = (
766
+ getattr(family_patch, "color", None) if family_patch is not None else None
767
+ )
768
+ resolved_channels = normalize_chart_channels(
769
+ chart, available_cols, style_color=style_color
770
+ )
771
+
772
+ # Project the chart-level conditional_formatting block into synthetic
773
+ # per-channel conditional channels for VL mark-fill chart types and KPI.
774
+ # Tables read the block directly at render time; projection is a no-op
775
+ # there but the unknown-column validation still runs.
776
+ projected = _project_conditional_formatting(chart, available_cols)
777
+ for channel_name, projected_ch in projected.items():
778
+ if channel_name in resolved_channels:
779
+ existing = resolved_channels[channel_name]
780
+ # Gradient + conditional on the same channel: compose them so
781
+ # threshold rules take priority and the gradient shows through when
782
+ # no rule matches. This mirrors Looker's "scale with rule override"
783
+ # behaviour and is the only safe composition — all other conflicts
784
+ # (e.g. two gradients, literal + conditional) remain an error.
785
+ if existing.mode == "gradient" and projected_ch.mode == "conditional":
786
+ projected_ch = dataclasses.replace(
787
+ projected_ch, fallback_scale=existing.scale
788
+ )
789
+ else:
790
+ raise ValueError(
791
+ f"chart.{channel_name} conflicts with conditional_formatting "
792
+ f"rules on column '{projected_ch.data_field}'. Use one surface: "
793
+ "per-channel encoding (scale/value/column) OR rule-driven "
794
+ "conditional_formatting."
795
+ )
796
+ resolved_channels[channel_name] = projected_ch
797
+
798
+ # Add enrichment-inferred color channel when the user did not author one.
799
+ # Must happen before table lowering so the injected channel passes through
800
+ # the same lowering gate as authored channels.
801
+ inferred_color = inferred.get("color")
802
+ if inferred_color and "color" not in resolved_channels:
803
+ resolved_channels = {
804
+ **resolved_channels,
805
+ "color": ResolvedStyleChannel(
806
+ channel="color", mode="series", data_field=str(inferred_color)
807
+ ),
808
+ }
809
+
810
+ if resolved.chart_type == "table" and resolved_channels:
811
+ resolved, resolved_channels = _lower_channels_to_table(
812
+ resolved, resolved_channels
813
+ )
814
+
815
+ return dataclasses.replace(
816
+ resolved,
817
+ resolved_channels=resolved_channels,
818
+ )