diff --git a/PANEL_DEPLOYMENT.md b/PANEL_DEPLOYMENT.md new file mode 100644 index 00000000..6ba3c245 --- /dev/null +++ b/PANEL_DEPLOYMENT.md @@ -0,0 +1,225 @@ +# Panel Deployment Guide + +This guide explains how to deploy the Trailpack UI using Panel. + +## Prerequisites + +1. A server or hosting service that supports Python web applications +2. Python 3.12 or higher +3. PyST API credentials (PYST_HOST and PYST_AUTH_TOKEN) + +## Local Development + +### Installation + +Install the package with UI dependencies: + +```bash +pip install -e . +``` + +### Configuration + +Create a `.env` file for local development: + +```bash +cp .env.example .env +# Edit .env with your credentials +``` + +Example `.env` file: + +```bash +PYST_HOST=https://your-pyst-api-server.com +PYST_AUTH_TOKEN=your_secret_token_here +``` + +### Running Locally + +Run the app using the CLI: + +```bash +trailpack ui +``` + +Or directly with Panel: + +```bash +panel serve trailpack/ui/panel_app.py --port 5006 --show +``` + +The app will open in your browser at `http://localhost:5006`. + +## Deployment Options + +### 1. Panel Cloud (Recommended) + +Panel provides cloud hosting at [panel.holoviz.org](https://panel.holoviz.org). + +1. Sign up for a Panel Cloud account +2. Connect your GitHub repository +3. Configure environment variables in the Panel Cloud dashboard +4. Deploy the `trailpack/ui/panel_app.py` file + +### 2. Docker Deployment + +Create a `Dockerfile`: + +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV PYST_HOST=${PYST_HOST} +ENV PYST_AUTH_TOKEN=${PYST_AUTH_TOKEN} + +EXPOSE 5006 + +CMD ["panel", "serve", "trailpack/ui/panel_app.py", "--port", "5006", "--address", "0.0.0.0", "--allow-websocket-origin=*"] +``` + +Build and run: + +```bash +docker build -t trailpack-ui . +docker run -p 5006:5006 -e PYST_HOST=your_host -e PYST_AUTH_TOKEN=your_token trailpack-ui +``` + +### 3. Traditional Server Deployment + +On a Linux server with nginx: + +1. Install dependencies: +```bash +pip install -e . +``` + +2. Create a systemd service (`/etc/systemd/system/trailpack-ui.service`): + +```ini +[Unit] +Description=Trailpack Panel UI +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/path/to/trailpack +Environment="PYST_HOST=your_host" +Environment="PYST_AUTH_TOKEN=your_token" +ExecStart=/usr/bin/panel serve trailpack/ui/panel_app.py --port 5006 --address 0.0.0.0 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +3. Start the service: +```bash +sudo systemctl daemon-reload +sudo systemctl start trailpack-ui +sudo systemctl enable trailpack-ui +``` + +4. Configure nginx as reverse proxy: + +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://localhost:5006; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## Configuration Details + +### Environment Variables + +The application uses the following environment variables: + +- `PYST_HOST`: URL of the PyST API server (required) +- `PYST_AUTH_TOKEN`: Authentication token for the PyST API (required) + +These can be set via: +1. `.env` file (for local development) +2. System environment variables +3. Deployment platform configuration + +### Security Considerations + +**Important Notes:** +- Never commit secrets to version control +- Use environment variables or secure secret management +- Ensure HTTPS is used in production +- Configure appropriate CORS settings for your deployment + +## Troubleshooting + +**Issue: App crashes on startup** +- Verify Python version is 3.12 or higher +- Check that all dependencies are installed: `pip install -e .` +- Verify environment variables are set correctly +- Check application logs for specific error messages + +**Issue: API calls fail** +- Verify PYST_HOST is accessible from your deployment environment +- Check that PYST_AUTH_TOKEN is valid +- Ensure the PyST API server accepts connections from your IP/domain + +**Issue: Module not found errors** +- Ensure the package is installed: `pip install -e .` +- Check that the Python path includes the repository root +- Verify all external dependencies are listed in `pyproject.toml` + +**Issue: WebSocket connection failures** +- Check that your deployment allows WebSocket connections +- Verify the `--allow-websocket-origin` parameter is set correctly +- Ensure your reverse proxy is configured to support WebSocket upgrades + +## Requirements + +The `requirements.txt` file lists all dependencies: + +``` +pyst-client @ git+https://github.com/cauldron/pyst-client.git +langcodes +python-dotenv +httpx +openpyxl +panel>=1.3.0 +pandas>=2.0.0 +pydantic>=2.0.0 +pyyaml +``` + +## Advantages of Panel over Streamlit + +Panel offers several benefits for maintainability: + +1. **More flexible architecture**: Panel apps can be served as standalone web applications +2. **Better integration with HoloViz ecosystem**: Works seamlessly with Bokeh, HoloViews, and other viz libraries +3. **More deployment options**: Can be embedded in notebooks, deployed as dashboards, or served as web apps +4. **Greater control over layout and styling**: More programmatic control over UI components +5. **Better performance**: More efficient WebSocket handling and rendering + +## Support + +For issues related to: +- **Trailpack**: Open an issue on GitHub +- **Panel**: Check https://panel.holoviz.org/ +- **PyST API**: Contact your PyST API provider diff --git a/README.md b/README.md index b3285d20..a2615fcf 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ $ pip install trailpack ### Web Application -The easiest way to use Trailpack is through the **[web application](https://trailpack.streamlit.app/)**. +The easiest way to use Trailpack is through the **web application**. The web app provides a step-by-step workflow: 1. **Upload File & Select Language**: Upload an Excel file and select language for PyST mapping @@ -61,15 +61,17 @@ For walkthrough videos demonstrating the workflow, see the [documentation](https ### Local Web UI -You can also run the Streamlit UI locally: +You can also run the Panel UI locally: ```console $ trailpack ui ``` +The UI will open in your browser at `http://localhost:5006`. + For more details, see [trailpack/ui/README.md](trailpack/ui/README.md). -**Deploying to Streamlit Cloud?** See [STREAMLIT_DEPLOYMENT.md](STREAMLIT_DEPLOYMENT.md) for complete deployment instructions. +**Deploying the web UI?** See [PANEL_DEPLOYMENT.md](PANEL_DEPLOYMENT.md) for complete deployment instructions. ## 📦 DataPackage Schema Classes diff --git a/STREAMLIT_DEPLOYMENT.md b/STREAMLIT_DEPLOYMENT.md deleted file mode 100644 index 5aa84276..00000000 --- a/STREAMLIT_DEPLOYMENT.md +++ /dev/null @@ -1,122 +0,0 @@ -# Streamlit Cloud Deployment Guide - -This guide explains how to deploy the Trailpack UI to Streamlit Cloud. - -## Prerequisites - -1. A GitHub account with access to this repository -2. A Streamlit Cloud account (free tier available at https://share.streamlit.io) -3. PyST API credentials (PYST_HOST and PYST_AUTH_TOKEN) - -## Deployment Steps - -### 1. Prepare Your Repository - -Ensure your repository is pushed to GitHub: - -```bash -git push origin main -``` - -### 2. Create a New App on Streamlit Cloud - -1. Go to https://share.streamlit.io -2. Click "New app" -3. Select your repository: `TimoDiepers/trailpack` -4. Set the branch (e.g., `main` or your deployment branch) -5. Set the main file path: `trailpack/ui/streamlit_app.py` -6. Click "Advanced settings" - -### 3. Configure Secrets - -In the "Secrets" section, add your PyST API credentials in TOML format: - -```toml -PYST_HOST = "https://your-pyst-api-server.com" -PYST_AUTH_TOKEN = "your_secret_token_here" -``` - -**Important Notes:** -- Do NOT include these secrets in your code or repository -- The config system automatically detects Streamlit Cloud and uses `st.secrets` -- For local development, use a `.env` file instead (see `.env.example`) - -### 4. Deploy - -Click "Deploy" and wait for the app to build and start. - -## Configuration Details - -### How Configuration Loading Works - -The application uses a smart configuration system that: - -1. **On Streamlit Cloud**: Automatically loads from `st.secrets` -2. **Locally**: Falls back to environment variables or `.env` file -3. **Lazy Loading**: Secrets are loaded when first accessed, not at import time - -This ensures compatibility with both local development and cloud deployment. - -### Troubleshooting - -**Issue: App crashes on startup** -- Check that your secrets are correctly formatted in TOML -- Verify PYST_HOST includes the protocol (http:// or https://) -- Check Streamlit Cloud logs for specific error messages - -**Issue: API calls fail** -- Verify your PYST_HOST is accessible from Streamlit Cloud -- Check that PYST_AUTH_TOKEN is valid -- Ensure the PyST API server accepts connections from Streamlit Cloud's IP range - -**Issue: Module not found errors** -- The app includes automatic path configuration to find trailpack modules -- Verify all external dependencies are listed in `requirements.txt` -- Check that there are no circular dependencies -- Review Streamlit Cloud build logs - -## Local Development vs. Cloud Deployment - -### Local Development - -Create a `.env` file: - -```bash -cp .env.example .env -# Edit .env with your credentials -``` - -Run the app: - -```bash -streamlit run trailpack/ui/streamlit_app.py -``` - -### Cloud Deployment - -- No `.env` file needed -- Configure secrets through Streamlit Cloud dashboard -- App automatically uses cloud configuration - -## Requirements - -The `requirements.txt` file at the repository root lists all dependencies: - -``` -pyst-client @ git+https://github.com/cauldron/pyst-client.git -langcodes -python-dotenv -httpx -openpyxl -streamlit>=1.28.0 -pandas>=2.0.0 -``` - -**Note**: The package code is deployed directly, so `requirements.txt` does NOT include the trailpack package itself. The streamlit app automatically adds the repository root to Python's import path to ensure all trailpack modules can be imported. - -## Support - -For issues related to: -- **Trailpack**: Open an issue on GitHub -- **Streamlit Cloud**: Check https://docs.streamlit.io/streamlit-community-cloud -- **PyST API**: Contact your PyST API provider diff --git a/pyproject.toml b/pyproject.toml index e2e5f319..429fffab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "langcodes", "httpx", "openpyxl", - "streamlit>=1.28.0", + "panel>=1.3.0", "pandas>=2.0.0", "polars>=0.20.0", "pyarrow>=14.0.0", diff --git a/requirements.txt b/requirements.txt index f8073720..944bf6fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ langcodes python-dotenv httpx openpyxl -streamlit>=1.28.0 +panel>=1.3.0 pandas>=2.0.0 pydantic>=2.0.0 pyyaml \ No newline at end of file diff --git a/tests/test_streamlit_helpers.py b/tests/test_panel_helpers.py similarity index 90% rename from tests/test_streamlit_helpers.py rename to tests/test_panel_helpers.py index fd50d568..81ab2b54 100644 --- a/tests/test_streamlit_helpers.py +++ b/tests/test_panel_helpers.py @@ -1,9 +1,9 @@ -"""Tests for Streamlit UI helper functions. +"""Tests for Panel UI helper functions. Note: These tests duplicate function implementations instead of importing from -streamlit_app.py because the Streamlit app has Streamlit-specific imports that +panel_app.py because the Panel app has Panel-specific imports that would fail in the test environment. This is an acceptable trade-off for testing -core logic without requiring a full Streamlit environment. +core logic without requiring a full Panel environment. """ import pytest @@ -13,7 +13,7 @@ def extract_first_word(query: str) -> str: """ Extract the first word from a string, stopping at the first space. - This duplicates the implementation in streamlit_app.py for testing purposes. + This duplicates the implementation in panel_app.py for testing purposes. """ if not query: return "" @@ -24,7 +24,7 @@ def extract_first_word(query: str) -> str: def sanitize_search_query(query: str) -> str: """ Sanitize search query for safe API calls. - This duplicates the implementation in streamlit_app.py for testing purposes. + This duplicates the implementation in panel_app.py for testing purposes. """ # Replace forward slashes, backslashes, and other special characters with spaces # Keep alphanumeric, spaces, hyphens, underscores, and periods diff --git a/tests/test_pyst_client.py b/tests/test_pyst_client.py index 194e14cd..9a2830af 100644 --- a/tests/test_pyst_client.py +++ b/tests/test_pyst_client.py @@ -97,26 +97,32 @@ async def test_get_concept_handles_http_errors(): await client.get_concept("http://example.com/nonexistent") -def test_fetch_concept_sync_extracts_definition_correctly(): - """Test that fetch_concept_sync extracts SKOS definition correctly.""" - from trailpack.ui.streamlit_app import fetch_concept_sync +def test_fetch_concept_async_extracts_definition_correctly(): + """Test that fetch_concept_async extracts SKOS definition correctly.""" + import asyncio + from trailpack.ui.panel_app import fetch_concept_async + from trailpack.pyst.api.client import get_suggest_client # Mock the async function mock_definition = "This is a test definition" + + mock_client = AsyncMock() + mock_concept_data = { + "http://www.w3.org/2004/02/skos/core#definition": [ + {"@language": "en", "@value": mock_definition} + ] + } + mock_client.get_concept.return_value = mock_concept_data - with patch("trailpack.ui.streamlit_app.fetch_concept_async") as mock_fetch: - mock_fetch.return_value = mock_definition - - result = fetch_concept_sync("http://example.com/concept", "en") + result = asyncio.run(fetch_concept_async(mock_client, "http://example.com/concept", "en")) - assert result == mock_definition + assert result == mock_definition def test_fetch_concept_async_extracts_english_definition(): """Test that fetch_concept_async extracts definition in requested language.""" import asyncio - from trailpack.ui.streamlit_app import fetch_concept_async - from trailpack.pyst.api.client import get_suggest_client + from trailpack.ui.panel_app import fetch_concept_async # Mock response with multiple language definitions mock_concept_data = { @@ -127,22 +133,18 @@ def test_fetch_concept_async_extracts_english_definition(): ] } - # Mock the client's get_concept method - with patch.object( - get_suggest_client(), "get_concept", new_callable=AsyncMock - ) as mock_get: - mock_get.return_value = mock_concept_data + mock_client = AsyncMock() + mock_client.get_concept.return_value = mock_concept_data - # Test English - result = asyncio.run(fetch_concept_async("http://example.com/concept", "en")) - assert result == "English definition" + # Test English + result = asyncio.run(fetch_concept_async(mock_client, "http://example.com/concept", "en")) + assert result == "English definition" def test_fetch_concept_async_falls_back_to_first_definition(): """Test fetch_concept_async falls back to first definition if language not found.""" import asyncio - from trailpack.ui.streamlit_app import fetch_concept_async - from trailpack.pyst.api.client import get_suggest_client + from trailpack.ui.panel_app import fetch_concept_async # Mock response with only German definition mock_concept_data = { @@ -151,22 +153,18 @@ def test_fetch_concept_async_falls_back_to_first_definition(): ] } - # Mock the client's get_concept method - with patch.object( - get_suggest_client(), "get_concept", new_callable=AsyncMock - ) as mock_get: - mock_get.return_value = mock_concept_data + mock_client = AsyncMock() + mock_client.get_concept.return_value = mock_concept_data - # Request English but only German available - should return German - result = asyncio.run(fetch_concept_async("http://example.com/concept", "en")) - assert result == "Deutsche Definition" + # Request English but only German available - should return German + result = asyncio.run(fetch_concept_async(mock_client, "http://example.com/concept", "en")) + assert result == "Deutsche Definition" def test_fetch_concept_async_returns_none_if_no_definition(): """Test that fetch_concept_async returns None if no definition exists.""" import asyncio - from trailpack.ui.streamlit_app import fetch_concept_async - from trailpack.pyst.api.client import get_suggest_client + from trailpack.ui.panel_app import fetch_concept_async # Mock response without definition mock_concept_data = { @@ -176,27 +174,20 @@ def test_fetch_concept_async_returns_none_if_no_definition(): ], } - # Mock the client's get_concept method - with patch.object( - get_suggest_client(), "get_concept", new_callable=AsyncMock - ) as mock_get: - mock_get.return_value = mock_concept_data + mock_client = AsyncMock() + mock_client.get_concept.return_value = mock_concept_data - result = asyncio.run(fetch_concept_async("http://example.com/concept", "en")) - assert result is None + result = asyncio.run(fetch_concept_async(mock_client, "http://example.com/concept", "en")) + assert result is None def test_fetch_concept_async_handles_errors_gracefully(): """Test that fetch_concept_async handles errors gracefully.""" import asyncio - from trailpack.ui.streamlit_app import fetch_concept_async - from trailpack.pyst.api.client import get_suggest_client + from trailpack.ui.panel_app import fetch_concept_async - # Mock the client's get_concept method to raise an exception - with patch.object( - get_suggest_client(), "get_concept", new_callable=AsyncMock - ) as mock_get: - mock_get.side_effect = Exception("API error") + mock_client = AsyncMock() + mock_client.get_concept.side_effect = Exception("API error") - result = asyncio.run(fetch_concept_async("http://example.com/concept", "en")) - assert result is None + result = asyncio.run(fetch_concept_async(mock_client, "http://example.com/concept", "en")) + assert result is None diff --git a/trailpack/cli.py b/trailpack/cli.py index 57811ec3..31b175c0 100644 --- a/trailpack/cli.py +++ b/trailpack/cli.py @@ -26,13 +26,13 @@ @app.command() def ui( - port: int = typer.Option(8501, "--port", "-p", help="Port to run Streamlit on"), + port: int = typer.Option(5006, "--port", "-p", help="Port to run Panel on"), host: str = typer.Option("localhost", "--host", help="Host to bind to"), ): """ - Launch the Streamlit UI for interactive data mapping. + Launch the Panel UI for interactive data mapping. - Opens a browser at localhost:8501 with the full interactive interface + Opens a browser at localhost:5006 with the full interactive interface for mapping columns to ontologies and exporting data packages. Example: @@ -42,31 +42,36 @@ def ui( import subprocess from pathlib import Path - # Get the path to streamlit_app.py - app_path = Path(__file__).parent / "ui" / "streamlit_app.py" + # Get the path to panel_app.py + app_path = Path(__file__).parent / "ui" / "panel_app.py" if not app_path.exists(): - console.print(f"[red]Error: Could not find streamlit_app.py at {app_path}[/red]") + console.print(f"[red]Error: Could not find panel_app.py at {app_path}[/red]") raise typer.Exit(1) - console.print(f"[green]Starting Streamlit UI on {host}:{port}...[/green]") + console.print(f"[green]Starting Panel UI on {host}:{port}...[/green]") try: subprocess.run( [ - "streamlit", - "run", + sys.executable, + "-m", + "panel", + "serve", str(app_path), - f"--server.port={port}", - f"--server.address={host}", + "--port", + str(port), + "--address", + host, + "--show", ], check=True, ) except subprocess.CalledProcessError as e: - console.print(f"[red]Error launching Streamlit: {e}[/red]") + console.print(f"[red]Error launching Panel: {e}[/red]") raise typer.Exit(1) except KeyboardInterrupt: - console.print("\n[yellow]Streamlit UI stopped[/yellow]") + console.print("\n[yellow]Panel UI stopped[/yellow]") @app.command() diff --git a/trailpack/ui/README.md b/trailpack/ui/README.md index b88fe2dd..1712e358 100644 --- a/trailpack/ui/README.md +++ b/trailpack/ui/README.md @@ -1,6 +1,6 @@ # Trailpack UI -A Streamlit-based web interface for mapping Excel columns to PyST concepts. +A Panel-based web interface for mapping Excel columns to PyST concepts. ## Features @@ -8,26 +8,33 @@ A Streamlit-based web interface for mapping Excel columns to PyST concepts. - **Page 2**: Select sheet from the uploaded Excel file with data preview - **Page 3**: Map columns to PyST concepts with automatic suggestions and dataframe preview - **Page 4**: Enter general details and metadata for the data package +- **Page 5**: Review and download the generated Parquet file ## Running the UI -### Option 1: Using Streamlit directly +### Option 1: Using the CLI command ```bash -streamlit run trailpack/ui/streamlit_app.py +trailpack ui ``` -### Option 2: Using the run script +### Option 2: Using Panel directly ```bash -python trailpack/ui/run_streamlit.py +panel serve trailpack/ui/panel_app.py --show ``` -The UI will be available at http://localhost:8501 +### Option 3: Using the run script + +```bash +python trailpack/ui/run_panel.py +``` + +The UI will be available at http://localhost:5006 ## Requirements -- streamlit >= 1.28.0 +- panel >= 1.3.0 - pandas >= 2.0.0 - openpyxl - httpx @@ -44,8 +51,8 @@ The UI will be available at http://localhost:8501 3. **Map Columns**: For each column in the selected sheet: - View the dataframe preview at the top - See sample values from each column - - View automatic PyST concept suggestions based on the column name - - Select the most appropriate PyST concept mapping + - Search for PyST concept mappings + - Add column descriptions - Continue to general details 4. **General Details**: Provide metadata for the data package: @@ -53,45 +60,17 @@ The UI will be available at http://localhost:8501 - Title, description, and version (optional) - Profile type, keywords, homepage, and repository (optional) - Real-time validation of inputs - - Finish to complete the workflow + - Generate the Parquet file + +5. **Review**: Review the generated Parquet file with embedded metadata and download it. ## Features -- **Smooth Page Transitions**: Uses Streamlit's session state for seamless navigation +- **Page-Based Navigation**: Clear step-by-step workflow with manual page transitions - **Data Preview**: View the first entries of your dataframe before and during mapping -- **Simplified Column Mapping**: Clean, table-like interface for mapping columns -- **Internal View Object**: Mappings are stored internally in the correct format +- **Simplified Column Mapping**: Clean interface for mapping columns - **Progress Indicators**: Visual feedback on current step and completion status - -## Output Format - -The UI internally generates a view object with the following structure: - -```json -{ - "sheet_name": "Sheet1", - "dataset_name": "file_name_Sheet1", - "columns": { - "column_name_1": { - "values": ["value1", "value2", "..."], - "mapping_to_pyst": { - "suggestions": [ - { - "label": "string", - "id": "string" - } - ], - "selected": { - "label": "string", - "id": "string" - } - } - } - } -} -``` - -This view object is stored in `st.session_state.view_object` and can be accessed programmatically. +- **Modern UI**: Built with Panel's FastListTemplate for a professional look ## Configuration @@ -106,28 +85,31 @@ PYST_HOST=https://api.pyst.example.com PYST_AUTH_TOKEN=your_token_here ``` -### Streamlit Cloud Deployment - -When deploying to Streamlit Cloud: +### Cloud Deployment -1. **Repository Setup**: Ensure your repository is pushed to GitHub -2. **Secrets Configuration**: In the Streamlit Cloud dashboard, go to your app settings and add the following secrets: - ```toml - PYST_HOST = "https://your-pyst-api.example.com" - PYST_AUTH_TOKEN = "your_token_here" - ``` -3. **Requirements**: The `requirements.txt` file at the repository root lists all necessary dependencies -4. **App Path**: Set the main file path to `trailpack/ui/streamlit_app.py` +For deployment options, see [PANEL_DEPLOYMENT.md](../../PANEL_DEPLOYMENT.md). -The configuration system automatically detects whether it's running locally (uses `.env` file) or on Streamlit Cloud (uses `st.secrets`). +The configuration system automatically loads from: +1. Environment variables (set via `.env` file or system) +2. Deployment platform secrets/configuration ## Architecture -The UI is built using Streamlit with the following features: +The UI is built using Panel with the following features: -- `streamlit_app.py`: Main application with session state management -- Smooth page transitions using `st.rerun()` +- `panel_app.py`: Main application with state management +- `TrailpackApp` class: Encapsulates all UI logic and state +- Page-based navigation system with manual transitions - Asynchronous API calls for fetching PyST suggestions -- Clean, responsive layout with sidebar navigation +- Responsive layout with sidebar navigation - Data preview on multiple pages -- Internal view object generation (not displayed to user) + +## Why Panel? + +Panel was chosen over Streamlit for better maintainability: + +1. **More flexible architecture**: Panel apps can be served standalone or embedded +2. **Better integration**: Works seamlessly with the HoloViz ecosystem +3. **More deployment options**: Greater flexibility in how and where to deploy +4. **Greater control**: More programmatic control over UI components +5. **Better performance**: More efficient WebSocket handling and rendering diff --git a/trailpack/ui/panel_app.py b/trailpack/ui/panel_app.py new file mode 100644 index 00000000..09ed09e8 --- /dev/null +++ b/trailpack/ui/panel_app.py @@ -0,0 +1,874 @@ +"""Panel UI application for trailpack - Excel to PyST mapper.""" + +import sys +from pathlib import Path + +# Add parent directory to path for deployment +_current_dir = Path(__file__).resolve().parent +_repo_root = _current_dir.parent.parent +if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) + +# Load .env file before importing any trailpack modules +try: + from dotenv import load_dotenv + + env_path = _repo_root / ".env" + if env_path.exists(): + load_dotenv(env_path) + print(f"Loaded .env from: {env_path}") +except ImportError: + print("python-dotenv not installed, skipping .env loading") + +import asyncio +import base64 +import tempfile +import json +from typing import Dict, List, Optional, Any +from datetime import datetime +from urllib.parse import quote + +import panel as pn +import pandas as pd +import openpyxl + +from trailpack.excel import ExcelReader +from trailpack.io.smart_reader import SmartDataReader +from trailpack.pyst.api.requests.suggest import SUPPORTED_LANGUAGES +from trailpack.pyst.api.client import get_suggest_client +from trailpack.packing.datapackage_schema import DataPackageSchema, COMMON_LICENSES +from trailpack.validation import StandardValidator +from trailpack.config import ( + build_mapping_config, + build_metadata_config, + export_mapping_json, + export_metadata_json, + generate_config_filename, +) + +# Initialize Panel extension +pn.extension('tabulator', sizing_mode="stretch_width") + +ICON_PATH = Path(__file__).parent / "icon.svg" +PAGE_ICON = str(ICON_PATH) if ICON_PATH.is_file() else "📦" +LOGO_BASE64 = ( + base64.b64encode(ICON_PATH.read_bytes()).decode("utf-8") + if ICON_PATH.is_file() + else None +) + + +def iri_to_web_url(iri: str, language: str = "en") -> str: + """ + Convert an IRI to a vocab.sentier.dev web page URL. + + Args: + iri: The IRI (e.g., "https://vocab.sentier.dev/Geonames/A") + language: Language code (default: "en") + + Returns: + Web page URL + """ + parts = iri.split("/") + if len(parts) >= 5 and parts[2] == "vocab.sentier.dev": + concept_scheme = "/".join(parts[:4]) + "/" + else: + concept_scheme = "/".join(parts[:3]) + "/" if len(parts) >= 3 else iri + + encoded_iri = quote(iri, safe="") + encoded_scheme = quote(concept_scheme, safe="") + web_url = f"https://vocab.sentier.dev/web/concept/{encoded_iri}" + + return web_url + + +def sanitize_search_query(query: str) -> str: + """ + Sanitize search query for safe API calls. + """ + import re + sanitized = re.sub(r"[^\w\s\-.]", " ", query) + sanitized = re.sub(r"\s+", " ", sanitized) + sanitized = sanitized.strip() + return sanitized + + +def extract_first_word(query: str) -> str: + """ + Extract the first word from a string, stopping at the first space. + """ + if not query: + return "" + parts = query.split(" ", 1) + return parts[0] if parts else "" + + +async def fetch_suggestions_async( + client, column_name: str, language: str +) -> List[Dict[str, str]]: + """Fetch PyST suggestions for a column name.""" + try: + sanitized_query = sanitize_search_query(column_name) + if not sanitized_query: + return [] + + suggestions = await client.suggest(sanitized_query, language) + return suggestions[:5] + except Exception as e: + print(f"Could not fetch suggestions for '{column_name}': {e}") + return [] + + +async def fetch_concept_async(client, iri: str, language: str) -> Optional[str]: + """Fetch concept definition from PyST API.""" + try: + concept = await client.get_concept(iri) + + definitions = concept.get("http://www.w3.org/2004/02/skos/core#definition", []) + + if not definitions: + return None + + for definition in definitions: + if isinstance(definition, dict) and definition.get("@language") == language: + return definition.get("@value") + + if definitions and isinstance(definitions[0], dict): + return definitions[0].get("@value") + + return None + except Exception as e: + print(f"Error fetching concept {iri}: {e}") + return None + + +def load_excel_data(file_path: Path, sheet_name: str) -> pd.DataFrame: + """Load Excel data into a pandas DataFrame using SmartDataReader.""" + try: + smart_reader = SmartDataReader(file_path) + df = smart_reader.read(sheet_name=sheet_name) + return df + except Exception as e: + print(f"Error loading Excel data: {e}") + return None + + +class TrailpackApp: + """Panel-based UI for Trailpack Excel to PyST mapper.""" + + def __init__(self): + self.page = 1 + self.file_bytes = None + self.file_name = None + self.language = "en" + self.temp_path = None + self.reader = None + self.selected_sheet = None + self.df = None + self.column_mappings = {} + self.column_descriptions = {} + self.concept_definitions = {} + self.suggestions_cache = {} + self.general_details = {} + self.resource_name = None + self.resource_name_confirmed = False + self.resource_name_accepted = False + + # Create PyST client once for reuse + self.pyst_client = get_suggest_client() + + # Create main layout + self.main_panel = pn.Column(sizing_mode="stretch_both") + self.update_view() + + def update_view(self): + """Update the main panel based on current page.""" + self.main_panel.clear() + + if self.page == 1: + self.main_panel.append(self.page_1_upload()) + elif self.page == 2: + self.main_panel.append(self.page_2_select_sheet()) + elif self.page == 3: + self.main_panel.append(self.page_3_map_columns()) + elif self.page == 4: + self.main_panel.append(self.page_4_general_details()) + elif self.page == 5: + self.main_panel.append(self.page_5_review()) + + def navigate_to(self, page: int): + """Navigate to a specific page.""" + self.page = page + self.update_view() + + def page_1_upload(self): + """Page 1: File Upload and Language Selection.""" + title = pn.pane.Markdown("# Step 1: Upload File and Select Language") + description = pn.pane.Markdown( + "Upload an Excel file and select the language for PyST concept mapping." + ) + + file_input = pn.widgets.FileInput(accept=".xlsx,.xlsm,.xltx,.xltm", name="Upload Excel File") + + language_select = pn.widgets.Select( + name="Select Language", + options=sorted(list(SUPPORTED_LANGUAGES)), + value="en" + ) + + def handle_upload(event): + if event.new is not None: + self.file_bytes = event.new + self.file_name = file_input.filename + + # Save to temp file + with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp: + tmp.write(self.file_bytes) + self.temp_path = Path(tmp.name) + + # Load Excel reader + try: + self.reader = ExcelReader(self.temp_path) + except Exception as e: + print(f"Error loading Excel file: {e}") + + file_input.param.watch(handle_upload, 'value') + + def handle_language_change(event): + self.language = event.new + + language_select.param.watch(handle_language_change, 'value') + + next_button = pn.widgets.Button(name="Next →", button_type="primary") + + def on_next(event): + if self.file_name: + self.navigate_to(2) + + next_button.on_click(on_next) + + return pn.Column( + title, + description, + file_input, + language_select, + pn.Row(pn.Spacer(), next_button), + sizing_mode="stretch_width" + ) + + def page_2_select_sheet(self): + """Page 2: Sheet Selection.""" + title = pn.pane.Markdown(f"# Step 2: Select Sheet\n**File:** {self.file_name}") + + if not self.reader: + return pn.Column(title, pn.pane.Markdown("No file loaded")) + + sheets = self.reader.sheets() + sheet_select = pn.widgets.RadioButtonGroup( + name="Available Sheets", + options=sheets, + value=self.selected_sheet or sheets[0] + ) + + preview_pane = pn.Column() + + def update_preview(event): + self.selected_sheet = event.new + df = load_excel_data(self.temp_path, self.selected_sheet) + if df is not None: + self.df = df + preview_pane.clear() + preview_pane.append(pn.pane.Markdown("### Data Preview")) + preview_pane.append(pn.pane.DataFrame(df.head(10), sizing_mode="stretch_width")) + + sheet_select.param.watch(update_preview, 'value') + + # Trigger initial preview + if self.selected_sheet is None and sheets: + self.selected_sheet = sheets[0] + df = load_excel_data(self.temp_path, self.selected_sheet) + if df is not None: + self.df = df + preview_pane.append(pn.pane.Markdown("### Data Preview")) + preview_pane.append(pn.pane.DataFrame(df.head(10), sizing_mode="stretch_width")) + + back_button = pn.widgets.Button(name="← Back", button_type="default") + next_button = pn.widgets.Button(name="Next →", button_type="primary") + + def on_back(event): + self.navigate_to(1) + + def on_next(event): + if self.selected_sheet: + self.navigate_to(3) + + back_button.on_click(on_back) + next_button.on_click(on_next) + + return pn.Column( + title, + sheet_select, + preview_pane, + pn.Row(back_button, pn.Spacer(), next_button), + sizing_mode="stretch_width" + ) + + def page_3_map_columns(self): + """Page 3: Column Mapping.""" + title = pn.pane.Markdown( + f"# Step 3: Map Columns to PyST Concepts\n" + f"**File:** {self.file_name} | **Sheet:** {self.selected_sheet}" + ) + + if self.df is None: + return pn.Column(title, pn.pane.Markdown("No data loaded")) + + columns = self.reader.columns(self.selected_sheet) + + mapping_widgets = [] + + for column in columns: + column_pane = pn.Column( + pn.pane.Markdown(f"**{column}**"), + sizing_mode="stretch_width" + ) + + # Sample values + sample_values = self.df[column].dropna().head(3).astype(str).tolist() + if sample_values: + column_pane.append(pn.pane.Markdown(f"*Sample: {', '.join(sample_values[:3])}*")) + + # Check if column is numeric + is_numeric = pd.api.types.is_numeric_dtype(self.df[column]) + + # Create AutocompleteInput that dynamically fetches options as user types + autocomplete_input = pn.widgets.AutocompleteInput( + name="Search for ontology", + placeholder="Type to search PyST concepts...", + options=[], + case_sensitive=False, + min_characters=2, + value="" + ) + + # Define async function to fetch and update options + def make_update_options(widget, col): + async def update_options(event): + """Fetch PyST suggestions and update options based on what user types.""" + search_text = event.new + if not search_text or len(search_text) < 2: + widget.options = [] + return + + sanitized_query = sanitize_search_query(search_text) + if not sanitized_query: + widget.options = [] + return + + # Check cache first + cache_key = f"{col}_{sanitized_query}" + if cache_key in self.suggestions_cache: + suggestions = self.suggestions_cache[cache_key] + else: + suggestions = await fetch_suggestions_async( + self.pyst_client, + sanitized_query, + self.language + ) + self.suggestions_cache[cache_key] = suggestions + + # Extract labels + labels = [] + for s in suggestions: + try: + if isinstance(s, dict): + s_label = s.get("label") or s.get("name") or s.get("title") + else: + s_label = getattr(s, "label", None) or getattr(s, "name", None) + + if s_label: + labels.append(s_label) + except Exception: + continue + + # Update the options + widget.options = labels[:10] + return update_options + + # Watch value_input (what user is typing) and update options dynamically + autocomplete_input.param.watch( + make_update_options(autocomplete_input, column), + 'value_input' + ) + + # Function to update info pane when selection is made + async def update_info_pane(selected_value, col=column): + """Update info pane with concept description.""" + if not selected_value: + return "" + + # Find the concept ID from cached suggestions + concept_id = None + for cache_key, suggestions in self.suggestions_cache.items(): + if cache_key.startswith(f"{col}_"): + for s in suggestions: + try: + if isinstance(s, dict): + s_id = s.get("id") or s.get("id_") or s.get("uri") + s_label = s.get("label") or s.get("name") or s.get("title") + else: + s_id = getattr(s, "id", None) or getattr(s, "id_", None) + s_label = getattr(s, "label", None) or getattr(s, "name", None) + + if s_label == selected_value and s_id: + concept_id = s_id + break + except Exception: + continue + if concept_id: + break + + if not concept_id: + return "" + + # Store the mapping + self.column_mappings[col] = concept_id + + # Fetch and display concept definition + definition = await fetch_concept_async( + self.pyst_client, + concept_id, + self.language + ) + web_url = iri_to_web_url(concept_id, self.language) + + if definition: + return ( + f"**Selected:** {selected_value}\n\n" + f"**Description:** {definition}\n\n" + f"[🔗 View on vocab.sentier.dev]({web_url})" + ) + else: + return ( + f"**Selected:** {selected_value}\n\n" + f"[🔗 View on vocab.sentier.dev]({web_url})" + ) + + # Create info pane with bound content + info_pane = pn.pane.Markdown( + pn.bind(update_info_pane, autocomplete_input.param.value), + sizing_mode="stretch_width" + ) + + # Initialize with first word from column name + initial_query = sanitize_search_query(column) + if initial_query and len(initial_query) >= 2: + first_word = extract_first_word(initial_query) + + # Pre-fill the value_input to trigger initial search + autocomplete_input.value_input = first_word + + # Pre-fetch and set initial value + async def set_initial_value(): + suggestions = await fetch_suggestions_async( + self.pyst_client, + first_word, + self.language + ) + + # Extract labels and set options + labels = [] + for s in suggestions: + try: + if isinstance(s, dict): + s_label = s.get("label") or s.get("name") or s.get("title") + else: + s_label = getattr(s, "label", None) or getattr(s, "name", None) + + if s_label: + labels.append(s_label) + except Exception: + continue + + # Update options and cache + if labels: + autocomplete_input.options = labels[:10] + cache_key = f"{column}_{first_word}" + self.suggestions_cache[cache_key] = suggestions + # Set initial value to first suggestion + autocomplete_input.value = labels[0] + + pn.state.execute(set_initial_value) + + column_pane.append(autocomplete_input) + column_pane.append(info_pane) + + # Description field - changes based on whether ontology is selected + description_input = pn.widgets.TextAreaInput( + name="Comment (optional)" if column in self.column_mappings else "Column Description *", + placeholder="Add optional comments or notes..." if column in self.column_mappings else "Describe what this column represents (required if no ontology match)...", + value=self.column_descriptions.get(column, ""), + height=80 + ) + column_pane.append(description_input) + + def make_description_handler(col, desc_widget, auto_widget): + def handler(event): + if event.new: + self.column_descriptions[col] = event.new + else: + self.column_descriptions.pop(col, None) + return handler + + description_input.param.watch(make_description_handler(column, description_input, autocomplete_input), 'value') + + # Update description field label when ontology selection changes + def make_ontology_change_handler(col, desc_widget): + def handler(event): + # Update the label based on whether an ontology is now selected + if col in self.column_mappings and self.column_mappings[col]: + desc_widget.name = "Comment (optional)" + desc_widget.placeholder = "Add optional comments or notes..." + else: + desc_widget.name = "Column Description *" + desc_widget.placeholder = "Describe what this column represents (required if no ontology match)..." + return handler + + autocomplete_input.param.watch(make_ontology_change_handler(column, description_input), 'value') + + # If numeric, add unit search field + if is_numeric: + # Create AutocompleteInput that dynamically fetches unit options as user types + unit_autocomplete = pn.widgets.AutocompleteInput( + name="Search for unit *", + placeholder="Type to search for unit (required for numeric columns)...", + options=[], + case_sensitive=False, + min_characters=2, + value="" + ) + + # Define async function to fetch and update unit options + def make_update_unit_options(widget, col): + async def update_unit_options(event): + """Fetch PyST unit suggestions and update options based on what user types.""" + search_text = event.new + if not search_text or len(search_text) < 2: + widget.options = [] + return + + sanitized_query = sanitize_search_query(search_text) + if not sanitized_query: + widget.options = [] + return + + # Check cache first + cache_key = f"{col}_unit_{sanitized_query}" + if cache_key in self.suggestions_cache: + suggestions = self.suggestions_cache[cache_key] + else: + suggestions = await fetch_suggestions_async( + self.pyst_client, + sanitized_query, + self.language + ) + self.suggestions_cache[cache_key] = suggestions + + # Extract labels + labels = [] + for s in suggestions: + try: + if isinstance(s, dict): + s_label = s.get("label") or s.get("name") or s.get("title") + else: + s_label = getattr(s, "label", None) or getattr(s, "name", None) + + if s_label: + labels.append(s_label) + except Exception: + continue + + # Update the options + widget.options = labels[:10] + return update_unit_options + + # Watch value_input (what user is typing) and update options dynamically + unit_autocomplete.param.watch( + make_update_unit_options(unit_autocomplete, column), + 'value_input' + ) + + # Function to update unit info pane when selection is made + async def update_unit_info_pane(selected_value, col=column): + """Update unit info pane with concept description.""" + if not selected_value: + return "" + + # Find the concept ID from cached suggestions + concept_id = None + for cache_key, suggestions in self.suggestions_cache.items(): + if cache_key.startswith(f"{col}_unit_"): + for s in suggestions: + try: + if isinstance(s, dict): + s_id = s.get("id") or s.get("id_") or s.get("uri") + s_label = s.get("label") or s.get("name") or s.get("title") + else: + s_id = getattr(s, "id", None) or getattr(s, "id_", None) + s_label = getattr(s, "label", None) or getattr(s, "name", None) + + if s_label == selected_value and s_id: + concept_id = s_id + break + except Exception: + continue + if concept_id: + break + + if not concept_id: + return "" + + # Store the mapping + self.column_mappings[f"{col}_unit"] = concept_id + + # Fetch and display unit concept definition + definition = await fetch_concept_async( + self.pyst_client, + concept_id, + self.language + ) + web_url = iri_to_web_url(concept_id, self.language) + + if definition: + return ( + f"**Selected unit:** {selected_value}\n\n" + f"**Description:** {definition}\n\n" + f"[🔗 View on vocab.sentier.dev]({web_url})" + ) + else: + return ( + f"**Selected unit:** {selected_value}\n\n" + f"[🔗 View on vocab.sentier.dev]({web_url})" + ) + + # Create unit info pane with bound content + unit_info_pane = pn.pane.Markdown( + pn.bind(update_unit_info_pane, unit_autocomplete.param.value), + sizing_mode="stretch_width" + ) + + column_pane.append(unit_autocomplete) + column_pane.append(unit_info_pane) + + mapping_widgets.append(column_pane) + mapping_widgets.append(pn.layout.Divider()) + + back_button = pn.widgets.Button(name="← Back", button_type="default") + next_button = pn.widgets.Button(name="Next →", button_type="primary") + validation_message = pn.pane.Markdown("", sizing_mode="stretch_width") + + def on_back(event): + self.navigate_to(2) + + def on_next(event): + # Validate all columns before proceeding + errors = [] + + for column in self.df.columns: + is_numeric = pd.api.types.is_numeric_dtype(self.df[column]) + has_ontology = column in self.column_mappings and self.column_mappings[column] + has_description = column in self.column_descriptions and self.column_descriptions[column] + has_unit = f"{column}_unit" in self.column_mappings and self.column_mappings[f"{column}_unit"] + + # Each column must have either an ontology or a description + if not has_ontology and not has_description: + errors.append(f"**{column}**: Must have either an ontology match or a description") + + # Numeric columns must have a unit + if is_numeric and not has_unit: + errors.append(f"**{column}**: Numeric column requires a unit selection") + + if errors: + validation_message.object = "## ⚠️ Validation Errors\n\nPlease fix the following issues:\n\n" + "\n".join(f"- {e}" for e in errors) + else: + validation_message.object = "" + self.navigate_to(4) + + back_button.on_click(on_back) + next_button.on_click(on_next) + + return pn.Column( + title, + *mapping_widgets, + validation_message, + pn.Row(back_button, pn.Spacer(), next_button), + sizing_mode="stretch_width", + scroll=True + ) + + def page_4_general_details(self): + """Page 4: General Details.""" + title = pn.pane.Markdown("# Step 4: General Details") + + schema = DataPackageSchema() + field_defs = schema.field_definitions + + name_input = pn.widgets.TextInput( + name="Package Name *", + placeholder="my-data-package", + value=self.general_details.get("name", "") + ) + + title_input = pn.widgets.TextInput( + name="Title *", + placeholder="My Data Package", + value=self.general_details.get("title", "") + ) + + description_input = pn.widgets.TextAreaInput( + name="Description", + placeholder="Describe your data package...", + value=self.general_details.get("description", ""), + height=100 + ) + + def make_handler(key, widget): + def handler(event): + if event.new: + self.general_details[key] = event.new + else: + self.general_details.pop(key, None) + return handler + + name_input.param.watch(make_handler("name", name_input), 'value') + title_input.param.watch(make_handler("title", title_input), 'value') + description_input.param.watch(make_handler("description", description_input), 'value') + + back_button = pn.widgets.Button(name="← Back", button_type="default") + generate_button = pn.widgets.Button(name="📦 Generate Parquet File", button_type="primary") + + def on_back(event): + self.navigate_to(3) + + def on_generate(event): + if self.general_details.get("name") and self.general_details.get("title"): + # Set default values for required fields + if "licenses" not in self.general_details: + self.general_details["licenses"] = [{"name": "CC-BY-4.0", "title": "Creative Commons Attribution 4.0"}] + if "contributors" not in self.general_details: + self.general_details["contributors"] = [{"name": "User", "role": "author"}] + if "sources" not in self.general_details: + self.general_details["sources"] = [{"title": "Original Data"}] + if "created" not in self.general_details: + self.general_details["created"] = datetime.now().strftime("%Y-%m-%d") + + try: + from trailpack.packing.export_service import DataPackageExporter + + exporter = DataPackageExporter( + df=self.df, + column_mappings=self.column_mappings, + general_details=self.general_details, + sheet_name=self.selected_sheet, + file_name=self.file_name, + suggestions_cache=self.suggestions_cache, + column_descriptions=self.column_descriptions, + ) + + with tempfile.NamedTemporaryFile(delete=False, suffix=".parquet") as tmp: + output_path, quality_level, validation_result = exporter.export(tmp.name) + self.output_path = output_path + self.quality_level = quality_level + self.validation_result = validation_result + self.exporter = exporter + + self.navigate_to(5) + except Exception as e: + print(f"Export failed: {e}") + + back_button.on_click(on_back) + generate_button.on_click(on_generate) + + return pn.Column( + title, + pn.pane.Markdown("### Basic Information"), + name_input, + title_input, + description_input, + pn.Row(back_button, pn.Spacer(), generate_button), + sizing_mode="stretch_width" + ) + + def page_5_review(self): + """Page 5: Review Parquet File.""" + title = pn.pane.Markdown("# Step 5: Review Parquet File") + + if not hasattr(self, 'output_path'): + return pn.Column( + title, + pn.pane.Markdown("No parquet file has been generated yet."), + sizing_mode="stretch_width" + ) + + from trailpack.packing.packing import read_parquet + + exported_df, exported_metadata = read_parquet(self.output_path) + + success_msg = pn.pane.Alert( + f"✅ Data package created successfully!\n\n**Validation Level:** {getattr(self, 'quality_level', 'VALID')}", + alert_type="success" + ) + + metadata_pane = pn.pane.JSON(exported_metadata, depth=3) + data_pane = pn.pane.DataFrame(exported_df.head(10), sizing_mode="stretch_width") + + back_button = pn.widgets.Button(name="← Back", button_type="default") + + def on_back(event): + self.navigate_to(4) + + back_button.on_click(on_back) + + return pn.Column( + title, + success_msg, + pn.pane.Markdown("### Embedded Metadata"), + metadata_pane, + pn.pane.Markdown("### 📊 Data Sample (first 10 rows)"), + data_pane, + pn.Row(back_button), + sizing_mode="stretch_width" + ) + + def view(self): + """Return the main panel view.""" + template = pn.template.FastListTemplate( + title="Trailpack - Excel to PyST Mapper", + sidebar=[ + pn.pane.Markdown("## Trailpack"), + pn.pane.Markdown("Excel to PyST Mapper"), + pn.layout.Divider(), + pn.pane.Markdown("### Steps:"), + pn.pane.Markdown("1. Upload & Select Language"), + pn.pane.Markdown("2. Select Sheet"), + pn.pane.Markdown("3. Map Columns"), + pn.pane.Markdown("4. General Details"), + pn.pane.Markdown("5. Review Parquet File"), + ], + main=[self.main_panel], + header_background="#1f77b4" + ) + return template + + +# Create and serve the app +app = TrailpackApp() + + +def create_panel_app(): + """Create and return the Panel app.""" + return app.view() + + +if __name__ == "__main__": + pn.serve(create_panel_app, port=5006, show=True) diff --git a/trailpack/ui/run_panel.py b/trailpack/ui/run_panel.py new file mode 100644 index 00000000..07a27b2a --- /dev/null +++ b/trailpack/ui/run_panel.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +"""Script to run the Trailpack Panel UI.""" + +import sys +from pathlib import Path + +# This script should be run from the repository root +# Usage: python trailpack/ui/run_panel.py + +if __name__ == "__main__": + import subprocess + + # Get the path to the panel app + app_path = Path(__file__).parent / "panel_app.py" + + print("Starting Trailpack Panel UI...") + print(f"App path: {app_path}") + print("\nPress Ctrl+C to stop the server") + print("=" * 60) + + # Run panel serve + subprocess.run([ + sys.executable, "-m", "panel", "serve", + str(app_path), + "--port", "5006", + "--show" + ])