mirror of
https://github.com/microsoft/data-formulator.git
synced 2026-11-04 12:01:37 +00:00
add plugin back
This commit is contained in:
1 parent
3b58fd2091
commit
f7f9155b6f
18 files changed
+1718
-135
No files matched your search
@@ -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` 插件。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
也可以通过环境变量指定目录:
|
||||
|
||||
@@ -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)"
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user