From f95607cb7b33c5f8c6ee1389191f8df52a85e0a6 Mon Sep 17 00:00:00 2001 From: Russell Martin Date: Wed, 3 May 2023 10:39:36 -0400 Subject: [PATCH] Add conditional coverage flags for accurate platform coverage --- .github/workflows/ci.yml | 135 ++++++++++++++---- changes/1260.misc.rst | 1 + pyproject.toml | 32 +++++ setup.cfg | 5 +- src/briefcase/__init__.py | 2 +- src/briefcase/commands/base.py | 12 +- src/briefcase/commands/open.py | 6 +- src/briefcase/config.py | 2 +- src/briefcase/integrations/docker.py | 13 +- src/briefcase/integrations/linuxdeploy.py | 2 +- src/briefcase/integrations/windows_sdk.py | 2 +- src/briefcase/platforms/__init__.py | 2 +- src/briefcase/platforms/linux/__init__.py | 4 +- src/briefcase/platforms/linux/appimage.py | 2 +- src/briefcase/platforms/linux/system.py | 10 +- src/briefcase/platforms/macOS/__init__.py | 8 +- src/briefcase/platforms/web/static.py | 2 +- tests/commands/dev/test_get_environment.py | 17 +++ .../android_sdk/AndroidSDK/test_verify.py | 54 ++++--- tests/platforms/iOS/xcode/test_mixin.py | 39 +++++ tests/platforms/linux/appimage/test_mixin.py | 11 ++ .../linux/test_LocalRequirementsMixin.py | 3 +- tests/platforms/macOS/app/test_package.py | 25 +++- tests/platforms/macOS/xcode/test_package.py | 57 +++++++- .../windows/visualstudio/test_build.py | 16 ++- tox.ini | 44 ++++-- 26 files changed, 405 insertions(+), 101 deletions(-) create mode 100644 changes/1260.misc.rst diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc8efa524..4a1617f91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,17 +29,17 @@ jobs: uses: beeware/.github/.github/workflows/towncrier-run.yml@main package: - name: Python Package + name: Python package uses: beeware/.github/.github/workflows/python-package-create.yml@main unit-tests: name: Unit tests - needs: [pre-commit, towncrier, package] + needs: [ pre-commit, towncrier, package ] runs-on: ${{ matrix.platform }}-latest continue-on-error: ${{ matrix.experimental }} strategy: matrix: - platform: [ "macos", "ubuntu", "windows" ] + platform: [ "macOS", "Ubuntu", "Windows" ] python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12-dev" ] include: - experimental: false @@ -47,7 +47,7 @@ jobs: - python-version: "3.12-dev" experimental: true # Run tests against the latest Windows Store Python - - platform: "windows" + - platform: "Windows" python-version: "winstore" experimental: false steps: @@ -57,7 +57,7 @@ jobs: fetch-depth: 0 - name: Set up Python - if: matrix.python-version != 'winstore' + if: startswith(matrix.python-version, '3') uses: actions/setup-python@v4.6.0 with: python-version: ${{ matrix.python-version }} @@ -65,44 +65,122 @@ jobs: - name: Install Windows Store Python if: matrix.python-version == 'winstore' uses: beeware/.github/.github/actions/install-win-store-python@main - with: - python-version: "3.11" - - name: Get packages + - name: Get Packages uses: actions/download-artifact@v3.0.2 with: name: ${{ needs.package.outputs.artifact-name }} path: dist - - name: Install dev dependencies + - name: Install dev Dependencies run: | - # pip 23.1 has an issue with --user installs. - # See https://github.com/pypa/pip/issues/11982 for details - python -m pip install --upgrade "pip!=23.1" + python -m pip install --upgrade pip python -m pip install --upgrade setuptools # We don't actually want to install briefcase; we just # want the dev extras so we have a known version of tox. python -m pip install $(ls dist/briefcase-*.whl)[dev] - name: Test + id: test + env: + COVERAGE_FILE: ".coverage.${{ matrix.platform }}.${{ matrix.python-version }}" run: tox -e py --installpkg dist/briefcase-*.whl - - name: Store coverage data + - name: Store Coverage Data + if: always() && (steps.test.outcome == 'failure' || steps.test.outcome == 'success') uses: actions/upload-artifact@v3.1.2 with: name: coverage-data path: ".coverage.*" if-no-files-found: ignore - - name: Report platform coverage - run: tox -e coverage + coverage-platform: + name: Platform coverage - ${{ matrix.platform }} + runs-on: ${{ matrix.platform }}-latest + needs: unit-tests + strategy: + fail-fast: false + matrix: + platform: [ "macOS", "Ubuntu", "Windows" ] + steps: + - name: Checkout + uses: actions/checkout@v3.5.2 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v4.6.0 + with: + python-version: | + 3.12-dev + 3.11 + 3.10 + 3.9 + 3.8 + + - name: Install dev Dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade setuptools + # We don't actually want to install briefcase; we just + # want the dev extras so we have a known version of tox. + python -m pip install -e .[dev] + + - name: Retrieve Coverage Data + uses: actions/download-artifact@v3.0.2 + with: + name: coverage-data - coverage: - name: Combine & check coverage. + - name: ${{ matrix.platform }} Coverage Report + env: + COVERAGE_FILE: ".coverage.${{ matrix.platform }}" + run: tox -qe coverage-platform-html-keep + + - name: Python 3.12 on ${{ matrix.platform }} Coverage Report + if: success() || failure() + continue-on-error: true + env: + COVERAGE_FILE: ".coverage.${{ matrix.platform }}.3.12-dev" + run: tox -qe coverage312-html-keep + + - name: Python 3.11 on ${{ matrix.platform }} Coverage Report + if: success() || failure() + env: + COVERAGE_FILE: ".coverage.${{ matrix.platform }}.3.11" + run: tox -qe coverage311-html-keep + + - name: Python 3.10 on ${{ matrix.platform }} Coverage Report + if: success() || failure() + env: + COVERAGE_FILE: ".coverage.${{ matrix.platform }}.3.10" + run: tox -qe coverage310-html-keep + + - name: Python 3.9 on ${{ matrix.platform }} Coverage Report + if: success() || failure() + env: + COVERAGE_FILE: ".coverage.${{ matrix.platform }}.3.9" + run: tox -qe coverage39-html-keep + + - name: Python 3.8 on ${{ matrix.platform }} Coverage Report + if: success() || failure() + env: + COVERAGE_FILE: ".coverage.${{ matrix.platform }}.3.8" + run: tox -qe coverage38-html-keep + + - name: Upload HTML Coverage Report + if: failure() + uses: actions/upload-artifact@v3.1.2 + with: + name: html-platform-coverage-report-${{ matrix.platform }} + path: htmlcov + + coverage-project: + name: Project coverage runs-on: ubuntu-latest needs: unit-tests steps: - - uses: actions/checkout@v3.5.2 + - name: Checkout + uses: actions/checkout@v3.5.2 with: fetch-depth: 0 @@ -113,33 +191,32 @@ jobs: # https://github.com/nedbat/coveragepy/issues/1572#issuecomment-1522546425 python-version: "3.8" - - name: Install dev dependencies + - name: Install dev Dependencies run: | - # pip 23.1 has an issue with --user installs. - # See https://github.com/pypa/pip/issues/11982 for details - python -m pip install --upgrade "pip!=23.1" + python -m pip install --upgrade pip python -m pip install --upgrade setuptools # We don't actually want to install briefcase; we just # want the dev extras so we have a known version of tox. python -m pip install -e .[dev] - - name: Retrieve coverage data + - name: Retrieve Coverage Data uses: actions/download-artifact@v3.0.2 with: name: coverage-data - - name: Generate coverage report - run: tox -e coverage-html-fail + - name: Project Coverage Report + id: coverage + run: tox -qe coverage-project-html - - name: Upload HTML report if check failed. - if: ${{ failure() }} + - name: Upload HTML Coverage Report + if: always() && steps.coverage.outcome == 'failure' uses: actions/upload-artifact@v3.1.2 with: - name: html-coverage-report + name: html-project-coverage-report path: htmlcov verify-apps: - name: Build App + name: Build app needs: unit-tests uses: beeware/.github/.github/workflows/app-build-verify.yml@main with: diff --git a/changes/1260.misc.rst b/changes/1260.misc.rst new file mode 100644 index 000000000..66af39fb3 --- /dev/null +++ b/changes/1260.misc.rst @@ -0,0 +1 @@ +Coverage reporting for a specific versions of Python or a specific platform is now supported. diff --git a/pyproject.toml b/pyproject.toml index 9c1d2e449..90289664e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,7 @@ requires = ["setuptools>=60", "setuptools_scm[toml]>=7.0"] build-backend = "setuptools.build_meta" [tool.coverage.run] +plugins = ["coverage_conditional_plugin"] parallel = true branch = true relative_files = true @@ -26,6 +27,37 @@ exclude_lines = [ "if TYPE_CHECKING:", ] +[tool.coverage.coverage_conditional_plugin.rules] +# Packages/Modules +no-cover-if-missing-setuptools_scm = "not is_installed('setuptools_scm')" +# Linux +no-cover-if-is-linux = "sys_platform == 'linux' and os_environ.get('COVERAGE_EXCLUDE_PLATFORM') != 'disable'" +no-cover-if-not-linux = "sys_platform != 'linux' and os_environ.get('COVERAGE_EXCLUDE_PLATFORM') != 'disable'" +# macOS +no-cover-if-is-macos = "sys_platform == 'darwin' and os_environ.get('COVERAGE_EXCLUDE_PLATFORM') != 'disable'" +no-cover-if-not-macos = "sys_platform != 'darwin' and os_environ.get('COVERAGE_EXCLUDE_PLATFORM') != 'disable'" +# Windows +no-cover-if-is-windows = "sys_platform == 'win32' and os_environ.get('COVERAGE_EXCLUDE_PLATFORM') != 'disable'" +no-cover-if-not-windows = "sys_platform != 'win32' and os_environ.get('COVERAGE_EXCLUDE_PLATFORM') != 'disable'" +# Python 3.12 +no-cover-if-is-py312 = "python_version == '3.12' and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +no-cover-if-lt-py312 = "sys_version_info < (3, 12) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +no-cover-if-gte-py312 = "sys_version_info > (3, 12) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +# Python 3.11 +no-cover-if-is-py311 = "python_version == '3.11' and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +no-cover-if-lt-py311 = "sys_version_info < (3, 11) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +no-cover-if-gte-py311 = "sys_version_info > (3, 11) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +# Python 3.10 +no-cover-if-is-py310 = "python_version == '3.10' and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +no-cover-if-lt-py310 = "sys_version_info < (3, 10) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +no-cover-if-gte-py310 = "sys_version_info > (3, 10) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +# Python 3.9 +no-cover-if-is-py39 = "python_version == '3.9' and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +no-cover-if-lt-py39 = "sys_version_info < (3, 9) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +no-cover-if-gte-py39 = "sys_version_info > (3, 9) and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" +# Python 3.8 +no-cover-if-is-py38 = "python_version == '3.8' and os_environ.get('COVERAGE_EXCLUDE_PYTHON_VERSION') != 'disable'" + [tool.isort] profile = "black" skip_glob = [ diff --git a/setup.cfg b/setup.cfg index 3bebf580a..08f995a96 100644 --- a/setup.cfg +++ b/setup.cfg @@ -64,9 +64,7 @@ install_requires = # the most recent version. importlib_metadata >= 4.4; python_version <= "3.9" packaging >= 22.0 - # pip 23.1 has an issue with --user installs. - # See https://github.com/pypa/pip/issues/11982 for details - pip >= 22, != 23.1 + pip >= 23.1.1 setuptools >= 60 wheel >= 0.37 build >= 0.10 @@ -91,6 +89,7 @@ install_requires = # ensure environment consistency. dev = coverage[toml] == 7.2.5 + coverage-conditional-plugin == 0.8.0 pre-commit == 3.2.2 pytest == 7.3.1 pytest-xdist == 3.2.1 diff --git a/src/briefcase/__init__.py b/src/briefcase/__init__.py index 3fa52c9c6..0ed6e069d 100644 --- a/src/briefcase/__init__.py +++ b/src/briefcase/__init__.py @@ -10,7 +10,7 @@ # Excluded from coverage because a pure test environment (such as the one # used by tox in CI) won't have setuptools_scm __version__ = get_version("../..", relative_to=__file__) # pragma: no cover -except (ModuleNotFoundError, LookupError): +except (ModuleNotFoundError, LookupError): # pragma: no-cover-if-missing-setuptools_scm # If setuptools_scm isn't in the environment, the call to import will fail. # If it *is* in the environment, but the code isn't a git checkout (e.g., # it's been pip installed non-editable) the call to get_version() will fail. diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py index b434bc211..9364ea7c9 100644 --- a/src/briefcase/commands/base.py +++ b/src/briefcase/commands/base.py @@ -17,12 +17,12 @@ try: import importlib_metadata -except ImportError: +except ImportError: # pragma: no-cover-if-lt-py310 import importlib.metadata as importlib_metadata try: import tomllib -except ModuleNotFoundError: +except ModuleNotFoundError: # pragma: no-cover-if-gte-py310 import tomli as tomllib from briefcase import __version__ @@ -177,10 +177,10 @@ def validate_data_path(self, data_path): "The path specified by BRIEFCASE_HOME does not exist." ) except KeyError: - if platform.system() == "Darwin": + if platform.system() == "Darwin": # pragma: no-cover-if-not-macos # macOS uses a bundle name, rather than just the app name app_name = "org.beeware.briefcase" - else: + else: # pragma: no-cover-if-is-macos app_name = "briefcase" data_path = PlatformDirs( @@ -214,7 +214,7 @@ def validate_data_path(self, data_path): # performed via ``cmd.exe`` in a different process. Once this # directory exists in the "real" %LOCALAPPDATA%, Windows will # allow normal interactions without attempting to sandbox them. - if platform.system() == "Windows": + if platform.system() == "Windows": # pragma: no-cover-if-not-windows subprocess.run( ["mkdir", data_path], shell=True, @@ -222,7 +222,7 @@ def validate_data_path(self, data_path): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - else: + else: # pragma: no-cover-if-is-windows os.makedirs(data_path, exist_ok=True) except (subprocess.CalledProcessError, OSError): raise BriefcaseCommandError( diff --git a/src/briefcase/commands/open.py b/src/briefcase/commands/open.py index 3b0fe4d3e..e63397a49 100644 --- a/src/briefcase/commands/open.py +++ b/src/briefcase/commands/open.py @@ -10,11 +10,11 @@ class OpenCommand(BaseCommand): description = "Open an app in the build tool for the target platform." def _open_app(self, app: BaseConfig): - if self.tools.host_os == "Windows": + if self.tools.host_os == "Windows": # pragma: no-cover-if-not-windows self.tools.os.startfile(self.project_path(app)) - elif self.tools.host_os == "Darwin": + elif self.tools.host_os == "Darwin": # pragma: no-cover-if-not-macos self.tools.subprocess.Popen(["open", self.project_path(app)]) - else: + else: # pragma: no-cover-if-not-linux self.tools.subprocess.Popen(["xdg-open", self.project_path(app)]) def open_app(self, app: BaseConfig, **options): diff --git a/src/briefcase/config.py b/src/briefcase/config.py index be1303186..824d34685 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -6,7 +6,7 @@ try: import tomllib -except ModuleNotFoundError: +except ModuleNotFoundError: # pragma: no-cover-if-gte-py310 import tomli as tomllib from briefcase.platforms import get_output_formats, get_platforms diff --git a/src/briefcase/integrations/docker.py b/src/briefcase/integrations/docker.py index b4b09329e..cb05c1f46 100644 --- a/src/briefcase/integrations/docker.py +++ b/src/briefcase/integrations/docker.py @@ -347,7 +347,7 @@ def prepare( f"Error building Docker container image for {self.app.app_name}." ) from e - def _dockerize_path(self, arg: str): + def _dockerize_path(self, arg: str): # pragma: no-cover-if-is-windows """Relocate any local path into the equivalent location on the docker filesystem. @@ -366,13 +366,20 @@ def _dockerize_path(self, arg: str): return arg - def _dockerize_args(self, args, interactive=False, mounts=None, env=None, cwd=None): + def _dockerize_args( + self, + args, + interactive=False, + mounts=None, + env=None, + cwd=None, + ): # pragma: no-cover-if-is-windows """Convert arguments and environment into a Docker-compatible form. Convert an argument and environment specification into a form that can be used as arguments to invoke Docker. This involves: * Configuring the Docker invocation to reference the - appropriate container image, and clean up afterwards + appropriate container image, and clean up afterward * Setting volume mounts for the container instance * Transforming any references to local paths into Docker path references. diff --git a/src/briefcase/integrations/linuxdeploy.py b/src/briefcase/integrations/linuxdeploy.py index 9b48d0901..784fb9982 100644 --- a/src/briefcase/integrations/linuxdeploy.py +++ b/src/briefcase/integrations/linuxdeploy.py @@ -368,7 +368,7 @@ def verify_plugins(self, plugin_definitions, bundle_path): if plugin_name.startswith(("https://", "http://")): self.tools.logger.info(f"Using URL plugin {plugin_name}") plugin = LinuxDeployURLPlugin.verify(self.tools, url=plugin_name) - else: + else: # pragma: no-cover-if-is-windows self.tools.logger.info(f"Using local file plugin {plugin_name}") plugin = LinuxDeployLocalFilePlugin.verify( self.tools, diff --git a/src/briefcase/integrations/windows_sdk.py b/src/briefcase/integrations/windows_sdk.py index 141b4c39c..f2176d3cd 100644 --- a/src/briefcase/integrations/windows_sdk.py +++ b/src/briefcase/integrations/windows_sdk.py @@ -7,7 +7,7 @@ # winreg can only be imported on Windows try: import winreg -except ImportError: +except ImportError: # pragma: no-cover-if-is-windows winreg = None from briefcase.exceptions import BriefcaseCommandError diff --git a/src/briefcase/platforms/__init__.py b/src/briefcase/platforms/__init__.py index ee52aedb8..12b043b78 100644 --- a/src/briefcase/platforms/__init__.py +++ b/src/briefcase/platforms/__init__.py @@ -5,7 +5,7 @@ # Therefore, we try to import the compatibility shim first; and fall # back to the stdlib module if the shim isn't there. from importlib_metadata import entry_points -except ImportError: +except ImportError: # pragma: no-cover-if-lt-py310 from importlib.metadata import entry_points diff --git a/src/briefcase/platforms/linux/__init__.py b/src/briefcase/platforms/linux/__init__.py index 1775951d7..05864ba10 100644 --- a/src/briefcase/platforms/linux/__init__.py +++ b/src/briefcase/platforms/linux/__init__.py @@ -112,7 +112,7 @@ def vendor_details(self, freedesktop_info): return vendor, codename, vendor_base -class LocalRequirementsMixin: +class LocalRequirementsMixin: # pragma: no-cover-if-is-windows # A mixin that captures the process of compiling requirements that are specified # as local file references into sdists, and then installing those requirements # from the sdist. @@ -209,7 +209,7 @@ def _pip_requires(self, app: AppConfig, requires: List[str]): return final -class DockerOpenCommand(OpenCommand): +class DockerOpenCommand(OpenCommand): # pragma: no-cover-if-is-windows # A command that redirects Open to an interactive shell in the container # if Docker is being used. Relies on the final command to provide # verification that Docker is available, and verify the app context. diff --git a/src/briefcase/platforms/linux/appimage.py b/src/briefcase/platforms/linux/appimage.py index 6f69c3828..085d7a11c 100644 --- a/src/briefcase/platforms/linux/appimage.py +++ b/src/briefcase/platforms/linux/appimage.py @@ -188,7 +188,7 @@ def verify_tools(self): super().verify_tools() LinuxDeploy.verify(tools=self.tools) - def build_app(self, app: AppConfig, **kwargs): + def build_app(self, app: AppConfig, **kwargs): # pragma: no-cover-if-is-windows """Build an application. :param app: The application to build diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index 804fa5c37..866c69fee 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -120,11 +120,11 @@ def app_python_version_tag(self, app): def platform_freedesktop_info(self, app): try: - if sys.version_info < (3, 10): + if sys.version_info < (3, 10): # pragma: no-cover-if-gte-py310 # This reproduces the Python 3.10 platform.freedesktop_os_release() function. with self.tools.ETC_OS_RELEASE.open(encoding="utf-8") as f: freedesktop_info = parse_freedesktop_os_release(f.read()) - else: + else: # pragma: no-cover-if-lt-py310 freedesktop_info = self.tools.platform.freedesktop_os_release() except OSError as e: @@ -684,7 +684,7 @@ def build_app(self, app: AppConfig, **kwargs): new_perms = user_perms | (world_perms << 3) | world_perms # If there's been any change in permissions, apply them - if new_perms != old_perms: + if new_perms != old_perms: # pragma: no-cover-if-is-windows self.logger.info( "Updating file permissions on " f"{path.relative_to(self.bundle_path(app))} " @@ -881,7 +881,7 @@ def _package_deb(self, app: AppConfig, **kwargs): self.distribution_path(app), ) - def _package_rpm(self, app: AppConfig, **kwargs): + def _package_rpm(self, app: AppConfig, **kwargs): # pragma: no-cover-if-is-windows self.logger.info("Building .rpm package...", prefix=app.app_name) # The long description *must* exist. @@ -1040,7 +1040,7 @@ def _package_rpm(self, app: AppConfig, **kwargs): self.distribution_path(app), ) - def _package_pkg(self, app: AppConfig, **kwargs): + def _package_pkg(self, app: AppConfig, **kwargs): # pragma: no-cover-if-is-windows self.logger.info("Building .pkg.tar.zst package...", prefix=app.app_name) # The description *must* exist. diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index f40cfdd55..f0cb50430 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -18,11 +18,9 @@ try: import dmgbuild -except ImportError: # pragma: no cover +except ImportError: # pragma: no-cover-if-is-macos # On non-macOS platforms, dmgbuild won't be installed. # Allow the plugin to be loaded; raise an error when tools are verified. - # We don't need to worry about coverage of this branch because it's - # handled by the verification process. dmgbuild = None @@ -167,12 +165,12 @@ def run_app( raise BriefcaseCommandError(f"Unable to start app {app.app_name}.") finally: # Ensure the App also terminates when exiting - if app_pid: + if app_pid: # pragma: no-cover-if-is-py310 with suppress(ProcessLookupError): self.tools.os.kill(app_pid, SIGTERM) -def is_mach_o_binary(path): +def is_mach_o_binary(path): # pragma: no-cover-if-is-windows """Determine if the file at the given path is a Mach-O binary. :param path: The path to check diff --git a/src/briefcase/platforms/web/static.py b/src/briefcase/platforms/web/static.py index 071e707a3..f4bf327ba 100644 --- a/src/briefcase/platforms/web/static.py +++ b/src/briefcase/platforms/web/static.py @@ -11,7 +11,7 @@ try: import tomllib -except ModuleNotFoundError: +except ModuleNotFoundError: # pragma: no-cover-if-gte-py310 import tomli as tomllib import tomli_w diff --git a/tests/commands/dev/test_get_environment.py b/tests/commands/dev/test_get_environment.py index d8a70c9f0..178f16d16 100644 --- a/tests/commands/dev/test_get_environment.py +++ b/tests/commands/dev/test_get_environment.py @@ -1,5 +1,6 @@ import sys from pathlib import Path +from unittest.mock import PropertyMock import pytest @@ -7,6 +8,22 @@ PYTHONMALLOC = "PYTHONMALLOC" +@pytest.mark.parametrize("platform", ["windows", "darwin", "linux", "wonky"]) +def test_pythonmalloc_only_set_for_windows( + dev_command, + first_app, + platform, + monkeypatch, +): + """PYTHONMALLOC env var is only set on Windows.""" + monkeypatch.setattr( + type(dev_command), "platform", PropertyMock(return_value=platform) + ) + env = dev_command.get_environment(first_app, test_mode=False) + expected = "default" if platform == "windows" else "missing" + assert env.get(PYTHONMALLOC, "missing") == expected + + @pytest.mark.skipif(sys.platform != "win32", reason="Relevant only for windows") def test_pythonpath_with_one_source_in_windows(dev_command, first_app): """Test get environment with one source.""" diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify.py b/tests/integrations/android_sdk/AndroidSDK/test_verify.py index 3237d13a5..439e757da 100644 --- a/tests/integrations/android_sdk/AndroidSDK/test_verify.py +++ b/tests/integrations/android_sdk/AndroidSDK/test_verify.py @@ -1,7 +1,6 @@ import os import platform import shutil -import sys from unittest.mock import MagicMock import pytest @@ -11,6 +10,15 @@ from briefcase.integrations.base import ToolCache +def sdk_download_tag(host_platform: str) -> str: + """Maps ``platform.system()`` to the OS tag in the sdk download URL.""" + return { + "Windows": "win", + "Darwin": "mac", + "Linux": "linux", + }[host_platform] + + @pytest.fixture def mock_tools(mock_tools) -> ToolCache: # Mock the os environment, but copy over other key functions. @@ -20,11 +28,7 @@ def mock_tools(mock_tools) -> ToolCache: mock_tools.os.X_OK = os.X_OK # Identify the host platform - mock_tools._test_download_tag = { - "Windows": "win", - "Darwin": "mac", - "Linux": "linux", - }[mock_tools.host_os] + mock_tools._test_download_tag = sdk_download_tag(mock_tools.host_os) # Use the original module rmtree implementation mock_tools.shutil.rmtree = shutil.rmtree @@ -166,11 +170,22 @@ def test_invalid_user_provided_sdk(mock_tools, tmp_path): assert sdk.root_path == android_sdk_root_path -def test_download_sdk(mock_tools, tmp_path): +@pytest.mark.parametrize("mock_host_os", ["Darwin", "Linux", "Windows"]) +def test_download_sdk(mock_tools, mock_host_os, tmp_path): """If an SDK is not available, one will be downloaded.""" android_sdk_root_path = tmp_path / "tools" / "android_sdk" cmdline_tools_base_path = android_sdk_root_path / "cmdline-tools" + # Mock the host os + mock_tools.host_os = mock_host_os + + # Allow calls to `os.access()` on when the actual host is Windows + if platform.system() == "Windows": + mock_tools.os.access = MagicMock( + spec_set=os.access, + side_effect=[True, False, True], + ) + # The download will produce a cached file. cache_file = MagicMock() mock_tools.download.file.return_value = cache_file @@ -187,7 +202,7 @@ def test_download_sdk(mock_tools, tmp_path): # Validate that the SDK was downloaded and unpacked url = ( "https://dl.google.com/android/repository/" - f"commandlinetools-{mock_tools._test_download_tag}-8092744_latest.zip" + f"commandlinetools-{sdk_download_tag(mock_host_os)}-8092744_latest.zip" ) mock_tools.download.file.assert_called_once_with( url=url, @@ -210,11 +225,20 @@ def test_download_sdk(mock_tools, tmp_path): assert sdk.cmdline_tools_path.is_dir() assert sdk.cmdline_tools_version_path.is_file() - if platform.system() != "Windows": - # On non-Windows, ensure the unpacked binary was made executable - assert os.access( - cmdline_tools_base_path / "latest" / "bin" / "sdkmanager", os.X_OK - ) + # Verify binaries marked executable for non-Windows + if mock_host_os != "Windows": + if platform.system() == "Windows": + # When the actual host is Windows, check the mock was called + mock_tools.os.access.assert_called_with( + cmdline_tools_base_path / "latest" / "bin" / "sdkmanager", + os.X_OK, + ) + else: + # When the actual host is non-Windows, ensure the unpacked binary is executable + assert os.access( + cmdline_tools_base_path / "latest" / "bin" / "sdkmanager", + os.X_OK, + ) # The license has been accepted assert (android_sdk_root_path / "licenses" / "android-sdk-license").exists() @@ -306,10 +330,6 @@ def test_no_install(mock_tools, tmp_path): assert mock_tools.download.file.call_count == 0 -@pytest.mark.skipif( - sys.platform == "win32", - reason="executable permission doesn't make sense on Windows", -) def test_download_sdk_if_sdkmanager_not_executable(mock_tools, tmp_path): """An SDK will be downloaded and unpacked if `tools/bin/sdkmanager` exists but does not have its permissions set properly.""" diff --git a/tests/platforms/iOS/xcode/test_mixin.py b/tests/platforms/iOS/xcode/test_mixin.py index 6d0b3f6a5..a94d3ea48 100644 --- a/tests/platforms/iOS/xcode/test_mixin.py +++ b/tests/platforms/iOS/xcode/test_mixin.py @@ -1,5 +1,8 @@ +from unittest.mock import MagicMock + import pytest +import briefcase.integrations.xcode from briefcase.console import Console, Log from briefcase.exceptions import NoDistributionArtefact from briefcase.platforms.iOS.xcode import iOSXcodeCreateCommand @@ -37,3 +40,39 @@ def test_distribution_path(create_command, first_app_config, tmp_path): match=r"WARNING: No distributable artefact has been generated", ): create_command.distribution_path(first_app_config) + + +def test_verify(create_command, monkeypatch): + """If you're on macOS, you can verify tools.""" + create_command.tools.host_os = "Darwin" + + mock_ensure_xcode_is_installed = MagicMock() + monkeypatch.setattr( + briefcase.integrations.xcode, + "ensure_xcode_is_installed", + mock_ensure_xcode_is_installed, + ) + mock_ensure_command_line_tools_are_installed = MagicMock() + monkeypatch.setattr( + briefcase.integrations.xcode, + "ensure_command_line_tools_are_installed", + mock_ensure_command_line_tools_are_installed, + ) + mock_confirm_xcode_license_accepted = MagicMock() + monkeypatch.setattr( + briefcase.integrations.xcode, + "confirm_xcode_license_accepted", + mock_confirm_xcode_license_accepted, + ) + + create_command.verify_tools() + + assert create_command.tools.xcode_cli is not None + mock_ensure_xcode_is_installed.assert_called_once_with( + create_command.tools, + min_version=(10, 0, 0), + ) + mock_ensure_command_line_tools_are_installed.assert_called_once_with( + create_command.tools + ) + mock_confirm_xcode_license_accepted.assert_called_once_with(create_command.tools) diff --git a/tests/platforms/linux/appimage/test_mixin.py b/tests/platforms/linux/appimage/test_mixin.py index 2d09a4d17..5baf7680c 100644 --- a/tests/platforms/linux/appimage/test_mixin.py +++ b/tests/platforms/linux/appimage/test_mixin.py @@ -39,6 +39,17 @@ def test_binary_path(create_command, first_app_config, tmp_path): ) +def test_project_path(create_command, first_app_config, tmp_path): + """The project path is the bundle path.""" + project_path = create_command.project_path(first_app_config) + bundle_path = create_command.bundle_path(first_app_config) + + expected_path = ( + tmp_path / "base_path" / "build" / "first-app" / "linux" / "appimage" + ) + assert expected_path == project_path == bundle_path + + def test_distribution_path(create_command, first_app_config, tmp_path): # Force the architecture to x86_64 for test purposes. create_command.tools.host_arch = "x86_64" diff --git a/tests/platforms/linux/test_LocalRequirementsMixin.py b/tests/platforms/linux/test_LocalRequirementsMixin.py index 744e72ad2..c68e8e6b2 100644 --- a/tests/platforms/linux/test_LocalRequirementsMixin.py +++ b/tests/platforms/linux/test_LocalRequirementsMixin.py @@ -17,7 +17,8 @@ class DummyCreateCommand(LocalRequirementsMixin, CreateCommand): - # An command that provides the stubs required to satisfy LocalRequirementeMixin + """A command that provides the stubs required to satisfy LocalRequirementsMixin.""" + platform = "Tester" output_format = "Dummy" diff --git a/tests/platforms/macOS/app/test_package.py b/tests/platforms/macOS/app/test_package.py index 06f41145b..a8e86510f 100644 --- a/tests/platforms/macOS/app/test_package.py +++ b/tests/platforms/macOS/app/test_package.py @@ -5,6 +5,7 @@ import pytest +import briefcase.integrations.xcode from briefcase.console import Console, Log from briefcase.exceptions import BriefcaseCommandError from briefcase.platforms.macOS.app import macOSAppPackageCommand @@ -834,14 +835,28 @@ def test_dmg_with_missing_installer_background( ) -def test_verify(package_command): +def test_verify(package_command, monkeypatch): """If you're on macOS, you can verify tools.""" + package_command.tools.host_os = "Darwin" + # Mock the existence of the command line tools - package_command.tools.subprocess.check_output.side_effect = [ - subprocess.CalledProcessError(cmd=["xcode-select", "--install"], returncode=1), - "clang 37.42", # clang --version - ] + mock_ensure_command_line_tools_are_installed = mock.MagicMock() + monkeypatch.setattr( + briefcase.integrations.xcode, + "ensure_command_line_tools_are_installed", + mock_ensure_command_line_tools_are_installed, + ) + mock_confirm_xcode_license_accepted = mock.MagicMock() + monkeypatch.setattr( + briefcase.integrations.xcode, + "confirm_xcode_license_accepted", + mock_confirm_xcode_license_accepted, + ) package_command.verify_tools() assert package_command.tools.xcode_cli is not None + mock_ensure_command_line_tools_are_installed.assert_called_once_with( + package_command.tools + ) + mock_confirm_xcode_license_accepted.assert_called_once_with(package_command.tools) diff --git a/tests/platforms/macOS/xcode/test_package.py b/tests/platforms/macOS/xcode/test_package.py index aa8eef7d1..3a1610b97 100644 --- a/tests/platforms/macOS/xcode/test_package.py +++ b/tests/platforms/macOS/xcode/test_package.py @@ -1 +1,56 @@ -# skip since packaging uses the same code as app command +from unittest import mock + +import pytest + +import briefcase.integrations.xcode +from briefcase.console import Console, Log +from briefcase.platforms.macOS.xcode import macOSXcodePackageCommand + +# skip most tests since packaging uses the same code as app command + + +@pytest.fixture +def package_command(tmp_path): + command = macOSXcodePackageCommand( + logger=Log(), + console=Console(), + base_path=tmp_path / "base_path", + data_path=tmp_path / "briefcase", + ) + return command + + +def test_verify(package_command, monkeypatch): + """If you're on macOS, you can verify tools.""" + package_command.tools.host_os = "Darwin" + + mock_ensure_xcode_is_installed = mock.MagicMock() + monkeypatch.setattr( + briefcase.integrations.xcode, + "ensure_xcode_is_installed", + mock_ensure_xcode_is_installed, + ) + mock_ensure_command_line_tools_are_installed = mock.MagicMock() + monkeypatch.setattr( + briefcase.integrations.xcode, + "ensure_command_line_tools_are_installed", + mock_ensure_command_line_tools_are_installed, + ) + mock_confirm_xcode_license_accepted = mock.MagicMock() + monkeypatch.setattr( + briefcase.integrations.xcode, + "confirm_xcode_license_accepted", + mock_confirm_xcode_license_accepted, + ) + + package_command.verify_tools() + + assert package_command.tools.xcode_cli is not None + mock_ensure_xcode_is_installed.assert_called_once_with( + package_command.tools, + min_version=(10, 0, 0), + ) + mock_ensure_command_line_tools_are_installed.assert_called_once_with( + package_command.tools + ) + mock_confirm_xcode_license_accepted.assert_called_once_with(package_command.tools) diff --git a/tests/platforms/windows/visualstudio/test_build.py b/tests/platforms/windows/visualstudio/test_build.py index 2d438ce1f..b8b3c3cc2 100644 --- a/tests/platforms/windows/visualstudio/test_build.py +++ b/tests/platforms/windows/visualstudio/test_build.py @@ -1,10 +1,10 @@ -import platform import subprocess from pathlib import Path from unittest import mock import pytest +import briefcase.platforms.windows.visualstudio from briefcase.console import Console, Log from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.subprocess import Subprocess @@ -28,15 +28,21 @@ def build_command(tmp_path): return command -@pytest.mark.skipif(platform.system() != "Windows", reason="Windows specific tests") -def test_verify(build_command): +def test_verify(build_command, monkeypatch): """Verifying on Windows creates a VisualStudio wrapper.""" + build_command.tools.host_os = "Windows" - build_command.tools.subprocess = mock.MagicMock(spec_set=Subprocess) + mock_visualstudio_verify = mock.MagicMock(wraps=VisualStudio.verify) + monkeypatch.setattr( + briefcase.platforms.windows.visualstudio.VisualStudio, + "verify", + mock_visualstudio_verify, + ) build_command.verify_tools() - # No error and an SDK wrapper is created + # VisualStudio tool was verified + mock_visualstudio_verify.assert_called_once_with(build_command.tools) assert isinstance(build_command.tools.visualstudio, VisualStudio) diff --git a/tox.ini b/tox.ini index 517d46e61..7320d419d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,10 @@ [tox] -envlist = py{38,39,310,311,312},coverage,pre-commit,docs-lint,towncrier-check -isolated_build = True +envlist = towncrier-check,docs-lint,pre-commit,py{38,39,310,311,312},coverage +labels = + test = py{38,39,310,311,312},coverage + test-fast = py{38,39,310,311,312}-fast + test-cov = py{38,39,310,311,312},coverage-platform + ci = towncrier-check,docs-lint,pre-commit,py{38,39,310,311,312},coverage-platform skip_missing_interpreters = True [pkgenv] @@ -21,6 +25,7 @@ use_develop = fast: True skip_sdist = fast: True # Needed on Windows to test data directory creation passenv = LOCALAPPDATA +setenv = COVERAGE_FILE = {env:COVERAGE_FILE:.coverage} extras = dev # 2023-04-22 see pkgenv ↑ download = {[pkgenv]download} @@ -28,19 +33,40 @@ commands = !fast : python -m coverage run -m pytest {posargs:-vv} fast : python -m pytest {posargs:-vv -n auto} -[testenv:coverage{,-html}{,-fail}] +[testenv:coverage{,38,39,310,311,312}{,-platform}{,-project}{,-html}{,-keep}{,-nofail}] depends = py{,38,39,310,311,312} -# coverage should run on oldest supported Python -base_python = py38,py39,py310,py311 +# by default, coverage should run on oldest supported Python for testing platform coverage. +# however, coverage for a particular Python version should match the version used for pytest. +base_python = + coverage: py38,py39,py310,py311,py312 + coverage38: py38 + coverage39: py39 + coverage310: py310 + coverage311: py311 + coverage312: py312 parallel_show_output = True extras = dev # 2023-04-22 see pkgenv ↑ download = {[pkgenv]download} +setenv = + COVERAGE_FILE = {env:COVERAGE_FILE:.coverage} + keep: COMBINE_FLAGS = --keep + !nofail: REPORT_FLAGS = --fail-under=100 + # disable coverage exclusions for Python version to test entire platform + {platform,project}: COVERAGE_EXCLUDE_PYTHON_VERSION=disable + # disable coverage exclusions for host platform to test entire project + project: COVERAGE_EXCLUDE_PLATFORM=disable +commands_pre = + python --version + python -c 'if 1: \ + import os; \ + print("COVERAGE_FILE", os.environ.get("COVERAGE_FILE", "")); \ + print("COVERAGE_EXCLUDE_PYTHON_VERSION", os.environ.get("COVERAGE_EXCLUDE_PYTHON_VERSION", "")); \ + print("COVERAGE_EXCLUDE_PLATFORM", os.environ.get("COVERAGE_EXCLUDE_PLATFORM", ""))' commands = - -python -m coverage combine - html : python -m coverage html --skip-covered --skip-empty - !fail : python -m coverage report - fail : python -m coverage report --fail-under=100 + -python -m coverage combine {env:COMBINE_FLAGS} + html: python -m coverage html --skip-covered --skip-empty + python -m coverage report {env:REPORT_FLAGS} [testenv:towncrier-check] package_env = none