Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -569,12 +569,20 @@ jobs:
matrix:
bootstrap:
- "Positron (Django)"
- "Positron (FastAPI)"
- "Positron (PyScript)"
- "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 (PyScript)"
new-options: '-Q "bootstrap=Toga Positron (PyScript app)"'

- 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.
8 changes: 5 additions & 3 deletions positron/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ dynamic = ["version"]
dependencies = ["briefcase >= 0.3.21"]

[project.entry-points."briefcase.bootstraps"]
"Toga Positron (Django server)" = "positron.django:DjangoPositronBootstrap"
"Toga Positron (Static server)" = "positron.static:StaticPositronBootstrap"
"Toga Positron (Site-specific browser)" = "positron.sitespecific:SiteSpecificPositronBootstrap"
"Toga Positron (Django server)" = "positron.django.bootstrap:DjangoPositronBootstrap"
"Toga Positron (FastAPI server)" = "positron.fastapi.bootstrap:FastAPIPositronBootstrap"
"Toga Positron (PyScript app)" = "positron.pyscript.bootstrap:PyScriptPositronBootstrap"
"Toga Positron (Static server)" = "positron.static.bootstrap:StaticPositronBootstrap"
"Toga Positron (Site-specific browser)" = "positron.sitespecific.bootstrap:SiteSpecificPositronBootstrap"

[tool.setuptools_scm]
root = "../"
82 changes: 82 additions & 0 deletions positron/src/positron/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

import shutil
from pathlib import Path

from briefcase.bootstraps import TogaGuiBootstrap


class BasePositronBootstrap(TogaGuiBootstrap):
display_name_annotation = "does not support Web deployment"

def validate_url_path(self, value: str) -> bool:
"""Validate that the value is a valid path."""
if not value.startswith("/"):
raise ValueError("Path must start with a /")
return True

def validate_content_path(self, value: str) -> bool:
"""Validate that the value is a directory."""
if value:
if value.startswith("https://"):
raise ValueError("Positron can't scrape existing web sites (...yet!)")
elif not Path(value).resolve().is_dir():
raise ValueError(f"Path {Path(value).resolve()} does not exist")
return True

def templated_content(self, template_path, **context):
"""Render the template at the provided path.

If a {template_path}.tmpl exists, it will be expanded with the provided
context. Otherwise, the content will be used as-is.
"""
full_template_path = template_path.with_suffix(template_path.suffix + ".tmpl")
if full_template_path.exists():
template = full_template_path.read_text(encoding="utf-8")
return template.format(**context)
else:
return template_path.read_text(encoding="utf-8")

def templated_file(self, template_path, output_path, **context):
"""Render the template at the provided path with the provided context, saving
the result in `output_path`.
"""
self.console.debug(f"Writing {template_path.name}")
(output_path / template_path.name).write_text(
self.templated_content(template_path, **context),
encoding="utf-8",
)

def select_content_path(self, override_content_path):
"""Ask the user for a path to existing web content to use in the app."""
self.content_path = self.console.text_question(
intro=(
"Where can Briefcase find the web content for the Positron app?\n"
"\n"
"The value should be a path to a directory; the contents of that "
"directory will be copied into the Positron app, and form the root "
"folder of the served content. If you don't provide a path, default "
"content will be provided."
),
description="Path to web content",
default="",
validator=self.validate_content_path,
override_value=override_content_path,
)

def install_static_content(self, web_root_path):
if self.content_path.startswith(("http://", "https://")):
raise RuntimeError("Can't clone web content (...yet!)")
elif self.content_path:
# Copy an existing content path
with self.console.wait_bar("Copying web content..."):
shutil.copytree(
Path(self.content_path).resolve(),
web_root_path,
dirs_exist_ok=True,
)

def pyproject_table_web(self):
return """\
supported = false
"""
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,33 @@
from pathlib import Path
from typing import Any

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

TEMPLATE_PATH = Path(__file__).parent / "templates"

def validate_path(value: str) -> bool:
"""Validate that the value is a valid path."""
if not value.startswith("/"):
raise ValueError("Path must start with a /")
return True


def templated_content(template_name, **context):
"""Render a template for `template.name` with the provided context."""
template = (
Path(__file__).parent / f"django_templates/{template_name}.tmpl"
).read_text(encoding="utf-8")
return template.format(**context)


def templated_file(template_name, output_path, **context):
"""Render a template for `template.name` with the provided context, saving the
result in `output_path`."""
(output_path / template_name).write_text(
templated_content(template_name, **context), encoding="utf-8"
)


class DjangoPositronBootstrap(TogaGuiBootstrap):
display_name_annotation = "does not support Web deployment"

class DjangoPositronBootstrap(BasePositronBootstrap):
def app_source(self):
return templated_content("app.py", initial_path=self.initial_path)
return self.templated_content(
TEMPLATE_PATH / "app.py",
initial_path=self.initial_path,
)

def pyproject_table_briefcase_app_extra_content(self):
return """
requires = [
"django~=5.1",
"django~=6.0",
]
test_requires = [
{% if cookiecutter.test_framework == "pytest" %}
"pytest",
{% endif %}
]
"""

def pyproject_table_web(self):
return """\
supported = false
"""

def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None:
Expand All @@ -66,7 +51,7 @@ def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | N
),
description="Initial path",
default="/admin/",
validator=validate_path,
validator=self.validate_url_path,
override_value=project_overrides.pop("initial_path", None),
)

Expand All @@ -76,17 +61,16 @@ def post_generate(self, base_path: Path):
app_path = base_path / "src" / self.context["module_name"]

# Top level files
self.console.debug("Writing manage.py")
templated_file(
"manage.py",
self.templated_file(
TEMPLATE_PATH / "manage.py",
app_path.parent,
module_name=self.context["module_name"],
)

# App files
for template_name in ["settings.py", "urls.py", "wsgi.py"]:
self.console.debug(f"Writing {template_name}")
templated_file(
template_name,
self.templated_file(
TEMPLATE_PATH / template_name,
app_path,
module_name=self.context["module_name"],
)
Empty file.
111 changes: 111 additions & 0 deletions positron/src/positron/fastapi/bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from __future__ import annotations

import sys
from pathlib import Path
from typing import Any

if sys.version_info >= (3, 11): # pragma: no-cover-if-lt-py311
import tomllib
else: # pragma: no-cover-if-gte-py311
import tomli as tomllib

from ..base import BasePositronBootstrap

TEMPLATE_PATH = Path(__file__).parent / "templates"


class FastAPIPositronBootstrap(BasePositronBootstrap):
display_name_annotation = "does not support Web deployment"

@property
def template_path(self):
return Path(__file__).parent / "templates"

def app_start_source(self):
return self.templated_content(
TEMPLATE_PATH / "__main__.py",
initial_path=self.initial_path,
)

def app_source(self):
return self.templated_content(
TEMPLATE_PATH / "app.py",
initial_path=self.initial_path,
)

def pyproject_table_briefcase_app_extra_content(self):
return """
requires = [
# 0.125.0 is the last version of FastAPI that supports Pydantic < 2.0
# This is a blocker on iOS/Android until wheels for pydantic-core are available.
"fastAPI == 0.125.0",
"uvicorn == 0.40.0",
]
test_requires = [
{% if cookiecutter.test_framework == "pytest" %}
"pytest",
{% endif %}
]
"""

def pyproject_table_iOS(self):
iOS_table = tomllib.loads(super().pyproject_table_iOS())
base_requires = "\n".join(f' "{req}",' for req in iOS_table["requires"])
return f"""\
requires = [
{base_requires}
"pydantic < 2",
]
"""

def pyproject_table_android(self):
android_table = tomllib.loads(super().pyproject_table_android())
base_requires = "\n".join(f' "{req}",' for req in android_table["requires"])
return f"""\
requires = [
{base_requires}
"pydantic < 2",
]

base_theme = "Theme.MaterialComponents.Light.DarkActionBar"

build_gradle_dependencies = [
"com.google.android.material:material:1.13.0",
]
"""

def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None:
"""Runs prior to other plugin hooks to provide additional context.

This can be used to prompt the user with additional questions or run arbitrary
logic to supplement the context provided to cookiecutter.

:param project_overrides: Any overrides provided by the user as -Q options that
haven't been consumed by the standard bootstrap wizard questions.
"""
self.initial_path = self.console.text_question(
intro=(
"What path do you want to use as the initial URL for the app's "
"webview?\n"
"\n"
"The value should start with a '/', but can be any path that your "
"FastAPI site will serve."
),
description="Initial path",
default="/",
validator=self.validate_url_path,
override_value=project_overrides.pop("initial_path", None),
)

return {}

def post_generate(self, base_path: Path):
app_path = base_path / "src" / self.context["module_name"]

# App files
for template_name in ["server.py"]:
self.templated_file(
TEMPLATE_PATH / template_name,
app_path,
module_name=self.context["module_name"],
)
16 changes: 16 additions & 0 deletions positron/src/positron/fastapi/templates/__main__.py.tmpl
Original file line number Diff line number Diff line change
@@ -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()
Loading