From abb7f0e6598cb267a704e189237a14b5d777532d Mon Sep 17 00:00:00 2001 From: UebelAndre Date: Sat, 10 Aug 2024 16:23:11 -0700 Subject: [PATCH] rules_pytest v0.0.1 --- .bazelrc | 55 +++ .github/github.bazelrc | 14 + .github/release_notes.template | 12 + .github/workflows/build.yaml | 117 +++++ .github/workflows/compile-requirements.yaml | 26 + .github/workflows/release.yaml | 53 ++ .gitignore | 6 + .isort.cfg | 2 + .mypy.ini | 20 + BUILD.bazel | 5 + MODULE.bazel | 9 + README.md | 208 +++++++- WORKSPACE.bazel | 63 +++ docs/BUILD.bazel | 26 + pylintrc.toml | 25 + python/BUILD.bazel | 0 python/pytest/3rdparty/BUILD.bazel | 33 ++ python/pytest/BUILD.bazel | 46 ++ python/pytest/coveragerc | 2 + python/pytest/defs.bzl | 28 ++ python/pytest/private/BUILD.bazel | 48 ++ python/pytest/private/entrypoint_sanitizer.py | 46 ++ python/pytest/private/pytest.bzl | 382 +++++++++++++++ .../pytest/private/pytest_process_wrapper.py | 452 ++++++++++++++++++ python/pytest/private/pytest_utils.bzl | 58 +++ python/pytest/private/tests/BUILD.bazel | 12 + .../pytest/private/tests/coverage/BUILD.bazel | 0 .../tests/coverage/empty_config/.coveragerc | 0 .../tests/coverage/empty_config/BUILD.bazel | 16 + .../tests/coverage/empty_config/README.md | 4 + .../coverage/empty_config/lib/__init__.py | 28 ++ .../empty_config/lib/submod/__init__.py | 30 ++ .../empty_config/tests/coverage_test.py | 33 ++ .../tests/coverage/multi_file/.coveragerc | 8 + .../tests/coverage/multi_file/BUILD.bazel | 17 + .../tests/coverage/multi_file/README.md | 4 + .../tests/coverage/multi_file/lib/__init__.py | 28 ++ .../multi_file/lib/submod/__init__.py | 30 ++ .../tests/coverage/multi_file/tests/a_test.py | 21 + .../tests/coverage/multi_file/tests/b_test.py | 17 + .../private/tests/coverage/simple/.coveragerc | 6 + .../private/tests/coverage/simple/BUILD.bazel | 16 + .../private/tests/coverage/simple/README.md | 4 + .../tests/coverage/simple/lib/__init__.py | 28 ++ .../coverage/simple/lib/submod/__init__.py | 30 ++ .../coverage/simple/tests/coverage_test.py | 33 ++ .../coverage/split_collection/.coveragerc | 10 + .../coverage/split_collection/BUILD.bazel | 46 ++ .../tests/coverage/split_collection/README.md | 4 + .../coverage/split_collection/lib/__init__.py | 28 ++ .../split_collection/lib/submod/__init__.py | 30 ++ .../coverage/split_collection/tests/a_test.py | 21 + .../coverage/split_collection/tests/b_test.py | 17 + python/pytest/private/tests/env/BUILD.bazel | 6 + python/pytest/private/tests/env/env_test.py | 12 + .../private/tests/label_tests/BUILD.bazel | 5 + .../tests/label_tests/pytest_label_test.bzl | 22 + .../tests/label_tests/pytest_label_test.py | 6 + .../pytest/private/tests/negative/BUILD.bazel | 32 ++ .../tests/negative/pytest_negative_test.py | 15 + .../tests/pytest_process_wrapper_test.py | 215 +++++++++ .../private/tests/version_test/BUILD.bazel | 13 + .../tests/version_test/version_test.py | 57 +++ .../private/tests/with_args/BUILD.bazel | 39 ++ .../pytest/private/tests/with_args/README.md | 7 + .../private/tests/with_args/lib/__init__.py | 28 ++ .../tests/with_args/lib/submod/__init__.py | 30 ++ .../tests/with_args/lib_testonly/__init__.py | 0 .../private/tests/with_args/tests/conftest.py | 16 + .../tests/with_args/tests/with_args_test.py | 43 ++ python/pytest/pyproject.toml | 2 + python/pytest/repositories.bzl | 22 + python/pytest/repositories_toolchains.bzl | 9 + python/pytest/repositories_transitive_1.bzl | 11 + python/pytest/repositories_transitive_2.bzl | 21 + python/pytest/repositories_transitive_3.bzl | 9 + python/pytest/requirements.in | 12 + python/pytest/requirements.linux.txt | 109 +++++ python/pytest/requirements.macos.txt | 109 +++++ python/pytest/requirements.windows.txt | 116 +++++ python/pytest/toolchain/BUILD.bazel | 23 + version.bzl | 3 + 82 files changed, 3248 insertions(+), 1 deletion(-) create mode 100644 .bazelrc create mode 100644 .github/github.bazelrc create mode 100644 .github/release_notes.template create mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/compile-requirements.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .isort.cfg create mode 100644 .mypy.ini create mode 100644 BUILD.bazel create mode 100644 MODULE.bazel create mode 100644 WORKSPACE.bazel create mode 100644 docs/BUILD.bazel create mode 100644 pylintrc.toml create mode 100644 python/BUILD.bazel create mode 100644 python/pytest/3rdparty/BUILD.bazel create mode 100644 python/pytest/BUILD.bazel create mode 100644 python/pytest/coveragerc create mode 100644 python/pytest/defs.bzl create mode 100644 python/pytest/private/BUILD.bazel create mode 100644 python/pytest/private/entrypoint_sanitizer.py create mode 100644 python/pytest/private/pytest.bzl create mode 100644 python/pytest/private/pytest_process_wrapper.py create mode 100644 python/pytest/private/pytest_utils.bzl create mode 100644 python/pytest/private/tests/BUILD.bazel create mode 100644 python/pytest/private/tests/coverage/BUILD.bazel create mode 100644 python/pytest/private/tests/coverage/empty_config/.coveragerc create mode 100644 python/pytest/private/tests/coverage/empty_config/BUILD.bazel create mode 100644 python/pytest/private/tests/coverage/empty_config/README.md create mode 100644 python/pytest/private/tests/coverage/empty_config/lib/__init__.py create mode 100644 python/pytest/private/tests/coverage/empty_config/lib/submod/__init__.py create mode 100644 python/pytest/private/tests/coverage/empty_config/tests/coverage_test.py create mode 100644 python/pytest/private/tests/coverage/multi_file/.coveragerc create mode 100644 python/pytest/private/tests/coverage/multi_file/BUILD.bazel create mode 100644 python/pytest/private/tests/coverage/multi_file/README.md create mode 100644 python/pytest/private/tests/coverage/multi_file/lib/__init__.py create mode 100644 python/pytest/private/tests/coverage/multi_file/lib/submod/__init__.py create mode 100644 python/pytest/private/tests/coverage/multi_file/tests/a_test.py create mode 100644 python/pytest/private/tests/coverage/multi_file/tests/b_test.py create mode 100644 python/pytest/private/tests/coverage/simple/.coveragerc create mode 100644 python/pytest/private/tests/coverage/simple/BUILD.bazel create mode 100644 python/pytest/private/tests/coverage/simple/README.md create mode 100644 python/pytest/private/tests/coverage/simple/lib/__init__.py create mode 100644 python/pytest/private/tests/coverage/simple/lib/submod/__init__.py create mode 100644 python/pytest/private/tests/coverage/simple/tests/coverage_test.py create mode 100644 python/pytest/private/tests/coverage/split_collection/.coveragerc create mode 100644 python/pytest/private/tests/coverage/split_collection/BUILD.bazel create mode 100644 python/pytest/private/tests/coverage/split_collection/README.md create mode 100644 python/pytest/private/tests/coverage/split_collection/lib/__init__.py create mode 100644 python/pytest/private/tests/coverage/split_collection/lib/submod/__init__.py create mode 100644 python/pytest/private/tests/coverage/split_collection/tests/a_test.py create mode 100644 python/pytest/private/tests/coverage/split_collection/tests/b_test.py create mode 100644 python/pytest/private/tests/env/BUILD.bazel create mode 100644 python/pytest/private/tests/env/env_test.py create mode 100644 python/pytest/private/tests/label_tests/BUILD.bazel create mode 100644 python/pytest/private/tests/label_tests/pytest_label_test.bzl create mode 100644 python/pytest/private/tests/label_tests/pytest_label_test.py create mode 100644 python/pytest/private/tests/negative/BUILD.bazel create mode 100644 python/pytest/private/tests/negative/pytest_negative_test.py create mode 100644 python/pytest/private/tests/pytest_process_wrapper_test.py create mode 100644 python/pytest/private/tests/version_test/BUILD.bazel create mode 100644 python/pytest/private/tests/version_test/version_test.py create mode 100644 python/pytest/private/tests/with_args/BUILD.bazel create mode 100644 python/pytest/private/tests/with_args/README.md create mode 100644 python/pytest/private/tests/with_args/lib/__init__.py create mode 100644 python/pytest/private/tests/with_args/lib/submod/__init__.py create mode 100644 python/pytest/private/tests/with_args/lib_testonly/__init__.py create mode 100644 python/pytest/private/tests/with_args/tests/conftest.py create mode 100644 python/pytest/private/tests/with_args/tests/with_args_test.py create mode 100644 python/pytest/pyproject.toml create mode 100644 python/pytest/repositories.bzl create mode 100644 python/pytest/repositories_toolchains.bzl create mode 100644 python/pytest/repositories_transitive_1.bzl create mode 100644 python/pytest/repositories_transitive_2.bzl create mode 100644 python/pytest/repositories_transitive_3.bzl create mode 100644 python/pytest/requirements.in create mode 100644 python/pytest/requirements.linux.txt create mode 100644 python/pytest/requirements.macos.txt create mode 100644 python/pytest/requirements.windows.txt create mode 100644 python/pytest/toolchain/BUILD.bazel create mode 100644 version.bzl diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 0000000..a4c2540 --- /dev/null +++ b/.bazelrc @@ -0,0 +1,55 @@ +############################################################################### +## Bazel Configuration Flags +## +## `.bazelrc` is a Bazel configuration file. +## https://bazel.build/docs/best-practices#bazelrc-file +############################################################################### + +# https://bazel.build/reference/command-line-reference#flag--enable_platform_specific_config +common --enable_platform_specific_config + +# Enable the only currently supported report type +# https://bazel.build/reference/command-line-reference#flag--combined_report +coverage --combined_report=lcov + +# Avoid fully cached builds reporting no coverage and failing CI +# https://bazel.build/reference/command-line-reference#flag--experimental_fetch_all_coverage_outputs +coverage --experimental_fetch_all_coverage_outputs + +############################################################################### +## Incompatibility flags +############################################################################### + +# https://github.com/bazelbuild/bazel/issues/8195 +build --incompatible_disallow_empty_glob=true + +# https://github.com/bazelbuild/bazel/issues/12821 +build --nolegacy_external_runfiles + +# Required for cargo_build_script support before Bazel 7 +build --incompatible_merge_fixed_and_default_shell_env + +# Disable legacy __init__.py behavior which is known to conflict with +# modern python versions (3.9+) +build --incompatible_default_to_explicit_init_py + +############################################################################### +## Bzlmod +############################################################################### + +# TODO: migrate all dependencies from WORKSPACE to MODULE.bazel +# https://github.com/bazelbuild/rules_rust/issues/2181 +common --noenable_bzlmod + +# Disable the bzlmod lockfile, so we don't accidentally commit MODULE.bazel.lock +common --lockfile_mode=off + +############################################################################### +## Custom user flags +## +## This should always be the last thing in the `.bazelrc` file to ensure +## consistent behavior when setting flags in that file as `.bazelrc` files are +## evaluated top to bottom. +############################################################################### + +try-import %workspace%/user.bazelrc diff --git a/.github/github.bazelrc b/.github/github.bazelrc new file mode 100644 index 0000000..3b5742f --- /dev/null +++ b/.github/github.bazelrc @@ -0,0 +1,14 @@ +# Bazel settings for use in Github CI + +# Always display the flags being used +common --announce_rc + +# Show errors in CI +test --test_output=errors + +# Show more information about failures +build --verbose_failures + +# UI for cleaner CI output +common --color=no +common --show_timestamps diff --git a/.github/release_notes.template b/.github/release_notes.template new file mode 100644 index 0000000..b31160c --- /dev/null +++ b/.github/release_notes.template @@ -0,0 +1,12 @@ +# {version} + +```python +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") +http_archive( + name = "rules_pytest", + sha256 = "{sha256}", + urls = ["https://github.com/UebelAndre/rules_pytest/releases/download/{version}/rules_pytest-v{version}.tar.gz"], +) +``` + +Additional documentation can be found at: https://github.com/UebelAndre/rules_pytest diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..8220009 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,117 @@ +--- +name: CI + +on: + push: + branches: + - main + pull_request: + types: + - opened + - synchronize + +env: + BAZEL_STARTUP_FLAGS: --bazelrc=${{ github.workspace }}/.github/github.bazelrc + +jobs: + ci: + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: macos-11 + - os: ubuntu-20.04 + - os: windows-2019 + steps: + # Checkout the code + - uses: actions/checkout@v2 + + # Caches and restores the Bazel outputs. + - name: Retain Bazel cache (linux) + uses: actions/cache@v2 + env: + cache-name: bazel-cache + with: + path: | + ~/.cache/bazelisk + ~/.cache/bazel + key: ${{ runner.os }}-${{ env.cache-name }} + if: startswith(runner.os, 'Linux') + - name: Retain Bazel cache (MacOS) + uses: actions/cache@v2 + env: + cache-name: bazel-cache + with: + path: | + ~/.cache/bazelisk + /private/var/tmp/_bazel_runner + key: ${{ runner.os }}-${{ env.cache-name }} + if: startswith(runner.os, 'MacOS') + - name: Retain Bazel cache (Windows) + uses: actions/cache@v2 + env: + cache-name: bazel-cache + with: + path: | + ~/.cache/bazelisk + C:/bzl + key: ${{ runner.os }}-${{ env.cache-name }} + if: startswith(runner.os, 'Windows') + + - name: Setup Bazelrc (Windows) + run: | + echo "startup --output_user_root=C:/bzl" > ./user.bazelrc + if: startswith(runner.os, 'Windows') + - name: Setup Bazelrc + run: | + echo "common --noenable_bzlmod" >> ./user.bazelrc + echo "common --keep_going" >> ./user.bazelrc + + # Build and Test the code + - name: Test (Unix) + run: bazel ${BAZEL_STARTUP_FLAGS[@]} test //... + if: startswith(runner.os, 'Windows') != true + - name: Test (Windows) + run: bazel $env:BAZEL_STARTUP_FLAGS test //... + if: startswith(runner.os, 'Windows') + + ci-buildifier: + runs-on: ubuntu-20.04 + steps: + # Checkout the code + - uses: actions/checkout@v2 + - name: Download Buildifier + run: | + wget "https://github.com/bazelbuild/buildtools/releases/download/v7.1.2/buildifier-linux-amd64" -O buildifier + chmod +x buildifier + - name: Buildifier + run: ./buildifier -lint=warn -mode=check -warnings=all -r ${{ github.workspace }} + + ci-lint-and-format: + runs-on: ubuntu-20.04 + steps: + # Checkout the code + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.11 + - name: Setup pip + run: | + python -m pip install --upgrade pip setuptools + - name: Install dependencies + run: | + pip install -r python/pytest/requirements.linux.txt --user + - name: Run mypy + run: | + python -m mypy python + - name: Run pylint + run: | + PYTHONPATH="$(pwd)" python -m pylint python + - name: Run black + run: | + python -m black --check --diff python + - name: Run isort + run: | + python -m isort --check-only python + diff --git a/.github/workflows/compile-requirements.yaml b/.github/workflows/compile-requirements.yaml new file mode 100644 index 0000000..5e7971b --- /dev/null +++ b/.github/workflows/compile-requirements.yaml @@ -0,0 +1,26 @@ +--- +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: compile_requirements + +on: + workflow_dispatch: + inputs: + target: + description: 'The py_reqs_compiler target to run' + default: "//python/pytest/3rdparty:requirements.update" + +jobs: + build: + strategy: + fail-fast: false + matrix: + platform: ["ubuntu-latest", "macos-latest", "windows-latest"] + + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v2 + - name: Compile requirements + run: | + bazel run "${{ github.event.inputs.target }}" "--" "--upgrade" "--verbose" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..5b14287 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,53 @@ +--- +name: Release +on: + workflow_dispatch: + push: + branches: + - main + paths: + # Only trigger for new releases + - "version.bzl" + +defaults: + run: + shell: bash + +jobs: + release: + if: ${{ github.repository_owner == 'UebelAndre' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Detect the current version + run: | + version="$(grep 'VERSION =' ./version.bzl | sed 's/VERSION = "//' | sed 's/"//')" + echo "RELEASE_VERSION=${version}" >> $GITHUB_ENV + - name: Create release artifact + run: | + tar -czf ${{ github.workspace }}/.github/rules_pytest.tar.gz --exclude=".git" --exclude=".github" --exclude="scripts" --exclude="dist" --exclude="build" -C ${{ github.workspace }} . + sha256="$(shasum --algorithm 256 ${{ github.workspace }}/.github/rules_pytest.tar.gz | awk '{ print $1 }')" + echo "ARCHIVE_SHA256=${sha256}" >> $GITHUB_ENV + - name: Generate release notes + run: | + # Generate the release notes + sed 's/{version}/${{env.RELEASE_VERSION}}/g' ${{ github.workspace }}/.github/release_notes.template \ + | sed 's/{sha256}/${{env.ARCHIVE_SHA256}}/g' \ + > ${{ github.workspace }}/.github/release_notes.txt + - name: Release + uses: softprops/action-gh-release@v1 + id: rules_release + with: + generate_release_notes: true + tag_name: ${{ env.RELEASE_VERSION }} + body_path: ${{ github.workspace }}/.github/release_notes.txt + target_commitish: ${{ github.base_ref }} + - name: "Upload the rules archive" + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.rules_release.outputs.upload_url }} + asset_name: rules_pytest-v${{ env.RELEASE_VERSION }}.tar.gz + asset_path: ${{ github.workspace }}/.github/rules_pytest.tar.gz + asset_content_type: application/gzip diff --git a/.gitignore b/.gitignore index 10796bd..4de67b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ # Git ignore patterns + +/bazel-* +user.bazelrc + +/.venv* +/venv* diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..f238bf7 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +profile = black diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..26010e6 --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,20 @@ +# https://mypy.readthedocs.io/en/stable/config_file.html +[mypy] + +# Improve strictness of checks +strict = True + +# Avoid deprecated args in the config file +warn_unused_configs = True + +# Improve logging +pretty = True + +# Prevent mypy from incorrectly looking in multiple places for +# the same package. +explicit_package_bases = True + +# Because mypy is not running in Bazel, the runfiles library will +# not be available +[mypy-python.runfiles.*] +ignore_missing_imports = True diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 0000000..6dc0e22 --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,5 @@ +exports_files([ + "MODULE.bazel", + "README.md", + "version.bzl", +]) diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 0000000..6106636 --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,9 @@ +"""UebelAndre/rules_pytest""" + +module( + name = "rules_pytest", + version = "0.0.1", +) + +bazel_dep(name = "rules_python", version = "0.34.0") +bazel_dep(name = "rules_req_compile", version = "1.0.0rc23") diff --git a/README.md b/README.md index 3363eed..3d43f56 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,208 @@ + + # rules_pytest -Bazel rules for the Pytest Python test framework. + +Bazel rules for the [Pytest Python test framework](https://docs.pytest.org/en/stable/). + +## Rules + +- [py_pytest_test](#py_pytest_test) +- [py_pytest_test_suite](#py_pytest_test_suite) +- [py_pytest_toolchain](#py_pytest_toolchain) + +--- +--- + + + +## current_py_pytest_toolchain + +
+current_py_pytest_toolchain(name)
+
+ +A rule for exposing the current registered `py_pytest_toolchain`. + +**ATTRIBUTES** + + +| Name | Description | Type | Mandatory | Default | +| :------------- | :------------- | :------------- | :------------- | :------------- | +| name | A unique name for this target. | Name | required | | + + + + +## py_pytest_toolchain + +
+py_pytest_toolchain(name, pytest)
+
+ +A toolchain for the [pytest](https://python/pytest.readthedocs.io/en/stable/) formatter rules. + +**ATTRIBUTES** + + +| Name | Description | Type | Mandatory | Default | +| :------------- | :------------- | :------------- | :------------- | :------------- | +| name | A unique name for this target. | Name | required | | +| pytest | The pytest `py_library` to use with the rules. | Label | required | | + + + + +## py_pytest_test + +
+py_pytest_test(name, srcs, coverage_rc, pytest_config, numprocesses, tags, kwargs)
+
+ +A rule which runs python tests using [pytest][pt] as the [py_test][bpt] test runner. + +This rule also supports a build setting for globally applying extra flags to test invocations. +Users can add something similar to the following to their `.bazelrc` files: + +```text +build --//python/pytest:extra_args=--color=yes,-vv +``` + +The example above will add `--colors=yes` and `-vv` arguments to the end of the pytest invocation. + +Tips: + +- It's common for tests to have some utility code that does not live in a test source file. +To account for this. A `py_library` can be created that contains only these sources which are then +passed to `py_pytest_test` via `deps`. + +```python +load("@rules_python//python:defs.bzl", "py_library") +load("@rules_pytest//python/pytest:defs.bzl", "PYTEST_TARGET", "py_pytest_test") + +py_library( + name = "test_utils", + srcs = [ + "tests/__init__.py", + "tests/conftest.py", + ], + deps = [PYTEST_TARGET], + testonly = True, +) + +py_pytest_test( + name = "test", + srcs = ["tests/example_test.py"], + deps = [":test_utils"], +) +``` + +[pt]: https://docs.pytest.org/en/latest/ +[bpt]: https://docs.bazel.build/versions/master/be/python.html#py_test +[ptx]: https://pypi.org/project/pytest-xdist/ + + +**PARAMETERS** + + +| Name | Description | Default Value | +| :------------- | :------------- | :------------- | +| name | The name for the current target. | none | +| srcs | An explicit list of source files to test. | none | +| coverage_rc | The pytest-cov configuration file to use | `Label("@rules_pytest//python/pytest:coverage_rc")` | +| pytest_config | The pytest configuration file to use | `Label("@rules_pytest//python/pytest:config")` | +| numprocesses | If set the [pytest-xdist][ptx] argument `--numprocesses` (`-n`) will be passed to the test. | `None` | +| tags | Tags to set on the underlying `py_test` target. | `[]` | +| kwargs | Keyword arguments to forward to the underlying `py_test` target. | none | + + + + +## py_pytest_test_suite + +
+py_pytest_test_suite(name, tests, args, data, coverage_rc, pytest_config, kwargs)
+
+ +Generates a [test_suite][ts] which groups various test targets for a set of python sources. + +Given an idiomatic python project structure: +```text +BUILD.bazel +my_lib/ + __init__.py + mod_a.py + mod_b.py + mod_c.py +requirements.in +requirements.txt +tests/ + __init__.py + fixtures.py + test_mod_a.py + test_mod_b.py + test_mod_c.py +``` + +This rule can be used to easily define test targets: + +```python +load("@rules_python//python:defs.bzl", "py_library") +load("@rules_pytest//python/pytest:defs.bzl", "py_pytest_test_suite") + +py_library( + name = "my_lib", + srcs = glob(["my_lib/**/*.py"]) + imports = ["."], +) + +py_pytest_test_suite( + name = "my_lib_test_suite", + # Source files containing test helpers should go here. + # Note that the test sources are excluded. This avoids + # a test to be updated without invalidating all other + # targets. + srcs = glob( + include = ["tests/**/*.py"], + exclude = ["tests/**/*_test.py"], + ), + # Any data files the tests may need would be passed here + data = glob(["tests/**/*.json"]), + # This field is used for dedicated test files. + tests = glob(["tests/**/*_test.py"]), + deps = [ + ":my_lib", + ], +) +``` + +For each file passed to `tests`, a [py_pytest_test](#py_pytest_test) target will be created. From the example above, +the user should expect to see the following test targets: +```text +//:my_lib_test_suite +//:tests/test_mod_a +//:tests/test_mod_b +//:tests/test_mod_c +``` + +Additional Notes: +- No file passed to `tests` should be passed found in the `srcs` or `data` attributes or tests will not be able + to be individually cached. + +[pt]: https://docs.bazel.build/versions/master/be/python.html#py_test +[ts]: https://docs.bazel.build/versions/master/be/general.html#test_suite + + +**PARAMETERS** + + +| Name | Description | Default Value | +| :------------- | :------------- | :------------- | +| name | The name of the test suite | none | +| tests | A list of source files, typically `glob(["tests/**/*_test.py"])`, which are converted into test targets. | none | +| args | Arguments for the underlying `py_pytest_test` targets. | `[]` | +| data | A list of additional data for the test. This field would also include python files containing test helper functionality. | `[]` | +| coverage_rc | The pytest-cov configuration file to use. | `Label("@rules_pytest//python/pytest:coverage_rc")` | +| pytest_config | The pytest configuration file to use. | `Label("@rules_pytest//python/pytest:config")` | +| kwargs | Keyword arguments passed to the underlying `py_test` rule. | none | + + diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel new file mode 100644 index 0000000..92f6409 --- /dev/null +++ b/WORKSPACE.bazel @@ -0,0 +1,63 @@ +workspace(name = "rules_pytest") + +load("//python/pytest:repositories.bzl", "rules_pytest_dependencies") + +rules_pytest_dependencies() + +load("//python/pytest:repositories_transitive_1.bzl", "rules_pytest_transitive_deps_1") + +rules_pytest_transitive_deps_1() + +load("//python/pytest:repositories_transitive_2.bzl", "rules_pytest_transitive_deps_2") + +rules_pytest_transitive_deps_2() + +load("//python/pytest:repositories_transitive_3.bzl", "rules_pytest_transitive_deps_3") + +rules_pytest_transitive_deps_3() + +load("//python/pytest:repositories_toolchains.bzl", "register_pytest_toolchains") + +register_pytest_toolchains() + +# ============================================================================= +# Internal dependencies only +# ============================================================================= + +load("@rules_python//python:repositories.bzl", "python_register_toolchains") + +python_register_toolchains( + name = "python311", + python_version = "3.11", +) + +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "io_bazel_stardoc", + sha256 = "62bd2e60216b7a6fec3ac79341aa201e0956477e7c8f6ccc286f279ad1d96432", + urls = [ + "https://mirror.bazel.build/github.com/bazelbuild/stardoc/releases/download/0.6.2/stardoc-0.6.2.tar.gz", + "https://github.com/bazelbuild/stardoc/releases/download/0.6.2/stardoc-0.6.2.tar.gz", + ], +) + +load("@io_bazel_stardoc//:setup.bzl", "stardoc_repositories") + +stardoc_repositories() + +load("@rules_jvm_external//:repositories.bzl", "rules_jvm_external_deps") + +rules_jvm_external_deps() + +load("@rules_jvm_external//:setup.bzl", "rules_jvm_external_setup") + +rules_jvm_external_setup() + +load("@io_bazel_stardoc//:deps.bzl", "stardoc_external_deps") + +stardoc_external_deps() + +load("@stardoc_maven//:defs.bzl", stardoc_pinned_maven_install = "pinned_maven_install") + +stardoc_pinned_maven_install() diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel new file mode 100644 index 0000000..a95c4b0 --- /dev/null +++ b/docs/BUILD.bazel @@ -0,0 +1,26 @@ +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") +load("@io_bazel_stardoc//stardoc:stardoc.bzl", "stardoc") + +stardoc( + name = "docs", + out = "README.md", + input = "//python/pytest:defs.bzl", + # TODO: https://github.com/bazelbuild/stardoc/issues/110 + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), + deps = ["//python/pytest:bzl_lib"], +) + +diff_test( + name = "docs_diff_test", + failure_message = "To fix, run 'cp -f ./bazel-bin/docs/README.md ./README.md' from the root of the repo.", + file1 = ":docs", + file2 = "//:README.md", + # TODO: https://github.com/bazelbuild/stardoc/issues/110 + target_compatible_with = select({ + "@platforms//os:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), +) diff --git a/pylintrc.toml b/pylintrc.toml new file mode 100644 index 0000000..aa17cbc --- /dev/null +++ b/pylintrc.toml @@ -0,0 +1,25 @@ +[tool.pylint.main] + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension = true + +# Limit actions to using 1 core per action. +jobs = 1 + +disable = [ + "fixme", # Developers should be allowed to leave TODO comments. + "wrong-import-position", # isort is in charge of import ordering. + "wrong-import-order", # isort is in charge of import ordering. + "duplicate-code", # While pylint runs outside of Bazel, it considers code from unrelated modules for dupes. +] + +ignored-modules = [ + # Because pylint is not running in Bazel, the runfiles library will + # not be available + "python.runfiles" +] + +[tool.pylint.format] +# Maximum number of characters on a single line. +max-line-length=120 diff --git a/python/BUILD.bazel b/python/BUILD.bazel new file mode 100644 index 0000000..e69de29 diff --git a/python/pytest/3rdparty/BUILD.bazel b/python/pytest/3rdparty/BUILD.bazel new file mode 100644 index 0000000..8637a84 --- /dev/null +++ b/python/pytest/3rdparty/BUILD.bazel @@ -0,0 +1,33 @@ +load("@rules_req_compile//:defs.bzl", "py_reqs_compiler", "py_reqs_solution_test") + +PLATFORMS = [ + "linux", + "macos", + "windows", +] + +[ + py_reqs_compiler( + name = "requirements.{}.update".format(platform), + requirements_in = "//python/pytest:requirements.in", + requirements_txt = "//python/pytest:requirements.{}.txt".format(platform), + target_compatible_with = ["@platforms//os:{}".format(platform)], + ) + for platform in PLATFORMS +] + +[ + py_reqs_solution_test( + name = "requirements_{}_test".format(platform), + compiler = ":requirements.{}.update".format(platform), + ) + for platform in PLATFORMS +] + +alias( + name = "requirements.update", + actual = select({ + "@platforms//os:{}".format(platform): ":requirements.{}.update".format(platform) + for platform in PLATFORMS + }), +) diff --git a/python/pytest/BUILD.bazel b/python/pytest/BUILD.bazel new file mode 100644 index 0000000..684fdfa --- /dev/null +++ b/python/pytest/BUILD.bazel @@ -0,0 +1,46 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +load("//python/pytest/private:pytest_utils.bzl", "extra_pytest_args") +load(":defs.bzl", "current_py_pytest_toolchain") + +package(default_visibility = ["//visibility:public"]) + +exports_files([ + "coveragerc", + "pyproject.toml", + "defs.bzl", + "requirements.in", + "requirements.linux.txt", + "requirements.macos.txt", + "requirements.windows.txt", +]) + +label_flag( + name = "config", + build_setting_default = ":pyproject.toml", +) + +label_flag( + name = "coverage_rc", + build_setting_default = ":coveragerc", +) + +extra_pytest_args( + name = "extra_args", + build_setting_default = [], + visibility = ["//visibility:public"], +) + +toolchain_type( + name = "toolchain_type", +) + +current_py_pytest_toolchain( + name = "current_py_pytest_toolchain", +) + +bzl_library( + name = "bzl_lib", + srcs = glob(["*.bzl"]), + visibility = ["//visibility:public"], + deps = ["//python/pytest/private:bzl_lib"], +) diff --git a/python/pytest/coveragerc b/python/pytest/coveragerc new file mode 100644 index 0000000..398ff08 --- /dev/null +++ b/python/pytest/coveragerc @@ -0,0 +1,2 @@ +[run] +branch = True diff --git a/python/pytest/defs.bzl b/python/pytest/defs.bzl new file mode 100644 index 0000000..f6c4745 --- /dev/null +++ b/python/pytest/defs.bzl @@ -0,0 +1,28 @@ +"""# rules_pytest + +Bazel rules for the [Pytest Python test framework](https://docs.pytest.org/en/stable/). + +## Rules + +- [py_pytest_test](#py_pytest_test) +- [py_pytest_test_suite](#py_pytest_test_suite) +- [py_pytest_toolchain](#py_pytest_toolchain) + +--- +--- +""" + +load( + "//python/pytest/private:pytest.bzl", + _PYTEST_TARGET = "PYTEST_TARGET", + _current_py_pytest_toolchain = "current_py_pytest_toolchain", + _py_pytest_test = "py_pytest_test", + _py_pytest_test_suite = "py_pytest_test_suite", + _py_pytest_toolchain = "py_pytest_toolchain", +) + +current_py_pytest_toolchain = _current_py_pytest_toolchain +py_pytest_test = _py_pytest_test +py_pytest_test_suite = _py_pytest_test_suite +py_pytest_toolchain = _py_pytest_toolchain +PYTEST_TARGET = _PYTEST_TARGET diff --git a/python/pytest/private/BUILD.bazel b/python/pytest/private/BUILD.bazel new file mode 100644 index 0000000..522e0c9 --- /dev/null +++ b/python/pytest/private/BUILD.bazel @@ -0,0 +1,48 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +load("@rules_python//python:defs.bzl", "py_binary", "py_library") +load(":pytest_utils.bzl", "pytest_entrypoint_wrapper") + +# A wrapper around the runfiles dependency to avoid conflicts +# in users of the python rules defining it as a dependency as +# well. +py_library( + name = "runfiles_wrapper", + srcs = [], + visibility = ["//visibility:public"], + deps = [ + "@rules_python//python/runfiles", + ], +) + +py_library( + name = "pytest_process_wrapper", + srcs = [ + "pytest_process_wrapper.py", + ], + visibility = ["//visibility:public"], + deps = [ + ":runfiles_wrapper", + "//python/pytest:current_py_pytest_toolchain", + ], +) + +py_binary( + name = "entrypoint_sanitizer", + srcs = ["entrypoint_sanitizer.py"], +) + +pytest_entrypoint_wrapper( + name = "pytest_process_wrapper_entrypoint", + out = "process_wrapper.py", + entrypoint = "pytest_process_wrapper.py", + visibility = ["//visibility:public"], +) + +bzl_library( + name = "bzl_lib", + srcs = glob(["*.bzl"]), + visibility = ["//python/pytest:__pkg__"], + deps = [ + "@rules_python//python:defs_bzl", + ], +) diff --git a/python/pytest/private/entrypoint_sanitizer.py b/python/pytest/private/entrypoint_sanitizer.py new file mode 100644 index 0000000..beac0c2 --- /dev/null +++ b/python/pytest/private/entrypoint_sanitizer.py @@ -0,0 +1,46 @@ +"""A script for updating the pytest entrypoint to be ignored by common linters.""" + +import argparse +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + """Parse command line arguments""" + parser = argparse.ArgumentParser() + + parser.add_argument( + "--output", + type=Path, + required=True, + help="The location of the output file to write.", + ) + parser.add_argument( + "--entrypoint", + type=Path, + required=True, + help="The location of the entrypoint to read.", + ) + + return parser.parse_args() + + +def main() -> None: + """The main entrypoint.""" + args = parse_args() + + content = args.entrypoint.read_text(encoding="utf-8") + + args.output.write_text( + "\n".join( + [ + "# type: ignore", + "# pylint: skip-file", + content, + ] + ), + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/python/pytest/private/pytest.bzl b/python/pytest/private/pytest.bzl new file mode 100644 index 0000000..86e9670 --- /dev/null +++ b/python/pytest/private/pytest.bzl @@ -0,0 +1,382 @@ +"""Pytest rules for Bazel""" + +load("@rules_python//python:defs.bzl", "PyInfo", "py_library", "py_test") + +PYTEST_TARGET = Label("//python/pytest:current_py_pytest_toolchain") + +PY_PYTEST_TEST_ARGS_FILE = "PY_PYTEST_TEST_ARGS_FILE" + +test_configs = struct( + coverage_rc = Label("//python/pytest:coverage_rc"), + pytest_config = Label("//python/pytest:config"), +) + +_EXTRA_ARGS_MANIFEST = Label("//python/pytest:extra_args") + +def _is_pytest_test(src): + basename = src.basename + + if basename.startswith("test_"): + return True + + if basename.endswith("_test.py"): + return True + + return False + +def _rlocationpath(file, workspace_name): + if file.short_path.startswith("../"): + return file.short_path[len("../"):] + + return "{}/{}".format(workspace_name, file.short_path) + +def _pytest_tests_manifest_impl(ctx): + output = ctx.actions.declare_file(ctx.label.name) + + test_srcs = sorted([ + _rlocationpath(src, ctx.workspace_name) + for src in ctx.files.srcs + if _is_pytest_test(src) + ]) + + ctx.actions.write( + output = output, + content = "\n".join(test_srcs), + ) + + return DefaultInfo( + files = depset([output]), + runfiles = ctx.runfiles(files = [output]), + ) + +_pytest_tests_manifest = rule( + doc = "A rule for collecting files to test when running pytest", + implementation = _pytest_tests_manifest_impl, + attrs = { + "srcs": attr.label_list( + doc = "A list of python source files", + allow_files = [".py"], + mandatory = True, + ), + }, +) + +def py_pytest_test( + name, + srcs, + coverage_rc = test_configs.coverage_rc, + pytest_config = test_configs.pytest_config, + numprocesses = None, + tags = [], + **kwargs): + """A rule which runs python tests using [pytest][pt] as the [py_test][bpt] test runner. + + This rule also supports a build setting for globally applying extra flags to test invocations. + Users can add something similar to the following to their `.bazelrc` files: + + ```text + build --//python/pytest:extra_args=--color=yes,-vv + ``` + + The example above will add `--colors=yes` and `-vv` arguments to the end of the pytest invocation. + + Tips: + + - It's common for tests to have some utility code that does not live in a test source file. + To account for this. A `py_library` can be created that contains only these sources which are then + passed to `py_pytest_test` via `deps`. + + ```python + load("@rules_python//python:defs.bzl", "py_library") + load("@rules_pytest//python/pytest:defs.bzl", "PYTEST_TARGET", "py_pytest_test") + + py_library( + name = "test_utils", + srcs = [ + "tests/__init__.py", + "tests/conftest.py", + ], + deps = [PYTEST_TARGET], + testonly = True, + ) + + py_pytest_test( + name = "test", + srcs = ["tests/example_test.py"], + deps = [":test_utils"], + ) + ``` + + [pt]: https://docs.pytest.org/en/latest/ + [bpt]: https://docs.bazel.build/versions/master/be/python.html#py_test + [ptx]: https://pypi.org/project/pytest-xdist/ + + Args: + name (str): The name for the current target. + srcs (list): An explicit list of source files to test. + coverage_rc (Label, optional): The pytest-cov configuration file to use + pytest_config (Label, optional): The pytest configuration file to use + numprocesses (int, optional): If set the [pytest-xdist][ptx] argument + `--numprocesses` (`-n`) will be passed to the test. + tags (list, optional): Tags to set on the underlying `py_test` target. + **kwargs: Keyword arguments to forward to the underlying `py_test` target. + """ + if kwargs.get("tests"): + fail("The `tests` attribute is deprecated, please update `{}:{}` to use `srcs`.".format( + native.package_name(), + name, + )) + + runner_data = [ + pytest_config, + coverage_rc, + _EXTRA_ARGS_MANIFEST, + ] + + # Gather args for the runner + runner_args = [ + "--cov-config=$(rlocationpath {})".format(coverage_rc), + "--pytest-config=$(rlocationpath {})".format(pytest_config), + "--extra-args-manifest=$(rlocationpath {})".format(_EXTRA_ARGS_MANIFEST), + ] + + tests_manifest_name = name + ".pytest_tests_manifest" + _pytest_tests_manifest( + name = tests_manifest_name, + srcs = srcs, + tags = ["manual"], + testonly = True, + ) + runner_args.append( + "--tests-manifest=$(rlocationpath {})".format(tests_manifest_name), + ) + runner_data.append(tests_manifest_name) + + # Create an unfrozen list. + tags = tags[:] if tags else [] + + # Optionally enable multi-threading + if numprocesses != None: + runner_args.append("--numprocesses={}".format(numprocesses)) + cpu_tag = "cpu:{}".format(numprocesses) + if cpu_tag not in tags: + tags.append(cpu_tag) + + # Separate runner args from other inputs + runner_args.append("--") + + runner_deps = [ + Label("//python/pytest:current_py_pytest_toolchain"), + Label("//python/pytest/private:runfiles_wrapper"), + ] + + runner_main = Label("//python/pytest/private:process_wrapper.py") + + if "main" in kwargs: + fail("The attribute `main` should not be used as it's replaced by a test runner") + + py_test( + name = name, + srcs = [runner_main] + srcs, + main = runner_main, + deps = runner_deps + kwargs.pop("deps", []), + data = runner_data + kwargs.pop("data", []), + args = runner_args + kwargs.pop("args", []), + legacy_create_init = 0, + **kwargs + ) + +def _py_pytest_toolchain_impl(ctx): + pytest_target = ctx.attr.pytest + + # TODO: Default info changes behavior when it's simply forwarded. + # To avoid this a new one is recreated. + default_info = DefaultInfo( + files = pytest_target[DefaultInfo].files, + runfiles = pytest_target[DefaultInfo].default_runfiles, + ) + + return [ + platform_common.ToolchainInfo( + pytest = ctx.attr.pytest, + ), + default_info, + pytest_target[PyInfo], + pytest_target[OutputGroupInfo], + pytest_target[InstrumentedFilesInfo], + ] + +py_pytest_toolchain = rule( + implementation = _py_pytest_toolchain_impl, + doc = "A toolchain for the [pytest](https://python/pytest.readthedocs.io/en/stable/) formatter rules.", + attrs = { + "pytest": attr.label( + doc = "The pytest `py_library` to use with the rules.", + providers = [PyInfo], + mandatory = True, + ), + }, +) + +def _current_py_pytest_toolchain_impl(ctx): + toolchain = ctx.toolchains[str(Label("//python/pytest:toolchain_type"))] + + pytest_target = toolchain.pytest + + # TODO: Default info changes behavior when it's simply forwarded. + # To avoid this a new one is recreated. + default_info = DefaultInfo( + files = pytest_target[DefaultInfo].files, + runfiles = pytest_target[DefaultInfo].default_runfiles, + ) + + return [ + toolchain, + default_info, + pytest_target[PyInfo], + pytest_target[OutputGroupInfo], + pytest_target[InstrumentedFilesInfo], + ] + +current_py_pytest_toolchain = rule( + doc = "A rule for exposing the current registered `py_pytest_toolchain`.", + implementation = _current_py_pytest_toolchain_impl, + toolchains = [ + str(Label("//python/pytest:toolchain_type")), + ], +) + +def py_pytest_test_suite( + name, + tests, + args = [], + data = [], + coverage_rc = test_configs.coverage_rc, + pytest_config = test_configs.pytest_config, + **kwargs): + """Generates a [test_suite][ts] which groups various test targets for a set of python sources. + + Given an idiomatic python project structure: + ```text + BUILD.bazel + my_lib/ + __init__.py + mod_a.py + mod_b.py + mod_c.py + requirements.in + requirements.txt + tests/ + __init__.py + fixtures.py + test_mod_a.py + test_mod_b.py + test_mod_c.py + ``` + + This rule can be used to easily define test targets: + + ```python + load("@rules_python//python:defs.bzl", "py_library") + load("@rules_pytest//python/pytest:defs.bzl", "py_pytest_test_suite") + + py_library( + name = "my_lib", + srcs = glob(["my_lib/**/*.py"]) + imports = ["."], + ) + + py_pytest_test_suite( + name = "my_lib_test_suite", + # Source files containing test helpers should go here. + # Note that the test sources are excluded. This avoids + # a test to be updated without invalidating all other + # targets. + srcs = glob( + include = ["tests/**/*.py"], + exclude = ["tests/**/*_test.py"], + ), + # Any data files the tests may need would be passed here + data = glob(["tests/**/*.json"]), + # This field is used for dedicated test files. + tests = glob(["tests/**/*_test.py"]), + deps = [ + ":my_lib", + ], + ) + ``` + + For each file passed to `tests`, a [py_pytest_test](#py_pytest_test) target will be created. From the example above, + the user should expect to see the following test targets: + ```text + //:my_lib_test_suite + //:tests/test_mod_a + //:tests/test_mod_b + //:tests/test_mod_c + ``` + + Additional Notes: + - No file passed to `tests` should be passed found in the `srcs` or `data` attributes or tests will not be able + to be individually cached. + + [pt]: https://docs.bazel.build/versions/master/be/python.html#py_test + [ts]: https://docs.bazel.build/versions/master/be/general.html#test_suite + + Args: + name (str): The name of the test suite + tests (list): A list of source files, typically `glob(["tests/**/*_test.py"])`, which are converted + into test targets. + args (list, optional): Arguments for the underlying `py_pytest_test` targets. + data (list, optional): A list of additional data for the test. This field would also include python + files containing test helper functionality. + coverage_rc (Label, optional): The pytest-cov configuration file to use. + pytest_config (Label, optional): The pytest configuration file to use. + **kwargs: Keyword arguments passed to the underlying `py_test` rule. + """ + + tests_targets = [] + + deps = kwargs.pop("deps", []) + srcs = kwargs.pop("srcs", []) + if srcs: + test_lib_name = name + "_test_lib" + py_library( + name = test_lib_name, + srcs = srcs, + deps = deps, + data = data, + tags = ["manual"], + ) + deps = [test_lib_name] + deps + + for src in tests: + src_name = src.name if type(src) == "Label" else src + if not src_name.endswith(".py"): + fail("srcs should have `.py` extensions") + + # The test name should not end with `.py` + test_name = src_name[:-3] + py_pytest_test( + name = test_name, + coverage_rc = coverage_rc, + pytest_config = pytest_config, + args = args, + srcs = [src], + data = data, + deps = deps, + **kwargs + ) + + tests_targets.append(test_name) + + common_kwargs = { + "tags": kwargs.get("tags", []), + "target_compatible_with": kwargs.get("target_compatible_with"), + "visibility": kwargs.get("visibility"), + } + + native.test_suite( + name = name, + tests = tests_targets, + **common_kwargs + ) diff --git a/python/pytest/private/pytest_process_wrapper.py b/python/pytest/private/pytest_process_wrapper.py new file mode 100644 index 0000000..b071b6f --- /dev/null +++ b/python/pytest/private/pytest_process_wrapper.py @@ -0,0 +1,452 @@ +"""Wrapper to run pytest and gather coverage into an LCOV database.""" + +import argparse +import configparser +import json +import os +import subprocess +import sys +from pathlib import Path, PurePosixPath +from typing import Dict, List, Optional, Sequence + +import coverage +from coverage.cmdline import main as coverage_main +from python.runfiles import Runfiles + +RUNFILES: Optional[Runfiles] = Runfiles.Create() + + +CoverageSourceMap = Dict[Path, PurePosixPath] +"""A mapping of an `execpath` to `rootpath` for files to collect coverage for. + +For more details, see documentation on Bazel make variables: +https://docs.bazel.build/versions/main/be/make-variables.html#predefined_label_variables +""" + + +def bazel_runfile(arg: str) -> Path: + """A wrapper for locating Bazel runfiles + + Args: + arg: The command line input to be parsed + + Returns: + The path to the runfile + """ + + if not RUNFILES: + raise EnvironmentError( + "RUNFILES_MANIFEST_FILE and RUNFILES_DIR are not set. Is python running" + " under Bazel?" + ) + + rlocation = RUNFILES.Rlocation(arg) + if not rlocation: + raise ValueError(f"Failed to find runfile for `{arg}`") + + return Path(rlocation) + + +def _bazel_runfiles_manifest(arg: str) -> List[Path]: + """Reads a list of runfiles from a file of newline delimited paths + + Args: + arg (str): The command line input to be parsed + + Returns: + A list of runfiles + """ + manifest = bazel_runfile(arg) + + with manifest.open("r", encoding="utf-8") as fhd: + sources = [bazel_runfile(line.strip()) for line in fhd.readlines()] + + return sources + + +def _bazel_args_manifest(arg: str) -> List[str]: + """Read a file containing additional arguments for pytest. + + Args: + arg: The command line input to be parsed + + Returns: + A list of pytest arguments + """ + manifest = bazel_runfile(arg) + + with manifest.open("r", encoding="utf-8") as fhd: + data: List[str] = json.load(fhd) + return data + + +def parse_args(args: Optional[Sequence[str]] = None) -> argparse.Namespace: + """Parse command line arguments + + Args: + args: An optional list of arguments to use for parsing. If unset, `sys.argv` is used. + + Returns: + argparse.Namespace: A struct of parsed arguments + """ + parser = argparse.ArgumentParser(prog="pytest_process_wrapper", usage=__doc__) + parser.add_argument( + "--cov-config", + required=True, + type=bazel_runfile, + help="Path to a coverage.py rc file.", + ) + parser.add_argument( + "--pytest-config", + required=True, + type=bazel_runfile, + help="Path to a pytest config file.", + ) + parser.add_argument( + "--tests-manifest", + dest="sources", + type=_bazel_runfiles_manifest, + required=True, + help="A file contining a list of sources to test", + ) + parser.add_argument( + "--extra-args-manifest", + dest="extra_pytest_args", + type=_bazel_args_manifest, + required=True, + help="A file containing extra arugments for pytest.", + ) + parser.add_argument( + "-n", + "--numprocesses", + dest="numprocesses", + type=int, + help="pytest-xdist argument for running tests concurrently.", + ) + parser.add_argument( + "pytest_args", + nargs="*", + help="Remaining arguments to forward to pytest.", + ) + + if args is not None: + parsed_args = parser.parse_args(args) + else: + parsed_args = parser.parse_args() + + # Parse specific arguments from the list of pytest args + pytest_parser = argparse.ArgumentParser("internal_parser") + pytest_parser.add_argument( + "-n", + "--numprocesses", + dest="numprocesses", + type=int, + help="pytest-xdist argument for running tests concurrently", + ) + pytest_args, remaining = pytest_parser.parse_known_args(parsed_args.pytest_args) + + parsed_args.pytest_args = remaining + + if pytest_args.numprocesses: + if parsed_args.numprocesses != pytest_args.numprocesses: + parser.error( + "--numprocesses (-n) must be an argument to the process runner. " + "Please update the Bazel target to pass `numprocesses`." + ) + + if parsed_args.numprocesses is not None: + parsed_args.pytest_args = [ + "-n", + str(parsed_args.numprocesses), + ] + parsed_args.pytest_args + + return parsed_args + + +def collect_coverage_sources(manifest: Path) -> CoverageSourceMap: + """Generate a map of files to collect coverage for. + + Args: + manifest: A manifest containing newline delimited source paths. + + Returns: + A map of absolute paths to relative paths for coverage sources. + """ + if not RUNFILES: + raise RuntimeError( + "A rules_python.python.runfiles object is needed to locate coverage sources" + ) + + workspace = PurePosixPath(os.environ["TEST_WORKSPACE"]) + + sources = {} + for line in manifest.read_text().splitlines(): + line = line.strip() + # The coverage manifest may include files such as `.gcno` from other instrumented + # runfiles, for python these other coverage outputs are ignored. + if line.startswith("bazel-out"): + continue + + rlocationpath = str(workspace / line) + src = RUNFILES.Rlocation(rlocationpath) + if not src: + raise FileNotFoundError(f"Failed to find runfile {rlocationpath}") + sources.update({Path(src): PurePosixPath(line)}) + + return sources + + +def splice_coverage_config( + cov_config_path: Path, coverage_sources: CoverageSourceMap, data_file: Path +) -> Path: + """Modify a coveragerc file to explicitly include or omit source files from a coverage manfiest + + Args: + cov_config_path: The path to an existing coveragerc file. + coverage_sources: A map of source files to run coverage on. + data_file: The path where coverage should be written. + + Returns: + A path to a coverage config + """ + # Write the new includes to an rc file + cov_config = configparser.ConfigParser() + cov_config.read(str(cov_config_path)) + + # Ensure the `run` section exists + if "run" not in cov_config.sections(): + cov_config.add_section("run") + + # Force the data file to be an expected path + cov_config.set("run", "data_file", str(data_file)) + + # Grab any existing coverage.py include or omit settings + includes = cov_config.get("run", "include", fallback="") + omits = cov_config.get("run", "omit", fallback="") + + # In cases where a coverage manifest is provided but it's empty, we interpret + # that to be a test that has no dependencies from the same workspace and + # by extension, no dependencies used for coverage. All sources are then + # excluded from collecting coverage. Users who do not expect coverage to be + # collected from `deps` targets should annotate their `.coveragerc` file to + # collect the correct inputs from the `data` attribute. + if coverage_sources: + if includes: + existing_includes = includes.split(",") + else: + existing_includes = [] + existing_includes.extend([str(src) for src in sorted(coverage_sources.keys())]) + cov_config.set("run", "include", "\n".join(existing_includes)) + + elif not includes and not omits: + cov_config.set("run", "omit", "*") + + updated_cov_config = Path(os.environ["TEST_TMPDIR"]) / ".coveragerc" + with updated_cov_config.open("w", encoding="utf-8") as fhd: + cov_config.write(fhd) + + return updated_cov_config + + +def load_args_file() -> Optional[List[str]]: + """Attempt to load an args file from the environment + + Returns: + A list of args if a args file was found. + """ + argv = None + if "PY_PYTEST_TEST_ARGS_FILE" in os.environ: + args_file = bazel_runfile(os.environ["PY_PYTEST_TEST_ARGS_FILE"]) + argv = args_file.read_text(encoding="utf-8").splitlines() + return argv + + +# pylint: disable=too-many-branches +def main() -> None: + """Main execution.""" + patch_realpaths() + + parsed_args = parse_args(load_args_file()) + + temp_dir = Path(os.environ["TEST_TMPDIR"]) + + child_env = dict(os.environ) + child_env.update( + { + "HOME": str(temp_dir / "home"), + "TEMP": str(temp_dir / "tmp"), + "TMP": str(temp_dir / "tmp"), + "TMPDIR": str(temp_dir / "tmp"), + "USERPROFILE": str(temp_dir / "home"), + } + ) + # Default to a slightly less claustrophobic column width. + if "COLUMNS" not in child_env: + child_env["COLUMNS"] = "100" + + # Determine the directory in which pytest should run + test_dir = Path.cwd() + + existing_python_path = os.getenv("PYTHONPATH", "") + if existing_python_path: + existing_python_path = os.pathsep + existing_python_path + child_env["PYTHONPATH"] = str(test_dir) + existing_python_path + + # Custom arguments should not be passed to pytest here. This process wrapper + # is only intended to have what's absolutely necessary to run pytest in a Bazel + # test or coverage invocation. Custom arguments should be defined in the use of + # rules which invoke this process wrapper or by providing `--pytest-config`. + pytest_args = [ + sys.executable, + "-m", + "pytest", + ] + + cov_config_path = parsed_args.cov_config + coverage_sources = {} + + cov_enabled = os.getenv("COVERAGE") == "1" + if cov_enabled: + coverage_file = Path(os.environ["TEST_TMPDIR"], ".coverage") + child_env["COVERAGE_FILE"] = str(coverage_file) + + # Attempt to locate a coverage manifest indicating what files should be + # included in coverage reports. + if "COVERAGE_MANIFEST" in os.environ: + coverage_manifest = Path(os.environ["COVERAGE_MANIFEST"]) + if "ROOT" in os.environ and not coverage_manifest.absolute(): + coverage_manifest = Path(os.environ["ROOT"]) / coverage_manifest + + coverage_sources = collect_coverage_sources(coverage_manifest) + + # If no coverage sources are provided, then coverage is disabled. + if coverage_sources: + cov_config_path = splice_coverage_config( + cov_config_path=cov_config_path, + coverage_sources=coverage_sources, + data_file=coverage_file, + ) + + pytest_args.extend( + [ + "--cov", + "--cov-config", + str(cov_config_path), + ] + ) + + else: + pytest_args.append("--no-cov") + + # Emit JUnit XML if Bazel has specified an output file path. + # https://bazel.build/reference/test-encyclopedia#initial-conditions + xml_output_file = os.environ.get("XML_OUTPUT_FILE") + if xml_output_file is not None: + pytest_args.extend([f"--junitxml={xml_output_file}"]) + + # Explicitly tell pytest where the root directory of the test is + pytest_args.extend(["--rootdir", os.getcwd()]) + pytest_args.extend(["-c", str(parsed_args.pytest_config)]) + pytest_args.extend([str(src) for src in parsed_args.sources]) + pytest_args.extend(parsed_args.pytest_args) + pytest_args.extend(parsed_args.extra_pytest_args) + + try: + result = subprocess.run(pytest_args, cwd=test_dir, env=child_env, check=False) + # Exit code 5 indicates no tests were selected. + if result.returncode not in (0, 5): + sys.exit(result.returncode) + finally: + if cov_enabled: + dump_coverage( + coverage_file=coverage_file, + coverage_config=cov_config_path, + coverage_sources=coverage_sources, + coverage_output_file=Path( + os.environ["COVERAGE_DIR"], "python_coverage.dat" + ), + ) + + +def relativize_sf(line: bytes, coverage_sources: Dict[Path, PurePosixPath]) -> bytes: + """Parses a line of a lcov coverage file and normalizes source file (SF) paths + + Args: + line: A line from a lcov coverage file + coverage_sources: A mapping of real (`os.path.realpath`) file paths to + relative paths to the same source file from the Bazel exec root. + + Returns: + bytes: The sanitized lcov line. + """ + # Skip lines that aren't representing source files + if not line.startswith(b"SF:"): + return line + # Check if the source file has a map to a relative path + source = Path(line[3:].decode("utf-8")) + if source in coverage_sources: + return b"SF:" + str(coverage_sources[source]).encode() + + return line + + +def abs_file(filename: str) -> str: + """Return the absolute normalized form of `filename`.""" + return os.path.abspath(filename) + + +def normalize_path(filename: str) -> str: + """Normalize a file/dir name for comparison purposes.""" + return os.path.normcase(os.path.normpath(filename)) + + +def patch_realpaths() -> None: + """Patch os.path.realpath escapes. Coverage will be loaded even if not being + collected, so to be safe patch no matter what. + """ + coverage.files.abs_file = abs_file # type: ignore + coverage.control.abs_file = abs_file # type: ignore + coverage.files.set_relative_directory() + + +def dump_coverage( + coverage_file: Path, + coverage_config: Optional[Path], + coverage_sources: CoverageSourceMap, + coverage_output_file: Path, +) -> None: + """Dump coverage to LCOV format and verify coverage minimums are met. + + Args: + coverage_file: Output coverage file to write. + coverage_config: The path to a coveragerc file + coverage_sources: A map of paths to files within the sandbox to collect coverage for + coverage_output_file: The location where the lcov coverage file should be written. + """ + + cov_args = [ + "--data-file", + str(coverage_file), + ] + + if coverage_config: + cov_args.extend(["--rcfile", str(coverage_config)]) + + # Convert to LCOV and place where Bazel requests. + coverage_main(["lcov", "-o", str(coverage_output_file)] + cov_args) + + # Resolve the sandboxed files to absolute paths on the host's file system + real_path_cov_srcs = {src.resolve(): path for src, path in coverage_sources.items()} + + # Fixup the coverage file to ensure any absolute paths are corrected + # to be relative paths from the root fo the sandbox + if coverage_output_file.exists(): + cov_output_content = [ + relativize_sf(line, real_path_cov_srcs) + for line in coverage_output_file.read_bytes().splitlines() + ] + coverage_output_file.write_bytes(b"\n".join(cov_output_content)) + + +if __name__ == "__main__": + main() diff --git a/python/pytest/private/pytest_utils.bzl b/python/pytest/private/pytest_utils.bzl new file mode 100644 index 0000000..3be1add --- /dev/null +++ b/python/pytest/private/pytest_utils.bzl @@ -0,0 +1,58 @@ +"""Utility rules for pytest rules and macros""" + +def _extra_pytest_args_impl(ctx): + output = ctx.actions.declare_file(ctx.label.name + ".txt") + ctx.actions.write( + output = output, + content = json.encode(ctx.build_setting_value), + ) + + return [DefaultInfo( + files = depset([output]), + )] + +extra_pytest_args = rule( + doc = "A rule for providing additional arguments to pytest", + implementation = _extra_pytest_args_impl, + build_setting = config.string_list(flag = True), +) + +def _pytest_entrypoint_wrapper_impl(ctx): + output = ctx.outputs.out + + args = ctx.actions.args() + args.add("--output", output) + args.add("--entrypoint", ctx.file.entrypoint) + + ctx.actions.run( + mnemonic = "PytestEntrypointSanitizer", + outputs = [output], + inputs = [ctx.file.entrypoint], + arguments = [args], + executable = ctx.executable._sanitizer, + ) + + return [DefaultInfo( + files = depset([output]), + )] + +pytest_entrypoint_wrapper = rule( + doc = "A rule for injecting ignore directives for common linters.", + implementation = _pytest_entrypoint_wrapper_impl, + attrs = { + "entrypoint": attr.label( + doc = "The pytest entrypoint.", + mandatory = True, + allow_single_file = True, + ), + "out": attr.output( + doc = "The output file.", + mandatory = True, + ), + "_sanitizer": attr.label( + executable = True, + cfg = "exec", + default = Label("//python/pytest/private:entrypoint_sanitizer"), + ), + }, +) diff --git a/python/pytest/private/tests/BUILD.bazel b/python/pytest/private/tests/BUILD.bazel new file mode 100644 index 0000000..c4a2487 --- /dev/null +++ b/python/pytest/private/tests/BUILD.bazel @@ -0,0 +1,12 @@ +load("@rules_python//python:defs.bzl", "py_test") + +py_test( + name = "pytest_process_wrapper_test", + srcs = ["pytest_process_wrapper_test.py"], + deps = [ + "//python/pytest/private:pytest_process_wrapper", + "@pytest_deps//:coverage", + "@pytest_deps//:pytest_cov", + "@pytest_deps//:pytest_xdist", + ], +) diff --git a/python/pytest/private/tests/coverage/BUILD.bazel b/python/pytest/private/tests/coverage/BUILD.bazel new file mode 100644 index 0000000..e69de29 diff --git a/python/pytest/private/tests/coverage/empty_config/.coveragerc b/python/pytest/private/tests/coverage/empty_config/.coveragerc new file mode 100644 index 0000000..e69de29 diff --git a/python/pytest/private/tests/coverage/empty_config/BUILD.bazel b/python/pytest/private/tests/coverage/empty_config/BUILD.bazel new file mode 100644 index 0000000..d85f207 --- /dev/null +++ b/python/pytest/private/tests/coverage/empty_config/BUILD.bazel @@ -0,0 +1,16 @@ +load("@rules_python//python:defs.bzl", "py_library") +load("//python/pytest:defs.bzl", "py_pytest_test_suite") + +py_library( + name = "coverage", + srcs = glob(["lib/**/*.py"]), + imports = ["."], +) + +py_pytest_test_suite( + name = "coverage_test", + coverage_rc = ".coveragerc", + tests = ["tests/coverage_test.py"], + visibility = ["//python/pytest/private/tests/coverage:__pkg__"], + deps = [":coverage"], +) diff --git a/python/pytest/private/tests/coverage/empty_config/README.md b/python/pytest/private/tests/coverage/empty_config/README.md new file mode 100644 index 0000000..039ec83 --- /dev/null +++ b/python/pytest/private/tests/coverage/empty_config/README.md @@ -0,0 +1,4 @@ +# py_pytest_test empty config coverage + +This test shows that coverage continues to work when empty coveragerc files +are passed to `py_pytest_test`. diff --git a/python/pytest/private/tests/coverage/empty_config/lib/__init__.py b/python/pytest/private/tests/coverage/empty_config/lib/__init__.py new file mode 100644 index 0000000..5a9fec6 --- /dev/null +++ b/python/pytest/private/tests/coverage/empty_config/lib/__init__.py @@ -0,0 +1,28 @@ +"""A python module designed to test coverage""" + +from . import submod # noqa: F401 + + +def divide(num1: int, num2: int) -> float: + """Divide two numbers + + Args: + num1: The first number + num2: The second number + + Returns: + The result of dividing num1 into num2. + """ + if num2 == 0: + raise ValueError("Cannot divide by 0") + + return num1 / num2 + + +def say_greeting(name: str) -> None: # pragma: no cover + """Print a greeting + + Args: + name: The name of the character to greet. + """ + print(submod.greeting(name)) diff --git a/python/pytest/private/tests/coverage/empty_config/lib/submod/__init__.py b/python/pytest/private/tests/coverage/empty_config/lib/submod/__init__.py new file mode 100644 index 0000000..95a51fd --- /dev/null +++ b/python/pytest/private/tests/coverage/empty_config/lib/submod/__init__.py @@ -0,0 +1,30 @@ +"""A submodule designed to test coverage""" + + +# pylint: disable=redefined-builtin +def sum(num1: int, num2: int) -> int: + """Add two numbers together. + + Args: + num1: The first number + num2: The second number + + Returns: + The result of combining num1 and num2. + """ + return num1 + num2 + + +def greeting(name: str) -> str: + """Generate a greeting message. + + Args: + name: The name of the character to greet. + + Returns: + The greeting message + """ + if not name: + raise ValueError("The name cannot be empty") + + return f"Hello, {name}!" diff --git a/python/pytest/private/tests/coverage/empty_config/tests/coverage_test.py b/python/pytest/private/tests/coverage/empty_config/tests/coverage_test.py new file mode 100644 index 0000000..ea6938c --- /dev/null +++ b/python/pytest/private/tests/coverage/empty_config/tests/coverage_test.py @@ -0,0 +1,33 @@ +"""Tests to provide coverage used to excersize the coverage functionality of `py_pytest_test`""" + +import pytest + +import python.pytest.private.tests.coverage.empty_config.lib as coverage + + +def test_divide() -> None: + """Test division""" + assert coverage.divide(10, 2) == 5 + + +def test_divide_by_zero() -> None: + """Test attempting to divide by 0""" + with pytest.raises(ValueError) as e_info: + coverage.divide(3, 0) + assert e_info.match(r"Cannot divide by 0") + + +def test_sum() -> None: + """Test addition""" + assert coverage.submod.sum(128, 128) == 256 + + +def test_greeting() -> None: + """Test greeting""" + assert coverage.submod.greeting("Mars") == "Hello, Mars!" + + +def test_greeting_no_name() -> None: + """Test greeting with no name""" + with pytest.raises(ValueError): + coverage.submod.greeting("") diff --git a/python/pytest/private/tests/coverage/multi_file/.coveragerc b/python/pytest/private/tests/coverage/multi_file/.coveragerc new file mode 100644 index 0000000..620a5a5 --- /dev/null +++ b/python/pytest/private/tests/coverage/multi_file/.coveragerc @@ -0,0 +1,8 @@ +[run] +branch = True + +# Note that `fail_under` works here because the test +# consuming this runs all tests under a single target. +[report] +fail_under = 100 +show_missing = True diff --git a/python/pytest/private/tests/coverage/multi_file/BUILD.bazel b/python/pytest/private/tests/coverage/multi_file/BUILD.bazel new file mode 100644 index 0000000..551bd6a --- /dev/null +++ b/python/pytest/private/tests/coverage/multi_file/BUILD.bazel @@ -0,0 +1,17 @@ +load("@rules_python//python:defs.bzl", "py_library") +load("//python/pytest:defs.bzl", "py_pytest_test") + +py_library( + name = "coverage", + srcs = glob(["lib/**/*.py"]), + imports = ["."], +) + +py_pytest_test( + name = "coverage_test", + srcs = glob(["tests/**/*_test.py"]), + coverage_rc = ".coveragerc", + numprocesses = 4, + visibility = ["//python/pytest/private/tests/coverage:__pkg__"], + deps = [":coverage"], +) diff --git a/python/pytest/private/tests/coverage/multi_file/README.md b/python/pytest/private/tests/coverage/multi_file/README.md new file mode 100644 index 0000000..f785d43 --- /dev/null +++ b/python/pytest/private/tests/coverage/multi_file/README.md @@ -0,0 +1,4 @@ +# py_pytest_test multi file coverage + +This test shows that coverage reports can be collected for a library +from a single `py_pytest_test` that contains multiple test files. diff --git a/python/pytest/private/tests/coverage/multi_file/lib/__init__.py b/python/pytest/private/tests/coverage/multi_file/lib/__init__.py new file mode 100644 index 0000000..5a9fec6 --- /dev/null +++ b/python/pytest/private/tests/coverage/multi_file/lib/__init__.py @@ -0,0 +1,28 @@ +"""A python module designed to test coverage""" + +from . import submod # noqa: F401 + + +def divide(num1: int, num2: int) -> float: + """Divide two numbers + + Args: + num1: The first number + num2: The second number + + Returns: + The result of dividing num1 into num2. + """ + if num2 == 0: + raise ValueError("Cannot divide by 0") + + return num1 / num2 + + +def say_greeting(name: str) -> None: # pragma: no cover + """Print a greeting + + Args: + name: The name of the character to greet. + """ + print(submod.greeting(name)) diff --git a/python/pytest/private/tests/coverage/multi_file/lib/submod/__init__.py b/python/pytest/private/tests/coverage/multi_file/lib/submod/__init__.py new file mode 100644 index 0000000..95a51fd --- /dev/null +++ b/python/pytest/private/tests/coverage/multi_file/lib/submod/__init__.py @@ -0,0 +1,30 @@ +"""A submodule designed to test coverage""" + + +# pylint: disable=redefined-builtin +def sum(num1: int, num2: int) -> int: + """Add two numbers together. + + Args: + num1: The first number + num2: The second number + + Returns: + The result of combining num1 and num2. + """ + return num1 + num2 + + +def greeting(name: str) -> str: + """Generate a greeting message. + + Args: + name: The name of the character to greet. + + Returns: + The greeting message + """ + if not name: + raise ValueError("The name cannot be empty") + + return f"Hello, {name}!" diff --git a/python/pytest/private/tests/coverage/multi_file/tests/a_test.py b/python/pytest/private/tests/coverage/multi_file/tests/a_test.py new file mode 100644 index 0000000..39aebcc --- /dev/null +++ b/python/pytest/private/tests/coverage/multi_file/tests/a_test.py @@ -0,0 +1,21 @@ +"""Tests to provide coverage used to excersize the coverage functionality of `py_pytest_test`""" + +import pytest + +import python.pytest.private.tests.coverage.multi_file.lib as coverage + + +def test_sum() -> None: + """Test addition""" + assert coverage.submod.sum(128, 128) == 256 + + +def test_greeting() -> None: + """Test greeting""" + assert coverage.submod.greeting("Mars") == "Hello, Mars!" + + +def test_greeting_no_name() -> None: + """Test greeting with no name""" + with pytest.raises(ValueError): + coverage.submod.greeting("") diff --git a/python/pytest/private/tests/coverage/multi_file/tests/b_test.py b/python/pytest/private/tests/coverage/multi_file/tests/b_test.py new file mode 100644 index 0000000..9ab485f --- /dev/null +++ b/python/pytest/private/tests/coverage/multi_file/tests/b_test.py @@ -0,0 +1,17 @@ +"""Tests to provide coverage used to excersize the coverage functionality of `py_pytest_test`""" + +import pytest + +import python.pytest.private.tests.coverage.multi_file.lib as coverage + + +def test_divide() -> None: + """Test division""" + assert coverage.divide(10, 2) == 5 + + +def test_divide_by_zero() -> None: + """Test attempting to divide by 0""" + with pytest.raises(ValueError) as e_info: + coverage.divide(3, 0) + assert e_info.match(r"Cannot divide by 0") diff --git a/python/pytest/private/tests/coverage/simple/.coveragerc b/python/pytest/private/tests/coverage/simple/.coveragerc new file mode 100644 index 0000000..9b450bb --- /dev/null +++ b/python/pytest/private/tests/coverage/simple/.coveragerc @@ -0,0 +1,6 @@ +[run] +branch = True + +[report] +fail_under = 100 +show_missing = True diff --git a/python/pytest/private/tests/coverage/simple/BUILD.bazel b/python/pytest/private/tests/coverage/simple/BUILD.bazel new file mode 100644 index 0000000..d85f207 --- /dev/null +++ b/python/pytest/private/tests/coverage/simple/BUILD.bazel @@ -0,0 +1,16 @@ +load("@rules_python//python:defs.bzl", "py_library") +load("//python/pytest:defs.bzl", "py_pytest_test_suite") + +py_library( + name = "coverage", + srcs = glob(["lib/**/*.py"]), + imports = ["."], +) + +py_pytest_test_suite( + name = "coverage_test", + coverage_rc = ".coveragerc", + tests = ["tests/coverage_test.py"], + visibility = ["//python/pytest/private/tests/coverage:__pkg__"], + deps = [":coverage"], +) diff --git a/python/pytest/private/tests/coverage/simple/README.md b/python/pytest/private/tests/coverage/simple/README.md new file mode 100644 index 0000000..cfe66d6 --- /dev/null +++ b/python/pytest/private/tests/coverage/simple/README.md @@ -0,0 +1,4 @@ +# py_pytest_test simple coverage + +This test shows that coverage reports can be collected for a library +from a `py_pytest_test` target. diff --git a/python/pytest/private/tests/coverage/simple/lib/__init__.py b/python/pytest/private/tests/coverage/simple/lib/__init__.py new file mode 100644 index 0000000..5a9fec6 --- /dev/null +++ b/python/pytest/private/tests/coverage/simple/lib/__init__.py @@ -0,0 +1,28 @@ +"""A python module designed to test coverage""" + +from . import submod # noqa: F401 + + +def divide(num1: int, num2: int) -> float: + """Divide two numbers + + Args: + num1: The first number + num2: The second number + + Returns: + The result of dividing num1 into num2. + """ + if num2 == 0: + raise ValueError("Cannot divide by 0") + + return num1 / num2 + + +def say_greeting(name: str) -> None: # pragma: no cover + """Print a greeting + + Args: + name: The name of the character to greet. + """ + print(submod.greeting(name)) diff --git a/python/pytest/private/tests/coverage/simple/lib/submod/__init__.py b/python/pytest/private/tests/coverage/simple/lib/submod/__init__.py new file mode 100644 index 0000000..95a51fd --- /dev/null +++ b/python/pytest/private/tests/coverage/simple/lib/submod/__init__.py @@ -0,0 +1,30 @@ +"""A submodule designed to test coverage""" + + +# pylint: disable=redefined-builtin +def sum(num1: int, num2: int) -> int: + """Add two numbers together. + + Args: + num1: The first number + num2: The second number + + Returns: + The result of combining num1 and num2. + """ + return num1 + num2 + + +def greeting(name: str) -> str: + """Generate a greeting message. + + Args: + name: The name of the character to greet. + + Returns: + The greeting message + """ + if not name: + raise ValueError("The name cannot be empty") + + return f"Hello, {name}!" diff --git a/python/pytest/private/tests/coverage/simple/tests/coverage_test.py b/python/pytest/private/tests/coverage/simple/tests/coverage_test.py new file mode 100644 index 0000000..6674613 --- /dev/null +++ b/python/pytest/private/tests/coverage/simple/tests/coverage_test.py @@ -0,0 +1,33 @@ +"""Tests to provide coverage used to excersize the coverage functionality of `py_pytest_test`""" + +import pytest + +import python.pytest.private.tests.coverage.simple.lib as coverage + + +def test_divide() -> None: + """Test division""" + assert coverage.divide(10, 2) == 5 + + +def test_divide_by_zero() -> None: + """Test attempting to divide by 0""" + with pytest.raises(ValueError) as e_info: + coverage.divide(3, 0) + assert e_info.match(r"Cannot divide by 0") + + +def test_sum() -> None: + """Test addition""" + assert coverage.submod.sum(128, 128) == 256 + + +def test_greeting() -> None: + """Test greeting""" + assert coverage.submod.greeting("Mars") == "Hello, Mars!" + + +def test_greeting_no_name() -> None: + """Test greeting with no name""" + with pytest.raises(ValueError): + coverage.submod.greeting("") diff --git a/python/pytest/private/tests/coverage/split_collection/.coveragerc b/python/pytest/private/tests/coverage/split_collection/.coveragerc new file mode 100644 index 0000000..de184fa --- /dev/null +++ b/python/pytest/private/tests/coverage/split_collection/.coveragerc @@ -0,0 +1,10 @@ +[run] +branch = True + +# Note that `fail_under` does not work here because no single target represents +# 100% coverage. Instead, coverage is collected from different targets at the end +# and some post-processing needs to be explicitly done to confirm the desired level +# of of coverage. +# +# [report] +# fail_under = 100 diff --git a/python/pytest/private/tests/coverage/split_collection/BUILD.bazel b/python/pytest/private/tests/coverage/split_collection/BUILD.bazel new file mode 100644 index 0000000..7259c99 --- /dev/null +++ b/python/pytest/private/tests/coverage/split_collection/BUILD.bazel @@ -0,0 +1,46 @@ +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") +load("@bazel_skylib//rules:write_file.bzl", "write_file") +load("@rules_python//python:defs.bzl", "py_library") +load("//python/pytest:defs.bzl", "py_pytest_test_suite") + +py_library( + name = "coverage", + srcs = glob(["lib/**/*.py"]), + imports = ["."], +) + +py_pytest_test_suite( + name = "coverage_test", + coverage_rc = ".coveragerc", + tests = glob(["tests/**/*_test.py"]), + visibility = ["//python/pytest/private/tests/coverage:__pkg__"], + deps = [":coverage"], +) + +# The targets below ensures the expected number of tests is produced by `py_pytest_test_suite`. +genquery( + name = "defined_tests", + testonly = True, + expression = "tests(deps(//python/pytest/private/tests/coverage/split_collection:coverage_test))", + scope = [":coverage_test"], +) + +write_file( + name = "expected", + testonly = True, + out = "expected.txt", + content = [ + "//python/pytest/private/tests/coverage/split_collection:tests/a_test", + "//python/pytest/private/tests/coverage/split_collection:tests/b_test", + "", + ], +) + +diff_test( + name = "test_suite_test", + file1 = ":expected", + file2 = ":defined_tests", + # The `diff` tool is not installed in the remote image. + # See https://github.com/bazelbuild/bazel-skylib/issues/481 + tags = ["no-remote-exec"], +) diff --git a/python/pytest/private/tests/coverage/split_collection/README.md b/python/pytest/private/tests/coverage/split_collection/README.md new file mode 100644 index 0000000..b36e540 --- /dev/null +++ b/python/pytest/private/tests/coverage/split_collection/README.md @@ -0,0 +1,4 @@ +# py_pytest_test simple split collection + +This test shows that coverage reports can be collected for a library +from multiple `py_pytest_test` targets. diff --git a/python/pytest/private/tests/coverage/split_collection/lib/__init__.py b/python/pytest/private/tests/coverage/split_collection/lib/__init__.py new file mode 100644 index 0000000..5a9fec6 --- /dev/null +++ b/python/pytest/private/tests/coverage/split_collection/lib/__init__.py @@ -0,0 +1,28 @@ +"""A python module designed to test coverage""" + +from . import submod # noqa: F401 + + +def divide(num1: int, num2: int) -> float: + """Divide two numbers + + Args: + num1: The first number + num2: The second number + + Returns: + The result of dividing num1 into num2. + """ + if num2 == 0: + raise ValueError("Cannot divide by 0") + + return num1 / num2 + + +def say_greeting(name: str) -> None: # pragma: no cover + """Print a greeting + + Args: + name: The name of the character to greet. + """ + print(submod.greeting(name)) diff --git a/python/pytest/private/tests/coverage/split_collection/lib/submod/__init__.py b/python/pytest/private/tests/coverage/split_collection/lib/submod/__init__.py new file mode 100644 index 0000000..95a51fd --- /dev/null +++ b/python/pytest/private/tests/coverage/split_collection/lib/submod/__init__.py @@ -0,0 +1,30 @@ +"""A submodule designed to test coverage""" + + +# pylint: disable=redefined-builtin +def sum(num1: int, num2: int) -> int: + """Add two numbers together. + + Args: + num1: The first number + num2: The second number + + Returns: + The result of combining num1 and num2. + """ + return num1 + num2 + + +def greeting(name: str) -> str: + """Generate a greeting message. + + Args: + name: The name of the character to greet. + + Returns: + The greeting message + """ + if not name: + raise ValueError("The name cannot be empty") + + return f"Hello, {name}!" diff --git a/python/pytest/private/tests/coverage/split_collection/tests/a_test.py b/python/pytest/private/tests/coverage/split_collection/tests/a_test.py new file mode 100644 index 0000000..20a2d77 --- /dev/null +++ b/python/pytest/private/tests/coverage/split_collection/tests/a_test.py @@ -0,0 +1,21 @@ +"""Tests to provide coverage used to excersize the coverage functionality of `py_pytest_test`""" + +import pytest + +import python.pytest.private.tests.coverage.split_collection.lib as coverage + + +def test_sum() -> None: + """Test addition""" + assert coverage.submod.sum(128, 128) == 256 + + +def test_greeting() -> None: + """Test greeting""" + assert coverage.submod.greeting("Mars") == "Hello, Mars!" + + +def test_greeting_no_name() -> None: + """Test greeting with no name""" + with pytest.raises(ValueError): + coverage.submod.greeting("") diff --git a/python/pytest/private/tests/coverage/split_collection/tests/b_test.py b/python/pytest/private/tests/coverage/split_collection/tests/b_test.py new file mode 100644 index 0000000..b02d2cd --- /dev/null +++ b/python/pytest/private/tests/coverage/split_collection/tests/b_test.py @@ -0,0 +1,17 @@ +"""Tests to provide coverage used to excersize the coverage functionality of `py_pytest_test`""" + +import pytest + +import python.pytest.private.tests.coverage.split_collection.lib as coverage + + +def test_divide() -> None: + """Test division""" + assert coverage.divide(10, 2) == 5 + + +def test_divide_by_zero() -> None: + """Test attempting to divide by 0""" + with pytest.raises(ValueError) as e_info: + coverage.divide(3, 0) + assert e_info.match(r"Cannot divide by 0") diff --git a/python/pytest/private/tests/env/BUILD.bazel b/python/pytest/private/tests/env/BUILD.bazel new file mode 100644 index 0000000..6ef71b7 --- /dev/null +++ b/python/pytest/private/tests/env/BUILD.bazel @@ -0,0 +1,6 @@ +load("//python/pytest:defs.bzl", "py_pytest_test") + +py_pytest_test( + name = "env_test", + srcs = ["env_test.py"], +) diff --git a/python/pytest/private/tests/env/env_test.py b/python/pytest/private/tests/env/env_test.py new file mode 100644 index 0000000..b6db807 --- /dev/null +++ b/python/pytest/private/tests/env/env_test.py @@ -0,0 +1,12 @@ +"""Test interactions with modified environment variables""" + +import os +import pathlib + + +def test_user_home_expand() -> None: + """Test the use of `pathlib.Path.home` as it's behavior changed in py3.11""" + test_tmpdir = pathlib.Path(os.environ["TEST_TMPDIR"]) + home_dir = str(pathlib.Path.home()) + assert len(home_dir) > 0 + assert str(test_tmpdir / "home") == home_dir diff --git a/python/pytest/private/tests/label_tests/BUILD.bazel b/python/pytest/private/tests/label_tests/BUILD.bazel new file mode 100644 index 0000000..9caa622 --- /dev/null +++ b/python/pytest/private/tests/label_tests/BUILD.bazel @@ -0,0 +1,5 @@ +load(":pytest_label_test.bzl", "pytest_label_test") + +pytest_label_test( + name = "label_test", +) diff --git a/python/pytest/private/tests/label_tests/pytest_label_test.bzl b/python/pytest/private/tests/label_tests/pytest_label_test.bzl new file mode 100644 index 0000000..ff51cd3 --- /dev/null +++ b/python/pytest/private/tests/label_tests/pytest_label_test.bzl @@ -0,0 +1,22 @@ +"""Tests to confirm pytest rules work with labels as sources and tests""" + +load("//python/pytest:defs.bzl", "py_pytest_test", "py_pytest_test_suite") + +def pytest_label_test(name): + py_pytest_test( + name = name + ".non_suite", + srcs = [Label("//python/pytest/private/tests/label_tests:pytest_label_test.py")], + ) + + py_pytest_test_suite( + name = name + ".suite", + tests = [Label("//python/pytest/private/tests/label_tests:pytest_label_test.py")], + ) + + native.test_suite( + name = name, + tests = [ + name + ".non_suite", + name + ".suite", + ], + ) diff --git a/python/pytest/private/tests/label_tests/pytest_label_test.py b/python/pytest/private/tests/label_tests/pytest_label_test.py new file mode 100644 index 0000000..d8f5b32 --- /dev/null +++ b/python/pytest/private/tests/label_tests/pytest_label_test.py @@ -0,0 +1,6 @@ +"""A simple tests to run with pytest""" + + +def test_multiply() -> None: + """A simple test for pytest to run""" + assert 3 * 3 == 9 diff --git a/python/pytest/private/tests/negative/BUILD.bazel b/python/pytest/private/tests/negative/BUILD.bazel new file mode 100644 index 0000000..4049b26 --- /dev/null +++ b/python/pytest/private/tests/negative/BUILD.bazel @@ -0,0 +1,32 @@ +load("@bazel_skylib//rules:copy_file.bzl", "copy_file") +load("//python/pytest:defs.bzl", "py_pytest_test", "py_pytest_test_suite") + +py_pytest_test( + name = "test_tags_test", + srcs = ["pytest_negative_test.py"], + tags = ["manual"], +) + +py_pytest_test( + name = "test_compatibility_test", + srcs = ["pytest_negative_test.py"], + target_compatible_with = ["@platforms//:incompatible"], +) + +py_pytest_test_suite( + name = "test_suite_tags_test", + tags = ["manual"], + tests = ["pytest_negative_test.py"], +) + +copy_file( + name = "compat_copy", + src = "pytest_negative_test.py", + out = "pytest_compat_test.py", +) + +py_pytest_test_suite( + name = "test_suite_compatibility_test", + target_compatible_with = ["@platforms//:incompatible"], + tests = ["pytest_compat_test.py"], +) diff --git a/python/pytest/private/tests/negative/pytest_negative_test.py b/python/pytest/private/tests/negative/pytest_negative_test.py new file mode 100644 index 0000000..d61e64c --- /dev/null +++ b/python/pytest/private/tests/negative/pytest_negative_test.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""This test verifies that certain attributes are appropriately set on `py_pytest_test` rules. + +This code is not expected to be run. +""" + +import sys + +if __name__ == "__main__": + print( + "The fact that this test ran means the some attribute was " + "not applied to the target that prevented it from running.", + file=sys.stderr, + ) + raise AssertionError() diff --git a/python/pytest/private/tests/pytest_process_wrapper_test.py b/python/pytest/private/tests/pytest_process_wrapper_test.py new file mode 100644 index 0000000..2aada46 --- /dev/null +++ b/python/pytest/private/tests/pytest_process_wrapper_test.py @@ -0,0 +1,215 @@ +"""Tests for the pytest_process_wrapper.py process wrapper""" + +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from python.runfiles import runfiles + +import python.pytest.private.pytest_process_wrapper as process_wrapper + +WORKSPACE_NAME = "rules_pytest" + + +class TestRunPytestArgParsing(unittest.TestCase): + """Test cases for `pytest_process_wrapper.parse_args`""" + + def setUp(self) -> None: + self.temp_dir = Path(tempfile.mkdtemp(dir=os.environ.get("TEST_TMPDIR", None))) + self.temp_dir.mkdir(parents=True, exist_ok=True) + + tests_manifest = self.temp_dir / WORKSPACE_NAME / "tests_manifest.txt" + tests_manifest.parent.mkdir(exist_ok=True, parents=True) + tests_manifest.write_text( + "ext_workspace_name/tmp/some_test.py", encoding="utf-8" + ) + + extra_args_manifest = self.temp_dir / WORKSPACE_NAME / "extra_args_manifest.txt" + extra_args_manifest.write_text("[]", encoding="utf-8") + + self.tests_manifest = tests_manifest.relative_to(self.temp_dir) + self.extra_args_manifest = extra_args_manifest.relative_to(self.temp_dir) + + return super().setUp() + + def tearDown(self) -> None: + shutil.rmtree(str(self.temp_dir)) + return super().tearDown() + + def test_normal(self) -> None: + """Test parsing expected args""" + print(str(self.tests_manifest)) + args = [ + "--cov-config", + "tmp/coveragerc", + "--pytest-config", + "tmp/pytest.toml", + "--tests-manifest", + str(self.tests_manifest), + "--extra-args-manifest", + str(self.extra_args_manifest), + "--", + # Pytest args would go here + ] + + with mock.patch.dict( + os.environ, + { + "RUNFILES_DIR": str(self.temp_dir), + "TEST_WORKSPACE": WORKSPACE_NAME, + }, + clear=True, + ): + mock_runfiles = runfiles.Create() + with mock.patch( + "python.pytest.private.pytest_process_wrapper.RUNFILES", + mock_runfiles, + ): + parsed_args = process_wrapper.parse_args(args) + + self.assertListEqual(parsed_args.pytest_args, []) + + def test_no_trailing_delimiter(self) -> None: + """Test that the delimiter between process wrapper args and pytest arges is flexible""" + args = [ + "--cov-config", + "tmp/coveragerc", + "--pytest-config", + "tmp/pytest.toml", + "--tests-manifest", + str(self.tests_manifest), + "--extra-args-manifest", + str(self.extra_args_manifest), + # The pytest args delimiter is allowed to be missing + # "--"" + ] + + with mock.patch.dict( + os.environ, + { + "RUNFILES_DIR": str(self.temp_dir), + "TEST_WORKSPACE": WORKSPACE_NAME, + }, + clear=True, + ): + mock_runfiles = runfiles.Create() + with mock.patch( + "python.pytest.private.pytest_process_wrapper.RUNFILES", + mock_runfiles, + ): + parsed_args = process_wrapper.parse_args(args) + + self.assertListEqual(parsed_args.pytest_args, []) + + def test_pytest_args(self) -> None: + """Test parsing extra pytest args""" + args = [ + "--cov-config", + "tmp/coveragerc", + "--pytest-config", + "tmp/pytest.toml", + "--tests-manifest", + str(self.tests_manifest), + "--extra-args-manifest", + str(self.extra_args_manifest), + "--", + ] + + pytest_args = [ + "--log-level", + "DEBUG", + "-v", + "--duration-min", + "0.005", + ] + + with mock.patch.dict( + os.environ, + { + "RUNFILES_DIR": str(self.temp_dir), + "TEST_WORKSPACE": WORKSPACE_NAME, + }, + clear=True, + ): + mock_runfiles = runfiles.Create() + with mock.patch( + "python.pytest.private.pytest_process_wrapper.RUNFILES", + mock_runfiles, + ): + parsed_args = process_wrapper.parse_args(args + pytest_args) + + self.assertListEqual(parsed_args.pytest_args, pytest_args) + + def test_numprocesses(self) -> None: + """Ensure `numprocesses` (`-n`) is converted to a pytest arg""" + args = [ + "--cov-config", + "tmp/coveragerc", + "--pytest-config", + "tmp/pytest.toml", + "--tests-manifest", + str(self.tests_manifest), + "--extra-args-manifest", + str(self.extra_args_manifest), + "--numprocesses", + "4", + "--", + "--verbose", + ] + + with mock.patch.dict( + os.environ, + { + "RUNFILES_DIR": str(self.temp_dir), + "TEST_WORKSPACE": WORKSPACE_NAME, + }, + clear=True, + ): + mock_runfiles = runfiles.Create() + with mock.patch( + "python.pytest.private.pytest_process_wrapper.RUNFILES", + mock_runfiles, + ): + parsed_args = process_wrapper.parse_args(args) + + self.assertEqual(parsed_args.numprocesses, 4) + self.assertListEqual(parsed_args.pytest_args, ["-n", "4", "--verbose"]) + + def test_numprocesses_rejected(self) -> None: + """Ensure users are not allowed to pass `numprocesses` (`-n`) directly to pytest""" + args = [ + "--cov-config", + "tmp/coveragerc", + "--pytest-config", + "tmp/pytest.toml", + "--tests-manifest", + str(self.tests_manifest), + "--extra-args-manifest", + str(self.extra_args_manifest), + "--", + "-n", + "4", + ] + + with mock.patch.dict( + os.environ, + { + "RUNFILES_DIR": str(self.temp_dir), + "TEST_WORKSPACE": WORKSPACE_NAME, + }, + clear=True, + ): + mock_runfiles = runfiles.Create() + with mock.patch( + "python.pytest.private.pytest_process_wrapper.RUNFILES", + mock_runfiles, + ): + with self.assertRaises(SystemExit): + process_wrapper.parse_args(args) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/pytest/private/tests/version_test/BUILD.bazel b/python/pytest/private/tests/version_test/BUILD.bazel new file mode 100644 index 0000000..e300db4 --- /dev/null +++ b/python/pytest/private/tests/version_test/BUILD.bazel @@ -0,0 +1,13 @@ +load("//python/pytest:defs.bzl", "py_pytest_test") + +py_pytest_test( + name = "version_test", + srcs = ["version_test.py"], + data = [ + "//:MODULE.bazel", + "//:version.bzl", + ], + deps = [ + "@rules_python//python/runfiles", + ], +) diff --git a/python/pytest/private/tests/version_test/version_test.py b/python/pytest/private/tests/version_test/version_test.py new file mode 100644 index 0000000..200db74 --- /dev/null +++ b/python/pytest/private/tests/version_test/version_test.py @@ -0,0 +1,57 @@ +"""A suite of tests ensuring version strings are all in sync.""" + +import re +import unittest +from pathlib import Path + +from python.runfiles import Runfiles + + +def rlocation(runfiles: Runfiles, rlocationpath: str) -> Path: + """Look up a runfile and ensure the file exists + + Args: + runfiles: The runfiles object + rlocationpath: The runfile key + + Returns: + The requested runifle. + """ + runfile = runfiles.Rlocation(rlocationpath) + if not runfile: + raise FileNotFoundError(f"Failed to find runfile: {rlocationpath}") + path = Path(runfile) + if not path.exists(): + raise FileNotFoundError(f"Runfile does not exist: ({rlocationpath}) {path}") + return path + + +def test_versions() -> None: + """Test that the version.bzl and MOUDLE.bazel versions are synced.""" + runfiles = Runfiles.Create() + if not runfiles: + raise EnvironmentError("Failed to locate runfiles.") + + version_bzl = rlocation(runfiles, "rules_pytest/version.bzl") + bzl_version = re.findall( + r'VERSION = "([\w\d\.]+)"', + version_bzl.read_text(encoding="utf-8"), + re.MULTILINE, + ) + assert bzl_version, f"Failed to parse version from {version_bzl}" + + module_bazel = rlocation(runfiles, "rules_pytest/MODULE.bazel") + module_version = re.findall( + r'module\(\n\s+name = "rules_pytest",\n\s+version = "([\d\w\.]+)",\n\)', + module_bazel.read_text(encoding="utf-8"), + re.MULTILINE, + ) + assert module_version, f"Failed to parse version from {module_bazel}" + + assert ( + bzl_version[0] == module_version[0] + ), f"{bzl_version[0]} == {module_version[0]}" + + +if __name__ == "__main__": + unittest.main() diff --git a/python/pytest/private/tests/with_args/BUILD.bazel b/python/pytest/private/tests/with_args/BUILD.bazel new file mode 100644 index 0000000..d926c19 --- /dev/null +++ b/python/pytest/private/tests/with_args/BUILD.bazel @@ -0,0 +1,39 @@ +load("@rules_python//python:defs.bzl", "py_library") +load("//python/pytest:defs.bzl", "PYTEST_TARGET", "py_pytest_test") + +py_library( + name = "lib", + srcs = glob(["lib/**/*.py"]), + imports = ["."], +) + +py_library( + name = "lib_testonly", + testonly = True, + srcs = glob(["lib_testonly/**/*.py"]), +) + +py_library( + name = "with_args_test_lib", + srcs = ["tests/conftest.py"], + deps = [PYTEST_TARGET], +) + +py_pytest_test( + name = "with_args_test", + srcs = ["tests/with_args_test.py"], + args = [ + # Show that pytest-xdist args are accepted + "--dist", + "loadgroup", + # Show show that custom flags are also passed + "--custom_arg", + "Occupy Mars", + ], + numprocesses = 2, + deps = [ + ":lib", + ":lib_testonly", + ":with_args_test_lib", + ], +) diff --git a/python/pytest/private/tests/with_args/README.md b/python/pytest/private/tests/with_args/README.md new file mode 100644 index 0000000..be62c37 --- /dev/null +++ b/python/pytest/private/tests/with_args/README.md @@ -0,0 +1,7 @@ +# py_pytest_test with unique values for `args` + +This test shows that targets which pass values to `py_pytest_test.args` work +as expected. In the example here, if `--dist loadgroup` is not passed then the +test is expected to fail. The argument passed is explicitly a +[pytest-xdist](https://pypi.org/project/pytest-xdist/) argument to confirm arguments for +this plugin also work. diff --git a/python/pytest/private/tests/with_args/lib/__init__.py b/python/pytest/private/tests/with_args/lib/__init__.py new file mode 100644 index 0000000..5a9fec6 --- /dev/null +++ b/python/pytest/private/tests/with_args/lib/__init__.py @@ -0,0 +1,28 @@ +"""A python module designed to test coverage""" + +from . import submod # noqa: F401 + + +def divide(num1: int, num2: int) -> float: + """Divide two numbers + + Args: + num1: The first number + num2: The second number + + Returns: + The result of dividing num1 into num2. + """ + if num2 == 0: + raise ValueError("Cannot divide by 0") + + return num1 / num2 + + +def say_greeting(name: str) -> None: # pragma: no cover + """Print a greeting + + Args: + name: The name of the character to greet. + """ + print(submod.greeting(name)) diff --git a/python/pytest/private/tests/with_args/lib/submod/__init__.py b/python/pytest/private/tests/with_args/lib/submod/__init__.py new file mode 100644 index 0000000..95a51fd --- /dev/null +++ b/python/pytest/private/tests/with_args/lib/submod/__init__.py @@ -0,0 +1,30 @@ +"""A submodule designed to test coverage""" + + +# pylint: disable=redefined-builtin +def sum(num1: int, num2: int) -> int: + """Add two numbers together. + + Args: + num1: The first number + num2: The second number + + Returns: + The result of combining num1 and num2. + """ + return num1 + num2 + + +def greeting(name: str) -> str: + """Generate a greeting message. + + Args: + name: The name of the character to greet. + + Returns: + The greeting message + """ + if not name: + raise ValueError("The name cannot be empty") + + return f"Hello, {name}!" diff --git a/python/pytest/private/tests/with_args/lib_testonly/__init__.py b/python/pytest/private/tests/with_args/lib_testonly/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/pytest/private/tests/with_args/tests/conftest.py b/python/pytest/private/tests/with_args/tests/conftest.py new file mode 100644 index 0000000..6ab922c --- /dev/null +++ b/python/pytest/private/tests/with_args/tests/conftest.py @@ -0,0 +1,16 @@ +"""Pytest configure functionality""" + +from typing import Any + +import pytest + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Add additional command line flags to pytest""" + parser.addoption("--custom_arg", action="store") + + +@pytest.fixture +def custom_arg(request: pytest.FixtureRequest) -> Any: + """Expose custom command line values as fixtures""" + return request.config.getoption("--custom_arg") diff --git a/python/pytest/private/tests/with_args/tests/with_args_test.py b/python/pytest/private/tests/with_args/tests/with_args_test.py new file mode 100644 index 0000000..e1ed486 --- /dev/null +++ b/python/pytest/private/tests/with_args/tests/with_args_test.py @@ -0,0 +1,43 @@ +"""Tests to provide coverage used to exercise the coverage functionality of `py_pytest_test`""" + +import pytest + +from python.pytest.private.tests.with_args import lib, lib_testonly + + +def test_divide() -> None: + """Test division""" + assert lib_testonly + assert lib.divide(10, 2) == 5 + + +@pytest.mark.xdist_group("group_1") +def test_divide_by_zero() -> None: + """Test attempting to divide by 0""" + with pytest.raises(ValueError) as e_info: + lib.divide(3, 0) + assert str(e_info) == "Cannot divide by 0" + + +@pytest.mark.xdist_group("group_1") +def test_sum() -> None: + """Test addition""" + assert lib.submod.sum(128, 128) == 256 + + +@pytest.mark.xdist_group("group_2") +def test_greeting() -> None: + """Test greeting""" + assert lib.submod.greeting("Mars") == "Hello, Mars!" + + +@pytest.mark.xdist_group("group_2") +def test_greeting_no_name() -> None: + """Test greeting with no name""" + with pytest.raises(ValueError): + lib.submod.greeting("") + + +def test_custom_arg(custom_arg: str) -> None: + """Test that custom command line arguments are assigend to expected values""" + assert custom_arg == "Occupy Mars" diff --git a/python/pytest/pyproject.toml b/python/pytest/pyproject.toml new file mode 100644 index 0000000..dd358a3 --- /dev/null +++ b/python/pytest/pyproject.toml @@ -0,0 +1,2 @@ +# pyproject.toml: https://docs.pytest.org/en/stable/customize.html#pyproject-toml +[tool.pytest.ini_options] diff --git a/python/pytest/repositories.bzl b/python/pytest/repositories.bzl new file mode 100644 index 0000000..aca1a41 --- /dev/null +++ b/python/pytest/repositories.bzl @@ -0,0 +1,22 @@ +"""Pytest dependencies""" + +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") +load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe") + +# buildifier: disable=unnamed-macro +def rules_pytest_dependencies(): + """Defines pytest dependencies""" + maybe( + http_archive, + name = "rules_python", + sha256 = "778aaeab3e6cfd56d681c89f5c10d7ad6bf8d2f1a72de9de55b23081b2d31618", + strip_prefix = "rules_python-0.34.0", + url = "https://github.com/bazelbuild/rules_python/releases/download/0.34.0/rules_python-0.34.0.tar.gz", + ) + + maybe( + http_archive, + name = "rules_req_compile", + sha256 = "934c221bec5c1862d91f760d17224e5b5662aa097d595cdd197af3089cd63817", + urls = ["https://github.com/sputt/req-compile/releases/download/1.0.0rc23/rules_req_compile-v1.0.0rc23.tar.gz"], + ) diff --git a/python/pytest/repositories_toolchains.bzl b/python/pytest/repositories_toolchains.bzl new file mode 100644 index 0000000..c4a543d --- /dev/null +++ b/python/pytest/repositories_toolchains.bzl @@ -0,0 +1,9 @@ +"""Pytest dependencies""" + +# buildifier: disable=unnamed-macro +def register_pytest_toolchains(register_toolchains = True): + """Defines pytest dependencies""" + if register_toolchains: + native.register_toolchains( + str(Label("//python/pytest/toolchain")), + ) diff --git a/python/pytest/repositories_transitive_1.bzl b/python/pytest/repositories_transitive_1.bzl new file mode 100644 index 0000000..d1c70c8 --- /dev/null +++ b/python/pytest/repositories_transitive_1.bzl @@ -0,0 +1,11 @@ +"""Pytest dependencies""" + +load("@rules_python//python:repositories.bzl", "py_repositories") +load("@rules_req_compile//:repositories.bzl", "req_compile_dependencies") + +# buildifier: disable=unnamed-macro +def rules_pytest_transitive_deps_1(): + """Defines pytest transitive dependencies""" + + py_repositories() + req_compile_dependencies() diff --git a/python/pytest/repositories_transitive_2.bzl b/python/pytest/repositories_transitive_2.bzl new file mode 100644 index 0000000..2db941c --- /dev/null +++ b/python/pytest/repositories_transitive_2.bzl @@ -0,0 +1,21 @@ +"""Pytest dependencies""" + +load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe") +load("@rules_req_compile//:defs.bzl", "py_requirements_repository") +load("@rules_req_compile//:repositories_transitive.bzl", "req_compile_transitive_dependencies") + +# buildifier: disable=unnamed-macro +def rules_pytest_transitive_deps_2(): + """Defines pytest transitive dependencies""" + + req_compile_transitive_dependencies() + + maybe( + py_requirements_repository, + name = "pytest_deps", + requirements_locks = { + Label("//python/pytest:requirements.linux.txt"): "@platforms//os:linux", + Label("//python/pytest:requirements.macos.txt"): "@platforms//os:macos", + Label("//python/pytest:requirements.windows.txt"): "@platforms//os:windows", + }, + ) diff --git a/python/pytest/repositories_transitive_3.bzl b/python/pytest/repositories_transitive_3.bzl new file mode 100644 index 0000000..aa52d7b --- /dev/null +++ b/python/pytest/repositories_transitive_3.bzl @@ -0,0 +1,9 @@ +"""Pytest dependencies""" + +load("@pytest_deps//:defs.bzl", pip_repositories = "repositories") + +# buildifier: disable=unnamed-macro +def rules_pytest_transitive_deps_3(): + """Defines pytest transitive dependencies""" + + pip_repositories() diff --git a/python/pytest/requirements.in b/python/pytest/requirements.in new file mode 100644 index 0000000..f7f77ad --- /dev/null +++ b/python/pytest/requirements.in @@ -0,0 +1,12 @@ +# Pytest requirements. + +pytest-cov +pytest-xdist +pytest>=7.0.1 # Required to avoid realpathing during startup +coverage>=7.6 + +# Intenral test only +pylint +mypy +isort +black diff --git a/python/pytest/requirements.linux.txt b/python/pytest/requirements.linux.txt new file mode 100644 index 0000000..746cdb5 --- /dev/null +++ b/python/pytest/requirements.linux.txt @@ -0,0 +1,109 @@ +################################################################################ +## AUTOGENERATED: This file is autogenerated by req-compile. +## +## Python: 3.11.9 +## Platform: Linux +## +## To regenerate this file, use the following command: +## +## bazel run "@//python/pytest/3rdparty:requirements.linux.update" +## +################################################################################ + +astroid==3.2.4 \ + --hash=sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25 + # via pylint (<=3.3.0-dev0,>=3.2.4) + # https://files.pythonhosted.org/packages/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl#sha256=413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25 +black==24.8.0 \ + --hash=sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4 + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/a5/b5/f485e1bbe31f768e2e5210f52ea3f432256201289fd1a3c0afda693776b0/black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl#sha256=62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4 +click==8.1.7 \ + --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 + # via black (>=8.0.0) + # https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl#sha256=ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 +coverage==7.6.1 \ + --hash=sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6 + # via + # pytest-cov (>=5.2.1 [toml]) + # rules_pytest/python/pytest/requirements.in (>=7.6) + # https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl#sha256=0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6 +dill==0.3.8 \ + --hash=sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7 + # via pylint (>=0.3.6) + # https://files.pythonhosted.org/packages/c9/7a/cef76fd8438a42f96db64ddaa85280485a9c395e7df3db8158cfec1eee34/dill-0.3.8-py3-none-any.whl#sha256=c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7 +execnet==2.1.1 \ + --hash=sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc + # via pytest-xdist (>=2.1) + # https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl#sha256=26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc +iniconfig==2.0.0 \ + --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + # via pytest + # https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl#sha256=b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 +isort==5.13.2 \ + --hash=sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6 + # via + # pylint (!=5.13.0,<6,>=4.2.5) + # rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl#sha256=8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6 +mccabe==0.7.0 \ + --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e + # via pylint (<0.8,>=0.6) + # https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl#sha256=6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e +mypy==1.11.1 \ + --hash=sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/4d/7f/77feb389d91603f55b3c4e3e16ccf8752bce007ed73ca921e42c9a5dff12/mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl#sha256=886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de +mypy-extensions==1.0.0 \ + --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d + # via + # black (>=0.4.3) + # mypy (>=1.0.0) + # https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl#sha256=4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d +packaging==24.1 \ + --hash=sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 + # via + # black (>=22.0) + # pytest + # https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl#sha256=5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 +pathspec==0.12.1 \ + --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 + # via black (>=0.9.0) + # https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl#sha256=a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 +platformdirs==4.2.2 \ + --hash=sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee + # via + # black (>=2) + # pylint (>=2.2.0) + # https://files.pythonhosted.org/packages/68/13/2aa1f0e1364feb2c9ef45302f387ac0bd81484e9c9a4c5688a322fbdfd08/platformdirs-4.2.2-py3-none-any.whl#sha256=2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee +pluggy==1.5.0 \ + --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 + # via pytest (<2,>=1.5) + # https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl#sha256=44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 +pylint==3.2.6 \ + --hash=sha256:03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/09/88/1a406dd0b17a4796f025d8c937d8d56f97869cffa55c21d9edb07f5a3912/pylint-3.2.6-py3-none-any.whl#sha256=03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f +pytest==8.3.2 \ + --hash=sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5 + # via + # pytest-cov (>=4.6) + # pytest-xdist (>=7.0.0) + # rules_pytest/python/pytest/requirements.in (>=7.0.1) + # https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl#sha256=4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5 +pytest-cov==5.0.0 \ + --hash=sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652 + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl#sha256=4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652 +pytest-xdist==3.6.1 \ + --hash=sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7 + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl#sha256=9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7 +tomlkit==0.13.2 \ + --hash=sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde + # via pylint (>=0.10.1) + # https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl#sha256=7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde +typing_extensions==4.12.2 \ + --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d + # via mypy (>=4.6.0) + # https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl#sha256=04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d diff --git a/python/pytest/requirements.macos.txt b/python/pytest/requirements.macos.txt new file mode 100644 index 0000000..08efdd1 --- /dev/null +++ b/python/pytest/requirements.macos.txt @@ -0,0 +1,109 @@ +################################################################################ +## AUTOGENERATED: This file is autogenerated by req-compile. +## +## Python: 3.11.9 +## Platform: Darwin +## +## To regenerate this file, use the following command: +## +## bazel run "@//python/pytest/3rdparty:requirements.macos.update" +## +################################################################################ + +astroid==3.2.4 \ + --hash=sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25 + # via pylint (<=3.3.0-dev0,>=3.2.4) + # https://files.pythonhosted.org/packages/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl#sha256=413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25 +black==24.8.0 \ + --hash=sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/db/94/b803d810e14588bb297e565821a947c108390a079e21dbdcb9ab6956cd7a/black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl#sha256=837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af +click==8.1.7 \ + --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 + # via black (>=8.0.0) + # https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl#sha256=ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 +coverage==7.6.1 \ + --hash=sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3 + # via + # pytest-cov (>=5.2.1 [toml]) + # rules_pytest/python/pytest/requirements.in (>=7.6) + # https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl#sha256=ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3 +dill==0.3.8 \ + --hash=sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7 + # via pylint (>=0.3.6) + # https://files.pythonhosted.org/packages/c9/7a/cef76fd8438a42f96db64ddaa85280485a9c395e7df3db8158cfec1eee34/dill-0.3.8-py3-none-any.whl#sha256=c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7 +execnet==2.1.1 \ + --hash=sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc + # via pytest-xdist (>=2.1) + # https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl#sha256=26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc +iniconfig==2.0.0 \ + --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + # via pytest + # https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl#sha256=b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 +isort==5.13.2 \ + --hash=sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6 + # via + # pylint (!=5.13.0,<6,>=4.2.5) + # rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl#sha256=8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6 +mccabe==0.7.0 \ + --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e + # via pylint (<0.8,>=0.6) + # https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl#sha256=6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e +mypy==1.11.1 \ + --hash=sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/fe/aa/2ad15a318bc6a17b7f23e1641a624603949904f6131e09681f40340fb875/mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl#sha256=e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca +mypy-extensions==1.0.0 \ + --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d + # via + # black (>=0.4.3) + # mypy (>=1.0.0) + # https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl#sha256=4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d +packaging==24.1 \ + --hash=sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 + # via + # black (>=22.0) + # pytest + # https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl#sha256=5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 +pathspec==0.12.1 \ + --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 + # via black (>=0.9.0) + # https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl#sha256=a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 +platformdirs==4.2.2 \ + --hash=sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee + # via + # black (>=2) + # pylint (>=2.2.0) + # https://files.pythonhosted.org/packages/68/13/2aa1f0e1364feb2c9ef45302f387ac0bd81484e9c9a4c5688a322fbdfd08/platformdirs-4.2.2-py3-none-any.whl#sha256=2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee +pluggy==1.5.0 \ + --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 + # via pytest (<2,>=1.5) + # https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl#sha256=44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 +pylint==3.2.6 \ + --hash=sha256:03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/09/88/1a406dd0b17a4796f025d8c937d8d56f97869cffa55c21d9edb07f5a3912/pylint-3.2.6-py3-none-any.whl#sha256=03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f +pytest==8.3.2 \ + --hash=sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5 + # via + # pytest-cov (>=4.6) + # pytest-xdist (>=7.0.0) + # rules_pytest/python/pytest/requirements.in (>=7.0.1) + # https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl#sha256=4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5 +pytest-cov==5.0.0 \ + --hash=sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652 + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl#sha256=4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652 +pytest-xdist==3.6.1 \ + --hash=sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7 + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl#sha256=9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7 +tomlkit==0.13.2 \ + --hash=sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde + # via pylint (>=0.10.1) + # https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl#sha256=7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde +typing_extensions==4.12.2 \ + --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d + # via mypy (>=4.6.0) + # https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl#sha256=04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d diff --git a/python/pytest/requirements.windows.txt b/python/pytest/requirements.windows.txt new file mode 100644 index 0000000..24c3139 --- /dev/null +++ b/python/pytest/requirements.windows.txt @@ -0,0 +1,116 @@ +################################################################################ +## AUTOGENERATED: This file is autogenerated by req-compile. +## +## Python: 3.11.9 +## Platform: Windows +## +## To regenerate this file, use the following command: +## +## bazel run "@//python/pytest/3rdparty:requirements.windows.update" +## +################################################################################ + +astroid==3.2.4 \ + --hash=sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25 + # via pylint (<=3.3.0-dev0,>=3.2.4) + # https://files.pythonhosted.org/packages/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl#sha256=413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25 +black==24.8.0 \ + --hash=sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/a8/69/a000fc3736f89d1bdc7f4a879f8aaf516fb03613bb51a0154070383d95d9/black-24.8.0-cp311-cp311-win_amd64.whl#sha256=72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af +click==8.1.7 \ + --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 + # via black (>=8.0.0) + # https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl#sha256=ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 +colorama==0.4.6 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via + # click + # pylint (>=0.4.5) + # pytest + # https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl#sha256=4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +coverage==7.6.1 \ + --hash=sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6 + # via + # pytest-cov (>=5.2.1 [toml]) + # rules_pytest/python/pytest/requirements.in (>=7.6) + # https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl#sha256=8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6 +dill==0.3.8 \ + --hash=sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7 + # via pylint (>=0.3.6) + # https://files.pythonhosted.org/packages/c9/7a/cef76fd8438a42f96db64ddaa85280485a9c395e7df3db8158cfec1eee34/dill-0.3.8-py3-none-any.whl#sha256=c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7 +execnet==2.1.1 \ + --hash=sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc + # via pytest-xdist (>=2.1) + # https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl#sha256=26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc +iniconfig==2.0.0 \ + --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + # via pytest + # https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl#sha256=b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 +isort==5.13.2 \ + --hash=sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6 + # via + # pylint (!=5.13.0,<6,>=4.2.5) + # rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl#sha256=8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6 +mccabe==0.7.0 \ + --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e + # via pylint (<0.8,>=0.6) + # https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl#sha256=6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e +mypy==1.11.1 \ + --hash=sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72 + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/5b/b3/2a83be637825d7432b8e6a51e45d02de4f463b6c7ec7164a45009a7cf477/mypy-1.11.1-cp311-cp311-win_amd64.whl#sha256=0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72 +mypy-extensions==1.0.0 \ + --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d + # via + # black (>=0.4.3) + # mypy (>=1.0.0) + # https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl#sha256=4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d +packaging==24.1 \ + --hash=sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 + # via + # black (>=22.0) + # pytest + # https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl#sha256=5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124 +pathspec==0.12.1 \ + --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 + # via black (>=0.9.0) + # https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl#sha256=a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 +platformdirs==4.2.2 \ + --hash=sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee + # via + # black (>=2) + # pylint (>=2.2.0) + # https://files.pythonhosted.org/packages/68/13/2aa1f0e1364feb2c9ef45302f387ac0bd81484e9c9a4c5688a322fbdfd08/platformdirs-4.2.2-py3-none-any.whl#sha256=2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee +pluggy==1.5.0 \ + --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 + # via pytest (<2,>=1.5) + # https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl#sha256=44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 +pylint==3.2.6 \ + --hash=sha256:03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/09/88/1a406dd0b17a4796f025d8c937d8d56f97869cffa55c21d9edb07f5a3912/pylint-3.2.6-py3-none-any.whl#sha256=03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f +pytest==8.3.2 \ + --hash=sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5 + # via + # pytest-cov (>=4.6) + # pytest-xdist (>=7.0.0) + # rules_pytest/python/pytest/requirements.in (>=7.0.1) + # https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl#sha256=4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5 +pytest-cov==5.0.0 \ + --hash=sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652 + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl#sha256=4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652 +pytest-xdist==3.6.1 \ + --hash=sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7 + # via rules_pytest/python/pytest/requirements.in + # https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl#sha256=9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7 +tomlkit==0.13.2 \ + --hash=sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde + # via pylint (>=0.10.1) + # https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl#sha256=7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde +typing_extensions==4.12.2 \ + --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d + # via mypy (>=4.6.0) + # https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl#sha256=04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d diff --git a/python/pytest/toolchain/BUILD.bazel b/python/pytest/toolchain/BUILD.bazel new file mode 100644 index 0000000..88692fb --- /dev/null +++ b/python/pytest/toolchain/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_python//python:defs.bzl", "py_library") +load("//python/pytest:defs.bzl", "py_pytest_toolchain") + +py_library( + name = "pytest_deps", + deps = [ + "@pytest_deps//:pytest", + "@pytest_deps//:pytest_cov", + "@pytest_deps//:pytest_xdist", + ], +) + +py_pytest_toolchain( + name = "pytest_toolchain", + pytest = ":pytest_deps", +) + +toolchain( + name = "toolchain", + toolchain = ":pytest_toolchain", + toolchain_type = "//python/pytest:toolchain_type", + visibility = ["//visibility:public"], +) diff --git a/version.bzl b/version.bzl new file mode 100644 index 0000000..24f2e21 --- /dev/null +++ b/version.bzl @@ -0,0 +1,3 @@ +"""Version info for the `rules_pytest` repository""" + +VERSION = "0.0.1"