From 985dad72ac41af8a0e517e2572327782442611aa Mon Sep 17 00:00:00 2001 From: Martin Turoci Date: Mon, 26 Jun 2023 11:22:47 +0200 Subject: [PATCH] feat: Multiuser University. --- university/h2o_wave_university/university.py | 96 ++++++++++---------- university/h2o_wave_university/utils.py | 40 ++++++++ 2 files changed, 88 insertions(+), 48 deletions(-) create mode 100644 university/h2o_wave_university/utils.py diff --git a/university/h2o_wave_university/university.py b/university/h2o_wave_university/university.py index 2f8f50c2a8..db65588a45 100644 --- a/university/h2o_wave_university/university.py +++ b/university/h2o_wave_university/university.py @@ -15,25 +15,26 @@ import collections import os import os.path -import re import shutil import subprocess import sys +import uuid +from asyncio import create_subprocess_exec from glob import glob from pathlib import Path from string import Template from typing import Dict, List, Optional from urllib.parse import urlparse -from h2o_wave import Q, app, main, ui +from h2o_wave import Q, app, handle_on, main, on, ui + +from .utils import natural_keys, read_file, scan_free_port, strip_comment tmp_dir = '__tmp_apps_dir' university_dir = 'h2o_wave_university' _base_url = os.environ.get('H2O_WAVE_BASE_URL', '/') _app_address = urlparse(os.environ.get('H2O_WAVE_APP_ADDRESS', 'http://127.0.0.1:8000')) -_app_host = _app_address.hostname -_app_port = '10102' default_lesson = 'lesson1' vsc_extension_path = os.path.join('..', 'tools', 'vscode-extension') @@ -49,7 +50,7 @@ def __init__(self, filename: str, title: str, description: str, source: str): self.next_lesson: Optional[Lesson] = None self.process: Optional[subprocess.Popen] = None - async def start(self, filename: str, code: str): + async def start(self, filename: str, is_app: bool, q: Q): env = os.environ.copy() env['H2O_WAVE_BASE_URL'] = _base_url env['H2O_WAVE_ADDRESS'] = os.environ.get('H2O_WAVE_ADDRESS', 'http://127.0.0.1:10101') @@ -57,45 +58,23 @@ async def start(self, filename: str, code: str): # inside python during initialization if %PATH% is configured, but without %SYSTEMROOT%. if sys.platform.lower().startswith('win'): env['SYSTEMROOT'] = os.environ['SYSTEMROOT'] - if code.find('@app(') > 0: - env['H2O_WAVE_APP_ADDRESS'] = f'http://{_app_host}:{_app_port}' - self.process = subprocess.Popen([ + if is_app: + q.app.app_port = scan_free_port(q.app.app_port) + env['H2O_WAVE_APP_ADDRESS'] = f'http://{_app_address.hostname}:{q.app.app_port}' + cmd = [ sys.executable, '-m', 'uvicorn', '--host', '0.0.0.0', - '--port', _app_port, + '--port', str(q.app.app_port), f'{filename.replace(".py", "")}:main', - ], env=env) + ] + self.process = await create_subprocess_exec(*cmd, env=env) else: - self.process = subprocess.Popen([sys.executable, filename], env=env) + self.process = await create_subprocess_exec(sys.executable, filename, env=env) async def stop(self): if self.process and self.process.returncode is None: self.process.terminate() - self.process.wait() - - -def read_file(p: str) -> str: - with open(p, encoding='utf-8') as f: - return f.read() - - -def atoi(text): - return int(text) if text.isdigit() else text - - -def natural_keys(text): - return [atoi(c) for c in re.split(r'(\d+)', text)] - - -def strip_comment(line: str) -> str: - """Returns the content of a line without '#' and ' ' characters - - remove leading '#', but preserve '#' that is part of a tag - lesson: - >>> '# #hello '.strip('#').strip() - '#hello' - """ - return line.strip('#').strip() + await self.process.wait() def load_lesson(filename: str) -> Lesson: @@ -226,7 +205,7 @@ def get_wave_completions(line, character, file_content): def make_blurb(q: Q): - lesson = q.user.active_lesson + lesson = q.client.active_lesson # HACK: Recreate dropdown every time (by dynamic name) to control value (needed for next / prev btn functionality). prev_lesson_name = lesson.previous_lesson.name if lesson.previous_lesson is not None else '' next_lesson_name = lesson.next_lesson.name if lesson.next_lesson is not None else '' @@ -243,33 +222,38 @@ def make_blurb(q: Q): async def show_lesson(q: Q, lesson: Lesson): # Clear demo page - demo_page = q.site['/demo'] + demo_page = q.site[f'/{q.client.path}'] demo_page.drop() await demo_page.save() - filename = os.path.join(tmp_dir, 'tmp.py') + filename = os.path.join(tmp_dir, f'{q.client.path}.py') code = q.events.editor.change if q.events.editor else lesson.source code = code.replace("`", "\\`") + is_app = '@app(' in code with open(filename, 'w', encoding='utf-8') as f: - f.write(code) - filename = '.'.join([tmp_dir, 'tmp.py']).split(os.sep)[-1] if code.find('@app(') > 0 else filename + fixed_path = code + if is_app: + fixed_path = fixed_path.replace("@app('/demo')", f"@app('/{q.client.path}')") + else: + fixed_path = fixed_path.replace("site['/demo']", f"site['/{q.client.path}']") + f.write(fixed_path) + if is_app: + filename = '.'.join([tmp_dir, f'{q.client.path}.py']).split(os.sep)[-1] # Stop active lesson, if any. - active_lesson = q.user.active_lesson + active_lesson = q.client.active_lesson if active_lesson: await active_lesson.stop() # Start new lesson - await lesson.start(filename, code) - q.user.active_lesson = lesson + await lesson.start(filename, is_app, q) + q.client.active_lesson = lesson # Update lesson blurb make_blurb(q) - preview_card = q.page['preview'] - # Update preview title - preview_card.title = f'Preview of {lesson.filename}' + q.page['preview'].title = f'Preview of {lesson.filename}' q.page['code'].title = lesson.filename await q.page.save() @@ -290,7 +274,7 @@ async def show_lesson(q: Q, lesson: Lesson): # HACK # The ?e= appended to the path forces the frame to reload. # The url param is not actually used. - preview_card.path = f'{_base_url}demo?e={lesson.name}' + q.page['preview'].path = f'{_base_url}{q.client.path}?e={lesson.name}' await q.page.save() @@ -306,9 +290,23 @@ async def on_shutdown(): shutil.rmtree(dirpath) +@on("@system.client_disconnect") +async def client_disconnect(q: Q): + demo_page = q.site[f'/{q.client.path}'] + demo_page.drop() + await demo_page.save() + + if q.client.active_lesson: + await q.client.active_lesson.stop() + filename = os.path.join(tmp_dir, f'{q.client.path}.py') + if os.path.exists(filename): + os.remove(filename) + + @app('/', on_startup=on_startup, on_shutdown=on_shutdown) async def serve(q: Q): if not q.app.initialized: + q.app.app_port = 10102 base_snippets_path = os.path.join(university_dir, 'static', 'base-snippets.json') component_snippets_path = os.path.join(university_dir, 'static', 'component-snippets.json') # Prod. @@ -328,6 +326,7 @@ async def serve(q: Q): if not q.client.initialized: q.client.initialized = True q.client.is_first_load = True + q.client.path = uuid.uuid4() await setup_page(q) search = q.args[q.args['#'] or default_lesson] @@ -335,3 +334,4 @@ async def serve(q: Q): q.page['meta'] = ui.meta_card(box='', redirect=f'#{search}') await show_lesson(q, q.app.catalog[q.args['#'] or default_lesson]) + await handle_on(q) diff --git a/university/h2o_wave_university/utils.py b/university/h2o_wave_university/utils.py new file mode 100644 index 0000000000..837296d75e --- /dev/null +++ b/university/h2o_wave_university/utils.py @@ -0,0 +1,40 @@ + + +from contextlib import closing +import re +import socket + + +def scan_free_port(port: int): + while True: + # If we run out of ports, wrap around. + if port > 60000: + port = 10000 + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: + if sock.connect_ex(('localhost', port)): + return port + port += 1 + + +def read_file(p: str) -> str: + with open(p, encoding='utf-8') as f: + return f.read() + + +def atoi(text): + return int(text) if text.isdigit() else text + + +def natural_keys(text): + return [atoi(c) for c in re.split(r'(\d+)', text)] + + +def strip_comment(line: str) -> str: + """Returns the content of a line without '#' and ' ' characters + + remove leading '#', but preserve '#' that is part of a tag + lesson: + >>> '# #hello '.strip('#').strip() + '#hello' + """ + return line.strip('#').strip()