diff --git a/align_browser/build.py b/align_browser/build.py index 48e5734..07e9248 100644 --- a/align_browser/build.py +++ b/align_browser/build.py @@ -1,6 +1,5 @@ import shutil import json -import http.server import socket from pathlib import Path import argparse @@ -20,39 +19,17 @@ def copy_static_assets(output_dir): """Copy static assets from package static/ directory to output directory.""" - try: - # Use importlib.resources for robust package data access - static_files = files("align_browser.static") - - for filename in ["index.html", "app.js", "state.js", "style.css"]: - try: - # Read the file content from the package - file_content = (static_files / filename).read_bytes() - - # Write to destination - dst_file = output_dir / filename - dst_file.write_bytes(file_content) - - except FileNotFoundError: - pass - - except Exception as e: - # Fallback to filesystem approach for development - print(f"Package resource access failed, trying filesystem fallback: {e}") - script_dir = Path(__file__).parent - static_dir = script_dir / "static" + # Use importlib.resources for robust package data access + static_files = files("align_browser.static") - if not static_dir.exists(): - raise FileNotFoundError(f"Static assets directory not found: {static_dir}") - - static_files = ["index.html", "app.js", "state.js", "style.css"] - - for filename in static_files: - src_file = static_dir / filename - dst_file = output_dir / filename - - if src_file.exists(): - shutil.copy2(src_file, dst_file) + # Copy all files from the static directory, excluding Python files + for file_ref in static_files.iterdir(): + if file_ref.is_file() and not file_ref.name.endswith(".py"): + # Read the file content from the package + file_content = file_ref.read_bytes() + # Write to destination + dst_file = output_dir / file_ref.name + dst_file.write_bytes(file_content) def build_frontend( @@ -73,11 +50,8 @@ def build_frontend( print(f"Processing experiments directory: {experiments_root}") # Determine output directory based on mode - if dev_mode: - print("Development mode: using provided directory") - else: - # Production mode: copy static assets - print(f"Production mode: creating site in {output_dir}") + if not dev_mode: + print(f"creating site in {output_dir}") output_dir.mkdir(parents=True, exist_ok=True) copy_static_assets(output_dir) @@ -100,8 +74,6 @@ def build_frontend( with open(data_output_dir / "manifest.json", "w") as f: json.dump(manifest.model_dump(), f, indent=2) - print(f"Data generated in {data_output_dir}") - return output_dir @@ -179,45 +151,51 @@ def main(): def serve_directory(directory, host="localhost", port=8000): """Start HTTP server to serve the specified directory.""" - import os + from waitress import serve + import mimetypes + from pathlib import Path + + # Find an available port starting from the requested port + actual_port = find_available_port(port, host) + + def static_app(environ, start_response): + """Simple WSGI app for serving static files.""" + path_info = environ["PATH_INFO"] + if path_info == "/": + path_info = "/index.html" + + file_path = Path(directory) / path_info.lstrip("/") + + if not file_path.exists() or not file_path.is_file(): + start_response("404 Not Found", [("Content-Type", "text/plain")]) + return [b"404 Not Found"] + + content_type, _ = mimetypes.guess_type(str(file_path)) + if content_type is None: + content_type = "application/octet-stream" + + start_response("200 OK", [("Content-Type", content_type)]) + with open(file_path, "rb") as f: + return [f.read()] + + if actual_port != port: + print(f"Port {port} was busy, using port {actual_port} instead") + + # Display appropriate URL based on host + if host == "0.0.0.0": + url = f"http://localhost:{actual_port}" + print(f"Serving {directory} on all network interfaces at port {actual_port}") + print(f"Local access: {url}") + print(f"Network access: http://:{actual_port}") + else: + url = f"http://{host}:{actual_port}" + print(f"Serving {directory} at {url}") - # Change to the output directory - original_dir = os.getcwd() + print("Press Ctrl+C to stop the server") try: - os.chdir(directory) - - # Find an available port starting from the requested port - actual_port = find_available_port(port, host) - - # Create HTTP server - handler = http.server.SimpleHTTPRequestHandler - with http.server.HTTPServer((host, actual_port), handler) as httpd: - # Enable socket reuse to prevent "Address already in use" errors - httpd.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - # Display appropriate URL based on host - if host == "0.0.0.0": - url = f"http://localhost:{actual_port}" - print( - f"Serving {directory} on all network interfaces at port {actual_port}" - ) - print(f"Local access: {url}") - print(f"Network access: http://:{actual_port}") - else: - url = f"http://{host}:{actual_port}" - print(f"Serving {directory} at {url}") - - if actual_port != port: - print(f"Port {port} was busy, using port {actual_port} instead") - - print("Press Ctrl+C to stop the server") - try: - httpd.serve_forever() - except KeyboardInterrupt: - print("\nServer stopped") - - finally: - # Restore original directory - os.chdir(original_dir) + serve(static_app, host=host, port=actual_port) + except KeyboardInterrupt: + print("\nServer stopped") def find_available_port(start_port=8000, host="localhost"): diff --git a/align_browser/experiment_models.py b/align_browser/experiment_models.py index 01d55a0..8227be7 100644 --- a/align_browser/experiment_models.py +++ b/align_browser/experiment_models.py @@ -45,12 +45,15 @@ def parse_alignment_target_id(alignment_target_id: str) -> List[KDMAValue]: Supports both single and multi-KDMA formats: - Single: "ADEPT-June2025-merit-0.0" -> [KDMAValue(kdma="merit", value=0.0)] + - Single with underscore: "ADEPT-June2025-personal_safety-0.0" -> [KDMAValue(kdma="personal_safety", value=0.0)] + - Short format: "personal_safety-0.0" -> [KDMAValue(kdma="personal_safety", value=0.0)] - Multi: "ADEPT-June2025-affiliation_merit-0.0_0.0" -> [KDMAValue(kdma="affiliation", value=0.0), KDMAValue(kdma="merit", value=0.0)] - Unaligned: "unaligned" -> [] (no KDMAs) Args: alignment_target_id: String like "ADEPT-June2025-merit-0.0", + "ADEPT-June2025-personal_safety-0.0", "personal_safety-0.0", "ADEPT-June2025-affiliation_merit-0.0_0.0", or "unaligned" Returns: @@ -59,18 +62,15 @@ def parse_alignment_target_id(alignment_target_id: str) -> List[KDMAValue]: if not alignment_target_id or alignment_target_id == "unaligned": return [] - # Split by hyphens: [prefix, scenario, kdma_part, value_part] + # Split by hyphens parts = alignment_target_id.split("-") - if len(parts) < 4: + if len(parts) < 2: return [] # Extract KDMA names and values from the last two parts - kdma_part = parts[-2] # e.g., "affiliation_merit" or "merit" + kdma_part = parts[-2] # e.g., "affiliation_merit", "merit", or "personal_safety" value_part = parts[-1] # e.g., "0.0_0.0" or "0.0" - # Split KDMA names by underscore - kdma_names = kdma_part.split("_") - # Split values by underscore and convert to float try: value_strings = value_part.split("_") @@ -78,9 +78,17 @@ def parse_alignment_target_id(alignment_target_id: str) -> List[KDMAValue]: except ValueError: return [] - # Ensure we have the same number of KDMAs and values - if len(kdma_names) != len(values): - return [] + # Determine how to split KDMA names based on number of values + if len(values) == 1: + # Single value: treat entire kdma_part as one KDMA name (handles personal_safety) + kdma_names = [kdma_part] + else: + # Multiple values: split KDMA names by underscore + kdma_names = kdma_part.split("_") + + # Ensure we have the same number of KDMAs and values + if len(kdma_names) != len(values): + return [] # Create KDMAValue objects kdma_values = [] diff --git a/align_browser/experiment_parser.py b/align_browser/experiment_parser.py index d6f2fa4..2d6ce32 100644 --- a/align_browser/experiment_parser.py +++ b/align_browser/experiment_parser.py @@ -236,7 +236,6 @@ def build_manifest_from_experiments( input_output_files.add(experiment.experiment_path / "input_output.json") # Calculate checksums for all files - print(f"Calculating checksums for {len(input_output_files)} files...") source_file_checksums = calculate_file_checksums(list(input_output_files)) # Process experiments with conflict detection similar to original diff --git a/align_browser/static/notifications.js b/align_browser/static/notifications.js new file mode 100644 index 0000000..c141122 --- /dev/null +++ b/align_browser/static/notifications.js @@ -0,0 +1,97 @@ +// Notification system for temporary user messages +// Provides toast-style notifications that auto-dismiss + +let notificationContainer = null; +let notificationQueue = []; +let isProcessingQueue = false; + +// Initialize notification container +function initNotificationContainer() { + if (!notificationContainer) { + notificationContainer = document.createElement('div'); + notificationContainer.id = 'notification-container'; + notificationContainer.className = 'notification-container'; + document.body.appendChild(notificationContainer); + } +} + +// Create notification element +function createNotificationElement(message, type = 'warning') { + const notification = document.createElement('div'); + notification.className = `notification notification-${type}`; + notification.textContent = message; + return notification; +} + +// Show notification with animation +function displayNotification(notification, duration = 4000) { + initNotificationContainer(); + + // Add to container + notificationContainer.appendChild(notification); + + // Trigger slide-in animation + requestAnimationFrame(() => { + notification.classList.add('notification-show'); + }); + + // Auto-dismiss after duration + setTimeout(() => { + dismissNotification(notification); + }, duration); +} + +// Dismiss notification with animation +function dismissNotification(notification) { + if (notification && notification.parentNode) { + notification.classList.remove('notification-show'); + notification.classList.add('notification-hide'); + + // Remove from DOM after animation + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + processNotificationQueue(); + }, 300); + } +} + +// Process queued notifications +function processNotificationQueue() { + if (notificationQueue.length > 0 && !isProcessingQueue) { + isProcessingQueue = true; + const { message, type, duration } = notificationQueue.shift(); + const notification = createNotificationElement(message, type); + displayNotification(notification, duration); + + // Reset processing flag after notification is shown + setTimeout(() => { + isProcessingQueue = false; + }, 500); + } +} + +// Main function to show notifications +export function showNotification(message, type = 'warning', duration = 4000) { + // Add to queue to prevent overlapping + notificationQueue.push({ message, type, duration }); + + // Process immediately if not already processing + if (!isProcessingQueue) { + processNotificationQueue(); + } +} + +// Convenience functions for different notification types +export function showWarning(message, duration = 4000) { + showNotification(message, 'warning', duration); +} + +export function showInfo(message, duration = 3000) { + showNotification(message, 'info', duration); +} + +export function showError(message, duration = 5000) { + showNotification(message, 'error', duration); +} \ No newline at end of file diff --git a/align_browser/static/state.js b/align_browser/static/state.js index d9b9931..7aec5d8 100644 --- a/align_browser/static/state.js +++ b/align_browser/static/state.js @@ -1,6 +1,8 @@ // Functional State Management Module // Pure functions for managing application state without mutations +import { showWarning } from './notifications.js'; + // Constants for KDMA processing const KDMA_CONSTANTS = { DECIMAL_PRECISION: 10, // For 1 decimal place normalization @@ -200,7 +202,7 @@ export function decodeStateFromURL() { const currentManifest = GlobalState.getManifest(); if (currentManifest && decodedState.manifestCreatedAt && decodedState.manifestCreatedAt !== currentManifest.generated_at) { - console.warn('URL parameters are from a different manifest version, ignoring URL state'); + showWarning('URL parameters are from an older version and have been reset'); return null; } diff --git a/align_browser/static/style.css b/align_browser/static/style.css index 065bfd0..1b57eaf 100644 --- a/align_browser/static/style.css +++ b/align_browser/static/style.css @@ -3,6 +3,15 @@ :root { --run-column-min-width: 350px; --accent-color: #007bff; + --notification-warning: #856404; + --notification-warning-bg: #fff3cd; + --notification-warning-border: #ffeaa7; + --notification-info: #0c5460; + --notification-info-bg: #d1ecf1; + --notification-info-border: #bee5eb; + --notification-error: #721c24; + --notification-error-bg: #f8d7da; + --notification-error-border: #f5c6cb; } body { @@ -597,3 +606,60 @@ footer { font-size: 11px; flex-shrink: 0; } + +/* Notification system styles */ +.notification-container { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; +} + +.notification { + padding: 12px 16px; + border-radius: 6px; + border: 1px solid; + font-size: 14px; + font-weight: 500; + max-width: 400px; + min-width: 300px; + text-align: center; + transform: translateY(-100px); + opacity: 0; + transition: all 0.3s ease-out; + pointer-events: auto; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.notification-show { + transform: translateY(0); + opacity: 1; +} + +.notification-hide { + transform: translateY(-100px); + opacity: 0; +} + +.notification-warning { + background-color: var(--notification-warning-bg); + color: var(--notification-warning); + border-color: var(--notification-warning-border); +} + +.notification-info { + background-color: var(--notification-info-bg); + color: var(--notification-info); + border-color: var(--notification-info-border); +} + +.notification-error { + background-color: var(--notification-error-bg); + color: var(--notification-error); + border-color: var(--notification-error-border); +} diff --git a/align_browser/test_kdma_parsing.py b/align_browser/test_kdma_parsing.py new file mode 100644 index 0000000..342980f --- /dev/null +++ b/align_browser/test_kdma_parsing.py @@ -0,0 +1,119 @@ +"""Tests specifically for KDMA parsing functionality.""" + +import pytest +from align_browser.experiment_models import parse_alignment_target_id + + +def test_parse_alignment_target_id_single_kdma(): + """Test parsing single KDMA alignment targets.""" + + # Standard format + result = parse_alignment_target_id("ADEPT-June2025-merit-0.0") + assert len(result) == 1 + assert result[0].kdma == "merit" + assert result[0].value == 0.0 + + # KDMA with underscore + result = parse_alignment_target_id("ADEPT-June2025-personal_safety-0.5") + assert len(result) == 1 + assert result[0].kdma == "personal_safety" + assert result[0].value == 0.5 + + # Short format (directory name style) + result = parse_alignment_target_id("personal_safety-0.0") + assert len(result) == 1 + assert result[0].kdma == "personal_safety" + assert result[0].value == 0.0 + + # Another standard case + result = parse_alignment_target_id("ADEPT-June2025-affiliation-1.0") + assert len(result) == 1 + assert result[0].kdma == "affiliation" + assert result[0].value == 1.0 + + +def test_parse_alignment_target_id_multi_kdma(): + """Test parsing multi-KDMA alignment targets.""" + + # Two KDMAs without underscores in names + result = parse_alignment_target_id("ADEPT-June2025-affiliation_merit-0.0_0.5") + assert len(result) == 2 + + # Sort by KDMA name for consistent comparison + result = sorted(result, key=lambda x: x.kdma) + assert result[0].kdma == "affiliation" + assert result[0].value == 0.0 + assert result[1].kdma == "merit" + assert result[1].value == 0.5 + + # Three values + result = parse_alignment_target_id("ADEPT-June2025-a_b_c-0.1_0.2_0.3") + assert len(result) == 3 + result = sorted(result, key=lambda x: x.kdma) + assert result[0].kdma == "a" + assert result[0].value == 0.1 + assert result[1].kdma == "b" + assert result[1].value == 0.2 + assert result[2].kdma == "c" + assert result[2].value == 0.3 + + +def test_parse_alignment_target_id_edge_cases(): + """Test edge cases and invalid inputs.""" + + # Unaligned + result = parse_alignment_target_id("unaligned") + assert result == [] + + # Empty string + result = parse_alignment_target_id("") + assert result == [] + + # None + result = parse_alignment_target_id(None) + assert result == [] + + # Invalid format - no hyphens + result = parse_alignment_target_id("merit0.0") + assert result == [] + + # Invalid format - not enough parts + result = parse_alignment_target_id("merit") + assert result == [] + + # Invalid format - missing value + result = parse_alignment_target_id("ADEPT-June2025-merit") + assert result == [] + + # Invalid format - bad float value + result = parse_alignment_target_id("ADEPT-June2025-merit-invalid") + assert result == [] + + # Mismatched KDMA names and values count (should not happen with new logic for single values) + # This case would be handled properly now since single values treat the whole kdma_part as one name + + +def test_parse_alignment_target_id_regression_personal_safety(): + """Regression test for the specific personal_safety bug.""" + + # This was the main failing case + result = parse_alignment_target_id("personal_safety-0.0") + assert len(result) == 1 + assert result[0].kdma == "personal_safety" + assert result[0].value == 0.0 + + # Longer format that was also failing + result = parse_alignment_target_id("ADEPT-June2025-personal_safety-0.0") + assert len(result) == 1 + assert result[0].kdma == "personal_safety" + assert result[0].value == 0.0 + + # Ensure other underscore-containing KDMA names work + result = parse_alignment_target_id("some_other_kdma-1.0") + assert len(result) == 1 + assert result[0].kdma == "some_other_kdma" + assert result[0].value == 1.0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/pyproject.toml b/pyproject.toml index 4479559..5606a71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,8 @@ description = "Static web application for visualizing ADM results" requires-python = ">=3.10" dependencies = [ "pyyaml", - "pydantic" + "pydantic", + "waitress" ] [project.scripts] diff --git a/uv.lock b/uv.lock index 8fda2f3..8780ba2 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,7 @@ source = { editable = "." } dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, + { name = "waitress" }, ] [package.dev-dependencies] @@ -26,6 +27,7 @@ dev = [ requires-dist = [ { name = "pydantic" }, { name = "pyyaml" }, + { name = "waitress" }, ] [package.metadata.requires-dev] @@ -714,3 +716,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599 wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] + +[[package]] +name = "waitress" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, +]