From 35cf85699cb215802ec7df78675c150c6682b55a Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 22 Jul 2025 15:27:37 -0400 Subject: [PATCH 1/5] fix: replace http.server with waitress to eliminate BrokenPipeError Replaces Python's SimpleHTTPRequestHandler with waitress WSGI server to prevent broken pipe errors when browsers close connections early. --- align_browser/build.py | 81 ++++++++++++++++++++++-------------------- pyproject.toml | 3 +- uv.lock | 11 ++++++ 3 files changed, 56 insertions(+), 39 deletions(-) diff --git a/align_browser/build.py b/align_browser/build.py index 48e5734..6ff8a43 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 @@ -179,45 +178,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()] + + # 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() - 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") + if actual_port != port: + print(f"Port {port} was busy, using port {actual_port} instead") - finally: - # Restore original directory - os.chdir(original_dir) + print("Press Ctrl+C to stop the server") + try: + 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/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" }, +] From 4ed5bf96bcf2508a6173ac2cde651a84adcafd88 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 22 Jul 2025 15:32:00 -0400 Subject: [PATCH 2/5] cleanup: reduce verbose console output during build and server startup --- align_browser/build.py | 15 +++++---------- align_browser/experiment_parser.py | 1 - 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/align_browser/build.py b/align_browser/build.py index 6ff8a43..4c9ab20 100644 --- a/align_browser/build.py +++ b/align_browser/build.py @@ -72,11 +72,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) @@ -99,8 +96,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 @@ -205,6 +200,9 @@ def static_app(environ, start_response): 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}" @@ -215,9 +213,6 @@ def static_app(environ, start_response): 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: serve(static_app, host=host, port=actual_port) 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 From e71d772120264ae15a1749f99f94aaaae5b057e4 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 22 Jul 2025 15:46:34 -0400 Subject: [PATCH 3/5] fix: parse personal_safety KDMA names with underscores - Modify parse_alignment_target_id to handle single KDMA names containing underscores - Add comprehensive test coverage for KDMA parsing edge cases - Apply ruff formatting to build.py --- align_browser/build.py | 28 +++---- align_browser/experiment_models.py | 26 ++++--- align_browser/test_kdma_parsing.py | 119 +++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 align_browser/test_kdma_parsing.py diff --git a/align_browser/build.py b/align_browser/build.py index 4c9ab20..5b834bf 100644 --- a/align_browser/build.py +++ b/align_browser/build.py @@ -182,27 +182,27 @@ def serve_directory(directory, host="localhost", port=8000): 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('/') - + 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'] - + 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: + 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}" 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/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"]) From 18ec9feeba8de60e7bd5610015c20cfed0d3eea9 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 22 Jul 2025 15:57:40 -0400 Subject: [PATCH 4/5] feat: add user notification system for manifest version mismatch Replace console warnings with user-visible notifications when URL parameters are from different manifest versions. Implements toast-style notifications that auto-dismiss after 4 seconds. Closes #29 --- align_browser/static/notifications.js | 97 +++++++++++++++++++++++++++ align_browser/static/state.js | 4 +- align_browser/static/style.css | 66 ++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 align_browser/static/notifications.js 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); +} From 42b10824eab2e7260ba1b370528e5424cc383aa1 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 22 Jul 2025 16:06:43 -0400 Subject: [PATCH 5/5] fix: improve static asset copying to include all files automatically Replace hardcoded file list with automatic file discovery. Copy all static files except Python files, ensuring new files like notifications.js are included in production builds without manual updates. Remove try-catch blocks - file copy failures should be fatal errors. --- align_browser/build.py | 44 +++++++++++------------------------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/align_browser/build.py b/align_browser/build.py index 5b834bf..07e9248 100644 --- a/align_browser/build.py +++ b/align_browser/build.py @@ -19,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" - - 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) + # Use importlib.resources for robust package data access + static_files = files("align_browser.static") + + # 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(