From 8005c8bf72a2e4f3f3eedc0f1922f7d29abe926b Mon Sep 17 00:00:00 2001 From: observedobserver <270001151@qq.com> Date: Wed, 1 Jul 2026 01:08:37 -0700 Subject: [PATCH 1/2] feat: add dev-mode hot reload, dev-stack orchestrator, and contributor/agent docs Improve the local developer experience for both humans and coding agents. Dev mode (opt-in, production unchanged): - anywidget widget loads pygwalker-app.es.js from disk as a pathlib.Path when PYGWALKER_DEV=1 or ANYWIDGET_HMR=1, so anywidget hot-reloads it live in the notebook. With the flags unset, the bundled ESM string is embedded exactly as before. - app: add `yarn dev:build` (vite build --watch) to rebuild the bundle on change. Tooling: - scripts/dev.py: one command starts the frontend watch build + JupyterLab with the dev env flags and tees all output to logs/ (frontend.log, jupyter.log, pygwalker.log). Waits for the first build, fails fast if it crashes, and tears down the process tree cleanly on Ctrl+C. - utils/log.py: honor PYGWALKER_LOG_LEVEL and PYGWALKER_LOG_FILE (idempotent handlers); default console logging unchanged. Docs: - AGENTS.md (repo map, build model, dev stack, logs, commands, gotchas) with CLAUDE.md importing it; docs/ARCHITECTURE.md (how the Python and frontend halves are built and communicate); docs/DEVELOPMENT.md rewritten around the anywidget HMR workflow with the legacy Vite dev-server/component_url path kept as an appendix; cross-links from README and CONTRIBUTING. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + AGENTS.md | 215 +++++++++++++++++++ CLAUDE.md | 24 +++ CONTRIBUTING.md | 10 +- README.md | 9 +- app/package.json | 1 + docs/ARCHITECTURE.md | 139 ++++++++++++ docs/CONTRIBUTING.md | 42 ++-- docs/DEVELOPMENT.md | 208 ++++++++++-------- pygwalker/services/anywidget_widget.py | 29 ++- pygwalker/utils/frontend_assets.py | 19 ++ pygwalker/utils/log.py | 58 ++++- scripts/dev.py | 284 +++++++++++++++++++++++++ 13 files changed, 916 insertions(+), 125 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 scripts/dev.py diff --git a/.gitignore b/.gitignore index eff1ac28..923be481 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,6 @@ __pycache__/ .web *.py[cod] assets/external/ + +# Local dev stack logs (scripts/dev.py, PYGWALKER_LOG_FILE) +logs/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d713e2ef --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,215 @@ +# AGENTS.md — PyGWalker contributor & agent guide + +This is the fast-start map of the PyGWalker repo for both human contributors and coding +agents. It explains how the project is put together, how to run it in **dev mode with live +frontend reload**, and where all the logs go. Read this first — it is written to save you +from re-deriving the architecture by grepping. + +> Deeper references: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (how it is built), +> [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) (dev workflow + troubleshooting), +> [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) (validation & CI). + +--- + +## 1. What PyGWalker is (30-second model) + +PyGWalker turns a pandas / polars / pyarrow dataframe into an interactive +[Graphic Walker](https://github.com/Kanaries/graphic-walker) UI inside notebooks, Streamlit, +and plain web servers. It has **two halves that ship together**: + +- **Python package** (`pygwalker/`) — public API (`walk`, `render`, `table`, `Walker`, + `to_html`), data parsing, and the transports that talk to the UI. +- **Frontend app** (`app/`, React + Vite) — the UI. It is compiled into JavaScript bundles + that are checked into the wheel under `pygwalker/templates/dist/` and loaded by the Python + side at render time. + +The Python side never renders charts itself; it hands built JS + serialized data to a +notebook/browser and then answers data/spec requests over a message channel. + +--- + +## 2. Repo map + +| Path | What lives here | +|------|-----------------| +| `pygwalker/api/` | Public entry points. `adapter.py` picks jupyter vs webserver; `jupyter.py` = notebook dispatch; `walker.py` = the reusable `Walker`; `pygwalker.py` = the core `PygWalker`. | +| `pygwalker/services/` | Rendering + display. `anywidget_widget.py` (default transport), `render.py` + `templates/*.html` (iframe transport), `global_var.py` (runtime globals), `jupyter_display.py`. | +| `pygwalker/communications/` | Kernel⇄frontend transports: `anywidget_comm.py` (default), `hacker_comm.py` (iframe), `streamlit_comm.py`, `gradio_comm.py`, `reflex_comm.py`. `protocol.py` is the shared message schema. | +| `pygwalker/data_parsers/` | Dataframe/connector adapters (pandas, polars, pyarrow, SQL, spark…). | +| `pygwalker/templates/dist/` | **Build output** (git-ignored). The JS bundles the Python side loads. | +| `pygwalker/utils/` | Helpers: `frontend_assets.py` (locate/load bundles), `log.py` (logging), encoders. | +| `app/src/` | Frontend source. `index.tsx` = entry; `utils/communication.tsx` = transports; `dataSource/` = data ingest; `interfaces/comm.generated.ts` = **generated** protocol types; `store/` = MobX state. | +| `scripts/` | `dev.py` (dev orchestrator), `compile.sh` (build frontend), `local_ci.py` (mirror CI), `generate_comm_protocol_ts.py` (regenerate protocol types). | +| `tests/` | Python tests + `*.ipynb` notebooks run by `nbmake`. `app/tests/` holds Playwright smoke tests. | + +--- + +## 3. How the two halves fit together (build & load model) + +``` +app/src/* --(vite build)--> pygwalker/templates/dist/*.js --(read at runtime)--> Python renders it +``` + +**Frontend build variants** (`app/vite.config.ts`, output to `pygwalker/templates/dist/`): + +| Bundle | Built from | Loaded by | +|--------|-----------|-----------| +| `pygwalker-app.es.js` | `src/index.tsx` | **anywidget** transport (the default `pyg.walk` path) | +| `pygwalker-app.iife.js` | `src/index.tsx` | iframe transport / static `to_html()` | +| `dsl-to-workflow.umd.js` | `src/lib/dslToWorkflow.ts` | kernel-side DSL→workflow conversion | +| `vega-to-dsl.umd.js` | `src/lib/vegaToDsl.ts` | kernel-side Vega→DSL conversion | + +`yarn build` builds all four (+ typecheck). `yarn build:app` builds only the two app +bundles (fast, no typecheck) — good for a quick manual rebuild, not for CI. + +**The message protocol is generated, not hand-written.** Python Pydantic models in +`pygwalker/communications/protocol.py` are the source of truth. Running +`python scripts/generate_comm_protocol_ts.py` regenerates +`app/src/interfaces/comm.generated.ts`. **If you change `protocol.py`, regenerate and rebuild +the frontend.** Never edit `comm.generated.ts` by hand. + +**Transports.** The default notebook transport is **anywidget** (`env='JupyterAnywidget'`). +`env='Jupyter'` / `env='JupyterWidget'` are deprecated aliases that are coerced to anywidget +and slated for removal in 0.7.0. Streamlit/Gradio/Reflex/web-server have their own transports. + +--- + +## 4. First-time setup + +Requires **Python 3.10+**, **Node.js 22.x**, **Yarn 1.x**. + +```bash +# Python (editable install with dev extras) +python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate +pip install -e ".[dev]" + +# Frontend deps + one full build so pygwalker/templates/dist/ is populated +cd app && yarn install && yarn build && cd .. +``` + +--- + +## 5. Dev mode: edit the frontend and see it live (anywidget HMR) + +The default `pyg.walk(df)` uses the anywidget transport, which loads +`pygwalker-app.es.js` from disk. In dev mode we (a) rebuild that bundle on every source +change and (b) let anywidget hot-reload it into open widgets. **One command starts +everything and captures all logs:** + +```bash +source venv/bin/activate +python scripts/dev.py +``` + +This launches, and tees the output of, two long-running processes: + +- **frontend** — `cd app && yarn dev:build` (`vite build --watch`) rebuilds the bundles into + `pygwalker/templates/dist/` on every edit under `app/src/`. → `logs/frontend.log` +- **jupyter** — `jupyter lab` started with `PYGWALKER_DEV=1` and `ANYWIDGET_HMR=1` in its + environment (inherited by kernels). → `logs/jupyter.log` + +Then, in a notebook cell, **no special setup is needed** — just use PyGWalker normally: + +```python +import pandas as pd, pygwalker as pyg +pyg.walk(pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})) +``` + +Edit a file under `app/src/`, wait for the rebuild to finish in `logs/frontend.log` +(`built in …`), and the widget hot-reloads in place — usually without re-running the cell. +For a heavy change you can always re-run the cell. + +**Why it works:** with `ANYWIDGET_HMR=1` (or `PYGWALKER_DEV=1`), `WalkerAnyWidget._esm` is set +to a `pathlib.Path` pointing at the built `pygwalker-app.es.js` instead of an embedded string. +anywidget then reads that file and watches it (via `watchfiles`), pushing new code to the +frontend when `vite build --watch` rewrites it. With the flags **off** (normal installs), the +bundle is embedded as a string exactly as before — production behavior is unchanged. + +Useful flags: `--no-jupyter` (only rebuild the frontend), `--no-frontend` (only Jupyter), +`--jupyter-port N`, `--no-browser` (auto-enabled when output is not a TTY, e.g. agent runs), +`--log-dir DIR`. Stop everything with **Ctrl+C** (services are torn down cleanly). + +> Alternative (Vite dev server + browser refresh, using the legacy iframe transport) is +> documented in [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md#appendix-vite-dev-server-iframe-transport). +> Prefer the anywidget HMR path above. + +--- + +## 6. Logs: one place to look + +`scripts/dev.py` writes everything under `logs/` (git-ignored) at the repo root: + +| File | Contents | +|------|----------| +| `logs/frontend.log` | Vite build/watch output — watch for `built in …` (rebuild done) and TypeScript errors. | +| `logs/jupyter.log` | JupyterLab server output — **the URL + token to open the notebook is here**. | +| `logs/pygwalker.log` | Kernel-side PyGWalker Python logs (set via `PYGWALKER_LOG_FILE`). | + +**Python log controls** (honored by `pygwalker/utils/log.py`, independent of the orchestrator): + +- `PYGWALKER_LOG_FILE=/path/to/file.log` — also append Python logs to a file. +- `PYGWALKER_LOG_LEVEL=DEBUG` — raise/lower verbosity (default `INFO`). + +**Frontend runtime logs** (what happens in the browser, e.g. comm errors) appear in the +**browser devtools console**, not in `logs/`. The frontend surfaces user-facing errors as +in-app toast notifications rather than `console.log`. + +Agent tip: tail `logs/jupyter.log` for the server URL, `logs/frontend.log` to know when a +rebuild finished, and `logs/pygwalker.log` for kernel-side errors. + +--- + +## 7. Everyday commands + +```bash +# Frontend (run from app/) +yarn build # full build: typecheck + all 4 bundles (CI-equivalent) +yarn build:app # fast: only the two app bundles, no typecheck +yarn dev:build # rebuild-on-change (what scripts/dev.py runs) +yarn typecheck # tsc --noEmit +yarn test:front_end # Playwright smoke test (run `yarn playwright install chromium` once) + +# Python (run from repo root, venv active) +python -m ruff check pygwalker tests scripts bin pygwalker_tools +python -m ruff format --check pygwalker tests scripts bin pygwalker_tools +python -X faulthandler -W error::DeprecationWarning:pygwalker -m pytest -o faulthandler_timeout=60 tests +python -m pytest --nbmake --nbmake-kernel=python tests/*.ipynb # notebook tests + +# Regenerate the JS protocol types after editing pygwalker/communications/protocol.py +python scripts/generate_comm_protocol_ts.py + +# Run the whole CI flow locally (frontend build + smoke test + notebooks + python) +python scripts/local_ci.py # add --skip-frontend / --skip-notebooks to narrow +``` + +--- + +## 8. Rules & gotchas + +- **Do not commit `pygwalker/templates/dist/`** — it is generated and git-ignored. The wheel + build (and CI) rebuilds it via the Hatch jupyter-builder hook. +- **Regenerate + rebuild after protocol changes.** Editing `communications/protocol.py` + without running `scripts/generate_comm_protocol_ts.py` and rebuilding the frontend leaves + Python and JS out of sync. +- **`yarn build:app` skips typecheck** and the DSL bundles. Run full `yarn build` (or + `yarn typecheck`) before pushing frontend changes. +- **anywidget is the transport of record.** Don't reach for the deprecated `env='Jupyter'` / + iframe path for new work; it is removed in 0.7.0. +- **Dev-mode flags are opt-in.** `PYGWALKER_DEV` / `ANYWIDGET_HMR` only affect a dev session. + Never rely on them being set at runtime for end users. +- **First run needs a build.** In dev mode the widget loads `dist/pygwalker-app.es.js` from + disk; if it is missing you'll get a clear "Missing PyGWalker frontend asset" error — run the + frontend build (or wait for `scripts/dev.py`'s first build to finish). + +--- + +## 9. Where to look for X + +| Question | Start here | +|----------|-----------| +| How does `pyg.walk()` decide what to display? | `pygwalker/api/adapter.py` → `api/jupyter.py` (`env_display_map`) | +| How is the frontend bundle loaded / dev-swapped? | `pygwalker/services/anywidget_widget.py`, `utils/frontend_assets.py` | +| What messages can the frontend send the kernel? | `pygwalker/communications/protocol.py` ⇄ `app/src/interfaces/comm.generated.ts` | +| How is data sent to the browser? | `pygwalker/services/data_communication.py`, `app/src/dataSource/` | +| How is a chart exported to PNG/SVG/code? | `pygwalker/services/chart_export.py`, `app/src/tools/` | +| How does the iframe/static-HTML path render? | `pygwalker/services/render.py` + `pygwalker/templates/*.html` | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f5cad602 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +# CLAUDE.md + +This project's contributor & agent guide is maintained in **[AGENTS.md](AGENTS.md)** — a +single source of truth for the architecture, dev-mode workflow, and log locations. Read it +before making changes. + +@AGENTS.md + +## TL;DR for a coding agent + +- **Setup:** `python -m venv venv && source venv/bin/activate && pip install -e ".[dev]"`, + then `cd app && yarn install && yarn build`. +- **Run everything in dev mode with live reload + centralized logs:** `python scripts/dev.py` + (starts the frontend watch build + JupyterLab with `PYGWALKER_DEV=1` / `ANYWIDGET_HMR=1`). + Pass `--no-browser` for headless runs. +- **Logs:** `logs/frontend.log` (build/watch), `logs/jupyter.log` (server URL + token), + `logs/pygwalker.log` (kernel-side Python logs). Frontend runtime logs are in the browser + console. +- **In a notebook:** just `import pygwalker as pyg; pyg.walk(df)` — no special setup; edits + under `app/src/` hot-reload into the widget. +- **Before pushing:** `python scripts/local_ci.py` (or, narrower, `yarn typecheck` + `yarn build` + in `app/`, and `ruff check`/`ruff format --check`/`pytest` from the root). +- **Don't:** commit `pygwalker/templates/dist/`, hand-edit `app/src/interfaces/comm.generated.ts`, + or rely on `PYGWALKER_DEV`/`ANYWIDGET_HMR` at runtime for end users. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb92b098..f59907fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,17 @@ # Contributing to PyGWalker -The contributor workflow lives in [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md). +New contributors (and coding agents) should start with [`AGENTS.md`](AGENTS.md): it maps the +repo, explains how the Python and frontend halves are built, and documents the one-command dev +stack (`python scripts/dev.py`) with live frontend reload and centralized logs. -Start there for the supported local setup, including: +The detailed contributor workflow lives in [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md), +covering: - Python editable installs and test commands. - Frontend dependency installation, builds, type checks, and Playwright smoke tests. - Optional local Graphic Walker source builds through `app`'s `dev:preinstall` script. -- Vite dev-server setup for JupyterLab hot reload. +- The hot-reload dev workflow ([`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md)) and project + architecture ([`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)). - CI and package-build expectations. Keeping the detailed guide under `docs/` lets the development notes and diff --git a/README.md b/README.md index a66123e8..4bb63fa0 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,14 @@ renderer.explorer() ## Development -Refer it: [local-development](https://docs.kanaries.net/pygwalker/installation#local-development) +Local development is documented in this repository: + +- [AGENTS.md](AGENTS.md) — repo map, the one-command dev stack (`python scripts/dev.py`), and log locations (written for both human and AI-agent contributors). +- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) — hot-reload dev workflow and troubleshooting. +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — how the Python and frontend halves are built and talk to each other. +- [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) — validation commands, CI, and package builds. + +See also the hosted guide: [local-development](https://docs.kanaries.net/pygwalker/installation#local-development). ## Tested Environments diff --git a/app/package.json b/app/package.json index f1be59d1..29cce17c 100644 --- a/app/package.json +++ b/app/package.json @@ -7,6 +7,7 @@ "scripts": { "build": "yarn typecheck && vite build && vite build --mode=dsl_to_workflow && vite build --mode=vega_to_dsl", "build:app": "vite build", + "dev:build": "vite build --watch --mode production", "typecheck": "tsc --noEmit --pretty false", "dev:preinstall": "(cd ../graphic-walker/packages/graphic-walker; yarn --frozen-lockfile && yarn build)", "dev:server": "vite --host", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..4398aa84 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,139 @@ +# PyGWalker Architecture + +How the project is constructed across its Python and frontend halves, and how they load and +talk to each other at runtime. This is the orientation doc for contributors and agents; for +the hands-on dev workflow see [`DEVELOPMENT.md`](./DEVELOPMENT.md), and for the quick map see +[`../AGENTS.md`](../AGENTS.md). + +## Two halves that ship together + +PyGWalker is a Python library plus a React frontend, packaged as one wheel: + +- **Python package** — `pygwalker/`. The public API, dataframe parsing, and the transports + that exchange data and chart specs with the UI. +- **Frontend app** — `app/`. A React + Vite application built into JavaScript bundles that are + placed in `pygwalker/templates/dist/` and shipped inside the wheel. + +At render time the Python side serializes the dataframe + config and hands the built +JavaScript to a notebook/browser. The running UI then calls back into the Python kernel to +fetch data, run queries, and save chart specs. + +``` +┌────────────────────────┐ build ┌──────────────────────────────┐ +│ app/ (React + Vite) │ ───────────────────▶ │ pygwalker/templates/dist/*.js │ +└────────────────────────┘ vite build └──────────────┬───────────────┘ + │ read at runtime + ▼ +┌───────────────────────────────────────────────────────────────────────────────┐ +│ pygwalker/ (Python) walk()/render()/table()/Walker → transport → rendered UI │ +└───────────────────────────────────────────────────────────────────────────────┘ + ▲ │ + │ messages (data, spec, export) │ + └────────────────────────────────────────────┘ +``` + +## Python package layout + +| Module | Responsibility | +|--------|----------------| +| `api/adapter.py` | Top-level `walk`/`render`/`table`. Detects environment (`get_current_env`) and routes to the notebook (`api/jupyter.py`) or web-server (`api/webserver.py`) path. | +| `api/jupyter.py` | Notebook dispatch. Maps `env` → a display method (`env_display_map`). Default `JupyterAnywidget`. | +| `api/walker.py` | `Walker`, the reusable object (`.show()`, `.to_html()`, `.to_streamlit()`). | +| `api/pygwalker.py` | `PygWalker`, the core object holding data source, spec, and props. | +| `services/anywidget_widget.py` | **Default transport.** Wraps the built ESM in an `anywidget.AnyWidget`. | +| `services/render.py` + `templates/*.html` | Iframe / static-HTML transport (Jinja templates). | +| `services/jupyter_display.py` | The concrete display flows (anywidget, iframe, widgets, preview, convert). | +| `services/global_var.py` | Process-wide runtime settings (`GlobalVarManager`): component URL, API keys, feature flags. | +| `communications/` | Kernel⇄frontend transports + the shared message `protocol.py`. | +| `data_parsers/` | Adapters that normalize pandas/polars/pyarrow/SQL/spark sources to a common interface. | +| `utils/frontend_assets.py` | Locates and loads the built bundles from `templates/dist/`. | +| `utils/log.py` | The shared `pygwalker` logger (console + optional file). | + +## Frontend app layout + +| Path | Responsibility | +|------|----------------| +| `src/index.tsx` | Entry point. Exports `GWalker` (iframe/http), `AnywidgetGWalkerApp` (anywidget), Streamlit and preview apps. Selects a transport by `env`. | +| `src/utils/communication.tsx` | The four client transports: Jupyter widgets, HTTP (Streamlit/Gradio/web), and anywidget. | +| `src/dataSource/` | Receives chunked data from the kernel and delegates kernel computation. | +| `src/interfaces/comm.generated.ts` | **Generated** protocol types (request/response envelopes, action map). Do not hand-edit. | +| `src/store/` | MobX stores for UI state, notifications, and the active communication instance. | +| `src/tools/` | Toolbar actions: save, export chart, export dataframe, open-in-desktop. | +| `src/lib/` | Standalone transforms (`dslToWorkflow.ts`, `vegaToDsl.ts`) built as separate UMD bundles. | + +## Build pipeline + +**Frontend → bundles.** `app/vite.config.ts` defines the build. `yarn build` runs a typecheck +then produces four artifacts into `pygwalker/templates/dist/`: + +| Bundle | Entry | Consumer | +|--------|-------|----------| +| `pygwalker-app.es.js` | `src/index.tsx` | anywidget transport (default notebook path) | +| `pygwalker-app.iife.js` | `src/index.tsx` | iframe transport / `to_html()` | +| `dsl-to-workflow.umd.js` | `src/lib/dslToWorkflow.ts` | kernel-side DSL→workflow conversion | +| `vega-to-dsl.umd.js` | `src/lib/vegaToDsl.ts` | kernel-side Vega→DSL conversion | + +`yarn build:app` is a fast subset (only the two app bundles, no typecheck). + +**Protocol types are generated.** The message contract is defined once, in Python Pydantic +models at `pygwalker/communications/protocol.py`. +`python scripts/generate_comm_protocol_ts.py` renders those models into +`app/src/interfaces/comm.generated.ts`. Change the Python models, regenerate, then rebuild the +frontend — otherwise the two sides drift. + +**Wheel build.** `pyproject.toml` wires a Hatch `jupyter-builder` hook that runs the frontend +`build` during `python -m build`, so a released wheel always contains freshly built bundles. +`pygwalker/templates/dist/` is git-ignored and rebuilt on demand; never commit it. + +## Runtime flow (default anywidget path) + +1. `pyg.walk(df)` → `adapter.walk` detects a notebook → `jupyter.walk` with the default + `env='JupyterAnywidget'`. +2. A `PygWalker` is created; `jupyter.py`'s `env_display_map` calls + `display_on_jupyter_use_anywidget`. +3. `services/anywidget_widget.py` builds a `WalkerAnyWidget` whose `_esm` is the built + `pygwalker-app.es.js`, sets `props` (serialized config + a data sample), and registers an + `AnywidgetCommunication` channel. +4. In the browser, `AnywidgetGWalkerApp` mounts Graphic Walker and, as the user explores, + sends typed requests (fetch data, run SQL, save spec, export) back over the anywidget + channel. `communications/` validates each message against `protocol.py` and dispatches it. + +The **iframe transport** (`services/render.py` + `templates/pygwalker_main_page.html`) is an +alternative that embeds the compressed `iife` bundle in an `iframe` `srcdoc`, or points the +iframe at a dev-server URL when `GlobalVarManager.component_url` is set. It backs +`to_html()` and the deprecated `env='Jupyter'`/`'JupyterWidget'` paths. + +## Message protocol & transports + +Every transport speaks the same envelope (see `protocol.py` / +`comm.generated.ts`): a request carries an `action`, a `data` payload, a request id `rid`, and +the widget/app id `gid`; a response carries `code`, `data`, and `message`. Actions include +data fetches (`get_datas`, `batch_get_datas_by_sql`), spec operations +(`get_latest_vis_spec`, `save_chart`, `update_spec`), export, and AI features. + +| Transport | Module | Mechanism | +|-----------|--------|-----------| +| anywidget (default) | `communications/anywidget_comm.py` | anywidget model `send`/`on_msg` | +| iframe widgets | `communications/hacker_comm.py` | ipywidgets text fields observed by the frontend | +| Streamlit | `communications/streamlit_comm.py` | HTTP route under Streamlit's server | +| Gradio | `communications/gradio_comm.py` | Starlette mount | +| Reflex | `communications/reflex_comm.py` | Reflex endpoint | +| web server | `api/webserver.py` | standalone HTTP handler | + +## Computation modes + +`computation` selects where data queries run: `browser` (all data sent to the client and +computed there), `kernel` (a local DuckDB engine in the Python process answers queries on +demand — best for large data), or `cloud` (Kanaries cloud). In `kernel` mode the frontend +parses the Graphic Walker DSL to SQL (using the `gw-dsl-parser` WASM / the UMD bundles) and +asks the kernel to execute it, so only aggregated results cross the boundary. + +## How dev mode changes asset loading + +Normally `WalkerAnyWidget._esm` is the **contents** of `pygwalker-app.es.js` read once at +import — a plain string embedded in the widget. When `PYGWALKER_DEV=1` or `ANYWIDGET_HMR=1` +is set, `_esm` instead points at the **file path** of that bundle. anywidget then reads it from +disk and, under HMR, watches it and live-reloads the widget whenever `vite build --watch` +rewrites the file. This is entirely opt-in: with the flags unset, loading is byte-for-byte the +same as before. See [`DEVELOPMENT.md`](./DEVELOPMENT.md) for the workflow and +[`../AGENTS.md`](../AGENTS.md) for the one-command dev stack. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 692aa3fc..6c6cfba6 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -3,6 +3,11 @@ This guide covers the local development path used by the Python package and the React frontend in `app/`. +> **New here?** Start with [`../AGENTS.md`](../AGENTS.md) (repo map + one-command dev stack) +> and [`ARCHITECTURE.md`](./ARCHITECTURE.md) (how the two halves are built). For the +> hot-reload dev workflow, see [`DEVELOPMENT.md`](./DEVELOPMENT.md). The fastest way to run +> everything locally with live frontend reload and centralized logs is `python scripts/dev.py`. + ## Prerequisites - Python 3.10 or newer @@ -83,38 +88,33 @@ yarn build If `../graphic-walker/packages/graphic-walker` does not exist, skip this command and use the locked npm dependency. -## Frontend Dev Server - -Start the Vite dev server from `app/`: - -```bash -yarn dev:server -``` - -The dev server listens on port `8769` and serves the app under -`/pyg_dev_app/`. +## Dev Server / Hot Reload -For JupyterLab development, run Jupyter with `jupyter-server-proxy`: +For iterating on the frontend, use the one-command dev stack, which rebuilds the app on every +change and hot-reloads it into open notebook widgets via anywidget HMR: ```bash -jupyter lab --ServerProxy.servers="{'pyg_dev_app': {'command': [], 'absolute_url': True, 'port': 8769, 'timeout': 30}}" +python scripts/dev.py ``` -In the notebook, point PyGWalker at the dev frontend before rendering: +This starts `yarn dev:build` (`vite build --watch`) and JupyterLab with `PYGWALKER_DEV=1` / +`ANYWIDGET_HMR=1`, teeing all output into `logs/`. See +[`DEVELOPMENT.md`](./DEVELOPMENT.md) for the full workflow, flags, and log locations. -```python -from pygwalker.services.global_var import GlobalVarManager +The older Vite dev-server + `GlobalVarManager.set_component_url("/pyg_dev_app/")` + +`jupyter-server-proxy` flow only drives the deprecated iframe transport (`env='Jupyter'`) and +is documented as an appendix in [`DEVELOPMENT.md`](./DEVELOPMENT.md#appendix-vite-dev-server-iframe-transport). -GlobalVarManager.set_component_url("/pyg_dev_app/") -``` +## Validation -Reset to bundled assets with: +To run the full CI flow locally in one command (frontend build + Playwright smoke test + +notebook tests + Python lint/tests), use: -```python -GlobalVarManager.set_component_url("") +```bash +python scripts/local_ci.py # add --skip-frontend / --skip-notebooks to narrow scope ``` -## Validation +Or run the individual steps below. Run Python formatting, lint, and tests from the repository root: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6e35f46a..dccc3fbe 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,113 +1,144 @@ # PyGWalker Development Setup -For the current end-to-end contributor workflow, see -[`docs/CONTRIBUTING.md`](./CONTRIBUTING.md). This page keeps extra -hot-reloading notes and troubleshooting details. +How to run PyGWalker locally with **live frontend reloading**, so you can edit the React app +under `app/src/` and see the change in a notebook without reinstalling anything. -This guide explains how to set up a local development environment for PyGWalker with hot-reloading support. +- New to the codebase? Read [`../AGENTS.md`](../AGENTS.md) and + [`ARCHITECTURE.md`](./ARCHITECTURE.md) first — they explain the two halves and the build. +- For the full validation/CI commands and package-build details, see + [`CONTRIBUTING.md`](./CONTRIBUTING.md). ## Prerequisites - Python 3.10+ -- Node.js 22.x -- Yarn +- Node.js 22.x (matches CI) +- Yarn 1.x -## Quick Start - -### 1. Clone and Set Up Python Environment +## 1. Set up Python ```bash git clone https://github.com/Kanaries/pygwalker.git cd pygwalker -# Create and activate virtual environment python -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate - -# Install pygwalker in editable mode with dev dependencies +source venv/bin/activate # Windows: venv\Scripts\activate pip install -e ".[dev]" ``` -### 2. Install Frontend Dependencies +## 2. Set up the frontend ```bash cd app yarn install +yarn build # first full build populates pygwalker/templates/dist/ +cd .. ``` -### 3. Build the Frontend (Required for First Run) +`yarn build` writes the JS bundles that the Python side loads into +`pygwalker/templates/dist/` (git-ignored, generated). You only need the full build once; the +dev workflow below rebuilds incrementally after that. + +## Recommended: one-command dev stack with hot reload + +PyGWalker's default notebook transport is **anywidget**, which loads +`pygwalker/templates/dist/pygwalker-app.es.js` from disk. In dev mode we rebuild that bundle on +every edit and let anywidget hot-reload it into open widgets. A single script starts both +processes and captures their logs centrally: ```bash -yarn build +source venv/bin/activate +python scripts/dev.py ``` -## Development Workflow +It launches, tees to your terminal, and logs to `logs/`: -### Option A: Simple Development (Rebuild on Changes) +- **`yarn dev:build`** (`vite build --watch`) — rebuilds the bundles when you edit `app/src/`. + → `logs/frontend.log` +- **`jupyter lab`** with `PYGWALKER_DEV=1` and `ANYWIDGET_HMR=1` in its environment (kernels + inherit these). → `logs/jupyter.log` -1. Make changes to files in `app/src/` -2. Rebuild the frontend: - ```bash - cd app - yarn build:app # Faster than full build - ``` -3. Restart your Jupyter kernel -4. Test your changes +Open the JupyterLab URL printed in the console (or found in `logs/jupyter.log`) and run +PyGWalker normally — **no special notebook setup is required**: -Before committing frontend changes, run the full validation path from -[`docs/CONTRIBUTING.md`](./CONTRIBUTING.md): `yarn typecheck`, `yarn build`, -and `yarn test:front_end`. +```python +import pandas as pd +import pygwalker as pyg -### Option B: Hot-Reload Development (Recommended) +pyg.walk(pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})) +``` -This setup enables live reloading when you change frontend code. +Now edit any file under `app/src/`. When `logs/frontend.log` shows the rebuild finished +(`built in …`), the widget hot-reloads in place — usually without re-running the cell. For a +large change, just re-run the cell. -#### Step 1: Start the Vite Dev Server +Handy flags: -```bash -cd app -yarn dev:server -``` +| Flag | Effect | +|------|--------| +| `--no-jupyter` | Only run the frontend watch build. | +| `--no-frontend` | Only start JupyterLab (bundle already built). | +| `--jupyter-port N` | Set the JupyterLab port. | +| `--no-browser` | Don't open a browser (auto-enabled when output isn't a TTY, e.g. agent runs). | +| `--log-dir DIR` | Change where per-service logs are written (default `logs/`). | -The dev server will start at `http://localhost:8769/pyg_dev_app/` +Stop the stack with **Ctrl+C** — both processes are torn down cleanly. -#### Step 2: Start JupyterLab with Server Proxy +### How the dev swap works (and why it's safe) -In a new terminal: +`pygwalker/services/anywidget_widget.py` chooses the widget source based on the environment: -```bash -source venv/bin/activate -jupyter lab --ServerProxy.servers="{'pyg_dev_app': {'command': [], 'absolute_url': True, 'port': 8769, 'timeout': 30}}" -``` +- **Normal installs:** `WalkerAnyWidget._esm` is the *contents* of `pygwalker-app.es.js` + (a string embedded at import) — identical to historical behavior. +- **Dev mode** (`PYGWALKER_DEV=1` or `ANYWIDGET_HMR=1`): `_esm` is the *file path* of the + bundle. anywidget reads it from disk and, with HMR, watches it (via `watchfiles`) and pushes + new code to the browser whenever `vite build --watch` rewrites it. -#### Step 3: Configure PyGWalker to Use Dev Server +Because the switch is opt-in via environment variables, end-user behavior is unchanged when the +flags are absent. -In your Jupyter notebook, **before** importing pygwalker: +## Alternative: manual rebuild (no watcher) -```python -from pygwalker.services.global_var import GlobalVarManager +If you prefer not to run the watcher: -# Point pygwalker to the dev server -GlobalVarManager.set_component_url("/pyg_dev_app/") +1. Edit files under `app/src/`. +2. Rebuild: `cd app && yarn build:app` (fast; no typecheck) — or `yarn build` for the full set. +3. Re-run the notebook cell (restart the kernel if the change doesn't appear). -# Now use pygwalker as normal -import pygwalker as pyg -pyg.walk(df) -``` +Before committing frontend changes, run the full validation path from +[`CONTRIBUTING.md`](./CONTRIBUTING.md): `yarn typecheck`, `yarn build`, and `yarn test:front_end`. -Now any changes you make in `app/src/` will hot-reload automatically! +## Logs & debugging -#### Step 4: Disable Dev Server (Return to Bundled Version) +`scripts/dev.py` centralizes logs under `logs/` (git-ignored): -```python -GlobalVarManager.set_component_url("") # Empty string = use bundled JS -``` +| File | Contents | +|------|----------| +| `logs/frontend.log` | Vite build/watch output. Watch for `built in …` and TypeScript errors. | +| `logs/jupyter.log` | JupyterLab server output, including the URL + token to open. | +| `logs/pygwalker.log` | Kernel-side PyGWalker Python logs (via `PYGWALKER_LOG_FILE`). | + +Python logging is configurable anywhere PyGWalker runs (not just under the orchestrator): + +- `PYGWALKER_LOG_FILE=/path/to/file.log` — also append Python logs to a file. +- `PYGWALKER_LOG_LEVEL=DEBUG` — change verbosity (default `INFO`). + +**Frontend runtime logs** (comm errors, render issues) show up in the **browser devtools +console**, not in `logs/`. User-facing problems are surfaced as in-app toast notifications. ## Troubleshooting -### Clean Rebuild +### "Missing PyGWalker frontend asset" error + +The bundle hasn't been built yet. Run `cd app && yarn build` (or let `scripts/dev.py` finish +its first build) before rendering. -If you encounter React version errors or other strange issues, try a clean rebuild: +### Frontend edits don't appear + +- Confirm `logs/frontend.log` shows a completed rebuild (`built in …`) after your edit. +- Confirm JupyterLab was started with `ANYWIDGET_HMR=1` (the orchestrator sets this for you). +- For a large change, re-run the cell; if needed, restart the kernel. + +### React version errors or other strange issues (clean rebuild) ```bash cd app @@ -116,46 +147,39 @@ yarn install yarn build ``` -### WASM 404 Errors +## Appendix: Vite dev server (iframe transport) + +This is the **legacy** hot-reload path. It uses the Vite *dev server* plus the **iframe** +transport (`env='Jupyter'`) and the `GlobalVarManager.set_component_url(...)` hook — it does +**not** apply to the default anywidget transport. Prefer the anywidget HMR workflow above; +this remains only for working on the iframe/`to_html` rendering path, which is deprecated and +slated for removal in 0.7.0. -If you see 404 errors for `.wasm` files when using the dev server, ensure `vite.config.ts` has: +Start the Vite dev server (serves the app under `/pyg_dev_app/` on port 8769): -```typescript -optimizeDeps: { - exclude: ['@kanaries/gw-dsl-parser'], -}, +```bash +cd app +yarn dev:server ``` -### Cross-Origin Errors +Start JupyterLab with a server proxy so the notebook can reach the dev server same-origin: -If you see CORS errors, make sure you're using the jupyter-server-proxy setup (Option B) rather than accessing the dev server directly. +```bash +source venv/bin/activate +jupyter lab --ServerProxy.servers="{'pyg_dev_app': {'command': [], 'absolute_url': True, 'port': 8769, 'timeout': 30}}" +``` -## Project Structure +Point PyGWalker at the dev server and render through the iframe transport: -``` -pygwalker/ -├── app/ # Frontend React application -│ ├── src/ -│ │ ├── index.tsx # Main entry point -│ │ ├── components/ # React components -│ │ └── ... -│ ├── package.json -│ └── vite.config.ts -├── pygwalker/ # Python package -│ ├── api/ # Python API -│ ├── templates/ # HTML templates & built JS -│ │ └── dist/ # Built frontend assets -│ └── ... -└── docs/ -``` +```python +from pygwalker.services.global_var import GlobalVarManager -## Building for Production +GlobalVarManager.set_component_url("/pyg_dev_app/") # "" to return to bundled assets -```bash -cd app -yarn build # Builds all variants (iife, es, dsl-to-workflow, vega-to-dsl) +import pygwalker as pyg +pyg.walk(df, env="Jupyter") # iframe transport honors component_url ``` -The built files are placed in `pygwalker/templates/dist/`. -`yarn build:app` only rebuilds the main app bundle and is intended for local -iteration, not release or CI validation. +If `.wasm` files 404 from the dev server, ensure `vite.config.ts` keeps +`optimizeDeps.exclude: ['@kanaries/gw-dsl-parser']`. If you hit CORS errors, use the +`jupyter-server-proxy` setup above rather than pointing at the dev server directly. diff --git a/pygwalker/services/anywidget_widget.py b/pygwalker/services/anywidget_widget.py index 9c07e305..68196a7c 100644 --- a/pygwalker/services/anywidget_widget.py +++ b/pygwalker/services/anywidget_widget.py @@ -1,4 +1,5 @@ import json +import os from typing import Any, List, Optional import anywidget @@ -6,13 +7,37 @@ from pygwalker.communications.anywidget_comm import AnywidgetCommunication from pygwalker.utils.encode import DataFrameEncoder -from pygwalker.utils.frontend_assets import read_frontend_asset +from pygwalker.utils.frontend_assets import frontend_asset_pathlib, read_frontend_asset + +_TRUTHY = {"1", "true", "yes", "on"} + + +def _frontend_dev_mode() -> bool: + """Whether to run the widget frontend in dev mode (load ESM from disk + enable HMR). + + Enabled by ``PYGWALKER_DEV=1`` (PyGWalker's umbrella dev flag) or anywidget's own + ``ANYWIDGET_HMR=1``. Off by default, so normal installs keep embedding the bundled + ESM as a string and behave exactly as before. + """ + if os.getenv("ANYWIDGET_HMR") == "1": + return True + return os.getenv("PYGWALKER_DEV", "").strip().lower() in _TRUTHY + + +def _resolve_widget_esm() -> Any: + """Pick the widget ESM source: a watched file Path in dev mode, else the bundled string.""" + if _frontend_dev_mode(): + # A Path lets anywidget read the built bundle from disk; with ANYWIDGET_HMR=1 it also + # watches the file and hot-reloads the widget when `vite build --watch` rewrites it. + os.environ.setdefault("ANYWIDGET_HMR", "1") + return frontend_asset_pathlib("pygwalker-app.es.js") + return read_frontend_asset("pygwalker-app.es.js", encoding="utf-8") class WalkerAnyWidget(anywidget.AnyWidget): """Anywidget shell for rendering the PyGWalker frontend.""" - _esm = read_frontend_asset("pygwalker-app.es.js", encoding="utf-8") + _esm = _resolve_widget_esm() props = traitlets.Unicode("").tag(sync=True) diff --git a/pygwalker/utils/frontend_assets.py b/pygwalker/utils/frontend_assets.py index 8e74f1df..97103764 100644 --- a/pygwalker/utils/frontend_assets.py +++ b/pygwalker/utils/frontend_assets.py @@ -1,5 +1,6 @@ import os import posixpath +from pathlib import Path from pygwalker._constants import ROOT_DIR @@ -8,6 +9,24 @@ def frontend_asset_path(*path_parts: str) -> str: return os.path.join(ROOT_DIR, "templates", "dist", *path_parts) +def frontend_asset_pathlib(*path_parts: str) -> Path: + """Return the built frontend asset as a ``pathlib.Path``. + + Used by the anywidget dev/HMR path: when ``_esm`` is a ``Path`` (instead of the + embedded source string) anywidget can read it from disk and, with + ``ANYWIDGET_HMR=1``, watch it for changes and live-reload the widget. + """ + path = Path(frontend_asset_path(*path_parts)) + if not path.exists(): + rel_path = posixpath.join("pygwalker", "templates", "dist", *path_parts) + raise RuntimeError( + f"Missing PyGWalker frontend asset: {rel_path}. " + "Build the frontend first: run `python scripts/dev.py` (dev watch) " + "or `cd app && yarn build` before enabling dev mode." + ) + return path + + def read_frontend_asset(*path_parts: str, encoding: str = "utf8") -> str: path = frontend_asset_path(*path_parts) try: diff --git a/pygwalker/utils/log.py b/pygwalker/utils/log.py index 4c566d34..36c1326d 100644 --- a/pygwalker/utils/log.py +++ b/pygwalker/utils/log.py @@ -1,10 +1,56 @@ import logging +import os +_STREAM_FORMAT = "%(levelname)s: %(message)s" +_FILE_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s" -def init_logging(): + +def _resolve_level() -> int: + """Log level from ``PYGWALKER_LOG_LEVEL`` (name or number); defaults to INFO.""" + raw = os.getenv("PYGWALKER_LOG_LEVEL") + if not raw: + return logging.INFO + raw = raw.strip() + if raw.isdigit(): + return int(raw) + level = logging.getLevelName(raw.upper()) + return level if isinstance(level, int) else logging.INFO + + +def _has_stream_handler(logger: logging.Logger) -> bool: + # FileHandler subclasses StreamHandler, so match the exact class. + return any(type(handler) is logging.StreamHandler for handler in logger.handlers) + + +def _has_file_handler(logger: logging.Logger, path: str) -> bool: + target = os.path.abspath(path) + return any(getattr(handler, "baseFilename", None) == target for handler in logger.handlers) + + +def init_logging() -> None: + """Configure the shared ``pygwalker`` logger. + + Console (stderr) logging is always on, matching the historical behaviour. Two optional + environment variables let a dev session or agent capture logs centrally: + + - ``PYGWALKER_LOG_LEVEL`` — logger level (e.g. ``DEBUG``, ``INFO``, or a number). + - ``PYGWALKER_LOG_FILE`` — also append logs to this file (created if needed). + + The function is idempotent: repeated calls will not add duplicate handlers. + """ logger = logging.getLogger("pygwalker") - logger.setLevel(logging.INFO) - handler = logging.StreamHandler() - formatter = logging.Formatter("%(levelname)s: %(message)s") - handler.setFormatter(formatter) - logger.addHandler(handler) + logger.setLevel(_resolve_level()) + + if not _has_stream_handler(logger): + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(logging.Formatter(_STREAM_FORMAT)) + logger.addHandler(stream_handler) + + log_file = os.getenv("PYGWALKER_LOG_FILE") + if log_file and not _has_file_handler(logger, log_file): + log_dir = os.path.dirname(os.path.abspath(log_file)) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setFormatter(logging.Formatter(_FILE_FORMAT)) + logger.addHandler(file_handler) diff --git a/scripts/dev.py b/scripts/dev.py new file mode 100644 index 00000000..ea086c26 --- /dev/null +++ b/scripts/dev.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python +"""Start the PyGWalker local dev stack with one command and centralized logs. + +This launches the two long-running processes a frontend/full-stack contributor needs and +streams their output both to your terminal and to per-service files under ``logs/``: + + * frontend -> ``cd app && yarn dev:build`` (``vite build --watch``) rebuilds + ``pygwalker/templates/dist/pygwalker-app.es.js`` every time you edit ``app/src``. + * jupyter -> ``jupyter lab`` with ``PYGWALKER_DEV=1`` / ``ANYWIDGET_HMR=1`` so the + anywidget widget loads that bundle from disk and hot-reloads it live in the notebook. + +With both running, editing ``app/src`` triggers a rebuild, and anywidget swaps the new code +into any open ``pyg.walk(df)`` widget without re-running the cell. See ``AGENTS.md`` and +``docs/DEVELOPMENT.md`` for the full workflow. + +Examples +-------- + python scripts/dev.py # frontend watch + JupyterLab + python scripts/dev.py --no-jupyter # only rebuild the frontend on change + python scripts/dev.py --no-frontend # only JupyterLab (bundle already built) + python scripts/dev.py --jupyter-port 8899 --no-browser +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import signal +import subprocess +import sys +import threading +import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +APP_DIR = REPO_ROOT / "app" +POSIX = os.name == "posix" + +_shutting_down = threading.Event() + + +def _resolve_exe(name: str, hint: str) -> str: + """Find an executable, preferring the one next to the current Python (venv).""" + sibling = Path(sys.executable).parent / name + if sibling.exists(): + return str(sibling) + found = shutil.which(name) + if found: + return found + sys.exit(f"error: `{name}` not found on PATH. {hint}") + + +class Service: + """A managed child process that tees combined stdout/stderr to a log file.""" + + def __init__(self, name: str, argv: list, cwd: Path, env: dict, log_path: Path): + self.name = name + self.argv = argv + self.cwd = cwd + self.env = env + self.log_path = log_path + self.proc: subprocess.Popen | None = None + self.thread: threading.Thread | None = None + + def start(self) -> None: + # Truncate/create the log up front so a reader (e.g. _wait_for_first_build) can never + # observe a previous run's output before the pump thread has opened the file. + self.log_path.write_text("", encoding="utf-8") + creationflags = 0 + if not POSIX: + # Windows: give the child its own group so the whole tree can be signalled/killed. + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + self.proc = subprocess.Popen( + self.argv, + cwd=str(self.cwd), + env=self.env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=POSIX, # POSIX: own process group -> clean group shutdown + creationflags=creationflags, + ) + self.thread = threading.Thread(target=self._pump, daemon=True) + self.thread.start() + + def _pump(self) -> None: + prefix = f"[{self.name}]" + # Append: start() already truncated the file, so readers see only this run's output. + with open(self.log_path, "a", encoding="utf-8", buffering=1) as log_file: + assert self.proc is not None and self.proc.stdout is not None + for line in self.proc.stdout: + log_file.write(line) + log_file.flush() + sys.stdout.write(f"{prefix} {line}") + sys.stdout.flush() + + def is_running(self) -> bool: + return self.proc is not None and self.proc.poll() is None + + def stop(self) -> None: + if self.proc is None or self.proc.poll() is not None: + return + if not POSIX: + self._stop_windows() + return + try: + os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM) + except (ProcessLookupError, OSError): + return + try: + self.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL) + except (ProcessLookupError, OSError): + pass + + def _stop_windows(self) -> None: + # terminate() only kills the wrapper (e.g. yarn), leaving the node/Vite tree alive. + # taskkill /T kills the whole process tree; fall back to terminate() if unavailable. + assert self.proc is not None + try: + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(self.proc.pid)], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError: + self.proc.terminate() + try: + self.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + self.proc.kill() + + +def _wait_for_first_build(service: "Service", timeout: float = 180.0) -> bool: + """Block until `vite build --watch` reports a *finished* build. + + Returns True once a build completes, False if the frontend process exits first or the + timeout elapses. Vite prints "watching for file changes" before the initial build + finishes, so we key only on the "built in" completion marker. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline and not _shutting_down.is_set(): + try: + text = service.log_path.read_text(encoding="utf-8", errors="ignore") + except FileNotFoundError: + text = "" + if "built in" in text: + return True + if not service.is_running(): + return False # frontend exited before completing a build + time.sleep(0.5) + return False + + +def _build_env(log_dir: Path) -> dict: + env = os.environ.copy() + # Force the frontend dev path on: the widget loads the built ESM from disk and hot-reloads. + env["PYGWALKER_DEV"] = "1" + env["ANYWIDGET_HMR"] = "1" + # Capture kernel-side pygwalker logs centrally (does not override an explicit choice). + env.setdefault("PYGWALKER_LOG_FILE", str(log_dir / "pygwalker.log")) + return env + + +def _install_signal_handlers() -> None: + def _handler(signum, _frame): + _shutting_down.set() + + signal.signal(signal.SIGINT, _handler) + signal.signal(signal.SIGTERM, _handler) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Start the PyGWalker dev stack (frontend watch + JupyterLab) with central logs.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--no-frontend", action="store_true", help="Do not run the frontend watch build.") + parser.add_argument("--no-jupyter", action="store_true", help="Do not start JupyterLab.") + parser.add_argument("--jupyter-port", type=int, default=None, help="Port for JupyterLab.") + parser.add_argument( + "--notebook-dir", + default=str(REPO_ROOT), + help="Working directory for JupyterLab (default: repo root).", + ) + parser.add_argument( + "--no-browser", + action="store_true", + help="Do not open a browser for JupyterLab (auto-enabled when output is not a TTY).", + ) + parser.add_argument( + "--log-dir", + default=str(REPO_ROOT / "logs"), + help="Directory for per-service log files (default: /logs).", + ) + args = parser.parse_args() + + if args.no_frontend and args.no_jupyter: + sys.exit("error: nothing to run (both --no-frontend and --no-jupyter given).") + + log_dir = Path(args.log_dir).resolve() + log_dir.mkdir(parents=True, exist_ok=True) + env = _build_env(log_dir) + + services: list[Service] = [] + + if not args.no_frontend: + yarn = _resolve_exe("yarn", "Install Node.js 22.x and Yarn 1.x, then run `cd app && yarn install`.") + services.append(Service("frontend", [yarn, "dev:build"], APP_DIR, env, log_dir / "frontend.log")) + + jupyter_service: Service | None = None + if not args.no_jupyter: + jupyter = _resolve_exe("jupyter", 'Install dev deps: `pip install -e ".[dev]"`.') + jupyter_argv = [jupyter, "lab", f"--notebook-dir={args.notebook_dir}"] + if args.no_browser or not sys.stdout.isatty(): + jupyter_argv.append("--no-browser") + if args.jupyter_port is not None: + jupyter_argv.append(f"--port={args.jupyter_port}") + jupyter_service = Service("jupyter", jupyter_argv, REPO_ROOT, env, log_dir / "jupyter.log") + + _install_signal_handlers() + + print("=" * 78) + print("PyGWalker dev stack") + print(f" logs directory : {log_dir}") + if not args.no_frontend: + print(f" frontend : yarn dev:build (vite build --watch) -> {log_dir / 'frontend.log'}") + if jupyter_service is not None: + print(f" jupyter lab : PYGWALKER_DEV=1 ANYWIDGET_HMR=1 -> {log_dir / 'jupyter.log'}") + print(f" kernel logs : PYGWALKER_LOG_FILE -> {env['PYGWALKER_LOG_FILE']}") + print("=" * 78) + + # Start the frontend first and wait for its initial build so the kernel finds the bundle. + frontend = next((svc for svc in services if svc.name == "frontend"), None) + if frontend is not None: + frontend.start() + if jupyter_service is not None: + print("[dev] waiting for the first frontend build to finish...") + if _wait_for_first_build(frontend): + print("[dev] frontend build ready.") + elif not frontend.is_running(): + print("[dev] frontend build failed before completing; see logs/frontend.log.") + print("[dev] not starting Jupyter.") + _shutting_down.set() + elif not _shutting_down.is_set(): + print("[dev] warning: timed out waiting for first build; starting Jupyter anyway.") + + if jupyter_service is not None and not _shutting_down.is_set(): + jupyter_service.start() + services.append(jupyter_service) + print("\n[dev] In a notebook cell, run your normal code — no special setup needed:") + print("[dev] import pandas as pd, pygwalker as pyg") + print("[dev] pyg.walk(pd.DataFrame({'x': [1, 2, 3]}))") + print("[dev] Edit files under app/src/, watch logs/frontend.log rebuild, and the widget hot-reloads.") + print("[dev] Find the JupyterLab URL/token in logs/jupyter.log. Press Ctrl+C to stop.\n") + + if not services: + return 0 + + try: + while not _shutting_down.is_set(): + for svc in services: + if not svc.is_running(): + code = svc.proc.returncode if svc.proc else "?" + print(f"[dev] {svc.name} exited (code {code}); shutting down the rest.") + _shutting_down.set() + break + time.sleep(0.5) + finally: + print("\n[dev] stopping services...") + for svc in reversed(services): + svc.stop() + print("[dev] done.") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 973118d4f60527931dbf14c8f1c4191c543f2b63 Mon Sep 17 00:00:00 2001 From: observedobserver <270001151@qq.com> Date: Sat, 25 Jul 2026 12:07:04 -0700 Subject: [PATCH 2/2] fix: address dev workflow review feedback --- docs/ARCHITECTURE.md | 4 +- docs/CONTRIBUTING.md | 6 +- docs/DEVELOPMENT.md | 18 +++--- pyproject.toml | 1 + scripts/dev.py | 13 +++- tests/test_dev_workflow.py | 126 +++++++++++++++++++++++++++++++++++++ 6 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 tests/test_dev_workflow.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4398aa84..622e2426 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -101,7 +101,9 @@ frontend — otherwise the two sides drift. The **iframe transport** (`services/render.py` + `templates/pygwalker_main_page.html`) is an alternative that embeds the compressed `iife` bundle in an `iframe` `srcdoc`, or points the iframe at a dev-server URL when `GlobalVarManager.component_url` is set. It backs -`to_html()` and the deprecated `env='Jupyter'`/`'JupyterWidget'` paths. +`to_html()` and the deprecated low-level `PygWalker.display_on_jupyter()` compatibility +method. The legacy `env='Jupyter'`/`'JupyterWidget'` aliases are coerced to anywidget and do +not select the iframe transport. ## Message protocol & transports diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 6c6cfba6..c367e4b4 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -102,8 +102,10 @@ This starts `yarn dev:build` (`vite build --watch`) and JupyterLab with `PYGWALK [`DEVELOPMENT.md`](./DEVELOPMENT.md) for the full workflow, flags, and log locations. The older Vite dev-server + `GlobalVarManager.set_component_url("/pyg_dev_app/")` + -`jupyter-server-proxy` flow only drives the deprecated iframe transport (`env='Jupyter'`) and -is documented as an appendix in [`DEVELOPMENT.md`](./DEVELOPMENT.md#appendix-vite-dev-server-iframe-transport). +`jupyter-server-proxy` flow only drives the deprecated low-level iframe compatibility +renderer. The legacy `env='Jupyter'` aliases now select anywidget instead; the explicit +compatibility call is documented in +[`DEVELOPMENT.md`](./DEVELOPMENT.md#appendix-vite-dev-server-iframe-transport). ## Validation diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index dccc3fbe..cbed8a68 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -149,11 +149,12 @@ yarn build ## Appendix: Vite dev server (iframe transport) -This is the **legacy** hot-reload path. It uses the Vite *dev server* plus the **iframe** -transport (`env='Jupyter'`) and the `GlobalVarManager.set_component_url(...)` hook — it does -**not** apply to the default anywidget transport. Prefer the anywidget HMR workflow above; -this remains only for working on the iframe/`to_html` rendering path, which is deprecated and -slated for removal in 0.7.0. +This is the **legacy** hot-reload path. It uses the Vite *dev server* plus the deprecated +low-level **iframe** compatibility renderer and the `GlobalVarManager.set_component_url(...)` +hook — it does **not** apply to the default anywidget transport. The legacy `env='Jupyter'` +and `env='JupyterWidget'` aliases are now coerced to anywidget, so they do not select this +renderer. Prefer the anywidget HMR workflow above; the compatibility renderer is slated for +removal in 0.7.0. Start the Vite dev server (serves the app under `/pyg_dev_app/` on port 8769): @@ -169,7 +170,8 @@ source venv/bin/activate jupyter lab --ServerProxy.servers="{'pyg_dev_app': {'command': [], 'absolute_url': True, 'port': 8769, 'timeout': 30}}" ``` -Point PyGWalker at the dev server and render through the iframe transport: +Point PyGWalker at the dev server and explicitly invoke the deprecated iframe compatibility +method: ```python from pygwalker.services.global_var import GlobalVarManager @@ -177,7 +179,9 @@ from pygwalker.services.global_var import GlobalVarManager GlobalVarManager.set_component_url("/pyg_dev_app/") # "" to return to bundled assets import pygwalker as pyg -pyg.walk(df, env="Jupyter") # iframe transport honors component_url + +walker = pyg.Walker(df, computation="browser") +walker.core.display_on_jupyter() # deprecated; iframe transport honors component_url ``` If `.wasm` files 404 from the dev server, ensure `vite.config.ts` keeps diff --git a/pyproject.toml b/pyproject.toml index 3eb434f1..ee6cf22f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ dev = [ "twine", "jupyterlab", "jupyter_server_proxy", + "watchfiles>=0.18.0", ] [project.scripts] diff --git a/scripts/dev.py b/scripts/dev.py index ea086c26..1b46aa8d 100644 --- a/scripts/dev.py +++ b/scripts/dev.py @@ -157,6 +157,14 @@ def _wait_for_first_build(service: "Service", timeout: float = 180.0) -> bool: return False +def _unexpected_exit_code(service: "Service") -> int: + """Return a failing status for a managed service that stopped unexpectedly.""" + if service.proc is None: + return 1 + code = service.proc.returncode + return code if code not in (None, 0) else 1 + + def _build_env(log_dir: Path) -> dict: env = os.environ.copy() # Force the frontend dev path on: the widget loads the built ESM from disk and hot-reloads. @@ -206,6 +214,7 @@ def main() -> int: log_dir = Path(args.log_dir).resolve() log_dir.mkdir(parents=True, exist_ok=True) env = _build_env(log_dir) + exit_code = 0 services: list[Service] = [] @@ -244,6 +253,7 @@ def main() -> int: if _wait_for_first_build(frontend): print("[dev] frontend build ready.") elif not frontend.is_running(): + exit_code = _unexpected_exit_code(frontend) print("[dev] frontend build failed before completing; see logs/frontend.log.") print("[dev] not starting Jupyter.") _shutting_down.set() @@ -268,6 +278,7 @@ def main() -> int: if not svc.is_running(): code = svc.proc.returncode if svc.proc else "?" print(f"[dev] {svc.name} exited (code {code}); shutting down the rest.") + exit_code = _unexpected_exit_code(svc) _shutting_down.set() break time.sleep(0.5) @@ -277,7 +288,7 @@ def main() -> int: svc.stop() print("[dev] done.") - return 0 + return exit_code if __name__ == "__main__": diff --git a/tests/test_dev_workflow.py b/tests/test_dev_workflow.py new file mode 100644 index 00000000..e09f1ee9 --- /dev/null +++ b/tests/test_dev_workflow.py @@ -0,0 +1,126 @@ +import os +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from pygwalker.services import anywidget_widget +from scripts import dev + + +def test_dev_extra_declares_anywidget_hmr_watcher(): + pyproject = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(encoding="utf-8") + dev_dependencies = pyproject.partition("dev = [")[2].partition("]")[0] + + assert '"watchfiles>=0.18.0"' in dev_dependencies + + +def test_pygwalker_dev_mode_enables_anywidget_hmr(monkeypatch, tmp_path): + bundle = tmp_path / "pygwalker-app.es.js" + bundle.write_text("export function render() {}", encoding="utf-8") + monkeypatch.setenv("PYGWALKER_DEV", "true") + monkeypatch.delenv("ANYWIDGET_HMR", raising=False) + monkeypatch.setattr(anywidget_widget, "frontend_asset_pathlib", lambda *_parts: bundle) + + assert anywidget_widget._resolve_widget_esm() == bundle + assert os.environ["ANYWIDGET_HMR"] == "1" + + +@pytest.mark.parametrize(("child_code", "expected_code"), [(7, 7), (0, 1)]) +def test_dev_stack_propagates_unexpected_service_exit(monkeypatch, tmp_path, child_code, expected_code): + class StoppedService: + def __init__(self, name, _argv, _cwd, _env, _log_path): + self.name = name + self.proc = SimpleNamespace(returncode=child_code) + + def start(self): + return None + + def is_running(self): + return False + + def stop(self): + return None + + dev._shutting_down.clear() + monkeypatch.setattr(dev, "Service", StoppedService) + monkeypatch.setattr(dev, "_resolve_exe", lambda _name, _hint: "unused") + monkeypatch.setattr(dev, "_install_signal_handlers", lambda: None) + monkeypatch.setattr(dev.time, "sleep", lambda _seconds: None) + monkeypatch.setattr( + sys, + "argv", + ["dev.py", "--no-jupyter", "--log-dir", str(tmp_path)], + ) + + try: + assert dev.main() == expected_code + finally: + dev._shutting_down.clear() + + +def test_dev_stack_propagates_initial_frontend_build_failure(monkeypatch, tmp_path): + started_services = [] + + class StoppedFrontendService: + def __init__(self, name, _argv, _cwd, _env, _log_path): + self.name = name + self.proc = SimpleNamespace(returncode=9 if name == "frontend" else None) + + def start(self): + started_services.append(self.name) + + def is_running(self): + return self.name != "frontend" + + def stop(self): + return None + + dev._shutting_down.clear() + monkeypatch.setattr(dev, "Service", StoppedFrontendService) + monkeypatch.setattr(dev, "_resolve_exe", lambda _name, _hint: "unused") + monkeypatch.setattr(dev, "_install_signal_handlers", lambda: None) + monkeypatch.setattr(dev, "_wait_for_first_build", lambda _service: False) + monkeypatch.setattr( + sys, + "argv", + ["dev.py", "--no-browser", "--log-dir", str(tmp_path)], + ) + + try: + assert dev.main() == 9 + assert started_services == ["frontend"] + finally: + dev._shutting_down.clear() + + +def test_dev_stack_signal_shutdown_remains_successful(monkeypatch, tmp_path): + class SignalledService: + def __init__(self, name, _argv, _cwd, _env, _log_path): + self.name = name + self.proc = SimpleNamespace(returncode=None) + + def start(self): + dev._shutting_down.set() + + def is_running(self): + return True + + def stop(self): + return None + + dev._shutting_down.clear() + monkeypatch.setattr(dev, "Service", SignalledService) + monkeypatch.setattr(dev, "_resolve_exe", lambda _name, _hint: "unused") + monkeypatch.setattr(dev, "_install_signal_handlers", lambda: None) + monkeypatch.setattr( + sys, + "argv", + ["dev.py", "--no-jupyter", "--log-dir", str(tmp_path)], + ) + + try: + assert dev.main() == 0 + finally: + dev._shutting_down.clear()