diff --git a/.github/workflows/build-exe.yml b/.github/workflows/build-exe.yml new file mode 100644 index 0000000..0c69625 --- /dev/null +++ b/.github/workflows/build-exe.yml @@ -0,0 +1,106 @@ +name: Build Windows Executable + +on: + push: + branches: [ main, develop, 'feature/**' ] + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pyinstaller + pip install -e . + + - name: Build executable + run: | + pyinstaller build/gui.spec --clean --noconfirm + + - name: Display build info + shell: pwsh + run: | + Write-Host "Build Information" + Write-Host "==================" + $exePath = "dist\Warehouse-GUI.exe" + if (Test-Path $exePath) { + $fileInfo = Get-Item $exePath + Write-Host "File: $($fileInfo.Name)" + Write-Host "Size: $([math]::Round($fileInfo.Length / 1MB, 2)) MB" + Write-Host "Created: $($fileInfo.LastWriteTime)" + Write-Host "Full Path: $($fileInfo.FullName)" + } else { + Write-Host "ERROR: Executable not found!" + exit 1 + } + + - name: Verify executable + shell: pwsh + run: | + $exePath = "dist\Warehouse-GUI.exe" + if (-not (Test-Path $exePath)) { + Write-Host "ERROR: Executable was not built!" + exit 1 + } + # Check file size (should be at least 10 MB) + $fileSize = (Get-Item $exePath).Length + if ($fileSize -lt 10MB) { + Write-Host "WARNING: Executable size is unusually small: $fileSize bytes" + } + Write-Host "Executable verified successfully" + + - name: Generate SHA256 checksum + shell: pwsh + run: | + $exePath = "dist\Warehouse-GUI.exe" + $hash = Get-FileHash -Path $exePath -Algorithm SHA256 + $checksumPath = "dist\Warehouse-GUI.exe.sha256" + $hash.Hash | Out-File -FilePath $checksumPath -Encoding utf8 + Write-Host "Checksum: $($hash.Hash)" + Write-Host "Saved to: $checksumPath" + + - name: Generate build metadata + shell: pwsh + run: | + $metadata = @{ + "build_date" = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") + "git_commit" = "${{ github.sha }}" + "git_ref" = "${{ github.ref }}" + "github_run_id" = "${{ github.run_id }}" + "github_run_number" = "${{ github.run_number }}" + } + $metadata | Out-File "dist\build-metadata.txt" -Encoding utf8 + Write-Host "Build metadata created" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: wareflow-gui-windows + path: | + dist/Warehouse-GUI.exe + dist/Warehouse-GUI.exe.sha256 + dist/build-metadata.txt + retention-days: 30 + + - name: Upload build summary + uses: actions/upload-artifact@v4 + with: + name: build-summary + path: | + dist/*.txt + retention-days: 30 + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c0045c0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,132 @@ +name: Create Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + +jobs: + build: + runs-on: windows-latest + + outputs: + artifact_name: ${{ steps.artifact.outputs.name }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pyinstaller + pip install -e . + + - name: Build executable + run: | + pyinstaller build/gui.spec --clean --noconfirm + + - name: Display build info + shell: pwsh + run: | + Write-Host "Build Information" + Write-Host "==================" + $exePath = "dist\Warehouse-GUI.exe" + if (Test-Path $exePath) { + $fileInfo = Get-Item $exePath + Write-Host "File: $($fileInfo.Name)" + Write-Host "Size: $([math]::Round($fileInfo.Length / 1MB, 2)) MB" + Write-Host "Created: $($fileInfo.LastWriteTime)" + } + + - name: Verify executable + shell: pwsh + run: | + $exePath = "dist\Warehouse-GUI.exe" + if (-not (Test-Path $exePath)) { + Write-Host "ERROR: Executable was not built!" + exit 1 + } + $fileSize = (Get-Item $exePath).Length + if ($fileSize -lt 10MB) { + Write-Host "WARNING: Executable size is unusually small: $fileSize bytes" + } + + - name: Generate SHA256 checksum + shell: pwsh + run: | + $exePath = "dist\Warehouse-GUI.exe" + $hash = Get-FileHash -Path $exePath -Algorithm SHA256 + $checksumPath = "dist\Warehouse-GUI.exe.sha256" + $hash.Hash | Out-File -FilePath $checksumPath -Encoding utf8 + Write-Host "Checksum: $($hash.Hash)" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: wareflow-gui-windows + path: | + dist/Warehouse-GUI.exe + dist/Warehouse-GUI.exe.sha256 + retention-days: 90 + + - name: Set artifact name + id: artifact + run: echo "name=wareflow-gui-windows" >> $env:GITHUB_OUTPUT + + release: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.artifact_name }} + path: ./artifacts + + - name: Display artifacts + run: | + echo "Downloaded artifacts:" + ls -lh artifacts/ + + - name: Verify artifacts + run: | + if [ ! -f "artifacts/Warehouse-GUI.exe" ]; then + echo "ERROR: Warehouse-GUI.exe not found!" + exit 1 + fi + if [ ! -f "artifacts/Warehouse-GUI.exe.sha256" ]; then + echo "ERROR: Checksum file not found!" + exit 1 + fi + echo "All artifacts verified successfully" + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + files: | + artifacts/Warehouse-GUI.exe + artifacts/Warehouse-GUI.exe.sha256 + draft: false + prerelease: false + generate_release_notes: true + name: Wareflow Analysis v${{ steps.version.outputs.version }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e5391c5..8f896cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # Python-generated files __pycache__/ *.py[oc] -build/ +build/__pycache__/ +build/built/ dist/ wheels/ *.egg-info diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..84a306a --- /dev/null +++ b/BUILD.md @@ -0,0 +1,298 @@ +# Build Instructions + +This document describes how to build Wareflow Analysis from source, including creating a standalone Windows executable. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Development Installation](#development-installation) +- [Building the Windows Executable](#building-the-windows-executable) +- [Building on Other Platforms](#building-on-other-platforms) +- [Running Tests](#running-tests) +- [Troubleshooting](#troubleshooting) + +## Prerequisites + +- Python 3.10 or higher +- Git +- For Windows builds: Windows 10 or later +- For development: pip and virtualenv (or uv) + +## Development Installation + +### Option 1: Using pip + +```bash +# Clone the repository +git clone https://github.com/wareflowx/wareflow-analysis.git +cd wareflow-analysis + +# Create a virtual environment +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install in development mode +pip install -e ".[dev]" + +# Run tests +pytest +``` + +### Option 2: Using uv (Faster) + +```bash +# Clone the repository +git clone https://github.com/wareflowx/wareflow-analysis.git +cd wareflow-analysis + +# Sync dependencies with uv +uv sync + +# Run tests +uv run pytest +``` + +## Building the Windows Executable + +The Windows executable is built using PyInstaller. This creates a standalone `.exe` file that can be distributed without requiring Python installation. + +### Step 1: Install Dependencies + +```bash +pip install pyinstaller +pip install -e . +``` + +### Step 2: Build the Executable + +```bash +# Build using the spec file +pyinstaller build/gui.spec --clean --noconfirm +``` + +This will create `dist/Warehouse-GUI.exe`. + +### Step 3: Verify the Build + +```bash +# Check file size (should be 50-100 MB) +ls -lh dist/Warehouse-GUI.exe + +# Generate SHA256 checksum +sha256sum dist/Warehouse-GUI.exe > dist/Warehouse-GUI.exe.sha256 + +# Run the executable +dist/Warehouse-GUI.exe +``` + +### Step 4: Test the Executable + +```bash +# Run smoke tests against the built executable +pytest tests/integration/test_exe_smoke.py -v +``` + +## PyInstaller Configuration + +The build configuration is stored in `build/gui.spec`. Key settings: + +- **Entry Point**: `src/wareflow_analysis/gui/__main__.py` +- **Output**: `dist/Warehouse-GUI.exe` +- **Icon**: `build/icon.ico` +- **Console**: Disabled (windowed mode) +- **UPX Compression**: Enabled + +### Modifying the Build + +To customize the build: + +1. Edit `build/gui.spec` +2. Add/remove hidden imports if needed +3. Update version info in `build/version_info.txt` +4. Rebuild with `pyinstaller build/gui.spec --clean --noconfirm` + +## Building on Other Platforms + +### macOS + +PyInstaller can create macOS bundles, but this has not been tested: + +```bash +pyinstaller build/gui.spec --windowed +``` + +### Linux + +For Linux, you can create an AppImage: + +```bash +# Install pyinstaller +pip install pyinstaller + +# Build +pyinstaller build/gui.spec --onefile + +# The binary will be in dist/ +``` + +Note: CustomTkinter may have platform-specific limitations on Linux. + +## Running Tests + +### All Tests + +```bash +pytest +``` + +### Unit Tests Only + +```bash +pytest tests/unit/ +``` + +### Integration Tests + +```bash +pytest tests/integration/ +``` + +### GUI Tests + +```bash +pytest tests/gui/ +``` + +### With Coverage + +```bash +pytest --cov=wareflow_analysis --cov-report=html +``` + +## Automated Builds + +GitHub Actions automatically builds the Windows executable on: + +- Every push to `main`, `develop`, and `feature/**` branches +- Every pull request +- Every version tag (creates a release) + +### Downloading Build Artifacts + +1. Go to the [Actions](https://github.com/wareflowx/wareflow-analysis/actions) page +2. Select a workflow run +3. Scroll to "Artifacts" section +4. Download `wareflow-gui-windows` + +### Creating a Release + +To create a new release: + +```bash +# Update version in pyproject.toml +vim pyproject.toml + +# Commit the version bump +git add pyproject.toml +git commit -m "chore: bump version to x.y.z" + +# Create and push tag +git tag vx.y.z +git push origin main --tags +``` + +GitHub Actions will automatically: +1. Build the executable +2. Run tests +3. Create a GitHub release +4. Upload the executable and checksum + +## Troubleshooting + +### Build Fails with Missing Module + +If PyInstaller reports a missing module: + +1. Add it to `hiddenimports` in `build/gui.spec` +2. Rebuild with `--clean` flag + +Example: +```python +hiddenimports=[ + 'your_missing_module', + # ... other imports +] +``` + +### Executable is Too Large + +If the executable is larger than expected: + +1. Check if UPX compression is enabled: `upx=True` +2. Add unnecessary packages to `excludes` in `build/gui.spec` +3. Use `--exclude-module` when building + +### Import Errors in Executable + +If the executable fails with import errors: + +1. Test the imports in a normal Python environment first +2. Check if the module is in `hiddenimports` +3. Verify data files are included in `datas` section +4. Check PyInstaller hooks for problematic packages + +### GUI Doesn't Display Properly + +If the GUI has rendering issues: + +1. Ensure customtkinter and pillow are in `hiddenimports` +2. Check that `console=False` for windowed mode +3. Verify high DPI scaling is handled by CustomTkinter + +### Antivirus False Positives + +Some antivirus software may flag PyInstaller executables: + +1. This is a known issue with PyInstaller +2. The executable is safe (you can verify the checksum) +3. In production, consider code signing the executable + +## Performance Optimization + +### Reducing File Size + +Current optimization strategies in `build/gui.spec`: + +- Exclude test frameworks (pytest, unittest, mock) +- Exclude development tools (ruff, black, mypy) +- Exclude unused standard library modules +- Enable UPX compression + +Target file size: **50-80 MB** + +### Improving Startup Time + +To improve startup time: + +1. Lazy load non-critical modules +2. Optimize imports in `__main__.py` +3. Profile the startup process + +Target startup time: **< 3 seconds** + +## Resources + +- [PyInstaller Documentation](https://pyinstaller.org/en/stable/) +- [CustomTkinter Documentation](https://customtkinter.tomschimansky.com/) +- [GitHub Actions Documentation](https://docs.github.com/en/actions) + +## Support + +If you encounter build issues: + +1. Check the [Troubleshooting](#troubleshooting) section +2. Search [existing issues](https://github.com/wareflowx/wareflow-analysis/issues) +3. Create a new issue with: + - Your platform and Python version + - Full error message + - Steps to reproduce diff --git a/README.md b/README.md index 7aac3a8..dae8dae 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,146 @@ -# Wareflow Analysis CLI +# Wareflow Analysis -CLI tool for warehouse data analysis automation. +Warehouse data analysis tool for ABC classification, inventory analysis, and reporting. -## Installation +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python Version](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) -```bash -# Clone repository -git clone https://github.com/wareflowx/wareflow-analysis -cd wareflow-analysis +## Features -# Install with uv -uv sync -``` +- **CLI Interface**: Command-line tool for automation and scripting +- **GUI Application**: User-friendly graphical interface for non-technical users +- **ABC Analysis**: Classify products by importance (A/B/C categories) +- **Inventory Analysis**: Analyze warehouse stock and movements +- **Excel Export**: Generate professional Excel reports with formatting +- **SQLite Storage**: Local database for fast queries and data persistence -## Usage +## Installation -### Create a new project +### Option 1: Using pip (Recommended for developers) ```bash -uv run wareflow init my-warehouse +pip install wareflow-analysis ``` -This creates a complete project structure: -- `config.yaml` - Configuration for excel-to-sql -- `schema.sql` - Database schema -- `scripts/` - Analysis and export scripts -- `data/` - Place your Excel files here -- `output/` - Generated reports -- `warehouse.db` - SQLite database (empty) - -### Use the project +### Option 2: Using the standalone Windows executable (Recommended for users) -```bash -cd my-warehouse - -# Place your Excel files in data/ -# - produits.xlsx -# - mouvements.xlsx -# - commandes.xlsx +Download the latest `Warehouse-GUI.exe` from the [Releases](https://github.com/wareflowx/wareflow-analysis/releases) page. No Python installation required. -# Import data -wareflow import +## Quick Start -# Run analyses -wareflow analyze +### Using the GUI (Recommended for non-technical users) -# Generate reports -wareflow export +If installed via pip: +```bash +wareflow-gui ``` -### Full pipeline - +Or run the executable directly: ```bash -# Run everything at once -wareflow run +./Warehouse-GUI.exe ``` -## Commands +The GUI provides an intuitive interface for: +- Creating and managing projects +- Importing Excel data +- Running analyses +- Exporting reports + +### Using the CLI (Recommended for automation) + +1. Initialize a new project: + ```bash + mkdir my-warehouse + cd my-warehouse + wareflow init + ``` + +2. Place your Excel files in the `data/` directory: + - produits.xlsx (Products catalog) + - mouvements.xlsx (Stock movements) + - commandes.xlsx (Orders) + +3. Import data: + ```bash + wareflow import + ``` + +4. Run analyses: + ```bash + wareflow analyze abc + wareflow analyze inventory + ``` + +5. Generate reports: + ```bash + wareflow export abc --output output/abc_report.xlsx + wareflow export inventory --output output/inventory_report.xlsx + ``` + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `wareflow init` | Initialize a new project | +| `wareflow import` | Import Excel data to SQLite | +| `wareflow analyze abc` | Run ABC classification analysis | +| `wareflow analyze inventory` | Run inventory analysis | +| `wareflow export abc` | Export ABC analysis to Excel | +| `wareflow export inventory` | Export inventory analysis to Excel | +| `wareflow status` | Show database and project status | +| `wareflow run` | Run the complete pipeline | + +## Project Structure + +After initialization, your project will have: -- `wareflow init ` - Initialize new project -- `wareflow import` - Import Excel data to SQLite -- `wareflow analyze` - Run database analyses -- `wareflow export` - Generate Excel reports -- `wareflow run` - Run full pipeline (import -> analyze -> export) -- `wareflow status` - Show database status +``` +my-warehouse/ +├── config.yaml # Excel-to-SQL configuration +├── data/ # Place your Excel files here +│ ├── produits.xlsx +│ ├── mouvements.xlsx +│ └── commandes.xlsx +├── output/ # Generated reports will be saved here +├── warehouse.db # SQLite database +└── scripts/ # Custom analysis scripts +``` ## Development -```bash -# Run tests -uv run pytest +### Building from Source -# Run with coverage -uv run pytest --cov +See [BUILD.md](BUILD.md) for detailed build instructions. -# Format code -uv run ruff format . +### Running Tests -# Lint code -uv run ruff check . +```bash +pip install -e ".[dev]" +pytest ``` -## Tech Stack +### Building the Windows Executable -- **uv**: Modern Python package manager -- **Typer**: CLI framework -- **pandas**: Data manipulation -- **openpyxl**: Excel file handling -- **pytest**: Testing framework +See [BUILD.md](BUILD.md) for instructions on building the standalone executable. ## License -MIT License +MIT License - see LICENSE file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## Support + +- **Issues**: [GitHub Issues](https://github.com/wareflowx/wareflow-analysis/issues) +- **Documentation**: [Project Docs](https://github.com/wareflowx/wareflow-analysis) + +## Acknowledgments + +Built with: +- [Typer](https://typer.tiangolo.com/) - CLI framework +- [CustomTkinter](https://github.com/TomSchimansky/CustomTkinter) - Modern GUI framework +- [Pandas](https://pandas.pydata.org/) - Data analysis +- [OpenPyXL](https://openpyxl.readthedocs.io/) - Excel handling +- [PyYAML](https://pyyaml.org/) - Configuration management diff --git a/build/README.md b/build/README.md new file mode 100644 index 0000000..995f1c2 --- /dev/null +++ b/build/README.md @@ -0,0 +1,51 @@ +# Build Directory + +This directory contains configuration files and assets for building the standalone Windows executable. + +## Files + +- **gui.spec** - PyInstaller configuration for building the executable +- **icon.ico** - Application icon (multi-resolution Windows icon) +- **create_icon.py** - Script to regenerate the application icon +- **version_info.txt** - Windows version information resource + +## Building the Executable + +### Local Build + +To build the executable locally: + +```bash +# Install PyInstaller +pip install pyinstaller + +# Build the executable +pyinstaller build/gui.spec --clean --noconfirm + +# The executable will be in: dist/Warehouse-GUI.exe +``` + +### Automated Build + +The executable is automatically built by GitHub Actions on: +- Every push to `main`, `develop`, or `feature/*` branches +- Pull requests to `main` or `develop` +- Manual trigger via GitHub Actions UI + +Artifacts are available for download from the Actions page. + +## Icon + +The application icon was generated using `create_icon.py`. To regenerate: + +```bash +cd build +python create_icon.py +``` + +This will create a new `icon.ico` with multiple resolutions (16x16 to 256x256). + +## Version Information + +Version information is stored in `version_info.txt` and embedded in the executable. +To update the version, edit `version_info.txt` and rebuild. diff --git a/build/create_icon.py b/build/create_icon.py new file mode 100644 index 0000000..4c39c79 --- /dev/null +++ b/build/create_icon.py @@ -0,0 +1,99 @@ +"""Create application icon for Warehouse-GUI. + +This script generates a simple icon file for the application. +Run this script to generate the icon.ico file. +""" + +from PIL import Image, ImageDraw, ImageFont +import os + + +def create_icon(): + """Create application icon.""" + # Image sizes for Windows icon + sizes = [(256, 256), (128, 128), (64, 64), (48, 48), (32, 32), (16, 16)] + + images = [] + + for size in sizes: + # Create a new image with a transparent background + img = Image.new('RGBA', size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Draw a rounded rectangle (box/warehouse shape) + padding = size[0] // 10 + box = ( + padding, + padding, + size[0] - padding, + size[1] - padding + ) + + # Draw gradient background (blue to dark blue) + gradient_steps = 20 + for i in range(gradient_steps): + progress = i / gradient_steps + color_intensity = int(100 + 100 * progress) + color = (0, color_intensity, 200, 255) + + # Calculate rectangle coordinates + x0 = padding + y0 = padding + int((size[1] - 2 * padding) * progress) + x1 = size[0] - padding + y1 = y0 + int((size[1] - 2 * padding) / gradient_steps) + 1 + + # Ensure y1 >= y0 + if y1 < y0: + y1 = y0 + 1 + + draw.rectangle( + (x0, y0, x1, min(y1, size[1] - padding)), + fill=color, + outline=color + ) + + # Draw box shape (warehouse) + box_padding = size[0] // 6 + draw.rectangle( + (box_padding, box_padding + size[0]//8, size[0] - box_padding, size[1] - box_padding), + fill=(255, 255, 255, 200), + outline=(255, 255, 255, 255), + width=2 + ) + + # Draw "W" text for Warehouse + try: + font_size = size[0] // 3 + font = ImageFont.truetype("arial.ttf", font_size) + except: + # Fallback to default font if arial not available + font = ImageFont.load_default() + + text = "W" + text_bbox = draw.textbbox((0, 0), text, font=font) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + + text_position = ( + (size[0] - text_width) // 2, + (size[1] - text_height) // 2 + ) + + draw.text(text_position, text, fill=(255, 255, 255, 255), font=font) + + images.append(img) + + # Save as ICO file + icon_path = os.path.join(os.path.dirname(__file__), 'icon.ico') + images[0].save( + icon_path, + format='ICO', + sizes=[(size[0], size[1]) for size in sizes] + ) + + print(f"Icon created: {icon_path}") + return icon_path + + +if __name__ == '__main__': + create_icon() diff --git a/build/gui.spec b/build/gui.spec new file mode 100644 index 0000000..79c244e --- /dev/null +++ b/build/gui.spec @@ -0,0 +1,166 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller configuration for Wareflow Analysis GUI. + +This spec file creates a standalone Windows executable that includes +all dependencies and can run without Python installation. +""" + +import sys +import os +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +block_cipher = None + +# PyInstaller executes this from the repository root +# Use os.getcwd() to get the current working directory +REPO_ROOT = os.getcwd() +SPEC_DIR = os.path.join(REPO_ROOT, 'build') + +# Collect all data files from excel_to_sql +excel_to_sql_datas = collect_data_files('excel_to_sql') + +# Collect ALL submodules from excel_to_sql automatically +excel_to_sql_modules = collect_submodules('excel_to_sql') + +# Collect ALL submodules from wareflow_analysis automatically +wareflow_modules = collect_submodules('wareflow_analysis') + +a = Analysis( + [os.path.join(REPO_ROOT, 'src', 'wareflow_analysis', 'gui', '__main__.py')], + pathex=[REPO_ROOT], + binaries=[], + datas=[ + # Source code + (os.path.join(REPO_ROOT, 'src', 'wareflow_analysis'), 'wareflow_analysis'), + + # Templates + (os.path.join(REPO_ROOT, 'src', 'wareflow_analysis', 'templates'), 'wareflow_analysis/templates'), + + # Excel-to-SQL data files + *[(os.path.join(REPO_ROOT, src), dst) for src, dst in excel_to_sql_datas], + ], + hiddenimports=[ + # GUI framework + 'customtkinter', + 'tkinter', + '_tkinter', + + # Data processing + 'pandas', + 'pandas._libs.tslibs.base', + 'pandas._libs.tslibs.dtypes', + 'pandas._libs.tslibs.np_datetime', + 'pandas._libs.tslibs.nattype', + 'pandas._libs.tslibs.timestamps', + 'pandas._libs.tslibs.period', + 'pandas._libs.tslibs.vectorized', + + # Excel handling + 'openpyxl', + 'openpyxl.cell._writer', + 'openpyxl.styles', + 'openpyxl.utils', + + # Configuration + 'yaml', + 'yaml.constructor', + + # Database + 'sqlite3', + '_sqlite3', + + # Image handling + 'PIL', + 'PIL._tkinter_finder', + 'PIL.Image', + + # Theme detection + 'darkdetect', + + # Auto-include ALL excel_to_sql submodules (simple & robust) + *excel_to_sql_modules, + + # Auto-include ALL wareflow_analysis submodules (for CLI integration) + *wareflow_modules, + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + # Testing frameworks + 'pytest', + 'tests', + 'unittest', + 'mock', + 'coverage', + 'pytest_cov', + + # Development tools + 'ruff', + 'black', + 'mypy', + 'flake8', + 'pylint', + 'isort', + + # Unused standard library + 'email', + 'smtplib', + 'email.mime', + 'html', + 'html.parser', + 'http', + 'http.server', + 'urllib3', + # Note: urllib and urllib.parse cannot be excluded - pathlib needs them + 'xml', + 'xmlrpc', + + # Unused databases + 'psycopg2', + 'pymysql', + 'cx_oracle', + 'redis', + 'pymongo', + + # Web frameworks + 'django', + 'flask', + 'fastapi', + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +# Remove __pycache__ from datas +for src in list(a.datas): + if '__pycache__' in src[0] or '.pyc' in src[0]: + a.datas.remove(src) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='Warehouse-GUI', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, # Windowed mode (no console window) + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=os.path.join(SPEC_DIR, 'icon.ico'), +) diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 0000000..ab57885 Binary files /dev/null and b/build/icon.ico differ diff --git a/build/version_info.txt b/build/version_info.txt new file mode 100644 index 0000000..7ff66f7 --- /dev/null +++ b/build/version_info.txt @@ -0,0 +1,55 @@ +# UTF-8 +# +# For more details about fixed file info: +# http://msdn.microsoft.com/en-us/library/ms646997.aspx + +VSVersionInfo( + ffi=FixedFileInfo( + # File version and product version numbers + # (major, minor, build, revision) + filevers=(0, 6, 0, 0), + prodvers=(0, 6, 0, 0), + + # Mask for file flags + mask=0x3f, + + # File flags + flags=0x0, + + # Operating system type + # 0x40004 = NT Windows (Windows NT/2000/XP/Vista/7/8/10/11) + OS=0x40004, + + # File type + # 0x1 = Application + fileType=0x1, + + # File subtype + # 0x0 = Non-specific file subtype + subtype=0x0, + + # File date (year, month, day, hour, min, sec) + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + u'040904B0', + [ + StringStruct(u'CompanyName', u'Wareflow'), + StringStruct(u'FileDescription', u'Warehouse Data Analysis GUI'), + StringStruct(u'FileVersion', u'0.6.0.0'), + StringStruct(u'InternalName', u'Warehouse-GUI'), + StringStruct(u'LegalCopyright', u'MIT License'), + StringStruct(u'OriginalFilename', u'Warehouse-GUI.exe'), + StringStruct(u'ProductName', u'Wareflow Analysis'), + StringStruct(u'ProductVersion', u'0.6.0.0'), + StringStruct(u'Comments', u'Warehouse data analysis and reporting tool'), + ] + ) + ] + ), + VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) + ] +) diff --git a/docs/issues/004-version-inconsistency-bug.md b/docs/issues/004-version-inconsistency-bug.md new file mode 100644 index 0000000..23a2921 --- /dev/null +++ b/docs/issues/004-version-inconsistency-bug.md @@ -0,0 +1,148 @@ +# Version Inconsistency Bug: `__init__.py` Hardcoded Version Mismatch + +## 🐛 Bug Description + +There is a critical version inconsistency in the `excel-to-sql` package where `__version__` is defined in two different places with different values, causing import-time confusion and breaking downstream packages. + +## 🔍 Root Cause Analysis + +### Current State (Broken) + +**File: `excel_to_sql/__init__.py`** +```python +"""Excel to SQL - Import Excel files to SQL and export back.""" + +__version__ = "0.2.0" # ← HARDCODED VERSION (WRONG!) +``` + +**File: `excel_to_sql/__version__.py`** +```python +"""Version information for excel-to-sql.""" + +__version__ = "0.4.0" # ← CORRECT VERSION +``` + +### Version Mismatch Table + +| Location | Version | Source | +|----------|---------|--------| +| `__init__.py` line 4 | `0.2.0` | Hardcoded ❌ | +| `__version__.py` | `0.4.0` | Dynamic ✅ | +| `pip show excel-to-sql` | `0.4.0` | Package metadata ✅ | +| `python -c "import excel_to_sql; print(excel_to_sql.__version__)"` | `0.2.0` | Runtime import ❌ | + +### Why This Happens + +When Python imports `excel_to_sql`, it executes `__init__.py` first, which defines `__version__ = "0.2.0"`. This **overwrites** the value from `__version__.py` if it's imported later, or simply never imports from `__version__.py` at all. + +## 💥 Impact + +### Downstream Impact: `wareflow-analysis` + +The `wareflow-analysis` package depends on `excel-to-sql>=0.3.0` and has version-dependent logic: + +```python +# src/wareflow_analysis/data_import/autopilot.py +try: + from excel_to_sql.auto_pilot import PatternDetector + from excel_to_sql import ExcelToSqlite +except ImportError: + raise ImportError( + "excel-to-sql>=0.3.0 is required. " + "Install it with: pip install excel-to-sql>=0.3.0" + ) +``` + +When `wareflow-analysis` checks the version: +- **Expected**: `excel_to_sql.__version__ >= "0.3.0"` +- **Actual**: `excel_to_sql.__version__ == "0.2.0"` +- **Result**: Version check fails, misleading error message + +### User Impact + +1. **Confusing error messages**: Users see "excel-to-sql>=0.3.0 is required" even when 0.4.0 is installed +2. **Broken PyInstaller builds**: Compiled executables fail to load with version errors +3. **Development workflow issues**: Local development shows different version than CI/CD + +## ✅ Proposed Solution + +### Fix: Import `__version__` dynamically + +**File: `excel_to_sql/__init__.py`** + +```python +"""Excel to SQL - Import Excel files to SQL and export back.""" + +from excel_to_sql.__version__ import __version__ # ← Dynamic import + +# OR keep both for backward compatibility +from excel_to_sql.__version__ import __version__ as __version__ +__all__ = ["__version__"] +``` + +This ensures that: +1. `__version__` is sourced from a **single source of truth** (`__version__.py`) +2. Version updates only require changing one file +3. Import-time `__version__` matches package metadata + +### Alternative Solution (if backward compatibility is critical) + +```python +"""Excel to SQL - Import Excel files to SQL and export back.""" + +# Try to import from __version__.py, fallback to hardcoded +try: + from excel_to_sql.__version__ import __version__ +except ImportError: + __version__ = "0.4.0" # Fallback (keep in sync!) +``` + +## 🧪 Verification Steps + +After applying the fix, verify with: + +```bash +# 1. Install the package +pip install -e . + +# 2. Check version matches +python -c "import excel_to_sql; print(f'Version: {excel_to_sql.__version__}')" +# Expected: Version: 0.4.0 + +# 3. Verify matches pip +pip show excel-to-sql | grep Version +# Expected: Version: 0.4.0 + +# 4. Test downstream package +cd ../wareflow-analysis +python -c "import excel_to_sql; assert excel_to_sql.__version__ >= '0.3.0', 'Version check failed'" +# Expected: No assertion error +``` + +## 📋 Acceptance Criteria + +- [ ] `excel_to_sql.__version__` returns `"0.4.0"` when imported +- [ ] `python -c "import excel_to_sql; print(excel_to_sql.__version__)"` matches `pip show excel-to-sql` +- [ ] Downstream packages can successfully check `excel_to_sql.__version__ >= "0.3.0"` +- [ ] Version is defined in **only one place** (`__version__.py`) +- [ ] `__init__.py` imports from `__version__.py` (no hardcoded values) + +## 🏷️ Labels + +`bug` `critical` `version` `compatibility` `priority:high` + +## 🔗 Related Issues + +- wareflow-analysis Issue: Excel-to-sql version detection fails in PyInstaller builds +- wareflow-analysis PR: GUI and Windows executable implementation + +## 📝 Additional Notes + +- This is a **blocking issue** for releasing wareflow-analysis v0.7.x +- Affects all downstream packages that depend on excel-to-sql>=0.3.0 +- Simple fix but **high impact** if not resolved +- Should be included in next excel-to-sql release (0.4.1 or 0.5.0) + +## 🎯 Priority + +**HIGH** - Blocking production releases of downstream packages and causing user-facing errors. diff --git a/pyproject.toml b/pyproject.toml index 77da063..60bcd2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wareflow-analysis" -version = "0.6.0" +version = "0.7.8" description = "Warehouse data analysis CLI tool" readme = "README.md" requires-python = ">=3.10" @@ -17,12 +17,15 @@ dependencies = [ "typer>=0.21", "pandas>=2.0", "openpyxl>=3.0", - "excel-to-sql>=0.4.0", + "excel-to-sql>=0.4.1", "pyyaml", + "customtkinter>=5.2", + "pillow>=10.0", ] [project.scripts] wareflow = "wareflow_analysis.cli:cli" +wareflow-gui = "wareflow_analysis.gui:main" [project.urls] Homepage = "https://github.com/wareflowx/wareflow-analysis" diff --git a/src/wareflow_analysis/common/__init__.py b/src/wareflow_analysis/common/__init__.py new file mode 100644 index 0000000..5f80a8f --- /dev/null +++ b/src/wareflow_analysis/common/__init__.py @@ -0,0 +1,5 @@ +"""Common utilities for wareflow-analysis.""" + +from wareflow_analysis.common.output_handler import OutputHandler + +__all__ = ["OutputHandler"] diff --git a/src/wareflow_analysis/common/output_handler.py b/src/wareflow_analysis/common/output_handler.py new file mode 100644 index 0000000..24757bd --- /dev/null +++ b/src/wareflow_analysis/common/output_handler.py @@ -0,0 +1,152 @@ +"""Flexible output handler for CLI and GUI modes. + +This module provides a unified output interface that can work with both +CLI (print statements) and GUI (callback functions) modes. +""" + +from typing import Callable, Optional, Any +import sys + + +class OutputHandler: + """Handle output for both CLI and GUI modes. + + This class provides a flexible way to handle output that works in + both CLI and GUI contexts. In CLI mode, it prints to stdout/stderr. + In GUI mode, it sends messages to a callback function. + + Attributes: + mode: Output mode - "cli" or "gui" + callback: Optional callback function for GUI mode + verbose: Whether to enable verbose output + """ + + def __init__( + self, + mode: str = "cli", + callback: Optional[Callable[[str], None]] = None, + verbose: bool = True + ): + """Initialize the OutputHandler. + + Args: + mode: Output mode - "cli" or "gui" + callback: Optional callback function for GUI mode + verbose: Whether to enable verbose output + """ + if mode not in ["cli", "gui"]: + raise ValueError(f"Invalid mode: {mode}. Must be 'cli' or 'gui'") + + self.mode = mode + self.callback = callback + self.verbose = verbose + + def print(self, message: str, force: bool = False) -> None: + """Print a message based on the current mode. + + Args: + message: The message to print + force: If True, print even if verbose is False + """ + if not self.verbose and not force: + return + + if self.mode == "cli": + print(message) + elif self.mode == "gui" and self.callback: + self.callback(message) + + def error(self, message: str) -> None: + """Print an error message based on the current mode. + + Args: + message: The error message to print + """ + if self.mode == "cli": + print(f"Error: {message}", file=sys.stderr) + elif self.mode == "gui" and self.callback: + self.callback(f"ERROR: {message}") + + def warning(self, message: str) -> None: + """Print a warning message based on the current mode. + + Args: + message: The warning message to print + """ + if self.mode == "cli": + print(f"Warning: {message}") + elif self.mode == "gui" and self.callback: + self.callback(f"WARNING: {message}") + + def success(self, message: str) -> None: + """Print a success message based on the current mode. + + Args: + message: The success message to print + """ + if self.mode == "cli": + print(f"✓ {message}") + elif self.mode == "gui" and self.callback: + self.callback(f"SUCCESS: {message}") + + def info(self, message: str) -> None: + """Print an info message based on the current mode. + + Args: + message: The info message to print + """ + if not self.verbose: + return + + if self.mode == "cli": + print(f" {message}") + elif self.mode == "gui" and self.callback: + self.callback(f"INFO: {message}") + + def debug(self, message: str) -> None: + """Print a debug message based on the current mode. + + Args: + message: The debug message to print + """ + if not self.verbose: + return + + if self.mode == "cli": + print(f"DEBUG: {message}") + elif self.mode == "gui" and self.callback: + self.callback(f"DEBUG: {message}") + + def progress(self, current: int, total: int, message: str = "") -> None: + """Show progress for long-running operations. + + Args: + current: Current progress value + total: Total value for progress calculation + message: Optional message to display with progress + """ + percentage = (current / total * 100) if total > 0 else 0 + + if self.mode == "cli": + if message: + print(f"{message} [{current}/{total}] ({percentage:.1f}%)") + else: + print(f"Progress: {current}/{total} ({percentage:.1f}%)") + elif self.mode == "gui" and self.callback: + self.callback(f"PROGRESS:{current}:{total}:{percentage}:{message}") + + def set_callback(self, callback: Callable[[str], None]) -> None: + """Set or update the callback function. + + Args: + callback: The callback function to set + """ + self.callback = callback + + def set_verbose(self, verbose: bool) -> None: + """Set verbose mode. + + Args: + verbose: Whether to enable verbose output + """ + self.verbose = verbose diff --git a/src/wareflow_analysis/gui/__init__.py b/src/wareflow_analysis/gui/__init__.py new file mode 100644 index 0000000..d5230a2 --- /dev/null +++ b/src/wareflow_analysis/gui/__init__.py @@ -0,0 +1,12 @@ +"""Wareflow Analysis GUI. + +This package provides a graphical user interface for the Wareflow Analysis +warehouse data analysis tool, built with CustomTkinter. + +The GUI wraps all existing CLI functionality and provides an intuitive +interface for non-technical users. +""" + +from wareflow_analysis.gui.main_window import MainWindow, main + +__all__ = ["MainWindow", "main"] diff --git a/src/wareflow_analysis/gui/__main__.py b/src/wareflow_analysis/gui/__main__.py new file mode 100644 index 0000000..32d0202 --- /dev/null +++ b/src/wareflow_analysis/gui/__main__.py @@ -0,0 +1,11 @@ +"""Main entry point for running the GUI as a module. + +Usage: + python -m wareflow_analysis.gui + uv run python -m wareflow_analysis.gui +""" + +from wareflow_analysis.gui import main + +if __name__ == "__main__": + main() diff --git a/src/wareflow_analysis/gui/controllers/__init__.py b/src/wareflow_analysis/gui/controllers/__init__.py new file mode 100644 index 0000000..7771631 --- /dev/null +++ b/src/wareflow_analysis/gui/controllers/__init__.py @@ -0,0 +1,9 @@ +"""GUI controllers for wareflow-analysis.""" + +from wareflow_analysis.gui.controllers.state_manager import ( + StateManager, + get_state_manager, + reset_state_manager, +) + +__all__ = ["StateManager", "get_state_manager", "reset_state_manager"] diff --git a/src/wareflow_analysis/gui/controllers/state_manager.py b/src/wareflow_analysis/gui/controllers/state_manager.py new file mode 100644 index 0000000..46aa499 --- /dev/null +++ b/src/wareflow_analysis/gui/controllers/state_manager.py @@ -0,0 +1,243 @@ +"""State manager for GUI application. + +This module provides centralized state management for the GUI application, +tracking project status, database state, and application settings. +""" + +from pathlib import Path +from typing import Optional, Dict, Any +from datetime import datetime + + +class StateManager: + """Manage application state for GUI. + + This class tracks: + - Current project directory + - Database status + - Configuration state + - Recent operations + - Application settings + + Attributes: + project_dir: Current project directory path + db_path: Path to database file + config_path: Path to config file + database_exists: Whether database file exists + config_exists: Whether config file exists + last_operation: Last operation performed + settings: Application settings dictionary + """ + + def __init__(self): + """Initialize the StateManager.""" + self.project_dir: Optional[Path] = None + self.db_path: Optional[Path] = None + self.config_path: Optional[Path] = None + self.database_exists: bool = False + self.config_exists: bool = False + self.last_operation: Optional[str] = None + self.last_operation_time: Optional[datetime] = None + + # Application settings + self.settings: Dict[str, Any] = { + "remember_last_project": True, + "auto_create_backup": True, + "verbose_output": True, + "theme": "System", # System, Light, Dark + "confirm_destructive": True, + } + + # Database statistics (cached) + self.db_stats: Dict[str, Any] = {} + + # Listeners for state changes + self._listeners: list = [] + + def set_project_dir(self, project_dir: Path) -> bool: + """Set the current project directory. + + Args: + project_dir: Path to project directory + + Returns: + True if project directory is valid, False otherwise + """ + project_dir = Path(project_dir) + + # Check if it's a valid wareflow project + config_file = project_dir / "config.yaml" + if not config_file.exists(): + return False + + self.project_dir = project_dir + self.config_path = config_file + self.db_path = project_dir / "warehouse.db" + self.config_exists = True + self.database_exists = self.db_path.exists() + + # Clear cached stats + self.db_stats = {} + + # Notify listeners + self._notify_listeners("project_changed") + + return True + + def get_project_dir(self) -> Optional[Path]: + """Get the current project directory. + + Returns: + Current project directory path or None + """ + return self.project_dir + + def is_project_loaded(self) -> bool: + """Check if a project is currently loaded. + + Returns: + True if a project is loaded, False otherwise + """ + return self.project_dir is not None and self.config_exists + + def is_database_ready(self) -> bool: + """Check if database is ready for operations. + + Returns: + True if database exists and has data, False otherwise + """ + return self.database_exists and self.is_project_loaded() + + def get_database_stats(self) -> Dict[str, Any]: + """Get database statistics. + + Returns: + Dictionary with database statistics + """ + if not self.is_database_ready(): + return {} + + # Return cached stats if available + if self.db_stats: + return self.db_stats + + # Import here to avoid circular dependencies + from wareflow_analysis.data_import.importer import get_import_status + + stats = get_import_status(self.project_dir) + self.db_stats = stats + + return self.db_stats + + def refresh_database_stats(self) -> Dict[str, Any]: + """Force refresh of database statistics. + + Returns: + Dictionary with fresh database statistics + """ + self.db_stats = {} + return self.get_database_stats() + + def update_database_state(self) -> None: + """Update database state (exists/doesn't exist).""" + if self.project_dir: + self.database_exists = self.db_path.exists() if self.db_path else False + self.db_stats = {} + self._notify_listeners("database_changed") + + def set_last_operation(self, operation: str) -> None: + """Set the last performed operation. + + Args: + operation: Description of the operation + """ + self.last_operation = operation + self.last_operation_time = datetime.now() + self._notify_listeners("operation_completed") + + def get_setting(self, key: str, default: Any = None) -> Any: + """Get a setting value. + + Args: + key: Setting key + default: Default value if key not found + + Returns: + Setting value or default + """ + return self.settings.get(key, default) + + def set_setting(self, key: str, value: Any) -> None: + """Set a setting value. + + Args: + key: Setting key + value: Setting value + """ + self.settings[key] = value + self._notify_listeners("settings_changed") + + def register_listener(self, callback) -> None: + """Register a listener for state changes. + + Args: + callback: Function to call when state changes + """ + if callback not in self._listeners: + self._listeners.append(callback) + + def unregister_listener(self, callback) -> None: + """Unregister a state change listener. + + Args: + callback: Function to remove from listeners + """ + if callback in self._listeners: + self._listeners.remove(callback) + + def _notify_listeners(self, event: str) -> None: + """Notify all listeners of a state change. + + Args: + event: Event type that occurred + """ + for listener in self._listeners: + try: + listener(event) + except Exception: + # Don't let listener errors break the app + pass + + def reset(self) -> None: + """Reset all state to initial values.""" + self.project_dir = None + self.db_path = None + self.config_path = None + self.database_exists = False + self.config_exists = False + self.last_operation = None + self.last_operation_time = None + self.db_stats = {} + self._notify_listeners("state_reset") + + +# Global state manager instance +_state_manager: Optional[StateManager] = None + + +def get_state_manager() -> StateManager: + """Get the global state manager instance. + + Returns: + Global StateManager instance + """ + global _state_manager + if _state_manager is None: + _state_manager = StateManager() + return _state_manager + + +def reset_state_manager() -> None: + """Reset the global state manager.""" + global _state_manager + _state_manager = None diff --git a/src/wareflow_analysis/gui/main_window.py b/src/wareflow_analysis/gui/main_window.py new file mode 100644 index 0000000..5026ceb --- /dev/null +++ b/src/wareflow_analysis/gui/main_window.py @@ -0,0 +1,361 @@ +"""Main window for the Wareflow Analysis GUI. + +This module provides the main application window with navigation +between different views. +""" + +import customtkinter as ctk +from pathlib import Path +from typing import Optional + +from wareflow_analysis.gui.controllers.state_manager import get_state_manager +from wareflow_analysis.gui.views import ( + HomeView, + ImportView, + AnalyzeView, + ExportView, + StatusView, +) + + +class MainWindow(ctk.CTk): + """Main application window. + + This window provides: + - Navigation between views + - Menu bar + - Status bar + - View management + + Attributes: + state_manager: StateManager instance + current_view: Currently displayed view + views: Dictionary of available views + """ + + def __init__(self): + """Initialize the MainWindow.""" + super().__init__() + + self.state_manager = get_state_manager() + self.current_view: Optional[ctk.CTkFrame] = None + self.views = {} + + self._setup_window() + self._build_ui() + self._show_home_view() + + def _setup_window(self) -> None: + """Setup window properties.""" + self.title("Wareflow Analysis") + self.geometry("1000x700") + + # Set minimum size + self.minsize(800, 600) + + # Configure grid + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=0) # Navigation + self.grid_rowconfigure(1, weight=1) # Content + self.grid_rowconfigure(2, weight=0) # Status bar + + def _build_ui(self) -> None: + """Build the UI components.""" + # Navigation bar + self._build_navigation() + + # Content container + self.content_frame = ctk.CTkFrame(self, fg_color="transparent") + self.content_frame.grid(row=1, column=0, sticky="nsew") + self.content_frame.grid_columnconfigure(0, weight=1) + self.content_frame.grid_rowconfigure(0, weight=1) + + # Status bar + self._build_status_bar() + + def _build_navigation(self) -> None: + """Build the navigation bar.""" + nav_frame = ctk.CTkFrame(self, height=60) + nav_frame.grid(row=0, column=0, sticky="ew") + nav_frame.grid_columnconfigure(0, weight=1) + + # Title + title_label = ctk.CTkLabel( + nav_frame, + text="📦 Wareflow Analysis", + font=ctk.CTkFont(size=18, weight="bold") + ) + title_label.grid(row=0, column=0, padx=20, pady=15, sticky="w") + + # Navigation buttons + button_frame = ctk.CTkFrame(nav_frame, fg_color="transparent") + button_frame.grid(row=0, column=1, padx=20, sticky="e") + + self.nav_buttons = {} + nav_items = [ + ("🏠 Home", "home"), + ("📥 Import", "import"), + ("📊 Analyze", "analyze"), + ("📤 Export", "export"), + ("📊 Status", "status"), + ] + + for i, (label, view_name) in enumerate(nav_items): + btn = ctk.CTkButton( + button_frame, + text=label, + width=100, + command=lambda v=view_name: self._navigate_to(v) + ) + btn.grid(row=0, column=i, padx=2) + self.nav_buttons[view_name] = btn + + # Set home button as default + self._set_active_nav("home") + + def _build_status_bar(self) -> None: + """Build the status bar.""" + status_frame = ctk.CTkFrame(self, height=30) + status_frame.grid(row=2, column=0, sticky="ew") + + self.status_label = ctk.CTkLabel( + status_frame, + text="Ready", + anchor="w", + font=ctk.CTkFont(size=11) + ) + self.status_label.grid(row=0, column=0, padx=10, pady=5, sticky="w") + + # Project indicator + self.project_label = ctk.CTkLabel( + status_frame, + text="No project", + anchor="e", + font=ctk.CTkFont(size=11) + ) + self.project_label.grid(row=0, column=1, padx=10, pady=5, sticky="e") + status_frame.grid_columnconfigure(1, weight=1) + + # Update project label + self._update_project_label() + + # Register for state changes + self.state_manager.register_listener(self._on_state_change) + + def _show_home_view(self) -> None: + """Show the home view.""" + self._show_view("home", lambda: HomeView( + self.content_frame, + self.state_manager, + on_action_callback=self._on_home_action + )) + + def _show_import_view(self) -> None: + """Show the import view.""" + self._show_view("import", lambda: ImportView( + self.content_frame, + self.state_manager, + on_complete=self._on_operation_complete + )) + + def _show_analyze_view(self) -> None: + """Show the analyze view.""" + if not self.state_manager.is_database_ready(): + self._show_error("Database not ready. Please import data first.") + return + + self._show_view("analyze", lambda: AnalyzeView( + self.content_frame, + self.state_manager, + self.state_manager.db_path, + on_complete=self._on_operation_complete + )) + + def _show_export_view(self) -> None: + """Show the export view.""" + if not self.state_manager.is_database_ready(): + self._show_error("Database not ready. Please import data first.") + return + + self._show_view("export", lambda: ExportView( + self.content_frame, + self.state_manager, + self.state_manager.db_path, + on_complete=self._on_operation_complete + )) + + def _show_status_view(self) -> None: + """Show the status view.""" + self._show_view("status", lambda: StatusView( + self.content_frame, + self.state_manager, + on_complete=self._on_operation_complete + )) + + def _show_view(self, view_name: str, view_factory) -> None: + """Show a view. + + Args: + view_name: Name of the view + view_factory: Factory function to create the view + """ + # Cleanup current view + if self.current_view: + self.current_view.destroy() + self.current_view = None + + # Create and show new view + view = view_factory() + view.grid(row=0, column=0, sticky="nsew") + self.current_view = view + + # Store for reuse + self.views[view_name] = view + + # Update navigation + self._set_active_nav(view_name) + + # Update status + self._update_status(f"View: {view_name.capitalize()}") + + def _navigate_to(self, view_name: str) -> None: + """Navigate to a view. + + Args: + view_name: Name of the view to navigate to + """ + if view_name == "home": + self._show_home_view() + elif view_name == "import": + self._show_import_view() + elif view_name == "analyze": + self._show_analyze_view() + elif view_name == "export": + self._show_export_view() + elif view_name == "status": + self._show_status_view() + + def _set_active_nav(self, active_view: str) -> None: + """Set the active navigation button. + + Args: + active_view: Name of the active view + """ + for view_name, button in self.nav_buttons.items(): + if view_name == active_view: + button.configure(fg_color="blue", hover_color="darkblue") + else: + button.configure(fg_color="gray", hover_color="darkgray") + + def _on_home_action(self, action: str) -> None: + """Handle action from home view. + + Args: + action: Action identifier + """ + if action == "import": + self._navigate_to("import") + elif action == "analyze_abc": + self._navigate_to("analyze") + # Set ABC as default + if self.current_view and hasattr(self.current_view, "analysis_type_var"): + self.current_view.analysis_type_var.set("abc") + elif action == "analyze_inventory": + self._navigate_to("analyze") + # Set Inventory as default + if self.current_view and hasattr(self.current_view, "analysis_type_var"): + self.current_view.analysis_type_var.set("inventory") + elif action == "export": + self._navigate_to("export") + elif action == "validate": + self._show_info("Validation will be implemented in the next version") + + def _on_operation_complete(self, result) -> None: + """Handle operation completion. + + Args: + result: Operation result + """ + if result is None: + # Operation was cancelled + return + + if isinstance(result, bool): + if result: + self._update_status("Operation completed successfully") + else: + self._update_status("Operation failed") + elif isinstance(result, str): + self._update_status(f"Completed: {result}") + else: + self._update_status("Operation completed") + + # Refresh database state + self.state_manager.update_database_state() + + def _on_state_change(self, event: str) -> None: + """Handle state change events. + + Args: + event: Event type + """ + if event == "project_changed": + self._update_project_label() + + def _update_status(self, message: str) -> None: + """Update the status bar. + + Args: + message: Status message + """ + self.status_label.configure(text=message) + + def _update_project_label(self) -> None: + """Update the project label in status bar.""" + if self.state_manager.is_project_loaded(): + project_dir = self.state_manager.get_project_dir() + self.project_label.configure(text=f"Project: {project_dir.name}") + else: + self.project_label.configure(text="No project loaded") + + def _show_info(self, message: str) -> None: + """Show an info dialog. + + Args: + message: Info message + """ + from tkinter import messagebox + messagebox.showinfo("Information", message) + + def _show_error(self, message: str) -> None: + """Show an error dialog. + + Args: + message: Error message + """ + from tkinter import messagebox + messagebox.showerror("Error", message) + + def cleanup(self) -> None: + """Clean up resources before closing.""" + if self.current_view and hasattr(self.current_view, "cleanup"): + self.current_view.cleanup() + + self.state_manager.unregister_listener(self._on_state_change) + + +def main(): + """Main entry point for the GUI application.""" + # Set appearance mode + ctk.set_appearance_mode("System") # Modes: "System" (standard), "Dark", "Light" + + # Set default color theme + ctk.set_default_color_theme("blue") # Themes: "blue" (standard), "green", "dark-blue" + + # Create and run app + app = MainWindow() + + # Handle cleanup on close + app.protocol("WM_DELETE_WINDOW", lambda: (app.cleanup(), app.destroy())) + + app.mainloop() diff --git a/src/wareflow_analysis/gui/views/__init__.py b/src/wareflow_analysis/gui/views/__init__.py new file mode 100644 index 0000000..a5f008d --- /dev/null +++ b/src/wareflow_analysis/gui/views/__init__.py @@ -0,0 +1,15 @@ +"""GUI views for wareflow-analysis.""" + +from wareflow_analysis.gui.views.home_view import HomeView +from wareflow_analysis.gui.views.import_view import ImportView +from wareflow_analysis.gui.views.analyze_view import AnalyzeView +from wareflow_analysis.gui.views.export_view import ExportView +from wareflow_analysis.gui.views.status_view import StatusView + +__all__ = [ + "HomeView", + "ImportView", + "AnalyzeView", + "ExportView", + "StatusView", +] diff --git a/src/wareflow_analysis/gui/views/analyze_view.py b/src/wareflow_analysis/gui/views/analyze_view.py new file mode 100644 index 0000000..6c2f9dc --- /dev/null +++ b/src/wareflow_analysis/gui/views/analyze_view.py @@ -0,0 +1,496 @@ +"""Analyze view for the GUI. + +This module provides the analysis view for running warehouse analyses +including ABC classification and inventory analysis. +""" + +import customtkinter as ctk +from typing import Optional, Callable + + +class AnalyzeView(ctk.CTkFrame): + """Analyze view for running warehouse analyses. + + This view provides: + - Analysis type selection (ABC, Inventory) + - Parameter configuration + - Analysis execution with progress tracking + - Results preview + + Attributes: + master: Parent widget + state_manager: StateManager instance + db_path: Path to database + on_complete: Optional callback when analysis completes + """ + + def __init__( + self, + master, + state_manager, + db_path, + on_complete: Optional[Callable] = None, + **kwargs + ): + """Initialize the AnalyzeView. + + Args: + master: Parent widget + state_manager: StateManager instance + db_path: Path to database + on_complete: Optional callback when analysis completes + **kwargs: Additional arguments for CTkFrame + """ + super().__init__(master, **kwargs) + + self.state_manager = state_manager + self.db_path = db_path + self.on_complete = on_complete + self.is_analyzing = False + self.last_results = None + + self._build_ui() + + def _build_ui(self) -> None: + """Build the UI components.""" + # Configure grid + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=0) # Title + self.grid_rowconfigure(1, weight=0) # Analysis type + self.grid_rowconfigure(2, weight=0) # Parameters + self.grid_rowconfigure(3, weight=0) # Actions + self.grid_rowconfigure(4, weight=0) # Progress + self.grid_rowconfigure(5, weight=1) # Results + self.grid_rowconfigure(6, weight=0) # Buttons + + # Title + title_label = ctk.CTkLabel( + self, + text="📊 Warehouse Analysis", + font=ctk.CTkFont(size=24, weight="bold") + ) + title_label.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="ew") + + # Analysis type section + self._build_analysis_type() + + # Parameters section + self._build_parameters() + + # Action buttons + self._build_actions() + + # Progress section + self._build_progress() + + # Results section + self._build_results() + + # Bottom buttons + self._build_bottom_buttons() + + def _build_analysis_type(self) -> None: + """Build the analysis type selection section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=1, column=0, padx=20, pady=10, sticky="ew") + + ctk.CTkLabel( + frame, + text="Analysis Type", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + # Analysis type radio buttons + radio_frame = ctk.CTkFrame(frame, fg_color="transparent") + radio_frame.pack(padx=15, pady=(0, 15)) + + self.analysis_type_var = ctk.StringVar(value="abc") + + ctk.CTkRadioButton( + radio_frame, + text="ABC Classification (Pareto Analysis)", + variable=self.analysis_type_var, + value="abc", + command=self._on_analysis_type_changed + ).pack(anchor="w", pady=2) + + ctk.CTkRadioButton( + radio_frame, + text="Inventory Analysis (Product Catalog Statistics)", + variable=self.analysis_type_var, + value="inventory", + command=self._on_analysis_type_changed + ).pack(anchor="w", pady=2) + + def _build_parameters(self) -> None: + """Build the parameters section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=2, column=0, padx=20, pady=10, sticky="ew") + + ctk.CTkLabel( + frame, + text="Parameters", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + # Parameters container + param_frame = ctk.CTkFrame(frame, fg_color="transparent") + param_frame.pack(padx=15, pady=(0, 15), fill="x") + + # Lookback days (for ABC analysis) + days_frame = ctk.CTkFrame(param_frame, fg_color="transparent") + days_frame.grid(row=0, column=0, sticky="ew") + + ctk.CTkLabel(days_frame, text="Lookback Period (days):").grid(row=0, column=0, sticky="w") + + self.days_entry = ctk.CTkEntry(days_frame, width=100) + self.days_entry.insert(0, "90") + self.days_entry.grid(row=0, column=1, padx=(10, 0)) + + ctk.CTkLabel( + days_frame, + text="Only used for ABC analysis", + font=ctk.CTkFont(size=10) + ).grid(row=1, column=0, columnspan=2, sticky="w") + + def _build_actions(self) -> None: + """Build the action buttons section.""" + frame = ctk.CTkFrame(self, fg_color="transparent") + frame.grid(row=3, column=0, padx=20, pady=10, sticky="ew") + + self.analyze_btn = ctk.CTkButton( + frame, + text="▶ Run Analysis", + width=200, + height=40, + fg_color="blue", + hover_color="darkblue", + command=self._on_run_analysis + ) + self.analyze_btn.pack() + + def _build_progress(self) -> None: + """Build the progress section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=4, column=0, padx=20, pady=10, sticky="ew") + + self.progress_label = ctk.CTkLabel( + frame, + text="Ready", + font=ctk.CTkFont(size=12) + ) + self.progress_label.pack(padx=15, pady=(15, 10)) + + self.progress_bar = ctk.CTkProgressBar(frame) + self.progress_bar.pack(padx=15, pady=(0, 15), fill="x") + self.progress_bar.set(0) + + def _build_results(self) -> None: + """Build the results display section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=5, column=0, padx=20, pady=10, sticky="nsew") + + ctk.CTkLabel( + frame, + text="Results", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + self.results_text = ctk.CTkTextbox( + frame, + font=ctk.CTkFont(family="Consolas", size=11) + ) + self.results_text.pack(padx=15, pady=(0, 15), fill="both", expand=True) + self.results_text.insert("1.0", "Run an analysis to see results here...") + + def _build_bottom_buttons(self) -> None: + """Build the bottom action buttons.""" + frame = ctk.CTkFrame(self, fg_color="transparent") + frame.grid(row=6, column=0, padx=20, pady=(10, 20), sticky="ew") + + button_frame = ctk.CTkFrame(frame, fg_color="transparent") + button_frame.pack() + + self.export_btn = ctk.CTkButton( + button_frame, + text="📤 Export Results", + width=150, + state="disabled", + command=self._on_export_results + ) + self.export_btn.grid(row=0, column=0, padx=5) + + self.clear_btn = ctk.CTkButton( + button_frame, + text="Clear Results", + width=150, + command=self._on_clear_results + ) + self.clear_btn.grid(row=0, column=1, padx=5) + + self.close_btn = ctk.CTkButton( + button_frame, + text="Close", + width=150, + command=self._on_close + ) + self.close_btn.grid(row=0, column=2, padx=5) + + def _on_analysis_type_changed(self) -> None: + """Handle analysis type radio button change.""" + analysis_type = self.analysis_type_var.get() + + # Enable/disable days entry based on analysis type + if analysis_type == "abc": + self.days_entry.configure(state="normal") + else: + self.days_entry.configure(state="disabled") + + def _on_run_analysis(self) -> None: + """Handle run analysis button click.""" + if self.is_analyzing: + return + + if not self.state_manager.is_database_ready(): + self._append_results("Error: Database not ready. Please import data first.") + return + + analysis_type = self.analysis_type_var.get() + + self.is_analyzing = True + self.analyze_btn.configure(state="disabled") + self.progress_bar.set(0) + self.progress_label.configure(text="Running analysis...") + self._append_results(f"\n{'='*60}\n") + self._append_results(f"Running {analysis_type.upper()} analysis...\n") + self._append_results(f"{'='*60}\n\n") + + # Run analysis in thread + from wareflow_analysis.gui.widgets import run_in_thread + + def analysis_operation(): + if analysis_type == "abc": + from wareflow_analysis.analyze.abc import ABCAnalysis + + try: + days = int(self.days_entry.get()) + except ValueError: + days = 90 + + analyzer = ABCAnalysis(self.db_path) + success, message = analyzer.connect() + + if not success: + return False, message + + try: + results = analyzer.run(days) + analyzer.close() + return True, results + except Exception as e: + analyzer.close() + return False, str(e) + + elif analysis_type == "inventory": + from wareflow_analysis.analyze.inventory import InventoryAnalysis + + analyzer = InventoryAnalysis(self.db_path) + success, message = analyzer.connect() + + if not success: + return False, message + + try: + results = analyzer.run() + analyzer.close() + return True, results + except Exception as e: + analyzer.close() + return False, str(e) + + def on_complete(result): + success, data = result + + if success: + self.last_results = data + self.last_analysis_type = analysis_type + + # Format and display results + output = self._format_results(analysis_type, data) + self._append_results(output) + + self.progress_label.configure(text="Analysis completed successfully") + self.progress_bar.set(1.0) + self.export_btn.configure(state="normal") + + if self.on_complete: + self.on_complete(analysis_type, data) + else: + self._append_results(f"Error: {data}\n") + self.progress_label.configure(text="Analysis failed") + + self.is_analyzing = False + self.analyze_btn.configure(state="normal") + + def on_error(error): + self._append_results(f"Exception: {error}\n") + self.progress_label.configure(text="Analysis failed with exception") + self.is_analyzing = False + self.analyze_btn.configure(state="normal") + + run_in_thread( + operation=analysis_operation, + on_complete=on_complete, + on_error=on_error + ) + + def _format_results(self, analysis_type: str, results) -> str: + """Format analysis results for display. + + Args: + analysis_type: Type of analysis + results: Analysis results + + Returns: + Formatted results string + """ + if analysis_type == "abc": + return self._format_abc_results(results) + elif analysis_type == "inventory": + return self._format_inventory_results(results) + return str(results) + + def _format_abc_results(self, results) -> str: + """Format ABC analysis results. + + Args: + results: ABC analysis results + + Returns: + Formatted string + """ + lines = [] + + lines.append("ABC Classification Results\n") + lines.append("-" * 40 + "\n") + + if "summary" in results: + summary = results["summary"] + lines.append(f"Analysis Period: Last {summary.get('days', 90)} days\n") + lines.append(f"Total Products: {summary.get('total_products', 0):,}\n") + lines.append("\n") + + if "class_distribution" in results: + dist = results["class_distribution"] + lines.append("Class Distribution:\n") + for class_name, count in dist.items(): + percentage = (count / results["summary"]["total_products"] * 100) if results["summary"]["total_products"] > 0 else 0 + lines.append(f" Class {class_name}: {count:,} products ({percentage:.1f}%)\n") + + if "top_products" in results: + lines.append("\nTop Products:\n") + for i, product in enumerate(results["top_products"][:10], 1): + lines.append(f" {i}. {product}\n") + + return "".join(lines) + + def _format_inventory_results(self, results) -> str: + """Format inventory analysis results. + + Args: + results: Inventory analysis results + + Returns: + Formatted string + """ + lines = [] + + lines.append("Inventory Analysis Results\n") + lines.append("-" * 40 + "\n") + + if "total_products" in results: + lines.append(f"Total Products: {results['total_products']:,}\n") + + if "active_products" in results: + lines.append(f"Active Products: {results['active_products']:,}\n") + + if "categories" in results: + lines.append(f"\nCategories: {results['categories']:,}\n") + + if "data_quality" in results: + lines.append("\nData Quality:\n") + for metric, value in results["data_quality"].items(): + lines.append(f" {metric}: {value}\n") + + return "".join(lines) + + def _append_results(self, text: str) -> None: + """Append text to results display. + + Args: + text: Text to append + """ + self.results_text.insert("end", text) + self.results_text.see("end") + + def _on_export_results(self) -> None: + """Handle export results button click.""" + if not self.last_results: + return + + from tkinter import filedialog + + analysis_type = getattr(self, "last_analysis_type", "abc") + + filename = filedialog.asksaveasfilename( + title="Export Analysis Results", + defaultextension=".xlsx", + filetypes=[("Excel files", "*.xlsx"), ("All files", "*.*")], + initialfile=f"{analysis_type}_report.xlsx" + ) + + if filename: + self._export_results(filename) + + def _export_results(self, output_path: str) -> None: + """Export results to Excel file. + + Args: + output_path: Path to output file + """ + try: + from pathlib import Path + from datetime import datetime + + analysis_type = getattr(self, "last_analysis_type", "abc") + output_path = Path(output_path) + + # Create output directory if needed + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Export based on analysis type + if analysis_type == "abc": + from wareflow_analysis.export.reports.abc_report import ABCReportExporter + exporter = ABCReportExporter() + exporter.export(self.last_results, output_path) + elif analysis_type == "inventory": + from wareflow_analysis.export.reports.inventory_report import InventoryReportExporter + exporter = InventoryReportExporter() + exporter.export(self.last_results, output_path) + + self._append_results(f"\n✓ Results exported to: {output_path}\n") + except Exception as e: + self._append_results(f"\n✗ Export failed: {e}\n") + + def _on_clear_results(self) -> None: + """Handle clear results button click.""" + self.results_text.delete("1.0", "end") + self.last_results = None + self.export_btn.configure(state="disabled") + self.progress_bar.set(0) + self.progress_label.configure(text="Ready") + + def _on_close(self) -> None: + """Handle close button click.""" + if self.on_complete: + self.on_complete(None) diff --git a/src/wareflow_analysis/gui/views/export_view.py b/src/wareflow_analysis/gui/views/export_view.py new file mode 100644 index 0000000..fc601fb --- /dev/null +++ b/src/wareflow_analysis/gui/views/export_view.py @@ -0,0 +1,410 @@ +"""Export view for the GUI. + +This module provides the export view for generating Excel reports +from analysis results. +""" + +import customtkinter as ctk +from pathlib import Path +from typing import Optional, Callable +from datetime import datetime + + +class ExportView(ctk.CTkFrame): + """Export view for generating Excel reports. + + This view provides: + - Analysis source selection + - Output filename and directory configuration + - Export execution with progress tracking + - Export completion dialog + + Attributes: + master: Parent widget + state_manager: StateManager instance + db_path: Path to database + on_complete: Optional callback when export completes + """ + + def __init__( + self, + master, + state_manager, + db_path, + on_complete: Optional[Callable] = None, + **kwargs + ): + """Initialize the ExportView. + + Args: + master: Parent widget + state_manager: StateManager instance + db_path: Path to database + on_complete: Optional callback when export completes + **kwargs: Additional arguments for CTkFrame + """ + super().__init__(master, **kwargs) + + self.state_manager = state_manager + self.db_path = db_path + self.on_complete = on_complete + self.is_exporting = False + + self._build_ui() + + def _build_ui(self) -> None: + """Build the UI components.""" + # Configure grid + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=0) # Title + self.grid_rowconfigure(1, weight=0) # Analysis source + self.grid_rowconfigure(2, weight=0) # Output config + self.grid_rowconfigure(3, weight=0) # Progress + self.grid_rowconfigure(4, weight=1) # Log + self.grid_rowconfigure(5, weight=0) # Buttons + + # Title + title_label = ctk.CTkLabel( + self, + text="📤 Export Analysis Report", + font=ctk.CTkFont(size=24, weight="bold") + ) + title_label.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="ew") + + # Analysis source section + self._build_analysis_source() + + # Output configuration section + self._build_output_config() + + # Progress section + self._build_progress() + + # Log section + self._build_log() + + # Action buttons + self._build_buttons() + + def _build_analysis_source(self) -> None: + """Build the analysis source selection section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=1, column=0, padx=20, pady=10, sticky="ew") + + ctk.CTkLabel( + frame, + text="Analysis Source", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + # Analysis type selection + radio_frame = ctk.CTkFrame(frame, fg_color="transparent") + radio_frame.pack(padx=15, pady=(0, 15)) + + self.analysis_var = ctk.StringVar(value="inventory") + + ctk.CTkRadioButton( + radio_frame, + text="Inventory Analysis Report", + variable=self.analysis_var, + value="inventory" + ).pack(anchor="w", pady=2) + + ctk.CTkRadioButton( + radio_frame, + text="ABC Classification Report", + variable=self.analysis_var, + value="abc" + ).pack(anchor="w", pady=2) + + def _build_output_config(self) -> None: + """Build the output configuration section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=2, column=0, padx=20, pady=10, sticky="ew") + + ctk.CTkLabel( + frame, + text="Output Configuration", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + config_frame = ctk.CTkFrame(frame, fg_color="transparent") + config_frame.pack(padx=15, pady=(0, 15), fill="x") + + # Output directory + dir_frame = ctk.CTkFrame(config_frame, fg_color="transparent") + dir_frame.grid(row=0, column=0, sticky="ew", pady=5) + config_frame.grid_rowconfigure(0, weight=1) + + ctk.CTkLabel(dir_frame, text="Output Directory:", width=120).grid(row=0, column=0, sticky="w") + + self.dir_entry = ctk.CTkEntry(dir_frame) + default_dir = self.state_manager.get_project_dir() / "output" if self.state_manager.is_project_loaded() else "output" + self.dir_entry.insert(0, str(default_dir)) + self.dir_entry.grid(row=0, column=1, sticky="ew", padx=(10, 5)) + dir_frame.grid_columnconfigure(1, weight=1) + + ctk.CTkButton( + dir_frame, + text="Browse...", + width=80, + command=self._on_browse_dir + ).grid(row=0, column=2) + + # Output filename + file_frame = ctk.CTkFrame(config_frame, fg_color="transparent") + file_frame.grid(row=1, column=0, sticky="ew", pady=5) + + ctk.CTkLabel(file_frame, text="Filename:", width=120).grid(row=0, column=0, sticky="w") + + self.file_entry = ctk.CTkEntry(file_frame) + self.file_entry.grid(row=0, column=1, sticky="ew", padx=(10, 5)) + file_frame.grid_columnconfigure(1, weight=1) + + ctk.CTkButton( + file_frame, + text="Auto-generate", + width=100, + command=self._on_autogenerate_filename + ).grid(row=0, column=2) + + # Checkbox for auto-filename + self.autogen_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox( + config_frame, + text="Auto-generate filename with timestamp", + variable=self.autogen_var + ).grid(row=2, column=0, sticky="w", pady=(5, 0)) + + def _build_progress(self) -> None: + """Build the progress section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=3, column=0, padx=20, pady=10, sticky="ew") + + self.status_label = ctk.CTkLabel( + frame, + text="Ready to export", + font=ctk.CTkFont(size=12) + ) + self.status_label.pack(padx=15, pady=(15, 10)) + + self.progress_bar = ctk.CTkProgressBar(frame) + self.progress_bar.pack(padx=15, pady=(0, 15), fill="x") + self.progress_bar.set(0) + + def _build_log(self) -> None: + """Build the log section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=4, column=0, padx=20, pady=10, sticky="nsew") + + ctk.CTkLabel( + frame, + text="Export Log", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + self.log_text = ctk.CTkTextbox( + frame, + font=ctk.CTkFont(family="Consolas", size=11) + ) + self.log_text.pack(padx=15, pady=(0, 15), fill="both", expand=True) + + def _build_buttons(self) -> None: + """Build the action buttons section.""" + frame = ctk.CTkFrame(self, fg_color="transparent") + frame.grid(row=5, column=0, padx=20, pady=(10, 20), sticky="ew") + + button_frame = ctk.CTkFrame(frame, fg_color="transparent") + button_frame.pack() + + self.export_btn = ctk.CTkButton( + button_frame, + text="▶ Export Report", + width=150, + height=40, + fg_color="green", + hover_color="darkgreen", + command=self._on_export + ) + self.export_btn.grid(row=0, column=0, padx=5) + + self.open_folder_btn = ctk.CTkButton( + button_frame, + text="Open Folder", + width=150, + command=self._on_open_folder + ) + self.open_folder_btn.grid(row=0, column=1, padx=5) + + self.close_btn = ctk.CTkButton( + button_frame, + text="Close", + width=150, + command=self._on_close + ) + self.close_btn.grid(row=0, column=2, padx=5) + + def _on_browse_dir(self) -> None: + """Handle browse directory button click.""" + from tkinter import filedialog + + path = filedialog.askdirectory(title="Select Output Directory") + + if path: + self.dir_entry.delete(0, "end") + self.dir_entry.insert(0, path) + + def _on_autogenerate_filename(self) -> None: + """Handle auto-generate filename button click.""" + analysis = self.analysis_var.get() + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{analysis}_report_{timestamp}.xlsx" + self.file_entry.delete(0, "end") + self.file_entry.insert(0, filename) + + def _on_export(self) -> None: + """Handle export button click.""" + if self.is_exporting: + return + + if not self.state_manager.is_database_ready(): + self._log("Error: Database not ready. Please import data first.") + return + + self.is_exporting = True + self.export_btn.configure(state="disabled") + self.progress_bar.set(0) + self.status_label.configure(text="Exporting...") + + # Get output path + if self.autogen_var.get(): + self._on_autogenerate_filename() + + output_dir = Path(self.dir_entry.get()) + output_filename = self.file_entry.get() + output_path = output_dir / output_filename + + self._log(f"Starting export to: {output_path}") + + # Run export in thread + from wareflow_analysis.gui.widgets import run_in_thread + + def export_operation(): + analysis = self.analysis_var.get() + + # Run analysis first + if analysis == "inventory": + from wareflow_analysis.analyze.inventory import InventoryAnalysis + analyzer = InventoryAnalysis(self.db_path) + success, message = analyzer.connect() + + if not success: + return False, message + + try: + results = analyzer.run() + analyzer.close() + except Exception as e: + analyzer.close() + return False, str(e) + + # Export + from wareflow_analysis.export.reports.inventory_report import InventoryReportExporter + exporter = InventoryReportExporter() + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + exporter.export(results, output_path) + return True, f"Inventory report exported to {output_path}" + except Exception as e: + return False, f"Export failed: {e}" + + elif analysis == "abc": + from wareflow_analysis.analyze.abc import ABCAnalysis + analyzer = ABCAnalysis(self.db_path) + success, message = analyzer.connect() + + if not success: + return False, message + + try: + results = analyzer.run(days=90) + analyzer.close() + except Exception as e: + analyzer.close() + return False, str(e) + + # Export + from wareflow_analysis.export.reports.abc_report import ABCReportExporter + exporter = ABCReportExporter() + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + exporter.export(results, output_path) + return True, f"ABC report exported to {output_path}" + except Exception as e: + return False, f"Export failed: {e}" + + def on_complete(result): + success, message = result + + if success: + self._log(f"✓ {message}") + self.status_label.configure(text="Export completed successfully") + self.progress_bar.set(1.0) + + if self.on_complete: + self.on_complete(str(output_path)) + else: + self._log(f"✗ {message}") + self.status_label.configure(text="Export failed") + + self.is_exporting = False + self.export_btn.configure(state="normal") + + def on_error(error): + self._log(f"✗ Exception: {error}") + self.status_label.configure(text="Export failed with exception") + self.is_exporting = False + self.export_btn.configure(state="normal") + + run_in_thread( + operation=export_operation, + on_complete=on_complete, + on_error=on_error + ) + + def _on_open_folder(self) -> None: + """Handle open folder button click.""" + import subprocess + import platform + + output_dir = Path(self.dir_entry.get()) + + if not output_dir.exists(): + self._log(f"Error: Directory does not exist: {output_dir}") + return + + try: + if platform.system() == "Windows": + subprocess.run(f'explorer "{output_dir}"') + elif platform.system() == "Darwin": # macOS + subprocess.run(["open", str(output_dir)]) + else: # Linux + subprocess.run(["xdg-open", str(output_dir)]) + except Exception as e: + self._log(f"Error opening folder: {e}") + + def _on_close(self) -> None: + """Handle close button click.""" + if self.on_complete: + self.on_complete(None) + + def _log(self, message: str) -> None: + """Add a message to the log. + + Args: + message: Message to log + """ + self.log_text.insert("end", f"{message}\n") + self.log_text.see("end") diff --git a/src/wareflow_analysis/gui/views/home_view.py b/src/wareflow_analysis/gui/views/home_view.py new file mode 100644 index 0000000..b173e1a --- /dev/null +++ b/src/wareflow_analysis/gui/views/home_view.py @@ -0,0 +1,317 @@ +"""Home view (dashboard) for the GUI. + +This module provides the main dashboard view showing project status, +database statistics, and quick action buttons. +""" + +import customtkinter as ctk +from pathlib import Path +from typing import Optional, Callable + + +class HomeView(ctk.CTkFrame): + """Home dashboard view. + + This view displays: + - Project path and status + - Database statistics + - Quick action buttons + - Recent activity log + + Attributes: + master: Parent widget + state_manager: StateManager instance + on_action_callback: Callback for action button clicks + """ + + def __init__( + self, + master, + state_manager, + on_action_callback: Optional[Callable[[str], None]] = None, + **kwargs + ): + """Initialize the HomeView. + + Args: + master: Parent widget + state_manager: StateManager instance + on_action_callback: Optional callback for action buttons + **kwargs: Additional arguments for CTkFrame + """ + super().__init__(master, **kwargs) + + self.state_manager = state_manager + self.on_action_callback = on_action_callback + + self._build_ui() + self._refresh_display() + + # Register for state changes + self.state_manager.register_listener(self._on_state_change) + + def _build_ui(self) -> None: + """Build the UI components.""" + # Configure grid + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=0) # Header + self.grid_rowconfigure(1, weight=0) # Project info + self.grid_rowconfigure(2, weight=0) # Database stats + self.grid_rowconfigure(3, weight=0) # Quick actions + self.grid_rowconfigure(4, weight=1) # Activity log + + # Title + title_label = ctk.CTkLabel( + self, + text="📦 Wareflow Analysis Dashboard", + font=ctk.CTkFont(size=24, weight="bold") + ) + title_label.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="ew") + + # Project Info Frame + self._build_project_info() + + # Database Stats Frame + self._build_database_stats() + + # Quick Actions Frame + self._build_quick_actions() + + # Activity Log Frame + self._build_activity_log() + + def _build_project_info(self) -> None: + """Build the project information section.""" + frame = ctk.CTkFrame(self, fg_color="transparent") + frame.grid(row=1, column=0, padx=20, pady=10, sticky="ew") + + frame.grid_columnconfigure(0, weight=0) + frame.grid_columnconfigure(1, weight=1) + frame.grid_columnconfigure(2, weight=0) + + # Label + ctk.CTkLabel( + frame, + text="Project:", + font=ctk.CTkFont(size=14, weight="bold") + ).grid(row=0, column=0, padx=(0, 10), sticky="w") + + # Project path + self.project_path_label = ctk.CTkLabel( + frame, + text="No project loaded", + font=ctk.CTkFont(size=13) + ) + self.project_path_label.grid(row=0, column=1, sticky="w") + + # Buttons frame + button_frame = ctk.CTkFrame(frame, fg_color="transparent") + button_frame.grid(row=0, column=2, padx=(10, 0)) + + # New project button + new_btn = ctk.CTkButton( + button_frame, + text="+ New", + width=80, + fg_color="green", + hover_color="darkgreen", + command=self._on_new_project + ) + new_btn.grid(row=0, column=0, padx=2) + + # Browse button + browse_btn = ctk.CTkButton( + button_frame, + text="Open...", + width=80, + command=self._on_open_project + ) + browse_btn.grid(row=0, column=1, padx=2) + + def _build_database_stats(self) -> None: + """Build the database statistics section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=2, column=0, padx=20, pady=10, sticky="ew") + + # Title + ctk.CTkLabel( + frame, + text="Database Status", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + # Stats container + self.stats_text = ctk.CTkTextbox( + frame, + height=120, + font=ctk.CTkFont(family="Consolas", size=12) + ) + self.stats_text.pack(padx=15, pady=(0, 15), fill="both", expand=True) + self.stats_text.configure(state="disabled") + + def _build_quick_actions(self) -> None: + """Build the quick actions section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=3, column=0, padx=20, pady=10, sticky="ew") + + # Title + ctk.CTkLabel( + frame, + text="Quick Actions", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + # Button container + button_frame = ctk.CTkFrame(frame, fg_color="transparent") + button_frame.pack(padx=15, pady=(0, 15)) + + # Action buttons + actions = [ + ("📥 Import Data", "import"), + ("📊 Run ABC Analysis", "analyze_abc"), + ("📈 Run Inventory Analysis", "analyze_inventory"), + ("📤 Export Report", "export"), + ("✓ Validate Data", "validate"), + ] + + for i, (label, action) in enumerate(actions): + btn = ctk.CTkButton( + button_frame, + text=label, + width=180, + height=35, + command=lambda a=action: self._on_action(a) + ) + btn.grid(row=i // 3, column=i % 3, padx=5, pady=5) + + def _build_activity_log(self) -> None: + """Build the activity log section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=4, column=0, padx=20, pady=(10, 20), sticky="nsew") + + # Title + ctk.CTkLabel( + frame, + text="Recent Activity", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + # Log text + self.activity_text = ctk.CTkTextbox( + frame, + font=ctk.CTkFont(family="Consolas", size=11) + ) + self.activity_text.pack(padx=15, pady=(0, 15), fill="both", expand=True) + self.activity_text.configure(state="disabled") + + def _on_new_project(self) -> None: + """Handle new project button click.""" + from wareflow_analysis.gui.widgets.project_dialog import NewProjectDialog + + NewProjectDialog( + self, + on_project_created=self._on_project_created + ) + + def _on_open_project(self) -> None: + """Handle open project button click.""" + from wareflow_analysis.gui.widgets.project_dialog import OpenProjectDialog + + OpenProjectDialog( + self, + on_project_opened=self._on_project_opened + ) + + def _on_project_created(self, project_path: Path) -> None: + """Handle project creation callback. + + Args: + project_path: Path to the created project + """ + success = self.state_manager.set_project_dir(project_path) + if success: + self._refresh_display() + self._log(f"Project created: {project_path.name}") + else: + self._log("Error: Failed to load newly created project") + + def _on_project_opened(self, project_path: Path) -> None: + """Handle project opened callback. + + Args: + project_path: Path to the opened project + """ + success = self.state_manager.set_project_dir(project_path) + if success: + self._refresh_display() + self._log(f"Project opened: {project_path.name}") + else: + self._log("Error: Failed to load project") + + def _on_action(self, action: str) -> None: + """Handle action button click. + + Args: + action: Action identifier + """ + if self.on_action_callback: + self.on_action_callback(action) + + def _on_state_change(self, event: str) -> None: + """Handle state change events. + + Args: + event: Event type + """ + if event in ["project_changed", "database_changed"]: + self._refresh_display() + + def _refresh_display(self) -> None: + """Refresh the display with current state.""" + # Update project path + if self.state_manager.is_project_loaded(): + project_path = str(self.state_manager.get_project_dir()) + self.project_path_label.configure(text=project_path) + else: + self.project_path_label.configure(text="No project loaded") + + # Update database stats + self._update_database_stats() + + def _update_database_stats(self) -> None: + """Update database statistics display.""" + self.stats_text.configure(state="normal") + self.stats_text.delete("1.0", "end") + + if not self.state_manager.is_database_ready(): + self.stats_text.insert("1.0", "❌ Database not ready\n\n") + self.stats_text.insert("2.0", "Run 'Import Data' to create the database") + else: + stats = self.state_manager.get_database_stats() + + self.stats_text.insert("1.0", f"✅ Database: {stats.get('database_path', 'N/A')}\n\n") + + tables = stats.get("tables", {}) + if tables: + self.stats_text.insert("end", "Tables:\n") + for table_name, row_count in tables.items(): + self.stats_text.insert("end", f" {table_name:20} {row_count:>10,} rows\n") + else: + self.stats_text.insert("end", "No data imported yet\n") + + self.stats_text.configure(state="disabled") + + def _log(self, message: str) -> None: + """Add a message to the activity log. + + Args: + message: Message to log + """ + self.activity_text.configure(state="normal") + self.activity_text.insert("end", f"• {message}\n") + self.activity_text.see("end") + self.activity_text.configure(state="disabled") + + def cleanup(self) -> None: + """Clean up resources.""" + self.state_manager.unregister_listener(self._on_state_change) diff --git a/src/wareflow_analysis/gui/views/import_view.py b/src/wareflow_analysis/gui/views/import_view.py new file mode 100644 index 0000000..9e2d30f --- /dev/null +++ b/src/wareflow_analysis/gui/views/import_view.py @@ -0,0 +1,397 @@ +"""Import view for the GUI. + +This module provides the import view for importing Excel data +into the database with progress tracking. +""" + +import customtkinter as ctk +from pathlib import Path +from typing import Optional, Callable + + +class ImportView(ctk.CTkFrame): + """Import view for Excel data import. + + This view provides: + - File selection for Excel files + - Configuration generation (Auto-Pilot) + - Import execution with progress tracking + - Import summary with success/error counts + + Attributes: + master: Parent widget + state_manager: StateManager instance + on_complete: Optional callback when import completes + """ + + def __init__(self, master, state_manager, on_complete: Optional[Callable] = None, **kwargs): + """Initialize the ImportView. + + Args: + master: Parent widget + state_manager: StateManager instance + on_complete: Optional callback when import completes + **kwargs: Additional arguments for CTkFrame + """ + super().__init__(master, **kwargs) + + self.state_manager = state_manager + self.on_complete = on_complete + self.is_importing = False + + self._build_ui() + self._load_existing_config() + + def _build_ui(self) -> None: + """Build the UI components.""" + # Configure grid + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=0) # Title + self.grid_rowconfigure(1, weight=0) # Config section + self.grid_rowconfigure(2, weight=0) # File selection + self.grid_rowconfigure(3, weight=0) # Options + self.grid_rowconfigure(4, weight=0) # Progress + self.grid_rowconfigure(5, weight=1) # Output log + self.grid_rowconfigure(6, weight=0) # Buttons + + # Title + title_label = ctk.CTkLabel( + self, + text="📥 Import Excel Data", + font=ctk.CTkFont(size=24, weight="bold") + ) + title_label.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="ew") + + # Configuration section + self._build_config_section() + + # File selection section + self._build_file_selection() + + # Options section + self._build_options() + + # Progress section + self._build_progress() + + # Output log section + self._build_output_log() + + # Action buttons + self._build_action_buttons() + + def _build_config_section(self) -> None: + """Build the configuration generation section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=1, column=0, padx=20, pady=10, sticky="ew") + + ctk.CTkLabel( + frame, + text="1️⃣ Configuration", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + button_frame = ctk.CTkFrame(frame, fg_color="transparent") + button_frame.pack(padx=15, pady=(0, 15)) + + self.generate_config_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox( + button_frame, + text="Generate config from Excel files (Auto-Pilot)", + variable=self.generate_config_var + ).pack(anchor="w") + + def _build_file_selection(self) -> None: + """Build the file selection section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=2, column=0, padx=20, pady=10, sticky="ew") + + ctk.CTkLabel( + frame, + text="2️⃣ Source Files", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + # File entries + file_frame = ctk.CTkFrame(frame, fg_color="transparent") + file_frame.pack(padx=15, pady=(0, 15), fill="x") + + self.file_entries = {} + self.file_labels = { + "Products": "Products Excel file", + "Movements": "Movements Excel file", + "Orders": "Orders Excel file", + } + + for i, (key, label) in enumerate(self.file_labels.items()): + row_frame = ctk.CTkFrame(file_frame, fg_color="transparent") + row_frame.grid(row=i, column=0, sticky="ew", pady=5) + file_frame.grid_rowconfigure(i, weight=1) + + ctk.CTkLabel(row_frame, text=label, width=150).grid(row=0, column=0, sticky="w") + + entry = ctk.CTkEntry(row_frame) + entry.grid(row=0, column=1, sticky="ew", padx=(10, 5)) + row_frame.grid_columnconfigure(1, weight=1) + + btn = ctk.CTkButton( + row_frame, + text="Browse...", + width=80, + command=lambda k=key.lower(): self._on_browse_file(k) + ) + btn.grid(row=0, column=2) + + self.file_entries[key.lower()] = entry + + def _build_options(self) -> None: + """Build the options section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=3, column=0, padx=20, pady=10, sticky="ew") + + ctk.CTkLabel( + frame, + text="3️⃣ Options", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + options_frame = ctk.CTkFrame(frame, fg_color="transparent") + options_frame.pack(padx=15, pady=(0, 15)) + + self.verbose_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox( + options_frame, + text="Show verbose output", + variable=self.verbose_var + ).pack(anchor="w") + + self.backup_var = ctk.BooleanVar(value=True) + ctk.CTkCheckBox( + options_frame, + text="Create backup before import", + variable=self.backup_var + ).pack(anchor="w") + + def _build_progress(self) -> None: + """Build the progress section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=4, column=0, padx=20, pady=10, sticky="ew") + + ctk.CTkLabel( + frame, + text="4️⃣ Progress", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + # Progress bar + self.progress_bar = ctk.CTkProgressBar(frame) + self.progress_bar.pack(padx=15, pady=(0, 10), fill="x") + self.progress_bar.set(0) + + # Status label + self.status_label = ctk.CTkLabel( + frame, + text="Ready to import", + font=ctk.CTkFont(size=12) + ) + self.status_label.pack(padx=15, pady=(0, 15)) + + def _build_output_log(self) -> None: + """Build the output log section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=5, column=0, padx=20, pady=10, sticky="nsew") + + ctk.CTkLabel( + frame, + text="5️⃣ Import Log", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + self.output_text = ctk.CTkTextbox( + frame, + font=ctk.CTkFont(family="Consolas", size=11) + ) + self.output_text.pack(padx=15, pady=(0, 15), fill="both", expand=True) + + def _build_action_buttons(self) -> None: + """Build the action buttons section.""" + frame = ctk.CTkFrame(self, fg_color="transparent") + frame.grid(row=6, column=0, padx=20, pady=(10, 20), sticky="ew") + + button_frame = ctk.CTkFrame(frame, fg_color="transparent") + button_frame.pack() + + self.generate_btn = ctk.CTkButton( + button_frame, + text="Generate Config", + width=150, + command=self._on_generate_config + ) + self.generate_btn.grid(row=0, column=0, padx=5) + + self.import_btn = ctk.CTkButton( + button_frame, + text="Start Import", + width=150, + command=self._on_start_import, + fg_color="green" + ) + self.import_btn.grid(row=0, column=1, padx=5) + + self.close_btn = ctk.CTkButton( + button_frame, + text="Close", + width=150, + command=self._on_close + ) + self.close_btn.grid(row=0, column=2, padx=5) + + def _load_existing_config(self) -> None: + """Load existing configuration if available.""" + if not self.state_manager.is_project_loaded(): + return + + config_path = self.state_manager.config_path + if not config_path or not config_path.exists(): + return + + try: + import yaml + with open(config_path, "r") as f: + config = yaml.safe_load(f) + + # Load file paths from config + imports = config.get("imports", {}) + for key, entry in self.file_entries.items(): + if key in imports: + entry.delete(0, "end") + entry.insert(0, imports[key].get("source", "")) + + self._log("Configuration loaded from existing config.yaml") + except Exception as e: + self._log(f"Error loading config: {e}") + + def _on_browse_file(self, file_key: str) -> None: + """Handle browse file button click. + + Args: + file_key: Key identifying which file to browse for + """ + from tkinter import filedialog + + path = filedialog.askopenfilename( + title=f"Select {self.file_labels[file_key.capitalize()]}", + filetypes=[("Excel files", "*.xlsx *.xls"), ("All files", "*.*")] + ) + + if path: + self.file_entries[file_key].delete(0, "end") + self.file_entries[file_key].insert(0, path) + + def _on_generate_config(self) -> None: + """Handle generate config button click.""" + if not self.state_manager.is_project_loaded(): + self._log("Error: No project loaded") + return + + self._log("Generating configuration with Auto-Pilot...") + + try: + from wareflow_analysis.data_import.importer import init_import_config + + data_dir = self.state_manager.project_dir / "data" + success, message = init_import_config( + data_dir, + self.state_manager.project_dir, + verbose=True + ) + + if success: + self._log(f"✓ {message}") + self._load_existing_config() + else: + self._log(f"✗ Error: {message}") + except Exception as e: + self._log(f"✗ Error generating config: {e}") + + def _on_start_import(self) -> None: + """Handle start import button click.""" + if self.is_importing: + return + + if not self.state_manager.is_project_loaded(): + self._log("Error: No project loaded") + return + + self.is_importing = True + self.import_btn.configure(state="disabled") + self.generate_btn.configure(state="disabled") + self.progress_bar.set(0) + self._log("Starting import...") + + # Run import in thread + from wareflow_analysis.gui.widgets import run_in_thread + + def import_operation(): + from wareflow_analysis.data_import.importer import run_import + + success, message = run_import( + self.state_manager.project_dir, + verbose=self.verbose_var.get() + ) + + return success, message + + def on_complete(result): + success, message = result + if success: + self._log(f"✓ {message}") + self.status_label.configure(text="Import completed successfully") + self.progress_bar.set(1.0) + + # Refresh database stats + self.state_manager.refresh_database_stats() + + if self.on_complete: + self.on_complete(success) + else: + self._log(f"✗ Error: {message}") + self.status_label.configure(text="Import failed") + + self.is_importing = False + self.import_btn.configure(state="normal") + self.generate_btn.configure(state="normal") + + def on_error(error): + self._log(f"✗ Exception: {error}") + self.status_label.configure(text="Import failed with exception") + self.is_importing = False + self.import_btn.configure(state="normal") + self.generate_btn.configure(state="normal") + + def on_progress(message): + self._log(message) + # Update progress bar (estimated) + current = self.progress_bar.get() + self.progress_bar.set(min(current + 0.1, 0.9)) + + run_in_thread( + operation=import_operation, + on_complete=on_complete, + on_error=on_error, + on_progress=on_progress + ) + + def _on_close(self) -> None: + """Handle close button click.""" + if self.on_complete: + self.on_complete(None) + + def _log(self, message: str) -> None: + """Add a message to the output log. + + Args: + message: Message to log + """ + self.output_text.insert("end", f"{message}\n") + self.output_text.see("end") diff --git a/src/wareflow_analysis/gui/views/status_view.py b/src/wareflow_analysis/gui/views/status_view.py new file mode 100644 index 0000000..fc18a5b --- /dev/null +++ b/src/wareflow_analysis/gui/views/status_view.py @@ -0,0 +1,320 @@ +"""Status view for the GUI. + +This module provides the status view showing database status, +configuration information, and project statistics. +""" + +import customtkinter as ctk +from typing import Optional, Callable + + +class StatusView(ctk.CTkFrame): + """Status view for project and database status. + + This view displays: + - Project configuration details + - Database schema and tables + - Table row counts and statistics + - File sizes and timestamps + + Attributes: + master: Parent widget + state_manager: StateManager instance + on_complete: Optional callback when view closes + """ + + def __init__(self, master, state_manager, on_complete: Optional[Callable] = None, **kwargs): + """Initialize the StatusView. + + Args: + master: Parent widget + state_manager: StateManager instance + on_complete: Optional callback when view closes + **kwargs: Additional arguments for CTkFrame + """ + super().__init__(master, **kwargs) + + self.state_manager = state_manager + self.on_complete = on_complete + + self._build_ui() + self._refresh_status() + + # Register for state changes + self.state_manager.register_listener(self._on_state_change) + + def _build_ui(self) -> None: + """Build the UI components.""" + # Configure grid + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=0) # Title + self.grid_rowconfigure(1, weight=0) # Project info + self.grid_rowconfigure(2, weight=1) # Database info + self.grid_rowconfigure(3, weight=1) # Config info + self.grid_rowconfigure(4, weight=0) # Buttons + + # Title + title_label = ctk.CTkLabel( + self, + text="📊 Project Status", + font=ctk.CTkFont(size=24, weight="bold") + ) + title_label.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="ew") + + # Project information section + self._build_project_info() + + # Database information section + self._build_database_info() + + # Configuration information section + self._build_config_info() + + # Action buttons + self._build_buttons() + + def _build_project_info(self) -> None: + """Build the project information section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=1, column=0, padx=20, pady=10, sticky="ew") + + ctk.CTkLabel( + frame, + text="Project Information", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + # Info container + self.project_info_text = ctk.CTkTextbox( + frame, + height=100, + font=ctk.CTkFont(family="Consolas", size=12) + ) + self.project_info_text.pack(padx=15, pady=(0, 15), fill="both", expand=True) + self.project_info_text.configure(state="disabled") + + def _build_database_info(self) -> None: + """Build the database information section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=2, column=0, padx=20, pady=10, sticky="nsew") + + ctk.CTkLabel( + frame, + text="Database Information", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + # Info container + self.database_info_text = ctk.CTkTextbox( + frame, + font=ctk.CTkFont(family="Consolas", size=12) + ) + self.database_info_text.pack(padx=15, pady=(0, 15), fill="both", expand=True) + self.database_info_text.configure(state="disabled") + + def _build_config_info(self) -> None: + """Build the configuration information section.""" + frame = ctk.CTkFrame(self) + frame.grid(row=3, column=0, padx=20, pady=10, sticky="nsew") + + ctk.CTkLabel( + frame, + text="Configuration File (config.yaml)", + font=ctk.CTkFont(size=16, weight="bold") + ).pack(padx=15, pady=(15, 10)) + + # Info container + self.config_info_text = ctk.CTkTextbox( + frame, + font=ctk.CTkFont(family="Consolas", size=12) + ) + self.config_info_text.pack(padx=15, pady=(0, 15), fill="both", expand=True) + self.config_info_text.configure(state="disabled") + + def _build_buttons(self) -> None: + """Build the action buttons section.""" + frame = ctk.CTkFrame(self, fg_color="transparent") + frame.grid(row=4, column=0, padx=20, pady=(10, 20), sticky="ew") + + button_frame = ctk.CTkFrame(frame, fg_color="transparent") + button_frame.pack() + + refresh_btn = ctk.CTkButton( + button_frame, + text="🔄 Refresh", + width=150, + command=self._refresh_status + ) + refresh_btn.grid(row=0, column=0, padx=5) + + close_btn = ctk.CTkButton( + button_frame, + text="Close", + width=150, + command=self._on_close + ) + close_btn.grid(row=0, column=1, padx=5) + + def _refresh_status(self) -> None: + """Refresh all status information.""" + # Update project info + self._update_project_info() + + # Update database info + self._update_database_info() + + # Update config info + self._update_config_info() + + def _update_project_info(self) -> None: + """Update project information display.""" + self.project_info_text.configure(state="normal") + self.project_info_text.delete("1.0", "end") + + if not self.state_manager.is_project_loaded(): + self.project_info_text.insert("1.0", "No project loaded\n") + else: + project_dir = self.state_manager.get_project_dir() + self.project_info_text.insert("1.0", f"Project Path: {project_dir}\n") + self.project_info_text.insert("end", f"Config File: {self.state_manager.config_path}\n") + self.project_info_text.insert("end", f"Database: {self.state_manager.db_path}\n") + + if self.state_manager.last_operation: + self.project_info_text.insert( + "end", + f"\nLast Operation:\n {self.state_manager.last_operation}\n" + ) + if self.state_manager.last_operation_time: + time_str = self.state_manager.last_operation_time.strftime("%Y-%m-%d %H:%M:%S") + self.project_info_text.insert("end", f" at {time_str}\n") + + self.project_info_text.configure(state="disabled") + + def _update_database_info(self) -> None: + """Update database information display.""" + self.database_info_text.configure(state="normal") + self.database_info_text.delete("1.0", "end") + + if not self.state_manager.is_database_ready(): + self.database_info_text.insert("1.0", "❌ Database not ready\n\n") + self.database_info_text.insert("2.0", "Possible reasons:\n") + self.database_info_text.insert("end", " - No project loaded\n") + self.database_info_text.insert("end", " - Database file does not exist\n") + self.database_info_text.insert("end", " - Run 'Import Data' to create the database\n") + else: + stats = self.state_manager.get_database_stats() + + self.database_info_text.insert("1.0", f"✅ Database Status\n\n") + self.database_info_text.insert("end", f"Database: {stats.get('database_path', 'N/A')}\n") + + db_size = self._get_file_size(stats.get('database_path')) + self.database_info_text.insert("end", f"Size: {db_size}\n\n") + + tables = stats.get("tables", {}) + if tables: + total_rows = sum(tables.values()) + self.database_info_text.insert("end", f"Tables ({len(tables)}, {total_rows:,} total rows):\n\n") + + for table_name, row_count in tables.items(): + self.database_info_text.insert( + "end", + f" {table_name:20} {row_count:>10,} rows\n" + ) + else: + self.database_info_text.insert("end", "No tables found or no data imported.\n") + + self.database_info_text.configure(state="disabled") + + def _update_config_info(self) -> None: + """Update configuration information display.""" + self.config_info_text.configure(state="normal") + self.config_info_text.delete("1.0", "end") + + if not self.state_manager.is_project_loaded(): + self.config_info_text.insert("1.0", "No project loaded\n") + else: + config_path = self.state_manager.config_path + + if not config_path or not config_path.exists(): + self.config_info_text.insert("1.0", f"❌ Config file not found: {config_path}\n") + else: + try: + import yaml + + with open(config_path, "r") as f: + config = yaml.safe_load(f) + + self.config_info_text.insert("1.0", f"✅ Configuration File: {config_path}\n") + self.config_info_text.insert("end", f"Size: {self._get_file_size(config_path)}\n\n") + + # Database configuration + if "database" in config: + self.config_info_text.insert("end", "Database Configuration:\n") + db_config = config["database"] + for key, value in db_config.items(): + self.config_info_text.insert("end", f" {key}: {value}\n") + self.config_info_text.insert("end", "\n") + + # Import configuration + if "imports" in config: + imports = config["imports"] + self.config_info_text.insert("end", f"Import Configuration ({len(imports)} imports):\n\n") + + for table_name, import_config in imports.items(): + self.config_info_text.insert("end", f" Table: {table_name}\n") + self.config_info_text.insert("end", f" Source: {import_config.get('source', 'N/A')}\n") + self.config_info_text.insert("end", f" Primary Key: {import_config.get('primary_key', 'N/A')}\n") + self.config_info_text.insert("end", "\n") + + except Exception as e: + self.config_info_text.insert("1.0", f"❌ Error reading config: {e}\n") + + self.config_info_text.configure(state="disabled") + + def _get_file_size(self, path) -> str: + """Get human-readable file size. + + Args: + path: Path to file + + Returns: + Human-readable file size + """ + try: + from pathlib import Path + + path = Path(path) + if not path.exists(): + return "N/A" + + size = path.stat().st_size + + for unit in ["B", "KB", "MB", "GB"]: + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + + return f"{size:.1f} TB" + except Exception: + return "N/A" + + def _on_state_change(self, event: str) -> None: + """Handle state change events. + + Args: + event: Event type + """ + if event in ["project_changed", "database_changed"]: + self._refresh_status() + + def _on_close(self) -> None: + """Handle close button click.""" + # Unregister listener + self.state_manager.unregister_listener(self._on_state_change) + + if self.on_complete: + self.on_complete(None) + + def cleanup(self) -> None: + """Clean up resources.""" + self.state_manager.unregister_listener(self._on_state_change) diff --git a/src/wareflow_analysis/gui/widgets/__init__.py b/src/wareflow_analysis/gui/widgets/__init__.py new file mode 100644 index 0000000..d0783bc --- /dev/null +++ b/src/wareflow_analysis/gui/widgets/__init__.py @@ -0,0 +1,17 @@ +"""GUI widgets for wareflow-analysis.""" + +from wareflow_analysis.gui.widgets.threaded_operation import ( + ThreadedOperation, + run_in_thread, +) +from wareflow_analysis.gui.widgets.project_dialog import ( + NewProjectDialog, + OpenProjectDialog, +) + +__all__ = [ + "ThreadedOperation", + "run_in_thread", + "NewProjectDialog", + "OpenProjectDialog", +] diff --git a/src/wareflow_analysis/gui/widgets/project_dialog.py b/src/wareflow_analysis/gui/widgets/project_dialog.py new file mode 100644 index 0000000..a039fc6 --- /dev/null +++ b/src/wareflow_analysis/gui/widgets/project_dialog.py @@ -0,0 +1,376 @@ +"""Project management dialog for creating and opening projects. + +This module provides dialogs for: +- Creating a new wareflow project +- Opening an existing project +""" + +import os +import customtkinter as ctk +from pathlib import Path +from typing import Optional, Callable + + +class NewProjectDialog(ctk.CTkToplevel): + """Dialog for creating a new project. + + This dialog allows users to: + - Choose a project directory + - Enter a project name + - Create a new wareflow project + + Attributes: + parent: Parent window + on_project_created: Callback when project is created + """ + + def __init__( + self, + parent, + on_project_created: Optional[Callable[[Path], None]] = None, + **kwargs + ): + """Initialize the NewProjectDialog. + + Args: + parent: Parent window + on_project_created: Optional callback when project is created + **kwargs: Additional arguments for CTkToplevel + """ + super().__init__(parent, **kwargs) + + self.on_project_created = on_project_created + self.project_path: Optional[Path] = None + + self._setup_window() + self._build_ui() + + # Make modal + self.grab_set() + + def _setup_window(self) -> None: + """Setup window properties.""" + self.title("Create New Project") + self.geometry("500x350") + + # Center on parent + self.update_idletasks() + if self.master: + x = self.master.winfo_x() + (self.master.winfo_width() - 500) // 2 + y = self.master.winfo_y() + (self.master.winfo_height() - 350) // 2 + self.geometry(f"+{x}+{y}") + + def _build_ui(self) -> None: + """Build the UI components.""" + # Configure grid + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=0) # Title + self.grid_rowconfigure(1, weight=0) # Location + self.grid_rowconfigure(2, weight=0) # Name + self.grid_rowconfigure(3, weight=1) # Info + self.grid_rowconfigure(4, weight=0) # Buttons + + # Title + title = ctk.CTkLabel( + self, + text="📁 Create New Wareflow Project", + font=ctk.CTkFont(size=18, weight="bold") + ) + title.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="ew") + + # Location frame + location_frame = ctk.CTkFrame(self, fg_color="transparent") + location_frame.grid(row=1, column=0, padx=20, pady=10, sticky="ew") + location_frame.grid_columnconfigure(0, weight=0) + location_frame.grid_columnconfigure(1, weight=1) + location_frame.grid_columnconfigure(2, weight=0) + + ctk.CTkLabel( + location_frame, + text="Location:", + font=ctk.CTkFont(size=12, weight="bold") + ).grid(row=0, column=0, padx=(0, 10), sticky="w") + + self.location_entry = ctk.CTkEntry(location_frame, placeholder_text="Select parent directory") + self.location_entry.grid(row=0, column=1, padx=5, sticky="ew") + self.location_entry.insert(0, str(Path.home() / "Documents")) + + browse_btn = ctk.CTkButton( + location_frame, + text="Browse...", + width=80, + command=self._browse_location + ) + browse_btn.grid(row=0, column=2, padx=(5, 0), sticky="e") + + # Name frame + name_frame = ctk.CTkFrame(self, fg_color="transparent") + name_frame.grid(row=2, column=0, padx=20, pady=10, sticky="ew") + name_frame.grid_columnconfigure(0, weight=0) + name_frame.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel( + name_frame, + text="Project name:", + font=ctk.CTkFont(size=12, weight="bold") + ).grid(row=0, column=0, padx=(0, 10), sticky="w") + + self.name_entry = ctk.CTkEntry(name_frame, placeholder_text="my-warehouse") + self.name_entry.grid(row=0, column=1, padx=5, sticky="ew") + + # Info text + info_label = ctk.CTkLabel( + self, + text="This will create a new directory with:\n" + "• config.yaml\n" + "• data/ (for Excel files)\n" + "• output/ (for reports)\n" + "• scripts/ (for custom scripts)", + font=ctk.CTkFont(size=11), + justify="left" + ) + info_label.grid(row=3, column=0, padx=20, pady=10, sticky="nw") + + # Buttons + button_frame = ctk.CTkFrame(self, fg_color="transparent") + button_frame.grid(row=4, column=0, padx=20, pady=(10, 20), sticky="ew") + button_frame.grid_columnconfigure(0, weight=1) + button_frame.grid_columnconfigure(1, weight=0) + button_frame.grid_columnconfigure(2, weight=0) + + ctk.CTkButton( + button_frame, + text="Cancel", + width=100, + command=self.destroy + ).grid(row=0, column=1, padx=5) + + create_btn = ctk.CTkButton( + button_frame, + text="Create", + width=100, + fg_color="green", + hover_color="darkgreen", + command=self._create_project + ) + create_btn.grid(row=0, column=2, padx=5) + + def _browse_location(self) -> None: + """Browse for project location.""" + from tkinter import filedialog + + location = filedialog.askdirectory( + title="Select Parent Directory", + initialdir=self.location_entry.get() + ) + + if location: + self.location_entry.delete(0, "end") + self.location_entry.insert(0, location) + + def _create_project(self) -> None: + """Create the project.""" + location = self.location_entry.get().strip() + name = self.name_entry.get().strip() + + if not location: + self._show_error("Please select a location") + return + + if not name: + self._show_error("Please enter a project name") + return + + # Validate name + if not name.replace("-", "").replace("_", "").isalnum(): + self._show_error( + "Project name must contain only letters, numbers, hyphens, and underscores" + ) + return + + # Create project path + project_path = Path(location) / name + + if project_path.exists(): + self._show_error(f"Directory '{name}' already exists") + return + + try: + # Import and call the initialization function directly + from wareflow_analysis.init import initialize_project + + # Initialize the project + success, message = initialize_project(name, Path(location)) + + if not success: + self._show_error(f"Failed to create project: {message}") + return + + # Store project path + self.project_path = project_path + + # Call callback + if self.on_project_created: + self.on_project_created(project_path) + + self._show_success("Project created successfully!") + self.destroy() + + except Exception as e: + self._show_error(f"Failed to create project: {str(e)}") + + def _show_error(self, message: str) -> None: + """Show an error message. + + Args: + message: Error message + """ + from tkinter import messagebox + messagebox.showerror("Error", message, parent=self) + + def _show_success(self, message: str) -> None: + """Show a success message. + + Args: + message: Success message + """ + from tkinter import messagebox + messagebox.showinfo("Success", message, parent=self) + + +class OpenProjectDialog(ctk.CTkToplevel): + """Dialog for opening an existing project. + + This dialog allows users to browse and select an existing + wareflow project directory. + + Attributes: + parent: Parent window + on_project_opened: Callback when project is opened + """ + + def __init__( + self, + parent, + on_project_opened: Optional[Callable[[Path], None]] = None, + **kwargs + ): + """Initialize the OpenProjectDialog. + + Args: + parent: Parent window + on_project_opened: Optional callback when project is opened + **kwargs: Additional arguments for CTkToplevel + """ + super().__init__(parent, **kwargs) + + self.on_project_opened = on_project_opened + self.project_path: Optional[Path] = None + + self._setup_window() + self._build_ui() + + # Make modal + self.grab_set() + + def _setup_window(self) -> None: + """Setup window properties.""" + self.title("Open Project") + self.geometry("500x200") + + # Center on parent + self.update_idletasks() + if self.master: + x = self.master.winfo_x() + (self.master.winfo_width() - 500) // 2 + y = self.master.winfo_y() + (self.master.winfo_height() - 200) // 2 + self.geometry(f"+{x}+{y}") + + def _build_ui(self) -> None: + """Build the UI components.""" + # Configure grid + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=0) # Title + self.grid_rowconfigure(1, weight=1) # Info + self.grid_rowconfigure(2, weight=0) # Buttons + + # Title + title = ctk.CTkLabel( + self, + text="📂 Open Wareflow Project", + font=ctk.CTkFont(size=18, weight="bold") + ) + title.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="ew") + + # Info text + info_label = ctk.CTkLabel( + self, + text="Select a directory containing config.yaml\n" + "to open an existing wareflow project.", + font=ctk.CTkFont(size=11), + justify="center" + ) + info_label.grid(row=1, column=0, padx=20, pady=10, sticky="ew") + + # Buttons + button_frame = ctk.CTkFrame(self, fg_color="transparent") + button_frame.grid(row=2, column=0, padx=20, pady=(10, 20), sticky="ew") + button_frame.grid_columnconfigure(0, weight=1) + button_frame.grid_columnconfigure(1, weight=0) + button_frame.grid_columnconfigure(2, weight=0) + + ctk.CTkButton( + button_frame, + text="Cancel", + width=100, + command=self.destroy + ).grid(row=0, column=1, padx=5) + + browse_btn = ctk.CTkButton( + button_frame, + text="Browse...", + width=100, + fg_color="blue", + hover_color="darkblue", + command=self._browse_project + ) + browse_btn.grid(row=0, column=2, padx=5) + + def _browse_project(self) -> None: + """Browse for project directory.""" + from tkinter import filedialog + + project_dir = filedialog.askdirectory( + title="Select Project Directory", + initialdir=str(Path.home()) + ) + + if not project_dir: + return + + project_path = Path(project_dir) + + # Check if it's a valid project + config_file = project_path / "config.yaml" + if not config_file.exists(): + self._show_error( + f"'{project_path.name}' is not a valid wareflow project.\n" + f"config.yaml not found." + ) + return + + self.project_path = project_path + + # Call callback + if self.on_project_opened: + self.on_project_opened(project_path) + + self.destroy() + + def _show_error(self, message: str) -> None: + """Show an error message. + + Args: + message: Error message + """ + from tkinter import messagebox + messagebox.showerror("Error", message, parent=self) diff --git a/src/wareflow_analysis/gui/widgets/threaded_operation.py b/src/wareflow_analysis/gui/widgets/threaded_operation.py new file mode 100644 index 0000000..87d5fb2 --- /dev/null +++ b/src/wareflow_analysis/gui/widgets/threaded_operation.py @@ -0,0 +1,135 @@ +"""Threaded operation utility for GUI. + +This module provides utilities for running operations in background threads +to prevent GUI freezing during long-running tasks. +""" + +import threading +from queue import Queue +from typing import Callable, Optional, Any + + +class ThreadedOperation: + """Run an operation in a background thread. + + This class executes a function in a separate thread and provides + progress updates and result handling through callbacks. + + Attributes: + operation: The function to execute + callback: Optional callback for progress updates + completion_callback: Optional callback when operation completes + error_callback: Optional callback for error handling + queue: Queue for thread communication + thread: Background thread instance + """ + + def __init__( + self, + operation: Callable, + callback: Optional[Callable[[str], None]] = None, + completion_callback: Optional[Callable[[Any], None]] = None, + error_callback: Optional[Callable[[Exception], None]] = None, + ): + """Initialize the ThreadedOperation. + + Args: + operation: Function to execute in background thread + callback: Optional callback for progress updates + completion_callback: Optional callback when operation completes + error_callback: Optional callback for error handling + """ + self.operation = operation + self.callback = callback + self.completion_callback = completion_callback + self.error_callback = error_callback + self.queue = Queue() + self.thread: Optional[threading.Thread] = None + self._is_running = False + + def start(self) -> None: + """Start the operation in a background thread.""" + if self._is_running: + raise RuntimeError("Operation is already running") + + self._is_running = True + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + + def _run(self) -> None: + """Run the operation and handle result.""" + try: + result = self.operation() + self.queue.put(("success", result)) + + if self.completion_callback: + self.completion_callback(result) + except Exception as e: + self.queue.put(("error", e)) + + if self.error_callback: + self.error_callback(e) + finally: + self._is_running = False + + def is_running(self) -> bool: + """Check if operation is currently running. + + Returns: + True if operation is running, False otherwise + """ + return self._is_running + + def get_result(self, timeout: Optional[float] = None) -> tuple[str, Any]: + """Get the operation result (blocking). + + Args: + timeout: Optional timeout in seconds + + Returns: + Tuple of (status, result) where status is "success" or "error" + """ + return self.queue.get(timeout=timeout) + + def wait(self, timeout: Optional[float] = None) -> bool: + """Wait for operation to complete. + + Args: + timeout: Optional timeout in seconds + + Returns: + True if operation completed, False if timeout + """ + if self.thread: + self.thread.join(timeout=timeout) + return not self._is_running + return False + + +def run_in_thread( + operation: Callable, + on_progress: Optional[Callable[[str], None]] = None, + on_complete: Optional[Callable[[Any], None]] = None, + on_error: Optional[Callable[[Exception], None]] = None, +) -> ThreadedOperation: + """Run an operation in a background thread. + + This is a convenience function that creates and starts a ThreadedOperation. + + Args: + operation: Function to execute + on_progress: Optional callback for progress updates + on_complete: Optional callback when complete + on_error: Optional callback for errors + + Returns: + ThreadedOperation instance + """ + threaded_op = ThreadedOperation( + operation=operation, + callback=on_progress, + completion_callback=on_complete, + error_callback=on_error, + ) + threaded_op.start() + return threaded_op diff --git a/tests/common/__init__.py b/tests/common/__init__.py new file mode 100644 index 0000000..13e0aed --- /dev/null +++ b/tests/common/__init__.py @@ -0,0 +1 @@ +"""Tests for common utilities.""" diff --git a/tests/common/test_output_handler.py b/tests/common/test_output_handler.py new file mode 100644 index 0000000..e4eb100 --- /dev/null +++ b/tests/common/test_output_handler.py @@ -0,0 +1,189 @@ +"""Tests for OutputHandler.""" + +import pytest +from wareflow_analysis.common.output_handler import OutputHandler + + +class TestOutputHandler: + """Test suite for OutputHandler class.""" + + def test_init_cli_mode(self): + """Test OutputHandler initialization in CLI mode.""" + handler = OutputHandler(mode="cli") + assert handler.mode == "cli" + assert handler.verbose is True + assert handler.callback is None + + def test_init_gui_mode(self): + """Test OutputHandler initialization in GUI mode.""" + callback = lambda msg: None + handler = OutputHandler(mode="gui", callback=callback) + assert handler.mode == "gui" + assert handler.callback == callback + + def test_init_invalid_mode(self): + """Test OutputHandler with invalid mode raises ValueError.""" + with pytest.raises(ValueError, match="Invalid mode"): + OutputHandler(mode="invalid") + + def test_print_in_cli_mode(self, capsys): + """Test print method in CLI mode.""" + handler = OutputHandler(mode="cli", verbose=True) + handler.print("Test message") + + captured = capsys.readouterr() + assert "Test message" in captured.out + + def test_print_in_gui_mode(self): + """Test print method in GUI mode.""" + messages = [] + callback = messages.append + + handler = OutputHandler(mode="gui", callback=callback, verbose=True) + handler.print("Test message") + + assert len(messages) == 1 + assert messages[0] == "Test message" + + def test_print_respects_verbose(self, capsys): + """Test that print respects verbose setting.""" + handler = OutputHandler(mode="cli", verbose=False) + handler.print("Test message") + + captured = capsys.readouterr() + assert "Test message" not in captured.out + + def test_print_with_force(self, capsys): + """Test print with force flag overrides verbose.""" + handler = OutputHandler(mode="cli", verbose=False) + handler.print("Test message", force=True) + + captured = capsys.readouterr() + assert "Test message" in captured.out + + def test_error_in_cli_mode(self, capsys): + """Test error method in CLI mode.""" + handler = OutputHandler(mode="cli") + handler.error("Test error") + + captured = capsys.readouterr() + assert "Error: Test error" in captured.err + + def test_error_in_gui_mode(self): + """Test error method in GUI mode.""" + messages = [] + callback = messages.append + + handler = OutputHandler(mode="gui", callback=callback) + handler.error("Test error") + + assert len(messages) == 1 + assert "ERROR: Test error" in messages[0] + + def test_warning_in_cli_mode(self, capsys): + """Test warning method in CLI mode.""" + handler = OutputHandler(mode="cli") + handler.warning("Test warning") + + captured = capsys.readouterr() + assert "Warning: Test warning" in captured.out + + def test_warning_in_gui_mode(self): + """Test warning method in GUI mode.""" + messages = [] + callback = messages.append + + handler = OutputHandler(mode="gui", callback=callback) + handler.warning("Test warning") + + assert len(messages) == 1 + assert "WARNING: Test warning" in messages[0] + + def test_success_in_cli_mode(self, capsys): + """Test success method in CLI mode.""" + handler = OutputHandler(mode="cli") + handler.success("Test success") + + captured = capsys.readouterr() + assert "✓ Test success" in captured.out + + def test_success_in_gui_mode(self): + """Test success method in GUI mode.""" + messages = [] + callback = messages.append + + handler = OutputHandler(mode="gui", callback=callback) + handler.success("Test success") + + assert len(messages) == 1 + assert "SUCCESS: Test success" in messages[0] + + def test_info_in_cli_mode(self, capsys): + """Test info method in CLI mode.""" + handler = OutputHandler(mode="cli", verbose=True) + handler.info("Test info") + + captured = capsys.readouterr() + assert "Test info" in captured.out + + def test_info_respects_verbose(self, capsys): + """Test info respects verbose setting.""" + handler = OutputHandler(mode="cli", verbose=False) + handler.info("Test info") + + captured = capsys.readouterr() + assert "Test info" not in captured.out + + def test_debug_in_cli_mode(self, capsys): + """Test debug method in CLI mode.""" + handler = OutputHandler(mode="cli", verbose=True) + handler.debug("Test debug") + + captured = capsys.readouterr() + assert "DEBUG: Test debug" in captured.out + + def test_progress_in_cli_mode(self, capsys): + """Test progress method in CLI mode.""" + handler = OutputHandler(mode="cli") + handler.progress(50, 100, "Processing") + + captured = capsys.readouterr() + assert "Processing [50/100]" in captured.out + assert "50.0%" in captured.out + + def test_progress_in_gui_mode(self): + """Test progress method in GUI mode.""" + messages = [] + callback = messages.append + + handler = OutputHandler(mode="gui", callback=callback) + handler.progress(50, 100, "Processing") + + assert len(messages) == 1 + assert "PROGRESS:50:100:50.0:Processing" in messages[0] + + def test_progress_zero_total(self, capsys): + """Test progress with zero total.""" + handler = OutputHandler(mode="cli") + handler.progress(10, 0) + + captured = capsys.readouterr() + # Should not crash, just show 0% + assert "0.0%" in captured.out + + def test_set_callback(self): + """Test setting callback after initialization.""" + handler = OutputHandler(mode="gui") + assert handler.callback is None + + callback = lambda msg: None + handler.set_callback(callback) + assert handler.callback == callback + + def test_set_verbose(self): + """Test setting verbose after initialization.""" + handler = OutputHandler(mode="cli", verbose=False) + assert handler.verbose is False + + handler.set_verbose(True) + assert handler.verbose is True diff --git a/tests/gui/__init__.py b/tests/gui/__init__.py new file mode 100644 index 0000000..935c296 --- /dev/null +++ b/tests/gui/__init__.py @@ -0,0 +1 @@ +"""Tests for GUI components.""" diff --git a/tests/gui/test_state_manager.py b/tests/gui/test_state_manager.py new file mode 100644 index 0000000..3103c47 --- /dev/null +++ b/tests/gui/test_state_manager.py @@ -0,0 +1,249 @@ +"""Tests for StateManager.""" + +import pytest +from pathlib import Path +from tempfile import TemporaryDirectory +import yaml + +from wareflow_analysis.gui.controllers.state_manager import ( + StateManager, + get_state_manager, + reset_state_manager, +) + + +class TestStateManager: + """Test suite for StateManager class.""" + + def test_init(self): + """Test StateManager initialization.""" + manager = StateManager() + + assert manager.project_dir is None + assert manager.db_path is None + assert manager.config_path is None + assert manager.database_exists is False + assert manager.config_exists is False + assert manager.last_operation is None + assert manager.last_operation_time is None + + def test_default_settings(self): + """Test default settings.""" + manager = StateManager() + + assert manager.settings["remember_last_project"] is True + assert manager.settings["auto_create_backup"] is True + assert manager.settings["verbose_output"] is True + assert manager.settings["theme"] == "System" + assert manager.settings["confirm_destructive"] is True + + def test_set_project_dir_valid(self): + """Test setting a valid project directory.""" + manager = StateManager() + + with TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + config_file = project_dir / "config.yaml" + + # Create a valid config file + with open(config_file, "w") as f: + yaml.dump({"database": {"path": "warehouse.db"}}, f) + + success = manager.set_project_dir(project_dir) + + assert success is True + assert manager.project_dir == project_dir + assert manager.config_path == config_file + assert manager.config_exists is True + assert manager.db_path == project_dir / "warehouse.db" + + def test_set_project_dir_invalid(self): + """Test setting an invalid project directory.""" + manager = StateManager() + + with TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + # No config.yaml created + + success = manager.set_project_dir(project_dir) + + assert success is False + assert manager.project_dir is None + + def test_is_project_loaded(self): + """Test is_project_loaded method.""" + manager = StateManager() + + assert manager.is_project_loaded() is False + + with TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + config_file = project_dir / "config.yaml" + + with open(config_file, "w") as f: + yaml.dump({"database": {"path": "warehouse.db"}}, f) + + manager.set_project_dir(project_dir) + assert manager.is_project_loaded() is True + + def test_is_database_ready(self): + """Test is_database_ready method.""" + manager = StateManager() + + assert manager.is_database_ready() is False + + with TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + config_file = project_dir / "config.yaml" + db_file = project_dir / "warehouse.db" + + with open(config_file, "w") as f: + yaml.dump({"database": {"path": "warehouse.db"}}, f) + + manager.set_project_dir(project_dir) + + # Database doesn't exist yet + assert manager.is_database_ready() is False + + # Create database file + db_file.touch() + + manager.update_database_state() + assert manager.is_database_ready() is True + + def test_get_project_dir(self): + """Test get_project_dir method.""" + manager = StateManager() + + assert manager.get_project_dir() is None + + with TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + config_file = project_dir / "config.yaml" + + with open(config_file, "w") as f: + yaml.dump({"database": {"path": "warehouse.db"}}, f) + + manager.set_project_dir(project_dir) + assert manager.get_project_dir() == project_dir + + def test_get_setting(self): + """Test getting settings.""" + manager = StateManager() + + assert manager.get_setting("verbose_output") is True + assert manager.get_setting("nonexistent", "default") == "default" + assert manager.get_setting("nonexistent") is None + + def test_set_setting(self): + """Test setting settings.""" + manager = StateManager() + + manager.set_setting("verbose_output", False) + assert manager.get_setting("verbose_output") is False + + manager.set_setting("new_setting", "value") + assert manager.get_setting("new_setting") == "value" + + def test_set_last_operation(self): + """Test setting last operation.""" + from datetime import datetime + + manager = StateManager() + + manager.set_last_operation("Test operation") + + assert manager.last_operation == "Test operation" + assert manager.last_operation_time is not None + assert isinstance(manager.last_operation_time, datetime) + + def test_update_database_state(self): + """Test updating database state.""" + manager = StateManager() + + with TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + config_file = project_dir / "config.yaml" + db_file = project_dir / "warehouse.db" + + with open(config_file, "w") as f: + yaml.dump({"database": {"path": "warehouse.db"}}, f) + + manager.set_project_dir(project_dir) + + # Database doesn't exist + manager.update_database_state() + assert manager.database_exists is False + + # Create database + db_file.touch() + manager.update_database_state() + assert manager.database_exists is True + + def test_register_listener(self): + """Test registering state change listeners.""" + manager = StateManager() + + events = [] + listener = lambda event: events.append(event) + + manager.register_listener(listener) + + manager.set_setting("test", "value") + + assert len(events) == 1 + assert events[0] == "settings_changed" + + def test_unregister_listener(self): + """Test unregistering state change listeners.""" + manager = StateManager() + + events = [] + listener = lambda event: events.append(event) + + manager.register_listener(listener) + manager.unregister_listener(listener) + + manager.set_setting("test", "value") + + assert len(events) == 0 + + def test_reset(self): + """Test resetting state manager.""" + manager = StateManager() + + with TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + config_file = project_dir / "config.yaml" + + with open(config_file, "w") as f: + yaml.dump({"database": {"path": "warehouse.db"}}, f) + + manager.set_project_dir(project_dir) + manager.set_last_operation("Test") + + manager.reset() + + assert manager.project_dir is None + assert manager.database_exists is False + assert manager.config_exists is False + assert manager.last_operation is None + + def test_global_state_manager(self): + """Test global state manager singleton.""" + reset_state_manager() + + manager1 = get_state_manager() + manager2 = get_state_manager() + + assert manager1 is manager2 + + def test_reset_global_state_manager(self): + """Test resetting global state manager.""" + manager1 = get_state_manager() + + reset_state_manager() + + manager2 = get_state_manager() + + assert manager1 is not manager2 diff --git a/tests/gui/test_threaded_operation.py b/tests/gui/test_threaded_operation.py new file mode 100644 index 0000000..eb71ae1 --- /dev/null +++ b/tests/gui/test_threaded_operation.py @@ -0,0 +1,224 @@ +"""Tests for ThreadedOperation.""" + +import pytest +import time + +from wareflow_analysis.gui.widgets.threaded_operation import ( + ThreadedOperation, + run_in_thread, +) + + +class TestThreadedOperation: + """Test suite for ThreadedOperation class.""" + + def test_init(self): + """Test ThreadedOperation initialization.""" + operation = lambda: "result" + + threaded_op = ThreadedOperation(operation) + + assert threaded_op.operation == operation + assert threaded_op.callback is None + assert threaded_op.completion_callback is None + assert threaded_op.error_callback is None + assert threaded_op._is_running is False + + def test_with_callbacks(self): + """Test ThreadedOperation with callbacks.""" + operation = lambda: "result" + progress_callback = lambda msg: None + completion_callback = lambda result: None + error_callback = lambda error: None + + threaded_op = ThreadedOperation( + operation=operation, + callback=progress_callback, + completion_callback=completion_callback, + error_callback=error_callback, + ) + + assert threaded_op.callback == progress_callback + assert threaded_op.completion_callback == completion_callback + assert threaded_op.error_callback == error_callback + + def test_successful_operation(self): + """Test running a successful operation.""" + def operation(): + return "success" + + results = [] + completion_callback = results.append + + threaded_op = ThreadedOperation( + operation=operation, + completion_callback=completion_callback, + ) + + threaded_op.start() + threaded_op.wait(timeout=5) + + assert len(results) == 1 + assert results[0] == "success" + assert threaded_op.is_running() is False + + def test_failing_operation(self): + """Test running a failing operation.""" + def operation(): + raise ValueError("Test error") + + errors = [] + error_callback = errors.append + + threaded_op = ThreadedOperation( + operation=operation, + error_callback=error_callback, + ) + + threaded_op.start() + threaded_op.wait(timeout=5) + + assert len(errors) == 1 + assert isinstance(errors[0], ValueError) + assert str(errors[0]) == "Test error" + + def test_get_result_success(self): + """Test getting result from successful operation.""" + def operation(): + return "result" + + threaded_op = ThreadedOperation(operation=operation) + threaded_op.start() + + status, result = threaded_op.get_result(timeout=5) + + assert status == "success" + assert result == "result" + + def test_get_result_error(self): + """Test getting result from failed operation.""" + def operation(): + raise ValueError("Test error") + + threaded_op = ThreadedOperation(operation=operation) + threaded_op.start() + + status, result = threaded_op.get_result(timeout=5) + + assert status == "error" + assert isinstance(result, ValueError) + + def test_is_running(self): + """Test is_running method.""" + def slow_operation(): + time.sleep(0.2) + return "done" + + threaded_op = ThreadedOperation(operation=slow_operation) + + assert threaded_op.is_running() is False + + threaded_op.start() + assert threaded_op.is_running() is True + + threaded_op.wait(timeout=5) + assert threaded_op.is_running() is False + + def test_start_already_running(self): + """Test starting an already running operation.""" + def slow_operation(): + time.sleep(0.2) + return "done" + + threaded_op = ThreadedOperation(operation=slow_operation) + threaded_op.start() + + with pytest.raises(RuntimeError, match="already running"): + threaded_op.start() + + threaded_op.wait(timeout=5) + + def test_wait_timeout(self): + """Test wait with timeout.""" + def slow_operation(): + time.sleep(0.5) + return "done" + + threaded_op = ThreadedOperation(operation=slow_operation) + threaded_op.start() + + # Wait with short timeout + completed = threaded_op.wait(timeout=0.1) + + assert completed is False + + # Wait for completion + completed = threaded_op.wait(timeout=1) + assert completed is True + + def test_run_in_thread_convenience(self): + """Test run_in_thread convenience function.""" + def operation(): + time.sleep(0.05) # Small delay to ensure thread starts + return "result" + + results = [] + completion_callback = results.append + + threaded_op = run_in_thread( + operation=operation, + on_complete=completion_callback, + ) + + assert isinstance(threaded_op, ThreadedOperation) + # Give thread a moment to start + time.sleep(0.01) + assert threaded_op.is_running() is True + + threaded_op.wait(timeout=5) + + assert len(results) == 1 + assert results[0] == "result" + + def test_progress_callback(self): + """Test progress callback during operation.""" + def operation(): + return "result" + + progress_messages = [] + + threaded_op = ThreadedOperation( + operation=operation, + callback=progress_messages.append, + ) + + threaded_op.start() + threaded_op.wait(timeout=5) + + # Progress callback is stored but not automatically called + # The operation needs to call it manually + assert threaded_op.callback is not None + + def test_multiple_operations(self): + """Test running multiple operations concurrently.""" + def operation(value): + time.sleep(0.1) + return value * 2 + + results = [] + + operations = [] + for i in range(5): + op = ThreadedOperation( + operation=lambda v=i: operation(v), + completion_callback=results.append, + ) + op.start() + operations.append(op) + + # Wait for all + for op in operations: + op.wait(timeout=5) + + assert len(results) == 5 + assert sorted(results) == [0, 2, 4, 6, 8] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..7ae60cd --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,5 @@ +"""Integration tests for Wareflow Analysis. + +This package contains integration tests that verify the application +works as a whole, including smoke tests for the compiled executable. +""" diff --git a/tests/integration/test_exe_smoke.py b/tests/integration/test_exe_smoke.py new file mode 100644 index 0000000..2a3711b --- /dev/null +++ b/tests/integration/test_exe_smoke.py @@ -0,0 +1,192 @@ +"""Smoke tests for compiled Windows executable. + +These tests verify that the compiled .exe file works correctly. +They are designed to run against a built executable in the dist/ directory. +""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +# Skip these tests if not on Windows or if executable doesn't exist +pytestmark = [ + pytest.mark.skipif( + sys.platform != "win32", + reason="Executable smoke tests only run on Windows", + ), +] + + +class TestExeSmokeTests: + """Basic smoke tests for the compiled .exe.""" + + @staticmethod + def get_exe_path() -> Path: + """Get path to compiled executable. + + Returns: + Path to the executable file. + + Raises: + FileNotFoundError: If executable is not found. + """ + # Check common locations + exe_paths = [ + Path("dist/Warehouse-GUI.exe"), + Path("dist/Warehouse-GUI/Warehouse-GUI.exe"), + Path("artifacts/Warehouse-GUI.exe"), + ] + + for path in exe_paths: + if path.exists(): + return path + + # Raise skip error if not found + pytest.skip("Executable not found - build may not have been run") + + def test_exe_exists(self): + """Test that executable file exists and has reasonable size.""" + exe_path = self.get_exe_path() + assert exe_path.exists() + + # Check file size (should be at least 10 MB for a valid build) + file_size = exe_path.stat().st_size + assert file_size > 10_000_000, f"Executable size {file_size} is too small" + + def test_exe_version_info(self): + """Test that executable has version information.""" + exe_path = self.get_exe_path() + + # Try to read version info using PowerShell + try: + result = subprocess.run( + [ + "powershell", + "-Command", + f"(Get-Item '{exe_path}').VersionInfo | Format-List", + ], + capture_output=True, + text=True, + timeout=10, + ) + + # Should succeed + assert result.returncode == 0 + + # Should contain version information + output = result.stdout + assert "FileVersion" in output or "ProductVersion" in output + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + pytest.skip(f"Could not read version info: {e}") + + def test_exe_launches_without_crash(self): + """Test that executable launches and doesn't immediately crash. + + Note: This is a basic smoke test. A more comprehensive test would + require UI automation which is beyond the scope of smoke tests. + """ + exe_path = self.get_exe_path() + + # Try to launch the executable with a timeout + # We expect it to either run successfully or be terminated by timeout + try: + # Use STARTUPINFO to hide the window + startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined] + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined] + + result = subprocess.run( + [str(exe_path)], + timeout=5, + capture_output=True, + startupinfo=startupinfo, + ) + + # Exit code 0 or 1 is acceptable (user closed window or normal exit) + # We just want to ensure it didn't crash with an error code + assert result.returncode in [0, 1, -15] # -15 is SIGTERM + except subprocess.TimeoutExpired: + # Timeout is OK - means the app is running + pass + except FileNotFoundError: + pytest.skip("Executable could not be launched") + + def test_checksum_file_exists(self): + """Test that SHA256 checksum file exists and is valid.""" + exe_path = self.get_exe_path() + checksum_path = exe_path.with_suffix(".exe.sha256") + + if not checksum_path.exists(): + pytest.skip("Checksum file not found") + + # Read checksum file + content = checksum_path.read_text().strip() + + # SHA256 should be 64 characters (hex string) + assert len(content) == 64, f"Invalid checksum length: {len(content)}" + + # Should be valid hex characters + try: + int(content, 16) + except ValueError: + pytest.fail(f"Invalid checksum format: {content}") + + def test_imports_work(self): + """Test that critical imports work in the frozen environment. + + This test verifies that the PyInstaller build correctly included + all necessary dependencies. + """ + exe_path = self.get_exe_path() + + # We can't directly test imports in the exe, but we can verify + # the file structure suggests dependencies are bundled + # This is a basic structural check + + # On Windows, built executables are single-file by default + # We can verify the exe exists and has reasonable size + assert exe_path.exists() + + # A working executable should be at least 20MB with all dependencies + file_size = exe_path.stat().st_size + assert file_size > 20_000_000, f"Executable may be missing dependencies (size: {file_size})" + + def test_exe_help(self): + """Test that executable responds to command line arguments. + + Note: CustomTkinter GUI apps may not support --help in the traditional + sense. This test verifies the exe can at least be invoked. + """ + exe_path = self.get_exe_path() + + try: + # Try running with --help or similar + result = subprocess.run( + [str(exe_path), "--help"], + capture_output=True, + text=True, + timeout=5, + ) + + # Any response is OK - we just want to verify it doesn't crash + # GUI apps typically ignore unknown arguments + assert result.returncode in [0, 1] + except subprocess.TimeoutExpired: + # Timeout means the GUI launched - that's OK + pass + except Exception as e: + pytest.skip(f"Could not run with --help: {e}") + + +@pytest.fixture(scope="session") +def exe_path(): + """Fixture providing path to the executable. + + Skips tests if executable is not found. + """ + test_instance = TestExeSmokeTests() + try: + return test_instance.get_exe_path() + except pytest.skip.Exception: + pytest.skip("Executable not found") diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d4901b..773b172 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,7 +33,7 @@ def test_analyze_command_exists() -> None: """Test that analyze command exists.""" result = runner.invoke(app, ["analyze", "--help"]) assert result.exit_code == 0 - assert "Run all analyses" in result.stdout + assert "Run warehouse analysis" in result.stdout def test_export_command_exists() -> None: