Skip to content

Commit

Permalink
Refactor GUI toolkit selection for potential improvement
Browse files Browse the repository at this point in the history
- Factor out the explicit enumeration of gui bootstrap options
- Add the concept of an annotation to the display name of a GUI
  bootstrap when it is presented to users for creating a project
  • Loading branch information
rmartin16 committed Nov 13, 2023
1 parent 766ede8 commit 3488cad
Showing 11 changed files with 144 additions and 186 deletions.
8 changes: 8 additions & 0 deletions src/briefcase/bootstraps/base.py
Original file line number Diff line number Diff line change
@@ -24,6 +24,10 @@ class AppContext(TypedDict):
class BaseGuiBootstrap(ABC):
"""Definition for a plugin that defines a new Briefcase app."""

# These are the field names that will be defined in the cookiecutter context.
# Any fields defined here must be implemented as methods that return a ``str``
# or ``None``. Returning ``None`` omits the field as a key in the context, thereby
# deferring the value for the field to the cookiecutter template.
fields: list[str] = [
"app_source",
"app_start_source",
@@ -44,6 +48,10 @@ class BaseGuiBootstrap(ABC):
"pyproject_extra_content",
]

# A short annotation that's appended to the name of the GUI toolkit when the user
# is presented with the options to create a new project.
display_name_annotation: str = ""

def __init__(self, context: AppContext):
# context contains metadata about the app being created
self.context = context
2 changes: 2 additions & 0 deletions src/briefcase/bootstraps/pursuedpybear.py
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@


class PursuedPyBearGuiBootstrap(BaseGuiBootstrap):
display_name_annotation = "does not support iOS/Android deployment"

def app_source(self):
return """\
import importlib.metadata
2 changes: 2 additions & 0 deletions src/briefcase/bootstraps/pygame.py
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@


class PygameGuiBootstrap(BaseGuiBootstrap):
display_name_annotation = "does not support iOS/Android deployment"

def app_source(self):
return """\
import importlib.metadata
2 changes: 2 additions & 0 deletions src/briefcase/bootstraps/pyside2.py
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@


class PySide2GuiBootstrap(BaseGuiBootstrap):
display_name_annotation = "does not support iOS/Android deployment"

def app_source(self):
return """\
import importlib.metadata
2 changes: 2 additions & 0 deletions src/briefcase/bootstraps/pyside6.py
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@


class PySide6GuiBootstrap(BaseGuiBootstrap):
display_name_annotation = "does not support iOS/Android deployment"

def app_source(self):
return """\
import importlib.metadata
131 changes: 60 additions & 71 deletions src/briefcase/commands/new.py
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@
import re
import sys
import unicodedata
from collections import OrderedDict
from email.utils import parseaddr
from urllib.parse import urlparse

@@ -16,12 +17,13 @@
from importlib_metadata import entry_points

import briefcase
from briefcase.bootstraps.base import BaseGuiBootstrap
from briefcase.bootstraps import BaseGuiBootstrap
from briefcase.config import (
is_valid_app_name,
is_valid_bundle_identifier,
make_class_name,
)
from briefcase.console import select_option
from briefcase.exceptions import BriefcaseCommandError, TemplateUnsupportedVersion
from briefcase.integrations.git import Git

@@ -273,39 +275,6 @@ def input_text(self, intro, variable, default, validator=None):
self.input.prompt()
self.input.prompt(f"Invalid value; {e}")

def input_select(self, intro, variable, options):
"""Select one from a list of options.
The first option is assumed to be the default.
:param intro: An introductory paragraph explaining the question being asked.
:param variable: The variable to display to the user.
:param options: A list of text strings, describing the available options.
:returns: The string content of the selected option.
"""
self.input.prompt(intro)

index_choices = [str(key) for key in range(1, len(options) + 1)]
display_options = "\n".join(
f" [{index}] {option}" for index, option in zip(index_choices, options)
)
error_message = (
f"Invalid selection; please enter a number between 1 and {len(options)}"
)
prompt = f"""
Select one of the following:
{display_options}
{titlecase(variable)} [1]: """
selection = self.input.selection_input(
prompt=prompt,
choices=index_choices,
default="1",
error_message=error_message,
)
return options[int(selection) - 1]

def build_context(
self,
template_source: str,
@@ -421,21 +390,25 @@ def build_app_context(self) -> dict[str, str]:
validator=self.validate_url,
)

project_license = self.input_select(
intro="""
What license do you want to use for this project's code?""",
variable="project license",
options=[
"BSD license",
"MIT license",
"Apache Software License",
"GNU General Public License v2 (GPLv2)",
"GNU General Public License v2 or later (GPLv2+)",
"GNU General Public License v3 (GPLv3)",
"GNU General Public License v3 or later (GPLv3+)",
"Proprietary",
"Other",
],
self.input.prompt()
self.input.prompt("What license do you want to use for this project's code?")
self.input.prompt()
licenses = [
"BSD license",
"MIT license",
"Apache Software License",
"GNU General Public License v2 (GPLv2)",
"GNU General Public License v2 or later (GPLv2+)",
"GNU General Public License v3 (GPLv3)",
"GNU General Public License v3 or later (GPLv3+)",
"Proprietary",
"Other",
]
project_license = select_option(
prompt="Project License [1]: ",
input=self.input,
default="1",
options=list(zip(licenses, licenses)),
)

return {
@@ -455,33 +428,20 @@ def build_app_context(self) -> dict[str, str]:
def build_gui_context(self, context: dict[str, str]) -> dict[str, str]:
"""Build context specific to the GUI toolkit."""
bootstraps = get_gui_bootstraps()
bootstrap_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_bootstraps = [c.split(" ")[0] for c in bootstrap_choices]
# add choices for bootstraps that aren't built-in to Briefcase
bootstrap_choices.extend(set(bootstraps) - set(builtin_bootstraps))
bootstrap_choices.append("None")

bootstrap_choice = self.input_select(
intro="""
What GUI toolkit do you want to use for this project?""",
variable="GUI framework",
options=bootstrap_choices,
self.input.prompt()
self.input.prompt("What GUI toolkit do you want to use for this project?")
self.input.prompt()
bootstrap_class: type[BaseGuiBootstrap] = select_option(
prompt="GUI Framework [1]: ",
input=self.input,
default="1",
options=self._gui_bootstrap_choices(bootstraps),
)

gui_context = {}

if bootstrap_choice != "None":
try:
bootstrap_class = bootstraps[bootstrap_choice]
except KeyError:
bootstrap_class = bootstraps[bootstrap_choice.split(" ")[0]]

if bootstrap_class is not None:
bootstrap = bootstrap_class(context=context)

# Iterate over the Bootstrap interface to build the context.
@@ -498,6 +458,35 @@ def build_gui_context(self, context: dict[str, str]) -> dict[str, str]:

return gui_context

def _gui_bootstrap_choices(self, bootstraps):
"""Construct the list of available GUI bootstraps to display to the user."""
# Sort the options alphabetically first
ordered = OrderedDict(sorted(bootstraps.items()))

# Ensure the first 5 options are: Toga, PySide2, PySide6, PursuedPyBear, Pygame
ordered.move_to_end("Pygame", last=False)
ordered.move_to_end("PursuedPyBear", last=False)
ordered.move_to_end("PySide6", last=False)
ordered.move_to_end("PySide2", last=False)
ordered.move_to_end("Toga", last=False)

# Option None should always be last
ordered["None"] = None
ordered.move_to_end("None")

# Construct the bootstrap options as they should be presented to users.
# The name of the bootstrap is its registered entry point name. Along with the
# bootstrap's name, a short message important to a user's choice can be shown
# also; for instance, several show "does not support iOS/Android deployment".
bootstrap_choices = []
max_len = max(map(len, ordered))
for name, klass in ordered.items():
if annotation := getattr(klass, "display_name_annotation", ""):
annotation = f"{' ' * (max_len - len(name))} ({annotation})"
bootstrap_choices.append((klass, f"{name}{annotation or ''}"))

return bootstrap_choices

def new_app(
self,
template: str | None = None,
8 changes: 6 additions & 2 deletions src/briefcase/console.py
Original file line number Diff line number Diff line change
@@ -666,7 +666,7 @@ def __call__(self, prompt, *, markup=False):
raise KeyboardInterrupt


def select_option(options, input, prompt="> ", error="Invalid selection"):
def select_option(options, input, prompt="> ", error="Invalid selection", default=None):
"""Prompt the user for a choice from a list of options.
The options are provided as a dictionary; the values are the human-readable options,
@@ -683,6 +683,8 @@ def select_option(options, input, prompt="> ", error="Invalid selection"):
the user's input can be easily mocked during testing.
:param prompt: The prompt to display to the user.
:param error: The error message to display when the user provides invalid input.
:param default: The default option for empty user input. The options for the user
start numbering at 1; so, to default to the first item, this should be "1".
:returns: The key corresponding to the user's chosen option.
"""
if isinstance(options, dict):
@@ -697,5 +699,7 @@ def select_option(options, input, prompt="> ", error="Invalid selection"):
input.prompt()

choices = [str(index) for index in range(1, len(ordered) + 1)]
index = input.selection_input(prompt=prompt, choices=choices, error_message=error)
index = input.selection_input(
prompt=prompt, choices=choices, error_message=error, default=default
)
return ordered[int(index) - 1][0]
3 changes: 2 additions & 1 deletion tests/bootstraps/test_base.py
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ def test_base_bootstrap_fields():
for field in [
attr
for attr in BaseGuiBootstrap.__dict__
if not attr.startswith("_") and attr not in {"fields", "extra_context"}
if not attr.startswith("_")
and attr not in {"fields", "display_name_annotation", "extra_context"}
]:
assert field in BaseGuiBootstrap.fields
40 changes: 37 additions & 3 deletions tests/commands/new/test_build_context.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
from unittest.mock import MagicMock

import pytest
from packaging.version import Version

import briefcase.commands.new
from briefcase.bootstraps import (
PursuedPyBearGuiBootstrap,
PygameGuiBootstrap,
PySide2GuiBootstrap,
PySide6GuiBootstrap,
TogaGuiBootstrap,
)


@pytest.fixture
def mock_builtin_bootstraps():
return {
"Toga": TogaGuiBootstrap,
"PySide2": PySide2GuiBootstrap,
"PySide6": PySide6GuiBootstrap,
"PursuedPyBear": PursuedPyBearGuiBootstrap,
"Pygame": PygameGuiBootstrap,
}


def test_question_sequence_toga(new_command):
@@ -1191,7 +1210,11 @@ def main():
)


def test_question_sequence_custom_bootstrap(new_command, monkeypatch):
def test_question_sequence_custom_bootstrap(
new_command,
mock_builtin_bootstraps,
monkeypatch,
):
"""Questions are asked, a context is constructed."""

class GuiBootstrap:
@@ -1212,7 +1235,12 @@ def platform(self):
monkeypatch.setattr(
briefcase.commands.new,
"get_gui_bootstraps",
MagicMock(return_value={"Custom GUI": GuiBootstrap}),
MagicMock(
return_value=dict(
**mock_builtin_bootstraps,
**{"Custom GUI": GuiBootstrap},
),
),
)

# Prime answers for all the questions.
@@ -1258,6 +1286,7 @@ def platform(self):

def test_question_sequence_custom_bootstrap_without_additional_context(
new_command,
mock_builtin_bootstraps,
monkeypatch,
):
"""Questions are asked, a context is constructed."""
@@ -1277,7 +1306,12 @@ def platform(self):
monkeypatch.setattr(
briefcase.commands.new,
"get_gui_bootstraps",
MagicMock(return_value={"Custom GUI": GuiBootstrap}),
MagicMock(
return_value=dict(
**mock_builtin_bootstraps,
**{"Custom GUI": GuiBootstrap},
),
),
)

# Prime answers for all the questions.
Loading

0 comments on commit 3488cad

Please sign in to comment.