Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions pygwalker/api/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import sys
from pathlib import Path
import typer
import polars as pl

from .server import walk_server

app = typer.Typer(name="pygwalker-cli", help="PyGWalker Standalone Fast CLI - Zero Jupyter required.")


@app.command()
def main(
file_path: Path = typer.Argument(..., help="Path to CSV or Parquet dataset"),
port: int = typer.Option(8080, "--port", "-p", help="Port for the FastAPI server"),
no_browser: bool = typer.Option(False, "--no-browser", help="Do not automatically open the web browser"),
):
"""Start standalone Graphic-Walker web server for a dataset."""
if not file_path.exists():
typer.echo(f"Error: File '{file_path}' not found.", err=True)
raise typer.Exit(1)

typer.echo(f"Loading '{file_path}' into Polars Rust engine...")
if file_path.suffix.lower() == ".csv":
df = pl.read_csv(file_path, try_parse_dates=True, infer_schema_length=10000)
elif file_path.suffix.lower() == ".parquet":
df = pl.read_parquet(file_path)
else:
typer.echo("Unsupported file format. Please provide a CSV or Parquet file.", err=True)
raise typer.Exit(1)

typer.echo(f"Loaded dataset with shape {df.shape}. Starting server...")
walk_server(df, port=port, auto_open=not no_browser)


def entry_point():
app()


if __name__ == "__main__":
entry_point()
116 changes: 116 additions & 0 deletions pygwalker/api/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import json
import webbrowser
import threading
import time
from typing import Union, List, Optional, Any, Dict

from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
Comment thread
Sagar369r marked this conversation as resolved.

from .pygwalker import PygWalker
from pygwalker.data_parsers.base import FieldSpec
from pygwalker._typing import DataFrame, IAppearance, IThemeKey
from pygwalker.utils.encode import DataFrameEncoder
from pygwalker.utils.free_port import find_free_port
from pygwalker.communications.base import BaseCommunication


def create_fastapi_server(walker: PygWalker) -> FastAPI:
"""Create a high-performance FastAPI server to serve a PygWalker instance to the browser."""
app = FastAPI(title="PyGWalker Fast Server (Jupyter Bypass)")

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# Initialize communication handler
walker.use_preview = False
walker._init_callback(BaseCommunication(str(walker.gid)))

@app.get("/", response_class=HTMLResponse)
async def get_index():
props = walker._get_props("web_server")
props["communicationUrl"] = "/comm"
# Render HTML without jupyter iframe wrapping if desired, or with iframe
html = walker._get_render_iframe(props, return_iframe=False)
return HTMLResponse(content=html, status_code=200)

@app.post("/comm")
Comment thread
Sagar369r marked this conversation as resolved.
async def post_comm(request: Request):
payload = await request.json()
action = payload.get("action", "")
data = payload.get("data", {})

# Handle message via communication handler
result = walker.comm._receive_msg(action, data)
encoded_json = json.dumps(result, cls=DataFrameEncoder)
return Response(content=encoded_json, media_type="application/json")

@app.get("/health")
async def health_check():
return {"status": "ok", "gid": str(walker.gid)}

return app


def walk_server(
dataset: Union[DataFrame, Any],
gid: Optional[Union[int, str]] = None,
*,
field_specs: Optional[List[FieldSpec]] = None,
theme_key: IThemeKey = "g2",
appearance: IAppearance = "media",
spec: str = "",
spec_path: Optional[str] = None,
port: Optional[int] = None,
auto_open: bool = True,
**kwargs,
) -> None:
"""Launch PyGWalker as a standalone FastAPI web server directly in the browser (No Jupyter required)."""
if field_specs is None:
field_specs = []

if port is None:
port = find_free_port()

walker = PygWalker(
gid=gid,
dataset=dataset,
field_specs=field_specs,
spec=spec,
Comment thread
Sagar369r marked this conversation as resolved.
source_invoke_code="",
theme_key=theme_key,
appearance=appearance,
show_cloud_tool=False,
use_preview=False,
kernel_computation=True,
use_save_tool=True,
gw_mode="explore",
is_export_dataframe=True,
kanaries_api_key="",
default_tab="vis",
cloud_computation=False,
**kwargs,
)

app = create_fastapi_server(walker)
url = f"http://localhost:{port}"

def _open_browser():
time.sleep(0.8)
try:
webbrowser.open(url)
except Exception:
pass

if auto_open:
threading.Thread(target=_open_browser, daemon=True).start()

print(f"\n🚀 PyGWalker Fast Server running at: {url}\nPress Ctrl+C to stop.\n")
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
45 changes: 44 additions & 1 deletion pygwalker/data_parsers/polars_parser.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from typing import List, Any, Dict, Optional
from functools import cached_property
import io

import polars as pl

from .base import BaseDataFrameDataParser, is_temporal_field, is_geo_field
from pygwalker.services.fname_encodings import rename_columns
from pygwalker.utils.payload_to_sql import get_sql_from_payload


def _is_numeric_dtype(dtype: pl.DataType) -> bool:
Expand All @@ -29,7 +31,48 @@ def _is_temporal_dtype(dtype: pl.DataType) -> bool:


class PolarsDataFrameDataParser(BaseDataFrameDataParser[pl.DataFrame]):
"""prop parser for polars.DataFrame"""
"""Surgically optimized property & data parser for polars.DataFrame.

Bypasses all DuckDB and Pandas conversions to process queries purely using
Polars' multi-threaded Rust SQLContext and Arrow memory backend.
"""

@cached_property
def field_metas(self) -> List[Dict[str, str]]:
meta_types = []
for col_name, dtype in self.df.schema.items():
if _is_temporal_dtype(dtype):
time_zone = getattr(dtype, "time_zone", None)
field_meta_type = "datetime_tz" if time_zone else "datetime"
elif _is_numeric_dtype(dtype):
field_meta_type = "number"
else:
field_meta_type = "string"
meta_types.append({"key": col_name, "type": field_meta_type})
return meta_types

def get_datas_by_sql(self, sql: str) -> List[Dict[str, Any]]:
"""Execute SQL query using Polars native multi-threaded SQLContext."""
ctx = pl.SQLContext(pygwalker_mid_table=self.df.lazy())
result_df = ctx.execute(sql, eager=True)
result_df = result_df.fill_nan(None)
return result_df.to_dicts()

def get_datas_by_payload(self, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
sql = get_sql_from_payload("pygwalker_mid_table", payload, {"pygwalker_mid_table": self.field_metas})
return self.get_datas_by_sql(sql)

def batch_get_datas_by_sql(self, sql_list: List[str]) -> List[List[Dict[str, Any]]]:
"""Batch get records using native Polars SQLContext."""
return [self.get_datas_by_sql(sql) for sql in sql_list]

def batch_get_datas_by_payload(self, payload_list: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]:
"""Batch get records via payload using native Polars SQLContext."""
return [self.get_datas_by_payload(payload) for payload in payload_list]

@property
def data_size(self) -> int:
return self.df.estimated_size()

def to_records(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
df = self.df[:limit] if limit is not None else self.df
Expand Down