You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by mi...@apache.org on 2026/04/09 09:44:04 UTC

(datafusion-ballista) branch main updated: feat: jupyter notebook support (#1513)

This is an automated email from the ASF dual-hosted git repository.

milenkovicm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git


The following commit(s) were added to refs/heads/main by this push:
     new 6091b5259 feat: jupyter notebook support (#1513)
6091b5259 is described below

commit 6091b525959c75623dfa8ee6cef863b26d9fdd7d
Author: alexander domenti <14...@users.noreply.github.com>
AuthorDate: Thu Apr 9 12:44:00 2026 +0300

    feat: jupyter notebook support (#1513)
    
    * feat(python): Improve Jupyter notebook support with SQL magic commands and examples
    
    This PR implements all items from the checklist in issue #1398:
    
    ## Implementation Checklist
    
    - [x] Add example .ipynb notebooks to python/examples/
      - getting_started.ipynb - Basic connection and queries
      - dataframe_api.ipynb - DataFrame transformations
      - distributed_queries.ipynb - Multi-stage query examples
    
    - [x] Document notebook support in Python README
      - Added comprehensive Jupyter section with examples
    
    - [x] Create ballista.jupyter module with magic commands
      - Full implementation with BallistaMagics class
    
    - [x] Add %ballista connect/status/tables/schema line magics
      - connect: Connect to Ballista cluster
      - status: Show connection status
      - tables: List registered tables
      - schema: Show table schema
      - disconnect: Disconnect from cluster
      - history: Show query history
    
    - [x] Add %%sql cell magic
      - Line magic for single-line queries
      - Cell magic for multi-line queries
      - Variable assignment support
      - --no-display and --limit options
    
    - [x] Add explain_visual() method for query plan rendering
      - Generates DOT/SVG visualization
      - Supports Jupyter _repr_html_
      - Fallback when graphviz not installed
    
    - [x] Add progress indicator support for long-running queries
      - collect_with_progress() method
      - Callback support for custom progress handling
      - Jupyter-aware display
    
    - [x] Consider JupySQL integration
      - Documented as alternative in README
    
    ## Additional Features
    
    - ExecutionPlanVisualization class for plan rendering
    - tables() method on BallistaSessionContext
    - Optional jupyter dependency in pyproject.toml
    - Comprehensive test coverage (45 tests passing)
    
    Closes #1398
    
    * Refactored magic commands
    
    * Changed .gitignore and added docstring
    
    * Removed previous ver of function
    
    * Changed the jupyter, extension and fixed the README.md
    
    * Added test cases
    
    * Fixed pipeline errors
    
    * Add ruff cache to .gitignore
    
    ---------
    
    Co-authored-by: Yuxuan Che <ch...@gmail.com>
---
 .gitignore                                    |   2 +
 python/README.md                              |  96 +++++
 python/examples/dataframe_api.ipynb           | 419 ++++++++++++++++++++++
 python/examples/distributed_queries.ipynb     | 411 +++++++++++++++++++++
 python/examples/getting_started.ipynb         | 329 +++++++++++++++++
 python/pyproject.toml                         |   5 +
 python/python/ballista/__init__.py            |   8 +-
 python/python/ballista/extension.py           | 355 ++++++++++++++++--
 python/python/ballista/jupyter.py             | 494 ++++++++++++++++++++++++++
 python/python/tests/test_jupyter.py           | 316 ++++++++++++++++
 python/python/tests/test_notebook_features.py | 233 ++++++++++++
 python/uv.lock                                | 397 ++++++++++++---------
 12 files changed, 2858 insertions(+), 207 deletions(-)

diff --git a/.gitignore b/.gitignore
index 1e47926b2..506931c98 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,12 +58,14 @@ docs/example1.dat
 docs/example3.dat
 python/.eggs/
 python/doc/
+python/examples/.ipynb_checkpoints
 # Egg metadata
 *.egg-info
 
 .vscode
 .idea/
 .pytest_cache/
+.ruff_cache/
 pkgs
 docker_cache
 .gdb_history
diff --git a/python/README.md b/python/README.md
index 0a26ee979..8ba7d1f27 100644
--- a/python/README.md
+++ b/python/README.md
@@ -79,6 +79,102 @@ pyarrow_batches = df.collect()
 
 Check [DataFusion python](https://datafusion.apache.org/python/) provides more examples and manuals.
 
+## Jupyter Notebook Support
+
+PyBallista provides first-class Jupyter notebook support with SQL magic commands and rich HTML rendering.
+
+### Install Jupyter extras first:
+```bash
+pip install "ballista[jupyter]"
+```
+
+### HTML Table Rendering
+
+DataFrames automatically render as styled HTML tables in Jupyter notebooks:
+
+```python
+from ballista import BallistaSessionContext
+
+ctx = BallistaSessionContext("df://localhost:50050")
+df = ctx.sql("SELECT * FROM my_table LIMIT 10")
+df  # Renders as HTML table via _repr_html_()
+```
+
+### SQL Magic Commands
+
+For a more interactive SQL experience, load the Ballista Jupyter extension:
+
+```python
+# Load the extension
+%load_ext ballista.jupyter
+
+# Connect to a Ballista cluster
+%ballista connect df://localhost:50050
+
+# Register .parquet table
+%register parquet public.test_data_v1 ../testdata/test.parquet
+
+# Check connection status
+%ballista status
+
+# List registered tables
+%ballista tables
+
+# Show table schema
+%ballista schema my_table
+
+# Execute a simple query (line magic)
+%sql SELECT COUNT(*) FROM orders
+
+# Execute a complex query (cell magic)
+%%sql
+SELECT
+    customer_id,
+    SUM(amount) as total
+FROM orders
+GROUP BY customer_id
+ORDER BY total DESC
+LIMIT 10
+```
+
+You can also store results in a variable:
+
+```python
+%%sql my_result
+SELECT * FROM orders WHERE status = 'pending'
+```
+
+### Execution Plan Visualization
+
+Visualize query execution plans directly in notebooks:
+
+```python
+df = ctx.sql("SELECT * FROM orders WHERE amount > 100")
+df.explain_visual()  # Displays SVG visualization
+
+# With runtime statistics
+df.explain_visual(analyze=True)
+```
+
+> **Note:** Full SVG visualization requires graphviz to be installed (`brew install graphviz` on macOS).
+
+### Progress Indicators
+
+For long-running queries, use `collect_with_progress()` to see execution status:
+
+```python
+df = ctx.sql("SELECT * FROM large_table")
+batches = df.collect_with_progress()
+```
+
+### Example Notebooks
+
+See the `examples/` directory for Jupyter notebooks demonstrating various features:
+
+- `getting_started.ipynb` - Basic connection and queries
+- `dataframe_api.ipynb` - DataFrame transformations
+- `distributed_queries.ipynb` - Multi-stage distributed query examples
+
 ## Scheduler and Executor
 
 Scheduler and executors can be configured and started from python code.
diff --git a/python/examples/dataframe_api.ipynb b/python/examples/dataframe_api.ipynb
new file mode 100644
index 000000000..aaa86df75
--- /dev/null
+++ b/python/examples/dataframe_api.ipynb
@@ -0,0 +1,419 @@
+{
+  "cells": [
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "<!---\n",
+        "  Licensed to the Apache Software Foundation (ASF) under one\n",
+        "  or more contributor license agreements.  See the NOTICE file\n",
+        "  distributed with this work for additional information\n",
+        "  regarding copyright ownership.  The ASF licenses this file\n",
+        "  to you under the Apache License, Version 2.0 (the\n",
+        "  \"License\"); you may not use this file except in compliance\n",
+        "  with the License.  You may obtain a copy of the License at\n",
+        "\n",
+        "    http://www.apache.org/licenses/LICENSE-2.0\n",
+        "\n",
+        "  Unless required by applicable law or agreed to in writing,\n",
+        "  software distributed under the License is distributed on an\n",
+        "  \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n",
+        "  KIND, either express or implied.  See the License for the\n",
+        "  specific language governing permissions and limitations\n",
+        "  under the License.\n",
+        "-->\n",
+        "\n",
+        "# DataFrame API with Ballista\n",
+        "\n",
+        "This notebook demonstrates the DataFrame API available in Ballista.\n",
+        "\n",
+        "The DataFrame API provides a programmatic way to build queries, which can be\n",
+        "more convenient than writing SQL for complex transformations."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "from ballista import BallistaSessionContext, setup_test_cluster\n",
+        "from datafusion import col, lit\n",
+        "from datafusion import functions as f\n",
+        "\n",
+        "# Set up test cluster and connect\n",
+        "host, port = setup_test_cluster()\n",
+        "ctx = BallistaSessionContext(f\"df://{host}:{port}\")\n",
+        "\n",
+        "# Register sample data\n",
+        "ctx.register_parquet(\"test_data\", \"../testdata/test.parquet\")\n",
+        "ctx.register_csv(\"csv_data\", \"../testdata/test.csv\", has_header=True)\n",
+        "\n",
+        "print(f\"Connected! Session ID: {ctx.session_id}\")"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Basic Operations"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Read a table as a DataFrame\n",
+        "df = ctx.table(\"test_data\")\n",
+        "\n",
+        "# Display schema\n",
+        "print(\"Schema:\")\n",
+        "for field in df.schema():\n",
+        "    print(f\"  {field.name}: {field.type}\")"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Show first few rows\n",
+        "df.show(5)"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Selecting Columns"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Select specific columns by name\n",
+        "df.select(\"id\", \"bool_col\", \"tinyint_col\")"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Select with column expressions\n",
+        "df.select(\n",
+        "    col(\"id\"),\n",
+        "    col(\"tinyint_col\").alias(\"tiny\"),\n",
+        "    (col(\"id\") * lit(10)).alias(\"id_times_10\")\n",
+        ")"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Filtering Data"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Simple filter\n",
+        "df.filter(col(\"id\") > lit(4))"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Complex filter with AND/OR\n",
+        "df.filter(\n",
+        "    (col(\"id\") >= lit(2)) & (col(\"id\") <= lit(5))\n",
+        ")"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Filter with boolean column\n",
+        "df.filter(col(\"bool_col\") == lit(True))"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Sorting"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Sort ascending\n",
+        "df.sort(col(\"id\").sort(ascending=True))"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Sort descending\n",
+        "df.sort(col(\"id\").sort(ascending=False))"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Limiting Results"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Limit number of rows\n",
+        "df.limit(3)"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Aggregations"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Count all rows\n",
+        "result = df.aggregate([], [f.count_star().alias(\"total_count\")])\n",
+        "result"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Group by and aggregate\n",
+        "df.aggregate(\n",
+        "    [col(\"bool_col\")],\n",
+        "    [\n",
+        "        f.count_star().alias(\"count\"),\n",
+        "        f.sum(col(\"id\")).alias(\"sum_id\"),\n",
+        "        f.avg(col(\"id\")).alias(\"avg_id\"),\n",
+        "    ]\n",
+        ")"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Distinct Values"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Get distinct values\n",
+        "df.select(\"bool_col\").distinct()"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Chaining Operations\n",
+        "\n",
+        "DataFrame operations can be chained together to build complex transformations."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Complex chained query\n",
+        "result = (\n",
+        "    ctx.table(\"test_data\")\n",
+        "    .select(\"id\", \"bool_col\", \"tinyint_col\")\n",
+        "    .filter(col(\"id\") > lit(2))\n",
+        "    .sort(col(\"id\").sort(ascending=False))\n",
+        "    .limit(5)\n",
+        ")\n",
+        "\n",
+        "result"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# View the execution plan for the chained query\n",
+        "print(result.explain())"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Visual execution plan\n",
+        "result.explain_visual()"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Collecting Results"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Collect as Arrow batches\n",
+        "batches = result.collect()\n",
+        "print(f\"Got {len(batches)} batch(es)\")\n",
+        "print(f\"Total rows: {sum(len(batch) for batch in batches)}\")"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Collect as Arrow table\n",
+        "table = result.to_arrow_table()\n",
+        "print(f\"Arrow table: {table.num_rows} rows, {table.num_columns} columns\")"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Convert to Pandas\n",
+        "pdf = result.to_pandas()\n",
+        "pdf"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Get the count without collecting all data\n",
+        "count = ctx.table(\"test_data\").count()\n",
+        "print(f\"Total rows in test_data: {count}\")"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Working with CSV Data"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Read CSV directly without registering\n",
+        "csv_df = ctx.read_csv(\"../testdata/test.csv\", has_header=True)\n",
+        "\n",
+        "# Show schema and data\n",
+        "print(\"CSV Schema:\")\n",
+        "for field in csv_df.schema():\n",
+        "    print(f\"  {field.name}: {field.type}\")\n",
+        "\n",
+        "csv_df"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Filter CSV data\n",
+        "csv_df.filter(col(\"a\") > lit(2))"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Next Steps\n",
+        "\n",
+        "- See `distributed_queries.ipynb` for examples of distributed query execution\n",
+        "- Check the [DataFusion Python documentation](https://datafusion.apache.org/python/) for more DataFrame operations\n",
+        "- Review the SQL magic commands in `getting_started.ipynb` for interactive querying"
+      ]
+    }
+  ],
+  "metadata": {
+    "kernelspec": {
+      "display_name": "Python 3",
+      "language": "python",
+      "name": "python3"
+    },
+    "language_info": {
+      "codemirror_mode": {
+        "name": "ipython",
+        "version": 3
+      },
+      "file_extension": ".py",
+      "mimetype": "text/x-python",
+      "name": "python",
+      "nbconvert_exporter": "python",
+      "pygments_lexer": "ipython3",
+      "version": "3.11.0"
+    }
+  },
+  "nbformat": 4,
+  "nbformat_minor": 4
+}
diff --git a/python/examples/distributed_queries.ipynb b/python/examples/distributed_queries.ipynb
new file mode 100644
index 000000000..2c3c14902
--- /dev/null
+++ b/python/examples/distributed_queries.ipynb
@@ -0,0 +1,411 @@
+{
+  "cells": [
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "<!---\n",
+        "  Licensed to the Apache Software Foundation (ASF) under one\n",
+        "  or more contributor license agreements.  See the NOTICE file\n",
+        "  distributed with this work for additional information\n",
+        "  regarding copyright ownership.  The ASF licenses this file\n",
+        "  to you under the Apache License, Version 2.0 (the\n",
+        "  \"License\"); you may not use this file except in compliance\n",
+        "  with the License.  You may obtain a copy of the License at\n",
+        "\n",
+        "    http://www.apache.org/licenses/LICENSE-2.0\n",
+        "\n",
+        "  Unless required by applicable law or agreed to in writing,\n",
+        "  software distributed under the License is distributed on an\n",
+        "  \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n",
+        "  KIND, either express or implied.  See the License for the\n",
+        "  specific language governing permissions and limitations\n",
+        "  under the License.\n",
+        "-->\n",
+        "\n",
+        "# Distributed Queries with Ballista\n",
+        "\n",
+        "This notebook demonstrates distributed query execution features in Ballista.\n",
+        "\n",
+        "## Overview\n",
+        "\n",
+        "Ballista is a distributed query engine that can execute queries across multiple\n",
+        "nodes. When you submit a query, Ballista:\n",
+        "\n",
+        "1. Parses and optimizes the query\n",
+        "2. Creates a distributed execution plan\n",
+        "3. Distributes work across executors\n",
+        "4. Collects and returns results\n",
+        "\n",
+        "This enables processing of datasets much larger than a single machine's memory."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "from ballista import BallistaSessionContext, setup_test_cluster\n",
+        "from datafusion import col, lit\n",
+        "from datafusion import functions as f\n",
+        "\n",
+        "# Set up test cluster and connect\n",
+        "host, port = setup_test_cluster()\n",
+        "ctx = BallistaSessionContext(f\"df://{host}:{port}\")\n",
+        "\n",
+        "# Register sample data\n",
+        "ctx.register_parquet(\"test_data\", \"../testdata/test.parquet\")\n",
+        "\n",
+        "print(f\"Connected! Session ID: {ctx.session_id}\")"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Execution Plans\n",
+        "\n",
+        "Understanding execution plans is key to optimizing distributed queries."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Create a query with multiple stages\n",
+        "df = ctx.sql(\"\"\"\n",
+        "    SELECT \n",
+        "        bool_col,\n",
+        "        COUNT(*) as cnt,\n",
+        "        SUM(id) as sum_id,\n",
+        "        AVG(tinyint_col) as avg_tiny\n",
+        "    FROM test_data\n",
+        "    WHERE id > 2\n",
+        "    GROUP BY bool_col\n",
+        "    ORDER BY cnt DESC\n",
+        "\"\"\")\n",
+        "\n",
+        "df"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# View the logical plan\n",
+        "print(\"Logical Plan:\")\n",
+        "print(df.explain())"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Visualize the execution plan\n",
+        "# This shows the query plan as a graph (requires graphviz for full SVG)\n",
+        "df.explain_visual()"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# View the plan with runtime statistics (analyze=True runs the query)\n",
+        "print(\"Analyzed Plan (with statistics):\")\n",
+        "print(df.explain(analyze=True))"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Multi-Stage Queries\n",
+        "\n",
+        "Complex queries may involve multiple stages of distributed execution."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Subquery example\n",
+        "result = ctx.sql(\"\"\"\n",
+        "    WITH stats AS (\n",
+        "        SELECT \n",
+        "            bool_col,\n",
+        "            COUNT(*) as cnt\n",
+        "        FROM test_data\n",
+        "        GROUP BY bool_col\n",
+        "    )\n",
+        "    SELECT \n",
+        "        t.id,\n",
+        "        t.bool_col,\n",
+        "        s.cnt as group_count\n",
+        "    FROM test_data t\n",
+        "    JOIN stats s ON t.bool_col = s.bool_col\n",
+        "    WHERE t.id <= 5\n",
+        "    ORDER BY t.id\n",
+        "\"\"\")\n",
+        "\n",
+        "result"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# View the execution plan - notice the join and exchange stages\n",
+        "result.explain_visual()"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## DataFrame API for Complex Transformations"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Build a complex transformation using the DataFrame API\n",
+        "df1 = ctx.table(\"test_data\")\n",
+        "\n",
+        "# Aggregate to get group statistics\n",
+        "group_stats = df1.aggregate(\n",
+        "    [col(\"bool_col\")],\n",
+        "    [\n",
+        "        f.count_star().alias(\"group_count\"),\n",
+        "        f.avg(col(\"id\")).alias(\"avg_id\"),\n",
+        "    ]\n",
+        ")\n",
+        "\n",
+        "group_stats"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Join original data with statistics\n",
+        "joined = df1.join(\n",
+        "    group_stats,\n",
+        "    on=\"bool_col\",\n",
+        "    how=\"inner\"\n",
+        ")\n",
+        "\n",
+        "joined.select(\"id\", \"bool_col\", \"group_count\", \"avg_id\").limit(10)"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Window Functions\n",
+        "\n",
+        "Window functions allow computations across related rows."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Window function example\n",
+        "window_result = ctx.sql(\"\"\"\n",
+        "    SELECT \n",
+        "        id,\n",
+        "        bool_col,\n",
+        "        tinyint_col,\n",
+        "        SUM(tinyint_col) OVER (\n",
+        "            PARTITION BY bool_col \n",
+        "            ORDER BY id\n",
+        "        ) as running_sum,\n",
+        "        ROW_NUMBER() OVER (\n",
+        "            PARTITION BY bool_col \n",
+        "            ORDER BY id\n",
+        "        ) as row_num\n",
+        "    FROM test_data\n",
+        "    ORDER BY bool_col, id\n",
+        "\"\"\")\n",
+        "\n",
+        "window_result"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Progress Tracking for Long Queries\n",
+        "\n",
+        "For long-running queries, you can track progress."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Execute a query with progress tracking\n",
+        "df = ctx.sql(\"SELECT * FROM test_data\")\n",
+        "\n",
+        "# collect_with_progress shows elapsed time in Jupyter\n",
+        "batches = df.collect_with_progress()\n",
+        "print(f\"Collected {len(batches)} batch(es)\")"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# You can also provide a custom callback\n",
+        "def my_progress_callback(status, progress):\n",
+        "    if progress < 0:\n",
+        "        print(f\"Status: {status} (in progress...)\")\n",
+        "    else:\n",
+        "        print(f\"Status: {status} ({progress:.0%} complete)\")\n",
+        "\n",
+        "df = ctx.sql(\"SELECT * FROM test_data\")\n",
+        "batches = df.collect_with_progress(callback=my_progress_callback)"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Writing Results\n",
+        "\n",
+        "Distributed write operations for large result sets."
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Prepare a query result\n",
+        "df = ctx.sql(\"\"\"\n",
+        "    SELECT \n",
+        "        id,\n",
+        "        bool_col,\n",
+        "        tinyint_col * 2 as doubled\n",
+        "    FROM test_data\n",
+        "    WHERE id > 3\n",
+        "\"\"\")\n",
+        "\n",
+        "df"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Write to Parquet (distributed write)\n",
+        "# df.write_parquet(\"../target/output.parquet\")\n",
+        "\n",
+        "# Write to CSV\n",
+        "# df.write_csv(\"../target/output.csv\")\n",
+        "\n",
+        "# Write to JSON\n",
+        "# df.write_json(\"../target/output.json\")\n",
+        "\n",
+        "print(\"Write operations are commented out - uncomment to test\")"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Best Practices for Distributed Queries\n",
+        "\n",
+        "1. **Filter early**: Push filters as close to the data source as possible\n",
+        "2. **Project early**: Select only needed columns to reduce data movement\n",
+        "3. **Partition wisely**: Ensure data is partitioned for efficient joins\n",
+        "4. **Check plans**: Use `explain()` and `explain_visual()` to understand execution\n",
+        "5. **Monitor progress**: Use `collect_with_progress()` for long queries"
+      ]
+    },
+    {
+      "cell_type": "code",
+      "execution_count": null,
+      "metadata": {},
+      "outputs": [],
+      "source": [
+        "# Example: Optimized query pattern\n",
+        "optimized = (\n",
+        "    ctx.table(\"test_data\")\n",
+        "    # 1. Filter early\n",
+        "    .filter(col(\"id\") > lit(2))\n",
+        "    # 2. Project only needed columns\n",
+        "    .select(\"id\", \"bool_col\", \"tinyint_col\")\n",
+        "    # 3. Aggregate\n",
+        "    .aggregate(\n",
+        "        [col(\"bool_col\")],\n",
+        "        [f.count_star().alias(\"cnt\")]\n",
+        "    )\n",
+        ")\n",
+        "\n",
+        "# 4. Check the plan\n",
+        "print(\"Optimized plan:\")\n",
+        "print(optimized.explain())"
+      ]
+    },
+    {
+      "cell_type": "markdown",
+      "metadata": {},
+      "source": [
+        "## Next Steps\n",
+        "\n",
+        "- Review the [Ballista Architecture docs](https://datafusion.apache.org/ballista/)\n",
+        "- Learn about cluster deployment and configuration\n",
+        "- Explore advanced features like custom functions and plugins"
+      ]
+    }
+  ],
+  "metadata": {
+    "kernelspec": {
+      "display_name": "Python 3",
+      "language": "python",
+      "name": "python3"
+    },
+    "language_info": {
+      "codemirror_mode": {
+        "name": "ipython",
+        "version": 3
+      },
+      "file_extension": ".py",
+      "mimetype": "text/x-python",
+      "name": "python",
+      "nbconvert_exporter": "python",
+      "pygments_lexer": "ipython3",
+      "version": "3.11.0"
+    }
+  },
+  "nbformat": 4,
+  "nbformat_minor": 4
+}
diff --git a/python/examples/getting_started.ipynb b/python/examples/getting_started.ipynb
new file mode 100644
index 000000000..b2f725cba
--- /dev/null
+++ b/python/examples/getting_started.ipynb
@@ -0,0 +1,329 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "<!---\n",
+    "  Licensed to the Apache Software Foundation (ASF) under one\n",
+    "  or more contributor license agreements.  See the NOTICE file\n",
+    "  distributed with this work for additional information\n",
+    "  regarding copyright ownership.  The ASF licenses this file\n",
+    "  to you under the Apache License, Version 2.0 (the\n",
+    "  \"License\"); you may not use this file except in compliance\n",
+    "  with the License.  You may obtain a copy of the License at\n",
+    "\n",
+    "    http://www.apache.org/licenses/LICENSE-2.0\n",
+    "\n",
+    "  Unless required by applicable law or agreed to in writing,\n",
+    "  software distributed under the License is distributed on an\n",
+    "  \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n",
+    "  KIND, either express or implied.  See the License for the\n",
+    "  specific language governing permissions and limitations\n",
+    "  under the License.\n",
+    "-->\n",
+    "\n",
+    "# Getting Started with PyBallista\n",
+    "\n",
+    "This notebook demonstrates how to get started with Ballista using Python.\n",
+    "\n",
+    "## Prerequisites\n",
+    "\n",
+    "1. Install PyBallista: `pip install ballista`\n",
+    "2. Have a Ballista cluster running (or use the built-in test cluster)\n",
+    "\n",
+    "## Overview\n",
+    "\n",
+    "Ballista is a distributed query engine built on Apache DataFusion. PyBallista provides:\n",
+    "\n",
+    "- **BallistaSessionContext**: Drop-in replacement for DataFusion's SessionContext\n",
+    "- **SQL Magic Commands**: Interactive SQL in Jupyter notebooks via `%sql` and `%%sql`\n",
+    "- **DataFrame API**: Full DataFrame API for data transformations\n",
+    "- **Rich HTML Display**: DataFrames render as styled HTML tables"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Method 1: Python API\n",
+    "\n",
+    "The most straightforward way to use Ballista is via the Python API."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import ballista\n",
+    "from ballista import BallistaSessionContext, setup_test_cluster\n",
+    "%load_ext autoreload\n",
+    "%autoreload 2\n",
+    "\n",
+    "# Check versions\n",
+    "print(f\"Ballista version: {ballista.__version__}\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# For this demo, we'll use the built-in test cluster\n",
+    "# In production, you would connect to your Ballista scheduler:\n",
+    "# ctx = BallistaSessionContext(\"df://your-scheduler:50050\")\n",
+    "\n",
+    "host, port = setup_test_cluster()\n",
+    "ctx = BallistaSessionContext(f\"df://{host}:{port}\")\n",
+    "\n",
+    "print(f\"Connected to Ballista at {host}:{port}\")\n",
+    "print(f\"Session ID: {ctx.session_id}\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Register two .parquet files at the same db schema\n",
+    "ctx.register_parquet(\"public.test_data_v1\", \"../testdata/test.parquet\")\n",
+    "ctx.register_parquet(\"public.test_data_v2\", \"../testdata/test.parquet\")\n",
+    "\n",
+    "# List registered tables\n",
+    "print(\"Registered schemas and tables:\\n\", ctx.get_tables())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Execute a SQL query - the DataFrame will render as a nice HTML table\n",
+    "df = ctx.sql(\"SELECT * FROM public.test_data_v1 LIMIT 10\")\n",
+    "df"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# You can also use show() for terminal-style output\n",
+    "df.show(5)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Get the execution plan\n",
+    "print(df.explain())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Visualize the execution plan (requires graphviz for full SVG)\n",
+    "df.explain_visual()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Method 2: SQL Magic Commands\n",
+    "\n",
+    "For a more interactive experience, use the SQL magic commands!"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Load the Ballista Jupyter extension\n",
+    "%reload_ext ballista.jupyter"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Connect to the cluster\n",
+    "%ballista connect df://localhost:39431"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Register .parquet table\n",
+    "%register parquet public.test_data_v1 ../testdata/test.parquet"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Check connection status\n",
+    "%ballista status"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# List registered tables\n",
+    "%ballista tables"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Execute a single-line SQL query\n",
+    "%sql select count(*) as count_d from public.test_data_v1"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%%sql\n",
+    "-- Multi-line queries work with %%sql cell magic\n",
+    "SELECT\n",
+    "    id,\n",
+    "    bool_col,\n",
+    "    tinyint_col\n",
+    "FROM test_data_v1\n",
+    "WHERE id > 2\n",
+    "ORDER BY id\n",
+    "LIMIT 5"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%%sql my_result\n",
+    "-- Store the result in a variable for further processing\n",
+    "SELECT * FROM test_data_v1 WHERE id <= 3"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# View query history\n",
+    "%ballista history"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%ballista help"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Data Export\n",
+    "\n",
+    "Ballista supports exporting data in multiple formats."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "df = ctx.sql(\"SELECT * FROM test_data_v1 LIMIT 100\")\n",
+    "\n",
+    "# Export to various formats\n",
+    "# df.write_parquet(\"output.parquet\")\n",
+    "# df.write_csv(\"output.csv\")\n",
+    "# df.write_json(\"output.json\")\n",
+    "\n",
+    "# Convert to Arrow, Pandas, or Polars\n",
+    "arrow_table = df.to_arrow_table()\n",
+    "print(f\"Arrow Table Schema:\\n{arrow_table.schema}\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Convert to pandas\n",
+    "pandas_df = df.to_pandas()\n",
+    "pandas_df.head()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Next Steps\n",
+    "\n",
+    "- Check out the `dataframe_api.ipynb` notebook for more DataFrame operations\n",
+    "- See `distributed_queries.ipynb` for examples of distributed query execution\n",
+    "- Read the [PyBallista documentation](https://datafusion.apache.org/ballista/) for more details"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.12.3"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/python/pyproject.toml b/python/pyproject.toml
index bce9f5d11..84f36c7eb 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -46,6 +46,11 @@ dependencies = [
 ]
 dynamic = ["version"]
 
+[project.optional-dependencies]
+jupyter = [
+    "ipython>=8.0.0",
+]
+
 [project.urls]
 homepage = "https://datafusion.apache.org/ballista"
 documentation = "https://datafusion.apache.org/ballista"
diff --git a/python/python/ballista/__init__.py b/python/python/ballista/__init__.py
index 0c3aaa75a..5a5ef7bb0 100644
--- a/python/python/ballista/__init__.py
+++ b/python/python/ballista/__init__.py
@@ -25,7 +25,11 @@ from ._internal_ballista import (
     BallistaExecutor,
     setup_test_cluster,
 )
-from .extension import BallistaSessionContext
+from .extension import (
+    BallistaSessionContext,
+    DistributedDataFrame,
+    ExecutionPlanVisualization,
+)
 
 __version__ = importlib_metadata.version(__name__)
 
@@ -34,4 +38,6 @@ __all__ = [
     "BallistaScheduler",
     "BallistaExecutor",
     "BallistaSessionContext",
+    "DistributedDataFrame",
+    "ExecutionPlanVisualization",
 ]
diff --git a/python/python/ballista/extension.py b/python/python/ballista/extension.py
index e1217e845..87f2d6d66 100644
--- a/python/python/ballista/extension.py
+++ b/python/python/ballista/extension.py
@@ -19,8 +19,12 @@ from datafusion import SessionContext, DataFrame, ParquetWriterOptions
 from datafusion.dataframe import Compression
 
 from typing import (
+    List,
     Union,
+    Optional,
+    Callable
 )
+import warnings
 
 
 from ._internal_ballista import create_ballista_data_frame
@@ -28,26 +32,6 @@ from ._internal_ballista import ParquetColumnOptions as ParquetColumnOptionsInte
 from ._internal_ballista import ParquetWriterOptions as ParquetWriterOptionsInternal
 import pathlib
 
-# DataFrame execution methods which should be automatically
-# overridden.
-
-OVERRIDDEN_EXECUTION_METHODS = [
-    "show",
-    "count",
-    "collect",
-    "collect_partitioned",
-    "write_json",
-    "to_arrow_table",
-    "to_pandas",
-    "to_pydict",
-    "to_polars",
-    "to_pylist",
-    "_repr_html_",
-    "execute_stream",
-    "execute_stream_partitioned",
-]
-
-
 # class used to redefine DataFrame object
 # intercepting execution methods and methods
 # which returns `DataFrame`
@@ -90,10 +74,6 @@ class RedefiningDataFrameMeta(type):
                 #
                 attrs[base_name] = __wrap_dataframe_result(base_value)
 
-        # TODO: we could do better here
-        for function in OVERRIDDEN_EXECUTION_METHODS:
-            attrs[function] = __wrap_dataframe_execution(function)
-
         return super().__new__(cls, name, bases, attrs)
 
 
@@ -131,21 +111,18 @@ class RedefiningSessionContextMeta(type):
 # serialize it and invoke ballista client to execute it
 #
 # this class keeps reference to remote ballista
-
-
-class DistributedDataFrame(DataFrame, metaclass=RedefiningDataFrameMeta):
+class DistributedDataFrame(DataFrame, metaclass=type):
     def __init__(self, df: DataFrame, session_id: str, address: str):
         super().__init__(df.df)
         self.address = address
-        self.session_id = session_id
-
+        self._session_id = session_id
     #
     # this will create a ballista dataframe, which has ballista
     # session context, and ballista planner.
     #
     def _to_internal_df(self):
         blob_plan = self.logical_plan().to_proto()
-        df = create_ballista_data_frame(blob_plan, self.address, self.session_id)
+        df = create_ballista_data_frame(blob_plan, self.address, self._session_id)
         return df
 
     def write_csv(self, path, with_header=False):
@@ -229,9 +206,325 @@ class DistributedDataFrame(DataFrame, metaclass=RedefiningDataFrameMeta):
         df = self._to_internal_df()
         df.write_parquet(str(path), compression.value, compression_level)
 
+    def explain_visual(self, analyze: bool = False) -> "ExecutionPlanVisualization":
+        """
+        Generate a visual representation of the execution plan.
+
+        This method creates an SVG visualization of the query execution plan,
+        which can be displayed directly in Jupyter notebooks.
+
+        Args:
+            analyze: If True, includes runtime statistics from actual execution.
+
+        Returns:
+            ExecutionPlanVisualization: An object that renders as SVG in Jupyter.
+
+        Example:
+            >>> df = ctx.sql("SELECT * FROM orders WHERE amount > 100")
+            >>> df.explain_visual()  # Displays SVG in notebook
+            >>> viz = df.explain_visual(analyze=True)
+            >>> viz.save("plan.svg")  # Save to file
+        """
+        # Get the execution plan as a string representation
+        # Note: explain() prints but doesn't return a string, so we use logical_plan()
+        try:
+            plan = self.logical_plan()
+            plan_str = plan.display_indent()
+        except Exception:
+            # Fallback if logical_plan() fails
+            plan_str = "Unable to retrieve execution plan"
+        return ExecutionPlanVisualization(plan_str, analyze=analyze)
+
+    def collect_with_progress(
+        self,
+        callback: Optional[Callable] = None,
+        poll_interval: float = 0.5,
+    ):
+        """
+        Collect results with progress indication.
+
+        For long-running queries, this method provides progress updates
+        through a callback function or displays a progress bar in Jupyter.
+
+        Args:
+            callback: Optional function to call with progress updates.
+                     Signature: callback(status: str, progress: float)
+            poll_interval: How often to check progress (seconds).
+
+        Returns:
+            The collected result batches.
+
+        Example:
+            >>> def my_callback(status, progress):
+            ...     print(f"{status}: {progress:.1%}")
+            >>> batches = df.collect_with_progress(callback=my_callback)
+        """
+        import threading
+        import time
+
+        result = [None]
+        error = [None]
+        done = threading.Event()
+
+        def execute():
+            try:
+                result[0] = self.collect()
+            except Exception as e:
+                error[0] = e
+            finally:
+                done.set()
+
+        thread = threading.Thread(target=execute)
+        thread.start()
+
+        # Check if we're in a Jupyter environment
+        try:
+            from IPython.display import clear_output
+            from IPython.core.getipython import get_ipython
+
+            in_jupyter = get_ipython() is not None
+        except (ImportError, AttributeError):
+            in_jupyter = False
+
+        start_time = time.time()
+
+        if in_jupyter and callback is None:
+            # Display a simple progress indicator
+            try:
+                while not done.wait(timeout=poll_interval):
+                    elapsed = time.time() - start_time
+                    clear_output(wait=True)
+                    print(f"⏳ Query executing... ({elapsed:.1f}s elapsed)")
+
+                clear_output(wait=True)
+                elapsed = time.time() - start_time
+                print(f"✓ Query completed in {elapsed:.1f}s")
+            except Exception:
+                pass  # Ignore display errors
+        elif callback is not None:
+            while not done.wait(timeout=poll_interval):
+                elapsed = time.time() - start_time
+                callback(f"Executing ({elapsed:.1f}s)", -1.0)  # -1 means indeterminate
+
+            elapsed = time.time() - start_time
+            callback(f"Completed in {elapsed:.1f}s", 1.0)
+        else:
+            done.wait()
+
+        thread.join()
+
+        if error[0] is not None:
+            raise error[0]
+
+        return result[0]
+
+
+class ExecutionPlanVisualization:
+    """
+    A wrapper for execution plan visualizations that can render as SVG in Jupyter.
+
+    This class takes the text representation of an execution plan and converts
+    it to a Graphviz DOT format, which is then rendered as SVG.
+    """
+
+    def __init__(self, plan_str: str, analyze: bool = False):
+        self.plan_str = plan_str
+        self.analyze = analyze
+        self._svg_cache: Optional[str] = None
+
+    def _parse_plan_to_dot(self) -> str:
+        """Convert the plan string to DOT format for Graphviz."""
+        lines = self.plan_str.strip().split("\n")
+
+        dot_lines = [
+            "digraph ExecutionPlan {",
+            '    rankdir=TB;',
+            '    node [shape=box, style="rounded,filled", fontname="Helvetica"];',
+            '    edge [fontname="Helvetica"];',
+            "",
+        ]
+
+        nodes = []
+        edges = []
+        node_id = 0
+        stack = []  # (indent_level, node_id)
+
+        for line in lines:
+            if not line.strip():
+                continue
+
+            # Calculate indent level
+            indent = len(line) - len(line.lstrip())
+            content = line.strip()
+
+            # Skip non-plan lines
+            if content.startswith("physical_plan") or content.startswith("logical_plan"):
+                continue
+
+            # Create a node for this plan element
+            current_id = node_id
+            node_id += 1
+
+            # Determine node color based on operation type
+            color = "#E3F2FD"  # Default light blue
+            if "Scan" in content or "TableScan" in content:
+                color = "#E8F5E9"  # Light green for scans
+            elif "Filter" in content:
+                color = "#FFF3E0"  # Light orange for filters
+            elif "Aggregate" in content or "HashAggregate" in content:
+                color = "#F3E5F5"  # Light purple for aggregations
+            elif "Join" in content:
+                color = "#FFEBEE"  # Light red for joins
+            elif "Sort" in content:
+                color = "#E0F7FA"  # Light cyan for sorts
+            elif "Projection" in content:
+                color = "#FFF8E1"  # Light amber for projections
+
+            # Escape special characters for DOT format
+            label = content.replace('"', '\\"').replace("\n", "\\n")
+            if len(label) > 60:
+                # Wrap long labels
+                label = label[:57] + "..."
+
+            nodes.append(f'    node{current_id} [label="{label}", fillcolor="{color}"];')
+
+            # Connect to parent based on indentation
+            while stack and stack[-1][0] >= indent:
+                stack.pop()
+
+            if stack:
+                parent_id = stack[-1][1]
+                edges.append(f"    node{parent_id} -> node{current_id};")
+
+            stack.append((indent, current_id))
+
+        dot_lines.extend(nodes)
+        dot_lines.append("")
+        dot_lines.extend(edges)
+        dot_lines.append("}")
+
+        return "\n".join(dot_lines)
+
+    def to_dot(self) -> str:
+        """Get the DOT representation of the execution plan."""
+        return self._parse_plan_to_dot()
+
+    def to_svg(self) -> str:
+        """
+        Convert the execution plan to SVG format.
+
+        Requires graphviz to be installed. If graphviz is not available,
+        returns a simple HTML representation instead.
+        """
+        if self._svg_cache is not None:
+            return self._svg_cache
+
+        dot_source = self._parse_plan_to_dot()
+
+        try:
+            import subprocess
+
+            # Try to use graphviz's dot command
+            process = subprocess.run(
+                ["dot", "-Tsvg"],
+                input=dot_source.encode(),
+                capture_output=True,
+                timeout=30,
+            )
+
+            if process.returncode == 0:
+                self._svg_cache = process.stdout.decode()
+                return self._svg_cache
+        except (subprocess.SubprocessError, FileNotFoundError, subprocess.TimeoutExpired) as e:
+            warnings.warn(f"Could not convert the execution plan to SVG format: {e}")
+            pass
+
+        # Fallback: return a pre-formatted HTML representation
+        escaped_plan = (
+            self.plan_str.replace("&", "&amp;")
+            .replace("<", "&lt;")
+            .replace(">", "&gt;")
+        )
+        self._svg_cache = f"""
+        <div style="font-family: monospace; background: #f5f5f5; padding: 10px; 
+                    border-radius: 5px; overflow-x: auto;">
+            <div style="color: #666; margin-bottom: 5px;">
+                Execution Plan {'(with statistics)' if self.analyze else ''}
+                <br><small>Install graphviz for visual diagram: brew install graphviz</small>
+            </div>
+            <pre style="margin: 0;">{escaped_plan}</pre>
+        </div>
+        """
+        return self._svg_cache
+
+    def save(self, path: str) -> None:
+        """Save the visualization to a file (SVG or DOT format)."""
+        if path.endswith(".dot"):
+            content = self.to_dot()
+        else:
+            content = self.to_svg()
+
+        with open(path, "w") as f:
+            f.write(content)
+
+    def _repr_html_(self) -> str:
+        """HTML representation for Jupyter notebooks."""
+        return self.to_svg()
+
+    def _repr_svg_(self) -> str:
+        """SVG representation for Jupyter notebooks."""
+        svg = self.to_svg()
+        # Only return if it's actual SVG content
+        if svg.strip().startswith("<svg") or svg.strip().startswith("<?xml"):
+            return svg
+        return ""
+
+    def __repr__(self) -> str:
+        """String representation."""
+        return f"ExecutionPlanVisualization(analyze={self.analyze})\n{self.plan_str}"
+
 
 class BallistaSessionContext(SessionContext, metaclass=RedefiningSessionContextMeta):
+    """
+    A session context for connecting to and querying a Ballista cluster.
+
+    This class extends DataFusion's SessionContext to work with distributed
+    Ballista clusters, automatically routing query execution to the cluster
+    while maintaining API compatibility with local DataFusion usage.
+
+    Example:
+        >>> from ballista import BallistaSessionContext
+        >>> ctx = BallistaSessionContext("df://localhost:50050")
+        >>> df = ctx.sql("SELECT * FROM my_table LIMIT 10")
+        >>> df.show()
+
+    For Jupyter notebook users:
+        >>> %load_ext ballista.jupyter
+        >>> %ballista connect df://localhost:50050
+        >>> %sql SELECT * FROM my_table
+    """
+
     def __init__(self, address: str, config=None, runtime=None):
         super().__init__(config, runtime)
         self.address = address
-        self.session_id = self.session_id()
+        self.session_id_internal = super().session_id() 
+
+    @property
+    def session_id(self):
+        return self.session_id_internal
+
+    def get_tables(self) -> Optional[dict[str, List[str]]]:
+        """Get tables and their respective schemas (in terms of database schema)."""
+        try:
+            catalog = self.catalog()
+            schema_names = list(catalog.schema_names())
+            if schema_names:
+                tables_info = {}
+                for schema_name in schema_names:
+                    tables_info[schema_name] = list(catalog.schema(name=schema_name).table_names())
+                return tables_info
+        except (AttributeError, NotImplementedError) as e:
+            warnings.warn(f"Could not retrieve tables from catalog: {e}")
+            pass
+        return {}
+    
\ No newline at end of file
diff --git a/python/python/ballista/jupyter.py b/python/python/ballista/jupyter.py
new file mode 100644
index 000000000..c6e2b83de
--- /dev/null
+++ b/python/python/ballista/jupyter.py
@@ -0,0 +1,494 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Ballista Jupyter Magic Commands.
+
+This module provides IPython magic commands for interacting with Ballista
+clusters directly from Jupyter notebooks.
+
+Usage:
+    %load_ext ballista.jupyter
+
+    # Connect to a Ballista cluster
+    %ballista connect df://localhost:50050
+
+    # Check connection status
+    %ballista status
+
+    # List registered tables
+    %ballista tables
+
+    # Show schema for a table
+    %ballista schema my_table
+
+    # Execute a simple SQL query (line magic)
+    %sql SELECT COUNT(*) FROM my_table
+
+    # Execute a complex SQL query (cell magic)
+    %%sql
+    SELECT
+        customer_id,
+        SUM(amount) as total
+    FROM orders
+    GROUP BY customer_id
+    ORDER BY total DESC
+    LIMIT 10
+"""
+
+from typing import Optional, List, Dict, Any
+import warnings
+import time
+
+try:
+    from IPython.core.magic import Magics, magics_class, line_magic, line_cell_magic
+    from IPython.display import display, HTML
+
+    IPYTHON_AVAILABLE = True
+except ImportError:
+    IPYTHON_AVAILABLE = False
+
+    # Provide stub classes for when IPython is not available
+    class Magics:
+        def __init__(self, shell=None):
+            self.shell = shell
+
+    def magics_class(cls):
+        return cls
+
+    def line_magic(name_or_func):
+        """Stub line_magic decorator for when IPython is not available."""
+        # Handle both @line_magic and @line_magic("name") usage
+        if callable(name_or_func):
+            # Used as @line_magic without arguments
+            return name_or_func
+        else:
+            # Used as @line_magic("name") with arguments
+            def decorator(func):
+                return func
+            return decorator
+
+    def cell_magic(name_or_func):
+        """Stub cell_magic decorator for when IPython is not available."""
+        # Handle both @cell_magic and @cell_magic("name") usage
+        if callable(name_or_func):
+            return name_or_func
+        else:
+            def decorator(func):
+                return func
+            return decorator
+        
+    def line_cell_magic(name_or_func):
+        """Stub line_cell_magic decorator for when IPython is not available"""
+        # Handle both @line_cell_magic and @line_cell_magic("name") usage
+        if callable(name_or_func):
+            return name_or_func
+        else:
+            def decorator(func):
+                return func
+            return decorator
+
+
+from .extension import BallistaSessionContext, DistributedDataFrame
+
+
+class BallistaConnectionError(Exception):
+    """Raised when not connected to a Ballista cluster."""
+
+    pass
+
+
+@magics_class
+class BallistaMagics(Magics):
+    """
+    IPython magic commands for Ballista.
+
+    Provides convenient commands for connecting to Ballista clusters,
+    executing SQL queries, and exploring table schemas.
+    """
+
+    def __init__(self, shell=None):
+        super().__init__(shell)
+        self._ctx: Optional[BallistaSessionContext] = None
+        self._address: Optional[str] = None
+        self._last_result: Optional[DistributedDataFrame] = None
+        self._query_history: List[Dict[str, Any]] = []
+
+    @property
+    def ctx(self) -> BallistaSessionContext:
+        """Get the current context, raising an error if not connected."""
+        if self._ctx is None:
+            raise BallistaConnectionError(
+                "Not connected to a Ballista cluster. "
+                "Use: %ballista connect df://host:port"
+            )
+        return self._ctx
+
+    @property
+    def is_connected(self) -> bool:
+        """Check if connected to a Ballista cluster."""
+        return self._ctx is not None
+
+    @line_magic
+    def ballista(self, line: str) -> Optional[str]:
+        """
+        Ballista management commands.
+
+        Usage:
+            %ballista connect df://localhost:50050  - Connect to cluster
+            %ballista status                        - Show connection status
+            %ballista tables                        - List registered tables
+            %ballista schema <table>                - Show table schema
+            %ballista disconnect                    - Disconnect from cluster
+            %ballista history                       - Show query history
+
+        Examples:
+            %ballista connect df://localhost:50050
+            %ballista tables
+            %ballista schema orders
+        """
+        parts = line.strip().split(maxsplit=1)
+        if not parts:
+            return self._show_help()
+
+        cmd = parts[0].lower()
+        args = parts[1] if len(parts) > 1 else ""
+
+        if cmd == "connect":
+            return self._connect(args)
+        elif cmd == "status":
+            return self._status()
+        elif cmd == "tables":
+            return self._tables()
+        elif cmd == "schema":
+            return self._schema(args)
+        elif cmd == "disconnect":
+            return self._disconnect()
+        elif cmd == "history":
+            return self._show_history()
+        elif cmd == "help":
+            return self._show_help()
+        else:
+            return f"Unknown command: {cmd}. Use '%ballista help' for available commands."
+        
+    @line_magic
+    def register(self, line: str) -> Optional[str]:
+        """Register a new table"""
+        if not line:
+            return "You should provide file extension and table name to register"
+        elif self._ctx is None:
+            raise BallistaConnectionError(
+                "Not connected to a Ballista cluster. "
+                "Use: %ballista connect df://host:port"
+            )
+        else:
+            args = line.strip().split()
+            file_type = args[0]
+            if len(args) < 2:
+                return f"You should provide table name for this .{file_type} file"
+            table_name = args[1]
+            if len(args) < 3:
+                return "You should provide path to your file"
+            file_name = args[2]
+
+            if file_type == "parquet":
+                self._ctx.register_parquet(table_name, file_name)
+            elif file_type == "csv":
+                self._ctx.register_csv(table_name, file_name)
+            else:
+                raise NotImplementedError("Currently not supporting the inserted file format")
+
+    @line_cell_magic 
+    def sql(self, line: str, cell=None) -> Optional[DistributedDataFrame]:
+        """ 
+        Execute a SQL query (both line and cell magic).
+
+        Two cases possible: with cell or without cell
+
+        Examples:
+        1. Without a cell (line_magic):
+            %sql SELECT * FROM test_table
+        2. With a cell (cell_magic) -- we can also use a variable to store the result of the query like this:
+            %%sql my_result
+            SELECT
+                id,
+                bool_col,
+                tinyint_col
+            FROM test_data_v1
+            WHERE id > 2
+            ORDER BY id
+            LIMIT 5 
+
+        `my_result` will store the result of the SQL-query
+        """
+        if not cell:
+            return self._execute_sql(line.strip()) if line.strip() else None
+        else:
+            var_name = None
+            query = cell.strip()
+            if not query:
+                return None
+            
+            args = line.strip().split()
+            i = 0
+            while i < len(args):
+                if not args[i].startswith("--"):
+                    var_name = args[i]
+                i += 1
+
+            result = self._execute_sql(query)
+
+            # Store in user namespace if variable name provided
+            if var_name and self.shell is not None:
+                self.shell.user_ns[var_name] = result
+            return result
+
+    def _connect(self, address: str) -> Optional[str]:
+        """Connect to a Ballista cluster."""
+        if not address:
+            return "Usage: %ballista connect df://host:port"
+
+        # Normalize address
+        if not address.startswith("df://"):
+            address = f"df://{address}"
+
+        try:
+            self._ctx = BallistaSessionContext(address)
+            self._address = address
+            if IPYTHON_AVAILABLE:
+                display(HTML(f"✓ Connected to Ballista cluster at {address}"))
+            else:
+                print(f"✓ Connected to Ballista cluster at {address}")
+        except Exception as e:
+            self._ctx = None
+            self._address = None
+            if IPYTHON_AVAILABLE:
+                display(HTML(f"✗ Failed to connect to {address}: {e}"))
+            else:
+                print(f"✗ Failed to connect to {address}: {e}")
+
+    def _disconnect(self) -> Optional[str]:
+        """Disconnect from the Ballista cluster."""
+        if not self.is_connected:
+            return "Not connected to any cluster."
+
+        address = self._address
+        self._ctx = None
+        self._address = None
+        self._last_result = None
+        if IPYTHON_AVAILABLE:
+            display(HTML(f"✓ Disconnected from {address}"))
+        else:
+             print(f"✓ Disconnected from {address}")
+
+    def _status(self) -> Optional[str]:
+        """Show connection status."""
+        if not self.is_connected:
+            return "Status: Not connected\n\nUse '%ballista connect df://host:port' to connect."
+
+        status_lines = [
+            "Status: Connected",
+            f"Address: {self._address}",
+            f"Session ID: {self._ctx.session_id}",
+            f"Queries executed: {len(self._query_history)}",
+        ]
+
+        if self._last_result is not None:
+            status_lines.append("Last result: Available (access via '_' or '_last_result')")
+
+        def _format_html_status_output(line: str) -> str:
+            name, value = line.split(":", 1)
+            return f"<pre><strong>{name}:</strong> {value.strip()}</pre>"
+
+        html = "".join(_format_html_status_output(line) for line in status_lines)
+        if IPYTHON_AVAILABLE:
+            display(HTML(html))
+        else:
+            print("\n".join(status_lines))
+            
+    def _tables(self) -> Optional[str]:
+        """List all registered tables."""
+        try:
+            # Get table names from the catalog and their respective schemas
+            tables = self.ctx.get_tables()
+            if not tables:
+                return "No tables registered.\n\nUse ctx.register_parquet() or ctx.register_csv() to register tables."
+            schema_count = len(tables.keys())
+            table_count = sum(len(v) for v in tables.values())
+            # Build a nice table display (HTML-formatted if applicable)
+            lines = [
+                {"content": f"Total: {table_count} table(s) in {schema_count} schema(s)", "is_info": True},
+                {"content": "Registered tables:", "is_info": True},
+                *[{"content": f"Schema: {schema_name}. Tables: {', '.join(table_names)}", "is_info": False}
+                  for schema_name, table_names in tables.items()]
+            ]
+
+            def _format_html_tables_output(line: str, is_info: bool = False) -> str:
+                if is_info:
+                    return f"<pre><strong>{line}</strong></pre>"
+                else:
+                    return f"<p><pre><i>{line}</i></pre></p>"
+            
+            if IPYTHON_AVAILABLE:
+                display(
+                    HTML("".join(_format_html_tables_output(val["content"], val["is_info"]) for val in lines))
+                )
+            else:
+                print("".join(val["content"] for val in lines))
+        except Exception as e:
+            warnings.warn(f"Error listing tables: {e}")
+
+    def _schema(self, table_name: str) -> Optional[str]:
+        """Show schema for a table."""
+        if not table_name:
+            return "Usage: %ballista schema <table_name>"
+
+        try:
+            # Query the table with LIMIT 0 to get schema without data
+            df = self.ctx.sql(f"SELECT * FROM {table_name} LIMIT 0")
+            schema = df.schema()
+
+            lines = [f"Schema for '{table_name}':", "-" * 50]
+            for field in schema:
+                nullable = "NULL" if field.nullable else "NOT NULL"
+                lines.append(f"  {field.name:20} {str(field.type):15} {nullable}")
+            lines.append("-" * 50)
+            lines.append(f"Total: {len(schema)} column(s)")
+
+            print("\n".join(lines))
+        except Exception as e:
+            warnings.warn(f"Error getting schema for '{table_name}': {e}")
+
+    def _execute_sql(
+        self,
+        query: str,
+    ) -> Optional[DistributedDataFrame]:
+        """Execute a SQL query and return the result."""
+        start_time = time.time()
+
+        try:
+            result = self.ctx.sql(query)
+            elapsed = time.time() - start_time
+
+            # Store result
+            self._last_result = result
+            if self.shell is not None and hasattr(self.shell, 'user_ns'):
+                self.shell.user_ns["_last_result"] = result
+
+            # Record in history
+            self._query_history.append(
+                {
+                    "query": query,
+                    "elapsed_seconds": elapsed,
+                    "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
+                }
+            )
+
+            # Display if requested and in notebook environment
+            return result
+
+        except Exception as e:
+            error_msg = f"Query failed: {e}"
+            if IPYTHON_AVAILABLE:
+                try:
+                    display(HTML(f'<div style="color: red; font-weight: bold;">{error_msg}</div>'))
+                except Exception:
+                    print(error_msg)
+            else:
+                print(error_msg)
+            return None
+
+    def _show_history(self) -> Optional[str]:
+        """Show query history."""
+        if not self._query_history:
+            return "No queries executed yet."
+
+        lines = ["Query History:", "-" * 60]
+        for i, entry in enumerate(self._query_history[-10:], 1):  # Last 10 queries
+            query_preview = entry["query"][:50] + "..." if len(entry["query"]) > 50 else entry["query"]
+            query_preview = query_preview.replace("\n", " ")
+            lines.append(f"{i}. [{entry['timestamp']}] ({entry['elapsed_seconds']:.2f}s)")
+            lines.append(f"   {query_preview}")
+        lines.append("-" * 60)
+        output = "\n".join(lines)
+        if IPYTHON_AVAILABLE:
+            display(HTML(f"<pre>{output}</pre>"))
+        else:
+            print(output)
+
+    def _show_help(self) -> Optional[str]:
+        """Show help for Ballista magic commands."""
+        help_info = """
+Ballista Jupyter Magic Commands
+================================
+
+Connection:
+    %ballista connect <url>   - Connect to Ballista cluster
+    %ballista disconnect      - Disconnect from cluster
+    %ballista status          - Show connection status
+
+Exploration:
+    %ballista tables          - List registered tables
+    %ballista schema <table>  - Show table schema
+
+Table-register:
+    %register [format] [schema.table_name] [file_path] - Register a new table in the current Ballista Context
+
+Query:
+    %sql <query>              - Execute single-line SQL query
+
+    %%sql [options] [var]     - Execute multi-line SQL query
+        Options:
+            --no-display      - Don't display results
+            --limit N         - Limit displayed rows (default: 50)
+        var                   - Store result in variable
+
+History:
+    %ballista history         - Show recent query history
+
+Examples:
+    %ballista connect df://localhost:50050
+    %ballista tables
+    %ballista schema orders
+
+    %sql SELECT COUNT(*) FROM orders
+
+    %%sql my_result
+    SELECT customer_id, SUM(amount) as total
+    FROM orders
+    GROUP BY customer_id
+    ORDER BY total DESC
+    LIMIT 10
+        """
+        if IPYTHON_AVAILABLE:
+            display(HTML(f"<pre>{help_info}</pre>"))
+        else:
+            print(help_info)
+
+def load_ipython_extension(ipython):
+    """
+    Load the Ballista IPython extension.
+
+    Usage in Jupyter notebook:
+        %load_ext ballista.jupyter
+    """
+    ipython.register_magics(BallistaMagics)
+
+
+def unload_ipython_extension(ipython):
+    """Unload the Ballista IPython extension."""
+    pass  # IPython handles magic cleanup automatically
diff --git a/python/python/tests/test_jupyter.py b/python/python/tests/test_jupyter.py
new file mode 100644
index 000000000..73c69ff4f
--- /dev/null
+++ b/python/python/tests/test_jupyter.py
@@ -0,0 +1,316 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for the Ballista Jupyter module."""
+
+import pytest
+from unittest.mock import patch, MagicMock
+from ballista import setup_test_cluster
+from ballista.jupyter import (
+    BallistaMagics,
+    BallistaConnectionError,
+    load_ipython_extension,
+    IPYTHON_AVAILABLE,
+)
+
+
+@pytest.fixture
+def magics():
+    """Create a BallistaMagics instance for testing."""
+    return BallistaMagics(shell=None)
+
+
+@pytest.fixture
+def connected_magics():
+    """Create a connected BallistaMagics instance."""
+    magics = BallistaMagics(shell=None)
+    (address, port) = setup_test_cluster()
+    magics._connect(f"df://{address}:{port}")
+    return magics
+
+
+class TestBallistaMagicsInitialState:
+    """Tests for initial (disconnected) state."""
+
+    def test_initial_state(self, magics):
+        """Test that magics start disconnected."""
+        assert not magics.is_connected
+        assert magics._ctx is None
+        assert magics._address is None
+
+    def test_ctx_property_raises_when_disconnected(self, magics):
+        """Test that accessing ctx raises BallistaConnectionError when not connected."""
+        with pytest.raises(BallistaConnectionError):
+            _ = magics.ctx
+
+    def test_disconnect_when_not_connected_returns_message(self, magics):
+        result = magics._disconnect()
+        assert "Not connected" in result
+
+    def test_status_when_disconnected_returns_message(self, magics):
+        result = magics._status()
+        assert "Not connected" in result
+        assert "connect" in result.lower()
+
+    def test_show_history_empty_returns_message(self, magics):
+        result = magics._show_history()
+        assert "No queries" in result
+
+    def test_show_help_prints(self, magics, capsys):
+        """Test that help is printed correctly."""
+        with patch("ballista.jupyter.IPYTHON_AVAILABLE", False):
+            magics._show_help()
+            captured = capsys.readouterr()
+            assert "connect" in captured.out.lower()
+            assert "status" in captured.out.lower()
+            assert "tables" in captured.out.lower()
+            assert "sql" in captured.out.lower()
+
+
+class TestBallistaMagicsConnect:
+    """Tests for connect/disconnect behavior."""
+
+    def test_connect_requires_address_returns_usage(self, magics):
+        """Test that connect with empty address returns a usage string."""
+        result = magics._connect("")
+        assert result is not None
+        assert "Usage:" in result
+
+    def test_connect_with_invalid_address_does_not_raise(self, magics):
+        """Test connection to invalid address fails gracefully without raising."""
+        # Should not raise, but context should not be set
+        magics._connect("df://invalid-host:99999")
+        # Connection may be lazy; just assert no unhandled exception occurred
+
+    def test_connect_normalizes_address_without_scheme(self, magics):
+        """Test that connect prepends df:// if missing."""
+        # Use an invalid host so it fails, but we can inspect the stored address
+        magics._connect("localhost:12345")
+        # Either connected (unlikely with bad host) or failed cleanly
+        # Either way, _address if set should have df:// prefix
+        if magics._address is not None:
+            assert magics._address.startswith("df://")
+
+    def test_successful_connect_sets_state(self, magics):
+        """Test that successful connect sets ctx, address, and is_connected."""
+        (address, port) = setup_test_cluster()
+        magics._connect(f"df://{address}:{port}")
+        assert magics.is_connected
+        assert magics._ctx is not None
+        assert magics._address is not None
+
+    def test_disconnect_clears_state(self, connected_magics):
+        """Test that disconnect clears ctx, address and sets is_connected=False."""
+        connected_magics._disconnect()
+        assert not connected_magics.is_connected
+        assert connected_magics._ctx is None
+        assert connected_magics._address is None
+        assert connected_magics._last_result is None
+
+    def test_disconnect_prints_confirmation(self, connected_magics, capsys):
+        """Test that disconnect prints confirmation message."""
+        with patch("ballista.jupyter.IPYTHON_AVAILABLE", False):
+            connected_magics._disconnect()
+            captured = capsys.readouterr()
+            assert "Disconnected" in captured.out
+
+
+class TestBallistaMagicsCommandParsing:
+    """Tests for %ballista command dispatch."""
+
+    def test_ballista_help_command(self, magics, capsys):
+        """Test that help command prints help text."""
+        with patch("ballista.jupyter.IPYTHON_AVAILABLE", False):
+            magics.ballista("help")
+            captured = capsys.readouterr()
+            assert "connect" in captured.out.lower()
+
+    def test_ballista_unknown_command_returns_message(self, magics):
+        """Test that unknown command returns an error message."""
+        result = magics.ballista("unknown_command_xyz")
+        assert result is not None
+        assert "Unknown command" in result
+
+    def test_ballista_empty_line_shows_help(self, magics, capsys):
+        """Test that empty line shows help."""
+        with patch("ballista.jupyter.IPYTHON_AVAILABLE", False):
+            magics.ballista("")
+            captured = capsys.readouterr()
+            assert "connect" in captured.out.lower()
+
+    def test_ballista_status_dispatches(self, magics):
+        result = magics.ballista("status")
+        assert "Not connected" in result
+
+    def test_ballista_disconnect_when_not_connected(self, magics):
+        """Test disconnect subcommand when not connected."""
+        result = magics.ballista("disconnect")
+        assert result is not None
+        assert "Not connected" in result
+
+
+class TestBallistaMagicsConnected:
+    """Tests for BallistaMagics when connected to a real cluster."""
+
+    def test_is_connected(self, connected_magics):
+        """Test that is_connected is True after connect."""
+        assert connected_magics.is_connected
+        assert connected_magics._ctx is not None
+
+    def test_status_when_connected_prints(self, connected_magics, capsys):
+        """Test status shows connected state via print."""
+        with patch("ballista.jupyter.IPYTHON_AVAILABLE", False):
+            connected_magics._status()
+            captured = capsys.readouterr()
+            assert "Connected" in captured.out
+            assert "Session ID" in captured.out
+
+    def test_tables_empty_does_not_raise(self, connected_magics):
+        """Test listing tables when none registered does not raise."""
+        # Should print or return a message, not raise
+        connected_magics._tables()  # no assertion needed beyond "does not raise"
+
+    def test_execute_sql_returns_dataframe(self, connected_magics):
+        """Test executing a SQL query returns a DistributedDataFrame."""
+        result = connected_magics._execute_sql("SELECT 1 as value")
+        assert result is not None
+
+    def test_execute_sql_records_history(self, connected_magics):
+        """Test that executed queries are recorded in history."""
+        connected_magics._execute_sql("SELECT 1")
+        assert len(connected_magics._query_history) == 1
+        assert "SELECT 1" in connected_magics._query_history[0]["query"]
+
+    def test_execute_sql_history_has_timestamp_and_elapsed(self, connected_magics):
+        """Test that history entries contain timing info."""
+        connected_magics._execute_sql("SELECT 1")
+        entry = connected_magics._query_history[0]
+        assert "timestamp" in entry
+        assert "elapsed_seconds" in entry
+        assert isinstance(entry["elapsed_seconds"], float)
+
+    def test_execute_sql_stores_last_result(self, connected_magics):
+        """Test that _last_result is updated after execution."""
+        connected_magics._execute_sql("SELECT 1")
+        assert connected_magics._last_result is not None
+
+    def test_show_history_prints_entries(self, connected_magics, capsys):
+        """Test that history is printed after queries are executed."""
+        connected_magics._execute_sql("SELECT 1")
+        with patch("ballista.jupyter.IPYTHON_AVAILABLE", False):
+            connected_magics._show_history()
+            captured = capsys.readouterr()
+            assert "SELECT 1" in captured.out
+
+    def test_sql_magic_line_returns_dataframe(self, connected_magics):
+        """Test %sql line magic returns a DistributedDataFrame."""
+        result = connected_magics.sql("SELECT 1 as value")
+        assert result is not None
+
+    def test_sql_magic_cell_returns_dataframe(self, connected_magics):
+        """Test %%sql cell magic returns a DistributedDataFrame."""
+        result = connected_magics.sql("", cell="SELECT 1 as value")
+        assert result is not None
+
+    def test_sql_magic_cell_stores_in_shell_namespace(self, connected_magics):
+        """Test %%sql stores result in shell namespace when var name given."""
+        mock_shell = MagicMock()
+        mock_shell.user_ns = {}
+        connected_magics.shell = mock_shell
+
+        connected_magics.sql("my_var", cell="SELECT 1 as value")
+        assert "my_var" in mock_shell.user_ns
+        assert mock_shell.user_ns["my_var"] is not None
+
+    def test_sql_magic_empty_line_returns_none(self, connected_magics):
+        """Test %sql with empty line returns None."""
+        result = connected_magics.sql("")
+        assert result is None
+
+    def test_schema_missing_table_name_returns_usage(self, connected_magics):
+        """Test _schema with no table name returns usage string."""
+        result = connected_magics._schema("")
+        assert result is not None
+        assert "Usage:" in result
+
+    def test_register_parquet_dispatches(self, connected_magics, tmp_path):
+        """Test %register parquet calls register_parquet on context."""
+        with patch.object(connected_magics._ctx, "register_parquet") as mock_reg:
+            connected_magics.register(f"parquet my_table {tmp_path}/file.parquet")
+            mock_reg.assert_called_once()
+
+    def test_register_csv_dispatches(self, connected_magics, tmp_path):
+        """Test %register csv calls register_csv on context."""
+        with patch.object(connected_magics._ctx, "register_csv") as mock_reg:
+            connected_magics.register(f"csv my_table {tmp_path}/file.csv")
+            mock_reg.assert_called_once()
+
+    def test_register_unsupported_format_raises(self, connected_magics):
+        """Test %register with unsupported format raises NotImplementedError."""
+        with pytest.raises(NotImplementedError):
+            connected_magics.register("xlsx my_table file.xlsx")
+
+    def test_register_missing_table_name_returns_message(self, connected_magics):
+        """Test %register with missing table name returns a message."""
+        result = connected_magics.register("parquet")
+        assert result is not None
+
+    def test_register_missing_file_path_returns_message(self, connected_magics):
+        """Test %register with missing file path returns a message."""
+        result = connected_magics.register("parquet my_table")
+        assert result is not None
+
+
+class TestIPythonExtension:
+    """Tests for IPython extension loading."""
+
+    def test_load_extension_function_exists(self):
+        """Test that load_ipython_extension is defined and callable."""
+        assert callable(load_ipython_extension)
+
+    def test_load_extension_registers_magics(self):
+        """Test that load_ipython_extension registers BallistaMagics."""
+        mock_ipython = MagicMock()
+        load_ipython_extension(mock_ipython)
+        mock_ipython.register_magics.assert_called_once_with(BallistaMagics)
+
+    @pytest.mark.skipif(not IPYTHON_AVAILABLE, reason="IPython not available")
+    def test_load_extension_with_none_shell_does_not_crash(self):
+        """Test loading extension with None shell doesn't crash."""
+        try:
+            load_ipython_extension(None)
+        except (AttributeError, TypeError):
+            pass  # Expected when shell is None
+
+
+class TestBallistaConnectionError:
+    """Tests for the BallistaConnectionError exception."""
+
+    def test_tables_when_disconnected_warns(self, magics):
+        with pytest.warns(UserWarning, match="Not connected"):
+            magics._tables()
+
+    def test_schema_when_disconnected_warns(self, magics):
+        with pytest.warns(UserWarning, match="Not connected"):
+            magics._schema("some_table")
+
+    def test_execute_sql_when_disconnected_prints_error(self, magics, capsys):
+        with patch("ballista.jupyter.IPYTHON_AVAILABLE", False):
+            result = magics._execute_sql("SELECT 1")
+            assert result is None
+            captured = capsys.readouterr()
+            assert "Query failed" in captured.out
diff --git a/python/python/tests/test_notebook_features.py b/python/python/tests/test_notebook_features.py
new file mode 100644
index 000000000..764d1d6dc
--- /dev/null
+++ b/python/python/tests/test_notebook_features.py
@@ -0,0 +1,233 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for notebook-specific features in Ballista."""
+
+import pytest
+import tempfile
+import os
+from ballista import (
+    BallistaSessionContext,
+    setup_test_cluster,
+    ExecutionPlanVisualization,
+)
+
+
+@pytest.fixture
+def ctx():
+    """Create a connected Ballista context for testing."""
+    (address, port) = setup_test_cluster()
+    return BallistaSessionContext(address=f"df://{address}:{port}")
+
+
+class TestExplainVisual:
+    """Tests for the explain_visual method."""
+
+    def test_explain_visual_returns_visualization(self, ctx):
+        """Test that explain_visual returns an ExecutionPlanVisualization."""
+        df = ctx.sql("SELECT 1 as value")
+        viz = df.explain_visual()
+        assert isinstance(viz, ExecutionPlanVisualization)
+
+    def test_explain_visual_with_analyze(self, ctx):
+        """Test explain_visual with analyze=True."""
+        df = ctx.sql("SELECT 1 as value")
+        viz = df.explain_visual(analyze=True)
+        assert isinstance(viz, ExecutionPlanVisualization)
+        assert viz.analyze is True
+
+    def test_visualization_to_dot(self, ctx):
+        """Test converting visualization to DOT format."""
+        df = ctx.sql("SELECT 1 as value")
+        viz = df.explain_visual()
+        dot = viz.to_dot()
+        assert "digraph" in dot
+        assert "ExecutionPlan" in dot
+
+    def test_visualization_to_svg(self, ctx):
+        """Test converting visualization to SVG or HTML fallback."""
+        df = ctx.sql("SELECT 1 as value")
+        viz = df.explain_visual()
+        svg = viz.to_svg()
+        # Should return either SVG or HTML fallback
+        assert svg is not None
+        assert len(svg) > 0
+
+    def test_visualization_repr_html(self, ctx):
+        """Test HTML representation for Jupyter."""
+        df = ctx.sql("SELECT 1 as value")
+        viz = df.explain_visual()
+        html = viz._repr_html_()
+        assert html is not None
+        assert len(html) > 0
+
+    def test_visualization_repr(self, ctx):
+        """Test string representation."""
+        df = ctx.sql("SELECT 1 as value")
+        viz = df.explain_visual()
+        s = repr(viz)
+        assert "ExecutionPlanVisualization" in s
+
+    def test_visualization_save_dot(self, ctx):
+        """Test saving visualization as DOT file."""
+        df = ctx.sql("SELECT 1 as value")
+        viz = df.explain_visual()
+
+        with tempfile.NamedTemporaryFile(suffix=".dot", delete=False) as f:
+            path = f.name
+
+        try:
+            viz.save(path)
+            with open(path) as f:
+                content = f.read()
+            assert "digraph" in content
+        finally:
+            os.unlink(path)
+
+    def test_visualization_save_svg(self, ctx):
+        """Test saving visualization as SVG file."""
+        df = ctx.sql("SELECT 1 as value")
+        viz = df.explain_visual()
+
+        with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as f:
+            path = f.name
+
+        try:
+            viz.save(path)
+            with open(path) as f:
+                content = f.read()
+            # Should have some content (either SVG or HTML fallback)
+            assert len(content) > 0
+        finally:
+            os.unlink(path)
+
+
+class TestExecutionPlanVisualization:
+    """Tests for ExecutionPlanVisualization class."""
+
+    def test_creation(self):
+        """Test creating a visualization from plan string."""
+        plan_str = """
+        LogicalPlan
+          Projection: a, b
+            Filter: a > 1
+              TableScan: test
+        """
+        viz = ExecutionPlanVisualization(plan_str)
+        assert viz.plan_str == plan_str
+        assert viz.analyze is False
+
+    def test_creation_with_analyze(self):
+        """Test creating a visualization with analyze flag."""
+        plan_str = "LogicalPlan\n  Projection: a"
+        viz = ExecutionPlanVisualization(plan_str, analyze=True)
+        assert viz.analyze is True
+
+    def test_dot_generation(self):
+        """Test DOT format generation."""
+        plan_str = """
+LogicalPlan
+  Projection: a, b
+    Filter: a > 1
+      TableScan: test
+        """
+        viz = ExecutionPlanVisualization(plan_str)
+        dot = viz.to_dot()
+
+        assert "digraph ExecutionPlan" in dot
+        assert "node" in dot
+        assert "->" in dot  # Should have edges
+
+    def test_svg_caching(self):
+        """Test that SVG is cached after first generation."""
+        viz = ExecutionPlanVisualization("Test plan")
+        svg1 = viz.to_svg()
+        svg2 = viz.to_svg()
+        assert svg1 == svg2  # Should be same cached result
+
+
+class TestCollectWithProgress:
+    """Tests for the collect_with_progress method."""
+
+    def test_collect_with_progress_returns_batches(self, ctx):
+        """Test that collect_with_progress returns batches."""
+        df = ctx.sql("SELECT 1 as value")
+        batches = df.collect_with_progress()
+        assert batches is not None
+        assert len(batches) > 0
+
+    def test_collect_with_progress_callback(self, ctx):
+        """Test collect_with_progress with callback."""
+        df = ctx.sql("SELECT 1 as value")
+        callback_calls = []
+
+        def callback(status, progress):
+            callback_calls.append((status, progress))
+
+        batches = df.collect_with_progress(callback=callback)
+        assert batches is not None
+        # Callback should have been called at least once for completion
+        assert len(callback_calls) > 0
+
+    def test_collect_with_progress_matches_collect(self, ctx):
+        """Test that collect_with_progress returns same data as collect."""
+        df = ctx.sql("SELECT 1 as value")
+        batches_progress = df.collect_with_progress()
+        batches_normal = df.collect()
+
+        assert len(batches_progress) == len(batches_normal)
+        for bp, bn in zip(batches_progress, batches_normal):
+            assert len(bp) == len(bn)
+
+
+class TestBallistaSessionContextTables:
+    """Tests for the BallistaSessionContext.tables() method."""
+
+    def test_tables_empty(self, ctx):
+        """Test listing tables when none registered."""
+        # Should return empty list or at least not crash
+        tables = ctx.tables()
+        assert isinstance(tables, set)
+
+    def test_tables_after_register_parquet(self, ctx):
+        """Test listing tables after registering a Parquet file."""
+        ctx.register_parquet("test_parquet", "testdata/test.parquet")
+        tables = ctx.tables()
+        # The table might be in the list depending on catalog implementation
+        assert isinstance(tables, set)
+
+    def test_tables_after_register_csv(self, ctx):
+        """Test listing tables after registering a CSV file."""
+        ctx.register_csv("test_csv", "testdata/test.csv", has_header=True)
+        tables = ctx.tables()
+        assert isinstance(tables, set)
+
+
+class TestHTMLRendering:
+    """Tests for HTML rendering in notebooks."""
+
+    def test_dataframe_repr_html(self, ctx):
+        """Test that DataFrame has _repr_html_ method."""
+        df = ctx.sql("SELECT 1 as value")
+        assert hasattr(df, "_repr_html_")
+
+    def test_dataframe_repr_html_returns_html(self, ctx):
+        """Test that _repr_html_ returns valid HTML."""
+        df = ctx.sql("SELECT 1 as value")
+        html = df._repr_html_()
+        # The HTML should contain table tags or similar
+        assert html is not None
diff --git a/python/uv.lock b/python/uv.lock
index f032aae84..bcc52920b 100644
--- a/python/uv.lock
+++ b/python/uv.lock
@@ -1,9 +1,19 @@
 version = 1
 revision = 3
-requires-python = ">=3.9"
+requires-python = ">=3.10"
 resolution-markers = [
-    "python_full_version >= '3.10'",
-    "python_full_version < '3.10'",
+    "python_full_version >= '3.14'",
+    "python_full_version >= '3.11' and python_full_version < '3.14'",
+    "python_full_version < '3.11'",
+]
+
+[[package]]
+name = "asttokens"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
 ]
 
 [[package]]
@@ -20,29 +30,34 @@ name = "ballista"
 source = { editable = "." }
 dependencies = [
     { name = "datafusion" },
-    { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
-    { name = "pyarrow", version = "23.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+    { name = "pyarrow" },
     { name = "typing-extensions", marker = "python_full_version < '3.13'" },
 ]
 
+[package.optional-dependencies]
+jupyter = [
+    { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+    { name = "ipython", version = "9.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+]
+
 [package.dev-dependencies]
 dev = [
     { name = "maturin" },
     { name = "pygithub" },
-    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
-    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
-    { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
-    { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+    { name = "pytest" },
+    { name = "pytest-asyncio" },
     { name = "ruff" },
     { name = "toml" },
 ]
 
 [package.metadata]
 requires-dist = [
-    { name = "datafusion", specifier = "==49" },
+    { name = "datafusion", specifier = "==51" },
+    { name = "ipython", marker = "extra == 'jupyter'", specifier = ">=8.0.0" },
     { name = "pyarrow", specifier = ">=21.0.0" },
     { name = "typing-extensions", marker = "python_full_version < '3.13'" },
 ]
+provides-extras = ["jupyter"]
 
 [package.metadata.requires-dev]
 dev = [
@@ -144,18 +159,6 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
     { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
     { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
-    { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" },
-    { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" },
-    { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" },
-    { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" },
-    { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" },
-    { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" },
-    { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" },
-    { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" },
-    { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" },
-    { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" },
-    { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" },
-    { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" },
 ]
 
 [[package]]
@@ -244,22 +247,6 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
     { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
     { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
-    { url = "https://files.pythonhosted.org/packages/46/7c/0c4760bccf082737ca7ab84a4c2034fcc06b1f21cf3032ea98bd6feb1725/charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", size = 209609, upload-time = "2025-10-14T04:42:10.922Z" },
-    { url = "https://files.pythonhosted.org/packages/bb/a4/69719daef2f3d7f1819de60c9a6be981b8eeead7542d5ec4440f3c80e111/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", size = 149029, upload-time = "2025-10-14T04:42:12.38Z" },
-    { url = "https://files.pythonhosted.org/packages/e6/21/8d4e1d6c1e6070d3672908b8e4533a71b5b53e71d16828cc24d0efec564c/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608", size = 144580, upload-time = "2025-10-14T04:42:13.549Z" },
-    { url = "https://files.pythonhosted.org/packages/a7/0a/a616d001b3f25647a9068e0b9199f697ce507ec898cacb06a0d5a1617c99/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", size = 162340, upload-time = "2025-10-14T04:42:14.892Z" },
-    { url = "https://files.pythonhosted.org/packages/85/93/060b52deb249a5450460e0585c88a904a83aec474ab8e7aba787f45e79f2/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", size = 159619, upload-time = "2025-10-14T04:42:16.676Z" },
-    { url = "https://files.pythonhosted.org/packages/dd/21/0274deb1cc0632cd587a9a0ec6b4674d9108e461cb4cd40d457adaeb0564/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", size = 153980, upload-time = "2025-10-14T04:42:17.917Z" },
-    { url = "https://files.pythonhosted.org/packages/28/2b/e3d7d982858dccc11b31906976323d790dded2017a0572f093ff982d692f/charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", size = 152174, upload-time = "2025-10-14T04:42:19.018Z" },
-    { url = "https://files.pythonhosted.org/packages/6e/ff/4a269f8e35f1e58b2df52c131a1fa019acb7ef3f8697b7d464b07e9b492d/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", size = 151666, upload-time = "2025-10-14T04:42:20.171Z" },
-    { url = "https://files.pythonhosted.org/packages/da/c9/ec39870f0b330d58486001dd8e532c6b9a905f5765f58a6f8204926b4a93/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", size = 145550, upload-time = "2025-10-14T04:42:21.324Z" },
-    { url = "https://files.pythonhosted.org/packages/75/8f/d186ab99e40e0ed9f82f033d6e49001701c81244d01905dd4a6924191a30/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", size = 163721, upload-time = "2025-10-14T04:42:22.46Z" },
-    { url = "https://files.pythonhosted.org/packages/96/b1/6047663b9744df26a7e479ac1e77af7134b1fcf9026243bb48ee2d18810f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", size = 152127, upload-time = "2025-10-14T04:42:23.712Z" },
-    { url = "https://files.pythonhosted.org/packages/59/78/e5a6eac9179f24f704d1be67d08704c3c6ab9f00963963524be27c18ed87/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", size = 161175, upload-time = "2025-10-14T04:42:24.87Z" },
-    { url = "https://files.pythonhosted.org/packages/e5/43/0e626e42d54dd2f8dd6fc5e1c5ff00f05fbca17cb699bedead2cae69c62f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", size = 155375, upload-time = "2025-10-14T04:42:27.246Z" },
-    { url = "https://files.pythonhosted.org/packages/e9/91/d9615bf2e06f35e4997616ff31248c3657ed649c5ab9d35ea12fce54e380/charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", size = 99692, upload-time = "2025-10-14T04:42:28.425Z" },
-    { url = "https://files.pythonhosted.org/packages/d1/a9/6c040053909d9d1ef4fcab45fddec083aedc9052c10078339b47c8573ea8/charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", size = 107192, upload-time = "2025-10-14T04:42:29.482Z" },
-    { url = "https://files.pythonhosted.org/packages/f0/c6/4fa536b2c0cd3edfb7ccf8469fa0f363ea67b7213a842b90909ca33dd851/charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", size = 100220, upload-time = "2025-10-14T04:42:30.632Z" },
     { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
 ]
 
@@ -339,20 +326,28 @@ wheels = [
 
 [[package]]
 name = "datafusion"
-version = "49.0.0"
+version = "51.0.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
-    { name = "pyarrow", version = "23.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+    { name = "pyarrow" },
     { name = "typing-extensions", marker = "python_full_version < '3.13'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/1b/81/eb7f69e184eba6f8fc748f09aa6ac39a50a4aa54e66e1529769d68d7c9e4/datafusion-49.0.0.tar.gz", hash = "sha256:f3ba4f00d56a199a90df145f43667131dbba9aea9b170cc61cd3663241a74a94", size = 183558, upload-time = "2025-08-29T01:39:45.889Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/6d/d0e2632c93bbcca0687eeda672af3f92042ecd349df7be55da86253594a9/datafusion-51.0.0.tar.gz", hash = "sha256:1887c7d5ed3ae5d9f389e62ba869864afad4006a3f7c99ef0ca4707782a7838f", size = 193751, upload-time = "2026-01-09T13:23:41.562Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/02/16/a1438058d784deea42105b869323007cf9dd8a52dbb4551a9ef23967a235/datafusion-49.0.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bb419810c7b1032ec4a374260d289279c5911e3fcdcfb57c9513b6d256fe0330", size = 26455326, upload-time = "2025-08-29T01:39:19.759Z" },
-    { url = "https://files.pythonhosted.org/packages/83/8d/d906b27b61838002b1cf0880eaa75c741409f0f6386192f2fabee24684af/datafusion-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:95d82ceda75767714629c1b5572a41bc919066156163d1ca3777418ffdbf4916", size = 23623419, upload-time = "2025-08-29T01:39:26.613Z" },
-    { url = "https://files.pythonhosted.org/packages/66/4e/2a113bef5e51e56f7273f193959a80e4abd48a2ff6856cfa219dbba85600/datafusion-49.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e093a8d8cc68bf48376c2f553dcec35829be3ea2c9557516fcf93d2bf4695593", size = 29392602, upload-time = "2025-08-29T01:39:32.053Z" },
-    { url = "https://files.pythonhosted.org/packages/ea/80/f49809e61f28343f303059572d9fdfedcb6f08653f34c057c2018d98ab5e/datafusion-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7e9286f92f9f292a57873f8b83f10796decba98f4c022e92d929511f68e2d79a", size = 27505100, upload-time = "2025-08-29T01:39:36.398Z" },
-    { url = "https://files.pythonhosted.org/packages/f6/d7/54f1d73a9e8b084965ea40531988ca25e57f7f5c77560e198483b1c7bf25/datafusion-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:2079a2ec6e73820a21d6f8d466a1410a94f8ef3171eba2cd2461790fc0437c91", size = 28352189, upload-time = "2025-08-29T01:39:43.546Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/a9/7717cec053a3309be3020fe3147e3f76e5bf21295fa8adf9b52dd44ea3ff/datafusion-51.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0c0d265fe3ee0dcbfa7cc3c64c7cd94fc493f38418bd79debb7ec29f29b7176e", size = 30389413, upload-time = "2026-01-09T13:23:23.266Z" },
+    { url = "https://files.pythonhosted.org/packages/55/45/72c9874fd3740a4cb9d55049fdbae0df512dc5433e9f1176f3cfd970f1a1/datafusion-51.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:43e6011db86e950bf9a21ed73cc089c2346b340a41a4f1044268af6c3a357acc", size = 26982206, upload-time = "2026-01-09T13:23:27.437Z" },
+    { url = "https://files.pythonhosted.org/packages/21/ac/b32ba1f25d38fc16e7623cc4bfb7bd68db61be2ef27b2d9969ea5c865765/datafusion-51.0.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e76803907150159aa059d5cc9291645bbaac1b6a46d07e56035118d327b741ae", size = 33246117, upload-time = "2026-01-09T13:23:30.981Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/4e/437121422ef010690fc3cdd7f080203e986ba00e0e3c3b577e03f5b54ca2/datafusion-51.0.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9d0cfabfe1853994adc2e6e9da5f36c1eb061102e34a2f1101fa935c6991c9e1", size = 31421867, upload-time = "2026-01-09T13:23:34.436Z" },
+    { url = "https://files.pythonhosted.org/packages/db/fc/58cf27fcb85b2fd2a698253ae46213b1cbda784407e205c148f4006c1429/datafusion-51.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:fd5f9abfd6669062debf0658d13e4583234c89d4df95faf381927b11cea411f5", size = 32517679, upload-time = "2026-01-09T13:23:39.615Z" },
+]
+
+[[package]]
+name = "decorator"
+version = "5.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
 ]
 
 [[package]]
@@ -372,13 +367,22 @@ name = "exceptiongroup"
 version = "1.3.1"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
-    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
 wheels = [
     { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
 ]
 
+[[package]]
+name = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
 [[package]]
 name = "idna"
 version = "3.11"
@@ -390,26 +394,98 @@ wheels = [
 
 [[package]]
 name = "iniconfig"
-version = "2.1.0"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "ipython"
+version = "8.38.0"
 source = { registry = "https://pypi.org/simple" }
 resolution-markers = [
-    "python_full_version < '3.10'",
+    "python_full_version < '3.11'",
+]
+dependencies = [
+    { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
+    { name = "decorator", marker = "python_full_version < '3.11'" },
+    { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+    { name = "jedi", marker = "python_full_version < '3.11'" },
+    { name = "matplotlib-inline", marker = "python_full_version < '3.11'" },
+    { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+    { name = "prompt-toolkit", marker = "python_full_version < '3.11'" },
+    { name = "pygments", marker = "python_full_version < '3.11'" },
+    { name = "stack-data", marker = "python_full_version < '3.11'" },
+    { name = "traitlets", marker = "python_full_version < '3.11'" },
+    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/df/db59624f4c71b39717c423409950ac3f2c8b2ce4b0aac843112c7fb3f721/ipython-8.38.0-py3-none-any.whl", hash = "sha256:750162629d800ac65bb3b543a14e7a74b0e88063eac9b92124d4b2aa3f6d8e86", size = 831813, upload-time = "2026-01-05T10:59:04.239Z" },
 ]
 
 [[package]]
-name = "iniconfig"
-version = "2.3.0"
+name = "ipython"
+version = "9.9.0"
 source = { registry = "https://pypi.org/simple" }
 resolution-markers = [
-    "python_full_version >= '3.10'",
+    "python_full_version >= '3.14'",
+    "python_full_version >= '3.11' and python_full_version < '3.14'",
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+dependencies = [
+    { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" },
+    { name = "decorator", marker = "python_full_version >= '3.11'" },
+    { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" },
+    { name = "jedi", marker = "python_full_version >= '3.11'" },
+    { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" },
+    { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
+    { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" },
+    { name = "pygments", marker = "python_full_version >= '3.11'" },
+    { name = "stack-data", marker = "python_full_version >= '3.11'" },
+    { name = "traitlets", marker = "python_full_version >= '3.11'" },
+    { name = "typing-extensions", marker = "python_full_version == '3.11.*'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/dd/fb08d22ec0c27e73c8bc8f71810709870d51cadaf27b7ddd3f011236c100/ipython-9.9.0.tar.gz", hash = "sha256:48fbed1b2de5e2c7177eefa144aba7fcb82dac514f09b57e2ac9da34ddb54220", size = 4425043, upload-time = "2026-01-05T12:36:46.233Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+    { url = "https://files.pythonhosted.org/packages/86/92/162cfaee4ccf370465c5af1ce36a9eacec1becb552f2033bb3584e6f640a/ipython-9.9.0-py3-none-any.whl", hash = "sha256:b457fe9165df2b84e8ec909a97abcf2ed88f565970efba16b1f7229c283d252b", size = 621431, upload-time = "2026-01-05T12:36:44.669Z" },
+]
+
+[[package]]
+name = "ipython-pygments-lexers"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pygments", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
+]
+
+[[package]]
+name = "jedi"
+version = "0.19.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "parso" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
+]
+
+[[package]]
+name = "matplotlib-inline"
+version = "0.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" },
 ]
 
 [[package]]
@@ -445,6 +521,27 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
 ]
 
+[[package]]
+name = "parso"
+version = "0.8.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" },
+]
+
+[[package]]
+name = "pexpect"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "ptyprocess" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
+]
+
 [[package]]
 name = "pluggy"
 version = "1.6.0"
@@ -455,65 +552,39 @@ wheels = [
 ]
 
 [[package]]
-name = "pyarrow"
-version = "21.0.0"
+name = "prompt-toolkit"
+version = "3.0.52"
 source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
-    "python_full_version < '3.10'",
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ef/c2/ea068b8f00905c06329a3dfcd40d0fcc2b7d0f2e355bdb25b65e0a0e4cd4/pyarrow-21.0.0.tar.gz", hash = "sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc", size = 1133487, upload-time = "2025-07-18T00:57:31.761Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/17/d9/110de31880016e2afc52d8580b397dbe47615defbf09ca8cf55f56c62165/pyarrow-21.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e563271e2c5ff4d4a4cbeb2c83d5cf0d4938b891518e676025f7268c6fe5fe26", size = 31196837, upload-time = "2025-07-18T00:54:34.755Z" },
-    { url = "https://files.pythonhosted.org/packages/df/5f/c1c1997613abf24fceb087e79432d24c19bc6f7259cab57c2c8e5e545fab/pyarrow-21.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79", size = 32659470, upload-time = "2025-07-18T00:54:38.329Z" },
-    { url = "https://files.pythonhosted.org/packages/3e/ed/b1589a777816ee33ba123ba1e4f8f02243a844fed0deec97bde9fb21a5cf/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7be45519b830f7c24b21d630a31d48bcebfd5d4d7f9d3bdb49da9cdf6d764edb", size = 41055619, upload-time = "2025-07-18T00:54:42.172Z" },
-    { url = "https://files.pythonhosted.org/packages/44/28/b6672962639e85dc0ac36f71ab3a8f5f38e01b51343d7aa372a6b56fa3f3/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:26bfd95f6bff443ceae63c65dc7e048670b7e98bc892210acba7e4995d3d4b51", size = 42733488, upload-time = "2025-07-18T00:54:47.132Z" },
-    { url = "https://files.pythonhosted.org/packages/f8/cc/de02c3614874b9089c94eac093f90ca5dfa6d5afe45de3ba847fd950fdf1/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd04ec08f7f8bd113c55868bd3fc442a9db67c27af098c5f814a3091e71cc61a", size = 43329159, upload-time = "2025-07-18T00:54:51.686Z" },
-    { url = "https://files.pythonhosted.org/packages/a6/3e/99473332ac40278f196e105ce30b79ab8affab12f6194802f2593d6b0be2/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9b0b14b49ac10654332a805aedfc0147fb3469cbf8ea951b3d040dab12372594", size = 45050567, upload-time = "2025-07-18T00:54:56.679Z" },
-    { url = "https://files.pythonhosted.org/packages/7b/f5/c372ef60593d713e8bfbb7e0c743501605f0ad00719146dc075faf11172b/pyarrow-21.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d9f8bcb4c3be7738add259738abdeddc363de1b80e3310e04067aa1ca596634", size = 26217959, upload-time = "2025-07-18T00:55:00.482Z" },
-    { url = "https://files.pythonhosted.org/packages/94/dc/80564a3071a57c20b7c32575e4a0120e8a330ef487c319b122942d665960/pyarrow-21.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c077f48aab61738c237802836fc3844f85409a46015635198761b0d6a688f87b", size = 31243234, upload-time = "2025-07-18T00:55:03.812Z" },
-    { url = "https://files.pythonhosted.org/packages/ea/cc/3b51cb2db26fe535d14f74cab4c79b191ed9a8cd4cbba45e2379b5ca2746/pyarrow-21.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:689f448066781856237eca8d1975b98cace19b8dd2ab6145bf49475478bcaa10", size = 32714370, upload-time = "2025-07-18T00:55:07.495Z" },
-    { url = "https://files.pythonhosted.org/packages/24/11/a4431f36d5ad7d83b87146f515c063e4d07ef0b7240876ddb885e6b44f2e/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:479ee41399fcddc46159a551705b89c05f11e8b8cb8e968f7fec64f62d91985e", size = 41135424, upload-time = "2025-07-18T00:55:11.461Z" },
-    { url = "https://files.pythonhosted.org/packages/74/dc/035d54638fc5d2971cbf1e987ccd45f1091c83bcf747281cf6cc25e72c88/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:40ebfcb54a4f11bcde86bc586cbd0272bac0d516cfa539c799c2453768477569", size = 42823810, upload-time = "2025-07-18T00:55:16.301Z" },
-    { url = "https://files.pythonhosted.org/packages/2e/3b/89fced102448a9e3e0d4dded1f37fa3ce4700f02cdb8665457fcc8015f5b/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8d58d8497814274d3d20214fbb24abcad2f7e351474357d552a8d53bce70c70e", size = 43391538, upload-time = "2025-07-18T00:55:23.82Z" },
-    { url = "https://files.pythonhosted.org/packages/fb/bb/ea7f1bd08978d39debd3b23611c293f64a642557e8141c80635d501e6d53/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:585e7224f21124dd57836b1530ac8f2df2afc43c861d7bf3d58a4870c42ae36c", size = 45120056, upload-time = "2025-07-18T00:55:28.231Z" },
-    { url = "https://files.pythonhosted.org/packages/6e/0b/77ea0600009842b30ceebc3337639a7380cd946061b620ac1a2f3cb541e2/pyarrow-21.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:555ca6935b2cbca2c0e932bedd853e9bc523098c39636de9ad4693b5b1df86d6", size = 26220568, upload-time = "2025-07-18T00:55:32.122Z" },
-    { url = "https://files.pythonhosted.org/packages/ca/d4/d4f817b21aacc30195cf6a46ba041dd1be827efa4a623cc8bf39a1c2a0c0/pyarrow-21.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd", size = 31160305, upload-time = "2025-07-18T00:55:35.373Z" },
-    { url = "https://files.pythonhosted.org/packages/a2/9c/dcd38ce6e4b4d9a19e1d36914cb8e2b1da4e6003dd075474c4cfcdfe0601/pyarrow-21.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876", size = 32684264, upload-time = "2025-07-18T00:55:39.303Z" },
-    { url = "https://files.pythonhosted.org/packages/4f/74/2a2d9f8d7a59b639523454bec12dba35ae3d0a07d8ab529dc0809f74b23c/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d", size = 41108099, upload-time = "2025-07-18T00:55:42.889Z" },
-    { url = "https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e", size = 42829529, upload-time = "2025-07-18T00:55:47.069Z" },
-    { url = "https://files.pythonhosted.org/packages/33/27/1a93a25c92717f6aa0fca06eb4700860577d016cd3ae51aad0e0488ac899/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82", size = 43367883, upload-time = "2025-07-18T00:55:53.069Z" },
-    { url = "https://files.pythonhosted.org/packages/05/d9/4d09d919f35d599bc05c6950095e358c3e15148ead26292dfca1fb659b0c/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623", size = 45133802, upload-time = "2025-07-18T00:55:57.714Z" },
-    { url = "https://files.pythonhosted.org/packages/71/30/f3795b6e192c3ab881325ffe172e526499eb3780e306a15103a2764916a2/pyarrow-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18", size = 26203175, upload-time = "2025-07-18T00:56:01.364Z" },
-    { url = "https://files.pythonhosted.org/packages/16/ca/c7eaa8e62db8fb37ce942b1ea0c6d7abfe3786ca193957afa25e71b81b66/pyarrow-21.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e99310a4ebd4479bcd1964dff9e14af33746300cb014aa4a3781738ac63baf4a", size = 31154306, upload-time = "2025-07-18T00:56:04.42Z" },
-    { url = "https://files.pythonhosted.org/packages/ce/e8/e87d9e3b2489302b3a1aea709aaca4b781c5252fcb812a17ab6275a9a484/pyarrow-21.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d2fe8e7f3ce329a71b7ddd7498b3cfac0eeb200c2789bd840234f0dc271a8efe", size = 32680622, upload-time = "2025-07-18T00:56:07.505Z" },
-    { url = "https://files.pythonhosted.org/packages/84/52/79095d73a742aa0aba370c7942b1b655f598069489ab387fe47261a849e1/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd", size = 41104094, upload-time = "2025-07-18T00:56:10.994Z" },
-    { url = "https://files.pythonhosted.org/packages/89/4b/7782438b551dbb0468892a276b8c789b8bbdb25ea5c5eb27faadd753e037/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:69cbbdf0631396e9925e048cfa5bce4e8c3d3b41562bbd70c685a8eb53a91e61", size = 42825576, upload-time = "2025-07-18T00:56:15.569Z" },
-    { url = "https://files.pythonhosted.org/packages/b3/62/0f29de6e0a1e33518dec92c65be0351d32d7ca351e51ec5f4f837a9aab91/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:731c7022587006b755d0bdb27626a1a3bb004bb56b11fb30d98b6c1b4718579d", size = 43368342, upload-time = "2025-07-18T00:56:19.531Z" },
-    { url = "https://files.pythonhosted.org/packages/90/c7/0fa1f3f29cf75f339768cc698c8ad4ddd2481c1742e9741459911c9ac477/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc56bc708f2d8ac71bd1dcb927e458c93cec10b98eb4120206a4091db7b67b99", size = 45131218, upload-time = "2025-07-18T00:56:23.347Z" },
-    { url = "https://files.pythonhosted.org/packages/01/63/581f2076465e67b23bc5a37d4a2abff8362d389d29d8105832e82c9c811c/pyarrow-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:186aa00bca62139f75b7de8420f745f2af12941595bbbfa7ed3870ff63e25636", size = 26087551, upload-time = "2025-07-18T00:56:26.758Z" },
-    { url = "https://files.pythonhosted.org/packages/c9/ab/357d0d9648bb8241ee7348e564f2479d206ebe6e1c47ac5027c2e31ecd39/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:a7a102574faa3f421141a64c10216e078df467ab9576684d5cd696952546e2da", size = 31290064, upload-time = "2025-07-18T00:56:30.214Z" },
-    { url = "https://files.pythonhosted.org/packages/3f/8a/5685d62a990e4cac2043fc76b4661bf38d06efed55cf45a334b455bd2759/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:1e005378c4a2c6db3ada3ad4c217b381f6c886f0a80d6a316fe586b90f77efd7", size = 32727837, upload-time = "2025-07-18T00:56:33.935Z" },
-    { url = "https://files.pythonhosted.org/packages/fc/de/c0828ee09525c2bafefd3e736a248ebe764d07d0fd762d4f0929dbc516c9/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:65f8e85f79031449ec8706b74504a316805217b35b6099155dd7e227eef0d4b6", size = 41014158, upload-time = "2025-07-18T00:56:37.528Z" },
-    { url = "https://files.pythonhosted.org/packages/6e/26/a2865c420c50b7a3748320b614f3484bfcde8347b2639b2b903b21ce6a72/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3a81486adc665c7eb1a2bde0224cfca6ceaba344a82a971ef059678417880eb8", size = 42667885, upload-time = "2025-07-18T00:56:41.483Z" },
-    { url = "https://files.pythonhosted.org/packages/0a/f9/4ee798dc902533159250fb4321267730bc0a107d8c6889e07c3add4fe3a5/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503", size = 43276625, upload-time = "2025-07-18T00:56:48.002Z" },
-    { url = "https://files.pythonhosted.org/packages/5a/da/e02544d6997037a4b0d22d8e5f66bc9315c3671371a8b18c79ade1cefe14/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6299449adf89df38537837487a4f8d3bd91ec94354fdd2a7d30bc11c48ef6e79", size = 44951890, upload-time = "2025-07-18T00:56:52.568Z" },
-    { url = "https://files.pythonhosted.org/packages/e5/4e/519c1bc1876625fe6b71e9a28287c43ec2f20f73c658b9ae1d485c0c206e/pyarrow-21.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:222c39e2c70113543982c6b34f3077962b44fca38c0bd9e68bb6781534425c10", size = 26371006, upload-time = "2025-07-18T00:56:56.379Z" },
-    { url = "https://files.pythonhosted.org/packages/3e/cc/ce4939f4b316457a083dc5718b3982801e8c33f921b3c98e7a93b7c7491f/pyarrow-21.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:a7f6524e3747e35f80744537c78e7302cd41deee8baa668d56d55f77d9c464b3", size = 31211248, upload-time = "2025-07-18T00:56:59.7Z" },
-    { url = "https://files.pythonhosted.org/packages/1f/c2/7a860931420d73985e2f340f06516b21740c15b28d24a0e99a900bb27d2b/pyarrow-21.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:203003786c9fd253ebcafa44b03c06983c9c8d06c3145e37f1b76a1f317aeae1", size = 32676896, upload-time = "2025-07-18T00:57:03.884Z" },
-    { url = "https://files.pythonhosted.org/packages/68/a8/197f989b9a75e59b4ca0db6a13c56f19a0ad8a298c68da9cc28145e0bb97/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:3b4d97e297741796fead24867a8dabf86c87e4584ccc03167e4a811f50fdf74d", size = 41067862, upload-time = "2025-07-18T00:57:07.587Z" },
-    { url = "https://files.pythonhosted.org/packages/fa/82/6ecfa89487b35aa21accb014b64e0a6b814cc860d5e3170287bf5135c7d8/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:898afce396b80fdda05e3086b4256f8677c671f7b1d27a6976fa011d3fd0a86e", size = 42747508, upload-time = "2025-07-18T00:57:13.917Z" },
-    { url = "https://files.pythonhosted.org/packages/3b/b7/ba252f399bbf3addc731e8643c05532cf32e74cebb5e32f8f7409bc243cf/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:067c66ca29aaedae08218569a114e413b26e742171f526e828e1064fcdec13f4", size = 43345293, upload-time = "2025-07-18T00:57:19.828Z" },
-    { url = "https://files.pythonhosted.org/packages/ff/0a/a20819795bd702b9486f536a8eeb70a6aa64046fce32071c19ec8230dbaa/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0c4e75d13eb76295a49e0ea056eb18dbd87d81450bfeb8afa19a7e5a75ae2ad7", size = 45060670, upload-time = "2025-07-18T00:57:24.477Z" },
-    { url = "https://files.pythonhosted.org/packages/10/15/6b30e77872012bbfe8265d42a01d5b3c17ef0ac0f2fae531ad91b6a6c02e/pyarrow-21.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdc4c17afda4dab2a9c0b79148a43a7f4e1094916b3e18d8975bfd6d6d52241f", size = 26227521, upload-time = "2025-07-18T00:57:29.119Z" },
+dependencies = [
+    { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
+]
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
+]
+
+[[package]]
+name = "pure-eval"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
 ]
 
 [[package]]
 name = "pyarrow"
 version = "23.0.0"
 source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
-    "python_full_version >= '3.10'",
-]
 sdist = { url = "https://files.pythonhosted.org/packages/01/33/ffd9c3eb087fa41dd79c3cf20c4c0ae3cdb877c4f8e1107a446006344924/pyarrow-23.0.0.tar.gz", hash = "sha256:180e3150e7edfcd182d3d9afba72f7cf19839a497cc76555a8dce998a8f67615", size = 1167185, upload-time = "2026-01-18T16:19:42.218Z" }
 wheels = [
     { url = "https://files.pythonhosted.org/packages/ae/2f/23e042a5aa99bcb15e794e14030e8d065e00827e846e53a66faec73c7cd6/pyarrow-23.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:cbdc2bf5947aa4d462adcf8453cf04aee2f7932653cb67a27acd96e5e8528a67", size = 34281861, upload-time = "2026-01-18T16:13:34.332Z" },
@@ -651,76 +722,32 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
 ]
 
-[[package]]
-name = "pytest"
-version = "8.4.2"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
-    "python_full_version < '3.10'",
-]
-dependencies = [
-    { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
-    { name = "exceptiongroup", marker = "python_full_version < '3.10'" },
-    { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
-    { name = "packaging", marker = "python_full_version < '3.10'" },
-    { name = "pluggy", marker = "python_full_version < '3.10'" },
-    { name = "pygments", marker = "python_full_version < '3.10'" },
-    { name = "tomli", marker = "python_full_version < '3.10'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
-]
-
 [[package]]
 name = "pytest"
 version = "9.0.2"
 source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
-    "python_full_version >= '3.10'",
-]
 dependencies = [
-    { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
-    { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
-    { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
-    { name = "packaging", marker = "python_full_version >= '3.10'" },
-    { name = "pluggy", marker = "python_full_version >= '3.10'" },
-    { name = "pygments", marker = "python_full_version >= '3.10'" },
-    { name = "tomli", marker = "python_full_version == '3.10.*'" },
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+    { name = "iniconfig" },
+    { name = "packaging" },
+    { name = "pluggy" },
+    { name = "pygments" },
+    { name = "tomli", marker = "python_full_version < '3.11'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
 wheels = [
     { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
 ]
 
-[[package]]
-name = "pytest-asyncio"
-version = "1.2.0"
-source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
-    "python_full_version < '3.10'",
-]
-dependencies = [
-    { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" },
-    { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
-    { name = "typing-extensions", marker = "python_full_version < '3.10'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
-]
-
 [[package]]
 name = "pytest-asyncio"
 version = "1.3.0"
 source = { registry = "https://pypi.org/simple" }
-resolution-markers = [
-    "python_full_version >= '3.10'",
-]
 dependencies = [
-    { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" },
-    { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
-    { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" },
+    { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
+    { name = "pytest" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
 wheels = [
@@ -768,6 +795,20 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" },
 ]
 
+[[package]]
+name = "stack-data"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "asttokens" },
+    { name = "executing" },
+    { name = "pure-eval" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
+]
+
 [[package]]
 name = "toml"
 version = "0.10.2"
@@ -831,6 +872,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
 ]
 
+[[package]]
+name = "traitlets"
+version = "5.14.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
+]
+
 [[package]]
 name = "typing-extensions"
 version = "4.15.0"
@@ -849,6 +899,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
 ]
 
+[[package]]
+name = "wcwidth"
+version = "0.5.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c2/62/a7c072fbfefb2980a00f99ca994279cb9ecf310cb2e6b2a4d2a28fe192b3/wcwidth-0.5.3.tar.gz", hash = "sha256:53123b7af053c74e9fe2e92ac810301f6139e64379031f7124574212fb3b4091", size = 157587, upload-time = "2026-01-31T03:52:10.92Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3c/c1/d73f12f8cdb1891334a2ccf7389eed244d3941e74d80dd220badb937f3fb/wcwidth-0.5.3-py3-none-any.whl", hash = "sha256:d584eff31cd4753e1e5ff6c12e1edfdb324c995713f75d26c29807bb84bf649e", size = 92981, upload-time = "2026-01-31T03:52:09.14Z" },
+]
+
 [[package]]
 name = "wrapt"
 version = "2.0.1"
@@ -939,17 +998,5 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c", size = 60609, upload-time = "2025-11-07T00:45:03.315Z" },
     { url = "https://files.pythonhosted.org/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395", size = 64038, upload-time = "2025-11-07T00:45:00.948Z" },
     { url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" },
-    { url = "https://files.pythonhosted.org/packages/c6/1f/5af0ae22368ec69067a577f9e07a0dd2619a1f63aabc2851263679942667/wrapt-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:68424221a2dc00d634b54f92441914929c5ffb1c30b3b837343978343a3512a3", size = 77478, upload-time = "2025-11-07T00:45:16.65Z" },
-    { url = "https://files.pythonhosted.org/packages/8c/b7/fd6b563aada859baabc55db6aa71b8afb4a3ceb8bc33d1053e4c7b5e0109/wrapt-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd1a18f5a797fe740cb3d7a0e853a8ce6461cc62023b630caec80171a6b8097", size = 60687, upload-time = "2025-11-07T00:45:17.896Z" },
-    { url = "https://files.pythonhosted.org/packages/0f/8c/9ededfff478af396bcd081076986904bdca336d9664d247094150c877dcb/wrapt-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fb3a86e703868561c5cad155a15c36c716e1ab513b7065bd2ac8ed353c503333", size = 61563, upload-time = "2025-11-07T00:45:19.109Z" },
-    { url = "https://files.pythonhosted.org/packages/ab/a7/d795a1aa2b6ab20ca21157fe03cbfc6aa7e870a88ac3b4ea189e2f6c79f0/wrapt-2.0.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5dc1b852337c6792aa111ca8becff5bacf576bf4a0255b0f05eb749da6a1643e", size = 113395, upload-time = "2025-11-07T00:45:21.551Z" },
-    { url = "https://files.pythonhosted.org/packages/61/32/56cde2bbf95f2d5698a1850a765520aa86bc7ae0f95b8ec80b6f2e2049bb/wrapt-2.0.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c046781d422f0830de6329fa4b16796096f28a92c8aef3850674442cdcb87b7f", size = 115362, upload-time = "2025-11-07T00:45:22.809Z" },
-    { url = "https://files.pythonhosted.org/packages/cf/53/8d3cc433847c219212c133a3e8305bd087b386ef44442ff39189e8fa62ac/wrapt-2.0.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f73f9f7a0ebd0db139253d27e5fc8d2866ceaeef19c30ab5d69dcbe35e1a6981", size = 111766, upload-time = "2025-11-07T00:45:20.294Z" },
-    { url = "https://files.pythonhosted.org/packages/b8/d3/14b50c2d0463c0dcef8f388cb1527ed7bbdf0972b9fd9976905f36c77ebf/wrapt-2.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b667189cf8efe008f55bbda321890bef628a67ab4147ebf90d182f2dadc78790", size = 114560, upload-time = "2025-11-07T00:45:24.054Z" },
-    { url = "https://files.pythonhosted.org/packages/3a/b8/4f731ff178f77ae55385586de9ff4b4261e872cf2ced4875e6c976fbcb8b/wrapt-2.0.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:a9a83618c4f0757557c077ef71d708ddd9847ed66b7cc63416632af70d3e2308", size = 110999, upload-time = "2025-11-07T00:45:25.596Z" },
-    { url = "https://files.pythonhosted.org/packages/fe/bb/5f1bb0f9ae9d12e19f1d71993d052082062603e83fe3e978377f918f054d/wrapt-2.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e9b121e9aeb15df416c2c960b8255a49d44b4038016ee17af03975992d03931", size = 113164, upload-time = "2025-11-07T00:45:26.8Z" },
-    { url = "https://files.pythonhosted.org/packages/ad/f6/f3a3c623d3065c7bf292ee0b73566236b562d5ed894891bd8e435762b618/wrapt-2.0.1-cp39-cp39-win32.whl", hash = "sha256:1f186e26ea0a55f809f232e92cc8556a0977e00183c3ebda039a807a42be1494", size = 58028, upload-time = "2025-11-07T00:45:30.943Z" },
-    { url = "https://files.pythonhosted.org/packages/24/78/647c609dfa18063a7fcd5c23f762dd006be401cc9206314d29c9b0b12078/wrapt-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:bf4cb76f36be5de950ce13e22e7fdf462b35b04665a12b64f3ac5c1bbbcf3728", size = 60380, upload-time = "2025-11-07T00:45:28.341Z" },
-    { url = "https://files.pythonhosted.org/packages/07/90/0c14b241d18d80ddf4c847a5f52071e126e8a6a9e5a8a7952add8ef0d766/wrapt-2.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:d6cc985b9c8b235bd933990cdbf0f891f8e010b65a3911f7a55179cd7b0fc57b", size = 58895, upload-time = "2025-11-07T00:45:29.527Z" },
     { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" },
 ]


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org