diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2b794641b..976c35442b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -604,12 +604,16 @@ jobs: matrix: bootstrap: - "Positron (Django)" + - "Positron (FastAPI)" - "Positron (Static)" - "Positron (Site-specific)" include: - bootstrap: "Positron (Django)" new-options: '-Q "bootstrap=Toga Positron (Django server)"' + - bootstrap: "Positron (FastAPI)" + new-options: '-Q "bootstrap=Toga Positron (FastAPI server)"' + - bootstrap: "Positron (Static)" new-options: '-Q "bootstrap=Toga Positron (Static server)"' diff --git a/changes/3327.feature.rst b/changes/3327.feature.rst new file mode 100644 index 0000000000..3e583d3933 --- /dev/null +++ b/changes/3327.feature.rst @@ -0,0 +1 @@ +Toga Positron now has a bootstrap for FastAPI-based websites. diff --git a/positron/pyproject.toml b/positron/pyproject.toml index 92e714161e..8cfed1142b 100644 --- a/positron/pyproject.toml +++ b/positron/pyproject.toml @@ -19,9 +19,10 @@ dynamic = ["version"] dependencies = ["briefcase >= 0.3.21"] [project.entry-points."briefcase.bootstraps"] -"Toga Positron (Django server)" = "positron.django:DjangoPositronBootstrap" -"Toga Positron (Static server)" = "positron.static:StaticPositronBootstrap" -"Toga Positron (Site-specific browser)" = "positron.sitespecific:SiteSpecificPositronBootstrap" +"Toga Positron (Django server)" = "positron.django.bootstrap:DjangoPositronBootstrap" +"Toga Positron (FastAPI server)" = "positron.fastapi.bootstrap:FastAPIPositronBootstrap" +"Toga Positron (Static server)" = "positron.static.bootstrap:StaticPositronBootstrap" +"Toga Positron (Site-specific browser)" = "positron.sitespecific.bootstrap:SiteSpecificPositronBootstrap" [tool.setuptools_scm] root = "../" diff --git a/positron/src/positron/base.py b/positron/src/positron/base.py new file mode 100644 index 0000000000..84fa9363d8 --- /dev/null +++ b/positron/src/positron/base.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from briefcase.bootstraps import TogaGuiBootstrap + + +class BasePositronBootstrap(TogaGuiBootstrap): + display_name_annotation = "does not support Web deployment" + + def validate_url_path(self, value: str) -> bool: + """Validate that the value is a valid path.""" + if not value.startswith("/"): + raise ValueError("Path must start with a /") + return True + + def validate_content_path(self, value: str) -> bool: + """Validate that the value is a directory.""" + if value: + if value.startswith("https://"): + raise ValueError("Positron can't scrape existing web sites (...yet!)") + elif not Path(value).resolve().is_dir(): + raise ValueError(f"Path {Path(value).resolve()} does not exist") + return True + + def templated_content(self, template_path, **context): + """Render the template at the provided path. + + If a {template_path}.tmpl exists, it will be expanded with the provided + context. Otherwise, the content will be used as-is. + """ + full_template_path = template_path.with_suffix(template_path.suffix + ".tmpl") + if full_template_path.exists(): + template = full_template_path.read_text(encoding="utf-8") + return template.format(**context) + else: + return template_path.read_text(encoding="utf-8") + + def templated_file(self, template_path, output_path, **context): + """Render the template at the provided path with the provided context, saving + the result in `output_path`. + """ + self.console.debug(f"Writing {template_path.name}") + (output_path / template_path.name).write_text( + self.templated_content(template_path, **context), + encoding="utf-8", + ) + + def select_content_path(self, override_content_path): + """Ask the user for a path to existing web content to use in the app.""" + self.content_path = self.console.text_question( + intro=( + "Where can Briefcase find the web content for the Positron app?\n" + "\n" + "The value should be a path to a directory; the contents of that " + "directory will be copied into the Positron app, and form the root " + "folder of the served content. If you don't provide a path, default " + "content will be provided." + ), + description="Path to web content", + default="", + validator=self.validate_content_path, + override_value=override_content_path, + ) + + def install_static_content(self, web_root_path): + if self.content_path.startswith(("http://", "https://")): + raise RuntimeError("Can't clone web content (...yet!)") + elif self.content_path: + # Copy an existing content path + with self.console.wait_bar("Copying web content..."): + shutil.copytree( + Path(self.content_path).resolve(), + web_root_path, + dirs_exist_ok=True, + ) + + def pyproject_table_web(self): + return """\ +supported = false +""" diff --git a/positron/src/positron/django/__init__.py b/positron/src/positron/django/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/positron/src/positron/django.py b/positron/src/positron/django/bootstrap.py similarity index 57% rename from positron/src/positron/django.py rename to positron/src/positron/django/bootstrap.py index 5ddca27537..e7fb080e8e 100644 --- a/positron/src/positron/django.py +++ b/positron/src/positron/django/bootstrap.py @@ -3,48 +3,33 @@ from pathlib import Path from typing import Any -from briefcase.bootstraps import TogaGuiBootstrap +from ..base import BasePositronBootstrap +TEMPLATE_PATH = Path(__file__).parent / "templates" -def validate_path(value: str) -> bool: - """Validate that the value is a valid path.""" - if not value.startswith("/"): - raise ValueError("Path must start with a /") - return True - - -def templated_content(template_name, **context): - """Render a template for `template.name` with the provided context.""" - template = ( - Path(__file__).parent / f"django_templates/{template_name}.tmpl" - ).read_text(encoding="utf-8") - return template.format(**context) - - -def templated_file(template_name, output_path, **context): - """Render a template for `template.name` with the provided context, saving the - result in `output_path`.""" - (output_path / template_name).write_text( - templated_content(template_name, **context), encoding="utf-8" - ) - - -class DjangoPositronBootstrap(TogaGuiBootstrap): - display_name_annotation = "does not support Web deployment" +class DjangoPositronBootstrap(BasePositronBootstrap): def app_source(self): - return templated_content("app.py", initial_path=self.initial_path) + return self.templated_content( + TEMPLATE_PATH / "app.py", + initial_path=self.initial_path, + ) def pyproject_table_briefcase_app_extra_content(self): return """ requires = [ - "django~=5.1", + "django~=6.0", ] test_requires = [ {% if cookiecutter.test_framework == "pytest" %} "pytest", {% endif %} ] +""" + + def pyproject_table_web(self): + return """\ +supported = false """ def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None: @@ -66,7 +51,7 @@ def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | N ), description="Initial path", default="/admin/", - validator=validate_path, + validator=self.validate_url_path, override_value=project_overrides.pop("initial_path", None), ) @@ -76,17 +61,16 @@ def post_generate(self, base_path: Path): app_path = base_path / "src" / self.context["module_name"] # Top level files - self.console.debug("Writing manage.py") - templated_file( - "manage.py", + self.templated_file( + TEMPLATE_PATH / "manage.py", app_path.parent, module_name=self.context["module_name"], ) + # App files for template_name in ["settings.py", "urls.py", "wsgi.py"]: - self.console.debug(f"Writing {template_name}") - templated_file( - template_name, + self.templated_file( + TEMPLATE_PATH / template_name, app_path, module_name=self.context["module_name"], ) diff --git a/positron/src/positron/django_templates/app.py.tmpl b/positron/src/positron/django/templates/app.py.tmpl similarity index 100% rename from positron/src/positron/django_templates/app.py.tmpl rename to positron/src/positron/django/templates/app.py.tmpl diff --git a/positron/src/positron/django_templates/manage.py.tmpl b/positron/src/positron/django/templates/manage.py.tmpl similarity index 100% rename from positron/src/positron/django_templates/manage.py.tmpl rename to positron/src/positron/django/templates/manage.py.tmpl diff --git a/positron/src/positron/django_templates/settings.py.tmpl b/positron/src/positron/django/templates/settings.py.tmpl similarity index 100% rename from positron/src/positron/django_templates/settings.py.tmpl rename to positron/src/positron/django/templates/settings.py.tmpl diff --git a/positron/src/positron/django_templates/urls.py.tmpl b/positron/src/positron/django/templates/urls.py.tmpl similarity index 100% rename from positron/src/positron/django_templates/urls.py.tmpl rename to positron/src/positron/django/templates/urls.py.tmpl diff --git a/positron/src/positron/django_templates/wsgi.py.tmpl b/positron/src/positron/django/templates/wsgi.py.tmpl similarity index 100% rename from positron/src/positron/django_templates/wsgi.py.tmpl rename to positron/src/positron/django/templates/wsgi.py.tmpl diff --git a/positron/src/positron/fastapi/__init__.py b/positron/src/positron/fastapi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/positron/src/positron/fastapi/bootstrap.py b/positron/src/positron/fastapi/bootstrap.py new file mode 100644 index 0000000000..94881c11db --- /dev/null +++ b/positron/src/positron/fastapi/bootstrap.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +if sys.version_info >= (3, 11): # pragma: no-cover-if-lt-py311 + import tomllib +else: # pragma: no-cover-if-gte-py311 + import tomli as tomllib + +from ..base import BasePositronBootstrap + +TEMPLATE_PATH = Path(__file__).parent / "templates" + + +class FastAPIPositronBootstrap(BasePositronBootstrap): + display_name_annotation = "does not support Web deployment" + + @property + def template_path(self): + return Path(__file__).parent / "templates" + + def app_start_source(self): + return self.templated_content( + TEMPLATE_PATH / "__main__.py", + initial_path=self.initial_path, + ) + + def app_source(self): + return self.templated_content( + TEMPLATE_PATH / "app.py", + initial_path=self.initial_path, + ) + + def pyproject_table_briefcase_app_extra_content(self): + return """ +requires = [ + # 0.125.0 is the last version of FastAPI that supports Pydantic < 2.0 + # This is a blocker on iOS/Android until wheels for pydantic-core are available. + "fastAPI == 0.125.0", + "uvicorn == 0.40.0", +] +test_requires = [ +{% if cookiecutter.test_framework == "pytest" %} + "pytest", +{% endif %} +] +""" + + def pyproject_table_iOS(self): + iOS_table = tomllib.loads(super().pyproject_table_iOS()) + base_requires = "\n".join(f' "{req}",' for req in iOS_table["requires"]) + return f"""\ +requires = [ +{base_requires} + "pydantic < 2", +] +""" + + def pyproject_table_android(self): + android_table = tomllib.loads(super().pyproject_table_android()) + base_requires = "\n".join(f' "{req}",' for req in android_table["requires"]) + return f"""\ +requires = [ +{base_requires} + "pydantic < 2", +] + +base_theme = "Theme.MaterialComponents.Light.DarkActionBar" + +build_gradle_dependencies = [ + "com.google.android.material:material:1.13.0", +] +""" + + def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None: + """Runs prior to other plugin hooks to provide additional context. + + This can be used to prompt the user with additional questions or run arbitrary + logic to supplement the context provided to cookiecutter. + + :param project_overrides: Any overrides provided by the user as -Q options that + haven't been consumed by the standard bootstrap wizard questions. + """ + self.initial_path = self.console.text_question( + intro=( + "What path do you want to use as the initial URL for the app's " + "webview?\n" + "\n" + "The value should start with a '/', but can be any path that your " + "FastAPI site will serve." + ), + description="Initial path", + default="/", + validator=self.validate_url_path, + override_value=project_overrides.pop("initial_path", None), + ) + + return {} + + def post_generate(self, base_path: Path): + app_path = base_path / "src" / self.context["module_name"] + + # App files + for template_name in ["server.py"]: + self.templated_file( + TEMPLATE_PATH / template_name, + app_path, + module_name=self.context["module_name"], + ) diff --git a/positron/src/positron/fastapi/templates/__main__.py.tmpl b/positron/src/positron/fastapi/templates/__main__.py.tmpl new file mode 100644 index 0000000000..b71dd92b49 --- /dev/null +++ b/positron/src/positron/fastapi/templates/__main__.py.tmpl @@ -0,0 +1,16 @@ +import sys +from types import ModuleType + + +if __name__ == "__main__": + # Install a mock multiprocessing binary module on iOS + # This is required to import uvicorn, even if multiprocessing isn't used at runtime. + # It must be done *before* uvicorn is imported. + if sys.platform == "ios": + _mp_override = ModuleType("_multiprocessing") + sys.modules["_multiprocessing"] = _mp_override + + # Import the app and start it. + from {{{{ cookiecutter.module_name }}}}.app import main + + main().main_loop() diff --git a/positron/src/positron/fastapi/templates/app.py.tmpl b/positron/src/positron/fastapi/templates/app.py.tmpl new file mode 100644 index 0000000000..ae82cca130 --- /dev/null +++ b/positron/src/positron/fastapi/templates/app.py.tmpl @@ -0,0 +1,77 @@ +from __future__ import annotations + +import asyncio + +import toga +import uvicorn + +from .server import app as fastapi_app + + +class {{{{ cookiecutter.class_name }}}}(toga.App): + async def cleanup(self, app, **kwargs): + # Make sure we don't try to clean up before the server is actually running. + # This is to prevent the server task dangling on app exit. + if not self.server.started: + print("APP : Waiting for the server to finish starting...") + await self.socket + + print("APP : Shutting down...") + await self.server.shutdown() + return True + + async def wait_for_socket(self): + # uvicorn doesn't provide a way to wait until the server is running, + # or to get the auto-allocated port. See: + # https://github.com/Kludex/uvicorn/issues/761 + print("APP : Waiting for server socket...") + while not self.server.started: # noqa: ASYNC110 + await asyncio.sleep(0.01) + + for server in self.server.servers: + for socket in server.sockets: + self.socket.set_result(socket) + print("APP : Server is running.") + return + + def startup(self): + # Create a uvicorn server on 127.0.0.1, any available port + config = uvicorn.Config( + fastapi_app, + host="127.0.0.1", + port=0, + reload=False, + workers=1, + ) + self.server = uvicorn.Server(config) + self.socket = asyncio.Future() + + # Start the server asynchronously + asyncio.create_task(self.server.serve()) + asyncio.create_task(self.wait_for_socket()) + + self.web_view = toga.WebView(on_webview_load=self.on_initial_webview_load) + + self.on_exit = self.cleanup + + self.main_window = toga.MainWindow() + self.main_window.content = self.web_view + + async def on_running(self): + # Wait for the socket, then extract host and port. + await self.socket + host, port = self.socket.result().getsockname() + + # Point the webview at the internal server. + self.web_view.url = f"http://{{host}}:{{port}}/" + + def on_initial_webview_load(self, widget, **kwargs): + # When the first page is loaded, show the main window. Then clear the load + # handler; we don't want to force re-showing the window on any subsequence + # page load. + self.main_window.show() + self.web_view.on_webview_load = None + + +def main(): + return {{{{ cookiecutter.class_name }}}}() diff --git a/positron/src/positron/fastapi/templates/server.py b/positron/src/positron/fastapi/templates/server.py new file mode 100644 index 0000000000..ed9686fba3 --- /dev/null +++ b/positron/src/positron/fastapi/templates/server.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI +from fastapi.responses import HTMLResponse + +app = FastAPI() + + +@app.get("/", response_class=HTMLResponse) +async def root(): + return "