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,610 @@
|
|
|
1
|
+
"""External MCP client: discover and connect to user-configured MCP servers.
|
|
2
|
+
|
|
3
|
+
On `dft chat` start, reads the user's existing MCP client configs (same files
|
|
4
|
+
that `dft init mcp` writes), filters out the dft self-entry, spawns the remaining
|
|
5
|
+
servers over stdio using the `mcp` Python client, and merges their tools into the
|
|
6
|
+
agent's tool list namespaced as `<server>__<tool>` (double underscore, same convention
|
|
7
|
+
as Claude Code's MCP client).
|
|
8
|
+
|
|
9
|
+
Lifecycle: `ExternalMCPManager` is created once per `dft chat` session, opened
|
|
10
|
+
before the chat loop starts, and closed in the CLI's top-level finally block.
|
|
11
|
+
Per-session is mandatory because dbt MCP cold-starts in 5–10s; per-message
|
|
12
|
+
respawn would make the agent unusable.
|
|
13
|
+
|
|
14
|
+
Async/sync bridge: a single asyncio event loop lives on a background thread.
|
|
15
|
+
`call_tool` bridges to it via `asyncio.run_coroutine_threadsafe(...).result()`.
|
|
16
|
+
Never use `asyncio.run()` here — it tears down the loop and terminates the long-
|
|
17
|
+
lived stdio subprocess sessions.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
if sys.version_info >= (3, 11):
|
|
33
|
+
import tomllib
|
|
34
|
+
else:
|
|
35
|
+
import tomli as tomllib
|
|
36
|
+
|
|
37
|
+
import mcp
|
|
38
|
+
from mcp import ClientSession
|
|
39
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
40
|
+
from rich.console import Console
|
|
41
|
+
|
|
42
|
+
_console = Console(stderr=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Data types
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ServerConfig:
|
|
52
|
+
"""Parsed MCP server entry from a client config file."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
command: str
|
|
56
|
+
args: list[str] = field(default_factory=list)
|
|
57
|
+
env: dict[str, str] | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Self-MCP detection
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
_SELF_BASENAMES = {"dft", "dataface"}
|
|
65
|
+
_UV_BASENAMES = {"uvx", "uv"}
|
|
66
|
+
# Match python, python3, python3.10, python3.13, etc. — but not `pythonista` or similar
|
|
67
|
+
_PYTHON_RE = re.compile(r"python\d*(\.\d+)?$")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_self_mcp(cfg: ServerConfig) -> bool:
|
|
71
|
+
"""Return True if cfg points at dft's own MCP server.
|
|
72
|
+
|
|
73
|
+
Patterns detected:
|
|
74
|
+
1. command basename in {dft, dataface} AND args[:2] == ["mcp", "serve"]
|
|
75
|
+
2. command basename in {uvx, uv} AND args contains a dataface/dft token
|
|
76
|
+
followed eventually by "mcp" "serve"
|
|
77
|
+
3. command is a Python interpreter AND args invoke -m dataface/-m dft
|
|
78
|
+
followed by "mcp" "serve"
|
|
79
|
+
"""
|
|
80
|
+
basename = Path(cfg.command).name
|
|
81
|
+
args = cfg.args
|
|
82
|
+
|
|
83
|
+
# Pattern 1: dft/dataface directly
|
|
84
|
+
if basename in _SELF_BASENAMES:
|
|
85
|
+
return _has_mcp_serve(args)
|
|
86
|
+
|
|
87
|
+
# Pattern 2: uvx / uv run dataface mcp serve
|
|
88
|
+
# Require the three tokens to be immediately adjacent: <dft|dataface> mcp serve
|
|
89
|
+
if basename in _UV_BASENAMES:
|
|
90
|
+
for i, token in enumerate(args):
|
|
91
|
+
if token in _SELF_BASENAMES:
|
|
92
|
+
return (
|
|
93
|
+
i + 2 < len(args)
|
|
94
|
+
and args[i + 1] == "mcp"
|
|
95
|
+
and args[i + 2] == "serve"
|
|
96
|
+
)
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
# Pattern 3: python / python3 / python3.13 -m dataface mcp serve
|
|
100
|
+
# Require the three tokens immediately: <dft|dataface> mcp serve (no flags between)
|
|
101
|
+
if _PYTHON_RE.match(basename):
|
|
102
|
+
try:
|
|
103
|
+
m_idx = args.index("-m")
|
|
104
|
+
except ValueError:
|
|
105
|
+
return False
|
|
106
|
+
module_args = args[m_idx + 1 :]
|
|
107
|
+
if not module_args:
|
|
108
|
+
return False
|
|
109
|
+
if module_args[0] in _SELF_BASENAMES:
|
|
110
|
+
return (
|
|
111
|
+
len(module_args) >= 3
|
|
112
|
+
and module_args[1] == "mcp"
|
|
113
|
+
and module_args[2] == "serve"
|
|
114
|
+
)
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _has_mcp_serve(args: list[str]) -> bool:
|
|
121
|
+
"""True if args contains 'mcp' immediately followed by 'serve' anywhere."""
|
|
122
|
+
for i in range(len(args) - 1):
|
|
123
|
+
if args[i] == "mcp" and args[i + 1] == "serve":
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Config discovery
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
# (relative_path, servers_key) for each JSON-based client config
|
|
133
|
+
_JSON_CLIENT_CONFIGS: list[tuple[Path, str]] = [
|
|
134
|
+
(Path(".cursor/mcp.json"), "mcpServers"),
|
|
135
|
+
(Path(".mcp.json"), "mcpServers"), # claude-code
|
|
136
|
+
(Path(".vscode/mcp.json"), "servers"),
|
|
137
|
+
(Path(".github/copilot/mcp.json"), "servers"),
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
# (relative_path, servers_key) for each TOML-based client config
|
|
141
|
+
_TOML_CLIENT_CONFIGS: list[tuple[Path, str]] = [
|
|
142
|
+
(Path(".codex/config.toml"), "mcp_servers"), # OpenAI Codex CLI
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
# Absolute paths (global configs, not project-relative).
|
|
146
|
+
# These mirror the paths that `dft init mcp` writes, plus vendor-specific locations.
|
|
147
|
+
#
|
|
148
|
+
# ~/.cursor/mcp.json — Cursor global MCP config
|
|
149
|
+
# ~/.config/claude/config.json — written by `dft init mcp claude` (Claude Code/CLI)
|
|
150
|
+
# ~/Library/Application Support/Claude/claude_desktop_config.json — macOS Claude Desktop
|
|
151
|
+
_GLOBAL_JSON_CONFIGS: list[tuple[Path, str]] = [
|
|
152
|
+
(Path.home() / ".cursor" / "mcp.json", "mcpServers"),
|
|
153
|
+
(Path.home() / ".config" / "claude" / "config.json", "mcpServers"),
|
|
154
|
+
]
|
|
155
|
+
if sys.platform == "darwin":
|
|
156
|
+
_GLOBAL_JSON_CONFIGS.append(
|
|
157
|
+
(
|
|
158
|
+
Path.home()
|
|
159
|
+
/ "Library"
|
|
160
|
+
/ "Application Support"
|
|
161
|
+
/ "Claude"
|
|
162
|
+
/ "claude_desktop_config.json",
|
|
163
|
+
"mcpServers",
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def discover_servers(
|
|
169
|
+
config_dirs: list[Path] | None = None,
|
|
170
|
+
*,
|
|
171
|
+
global_configs: list[tuple[Path, str]] | None = None,
|
|
172
|
+
) -> list[ServerConfig]:
|
|
173
|
+
"""Read user's MCP configs, return external server configs (self filtered out).
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
config_dirs: directories to search for relative configs (default: [Path.cwd()])
|
|
177
|
+
global_configs: absolute-path configs to search (default: _GLOBAL_JSON_CONFIGS).
|
|
178
|
+
Pass [] in tests to prevent reading the developer's home directory.
|
|
179
|
+
"""
|
|
180
|
+
search_dirs = config_dirs if config_dirs is not None else [Path.cwd()]
|
|
181
|
+
effective_globals = (
|
|
182
|
+
_GLOBAL_JSON_CONFIGS if global_configs is None else global_configs
|
|
183
|
+
)
|
|
184
|
+
seen_names: set[str] = set()
|
|
185
|
+
results: list[ServerConfig] = []
|
|
186
|
+
|
|
187
|
+
for search_dir in search_dirs:
|
|
188
|
+
for rel_path, servers_key in _JSON_CLIENT_CONFIGS:
|
|
189
|
+
cfg_path = search_dir / rel_path
|
|
190
|
+
_parse_json_config(cfg_path, servers_key, seen_names, results)
|
|
191
|
+
for rel_path, servers_key in _TOML_CLIENT_CONFIGS:
|
|
192
|
+
cfg_path = search_dir / rel_path
|
|
193
|
+
_parse_toml_config(cfg_path, servers_key, seen_names, results)
|
|
194
|
+
|
|
195
|
+
for cfg_path, servers_key in effective_globals:
|
|
196
|
+
_parse_json_config(cfg_path, servers_key, seen_names, results)
|
|
197
|
+
|
|
198
|
+
return results
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _accept_server(
|
|
202
|
+
name: str,
|
|
203
|
+
cfg: ServerConfig,
|
|
204
|
+
seen_names: set[str],
|
|
205
|
+
results: list[ServerConfig],
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Validate and register a ServerConfig if it passes all guards."""
|
|
208
|
+
if "__" in name:
|
|
209
|
+
_console.print(
|
|
210
|
+
f'[yellow]MCP: skipping "{name}" — server names must not contain __ '
|
|
211
|
+
f"(would break tool-name routing)[/yellow]"
|
|
212
|
+
)
|
|
213
|
+
return
|
|
214
|
+
if name in seen_names:
|
|
215
|
+
return # first config wins
|
|
216
|
+
if is_self_mcp(cfg):
|
|
217
|
+
# Claim the name so a later global config with the same name cannot sneak in
|
|
218
|
+
# a non-dft server that happens to share the self-entry's name.
|
|
219
|
+
seen_names.add(name)
|
|
220
|
+
_console.print(
|
|
221
|
+
f'[dim]MCP: skipping self-entry "{name}" (using in-process tools)[/dim]'
|
|
222
|
+
)
|
|
223
|
+
return
|
|
224
|
+
seen_names.add(name)
|
|
225
|
+
results.append(cfg)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _parse_json_config(
|
|
229
|
+
path: Path,
|
|
230
|
+
servers_key: str,
|
|
231
|
+
seen_names: set[str],
|
|
232
|
+
results: list[ServerConfig],
|
|
233
|
+
) -> None:
|
|
234
|
+
if not path.exists():
|
|
235
|
+
return
|
|
236
|
+
try:
|
|
237
|
+
data = json.loads(path.read_text())
|
|
238
|
+
except json.JSONDecodeError:
|
|
239
|
+
_console.print(f"[yellow]MCP: {path} has invalid JSON; skipping[/yellow]")
|
|
240
|
+
return
|
|
241
|
+
except OSError as exc:
|
|
242
|
+
_console.print(f"[yellow]MCP: could not read {path}: {exc}; skipping[/yellow]")
|
|
243
|
+
return
|
|
244
|
+
if not isinstance(data, dict):
|
|
245
|
+
return
|
|
246
|
+
servers = data.get(servers_key, {})
|
|
247
|
+
if not isinstance(servers, dict):
|
|
248
|
+
return
|
|
249
|
+
for name, entry in servers.items():
|
|
250
|
+
if not isinstance(entry, dict):
|
|
251
|
+
continue
|
|
252
|
+
command = entry.get("command", "")
|
|
253
|
+
if not command:
|
|
254
|
+
continue
|
|
255
|
+
cfg = ServerConfig(
|
|
256
|
+
name=name,
|
|
257
|
+
command=command,
|
|
258
|
+
args=entry.get("args") or [],
|
|
259
|
+
env=entry.get("env"),
|
|
260
|
+
)
|
|
261
|
+
_accept_server(name, cfg, seen_names, results)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _parse_toml_config(
|
|
265
|
+
path: Path,
|
|
266
|
+
servers_key: str,
|
|
267
|
+
seen_names: set[str],
|
|
268
|
+
results: list[ServerConfig],
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Parse a TOML-format MCP config (Codex CLI's config.toml)."""
|
|
271
|
+
if not path.exists():
|
|
272
|
+
return
|
|
273
|
+
try:
|
|
274
|
+
data = tomllib.loads(path.read_text())
|
|
275
|
+
except (tomllib.TOMLDecodeError, OSError) as exc:
|
|
276
|
+
_console.print(
|
|
277
|
+
f"[yellow]MCP: {path} has invalid TOML: {exc}; skipping[/yellow]"
|
|
278
|
+
)
|
|
279
|
+
return
|
|
280
|
+
servers = data.get(servers_key, {})
|
|
281
|
+
if not isinstance(servers, dict):
|
|
282
|
+
return
|
|
283
|
+
for name, entry in servers.items():
|
|
284
|
+
if not isinstance(entry, dict):
|
|
285
|
+
continue
|
|
286
|
+
command = entry.get("command", "")
|
|
287
|
+
if not command:
|
|
288
|
+
continue
|
|
289
|
+
cfg = ServerConfig(
|
|
290
|
+
name=name,
|
|
291
|
+
command=command,
|
|
292
|
+
args=entry.get("args") or [],
|
|
293
|
+
env=entry.get("env"),
|
|
294
|
+
)
|
|
295
|
+
_accept_server(name, cfg, seen_names, results)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# Tool namespacing helpers
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _namespace_tool(server_name: str, tool: dict[str, Any]) -> dict[str, Any]:
|
|
304
|
+
"""Rewrite tool name to <server>__<tool>.
|
|
305
|
+
|
|
306
|
+
Uses `__` as the separator — matches Claude Code's MCP convention and avoids
|
|
307
|
+
`:`, which both Anthropic and OpenAI APIs reject in tool names. Built-in
|
|
308
|
+
Dataface tool names do not contain `__`, so no collision is possible.
|
|
309
|
+
"""
|
|
310
|
+
return {**tool, "name": f"{server_name}__{tool['name']}"}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _tools_for_config(server_name: str, mcp_tools: list[Any]) -> list[dict[str, Any]]:
|
|
314
|
+
"""Convert mcp.types.Tool list to canonical tool dicts with namespaced names."""
|
|
315
|
+
result = []
|
|
316
|
+
for t in mcp_tools:
|
|
317
|
+
tool_dict = {
|
|
318
|
+
"name": t.name,
|
|
319
|
+
"description": t.description or "",
|
|
320
|
+
"input_schema": t.inputSchema,
|
|
321
|
+
}
|
|
322
|
+
result.append(_namespace_tool(server_name, tool_dict))
|
|
323
|
+
return result
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# Per-server holder: persistent async task that keeps context managers alive
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class _ServerHolder:
|
|
332
|
+
"""Runs a long-lived MCP session inside a persistent asyncio task.
|
|
333
|
+
|
|
334
|
+
The MCP stdio_client and ClientSession use anyio context managers that must
|
|
335
|
+
be entered AND exited within the same task. This holder runs a coroutine on
|
|
336
|
+
the background loop that keeps the `async with` blocks open for the session's
|
|
337
|
+
lifetime and processes call_tool requests via a shared Queue.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
def __init__(self, name: str) -> None:
|
|
341
|
+
self.name = name
|
|
342
|
+
self.tools: list[dict[str, Any]] = []
|
|
343
|
+
# Set once the session is ready (or failed)
|
|
344
|
+
self._ready_event = threading.Event()
|
|
345
|
+
self._error: Exception | None = None
|
|
346
|
+
# Async queue: each item is (tool_name, args, threading.Event, result_box)
|
|
347
|
+
# None is the shutdown sentinel.
|
|
348
|
+
self._call_queue: (
|
|
349
|
+
asyncio.Queue[tuple[str, dict[str, Any], threading.Event, list[Any]] | None]
|
|
350
|
+
| None
|
|
351
|
+
) = None
|
|
352
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
353
|
+
|
|
354
|
+
def error(self) -> Exception | None:
|
|
355
|
+
"""Return the startup error, if any. Thread-safe after _ready_event is set."""
|
|
356
|
+
return self._error
|
|
357
|
+
|
|
358
|
+
def wait_ready(self, timeout: float) -> bool:
|
|
359
|
+
"""Block until ready or timed out. Returns True if connected with no error."""
|
|
360
|
+
self._ready_event.wait(timeout=timeout)
|
|
361
|
+
return self.is_connected()
|
|
362
|
+
|
|
363
|
+
async def run(self, cfg: ServerConfig, startup_timeout: float) -> None:
|
|
364
|
+
"""Persistent coroutine: open the MCP session, signal ready, service calls.
|
|
365
|
+
|
|
366
|
+
The startup_timeout applies only to initialize + tools/list. The service
|
|
367
|
+
loop has no timeout — it runs until the shutdown sentinel is received.
|
|
368
|
+
"""
|
|
369
|
+
self._loop = asyncio.get_running_loop()
|
|
370
|
+
self._call_queue = asyncio.Queue()
|
|
371
|
+
|
|
372
|
+
params = StdioServerParameters(
|
|
373
|
+
command=cfg.command,
|
|
374
|
+
args=cfg.args,
|
|
375
|
+
env=cfg.env,
|
|
376
|
+
)
|
|
377
|
+
try:
|
|
378
|
+
async with stdio_client(params) as (read, write): # noqa: SIM117
|
|
379
|
+
async with ClientSession(read, write) as session:
|
|
380
|
+
# Apply timeout only to the startup phase — cannot combine these
|
|
381
|
+
# three async with blocks: stdio_client + ClientSession must stay
|
|
382
|
+
# alive for the whole session; the timeout wraps only startup.
|
|
383
|
+
await asyncio.wait_for(
|
|
384
|
+
session.initialize(), timeout=startup_timeout
|
|
385
|
+
)
|
|
386
|
+
tools_result = await asyncio.wait_for(
|
|
387
|
+
session.list_tools(), timeout=startup_timeout
|
|
388
|
+
)
|
|
389
|
+
self.tools = _tools_for_config(cfg.name, tools_result.tools)
|
|
390
|
+
self._ready_event.set()
|
|
391
|
+
# Service call requests until shutdown sentinel (no timeout)
|
|
392
|
+
while True:
|
|
393
|
+
item = await self._call_queue.get()
|
|
394
|
+
if item is None:
|
|
395
|
+
break
|
|
396
|
+
tool_name, arguments, done_event, result_box = item
|
|
397
|
+
try:
|
|
398
|
+
result = await session.call_tool(tool_name, arguments)
|
|
399
|
+
result_box.append(result)
|
|
400
|
+
except Exception as exc: # noqa: BLE001 — keep loop alive
|
|
401
|
+
result_box.append(exc)
|
|
402
|
+
done_event.set()
|
|
403
|
+
except (mcp.McpError, asyncio.TimeoutError, OSError, EOFError) as exc:
|
|
404
|
+
# Startup/connection failure — unblock waiters with the error recorded.
|
|
405
|
+
self._error = exc
|
|
406
|
+
self._ready_event.set()
|
|
407
|
+
|
|
408
|
+
def is_connected(self) -> bool:
|
|
409
|
+
"""True if session started successfully (no error, ready event set)."""
|
|
410
|
+
return self._error is None and self._ready_event.is_set()
|
|
411
|
+
|
|
412
|
+
def call_tool_sync(
|
|
413
|
+
self, tool_name: str, arguments: dict[str, Any], timeout: float | None
|
|
414
|
+
) -> Any:
|
|
415
|
+
"""Enqueue a call_tool request and wait for the result (sync)."""
|
|
416
|
+
if self._call_queue is None or self._loop is None:
|
|
417
|
+
raise RuntimeError("Session not running")
|
|
418
|
+
done_event = threading.Event()
|
|
419
|
+
result_box: list[Any] = []
|
|
420
|
+
item = (tool_name, arguments, done_event, result_box)
|
|
421
|
+
self._loop.call_soon_threadsafe(self._call_queue.put_nowait, item)
|
|
422
|
+
if not done_event.wait(timeout=timeout):
|
|
423
|
+
raise TimeoutError(f"call_tool({tool_name!r}) timed out after {timeout}s")
|
|
424
|
+
result = result_box[0]
|
|
425
|
+
if isinstance(result, Exception):
|
|
426
|
+
raise result
|
|
427
|
+
return result
|
|
428
|
+
|
|
429
|
+
def shutdown(self) -> None:
|
|
430
|
+
if self._call_queue is not None and self._loop is not None:
|
|
431
|
+
self._loop.call_soon_threadsafe(self._call_queue.put_nowait, None)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
# ExternalMCPManager — lifecycle + dispatch
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class ExternalMCPManager:
|
|
440
|
+
"""Manages external MCP server sessions for the lifetime of a dft chat session.
|
|
441
|
+
|
|
442
|
+
Usage::
|
|
443
|
+
|
|
444
|
+
manager = ExternalMCPManager(discover_servers())
|
|
445
|
+
manager.start(timeout=10.0) # parallel startup; prints status
|
|
446
|
+
# ...run agent with manager.all_tools() merged in...
|
|
447
|
+
manager.close() # in a finally block
|
|
448
|
+
|
|
449
|
+
Tool calls route via::
|
|
450
|
+
|
|
451
|
+
manager.call_tool("dbt__list_metrics", {"filter": "rev"})
|
|
452
|
+
"""
|
|
453
|
+
|
|
454
|
+
def __init__(self, configs: list[ServerConfig]) -> None:
|
|
455
|
+
self._configs = configs
|
|
456
|
+
self._holders: dict[str, _ServerHolder] = {}
|
|
457
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
458
|
+
self._loop_thread: threading.Thread | None = None
|
|
459
|
+
|
|
460
|
+
# ------------------------------------------------------------------
|
|
461
|
+
# Startup
|
|
462
|
+
# ------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
def start(self, timeout: float = 10.0) -> None:
|
|
465
|
+
"""Spawn all servers in parallel and collect their tools.
|
|
466
|
+
|
|
467
|
+
Per-server timeout: `timeout` seconds for initialize + tools/list.
|
|
468
|
+
On failure, log a yellow warning and continue without that server.
|
|
469
|
+
"""
|
|
470
|
+
if not self._configs:
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
self._loop = asyncio.new_event_loop()
|
|
474
|
+
self._loop_thread = threading.Thread(
|
|
475
|
+
target=self._loop.run_forever, daemon=True, name="dft-mcp-loop"
|
|
476
|
+
)
|
|
477
|
+
self._loop_thread.start()
|
|
478
|
+
|
|
479
|
+
names = ", ".join(c.name for c in self._configs)
|
|
480
|
+
_console.print(f"[dim]MCP: starting {names}...[/dim]")
|
|
481
|
+
|
|
482
|
+
# Launch one persistent task per server on the background loop.
|
|
483
|
+
for cfg in self._configs:
|
|
484
|
+
holder = _ServerHolder(cfg.name)
|
|
485
|
+
self._holders[cfg.name] = holder
|
|
486
|
+
asyncio.run_coroutine_threadsafe(
|
|
487
|
+
holder.run(cfg, startup_timeout=timeout), self._loop
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Wait for all servers in parallel — each holder has its own threading.Event.
|
|
491
|
+
# Use a shared deadline so servers that start fast don't wait for slow ones.
|
|
492
|
+
deadline = time.monotonic() + timeout + 1.0
|
|
493
|
+
for name, holder in self._holders.items():
|
|
494
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
495
|
+
holder.wait_ready(remaining)
|
|
496
|
+
if not holder.is_connected():
|
|
497
|
+
err = holder.error()
|
|
498
|
+
_console.print(
|
|
499
|
+
f"[yellow]MCP: {name} failed to start "
|
|
500
|
+
f"({err}); continuing without it[/yellow]"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
loaded = [
|
|
504
|
+
f"{n} ({len(h.tools)} tools)"
|
|
505
|
+
for n, h in self._holders.items()
|
|
506
|
+
if h.is_connected()
|
|
507
|
+
]
|
|
508
|
+
failed = [n for n, h in self._holders.items() if not h.is_connected()]
|
|
509
|
+
parts = loaded + ([f"{', '.join(failed)} failed"] if failed else [])
|
|
510
|
+
_console.print(
|
|
511
|
+
f"[dim]MCP: {'; '.join(parts) if parts else 'no external tools'}[/dim]"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# ------------------------------------------------------------------
|
|
515
|
+
# Tool access
|
|
516
|
+
# ------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
def all_tools(self) -> list[dict[str, Any]]:
|
|
519
|
+
"""Return all namespaced tool dicts from all loaded servers."""
|
|
520
|
+
result = []
|
|
521
|
+
for h in self._holders.values():
|
|
522
|
+
if h.is_connected():
|
|
523
|
+
result.extend(h.tools)
|
|
524
|
+
return result
|
|
525
|
+
|
|
526
|
+
# Default per-call timeout. dbt semantic-layer queries can legitimately take
|
|
527
|
+
# 60–120s on large warehouses; 120s gives a wide margin without hanging forever.
|
|
528
|
+
_DEFAULT_TOOL_TIMEOUT: float = 120.0
|
|
529
|
+
|
|
530
|
+
def call_tool(
|
|
531
|
+
self,
|
|
532
|
+
namespaced_name: str,
|
|
533
|
+
arguments: dict[str, Any],
|
|
534
|
+
timeout: float | None = None,
|
|
535
|
+
) -> dict[str, Any]:
|
|
536
|
+
"""Call an external tool by 'server__tool' name. Bridges async → sync.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
timeout: per-call timeout in seconds (default: 120s). Pass None only
|
|
540
|
+
if you are sure the upstream tool is bounded by its own logic.
|
|
541
|
+
"""
|
|
542
|
+
if timeout is None:
|
|
543
|
+
timeout = self._DEFAULT_TOOL_TIMEOUT
|
|
544
|
+
if "__" not in namespaced_name:
|
|
545
|
+
raise ValueError(
|
|
546
|
+
f"Expected 'server__tool' format, got: {namespaced_name!r}"
|
|
547
|
+
)
|
|
548
|
+
server_name, tool_name = namespaced_name.split("__", 1)
|
|
549
|
+
holder = self._holders.get(server_name)
|
|
550
|
+
if holder is None or not holder.is_connected():
|
|
551
|
+
return {"error": f"MCP server '{server_name}' is not connected"}
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
result = holder.call_tool_sync(tool_name, arguments, timeout=timeout)
|
|
555
|
+
except TimeoutError:
|
|
556
|
+
return {
|
|
557
|
+
"error": f"Tool call '{namespaced_name}' timed out after {timeout}s"
|
|
558
|
+
}
|
|
559
|
+
except Exception as exc: # noqa: BLE001 — surface any unexpected failure
|
|
560
|
+
return {"error": f"Tool call '{namespaced_name}' failed: {exc}"}
|
|
561
|
+
|
|
562
|
+
return _flatten_call_result(result)
|
|
563
|
+
|
|
564
|
+
# ------------------------------------------------------------------
|
|
565
|
+
# Shutdown
|
|
566
|
+
# ------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
def close(self) -> None:
|
|
569
|
+
"""Signal all sessions to shut down and stop the event loop."""
|
|
570
|
+
for holder in self._holders.values():
|
|
571
|
+
holder.shutdown()
|
|
572
|
+
if self._loop is None:
|
|
573
|
+
return
|
|
574
|
+
# Brief sleep lets the shutdown sentinel drain the queue and the
|
|
575
|
+
# stdio_client/ClientSession context managers exit cleanly before we
|
|
576
|
+
# stop the loop. This is best-effort CLI teardown — precision is not
|
|
577
|
+
# required here; the daemon thread and subprocess will be killed on
|
|
578
|
+
# process exit regardless.
|
|
579
|
+
time.sleep(0.3)
|
|
580
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
581
|
+
if self._loop_thread:
|
|
582
|
+
self._loop_thread.join(timeout=3.0)
|
|
583
|
+
self._holders.clear()
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# ---------------------------------------------------------------------------
|
|
587
|
+
# Flatten CallToolResult → plain dict
|
|
588
|
+
# ---------------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _flatten_call_result(result: Any) -> dict[str, Any]:
|
|
592
|
+
"""Convert mcp.types.CallToolResult to a plain dict the agent can use."""
|
|
593
|
+
if result.isError:
|
|
594
|
+
texts = [c.text for c in result.content if hasattr(c, "text")]
|
|
595
|
+
return {"error": "\n".join(texts) or "Tool returned an error"}
|
|
596
|
+
|
|
597
|
+
if result.structuredContent:
|
|
598
|
+
return result.structuredContent
|
|
599
|
+
|
|
600
|
+
texts = [c.text for c in result.content if hasattr(c, "text")]
|
|
601
|
+
if len(texts) == 1:
|
|
602
|
+
# Try to parse as JSON for structured results; only treat dicts as structured.
|
|
603
|
+
try:
|
|
604
|
+
parsed = json.loads(texts[0])
|
|
605
|
+
if isinstance(parsed, dict):
|
|
606
|
+
return parsed
|
|
607
|
+
except (json.JSONDecodeError, ValueError):
|
|
608
|
+
pass
|
|
609
|
+
return {"content": texts[0]}
|
|
610
|
+
return {"content": "\n".join(texts)}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Shared text-to-SQL generation function.
|
|
2
|
+
|
|
3
|
+
Single code path for SQL generation used by:
|
|
4
|
+
- Eval runner (standalone question → SQL)
|
|
5
|
+
- Cloud AIService (thin async wrapper)
|
|
6
|
+
- Playground (SQL guidance for system prompts)
|
|
7
|
+
|
|
8
|
+
Uses the rich schema context from get_schema_context() and the same
|
|
9
|
+
prompt quality across all consumers.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
from dataface.ai.llm import OpenAIClient
|
|
17
|
+
|
|
18
|
+
_SQL_SYSTEM_PROMPT = """You are a SQL expert. Generate a single SQL query that answers the user's question.
|
|
19
|
+
|
|
20
|
+
{schema_context}
|
|
21
|
+
|
|
22
|
+
Rules:
|
|
23
|
+
- Return ONLY valid SQL for the database dialect shown above.
|
|
24
|
+
- Use clear column aliases so results are self-describing.
|
|
25
|
+
- Include appropriate aggregations, groupings, and ordering.
|
|
26
|
+
- Prefer explicit column names over SELECT *.
|
|
27
|
+
- Do not wrap the SQL in markdown code fences.
|
|
28
|
+
|
|
29
|
+
Respond with JSON: {{"sql": "<your SQL query>"}}"""
|
|
30
|
+
|
|
31
|
+
_SQL_GUIDANCE = """## Shared SQL Generation Rules
|
|
32
|
+
|
|
33
|
+
- Write SQL that matches the database dialect in the schema context.
|
|
34
|
+
- Use real table and column names from the provided schema context.
|
|
35
|
+
- Use clear column aliases so results are self-describing.
|
|
36
|
+
- Include appropriate aggregations, groupings, and ordering.
|
|
37
|
+
- Prefer explicit column names over `SELECT *`.
|
|
38
|
+
- Return executable SQL, not pseudocode or placeholders.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_sql_generation_guidance() -> str:
|
|
43
|
+
"""Return the shared SQL rules used across text-to-SQL consumers."""
|
|
44
|
+
return _SQL_GUIDANCE
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_sql_system_prompt(schema_context: str) -> str:
|
|
48
|
+
"""Build the shared system prompt for standalone SQL generation."""
|
|
49
|
+
return _SQL_SYSTEM_PROMPT.format(schema_context=schema_context)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def generate_sql(
|
|
53
|
+
question: str,
|
|
54
|
+
schema_context: str,
|
|
55
|
+
*,
|
|
56
|
+
client: OpenAIClient,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Generate SQL from a natural-language question using the LLM.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
question: Natural-language question about the data.
|
|
62
|
+
schema_context: Rich schema context string from get_schema_context()
|
|
63
|
+
or format_schema_context(). Includes table names, column types,
|
|
64
|
+
roles, semantic types, and value distributions.
|
|
65
|
+
client: An ``OpenAIClient`` instance.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The generated SQL query string.
|
|
69
|
+
"""
|
|
70
|
+
system_prompt = build_sql_system_prompt(schema_context)
|
|
71
|
+
|
|
72
|
+
response = client.create(
|
|
73
|
+
model=client.model,
|
|
74
|
+
input=[
|
|
75
|
+
{"role": "system", "content": system_prompt},
|
|
76
|
+
{"role": "user", "content": question},
|
|
77
|
+
],
|
|
78
|
+
text={
|
|
79
|
+
"format": {
|
|
80
|
+
"type": "json_schema",
|
|
81
|
+
"name": "SQLResponse",
|
|
82
|
+
"schema": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"properties": {
|
|
85
|
+
"sql": {"type": "string"},
|
|
86
|
+
},
|
|
87
|
+
"required": ["sql"],
|
|
88
|
+
"additionalProperties": False,
|
|
89
|
+
},
|
|
90
|
+
"strict": True,
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
parsed = json.loads(response.output_text)
|
|
96
|
+
return parsed["sql"]
|