add plugin back

This commit is contained in:
Chenglong Wang committed 2026-05-27 18:30:03 -07:00
1 parent 3b58fd2091
commit f7f9155b6f
18 files changed
+1718 -135

No files matched your search

+1 -1
View File
@@ -249,7 +249,7 @@ Loader type 注册后,`DataConnector` 会自动把它包装成 connector insta
1. **内置 loader**:修改源码,在 `data_loader/__init__.py` 的 `_LOADER_SPECS`
列表中添加 `(registry_key, module_path, class_name, pip_package)` 条目。
2. **外部 loader 插件**:不修改仓库源码,把 `*_data_loader.py` 放到
`DF_PLUGIN_DIR` 指向的目录。默认目录是 `~/.data-formulator/plugins/`。
`DF_PLUGIN_DIR` 指向的目录。默认目录是 `~/.data_formulator/plugins/`。
内置 loader 当前**不是**包内自动发现;新增内置 loader 必须显式更新 `_LOADER_SPECS`。
零源码修改只适用于外部 `DF_PLUGIN_DIR` 插件。
+1 -1
View File
@@ -193,7 +193,7 @@ Vault 凭证同样按 `identity + connector_id` 隔离。两个用户连接同
Loader type discovery 来自 `DATA_LOADERS`:
- 内置 loader 在 `data_loader/__init__.py` 的 `_LOADER_SPECS` 中注册。
- 外部 loader 从 `DF_PLUGIN_DIR` 扫描,默认目录为 `~/.data-formulator/plugins/`。
- 外部 loader 从 `DF_PLUGIN_DIR` 扫描,默认目录为 `~/.data_formulator/plugins/`。
- 文件名必须匹配 `*_data_loader.py`,例如 `my_report_data_loader.py` 会注册为
`my_report`。
- 文件中需要定义公开的 `ExternalDataLoader` 子类;如果 key 与内置 loader 相同,
@@ -44,7 +44,7 @@ DF_PLUGIN_DIR # 不改源码外加 loader type 的目录
如果要接入一个全新的报表系统或内部数据服务,管理员可以提供一个外部 loader 文件:
```text
~/.data-formulator/plugins/my_report_data_loader.py
~/.data_formulator/plugins/my_report_data_loader.py
```
也可以通过环境变量指定目录:
+223
View File
@@ -0,0 +1,223 @@
# Data Formulator — Data Loader Plugins
Drop-in Python files that add new data-source connectors to Data
Formulator without modifying its source code. If the built-in
connectors don't cover your data source (an internal warehouse, a SaaS
API, a niche database), write a small plugin and DF will pick it up on
the next restart.
This folder contains **example plugins**. Treat them as templates: copy
one, rename it, and adapt the body.
---
## Quick start (3 steps)
1. **Find your plugin directory.** It lives under your Data Formulator
home dir:
```
$DATA_FORMULATOR_HOME/plugins/
```
If `DATA_FORMULATOR_HOME` is not set, DF defaults to
`~/.data_formulator/`, so the plugin dir is `~/.data_formulator/plugins/`.
Power users can point somewhere else with `DF_PLUGIN_DIR`
(highest precedence) — useful for sharing one plugin folder across
multiple DF installs.
2. **Copy an example into it.** For instance:
```bash
mkdir -p "${DATA_FORMULATOR_HOME:-$HOME/.data_formulator}/plugins"
cp examples/plugins/sqlite_data_loader.py \
"${DATA_FORMULATOR_HOME:-$HOME/.data_formulator}/plugins/"
```
3. **Restart Data Formulator.** The new connector appears in the UI
automatically. No registry edits, no rebuilds.
To verify it loaded, check the startup log for a line like:
```
INFO ... Plugin loader 'sqlite' registered from sqlite_data_loader.py
INFO ... Plugin scan complete: 1 registered, 0 failed (dir=..., reason=WORKSPACE_BACKEND=local)
```
### Plugin directory resolution order
| Precedence | Source | Default |
| ---------- | ----------------------------------------- | ----------------------------- |
| 1 | `DF_PLUGIN_DIR` env var (explicit override) | — |
| 2 | `$DATA_FORMULATOR_HOME/plugins` | — |
| 3 | Fallback | `~/.data_formulator/plugins/` |
---
## File-name contract
| Filename | Registry key |
| --------------------------------- | -------------- |
| `sqlite_data_loader.py` | `sqlite` |
| `acme_warehouse_data_loader.py` | `acme_warehouse` |
| `notion_data_loader.py` | `notion` |
Rules:
* The filename **must** end in `_data_loader.py`.
* The prefix becomes the registry key — keep it lowercase, no spaces.
* If the key matches a built-in (e.g. `mysql_data_loader.py`), the
plugin **overrides** the built-in. Useful for hot-patching.
---
## What goes inside the file
Each plugin defines exactly one class that subclasses
[`ExternalDataLoader`](../../py-src/data_formulator/data_loader/external_data_loader.py).
The minimum surface area:
```python
from data_formulator.data_loader.external_data_loader import (
ExternalDataLoader, MAX_IMPORT_ROWS,
)
import pyarrow as pa
class MyLoader(ExternalDataLoader):
# Optional: human-friendly UI label. Without this, the registry key
# is title-cased (``"my_warehouse"`` → ``"My Warehouse"``). Override
# to fix awkward casing (``"SQLite"``, ``"BigQuery"``).
DISPLAY_NAME = "My Warehouse"
@staticmethod
def list_params() -> list[dict]:
"""Declare connection-form fields. The UI auto-renders this."""
return [
{"name": "endpoint", "type": "string", "required": True,
"tier": "connection", "description": "Server URL"},
{"name": "token", "type": "string", "required": True,
"tier": "auth", "sensitive": True, "description": "API token"},
]
@staticmethod
def auth_instructions() -> str:
"""Markdown help text shown next to the form."""
return "Get your API token from https://example.com/settings/tokens"
def __init__(self, params: dict):
self.params = params
# validate + open connection here
def list_tables(self, table_filter: str | None = None) -> list[dict]:
"""Return catalog: [{name, metadata: {columns, row_count}}, ...]"""
...
def fetch_data_as_arrow(self, source_table: str,
import_options: dict | None = None) -> pa.Table:
"""Read rows. Honour import_options['size'] up to MAX_IMPORT_ROWS."""
...
```
Look at [`sqlite_data_loader.py`](sqlite_data_loader.py) for a runnable
implementation (~170 lines, stdlib only).
### `list_params()` field reference
| Key | Meaning |
| ------------- | ---------------------------------------------------------------- |
| `name` | Parameter key passed into `__init__(params)` |
| `type` | `"string"`, `"int"`, `"bool"`, `"password"` |
| `required` | If `True`, DF rejects connections that omit it |
| `default` | Pre-filled value in the form |
| `sensitive` | If `True`, value is redacted from logs / stored metadata |
| `tier` | `"auth"`, `"connection"`, or `"filter"` — groups fields visually |
| `description` | Help text shown under the field |
---
## Security gate — please read
Plugins execute arbitrary Python in the server process. To prevent
accidental code execution in shared deployments, the plugin scanner is
**enabled only in single-user local mode**:
* `WORKSPACE_BACKEND` unset or `local` → scanner runs.
* `WORKSPACE_BACKEND` is anything else → scanner is skipped silently.
* To opt in for a hosted deployment, set `DF_ALLOW_PLUGINS=1`
**and** make sure the plugin directory is writable only by trusted
administrators.
---
## Debugging a plugin that won't load
The scanner reports every failure two ways:
1. **In the log**, with a full traceback:
```
WARNING Failed to load plugin foo_data_loader.py: ... ModuleNotFoundError ...
```
2. **In `DISABLED_LOADERS`**, which the frontend surfaces as a greyed-out
connector with the reason. Common reasons:
| Message | Fix |
| ------------------------------------------------------ | --- |
| `missing dependency 'X' (pip install X)` | `uv pip install X` in DF's venv |
| `no ExternalDataLoader subclass found` | Add a `class Foo(ExternalDataLoader)` to the file |
| `could not create import spec` | File isn't valid Python or has wrong extension |
| `<ExceptionType>: <message>` | Anything else raised during `import` — check the traceback |
The plugin scanner cleans `sys.modules` on failure, so simply restarting
DF after editing the file picks up the fix — no manual cleanup needed.
---
## Adding third-party dependencies
If your plugin needs a package that isn't already in DF's environment:
```bash
# from the data-formulator repo root
uv pip install <package>
```
The plugin file is loaded into the same Python process as DF, so
anything importable from DF's venv is importable from your plugin.
---
## For AI coding agents
When asked to **add a new data source** to a user's Data Formulator
install, prefer writing a plugin file over modifying DF's source tree:
1. Read [`sqlite_data_loader.py`](sqlite_data_loader.py) and the
`ExternalDataLoader` ABC in
`py-src/data_formulator/data_loader/external_data_loader.py` to
understand the required methods.
2. Create a new file `<source>_data_loader.py` in this folder (or
directly in `~/.data_formulator/plugins/`).
3. Subclass `ExternalDataLoader`. Implement at minimum:
`list_params`, `auth_instructions`, `__init__`, `list_tables`,
`fetch_data_as_arrow`.
4. Return data as a `pyarrow.Table` from `fetch_data_as_arrow` — do
**not** convert to pandas in the hot path.
5. Quote identifiers when building SQL (see `_quote_ident` in the
SQLite example) to avoid injection vulnerabilities.
6. Respect `import_options['size']` and cap at `MAX_IMPORT_ROWS`.
7. If the source has credentials, mark those params `sensitive: True`
and `tier: "auth"` so DF redacts them from stored metadata.
8. Do not modify the user's data — open read-only connections where
the source supports it.
After writing the file, verify it loads with:
```bash
DF_PLUGIN_DIR=<dir> uv run python -c \
"from data_formulator import data_loader as dl; \
print(dl.PLUGIN_LOADERS, dl.DISABLED_LOADERS)"
```
+203
View File
@@ -0,0 +1,203 @@
"""SQLite data loader — example plugin for Data Formulator.
This file is a complete, working example of a Data Formulator plugin.
SQLite is part of the Python standard library, so this loader has **no
extra dependencies** and is a good template for building your own.
How to install
--------------
1. Make sure Data Formulator runs in single-user mode
(the default — ``WORKSPACE_BACKEND`` unset or ``local``).
2. Copy this file to ``~/.data_formulator/plugins/`` (or whatever
directory ``DF_PLUGIN_DIR`` points to). Filename must end in
``_data_loader.py``.
3. Restart Data Formulator. A new "sqlite" connector should appear
alongside the built-ins.
How to test it quickly
----------------------
::
sqlite3 /tmp/demo.db <<'SQL'
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER);
INSERT INTO users VALUES (1, 'Alice', 30), (2, 'Bob', 25);
SQL
Then in DF, add a SQLite connector pointing at ``/tmp/demo.db``.
What this example demonstrates
------------------------------
* Implementing every abstract method on :class:`ExternalDataLoader`.
* Declaring connection params via :meth:`list_params` so the UI
auto-renders a config form.
* Identifier quoting to avoid SQL-injection in table/column names.
* Returning data directly as a PyArrow Table (no pandas in the hot path).
* Read-only access (``mode=ro`` URI) — the loader never writes to the DB.
"""
from __future__ import annotations
import logging
import os
import sqlite3
import threading
from typing import Any
from urllib.parse import quote as _url_quote
import pyarrow as pa
from data_formulator.data_loader.external_data_loader import (
ExternalDataLoader,
MAX_IMPORT_ROWS,
)
logger = logging.getLogger(__name__)
def _quote_ident(name: str) -> str:
"""Quote a SQLite identifier safely (table or column name)."""
return '"' + str(name).replace('"', '""') + '"'
class SQLiteDataLoader(ExternalDataLoader):
"""Read tables from a local SQLite database file."""
# Override the default title-casing of the registry key ("Sqlite") with
# a properly-cased product name. Optional; remove if you don't care.
DISPLAY_NAME = "SQLite"
# ------------------------------------------------------------------ #
# Static metadata: what the UI shows + how to configure #
# ------------------------------------------------------------------ #
@staticmethod
def list_params() -> list[dict[str, Any]]:
return [
{
"name": "database_path",
"type": "string",
"required": True,
"default": "",
"tier": "connection",
"description": "Absolute path to a .db / .sqlite file",
},
]
@staticmethod
def auth_instructions() -> str:
return (
"**Example:** `/tmp/demo.db`\n\n"
"Provide the absolute path to a local SQLite database file. "
"The file must exist and be readable. The loader opens it "
"read-only — your data is never modified.\n\n"
"**Create a test DB:**\n"
"```\nsqlite3 /tmp/demo.db 'CREATE TABLE t(a,b); "
"INSERT INTO t VALUES (1,2),(3,4);'\n```"
)
# ------------------------------------------------------------------ #
# Connection #
# ------------------------------------------------------------------ #
def __init__(self, params: dict[str, Any]):
self.params = params or {}
self.database_path = (self.params.get("database_path") or "").strip()
if not self.database_path:
raise ValueError("SQLite database_path is required")
if not os.path.isfile(self.database_path):
raise ValueError(f"SQLite database not found: {self.database_path}")
# Open read-only via URI so we cannot accidentally mutate the DB.
uri = f"file:{_url_quote(self.database_path)}?mode=ro"
try:
self._conn = sqlite3.connect(
uri, uri=True, check_same_thread=False, isolation_level=None,
)
except sqlite3.Error as e:
raise ValueError(
f"Failed to open SQLite database '{self.database_path}': {e}"
) from e
self._lock = threading.Lock()
logger.info("SQLite plugin connected: %s", self.database_path)
# ------------------------------------------------------------------ #
# Helpers #
# ------------------------------------------------------------------ #
def _read_sql(self, query: str) -> pa.Table:
"""Run a query and return the result as a PyArrow Table."""
with self._lock:
cur = self._conn.execute(query)
if cur.description is None:
return pa.table({})
columns = [d[0] for d in cur.description]
rows = cur.fetchall()
# Transpose rows to per-column lists; pyarrow infers types.
col_data: dict[str, list[Any]] = {c: [] for c in columns}
for row in rows:
for col, value in zip(columns, row):
col_data[col].append(value)
return pa.table(col_data)
# ------------------------------------------------------------------ #
# Required loader API #
# ------------------------------------------------------------------ #
def list_tables(self, table_filter: str | None = None) -> list[dict[str, Any]]:
"""List every table + view in the database."""
names_tbl = self._read_sql(
"SELECT name, type FROM sqlite_master "
"WHERE type IN ('table', 'view') "
"AND name NOT LIKE 'sqlite_%' "
"ORDER BY name"
)
if names_tbl.num_rows == 0:
return []
results: list[dict[str, Any]] = []
for name in names_tbl.column("name").to_pylist():
if table_filter and table_filter.lower() not in name.lower():
continue
# PRAGMA table_info gives column name + declared type.
info = self._read_sql(f"PRAGMA table_info({_quote_ident(name)})")
columns = [
{"name": n, "type": t or "ANY"}
for n, t in zip(
info.column("name").to_pylist() if info.num_rows else [],
info.column("type").to_pylist() if info.num_rows else [],
)
]
count_tbl = self._read_sql(
f"SELECT COUNT(*) AS n FROM {_quote_ident(name)}"
)
row_count = int(count_tbl.column("n")[0].as_py()) if count_tbl.num_rows else 0
results.append({
"name": name,
"metadata": {"columns": columns, "row_count": row_count},
})
return results
def fetch_data_as_arrow(
self,
source_table: str,
import_options: dict[str, Any] | None = None,
) -> pa.Table:
"""Fetch rows from ``source_table`` as a PyArrow Table."""
if not source_table:
raise ValueError("source_table must be provided")
opts = import_options or {}
size = min(int(opts.get("size", MAX_IMPORT_ROWS)), MAX_IMPORT_ROWS)
sort_columns = opts.get("sort_columns") or []
sort_order = "DESC" if str(opts.get("sort_order", "asc")).lower() == "desc" else "ASC"
query = f"SELECT * FROM {_quote_ident(source_table)}"
if sort_columns:
order_by = ", ".join(f"{_quote_ident(c)} {sort_order}" for c in sort_columns)
query += f" ORDER BY {order_by}"
query += f" LIMIT {int(size)}"
logger.info("SQLite plugin query: %s", query)
return self._read_sql(query)
+1 -1
View File
@@ -59,7 +59,7 @@ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# Stores SSO tokens + service tokens without hitting the 4 KB cookie limit.
_data_home = os.environ.get(
'DATA_FORMULATOR_HOME',
str(Path.home() / '.data-formulator'),
str(Path.home() / '.data_formulator'),
)
_session_dir = os.path.join(_data_home, 'sessions')
os.makedirs(_session_dir, exist_ok=True)
+24 -3
View File
@@ -709,10 +709,14 @@ def list_data_loaders():
This is the discovery endpoint — tells the frontend what kinds of
connectors can be created.
"""
from data_formulator.data_loader import DATA_LOADERS, DISABLED_LOADERS
from data_formulator.data_loader import (
DATA_LOADERS, DISABLED_LOADERS, PLUGIN_LOADERS, PLUGIN_ERRORS, PLUGIN_DIR,
)
from data_formulator.data_loader import _plugin_scanning_enabled # type: ignore[attr-defined]
from data_formulator.auth.identity import is_local_mode
loaders = []
plugin_loaded_summary = []
for key, loader_class in DATA_LOADERS.items():
# local_folder has its own dedicated card — hide from Add Connection list
if key == "local_folder":
@@ -720,22 +724,39 @@ def list_data_loaders():
params = loader_class.list_params()
# Append common table_filter param (same as DataConnector.get_frontend_config)
params.append(DataConnector._TABLE_FILTER_PARAM)
plugin_path = PLUGIN_LOADERS.get(key)
display_name = loader_class.DISPLAY_NAME or key.replace("_", " ").title()
loaders.append({
"type": key,
"name": key.replace("_", " ").title(),
"name": display_name,
"params": params,
"hierarchy": _hierarchy_dicts(loader_class.catalog_hierarchy()),
"auth_mode": loader_class.auth_mode(),
"auth_instructions": loader_class.auth_instructions(),
"delegated_login": loader_class.delegated_login_config(),
"source": "plugin" if plugin_path else "builtin",
"source_path": plugin_path,
})
if plugin_path:
plugin_loaded_summary.append({
"type": key, "name": display_name, "source_path": plugin_path,
})
disabled = {
name: {"install_hint": hint}
for name, hint in DISABLED_LOADERS.items()
}
return json_ok({"loaders": loaders, "disabled": disabled})
enabled, reason = _plugin_scanning_enabled()
plugins_info = {
"dir": PLUGIN_DIR,
"enabled": enabled,
"reason": reason,
"loaded": plugin_loaded_summary,
"errors": list(PLUGIN_ERRORS),
}
return json_ok({"loaders": loaders, "disabled": disabled, "plugins": plugins_info})
@connectors_bp.route("/api/local/pick-directory", methods=["POST"])
+182 -48
View File
@@ -7,14 +7,33 @@ Two loader sources:
is independently imported via try/except so that a missing dependency
only disables that one loader.
2. **External plugins** — Python files matching ``*_data_loader.py`` found
in the directory pointed to by ``DF_PLUGIN_DIR`` (default:
``~/.data-formulator/plugins/``). Any ``ExternalDataLoader`` subclass
found in such a file is auto-registered. If a plugin key collides with
a built-in key the plugin wins (override).
2. **External plugins** — Python files matching ``*_data_loader.py``
found in the plugin directory. Resolution order:
1. ``DF_PLUGIN_DIR`` env var — explicit override (useful for
team-shared dirs, read-only mounts, dev iteration).
2. ``DATA_FORMULATOR_HOME/plugins`` — the default location,
consistent with every other DF artifact.
3. ``~/.data_formulator/plugins/`` — final fallback when
``DATA_FORMULATOR_HOME`` is unset.
Any ``ExternalDataLoader`` subclass found in such a file is
auto-registered. If a plugin key collides with a built-in key the
plugin wins (override).
For safety, plugin scanning is enabled only when running in local
single-user mode (``WORKSPACE_BACKEND=local``, the default). In
multi-user / hosted deployments, set ``DF_ALLOW_PLUGINS=1`` to opt in
explicitly — the plugin directory must be trusted, since loading a
plugin executes arbitrary Python code in the server process.
``DATA_LOADERS`` — loaders that imported successfully.
``DISABLED_LOADERS`` — loaders that failed, with a human-readable install hint.
``DISABLED_LOADERS`` — loaders that failed, with a human-readable hint.
``PLUGIN_LOADERS`` — subset of ``DATA_LOADERS`` registered from plugins,
mapped to the source file path.
``PLUGIN_ERRORS`` — plugins that were rejected (e.g. built-in override
attempt or duplicate key). Each entry is a dict:
``{"file", "reason", "kind"}``.
"""
from __future__ import annotations
@@ -57,7 +76,10 @@ _LOADER_SPECS: list[tuple[str, str, str, str]] = [
# ---------------------------------------------------------------------------
DATA_LOADERS: dict[str, type[ExternalDataLoader]] = {}
DISABLED_LOADERS: dict[str, str] = {} # key -> install instruction
DISABLED_LOADERS: dict[str, str] = {} # key -> install / failure hint
PLUGIN_LOADERS: dict[str, str] = {} # key -> absolute source file path
PLUGIN_ERRORS: list[dict] = [] # rejected plugin attempts (override / duplicate)
_BUILTIN_KEYS: frozenset[str] = frozenset(spec[0] for spec in _LOADER_SPECS)
def _scan_package_loaders() -> None:
"""Import built-in loaders from ``_LOADER_SPECS``."""
@@ -79,57 +101,168 @@ _scan_package_loaders()
# Phase 2: scan external plugin directory
# ---------------------------------------------------------------------------
_DEFAULT_PLUGIN_DIR = str(Path.home() / ".data-formulator" / "plugins")
PLUGIN_DIR: str = os.environ.get("DF_PLUGIN_DIR", _DEFAULT_PLUGIN_DIR)
def _resolve_plugin_dir() -> str:
"""Resolve the plugin directory.
Order: ``DF_PLUGIN_DIR`` (explicit override) >
``DATA_FORMULATOR_HOME/plugins`` (default) >
``~/.data_formulator/plugins`` (fallback).
"""
explicit = os.environ.get("DF_PLUGIN_DIR")
if explicit:
return explicit
df_home = os.environ.get("DATA_FORMULATOR_HOME")
base = Path(df_home) if df_home else Path.home() / ".data_formulator"
return str(base / "plugins")
PLUGIN_DIR: str = _resolve_plugin_dir()
def _plugin_scanning_enabled() -> tuple[bool, str]:
"""Return ``(enabled, reason)``.
Plugin loading executes arbitrary Python in the server process, so it
is only enabled by default in single-user local mode. Hosted
deployments must opt in via ``DF_ALLOW_PLUGINS=1``.
"""
if os.environ.get("DF_ALLOW_PLUGINS", "").lower() in ("1", "true", "yes"):
return True, "DF_ALLOW_PLUGINS opt-in"
backend = os.environ.get("WORKSPACE_BACKEND", "local")
if backend == "local":
return True, "WORKSPACE_BACKEND=local"
return False, f"WORKSPACE_BACKEND={backend} (set DF_ALLOW_PLUGINS=1 to enable)"
def _register_plugin_class(key: str, cls: type[ExternalDataLoader], py_file: Path) -> None:
"""Register a plugin loader class.
Plugins are **not** allowed to override built-in loaders or earlier
plugin loaders. Silent overrides are a credential-exfiltration risk
(a malicious ``mysql_data_loader.py`` could replace the built-in
MySQL connector and capture every existing MySQL connection's
password). Collisions are recorded in ``PLUGIN_ERRORS`` so the UI
can surface them at the top of the connector picker.
"""
if key in PLUGIN_LOADERS:
reason = (
f"plugin file '{py_file.name}' would override earlier plugin loader '{key}' "
f"from {PLUGIN_LOADERS[key]} — rename one of the files so their registry keys differ."
)
_log.error("Rejected plugin %s: %s", py_file.name, reason)
PLUGIN_ERRORS.append({"file": str(py_file), "reason": reason, "kind": "duplicate"})
return
# Block override of any built-in spec, even if that built-in failed
# to import (otherwise a missing dependency would silently hand the
# registry slot to the plugin — stealth substitution risk).
if key in _BUILTIN_KEYS:
reason = (
f"plugin file '{py_file.name}' would override built-in loader '{key}' — "
f"this is blocked for security reasons. Rename the plugin file to use a "
f"different prefix (e.g. '{key}_custom_data_loader.py')."
)
_log.error("Rejected plugin %s: %s", py_file.name, reason)
PLUGIN_ERRORS.append({"file": str(py_file), "reason": reason, "kind": "override_builtin"})
return
DATA_LOADERS[key] = cls
PLUGIN_LOADERS[key] = str(py_file)
# Clear any stale disabled entry from a previous failed scan.
DISABLED_LOADERS.pop(key, None)
def _load_plugin_file(py_file: Path) -> None:
"""Load a single ``*_data_loader.py`` plugin file.
On failure the key is recorded in ``DISABLED_LOADERS`` so the UI can
surface why the plugin is missing. ``sys.modules`` is cleaned up on
failure to avoid leaking a half-initialized module.
"""
key = py_file.stem.removesuffix("_data_loader")
module_name = f"df_plugin_{key}"
try:
spec = importlib.util.spec_from_file_location(module_name, py_file)
if spec is None or spec.loader is None:
DISABLED_LOADERS[key] = f"plugin {py_file.name}: could not create import spec"
return
mod = importlib.util.module_from_spec(spec)
# Only register in sys.modules *after* successful exec to avoid
# leaving a half-initialized module behind on failure.
try:
sys.modules[module_name] = mod
spec.loader.exec_module(mod) # type: ignore[union-attr]
except BaseException:
sys.modules.pop(module_name, None)
raise
candidates = [
obj for _name, obj in inspect.getmembers(mod, inspect.isclass)
if (
issubclass(obj, ExternalDataLoader)
and obj is not ExternalDataLoader
and obj.__module__ == module_name
)
]
if not candidates:
msg = f"plugin {py_file.name}: no ExternalDataLoader subclass found"
_log.warning(msg)
DISABLED_LOADERS[key] = msg
sys.modules.pop(module_name, None)
return
if len(candidates) > 1:
_log.warning(
"Plugin %s defines multiple ExternalDataLoader subclasses (%s); "
"registering '%s'.",
py_file.name,
", ".join(c.__name__ for c in candidates),
candidates[0].__name__,
)
_register_plugin_class(key, candidates[0], py_file)
_log.info("Plugin loader '%s' registered from %s", key, py_file.name)
except ImportError as exc:
# Most common failure mode: plugin imports a package the user
# hasn't installed. Surface a useful hint to the UI.
missing = getattr(exc, "name", None) or str(exc)
hint = f"plugin {py_file.name}: missing dependency '{missing}' (pip install {missing})"
DISABLED_LOADERS[key] = hint
_log.warning("Failed to load plugin %s: %s", py_file.name, hint, exc_info=True)
except Exception as exc:
DISABLED_LOADERS[key] = f"plugin {py_file.name}: {type(exc).__name__}: {exc}"
_log.warning("Failed to load plugin %s", py_file.name, exc_info=True)
def _scan_plugin_dir() -> None:
"""Scan ``PLUGIN_DIR`` for ``*_data_loader.py`` files.
Each file is loaded as a standalone module. Every public class that
subclasses ``ExternalDataLoader`` is registered. The registry key is
derived from the filename: ``my_custom_data_loader.py`` → ``my_custom``.
Plugins override built-ins with the same key.
The registry key is derived from the filename:
``my_custom_data_loader.py`` → ``my_custom``. Plugins override
built-ins with the same key.
"""
plugin_path = Path(PLUGIN_DIR)
if not plugin_path.is_dir():
enabled, reason = _plugin_scanning_enabled()
if not enabled:
_log.info("Plugin scanning disabled: %s", reason)
return
for py_file in sorted(plugin_path.glob("*_data_loader.py")):
key = py_file.stem.removesuffix("_data_loader")
module_name = f"df_plugin_{key}"
try:
spec = importlib.util.spec_from_file_location(module_name, py_file)
if spec is None or spec.loader is None:
continue
mod = importlib.util.module_from_spec(spec)
sys.modules[module_name] = mod
spec.loader.exec_module(mod) # type: ignore[union-attr]
plugin_path = Path(PLUGIN_DIR)
if not plugin_path.is_dir():
_log.debug("Plugin dir %s does not exist; skipping plugin scan", plugin_path)
return
found = False
for name, obj in inspect.getmembers(mod, inspect.isclass):
if (
issubclass(obj, ExternalDataLoader)
and obj is not ExternalDataLoader
and obj.__module__ == module_name
):
if key in DATA_LOADERS:
_log.info(
"Plugin '%s' overrides built-in loader '%s'",
py_file.name, key,
)
DATA_LOADERS[key] = obj
found = True
_log.info("Plugin loader '%s' registered from %s", key, py_file.name)
break # one class per file
files = sorted(plugin_path.glob("*_data_loader.py"))
if not files:
_log.info("Plugin dir %s contains no *_data_loader.py files", plugin_path)
return
if not found:
_log.warning(
"Plugin file %s has no ExternalDataLoader subclass", py_file.name,
)
except Exception as exc:
_log.warning("Failed to load plugin %s: %s", py_file.name, exc)
for py_file in files:
_load_plugin_file(py_file)
_log.info(
"Plugin scan complete: %d registered, %d failed, %d rejected (dir=%s, reason=%s)",
len(PLUGIN_LOADERS),
sum(1 for k in DISABLED_LOADERS if k not in {s[0] for s in _LOADER_SPECS}),
len(PLUGIN_ERRORS),
plugin_path,
reason,
)
_scan_plugin_dir()
@@ -163,6 +296,7 @@ __all__ = [
"CatalogNode",
"DATA_LOADERS",
"DISABLED_LOADERS",
"PLUGIN_LOADERS",
"PLUGIN_DIR",
"get_available_loaders",
]
@@ -539,6 +539,13 @@ class ExternalDataLoader(ABC):
"""Return human-readable authentication instructions."""
pass
#: Human-friendly UI label. When ``None``, the ``/api/data-loaders``
#: endpoint falls back to title-casing the registry key. Override on
#: the subclass to fix awkward casing (``"SQLite"``, ``"BigQuery"``,
#: ``"MySQL"`` instead of the default ``"Sqlite"`` / ``"Bigquery"`` /
#: ``"Mysql"``).
DISPLAY_NAME: str | None = None
@staticmethod
def delegated_login_config() -> dict[str, Any] | None:
"""Return config for delegated (popup-based) token login, or None.
@@ -1039,6 +1039,132 @@ export function genBarTableTests(): TestCase[] {
});
}
// 16. Column-faceted launch leaders with color = facet field and
// % of total enabled. Mirrors the app scenario where hconcat
// Bar Table needs top-level faceting and per-facet denominators.
{
const agencyTypes = ['private', 'startup', 'state'];
const data = [
{ Agency: 'Arianespace', 'Agency Type': 'private', 'Launch Count': 258 },
{ Agency: 'ILS-K', 'Agency Type': 'private', 'Launch Count': 97 },
{ Agency: 'ULA/LMA', 'Agency Type': 'private', 'Launch Count': 70 },
{ Agency: 'MDSSC', 'Agency Type': 'private', 'Launch Count': 62 },
{ Agency: 'OSC-Fairfax', 'Agency Type': 'private', 'Launch Count': 60 },
{ Agency: 'ULA/Boeing', 'Agency Type': 'private', 'Launch Count': 58 },
{ Agency: 'Boeing', 'Agency Type': 'private', 'Launch Count': 56 },
{ Agency: 'LMA', 'Agency Type': 'private', 'Launch Count': 43 },
{ Agency: 'SpaceX', 'Agency Type': 'startup', 'Launch Count': 65 },
{ Agency: 'Rocket Lab', 'Agency Type': 'startup', 'Launch Count': 2 },
{ Agency: 'RVSN', 'Agency Type': 'state', 'Launch Count': 1528 },
{ Agency: 'UNKS', 'Agency Type': 'state', 'Launch Count': 904 },
{ Agency: 'NASA', 'Agency Type': 'state', 'Launch Count': 469 },
{ Agency: 'USAF', 'Agency Type': 'state', 'Launch Count': 388 },
{ Agency: 'AFSC', 'Agency Type': 'state', 'Launch Count': 247 },
{ Agency: 'VKS RVSN', 'Agency Type': 'state', 'Launch Count': 200 },
{ Agency: 'CALT', 'Agency Type': 'state', 'Launch Count': 181 },
{ Agency: 'Roskosmos', 'Agency Type': 'state', 'Launch Count': 128 },
];
tests.push({
title: 'Faceted launch leaders (% within agency type)',
description: 'column + color both use Agency Type; % of total should be computed within each facet',
tags: ['nominal', 'facet', 'column', 'color', 'percentage', 'regression'],
chartType: 'Bar Table',
data,
fields: [makeField('Agency'), makeField('Agency Type'), makeField('Launch Count')],
metadata: {
Agency: { type: Type.String, semanticType: 'Category', levels: data.map(d => d.Agency) },
'Agency Type': { type: Type.String, semanticType: 'Category', levels: agencyTypes },
'Launch Count': { type: Type.Number, semanticType: 'Quantity', levels: [] },
},
encodingMap: {
y: makeEncodingItem('Agency'),
x: makeEncodingItem('Launch Count'),
color: makeEncodingItem('Agency Type'),
column: makeEncodingItem('Agency Type'),
},
chartProperties: { showPercent: true },
});
}
// 17. Many column facets with compact row counts. Exercises column
// wrapping, adaptive subplot height, and facet-aware fonts.
{
const markets = ['Americas', 'Europe', 'Middle East', 'Africa', 'East Asia', 'Oceania', 'South Asia'];
const agencies = ['Atlas', 'Beacon', 'Cosmos', 'Delta'];
const rand = seededRandom(8181);
const data: any[] = [];
for (const market of markets) {
for (const agency of agencies) {
data.push({
Market: market,
Agency: `${agency} ${market}`,
Launches: Math.round(rand() * 180 + 20),
});
}
}
tests.push({
title: 'Wrapped market facets (7 panels)',
description: 'Seven column facets with four rows each — mini-table height and font size should adapt after wrapping',
tags: ['nominal', 'facet', 'wrap', 'small-multiple', 'layout'],
chartType: 'Bar Table',
data,
fields: [makeField('Market'), makeField('Agency'), makeField('Launches')],
metadata: {
Market: { type: Type.String, semanticType: 'Category', levels: markets },
Agency: { type: Type.String, semanticType: 'Category', levels: data.map(d => d.Agency) },
Launches: { type: Type.Number, semanticType: 'Quantity', levels: [] },
},
encodingMap: {
y: makeEncodingItem('Agency'),
x: makeEncodingItem('Launches'),
column: makeEncodingItem('Market'),
},
chartProperties: { showPercent: true },
});
}
// 18. Row + column faceting. Ensures Bar Table's hconcat spec also
// hoists correctly for two-dimensional facet grids.
{
const regions = ['US', 'EU'];
const eras = ['Historic', 'Recent'];
const agencies = ['National', 'Commercial', 'Defense'];
const data: any[] = [];
for (const era of eras) {
for (const region of regions) {
for (let i = 0; i < agencies.length; i++) {
data.push({
Era: era,
Region: region,
Agency: `${region} ${agencies[i]}`,
Missions: (era === 'Historic' ? 120 : 70) + i * 35 + (region === 'US' ? 40 : 0),
});
}
}
}
tests.push({
title: 'Row + column facets (region × era)',
description: 'Two-dimensional faceting with percent totals computed per region/era panel',
tags: ['nominal', 'facet', 'row', 'column', 'percentage', 'layout'],
chartType: 'Bar Table',
data,
fields: [makeField('Era'), makeField('Region'), makeField('Agency'), makeField('Missions')],
metadata: {
Era: { type: Type.String, semanticType: 'Category', levels: eras },
Region: { type: Type.String, semanticType: 'Region', levels: regions },
Agency: { type: Type.String, semanticType: 'Category', levels: data.map(d => d.Agency) },
Missions: { type: Type.Number, semanticType: 'Quantity', levels: [] },
},
encodingMap: {
y: makeEncodingItem('Agency'),
x: makeEncodingItem('Missions'),
column: makeEncodingItem('Region'),
row: makeEncodingItem('Era'),
},
chartProperties: { showPercent: true },
});
}
return tests;
}
+59 -1
View File
@@ -426,7 +426,7 @@ export function assembleVegaLite(input: ChartAssemblyInput): any {
// RESULT
// ═══════════════════════════════════════════════════════════════════════
const result: any = { ...vgObj, data: { values } };
const result: any = { ...vgObj, data: vgObj.data ?? { values } };
if (warnings.length > 0) {
result._warnings = warnings;
}
@@ -740,6 +740,32 @@ function restructureFacets(
facetGrid?: { columns: number; rows: number },
): void {
const isConcatSpec = () => Array.isArray(vgObj.hconcat) || Array.isArray(vgObj.vconcat) || Array.isArray(vgObj.concat);
const hoistConcatIntoFacet = (facetDef: any, wrapColumns?: number) => {
const childSpec: any = {};
for (const key of ['hconcat', 'vconcat', 'concat', 'resolve', 'spacing', 'align', 'bounds', 'center'] as const) {
if (vgObj[key] !== undefined) {
childSpec[key] = vgObj[key];
delete vgObj[key];
}
}
if (vgObj.encoding && Object.keys(vgObj.encoding).length > 0) {
childSpec.encoding = vgObj.encoding;
delete vgObj.encoding;
}
vgObj.facet = facetDef;
if (wrapColumns != null) {
vgObj.columns = wrapColumns;
}
vgObj.spec = childSpec;
vgObj.resolve = {
...(vgObj.resolve || {}),
scale: { ...(vgObj.resolve?.scale || {}), y: 'independent' },
};
};
if (vgObj.encoding?.column != undefined && vgObj.encoding?.row == undefined) {
vgObj.encoding.facet = vgObj.encoding.column;
@@ -755,6 +781,20 @@ function restructureFacets(
delete vgObj.encoding.column;
// Faceting a concat spec must use top-level `facet` + child
// `spec`. Inline `encoding.facet` is ignored/invalid for
// hconcat/vconcat, which is the structure used by Bar Table.
if (isConcatSpec()) {
const facetDef = { ...vgObj.encoding.facet };
delete facetDef.columns;
delete vgObj.encoding.facet;
if (Object.keys(vgObj.encoding).length === 0) {
delete vgObj.encoding;
}
hoistConcatIntoFacet(facetDef, numCols);
return;
}
// For layered specs, VL doesn't support encoding.facet inline —
// restructure to top-level facet + spec.
// IMPORTANT: In top-level facet mode, `columns` must be a sibling
@@ -781,6 +821,24 @@ function restructureFacets(
return;
}
// For concat specs with row-only or column+row facets
if (isConcatSpec() && (vgObj.encoding?.column || vgObj.encoding?.row)) {
const facetDef: any = {};
if (vgObj.encoding.column) {
facetDef.column = vgObj.encoding.column;
delete vgObj.encoding.column;
}
if (vgObj.encoding.row) {
facetDef.row = vgObj.encoding.row;
delete vgObj.encoding.row;
}
if (Object.keys(vgObj.encoding).length === 0) {
delete vgObj.encoding;
}
hoistConcatIntoFacet(facetDef);
return;
}
// For layered specs with row-only or column+row facets
if (vgObj.layer && Array.isArray(vgObj.layer) &&
(vgObj.encoding?.column || vgObj.encoding?.row)) {
@@ -45,13 +45,26 @@ export const barTableDef: ChartTemplateDef = {
// canvas is sized for what the user will actually see, not for
// the (possibly huge) raw row count.
const yField = cs.y?.field;
const rawRowCount = yField
? new Set((table ?? []).map((r: any) => r[yField])).size
: 0;
const facetFields = [cs.column?.field, cs.row?.field].filter(Boolean) as string[];
const rawRowCount = (() => {
if (!yField) return 0;
if (facetFields.length === 0) {
return new Set((table ?? []).map((r: any) => r[yField])).size;
}
const perFacetRows = new Map<string, Set<any>>();
for (const r of table ?? []) {
const key = facetFields.map(f => String(r[f] ?? '')).join('\x00');
const rows = perFacetRows.get(key) ?? new Set<any>();
rows.add(r[yField]);
perFacetRows.set(key, rows);
}
return Math.max(0, ...Array.from(perFacetRows.values()).map(rows => rows.size));
})();
const maxRows = Math.max(0, Number(chartProperties?.maxRows ?? 20));
const displayedRows = maxRows > 0
? Math.min(rawRowCount, maxRows)
: rawRowCount;
const minSubplotSize = displayedRows >= 30 ? 360 : 280;
return {
axisFlags: { y: { banded: true } },
@@ -62,7 +75,7 @@ export const barTableDef: ChartTemplateDef = {
// Floor on overall subplot size — scales up when rows
// are dense so the bar column doesn't collapse below
// legibility.
minSubplotSize: displayedRows >= 30 ? 360 : 280,
minSubplotSize,
// Lengthen the continuous axis (bar) relative to the
// step height. Without this, a tall narrow canvas
// (many rows) leaves bars only a sliver wide.
@@ -90,6 +103,10 @@ export const barTableDef: ChartTemplateDef = {
const xField: string = x?.field || 'Value';
const yField: string = y?.field || 'Category';
const colorField: string | undefined = color?.field;
const facetFields = [column?.field, row?.field].filter(Boolean) as string[];
const hasFacet = facetFields.length > 0;
const scopeKeyOf = (r: any) => facetFields.map(f => String(r[f] ?? '')).join('\x00');
const scopeValuesOf = (r: any) => Object.fromEntries(facetFields.map(f => [f, r[f]]));
// ── Channel semantics (resolved by framework, may be undefined
// when the chart is rendered without a full pipeline) ───────
@@ -122,15 +139,34 @@ export const barTableDef: ChartTemplateDef = {
const aggValue = (g: { sum: number; n: number }) =>
useMeanForDisplay ? g.sum / Math.max(1, g.n) : g.sum;
const categoryAgg = new Map<any, { sum: number; n: number }>();
const scopedCategoryAgg = new Map<string, {
facetValues: Record<string, any>;
categories: Map<any, { sum: number; n: number }>;
}>();
for (const r of table) {
const v = r[xField];
if (typeof v !== 'number' || !isFinite(v)) continue;
const g = categoryAgg.get(r[yField]) ?? { sum: 0, n: 0 };
const scopeKey = hasFacet ? scopeKeyOf(r) : '';
let scope = scopedCategoryAgg.get(scopeKey);
if (!scope) {
scope = { facetValues: hasFacet ? scopeValuesOf(r) : {}, categories: new Map() };
scopedCategoryAgg.set(scopeKey, scope);
}
const g = scope.categories.get(r[yField]) ?? { sum: 0, n: 0 };
g.sum += v; g.n += 1;
categoryAgg.set(r[yField], g);
scope.categories.set(r[yField], g);
}
const uniqueCats = Array.from(categoryAgg.keys());
const scopes = Array.from(scopedCategoryAgg.entries());
const globalCategoryAgg = new Map<any, { sum: number; n: number }>();
for (const { categories } of scopedCategoryAgg.values()) {
for (const [cat, g] of categories.entries()) {
const total = globalCategoryAgg.get(cat) ?? { sum: 0, n: 0 };
total.sum += g.sum;
total.n += g.n;
globalCategoryAgg.set(cat, total);
}
}
const uniqueCats = Array.from(globalCategoryAgg.keys());
// ── Top-N + "Others" rollup ──────────────────────────────────
//
@@ -149,34 +185,82 @@ export const barTableDef: ChartTemplateDef = {
// stack; the Others row carries no color value and renders gray.
const maxRows: number = Math.max(0, Number(config?.maxRows ?? 20));
const ySortOrderForTrim: string[] | undefined = yCS?.ordinalSortOrder;
const maxScopedCategoryCount = Math.max(0, ...scopes.map(([, scope]) => scope.categories.size));
const canTrim = maxRows > 0
&& !(ySortOrderForTrim && ySortOrderForTrim.length > 0)
&& uniqueCats.length > maxRows;
&& maxScopedCategoryCount > maxRows;
let displayTable: any[] = table;
const sortRowsByValue = (items: Array<{ cat: any; value: number }>) => items
.sort((a, b) => yCS?.reversed ? a.value - b.value : b.value - a.value);
let displayTable: any[] = [];
let othersCatLabel: string | undefined;
let keptCatOrder: any[] | undefined;
let perCatAggValues: number[] = uniqueCats.map(c => aggValue(categoryAgg.get(c)!));
let perCatAggValues: number[] = [];
const perScopeAggValues: number[][] = [];
let maxDisplayRowsPerScope = 0;
if (canTrim) {
const sorted = uniqueCats
.map(cat => ({ cat, value: aggValue(categoryAgg.get(cat)!) }))
.sort((a, b) => yCS?.reversed ? a.value - b.value : b.value - a.value);
const keepN = Math.max(1, maxRows - 1);
const keptItems = sorted.slice(0, keepN);
const rest = sorted.slice(keepN);
keptCatOrder = keptItems.map(a => a.cat);
const keptCats = new Set(keptCatOrder);
const displayRows: any[] = [];
for (const [scopeKey, scope] of scopes) {
const sorted = sortRowsByValue(Array.from(scope.categories.entries())
.map(([cat, g]) => ({ cat, value: aggValue(g) })));
const keptItems = sorted.slice(0, keepN);
const rest = sorted.slice(keepN);
if (!hasFacet) {
keptCatOrder = keptItems.map(a => a.cat);
}
const keptCats = new Set(keptItems.map(a => a.cat));
const restSum = rest.reduce((s, a) => s + a.value, 0);
const othersValue = useMeanForDisplay && rest.length > 0 ? restSum / rest.length : restSum;
othersCatLabel = `Others (+${rest.length})`;
const othersSynth = { [yField]: othersCatLabel, [xField]: othersValue, __bt_others: true };
if (colorField) {
const keptRanks = new Map(keptItems.map((a, idx) => [a.cat, idx]));
for (const r of table) {
if ((hasFacet ? scopeKeyOf(r) : '') === scopeKey && keptCats.has(r[yField])) {
displayRows.push({ ...r, __bt_sort: keptRanks.get(r[yField]) ?? 0, __bt_others: false, __bt_others_num: 0 });
}
}
} else {
keptItems.forEach((a, idx) => {
displayRows.push({ ...scope.facetValues, [yField]: a.cat, [xField]: a.value, __bt_sort: idx, __bt_others: false, __bt_others_num: 0 });
});
}
displayTable = colorField
? [...table.filter(r => keptCats.has(r[yField])), othersSynth]
: [...keptItems.map(a => ({ [yField]: a.cat, [xField]: a.value })), othersSynth];
perCatAggValues = [...keptItems.map(a => a.value), othersValue];
const restSum = rest.reduce((s, a) => s + a.value, 0);
const othersValue = useMeanForDisplay && rest.length > 0 ? restSum / rest.length : restSum;
const scopeOthersLabel = `Others (+${rest.length})`;
othersCatLabel = othersCatLabel ?? scopeOthersLabel;
displayRows.push({
...scope.facetValues,
[yField]: scopeOthersLabel,
[xField]: othersValue,
__bt_sort: keptItems.length,
__bt_others: true,
__bt_others_num: 1,
});
const scopeAggValues = [...keptItems.map(a => a.value), othersValue];
perCatAggValues.push(...scopeAggValues);
perScopeAggValues.push(scopeAggValues);
maxDisplayRowsPerScope = Math.max(maxDisplayRowsPerScope, keptItems.length + 1);
}
displayTable = displayRows;
}
if (!canTrim) {
const sortRanksByScope = new Map<string, Map<any, number>>();
for (const [scopeKey, scope] of scopes) {
const sorted = sortRowsByValue(Array.from(scope.categories.entries())
.map(([cat, g]) => ({ cat, value: aggValue(g) })));
sortRanksByScope.set(scopeKey, new Map(sorted.map((a, idx) => [a.cat, idx])));
const scopeAggValues = sorted.map(a => a.value);
perCatAggValues.push(...scopeAggValues);
perScopeAggValues.push(scopeAggValues);
maxDisplayRowsPerScope = Math.max(maxDisplayRowsPerScope, sorted.length);
}
displayTable = table.map(r => {
const scopeKey = hasFacet ? scopeKeyOf(r) : '';
return { ...r, __bt_sort: sortRanksByScope.get(scopeKey)?.get(r[yField]) ?? 0, __bt_others: false, __bt_others_num: 0 };
});
}
// ── Column header labels ─────────────────────────────────────
@@ -212,31 +296,68 @@ export const barTableDef: ChartTemplateDef = {
// mark (overlapping) and each share would be value/grand-total
// (~0% per row instead of the per-category percent).
const sortOp: 'sum' | 'mean' = (xCS?.aggregationDefault === 'average') ? 'mean' : 'sum';
const uniqueGroupby = (fields: string[]) => Array.from(new Set(fields));
const textGroupby = hasFacet ? uniqueGroupby([...facetFields, yField]) : [yField];
const textPanelTransform: any[] = [
{ aggregate: [{ op: sortOp, field: xField, as: '__bt_val' }], groupby: [yField] },
{ aggregate: [{ op: sortOp, field: xField, as: '__bt_val' }, { op: 'min', field: '__bt_sort', as: '__bt_sort' }, { op: 'max', field: '__bt_others_num', as: '__bt_others_num' }], groupby: textGroupby },
];
if (showPercent) {
const totalTransform: any = { joinaggregate: [{ op: 'sum', field: '__bt_val', as: '__bt_total' }] };
if (hasFacet) {
totalTransform.groupby = facetFields;
}
textPanelTransform.push(
{ joinaggregate: [{ op: 'sum', field: '__bt_val', as: '__bt_total' }] },
{ calculate: `datum.__bt_val / datum.__bt_total`, as: '__bt_pct' },
totalTransform,
{ calculate: `datum.__bt_total === 0 ? null : datum.__bt_val / datum.__bt_total`, as: '__bt_pct' },
);
}
const uniqueFacetValueCount = (field?: string) => field
? new Set(displayTable.map(r => r[field])).size
: 0;
const columnFacetCount = uniqueFacetValueCount(column?.field);
const rowFacetCount = uniqueFacetValueCount(row?.field);
const layoutFacetColumns = ctx.layout?.facet?.columns ?? (columnFacetCount || 1);
const facetColsForSizing = hasFacet
? Math.max(1, Math.min(layoutFacetColumns, columnFacetCount || 1))
: 1;
const facetRowsForSizing = hasFacet
? Math.max(1, rowFacetCount || Math.ceil(Math.max(1, columnFacetCount) / facetColsForSizing))
: 1;
const subplotWidth = hasFacet ? (ctx.layout?.subplotWidth ?? canvasSize?.width) : canvasSize?.width;
const layoutSubplotHeight = hasFacet ? (ctx.layout?.subplotHeight ?? canvasSize?.height) : canvasSize?.height;
const facetHeightBudget = hasFacet && facetRowsForSizing > 1
? (() => {
const maxStretch = ctx.assembleOptions?.maxStretch ?? 2;
const facetElasticity = ctx.assembleOptions?.facetElasticity ?? 0.3;
const fixH = ctx.assembleOptions?.facetFixedPadding?.height ?? 0;
const gap = ctx.layout?.effectiveFacetGap ?? ctx.assembleOptions?.facetGap ?? 0;
const stretch = Math.min(maxStretch, Math.pow(facetRowsForSizing, facetElasticity));
return Math.max(0, Math.round((canvasSize.height * stretch - fixH) / facetRowsForSizing - gap));
})()
: layoutSubplotHeight;
// ── Sizing constants (responsive to row density) ────────
// `displayCount` is the number of rows the chart will actually
// render (post-rollup). As it grows, we shrink fonts so labels
// don't crowd; we also bump up the minimum bar-panel width so
// the bar column remains a real visual signal, not a sliver
// between text.
const displayCount = canTrim
? (keptCatOrder!.length + 1)
: uniqueCats.length;
// don't crowd. In facets, shrink again when each mini-table has
// substantially less plot budget than the full canvas.
const displayCount = maxDisplayRowsPerScope || uniqueCats.length;
// 0 (sparse, ≤12 rows) … 1 (dense, ≥52 rows) — font/density curve.
const density = Math.min(1, Math.max(0, (displayCount - 12) / 40));
const lerp = (a: number, b: number) => Math.round(a + (b - a) * density);
const subplotWidthRatio = hasFacet && canvasSize?.width
? Math.min(1, Math.max(0, (subplotWidth ?? canvasSize.width) / canvasSize.width))
: 1;
const subplotHeightRatio = hasFacet && canvasSize?.height
? Math.min(1, Math.max(0, (facetHeightBudget ?? canvasSize.height) / canvasSize.height))
: 1;
const facetFontDrop = hasFacet
? Math.round((1 - Math.min(subplotWidthRatio, subplotHeightRatio)) * 3)
: 0;
const fontSize = lerp(12, 10); // text panels
const labelFontSize = lerp(13, 10); // y-axis tick labels
const fontSize = Math.max(9, lerp(12, 10) - facetFontDrop); // text panels
const labelFontSize = Math.max(9, lerp(13, 10) - facetFontDrop); // y-axis tick labels
// ── Bar geometry: capped thickness, proportional gap ─────────
//
@@ -295,13 +416,13 @@ export const barTableDef: ChartTemplateDef = {
return [...keptCatOrder, othersCatLabel];
}
return uniqueCats
.map(cat => ({ cat, value: aggValue(categoryAgg.get(cat)!) }))
.map(cat => ({ cat, value: aggValue(globalCategoryAgg.get(cat)!) }))
.sort((a, b) => yCS?.reversed ? a.value - b.value : b.value - a.value)
.map(a => a.cat);
})();
const ySort: any = ySortOrder && ySortOrder.length > 0
? ySortOrder
: rankedCatOrder;
: hasFacet ? { field: '__bt_sort', op: 'min', order: 'ascending' } : rankedCatOrder;
// Labels are left-aligned and pushed flush with the panel's left
// edge so they line up under the "Category" column header.
@@ -449,31 +570,37 @@ export const barTableDef: ChartTemplateDef = {
maxTextPanel,
Math.max(valuePanelDataWidth, valueHeaderWrap.widthPx + headerPad, minTextPanel),
);
const displayTotal = perCatAggValues.reduce((a, b) => a + b, 0);
const percentPanelWidth = showPercent && Math.abs(displayTotal) > 1e-9
const pctValuesForSizing = perScopeAggValues.flatMap(values => {
const scopeTotal = values.reduce((a, b) => a + b, 0);
return Math.abs(scopeTotal) > 1e-9 ? values.map(v => v / scopeTotal) : [];
});
const percentPanelWidth = showPercent && pctValuesForSizing.length > 0
? Math.min(
maxTextPanel,
Math.max(
measure(perCatAggValues.map(v => approxPct(v / displayTotal))),
measure(pctValuesForSizing.map(approxPct)),
percentHeaderWrap.widthPx + headerPad,
minTextPanel,
),
)
: 0;
const totalWidth = canvasSize?.width ?? 480;
const totalWidth = subplotWidth ?? 480;
const interPanelGap = 8;
const reservedForText = valuePanelWidth + interPanelGap
+ (showPercent ? percentPanelWidth + interPanelGap : 0);
// Bar panel needs a meaningful min width — a 3-panel layout
// squeezes the bar column more than a basic bar chart, and the
// bar IS the chart, so it should never collapse below ~45% of
// the canvas (or 180px absolute, whichever is larger).
const minBarPanelWidth = Math.max(180, Math.round(totalWidth * 0.45));
// the plot budget. Faceted small multiples get a smaller
// absolute floor so each mini-table can shrink like other charts.
const minBarPanelWidth = hasFacet
? Math.max(80, Math.round(totalWidth * 0.45))
: Math.max(180, Math.round(totalWidth * 0.45));
const barPanelWidth = Math.max(minBarPanelWidth, totalWidth - reservedForText - categoryLabelWidth);
const yCard = Math.max(1, new Set(displayTable.map(r => r[yField])).size);
const panelHeight = Math.max(canvasSize?.height ?? 0, yCard * rowStep);
const yCard = Math.max(1, maxDisplayRowsPerScope || new Set(displayTable.map(r => r[yField])).size);
const panelHeight = Math.max(facetHeightBudget ?? 0, yCard * rowStep);
// ── Helpers to build a text encoding honoring prefix/suffix ──
//
@@ -519,14 +646,17 @@ export const barTableDef: ChartTemplateDef = {
// drop categories behind our back).
const datasetName = '__bt_displayTable';
spec.datasets = { ...(spec.datasets || {}), [datasetName]: displayTable };
const withData = (panel: any) => ({ data: { name: datasetName }, ...panel });
if (hasFacet) {
spec.data = { name: datasetName };
}
const withData = (panel: any) => hasFacet ? panel : ({ data: { name: datasetName }, ...panel });
// ── Others row: gray out across panels ───────────────────────
// Bar panel rows still carry `__bt_others: true` (no transform).
// Text panels lose it after the aggregate, so detect by label.
// Text panels aggregate rows, so carry a numeric flag through
// the aggregate instead of relying on facet-specific labels.
const othersGray = '#bdbdbd';
const othersTextTest = canTrim && othersCatLabel
? `datum['${yField.replace(/'/g, "\\'")}'] === '${othersCatLabel.replace(/'/g, "\\'")}'`
const othersTextTest = canTrim
? `datum.__bt_others_num === 1 || datum.__bt_others === true`
: undefined;
// ── Panel 0: bar (with y-axis labels) ────────────────────────
@@ -544,8 +674,8 @@ export const barTableDef: ChartTemplateDef = {
const barAggregate = useMeanForDisplay;
const barTransform: any[] | undefined = barAggregate
? [{
aggregate: [{ op: sortOp, field: xField, as: '__bt_val' }],
groupby: colorField ? [yField, colorField] : [yField],
aggregate: [{ op: sortOp, field: xField, as: '__bt_val' }, { op: 'min', field: '__bt_sort', as: '__bt_sort' }, { op: 'max', field: '__bt_others_num', as: '__bt_others_num' }],
groupby: uniqueGroupby([...facetFields, yField, ...(colorField ? [colorField] : [])]),
}]
: undefined;
const barXField = barAggregate ? '__bt_val' : xField;
+7 -3
View File
@@ -221,11 +221,15 @@ export const AgentChatInput: React.FC<AgentChatInputProps> = ({
<Tooltip title={stopTooltip ?? t('dataLoading.stopTooltip')} placement="top">
<IconButton size="small" onClick={onStop}
sx={{
bgcolor: 'error.main', color: 'white',
width: 28, height: 28,
'&:hover': { bgcolor: 'error.dark' },
bgcolor: 'transparent',
color: 'error.main',
'&:hover': {
bgcolor: alpha(theme.palette.error.main, 0.08),
color: 'error.dark',
},
}}>
<StopIcon sx={{ fontSize: 16 }} />
<StopIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
) : (
+12 -11
View File
@@ -68,9 +68,9 @@ const AgentWorkingOverlay: FC<{ message?: string; elapsed?: number; theme: Theme
px: 2,
overflow: 'hidden',
}}>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 1 }}>
<WritingPencil size={14} />
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500, fontSize: 13 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 0.75 }}>
<WritingPencil size={12} />
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 400, fontSize: 12, lineHeight: 1.5 }}>
{t('chartRec.agentWorking')}
</Typography>
</Box>
@@ -79,21 +79,22 @@ const AgentWorkingOverlay: FC<{ message?: string; elapsed?: number; theme: Theme
<IconButton
size="small"
onClick={onCancel}
// Filled error-circle stop affordance \u2014 same recipe
// as AgentChatInput's in-flight stop button so the
// cancel action reads consistently across surfaces.
sx={{
position: 'absolute', bottom: 8, right: 8,
width: 28, height: 28, p: 0,
bgcolor: 'error.main', color: 'common.white',
'&:hover': { bgcolor: 'error.dark' },
width: 24, height: 24, p: 0,
bgcolor: 'transparent',
color: 'error.main',
'&:hover': {
bgcolor: alpha(theme.palette.error.main, 0.08),
color: 'error.dark',
},
}}
>
<StopIcon sx={{ fontSize: 16 }} />
<StopIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
)}
<Typography variant="caption" sx={{
<Typography variant="body2" sx={{
color: 'text.disabled',
fontSize: 12,
textAlign: 'center',
+85 -11
View File
@@ -5,6 +5,8 @@ import * as React from 'react';
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { borderColor, transition, radius } from '../app/tokens';
import {
Alert,
AlertTitle,
Box,
Button,
Chip,
@@ -798,6 +800,16 @@ interface LoaderType {
auth_mode?: string;
auth_instructions?: string;
delegated_login?: { login_url: string; label?: string } | null;
source?: 'plugin' | 'builtin';
source_path?: string | null;
}
interface PluginsInfo {
dir: string;
enabled: boolean;
reason: string;
loaded: Array<{ type: string; name: string; source_path: string }>;
errors: Array<{ file: string; reason: string; kind: string }>;
}
const AddConnectionPanel: React.FC<{
@@ -809,6 +821,7 @@ const AddConnectionPanel: React.FC<{
);
const [loaderTypes, setLoaderTypes] = useState<LoaderType[]>([]);
const [disabledLoaders, setDisabledLoaders] = useState<Record<string, {install_hint: string}>>({});
const [pluginsInfo, setPluginsInfo] = useState<PluginsInfo | null>(null);
const [selectedType, setSelectedType] = useState<string>('');
const [displayName, setDisplayName] = useState('');
const dispatch = useDispatch<AppDispatch>();
@@ -827,6 +840,7 @@ const AddConnectionPanel: React.FC<{
.then(({ data }) => {
setLoaderTypes(data.loaders || []);
setDisabledLoaders(data.disabled || {});
setPluginsInfo(data.plugins || null);
if (data.loaders?.length > 0) {
setSelectedType(data.loaders[0].type);
setDisplayName(data.loaders[0].name);
@@ -930,17 +944,47 @@ const AddConnectionPanel: React.FC<{
}}>
{t('upload.dataSourceTypes', { defaultValue: 'Data Sources' })}
</Typography>
{[...loaderTypes].sort((a, b) => connectorSortOrder(a.type, b.type)).map((loader) => (
<Button
key={loader.type}
variant="text" size="small" color="primary"
onClick={() => handleSelectLoader(loader)}
sx={sidebarButtonSx(loader.type)}
startIcon={getConnectorIcon(loader.type, { sx: { fontSize: 16, opacity: 0.7 } })}
>
{loader.name}
</Button>
))}
{[...loaderTypes].sort((a, b) => connectorSortOrder(a.type, b.type)).map((loader) => {
const isPlugin = loader.source === 'plugin';
const btn = (
<Button
key={loader.type}
variant="text" size="small" color="primary"
onClick={() => handleSelectLoader(loader)}
sx={sidebarButtonSx(loader.type)}
startIcon={getConnectorIcon(loader.type, { sx: { fontSize: 16, opacity: 0.7 } })}
>
<Box component="span" sx={{ flex: 1, textAlign: 'left' }}>{loader.name}</Box>
{isPlugin && (
<Box
component="span"
sx={{
ml: 0.5,
px: 0.5,
fontSize: 9,
fontWeight: 500,
color: 'text.secondary',
border: '1px solid',
borderColor: 'divider',
borderRadius: 0.5,
lineHeight: '12px',
}}
>
plugin
</Box>
)}
</Button>
);
return isPlugin ? (
<Tooltip
key={loader.type}
title={`External plugin loaded from ${loader.source_path}`}
placement="right" arrow
>
<span>{btn}</span>
</Tooltip>
) : btn;
})}
{Object.entries(disabledLoaders).sort(([a], [b]) => connectorSortOrder(a, b)).map(([name, { install_hint }]) => (
<Tooltip key={name} title={install_hint} placement="right" arrow>
<span style={{ width: '100%' }}>
@@ -963,6 +1007,36 @@ const AddConnectionPanel: React.FC<{
{/* Right panel: display name + DataLoaderForm (or simplified Local Folder panel) */}
<Box sx={{ flex: 1, overflow: 'auto', p: 0 }}>
{/* Plugin status banner — surfaces loaded plugins (info) and rejected
plugins (warning) so the user knows what extension code is running. */}
{pluginsInfo && (pluginsInfo.errors.length > 0 || pluginsInfo.loaded.length > 0) && (
<Box sx={{ px: 2, pt: 1.5 }}>
{pluginsInfo.errors.length > 0 && (
<Alert severity="error" variant="outlined" sx={{ mb: 1, fontSize: 11, py: 0.5 }}>
<AlertTitle sx={{ fontSize: 12, fontWeight: 600, mb: 0.5 }}>
{pluginsInfo.errors.length} plugin{pluginsInfo.errors.length === 1 ? '' : 's'} rejected
</AlertTitle>
{pluginsInfo.errors.map((e, i) => (
<Box key={i} sx={{ fontSize: 11, lineHeight: 1.4 }}>
<code style={{ fontSize: 10 }}>{e.file.split('/').pop()}</code>: {e.reason}
</Box>
))}
</Alert>
)}
{pluginsInfo.loaded.length > 0 && (
<Alert severity="warning" variant="outlined" icon={false}
sx={{ mb: 1, fontSize: 11, py: 0.25, '& .MuiAlert-message': { py: 0.5 } }}>
<strong>{pluginsInfo.loaded.length} external plugin{pluginsInfo.loaded.length === 1 ? '' : 's'} loaded</strong>
{' — '}
{pluginsInfo.loaded.map(p => p.name).join(', ')}.
{' '}Plugins run with full server privileges; only load files you trust.
{' '}<Box component="span" sx={{ color: 'text.secondary', fontSize: 10 }}>
(dir: <code>{pluginsInfo.dir}</code>)
</Box>
</Alert>
)}
</Box>
)}
{selectedLoader && selectedType === 'local_folder' ? (
/* Simplified Local Folder panel — no connection name, no form tiers */
<LocalFolderPanel
+281
View File
@@ -0,0 +1,281 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
"""Tests for the data-loader plugin scanner.
The scanner runs at module import time, so each test sets up env vars and
plugin files first, then reloads the ``data_formulator.data_loader``
module to trigger a fresh scan.
"""
from __future__ import annotations
import importlib
import os
import sys
from pathlib import Path
import pytest
pytestmark = [pytest.mark.backend]
GOOD_PLUGIN = """\
from data_formulator.data_loader.external_data_loader import ExternalDataLoader
class DemoLoader(ExternalDataLoader):
def __init__(self, params=None):
self.params = params or {}
"""
BROKEN_IMPORT_PLUGIN = "import nonexistent_pkg_xyz_for_test # noqa: F401\n"
NO_SUBCLASS_PLUGIN = "x = 1\n"
OVERRIDE_BUILTIN_PLUGIN = """\
from data_formulator.data_loader.external_data_loader import ExternalDataLoader
class OverrideMysql(ExternalDataLoader):
def __init__(self, params=None):
self.params = params or {}
"""
MULTI_CLASS_PLUGIN = """\
from data_formulator.data_loader.external_data_loader import ExternalDataLoader
class AaaLoader(ExternalDataLoader):
def __init__(self, params=None):
self.params = params or {}
class BbbLoader(ExternalDataLoader):
def __init__(self, params=None):
self.params = params or {}
"""
def _write(dir_: Path, name: str, body: str) -> Path:
p = dir_ / name
p.write_text(body)
return p
def _reload_scanner():
"""Purge cached scanner state and reimport so the scan re-runs."""
for mod_name in list(sys.modules):
if mod_name == "data_formulator.data_loader" or mod_name.startswith("df_plugin_"):
sys.modules.pop(mod_name, None)
return importlib.import_module("data_formulator.data_loader")
# ── gating ────────────────────────────────────────────────────────────────
def test_scanner_disabled_in_hosted_mode(tmp_path, monkeypatch):
"""Plugin scanning is off when WORKSPACE_BACKEND != local and no opt-in."""
_write(tmp_path, "demo_data_loader.py", GOOD_PLUGIN)
monkeypatch.setenv("DF_PLUGIN_DIR", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "azure_blob")
monkeypatch.delenv("DF_ALLOW_PLUGINS", raising=False)
dl = _reload_scanner()
assert dl.PLUGIN_LOADERS == {}
assert "demo" not in dl.DATA_LOADERS
def test_scanner_enabled_in_local_mode(tmp_path, monkeypatch):
_write(tmp_path, "demo_data_loader.py", GOOD_PLUGIN)
monkeypatch.setenv("DF_PLUGIN_DIR", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
dl = _reload_scanner()
assert "demo" in dl.PLUGIN_LOADERS
assert dl.PLUGIN_LOADERS["demo"].endswith("demo_data_loader.py")
assert "demo" in dl.DATA_LOADERS
assert dl.DATA_LOADERS["demo"].__name__ == "DemoLoader"
def test_scanner_opt_in_overrides_hosted_gate(tmp_path, monkeypatch):
_write(tmp_path, "demo_data_loader.py", GOOD_PLUGIN)
monkeypatch.setenv("DF_PLUGIN_DIR", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "azure_blob")
monkeypatch.setenv("DF_ALLOW_PLUGINS", "1")
dl = _reload_scanner()
assert "demo" in dl.PLUGIN_LOADERS
# ── failure paths surface in DISABLED_LOADERS ─────────────────────────────
def test_missing_dependency_recorded_with_pip_hint(tmp_path, monkeypatch):
_write(tmp_path, "broken_data_loader.py", BROKEN_IMPORT_PLUGIN)
monkeypatch.setenv("DF_PLUGIN_DIR", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
dl = _reload_scanner()
assert "broken" not in dl.DATA_LOADERS
assert "broken" not in dl.PLUGIN_LOADERS
assert "broken" in dl.DISABLED_LOADERS
msg = dl.DISABLED_LOADERS["broken"]
assert "nonexistent_pkg_xyz_for_test" in msg
assert "pip install" in msg
def test_no_subclass_recorded_in_disabled(tmp_path, monkeypatch):
_write(tmp_path, "empty_data_loader.py", NO_SUBCLASS_PLUGIN)
monkeypatch.setenv("DF_PLUGIN_DIR", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
dl = _reload_scanner()
assert "empty" not in dl.DATA_LOADERS
assert "empty" in dl.DISABLED_LOADERS
assert "no ExternalDataLoader subclass" in dl.DISABLED_LOADERS["empty"]
def test_broken_plugin_does_not_leak_sys_modules(tmp_path, monkeypatch):
"""A failed exec_module should not leave a half-initialized module behind."""
_write(tmp_path, "broken_data_loader.py", BROKEN_IMPORT_PLUGIN)
monkeypatch.setenv("DF_PLUGIN_DIR", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
_reload_scanner()
assert "df_plugin_broken" not in sys.modules
# ── override semantics ────────────────────────────────────────────────────
def test_plugin_overriding_builtin_is_rejected(tmp_path, monkeypatch):
"""Security: a plugin keyed ``mysql`` must NOT replace the built-in.
Silent override would let a malicious plugin capture credentials for
every existing MySQL connection. The scanner rejects the override and
records a structured entry in PLUGIN_ERRORS so the UI can surface it.
"""
_write(tmp_path, "mysql_data_loader.py", OVERRIDE_BUILTIN_PLUGIN)
monkeypatch.setenv("DF_PLUGIN_DIR", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
dl = _reload_scanner()
# Plugin must NOT be registered.
assert "mysql" not in dl.PLUGIN_LOADERS
# If the built-in is present (pymysql installed), it must remain the
# original class — definitely not the plugin's class.
if "mysql" in dl.DATA_LOADERS:
assert dl.DATA_LOADERS["mysql"].__name__ != "OverrideMysql"
# Error must surface in PLUGIN_ERRORS with the right shape.
errors = [e for e in dl.PLUGIN_ERRORS if e["kind"] == "override_builtin"]
assert len(errors) == 1
assert errors[0]["file"].endswith("mysql_data_loader.py")
assert "mysql" in errors[0]["reason"]
def test_duplicate_plugin_keys_are_rejected(tmp_path, monkeypatch):
"""Two plugins claiming the same registry key: second is rejected."""
# Both files have prefix ``demo`` → same registry key.
_write(tmp_path, "demo_data_loader.py", GOOD_PLUGIN)
sub = tmp_path / "sub"
sub.mkdir()
# Same dir collision: write a second file that also resolves to "demo".
second = tmp_path / "demo_data_loader.py.bak" # won't collide
# Instead, use the actual collision path via two distinct filenames that
# produce the same key. Since key = filename minus "_data_loader",
# two files cannot share a name in one dir. So this test instead
# verifies that re-scanning twice without clearing PLUGIN_LOADERS
# rejects the second registration. Easiest path: write the same file,
# call _load_plugin_file twice on it manually.
monkeypatch.setenv("DF_PLUGIN_DIR", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
dl = _reload_scanner()
assert "demo" in dl.PLUGIN_LOADERS
# Manually re-invoke loader on the same file → simulates collision.
from pathlib import Path as _P
dl._load_plugin_file(_P(tmp_path) / "demo_data_loader.py") # type: ignore[attr-defined]
dups = [e for e in dl.PLUGIN_ERRORS if e["kind"] == "duplicate"]
assert len(dups) >= 1
# ── multiple subclasses ───────────────────────────────────────────────────
def test_multiple_subclasses_registers_first_alphabetically(tmp_path, monkeypatch):
_write(tmp_path, "multi_data_loader.py", MULTI_CLASS_PLUGIN)
monkeypatch.setenv("DF_PLUGIN_DIR", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
dl = _reload_scanner()
assert "multi" in dl.PLUGIN_LOADERS
# inspect.getmembers returns alphabetically-sorted; AaaLoader wins.
assert dl.DATA_LOADERS["multi"].__name__ == "AaaLoader"
# ── empty / missing plugin dir ────────────────────────────────────────────
def test_missing_plugin_dir_is_silent(tmp_path, monkeypatch):
missing = tmp_path / "does_not_exist"
monkeypatch.setenv("DF_PLUGIN_DIR", str(missing))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
dl = _reload_scanner()
assert dl.PLUGIN_LOADERS == {}
def test_empty_plugin_dir_is_silent(tmp_path, monkeypatch):
monkeypatch.setenv("DF_PLUGIN_DIR", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
dl = _reload_scanner()
assert dl.PLUGIN_LOADERS == {}
# ── plugin dir resolution ─────────────────────────────────────────────────
def test_plugin_dir_defaults_to_data_formulator_home(tmp_path, monkeypatch):
"""``DATA_FORMULATOR_HOME/plugins`` is the default when DF_PLUGIN_DIR unset."""
plugins = tmp_path / "plugins"
plugins.mkdir()
_write(plugins, "demo_data_loader.py", GOOD_PLUGIN)
monkeypatch.delenv("DF_PLUGIN_DIR", raising=False)
monkeypatch.setenv("DATA_FORMULATOR_HOME", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
dl = _reload_scanner()
assert dl.PLUGIN_DIR == str(plugins)
assert "demo" in dl.PLUGIN_LOADERS
def test_df_plugin_dir_overrides_data_formulator_home(tmp_path, monkeypatch):
"""Explicit DF_PLUGIN_DIR wins over DATA_FORMULATOR_HOME/plugins."""
df_home = tmp_path / "home"
(df_home / "plugins").mkdir(parents=True)
explicit = tmp_path / "explicit"
explicit.mkdir()
_write(explicit, "demo_data_loader.py", GOOD_PLUGIN)
monkeypatch.setenv("DATA_FORMULATOR_HOME", str(df_home))
monkeypatch.setenv("DF_PLUGIN_DIR", str(explicit))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
dl = _reload_scanner()
assert dl.PLUGIN_DIR == str(explicit)
assert "demo" in dl.PLUGIN_LOADERS
@@ -0,0 +1,200 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
"""Integration test: plugin loaders appear in /api/data-loaders.
This is the end-to-end "drop a file → connector card appears" contract.
It guards against future refactors of the discovery endpoint silently
dropping plugins.
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
import flask
import pytest
pytestmark = [pytest.mark.backend]
PLUGIN_BODY = '''\
from data_formulator.data_loader.external_data_loader import ExternalDataLoader
import pyarrow as pa
class FakeWarehouseLoader(ExternalDataLoader):
DISPLAY_NAME = "Fake Warehouse"
@staticmethod
def list_params():
return [
{"name": "endpoint", "type": "string", "required": True,
"tier": "connection", "description": "Server URL"},
]
@staticmethod
def auth_instructions():
return "Test plugin — no auth required."
def __init__(self, params=None):
self.params = params or {}
def list_tables(self, table_filter=None):
return []
def fetch_data_as_arrow(self, source_table, import_options=None):
return pa.table({})
'''
def _reload_data_loader_module():
"""Purge cached state and reimport so the scanner runs against the
current env vars."""
for mod_name in list(sys.modules):
if mod_name == "data_formulator.data_loader" or mod_name.startswith("df_plugin_"):
sys.modules.pop(mod_name, None)
return importlib.import_module("data_formulator.data_loader")
@pytest.fixture()
def client_with_plugin(tmp_path, monkeypatch):
"""Spin up a Flask app whose data_loader registry includes a fake plugin."""
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
(plugins_dir / "fake_warehouse_data_loader.py").write_text(PLUGIN_BODY)
monkeypatch.setenv("DATA_FORMULATOR_HOME", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
monkeypatch.delenv("DF_PLUGIN_DIR", raising=False)
dl = _reload_data_loader_module()
assert "fake_warehouse" in dl.PLUGIN_LOADERS, "plugin scanner didn't pick up fixture"
# Mount just the discovery blueprint on a minimal Flask app.
from data_formulator.data_connector import connectors_bp
from data_formulator.error_handler import register_error_handlers
test_app = flask.Flask(__name__)
test_app.config["TESTING"] = True
test_app.register_blueprint(connectors_bp)
register_error_handlers(test_app)
return test_app.test_client()
def _loaders_by_type(payload):
"""Normalize the response envelope and return loaders keyed by type."""
body = payload.get("data", payload) if isinstance(payload, dict) else payload
return {ldr["type"]: ldr for ldr in body["loaders"]}
def test_plugin_appears_in_discovery_endpoint(client_with_plugin):
resp = client_with_plugin.get("/api/data-loaders")
assert resp.status_code == 200
loaders = _loaders_by_type(resp.get_json())
assert "fake_warehouse" in loaders
plugin = loaders["fake_warehouse"]
# DISPLAY_NAME class attr overrides the title-cased default.
assert plugin["name"] == "Fake Warehouse"
# Source attribution: plugin file path surfaces in the API.
assert plugin["source"] == "plugin"
assert plugin["source_path"].endswith("fake_warehouse_data_loader.py")
# User-declared params come through (plus the common table_filter row).
param_names = {p["name"] for p in plugin["params"]}
assert "endpoint" in param_names
assert "table_filter" in param_names
# Auth instructions surface verbatim.
assert "Test plugin" in plugin["auth_instructions"]
def test_builtin_loader_marked_as_builtin(client_with_plugin):
"""Built-ins must be tagged source='builtin' (regression guard)."""
resp = client_with_plugin.get("/api/data-loaders")
loaders = _loaders_by_type(resp.get_json())
# Find any built-in that's available in the test env.
builtin_candidates = ["sample_datasets", "mysql", "postgresql", "s3"]
builtin = next((loaders[k] for k in builtin_candidates if k in loaders), None)
assert builtin is not None, "no built-in loader available in test env"
assert builtin["source"] == "builtin"
assert builtin["source_path"] is None
def test_display_name_default_titlecases_registry_key(tmp_path, monkeypatch):
"""A plugin without DISPLAY_NAME gets title-cased registry-key as name."""
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
(plugins_dir / "my_thing_data_loader.py").write_text(
PLUGIN_BODY.replace('DISPLAY_NAME = "Fake Warehouse"', "")
.replace("FakeWarehouseLoader", "MyThingLoader")
)
monkeypatch.setenv("DATA_FORMULATOR_HOME", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
monkeypatch.delenv("DF_PLUGIN_DIR", raising=False)
_reload_data_loader_module()
from data_formulator.data_connector import connectors_bp
from data_formulator.error_handler import register_error_handlers
test_app = flask.Flask(__name__)
test_app.config["TESTING"] = True
test_app.register_blueprint(connectors_bp)
register_error_handlers(test_app)
resp = test_app.test_client().get("/api/data-loaders")
loaders = _loaders_by_type(resp.get_json())
assert loaders["my_thing"]["name"] == "My Thing"
def test_plugins_block_surfaces_loaded_and_rejected(tmp_path, monkeypatch):
"""The discovery endpoint must expose a ``plugins`` summary containing
both successfully-loaded plugins and rejected override attempts. The
frontend uses this to render the security-status alert."""
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
# One legitimate plugin and one that tries to override a built-in.
(plugins_dir / "fake_warehouse_data_loader.py").write_text(PLUGIN_BODY)
(plugins_dir / "mysql_data_loader.py").write_text(
PLUGIN_BODY.replace("FakeWarehouseLoader", "BadMysql")
.replace('DISPLAY_NAME = "Fake Warehouse"', "")
)
monkeypatch.setenv("DATA_FORMULATOR_HOME", str(tmp_path))
monkeypatch.setenv("WORKSPACE_BACKEND", "local")
monkeypatch.delenv("DF_PLUGIN_DIR", raising=False)
_reload_data_loader_module()
from data_formulator.data_connector import connectors_bp
from data_formulator.error_handler import register_error_handlers
test_app = flask.Flask(__name__)
test_app.config["TESTING"] = True
test_app.register_blueprint(connectors_bp)
register_error_handlers(test_app)
resp = test_app.test_client().get("/api/data-loaders")
body = resp.get_json()
body = body.get("data", body)
assert "plugins" in body
plugins = body["plugins"]
assert plugins["enabled"] is True
assert plugins["dir"].endswith("plugins")
# Loaded list contains the legitimate plugin.
loaded_types = {p["type"] for p in plugins["loaded"]}
assert "fake_warehouse" in loaded_types
# The mysql override attempt must NOT appear in loaded.
assert "mysql" not in loaded_types
# ...and must appear in the errors list with the override_builtin kind.
err_files = [Path(e["file"]).name for e in plugins["errors"]]
assert "mysql_data_loader.py" in err_files
kinds = {e["kind"] for e in plugins["errors"]}
assert "override_builtin" in kinds
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { compile } from 'vega-lite';
import { assembleVegaLite } from '../../../../../../src/lib/agents-chart';
const canvasSize = { width: 400, height: 300 };
const encoding = {
y: { field: 'agency' },
x: { field: 'launches' },
column: { field: 'agency_type' },
};
const semanticTypes = {
agency: 'Category',
launches: 'Quantity',
agency_type: 'Category',
};
describe('Vega-Lite Bar Table facets', () => {
it('hoists column facets around the hconcat bar table and wraps them', () => {
const data = [
{ agency_type: 'state', agency: 'RVSN', launches: 1528 },
{ agency_type: 'state', agency: 'UNKS', launches: 904 },
{ agency_type: 'state', agency: 'NASA', launches: 469 },
{ agency_type: 'private', agency: 'Arianespace', launches: 258 },
{ agency_type: 'private', agency: 'ILS-K', launches: 97 },
{ agency_type: 'startup', agency: 'SpaceX', launches: 65 },
];
const spec = assembleVegaLite({
data: { values: data },
semantic_types: semanticTypes,
chart_spec: {
chartType: 'Bar Table',
encodings: encoding,
canvasSize,
},
});
expect(spec.facet).toEqual({ field: 'agency_type', type: 'nominal', sort: null });
expect(spec.columns).toBe(2);
expect(spec.hconcat).toBeUndefined();
expect(spec.spec.hconcat).toHaveLength(2);
expect(spec.spec.hconcat[0].data).toBeUndefined();
expect(spec.data).toEqual({ name: '__bt_displayTable' });
expect(spec.resolve.scale.y).toBe('independent');
expect(spec.spec.resolve.scale.y).toBe('shared');
expect(spec.spec.hconcat[0].width).toBeLessThan(canvasSize.width);
expect(spec.spec.hconcat[0].height).toBeLessThan(canvasSize.height);
expect(spec.spec.hconcat[0].encoding.y.axis.labelFontSize).toBeLessThan(13);
expect(spec.spec.hconcat[1].mark.fontSize).toBeLessThan(12);
expect(() => compile(spec)).not.toThrow();
});
it('rolls rows up within each facet without creating an undefined facet', () => {
const data = [
{ agency_type: 'state', agency: 'RVSN', launches: 100 },
{ agency_type: 'state', agency: 'UNKS', launches: 90 },
{ agency_type: 'state', agency: 'NASA', launches: 80 },
{ agency_type: 'state', agency: 'USAF', launches: 70 },
{ agency_type: 'private', agency: 'Arianespace', launches: 60 },
{ agency_type: 'private', agency: 'ILS-K', launches: 50 },
{ agency_type: 'private', agency: 'ULA', launches: 40 },
{ agency_type: 'private', agency: 'Boeing', launches: 30 },
];
const spec = assembleVegaLite({
data: { values: data },
semantic_types: semanticTypes,
chart_spec: {
chartType: 'Bar Table',
encodings: encoding,
canvasSize,
chartProperties: { maxRows: 3 },
},
});
const displayRows = spec.datasets.__bt_displayTable;
expect(displayRows).toHaveLength(6);
expect(displayRows.filter((row: any) => row.__bt_others)).toEqual([
expect.objectContaining({ agency_type: 'state', agency: 'Others (+2)', launches: 150 }),
expect.objectContaining({ agency_type: 'private', agency: 'Others (+2)', launches: 70 }),
]);
expect(displayRows.every((row: any) => row.agency_type === 'state' || row.agency_type === 'private')).toBe(true);
});
it('computes percentage totals within each facet', () => {
const data = [
{ agency_type: 'state', agency: 'RVSN', launches: 100 },
{ agency_type: 'state', agency: 'UNKS', launches: 50 },
{ agency_type: 'private', agency: 'Arianespace', launches: 40 },
{ agency_type: 'private', agency: 'ILS-K', launches: 10 },
];
const spec = assembleVegaLite({
data: { values: data },
semantic_types: semanticTypes,
chart_spec: {
chartType: 'Bar Table',
encodings: { ...encoding, color: { field: 'agency_type' } },
canvasSize,
chartProperties: { showPercent: true },
},
});
expect(spec.spec.hconcat).toHaveLength(3);
const percentPanel = spec.spec.hconcat[1];
expect(percentPanel.transform[0].groupby).toEqual(['agency_type', 'agency']);
expect(percentPanel.transform[1]).toEqual({
joinaggregate: [{ op: 'sum', field: '__bt_val', as: '__bt_total' }],
groupby: ['agency_type'],
});
expect(percentPanel.transform[2]).toEqual({
calculate: 'datum.__bt_total === 0 ? null : datum.__bt_val / datum.__bt_total',
as: '__bt_pct',
});
expect(() => compile(spec)).not.toThrow();
});
});