From d66a20ddda89ca6fe91b04cf9b44fe6bf9874ccf Mon Sep 17 00:00:00 2001 From: Austin Godber Date: Wed, 1 Oct 2025 19:01:13 -0700 Subject: [PATCH] rework server startup and cli This changes the dockerfile as well. --- .gitea/workflows/release.yml | 4 +- CLAUDE.md | 32 ++++------ Dockerfile | 10 +-- README.md | 32 ++++------ main.py | 10 --- pyproject.toml | 3 +- run_dev.py | 32 ---------- run_prod.py | 52 ---------------- src/embeddingbuddy/app.py | 88 ++++++++++++++++++++++++--- src/embeddingbuddy/cli.py | 74 ++++++++++++++++++++++ src/embeddingbuddy/config/settings.py | 2 +- src/embeddingbuddy/wsgi.py | 11 ++++ uv.lock | 2 +- wsgi.py | 20 ------ 14 files changed, 195 insertions(+), 177 deletions(-) delete mode 100644 main.py delete mode 100644 run_dev.py delete mode 100644 run_prod.py create mode 100644 src/embeddingbuddy/cli.py create mode 100644 src/embeddingbuddy/wsgi.py delete mode 100644 wsgi.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index f7d3e53..de42e9c 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -66,8 +66,8 @@ jobs: echo "## Installation" >> release-notes.md echo "" >> release-notes.md echo '```bash' >> release-notes.md - echo 'uv sync' >> release-notes.md - echo 'uv run python main.py' >> release-notes.md + echo 'pip install embeddingbuddy' >> release-notes.md + echo 'embeddingbuddy serve' >> release-notes.md echo '```' >> release-notes.md - name: Create Release diff --git a/CLAUDE.md b/CLAUDE.md index 36977ec..871d513 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,29 +21,23 @@ uv sync **Run the application:** -Development mode (with auto-reload): +Using the CLI (recommended): ```bash -uv run run_dev.py +# Production mode (no debug, no auto-reload) +embeddingbuddy serve + +# Development mode (debug + auto-reload on code changes) +embeddingbuddy serve --dev + +# Debug logging only (no auto-reload) +embeddingbuddy serve --debug + +# With custom host/port +embeddingbuddy serve --host 0.0.0.0 --port 8080 ``` -Production mode (with Gunicorn WSGI server): - -```bash -# First install production dependencies -uv sync --extra prod - -# Then run in production mode -uv run run_prod.py -``` - -Legacy mode (basic Dash server): - -```bash -uv run main.py -``` - -The app will be available at +The app will be available at by default **Run tests:** diff --git a/Dockerfile b/Dockerfile index e87aab2..cd6b33a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,9 +23,6 @@ COPY pyproject.toml uv.lock ./ # Copy source code (needed for editable install) COPY src/ src/ -COPY main.py . -COPY wsgi.py . -COPY run_prod.py . COPY assets/ assets/ # Change ownership of source files before building (lighter I/O) @@ -59,10 +56,7 @@ RUN chown appuser:appuser /app # Copy files from builder with correct ownership COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv COPY --from=builder --chown=appuser:appuser /app/src /app/src -COPY --from=builder --chown=appuser:appuser /app/main.py /app/main.py COPY --from=builder --chown=appuser:appuser /app/assets /app/assets -COPY --from=builder --chown=appuser:appuser /app/wsgi.py /app/wsgi.py -COPY --from=builder --chown=appuser:appuser /app/run_prod.py /app/run_prod.py # Switch to non-root user USER appuser @@ -86,5 +80,5 @@ EXPOSE 8050 HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ CMD python -c "import requests; requests.get('http://localhost:8050/', timeout=5)" || exit 1 -# Run application with Gunicorn in production -CMD ["python", "run_prod.py"] \ No newline at end of file +# Run application in production mode (no debug, no auto-reload) +CMD ["embeddingbuddy", "serve"] \ No newline at end of file diff --git a/README.md b/README.md index 786c979..7ea75aa 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ documents and prompts to understand how queries relate to your content. uv tool install embeddingbuddy # Run the application -embeddingbuddy +embeddingbuddy serve ``` **Option 2: Install with pip/pipx** @@ -124,26 +124,18 @@ uv sync 2. **Run the application:** -**Development mode** (with auto-reload): - ```bash -uv run run_dev.py -``` +# Production mode (no debug, no auto-reload) +embeddingbuddy serve -**Production mode** (with Gunicorn WSGI server): +# Development mode (debug + auto-reload on code changes) +embeddingbuddy serve --dev -```bash -# Install production dependencies -uv sync --extra prod +# Debug logging only (no auto-reload) +embeddingbuddy serve --debug -# Run in production mode -uv run run_prod.py -``` - -**Legacy mode** (basic Dash server): - -```bash -uv run main.py +# Custom host/port +embeddingbuddy serve --host 0.0.0.0 --port 8080 ``` 3. **Open your browser** to @@ -231,10 +223,8 @@ src/embeddingbuddy/ │ └── interactions.py # User interaction callbacks └── utils/ # Utility functions -main.py # Application runner (at project root) -main.py # Application runner (at project root) -run_dev.py # Development server runner -run_prod.py # Production server runner +# CLI entry point +embeddingbuddy serve # Main CLI command to start the server ``` ### Testing diff --git a/main.py b/main.py deleted file mode 100644 index 426194b..0000000 --- a/main.py +++ /dev/null @@ -1,10 +0,0 @@ -from src.embeddingbuddy.app import create_app, run_app - - -def main(): - app = create_app() - run_app(app) - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index b32ee30..f69f8d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,8 @@ dependencies = [ ] [project.scripts] -embeddingbuddy = "embeddingbuddy.app:main" +embeddingbuddy = "embeddingbuddy.cli:main" +embeddingbuddy-serve = "embeddingbuddy.app:serve" [project.optional-dependencies] test = [ diff --git a/run_dev.py b/run_dev.py deleted file mode 100644 index ab124d8..0000000 --- a/run_dev.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -""" -Development runner with auto-reload enabled. -This runs the Dash development server with hot reloading. -""" -import os -from src.embeddingbuddy.app import create_app, run_app - -def main(): - """Run the application in development mode with auto-reload.""" - # Force development settings - os.environ["EMBEDDINGBUDDY_ENV"] = "development" - os.environ["EMBEDDINGBUDDY_DEBUG"] = "true" - - # Check for OpenSearch disable flag (optional for testing) - # Set EMBEDDINGBUDDY_OPENSEARCH_ENABLED=false to test without OpenSearch - opensearch_status = os.getenv("EMBEDDINGBUDDY_OPENSEARCH_ENABLED", "true") - opensearch_enabled = opensearch_status.lower() == "true" - - print("🚀 Starting EmbeddingBuddy in development mode...") - print("📁 Auto-reload enabled - changes will trigger restart") - print("🌐 Server will be available at http://127.0.0.1:8050") - print(f"🔍 OpenSearch: {'Enabled' if opensearch_enabled else 'Disabled'}") - print("⏹️ Press Ctrl+C to stop") - - app = create_app() - - # Run with development server (includes auto-reload when debug=True) - run_app(app, debug=True) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/run_prod.py b/run_prod.py deleted file mode 100644 index 4ccce34..0000000 --- a/run_prod.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -""" -Production runner using Gunicorn WSGI server. -This provides better performance and stability for production deployments. -""" -import os -import subprocess -import sys -from src.embeddingbuddy.config.settings import AppSettings - -def main(): - """Run the application in production mode with Gunicorn.""" - # Force production settings - os.environ["EMBEDDINGBUDDY_ENV"] = "production" - os.environ["EMBEDDINGBUDDY_DEBUG"] = "false" - # Disable OpenSearch by default in production (can be overridden by setting env var) - if "EMBEDDINGBUDDY_OPENSEARCH_ENABLED" not in os.environ: - os.environ["EMBEDDINGBUDDY_OPENSEARCH_ENABLED"] = "false" - - print("🚀 Starting EmbeddingBuddy in production mode...") - print(f"⚙️ Workers: {AppSettings.GUNICORN_WORKERS}") - print(f"🌐 Server will be available at http://{AppSettings.GUNICORN_BIND}") - print("⏹️ Press Ctrl+C to stop") - - # Gunicorn command - cmd = [ - "gunicorn", - "--workers", str(AppSettings.GUNICORN_WORKERS), - "--bind", AppSettings.GUNICORN_BIND, - "--timeout", str(AppSettings.GUNICORN_TIMEOUT), - "--keep-alive", str(AppSettings.GUNICORN_KEEPALIVE), - "--access-logfile", "-", - "--error-logfile", "-", - "--log-level", "info", - "wsgi:application" - ] - - try: - subprocess.run(cmd, check=True) - except KeyboardInterrupt: - print("\n🛑 Shutting down...") - sys.exit(0) - except subprocess.CalledProcessError as e: - print(f"❌ Error running Gunicorn: {e}") - sys.exit(1) - except FileNotFoundError: - print("❌ Gunicorn not found. Install it with: uv add gunicorn") - print("💡 Or run in development mode with: python run_dev.py") - sys.exit(1) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/src/embeddingbuddy/app.py b/src/embeddingbuddy/app.py index 71f29c5..e04c3da 100644 --- a/src/embeddingbuddy/app.py +++ b/src/embeddingbuddy/app.py @@ -1,14 +1,20 @@ -import dash -import dash_bootstrap_components as dbc -from .config.settings import AppSettings -from .ui.layout import AppLayout -from .ui.callbacks.data_processing import DataProcessingCallbacks -from .ui.callbacks.visualization import VisualizationCallbacks -from .ui.callbacks.interactions import InteractionCallbacks +""" +EmbeddingBuddy application factory and server functions. + +This module contains the main application creation logic with imports +moved inside functions to avoid loading heavy dependencies at module level. +""" def create_app(): + """Create and configure the Dash application instance.""" import os + import dash + import dash_bootstrap_components as dbc + from .ui.layout import AppLayout + from .ui.callbacks.data_processing import DataProcessingCallbacks + from .ui.callbacks.visualization import VisualizationCallbacks + from .ui.callbacks.interactions import InteractionCallbacks # Get the project root directory (two levels up from this file) project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) @@ -124,6 +130,9 @@ def _register_client_side_callbacks(app): def run_app(app=None, debug=None, host=None, port=None): + """Run the Dash application with specified settings.""" + from .config.settings import AppSettings + if app is None: app = create_app() @@ -134,10 +143,69 @@ def run_app(app=None, debug=None, host=None, port=None): ) -def main(): - """Main entry point for the embeddingbuddy CLI command.""" +def serve(host=None, port=None, dev=False, debug=False): + """Start the EmbeddingBuddy web server. + + Args: + host: Host to bind to (default: 127.0.0.1) + port: Port to bind to (default: 8050) + dev: Development mode - enable debug logging and auto-reload (default: False) + debug: Enable debug logging only, no auto-reload (default: False) + """ + import os + from .config.settings import AppSettings + + # Determine actual values to use + actual_host = host if host is not None else AppSettings.HOST + actual_port = port if port is not None else AppSettings.PORT + + # Determine mode + # --dev takes precedence and enables both debug and auto-reload + # --debug enables only debug logging + # No flags = production mode (no debug, no auto-reload) + use_reloader = dev + use_debug = dev or debug + + # Only print startup messages in main process (not in Flask reloader) + if not os.environ.get('WERKZEUG_RUN_MAIN'): + mode = "development" if dev else ("debug" if debug else "production") + print(f"Starting EmbeddingBuddy in {mode} mode...") + print("Loading dependencies (this may take a few seconds)...") + print(f"Server will start at http://{actual_host}:{actual_port}") + if use_reloader: + print("Auto-reload enabled - server will restart on code changes") + app = create_app() - run_app(app) + + # Suppress Flask development server warning in production mode + if not use_debug and not use_reloader: + import warnings + import logging + + # Suppress the werkzeug warning + warnings.filterwarnings('ignore', message='.*development server.*') + + # Set werkzeug logger to ERROR level to suppress the warning + werkzeug_logger = logging.getLogger('werkzeug') + werkzeug_logger.setLevel(logging.ERROR) + + # Use Flask's built-in server with appropriate settings + app.run( + debug=use_debug, + host=actual_host, + port=actual_port, + use_reloader=use_reloader + ) + + +def main(): + """Legacy entry point - redirects to cli module. + + This is kept for backward compatibility but the main CLI + is now in embeddingbuddy.cli for faster startup. + """ + from .cli import main as cli_main + cli_main() if __name__ == "__main__": diff --git a/src/embeddingbuddy/cli.py b/src/embeddingbuddy/cli.py new file mode 100644 index 0000000..b7ffa09 --- /dev/null +++ b/src/embeddingbuddy/cli.py @@ -0,0 +1,74 @@ +""" +Lightweight CLI entry point for EmbeddingBuddy. + +This module provides a fast command-line interface that only imports +heavy dependencies when actually needed by subcommands. +""" +import argparse +import sys + + +def main(): + """Main CLI entry point with minimal imports for fast help text.""" + parser = argparse.ArgumentParser( + prog="embeddingbuddy", + description="EmbeddingBuddy - Interactive embedding visualization tool", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + embeddingbuddy serve # Production mode (no debug, no auto-reload) + embeddingbuddy serve --dev # Development mode (debug + auto-reload) + embeddingbuddy serve --debug # Debug logging only (no auto-reload) + embeddingbuddy serve --port 8080 # Custom port + embeddingbuddy serve --host 0.0.0.0 # Bind to all interfaces + """ + ) + + subparsers = parser.add_subparsers( + dest="command", + help="Available commands", + metavar="" + ) + + # Serve subcommand + serve_parser = subparsers.add_parser( + "serve", + help="Start the web server", + description="Start the EmbeddingBuddy web server for interactive visualization" + ) + serve_parser.add_argument( + "--host", + default=None, + help="Host to bind to (default: 127.0.0.1)" + ) + serve_parser.add_argument( + "--port", + type=int, + default=None, + help="Port to bind to (default: 8050)" + ) + serve_parser.add_argument( + "--dev", + action="store_true", + help="Development mode: enable debug logging and auto-reload" + ) + serve_parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging (no auto-reload)" + ) + + args = parser.parse_args() + + if args.command == "serve": + # Only import heavy dependencies when actually running serve + from embeddingbuddy.app import serve + serve(host=args.host, port=args.port, dev=args.dev, debug=args.debug) + else: + # No command specified, show help + parser.print_help() + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/embeddingbuddy/config/settings.py b/src/embeddingbuddy/config/settings.py index c697966..7bcd7fb 100644 --- a/src/embeddingbuddy/config/settings.py +++ b/src/embeddingbuddy/config/settings.py @@ -69,7 +69,7 @@ class AppSettings: TEXT_PREVIEW_LENGTH = 100 # App Configuration - DEBUG = os.getenv("EMBEDDINGBUDDY_DEBUG", "True").lower() == "true" + DEBUG = os.getenv("EMBEDDINGBUDDY_DEBUG", "False").lower() == "true" HOST = os.getenv("EMBEDDINGBUDDY_HOST", "127.0.0.1") PORT = int(os.getenv("EMBEDDINGBUDDY_PORT", "8050")) diff --git a/src/embeddingbuddy/wsgi.py b/src/embeddingbuddy/wsgi.py new file mode 100644 index 0000000..0c1b632 --- /dev/null +++ b/src/embeddingbuddy/wsgi.py @@ -0,0 +1,11 @@ +""" +WSGI entry point for production deployment. +Use this with a production WSGI server like Gunicorn. +""" +from embeddingbuddy.app import create_app + +# Create the application instance +application = create_app() + +# For compatibility with different WSGI servers +app = application diff --git a/uv.lock b/uv.lock index 792bdeb..271789b 100644 --- a/uv.lock +++ b/uv.lock @@ -412,7 +412,7 @@ wheels = [ [[package]] name = "embeddingbuddy" -version = "0.5.1" +version = "0.6.3" source = { editable = "." } dependencies = [ { name = "dash" }, diff --git a/wsgi.py b/wsgi.py deleted file mode 100644 index 0c26210..0000000 --- a/wsgi.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -WSGI entry point for production deployment. -Use this with a production WSGI server like Gunicorn. -""" -from src.embeddingbuddy.app import create_app - -# Create the application instance -application = create_app() - -# For compatibility with different WSGI servers -app = application - -if __name__ == "__main__": - # This won't be used in production, but useful for testing - from src.embeddingbuddy.config.settings import AppSettings - application.run( - host=AppSettings.HOST, - port=AppSettings.PORT, - debug=False - ) \ No newline at end of file