From c22de33d0e01b134d83bd192e91f0efd81ce4000 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 2 Feb 2026 11:25:27 +0800 Subject: [PATCH 01/17] Refactor Django backend to allow for better re-use. --- positron/pyproject.toml | 2 +- positron/src/positron/common.py | 22 +++++++++++++ positron/src/positron/django/__init__.py | 0 .../{django.py => django/bootstrap.py} | 33 ++++++------------- .../templates}/app.py.tmpl | 0 .../templates}/manage.py.tmpl | 0 .../templates}/settings.py.tmpl | 0 .../templates}/urls.py.tmpl | 0 .../templates}/wsgi.py.tmpl | 0 9 files changed, 33 insertions(+), 24 deletions(-) create mode 100644 positron/src/positron/common.py create mode 100644 positron/src/positron/django/__init__.py rename positron/src/positron/{django.py => django/bootstrap.py} (71%) rename positron/src/positron/{django_templates => django/templates}/app.py.tmpl (100%) rename positron/src/positron/{django_templates => django/templates}/manage.py.tmpl (100%) rename positron/src/positron/{django_templates => django/templates}/settings.py.tmpl (100%) rename positron/src/positron/{django_templates => django/templates}/urls.py.tmpl (100%) rename positron/src/positron/{django_templates => django/templates}/wsgi.py.tmpl (100%) diff --git a/positron/pyproject.toml b/positron/pyproject.toml index 95c64c5f00..4570674046 100644 --- a/positron/pyproject.toml +++ b/positron/pyproject.toml @@ -19,7 +19,7 @@ dynamic = ["version"] dependencies = ["briefcase >= 0.3.21"] [project.entry-points."briefcase.bootstraps"] -"Toga Positron (Django server)" = "positron.django:DjangoPositronBootstrap" +"Toga Positron (Django server)" = "positron.django.bootstrap:DjangoPositronBootstrap" "Toga Positron (Static server)" = "positron.static:StaticPositronBootstrap" "Toga Positron (Site-specific browser)" = "positron.sitespecific:SiteSpecificPositronBootstrap" diff --git a/positron/src/positron/common.py b/positron/src/positron/common.py new file mode 100644 index 0000000000..23a8f5e18f --- /dev/null +++ b/positron/src/positron/common.py @@ -0,0 +1,22 @@ +from __future__ import annotations + + +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_path, template_name, **context): + """Render a template for `template.name` with the provided context.""" + template = (template_path / f"{template_name}.tmpl").read_text(encoding="utf-8") + return template.format(**context) + + +def templated_file(template_path, 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_path, template_name, **context), encoding="utf-8" + ) 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 71% rename from positron/src/positron/django.py rename to positron/src/positron/django/bootstrap.py index 5ddca27537..fc60c85b5b 100644 --- a/positron/src/positron/django.py +++ b/positron/src/positron/django/bootstrap.py @@ -5,40 +5,25 @@ from briefcase.bootstraps import TogaGuiBootstrap +from ..common import templated_content, templated_file, validate_path -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" - ) +TEMPLATE_PATH = Path(__file__).parent / "templates" class DjangoPositronBootstrap(TogaGuiBootstrap): display_name_annotation = "does not support Web deployment" def app_source(self): - return templated_content("app.py", initial_path=self.initial_path) + return 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" %} @@ -78,6 +63,7 @@ def post_generate(self, base_path: Path): # Top level files self.console.debug("Writing manage.py") templated_file( + TEMPLATE_PATH, "manage.py", app_path.parent, module_name=self.context["module_name"], @@ -86,6 +72,7 @@ def post_generate(self, base_path: Path): for template_name in ["settings.py", "urls.py", "wsgi.py"]: self.console.debug(f"Writing {template_name}") 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 From 76444ece7df39f6e901b76d6d9676b1bb72b7155 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 2 Feb 2026 12:57:56 +0800 Subject: [PATCH 02/17] Add initial FastAPI server implementation. --- positron/pyproject.toml | 1 + positron/src/positron/fastapi/__init__.py | 0 positron/src/positron/fastapi/bootstrap.py | 72 +++++++++++++++++++ .../positron/fastapi/templates/app.py.tmpl | 53 ++++++++++++++ .../positron/fastapi/templates/site.py.tmpl | 8 +++ 5 files changed, 134 insertions(+) create mode 100644 positron/src/positron/fastapi/__init__.py create mode 100644 positron/src/positron/fastapi/bootstrap.py create mode 100644 positron/src/positron/fastapi/templates/app.py.tmpl create mode 100644 positron/src/positron/fastapi/templates/site.py.tmpl diff --git a/positron/pyproject.toml b/positron/pyproject.toml index 4570674046..37f6abed6f 100644 --- a/positron/pyproject.toml +++ b/positron/pyproject.toml @@ -20,6 +20,7 @@ dependencies = ["briefcase >= 0.3.21"] [project.entry-points."briefcase.bootstraps"] "Toga Positron (Django server)" = "positron.django.bootstrap:DjangoPositronBootstrap" +"Toga Positron (FastAPI server)" = "positron.fastapi.bootstrap:FastAPIPositronBootstrap" "Toga Positron (Static server)" = "positron.static:StaticPositronBootstrap" "Toga Positron (Site-specific browser)" = "positron.sitespecific:SiteSpecificPositronBootstrap" 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..c3c11c3816 --- /dev/null +++ b/positron/src/positron/fastapi/bootstrap.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from briefcase.bootstraps import TogaGuiBootstrap + +from ..common import templated_content, templated_file, validate_path + +TEMPLATE_PATH = Path(__file__).parent / "templates" + + +class FastAPIPositronBootstrap(TogaGuiBootstrap): + display_name_annotation = "does not support Web deployment" + + def app_source(self): + return templated_content( + TEMPLATE_PATH, + "app.py", + initial_path=self.initial_path, + ) + + def pyproject_table_briefcase_app_extra_content(self): + return """ +requires = [ + "fastAPI == 0.128.0", + "uvicorn == 0.40.0", +] +test_requires = [ +{% if cookiecutter.test_framework == "pytest" %} + "pytest", +{% endif %} +] +""" + + 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=validate_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 ["site.py"]: + self.console.debug(f"Writing {template_name}") + templated_file( + TEMPLATE_PATH, + template_name, + app_path, + module_name=self.context["module_name"], + ) 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..6e3eda556b --- /dev/null +++ b/positron/src/positron/fastapi/templates/app.py.tmpl @@ -0,0 +1,53 @@ +from __future__ import annotations + +import asyncio +import os +import shutil +import socketserver + +import toga +import uvicorn + +from .site import app + + +class {{{{ cookiecutter.class_name }}}}(toga.App): + async def cleanup(self, app, **kwargs): + print("Shutting down...") + await self.server.shutdown() + return True + + 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) + self.server = uvicorn.Server(config) + + # Start the server asynchronously + asyncio.create_task(self.server.serve()) + + self.web_view = toga.WebView() + + self.on_exit = self.cleanup + + self.main_window = toga.MainWindow() + self.main_window.content = self.web_view + + async def on_running(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 + while self.server is None or not self.server.started: # noqa: ASYNC110 + await asyncio.sleep(0.01) + + for server in self.server.servers: + for socket in server.sockets: + host, port = socket.getsockname() + break + + # Point the webview at the internal server. + self.web_view.url = f"http://{host}:{port}/" + self.main_window.show() + + +def main(): + return {{{{ cookiecutter.class_name }}}}() diff --git a/positron/src/positron/fastapi/templates/site.py.tmpl b/positron/src/positron/fastapi/templates/site.py.tmpl new file mode 100644 index 0000000000..b49553d5d1 --- /dev/null +++ b/positron/src/positron/fastapi/templates/site.py.tmpl @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +async def root(): + return "Hello World" From 794d15ad93a10c794be40804008bd0b8da6f8c5b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 2 Feb 2026 13:11:39 +0800 Subject: [PATCH 03/17] Block iOS/Android/Web for FastAPI, web deployment elsewhere. --- positron/src/positron/django/bootstrap.py | 5 +++++ positron/src/positron/fastapi/bootstrap.py | 19 ++++++++++++++++++- .../positron/fastapi/templates/app.py.tmpl | 2 +- positron/src/positron/sitespecific.py | 5 +++++ positron/src/positron/static.py | 5 +++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/positron/src/positron/django/bootstrap.py b/positron/src/positron/django/bootstrap.py index fc60c85b5b..428bc6ba81 100644 --- a/positron/src/positron/django/bootstrap.py +++ b/positron/src/positron/django/bootstrap.py @@ -30,6 +30,11 @@ def pyproject_table_briefcase_app_extra_content(self): "pytest", {% endif %} ] +""" + + def pyproject_table_web(self): + return """\ +supported = false """ def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None: diff --git a/positron/src/positron/fastapi/bootstrap.py b/positron/src/positron/fastapi/bootstrap.py index c3c11c3816..909a3c8e3a 100644 --- a/positron/src/positron/fastapi/bootstrap.py +++ b/positron/src/positron/fastapi/bootstrap.py @@ -11,7 +11,9 @@ class FastAPIPositronBootstrap(TogaGuiBootstrap): - display_name_annotation = "does not support Web deployment" + display_name_annotation = "does not support iOS/Android/Web deployment" + # Need a pydantic-core binary to make iOS/Android possible. + # display_name_annotation = "does not support Web deployment" def app_source(self): return templated_content( @@ -31,6 +33,21 @@ def pyproject_table_briefcase_app_extra_content(self): "pytest", {% endif %} ] +""" + + def pyproject_table_iOS(self): + return """\ +supported = false +""" + + def pyproject_table_android(self): + return """\ +supported = false +""" + + def pyproject_table_web(self): + return """\ +supported = false """ def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None: diff --git a/positron/src/positron/fastapi/templates/app.py.tmpl b/positron/src/positron/fastapi/templates/app.py.tmpl index 6e3eda556b..b6d66434ac 100644 --- a/positron/src/positron/fastapi/templates/app.py.tmpl +++ b/positron/src/positron/fastapi/templates/app.py.tmpl @@ -45,7 +45,7 @@ class {{{{ cookiecutter.class_name }}}}(toga.App): break # Point the webview at the internal server. - self.web_view.url = f"http://{host}:{port}/" + self.web_view.url = f"http://{{host}}:{{port}}/" self.main_window.show() diff --git a/positron/src/positron/sitespecific.py b/positron/src/positron/sitespecific.py index bcb6741c89..5753683bce 100644 --- a/positron/src/positron/sitespecific.py +++ b/positron/src/positron/sitespecific.py @@ -29,6 +29,11 @@ def main(): return {{{{ cookiecutter.class_name }}}}() """ + def pyproject_table_web(self): + return """\ +supported = false +""" + def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None: """Runs prior to other plugin hooks to provide additional context. diff --git a/positron/src/positron/static.py b/positron/src/positron/static.py index c90b32388c..ebd6ae8064 100644 --- a/positron/src/positron/static.py +++ b/positron/src/positron/static.py @@ -75,6 +75,11 @@ def main(): return {{ cookiecutter.class_name }}() """ + def pyproject_table_web(self): + return """\ +supported = false +""" + def post_generate(self, base_path: Path): resource_path = base_path / "src" / self.context["module_name"] / "resources" From 5376c6e8e9771002a3fcef890be59290b98f8fa6 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 2 Feb 2026 13:15:09 +0800 Subject: [PATCH 04/17] Add CI for FastAPI Positron bootstrap. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7d45edd0f..07f0b37fbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -569,12 +569,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)"' From be4d9fc4a1a2aa88938f7a6fa772ec0b9dfe3b72 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 2 Feb 2026 13:38:40 +0800 Subject: [PATCH 05/17] Correct the reference to the app in the template. --- positron/src/positron/fastapi/templates/app.py.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/positron/src/positron/fastapi/templates/app.py.tmpl b/positron/src/positron/fastapi/templates/app.py.tmpl index b6d66434ac..46e1956ddc 100644 --- a/positron/src/positron/fastapi/templates/app.py.tmpl +++ b/positron/src/positron/fastapi/templates/app.py.tmpl @@ -8,7 +8,7 @@ import socketserver import toga import uvicorn -from .site import app +from .site import app as fastapi_app class {{{{ cookiecutter.class_name }}}}(toga.App): From 65807448fd3d8ba1b802dea0348514562c729147 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 2 Feb 2026 13:40:39 +0800 Subject: [PATCH 06/17] Add a changenote. --- changes/3327.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/3327.feature.rst 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. From 11067b9d8fdf51e8717756570b92c64e730fc442 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 2 Feb 2026 13:54:04 +0800 Subject: [PATCH 07/17] Factor common tools into a Positron base class. --- positron/src/positron/base.py | 38 ++++++++++++++++++++++ positron/src/positron/common.py | 22 ------------- positron/src/positron/django/bootstrap.py | 26 +++++---------- positron/src/positron/fastapi/bootstrap.py | 28 +++++----------- positron/src/positron/sitespecific.py | 10 ++---- positron/src/positron/static.py | 11 ++----- 6 files changed, 60 insertions(+), 75 deletions(-) create mode 100644 positron/src/positron/base.py delete mode 100644 positron/src/positron/common.py diff --git a/positron/src/positron/base.py b/positron/src/positron/base.py new file mode 100644 index 0000000000..0763ea0524 --- /dev/null +++ b/positron/src/positron/base.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path + +from briefcase.bootstraps import TogaGuiBootstrap + + +class BasePositronBootstrap(TogaGuiBootstrap): + display_name_annotation = "does not support Web deployment" + + @property + def template_path(self): + return Path(__file__).parent / "templates" + + def validate_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 templated_content(self, template_name, **context): + """Render a template for `template.name` with the provided context.""" + template = (self.template_path / f"{template_name}.tmpl").read_text( + encoding="utf-8" + ) + return template.format(**context) + + def templated_file(self, 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( + self.templated_content(template_name, **context), encoding="utf-8" + ) + + def pyproject_table_web(self): + return """\ +supported = false +""" diff --git a/positron/src/positron/common.py b/positron/src/positron/common.py deleted file mode 100644 index 23a8f5e18f..0000000000 --- a/positron/src/positron/common.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - - -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_path, template_name, **context): - """Render a template for `template.name` with the provided context.""" - template = (template_path / f"{template_name}.tmpl").read_text(encoding="utf-8") - return template.format(**context) - - -def templated_file(template_path, 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_path, template_name, **context), encoding="utf-8" - ) diff --git a/positron/src/positron/django/bootstrap.py b/positron/src/positron/django/bootstrap.py index 428bc6ba81..26e33e002e 100644 --- a/positron/src/positron/django/bootstrap.py +++ b/positron/src/positron/django/bootstrap.py @@ -3,22 +3,16 @@ from pathlib import Path from typing import Any -from briefcase.bootstraps import TogaGuiBootstrap +from ..base import BasePositronBootstrap -from ..common import templated_content, templated_file, validate_path -TEMPLATE_PATH = Path(__file__).parent / "templates" - - -class DjangoPositronBootstrap(TogaGuiBootstrap): - display_name_annotation = "does not support Web deployment" +class DjangoPositronBootstrap(BasePositronBootstrap): + @property + def template_path(self): + return Path(__file__).parent / "templates" def app_source(self): - return templated_content( - TEMPLATE_PATH, - "app.py", - initial_path=self.initial_path, - ) + return self.templated_content("app.py", initial_path=self.initial_path) def pyproject_table_briefcase_app_extra_content(self): return """ @@ -56,7 +50,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_path, override_value=project_overrides.pop("initial_path", None), ) @@ -67,8 +61,7 @@ def post_generate(self, base_path: Path): # Top level files self.console.debug("Writing manage.py") - templated_file( - TEMPLATE_PATH, + self.templated_file( "manage.py", app_path.parent, module_name=self.context["module_name"], @@ -76,8 +69,7 @@ def post_generate(self, base_path: Path): # App files for template_name in ["settings.py", "urls.py", "wsgi.py"]: self.console.debug(f"Writing {template_name}") - templated_file( - TEMPLATE_PATH, + self.templated_file( template_name, app_path, module_name=self.context["module_name"], diff --git a/positron/src/positron/fastapi/bootstrap.py b/positron/src/positron/fastapi/bootstrap.py index 909a3c8e3a..013fc2cb0a 100644 --- a/positron/src/positron/fastapi/bootstrap.py +++ b/positron/src/positron/fastapi/bootstrap.py @@ -3,24 +3,20 @@ from pathlib import Path from typing import Any -from briefcase.bootstraps import TogaGuiBootstrap +from ..base import BasePositronBootstrap -from ..common import templated_content, templated_file, validate_path -TEMPLATE_PATH = Path(__file__).parent / "templates" - - -class FastAPIPositronBootstrap(TogaGuiBootstrap): +class FastAPIPositronBootstrap(BasePositronBootstrap): display_name_annotation = "does not support iOS/Android/Web deployment" # Need a pydantic-core binary to make iOS/Android possible. # display_name_annotation = "does not support Web deployment" + @property + def template_path(self): + return Path(__file__).parent / "templates" + def app_source(self): - return templated_content( - TEMPLATE_PATH, - "app.py", - initial_path=self.initial_path, - ) + return self.templated_content("app.py", initial_path=self.initial_path) def pyproject_table_briefcase_app_extra_content(self): return """ @@ -43,11 +39,6 @@ def pyproject_table_iOS(self): def pyproject_table_android(self): return """\ supported = false -""" - - def pyproject_table_web(self): - return """\ -supported = false """ def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None: @@ -69,7 +60,7 @@ def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | N ), description="Initial path", default="/", - validator=validate_path, + validator=self.validate_path, override_value=project_overrides.pop("initial_path", None), ) @@ -81,8 +72,7 @@ def post_generate(self, base_path: Path): # App files for template_name in ["site.py"]: self.console.debug(f"Writing {template_name}") - templated_file( - TEMPLATE_PATH, + self.templated_file( template_name, app_path, module_name=self.context["module_name"], diff --git a/positron/src/positron/sitespecific.py b/positron/src/positron/sitespecific.py index 5753683bce..8033daae86 100644 --- a/positron/src/positron/sitespecific.py +++ b/positron/src/positron/sitespecific.py @@ -2,13 +2,12 @@ from typing import Any -from briefcase.bootstraps import TogaGuiBootstrap from briefcase.config import validate_url +from .base import BasePositronBootstrap -class SiteSpecificPositronBootstrap(TogaGuiBootstrap): - display_name_annotation = "does not support Web deployment" +class SiteSpecificPositronBootstrap(BasePositronBootstrap): def app_source(self): return f"""\ import toga @@ -29,11 +28,6 @@ def main(): return {{{{ cookiecutter.class_name }}}}() """ - def pyproject_table_web(self): - return """\ -supported = false -""" - def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None: """Runs prior to other plugin hooks to provide additional context. diff --git a/positron/src/positron/static.py b/positron/src/positron/static.py index ebd6ae8064..677915a8f2 100644 --- a/positron/src/positron/static.py +++ b/positron/src/positron/static.py @@ -2,12 +2,10 @@ from pathlib import Path -from briefcase.bootstraps import TogaGuiBootstrap +from .base import BasePositronBootstrap -class StaticPositronBootstrap(TogaGuiBootstrap): - display_name_annotation = "does not support Web deployment" - +class StaticPositronBootstrap(BasePositronBootstrap): def app_source(self): return """\ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer @@ -75,11 +73,6 @@ def main(): return {{ cookiecutter.class_name }}() """ - def pyproject_table_web(self): - return """\ -supported = false -""" - def post_generate(self, base_path: Path): resource_path = base_path / "src" / self.context["module_name"] / "resources" From c3f0809745f27f56b7be5253609de3ed1ddd4f85 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 2 Feb 2026 16:34:11 +0800 Subject: [PATCH 08/17] Add support for iOS and Android. --- positron/src/positron/fastapi/bootstrap.py | 39 ++++++++++++++++--- .../fastapi/templates/__main__.py.tmpl | 16 ++++++++ .../positron/fastapi/templates/app.py.tmpl | 10 ++++- .../{site.py.tmpl => server.py.tmpl} | 0 4 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 positron/src/positron/fastapi/templates/__main__.py.tmpl rename positron/src/positron/fastapi/templates/{site.py.tmpl => server.py.tmpl} (100%) diff --git a/positron/src/positron/fastapi/bootstrap.py b/positron/src/positron/fastapi/bootstrap.py index 013fc2cb0a..c01fcb589c 100644 --- a/positron/src/positron/fastapi/bootstrap.py +++ b/positron/src/positron/fastapi/bootstrap.py @@ -1,8 +1,14 @@ 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 @@ -15,13 +21,18 @@ class FastAPIPositronBootstrap(BasePositronBootstrap): def template_path(self): return Path(__file__).parent / "templates" + def app_start_source(self): + return self.templated_content("__main__.py", initial_path=self.initial_path) + def app_source(self): return self.templated_content("app.py", initial_path=self.initial_path) def pyproject_table_briefcase_app_extra_content(self): return """ requires = [ - "fastAPI == 0.128.0", + # 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 = [ @@ -32,13 +43,29 @@ def pyproject_table_briefcase_app_extra_content(self): """ def pyproject_table_iOS(self): - return """\ -supported = false + 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): - return """\ -supported = false + 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: @@ -70,7 +97,7 @@ def post_generate(self, base_path: Path): app_path = base_path / "src" / self.context["module_name"] # App files - for template_name in ["site.py"]: + for template_name in ["server.py"]: self.console.debug(f"Writing {template_name}") self.templated_file( template_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 index 46e1956ddc..5bb99c3da7 100644 --- a/positron/src/positron/fastapi/templates/app.py.tmpl +++ b/positron/src/positron/fastapi/templates/app.py.tmpl @@ -8,7 +8,7 @@ import socketserver import toga import uvicorn -from .site import app as fastapi_app +from .server import app as fastapi_app class {{{{ cookiecutter.class_name }}}}(toga.App): @@ -19,7 +19,13 @@ class {{{{ cookiecutter.class_name }}}}(toga.App): 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) + config = uvicorn.Config( + fastapi_app, + host="127.0.0.1", + port=0, + reload=False, + workers=1, + ) self.server = uvicorn.Server(config) # Start the server asynchronously diff --git a/positron/src/positron/fastapi/templates/site.py.tmpl b/positron/src/positron/fastapi/templates/server.py.tmpl similarity index 100% rename from positron/src/positron/fastapi/templates/site.py.tmpl rename to positron/src/positron/fastapi/templates/server.py.tmpl From 18aa98db21836e32c979a8fbc91952cadc3d591b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 5 Feb 2026 08:39:22 +0800 Subject: [PATCH 09/17] Correct bootstrap name annotation. --- positron/src/positron/fastapi/bootstrap.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/positron/src/positron/fastapi/bootstrap.py b/positron/src/positron/fastapi/bootstrap.py index c01fcb589c..c444303213 100644 --- a/positron/src/positron/fastapi/bootstrap.py +++ b/positron/src/positron/fastapi/bootstrap.py @@ -13,9 +13,7 @@ class FastAPIPositronBootstrap(BasePositronBootstrap): - display_name_annotation = "does not support iOS/Android/Web deployment" - # Need a pydantic-core binary to make iOS/Android possible. - # display_name_annotation = "does not support Web deployment" + display_name_annotation = "does not support Web deployment" @property def template_path(self): From 52bfcfe274e760ebdb70630766ede647358d0762 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 5 Feb 2026 08:41:06 +0800 Subject: [PATCH 10/17] Ensure server starts before shutting down. --- .../positron/fastapi/templates/app.py.tmpl | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/positron/src/positron/fastapi/templates/app.py.tmpl b/positron/src/positron/fastapi/templates/app.py.tmpl index 5bb99c3da7..ae82cca130 100644 --- a/positron/src/positron/fastapi/templates/app.py.tmpl +++ b/positron/src/positron/fastapi/templates/app.py.tmpl @@ -1,9 +1,6 @@ from __future__ import annotations import asyncio -import os -import shutil -import socketserver import toga import uvicorn @@ -13,10 +10,30 @@ from .server import app as fastapi_app class {{{{ cookiecutter.class_name }}}}(toga.App): async def cleanup(self, app, **kwargs): - print("Shutting down...") + # 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( @@ -27,11 +44,13 @@ class {{{{ cookiecutter.class_name }}}}(toga.App): 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() + self.web_view = toga.WebView(on_webview_load=self.on_initial_webview_load) self.on_exit = self.cleanup @@ -39,20 +58,19 @@ class {{{{ cookiecutter.class_name }}}}(toga.App): self.main_window.content = self.web_view async def on_running(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 - while self.server is None or not self.server.started: # noqa: ASYNC110 - await asyncio.sleep(0.01) - - for server in self.server.servers: - for socket in server.sockets: - host, port = socket.getsockname() - break + # 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(): From 8b5843f754ed41a75ff09d4e0bad053bb7db912c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 5 Feb 2026 09:44:33 +0800 Subject: [PATCH 11/17] Refactor static and site specific bootstraps to support copying initial content. --- positron/pyproject.toml | 4 +- positron/src/positron/base.py | 59 +++++++++++++++++-- positron/src/positron/django/bootstrap.py | 5 +- positron/src/positron/fastapi/bootstrap.py | 3 +- .../src/positron/sitespecific/__init__.py | 0 .../bootstrap.py} | 26 +++----- .../sitespecific/templates/app.py.tmpl | 16 +++++ positron/src/positron/static/__init__.py | 0 positron/src/positron/static/bootstrap.py | 37 ++++++++++++ .../templates/app.py.tmpl} | 45 +------------- .../positron/static/templates/index.html.tmpl | 9 +++ .../positron/static/templates/positron.css | 3 + 12 files changed, 133 insertions(+), 74 deletions(-) create mode 100644 positron/src/positron/sitespecific/__init__.py rename positron/src/positron/{sitespecific.py => sitespecific/bootstrap.py} (70%) create mode 100644 positron/src/positron/sitespecific/templates/app.py.tmpl create mode 100644 positron/src/positron/static/__init__.py create mode 100644 positron/src/positron/static/bootstrap.py rename positron/src/positron/{static.py => static/templates/app.py.tmpl} (64%) create mode 100644 positron/src/positron/static/templates/index.html.tmpl create mode 100644 positron/src/positron/static/templates/positron.css diff --git a/positron/pyproject.toml b/positron/pyproject.toml index 37f6abed6f..fe24375783 100644 --- a/positron/pyproject.toml +++ b/positron/pyproject.toml @@ -21,8 +21,8 @@ dependencies = ["briefcase >= 0.3.21"] [project.entry-points."briefcase.bootstraps"] "Toga Positron (Django server)" = "positron.django.bootstrap:DjangoPositronBootstrap" "Toga Positron (FastAPI server)" = "positron.fastapi.bootstrap:FastAPIPositronBootstrap" -"Toga Positron (Static server)" = "positron.static:StaticPositronBootstrap" -"Toga Positron (Site-specific browser)" = "positron.sitespecific:SiteSpecificPositronBootstrap" +"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 index 0763ea0524..f9239cd468 100644 --- a/positron/src/positron/base.py +++ b/positron/src/positron/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shutil from pathlib import Path from briefcase.bootstraps import TogaGuiBootstrap @@ -12,26 +13,72 @@ class BasePositronBootstrap(TogaGuiBootstrap): def template_path(self): return Path(__file__).parent / "templates" - def validate_path(self, value: str) -> bool: + 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_name, **context): - """Render a template for `template.name` with the provided context.""" - template = (self.template_path / f"{template_name}.tmpl").read_text( - encoding="utf-8" - ) - return template.format(**context) + """Render a template for `template_name`. + + If a {template_name}.tmpl exists, it will be expanded with the provided + context. Otherwise, {template_name} will be used as-is. + """ + if (self.template_path / f"{template_name}.tmpl").exists(): + template = (self.template_path / f"{template_name}.tmpl").read_text( + encoding="utf-8" + ) + return template.format(**context) + else: + return (self.template_path / template_name).read_text(encoding="utf-8") def templated_file(self, template_name, output_path, **context): """Render a template for `template.name` with the provided context, saving the result in `output_path`.""" + self.console.debug(f"Writing {template_name}") (output_path / template_name).write_text( self.templated_content(template_name, **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/bootstrap.py b/positron/src/positron/django/bootstrap.py index 26e33e002e..9e92c97cba 100644 --- a/positron/src/positron/django/bootstrap.py +++ b/positron/src/positron/django/bootstrap.py @@ -50,7 +50,7 @@ def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | N ), description="Initial path", default="/admin/", - validator=self.validate_path, + validator=self.validate_url_path, override_value=project_overrides.pop("initial_path", None), ) @@ -60,15 +60,14 @@ 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") self.templated_file( "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}") self.templated_file( template_name, app_path, diff --git a/positron/src/positron/fastapi/bootstrap.py b/positron/src/positron/fastapi/bootstrap.py index c444303213..51d3d7add3 100644 --- a/positron/src/positron/fastapi/bootstrap.py +++ b/positron/src/positron/fastapi/bootstrap.py @@ -85,7 +85,7 @@ def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | N ), description="Initial path", default="/", - validator=self.validate_path, + validator=self.validate_url_path, override_value=project_overrides.pop("initial_path", None), ) @@ -96,7 +96,6 @@ def post_generate(self, base_path: Path): # App files for template_name in ["server.py"]: - self.console.debug(f"Writing {template_name}") self.templated_file( template_name, app_path, diff --git a/positron/src/positron/sitespecific/__init__.py b/positron/src/positron/sitespecific/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/positron/src/positron/sitespecific.py b/positron/src/positron/sitespecific/bootstrap.py similarity index 70% rename from positron/src/positron/sitespecific.py rename to positron/src/positron/sitespecific/bootstrap.py index 8033daae86..1a31816776 100644 --- a/positron/src/positron/sitespecific.py +++ b/positron/src/positron/sitespecific/bootstrap.py @@ -1,32 +1,20 @@ from __future__ import annotations +from pathlib import Path from typing import Any from briefcase.config import validate_url -from .base import BasePositronBootstrap +from ..base import BasePositronBootstrap class SiteSpecificPositronBootstrap(BasePositronBootstrap): - def app_source(self): - return f"""\ -import toga - - -class {{{{ cookiecutter.class_name }}}}(toga.App): - - def startup(self): - self.web_view = toga.WebView() - self.web_view.url = f"{self.site_url}" + @property + def template_path(self): + return Path(__file__).parent / "templates" - self.main_window = toga.MainWindow() - self.main_window.content = self.web_view - self.main_window.show() - - -def main(): - return {{{{ cookiecutter.class_name }}}}() -""" + def app_source(self): + return self.templated_content("app.py", site_url=self.site_url) def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None: """Runs prior to other plugin hooks to provide additional context. diff --git a/positron/src/positron/sitespecific/templates/app.py.tmpl b/positron/src/positron/sitespecific/templates/app.py.tmpl new file mode 100644 index 0000000000..b6a7a32336 --- /dev/null +++ b/positron/src/positron/sitespecific/templates/app.py.tmpl @@ -0,0 +1,16 @@ +import toga + + +class {{{{ cookiecutter.class_name }}}}(toga.App): + + def startup(self): + self.web_view = toga.WebView() + self.web_view.url = f"{site_url}" + + self.main_window = toga.MainWindow() + self.main_window.content = self.web_view + self.main_window.show() + + +def main(): + return {{{{ cookiecutter.class_name }}}}() diff --git a/positron/src/positron/static/__init__.py b/positron/src/positron/static/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/positron/src/positron/static/bootstrap.py b/positron/src/positron/static/bootstrap.py new file mode 100644 index 0000000000..c7b11dca66 --- /dev/null +++ b/positron/src/positron/static/bootstrap.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..base import BasePositronBootstrap + + +class StaticPositronBootstrap(BasePositronBootstrap): + @property + def template_path(self) -> Path: + return Path(__file__).parent / "templates" + + def app_source(self) -> str: + return self.templated_content("app.py") + + 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.select_content_path(project_overrides.pop("content_path", None)) + + def post_generate(self, base_path: Path): + resource_path = base_path / "src" / self.context["module_name"] / "resources" + + if self.content_path: + self.install_static_content(resource_path) + else: + # Write default content + for template_name in ["index.html", "positron.css"]: + self.templated_file(template_name, resource_path, **self.context) diff --git a/positron/src/positron/static.py b/positron/src/positron/static/templates/app.py.tmpl similarity index 64% rename from positron/src/positron/static.py rename to positron/src/positron/static/templates/app.py.tmpl index 677915a8f2..060404f51c 100644 --- a/positron/src/positron/static.py +++ b/positron/src/positron/static/templates/app.py.tmpl @@ -1,13 +1,3 @@ -from __future__ import annotations - -from pathlib import Path - -from .base import BasePositronBootstrap - - -class StaticPositronBootstrap(BasePositronBootstrap): - def app_source(self): - return """\ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from threading import Event, Thread @@ -36,7 +26,7 @@ def __init__(self, base_path, RequestHandlerClass=HTTPHandler): super().__init__(("127.0.0.1", 0), RequestHandlerClass) -class {{ cookiecutter.class_name }}(toga.App): +class {{{{ cookiecutter.class_name }}}}(toga.App): def web_server(self): print("Starting server...") self._httpd = LocalHTTPServer(self.paths.app / "resources") @@ -62,7 +52,7 @@ def startup(self): self.server_exists.wait() host, port = self._httpd.socket.getsockname() - self.web_view.url = f"http://{host}:{port}/" + self.web_view.url = f"http://{{host}}:{{port}}/" self.main_window = toga.MainWindow() self.main_window.content = self.web_view @@ -70,33 +60,4 @@ def startup(self): def main(): - return {{ cookiecutter.class_name }}() -""" - - def post_generate(self, base_path: Path): - resource_path = base_path / "src" / self.context["module_name"] / "resources" - - # Write an index.html file - (resource_path / "index.html").write_text( - f""" - - {self.context["formal_name"]} - - - -

Hello World

- - -""", - encoding="UTF-8", - ) - - # Write a CSS file - (resource_path / "positron.css").write_text( - """ -h1 { - font-family: sans-serif; -} -""", - encoding="UTF-8", - ) + return {{{{ cookiecutter.class_name }}}}() diff --git a/positron/src/positron/static/templates/index.html.tmpl b/positron/src/positron/static/templates/index.html.tmpl new file mode 100644 index 0000000000..2b27fb6ca0 --- /dev/null +++ b/positron/src/positron/static/templates/index.html.tmpl @@ -0,0 +1,9 @@ + + + {formal_name} + + + +

Hello World

+ + diff --git a/positron/src/positron/static/templates/positron.css b/positron/src/positron/static/templates/positron.css new file mode 100644 index 0000000000..2c3432cdaf --- /dev/null +++ b/positron/src/positron/static/templates/positron.css @@ -0,0 +1,3 @@ +h1 { + font-family: sans-serif; +} From 6001dd83b5e6fda0683230a27ee627358dbd706a Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 5 Feb 2026 11:29:55 +0800 Subject: [PATCH 12/17] Add initial PyScript Positron backend. --- positron/pyproject.toml | 1 + .../templates/{server.py.tmpl => server.py} | 0 positron/src/positron/pyscript/__init__.py | 0 positron/src/positron/pyscript/bootstrap.py | 52 +++++++++ .../pyscript/templates/__main__.py.tmpl | 16 +++ .../positron/pyscript/templates/app.py.tmpl | 77 +++++++++++++ .../pyscript/templates/index.html.tmpl | 14 +++ .../src/positron/pyscript/templates/main.py | 7 ++ .../positron/pyscript/templates/positron.css | 3 + .../positron/pyscript/templates/pyscript.toml | 0 .../src/positron/pyscript/templates/server.py | 108 ++++++++++++++++++ positron/src/positron/static/bootstrap.py | 2 +- 12 files changed, 279 insertions(+), 1 deletion(-) rename positron/src/positron/fastapi/templates/{server.py.tmpl => server.py} (100%) create mode 100644 positron/src/positron/pyscript/__init__.py create mode 100644 positron/src/positron/pyscript/bootstrap.py create mode 100644 positron/src/positron/pyscript/templates/__main__.py.tmpl create mode 100644 positron/src/positron/pyscript/templates/app.py.tmpl create mode 100644 positron/src/positron/pyscript/templates/index.html.tmpl create mode 100644 positron/src/positron/pyscript/templates/main.py create mode 100644 positron/src/positron/pyscript/templates/positron.css create mode 100644 positron/src/positron/pyscript/templates/pyscript.toml create mode 100644 positron/src/positron/pyscript/templates/server.py diff --git a/positron/pyproject.toml b/positron/pyproject.toml index fe24375783..95a3d447da 100644 --- a/positron/pyproject.toml +++ b/positron/pyproject.toml @@ -21,6 +21,7 @@ dependencies = ["briefcase >= 0.3.21"] [project.entry-points."briefcase.bootstraps"] "Toga Positron (Django server)" = "positron.django.bootstrap:DjangoPositronBootstrap" "Toga Positron (FastAPI server)" = "positron.fastapi.bootstrap:FastAPIPositronBootstrap" +"Toga Positron (PyScript app)" = "positron.pyscript.bootstrap:PyScriptPositronBootstrap" "Toga Positron (Static server)" = "positron.static.bootstrap:StaticPositronBootstrap" "Toga Positron (Site-specific browser)" = "positron.sitespecific.bootstrap:SiteSpecificPositronBootstrap" diff --git a/positron/src/positron/fastapi/templates/server.py.tmpl b/positron/src/positron/fastapi/templates/server.py similarity index 100% rename from positron/src/positron/fastapi/templates/server.py.tmpl rename to positron/src/positron/fastapi/templates/server.py diff --git a/positron/src/positron/pyscript/__init__.py b/positron/src/positron/pyscript/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/positron/src/positron/pyscript/bootstrap.py b/positron/src/positron/pyscript/bootstrap.py new file mode 100644 index 0000000000..fc9ad67601 --- /dev/null +++ b/positron/src/positron/pyscript/bootstrap.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..fastapi.bootstrap import FastAPIPositronBootstrap + + +class PyScriptPositronBootstrap(FastAPIPositronBootstrap): + display_name_annotation = "does not support Web deployment" + + @property + def template_path(self): + return Path(__file__).parent / "templates" + + 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.select_content_path(project_overrides.pop("content_path", None)) + return {} + + def post_generate(self, base_path: Path): + app_path = base_path / "src" / self.context["module_name"] + resource_path = app_path / "resources" + + # FastAPI server + for template_name in ["server.py"]: + self.templated_file( + template_name, + app_path, + module_name=self.context["module_name"], + ) + + # App files + if self.content_path: + self.install_static_content(resource_path) + else: + # Write default content for a PyScript app + for template_name in [ + "index.html", + "positron.css", + "main.py", + "pyscript.toml", + ]: + self.templated_file(template_name, resource_path, **self.context) diff --git a/positron/src/positron/pyscript/templates/__main__.py.tmpl b/positron/src/positron/pyscript/templates/__main__.py.tmpl new file mode 100644 index 0000000000..b71dd92b49 --- /dev/null +++ b/positron/src/positron/pyscript/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/pyscript/templates/app.py.tmpl b/positron/src/positron/pyscript/templates/app.py.tmpl new file mode 100644 index 0000000000..ae82cca130 --- /dev/null +++ b/positron/src/positron/pyscript/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/pyscript/templates/index.html.tmpl b/positron/src/positron/pyscript/templates/index.html.tmpl new file mode 100644 index 0000000000..dbac6e666d --- /dev/null +++ b/positron/src/positron/pyscript/templates/index.html.tmpl @@ -0,0 +1,14 @@ + + + {formal_name} + + + + + +

{formal_name}

+ +
+ + + diff --git a/positron/src/positron/pyscript/templates/main.py b/positron/src/positron/pyscript/templates/main.py new file mode 100644 index 0000000000..5e3162746d --- /dev/null +++ b/positron/src/positron/pyscript/templates/main.py @@ -0,0 +1,7 @@ +from pyscript import web, when + + +@when("click", "#my-button") +def handler(): + output_div = web.page["output"] + output_div.innerText = "Button clicked!" diff --git a/positron/src/positron/pyscript/templates/positron.css b/positron/src/positron/pyscript/templates/positron.css new file mode 100644 index 0000000000..2c3432cdaf --- /dev/null +++ b/positron/src/positron/pyscript/templates/positron.css @@ -0,0 +1,3 @@ +h1 { + font-family: sans-serif; +} diff --git a/positron/src/positron/pyscript/templates/pyscript.toml b/positron/src/positron/pyscript/templates/pyscript.toml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/positron/src/positron/pyscript/templates/server.py b/positron/src/positron/pyscript/templates/server.py new file mode 100644 index 0000000000..a4922276b1 --- /dev/null +++ b/positron/src/positron/pyscript/templates/server.py @@ -0,0 +1,108 @@ +# import asyncio +from pathlib import Path + +from fastapi import FastAPI + +# from fastapi.websockets import WebSocker +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +# from constants import MAIN_WS +# from reflected_ffi import local +# from reflected_ffi.direct import decode, encode +# from reflected_ffi.direct.js import Null, Promise + +# from next_resolver import next_resolver + +app = FastAPI() + + +# @app.websocket("/") +# async def websocket_endpoint(websocket: WebSocket): +# await websocket.accept() + +# ( +# next, +# resolve, +# ) = next_resolver(str) + +# coincident = -1 +# nmsp = None + +# while True: +# if coincident < 0: +# coincident = 0 +# try: +# data = decode(await websocket.receive_bytes()) +# if isinstance(data, list) and data[0] == MAIN_WS: +# coincident = 1 + +# async def reflect(*args): +# ( +# uid, +# promise, +# ) = next() +# await websocket.send_bytes(bytes(encode([uid, args]))) +# return promise + +# nmsp = local(reflect=reflect) + +# except Exception as e: +# pass + +# elif coincident > 0: +# try: +# buff = await websocket.receive_bytes() +# data = decode(buff) +# if isinstance(data[0], str): +# resolve(*data) +# else: +# try: +# value = nmsp.reflect(*data[1]) +# while asyncio.iscoroutine(value): +# value = await value + +# data[1] = value + +# except Exception as e: +# data[1] = Null +# data[2] = e + +# await websocket.send_bytes(bytes(encode(data))) +# except Exception as e: +# # connection closed +# break + + +app.add_middleware( + CORSMiddleware, + allow_credentials=True, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.middleware("http") +async def add_coi_headers(request, call_next): + response = await call_next(request) + response.headers.update( + { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + "Cross-Origin-Resource-Policy": "cross-origin", + "Cache-Control": "no-cache", + "Pragma": "no-cache", + "Expires": "0", + "Last-Modified": "0", + "ETag": "0", + } + ) + return response + + +app.mount( + "/", + StaticFiles(directory=Path(__file__).parent / "resources", html=True), + name="positron", +) diff --git a/positron/src/positron/static/bootstrap.py b/positron/src/positron/static/bootstrap.py index c7b11dca66..e2dfc163e1 100644 --- a/positron/src/positron/static/bootstrap.py +++ b/positron/src/positron/static/bootstrap.py @@ -23,8 +23,8 @@ def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | N :param project_overrides: Any overrides provided by the user as -Q options that haven't been consumed by the standard bootstrap wizard questions. """ - self.select_content_path(project_overrides.pop("content_path", None)) + return {} def post_generate(self, base_path: Path): resource_path = base_path / "src" / self.context["module_name"] / "resources" From 3b9370a278f351a2637bdeeee656fd9e6f88290b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 5 Feb 2026 11:30:32 +0800 Subject: [PATCH 13/17] Add CI for PyScript backend. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07f0b37fbb..d57e03549c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -570,6 +570,7 @@ jobs: bootstrap: - "Positron (Django)" - "Positron (FastAPI)" + - "Positron (PyScript)" - "Positron (Static)" - "Positron (Site-specific)" include: @@ -579,6 +580,9 @@ jobs: - bootstrap: "Positron (FastAPI)" new-options: '-Q "bootstrap=Toga Positron (FastAPI server)"' + - bootstrap: "Positron (PyScript)" + new-options: '-Q "bootstrap=Toga Positron (PyScript app)"' + - bootstrap: "Positron (Static)" new-options: '-Q "bootstrap=Toga Positron (Static server)"' From 160f60f6e66a931576d19a8d8256b38ad6864dc1 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 5 Feb 2026 12:13:12 +0800 Subject: [PATCH 14/17] Rework template handling to avoid duplicating shared templates. --- positron/src/positron/base.py | 35 ++++----- positron/src/positron/django/bootstrap.py | 15 ++-- positron/src/positron/fastapi/bootstrap.py | 14 +++- positron/src/positron/pyscript/bootstrap.py | 33 ++++---- .../pyscript/templates/__main__.py.tmpl | 16 ---- .../positron/pyscript/templates/app.py.tmpl | 77 ------------------- .../positron/pyscript/templates/positron.css | 3 - .../src/positron/sitespecific/bootstrap.py | 4 +- positron/src/positron/static/bootstrap.py | 14 ++-- 9 files changed, 66 insertions(+), 145 deletions(-) delete mode 100644 positron/src/positron/pyscript/templates/__main__.py.tmpl delete mode 100644 positron/src/positron/pyscript/templates/app.py.tmpl delete mode 100644 positron/src/positron/pyscript/templates/positron.css diff --git a/positron/src/positron/base.py b/positron/src/positron/base.py index f9239cd468..84fa9363d8 100644 --- a/positron/src/positron/base.py +++ b/positron/src/positron/base.py @@ -9,10 +9,6 @@ class BasePositronBootstrap(TogaGuiBootstrap): display_name_annotation = "does not support Web deployment" - @property - def template_path(self): - return Path(__file__).parent / "templates" - def validate_url_path(self, value: str) -> bool: """Validate that the value is a valid path.""" if not value.startswith("/"): @@ -28,26 +24,27 @@ def validate_content_path(self, value: str) -> bool: raise ValueError(f"Path {Path(value).resolve()} does not exist") return True - def templated_content(self, template_name, **context): - """Render a template for `template_name`. + def templated_content(self, template_path, **context): + """Render the template at the provided path. - If a {template_name}.tmpl exists, it will be expanded with the provided - context. Otherwise, {template_name} will be used as-is. + If a {template_path}.tmpl exists, it will be expanded with the provided + context. Otherwise, the content will be used as-is. """ - if (self.template_path / f"{template_name}.tmpl").exists(): - template = (self.template_path / f"{template_name}.tmpl").read_text( - encoding="utf-8" - ) + 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 (self.template_path / template_name).read_text(encoding="utf-8") + return template_path.read_text(encoding="utf-8") - def templated_file(self, template_name, output_path, **context): - """Render a template for `template.name` with the provided context, saving the - result in `output_path`.""" - self.console.debug(f"Writing {template_name}") - (output_path / template_name).write_text( - self.templated_content(template_name, **context), 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): diff --git a/positron/src/positron/django/bootstrap.py b/positron/src/positron/django/bootstrap.py index 9e92c97cba..e7fb080e8e 100644 --- a/positron/src/positron/django/bootstrap.py +++ b/positron/src/positron/django/bootstrap.py @@ -5,14 +5,15 @@ from ..base import BasePositronBootstrap +TEMPLATE_PATH = Path(__file__).parent / "templates" -class DjangoPositronBootstrap(BasePositronBootstrap): - @property - def template_path(self): - return Path(__file__).parent / "templates" +class DjangoPositronBootstrap(BasePositronBootstrap): def app_source(self): - return self.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 """ @@ -61,7 +62,7 @@ def post_generate(self, base_path: Path): # Top level files self.templated_file( - "manage.py", + TEMPLATE_PATH / "manage.py", app_path.parent, module_name=self.context["module_name"], ) @@ -69,7 +70,7 @@ def post_generate(self, base_path: Path): # App files for template_name in ["settings.py", "urls.py", "wsgi.py"]: self.templated_file( - template_name, + TEMPLATE_PATH / template_name, app_path, module_name=self.context["module_name"], ) diff --git a/positron/src/positron/fastapi/bootstrap.py b/positron/src/positron/fastapi/bootstrap.py index 51d3d7add3..94881c11db 100644 --- a/positron/src/positron/fastapi/bootstrap.py +++ b/positron/src/positron/fastapi/bootstrap.py @@ -11,6 +11,8 @@ from ..base import BasePositronBootstrap +TEMPLATE_PATH = Path(__file__).parent / "templates" + class FastAPIPositronBootstrap(BasePositronBootstrap): display_name_annotation = "does not support Web deployment" @@ -20,10 +22,16 @@ def template_path(self): return Path(__file__).parent / "templates" def app_start_source(self): - return self.templated_content("__main__.py", initial_path=self.initial_path) + return self.templated_content( + TEMPLATE_PATH / "__main__.py", + initial_path=self.initial_path, + ) def app_source(self): - return self.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 """ @@ -97,7 +105,7 @@ def post_generate(self, base_path: Path): # App files for template_name in ["server.py"]: self.templated_file( - template_name, + TEMPLATE_PATH / template_name, app_path, module_name=self.context["module_name"], ) diff --git a/positron/src/positron/pyscript/bootstrap.py b/positron/src/positron/pyscript/bootstrap.py index fc9ad67601..8d3e7daabc 100644 --- a/positron/src/positron/pyscript/bootstrap.py +++ b/positron/src/positron/pyscript/bootstrap.py @@ -5,14 +5,13 @@ from ..fastapi.bootstrap import FastAPIPositronBootstrap +TEMPLATE_PATH = Path(__file__).parent / "templates" +STATIC_TEMPLATE_PATH = Path(__file__).parent.parent / "static/templates" + class PyScriptPositronBootstrap(FastAPIPositronBootstrap): display_name_annotation = "does not support Web deployment" - @property - def template_path(self): - return Path(__file__).parent / "templates" - def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None: """Runs prior to other plugin hooks to provide additional context. @@ -33,7 +32,7 @@ def post_generate(self, base_path: Path): # FastAPI server for template_name in ["server.py"]: self.templated_file( - template_name, + TEMPLATE_PATH / template_name, app_path, module_name=self.context["module_name"], ) @@ -42,11 +41,19 @@ def post_generate(self, base_path: Path): if self.content_path: self.install_static_content(resource_path) else: - # Write default content for a PyScript app - for template_name in [ - "index.html", - "positron.css", - "main.py", - "pyscript.toml", - ]: - self.templated_file(template_name, resource_path, **self.context) + # Write default content for a PyScript app. + # Start with content shared with every static app + for template_name in ["positron.css"]: + self.templated_file( + STATIC_TEMPLATE_PATH / template_name, + resource_path, + **self.context, + ) + + # Then add content that is PyScript specific + for template_name in ["index.html", "main.py", "pyscript.toml"]: + self.templated_file( + TEMPLATE_PATH / template_name, + resource_path, + **self.context, + ) diff --git a/positron/src/positron/pyscript/templates/__main__.py.tmpl b/positron/src/positron/pyscript/templates/__main__.py.tmpl deleted file mode 100644 index b71dd92b49..0000000000 --- a/positron/src/positron/pyscript/templates/__main__.py.tmpl +++ /dev/null @@ -1,16 +0,0 @@ -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/pyscript/templates/app.py.tmpl b/positron/src/positron/pyscript/templates/app.py.tmpl deleted file mode 100644 index ae82cca130..0000000000 --- a/positron/src/positron/pyscript/templates/app.py.tmpl +++ /dev/null @@ -1,77 +0,0 @@ -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/pyscript/templates/positron.css b/positron/src/positron/pyscript/templates/positron.css deleted file mode 100644 index 2c3432cdaf..0000000000 --- a/positron/src/positron/pyscript/templates/positron.css +++ /dev/null @@ -1,3 +0,0 @@ -h1 { - font-family: sans-serif; -} diff --git a/positron/src/positron/sitespecific/bootstrap.py b/positron/src/positron/sitespecific/bootstrap.py index 1a31816776..3352a547c7 100644 --- a/positron/src/positron/sitespecific/bootstrap.py +++ b/positron/src/positron/sitespecific/bootstrap.py @@ -7,6 +7,8 @@ from ..base import BasePositronBootstrap +TEMPLATE_PATH = Path(__file__).parent / "templates" + class SiteSpecificPositronBootstrap(BasePositronBootstrap): @property @@ -14,7 +16,7 @@ def template_path(self): return Path(__file__).parent / "templates" def app_source(self): - return self.templated_content("app.py", site_url=self.site_url) + return self.templated_content(TEMPLATE_PATH / "app.py", site_url=self.site_url) def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None: """Runs prior to other plugin hooks to provide additional context. diff --git a/positron/src/positron/static/bootstrap.py b/positron/src/positron/static/bootstrap.py index e2dfc163e1..72dc1f4ddc 100644 --- a/positron/src/positron/static/bootstrap.py +++ b/positron/src/positron/static/bootstrap.py @@ -5,14 +5,12 @@ from ..base import BasePositronBootstrap +TEMPLATE_PATH = Path(__file__).parent / "templates" -class StaticPositronBootstrap(BasePositronBootstrap): - @property - def template_path(self) -> Path: - return Path(__file__).parent / "templates" +class StaticPositronBootstrap(BasePositronBootstrap): def app_source(self) -> str: - return self.templated_content("app.py") + return self.templated_content(TEMPLATE_PATH / "app.py") def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None: """Runs prior to other plugin hooks to provide additional context. @@ -34,4 +32,8 @@ def post_generate(self, base_path: Path): else: # Write default content for template_name in ["index.html", "positron.css"]: - self.templated_file(template_name, resource_path, **self.context) + self.templated_file( + TEMPLATE_PATH / template_name, + resource_path, + **self.context, + ) From 385306d93a8bc0cb0b0944a21c3c56f4a4486ff3 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 5 Feb 2026 12:43:08 +0800 Subject: [PATCH 15/17] Make the dummy FastAPI response prettier. Co-authored-by: Russell Martin --- positron/src/positron/fastapi/templates/server.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/positron/src/positron/fastapi/templates/server.py b/positron/src/positron/fastapi/templates/server.py index b49553d5d1..11bea608b0 100644 --- a/positron/src/positron/fastapi/templates/server.py +++ b/positron/src/positron/fastapi/templates/server.py @@ -1,8 +1,10 @@ from fastapi import FastAPI +from fastapi.responses import HTMLResponse app = FastAPI() -@app.get("/") +@app.get("/", response_class=HTMLResponse) async def root(): - return "Hello World" + return "

Hello World

" + From f6ff168db22e82dbd837bad4cfe267ecce232b00 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 5 Feb 2026 12:51:01 +0800 Subject: [PATCH 16/17] Cleanup ruff issues. --- positron/src/positron/fastapi/templates/server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/positron/src/positron/fastapi/templates/server.py b/positron/src/positron/fastapi/templates/server.py index 11bea608b0..ed9686fba3 100644 --- a/positron/src/positron/fastapi/templates/server.py +++ b/positron/src/positron/fastapi/templates/server.py @@ -7,4 +7,3 @@ @app.get("/", response_class=HTMLResponse) async def root(): return "

Hello World

" - From 603fc7bee0ac04dd3cc5d37e4f1b5809c91388ba Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 31 Mar 2026 09:57:36 +0800 Subject: [PATCH 17/17] Remove PyScript plugin from this branch. --- .github/workflows/ci.yml | 4 - positron/pyproject.toml | 1 - positron/src/positron/pyscript/__init__.py | 0 positron/src/positron/pyscript/bootstrap.py | 59 ---------- .../pyscript/templates/index.html.tmpl | 14 --- .../src/positron/pyscript/templates/main.py | 7 -- .../positron/pyscript/templates/pyscript.toml | 0 .../src/positron/pyscript/templates/server.py | 108 ------------------ 8 files changed, 193 deletions(-) delete mode 100644 positron/src/positron/pyscript/__init__.py delete mode 100644 positron/src/positron/pyscript/bootstrap.py delete mode 100644 positron/src/positron/pyscript/templates/index.html.tmpl delete mode 100644 positron/src/positron/pyscript/templates/main.py delete mode 100644 positron/src/positron/pyscript/templates/pyscript.toml delete mode 100644 positron/src/positron/pyscript/templates/server.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d5b2ef84a..976c35442b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -605,7 +605,6 @@ jobs: bootstrap: - "Positron (Django)" - "Positron (FastAPI)" - - "Positron (PyScript)" - "Positron (Static)" - "Positron (Site-specific)" include: @@ -615,9 +614,6 @@ jobs: - bootstrap: "Positron (FastAPI)" new-options: '-Q "bootstrap=Toga Positron (FastAPI server)"' - - bootstrap: "Positron (PyScript)" - new-options: '-Q "bootstrap=Toga Positron (PyScript app)"' - - bootstrap: "Positron (Static)" new-options: '-Q "bootstrap=Toga Positron (Static server)"' diff --git a/positron/pyproject.toml b/positron/pyproject.toml index b8c61f67d6..8cfed1142b 100644 --- a/positron/pyproject.toml +++ b/positron/pyproject.toml @@ -21,7 +21,6 @@ dependencies = ["briefcase >= 0.3.21"] [project.entry-points."briefcase.bootstraps"] "Toga Positron (Django server)" = "positron.django.bootstrap:DjangoPositronBootstrap" "Toga Positron (FastAPI server)" = "positron.fastapi.bootstrap:FastAPIPositronBootstrap" -"Toga Positron (PyScript app)" = "positron.pyscript.bootstrap:PyScriptPositronBootstrap" "Toga Positron (Static server)" = "positron.static.bootstrap:StaticPositronBootstrap" "Toga Positron (Site-specific browser)" = "positron.sitespecific.bootstrap:SiteSpecificPositronBootstrap" diff --git a/positron/src/positron/pyscript/__init__.py b/positron/src/positron/pyscript/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/positron/src/positron/pyscript/bootstrap.py b/positron/src/positron/pyscript/bootstrap.py deleted file mode 100644 index 8d3e7daabc..0000000000 --- a/positron/src/positron/pyscript/bootstrap.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from ..fastapi.bootstrap import FastAPIPositronBootstrap - -TEMPLATE_PATH = Path(__file__).parent / "templates" -STATIC_TEMPLATE_PATH = Path(__file__).parent.parent / "static/templates" - - -class PyScriptPositronBootstrap(FastAPIPositronBootstrap): - display_name_annotation = "does not support Web deployment" - - 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.select_content_path(project_overrides.pop("content_path", None)) - return {} - - def post_generate(self, base_path: Path): - app_path = base_path / "src" / self.context["module_name"] - resource_path = app_path / "resources" - - # FastAPI server - for template_name in ["server.py"]: - self.templated_file( - TEMPLATE_PATH / template_name, - app_path, - module_name=self.context["module_name"], - ) - - # App files - if self.content_path: - self.install_static_content(resource_path) - else: - # Write default content for a PyScript app. - # Start with content shared with every static app - for template_name in ["positron.css"]: - self.templated_file( - STATIC_TEMPLATE_PATH / template_name, - resource_path, - **self.context, - ) - - # Then add content that is PyScript specific - for template_name in ["index.html", "main.py", "pyscript.toml"]: - self.templated_file( - TEMPLATE_PATH / template_name, - resource_path, - **self.context, - ) diff --git a/positron/src/positron/pyscript/templates/index.html.tmpl b/positron/src/positron/pyscript/templates/index.html.tmpl deleted file mode 100644 index dbac6e666d..0000000000 --- a/positron/src/positron/pyscript/templates/index.html.tmpl +++ /dev/null @@ -1,14 +0,0 @@ - - - {formal_name} - - - - - -

{formal_name}

- -
- - - diff --git a/positron/src/positron/pyscript/templates/main.py b/positron/src/positron/pyscript/templates/main.py deleted file mode 100644 index 5e3162746d..0000000000 --- a/positron/src/positron/pyscript/templates/main.py +++ /dev/null @@ -1,7 +0,0 @@ -from pyscript import web, when - - -@when("click", "#my-button") -def handler(): - output_div = web.page["output"] - output_div.innerText = "Button clicked!" diff --git a/positron/src/positron/pyscript/templates/pyscript.toml b/positron/src/positron/pyscript/templates/pyscript.toml deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/positron/src/positron/pyscript/templates/server.py b/positron/src/positron/pyscript/templates/server.py deleted file mode 100644 index a4922276b1..0000000000 --- a/positron/src/positron/pyscript/templates/server.py +++ /dev/null @@ -1,108 +0,0 @@ -# import asyncio -from pathlib import Path - -from fastapi import FastAPI - -# from fastapi.websockets import WebSocker -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles - -# from constants import MAIN_WS -# from reflected_ffi import local -# from reflected_ffi.direct import decode, encode -# from reflected_ffi.direct.js import Null, Promise - -# from next_resolver import next_resolver - -app = FastAPI() - - -# @app.websocket("/") -# async def websocket_endpoint(websocket: WebSocket): -# await websocket.accept() - -# ( -# next, -# resolve, -# ) = next_resolver(str) - -# coincident = -1 -# nmsp = None - -# while True: -# if coincident < 0: -# coincident = 0 -# try: -# data = decode(await websocket.receive_bytes()) -# if isinstance(data, list) and data[0] == MAIN_WS: -# coincident = 1 - -# async def reflect(*args): -# ( -# uid, -# promise, -# ) = next() -# await websocket.send_bytes(bytes(encode([uid, args]))) -# return promise - -# nmsp = local(reflect=reflect) - -# except Exception as e: -# pass - -# elif coincident > 0: -# try: -# buff = await websocket.receive_bytes() -# data = decode(buff) -# if isinstance(data[0], str): -# resolve(*data) -# else: -# try: -# value = nmsp.reflect(*data[1]) -# while asyncio.iscoroutine(value): -# value = await value - -# data[1] = value - -# except Exception as e: -# data[1] = Null -# data[2] = e - -# await websocket.send_bytes(bytes(encode(data))) -# except Exception as e: -# # connection closed -# break - - -app.add_middleware( - CORSMiddleware, - allow_credentials=True, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.middleware("http") -async def add_coi_headers(request, call_next): - response = await call_next(request) - response.headers.update( - { - "Cross-Origin-Opener-Policy": "same-origin", - "Cross-Origin-Embedder-Policy": "require-corp", - "Cross-Origin-Resource-Policy": "cross-origin", - "Cache-Control": "no-cache", - "Pragma": "no-cache", - "Expires": "0", - "Last-Modified": "0", - "ETag": "0", - } - ) - return response - - -app.mount( - "/", - StaticFiles(directory=Path(__file__).parent / "resources", html=True), - name="positron", -)