diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bdd54235..9b6adcaad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,9 @@ on: default: "false" type: string outputs: - artifact-name: - description: "Name of the uploaded artifact; use for artifact retrieval." - value: ${{ jobs.package.outputs.artifact-name }} + artifact-basename: + description: "Base name of the uploaded artifacts; use for artifact retrieval." + value: ${{ jobs.package.outputs.artifact-basename }} # Cancel active CI runs for a PR before starting another run concurrency: @@ -46,20 +46,17 @@ jobs: id-token: write contents: read attestations: write + strategy: + matrix: + subdir: + - "" # root briefcase package + - "debugger" + - "automation" uses: beeware/.github/.github/workflows/python-package-create.yml@main with: + build-subdirectory: ${{ matrix.subdir }} attest: ${{ inputs.attest-package }} - package-automation: - name: Package Automation - permissions: - id-token: write - contents: read - attestations: write - uses: beeware/.github/.github/workflows/python-package-create.yml@main - with: - build-subdirectory: "automation" - unit-tests: name: Unit tests needs: [ pre-commit, towncrier, package ] @@ -96,13 +93,14 @@ jobs: - name: Get Packages uses: actions/download-artifact@v6.0.0 with: - name: ${{ needs.package.outputs.artifact-name }} + pattern: ${{ format('{0}*', needs.package.outputs.artifact-basename) }} + merge-multiple: true path: dist - name: Install Tox run: python -m pip install --group tox-uv - - name: Test + - name: Test Briefcase id: test run: | RUNNER_OS=$(cut -d- -f1 <<< ${{ matrix.platform }}) @@ -125,6 +123,10 @@ jobs: # coverage reporting must use the same Python version used to produce coverage run: tox -qe coverage$(tr -dc "0-9" <<< "${{ matrix.python-version }}") + - name: Test Debugger + run: | + tox -e py-debugger --installpkg dist/briefcase_debugger-*.whl + coverage: name: Project coverage runs-on: ubuntu-24.04 @@ -172,7 +174,7 @@ jobs: verify-projects: name: Verify project - needs: [ package, package-automation, unit-tests ] + needs: [ package, unit-tests ] uses: beeware/.github/.github/workflows/app-create-verify.yml@main with: runner-os: ${{ matrix.runner-os }} @@ -185,7 +187,7 @@ jobs: verify-apps: name: Build app - needs: [ package, package-automation, unit-tests ] + needs: [ package, unit-tests ] uses: beeware/.github/.github/workflows/app-build-verify.yml@main with: # Builds on Linux must use System Python; otherwise, fall back to version all GUI toolkits support diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a18d1fdf..8d36c5928 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,8 @@ jobs: - name: Get packages uses: actions/download-artifact@v6.0.0 with: - name: ${{ needs.ci.outputs.artifact-name }} + pattern: ${{ format('{0}*', needs.package.outputs.artifact-basename) }} + merge-multiple: true path: dist - name: Purge non-release packages diff --git a/.gitignore b/.gitignore index e92b55883..bc9bb7d76 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ comment.md /dist /build automation/build +debugger/build docs/_build/ distribute-* .DS_Store diff --git a/changes/2147.feature.md b/changes/2147.feature.md new file mode 100644 index 000000000..9c18cfbf7 --- /dev/null +++ b/changes/2147.feature.md @@ -0,0 +1 @@ +Added a `--debug` option to the `build` and `run` commands for Windows and macOS. diff --git a/debugger/LICENSE b/debugger/LICENSE new file mode 100644 index 000000000..bac8642ff --- /dev/null +++ b/debugger/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2015 Russell Keith-Magee. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of Briefcase-Debugger nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/debugger/README.md b/debugger/README.md new file mode 100644 index 000000000..026ddf364 --- /dev/null +++ b/debugger/README.md @@ -0,0 +1,56 @@ +# Briefcase Debugger Support +[![Python Versions](https://img.shields.io/pypi/pyversions/briefcase-debugger.svg)](https://pypi.python.org/pypi/briefcase-debugger) +[![PyPI Version](https://img.shields.io/pypi/v/briefcase-debugger.svg)](https://pypi.python.org/pypi/briefcase-debugger) +[![Maturity](https://img.shields.io/pypi/status/briefcase-debugger.svg)](https://pypi.python.org/pypi/briefcase-debugger) +[![BSD License](https://img.shields.io/pypi/l/briefcase-debugger.svg)](https://github.com/beeware/briefcase/blob/main/debugger/LICENSE) +[![Build Status](https://github.com/beeware/briefcase/workflows/CI/badge.svg?branch=main)](https://github.com/beeware/briefcase/actions) +[![Discord server](https://img.shields.io/discord/836455665257021440?label=Discord%20Chat&logo=discord&style=plastic)](https://beeware.org/bee/chat/) + +This package contains the debugger support package for the `pdb` and `debugpy` debuggers. + +It starts the remote debugger automatically at startup through an .pth file, if a `BRIEFCASE_DEBUGGER` environment variable is set. + +## Installation +As an end-user, you won't normally need to install this package. It will be installed automatically by Briefcase if you specify the `--debug=pdb` or `--debug=debugpy` option when running your application. + +## Financial support + +The BeeWare project would not be possible without the generous support +of our financial members: + +[![Anaconda logo](https://beeware.org/community/members/anaconda/anaconda-large.png)](https://anaconda.com/) + +Anaconda Inc. - Advancing AI through open source. + +Plus individual contributions from [users like +you](https://beeware.org/community/members/). If you find Briefcase, or +other BeeWare tools useful, please consider becoming a financial member. + +## Documentation + +Documentation for Briefcase can be found on [Read The +Docs](https://briefcase.readthedocs.io). + +## Community + +Briefcase 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 Briefcase [GitHub Discussions + forum](https://github.com/beeware/briefcase/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 Briefcase, [log them on +GitHub](https://github.com/beeware/briefcase/issues). + +If you'd like to contribute to Briefcase development, our [contribution +guide](https://briefcase.readthedocs.io/en/latest/how-to/contribute/index.html) +details how to set up a development environment, and other requirements +we have as part of our contribution process. diff --git a/debugger/pyproject.toml b/debugger/pyproject.toml new file mode 100644 index 000000000..ed36a85a4 --- /dev/null +++ b/debugger/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = [ + # keep versions in sync with automation/pyproject.toml and ../pyproject.toml + "setuptools==80.9.0", + "setuptools_scm==8.3.1", +] +build-backend = "setuptools.build_meta" + +[project] +name = "briefcase-debugger" +description = "A Briefcase plugin adding remote debugging support for PDB and Visual Studio Code." +readme = "README.md" +license = "BSD-3-Clause" +license-files = ["LICENSE"] +dependencies = [ + # see "pdb" or "debugpy" optional dependencies below +] +dynamic = ["version"] + +[project.optional-dependencies] +pdb = ["remote-pdb>=2.1.0,<3.0.0"] +debugpy = ["debugpy>=1.8.17,<2.0.0"] + +[dependency-groups] +dev = [ + "pytest == 8.4.1", + "coverage[toml] == 7.10.2", +] + +[tool.coverage.run] +parallel = true +branch = true +relative_files = true +source_pkgs = ["briefcase_debugger"] + +[tool.coverage.paths] +source = [ + "src", + "**/site-packages", +] + +[tool.coverage.report] +show_missing = true +skip_covered = true +skip_empty = true +precision = 1 + +[tool.ruff.lint] +ignore = [ + "T20", # flake8-print (useful for briefcase, but not in the debugger) +] + +[tool.setuptools_scm] +root = "../" diff --git a/debugger/setup.py b/debugger/setup.py new file mode 100644 index 000000000..fce94a2fa --- /dev/null +++ b/debugger/setup.py @@ -0,0 +1,44 @@ +import os + +import setuptools +from setuptools.command.install import install + + +# Copied from setuptools: +# (https://github.com/pypa/setuptools/blob/7c859e017368360ba66c8cc591279d8964c031bc/setup.py#L40C6-L82) +class install_with_pth(install): + """Custom install command to install a .pth file for distutils patching. + + This hack is necessary because there's no standard way to install behavior + on startup (and it's debatable if there should be one). This hack (ab)uses + the `extra_path` behavior in Setuptools to install a `.pth` file with + implicit behavior on startup to give higher precedence to the local version + of `distutils` over the version from the standard library. + + Please do not replicate this behavior. + """ + + _pth_name = "briefcase_debugger" + _pth_contents = ( + "import briefcase_debugger; briefcase_debugger.start_remote_debugger()" + ) + + def initialize_options(self): + install.initialize_options(self) + self.extra_path = self._pth_name, self._pth_contents + + def finalize_options(self): + install.finalize_options(self) + self._restore_install_lib() + + def _restore_install_lib(self): + """Undo secondary effect of `extra_path` adding to `install_lib`""" + suffix = os.path.relpath(self.install_lib, self.install_libbase) + + if suffix.strip() == self._pth_contents.strip(): + self.install_lib = self.install_libbase + + +setuptools.setup( + cmdclass={"install": install_with_pth}, +) diff --git a/debugger/src/briefcase_debugger/__init__.py b/debugger/src/briefcase_debugger/__init__.py new file mode 100644 index 000000000..d0117a29b --- /dev/null +++ b/debugger/src/briefcase_debugger/__init__.py @@ -0,0 +1,44 @@ +import json +import os +import sys +import traceback + + +def start_remote_debugger(): + try: + # check verbose output + verbose = os.environ.get("BRIEFCASE_DEBUG", "0") == "1" + + # reading config + config_str = os.environ.get("BRIEFCASE_DEBUGGER", None) + + # skip debugger if no config is set + if config_str is None: + if verbose: + print( + "No 'BRIEFCASE_DEBUGGER' environment variable found. Debugger not starting." + ) + return # If BRIEFCASE_DEBUGGER is not set, this packages does nothing... + + if verbose: + print(f"'BRIEFCASE_DEBUGGER'={config_str}") + + # Parsing config json + config = json.loads(config_str) + + # start debugger + print("Starting remote debugger...") + if config["debugger"] == "debugpy": + from briefcase_debugger.debugpy import start_debugpy + + start_debugpy(config, verbose) + elif config["debugger"] == "pdb": + from briefcase_debugger.pdb import start_pdb + + start_pdb(config, verbose) + else: + raise ValueError(f"Unknown debugger '{config['debugger']}'") + except Exception: + # Show exception and stop the whole application when an error occurs + print(traceback.format_exc()) + sys.exit(-1) diff --git a/debugger/src/briefcase_debugger/config.py b/debugger/src/briefcase_debugger/config.py new file mode 100644 index 000000000..0518d102f --- /dev/null +++ b/debugger/src/briefcase_debugger/config.py @@ -0,0 +1,21 @@ +from typing import TypedDict + + +class AppPathMappings(TypedDict): + device_sys_path_regex: str + device_subfolders: list[str] + host_folders: list[str] + + +class AppPackagesPathMappings(TypedDict): + sys_path_regex: str + host_folder: str + + +class DebuggerConfig(TypedDict): + debugger: str + host: str + port: int + host_os: str + app_path_mappings: AppPathMappings | None + app_packages_path_mappings: AppPackagesPathMappings | None diff --git a/debugger/src/briefcase_debugger/debugpy.py b/debugger/src/briefcase_debugger/debugpy.py new file mode 100644 index 000000000..b216860fe --- /dev/null +++ b/debugger/src/briefcase_debugger/debugpy.py @@ -0,0 +1,108 @@ +import re +import sys +from pathlib import Path + +import debugpy + +from briefcase_debugger.config import DebuggerConfig + + +def find_first_matching_path(regex: str) -> str: + """Returns the first element of sys.paths that matches regex, otherwise None.""" + for path in sys.path: + if re.search(regex, path): + return path + raise ValueError(f"No sys.path entry matches regex '{regex}'") + + +def load_path_mappings(config: DebuggerConfig, verbose: bool) -> list[tuple[str, str]]: + app_path_mappings = config.get("app_path_mappings", None) + app_packages_path_mappings = config.get("app_packages_path_mappings", None) + + mappings_list = [] + if app_path_mappings: + device_app_folder = find_first_matching_path( + app_path_mappings["device_sys_path_regex"] + ) + for app_subfolder_device, app_subfolder_host in zip( + app_path_mappings["device_subfolders"], + app_path_mappings["host_folders"], + strict=False, + ): + mappings_list.append( + ( + app_subfolder_host, + str(Path(device_app_folder) / app_subfolder_device), + ) + ) + if app_packages_path_mappings: + device_app_packages_folder = find_first_matching_path( + app_packages_path_mappings["sys_path_regex"] + ) + mappings_list.append( + ( + app_packages_path_mappings["host_folder"], + str(Path(device_app_packages_folder)), + ) + ) + + if verbose: + print("Extracted path mappings:") + for idx, mapping in enumerate(mappings_list): + print(f"[{idx}] host = {mapping[0]}") + print(f"[{idx}] device = {mapping[1]}") + + return mappings_list + + +def start_debugpy(config: DebuggerConfig, verbose: bool): + host = config["host"] + port = config["port"] + path_mappings = load_path_mappings(config, verbose) + + # Starting remote debugger... + print(f"Starting debugpy in server mode at {host}:{port}...") + debugpy.listen((host, port), in_process_debug_adapter=True) + + if verbose: + # pydevd is dynamically loaded and only available after debugpy is started + import pydevd + + pydevd.DebugInfoHolder.DEBUG_TRACE_LEVEL = 3 + + if len(path_mappings) > 0: + if verbose: + print("Adding path mappings...") + + # pydevd is dynamically loaded and only available after a debugger has connected + import pydevd_file_utils + + pydevd_file_utils.setup_client_server_paths(path_mappings) + + print("The debugpy server started. Waiting for debugger to attach...") + print( + f""" +To connect to debugpy using VS Code add the following configuration to '.vscode/launch.json': +{{ + "version": "0.2.0", + "configurations": [ + {{ + "name": "Briefcase: Attach (Connect)", + "type": "debugpy", + "request": "attach", + "connect": {{ + "host": "{host}", + "port": {port} + }}, + "justMyCode": false + }} + ] +}} + +For more information see: https://briefcase.beeware.org/en/stable/how-to/debugging/vscode/#bundled-app +""" + ) + debugpy.wait_for_client() + + print("Debugger attached.") + print("-" * 75) diff --git a/debugger/src/briefcase_debugger/pdb.py b/debugger/src/briefcase_debugger/pdb.py new file mode 100644 index 000000000..d889fb2b9 --- /dev/null +++ b/debugger/src/briefcase_debugger/pdb.py @@ -0,0 +1,44 @@ +import sys + +from remote_pdb import RemotePdb + +from briefcase_debugger.config import DebuggerConfig + + +def start_pdb(config: DebuggerConfig, verbose: bool): + """Start remote PDB server.""" + # Parsing host/port + host = config["host"] + port = config["port"] + + # Print help message + host_os = config["host_os"] + telnet_cmd = f"telnet {host} {port}" + nc_cmd = f"nc {host} {port}" + if host_os == "Windows": + cmds_hint = f" {telnet_cmd}" + elif host_os in ("Linux", "Darwin"): + cmds_hint = f" {nc_cmd}" + else: + cmds_hint = f"""\ + - {telnet_cmd} + - {nc_cmd} +""" + print(f""" +Remote PDB server opened at {host}:{port}. +Waiting for debugger to attach... +To connect to remote PDB use for example: + +{cmds_hint} + +For more information see: https://briefcase.beeware.org/en/stable/how-to/debugging/pdb/#bundled-app +""") + + # Create a RemotePdb instance + remote_pdb = RemotePdb(host, port, quiet=True) + + # Connect the remote PDB with the "breakpoint()" function + sys.breakpointhook = remote_pdb.set_trace + + print("Debugger client attached.") + print("-" * 75) diff --git a/debugger/tests/test_base.py b/debugger/tests/test_base.py new file mode 100644 index 000000000..cf9f63adc --- /dev/null +++ b/debugger/tests/test_base.py @@ -0,0 +1,39 @@ +import importlib +import json +import os +import sys +from unittest.mock import MagicMock + +import briefcase_debugger + + +def test_import_for_code_coverage(monkeypatch, capsys): + """Get 100% code coverage.""" + # The module `briefcase_debugger` is already imported through the .pth + # file. Code executed during .pth files are not covered by coverage.py. + # So we need to reload the module to get a 100% code coverage. + importlib.reload(importlib.import_module("briefcase_debugger")) + + +def test_unknown_debugger(monkeypatch, capsys): + """An unknown debugger raises an error and stops the application.""" + os_environ = {} + os_environ["BRIEFCASE_DEBUGGER"] = json.dumps( + { + "debugger": "unknown", + "host": "somehost", + "port": 9999, + "host_os": "Windows", + } + ) + monkeypatch.setattr(os, "environ", os_environ) + + fake_sys_exit = MagicMock() + monkeypatch.setattr(sys, "exit", fake_sys_exit) + + briefcase_debugger.start_remote_debugger() + + fake_sys_exit.assert_called_once_with(-1) + + captured = capsys.readouterr() + assert "Unknown debugger" in captured.out diff --git a/debugger/tests/test_debugpy.py b/debugger/tests/test_debugpy.py new file mode 100644 index 000000000..7c1f092c3 --- /dev/null +++ b/debugger/tests/test_debugpy.py @@ -0,0 +1,197 @@ +import json +import os +import sys +from unittest.mock import MagicMock + +import briefcase_debugger +import debugpy +import pytest +from briefcase_debugger.config import AppPathMappings + + +def test_no_env_vars(monkeypatch, capsys): + """Nothing happens, when no env vars are set.""" + os_environ = {} + monkeypatch.setattr(os, "environ", os_environ) + + # start test function + briefcase_debugger.start_remote_debugger() + + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +def test_no_debugger_verbose(monkeypatch, capsys): + """Nothing happens except a short message, when only verbose is requested.""" + os_environ = {} + os_environ["BRIEFCASE_DEBUG"] = "1" + monkeypatch.setattr(os, "environ", os_environ) + + # start test function + briefcase_debugger.start_remote_debugger() + + captured = capsys.readouterr() + assert ( + captured.out + == "No 'BRIEFCASE_DEBUGGER' environment variable found. Debugger not starting.\n" + ) + assert captured.err == "" + + +@pytest.mark.parametrize( + ("os_name", "app_path_mappings", "sys_path", "expected_path_mappings"), + [ + ( + "nt", + AppPathMappings( + device_sys_path_regex="app$", + device_subfolders=["helloworld"], + host_folders=["C:\\PROJECT_ROOT\\src\\helloworld"], + ), + ["C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\app"], + [ + ( + "C:\\PROJECT_ROOT\\src\\helloworld", + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\app\\helloworld", + ) + ], + ), + ( + "posix", + AppPathMappings( + device_sys_path_regex="app$", + device_subfolders=["helloworld"], + host_folders=["/PROJECT_ROOT/src/helloworld"], + ), + [ + "/PROJECT_ROOT/build/helloworld/macos/app/Hello World.app/Contents/Resources/app" + ], + [ + ( + "/PROJECT_ROOT/src/helloworld", + "/PROJECT_ROOT/build/helloworld/macos/app/Hello World.app/Contents/Resources/app/helloworld", + ) + ], + ), + ], +) +@pytest.mark.parametrize( + ("verbose", "some_verbose_output", "pydevd_trace_level"), + [ + (True, "Extracted path mappings:\n[0] host = ", 3), + (False, "", 0), + ], +) +def test_with_debugger( + os_name: str, + app_path_mappings: AppPathMappings, + sys_path: list[str], + expected_path_mappings: list[tuple[str, str]], + verbose: bool, + some_verbose_output: str, + pydevd_trace_level: int, + monkeypatch, + capsys, +): + """Normal debug session.""" + if os.name != os_name: + pytest.skip(f"Test only runs on {os_name} systems") + + os_environ = {} + os_environ["BRIEFCASE_DEBUG"] = "1" if verbose else "0" + os_environ["BRIEFCASE_DEBUGGER"] = json.dumps( + { + "debugger": "debugpy", + "host": "somehost", + "port": 9999, + "host_os": "SomeOS", + "app_path_mappings": app_path_mappings, + "app_packages_path_mappings": None, + } + ) + monkeypatch.setattr(os, "environ", os_environ) + + monkeypatch.setattr(sys, "path", sys_path) + + fake_debugpy_listen = MagicMock() + monkeypatch.setattr(debugpy, "listen", fake_debugpy_listen) + + fake_debugpy_wait_for_client = MagicMock() + monkeypatch.setattr(debugpy, "wait_for_client", fake_debugpy_wait_for_client) + + # pydevd is dynamically loaded and only available when a real debugger is attached. So + # we fake the whole module, as otherwise the import in start_remote_debugger would fail + fake_pydevd = MagicMock() + monkeypatch.setitem(sys.modules, "pydevd", fake_pydevd) + fake_pydevd.DebugInfoHolder.DEBUG_TRACE_LEVEL = 0 + fake_pydevd_file_utils = MagicMock() + fake_pydevd_file_utils.setup_client_server_paths.return_value = None + monkeypatch.setitem(sys.modules, "pydevd_file_utils", fake_pydevd_file_utils) + + # start test function + briefcase_debugger.start_remote_debugger() + + fake_debugpy_listen.assert_called_once_with( + ("somehost", 9999), + in_process_debug_adapter=True, + ) + + fake_debugpy_wait_for_client.assert_called_once() + fake_pydevd_file_utils.setup_client_server_paths.assert_called_once_with( + expected_path_mappings + ) + + captured = capsys.readouterr() + assert "Waiting for debugger to attach..." in captured.out + assert captured.err == "" + + assert some_verbose_output in captured.out + assert pydevd_trace_level == fake_pydevd.DebugInfoHolder.DEBUG_TRACE_LEVEL + + +def test_with_debugger_without_path_mappings(monkeypatch, capsys): + """Debug session without path mappings.""" + os_environ = {} + os_environ["BRIEFCASE_DEBUG"] = "0" + os_environ["BRIEFCASE_DEBUGGER"] = json.dumps( + { + "debugger": "debugpy", + "host": "somehost", + "port": 9999, + "host_os": "SomeOS", + "app_path_mappings": None, + "app_packages_path_mappings": None, + } + ) + monkeypatch.setattr(os, "environ", os_environ) + + fake_debugpy_listen = MagicMock() + monkeypatch.setattr(debugpy, "listen", fake_debugpy_listen) + + fake_debugpy_wait_for_client = MagicMock() + monkeypatch.setattr(debugpy, "wait_for_client", fake_debugpy_wait_for_client) + + # pydevd is dynamically loaded and only available when a real debugger is attached. So + # we fake the whole module, as otherwise the import in start_remote_debugger would fail + fake_pydevd = MagicMock() + monkeypatch.setitem(sys.modules, "pydevd", fake_pydevd) + fake_pydevd.DebugInfoHolder.DEBUG_TRACE_LEVEL = 0 + fake_pydevd_file_utils = MagicMock() + fake_pydevd_file_utils.setup_client_server_paths.return_value = None + monkeypatch.setitem(sys.modules, "pydevd_file_utils", fake_pydevd_file_utils) + + # start test function + briefcase_debugger.start_remote_debugger() + + fake_debugpy_listen.assert_called_once_with( + ("somehost", 9999), + in_process_debug_adapter=True, + ) + + fake_debugpy_wait_for_client.assert_called_once() + fake_pydevd_file_utils.setup_client_server_paths.assert_not_called() + + captured = capsys.readouterr() + assert "Waiting for debugger to attach..." in captured.out + assert captured.err == "" diff --git a/debugger/tests/test_debugpy_path_mappings.py b/debugger/tests/test_debugpy_path_mappings.py new file mode 100644 index 000000000..e40642826 --- /dev/null +++ b/debugger/tests/test_debugpy_path_mappings.py @@ -0,0 +1,287 @@ +import os +import sys + +import briefcase_debugger.debugpy +import pytest +from briefcase_debugger.config import ( + AppPackagesPathMappings, + AppPathMappings, + DebuggerConfig, +) + + +def test_mappings_not_existing(): + """Complete empty config.""" + path_mappings = briefcase_debugger.debugpy.load_path_mappings({}, False) + assert path_mappings == [] + + +def test_mappings_none(monkeypatch): + """Config with no mappings set.""" + config = DebuggerConfig( + debugger="debugpy", + host="", + port=0, + app_path_mappings=None, + app_packages_path_mappings=None, + ) + path_mappings = briefcase_debugger.debugpy.load_path_mappings(config, False) + assert path_mappings == [] + + +@pytest.mark.parametrize( + ( + "os_name", + "app_path_mappings", + "app_packages_path_mappings", + "sys_path", + "expected_path_mappings", + ), + [ + # Windows + pytest.param( + "nt", + AppPathMappings( + device_sys_path_regex="app$", + device_subfolders=["helloworld"], + host_folders=["C:\\PROJECT_ROOT\\src\\helloworld"], + ), + None, + [ + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\python313.zip", + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src", + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\app", + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\app_packages", + ], + [ + ( + "C:\\PROJECT_ROOT\\src\\helloworld", + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\app\\helloworld", + ), + ], + id="windows", + ), + # Windows with `app_packages_path_mappings` (currently not used by briefcase, but principally possible) + pytest.param( + "nt", + AppPathMappings( + device_sys_path_regex="app$", + device_subfolders=["helloworld"], + host_folders=["C:\\PROJECT_ROOT\\src\\helloworld"], + ), + AppPackagesPathMappings( + sys_path_regex="app_packages$", + host_folder="C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\app_packages", + ), + [ + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\python313.zip", + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src", + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\app", + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\app_packages", + ], + [ + ( + "C:\\PROJECT_ROOT\\src\\helloworld", + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\app\\helloworld", + ), + ( + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\app_packages", + "C:\\PROJECT_ROOT\\build\\helloworld\\windows\\app\\src\\app_packages", + ), + ], + id="windows-with-app-packages", + ), + # macOS + pytest.param( + "posix", + AppPathMappings( + device_sys_path_regex="app$", + device_subfolders=["helloworld"], + host_folders=["/PROJECT_ROOT/src/helloworld"], + ), + None, + [ + "/PROJECT_ROOT/build/helloworld/macos/app/Hello World.app/Contents/Frameworks/Python.framework/Versions/3.13/lib/python3.13", + "/PROJECT_ROOT/build/helloworld/macos/app/Hello World.app/Contents/Frameworks/Python.framework/Versions/3.13/lib/python3.13/lib-dynload", + "/PROJECT_ROOT/build/helloworld/macos/app/Hello World.app/Contents/Resources/app", + "/PROJECT_ROOT/build/helloworld/macos/app/Hello World.app/Contents/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages", + "/PROJECT_ROOT/build/helloworld/macos/app/Hello World.app/Contents/Resources/app_packages", + ], + [ + ( + "/PROJECT_ROOT/src/helloworld", + "/PROJECT_ROOT/build/helloworld/macos/app/Hello World.app/Contents/Resources/app/helloworld", + ) + ], + id="macos", + ), + # iOS + pytest.param( + "posix", + AppPathMappings( + device_sys_path_regex="app$", + device_subfolders=["helloworld"], + host_folders=["/PROJECT_ROOT/src/helloworld"], + ), + AppPackagesPathMappings( + sys_path_regex="app_packages$", + host_folder="/APP_PACKAGES_PATH/app_packages.iphonesimulator", + ), + [ + "CoreSimulator/Devices/RANDOM_NUMBER/data/Containers/Bundle/Application/RANDOM_NUMBER/Hello World.app/python/lib/python3.13", + "CoreSimulator/Devices/RANDOM_NUMBER/data/Containers/Bundle/Application/RANDOM_NUMBER/Hello World.app/python/lib/python3.13/lib-dynload", + "CoreSimulator/Devices/RANDOM_NUMBER/data/Containers/Bundle/Application/RANDOM_NUMBER/Hello World.app/app", + "CoreSimulator/Devices/RANDOM_NUMBER/data/Containers/Bundle/Application/RANDOM_NUMBER/Hello World.app/python/lib/python3.13/site-packages", + "CoreSimulator/Devices/RANDOM_NUMBER/data/Containers/Bundle/Application/RANDOM_NUMBER/Hello World.app/app_packages", + ], + [ + ( + "/PROJECT_ROOT/src/helloworld", + "CoreSimulator/Devices/RANDOM_NUMBER/data/Containers/Bundle/Application/RANDOM_NUMBER/Hello World.app/app/helloworld", + ), + ( + "/APP_PACKAGES_PATH/app_packages.iphonesimulator", + "CoreSimulator/Devices/RANDOM_NUMBER/data/Containers/Bundle/Application/RANDOM_NUMBER/Hello World.app/app_packages", + ), + ], + id="ios", + ), + # Android (with VS Code running on Windows) + pytest.param( + "posix", + AppPathMappings( + device_sys_path_regex="app$", + device_subfolders=["helloworld"], + host_folders=["C:\\PROJECT_ROOT\\src\\helloworld"], + ), + AppPackagesPathMappings( + sys_path_regex="requirements$", + host_folder="C:\\BUNDLE_PATH\\app\\build\\python\\pip\\debug\\common", + ), + [ + "/data/data/com.example.helloworld/files/chaquopy/AssetFinder/app", + "/data/data/com.example.helloworld/files/chaquopy/AssetFinder/requirements", + "/data/data/com.example.helloworld/files/chaquopy/AssetFinder/stdlib-x86_64", + "/data/user/0/com.example.helloworld/files/chaquopy/stdlib-common.imy", + "/data/user/0/com.example.helloworld/files/chaquopy/bootstrap.imy", + "/data/user/0/com.example.helloworld/files/chaquopy/bootstrap-native/x86_64", + ], + [ + ( + "C:\\PROJECT_ROOT\\src\\helloworld", + "/data/data/com.example.helloworld/files/chaquopy/AssetFinder/app/helloworld", + ), + ( + "C:\\BUNDLE_PATH\\app\\build\\python\\pip\\debug\\common", + "/data/data/com.example.helloworld/files/chaquopy/AssetFinder/requirements", + ), + ], + id="android-on-windows-host", + ), + # Android (with VS Code running on POSIX system) + pytest.param( + "posix", + AppPathMappings( + device_sys_path_regex="app$", + device_subfolders=["helloworld"], + host_folders=["/PROJECT_ROOT/src/helloworld"], + ), + AppPackagesPathMappings( + sys_path_regex="requirements$", + host_folder="/BUNDLE_PATH/app/build/python/pip/debug/common", + ), + [ + "/data/data/com.example.helloworld/files/chaquopy/AssetFinder/app", + "/data/data/com.example.helloworld/files/chaquopy/AssetFinder/requirements", + "/data/data/com.example.helloworld/files/chaquopy/AssetFinder/stdlib-x86_64", + "/data/user/0/com.example.helloworld/files/chaquopy/stdlib-common.imy", + "/data/user/0/com.example.helloworld/files/chaquopy/bootstrap.imy", + "/data/user/0/com.example.helloworld/files/chaquopy/bootstrap-native/x86_64", + ], + [ + ( + "/PROJECT_ROOT/src/helloworld", + "/data/data/com.example.helloworld/files/chaquopy/AssetFinder/app/helloworld", + ), + ( + "/BUNDLE_PATH/app/build/python/pip/debug/common", + "/data/data/com.example.helloworld/files/chaquopy/AssetFinder/requirements", + ), + ], + id="android-on-posix-host", + ), + ], +) +def test_mappings( + os_name: str, + app_path_mappings: AppPathMappings, + app_packages_path_mappings: AppPackagesPathMappings | None, + sys_path: list[str], + expected_path_mappings: list[tuple[str, str]], + monkeypatch, +): + if os.name != os_name: + pytest.skip(f"Test only runs on {os_name} systems") + + config = DebuggerConfig( + debugger="debugpy", + host="", + port=0, + app_path_mappings=app_path_mappings, + app_packages_path_mappings=app_packages_path_mappings, + ) + + monkeypatch.setattr(sys, "path", sys_path) + + path_mappings = briefcase_debugger.debugpy.load_path_mappings(config, False) + + assert path_mappings == expected_path_mappings + + +@pytest.mark.parametrize( + ("os_name", "app_path_mappings"), + [ + # Windows + pytest.param( + "nt", + AppPathMappings( + device_sys_path_regex="app$", + device_subfolders=["helloworld"], + host_folders=["C:\\PROJECT_ROOT\\src\\helloworld"], + ), + id="windows", + ), + # POSIX (macOS/iOS/Android) + pytest.param( + "posix", + AppPathMappings( + device_sys_path_regex="app$", + device_subfolders=["helloworld"], + host_folders=["/PROJECT_ROOT/src/helloworld"], + ), + id="posix", + ), + ], +) +def test_mappings_wrong_sys_path( + os_name: str, + app_path_mappings: AppPathMappings, + monkeypatch, +): + """Path mappings with a wrong sys path set.""" + if os.name != os_name: + pytest.skip(f"Test only runs on {os_name} systems") + + config = DebuggerConfig( + debugger="debugpy", + host="", + port=0, + app_path_mappings=app_path_mappings, + app_packages_path_mappings=None, + ) + + sys_path = [] + monkeypatch.setattr(sys, "path", sys_path) + + with pytest.raises(ValueError, match=r"No sys.path entry matches regex"): + briefcase_debugger.debugpy.load_path_mappings(config, False) diff --git a/debugger/tests/test_pdb.py b/debugger/tests/test_pdb.py new file mode 100644 index 000000000..6c3a362ab --- /dev/null +++ b/debugger/tests/test_pdb.py @@ -0,0 +1,80 @@ +import json +import os +from unittest.mock import MagicMock + +import briefcase_debugger +import briefcase_debugger.pdb +import pytest + + +def test_no_env_vars(monkeypatch, capsys): + """Nothing happens, when no env vars are set.""" + os_environ = {} + monkeypatch.setattr(os, "environ", os_environ) + + # start test function + briefcase_debugger.start_remote_debugger() + + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +def test_no_debugger_verbose(monkeypatch, capsys): + """Nothing happens except a short message, when only verbose is requested.""" + os_environ = {} + os_environ["BRIEFCASE_DEBUG"] = "1" + monkeypatch.setattr(os, "environ", os_environ) + + # start test function + briefcase_debugger.start_remote_debugger() + + captured = capsys.readouterr() + assert ( + captured.out + == "No 'BRIEFCASE_DEBUGGER' environment variable found. Debugger not starting.\n" + ) + assert captured.err == "" + + +@pytest.mark.parametrize("verbose", [True, False]) +@pytest.mark.parametrize( + ("host_os", "expected_host_cmds"), + [ + ("Windows", ["telnet somehost 9999"]), + ("Darwin", ["nc somehost 9999"]), + ("Linux", ["nc somehost 9999"]), + ("UnknownOS", ["nc somehost 9999", "telnet somehost 9999"]), + ], +) +def test_with_debugger(monkeypatch, host_os, expected_host_cmds, capsys, verbose): + """Normal debug session.""" + os_environ = {} + os_environ["BRIEFCASE_DEBUG"] = "1" if verbose else "0" + os_environ["BRIEFCASE_DEBUGGER"] = json.dumps( + { + "debugger": "pdb", + "host": "somehost", + "port": 9999, + "host_os": host_os, + } + ) + monkeypatch.setattr(os, "environ", os_environ) + + fake_remote_pdb = MagicMock() + monkeypatch.setattr(briefcase_debugger.pdb, "RemotePdb", fake_remote_pdb) + + # start test function + briefcase_debugger.start_remote_debugger() + + fake_remote_pdb.assert_called_once_with( + "somehost", + 9999, + quiet=True, + ) + + captured = capsys.readouterr() + assert "Waiting for debugger to attach..." in captured.out + for cmd in expected_host_cmds: + assert cmd in captured.out + assert captured.err == "" diff --git a/docs/en/SUMMARY.md b/docs/en/SUMMARY.md index be41dc29f..3f5136950 100644 --- a/docs/en/SUMMARY.md +++ b/docs/en/SUMMARY.md @@ -14,6 +14,9 @@ - [Packaging external apps](how-to/building/external-apps.md) - Testing apps - [Testing Linux Apps with Docker](how-to/testing/x11passthrough.md) + - Debugging apps + - [Debug via PDB](how-to/debugging/pdb.md) + - [Debug via VS Code](how-to/debugging/vscode.md) - Publishing your app - ./how-to/publishing/* - [Contributing to Briefcase](how-to/contribute/index.md) diff --git a/docs/en/how-to/debugging/pdb.md b/docs/en/how-to/debugging/pdb.md new file mode 100644 index 000000000..adfc2cf25 --- /dev/null +++ b/docs/en/how-to/debugging/pdb.md @@ -0,0 +1,67 @@ +# Debug via PDB { #debug-pdb } + +It is possible to debug a Briefcase app via [PDB](https://docs.python.org/3/library/pdb.html) at different stages in your development process. You can debug a development app via `briefcase dev`, but also a bundled app that is built via `briefcase build` and run via `briefcase run`. + +## Development + +Debugging a development app is quite easy. Just add `breakpoint()` inside your code and start the app via `briefcase dev`. When the breakpoint is hit, the PDB console opens in your terminal and you can debug your app. + +## Bundled App + +It is also possible to debug a bundled app. This is currently still an **experimental feature** that is only supported on Windows and macOS. + +To debug a bundled app, at first you have to add `breakpoint()` somewhere in your code, where the debugger should halt. + +Then you have to built your app with the debugger embedded into your app. This is done via: + +```console +$ briefcase build --debug pdb +``` + +This will build your app in debug mode and add [`remote-pdb`](https://pypi.org/project/remote-pdb/) together with a package that automatically starts `remote-pdb` on startup of your bundled app. + +Then it is time to run your app. You can do this via: + +```console +$ briefcase run --debug pdb +``` + +Running the app in debug mode will automatically start the `remote-pdb` debugger and wait for incoming connections. By default, it will listen on `localhost` and port `5678`. + +In a separate terminal on your host system, connect to your bundled app: + +/// tab | macOS + +```console +$ nc localhost 5678 +``` + +/// + +/// tab | Linux + +```console +$ nc localhost 5678 +``` + +/// + +/// tab | Windows + +To connect to your application, you need access to `telnet`. That is not activated by default, but can be activated by running the following command with admin rights + +```console +$ dism /online /Enable-Feature /FeatureName:TelnetClient +``` + +Then you can start the connection via + +```console +$ telnet localhost 5678 +``` + +/// + +The app will start after the connection is established. + +For more information, see [here][run-debug]. diff --git a/docs/en/how-to/debugging/vscode.md b/docs/en/how-to/debugging/vscode.md new file mode 100644 index 000000000..beec5a78d --- /dev/null +++ b/docs/en/how-to/debugging/vscode.md @@ -0,0 +1,77 @@ +# Debug via VS Code { #debug-vscode } + +Debugging is possible at different stages in your development process. You can debug a development app via `briefcase dev`, or a bundled app that is built via `briefcase build` and run via `briefcase run`. + +## Development + +During development on your host system you should use `briefcase dev`. To attach the VS Code debugger you have to create a configuration in your `.vscode/launch.json` file like this: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Briefcase: Dev", + "type": "debugpy", + "request": "launch", + "module": "briefcase", + "args": [ + "dev", + ], + "justMyCode": false + }, + ] +} +``` + +To start a debug session, open the debug view in VS Code using the sidebar, select the "Briefcase: Dev" configuration and press `Start Debugging (F5)`. That will run `briefcase dev` in a debug session. + +For more details about the VS Code configurations in the `.vscode/launch.json` file see the [VS Code documentation](https://code.visualstudio.com/docs/python/debugging). + +## Bundled App + +It is also possible to debug a bundled app. This is currently still an **experimental feature** that is only supported on Windows and macOS. + +To debug a bundled app a piece of the debugger has to be embedded into your app. This is done via: + +```console +$ briefcase build --debug debugpy +``` + +This will build your app in debug mode and add [debugpy](https://code.visualstudio.com/docs/debugtest/debugging#_debug-console-repl) together with a package that automatically starts `debugpy` at the startup of your bundled app. + +Then it is time to run your app. You can do this via: + +```console +$ briefcase run --debug debugpy +``` + +Running the app in debug mode will automatically start the `debugpy` debugger and listen for incoming connections. By default it will listen on `localhost` and port `5678`. You can then connect your VS Code debugger to the app by creating a configuration like this in the `.vscode/launch.json` file: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Briefcase: Attach", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "justMyCode": false + } + ] +} +``` + +The app will not start until you attach the debugger. Once you attach the VS Code debugger, you can set [breakpoints](https://code.visualstudio.com/docs/debugtest/debugging#_breakpoints), use the [data inspection](https://code.visualstudio.com/docs/debugtest/debugging#_data-inspection), use the [debug console REPL](https://code.visualstudio.com/docs/debugtest/debugging#_debug-console-repl) and all other debugging features of VS Code. + +But there are some restrictions that must be taken into account: + +- Restarting your application via the green circle is not working. You have to stop the app manually and start it again via `briefcase run --debug debugpy`. +- `justMyCode` has to be set to `false`. When setting it to `true`, or not defining it at all, breakpoints are missed on some platforms (e.g., Windows). The reason for this is currently unknown. +- `pathMappings` should not be set manually in the `launch.json`. The path mappings will be set by Briefcase programmatically and if setting it manually too the manual setting will overwrite settings by Briefcase. + +For more information see [here][run-debug]. diff --git a/docs/en/reference/commands/build.md b/docs/en/reference/commands/build.md index 95cdda4b0..a06a46d60 100644 --- a/docs/en/reference/commands/build.md +++ b/docs/en/reference/commands/build.md @@ -93,3 +93,22 @@ If you have previously run the app in "normal" mode, you may need to pass `-r` / ### `--no-update` Prevent the automated update of app code that is performed when specifying by the `--test` option. + +### `--debug ` + +Install the selected debugger into the bundled app. + +Currently the following debuggers are supported: + +- `pdb`: This is used for debugging via console (see [Debug via PDB][debug-pdb]) +- `debugpy`: This is used for debugging via VS Code (see [Debug via VS Code][debug-vscode]) + +If calling only `--debug` without selecting a debugger explicitly, `pdb` is used as default. + +This is an **experimental** new feature, that is currently only supported on Windows and macOS. + +This option may slow down the app a little bit. + +If you have previously run the app in "normal" mode, you may need to pass `-r` / `--update-requirements` the first time you build in debug mode to ensure that the debugger is embedded in your bundled app. + +The selected debugger in `build --debug ` has to match the selected debugger in `run --debug `. diff --git a/docs/en/reference/commands/package.md b/docs/en/reference/commands/package.md index d51fd493d..5d5409aa2 100644 --- a/docs/en/reference/commands/package.md +++ b/docs/en/reference/commands/package.md @@ -4,6 +4,8 @@ Compile/build an application installer. By default, targets the current platform This will produce an installable artefact. +You should not package an application that was built using `build --test` or `build --debug `. + ## Usage To build an installer of the default output format for the current platform: diff --git a/docs/en/reference/commands/run.md b/docs/en/reference/commands/run.md index ae4569bb1..e6042a239 100644 --- a/docs/en/reference/commands/run.md +++ b/docs/en/reference/commands/run.md @@ -98,6 +98,29 @@ Run the app in test mode in the bundled app environment. Running `run --test` wi Prevent the automated update and build of app code that is performed when specifying by the `--test` option. +## `--debug ` { #run-debug } + +Run the app in debug mode. + +Currently the following debuggers are supported: + +- `pdb`: This is used for debugging via console (see [Debug via PDB][debug-pdb]) +- `debugpy`: This is used for debugging via VS Code (see [Debug via VS Code][debug-vscode]) + +If calling only `--debug` without selecting a debugger explicitly, `pdb` is used as default. + +This is an **experimental** new feature, that is currently only supported on Windows and macOS. + +The selected debugger in `run --debug ` has to match the selected debugger in `build --debug `. + +## `--debugger-host ` + +Specifies the host of the socket connection for the debugger. This option is only used when the `--debug ` option is specified. The default value is `localhost`. + +## `--debugger-port ` + +Specifies the port of the socket connection for the debugger. This option is only used when the `--debug ` option is specified. The default value is `5678`. + ## Passthrough arguments If you want to pass any arguments to your app's command line, you can specify them using the `--` marker to separate Briefcase's arguments from your app's arguments. For example: diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 1e723e7de..f71ae2d83 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -24,6 +24,7 @@ beeware BeeWare BeeWare's blobless +breakpoint Bugfix Bugfixes ce @@ -44,6 +45,7 @@ CTRL customizations datetime DBus +debugpy dev dialogs Diataxis @@ -121,6 +123,7 @@ OSX passthrough Passthrough pbb +PDB PFX phablet PID @@ -133,6 +136,7 @@ precompiled proxied Proxied proxying +programmatically PRs PursuedPyBear px @@ -153,6 +157,7 @@ pytest RCEdit README RedHat +REPL ReST reStructuredText RGB diff --git a/pyproject.toml b/pyproject.toml index 38148197a..1d2166466 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,6 +144,10 @@ Console = "briefcase.bootstraps.console:ConsoleBootstrap" PySide6 = "briefcase.bootstraps.pyside6:PySide6GuiBootstrap" Pygame = "briefcase.bootstraps.pygame:PygameGuiBootstrap" +[project.entry-points."briefcase.debuggers"] +pdb = "briefcase.debuggers.pdb:PdbDebugger" +debugpy = "briefcase.debuggers.debugpy:DebugpyDebugger" + [project.entry-points."briefcase.platforms"] android = "briefcase.platforms.android" iOS = "briefcase.platforms.iOS" diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py index 6a942590f..87b463340 100644 --- a/src/briefcase/commands/base.py +++ b/src/briefcase/commands/base.py @@ -22,6 +22,8 @@ from packaging.version import Version from platformdirs import PlatformDirs +from briefcase.debuggers import get_debugger, get_debuggers + if sys.version_info >= (3, 11): # pragma: no-cover-if-lt-py311 import tomllib else: # pragma: no-cover-if-gte-py311 @@ -136,6 +138,8 @@ class BaseCommand(ABC): output_format: str # supports passing extra command line arguments to subprocess allows_passthrough = False + # supports remote debugging + supports_debugger = False # if specified for a platform, then any template for that platform must declare # compatibility with that version epoch. An epoch begins when a breaking change is # introduced for a platform such that older versions of a template are incompatible @@ -686,7 +690,14 @@ def finalize_app_config(self, app: AppConfig): """ return - def finalize(self, app: AppConfig | None = None, test_mode: bool = False): + def finalize( + self, + app: AppConfig | None = None, + test_mode: bool = False, + debugger: str | None = None, + debugger_host: str | None = None, + debugger_port: int | None = None, + ): """Finalize Briefcase configuration. This will: @@ -694,11 +705,16 @@ def finalize(self, app: AppConfig | None = None, test_mode: bool = False): 1. Ensure that the host has been verified 2. Ensure that the platform tools have been verified 3. Ensure that app configurations have been finalized. + 4. Ensure that the debugger is configured. App finalization will only occur once per invocation. :param app: If provided, the specific app configuration to finalize. By default, all apps will be finalized. + :param test_mode: Specify if the app is running in test mode + :param debugger: The debugger that should be used + :param debugger_host: The host to use for the debugger + :param debugger_port: The port to use for the debugger """ self.verify_host() self.verify_tools() @@ -706,6 +722,11 @@ def finalize(self, app: AppConfig | None = None, test_mode: bool = False): apps = self.apps.values() if app is None else [app] for app in apps: if hasattr(app, "__draft__"): + if debugger and debugger != "": + app.debugger = get_debugger(debugger) + app.debugger_host = debugger_host + app.debugger_port = debugger_port + app.test_mode = test_mode self.finalize_app_config(app) delattr(app, "__draft__") @@ -999,6 +1020,46 @@ def _add_test_options(self, parser, context_label): help=f"{context_label} the app in test mode", ) + def _add_debug_options(self, parser, context_label, run_cmd: bool = False): + """Internal utility method for adding common debug-related options. + + :param parser: The parser to which options should be added. + :param context_label: Label text for commands; the capitalized action being + performed (e.g., "Build", "Run",...) + """ + debuggers = get_debuggers() + debugger_names = list(reversed(debuggers.keys())) + + parser.add_argument( + "--debug", + dest="debugger", + nargs="?", + default=None, + const="pdb", + choices=debugger_names, + metavar="DEBUGGER", + help=( + f"{context_label} the app with the specified debugger. " + f"One of %(choices)s (default: %(const)s)" + ), + ) + + if run_cmd: + parser.add_argument( + "--debugger-host", + default="localhost", + help="The host on which to run the debug server (default: %(default)s)", + required=False, + ) + parser.add_argument( + "-dp", + "--debugger-port", + default=5678, + type=int, + help="The port on which to run the debug server (default: %(default)s)", + required=False, + ) + def add_options(self, parser): """Add any options that this command needs to parse from the command line. diff --git a/src/briefcase/commands/build.py b/src/briefcase/commands/build.py index 2363f1968..c2754faaf 100644 --- a/src/briefcase/commands/build.py +++ b/src/briefcase/commands/build.py @@ -16,6 +16,9 @@ def add_options(self, parser): self._add_update_options(parser, context_label=" before building") self._add_test_options(parser, context_label="Build") + if self.supports_debugger: + self._add_debug_options(parser, context_label="Build") + parser.add_argument( "-a", "--app", @@ -71,6 +74,7 @@ def _build_app( or update_support # An explicit app support update has been requested or update_stub # An explicit stub binary update has been requested or (app.test_mode and not no_update) # Test mode, but updates are enabled + or (app.debugger and not no_update) # Debug mode, but updates are enabled ): state = self.update_command( app, @@ -88,6 +92,7 @@ def _build_app( state = self.build_app(app, **full_options(state, options)) qualifier = " (test mode)" if app.test_mode else "" + qualifier += " (debug mode)" if app.debugger else "" self.console.info( f"Built {self.binary_path(app).relative_to(self.base_path)}{qualifier}", prefix=app.app_name, @@ -105,6 +110,7 @@ def __call__( update_stub: bool = False, no_update: bool = False, test_mode: bool = False, + debugger: str | None = None, **options, ) -> dict | None: # Has the user requested an invalid set of options? @@ -134,7 +140,7 @@ def __call__( # Confirm host compatibility, that all required tools are available, # and that the app configuration is finalized. - self.finalize(app, test_mode) + self.finalize(app, test_mode, debugger) if app_name: try: diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py index 6f86c0986..eecac9e2d 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -694,6 +694,9 @@ def install_app_requirements(self, app: AppConfig): if app.test_mode and app.test_requires: requires.extend(app.test_requires) + if app.debugger: + requires.append(app.debugger.debugger_support_pkg) + try: requirements_path = self.app_requirements_path(app) except KeyError: diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py index 890c18d97..692dbb32c 100644 --- a/src/briefcase/commands/run.py +++ b/src/briefcase/commands/run.py @@ -4,8 +4,13 @@ import subprocess from abc import abstractmethod from contextlib import suppress +from pathlib import Path from briefcase.config import AppConfig +from briefcase.debuggers.base import ( + AppPackagesPathMappings, + AppPathMappings, +) from briefcase.exceptions import BriefcaseCommandError, BriefcaseTestSuiteFailure from briefcase.integrations.subprocess import StopStreaming @@ -219,7 +224,44 @@ def add_options(self, parser): self._add_update_options(parser, context_label=" before running") self._add_test_options(parser, context_label="Run") - def _prepare_app_kwargs(self, app: AppConfig): + if self.supports_debugger: + self._add_debug_options(parser, context_label="Run", run_cmd=True) + + def debugger_app_path_mappings(self, app: AppConfig) -> AppPathMappings: + """Get the path mappings for the app code. + + :param app: The config object for the app + :returns: The path mappings for the app code + """ + device_subfolders = [] + host_folders = [] + for src in app.all_sources(): + original = Path(self.base_path / src) + device_subfolders.append(original.name) + host_folders.append(f"{original.absolute()}") + return AppPathMappings( + device_sys_path_regex="app$", + device_subfolders=device_subfolders, + host_folders=host_folders, + ) + + def debugger_app_packages_path_mapping( + self, + app: AppConfig, + ) -> AppPackagesPathMappings: + """Get the path mappings for the app packages. + + :param app: The config object for the app + :returns: The path mappings for the app packages + """ + # When developing an app on your host system for your host system, no path + # mapping is required. The paths are automatically found. + return None + + def _prepare_app_kwargs( + self, + app: AppConfig, + ): """Prepare the kwargs for running an app as a log stream. This won't be used by every backend; but it's a sufficiently common default that @@ -235,6 +277,10 @@ def _prepare_app_kwargs(self, app: AppConfig): if self.console.is_debug: env["BRIEFCASE_DEBUG"] = "1" + # If we're in remote debug mode, save the remote debugger config + if app.debugger: + env["BRIEFCASE_DEBUGGER"] = app.debugger.get_env_config(self, app) + if app.test_mode: # In test mode, set a BRIEFCASE_MAIN_MODULE environment variable # to override the module at startup @@ -260,6 +306,7 @@ def run_app( """Start an application. :param app: The application to start + :param passthrough: Any passthrough arguments """ def __call__( @@ -272,6 +319,9 @@ def __call__( update_stub: bool = False, no_update: bool = False, test_mode: bool = False, + debugger: str | None = None, + debugger_host: str | None = None, + debugger_port: int | None = None, passthrough: list[str] | None = None, **options, ) -> dict | None: @@ -295,7 +345,7 @@ def __call__( # Confirm host compatibility, that all required tools are available, # and that the app configuration is finalized. - self.finalize(app, test_mode) + self.finalize(app, test_mode, debugger, debugger_host, debugger_port) template_file = self.bundle_path(app) exec_file = self.binary_executable_path(app) diff --git a/src/briefcase/commands/update.py b/src/briefcase/commands/update.py index c0270983a..3222d091d 100644 --- a/src/briefcase/commands/update.py +++ b/src/briefcase/commands/update.py @@ -17,6 +17,9 @@ def add_options(self, parser): self._add_update_options(parser, update=False) self._add_test_options(parser, context_label="Update") + if self.supports_debugger: + self._add_debug_options(parser, context_label="Update") + parser.add_argument( "-a", "--app", @@ -100,11 +103,12 @@ def __call__( update_support: bool = False, update_stub: bool = False, test_mode: bool = False, + debugger: str | None = None, **options, ) -> dict | None: # Confirm host compatibility, that all required tools are available, # and that the app configuration is finalized. - self.finalize(app, test_mode) + self.finalize(app, test_mode, debugger) if app_name: try: diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 9f54428be..582dcf2d5 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -13,6 +13,7 @@ else: # pragma: no-cover-if-gte-py311 import tomli as tomllib +from briefcase.debuggers.base import BaseDebugger from briefcase.platforms import get_output_formats, get_platforms from .constants import RESERVED_WORDS @@ -477,6 +478,10 @@ def __init__( self.test_mode: bool = False + self.debugger: BaseDebugger | None = None + self.debugger_host: str | None = None # only for run command + self.debugger_port: int | None = None # only for run command + if not is_valid_app_name(self.app_name): raise BriefcaseConfigError( f"{self.app_name!r} is not a valid app name.\n\n" diff --git a/src/briefcase/debuggers/__init__.py b/src/briefcase/debuggers/__init__.py new file mode 100644 index 000000000..7dec9c4ac --- /dev/null +++ b/src/briefcase/debuggers/__init__.py @@ -0,0 +1,22 @@ +from importlib.metadata import entry_points + +from briefcase.debuggers.base import BaseDebugger +from briefcase.debuggers.debugpy import DebugpyDebugger # noqa: F401 +from briefcase.debuggers.pdb import PdbDebugger # noqa: F401 +from briefcase.exceptions import BriefcaseCommandError + + +def get_debuggers() -> dict[str, type[BaseDebugger]]: + """Loads built-in and third-party debuggers.""" + return { + entry_point.name: entry_point.load() + for entry_point in entry_points(group="briefcase.debuggers") + } + + +def get_debugger(name: str) -> BaseDebugger: + """Get a debugger by name.""" + debuggers = get_debuggers() + if name not in debuggers: + raise BriefcaseCommandError(f"Unknown debugger: {name}") + return debuggers[name]() diff --git a/src/briefcase/debuggers/base.py b/src/briefcase/debuggers/base.py new file mode 100644 index 000000000..b37f93294 --- /dev/null +++ b/src/briefcase/debuggers/base.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import enum +import json +import platform +from abc import ABC, abstractmethod +from importlib import metadata +from pathlib import Path +from typing import TYPE_CHECKING, TypedDict + +import briefcase + +if TYPE_CHECKING: + # avoid circular imports + from briefcase.commands.run import RunCommand + from briefcase.config import AppConfig + + +def _is_editable_pep610(dist_name: str) -> bool: + """Check if briefcase is installed as editable build. + + The check requires, that the tool that installs briefcase support PEP610 (eg. pip + since v20.1). + """ + try: + dist = metadata.distribution(dist_name) + except metadata.PackageNotFoundError: + raise + + direct_url = dist.read_text("direct_url.json") + if direct_url is None: + return False + + try: + data = json.loads(direct_url) + return data.get("dir_info", {}).get("editable", False) + except Exception: + return False + + +IS_EDITABLE = _is_editable_pep610("briefcase") +REPO_ROOT = Path(__file__).parent.parent.parent.parent if IS_EDITABLE else None + + +def get_debugger_requirement(package_name: str, extras: str = ""): + """Get the requirement of a debugger support package. + + On editable installs of briefcase the path to the local package is used, to simplify + the development of the debugger support packages. On normal installs the local + version is not available, so the package from pypi is used, that corresponds to the + version of briefcase. + + :param package_name: The name of the debugger support package. + :param extras: Optional extras to add to the package requirement. Including square + brackets. E.g. "[debugpy]". + :return: The package requirement. + """ + if IS_EDITABLE and REPO_ROOT is not None: + local_path = REPO_ROOT / "debugger" + if local_path.exists() and local_path.is_dir(): + return f"{local_path}{extras}" + return f"{package_name}{extras}=={briefcase.__version__}" + + +class AppPathMappings(TypedDict): + device_sys_path_regex: str + device_subfolders: list[str] + host_folders: list[str] + + +class AppPackagesPathMappings(TypedDict): + sys_path_regex: str + host_folder: str + + +class DebuggerConfig(TypedDict): + debugger: str + host: str + port: int + host_os: str + app_path_mappings: AppPathMappings | None + app_packages_path_mappings: AppPackagesPathMappings | None + + +class DebuggerConnectionMode(str, enum.Enum): + SERVER = "server" + CLIENT = "client" + + +class BaseDebugger(ABC): + """Definition for a plugin that defines a new Briefcase debugger.""" + + @property + @abstractmethod + def name(self) -> str: + """Return the name debugger.""" + + @property + @abstractmethod + def connection_mode(self) -> DebuggerConnectionMode: + """Return the connection mode of the debugger.""" + + @property + @abstractmethod + def debugger_support_pkg(self) -> str: + """Get the name of the debugger support package.""" + + def get_env_config( + self, + cmd: RunCommand, + app: AppConfig, + ) -> str: + """Get the environment config to start the debugger. + + :param cmd: The command that starts the debugger + :param app: The app to be debugged + :returns: The remote debugger configuration + """ + config = DebuggerConfig( + debugger=app.debugger.name, + host=app.debugger_host, + port=app.debugger_port, + host_os=platform.system(), + app_path_mappings=cmd.debugger_app_path_mappings(app), + app_packages_path_mappings=cmd.debugger_app_packages_path_mapping(app), + ) + return json.dumps(config) diff --git a/src/briefcase/debuggers/debugpy.py b/src/briefcase/debuggers/debugpy.py new file mode 100644 index 000000000..5e042a645 --- /dev/null +++ b/src/briefcase/debuggers/debugpy.py @@ -0,0 +1,24 @@ +from briefcase.debuggers.base import ( + BaseDebugger, + DebuggerConnectionMode, + get_debugger_requirement, +) + + +class DebugpyDebugger(BaseDebugger): + """Definition for a plugin that defines a new Briefcase debugger.""" + + @property + def name(self) -> str: + """Return the name debugger.""" + return "debugpy" + + @property + def connection_mode(self) -> DebuggerConnectionMode: + """Return the connection mode of the debugger.""" + return DebuggerConnectionMode.SERVER + + @property + def debugger_support_pkg(self) -> str: + """Get the name of the debugger support package.""" + return get_debugger_requirement("briefcase-debugger", "[debugpy]") diff --git a/src/briefcase/debuggers/pdb.py b/src/briefcase/debuggers/pdb.py new file mode 100644 index 000000000..ad66e6d74 --- /dev/null +++ b/src/briefcase/debuggers/pdb.py @@ -0,0 +1,24 @@ +from briefcase.debuggers.base import ( + BaseDebugger, + DebuggerConnectionMode, + get_debugger_requirement, +) + + +class PdbDebugger(BaseDebugger): + """Definition for a plugin that defines a new Briefcase debugger.""" + + @property + def name(self) -> str: + """Return the name debugger.""" + return "pdb" + + @property + def connection_mode(self) -> DebuggerConnectionMode: + """Return the connection mode of the debugger.""" + return DebuggerConnectionMode.SERVER + + @property + def debugger_support_pkg(self) -> str: + """Get the name of the debugger support package.""" + return get_debugger_requirement("briefcase-debugger", "[pdb]") diff --git a/src/briefcase/platforms/macOS/app.py b/src/briefcase/platforms/macOS/app.py index c985dcdd8..f667517a3 100644 --- a/src/briefcase/platforms/macOS/app.py +++ b/src/briefcase/platforms/macOS/app.py @@ -90,6 +90,7 @@ def install_app_resources(self, app: AppConfig): class macOSAppUpdateCommand(macOSAppCreateCommand, UpdateCommand): description = "Update an existing macOS app." + supports_debugger = True class macOSAppOpenCommand(macOSAppMixin, OpenCommand): @@ -103,6 +104,7 @@ class macOSAppBuildCommand( BuildCommand, ): description = "Build a macOS app." + supports_debugger = True def build_app(self, app: AppConfig, **kwargs): """Build the macOS app. @@ -144,6 +146,7 @@ def build_app(self, app: AppConfig, **kwargs): class macOSAppRunCommand(macOSRunMixin, macOSAppMixin, RunCommand): description = "Run a macOS app." + supports_debugger = True class macOSAppDevCommand(macOSAppMixin, DevCommand): diff --git a/src/briefcase/platforms/macOS/xcode.py b/src/briefcase/platforms/macOS/xcode.py index 041e8c77c..8454c60c1 100644 --- a/src/briefcase/platforms/macOS/xcode.py +++ b/src/briefcase/platforms/macOS/xcode.py @@ -55,10 +55,12 @@ class macOSXcodeOpenCommand(macOSXcodeMixin, OpenCommand): class macOSXcodeUpdateCommand(macOSXcodeCreateCommand, UpdateCommand): description = "Update an existing macOS Xcode project." + supports_debugger = True class macOSXcodeBuildCommand(macOSXcodeMixin, BuildCommand): description = "Build a macOS Xcode project." + supports_debugger = True def build_app(self, app: BaseConfig, **kwargs): """Build the Xcode project for the application. @@ -95,6 +97,7 @@ def build_app(self, app: BaseConfig, **kwargs): class macOSXcodeRunCommand(macOSRunMixin, macOSXcodeMixin, RunCommand): description = "Run a macOS app." + supports_debugger = True class macOSXcodeDevCommand(macOSXcodeMixin, DevCommand): diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index 88c58c3c7..6d33cb1d2 100644 --- a/src/briefcase/platforms/windows/__init__.py +++ b/src/briefcase/platforms/windows/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import re import subprocess import uuid @@ -326,6 +328,8 @@ def install_app_resources(self, app: AppConfig): class WindowsRunCommand(RunCommand): + supports_debugger = True + def run_app( self, app: AppConfig, diff --git a/src/briefcase/platforms/windows/app.py b/src/briefcase/platforms/windows/app.py index 801eebeeb..0332e19c5 100644 --- a/src/briefcase/platforms/windows/app.py +++ b/src/briefcase/platforms/windows/app.py @@ -36,6 +36,7 @@ class WindowsAppCreateCommand(WindowsAppMixin, WindowsCreateCommand): class WindowsAppUpdateCommand(WindowsAppCreateCommand, UpdateCommand): description = "Update an existing Windows app." + supports_debugger = True class WindowsAppOpenCommand(WindowsAppMixin, OpenCommand): @@ -44,6 +45,7 @@ class WindowsAppOpenCommand(WindowsAppMixin, OpenCommand): class WindowsAppBuildCommand(WindowsAppMixin, BuildCommand): description = "Build a Windows app." + supports_debugger = True def verify_tools(self): super().verify_tools() diff --git a/src/briefcase/platforms/windows/visualstudio.py b/src/briefcase/platforms/windows/visualstudio.py index 7100400be..b7275fe3b 100644 --- a/src/briefcase/platforms/windows/visualstudio.py +++ b/src/briefcase/platforms/windows/visualstudio.py @@ -33,6 +33,7 @@ class WindowsVisualStudioCreateCommand(WindowsVisualStudioMixin, WindowsCreateCo class WindowsVisualStudioUpdateCommand(WindowsVisualStudioCreateCommand, UpdateCommand): description = "Update an existing Visual Studio project." + supports_debugger = True class WindowsVisualStudioOpenCommand(WindowsVisualStudioMixin, OpenCommand): @@ -41,6 +42,7 @@ class WindowsVisualStudioOpenCommand(WindowsVisualStudioMixin, OpenCommand): class WindowsVisualStudioBuildCommand(WindowsVisualStudioMixin, BuildCommand): description = "Build a Visual Studio project." + supports_debugger = True def verify_tools(self): super().verify_tools() diff --git a/tests/commands/build/conftest.py b/tests/commands/build/conftest.py index 76b92307a..a092dc867 100644 --- a/tests/commands/build/conftest.py +++ b/tests/commands/build/conftest.py @@ -51,7 +51,15 @@ def verify_app_tools(self, app): self.actions.append(("verify-app-tools", app.app_name)) def build_app(self, app, **kwargs): - self.actions.append(("build", app.app_name, app.test_mode, kwargs.copy())) + self.actions.append( + ( + "build", + app.app_name, + app.test_mode, + app.debugger is not None, + kwargs.copy(), + ) + ) # Remove arguments consumed by the underlying call to build_app() kwargs.pop("update", None) kwargs.pop("update_requirements", None) @@ -65,12 +73,28 @@ def build_app(self, app, **kwargs): # they were invoked, rather than instantiating a Create/Update command. # This is for testing purposes. def create_command(self, app, **kwargs): - self.actions.append(("create", app.app_name, app.test_mode, kwargs.copy())) + self.actions.append( + ( + "create", + app.app_name, + app.test_mode, + app.debugger is not None, + kwargs.copy(), + ) + ) # Remove arguments consumed by the underlying call to create_app() return full_options({"create_state": app.app_name}, kwargs) def update_command(self, app, **kwargs): - self.actions.append(("update", app.app_name, app.test_mode, kwargs.copy())) + self.actions.append( + ( + "update", + app.app_name, + app.test_mode, + app.debugger is not None, + kwargs.copy(), + ) + ) # Remove arguments consumed by the underlying call to update_app() kwargs.pop("update_requirements", None) kwargs.pop("update_resources", None) diff --git a/tests/commands/build/test_call.py b/tests/commands/build/test_call.py index 3f00878d6..0ceeaa019 100644 --- a/tests/commands/build/test_call.py +++ b/tests/commands/build/test_call.py @@ -30,7 +30,7 @@ def test_specific_app(build_command, first_app, second_app): # App tools are verified for app ("verify-app-tools", "first"), # Build the first app; no state - ("build", "first", False, {}), + ("build", "first", False, False, {}), ] @@ -62,13 +62,13 @@ def test_multiple_apps(build_command, first_app, second_app): # App tools are verified for first app ("verify-app-tools", "first"), # Build the first app; no state - ("build", "first", False, {}), + ("build", "first", False, False, {}), # App template is verified for second app ("verify-app-template", "second"), # App tools are verified for second app ("verify-app-tools", "second"), # Build the second apps; state from previous build. - ("build", "second", False, {"build_state": "first"}), + ("build", "second", False, False, {"build_state": "first"}), ] @@ -96,12 +96,12 @@ def test_non_existent(build_command, first_app_config, second_app): ("finalize-app-config", "first"), ("finalize-app-config", "second"), # First App doesn't exist, so it will be created, then built - ("create", "first", False, {}), + ("create", "first", False, False, {}), # App template is verified for first app ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", False, {"create_state": "first"}), + ("build", "first", False, False, {"create_state": "first"}), # App template is verified for second app ("verify-app-template", "second"), # App tools are verified for second app @@ -111,6 +111,7 @@ def test_non_existent(build_command, first_app_config, second_app): "build", "second", False, + False, {"create_state": "first", "build_state": "first"}, ), ] @@ -145,13 +146,13 @@ def test_unbuilt(build_command, first_app_unbuilt, second_app): # App tools are verified for first app ("verify-app-tools", "first"), # First App exists, but hasn't been built; it will be built. - ("build", "first", False, {}), + ("build", "first", False, False, {}), # App template is verified for second app ("verify-app-template", "second"), # App tools are verified for second app ("verify-app-tools", "second"), # Second app has been built before; it will be built again. - ("build", "second", False, {"build_state": "first"}), + ("build", "second", False, False, {"build_state": "first"}), ] @@ -183,6 +184,7 @@ def test_update_app(build_command, first_app, second_app): "update", "first", False, + False, { "update_requirements": False, "update_resources": False, @@ -194,12 +196,13 @@ def test_update_app(build_command, first_app, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", False, {"update_state": "first"}), + ("build", "first", False, False, {"update_state": "first"}), # Update then build the second app ( "update", "second", False, + False, { "update_state": "first", "build_state": "first", @@ -217,6 +220,7 @@ def test_update_app(build_command, first_app, second_app): "build", "second", False, + False, {"update_state": "second", "build_state": "first"}, ), ] @@ -250,6 +254,7 @@ def test_update_app_requirements(build_command, first_app, second_app): "update", "first", False, + False, { "update_requirements": True, "update_resources": False, @@ -261,12 +266,13 @@ def test_update_app_requirements(build_command, first_app, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", False, {"update_state": "first"}), + ("build", "first", False, False, {"update_state": "first"}), # Update then build the second app ( "update", "second", False, + False, { "update_state": "first", "build_state": "first", @@ -284,6 +290,7 @@ def test_update_app_requirements(build_command, first_app, second_app): "build", "second", False, + False, {"update_state": "second", "build_state": "first"}, ), ] @@ -317,6 +324,7 @@ def test_update_app_support(build_command, first_app, second_app): "update", "first", False, + False, { "update_requirements": False, "update_resources": False, @@ -328,12 +336,13 @@ def test_update_app_support(build_command, first_app, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", False, {"update_state": "first"}), + ("build", "first", False, False, {"update_state": "first"}), # Update then build the second app ( "update", "second", False, + False, { "update_state": "first", "build_state": "first", @@ -351,6 +360,7 @@ def test_update_app_support(build_command, first_app, second_app): "build", "second", False, + False, {"update_state": "second", "build_state": "first"}, ), ] @@ -384,6 +394,7 @@ def test_update_app_stub(build_command, first_app, second_app): "update", "first", False, + False, { "update_requirements": False, "update_resources": False, @@ -395,12 +406,13 @@ def test_update_app_stub(build_command, first_app, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", False, {"update_state": "first"}), + ("build", "first", False, False, {"update_state": "first"}), # Update then build the second app ( "update", "second", False, + False, { "update_state": "first", "build_state": "first", @@ -418,6 +430,7 @@ def test_update_app_stub(build_command, first_app, second_app): "build", "second", False, + False, {"update_state": "second", "build_state": "first"}, ), ] @@ -451,6 +464,7 @@ def test_update_app_resources(build_command, first_app, second_app): "update", "first", False, + False, { "update_requirements": False, "update_resources": True, @@ -462,12 +476,13 @@ def test_update_app_resources(build_command, first_app, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", False, {"update_state": "first"}), + ("build", "first", False, False, {"update_state": "first"}), # Update then build the second app ( "update", "second", False, + False, { "update_state": "first", "build_state": "first", @@ -485,6 +500,7 @@ def test_update_app_resources(build_command, first_app, second_app): "build", "second", False, + False, {"update_state": "second", "build_state": "first"}, ), ] @@ -514,17 +530,18 @@ def test_update_non_existent(build_command, first_app_config, second_app): ("finalize-app-config", "first"), ("finalize-app-config", "second"), # First App doesn't exist, so it will be created, then built - ("create", "first", False, {}), + ("create", "first", False, False, {}), # App template is verified for first app ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", False, {"create_state": "first"}), + ("build", "first", False, False, {"create_state": "first"}), # Second app *does* exist, so it will be updated, then built ( "update", "second", False, + False, { "create_state": "first", "build_state": "first", @@ -542,6 +559,7 @@ def test_update_non_existent(build_command, first_app_config, second_app): "build", "second", False, + False, { "create_state": "first", "build_state": "first", @@ -579,6 +597,7 @@ def test_update_unbuilt(build_command, first_app_unbuilt, second_app): "update", "first", False, + False, { "update_requirements": False, "update_resources": False, @@ -590,12 +609,13 @@ def test_update_unbuilt(build_command, first_app_unbuilt, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", False, {"update_state": "first"}), + ("build", "first", False, False, {"update_state": "first"}), # Second app has been built before; it will be built again. ( "update", "second", False, + False, { "update_state": "first", "build_state": "first", @@ -613,6 +633,7 @@ def test_update_unbuilt(build_command, first_app_unbuilt, second_app): "build", "second", False, + False, {"update_state": "second", "build_state": "first"}, ), ] @@ -646,6 +667,7 @@ def test_build_test(build_command, first_app, second_app): "update", "first", True, + False, { "update_requirements": False, "update_resources": False, @@ -657,12 +679,13 @@ def test_build_test(build_command, first_app, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", True, {"update_state": "first"}), + ("build", "first", True, False, {"update_state": "first"}), # Update then build the second app ( "update", "second", True, + False, { "update_state": "first", "build_state": "first", @@ -680,6 +703,7 @@ def test_build_test(build_command, first_app, second_app): "build", "second", True, + False, {"update_state": "second", "build_state": "first"}, ), ] @@ -714,7 +738,7 @@ def test_build_test_no_update(build_command, first_app, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", True, {}), + ("build", "first", True, False, {}), # No update of the second app # App template is verified for second app ("verify-app-template", "second"), @@ -724,6 +748,7 @@ def test_build_test_no_update(build_command, first_app, second_app): "build", "second", True, + False, {"build_state": "first"}, ), ] @@ -758,6 +783,7 @@ def test_build_test_update_dependencies(build_command, first_app, second_app): "update", "first", True, + False, { "update_requirements": True, "update_resources": False, @@ -769,12 +795,13 @@ def test_build_test_update_dependencies(build_command, first_app, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", True, {"update_state": "first"}), + ("build", "first", True, False, {"update_state": "first"}), # Update then build the second app ( "update", "second", True, + False, { "update_state": "first", "build_state": "first", @@ -792,6 +819,7 @@ def test_build_test_update_dependencies(build_command, first_app, second_app): "build", "second", True, + False, {"update_state": "second", "build_state": "first"}, ), ] @@ -826,6 +854,7 @@ def test_build_test_update_resources(build_command, first_app, second_app): "update", "first", True, + False, { "update_requirements": False, "update_resources": True, @@ -837,12 +866,13 @@ def test_build_test_update_resources(build_command, first_app, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", True, {"update_state": "first"}), + ("build", "first", True, False, {"update_state": "first"}), # Update then build the second app ( "update", "second", True, + False, { "update_state": "first", "build_state": "first", @@ -860,6 +890,7 @@ def test_build_test_update_resources(build_command, first_app, second_app): "build", "second", True, + False, {"update_state": "second", "build_state": "first"}, ), ] @@ -894,6 +925,7 @@ def test_build_test_update_support(build_command, first_app, second_app): "update", "first", True, + False, { "update_requirements": False, "update_resources": False, @@ -905,12 +937,13 @@ def test_build_test_update_support(build_command, first_app, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", True, {"update_state": "first"}), + ("build", "first", True, False, {"update_state": "first"}), # Update then build the second app ( "update", "second", True, + False, { "update_state": "first", "build_state": "first", @@ -928,6 +961,7 @@ def test_build_test_update_support(build_command, first_app, second_app): "build", "second", True, + False, {"update_state": "second", "build_state": "first"}, ), ] @@ -962,6 +996,7 @@ def test_build_test_update_stub(build_command, first_app, second_app): "update", "first", True, + False, { "update_requirements": False, "update_resources": False, @@ -973,12 +1008,13 @@ def test_build_test_update_stub(build_command, first_app, second_app): ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", True, {"update_state": "first"}), + ("build", "first", True, False, {"update_state": "first"}), # Update then build the second app ( "update", "second", True, + False, { "update_state": "first", "build_state": "first", @@ -996,11 +1032,107 @@ def test_build_test_update_stub(build_command, first_app, second_app): "build", "second", True, + False, {"update_state": "second", "build_state": "first"}, ), ] +def test_build_debug(build_command, first_app, second_app): + """The update command can be called with debug option.""" + # Add two apps + build_command.apps = { + "first": first_app, + "second": second_app, + } + + # Emulate debugger support + build_command.supports_debugger = True + + # Configure command line options + options, _ = build_command.parse_options(["--debug=pdb"]) + + # Run the build command + build_command(**options) + + # The right sequence of things will be done + assert build_command.actions == [ + # Host OS is verified + ("verify-host",), + # Tools are verified + ("verify-tools",), + # App configs have been finalized + ("finalize-app-config", "first"), + ("finalize-app-config", "second"), + # Update then build the first app + ( + "update", + "first", + False, + True, + { + "update_requirements": False, + "update_resources": False, + "update_support": False, + "update_stub": False, + }, + ), + # App template is verified for first app + ("verify-app-template", "first"), + # App tools are verified for first app + ("verify-app-tools", "first"), + ( + "build", + "first", + False, + True, + {"update_state": "first"}, + ), + # Update then build the second app + ( + "update", + "second", + False, + True, + { + "update_state": "first", + "build_state": "first", + "update_requirements": False, + "update_resources": False, + "update_support": False, + "update_stub": False, + }, + ), + # App template is verified for second app + ("verify-app-template", "second"), + # App tools are verified for second app + ("verify-app-tools", "second"), + ( + "build", + "second", + False, + True, + { + "update_state": "second", + "build_state": "first", + }, + ), + ] + + +def test_build_debug_unsupported(build_command, first_app, second_app): + """If the user requests a debugger but it is not supported by the build command.""" + # Add two apps + build_command.apps = { + "first": first_app, + "second": second_app, + } + + # Configure command line options + with pytest.raises(SystemExit): + _, _ = build_command.parse_options(["--debug=pdb"]) + + def test_build_invalid_update(build_command, first_app, second_app): """If the user requests a build with update and no-update, an error is raised.""" # Add two apps @@ -1124,17 +1256,18 @@ def test_test_app_non_existent(build_command, first_app_config, second_app): ("finalize-app-config", "first"), ("finalize-app-config", "second"), # First App doesn't exist, so it will be created, then built - ("create", "first", True, {}), + ("create", "first", True, False, {}), # App template is verified for first app ("verify-app-template", "first"), # App tools are verified for first app ("verify-app-tools", "first"), - ("build", "first", True, {"create_state": "first"}), + ("build", "first", True, False, {"create_state": "first"}), # Second app *does* exist, so it will be updated, then built ( "update", "second", True, + False, { "create_state": "first", "build_state": "first", @@ -1152,6 +1285,7 @@ def test_test_app_non_existent(build_command, first_app_config, second_app): "build", "second", True, + False, { "create_state": "first", "build_state": "first", @@ -1190,6 +1324,7 @@ def test_test_app_unbuilt(build_command, first_app_unbuilt, second_app): "update", "first", True, + False, { "update_requirements": False, "update_resources": False, @@ -1205,6 +1340,7 @@ def test_test_app_unbuilt(build_command, first_app_unbuilt, second_app): "build", "first", True, + False, {"update_state": "first"}, ), # Second app has been built before; it will be built again. @@ -1212,6 +1348,7 @@ def test_test_app_unbuilt(build_command, first_app_unbuilt, second_app): "update", "second", True, + False, { "update_state": "first", "build_state": "first", @@ -1229,6 +1366,7 @@ def test_test_app_unbuilt(build_command, first_app_unbuilt, second_app): "build", "second", True, + False, {"update_state": "second", "build_state": "first"}, ), ] @@ -1264,7 +1402,7 @@ def test_build_app_single(build_command, first_app, second_app, app_flags): # App tools are verified for first app ("verify-app-tools", "first"), # Build the first app - ("build", "first", False, {}), + ("build", "first", False, False, {}), ] @@ -1342,6 +1480,7 @@ def test_build_app_all_flags(build_command, first_app, second_app): "update", "first", True, + False, { "update_requirements": True, "update_resources": True, @@ -1354,7 +1493,7 @@ def test_build_app_all_flags(build_command, first_app, second_app): # App tools are verified for first app ("verify-app-tools", "first"), # First app is built in test mode - ("build", "first", True, {"update_state": "first"}), + ("build", "first", True, False, {"update_state": "first"}), ] diff --git a/tests/commands/create/conftest.py b/tests/commands/create/conftest.py index 25aed48db..f4b86961f 100644 --- a/tests/commands/create/conftest.py +++ b/tests/commands/create/conftest.py @@ -102,7 +102,7 @@ def python_version_tag(self): return "3.X" # Define output format-specific template context. - def output_format_template_context(self, app): + def output_format_template_context(self, app: AppConfig): return {"output_format": "dummy"} # Handle platform-specific permissions. @@ -182,7 +182,9 @@ def install_app_support_package(self, app): self.actions.append(("support", app.app_name)) def install_app_requirements(self, app): - self.actions.append(("requirements", app.app_name, app.test_mode)) + self.actions.append( + ("requirements", app.app_name, app.test_mode, app.debugger is not None) + ) def install_app_code(self, app): self.actions.append(("code", app.app_name, app.test_mode)) diff --git a/tests/commands/create/test_call.py b/tests/commands/create/test_call.py index 52e435c72..5b00021a0 100644 --- a/tests/commands/create/test_call.py +++ b/tests/commands/create/test_call.py @@ -39,7 +39,7 @@ def test_create(tracking_create_command, tmp_path): ("verify-app-template", "first"), ("verify-app-tools", "first"), ("code", "first", False), - ("requirements", "first", False), + ("requirements", "first", False, False), ("resources", "first"), ("cleanup", "first"), # Create the second app @@ -48,7 +48,7 @@ def test_create(tracking_create_command, tmp_path): ("verify-app-template", "second"), ("verify-app-tools", "second"), ("code", "second", False), - ("requirements", "second", False), + ("requirements", "second", False, False), ("resources", "second"), ("cleanup", "second"), ] @@ -80,7 +80,7 @@ def test_create_single(tracking_create_command, tmp_path): ("verify-app-template", "first"), ("verify-app-tools", "first"), ("code", "first", False), - ("requirements", "first", False), + ("requirements", "first", False, False), ("resources", "first"), ("cleanup", "first"), ] @@ -119,7 +119,7 @@ def test_create_app_single(tracking_create_command, app_flags): ("verify-app-template", "first"), ("verify-app-tools", "first"), ("code", "first", False), - ("requirements", "first", False), + ("requirements", "first", False, False), ("resources", "first"), ("cleanup", "first"), ] @@ -184,7 +184,7 @@ def test_create_app_all_flags(tracking_create_command): ("verify-app-template", "first"), ("verify-app-tools", "first"), ("code", "first", False), - ("requirements", "first", False), + ("requirements", "first", False, False), ("resources", "first"), ("cleanup", "first"), ] diff --git a/tests/commands/create/test_create_app.py b/tests/commands/create/test_create_app.py index b3f7bed58..bdcab5ff3 100644 --- a/tests/commands/create/test_create_app.py +++ b/tests/commands/create/test_create_app.py @@ -18,7 +18,7 @@ def test_create_app(tracking_create_command, tmp_path): ("verify-app-template", "first"), ("verify-app-tools", "first"), ("code", "first", False), - ("requirements", "first", False), + ("requirements", "first", False, False), ("resources", "first"), ("cleanup", "first"), ] @@ -59,7 +59,7 @@ def test_create_existing_app_overwrite(tracking_create_command, tmp_path): ("verify-app-template", "first"), ("verify-app-tools", "first"), ("code", "first", False), - ("requirements", "first", False), + ("requirements", "first", False, False), ("resources", "first"), ("cleanup", "first"), ] @@ -194,7 +194,7 @@ def test_create_app_with_stub(tracking_create_command, tmp_path): ("verify-app-template", "first"), ("verify-app-tools", "first"), ("code", "first", False), - ("requirements", "first", False), + ("requirements", "first", False, False), ("resources", "first"), ("cleanup", "first"), ] diff --git a/tests/commands/create/test_generate_app_template.py b/tests/commands/create/test_generate_app_template.py index 4160edb0f..b3ffdcc2c 100644 --- a/tests/commands/create/test_generate_app_template.py +++ b/tests/commands/create/test_generate_app_template.py @@ -31,6 +31,9 @@ def full_context(): "sources": ["src/my_app"], "test_sources": None, "test_requires": None, + "debugger": None, + "debugger_host": None, + "debugger_port": None, "url": "https://example.com", "author": "First Last", "author_email": "first@example.com", diff --git a/tests/commands/create/test_install_app_requirements.py b/tests/commands/create/test_install_app_requirements.py index 2f4402675..0eb5bfb70 100644 --- a/tests/commands/create/test_install_app_requirements.py +++ b/tests/commands/create/test_install_app_requirements.py @@ -10,6 +10,7 @@ import briefcase from briefcase.commands.create import _is_local_path from briefcase.console import LogLevel +from briefcase.debuggers.base import BaseDebugger, DebuggerConnectionMode from briefcase.exceptions import BriefcaseCommandError, RequirementsInstallError from briefcase.integrations.subprocess import Subprocess @@ -1085,3 +1086,58 @@ def test_app_packages_only_test_requires_test_mode( # Original app definitions haven't changed assert myapp.requires is None assert myapp.test_requires == ["pytest", "pytest-tldr"] + + +class DummyDebugger(BaseDebugger): + @property + def name(self) -> str: + return "dummy" + + @property + def connection_mode(self) -> DebuggerConnectionMode: + raise NotImplementedError + + @property + def debugger_support_pkg(self) -> str: + """Get the name of the debugger support package.""" + return "briefcase-dummy-debugger-support" + + +def test_app_packages_debugger( + create_command, + myapp, + bundle_path, + app_packages_path, + app_packages_path_index, +): + """If an app has debug requirements and we're in debug mode, they are installed.""" + myapp.requires = ["first", "second==1.2.3", "third>=3.2.1"] + myapp.debugger = DummyDebugger() + + create_command.install_app_requirements(myapp) + + # A request was made to install requirements + create_command.tools[myapp].app_context.run.assert_called_with( + [ + sys.executable, + "-u", + "-X", + "utf8", + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--upgrade", + "--no-user", + f"--target={app_packages_path}", + "first", + "second==1.2.3", + "third>=3.2.1", + "briefcase-dummy-debugger-support", + ], + check=True, + encoding="UTF-8", + ) + + # Original app definitions haven't changed + assert myapp.requires == ["first", "second==1.2.3", "third>=3.2.1"] diff --git a/tests/commands/run/conftest.py b/tests/commands/run/conftest.py index 54874182b..423e0b756 100644 --- a/tests/commands/run/conftest.py +++ b/tests/commands/run/conftest.py @@ -51,7 +51,16 @@ def verify_app_tools(self, app): self.actions.append(("verify-app-tools", app.app_name)) def run_app(self, app, **kwargs): - self.actions.append(("run", app.app_name, app.test_mode, kwargs.copy())) + self.actions.append( + ( + "run", + app.app_name, + app.test_mode, + app.debugger is not None, + (app.debugger_host, app.debugger_port), + kwargs.copy(), + ) + ) # Remove arguments consumed by the underlying call to run_app() kwargs.pop("update", None) kwargs.pop("update_requirements", None) @@ -80,7 +89,15 @@ def update_command(self, app, **kwargs): return full_options({"update_state": app.app_name}, kwargs) def build_command(self, app, **kwargs): - self.actions.append(("build", app.app_name, app.test_mode, kwargs.copy())) + self.actions.append( + ( + "build", + app.app_name, + app.test_mode, + app.debugger is not None, + kwargs.copy(), + ) + ) # Remove arguments consumed by the underlying call to build_app() kwargs.pop("update", None) kwargs.pop("update_requirements", None) diff --git a/tests/commands/run/test_call.py b/tests/commands/run/test_call.py index 0320d9a95..6c160f97b 100644 --- a/tests/commands/run/test_call.py +++ b/tests/commands/run/test_call.py @@ -29,7 +29,14 @@ def test_no_args_one_app(run_command, first_app): # App tools are verified ("verify-app-tools", "first"), # Run the first app - ("run", "first", False, {"passthrough": []}), + ( + "run", + "first", + False, + False, + (None, None), + {"passthrough": []}, + ), ] @@ -60,7 +67,14 @@ def test_no_args_one_app_with_passthrough(run_command, first_app): # App tools have been verified ("verify-app-tools", "first"), # Run the first app - ("run", "first", False, {"passthrough": ["foo", "--bar"]}), + ( + "run", + "first", + False, + False, + (None, None), + {"passthrough": ["foo", "--bar"]}, + ), ] @@ -109,7 +123,14 @@ def test_with_arg_one_app(run_command, first_app): # App tools are verified ("verify-app-tools", "first"), # Run the first app - ("run", "first", False, {"passthrough": []}), + ( + "run", + "first", + False, + False, + (None, None), + {"passthrough": []}, + ), ] @@ -140,7 +161,14 @@ def test_with_arg_two_apps(run_command, first_app, second_app): # App tools have been verified ("verify-app-tools", "second"), # Run the second app - ("run", "second", False, {"passthrough": []}), + ( + "run", + "second", + False, + False, + (None, None), + {"passthrough": []}, + ), ] @@ -191,6 +219,7 @@ def test_create_app_before_start(run_command, first_app_config): "build", "first", False, + False, { "update": False, "update_requirements": False, @@ -209,7 +238,12 @@ def test_create_app_before_start(run_command, first_app_config): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -240,6 +274,7 @@ def test_build_app_before_start(run_command, first_app_unbuilt): "build", "first", False, + False, { "update": False, "update_requirements": False, @@ -258,7 +293,12 @@ def test_build_app_before_start(run_command, first_app_unbuilt): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -289,6 +329,7 @@ def test_update_app(run_command, first_app): "build", "first", False, + False, { "update": True, "update_requirements": False, @@ -307,7 +348,12 @@ def test_update_app(run_command, first_app): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -338,6 +384,7 @@ def test_update_app_requirements(run_command, first_app): "build", "first", False, + False, { "update": False, "update_requirements": True, @@ -356,7 +403,12 @@ def test_update_app_requirements(run_command, first_app): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -387,6 +439,7 @@ def test_update_app_resources(run_command, first_app): "build", "first", False, + False, { "update": False, "update_requirements": False, @@ -405,7 +458,12 @@ def test_update_app_resources(run_command, first_app): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -436,6 +494,7 @@ def test_update_app_support(run_command, first_app): "build", "first", False, + False, { "update": False, "update_requirements": False, @@ -454,7 +513,12 @@ def test_update_app_support(run_command, first_app): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -485,6 +549,7 @@ def test_update_app_stub(run_command, first_app): "build", "first", False, + False, { "update": False, "update_requirements": False, @@ -503,7 +568,12 @@ def test_update_app_stub(run_command, first_app): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -535,6 +605,7 @@ def test_update_unbuilt_app(run_command, first_app_unbuilt): "build", "first", False, + False, { "update": True, "update_requirements": False, @@ -553,7 +624,12 @@ def test_update_unbuilt_app(run_command, first_app_unbuilt): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -585,6 +661,7 @@ def test_update_non_existent(run_command, first_app_config): "build", "first", False, + False, { "update": True, "update_requirements": False, @@ -603,7 +680,12 @@ def test_update_non_existent(run_command, first_app_config): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -634,6 +716,7 @@ def test_test_mode_existing_app(run_command, first_app): "build", "first", True, + False, { "update": False, "update_requirements": False, @@ -652,7 +735,12 @@ def test_test_mode_existing_app(run_command, first_app): "run", "first", True, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -683,6 +771,7 @@ def test_test_mode_existing_app_with_passthrough(run_command, first_app): "build", "first", True, + False, { "update": False, "update_requirements": False, @@ -701,6 +790,8 @@ def test_test_mode_existing_app_with_passthrough(run_command, first_app): "run", "first", True, + False, + (None, None), { "build_state": "first", "passthrough": ["foo", "--bar"], @@ -740,6 +831,8 @@ def test_test_mode_existing_app_no_update(run_command, first_app): "run", "first", True, + False, + (None, None), {"passthrough": []}, ), ] @@ -771,6 +864,7 @@ def test_test_mode_existing_app_update_requirements(run_command, first_app): "build", "first", True, + False, { "update": False, "update_requirements": True, @@ -789,7 +883,12 @@ def test_test_mode_existing_app_update_requirements(run_command, first_app): "run", "first", True, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -820,6 +919,7 @@ def test_test_mode_existing_app_update_resources(run_command, first_app): "build", "first", True, + False, { "update": False, "update_requirements": False, @@ -838,7 +938,12 @@ def test_test_mode_existing_app_update_resources(run_command, first_app): "run", "first", True, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -869,6 +974,7 @@ def test_test_mode_update_existing_app(run_command, first_app): "build", "first", True, + False, { "update": True, "update_requirements": False, @@ -887,7 +993,12 @@ def test_test_mode_update_existing_app(run_command, first_app): "run", "first", True, - {"build_state": "first", "passthrough": []}, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, ), ] @@ -918,6 +1029,64 @@ def test_test_mode_non_existent(run_command, first_app_config): "build", "first", True, + False, + { + "update": False, + "update_requirements": False, + "update_resources": False, + "update_support": False, + "update_stub": False, + "no_update": False, + }, + ), + # App template is verified + ("verify-app-template", "first"), + # App tools are verified + ("verify-app-tools", "first"), + # Then, it will be started + ( + "run", + "first", + True, + False, + (None, None), + { + "build_state": "first", + "passthrough": [], + }, + ), + ] + + +def test_debug(run_command, first_app_config): + """Requesting a debugger.""" + # Add a single app, using the 'config only' fixture + run_command.apps = { + "first": first_app_config, + } + + run_command.supports_debugger = True + + # Configure a test option + options, _ = run_command.parse_options(["--debug=pdb"]) + + # Run the run command + run_command(**options) + + # The right sequence of things will be done + assert run_command.actions == [ + # Host OS is verified + ("verify-host",), + # Tools are verified + ("verify-tools",), + # App config has been finalized + ("finalize-app-config", "first"), + # App will be built with debugger + ( + "build", + "first", + False, + True, { "update": False, "update_requirements": False, @@ -935,8 +1104,13 @@ def test_test_mode_non_existent(run_command, first_app_config): ( "run", "first", + False, True, - {"build_state": "first", "passthrough": []}, + ("localhost", 5678), + { + "build_state": "first", + "passthrough": [], + }, ), ] diff --git a/tests/commands/update/conftest.py b/tests/commands/update/conftest.py index 919f5901e..47436432f 100644 --- a/tests/commands/update/conftest.py +++ b/tests/commands/update/conftest.py @@ -52,7 +52,9 @@ def verify_app_tools(self, app): # Override all the body methods of a UpdateCommand # with versions that we can use to track actions performed. def install_app_requirements(self, app): - self.actions.append(("requirements", app.app_name, app.test_mode)) + self.actions.append( + ("requirements", app.app_name, app.test_mode, app.debugger is not None) + ) create_file(self.bundle_path(app) / "requirements", "app requirements") def install_app_code(self, app): diff --git a/tests/commands/update/test_call.py b/tests/commands/update/test_call.py index 5b05a06c8..ffb7b7678 100644 --- a/tests/commands/update/test_call.py +++ b/tests/commands/update/test_call.py @@ -91,13 +91,13 @@ def test_update_with_requirements(update_command, first_app, second_app): ("verify-app-template", "first"), ("verify-app-tools", "first"), ("code", "first", False), - ("requirements", "first", False), + ("requirements", "first", False, False), ("cleanup", "first"), # Update the second app ("verify-app-template", "second"), ("verify-app-tools", "second"), ("code", "second", False), - ("requirements", "second", False), + ("requirements", "second", False, False), ("cleanup", "second"), ] @@ -265,7 +265,7 @@ def test_update_app_all_flags(update_command, first_app, second_app): ("verify-app-template", "first"), ("verify-app-tools", "first"), ("code", "first", False), - ("requirements", "first", False), + ("requirements", "first", False, False), ("resources", "first"), ("cleanup-support", "first"), ("support", "first"), @@ -293,3 +293,36 @@ def test_update_external_app(update_command, first_app): match=r"'first' is declared as an external app", ): update_command(**options) + + +def test_update_debug_with_requirements(update_command, first_app, second_app): + """The update command can be called, requesting a requirements update.""" + update_command.supports_debugger = True + + # Configure no command line options + options, _ = update_command.parse_options(["-r", "--debug=pdb"]) + + update_command(**options) + + # The right sequence of things will be done + assert update_command.actions == [ + # Host OS is verified + ("verify-host",), + # Tools are verified + ("verify-tools",), + # App configs have been finalized + ("finalize-app-config", "first"), + ("finalize-app-config", "second"), + # Update the first app + ("verify-app-template", "first"), + ("verify-app-tools", "first"), + ("code", "first", False), + ("requirements", "first", False, True), + ("cleanup", "first"), + # Update the second app + ("verify-app-template", "second"), + ("verify-app-tools", "second"), + ("code", "second", False), + ("requirements", "second", False, True), + ("cleanup", "second"), + ] diff --git a/tests/commands/update/test_update_app.py b/tests/commands/update/test_update_app.py index c5720a8bd..f8d7de6fc 100644 --- a/tests/commands/update/test_update_app.py +++ b/tests/commands/update/test_update_app.py @@ -63,7 +63,7 @@ def test_update_app_with_requirements(update_command, first_app, tmp_path): ("verify-app-template", "first"), ("verify-app-tools", "first"), ("code", "first", False), - ("requirements", "first", False), + ("requirements", "first", False, False), ("cleanup", "first"), ] @@ -267,7 +267,7 @@ def test_update_app_test_mode_requirements(update_command, first_app, tmp_path): ("verify-app-template", "first"), ("verify-app-tools", "first"), ("code", "first", True), - ("requirements", "first", True), + ("requirements", "first", True, False), ("cleanup", "first"), ] diff --git a/tests/debuggers/__init__.py b/tests/debuggers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/debuggers/test_base.py b/tests/debuggers/test_base.py new file mode 100644 index 000000000..0b130b0d8 --- /dev/null +++ b/tests/debuggers/test_base.py @@ -0,0 +1,129 @@ +import json +from importlib import metadata +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from briefcase.debuggers import ( + DebugpyDebugger, + PdbDebugger, + get_debugger, + get_debuggers, +) +from briefcase.debuggers.base import DebuggerConnectionMode, _is_editable_pep610 +from briefcase.exceptions import BriefcaseCommandError + + +class DummyDist: + def __init__(self, direct_url): + self._direct_url = direct_url + + def read_text(self, name): + return self._direct_url if name == "direct_url.json" else None + + +@pytest.mark.parametrize( + ("direct_url", "is_editable"), + [ + (json.dumps({"dir_info": {"editable": True}}), True), # editable + (json.dumps({"dir_info": {"editable": False}}), False), # not editable + (json.dumps({}), False), # missing dir_info + (None, False), # missing direct_url.json + ("not-json", False), # invalid JSON + ], +) +def test_is_editable_pep610(monkeypatch, direct_url, is_editable): + """Detection of editable installs via PEP 610 direct_url.json works.""" + monkeypatch.setattr(metadata, "distribution", lambda name: DummyDist(direct_url)) + assert _is_editable_pep610("briefcase") is is_editable + + +def test_is_editable_pep610_package_not_found(monkeypatch): + """Detection of editable install throws an Error if package is not found.""" + + def raise_not_found(name): + raise metadata.PackageNotFoundError + + monkeypatch.setattr(metadata, "distribution", raise_not_found) + with pytest.raises(metadata.PackageNotFoundError): + _is_editable_pep610("briefcase") + + +def test_get_debuggers(): + """Builtin debuggers are available.""" + debuggers = get_debuggers() + assert isinstance(debuggers, dict) + assert debuggers["pdb"] is PdbDebugger + assert debuggers["pdb"]().name == "pdb" + assert debuggers["debugpy"] is DebugpyDebugger + assert debuggers["debugpy"]().name == "debugpy" + + +def test_get_debugger(): + """Debugger can be retrieved by name.""" + assert isinstance(get_debugger("pdb"), PdbDebugger) + assert isinstance(get_debugger("debugpy"), DebugpyDebugger) + + # Test with an unknown debugger name + with pytest.raises(BriefcaseCommandError, match="Unknown debugger: unknown"): + get_debugger("unknown") + + +@pytest.mark.parametrize( + ("debugger_name", "expected_class", "connection_mode"), + [ + ( + "pdb", + PdbDebugger, + DebuggerConnectionMode.SERVER, + ), + ( + "debugpy", + DebugpyDebugger, + DebuggerConnectionMode.SERVER, + ), + ], +) +def test_debugger(debugger_name, expected_class, connection_mode, monkeypatch): + """Debugger uses correct connection mode and support package.""" + monkeypatch.setattr("briefcase.debuggers.base.IS_EDITABLE", False) + + debugger = get_debugger(debugger_name) + assert isinstance(debugger, expected_class) + assert debugger.connection_mode == connection_mode + assert f"briefcase-debugger[{debugger_name}]" in debugger.debugger_support_pkg + + +@pytest.mark.parametrize( + "debugger_name", + ["pdb", "debugpy"], +) +def test_debugger_editable(debugger_name, monkeypatch): + """Debugger support package is local path in editable briefcase install.""" + with TemporaryDirectory() as tmp_path: + tmp_path = Path(tmp_path) + (tmp_path / "debugger").mkdir(parents=True, exist_ok=True) + monkeypatch.setattr("briefcase.debuggers.base.IS_EDITABLE", True) + monkeypatch.setattr("briefcase.debuggers.base.REPO_ROOT", tmp_path) + + debugger = get_debugger(debugger_name) + assert ( + str(tmp_path / f"debugger[{debugger_name}]") + == debugger.debugger_support_pkg + ) + + +@pytest.mark.parametrize( + "debugger_name", + ["pdb", "debugpy"], +) +def test_debugger_editable_path_not_found(debugger_name, monkeypatch): + """Debugger support package is not the local path when path is not available.""" + with TemporaryDirectory() as tmp_path: + tmp_path = Path(tmp_path) + monkeypatch.setattr("briefcase.debuggers.base.IS_EDITABLE", True) + monkeypatch.setattr("briefcase.debuggers.base.REPO_ROOT", tmp_path) + + debugger = get_debugger(debugger_name) + assert f"briefcase-debugger[{debugger_name}]==" in debugger.debugger_support_pkg diff --git a/tests/platforms/conftest.py b/tests/platforms/conftest.py index 8ef32e631..16a4cdf68 100644 --- a/tests/platforms/conftest.py +++ b/tests/platforms/conftest.py @@ -1,6 +1,10 @@ import pytest from briefcase.config import AppConfig +from briefcase.debuggers.base import ( + BaseDebugger, + DebuggerConnectionMode, +) @pytest.fixture @@ -54,3 +58,23 @@ def underscore_app_config(first_app_config): requires=["foo==1.2.3", "bar>=4.5"], test_requires=["pytest"], ) + + +class DummyDebugger(BaseDebugger): + @property + def name(self) -> str: + return "dummy" + + @property + def connection_mode(self) -> DebuggerConnectionMode: + raise NotImplementedError + + @property + def debugger_support_pkg(self) -> str: + raise NotImplementedError + + +@pytest.fixture +def dummy_debugger(): + """A dummy debugger for testing purposes.""" + return DummyDebugger() diff --git a/tests/platforms/macOS/app/test_run.py b/tests/platforms/macOS/app/test_run.py index e7ef2cd74..872bd2406 100644 --- a/tests/platforms/macOS/app/test_run.py +++ b/tests/platforms/macOS/app/test_run.py @@ -1,3 +1,5 @@ +import json +import platform import subprocess from signal import SIGTERM from unittest import mock @@ -298,6 +300,84 @@ def test_run_gui_app_test_mode( run_command.tools.os.kill.assert_called_with(100, SIGTERM) +def test_run_gui_app_debugger( + run_command, + first_app_config, + sleep_zero, + tmp_path, + monkeypatch, + dummy_debugger, +): + """A macOS GUI app can be started in debug mode.""" + # Mock a popen object that represents the log stream + log_stream_process = mock.MagicMock(spec_set=subprocess.Popen) + run_command.tools.subprocess.Popen.return_value = log_stream_process + + first_app_config.debugger = dummy_debugger + first_app_config.debugger_host = "somehost" + first_app_config.debugger_port = 9999 + + # Monkeypatch the tools get the process ID + monkeypatch.setattr( + "briefcase.platforms.macOS.get_process_id_by_command", lambda *a, **kw: 100 + ) + + run_command.run_app(first_app_config, passthrough=[]) + + # Calls were made to start the app and to start a log stream. + bin_path = run_command.binary_path(first_app_config) + sender = bin_path / "Contents/MacOS/First App" + run_command.tools.subprocess.Popen.assert_called_with( + [ + "log", + "stream", + "--style", + "compact", + "--predicate", + f'senderImagePath=="{sender}"' + f' OR (processImagePath=="{sender}"' + ' AND senderImagePath=="/usr/lib/libffi.dylib")', + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + ) + run_command.tools.subprocess.run.assert_called_with( + ["open", "-n", bin_path], + cwd=tmp_path / "home", + check=True, + env={ + "BRIEFCASE_DEBUGGER": json.dumps( + { + "debugger": "dummy", + "host": "somehost", + "port": 9999, + "host_os": platform.system(), + "app_path_mappings": { + "device_sys_path_regex": "app$", + "device_subfolders": ["first_app"], + "host_folders": [str(tmp_path / "base_path/src/first_app")], + }, + "app_packages_path_mappings": None, + } + ) + }, + ) + + # The log stream was started + run_command._stream_app_logs.assert_called_with( + first_app_config, + popen=log_stream_process, + clean_filter=macOS_log_clean_filter, + clean_output=True, + stop_func=mock.ANY, + log_stream=True, + ) + + # The app process was killed on exit. + run_command.tools.os.kill.assert_called_with(100, SIGTERM) + + def test_run_console_app(run_command, first_app_config, tmp_path): """A macOS console app can be started.""" # Set the app to be a console app @@ -395,7 +475,10 @@ def test_run_console_app_test_mode_with_passthrough( app_process = mock.MagicMock(spec_set=subprocess.Popen) run_command.tools.subprocess.Popen.return_value = app_process - run_command.run_app(first_app_config, passthrough=["foo", "--bar"]) + run_command.run_app( + first_app_config, + passthrough=["foo", "--bar"], + ) # Calls were made to start the app and to start a log stream. bin_path = run_command.binary_path(first_app_config) diff --git a/tests/platforms/windows/app/test_run.py b/tests/platforms/windows/app/test_run.py index 76e1edea4..99b8da2b8 100644 --- a/tests/platforms/windows/app/test_run.py +++ b/tests/platforms/windows/app/test_run.py @@ -1,3 +1,5 @@ +import json +import platform import subprocess from unittest import mock @@ -263,3 +265,50 @@ def test_run_app_test_mode_with_passthrough( popen=log_popen, clean_output=False, ) + + +def test_run_gui_app_debugger(run_command, first_app_config, tmp_path, dummy_debugger): + """A Windows app can be started in debug mode.""" + # Set up the log streamer to return a known stream + log_popen = mock.MagicMock() + run_command.tools.subprocess.Popen.return_value = log_popen + + first_app_config.debugger = dummy_debugger + first_app_config.debugger_host = "somehost" + first_app_config.debugger_port = 9999 + + # Run the app + run_command.run_app(first_app_config, passthrough=[]) + + # The process was started + run_command.tools.subprocess.Popen.assert_called_with( + [tmp_path / "base_path/build/first-app/windows/app/src/First App.exe"], + cwd=tmp_path / "home", + encoding="UTF-8", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + env={ + "BRIEFCASE_DEBUGGER": json.dumps( + { + "debugger": "dummy", + "host": "somehost", + "port": 9999, + "host_os": platform.system(), + "app_path_mappings": { + "device_sys_path_regex": "app$", + "device_subfolders": ["first_app"], + "host_folders": [str(tmp_path / "base_path/src/first_app")], + }, + "app_packages_path_mappings": None, + } + ) + }, + ) + + # The streamer was started + run_command._stream_app_logs.assert_called_once_with( + first_app_config, + popen=log_popen, + clean_output=False, + ) diff --git a/tests/platforms/windows/visualstudio/test_run.py b/tests/platforms/windows/visualstudio/test_run.py index 3708ba17b..93d9a0165 100644 --- a/tests/platforms/windows/visualstudio/test_run.py +++ b/tests/platforms/windows/visualstudio/test_run.py @@ -1,6 +1,8 @@ # The run command inherits most of its behavior from the common base # implementation. Do a surface-level verification here, but the app # tests provide the actual test coverage. +import json +import platform import subprocess from unittest import mock @@ -161,3 +163,53 @@ def test_run_app_test_mode_with_args(run_command, first_app_config, tmp_path): popen=log_popen, clean_output=False, ) + + +def test_run_app_debugger(run_command, first_app_config, tmp_path, dummy_debugger): + """A windows Visual Studio project app can be started in debug mode.""" + first_app_config.debugger = dummy_debugger + first_app_config.debugger_host = "somehost" + first_app_config.debugger_port = 9999 + + # Set up the log streamer to return a known stream with a good returncode + log_popen = mock.MagicMock() + run_command.tools.subprocess.Popen.return_value = log_popen + + # Run the app in test mode + run_command.run_app(first_app_config, passthrough=[]) + + # Popen was called + run_command.tools.subprocess.Popen.assert_called_with( + [ + tmp_path + / "base_path/build/first-app/windows/visualstudio/x64/Release/First App.exe" + ], + cwd=tmp_path / "home", + encoding="UTF-8", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + env={ + "BRIEFCASE_DEBUGGER": json.dumps( + { + "debugger": "dummy", + "host": "somehost", + "port": 9999, + "host_os": platform.system(), + "app_path_mappings": { + "device_sys_path_regex": "app$", + "device_subfolders": ["first_app"], + "host_folders": [str(tmp_path / "base_path/src/first_app")], + }, + "app_packages_path_mappings": None, + } + ) + }, + ) + + # The streamer was started + run_command._stream_app_logs.assert_called_once_with( + first_app_config, + popen=log_popen, + clean_output=False, + ) diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py index 3ea8ce483..717c597c1 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -323,6 +323,9 @@ def test_run_command( "update_stub": False, "no_update": False, "test_mode": False, + "debugger": None, + "debugger_host": "localhost", + "debugger_port": 5678, "passthrough": [], **expected_options, } diff --git a/tox.ini b/tox.ini index caa7bc86e..615777462 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = towncrier-check,docs-lint,pre-commit,py{310,311,312,313,314}-cov,coverage-platform +envlist = towncrier-check,docs-lint,pre-commit,py{310,311,312,313,314}-cov,coverage-platform,py{310,311,312,313,314}-debugger labels = test = py-cov,coverage test310 = py310-cov,coverage310 @@ -67,6 +67,17 @@ commands = html: python -m coverage html --skip-covered --skip-empty python -m coverage report --fail-under=100 +[testenv:py{,310,311,312,313,314}-debugger] +changedir = debugger +skip_install = True +deps = + build +commands = + uv pip install ".[pdb,debugpy]" --group dev + python -X warn_default_encoding -m coverage run -m pytest {posargs:-vv --color yes} + python -m coverage combine + python -m coverage report --fail-under=100 + [testenv:towncrier{,-check}] skip_install = True dependency_groups = towncrier