Ctrl K
ComunidadGuías…

butler/tools/data_query.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Local data query tool — run SQL on CSV/JSON/Parquet files via duckdb (optional extra [analytics])."""

from __future__ import annotations

import json
import logging
import re
from pathlib import Path
from typing import Any, Callable

logger = logging.getLogger(__name__)

_MAX_ROWS = 200
_MAX_OUTPUT_CHARS = 32_000

_QUERYABLE_EXTENSIONS = frozenset({".csv", ".tsv", ".json", ".jsonl", ".parquet", ".db", ".sqlite"})

_WRITE_KEYWORDS = re.compile(
    r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|REPLACE|MERGE|GRANT|REVOKE|EXEC|EXECUTE|CALL|COPY|LOAD|IMPORT|EXPORT)\b",
    re.IGNORECASE,
)

_DANGEROUS_FUNCTIONS = re.compile(
    r"\b(read_csv_auto|read_csv|read_json_auto|read_json|read_parquet|read_text|"
    r"read_blob|read_ndjson|ATTACH|DETACH|read_csv_objects|glob|pragma_|"
    r"query_table|httpfs|s3_|hf_|azure_)\b",
    re.IGNORECASE,
)


def _duckdb_available() -> bool:
    try:
        import duckdb  # noqa: F401
        return True
    except ImportError:
        return False


def _analytics_enabled() -> bool:
    from butler.utilities.env_parse import env_truthy
    return bool(env_truthy("BUTLER_DATA_QUERY", default=True))


def _strip_sql_comments(sql: str) -> str:
    """Remove SQL comments to prevent keyword check bypass."""
    sql = re.sub(r"--[^\n]*", " ", sql)
    sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
    return sql


def _resolve_project_path(path: str) -> Path | None:
    """Resolve and validate a data file path within workspace via path_safety."""
    from butler.tools.path_safety import check_tool_path

    safety = check_tool_path(path, for_write=False)
    if not safety.allowed:
        logger.warning("data_query path rejected: %s — %s", path, safety.error)
        return None

    p = Path(safety.path)
    if not p.exists():
        return None
    if p.suffix.lower() not in _QUERYABLE_EXTENSIONS:
        return None
    return p


def tool_query_data(
    sql: str = "",
    *,
    file: str = "",
    max_rows: int = _MAX_ROWS,
    **_: Any,
) -> str:
    """Execute a read-only SQL query against a local data file.

    For CSV/JSON/Parquet files, the file is auto-registered as a table named 'data'.
    For SQLite .db files, all tables are available directly.
    """
    if not _duckdb_available():
        return json.dumps({
            "error": "duckdb not installed — data query unavailable",
            "hint": "pip install 'butler-system[analytics]'",
        })

    if not _analytics_enabled():
        return json.dumps({
            "error": "data query disabled",
            "hint": "set BUTLER_DATA_QUERY=1 to enable",
        })

    query = (sql or "").strip()
    if not query:
        return json.dumps({"error": "sql query required"})

    normalized = _strip_sql_comments(query)

    if _WRITE_KEYWORDS.search(normalized):
        match = _WRITE_KEYWORDS.search(normalized)
        if match is not None:
            return json.dumps({"error": f"write operations not allowed: {match.group(1).upper()}"})

    if _DANGEROUS_FUNCTIONS.search(normalized):
        match = _DANGEROUS_FUNCTIONS.search(normalized)
        if match is not None:
            return json.dumps({
                "error": f"direct file access functions not allowed in SQL: {match.group(1)}",
                "hint": "use the 'file' parameter to specify data source",
            })

    cap = min(max(10, int(max_rows if max_rows is not None else _MAX_ROWS)), 1000)

    target = (file or "").strip()
    if not target:
        return json.dumps({"error": "file parameter is required", "hint": "specify a data file path"})

    from butler.tools.data_query_ops import execute_readonly_duckdb_query

    return str(
        execute_readonly_duckdb_query(
            query=query,
            target=target,
            cap=cap,
            max_output_chars=_MAX_OUTPUT_CHARS,
            resolve_path=_resolve_project_path,
            queryable_extensions=_QUERYABLE_EXTENSIONS,
        )
    )


def register_data_query_tools(register_fn: Callable[..., None]) -> None:
    """Register query_data tool if duckdb is available."""
    if not _duckdb_available():
        logger.debug("duckdb not installed; query_data tool not registered")
        return

    register_fn(
        name="query_data",
        description=(
            "Run a read-only SQL query against local data files "
            "(CSV, TSV, JSON, Parquet, or SQLite databases). "
            "For CSV/JSON/Parquet, the file is loaded as table 'data'. "
            "For SQLite .db files, all tables are available by name."
        ),
        schema={
            "type": "object",
            "properties": {
                "sql": {
                    "type": "string",
                    "description": "SQL query to execute (read-only; SELECT only)",
                },
                "file": {
                    "type": "string",
                    "description": "Path to data file (CSV, JSON, Parquet, or .db)",
                },
                "max_rows": {
                    "type": "integer",
                    "default": _MAX_ROWS,
                    "minimum": 10,
                    "maximum": 1000,
                    "description": "Maximum rows to return",
                },
            },
            "required": ["sql", "file"],
        },
        handler=tool_query_data,
        toolset="analytics",
    )


__all__ = ["register_data_query_tools", "tool_query_data"]