Repository: beeware/briefcase

Issue:
Create new project with Positron template (Django or Static)
### What is the problem or limitation you are having?

Right now creating the Positron app is very hands-on, need to start with the Toga template and then paste the example

### Describe the solution you'd like

Adding options when calling `briefcase new` to use the Django Positron or Static Positron template

### Describe alternatives you've considered

Not yet, please let me know if you do

### Additional context

_No response_


Discussion:
I definitely acknowledge "Generate a Positron app" as a use case; while I'm not *personally* a huge fan of "web interface as app GUI" as an approach, I can't deny it's both popular, and an easy way to get into app development (for those that already have web skills).

However, I have a some concerns about this pair of PRs as an approach.

My first concern is the overlap with #392. Introducing this feature has required modifying both Briefcase and the briefcase template; ideally, we'd only need to change the template; and, if the questions were being generated from the template, any third party could manage their own Briefcase template without needing to have changes to a static list in Briefcase itself. 

My second concern is the complexity of the bootstrap. The template is now injecting a non-trivial amount of code - and web server code at that. This is a potential attack surface for the app; if someone were to identify a vulnerability in this bootstrap code, every Positron app would have that vulnerability - and we'd have no good way to effectively communicate a mitigation or fix. The existing GUI frameworks don't really have this surface because they're the minimal "display a window" app.

On that basis, I'd argue that `positron` should be a standalone Python package on PyPI (although the `positron` name is already taken on PyPI...), defining a subclass of `toga.App`. From Briefcase's perspective, Positron then becomes a "normal" GUI framework - the template only contributes the "create a bare bones window" code, where the user content for the window is configured by pointing at a Django project or static web code. If a vulnerability was found, we could publish a new version of the Positron package, and all users would get the fix.

My final concern is almost a synthesis of the first two. Ideally, BeeWare and Briefcase don't want to be in the game of picking "winners" when it comes to GUI frameworks, and being responsible maintaining support for every possible GUI framework. The maintenance burden of supporting Toga, PyGame, PPB, PySide2 and Pyside6 is already more than I'd prefer. 

Ideally, adding support for a new GUI framework shouldn't require any changes to Briefcase itself - Briefcase should define enough extension points so that a new GUI framework should be able to add *itself* to the list of available templates. 

How would this work? 

1. Briefcase would define a `briefcase.wizard.frameworks` entry point. 
2. Briefcase would define a base interface, and implementations of that interface for the "built in" GUI frameworks
3. A third-party package (like `positron`) could register against this entry point, and define it's own implementation (or implementations)
4. An end user wanting to use Positron would `pip install briefcase positron[django]`, installing positron in the local environment (and the Django dependency), and registering the Positron wizard entry point.
5. When `briefcase new` runs, it builds the list of GUI frameworks from the packages registered against the entry point, rather than a statically defined list. 

The "GUI framework" interface could provide customisation points to control the URL of the template (or possibly even refer to a file path that is packaged with the app as a resource); as well as potentially injecting additional questions into the Wizard after the GUI framework has been selected. It might not even be necessary to make the template URL customisable - if the base Briefcase template has enough extension points, it should be possible for a GUI framework implementation to define the packages that are needed as a Python list, and the bootstrap code as a multiline string, and have those injected into a common template.
 
This is somewhat akin to Django's approach to database backends - they provide a Sqlite, MySQL and Postgres backend as a matter of practicality, but they provide a robust extension interface so that Oracle, MSSQL, or any other database vendor could provide an ORM backend if they chose to do so.

In the extreme case, Briefcase could even entirely devolve GUI frameworks support to the GUI packages themselves. Briefcase itself would contain *no* support for GUI frameworks, but Toga would define how it is supported by Briefcase support interface.

Does that make sense? Any thoughts?
Sure, this sounds more like a future-proof approach. I like the idea of having a plugin and giving the flexibility for the community to develop more 3rd party GUI connections. 
One thought about the 3rd party plug-in, I think it will be great to have more community engagement (just like Django). I am on board with this great idea. Thank you 🙏🏻 



Patch:
diff --git a/changes/2114.feature.rst b/changes/2114.feature.rst
new file mode 100644
index 000000000..1b633c168
--- /dev/null
+++ b/changes/2114.feature.rst
@@ -0,0 +1,1 @@
+Project bootstraps now have access to the Briefcase logger, console, and the overrides specified with ``-Q`` options at the command line.
diff --git a/changes/2114.removal.rst b/changes/2114.removal.rst
new file mode 100644
index 000000000..87e47f972
--- /dev/null
+++ b/changes/2114.removal.rst
@@ -0,0 +1,1 @@
+The API for project bootstraps has been slightly modified. The constructor for a bootstrap must now accept a logger and console argument; the ``extra_context()`` method must now accept a ``project_overrides`` argument.
diff --git a/src/briefcase/bootstraps/base.py b/src/briefcase/bootstraps/base.py
index 2410798db..8df8899dc 100644
--- a/src/briefcase/bootstraps/base.py
+++ b/src/briefcase/bootstraps/base.py
@@ -3,6 +3,8 @@
 from abc import ABC
 from typing import Any, TypedDict
 
+from briefcase.console import Console, Log
+
 
 class AppContext(TypedDict):
     formal_name: str
@@ -51,15 +53,21 @@ class BaseGuiBootstrap(ABC):
     # is presented with the options to create a new project.
     display_name_annotation: str = ""
 
-    def __init__(self, context: AppContext):
+    def __init__(self, logger: Log, input: Console, context: AppContext):
+        self.logger = logger
+        self.input = input
+
         # context contains metadata about the app being created
         self.context = context
 
-    def extra_context(self) -> dict[str, Any] | None:
+    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.
         """
 
     def app_source(self) -> str | None:
diff --git a/src/briefcase/bootstraps/console.py b/src/briefcase/bootstraps/console.py
index 1b6c4b341..3d2f1079f 100644
--- a/src/briefcase/bootstraps/console.py
+++ b/src/briefcase/bootstraps/console.py
@@ -8,7 +8,7 @@
 class ConsoleBootstrap(BaseGuiBootstrap):
     display_name_annotation = "does not support iOS/Android/Web deployment"
 
-    def extra_context(self) -> dict[str, Any] | None:
+    def extra_context(self, project_overrides: dict[str, str]) -> dict[str, Any] | None:
         return {
             "console_app": True,
         }
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index 4f5ce70d1..669fd3f84 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -623,14 +623,20 @@ def build_gui_context(
         gui_context = {}
 
         if bootstrap_class is not None:
-            bootstrap: BaseGuiBootstrap = bootstrap_class(context=context)
+            bootstrap: BaseGuiBootstrap = bootstrap_class(
+                logger=self.logger,
+                input=self.input,
+                context=context,
+            )
 
             # Iterate over the Bootstrap interface to build the context.
             # Returning ``None`` is a special case that means the field should not be
             # included in the context and instead deferred to the template default.
 
             if hasattr(bootstrap, "extra_context"):
-                if (additional_context := bootstrap.extra_context()) is not None:
+                if (
+                    additional_context := bootstrap.extra_context(project_overrides)
+                ) is not None:
                     gui_context.update(additional_context)
 
             for context_field in bootstrap.fields:


Test patch:
diff --git a/tests/commands/new/test_build_context.py b/tests/commands/new/test_build_context.py
index 7e56bf56f..e0786b71c 100644
--- a/tests/commands/new/test_build_context.py
+++ b/tests/commands/new/test_build_context.py
@@ -34,7 +34,7 @@ def test_question_sequence_bootstrap_context(
     class GuiBootstrap:
         fields = []
 
-        def __init__(self, context):
+        def __init__(self, logger, input, context):
             nonlocal passed_context
             passed_context = context.copy()
 
@@ -825,7 +825,7 @@ def test_question_sequence_with_overrides(
     class GuiBootstrap:
         fields = []
 
-        def __init__(self, context):
+        def __init__(self, logger, input, context):
             pass
 
     monkeypatch.setattr(
@@ -886,7 +886,7 @@ def test_question_sequence_with_bad_license_override(
     class GuiBootstrap:
         fields = []
 
-        def __init__(self, context):
+        def __init__(self, logger, input, context):
             pass
 
     monkeypatch.setattr(
@@ -949,7 +949,7 @@ class GuiBootstrap:
         # requires() would cause an error
         fields = ["requires"]
 
-        def __init__(self, context):
+        def __init__(self, logger, input, context):
             pass
 
     monkeypatch.setattr(
@@ -1222,11 +1222,14 @@ def test_question_sequence_custom_bootstrap(
     class GuiBootstrap:
         fields = ["requires", "platform"]
 
-        def __init__(self, context):
+        def __init__(self, logger, input, context):
             pass
 
-        def extra_context(self):
-            return {"custom_context": "value"}
+        def extra_context(self, project_overrides):
+            return {
+                "custom_context": "value",
+                "custom_override": project_overrides.pop("custom_override", None),
+            }
 
         def requires(self):
             return "toga"
@@ -1259,7 +1262,7 @@ def platform(self):
         "5",  # Custom GUI bootstrap
     ]
 
-    context = new_command.build_context(project_overrides={})
+    context = new_command.build_context(project_overrides={"custom_override": "other"})
 
     assert context == dict(
         app_name="myapplication",
@@ -1276,6 +1279,7 @@ def platform(self):
         project_name="My Project",
         url="https://navy.mil/myapplication",
         custom_context="value",
+        custom_override="other",
         requires="toga",
         platform="bsd",
     )
@@ -1291,7 +1295,7 @@ def test_question_sequence_custom_bootstrap_without_additional_context(
     class GuiBootstrap:
         fields = ["requires", "platform"]
 
-        def __init__(self, context):
+        def __init__(self, logger, input, context):
             pass
 
         def requires(self):