Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"'

Expand Down
1 change: 1 addition & 0 deletions changes/3327.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Toga Positron now has a bootstrap for FastAPI-based websites.
3 changes: 2 additions & 1 deletion positron/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ 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 (FastAPI server)" = "positron.fastapi.bootstrap:FastAPIPositronBootstrap"
"Toga Positron (Static server)" = "positron.static:StaticPositronBootstrap"
"Toga Positron (Site-specific browser)" = "positron.sitespecific:SiteSpecificPositronBootstrap"

Expand Down
38 changes: 38 additions & 0 deletions positron/src/positron/base.py
Original file line number Diff line number Diff line change
@@ -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
"""
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,32 @@
from pathlib import Path
from typing import Any

from briefcase.bootstraps import TogaGuiBootstrap
from ..base import BasePositronBootstrap


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):
@property
def template_path(self):
return Path(__file__).parent / "templates"

def app_source(self):
return templated_content("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 """
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:
Expand All @@ -66,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),
)

Expand All @@ -77,15 +61,15 @@ def post_generate(self, base_path: Path):

# Top level files
self.console.debug("Writing manage.py")
templated_file(
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}")
templated_file(
self.templated_file(
template_name,
app_path,
module_name=self.context["module_name"],
Expand Down
Empty file.
79 changes: 79 additions & 0 deletions positron/src/positron/fastapi/bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from __future__ import annotations

from pathlib import Path
from typing import Any

from ..base import BasePositronBootstrap


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 self.templated_content("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 pyproject_table_iOS(self):
return """\
supported = false
"""

def pyproject_table_android(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.

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_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}")
self.templated_file(
template_name,
app_path,
module_name=self.context["module_name"],
)
53 changes: 53 additions & 0 deletions positron/src/positron/fastapi/templates/app.py.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from __future__ import annotations

import asyncio
import os
import shutil
import socketserver

import toga
import uvicorn

from .site import app as fastapi_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())
Comment thread
rmartin16 marked this conversation as resolved.

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 }}}}()
8 changes: 8 additions & 0 deletions positron/src/positron/fastapi/templates/site.py.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
return "Hello World"
5 changes: 2 additions & 3 deletions positron/src/positron/sitespecific.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions positron/src/positron/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down