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.
- d3_format/__init__.py +14 -0
- d3_format/errors.py +19 -0
- d3_format/format.py +551 -0
- d3_format/spec.py +159 -0
- dataface/DATAFACE_SYNTAX.md +1135 -0
- dataface/__init__.py +93 -0
- dataface/_docs_site.py +20 -0
- dataface/_install_hint.py +26 -0
- dataface/agent_api/__init__.py +79 -0
- dataface/agent_api/_init_templates/__init__.py +0 -0
- dataface/agent_api/_init_templates/agents_dft_snippet.md +26 -0
- dataface/agent_api/_init_templates/dataface.yml +15 -0
- dataface/agent_api/_init_templates/faces-dataface.yml +144 -0
- dataface/agent_api/_init_templates/index.md +24 -0
- dataface/agent_api/_paths.py +118 -0
- dataface/agent_api/_project_agents_md.py +43 -0
- dataface/agent_api/_session_store.py +486 -0
- dataface/agent_api/_state.py +28 -0
- dataface/agent_api/chat.py +221 -0
- dataface/agent_api/dashboards.py +257 -0
- dataface/agent_api/describe.py +366 -0
- dataface/agent_api/describe_query.py +120 -0
- dataface/agent_api/docs/__init__.py +25 -0
- dataface/agent_api/docs/_loader.py +292 -0
- dataface/agent_api/docs/yaml-reference.md +2757 -0
- dataface/agent_api/file_refs.py +118 -0
- dataface/agent_api/init.py +126 -0
- dataface/agent_api/inspect.py +128 -0
- dataface/agent_api/mcp_install.py +170 -0
- dataface/agent_api/query.py +274 -0
- dataface/agent_api/schema.py +658 -0
- dataface/agent_api/schema_search.py +284 -0
- dataface/agent_api/search.py +270 -0
- dataface/agent_api/skill_install.py +141 -0
- dataface/agent_api/skill_render.py +90 -0
- dataface/agent_api/skills.py +293 -0
- dataface/agent_api/surface_aliases.yaml +128 -0
- dataface/agent_api/validate.py +175 -0
- dataface/agent_api/validate_query.py +84 -0
- dataface/ai/__init__.py +39 -0
- dataface/ai/agent.py +139 -0
- dataface/ai/context.py +45 -0
- dataface/ai/events.py +62 -0
- dataface/ai/external_mcp.py +610 -0
- dataface/ai/generate_sql.py +96 -0
- dataface/ai/llm.py +403 -0
- dataface/ai/mcp/__init__.py +51 -0
- dataface/ai/mcp/server.py +289 -0
- dataface/ai/memories.py +85 -0
- dataface/ai/prompts.py +177 -0
- dataface/ai/schema_context.py +138 -0
- dataface/ai/skills/before-after-comparison/SKILL.md +102 -0
- dataface/ai/skills/before-after-comparison/examples/before-after-comparison.yml +24 -0
- dataface/ai/skills/dashboard-build/SKILL.md +212 -0
- dataface/ai/skills/dashboard-build/examples/_smoke.yml +15 -0
- dataface/ai/skills/dashboard-design/SKILL.md +182 -0
- dataface/ai/skills/dashboard-review/SKILL.md +113 -0
- dataface/ai/skills/dashboard-structural-review/SKILL.md +173 -0
- dataface/ai/skills/dashboard-visual-review/SKILL.md +139 -0
- dataface/ai/skills/dataface-mcp-setup/SKILL.md +177 -0
- dataface/ai/skills/dataface-troubleshooting/SKILL.md +225 -0
- dataface/ai/skills/drill-down-link/SKILL.md +112 -0
- dataface/ai/skills/drill-down-link/examples/drill-down-link.yml +27 -0
- dataface/ai/skills/faceted-small-multiples/SKILL.md +116 -0
- dataface/ai/skills/faceted-small-multiples/examples/faceted-small-multiples.yml +33 -0
- dataface/ai/skills/filter-bar-with-variables/SKILL.md +105 -0
- dataface/ai/skills/filter-bar-with-variables/examples/filter-bar-with-variables.yml +49 -0
- dataface/ai/skills/kpi-row/SKILL.md +101 -0
- dataface/ai/skills/kpi-row/examples/kpi-row.yml +55 -0
- dataface/ai/skills/report-design/SKILL.md +184 -0
- dataface/ai/skills/single-metric-bignum/SKILL.md +90 -0
- dataface/ai/skills/single-metric-bignum/examples/single-metric-bignum.yml +27 -0
- dataface/ai/skills/table-heavy-ops-dashboard/SKILL.md +114 -0
- dataface/ai/skills/table-heavy-ops-dashboard/examples/table-heavy-ops-dashboard.yml +48 -0
- dataface/ai/skills/time-series-trend/SKILL.md +93 -0
- dataface/ai/skills/time-series-trend/examples/time-series-trend.yml +26 -0
- dataface/ai/skills/top-n-with-detail/SKILL.md +98 -0
- dataface/ai/skills/top-n-with-detail/examples/top-n-with-detail.yml +45 -0
- dataface/ai/skills/two-by-two-grid-overview/SKILL.md +78 -0
- dataface/ai/skills/two-by-two-grid-overview/examples/two-by-two-grid-overview.yml +64 -0
- dataface/ai/tool_schemas.py +132 -0
- dataface/ai/tools/__init__.py +312 -0
- dataface/ai/yaml_utils.py +57 -0
- dataface/cli/__init__.py +3 -0
- dataface/cli/_console.py +48 -0
- dataface/cli/_error_format.py +83 -0
- dataface/cli/_extras.py +190 -0
- dataface/cli/_json_output.py +8 -0
- dataface/cli/_parsing.py +17 -0
- dataface/cli/_version_info.py +56 -0
- dataface/cli/commands/__init__.py +3 -0
- dataface/cli/commands/_agent_input.py +205 -0
- dataface/cli/commands/_agent_server.py +115 -0
- dataface/cli/commands/chat.py +645 -0
- dataface/cli/commands/describe.py +107 -0
- dataface/cli/commands/docs.py +131 -0
- dataface/cli/commands/extension.py +179 -0
- dataface/cli/commands/init.py +240 -0
- dataface/cli/commands/inspect.py +94 -0
- dataface/cli/commands/mcp_init.py +167 -0
- dataface/cli/commands/query.py +386 -0
- dataface/cli/commands/render.py +291 -0
- dataface/cli/commands/schema.py +411 -0
- dataface/cli/commands/search.py +49 -0
- dataface/cli/commands/serve.py +114 -0
- dataface/cli/commands/skills.py +133 -0
- dataface/cli/commands/skills_init.py +161 -0
- dataface/cli/commands/validate.py +63 -0
- dataface/cli/main.py +1501 -0
- dataface/core/__init__.py +75 -0
- dataface/core/compile/__init__.py +244 -0
- dataface/core/compile/_jinja_helpers.py +78 -0
- dataface/core/compile/channel.py +222 -0
- dataface/core/compile/chart_focus.py +101 -0
- dataface/core/compile/chart_resolved.py +169 -0
- dataface/core/compile/chart_type_detection.py +489 -0
- dataface/core/compile/chart_update.py +261 -0
- dataface/core/compile/colors.py +64 -0
- dataface/core/compile/compiler.py +904 -0
- dataface/core/compile/config.py +823 -0
- dataface/core/compile/custom_chart_types.py +208 -0
- dataface/core/compile/data_table_attachment.py +1287 -0
- dataface/core/compile/detect.py +110 -0
- dataface/core/compile/errors.py +302 -0
- dataface/core/compile/filter_injection.py +319 -0
- dataface/core/compile/introspection.py +527 -0
- dataface/core/compile/jinja.py +511 -0
- dataface/core/compile/labels_env.py +52 -0
- dataface/core/compile/markdown.py +154 -0
- dataface/core/compile/meta.py +388 -0
- dataface/core/compile/models/__init__.py +0 -0
- dataface/core/compile/models/chart/__init__.py +0 -0
- dataface/core/compile/models/chart/authored.py +2137 -0
- dataface/core/compile/models/chart/compiled.py +398 -0
- dataface/core/compile/models/config.py +347 -0
- dataface/core/compile/models/face/__init__.py +0 -0
- dataface/core/compile/models/face/authored.py +659 -0
- dataface/core/compile/models/face/compiled.py +522 -0
- dataface/core/compile/models/factories.py +201 -0
- dataface/core/compile/models/markers.py +40 -0
- dataface/core/compile/models/palette.py +36 -0
- dataface/core/compile/models/primitives.py +415 -0
- dataface/core/compile/models/query/__init__.py +0 -0
- dataface/core/compile/models/query/authored.py +246 -0
- dataface/core/compile/models/query/compiled.py +710 -0
- dataface/core/compile/models/refs.py +137 -0
- dataface/core/compile/models/source.py +611 -0
- dataface/core/compile/models/style/__init__.py +0 -0
- dataface/core/compile/models/style/authored.py +481 -0
- dataface/core/compile/models/style/compiled.py +3399 -0
- dataface/core/compile/models/style/merged.py +1682 -0
- dataface/core/compile/models/theme.py +362 -0
- dataface/core/compile/models/variable/__init__.py +0 -0
- dataface/core/compile/models/variable/authored.py +254 -0
- dataface/core/compile/models/vega_lite/__init__.py +0 -0
- dataface/core/compile/models/vega_lite/config.py +510 -0
- dataface/core/compile/models/vega_lite/contracts.py +171 -0
- dataface/core/compile/normalize_charts.py +494 -0
- dataface/core/compile/normalize_layout.py +1000 -0
- dataface/core/compile/normalize_queries.py +297 -0
- dataface/core/compile/normalize_variables.py +489 -0
- dataface/core/compile/normalizer.py +543 -0
- dataface/core/compile/palette.py +1100 -0
- dataface/core/compile/parameterized.py +658 -0
- dataface/core/compile/parser.py +228 -0
- dataface/core/compile/schema.py +20 -0
- dataface/core/compile/schema_renderers/__init__.py +0 -0
- dataface/core/compile/schema_renderers/json_schema.py +163 -0
- dataface/core/compile/schema_renderers/prompt.py +152 -0
- dataface/core/compile/schema_renderers/vscode_schema.py +301 -0
- dataface/core/compile/sizing.py +2126 -0
- dataface/core/compile/sources.py +518 -0
- dataface/core/compile/sql_authoring_lint.py +56 -0
- dataface/core/compile/style_cascade.py +471 -0
- dataface/core/compile/typography.py +299 -0
- dataface/core/compile/validator.py +301 -0
- dataface/core/compile/variables.py +53 -0
- dataface/core/compile/vega_config.py +98 -0
- dataface/core/compile/vega_lite/__init__.py +6 -0
- dataface/core/compile/vega_lite/validation.py +95 -0
- dataface/core/compile/yaml_error_formatter.py +838 -0
- dataface/core/connections.py +38 -0
- dataface/core/dashboard.py +358 -0
- dataface/core/defaults/default_config.yml +101 -0
- dataface/core/defaults/palettes/categorical/category-10-dark.yml +32 -0
- dataface/core/defaults/palettes/categorical/category-10-light.yml +43 -0
- dataface/core/defaults/palettes/categorical/category-10.yml +31 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-blue.yml +22 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-brown.yml +29 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-green.yml +20 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-orange.yml +21 -0
- dataface/core/defaults/palettes/categorical/category-6-tonal-purple.yml +20 -0
- dataface/core/defaults/palettes/categorical/editorial-10-dark.yml +32 -0
- dataface/core/defaults/palettes/categorical/editorial-10.yml +40 -0
- dataface/core/defaults/palettes/categorical/hero-6.yml +17 -0
- dataface/core/defaults/palettes/categorical/single-blue.yml +11 -0
- dataface/core/defaults/palettes/categorical/tableau.yml +20 -0
- dataface/core/defaults/palettes/data/xkcd_colors.json +3803 -0
- dataface/core/defaults/palettes/diverging/blue-red.yml +25 -0
- dataface/core/defaults/palettes/diverging/coolwarm.yml +24 -0
- dataface/core/defaults/palettes/diverging/crimson-green.yml +23 -0
- dataface/core/defaults/palettes/diverging/orange-teal.yml +23 -0
- dataface/core/defaults/palettes/diverging/sunset.yml +24 -0
- dataface/core/defaults/palettes/scaffold/dft-creams.yml +38 -0
- dataface/core/defaults/palettes/scaffold/dft-grays.yml +53 -0
- dataface/core/defaults/palettes/sequential/amber.yml +22 -0
- dataface/core/defaults/palettes/sequential/blue.yml +22 -0
- dataface/core/defaults/palettes/sequential/brown.yml +22 -0
- dataface/core/defaults/palettes/sequential/gray.yml +22 -0
- dataface/core/defaults/palettes/sequential/green.yml +22 -0
- dataface/core/defaults/palettes/sequential/purple.yml +22 -0
- dataface/core/defaults/palettes/sequential/rust.yml +22 -0
- dataface/core/defaults/palettes/sequential/teal.yml +22 -0
- dataface/core/defaults/palettes/tone/negative.yml +32 -0
- dataface/core/defaults/palettes/tone/positive.yml +22 -0
- dataface/core/defaults/palettes/tone/warning.yml +22 -0
- dataface/core/defaults/themes/_base.yaml +786 -0
- dataface/core/defaults/themes/bi.yaml +16 -0
- dataface/core/defaults/themes/carbong100.yaml +41 -0
- dataface/core/defaults/themes/cream.yaml +122 -0
- dataface/core/defaults/themes/dark.yaml +40 -0
- dataface/core/defaults/themes/diagnostics-title-angle-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-baseline-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-baseline.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-center.yaml +8 -0
- dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +25 -0
- dataface/core/defaults/themes/diagnostics-title-left.yaml +8 -0
- dataface/core/defaults/themes/diagnostics-title-offset-extreme.yaml +9 -0
- dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +24 -0
- dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +24 -0
- dataface/core/defaults/themes/editorial.yaml +147 -0
- dataface/core/defaults/themes/light.yaml +30 -0
- dataface/core/defaults/themes/looker.yaml +17 -0
- dataface/core/defaults/themes/stark.yaml +134 -0
- dataface/core/errors/__init__.py +67 -0
- dataface/core/errors/codes_compile.py +56 -0
- dataface/core/errors/codes_execute.py +177 -0
- dataface/core/errors/codes_render.py +106 -0
- dataface/core/errors/codes_unknown.py +15 -0
- dataface/core/errors/hints.py +74 -0
- dataface/core/errors/registry.py +42 -0
- dataface/core/errors/structured.py +92 -0
- dataface/core/execute/__init__.py +91 -0
- dataface/core/execute/adapters/__init__.py +49 -0
- dataface/core/execute/adapters/adapter_registry.py +400 -0
- dataface/core/execute/adapters/base.py +245 -0
- dataface/core/execute/adapters/csv_adapter.py +239 -0
- dataface/core/execute/adapters/dbt_adapter.py +283 -0
- dataface/core/execute/adapters/dbt_adapter_factory.py +212 -0
- dataface/core/execute/adapters/dbt_macro_loader.py +95 -0
- dataface/core/execute/adapters/dbt_utils.py +150 -0
- dataface/core/execute/adapters/http_adapter.py +224 -0
- dataface/core/execute/adapters/metricflow_adapter.py +94 -0
- dataface/core/execute/adapters/schema_resolver_adapter.py +144 -0
- dataface/core/execute/adapters/sql_adapter.py +710 -0
- dataface/core/execute/adapters/values_adapter.py +58 -0
- dataface/core/execute/batch.py +744 -0
- dataface/core/execute/cache_backend.py +135 -0
- dataface/core/execute/cache_keys.py +66 -0
- dataface/core/execute/dbt_jinja.py +21 -0
- dataface/core/execute/dialects/__init__.py +121 -0
- dataface/core/execute/dialects/athena.py +75 -0
- dataface/core/execute/dialects/base.py +302 -0
- dataface/core/execute/dialects/bigquery.py +38 -0
- dataface/core/execute/dialects/databricks.py +68 -0
- dataface/core/execute/dialects/duckdb.py +35 -0
- dataface/core/execute/dialects/mysql.py +68 -0
- dataface/core/execute/dialects/postgres.py +39 -0
- dataface/core/execute/dialects/redshift.py +12 -0
- dataface/core/execute/dialects/snowflake.py +51 -0
- dataface/core/execute/dialects/sqlserver.py +92 -0
- dataface/core/execute/duckdb_cache.py +712 -0
- dataface/core/execute/duckdb_config.py +26 -0
- dataface/core/execute/errors.py +213 -0
- dataface/core/execute/executor.py +1249 -0
- dataface/core/execute/parallel.py +162 -0
- dataface/core/execute/setup_sql.py +58 -0
- dataface/core/execute/source_registry.py +72 -0
- dataface/core/execute/source_resolver.py +255 -0
- dataface/core/execute/sql_guard.py +387 -0
- dataface/core/execute/sql_literals.py +199 -0
- dataface/core/fonts.py +52 -0
- dataface/core/inspect/__init__.py +32 -0
- dataface/core/inspect/cache_factory.py +98 -0
- dataface/core/inspect/db_types.py +162 -0
- dataface/core/inspect/dbt_schema.py +96 -0
- dataface/core/inspect/defaults.yml +37 -0
- dataface/core/inspect/fanout_risk.py +109 -0
- dataface/core/inspect/manifest_utils.py +77 -0
- dataface/core/inspect/partials/categorical.yml +40 -0
- dataface/core/inspect/partials/date.yml +40 -0
- dataface/core/inspect/partials/numeric.yml +55 -0
- dataface/core/inspect/partition_types.py +38 -0
- dataface/core/inspect/query_validator.py +975 -0
- dataface/core/inspect/renderer.py +354 -0
- dataface/core/inspect/resolver.py +808 -0
- dataface/core/inspect/search.py +461 -0
- dataface/core/inspect/sources/__init__.py +32 -0
- dataface/core/inspect/sources/dbt.py +738 -0
- dataface/core/inspect/sources/duckdb_utils.py +66 -0
- dataface/core/inspect/templates/__init__.py +1 -0
- dataface/core/inspect/templates/categorical_column.yml +196 -0
- dataface/core/inspect/templates/charts.yml +109 -0
- dataface/core/inspect/templates/date_column.yml +248 -0
- dataface/core/inspect/templates/model.yml +138 -0
- dataface/core/inspect/templates/numeric_column.yml +261 -0
- dataface/core/inspect/templates/quality.yml +80 -0
- dataface/core/inspect/templates/string_column.yml +263 -0
- dataface/core/project_roots.py +165 -0
- dataface/core/render/__init__.py +87 -0
- dataface/core/render/board_links.py +176 -0
- dataface/core/render/chart/__init__.py +27 -0
- dataface/core/render/chart/arc_attached_table.py +251 -0
- dataface/core/render/chart/artifacts.py +16 -0
- dataface/core/render/chart/callout.py +225 -0
- dataface/core/render/chart/decisions.py +358 -0
- dataface/core/render/chart/geo.py +700 -0
- dataface/core/render/chart/kpi.py +916 -0
- dataface/core/render/chart/labels.py +76 -0
- dataface/core/render/chart/pipeline.py +818 -0
- dataface/core/render/chart/presentation.py +36 -0
- dataface/core/render/chart/profile.py +3438 -0
- dataface/core/render/chart/render_single.py +347 -0
- dataface/core/render/chart/renderers.py +193 -0
- dataface/core/render/chart/rendering.py +565 -0
- dataface/core/render/chart/serialization.py +90 -0
- dataface/core/render/chart/spark.py +496 -0
- dataface/core/render/chart/spark_bar.py +370 -0
- dataface/core/render/chart/spec_builders.py +154 -0
- dataface/core/render/chart/standard_renderer.py +2645 -0
- dataface/core/render/chart/table.py +2957 -0
- dataface/core/render/chart/table_support.py +1452 -0
- dataface/core/render/chart/tick_values.py +66 -0
- dataface/core/render/chart/time_unit_detect.py +809 -0
- dataface/core/render/chart/title_overflow.py +157 -0
- dataface/core/render/chart/type_inference.py +122 -0
- dataface/core/render/chart/validation.py +99 -0
- dataface/core/render/chart/vega_lite.py +125 -0
- dataface/core/render/chart/vega_lite_types.py +268 -0
- dataface/core/render/chart/vl_field_maps.py +346 -0
- dataface/core/render/chart_interactivity.py +24 -0
- dataface/core/render/control_registry.py +287 -0
- dataface/core/render/converters/__init__.py +24 -0
- dataface/core/render/converters/chart.py +276 -0
- dataface/core/render/converters/html.py +98 -0
- dataface/core/render/converters/pdf.py +40 -0
- dataface/core/render/converters/png.py +41 -0
- dataface/core/render/errors.py +144 -0
- dataface/core/render/face_api.py +160 -0
- dataface/core/render/faces.py +1194 -0
- dataface/core/render/font_measurement.py +48 -0
- dataface/core/render/font_support.py +197 -0
- dataface/core/render/fonts/DFTSansTabular-Regular.ttf +0 -0
- dataface/core/render/fonts/DFTSansTabular-Regular.woff2 +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.ttf +0 -0
- dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.ttf +0 -0
- dataface/core/render/fonts/InterVariable.ttf +0 -0
- dataface/core/render/fonts/InterVariable.woff2 +0 -0
- dataface/core/render/fonts/NOTO_COLOR_EMOJI_LICENSE.txt +93 -0
- dataface/core/render/fonts/NOTO_EMOJI_LICENSE.txt +93 -0
- dataface/core/render/fonts/NotoColorEmoji-Regular.ttf +0 -0
- dataface/core/render/fonts/NotoColorEmoji-Regular.woff2 +0 -0
- dataface/core/render/fonts/NotoEmoji-Regular.ttf +0 -0
- dataface/core/render/fonts/NotoEmoji-Regular.woff2 +0 -0
- dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -0
- dataface/core/render/fonts/SOURCE_SERIF_4_LICENSE.txt +98 -0
- dataface/core/render/fonts/SourceCodePro-Regular.ttf +0 -0
- dataface/core/render/fonts/SourceSerif4-Regular.ttf +0 -0
- dataface/core/render/fonts/_emoji_font_face.css +43 -0
- dataface/core/render/fonts/source-serif-4-variable-latin.woff2 +0 -0
- dataface/core/render/format_utils.py +329 -0
- dataface/core/render/geo_defaults.yml +28 -0
- dataface/core/render/json_format.py +146 -0
- dataface/core/render/layout_sizing.py +865 -0
- dataface/core/render/layouts.py +541 -0
- dataface/core/render/markdown_defaults.yml +16 -0
- dataface/core/render/missing_vars_prompt.py +79 -0
- dataface/core/render/placeholder.py +389 -0
- dataface/core/render/render_result.py +14 -0
- dataface/core/render/renderer.py +467 -0
- dataface/core/render/script_embedding.py +16 -0
- dataface/core/render/svg_utils.py +212 -0
- dataface/core/render/template_loader.py +69 -0
- dataface/core/render/templates/controls/_styles.css +606 -0
- dataface/core/render/templates/controls/checkbox.html +16 -0
- dataface/core/render/templates/controls/date.html +16 -0
- dataface/core/render/templates/controls/number.html +19 -0
- dataface/core/render/templates/controls/readonly.html +9 -0
- dataface/core/render/templates/controls/select.html +21 -0
- dataface/core/render/templates/controls/slider.html +22 -0
- dataface/core/render/templates/controls/text.html +16 -0
- dataface/core/render/templates/scripts/chart_interactivity.js +191 -0
- dataface/core/render/templates/scripts/variables.js +976 -0
- dataface/core/render/templates/svg/grid_pattern.svg +3 -0
- dataface/core/render/templates/svg/styles.css +51 -0
- dataface/core/render/terminal.py +311 -0
- dataface/core/render/terminal_charts.py +563 -0
- dataface/core/render/terminal_defaults.yml +2 -0
- dataface/core/render/terminal_layouts.py +299 -0
- dataface/core/render/terminal_text.py +31 -0
- dataface/core/render/text/__init__.py +1 -0
- dataface/core/render/text/case.py +113 -0
- dataface/core/render/text_format.py +129 -0
- dataface/core/render/utils.py +106 -0
- dataface/core/render/variable_controls.py +946 -0
- dataface/core/render/variable_input_refinement.py +140 -0
- dataface/core/render/warnings/__init__.py +15 -0
- dataface/core/render/warnings/bar_color_1_to_1_with_x.py +80 -0
- dataface/core/render/warnings/base.py +44 -0
- dataface/core/render/warnings/fanout_risk.py +15 -0
- dataface/core/render/warnings/from_query_diagnostic.py +56 -0
- dataface/core/render/warnings/missing_join_predicate.py +13 -0
- dataface/core/render/warnings/query_parse_error.py +14 -0
- dataface/core/render/warnings/query_returned_zero_rows.py +42 -0
- dataface/core/render/warnings/reaggregation.py +14 -0
- dataface/core/render/warnings/registry.py +45 -0
- dataface/core/render/warnings/suppression.py +46 -0
- dataface/core/render/warnings/temporal_single_point.py +63 -0
- dataface/core/render/warnings/unreferenced_chart.py +15 -0
- dataface/core/render/warnings/y_encoding_mostly_null.py +76 -0
- dataface/core/render/yaml_format.py +167 -0
- dataface/core/resolve_face.py +195 -0
- dataface/core/schema/__init__.py +0 -0
- dataface/core/schema/guidance.py +151 -0
- dataface/core/scoped_paths.py +59 -0
- dataface/core/serve/__init__.py +14 -0
- dataface/core/serve/bootstrap.py +39 -0
- dataface/core/serve/embedded.py +57 -0
- dataface/core/serve/port.py +129 -0
- dataface/core/serve/server.py +938 -0
- dataface/core/serve/templates/__init__.py +0 -0
- dataface/core/serve/templates/directory.yml +6 -0
- dataface/core/serve/templates/error.html.j2 +217 -0
- dataface/core/utils.py +121 -0
- dataface/core/validate.py +64 -0
- dataface/integrations/__init__.py +0 -0
- dataface/integrations/highlighting.py +351 -0
- dataface/integrations/markdown.py +537 -0
- dataface/py.typed +0 -0
- dataface-0.1.2.dist-info/METADATA +375 -0
- dataface-0.1.2.dist-info/RECORD +455 -0
- dataface-0.1.2.dist-info/WHEEL +4 -0
- dataface-0.1.2.dist-info/entry_points.txt +2 -0
- dataface-0.1.2.dist-info/licenses/LICENSE +202 -0
- mdsvg/__init__.py +168 -0
- mdsvg/fonts.py +656 -0
- mdsvg/images.py +299 -0
- mdsvg/parser.py +629 -0
- mdsvg/playground.py +284 -0
- mdsvg/py.typed +2 -0
- mdsvg/renderer.py +1623 -0
- mdsvg/style.py +355 -0
- mdsvg/types.py +200 -0
- 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
|
+
)
|