diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dabd58127..67c64fda26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,7 @@ jobs: - "gtk" - "iOS" - "toga" + - "positron" - "travertino" - "textual" - "web" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a713e53b4b..eb3dc57659 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,6 +23,7 @@ jobs: - "toga_demo" - "toga_dummy" - "toga_gtk" + - "toga_positron" - "toga_textual" - "toga_iOS" - "toga_web" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff8cba2b8e..8b78da3d47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,6 +67,7 @@ jobs: - "toga_dummy" - "toga_gtk" - "toga_iOS" + - "toga_positron" - "toga_textual" - "toga_web" - "toga_winforms" diff --git a/changes/3114.feature.rst b/changes/3114.feature.rst new file mode 100644 index 0000000000..f52e5cef33 --- /dev/null +++ b/changes/3114.feature.rst @@ -0,0 +1 @@ +A Briefcase bootstrap for generating Positron apps (i.e., apps that are a web view in a native wrapper) was added. diff --git a/examples/positron-django/README.rst b/examples/positron-django/README.rst index f6cfbd5dc2..689410d382 100644 --- a/examples/positron-django/README.rst +++ b/examples/positron-django/README.rst @@ -18,7 +18,7 @@ To set up a development environment:: To run Django management commands:: - PYTHONPATH=src python src/webapp/manage.py + (venv) PYTHONPATH=src python src/manage.py To run in development mode:: @@ -27,3 +27,21 @@ To run in development mode:: To run as a packaged app:: (venv) $ briefcase run + +The Django app will run on a SQLite3 database, stored in the user's data directory (the +location of this directory is platform specific). This database file will be created if +it doesn't exist, and migrations will be run on every app start. + +If you need to start the database with some initial content (e.g., an initial user +login) you can use ``manage.py`` to create an initial database file. If there is a +``db.sqlite3`` in the ``src/positron/resources`` folder when the app starts, and the +user doesn't already have a ``db.sqlit3`` file in their app data folder, the initial +database file will be copied into the user's data folder as a starting point. + +To create an initial database, use ``manage.py`` - e.g.,: + + (venv) PYTHONPATH=src python src/manage.py migrate + (venv) PYTHONPATH=src python src/manage.py createsuperuser + +This will create an initial ``db.sqlite3`` file with a superuser account. All users of +the app will have this superuser account in their database. diff --git a/examples/positron-django/pyproject.toml b/examples/positron-django/pyproject.toml index 351bc522b4..5c27ab7dff 100644 --- a/examples/positron-django/pyproject.toml +++ b/examples/positron-django/pyproject.toml @@ -14,10 +14,10 @@ author_email = "tiberius@beeware.org" formal_name = "Positron" description = "Electron, but in Python" icon = "src/positron/resources/positron" -sources = ["src/positron", "src/webapp"] +sources = ["src/positron"] requires = [ "../../core", - "django~=4.1", + "django~=5.1", ] diff --git a/examples/positron-django/src/webapp/manage.py b/examples/positron-django/src/manage.py similarity index 89% rename from examples/positron-django/src/webapp/manage.py rename to examples/positron-django/src/manage.py index f6d0dcf0f9..f3275499e4 100755 --- a/examples/positron-django/src/webapp/manage.py +++ b/examples/positron-django/src/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "webapp.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "positron.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/examples/positron-django/src/positron/app.py b/examples/positron-django/src/positron/app.py index 8288d6dc26..4b32c16efc 100644 --- a/examples/positron-django/src/positron/app.py +++ b/examples/positron-django/src/positron/app.py @@ -1,9 +1,12 @@ +import asyncio import os +import shutil import socketserver -from threading import Event, Thread +from threading import Thread from wsgiref.simple_server import WSGIServer import django +from django.core import management as django_manage from django.core.handlers.wsgi import WSGIHandler from django.core.servers.basehttp import WSGIRequestHandler @@ -16,19 +19,36 @@ class ThreadedWSGIServer(socketserver.ThreadingMixIn, WSGIServer): class Positron(toga.App): def web_server(self): + print("Configuring settings...") + os.environ["DJANGO_SETTINGS_MODULE"] = "positron.settings" + django.setup(set_prefix=False) + + self.paths.data.mkdir(exist_ok=True) + user_db = self.paths.data / "db.sqlite3" + if user_db.exists(): + print("User already has a database.") + else: + template_db = self.paths.app / "resources" / "db.sqlite3" + if template_db.exists(): + print("Copying initial database...") + shutil.copy(template_db, user_db) + else: + print("No initial database.") + + print("Applying database migrations...") + django_manage.call_command("migrate") + print("Starting server...") # Use port 0 to let the server select an available port. self._httpd = ThreadedWSGIServer(("127.0.0.1", 0), WSGIRequestHandler) self._httpd.daemon_threads = True - os.environ["DJANGO_SETTINGS_MODULE"] = "webapp.settings" - django.setup(set_prefix=False) wsgi_handler = WSGIHandler() self._httpd.set_app(wsgi_handler) # The server is now listening, but connections will block until # serve_forever is run. - self.server_exists.set() + self.loop.call_soon_threadsafe(self.server_exists.set_result, "ready") self._httpd.serve_forever() def cleanup(self, app, **kwargs): @@ -37,7 +57,7 @@ def cleanup(self, app, **kwargs): return True def startup(self): - self.server_exists = Event() + self.server_exists = asyncio.Future() self.web_view = toga.WebView() @@ -46,12 +66,15 @@ def startup(self): self.on_exit = self.cleanup - self.server_exists.wait() - host, port = self._httpd.socket.getsockname() - self.web_view.url = f"http://{host}:{port}/" - self.main_window = toga.MainWindow() self.main_window.content = self.web_view + + async def on_running(self): + await self.server_exists + + host, port = self._httpd.socket.getsockname() + self.web_view.url = f"http://{host}:{port}/admin" + self.main_window.show() diff --git a/examples/positron-django/src/webapp/settings.py b/examples/positron-django/src/positron/settings.py similarity index 59% rename from examples/positron-django/src/webapp/settings.py rename to examples/positron-django/src/positron/settings.py index 52a6fd56c2..89ed2516d1 100644 --- a/examples/positron-django/src/webapp/settings.py +++ b/examples/positron-django/src/positron/settings.py @@ -1,27 +1,25 @@ -"""Django settings for webapp project. +""" +Django settings for positron project. -Generated by 'django-admin startproject' using Django 4.1.1. +Generated by "django-admin startproject" using Django 5.1.5. -For more information on this file, see: -- https://docs.djangoproject.com/en/4.1/topics/settings/ +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ -For the full list of settings and their values, see: -- https://docs.djangoproject.com/en/4.1/ref/settings/ +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ """ from pathlib import Path -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - +from toga import App as TogaApp -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ +BASE_PATH = Path(__file__).parent / "resources" -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-mcl=_9h9=1h)*%pbt8%*!n724ik0@v25b-=s0*v0bazgrnepyl" - -# SECURITY WARNING: don't run with debug turned on in production! +# A Positron app is only ever serving to itself, so a lot of the usual advice about +# Django best practices in production don't apply. The secret key doesn't need to be +# *that* secret; and running in debug mode (with staticfiles) is fine. +SECRET_KEY = "django-insecure-%vgal2@#0@feqe3jz@1d+f95c*@)2f9n^v9@#%&po5+ct7plwz" DEBUG = True ALLOWED_HOSTS = [] @@ -48,7 +46,7 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = "webapp.urls" +ROOT_URLCONF = "positron.urls" TEMPLATES = [ { @@ -66,29 +64,25 @@ }, ] -WSGI_APPLICATION = "webapp.wsgi.application" +WSGI_APPLICATION = "positron.wsgi.application" # Database -# https://docs.djangoproject.com/en/4.1/ref/settings/#databases +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "NAME": (TogaApp.app.paths.data if TogaApp.app else BASE_PATH) / "db.sqlite3", } } - # Password validation -# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - "NAME": ( - "django.contrib.auth.password_validation." - "UserAttributeSimilarityValidator" - ), + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # noqa: E501 }, { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", @@ -103,7 +97,7 @@ # Internationalization -# https://docs.djangoproject.com/en/4.1/topics/i18n/ +# https://docs.djangoproject.com/en/5.1/topics/i18n/ LANGUAGE_CODE = "en-us" @@ -115,11 +109,12 @@ # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.1/howto/static-files/ +# https://docs.djangoproject.com/en/5.1/howto/static-files/ STATIC_URL = "static/" +STATIC_ROOT = BASE_PATH / "static" # Default primary key field type -# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/examples/positron-django/src/positron/urls.py b/examples/positron-django/src/positron/urls.py new file mode 100644 index 0000000000..28203b3cdb --- /dev/null +++ b/examples/positron-django/src/positron/urls.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from django.contrib.staticfiles import views as staticfiles +from django.urls import path, re_path + +urlpatterns = [ + path("admin/", admin.site.urls), + re_path(r"^static/(?P.*)$", staticfiles.serve), +] diff --git a/examples/positron-django/src/webapp/wsgi.py b/examples/positron-django/src/positron/wsgi.py similarity index 57% rename from examples/positron-django/src/webapp/wsgi.py rename to examples/positron-django/src/positron/wsgi.py index 1a520e398b..bbf7b5e4f1 100644 --- a/examples/positron-django/src/webapp/wsgi.py +++ b/examples/positron-django/src/positron/wsgi.py @@ -1,15 +1,16 @@ -"""WSGI config for mysite project. +""" +WSGI config for positron project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see -https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "positron.settings") application = get_wsgi_application() diff --git a/examples/positron-django/src/webapp/asgi.py b/examples/positron-django/src/webapp/asgi.py deleted file mode 100644 index 8335ed4bf0..0000000000 --- a/examples/positron-django/src/webapp/asgi.py +++ /dev/null @@ -1,15 +0,0 @@ -"""ASGI config for mysite project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") - -application = get_asgi_application() diff --git a/positron/README.md b/positron/README.md new file mode 100644 index 0000000000..f76e763a5d --- /dev/null +++ b/positron/README.md @@ -0,0 +1,114 @@ +# toga-positron + +A [Briefcase](https://github.com/beeware/briefcase) bootstrap for setting up +[Toga](https://github.com/beeware/toga) apps whose GUI generated by displaying web +content. (i.e., Electron-like apps... but more positive, because they're using Python!) + +## Usage + +Create a fresh virtual environment, then install `toga-positron`. This will install +Briefcase, plus the Positron bootstrap. You can then use the Briefcase wizard to create +a new Toga app: + + $ python -m venv venv + $ source venv/bin/activate + (venv) $ pip install toga-positron + (venv) $ briefcase new + +This will ask you a number of questions about the app you want to generate, such as the +app's name, the authors name, and the project license. You'll then be asked which GUI +framework you want to use: + + -- GUI framework ------------------------------------------------------------- + + What GUI toolkit do you want to use for this project? + + Additional GUI bootstraps are available from the community. + + Check them out at https://beeware.org/bee/briefcase-bootstraps + + 1) Toga + 2) PySide6 (does not support iOS/Android/Web deployment) + 3) Pygame (does not support iOS/Android/Web deployment) + 4) Console (does not support iOS/Android/Web deployment) + 5) Toga Positron (Django server) (does not support Web deployment) + 6) Toga Positron (Site-specific browser) (does not support Web deployment) + 7) Toga Positron (Static server) (does not support Web deployment) + 8) None + + GUI framework [1]: + +This provides 3 options for a Toga Positron-based app: + +### Django server + +An app that runs a Django web server in a background thread, and points the app's web +browser at the URL for that server. Some additional files (such as `urls.py` and +`settings.py`) will be generated by the app template. + +The Django site that is generated is essentially identical to the default Django project +created by `startproject`, with some minor modifications to set app-specific paths and +ensure that static files will be served. + +If you select this option, you will be asked for the initial path that you want to +display in the app window. The default value is `/admin/`, which will cause the Django +Admin login page (i.e., `http://127.0.0.1/admin/`) to be the initial URL loaded by the +app, but you can choose any other URL you want (including `/` to serve the root URL +of the Django site). + +To run Django management commands, use:: + + (venv) PYTHONPATH=src python src/manage.py + +The Django app will run on a SQLite3 database, stored in the user's data directory (the +location of this directory is platform specific). This database file will be created if +it doesn't exist, and migrations will be run on every app start. + +If you need to start the database with some initial content (e.g., an initial user +login) you can use `manage.py` to create an initial database file. If there is a +`db.sqlite3` in the `src//resources` folder when the app starts, and the +user doesn't already have a `db.sqlite3` file in their app data folder, the initial +database file will be copied into the user's data folder as a starting point. + +To create an initial database, use `manage.py` - e.g.,: + + (venv) PYTHONPATH=src python src/manage.py migrate + (venv) PYTHONPATH=src python src/manage.py createsuperuser + +This will create an initial `db.sqlite3` file with a superuser account. All users +of the app will have this superuser account in their database. + +### Site-specific browser + +An app that behaves as a web browser that displays a single, externally served URL. + +If you select this option, you will be asked for the URL that you want to display in the +app window. For example, if you nominate `https://github.com` as the site URL, you will +generate an app that, when started, loads the Github homepage. + +### Static server + +An app that runs a simple HTTP server on a background thread, and points the app's web +browser at the URL for that server. This is suitable for serving simple static HTML and +CSS content. The app will serve the contents of the app's `resources` folder (i.e., +`src//resources`) as the root URL. A placeholder `index.html` file is generated +in the resources folder as part of the template. + +## Community + +Toga Positron is part of the [BeeWare suite](https://beeware.org). You can talk to the +community through: + +* [@beeware@fosstodon.org on Mastodon](https://fosstodon.org/@beeware) +* [Discord](https://beeware.org/bee/chat/) +* [The Toga Github Discussions forum](https://github.com/beeware/toga/discussions) + +We foster a welcoming and respectful community as described in our +[BeeWare Community Code of Conduct](https://beeware.org/community/behavior/) + +## Contributing + +If you experience problems with this backend, [log them on +GitHub](https://github.com/beeware/toga/issues). If you want to contribute code, please +[fork the code](https://github.com/beeware/toga) and [submit a pull +request](https://github.com/beeware/toga/pulls). diff --git a/positron/pyproject.toml b/positron/pyproject.toml new file mode 100644 index 0000000000..4b359ffe39 --- /dev/null +++ b/positron/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = [ + # keep versions in sync with ../pyproject.toml + "setuptools==75.3.0", + "setuptools_scm==8.1.0", + "setuptools_dynamic_dependencies @ git+https://github.com/beeware/setuptools_dynamic_dependencies", +] +build-backend = "setuptools.build_meta" + +[project] +name = "toga-positron" +description = "A Briefcase plugin for generating Positron apps." +readme = "README.md" +license.text = "New BSD" +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" + +[tool.setuptools_scm] +root = "../" diff --git a/examples/positron-django/src/webapp/__init__.py b/positron/src/positron/__init__.py similarity index 100% rename from examples/positron-django/src/webapp/__init__.py rename to positron/src/positron/__init__.py diff --git a/positron/src/positron/django.py b/positron/src/positron/django.py new file mode 100644 index 0000000000..5ddca27537 --- /dev/null +++ b/positron/src/positron/django.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from briefcase.bootstraps import TogaGuiBootstrap + + +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" + + def app_source(self): + return templated_content("app.py", initial_path=self.initial_path) + + def pyproject_table_briefcase_app_extra_content(self): + return """ +requires = [ + "django~=5.1", +] +test_requires = [ +{% if cookiecutter.test_framework == "pytest" %} + "pytest", +{% endif %} +] +""" + + 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 " + "Django site will serve." + ), + description="Initial path", + default="/admin/", + validator=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"] + + # Top level files + self.console.debug("Writing manage.py") + 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( + template_name, + app_path, + module_name=self.context["module_name"], + ) diff --git a/positron/src/positron/django_templates/app.py.tmpl b/positron/src/positron/django_templates/app.py.tmpl new file mode 100644 index 0000000000..63f8561d98 --- /dev/null +++ b/positron/src/positron/django_templates/app.py.tmpl @@ -0,0 +1,84 @@ +from __future__ import annotations + +import asyncio +import os +import shutil +import socketserver +from threading import Thread +from wsgiref.simple_server import WSGIServer + +import django +from django.core import management as django_manage +from django.core.handlers.wsgi import WSGIHandler +from django.core.servers.basehttp import WSGIRequestHandler + +import toga + + +class ThreadedWSGIServer(socketserver.ThreadingMixIn, WSGIServer): + pass + + +class {{{{ cookiecutter.class_name }}}}(toga.App): + def web_server(self): + print("Configuring settings...") + os.environ["DJANGO_SETTINGS_MODULE"] = "{{{{ cookiecutter.module_name }}}}.settings" + django.setup(set_prefix=False) + + self.paths.data.mkdir(exist_ok=True) + user_db = self.paths.data / "db.sqlite3" + if user_db.exists(): + print("User already has a database.") + else: + template_db = self.paths.app / "resources" / "db.sqlite3" + if template_db.exists(): + print("Copying initial database...") + shutil.copy(template_db, user_db) + else: + print("No initial database.") + + print("Applying database migrations...") + django_manage.call_command("migrate") + + print("Starting server...") + # Use port 0 to let the server select an available port. + self._httpd = ThreadedWSGIServer(("127.0.0.1", 0), WSGIRequestHandler) + self._httpd.daemon_threads = True + + wsgi_handler = WSGIHandler() + self._httpd.set_app(wsgi_handler) + + # The server is now listening, but connections will block until + # serve_forever is run. + self.loop.call_soon_threadsafe(self.server_exists.set_result, "ready") + self._httpd.serve_forever() + + def cleanup(self, app, **kwargs): + print("Shutting down...") + self._httpd.shutdown() + return True + + def startup(self): + self.server_exists = asyncio.Future() + + self.web_view = toga.WebView() + + self.server_thread = Thread(target=self.web_server) + self.server_thread.start() + + self.on_exit = self.cleanup + + self.main_window = toga.MainWindow() + self.main_window.content = self.web_view + + async def on_running(self): + await self.server_exists + + host, port = self._httpd.socket.getsockname() + self.web_view.url = f"http://{{host}}:{{port}}{initial_path}" + + self.main_window.show() + + +def main(): + return {{{{ cookiecutter.class_name }}}}() diff --git a/positron/src/positron/django_templates/manage.py.tmpl b/positron/src/positron/django_templates/manage.py.tmpl new file mode 100644 index 0000000000..9a95520e2d --- /dev/null +++ b/positron/src/positron/django_templates/manage.py.tmpl @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{module_name}.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/positron/src/positron/django_templates/settings.py.tmpl b/positron/src/positron/django_templates/settings.py.tmpl new file mode 100644 index 0000000000..50a2e21479 --- /dev/null +++ b/positron/src/positron/django_templates/settings.py.tmpl @@ -0,0 +1,120 @@ +""" +Django settings for a Positron project. + +Generated by "django-admin startproject" using Django 5.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from pathlib import Path + +from toga import App as TogaApp + +BASE_PATH = Path(__file__).parent / "resources" + +# A Positron app is only ever serving to itself, so a lot of the usual advice about +# Django best practices in production don't apply. The secret key doesn't need to be +# *that* secret; and running in debug mode (with staticfiles) is fine. +SECRET_KEY = "django-insecure-%vgal2@#0@feqe3jz@1d+f95c*@)2f9n^v9@#%&po5+ct7plwz" +DEBUG = True + +ALLOWED_HOSTS = [] + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "{module_name}.urls" + +TEMPLATES = [ + {{ + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": {{ + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }}, + }}, +] + +WSGI_APPLICATION = "{module_name}.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = {{ + "default": {{ + "ENGINE": "django.db.backends.sqlite3", + "NAME": (TogaApp.app.paths.data if TogaApp.app else BASE_PATH) + / "db.sqlite3", + }} +}} + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + {{ + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }}, + {{ + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }}, + {{ + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }}, + {{ + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }}, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = "static/" +STATIC_ROOT = BASE_PATH / "static" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/examples/positron-django/src/webapp/urls.py b/positron/src/positron/django_templates/urls.py.tmpl similarity index 70% rename from examples/positron-django/src/webapp/urls.py rename to positron/src/positron/django_templates/urls.py.tmpl index df2c234804..d6d544f2aa 100644 --- a/examples/positron-django/src/webapp/urls.py +++ b/positron/src/positron/django_templates/urls.py.tmpl @@ -1,7 +1,8 @@ -"""mysite URL Configuration. +""" +URL configuration for foobar project. The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/4.1/topics/http/urls/ + https://docs.djangoproject.com/en/5.1/topics/http/urls/ Examples: Function views 1. Add an import: from my_app import views @@ -13,10 +14,11 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ - from django.contrib import admin -from django.urls import path +from django.contrib.staticfiles import views as staticfiles +from django.urls import path, re_path urlpatterns = [ path("admin/", admin.site.urls), + re_path(r"^static/(?P.*)$", staticfiles.serve), ] diff --git a/positron/src/positron/django_templates/wsgi.py.tmpl b/positron/src/positron/django_templates/wsgi.py.tmpl new file mode 100644 index 0000000000..cd9fb3a640 --- /dev/null +++ b/positron/src/positron/django_templates/wsgi.py.tmpl @@ -0,0 +1,15 @@ +""" +WSGI config for a Positron project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{module_name}.settings") + +application = get_wsgi_application() diff --git a/positron/src/positron/sitespecific.py b/positron/src/positron/sitespecific.py new file mode 100644 index 0000000000..bcb6741c89 --- /dev/null +++ b/positron/src/positron/sitespecific.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any + +from briefcase.bootstraps import TogaGuiBootstrap +from briefcase.config import validate_url + + +class SiteSpecificPositronBootstrap(TogaGuiBootstrap): + display_name_annotation = "does not support Web deployment" + + def app_source(self): + return f"""\ +import toga + + +class {{{{ cookiecutter.class_name }}}}(toga.App): + + def startup(self): + self.web_view = toga.WebView() + self.web_view.url = f"{self.site_url}" + + self.main_window = toga.MainWindow() + self.main_window.content = self.web_view + self.main_window.show() + + +def main(): + return {{{{ cookiecutter.class_name }}}}() +""" + + 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.site_url = self.console.text_question( + intro="What website would you like to make a site-specific browser for?", + description="Site URL", + default="", + validator=validate_url, + override_value=project_overrides.pop("site_url", None), + ) + + return {} diff --git a/positron/src/positron/static.py b/positron/src/positron/static.py new file mode 100644 index 0000000000..6b187437f5 --- /dev/null +++ b/positron/src/positron/static.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from pathlib import Path + +from briefcase.bootstraps import TogaGuiBootstrap + + +class StaticPositronBootstrap(TogaGuiBootstrap): + display_name_annotation = "does not support Web deployment" + + def app_source(self): + return """\ +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from threading import Event, Thread + +import toga + + +class HTTPHandler(SimpleHTTPRequestHandler): + def translate_path(self, path): + return str(self.server.base_path / path[1:]) + + +class LocalHTTPServer(ThreadingHTTPServer): + def __init__(self, base_path, RequestHandlerClass=HTTPHandler): + self.base_path = base_path + # Use port 0 to let the server select an available port. + super().__init__(("127.0.0.1", 0), RequestHandlerClass) + + +class {{ cookiecutter.class_name }}(toga.App): + def web_server(self): + print("Starting server...") + self._httpd = LocalHTTPServer(self.paths.app / "resources") + # The server is now listening, but connections will block until + # serve_forever is run. + self.server_exists.set() + self._httpd.serve_forever() + + def cleanup(self, app, **kwargs): + print("Shutting down...") + self._httpd.shutdown() + return True + + def startup(self): + self.server_exists = Event() + + self.web_view = toga.WebView() + + self.server_thread = Thread(target=self.web_server) + self.server_thread.start() + + self.on_exit = self.cleanup + + self.server_exists.wait() + host, port = self._httpd.socket.getsockname() + self.web_view.url = f"http://{host}:{port}/" + + self.main_window = toga.MainWindow() + self.main_window.content = self.web_view + self.main_window.show() + + +def main(): + return {{ cookiecutter.class_name }}() +""" + + def post_generate(self, base_path: Path): + resource_path = base_path / "src" / self.context["module_name"] / "resources" + + # Write an index.html file + (resource_path / "index.html").write_text( + f""" + + {self.context["formal_name"]} + + + +

Hello World

+ + +""", + encoding="UTF-8", + ) + + # Write a CSS file + (resource_path / "positron.css").write_text( + """ +h1 { + font-family: sans-serif; +} +""", + encoding="UTF-8", + )