diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27078086d..f89a09bcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,10 @@ jobs: package: name: Python package - uses: beeware/.github/.github/workflows/python-package-create.yml@main +# uses: beeware/.github/.github/workflows/python-package-create.yml@main + uses: rmartin16/.github-beeware/.github/workflows/python-package-create.yml@build-verify-run # TODO:PR: remove me + with: + tox-factors: -with-automation unit-tests: name: Unit tests @@ -145,10 +148,13 @@ jobs: verify-projects: name: Verify project needs: unit-tests - uses: beeware/.github/.github/workflows/app-create-verify.yml@main +# uses: beeware/.github/.github/workflows/app-create-verify.yml@main + uses: rmartin16/.github-beeware/.github/workflows/app-create-verify.yml@build-verify-run # TODO:PR: remove me with: runner-os: ${{ matrix.runner-os }} framework: ${{ matrix.framework }} + workflow-repo: rmartin16/.github-beeware # TODO:PR: REMOVE ME + workflow-repo-ref: build-verify-run # TODO:PR: REMOVE ME strategy: fail-fast: false matrix: @@ -158,7 +164,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@build-verify-run # TODO:PR: remove me 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 @@ -168,6 +175,8 @@ jobs: python-version: "3.10" runner-os: ${{ matrix.runner-os }} framework: ${{ matrix.framework }} + workflow-repo: rmartin16/.github-beeware # TODO:PR: REMOVE ME + workflow-repo-ref: build-verify-run # TODO:PR: REMOVE ME strategy: fail-fast: false matrix: diff --git a/automation/README.md b/automation/README.md new file mode 100644 index 000000000..8497b4b21 --- /dev/null +++ b/automation/README.md @@ -0,0 +1,12 @@ +## Briefcase Automation + +This package provides Briefcase plugins to facilitate automation in CI. + +This package is internal to Briefcase's own development and is not needed to create, +develop, or distribute apps created with Briefcase. + +### Bootstraps + +There are bootstrap plugins for each GUI toolkit; each allows for Briefcase to create +a project using the toolkit but when the project's app run, the app automatically exits +after a few seconds. diff --git a/automation/pyproject.toml b/automation/pyproject.toml new file mode 100644 index 000000000..7922c86a9 --- /dev/null +++ b/automation/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = [ + # keep versions in sync with ../pyproject.toml + "setuptools==69.0.0", + "setuptools_scm==8.0.4", + "setuptools_dynamic_dependencies @ git+https://github.com/beeware/setuptools_dynamic_dependencies", +] +build-backend = "setuptools.build_meta" + +[project] +name = "x-briefcase-automation" +description = "A Briefcase plugin for CI automation." +readme = "README.md" +license.text = "New BSD" +classifiers = ["Private :: Do Not Upload"] +dynamic = ["version", "dependencies"] + +[project.entry-points."briefcase.bootstraps"] +"Toga Automation" = "automation.bootstraps.toga:TogaAutomationBootstrap" +"PySide6 Automation" = "automation.bootstraps.pyside6:PySide6AutomationBootstrap" +"Pygame Automation" = "automation.bootstraps.pygame:PygameAutomationBootstrap" +"PursuedPyBear Automation" = "automation.bootstraps.pursuedpybear:PursuedPyBearAutomationBootstrap" + +[tool.setuptools_scm] +root = "../" + +[tool.setuptools_dynamic_dependencies] +dependencies = ["briefcase == {version}"] diff --git a/automation/src/automation/__init__.py b/automation/src/automation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/automation/src/automation/bootstraps/__init__.py b/automation/src/automation/bootstraps/__init__.py new file mode 100644 index 000000000..9190ec7cb --- /dev/null +++ b/automation/src/automation/bootstraps/__init__.py @@ -0,0 +1,2 @@ +BRIEFCASE_EXIT_SUCCESS_SIGNAL = ">>>>>>>>>> EXIT 0 <<<<<<<<<<" +EXIT_SUCCESS_NOTIFY = ">>> successfully started...exiting <<<" diff --git a/automation/src/automation/bootstraps/pursuedpybear.py b/automation/src/automation/bootstraps/pursuedpybear.py new file mode 100644 index 000000000..54e699d62 --- /dev/null +++ b/automation/src/automation/bootstraps/pursuedpybear.py @@ -0,0 +1,83 @@ +import sys + +import tomli_w + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +from automation.bootstraps import BRIEFCASE_EXIT_SUCCESS_SIGNAL, EXIT_SUCCESS_NOTIFY +from briefcase.bootstraps import PursuedPyBearGuiBootstrap + + +class PursuedPyBearAutomationBootstrap(PursuedPyBearGuiBootstrap): + def app_source(self): + return f"""\ +import importlib.metadata +import os +import sys + +import ppb + + +class {{{{ cookiecutter.class_name }}}}(ppb.Scene): + def __init__(self, **props): + super().__init__(**props) + self.updates: int = 0 + + self.add( + ppb.Sprite( + image=ppb.Image("{{{{ cookiecutter.module_name }}}}/resources/{{{{ cookiecutter.app_name }}}}.png"), + ) + ) + + def on_update(self, event, signal): + self.updates += 1 + # quit after 2 seconds since on_update is run 60 times/second + if self.updates > 120: + print("{EXIT_SUCCESS_NOTIFY}") + print("{BRIEFCASE_EXIT_SUCCESS_SIGNAL}") + signal(ppb.events.Quit()) + + +def main(): + # Linux desktop environments use an app's .desktop file to integrate the app + # in to their application menus. The .desktop file of this app will include + # the StartupWMClass key, set to app's formal name. This helps associate the + # 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 the SDL_VIDEO_X11_WMCLASS 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"], + ) +""" + + # The constraint of pysdl2-dll==2.0.22 is required for ppb==1.1.0; + # the libraries in later versions of pysdl2-dll are not compatible. + + def pyproject_table_linux_flatpak(self): + table = tomllib.loads(super().pyproject_table_linux_flatpak()) + table.setdefault("requires", []).append("pysdl2-dll==2.0.22") + return f"\n{tomli_w.dumps(table)}" + + def pyproject_table_windows(self): + table = tomllib.loads(super().pyproject_table_windows()) + table.setdefault("requires", []).append("pysdl2-dll==2.0.22") + return f"\n{tomli_w.dumps(table)}" + + def pyproject_table_macOS(self): + table = tomllib.loads(super().pyproject_table_macOS()) + table.setdefault("requires", []).append("pysdl2-dll==2.0.22") + return f"\n{tomli_w.dumps(table)}" diff --git a/automation/src/automation/bootstraps/pygame.py b/automation/src/automation/bootstraps/pygame.py new file mode 100644 index 000000000..0f2fe8c9e --- /dev/null +++ b/automation/src/automation/bootstraps/pygame.py @@ -0,0 +1,61 @@ +from automation.bootstraps import BRIEFCASE_EXIT_SUCCESS_SIGNAL, EXIT_SUCCESS_NOTIFY +from briefcase.bootstraps import PygameGuiBootstrap + + +class PygameAutomationBootstrap(PygameGuiBootstrap): + def app_source(self): + return f"""\ +import importlib.metadata +import os +import sys +from pathlib import Path + +import pygame + + +SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600 +WHITE = (255, 255, 255) + + +def main(): + # Linux desktop environments use an app's .desktop file to integrate the app + # in to their application menus. The .desktop file of this app will include + # the StartupWMClass key, set to app's formal name. This helps associate the + # 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 PyGame, this is set + # using the SDL_VIDEO_X11_WMCLASS 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"] + + # Set the app's runtime icon + pygame.display.set_icon( + pygame.image.load(Path(__file__).parent / "resources/{{{{ cookiecutter.app_name }}}}.png") + ) + + pygame.init() + pygame.display.set_caption(metadata["Formal-Name"]) + screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) + + pygame.time.set_timer(pygame.QUIT, 2000) + + running = True + while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + print("{EXIT_SUCCESS_NOTIFY}") + print("{BRIEFCASE_EXIT_SUCCESS_SIGNAL}") + running = False + break + + screen.fill(WHITE) + pygame.display.flip() + + pygame.quit() +""" diff --git a/automation/src/automation/bootstraps/pyside6.py b/automation/src/automation/bootstraps/pyside6.py new file mode 100644 index 000000000..20e1ec110 --- /dev/null +++ b/automation/src/automation/bootstraps/pyside6.py @@ -0,0 +1,52 @@ +from automation.bootstraps import BRIEFCASE_EXIT_SUCCESS_SIGNAL, EXIT_SUCCESS_NOTIFY +from briefcase.bootstraps import PySide6GuiBootstrap + + +class PySide6AutomationBootstrap(PySide6GuiBootstrap): + def app_source(self): + return f"""\ +import importlib.metadata +import sys + +from PySide6 import QtWidgets +from PySide6.QtCore import QTimer + + +class {{{{ cookiecutter.class_name }}}}(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.init_ui() + + def init_ui(self): + self.setWindowTitle("{{{{ cookiecutter.app_name }}}}") + self.show() + + QTimer.singleShot(2000, self.exit_app) + + def exit_app(self): + print("{EXIT_SUCCESS_NOTIFY}") + print("{BRIEFCASE_EXIT_SUCCESS_SIGNAL}") + QtWidgets.QApplication.quit() + + +def main(): + # Linux desktop environments use an app's .desktop file to integrate the app + # in to their application menus. The .desktop file of this app will include + # the StartupWMClass key, set to app's formal name. This helps associate the + # 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 PySide6, this is set + # with setApplicationName(). + + # 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) + + QtWidgets.QApplication.setApplicationName(metadata["Formal-Name"]) + + app = QtWidgets.QApplication(sys.argv) + main_window = {{{{ cookiecutter.class_name }}}}() + sys.exit(app.exec()) +""" diff --git a/automation/src/automation/bootstraps/toga.py b/automation/src/automation/bootstraps/toga.py new file mode 100644 index 000000000..af271a234 --- /dev/null +++ b/automation/src/automation/bootstraps/toga.py @@ -0,0 +1,41 @@ +from automation.bootstraps import BRIEFCASE_EXIT_SUCCESS_SIGNAL, EXIT_SUCCESS_NOTIFY +from briefcase.bootstraps import TogaGuiBootstrap + + +class TogaAutomationBootstrap(TogaGuiBootstrap): + def app_source(self): + return f'''\ +import asyncio + +import toga +from toga.style import Pack +from toga.style.pack import COLUMN, ROW + + +class {{{{ cookiecutter.class_name }}}}(toga.App): + def startup(self): + """Construct and show the Toga application. + + Usually, you would add your application to a main content box. + We then create a main window (with a name matching the app), and + show the main window. + """ + main_box = toga.Box() + + self.main_window = toga.MainWindow(title=self.formal_name) + self.main_window.content = main_box + self.main_window.show() + + self.add_background_task(self.exit_soon) + + async def exit_soon(self, app: toga.App, **kwargs): + """Background task that closes the app after a few seconds.""" + await asyncio.sleep(2) + print("{EXIT_SUCCESS_NOTIFY}") + print("{BRIEFCASE_EXIT_SUCCESS_SIGNAL}") + self.exit() + + +def main(): + return {{{{ cookiecutter.class_name }}}}() +''' diff --git a/changes/1549.misc.rst b/changes/1549.misc.rst new file mode 100644 index 000000000..4ff04c3bb --- /dev/null +++ b/changes/1549.misc.rst @@ -0,0 +1 @@ +The Briefcase Automation package was created to facilitate automated testing in CI; for example, starting apps built in CI that can automatically exit. diff --git a/pyproject.toml b/pyproject.toml index aaf4db8b9..c39288100 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [build-system] requires = [ + # keep versions in sync with automation/pyproject.toml "setuptools==69.0.0", "setuptools_scm==8.0.4", ] diff --git a/tox.ini b/tox.ini index 6e9a284db..a32bee416 100644 --- a/tox.ini +++ b/tox.ini @@ -127,12 +127,13 @@ commands = lint : python -m sphinx {[docs]sphinx_args_extra} -b linkcheck . {[docs]build_dir}/links all : python -m sphinx {[docs]sphinx_args_extra} -b html . {[docs]build_dir}/html -[testenv:package] +[testenv:package{,-with-automation}] skip_install = True passenv = FORCE_COLOR deps = build==1.0.3 twine==4.0.2 commands = - python -m build --outdir dist/ . + python -m build . --outdir dist/ + with-automation: python -m build automation/ --outdir dist/ python -m twine check dist/*