diff --git a/src/tqdm_publisher/_demo/_client.css b/src/tqdm_publisher/_demo/_client.css new file mode 100644 index 0000000..35c818c --- /dev/null +++ b/src/tqdm_publisher/_demo/_client.css @@ -0,0 +1,37 @@ + +html, body { + font-family: sans-serif; +} + +h1 { + margin: 0; + padding: 0; + font-size: 1.5rem; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; +} + +#bars { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + flex-wrap: wrap; +} + +.progress { + width: 100%; + height: 20px; + background-color: #ddd; +} + +.progress div { + height: 100%; + background-color: #4caf50; + width: 0%; +} \ No newline at end of file diff --git a/src/tqdm_publisher/_demo/_client.html b/src/tqdm_publisher/_demo/_client.html deleted file mode 100644 index 0e514eb..0000000 --- a/src/tqdm_publisher/_demo/_client.html +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - Concurrent Client Demo - - - - - - - - - -
-
-

tqdm_progress

- Create multiple progress bars to test concurrent subscriptions -
- - - -
- - -
- - diff --git a/src/tqdm_publisher/_demo/_client.js b/src/tqdm_publisher/_demo/_client.js deleted file mode 100644 index edf3670..0000000 --- a/src/tqdm_publisher/_demo/_client.js +++ /dev/null @@ -1,72 +0,0 @@ - -// Grab bar container from HTML -const barContainer = document.querySelector('#bars'); - -// Create a progress bar and append it to the bar container -const createProgressBar = () => { - const element = document.createElement('div'); - element.classList.add('progress'); - const progress = document.createElement('div'); - element.appendChild(progress); - barContainer.appendChild(element); - return { element, progress }; -} - -// Create a simple WebSocket client wrapper class -class ProgressClient { - - #connect = (props = {}) => { - - const { - onopen = () => {}, - onclose = () => {}, - onmessage = () => {} - } = props; - - this.socket = new WebSocket('ws://localhost:8000'); - this.socket.addEventListener('open', onopen); - - // Attempt to reconnect every second if the connection is closed - this.socket.addEventListener('close', () => { - onclose(); - setTimeout(() => this.#connect(props), 1000); - }); - - this.socket.addEventListener('message', onmessage); - } - - constructor(props) { - this.#connect(props); - } - - close() { - this.socket.close(); - } - -} - - -const bars = {} // Track progress bars - - -// Update the specified progress bar when a message is received from the server -const onProgressUpdate = (event) => { - const { progress_bar_id, format_dict } = JSON.parse(event.data); - bars[progress_bar_id].style.width = 100 * (format_dict.n / format_dict.total) + '%'; -} - -// Create a new WebSocket client -const client = new ProgressClient({ onmessage: onProgressUpdate }); - -// Declare that the HTML Button should create a new progress bar when clicked -const button = document.querySelector('button'); -button.addEventListener('click', () => { - const { element, progress } = createProgressBar(); // Create a progress bar - - barContainer.appendChild(element); // Render the progress bar - - const progress_bar_id = Math.random().toString(36).substring(7); // Create a unique ID for the progress bar - bars[progress_bar_id] = progress; // Track the progress bar - - client.socket.send(JSON.stringify({ command: 'start', progress_bar_id })); // Send a message to the server to start the progress bar -}) diff --git a/src/tqdm_publisher/_demo/_demo_command_line_interface.py b/src/tqdm_publisher/_demo/_demo_command_line_interface.py index 2f1764c..2a6c0b7 100644 --- a/src/tqdm_publisher/_demo/_demo_command_line_interface.py +++ b/src/tqdm_publisher/_demo/_demo_command_line_interface.py @@ -3,12 +3,18 @@ import sys from pathlib import Path -from ._server import run_demo +import webbrowser + +DEMOS = { + "single": "_single", + "multiple": "_multiple", + # "parallel": "_parallel", +} DEMO_BASE_FOLDER_PATH = Path(__file__).parent -CLIENT_FILE_PATH = DEMO_BASE_FOLDER_PATH / "_client.html" -SERVER_FILE_PATH = DEMO_BASE_FOLDER_PATH / "_server.py" +CLIENT_PORT = 1234 +RELATIVE_DEMO_BASE_FOLDER_PATH = DEMO_BASE_FOLDER_PATH.relative_to(Path.cwd()) def _command_line_interface(): @@ -30,14 +36,23 @@ def _command_line_interface(): print(f"No flags are accepted at this time, but flags {flags_list} were received.") return - if command == "demo": - # For convenience - automatically pop-up a browser window on the locally hosted HTML page - if sys.platform == "win32": - os.system(f'start "" "{CLIENT_FILE_PATH}"') - else: - subprocess.run(["open", CLIENT_FILE_PATH]) + if command in DEMOS: + + subpath = DEMOS[command] + + # if command == "parallel": + # client_relative_path = Path(subpath) / "_client.py" + # subprocess.Popen(['python', str(DEMO_BASE_FOLDER_PATH / subpath / "_server.py")]) + # subprocess.Popen(['python', str(DEMO_BASE_FOLDER_PATH / subpath / "_client.py")]) + + # else: + + client_relative_path = Path(subpath) / "_client.html" + subprocess.Popen(['python', '-m', 'http.server', str(CLIENT_PORT), "-d", DEMO_BASE_FOLDER_PATH]) + + webbrowser.open_new_tab(f"http://localhost:{CLIENT_PORT}/{client_relative_path}") - run_demo() + subprocess.run(['python', str(DEMO_BASE_FOLDER_PATH / subpath / "_server.py")]) else: print(f"{command} is an invalid command.") diff --git a/src/tqdm_publisher/_demo/_multiple/_client.html b/src/tqdm_publisher/_demo/_multiple/_client.html new file mode 100644 index 0000000..a59ec1b --- /dev/null +++ b/src/tqdm_publisher/_demo/_multiple/_client.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + Multiple Bar Demo + + + + + + + + + +
+
+

tqdm_progress

+ Create multiple progress bars to test concurrent subscriptions +
+ + + +
+ + +
+ + diff --git a/src/tqdm_publisher/_demo/_multiple/_client.js b/src/tqdm_publisher/_demo/_multiple/_client.js new file mode 100644 index 0000000..29b5da9 --- /dev/null +++ b/src/tqdm_publisher/_demo/_multiple/_client.js @@ -0,0 +1,21 @@ +import { WebSocketManager } from '../utils/WebSocketManager.js'; +import { createProgressBar } from '../utils/elements.js'; + +const bars = {} // Track progress bars + +// Update the specified progress bar when a message is received from the server +const onProgressUpdate = (event) => { + const { request_id, format_dict } = JSON.parse(event.data); + bars[request_id].style.width = 100 * (format_dict.n / format_dict.total) + '%'; +} + +// Create a new WebSocket client +const client = new WebSocketManager({ onmessage: onProgressUpdate }); + +// Declare that the HTML Button should create a new progress bar when clicked +const button = document.querySelector('button'); +button.addEventListener('click', () => { + const request_id = Math.random().toString(36).substring(7); // Create a unique ID for the progress bar + bars[request_id] = createProgressBar(); // Create and render a progress bar + client.socket.send(JSON.stringify({ command: 'start', request_id })); // Send a message to the server to start the progress bar +}) diff --git a/src/tqdm_publisher/_demo/_multiple/_server.py b/src/tqdm_publisher/_demo/_multiple/_server.py new file mode 100644 index 0000000..856b679 --- /dev/null +++ b/src/tqdm_publisher/_demo/_multiple/_server.py @@ -0,0 +1,69 @@ +import asyncio +import json +import threading +import time + +import websockets + +import tqdm_publisher + + +async def handler(websocket: websockets.WebSocketServerProtocol) -> None: + """Handle messages from the client and manage the client connections.""" + + class WebSocketProgressBar(threading.Thread): + + def __init__(self, request_id: str): + super().__init__() + self.request_id = request_id + + def update(self, format_dict) -> None: + """ + This is the function that will run on every update of the TQDM object. + + It will forward the progress to the client. + """ + asyncio.run( + websocket.send(message=json.dumps(obj=dict(request_id=self.request_id, format_dict=format_dict))) + ) + + def run(self): + """ + Emulate running the specified number of tasks by sleeping the specified amount of time on each iteration. + + Defaults are chosen for a deterministic and regular update period of one second for a total time of one minute. + """ + all_task_durations_in_seconds = [.1 for _ in range(100)] # Ten seconds of one hundred tasks + progress_bar = self.progress_bar = tqdm_publisher.TQDMPublisher(iterable=all_task_durations_in_seconds) + progress_bar.subscribe(callback=self.update) + + for task_duration in progress_bar: + time.sleep(task_duration) + + # def start(self): + # thread = threading.Thread(target=self.run_progress_bar) + # thread.start() + + + # Wait for messages from the client + async for message in websocket: + message_from_client = json.loads(message) + + if message_from_client["command"] == "start": + progress_bar = WebSocketProgressBar(request_id=message_from_client["request_id"]) + progress_bar.start() + + +async def spawn_server() -> None: + """Spawn the server asynchronously.""" + async with websockets.serve(ws_handler=handler, host="", port=8000): + await asyncio.Future() + + +def run_demo() -> None: + """Trigger the execution of the asynchronous spawn.""" + asyncio.run(spawn_server()) + + +if __name__ == "__main__": + run_demo() \ No newline at end of file diff --git a/src/tqdm_publisher/_demo/_single_bar/_client.html b/src/tqdm_publisher/_demo/_single_bar/_client.html new file mode 100644 index 0000000..9ce3b3d --- /dev/null +++ b/src/tqdm_publisher/_demo/_single_bar/_client.html @@ -0,0 +1,25 @@ + + + + + + Single Bar Demo + + + + + +
+
+

tqdm_progress

+ Single Bar Demo +
+ + + +
+ + +
+ + diff --git a/src/tqdm_publisher/_demo/_single_bar/_client.js b/src/tqdm_publisher/_demo/_single_bar/_client.js new file mode 100644 index 0000000..1596925 --- /dev/null +++ b/src/tqdm_publisher/_demo/_single_bar/_client.js @@ -0,0 +1,25 @@ +import { WebSocketManager } from '../utils/WebSocketManager.js'; +import { createProgressBar } from '../utils/elements.js'; + + +const bar = createProgressBar(); // Create and render a progress bar + +// Update the specified progress bar when a message is received from the server +const onProgressUpdate = (event) => { + const { format_dict } = JSON.parse(event.data); + const ratio = format_dict.n / format_dict.total; + bar.style.width = `${100 * ratio}%`; + + if (ratio === 1) button.removeAttribute('disabled'); // Enable the button when the progress bar is complete +} + +// Create a new WebSocket client +const client = new WebSocketManager({ onmessage: onProgressUpdate }); + +// Declare that the HTML Button should create a new progress bar when clicked +const button = document.querySelector('button'); +button.addEventListener('click', () => { + button.setAttribute('disabled', true); // Disable the button to prevent multiple progress bars from being created + bar.style.width = 0; // Reset the progress bar + client.socket.send(JSON.stringify({ command: 'start' })); // Send a message to the server to start the progress bar +}) diff --git a/src/tqdm_publisher/_demo/_server.py b/src/tqdm_publisher/_demo/_single_bar/_server.py similarity index 62% rename from src/tqdm_publisher/_demo/_server.py rename to src/tqdm_publisher/_demo/_single_bar/_server.py index ac821dc..ff3da61 100644 --- a/src/tqdm_publisher/_demo/_server.py +++ b/src/tqdm_publisher/_demo/_single_bar/_server.py @@ -8,13 +8,13 @@ import tqdm_publisher -def start_progress_bar(*, progress_bar_id: str, client_callback: callable) -> None: +def start_progress_bar(*, progress_callback: callable) -> None: """ Emulate running the specified number of tasks by sleeping the specified amount of time on each iteration. Defaults are chosen for a deterministic and regular update period of one second for a total time of one minute. """ - all_task_durations_in_seconds = [1.0 for _ in range(60)] # One minute at one second per update + all_task_durations_in_seconds = [.1 for _ in range(100)] # Ten seconds of one hundred tasks progress_bar = tqdm_publisher.TQDMPublisher(iterable=all_task_durations_in_seconds) def run_function_on_progress_update(format_dict: dict) -> None: @@ -25,9 +25,11 @@ def run_function_on_progress_update(format_dict: dict) -> None: on outside parameters must be achieved by defining those fields at an outer scope and defining this server-specific callback inside the local scope. - In this demo, we will execute the `client_callback` whose protocol is known only to the WebSocketHandler. + In this demo, we will execute the `progress_callback` whose protocol is known only to the WebSocketHandler. + + This specifically requires the `id` of the progress bar and the `format_dict` of the TQDM instance. """ - client_callback(progress_bar_id=progress_bar_id, format_dict=format_dict) + progress_callback(id=progress_bar.id, format_dict=format_dict) progress_bar.subscribe(callback=run_function_on_progress_update) @@ -35,29 +37,33 @@ def run_function_on_progress_update(format_dict: dict) -> None: time.sleep(task_duration) -async def handler(websocket: websockets.WebSocketServerProtocol) -> None: - """Handle messages from the client and manage the client connections.""" +def send_message_to_client(*, websocket: websockets.WebSocketServerProtocol, message: dict) -> None: + """ + Send a message to a specific client. - def forward_progress_to_client(*, progress_bar_id: str, format_dict: dict) -> None: - """ - This is the function that will run on every update of the TQDM object. + This expects a WebSocket connection and a message (dict) to send. + """ - It will forward the progress to the client. - """ - asyncio.run( - websocket.send(message=json.dumps(obj=dict(progress_bar_id=progress_bar_id, format_dict=format_dict))) - ) + asyncio.run( + websocket.send(message=json.dumps(obj=message)) + ) + +async def handler(websocket: websockets.WebSocketServerProtocol) -> None: + """Handle messages from the client and manage the client connections.""" # Wait for messages from the client async for message in websocket: message_from_client = json.loads(message) if message_from_client["command"] == "start": + + # Start the progress bar in a separate thread thread = threading.Thread( target=start_progress_bar, + + # On each update of the progress bar, send this update to the requesting client kwargs=dict( - progress_bar_id=message_from_client["progress_bar_id"], - client_callback=forward_progress_to_client, + progress_callback=lambda id, format_dict: send_message_to_client(websocket, dict(id=id, format_dict=format_dict)) ), ) thread.start() @@ -72,3 +78,7 @@ async def spawn_server() -> None: def run_demo() -> None: """Trigger the execution of the asynchronous spawn.""" asyncio.run(spawn_server()) + + +if __name__ == "__main__": + run_demo() \ No newline at end of file diff --git a/src/tqdm_publisher/_demo/utils/WebSocketManager.js b/src/tqdm_publisher/_demo/utils/WebSocketManager.js new file mode 100644 index 0000000..50c845a --- /dev/null +++ b/src/tqdm_publisher/_demo/utils/WebSocketManager.js @@ -0,0 +1,32 @@ +// Create a simple WebSocket client wrapper class +export class WebSocketManager { + + #connect = (props = {}) => { + + const { + onopen = () => {}, + onclose = () => {}, + onmessage = () => {} + } = props; + + this.socket = new WebSocket('ws://localhost:8000'); + this.socket.addEventListener('open', onopen); + + // Attempt to reconnect every second if the connection is closed + this.socket.addEventListener('close', () => { + onclose(); + setTimeout(() => this.#connect(props), 1000); + }); + + this.socket.addEventListener('message', onmessage); + } + + constructor(props) { + this.#connect(props); + } + + close() { + this.socket.close(); + } + +} diff --git a/src/tqdm_publisher/_demo/utils/elements.js b/src/tqdm_publisher/_demo/utils/elements.js new file mode 100644 index 0000000..f316bb1 --- /dev/null +++ b/src/tqdm_publisher/_demo/utils/elements.js @@ -0,0 +1,12 @@ + +const barContainer = document.querySelector('#bars'); + +// Create a progress bar and append it to the bar container +export const createProgressBar = () => { + const element = document.createElement('div'); + element.classList.add('progress'); + const progress = document.createElement('div'); + element.appendChild(progress); + barContainer.appendChild(element); // Render the progress bar + return progress; +} diff --git a/src/tqdm_publisher/_publisher.py b/src/tqdm_publisher/_publisher.py index acd723d..d5cc3b3 100644 --- a/src/tqdm_publisher/_publisher.py +++ b/src/tqdm_publisher/_publisher.py @@ -7,6 +7,7 @@ class TQDMPublisher(base_tqdm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.id = str(uuid4()) self.callbacks = {} # Override the update method to run callbacks