From 8c6ed3d64254fa570b1c66e6be00c3ea22aedc10 Mon Sep 17 00:00:00 2001 From: DerGrumpf Date: Tue, 17 Jun 2025 13:06:48 +0200 Subject: [PATCH] Init --- .env | 23 +++ .env.example | 24 +++ Dockerfile | 0 Justfile | 163 ++++++++++++++++++ README.md | 236 +++++++++++++++++++++++++ app.py | 440 +++++++++++++++++++++++++++++++++++++++++++++++ compose.yml | 36 ++++ flake.nix | 313 +++++++++++++++++++++++++++++++++ requirements.txt | 0 9 files changed, 1235 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 Justfile create mode 100644 app.py create mode 100644 compose.yml create mode 100644 flake.nix create mode 100644 requirements.txt diff --git a/.env b/.env new file mode 100644 index 0000000..104d37f --- /dev/null +++ b/.env @@ -0,0 +1,23 @@ +# JupyterHub Notebook Viewer Configuration + +# Core Settings +JUPYTERHUB_SHARED_DIR=/shared +APP_TITLE=JupyterHub Notebook Viewer + +# Flask Settings +FLASK_HOST=0.0.0.0 +FLASK_PORT=5000 +FLASK_DEBUG=True +FLASK_SECRET_KEY=your-secret-key-change-in-production + +# File Handling +MAX_FILE_SIZE=16777216 +NOTEBOOKS_PER_PAGE=50 +ALLOWED_EXTENSIONS=.ipynb,.py,.md + +# Feature Toggles +ENABLE_DOWNLOAD=True +ENABLE_API=True + +# UI Settings +THEME=dark diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3555705 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# JupyterHub Notebook Viewer Configuration +# Copy this file to .env and modify as needed + +# Core Settings +JUPYTERHUB_SHARED_DIR=/shared +APP_TITLE=JupyterHub Notebook Viewer + +# Flask Settings +FLASK_HOST=0.0.0.0 +FLASK_PORT=5000 +FLASK_DEBUG=True +FLASK_SECRET_KEY=your-secret-key-change-in-production + +# File Handling +MAX_FILE_SIZE=16777216 # 16MB in bytes +NOTEBOOKS_PER_PAGE=50 +ALLOWED_EXTENSIONS=.ipynb,.py,.md + +# Feature Toggles +ENABLE_DOWNLOAD=True +ENABLE_API=True + +# UI Settings +THEME=dark # dark or light diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..2dd9701 --- /dev/null +++ b/Justfile @@ -0,0 +1,163 @@ +# JupyterHub Notebook Viewer - Development Commands + +# Default recipe +default: + @just --list + +# Set up development environment +setup: + @echo "Setting up development environment..." + mkdir -p shared notebooks templates + [ ! -f .env ] && cp .env.example .env || true + @echo "✓ Development environment ready" + +# Start development server +dev: + @echo "Starting development server..." + python app.py + +# Start development server with auto-reload (using nix shell) +dev-nix: + nix develop --command dev-server + +# Run tests +test: + @echo "Running tests..." + python -m pytest tests/ -v || python -c "import app; print('Basic import test passed')" + +# Run tests in nix environment +test-nix: + nix develop --command run-tests + +# Build Docker image +docker-build: + docker build -t jupyterhub-notebook-viewer . + +# Start with Docker Compose +docker-up: + docker-compose up + +# Start with Docker Compose in background +docker-up-daemon: + docker-compose up -d + +# Start with Docker Compose and rebuild +docker-rebuild: + docker-compose up --build + +# Start with nginx proxy +docker-proxy: + docker-compose --profile with-proxy up + +# Stop Docker services +docker-down: + docker-compose down + +# View Docker logs +docker-logs: + docker-compose logs -f + +# Clean Docker resources +docker-clean: + docker-compose down -v + docker system prune -f + +# Install Python dependencies +install: + pip install -r requirements.txt + +# Format code +format: + black app.py + isort app.py + +# Lint code +lint: + flake8 app.py + pylint app.py + +# Generate requirements.txt +freeze: + pip freeze > requirements.txt + +# Create sample notebook +sample-notebook: + @mkdir -p shared + @cat > shared/sample.ipynb << 'EOF' + { + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Sample Notebook\n\nThis is a sample Jupyter notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('Hello World!')\nimport numpy as np\nprint(f'NumPy version: {np.__version__}')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 + } + EOF + @echo "✓ Sample notebook created in shared/sample.ipynb" + +# Check environment configuration +check-env: + @echo "Environment Configuration:" + @echo "=========================" + @grep -E '^[A-Z_]' .env 2>/dev/null || echo "No .env file found" + @echo "" + @echo "Current Python: $(which python)" + @echo "Flask available: $(python -c 'import flask; print(flask.__version__)' 2>/dev/null || echo 'Not installed')" + @echo "Shared directory: ${JUPYTERHUB_SHARED_DIR:-./shared}" + +# Show application logs +logs: + tail -f app.log 2>/dev/null || echo "No log file found" + +# Clean temporary files +clean: + rm -rf __pycache__/ + rm -rf .pytest_cache/ + rm -rf *.pyc + rm -rf .coverage + rm -rf htmlcov/ + rm -rf dist/ + rm -rf build/ + rm -rf *.egg-info/ + +# Security scan +security: + safety check -r requirements.txt || echo "Safety not installed, skipping security scan" + bandit -r . -f json || echo "Bandit not installed, skipping security scan" + +# Performance test +perf: + @echo "Running performance test..." + ab -n 100 -c 10 http://localhost:5000/ || echo "Apache Bench not available" + +# Health check +health: + curl -f http://localhost:5000/ || echo "Application not responding" + +# Generate documentation +docs: + @echo "Generating documentation..." + mkdir -p docs + @echo "# JupyterHub Notebook Viewer\n\nGenerated documentation\n" > docs/README.md + @echo "✓ Documentation generated in docs/" diff --git a/README.md b/README.md index e69de29..32d8346 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,236 @@ +JupyterHub Notebook Viewer + +A Flask-based web application for viewing and browsing Jupyter notebooks from a JupyterHub shared directory. Features a responsive web interface with directory navigation, notebook preview, and download capabilities. +Features + + 📁 Directory Navigation - Browse through nested directories with breadcrumb navigation + 📄 Notebook Viewing - Convert and display Jupyter notebooks as HTML in the browser + ⬇️ Download Support - Direct download of notebook files + 🎨 Responsive Design - Bootstrap-based UI that works on desktop and mobile + 🔒 Security - Path traversal protection and configurable access controls + 🛠️ Configurable - All settings configurable via environment variables + 🐳 Docker Support - Ready-to-use Docker containers and compose files + ❄️ Nix Development - Complete Nix flake for reproducible development + +Quick Start +Using Nix (Recommended) + +bash + +# Enter development environment +nix develop + +# Set up the environment (first time only) +setup-dev + +# Start development server +dev-server + +Using Docker + +bash + +# Copy and configure environment +cp .env.example .env +# Edit .env with your settings + +# Start with Docker Compose +docker-compose up + +Manual Installation + +bash + +# Install dependencies +pip install -r requirements.txt + +# Configure environment +cp .env.example .env +# Edit .env with your settings + +# Start the application +python app.py + +Configuration + +All configuration is done via environment variables, typically in a .env file: +Core Settings + + JUPYTERHUB_SHARED_DIR - Path to the shared notebook directory (default: /shared) + APP_TITLE - Application title shown in the interface + +Flask Settings + + FLASK_HOST - Host to bind to (default: 0.0.0.0) + FLASK_PORT - Port to run on (default: 5000) + FLASK_DEBUG - Enable debug mode (default: True) + FLASK_SECRET_KEY - Secret key for sessions (change in production!) + +File Handling + + MAX_FILE_SIZE - Maximum file size in bytes (default: 16777216 = 16MB) + NOTEBOOKS_PER_PAGE - Maximum notebooks to show per directory (default: 50) + ALLOWED_EXTENSIONS - Comma-separated list of allowed file extensions (default: .ipynb,.py,.md) + +Feature Toggles + + ENABLE_DOWNLOAD - Enable file downloads (default: True) + ENABLE_API - Enable JSON API endpoints (default: True) + +UI Settings + + THEME - UI theme, dark or light (default: dark) + +Development +With Nix + +The project includes a complete Nix flake for reproducible development: + +bash + +# Enter development shell +nix develop + +# Available commands in the shell: +setup-dev # Set up development environment +dev-server # Start development server with auto-reload +run-tests # Run basic tests + +With Just + +If you have just installed: + +bash + +# See all available commands +just + +# Common commands +just setup # Set up development environment +just dev # Start development server +just docker-up # Start with Docker +just test # Run tests +just clean # Clean temporary files + +Manual Development + +bash + +# Install dependencies +pip install -r requirements.txt + +# Set up environment +cp .env.example .env + +# Create sample content +mkdir -p shared +# Add some .ipynb files to shared/ + +# Start development server +python app.py + +Docker Deployment +Basic Deployment + +bash + +# Build and start +docker-compose up --build + +# Run in background +docker-compose up -d + +# View logs +docker-compose logs -f + +With Reverse Proxy + +The compose file includes an optional nginx reverse proxy: + +bash + +# Start with proxy +docker-compose --profile with-proxy up + +Production Considerations + +For production deployment: + + Change the secret key: Set FLASK_SECRET_KEY to a secure random value + Disable debug mode: Set FLASK_DEBUG=False + Configure volumes: Mount your actual shared directory + Set up SSL: Configure HTTPS in your reverse proxy + Resource limits: Set appropriate CPU and memory limits in Docker + +API Endpoints + +When ENABLE_API=True, the following JSON API endpoints are available: + + GET /api/notebooks?dir= - List notebooks and directories + GET / - Main web interface + GET /view/ - View notebook as HTML + GET /download/ - Download notebook file (if enabled) + +Security Features + + Path Traversal Protection - Prevents access outside the configured directory + File Type Validation - Only allows configured file extensions + Configurable Downloads - Downloads can be disabled entirely + Non-root Docker User - Container runs as non-privileged user + +Troubleshooting +Common Issues + + "No notebooks found" + Check that JUPYTERHUB_SHARED_DIR points to the correct directory + Ensure the directory contains .ipynb files + Check file permissions + Notebook won't display + Verify the notebook file is valid JSON + Check that nbconvert dependencies are installed + Look for errors in the application logs + Permission denied + Ensure the application has read access to the shared directory + Check Docker volume mounts + +Logs + +Application logs are printed to stdout. In Docker: + +bash + +docker-compose logs -f notebook-viewer + +Contributing + + Fork the repository + Enter the development environment: nix develop + Make your changes + Run tests: run-tests + Submit a pull request + +License + +MIT License - see LICENSE file for details. +Architecture + +┌─────────────────────────────────────────────────────────────┐ +│ Web Browser │ +└─────────────────────┬───────────────────────────────────────┘ + │ HTTP +┌─────────────────────▼───────────────────────────────────────┐ +│ Flask App │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ Routes │ │ Templates │ │ Static Assets │ │ +│ └─────────────┘ └──────────────┘ └─────────────────────┘ │ +└─────────────────────┬───────────────────────────────────────┘ + │ File System +┌─────────────────────▼───────────────────────────────────────┐ +│ JupyterHub Shared Directory │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ Notebooks │ │ Directories │ │ Other Files │ │ +│ │ (.ipynb) │ │ │ │ (.py, .md) │ │ +│ └─────────────┘ └──────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + + diff --git a/app.py b/app.py new file mode 100644 index 0000000..3aea62c --- /dev/null +++ b/app.py @@ -0,0 +1,440 @@ +import os +import json +from pathlib import Path +from flask import Flask, render_template, request, jsonify, send_file, abort +import nbformat +from nbconvert import HTMLExporter +import mimetypes +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +app = Flask(__name__) + +# Configuration from environment variables +app.config.update({ + 'SHARED_DIRECTORY': os.environ.get('JUPYTERHUB_SHARED_DIR', '/shared'), + 'HOST': os.environ.get('FLASK_HOST', '0.0.0.0'), + 'PORT': int(os.environ.get('FLASK_PORT', 5000)), + 'DEBUG': os.environ.get('FLASK_DEBUG', 'True').lower() == 'true', + 'SECRET_KEY': os.environ.get('FLASK_SECRET_KEY', 'dev-key-change-in-production'), + 'MAX_CONTENT_LENGTH': int(os.environ.get('MAX_FILE_SIZE', 16 * 1024 * 1024)), # 16MB default + 'NOTEBOOKS_PER_PAGE': int(os.environ.get('NOTEBOOKS_PER_PAGE', 50)), + 'ALLOWED_EXTENSIONS': os.environ.get('ALLOWED_EXTENSIONS', '.ipynb').split(','), + 'ENABLE_DOWNLOAD': os.environ.get('ENABLE_DOWNLOAD', 'True').lower() == 'true', + 'ENABLE_API': os.environ.get('ENABLE_API', 'True').lower() == 'true', + 'APP_TITLE': os.environ.get('APP_TITLE', 'JupyterHub Notebook Viewer'), + 'THEME': os.environ.get('THEME', 'dark'), # dark or light +}) + +def get_notebook_files(directory): + """Recursively get all notebook files from directory""" + notebooks = [] + allowed_extensions = app.config['ALLOWED_EXTENSIONS'] + + try: + for root, dirs, files in os.walk(directory): + for file in files: + if any(file.endswith(ext) for ext in allowed_extensions): + full_path = os.path.join(root, file) + relative_path = os.path.relpath(full_path, directory) + notebooks.append({ + 'name': file, + 'path': relative_path, + 'full_path': full_path, + 'size': os.path.getsize(full_path), + 'modified': os.path.getmtime(full_path) + }) + except (OSError, PermissionError) as e: + app.logger.error(f"Error accessing directory {directory}: {e}") + + # Limit results based on configuration + notebooks = sorted(notebooks, key=lambda x: x['modified'], reverse=True) + return notebooks[:app.config['NOTEBOOKS_PER_PAGE']] + +def get_directory_structure(directory): + """Get directory structure for navigation""" + structure = [] + try: + for item in os.listdir(directory): + item_path = os.path.join(directory, item) + if os.path.isdir(item_path): + structure.append({ + 'name': item, + 'type': 'directory', + 'path': os.path.relpath(item_path, app.config['SHARED_DIRECTORY']) + }) + except (OSError, PermissionError): + pass + + return sorted(structure, key=lambda x: x['name']) + +def convert_notebook_to_html(notebook_path): + """Convert Jupyter notebook to HTML""" + try: + with open(notebook_path, 'r', encoding='utf-8') as f: + notebook = nbformat.read(f, as_version=4) + + html_exporter = HTMLExporter() + html_exporter.template_name = 'classic' + + (body, resources) = html_exporter.from_notebook_node(notebook) + return body + except Exception as e: + return f"
Error converting notebook: {str(e)}
" + +@app.route('/') +def index(): + """Main page showing all notebooks""" + current_dir = request.args.get('dir', '') + full_current_dir = os.path.join(app.config['SHARED_DIRECTORY'], current_dir) + + if not os.path.exists(full_current_dir): + abort(404) + + notebooks = get_notebook_files(full_current_dir) + directories = get_directory_structure(full_current_dir) + + # Breadcrumb navigation + breadcrumbs = [] + if current_dir: + parts = current_dir.split(os.sep) + for i, part in enumerate(parts): + breadcrumbs.append({ + 'name': part, + 'path': os.sep.join(parts[:i+1]) + }) + + return render_template('index.html', + notebooks=notebooks, + directories=directories, + current_dir=current_dir, + breadcrumbs=breadcrumbs, + config=app.config) + +@app.route('/view/') +def view_notebook(notebook_path): + """View a specific notebook""" + full_path = os.path.join(app.config['SHARED_DIRECTORY'], notebook_path) + + if not os.path.exists(full_path): + abort(404) + + # Check if file has allowed extension + if not any(full_path.endswith(ext) for ext in app.config['ALLOWED_EXTENSIONS']): + abort(403) + + # Security check - ensure path is within shared directory + if not os.path.commonpath([full_path, app.config['SHARED_DIRECTORY']]) == app.config['SHARED_DIRECTORY']: + abort(403) + + html_content = convert_notebook_to_html(full_path) + + return render_template('notebook.html', + html_content=html_content, + notebook_name=os.path.basename(notebook_path), + notebook_path=notebook_path, + config=app.config) + +@app.route('/download/') +def download_notebook(notebook_path): + """Download a notebook file""" + if not app.config['ENABLE_DOWNLOAD']: + abort(403, "Downloads are disabled") + + full_path = os.path.join(app.config['SHARED_DIRECTORY'], notebook_path) + + if not os.path.exists(full_path): + abort(404) + + # Check if file has allowed extension + if not any(full_path.endswith(ext) for ext in app.config['ALLOWED_EXTENSIONS']): + abort(403) + + # Security check + if not os.path.commonpath([full_path, app.config['SHARED_DIRECTORY']]) == app.config['SHARED_DIRECTORY']: + abort(403) + + return send_file(full_path, as_attachment=True) + +@app.route('/api/notebooks') +def api_notebooks(): + """API endpoint to get notebooks as JSON""" + if not app.config['ENABLE_API']: + abort(403, "API is disabled") + + current_dir = request.args.get('dir', '') + full_current_dir = os.path.join(app.config['SHARED_DIRECTORY'], current_dir) + + notebooks = get_notebook_files(full_current_dir) + directories = get_directory_structure(full_current_dir) + + return jsonify({ + 'notebooks': notebooks, + 'directories': directories, + 'current_dir': current_dir, + 'config': { + 'app_title': app.config['APP_TITLE'], + 'enable_download': app.config['ENABLE_DOWNLOAD'], + 'notebooks_per_page': app.config['NOTEBOOKS_PER_PAGE'] + } + }) + +@app.errorhandler(404) +def not_found(error): + return render_template('error.html', + error_code=404, + error_message="Notebook or directory not found"), 404 + +@app.errorhandler(403) +def forbidden(error): + return render_template('error.html', + error_code=403, + error_message="Access forbidden"), 403 + +@app.errorhandler(500) +def server_error(error): + return render_template('error.html', + error_code=500, + error_message="Internal server error"), 500 + +if __name__ == '__main__': + # Create templates directory if it doesn't exist + os.makedirs('templates', exist_ok=True) + + # Determine theme classes + navbar_class = "navbar-dark bg-dark" if app.config['THEME'] == 'dark' else "navbar-light bg-light" + + # Basic templates + index_template = f''' + + + + + {{{{ config.APP_TITLE }}}} + + + + + + +
+ + {{% if breadcrumbs %}} + + {{% endif %}} + +

+ {{% if current_dir %}} + Notebooks in {{{{ current_dir }}}} + {{% else %}} + All Notebooks + {{% endif %}} +

+ + + {{% if directories %}} +
+
+

Directories

+ {{% for dir in directories %}} + + {{% endfor %}} +
+
+ {{% endif %}} + + +
+
+

Notebooks

+ {{% if notebooks %}} + {{% for notebook in notebooks %}} +
+
+
+
+
+ {{{{ notebook.name }}}} +
+

+ + Size: {{{{ "%.1f"|format(notebook.size/1024) }}}} KB | + Modified: {{{{ notebook.modified|int|datetime }}}} + +

+
+
+ + View + + {{% if config.ENABLE_DOWNLOAD %}} + + Download + + {{% endif %}} +
+
+
+
+ {{% endfor %}} + {{% if notebooks|length == config.NOTEBOOKS_PER_PAGE %}} +
+ Showing first {{{{ config.NOTEBOOKS_PER_PAGE }}}} notebooks. Configure NOTEBOOKS_PER_PAGE to show more. +
+ {{% endif %}} + {{% else %}} +
+ No notebooks found in this directory. +
+ {{% endif %}} +
+
+
+ + + +''' + + notebook_template = f''' + + + + + {{{{ notebook_name }}}} - {{{{ config.APP_TITLE }}}} + + + + + + + +
+
+
+

+ {{{{ notebook_name }}}} +

+
+ {{{{ html_content|safe }}}} +
+
+
+
+ + + +''' + + error_template = f''' + + + + + Error {{{{ error_code }}}} - {{{{ config.APP_TITLE if config else 'JupyterHub Notebook Viewer' }}}} + + + + + + +
+
+
+
+

+

Error {{{{ error_code }}}}

+

{{{{ error_message }}}}

+ + Go Home + +
+
+
+
+ +''' + + # Write templates + with open('templates/index.html', 'w') as f: + f.write(index_template) + + with open('templates/notebook.html', 'w') as f: + f.write(notebook_template) + + with open('templates/error.html', 'w') as f: + f.write(error_template) + + # Add datetime filter + @app.template_filter('datetime') + def datetime_filter(timestamp): + from datetime import datetime + return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') + + print(f"Starting {app.config['APP_TITLE']}") + print(f"Shared directory: {app.config['SHARED_DIRECTORY']}") + print(f"Server running on http://{app.config['HOST']}:{app.config['PORT']}") + print(f"Debug mode: {app.config['DEBUG']}") + print(f"Downloads enabled: {app.config['ENABLE_DOWNLOAD']}") + print(f"API enabled: {app.config['ENABLE_API']}") + + app.run( + debug=app.config['DEBUG'], + host=app.config['HOST'], + port=app.config['PORT'] + ) diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..da02fff --- /dev/null +++ b/compose.yml @@ -0,0 +1,36 @@ +version: '3.8' + +services: + notebook-viewer: + build: . + ports: + - "${FLASK_PORT:-5000}:5000" + volumes: + - "${JUPYTERHUB_SHARED_DIR:-./shared}:/shared:ro" + - "./notebooks:/notebooks:ro" # Additional notebooks directory + environment: + - JUPYTERHUB_SHARED_DIR=/shared + - FLASK_HOST=0.0.0.0 + - FLASK_PORT=5000 + - FLASK_DEBUG=${FLASK_DEBUG:-False} + - FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-change-me-in-production} + - MAX_FILE_SIZE=${MAX_FILE_SIZE:-16777216} + - NOTEBOOKS_PER_PAGE=${NOTEBOOKS_PER_PAGE:-50} + - ALLOWED_EXTENSIONS=${ALLOWED_EXTENSIONS:-.ipynb,.py,.md} + - ENABLE_DOWNLOAD=${ENABLE_DOWNLOAD:-True} + - ENABLE_API=${ENABLE_API:-True} + - APP_TITLE=${APP_TITLE:-JupyterHub Notebook Viewer} + - THEME=${THEME:-dark} + env_file: + - .env + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + shared: + driver: local diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c5c3696 --- /dev/null +++ b/flake.nix @@ -0,0 +1,313 @@ +{ + description = "JupyterHub Notebook Viewer Development Environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # Python with required packages + python = pkgs.python311.withPackages (ps: with ps; [ + flask + python-dotenv + nbformat + nbconvert + werkzeug + jinja2 + markupsafe + itsdangerous + click + blinker + jupyter-core + traitlets + jupyter-client + ipython + bleach + defusedxml + mistune + pandocfilters + beautifulsoup4 + webencodings + fastjsonschema + jsonschema + pyrsistent + pyzmq + tornado + nest-asyncio + psutil + packaging + platformdirs + debugpy + matplotlib-inline + pygments + cffi + argon2-cffi + jupyterlab-pygments + nbclient + tinycss2 + ]); + + # Development tools + devTools = with pkgs; [ + # Core development + python + nodejs_20 + + # System tools + curl + jq + tree + htop + + # Container tools + docker + docker-compose + + # Documentation tools + pandoc + + # LaTeX for nbconvert + texlive.combined.scheme-medium + + # Version control + git + + # Text editors + vim + nano + + # Network tools + netcat + + # Process management + supervisor + ]; + + # Shell script for easy setup + setupScript = pkgs.writeShellScriptBin "setup-dev" '' + echo "Setting up JupyterHub Notebook Viewer development environment..." + + # Create directories + mkdir -p shared notebooks templates + + # Copy example .env if it doesn't exist + if [ ! -f .env ]; then + cp .env.example .env 2>/dev/null || echo "No .env.example found, using defaults" + fi + + # Set up git hooks if in git repo + if [ -d .git ]; then + echo "Setting up git hooks..." + cp .githooks/* .git/hooks/ 2>/dev/null || true + fi + + # Create sample notebook if shared directory is empty + if [ ! "$(ls -A shared)" ]; then + echo "Creating sample notebook..." + cat > shared/sample.ipynb << 'EOF' + { + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Sample Notebook\n", + "\n", + "This is a sample Jupyter notebook for testing the viewer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('Hello from JupyterHub Notebook Viewer!')\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "x = np.linspace(0, 10, 100)\n", + "y = np.sin(x)\n", + "\n", + "plt.figure(figsize=(10, 6))\n", + "plt.plot(x, y)\n", + "plt.title('Sample Plot')\n", + "plt.xlabel('X')\n", + "plt.ylabel('sin(x)')\n", + "plt.grid(True)\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 + } + EOF + fi + + echo "Development environment setup complete!" + echo "Run 'python app.py' to start the development server" + ''; + + # Script for running tests + testScript = pkgs.writeShellScriptBin "run-tests" '' + echo "Running tests for JupyterHub Notebook Viewer..." + + # Basic import test + python -c " + import app + print('✓ App imports successfully') + + # Test configuration loading + from dotenv import load_dotenv + load_dotenv() + print('✓ Environment variables loaded') + + # Test nbconvert + import nbformat + import nbconvert + print('✓ Jupyter dependencies working') + " + + # Test Flask app creation + python -c " + import app + test_app = app.app.test_client() + response = test_app.get('/') + if response.status_code in [200, 404]: # 404 is OK if no shared dir + print('✓ Flask app responds correctly') + else: + print('✗ Flask app error:', response.status_code) + exit(1) + " + + echo "All tests passed!" + ''; + + # Development server with auto-reload + devServerScript = pkgs.writeShellScriptBin "dev-server" '' + echo "Starting development server with auto-reload..." + export FLASK_DEBUG=True + export FLASK_ENV=development + + # Load .env file + if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) + fi + + # Start the server + python app.py + ''; + + in + { + # Development shell + devShells.default = pkgs.mkShell { + buildInputs = devTools ++ [ + setupScript + testScript + devServerScript + ]; + + shellHook = '' + echo "🚀 JupyterHub Notebook Viewer Development Environment" + echo "==================================================" + echo "" + echo "Available commands:" + echo " setup-dev - Set up development environment" + echo " dev-server - Start development server with auto-reload" + echo " run-tests - Run basic tests" + echo " python app.py - Start the application" + echo "" + echo "Docker commands:" + echo " docker-compose up - Start with Docker" + echo " docker-compose up --build - Rebuild and start" + echo " docker-compose up -d - Start in background" + echo " docker-compose --profile with-proxy up - Start with nginx proxy" + echo "" + echo "Configuration:" + echo " Edit .env file to configure the application" + echo " JUPYTERHUB_SHARED_DIR: $(echo ''${JUPYTERHUB_SHARED_DIR:-/shared})" + echo "" + + # Auto-setup on first run + if [ ! -f .env ] && [ ! -d shared ]; then + echo "First run detected, running setup..." + setup-dev + fi + ''; + + # Environment variables for development + FLASK_DEBUG = "True"; + FLASK_ENV = "development"; + PYTHONPATH = "."; + }; + + # Package for the application + packages.default = pkgs.stdenv.mkDerivation { + pname = "jupyterhub-notebook-viewer"; + version = "1.0.0"; + + src = ./.; + + buildInputs = [ python ]; + + installPhase = '' + mkdir -p $out/bin $out/share/jupyterhub-notebook-viewer + + # Copy application files + cp app.py $out/share/jupyterhub-notebook-viewer/ + cp -r templates $out/share/jupyterhub-notebook-viewer/ + + # Create wrapper script + cat > $out/bin/jupyterhub-notebook-viewer << EOF + #!${pkgs.bash}/bin/bash + cd $out/share/jupyterhub-notebook-viewer + exec ${python}/bin/python app.py "\$@" + EOF + chmod +x $out/bin/jupyterhub-notebook-viewer + ''; + + meta = with pkgs.lib; { + description = "Web viewer for JupyterHub shared notebooks"; + license = licenses.mit; + platforms = platforms.unix; + }; + }; + + # Docker image build + packages.docker = pkgs.dockerTools.buildImage { + name = "jupyterhub-notebook-viewer"; + tag = "latest"; + + contents = [ python pkgs.pandoc ]; + + config = { + Cmd = [ "${python}/bin/python" "/app/app.py" ]; + WorkingDir = "/app"; + ExposedPorts = { + "5000/tcp" = {}; + }; + }; + }; + + # Formatter for nix files + formatter = pkgs.nixpkgs-fmt; + }); +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29