diff --git a/.gitignore b/.gitignore index 9ae452b64..ecdf734b4 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ distribute-* .coverage.* coverage.xml venv*/ +.venv*/ .vscode/ .eggs/ .tox/ diff --git a/changes/2147.feature.rst b/changes/2147.feature.rst new file mode 100644 index 000000000..b1f0974b9 --- /dev/null +++ b/changes/2147.feature.rst @@ -0,0 +1 @@ +Added basic functions for an ``--debug `` option. diff --git a/docs/how-to/debugging/console.rst b/docs/how-to/debugging/console.rst new file mode 100644 index 000000000..791f4961c --- /dev/null +++ b/docs/how-to/debugging/console.rst @@ -0,0 +1,74 @@ +================= +Debug via Console +================= + +Debugging an app on the console is normally done via `PDB `_. +It is possible to debug a briefcase app at different stages in your development +process. You can debug a development app via ``briefcase dev``, but also an bundled +app that is build via ``briefcase build`` and run via ``briefcase run``. + + +Development +----------- +Debugging an development app is quiet easy. Just add ``breakpoint()`` inside +your code and start the app via ``briefcase dev``. When the breakpoint got hit +the pdb console opens on your console and you can debug your app. + + +Bundled App +----------- +It is also possible to debug a bundled app. This is the only way to debug your +app on a mobile device (iOS/Android). Note that there are some :ref:`limitations ` +when debugging an bundled app. + +For this you need to embed a remote debugger into your app. This is done via: + +.. code-block:: console + + $ briefcase build --debug pdb + +This will build your app in debug mode and add `remote-pdb `_ +together with a package that automatically starts ``remote-pdb`` at the +startup of your bundled app. Additionally it will optimize the app for +debugging. This means e.g. that all ``.py`` files are accessible on the device. + +Then it is time to run your app. You can do this via: + +.. code-block:: 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``. + +Then it is time to create a new console window on your host system and connect +to your bundled app by calling: + +.. tabs:: + + .. group-tab:: Windows + + .. code-block:: console + + $ telnet localhost 5678 + + .. group-tab:: Linux + + .. code-block:: console + + $ telnet localhost 5678 + + or + + .. code-block:: console + + $ rlwrap socat - tcp:localhost:5678 + + .. group-tab:: macOS + + .. code-block:: console + + $ rlwrap socat - tcp:localhost:5678 + +The app will start after the connection is established. diff --git a/docs/how-to/debugging/index.rst b/docs/how-to/debugging/index.rst new file mode 100644 index 000000000..36bd6f624 --- /dev/null +++ b/docs/how-to/debugging/index.rst @@ -0,0 +1,25 @@ +============== +Debug your app +============== + +If you get stuck when programming your app, it is time to debug your app. The +following sections describe how you can debug your app with or without an IDE. + +.. toctree:: + :maxdepth: 1 + + console + vscode + + +.. _debugging_limitations: + +Limitations when debugging bundled apps +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +To debug an bundle app you need an network connection from your host system to +the device your are trying to debug. If your bundled app is also running on +your host system this is no problem. But when debugging a mobile device your +app is running on another device. Running an iOS app in simulator is also no +problem, because the simulator shares the same network stack as your host. +But on Android there is a separate network stack. That's why briefcase will +automatically forward the port from your host to the Android device via ADB. diff --git a/docs/how-to/debugging/vscode.rst b/docs/how-to/debugging/vscode.rst new file mode 100644 index 000000000..0722ac9e1 --- /dev/null +++ b/docs/how-to/debugging/vscode.rst @@ -0,0 +1,83 @@ +================ +Debug via VSCode +================ + +Debugging is possible at different stages in your development process. It is +different to debug a development app via ``briefcase dev`` than an bundled app +that is build via ``briefcase build`` and run via ``briefcase run``. + +Development +----------- +During development on your host system you should use ``briefcase dev``. To +attach VSCode debugger you can simply create a configuration like this, +that runs ``briefcase dev`` for you and attaches a debugger. + +.. code-block:: JSON + + { + "version": "0.2.0", + "configurations": [ + { + "name": "Briefcase: Dev", + "type": "debugpy", + "request": "launch", + "module": "briefcase", + "args": [ + "dev", + ], + "justMyCode": false + }, + ] + } + + +Bundled App +----------- +It is also possible to debug a bundled app. This is the only way to debug your +app on a mobile device (iOS/Android). Note that there are some :ref:`limitations ` +when debugging an bundled app. + +For this you need to embed a remote debugger into your app. This is done via: + +.. code-block:: console + + $ briefcase build --debug debugpy + +This will build your app in debug mode and add the `debugpy `_ together with a +package that automatically starts ``debugpy`` at the startup of your bundled app. +Additionally it will optimize the app for debugging. This means e.g. that all +``.py`` files are accessible on the device. + +Then it is time to run your app. You can do this via: + +.. code-block:: 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 VSCode debugger to the app by +creating a configuration like this in the ``launch.json`` file: + +.. code-block:: JSON + + { + "version": "0.2.0", + "configurations": [ + { + "name": "Briefcase: Attach", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + } + } + ] + } + +The app will not start until you attach the debugger. Once you attached the +VSCode debugger you are ready to debug your app. You can set `breakpoints `_ +, use the `data inspection `_ +, use the `debug console REPL `_ +and all other debugging features of VSCode :) diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst index 1e60be3e9..d206f3c03 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -17,6 +17,7 @@ stand alone. ci cli-apps x11passthrough + debugging/index external-apps publishing/index contribute/index diff --git a/docs/reference/commands/build.rst b/docs/reference/commands/build.rst index b1fd77dc4..d69b66696 100644 --- a/docs/reference/commands/build.rst +++ b/docs/reference/commands/build.rst @@ -113,6 +113,24 @@ If you have previously run the app in "normal" mode, you may need to pass ``-r`` / ``--update-requirements`` the first time you build in test mode to ensure that your testing requirements are present in the test app. +``--debug `` +---------------------- + +Build the app in debug mode in the bundled app environment and establish an +debugger connection via a socket. This installs the selected debugger in the +bundled app. + +Currently the following debuggers are supported (default is ``pdb``): + +- ``pdb``: This is used for debugging via console (see :doc:`Debug via Console `) +- ``debugpy``: This is used for debugging via VSCode (see :doc:`Debug via VSCode `) + +It also optimizes the app for debugging. E.g. on Android it ensures, that all +`.py` files are extracted from the APK and are accessible for the debugger. + +This option may slow down the app a little bit. + + ``--no-update`` --------------- diff --git a/docs/reference/commands/run.rst b/docs/reference/commands/run.rst index 86b8023c2..2ecf7644f 100644 --- a/docs/reference/commands/run.rst +++ b/docs/reference/commands/run.rst @@ -137,6 +137,39 @@ contains the most recent test code. To prevent this update and build, use the Prevent the automated update and build of app code that is performed when specifying by the ``--test`` option. +``--debug `` +---------------------- + +Run the app in debug mode in the bundled app environment and establish an +debugger connection via a socket. + +Currently the following debuggers are supported (default is ``pdb``): + +- ``pdb``: This is used for debugging via console (see :doc:`Debug via Console `) +- ``debugpy``: This is used for debugging via VSCode (see :doc:`Debug via VSCode `) + +For ``debugpy`` there is also a mapping of the source code from your bundled +app to your local copy of the apps source code in the ``build`` folder. This +is useful for devices like iOS and Android, where the running source code is +not available on the host system. + +``--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``. + +On Android this also forwards the port from the Android device to the host pc +via ADB if the port is ``localhost``. + Passthrough arguments --------------------- diff --git a/pyproject.toml b/pyproject.toml index a246c68b4..76aa9befd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,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 71a8f49f4..3385f83c2 100644 --- a/src/briefcase/commands/base.py +++ b/src/briefcase/commands/base.py @@ -21,6 +21,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 @@ -134,6 +136,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 @@ -681,7 +685,12 @@ def finalize_app_config(self, app: AppConfig): :param app: The app configuration to finalize. """ - 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, + ): """Finalize Briefcase configuration. This will: @@ -701,6 +710,7 @@ 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__"): + self.finalize_debugger(app, debugger) app.test_mode = test_mode self.finalize_app_config(app) delattr(app, "__draft__") @@ -726,6 +736,18 @@ def finalize(self, app: AppConfig | None = None, test_mode: bool = False): f"{app.app_name!r} defines 'external_package_executable_path', but not 'external_package_path'." ) + def finalize_debugger(self, app: AppConfig, debugger_name: str | None = None): + """Finalize the debugger configuration. + + This will ensure that the debugger is available and that the app + configuration is valid. + + :param app: The app configuration to finalize. + """ + if debugger_name and debugger_name != "": + debugger = get_debugger(debugger_name) + app.debugger = debugger + def verify_app(self, app: AppConfig): """Verify the app is compatible and the app tools are available. @@ -982,6 +1004,28 @@ 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): + """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())) + choices_help = [f"'{choice}'" for choice in debugger_names] + + 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. One of {', '.join(choices_help)} (default: pdb)", + ) + 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 d9476f515..2bafdd84b 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", @@ -73,6 +76,9 @@ def _build_app( or ( app.test_mode and not no_update ) # Test mode, but updates have not been disabled + or ( + app.debugger and not no_update + ) # Debug mode, but updates have not been disabled ): state = self.update_command( app, @@ -90,6 +96,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, @@ -107,6 +114,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? @@ -135,7 +143,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 16bbfb8ae..d650fa23a 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -688,6 +688,10 @@ def install_app_requirements(self, app: AppConfig): if app.test_mode and app.test_requires: requires.extend(app.test_requires) + if app.debugger: + debugger_support_pkg = self.create_debugger_support_pkg(app) + requires.append(str(debugger_support_pkg.absolute())) + try: requirements_path = self.app_requirements_path(app) except KeyError: @@ -751,6 +755,27 @@ def install_app_code(self, app: AppConfig): / f"{app.module_name}-{app.version}.dist-info", ) + def create_debugger_support_pkg(self, app: AppConfig) -> Path: + """ + Create the debugger support package. + + This package is used to inject debugger support into the app when it is + run in debug mode. It is necessary to create this as own package, because + the code is automatically started via an .pth file and the .pth file + has to be located in the app's site-packages directory, that it is executed + correctly. + """ + # Remove existing debugger support folder if it exists + debugger_support_path = self.bundle_path(app) / ".debugger_support_package" + if debugger_support_path.exists(): + self.tools.shutil.rmtree(debugger_support_path) + self.tools.os.mkdir(debugger_support_path) + + # Create files for the debugger support package + app.debugger.create_debugger_support_pkg(debugger_support_path) + + return debugger_support_path + def install_image(self, role, variant, size, source, target): """Install an icon/image of the requested size at a target location, using the source images defined by the app config. diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py index 6163d0fc2..e6306e423 100644 --- a/src/briefcase/commands/run.py +++ b/src/briefcase/commands/run.py @@ -218,6 +218,23 @@ def add_options(self, parser): self._add_update_options(parser, context_label=" before running") self._add_test_options(parser, context_label="Run") + if self.supports_debugger: + self._add_debug_options(parser, context_label="Run") + parser.add_argument( + "--debugger-host", + default="localhost", + help="The host on which to run the debug server (default: localhost)", + required=False, + ) + parser.add_argument( + "-dp", + "--debugger-port", + default=5678, + type=int, + help="The port on which to run the debug server (default: 5678)", + required=False, + ) + def _prepare_app_kwargs(self, app: AppConfig): """Prepare the kwargs for running an app as a log stream. @@ -249,10 +266,20 @@ def _prepare_app_kwargs(self, app: AppConfig): return args @abstractmethod - def run_app(self, app: AppConfig, **options) -> dict | None: + def run_app( + self, + app: AppConfig, + debugger_host: str | None, + debugger_port: int | None, + passthrough: list[str], + **options, + ) -> dict | None: """Start an application. :param app: The application to start + :param debugger_host: The host on which to run the debug server + :param debugger_port: The port on which to run the debug server + :param passthrough: Any passthrough arguments """ def __call__( @@ -265,6 +292,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: @@ -287,7 +317,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) template_file = self.bundle_path(app) exec_file = self.binary_executable_path(app) @@ -326,6 +356,8 @@ def __call__( state = self.run_app( app, + debugger_host=debugger_host, + debugger_port=debugger_port, passthrough=[] if passthrough is None else passthrough, **full_options(state, options), ) diff --git a/src/briefcase/commands/update.py b/src/briefcase/commands/update.py index 372715a62..f62554d0b 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 7659880be..0812c2353 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 @@ -394,6 +395,8 @@ def __init__( self.test_mode: bool = False + self.debugger: BaseDebugger | None = None + 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..0574b988a --- /dev/null +++ b/src/briefcase/debuggers/__init__.py @@ -0,0 +1,30 @@ +import sys + +from briefcase.exceptions import BriefcaseCommandError + +if sys.version_info >= (3, 10): # pragma: no-cover-if-lt-py310 + from importlib.metadata import entry_points +else: # pragma: no-cover-if-gte-py310 + # Before Python 3.10, entry_points did not support the group argument; + # so, the backport package must be used on older versions. + 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 + + +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..dff3b4f1e --- /dev/null +++ b/src/briefcase/debuggers/base.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import enum +from abc import ABC, abstractmethod +from pathlib import Path +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): + host: str + port: int + 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 connection_mode(self) -> DebuggerConnectionMode: + """Return the connection mode of the debugger.""" + + @abstractmethod + def create_debugger_support_pkg(self, dir: Path) -> None: + """Create the support package for the debugger. + This package will be installed inside the packaged app bundle. + + :param dir: Directory where the support package should be created. + """ + + def _create_debugger_support_pkg_base( + self, dir: Path, dependencies: list[str] + ) -> None: + """Create the base for the support package for the debugger. + + :param dir: Directory where the support package should be created. + :param dependencies: List of dependencies to include in the package. + """ + pyproject = dir / "pyproject.toml" + setup = dir / "setup.py" + debugger_support = dir / "briefcase_debugger_support" / "__init__.py" + debugger_support.parent.mkdir(parents=True) + + pyproject.write_text( + f"""\ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "briefcase-debugger-support" +version = "0.1.0" +description = "Add-on for briefcase to add remote debugging." +license = {{ file = "MIT" }} +dependencies = {dependencies} +""", + encoding="utf-8", + ) + + setup.write_text( + '''\ +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_support' + _pth_contents = "import briefcase_debugger_support" + + 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}, +) +''', + encoding="utf-8", + ) + + debugger_support.write_text( + """\ +import json +import os +import sys +import traceback +from briefcase_debugger_support._remote_debugger import _start_remote_debugger + +REMOTE_DEBUGGER_STARTED = False + +def start_remote_debugger(): + global REMOTE_DEBUGGER_STARTED + REMOTE_DEBUGGER_STARTED = True + + # check verbose output + verbose = True if os.environ.get("BRIEFCASE_DEBUG", "0") == "1" else False + + # 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}") + + # start debugger + print("Starting remote debugger...") + _start_remote_debugger(config_str, verbose) + + +# only start remote debugger on the first import +if REMOTE_DEBUGGER_STARTED == False: + try: + start_remote_debugger() + except Exception: + # Show exception and stop the whole application when an error occurs + print(traceback.format_exc()) + sys.exit(-1) +""", + encoding="utf-8", + ) diff --git a/src/briefcase/debuggers/debugpy.py b/src/briefcase/debuggers/debugpy.py new file mode 100644 index 000000000..3fe9869bf --- /dev/null +++ b/src/briefcase/debuggers/debugpy.py @@ -0,0 +1,160 @@ +from pathlib import Path + +from briefcase.debuggers.base import BaseDebugger, DebuggerConnectionMode + + +class DebugpyDebugger(BaseDebugger): + """Definition for a plugin that defines a new Briefcase debugger.""" + + @property + def connection_mode(self) -> DebuggerConnectionMode: + """Return the connection mode of the debugger.""" + return DebuggerConnectionMode.SERVER + + def create_debugger_support_pkg(self, dir: Path) -> None: + """Create the support package for the debugger. + This package will be installed inside the packaged app bundle. + + :param dir: Directory where the support package should be created. + """ + self._create_debugger_support_pkg_base( + dir, + dependencies=["debugpy>=1.8.14,<2.0.0"], + ) + + remote_debugger = dir / "briefcase_debugger_support" / "_remote_debugger.py" + remote_debugger.write_text( + '''\ +import json +import os +import re +import sys +import traceback +from pathlib import Path +from typing import List, Optional, Tuple, TypedDict + +import debugpy + +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): + host: str + port: int + app_path_mappings: AppPathMappings | None + app_packages_path_mappings: AppPackagesPathMappings | None + + +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 = next( + ( + p + for p in sys.path + if re.search(app_path_mappings["device_sys_path_regex"], p) + ), + None, + ) + if device_app_folder: + for app_subfolder_device, app_subfolder_host in zip( + app_path_mappings["device_subfolders"], + app_path_mappings["host_folders"], + ): + mappings_list.append( + ( + app_subfolder_host, + str(Path(device_app_folder) / app_subfolder_device), + ) + ) + if app_packages_path_mappings: + device_app_packages_folder = next( + ( + p + for p in sys.path + if re.search(app_packages_path_mappings["sys_path_regex"], p) + ), + None, + ) + if device_app_packages_folder: + mappings_list.append( + ( + app_packages_path_mappings["host_folder"], + str(Path(device_app_packages_folder)), + ) + ) + + if verbose: + print("Extracted path mappings:") + for idx, p in enumerate(mappings_list): + print(f"[{idx}] host = {p[0]}") + print(f"[{idx}] device = {p[1]}") + + return mappings_list + + +def _start_remote_debugger(config_str: str, verbose: bool): + # Parsing config json + debugger_config: dict = json.loads(config_str) + + host = debugger_config["host"] + port = debugger_config["port"] + path_mappings = _load_path_mappings(debugger_config, verbose) + + # When an app is bundled with briefcase "os.__file__" is not set at runtime + # on some platforms (eg. windows). But debugpy accesses it internally, so it + # has to be set or an Exception is raised from debugpy. + if not hasattr(os, "__file__"): + if verbose: + print("'os.__file__' not available. Patching it...") + os.__file__ = "" + + # Starting remote debugger... + print(f"Starting debugpy in server mode at {host}:{port}...") + debugpy.listen((host, port), in_process_debug_adapter=True) + + if len(path_mappings) > 0: + if verbose: + print("Adding path mappings...") + + 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 VSCode add the following configuration to launch.json: +{{ + "version": "0.2.0", + "configurations": [ + {{ + "name": "Briefcase: Attach (Connect)", + "type": "debugpy", + "request": "attach", + "connect": {{ + "host": "{host}", + "port": {port} + }} + }} + ] +}} +""" + ) + debugpy.wait_for_client() + + print("Debugger attached.") +''', + encoding="utf-8", + ) diff --git a/src/briefcase/debuggers/pdb.py b/src/briefcase/debuggers/pdb.py new file mode 100644 index 000000000..6d9a1b125 --- /dev/null +++ b/src/briefcase/debuggers/pdb.py @@ -0,0 +1,59 @@ +from pathlib import Path + +from briefcase.debuggers.base import BaseDebugger, DebuggerConnectionMode + + +class PdbDebugger(BaseDebugger): + """Definition for a plugin that defines a new Briefcase debugger.""" + + @property + def connection_mode(self) -> DebuggerConnectionMode: + """Return the connection mode of the debugger.""" + return DebuggerConnectionMode.SERVER + + def create_debugger_support_pkg(self, dir: Path) -> None: + """Create the support package for the debugger. + This package will be installed inside the packaged app bundle. + + :param dir: Directory where the support package should be created. + """ + self._create_debugger_support_pkg_base( + dir, + dependencies=["remote-pdb>=2.1.0,<3.0.0"], + ) + + remote_debugger = dir / "briefcase_debugger_support" / "_remote_debugger.py" + remote_debugger.write_text( + '''\ +import json +import sys + +from remote_pdb import RemotePdb + +def _start_remote_debugger(config_str: str, verbose: bool): + """Start remote PDB server.""" + debugger_config: dict = json.loads(config_str) + + # Parsing host/port + host = debugger_config["host"] + port = debugger_config["port"] + + print( + f""" +Remote PDB server opened at {host}:{port}, waiting for connection... +To connect to remote PDB use eg.: + - telnet {host} {port} (Windows, Linux) + - rlwrap socat - tcp:{host}:{port} (Linux, macOS) +""" + ) + + # 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.") +''', + encoding="utf-8", + ) diff --git a/tests/commands/build/conftest.py b/tests/commands/build/conftest.py index edf6a45d3..e893a9af8 100644 --- a/tests/commands/build/conftest.py +++ b/tests/commands/build/conftest.py @@ -52,7 +52,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) @@ -66,12 +74,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..c998c650e 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 build with update and no-update, an error is raised.""" + # Add two apps + build_command.apps = { + "first": first_app, + "second": second_app, + } + + # Configure command line options + with pytest.raises(SystemExit): + options, _ = 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 fb30e9962..69ef67a27 100644 --- a/tests/commands/create/conftest.py +++ b/tests/commands/create/conftest.py @@ -99,7 +99,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. @@ -179,7 +179,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 4db08adf2..548a70a35 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 7e12f7109..49292b65b 100644 --- a/tests/commands/create/test_generate_app_template.py +++ b/tests/commands/create/test_generate_app_template.py @@ -31,6 +31,7 @@ def full_context(): "sources": ["src/my_app"], "test_sources": None, "test_requires": None, + "debugger": 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 b33a0a150..3a78c784e 100644 --- a/tests/commands/create/test_install_app_requirements.py +++ b/tests/commands/create/test_install_app_requirements.py @@ -2,6 +2,7 @@ import os import subprocess import sys +from pathlib import Path from unittest import mock import pytest @@ -10,6 +11,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 +1087,120 @@ 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): + debugger_support_pkg_dir = None + + @property + def connection_mode(self) -> DebuggerConnectionMode: + raise NotImplementedError + + def create_debugger_support_pkg(self, dir: Path) -> None: + self.debugger_support_pkg_dir = dir + (dir / "dummy.py").write_text("# Dummy", encoding="utf8") + + +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", + f"{bundle_path / '.debugger_support_package'}", + ], + check=True, + encoding="UTF-8", + ) + + # Original app definitions haven't changed + assert myapp.requires == ["first", "second==1.2.3", "third>=3.2.1"] + + # The debugger support package directory was created + assert ( + myapp.debugger.debugger_support_pkg_dir + == bundle_path / ".debugger_support_package" + ) + + # Check that the debugger support package exists + assert (bundle_path / ".debugger_support_package").exists() + assert (bundle_path / ".debugger_support_package" / "dummy.py").exists() + + +def test_app_packages_debugger_clear_old_package( + 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 dummy debugger support package directory, that should be cleared + (bundle_path / ".debugger_support_package").mkdir(parents=True, exist_ok=True) + (bundle_path / ".debugger_support_package" / "some_old_file.py").write_text( + "# some old file content", encoding="utf8" + ) + + 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", + f"{bundle_path / '.debugger_support_package'}", + ], + check=True, + encoding="UTF-8", + ) + + # Original app definitions haven't changed + assert myapp.requires == ["first", "second==1.2.3", "third>=3.2.1"] + + # The debugger support package directory was created + assert ( + myapp.debugger.debugger_support_pkg_dir + == bundle_path / ".debugger_support_package" + ) + + # Check that "some_old_file.py" got deleted + assert os.listdir(bundle_path / ".debugger_support_package") == ["dummy.py"] diff --git a/tests/commands/run/conftest.py b/tests/commands/run/conftest.py index 62d8c1179..078a70198 100644 --- a/tests/commands/run/conftest.py +++ b/tests/commands/run/conftest.py @@ -52,7 +52,15 @@ 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, + kwargs.copy(), + ) + ) # Remove arguments consumed by the underlying call to run_app() kwargs.pop("update", None) kwargs.pop("update_requirements", None) @@ -81,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..7f8ccfd8f 100644 --- a/tests/commands/run/test_call.py +++ b/tests/commands/run/test_call.py @@ -29,7 +29,13 @@ 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, + {"debugger_host": None, "debugger_port": None, "passthrough": []}, + ), ] @@ -60,7 +66,17 @@ 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, + { + "debugger_host": None, + "debugger_port": None, + "passthrough": ["foo", "--bar"], + }, + ), ] @@ -109,7 +125,13 @@ 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, + {"debugger_host": None, "debugger_port": None, "passthrough": []}, + ), ] @@ -140,7 +162,13 @@ 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, + {"debugger_host": None, "debugger_port": 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,13 @@ def test_create_app_before_start(run_command, first_app_config): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -240,6 +275,7 @@ def test_build_app_before_start(run_command, first_app_unbuilt): "build", "first", False, + False, { "update": False, "update_requirements": False, @@ -258,7 +294,13 @@ def test_build_app_before_start(run_command, first_app_unbuilt): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -289,6 +331,7 @@ def test_update_app(run_command, first_app): "build", "first", False, + False, { "update": True, "update_requirements": False, @@ -307,7 +350,13 @@ def test_update_app(run_command, first_app): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -338,6 +387,7 @@ def test_update_app_requirements(run_command, first_app): "build", "first", False, + False, { "update": False, "update_requirements": True, @@ -356,7 +406,13 @@ def test_update_app_requirements(run_command, first_app): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -387,6 +443,7 @@ def test_update_app_resources(run_command, first_app): "build", "first", False, + False, { "update": False, "update_requirements": False, @@ -405,7 +462,13 @@ def test_update_app_resources(run_command, first_app): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -436,6 +499,7 @@ def test_update_app_support(run_command, first_app): "build", "first", False, + False, { "update": False, "update_requirements": False, @@ -454,7 +518,13 @@ def test_update_app_support(run_command, first_app): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -485,6 +555,7 @@ def test_update_app_stub(run_command, first_app): "build", "first", False, + False, { "update": False, "update_requirements": False, @@ -503,7 +574,13 @@ def test_update_app_stub(run_command, first_app): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -535,6 +612,7 @@ def test_update_unbuilt_app(run_command, first_app_unbuilt): "build", "first", False, + False, { "update": True, "update_requirements": False, @@ -553,7 +631,13 @@ def test_update_unbuilt_app(run_command, first_app_unbuilt): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -585,6 +669,7 @@ def test_update_non_existent(run_command, first_app_config): "build", "first", False, + False, { "update": True, "update_requirements": False, @@ -603,7 +688,13 @@ def test_update_non_existent(run_command, first_app_config): "run", "first", False, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -634,6 +725,7 @@ def test_test_mode_existing_app(run_command, first_app): "build", "first", True, + False, { "update": False, "update_requirements": False, @@ -652,7 +744,13 @@ def test_test_mode_existing_app(run_command, first_app): "run", "first", True, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -683,6 +781,7 @@ def test_test_mode_existing_app_with_passthrough(run_command, first_app): "build", "first", True, + False, { "update": False, "update_requirements": False, @@ -701,8 +800,11 @@ def test_test_mode_existing_app_with_passthrough(run_command, first_app): "run", "first", True, + False, { "build_state": "first", + "debugger_host": None, + "debugger_port": None, "passthrough": ["foo", "--bar"], }, ), @@ -740,7 +842,8 @@ def test_test_mode_existing_app_no_update(run_command, first_app): "run", "first", True, - {"passthrough": []}, + False, + {"debugger_host": None, "debugger_port": None, "passthrough": []}, ), ] @@ -771,6 +874,7 @@ def test_test_mode_existing_app_update_requirements(run_command, first_app): "build", "first", True, + False, { "update": False, "update_requirements": True, @@ -789,7 +893,13 @@ def test_test_mode_existing_app_update_requirements(run_command, first_app): "run", "first", True, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -820,6 +930,7 @@ def test_test_mode_existing_app_update_resources(run_command, first_app): "build", "first", True, + False, { "update": False, "update_requirements": False, @@ -838,7 +949,13 @@ def test_test_mode_existing_app_update_resources(run_command, first_app): "run", "first", True, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -869,6 +986,7 @@ def test_test_mode_update_existing_app(run_command, first_app): "build", "first", True, + False, { "update": True, "update_requirements": False, @@ -887,7 +1005,13 @@ def test_test_mode_update_existing_app(run_command, first_app): "run", "first", True, - {"build_state": "first", "passthrough": []}, + False, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "passthrough": [], + }, ), ] @@ -918,6 +1042,65 @@ 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, + { + "build_state": "first", + "debugger_host": None, + "debugger_port": None, + "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 +1118,14 @@ def test_test_mode_non_existent(run_command, first_app_config): ( "run", "first", + False, True, - {"build_state": "first", "passthrough": []}, + { + "build_state": "first", + "debugger_host": "localhost", + "debugger_port": 5678, + "passthrough": [], + }, ), ] diff --git a/tests/commands/update/conftest.py b/tests/commands/update/conftest.py index a28fb8365..2cba43395 100644 --- a/tests/commands/update/conftest.py +++ b/tests/commands/update/conftest.py @@ -53,7 +53,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..d121fe2bb --- /dev/null +++ b/tests/debuggers/test_base.py @@ -0,0 +1,80 @@ +import py_compile +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +if sys.version_info >= (3, 11): # pragma: no-cover-if-lt-py311 + import tomllib +else: # pragma: no-cover-if-gte-py311 + import tomli as tomllib + +from briefcase.debuggers import ( + DebugpyDebugger, + PdbDebugger, + get_debugger, + get_debuggers, +) +from briefcase.debuggers.base import DebuggerConnectionMode +from briefcase.exceptions import BriefcaseCommandError + + +def test_get_debuggers(): + debuggers = get_debuggers() + assert isinstance(debuggers, dict) + assert debuggers["pdb"] is PdbDebugger + assert debuggers["debugpy"] is DebugpyDebugger + + +def test_get_debugger(): + assert isinstance(get_debugger("pdb"), PdbDebugger) + assert isinstance(get_debugger("debugpy"), DebugpyDebugger) + + # Test with an unknown debugger name + try: + get_debugger("unknown") + except BriefcaseCommandError as e: + assert str(e) == "Unknown 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): + debugger = get_debugger(debugger_name) + assert isinstance(debugger, expected_class) + assert debugger.connection_mode == connection_mode + + with TemporaryDirectory() as tmp_path: + tmp_path = Path(tmp_path) + debugger.create_debugger_support_pkg(tmp_path) + + # Try to parse pyproject.toml to check for toml-format errors + with (tmp_path / "pyproject.toml").open("rb") as f: + tomllib.load(f) + + # try to compile to check existence and for syntax errors + assert py_compile.compile(tmp_path / "setup.py") is not None + assert ( + py_compile.compile(tmp_path / "briefcase_debugger_support" / "__init__.py") + is not None + ) + assert ( + py_compile.compile( + tmp_path / "briefcase_debugger_support" / "_remote_debugger.py" + ) + is not None + )