Skip to content

Commit

Permalink
Add support for third-party GUI framework plugins
Browse files Browse the repository at this point in the history
  • Loading branch information
rmartin16 committed Nov 9, 2023
1 parent 4862d70 commit 84b6632
Show file tree
Hide file tree
Showing 13 changed files with 1,565 additions and 33 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Expand Up @@ -156,7 +156,8 @@ jobs:
verify-apps:
name: Build app
needs: unit-tests
uses: beeware/.github/.github/workflows/app-build-verify.yml@main
# uses: beeware/.github/.github/workflows/app-build-verify.yml@main
uses: rmartin16/.github-beeware/.github/workflows/app-build-verify.yml@gui-plugin-support
with:
# This *must* be the version of Python that is the system Python on the
# Ubuntu version used to run Linux tests. We use a fixed ubuntu-22.04
Expand All @@ -166,6 +167,8 @@ jobs:
python-version: "3.10"
runner-os: ${{ matrix.runner-os }}
framework: ${{ matrix.framework }}
briefcase-template-source: https://github.com/rmartin16/briefcase-template.git
briefcase-template-branch: gui-plugin-support
strategy:
fail-fast: false
matrix:
Expand Down
1 change: 1 addition & 0 deletions changes/1524.feature.rst
@@ -0,0 +1 @@
Creating new projects with arbitrary third-party GUI frameworks is now supported via plugins.
6 changes: 6 additions & 0 deletions setup.cfg
Expand Up @@ -112,6 +112,12 @@ where = src
[options.entry_points]
console_scripts =
briefcase = briefcase.__main__:main
briefcase.wizard.frameworks =
Toga = briefcase.plugins.frameworks.toga
PySide2 = briefcase.plugins.frameworks.pyside2
PySide6 = briefcase.plugins.frameworks.pyside6
PursuedPyBear = briefcase.plugins.frameworks.pursuedpybear
Pygame = briefcase.plugins.frameworks.pygame
briefcase.platforms =
android = briefcase.platforms.android
iOS = briefcase.platforms.iOS
Expand Down
67 changes: 52 additions & 15 deletions src/briefcase/commands/new.py
@@ -1,7 +1,10 @@
from __future__ import annotations

import contextlib
import re
import unicodedata
from email.utils import parseaddr
from typing import Optional
from types import ModuleType
from urllib.parse import urlparse

from packaging.version import Version
Expand All @@ -17,6 +20,11 @@

from .base import BaseCommand

try:
from importlib_metadata import entry_points
except ImportError: # pragma: no-cover-if-lt-py310
from importlib.metadata import entry_points


def titlecase(s):
"""Convert a string to titlecase.
Expand Down Expand Up @@ -60,6 +68,14 @@ def titlecase(s):
)


def get_frameworks() -> dict[str, ModuleType]:
"""Loads built-in and third party GUI frameworks."""
return {
entry_point.name: entry_point.load()
for entry_point in entry_points(group="briefcase.wizard.frameworks")
}


class NewCommand(BaseCommand):
cmd_line = "briefcase new"
command = "new"
Expand Down Expand Up @@ -401,21 +417,27 @@ def build_app_context(self):
],
)

frameworks = get_frameworks()
framework_choices = [
"Toga",
"PySide2 (does not support iOS/Android deployment)",
"PySide6 (does not support iOS/Android deployment)",
"PursuedPyBear (does not support iOS/Android deployment)",
"Pygame (does not support iOS/Android deployment)",
]
builtin_framework_names = [n.split(" ")[0] for n in framework_choices]
framework_choices += [
f for f in frameworks.keys() if f not in builtin_framework_names
] + ["None"]

gui_framework = self.input_select(
intro="""
What GUI toolkit do you want to use for this project?""",
variable="GUI framework",
options=[
"Toga",
"PySide2 (does not support iOS/Android deployment)",
"PySide6 (does not support iOS/Android deployment)",
"PursuedPyBear (does not support iOS/Android deployment)",
"Pygame (does not support iOS/Android deployment)",
"None",
],
options=framework_choices,
)

return {
context = {
"formal_name": formal_name,
"app_name": app_name,
"class_name": class_name,
Expand All @@ -427,13 +449,28 @@ def build_app_context(self):
"bundle": bundle,
"url": url,
"license": project_license,
"gui_framework": (gui_framework.split())[0],
}

plugin_context = {}
if gui_framework != "None":
try:
plugin = frameworks[gui_framework].plugin(context=context)
except KeyError:
plugin = frameworks[gui_framework.split(" ")[0]].plugin(context=context)
for context_field in plugin.fields:
with contextlib.suppress(AttributeError):
if (context_value := getattr(plugin, context_field)()) is not None:
plugin_context[context_field] = context_value

return {
**context,
**plugin_context,
}

def new_app(
self,
template: Optional[str] = None,
template_branch: Optional[str] = None,
template: str | None = None,
template_branch: str | None = None,
**options,
):
"""Ask questions to generate a new application, and generate a stub project from
Expand Down Expand Up @@ -520,8 +557,8 @@ def verify_tools(self):

def __call__(
self,
template: Optional[str] = None,
template_branch: Optional[str] = None,
template: str | None = None,
template_branch: str | None = None,
**options,
):
# Confirm host compatibility, and that all required tools are available.
Expand Down
Empty file.
8 changes: 8 additions & 0 deletions src/briefcase/plugins/frameworks/__init__.py
@@ -0,0 +1,8 @@
from briefcase.plugins.frameworks.base import BaseGuiPlugin # noqa: F401
from briefcase.plugins.frameworks.pursuedpybear import ( # noqa: F401
PursuedPyBearGuiPlugin,
)
from briefcase.plugins.frameworks.pygame import PygameGuiPlugin # noqa: F401
from briefcase.plugins.frameworks.pyside2 import PySide2GuiPlugin # noqa: F401
from briefcase.plugins.frameworks.pyside6 import PySide6GuiPlugin # noqa: F401
from briefcase.plugins.frameworks.toga import TogaGuiPlugin # noqa: F401
137 changes: 137 additions & 0 deletions src/briefcase/plugins/frameworks/base.py
@@ -0,0 +1,137 @@
from __future__ import annotations

from abc import ABC
from typing import Literal


class BaseGuiPlugin(ABC):
name: str
fields: list[str] = [
"app_source",
"start_app_source",
"requires",
"macos_requires",
"macos_universal_build",
"linux_requires",
"linux_system_debian_system_requires",
"linux_system_debian_system_runtime_requires",
"linux_system_rhel_system_requires",
"linux_system_rhel_system_runtime_requires",
"linux_system_suse_system_requires",
"linux_system_suse_system_runtime_requires",
"linux_system_arch_system_requires",
"linux_system_arch_system_runtime_requires",
"linux_appimage_manylinux",
"linux_appimage_system_requires",
"linux_appimage_linuxdeploy_plugins",
"linux_flatpak_runtime",
"linux_flatpak_runtime_version",
"linux_flatpak_sdk",
"windows_requires",
"ios_requires",
"ios_supported",
"android_requires",
"android_supported",
"web_requires",
"web_supported",
"web_style_framework",
]

def __init__(self, context: dict[str, str | int | bool]):
# context contains metadata about the app:
# formal_name
# app_name
# class_name
# module_name
# project_name
# description
# author
# author_email
# bundle
# url
# license
self.context = context

def app_source(self) -> str | None:
"""The Python source code for the project."""

def start_app_source(self) -> str | None:
"""The Python source code to start the app from __main__.py."""

def requires(self) -> str | None:
"""List of package requirements for all platforms."""

def macos_requires(self) -> str | None:
"""List of package requirements for macOS."""

def macos_universal_build(self) -> Literal["true", "false"] | None:
"""Whether to create a universal build for macOS."""

def linux_requires(self) -> str | None:
"""List of package requirements for Linux."""

def linux_system_debian_system_requires(self) -> str | None:
"""List of system package requirements to build the app."""

def linux_system_debian_system_runtime_requires(self) -> str | None:
"""List of system package requirements to run the app on Debian."""

def linux_system_rhel_system_requires(self) -> str | None:
"""List of system package requirements to build the app on RHEL."""

def linux_system_rhel_system_runtime_requires(self) -> str | None:
"""List of system package requirements to run the app on RHEL."""

def linux_system_suse_system_requires(self) -> str | None:
"""List of system package requirements to build the app on SUSE."""

def linux_system_suse_system_runtime_requires(self) -> str | None:
"""List of system package requirements to run the app on SUSE."""

def linux_system_arch_system_requires(self) -> str | None:
"""List of system package requirements to build the app on Arch."""

def linux_system_arch_system_runtime_requires(self) -> str | None:
"""List of system package requirements to run the app on Arch."""

def linux_appimage_manylinux(self) -> str | None:
"""The manylinux base, e.g. manylinux2014, to use to build the app."""

def linux_appimage_system_requires(self) -> str | None:
"""List of system package requirements to build the app in to an AppImage."""

def linux_appimage_linuxdeploy_plugins(self) -> str | None:
"""List of linuxdeploy plugins to use to build the app in to an AppImage."""

def linux_flatpak_runtime(self) -> str | None:
"""The Flatpak runtime, e.g. org.gnome.Platform, for the app."""

def linux_flatpak_runtime_version(self) -> str | None:
"""The Flatpak runtime version, e.g. 44, for the app."""

def linux_flatpak_sdk(self) -> str | None:
"""The Flatpak SDK, e.g. org.gnome.Sdk, for the app."""

def windows_requires(self) -> str | None:
"""List of package requirements for Windows."""

def ios_requires(self) -> str | None:
"""List of package requirements for iOS."""

def ios_supported(self) -> Literal["true", "false"] | None:
"""Whether the GUI framework supports iOS."""

def android_requires(self) -> str | None:
"""List of package requirements for Android."""

def android_supported(self) -> Literal["true", "false"] | None:
"""Whether the GUI framework supports Android."""

def web_requires(self) -> str | None:
"""List of package requirements for Web."""

def web_supported(self) -> Literal["true", "false"] | None:
"""Whether the GUI framework supports Web."""

def web_style_framework(self) -> str | None:
"""The style framework, e.g. Bootstrap or Shoelace, for web."""
71 changes: 71 additions & 0 deletions src/briefcase/plugins/frameworks/pursuedpybear.py
@@ -0,0 +1,71 @@
from briefcase.plugins.frameworks.base import BaseGuiPlugin


class PursuedPyBearGuiPlugin(BaseGuiPlugin):
name = "PursuedPyBear"

def app_source(self):
return """
import os
import sys
try:
from importlib import metadata as importlib_metadata
except ImportError:
# Backwards compatibility - importlib.metadata was added in Python 3.8
import importlib_metadata
import ppb
class {{ cookiecutter.class_name }}(ppb.Scene):
def __init__(self, **props):
super().__init__(**props)
self.add(ppb.Sprite(
image=ppb.Image('{{ cookiecutter.module_name }}/resources/{{ cookiecutter.app_name }}.png'),
))
def main():
# Linux desktop environments use app's .desktop file to integrate the app
# to their application menus. The .desktop file of this app will include
# StartupWMClass key, set to app's formal name, which helps associate
# app's windows to its menu item.
#
# For association to work any windows of the app must have WMCLASS
# property set to match the value set in app's desktop file. For PPB this
# is set using environment variable.
# Find the name of the module that was used to start the app
app_module = sys.modules['__main__'].__package__
# Retrieve the app's metadata
metadata = importlib_metadata.metadata(app_module)
os.environ['SDL_VIDEO_X11_WMCLASS'] = metadata['Formal-Name']
ppb.run(
starting_scene={{ cookiecutter.class_name }},
title=metadata['Formal-Name'],
)
"""

def requires(self):
return """
"ppb~=1.1",
"""

def linux_appimage_manylinux(self):
return "manylinux2014"

def linux_flatpak_runtime(self):
return "org.freedesktop.Platform"

def linux_flatpak_runtime_version(self):
return "22.08"

def linux_flatpak_sdk(self):
return "org.freedesktop.Sdk"


plugin = PursuedPyBearGuiPlugin

0 comments on commit 84b6632

Please sign in to comment.