Skip to main content

only test relevant files

Project description

prunepytest

CI codecov pypi

Do you have lots of Python tests?

Do you find yourself wondering whether you really need to run the full test suite for every tiny change?

Do you wish to know exactly which tests to run to assess the soundness of any given change?

Would you plausibly save a significant amount of time (and possibly money if your CI budget is significant) by skipping irrelevant tests?

Then prunepytest might be for you! Make sure you read the LICENSE first though.

Who can use this ?

As per the terms of the LICENSE, this software can be freely used as long as the code under test is either publicly available under an open source license, or private and never shared with anyone.

If you wish to use this on code that more than one individual can access, but that is not publicly available under an OSI or FSF approved license, you may take advantage of the trial period for up to 15 days per calendar year. After that, you MUST either obtain my written permission to continue using the software, or wait for usage restrictions to be lifted, 4 years after the initial public release, just in time for Christmas 2028.

Contact me

Installation

pip install prunepytest

How does it work?

prunepytest operates by first building an accurate module-level import graph for your Python code.

Then, based on that import graph, and a set of modified files (either provided explicitly, or inferred from a version control system), it is able to determine a safe set of test files that should be run.

The main interface is a pytest plugin that is intended to work out-of-the-box for most simple Python projects.

pytest --prune

The import graph can be computed very quickly by using a native extension, written in Rust with PyO3, which leverages the fast Python parser from ruff, and the parallel directory walker from ripgrep.

The combination of efficient native code with built-in support for parallelism means that a large codebase that would take over a minute to parse with Python's own ast module can be fully parsed, and have its transitive import closure computed in a matter of seconds!

Validating the import graph

Unfortunately, the Python import machinery is notoriously complex, due to the possibility arbitrary code execution at import time, and the ability to perform dynamic imports at run-time. For projects that depend on those features, it is important to first validate the accuracy of the import graph before relying on prunepytest for test selection.

import-time validation

As a first step, it is often useful to do a quick import-time validation, which works by:

  • computing a static import graph by parsing the Python code
  • hooking into the import machinery, then importing all test code, to obtain an accurate picture of the actual import graph.
  • validating that the set of static imports obtained from parsing is a superset of the set obtained by importing the code
python -m prunepytest validate

test-time validation

By default, prunepytest does the safe thing, and reports all unexpected dynamic imports within a test context as failures. However, this will only validate tests that are being run. Before rolling out prunepytest in a CI system, it is highly advisable to do a first full-scale test-time validation, by disabling test pruning, via the --prune-no-select flag:

pytest --prune --prune-no-select

Test-time validation can be explicitly disabled via the --prune-no-validate flag

pytest --prune --prune-no-validate

Alternatively, the errors can be downgraded to warnings, via the --prune-no-fail flag.

pytest --prune --prune-no-fail

Sensible defaults, and overriding them

Both the import-time validator and the pytest plugin have sensible defaults: they scan the repository under test to automatically detect relevant Python packages, and attempt to detect common configuration files (such as pyproject.toml) to automatically adjust to slight deviations from conventions.

For more complicated repository layouts, the default behavior might not quite work. This can be addressed by creating a Python file that contains an implementation of prunepytest.api.PluginHook or prunepytest.api.ValidatorHook and point prunepytest to this file:

pytest --prune --prune-hook hook.py

For slight deviations, it is possible to subclass prunepytest.DefaultHook instead of starting from scratch, to leverage sensible defaults as a starting point.

For more details, refer to docstrings in api.py

Dealing with dynamic imports

If validation points to inaccuracies in the static import graphs, there are a few ways to deal with that.

Providing hints to the import parser

To ensure maximum accuracy, the import parser goes deep, and considers all import statements, not just at-module level, but arbitrarily nested, whether inside functions or conditional blocks.

This makes it extremely easy to provide hints about dynamic imports to the parser, by gating import statements behind an always-False conditional:

if False:  # for the import parser
  import foo.bar  # noqa: F401

For improved usability, the import parser also departs from the specification of the import machinery when it comes to wildcard imports. Specifically, while the Python interpreter depends on an explicit listing of submodules in the __all__ variable of a package's __init__.py, prunepytest will instead scan the filesystem, and resolve a wildcard to all existing modules next to the __init__.py. This makes it more practical to provide hints that encompass a large number of submodules, without having to update the hints every time modules are added or removed.

Considering typechecking-only imports

One notable exception to the "parser goes deep" outlined above is that, by default, typechecking-only imports are excluded from the import graph. If that exclusion is undesirable, it can be changed through a hook:

from prunepytest.api import DefaultHook

class Hook(DefaultHook):
   def include_typechecking(self) -> bool:
      return True

Specifying extra dependencies via a custom Hook

There are two main hooks to specify extra dependencies:

  • dynamic_dependencies(), returns a mapping from module (specified as Python import path or filepath) to set of extra imports (specified as Python import paths, possibly including wildcards).
    Those extra dependencies are incorporated into the graph before computing the transitive closure.

  • dynamic_dependencies_at_leaves(), returns a sequence of entries to be incorporated after computing of the transitive closure of the import graph.
    This can be useful when, for instance, a common method triggers dynamic import based on a configuration file, and that file differs across source roots.
    In such a case, it would be possible to explicitly specifying extra dependencies for each test file relying on that method but that might be tedious and brittle. Assigning varying dependencies to the common module offers a more succinct and robust way to specify the same dynamic dependency information.

Sample hooks

Sample implementations for some real-world open source projects are available in prunepytest-validation:

Reusing the import graph

In cases where the import graph is large and slow to build, it can be worth saving it in a serialized form to reuse across multiple commands.

# collect and save import graph
# NB: this is a no-op if a valid graph exists
python -m prunepytest graph --prune-graph graph.bin

# use the existing graph instead of parsing source code again
pytest --prune --prune-graph graph.bin

Limitations

Because it only relies on a statically derived import graph, prunepytest can be used immediately, without needing a first full run of the test suite to collect dependency information (although it might be worth doing a validation run, as explained earlier), and saving/transferring that information across builds.

However, that also means that it will not catch more complicated test dependencies, such as dependencies on config files, or data-driven test cases.

Handling such dependencies in the general case is possible, for instance through syscall-tracing, but intentionally out-of-scope of this project. A future release might add some facility to explicitly annotate non-Python dependencies, until then, the handling of non-Python dependencies in the context of test selection is left as an exercise for the user.

The pytest plugin does a limited best-effort handling of data-driven test cases, inspired by the desire to support mypy's test suite. Contributions intended to expand this to a broader set of data-driven test cases would be welcome.

FAQ

  • What environments are supported?
    The minimum required Python version is 3.7
    The minimum required pytest version is 7.2
    Linux (x86_64, arm64), macOS (x86_64, arm64), and Windows (x86, x86_64) are supported, covered in CI, and available as pre-built binary wheels on PyPI. More environments may be added in the future based on demand.

  • Is this compatible with xdist?
    Yes!
    The pytest plugin will automatically detect whether xdist is being used, and make necessary adjustments to avoid redundant import graph computation, and ensure continued correctness of test-time validation.
    NB: this has only been tested with pytest-xdist==3.6.1

  • What about <other pytest plugin>?
    While there is no a-priori expectation of incompatibility, it is not practical to test interactions with all popular pytest plugins. Feel free to report any issue.

  • How does this compare to pytest-testmon?
    testmon is the only other attempt I am aware of to solve the problem of selecting a minimal safe set of tests to run based on modified files. Its design is based on detailed code coverage data, which has the potential to more aggressively prune the test set. However, it has a number of significant drawbacks:

    • testmon requires enabling code coverage with coverage.py, which typically incurs 2-4x slowdown. With prunepytest, you are free to leverage the amazing slipcover instead.
    • testmon requires a full initial test run to build the dependency database. For prunepytest, this is only relevant as a validation step for codebases that rely on dynamic imports.
    • testmon still needs to parse the Python source code, which can add considerable overhead at test-selection time for large codebases.
    • In most cases, testmon's database is considerably larger than prunepytest's serialized import graph, which makes a big difference for distributed test runs.
    • testmon does not offer meaningful validation of its test selection logic, whereas prunepytest takes validation seriously, and has a comprehensive test suite that achieves a high level of code coverage.
  • What's this weird license about?
    I started prototyping this code to support a contracting pitch to a software company in the financial sector. They would have probably saved upwards of $100k/month on EC2 costs alone!
    They declined my proposal, and out of respect for their wishes to keep wasting money, I want to make sure the solution is not available to them for free ;)
    At the same time, I believe this tool could be tremendously useful for many open source projects. This weird license is my attempt to strike a balance between serving the needs of the open source ecosystem while incentivizing large companies to pay for software that they derive significant value from.

  • So it's not actually Open Source?
    No.
    This license is not OSI-approved, and the presence of usage restrictions go against the fundamental "freedom 0", so it is unlikely to ever get the stamp of approval from OSI or FSF.

  • But it will eventually be Open Source?
    Yes.
    Four years after the initial public release.

  • Can I use it in the CI system of some Open Source project?
    Yes.

  • Can I use it in the CI system of some closed source project?
    You need to contact me to get written permission.

  • What if I make changes?
    The license still applies to any modified version. So using a modified version on open source code is fine, but using the modified version on code that is not open source would still require written permission from the copyright owner.

  • Is anyone actually going to pay for this?
    Some people think that capitalism implies that corporations are rational actors, or at least tend to act more rationally that many individuals. If that were so, I would expect that any company with a significant Python codebase that incurs a large CI expenditure, and lacking the time or expertise to build equivalent software in-house, would be excited to pay for prunepytest.
    Pay how much? At least as much as they would expect to save in the amount of time it would take them to build an equivalent in-house solution. And that would be arguably be a screaming deal, because they would be saving on the payroll required to fund an in-house alternative, avert the risk of the in-house project failing, and derive usability benefits beyond the CI cost savings.
    Whether there are any companies that would both benefit from this software, and can rationally assess its value to them, is an open question.

  • Can I contribute?
    Maybe.
    You have to be willing to make your contribution under a sufficiently permissive license for me to integrate it, and ensure that any prospective private user only have to get written permission from a single entity. Basically that means BSD, MIT, Apache, or Unlicense/Public Domain.

  • My codebase is too gnarly, help?!?
    If your codebase is open source, submit an issue in the tracker.
    If your codebase is not open source, I am open to negotiating a contract to help integrate this software in your CI system. Contact me

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

prunepytest-0.6.3-cp37-abi3-win_amd64.whl (2.0 MB view details)

Uploaded CPython 3.7+Windows x86-64

prunepytest-0.6.3-cp37-abi3-win32.whl (1.9 MB view details)

Uploaded CPython 3.7+Windows x86

prunepytest-0.6.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (10.7 MB view details)

Uploaded CPython 3.7+manylinux: glibc 2.17+ x86-64

prunepytest-0.6.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (9.5 MB view details)

Uploaded CPython 3.7+manylinux: glibc 2.17+ ARM64

prunepytest-0.6.3-cp37-abi3-macosx_11_0_arm64.whl (2.2 MB view details)

Uploaded CPython 3.7+macOS 11.0+ ARM64

prunepytest-0.6.3-cp37-abi3-macosx_10_12_x86_64.whl (2.4 MB view details)

Uploaded CPython 3.7+macOS 10.12+ x86-64

File details

Details for the file prunepytest-0.6.3-cp37-abi3-win_amd64.whl.

File metadata

File hashes

Hashes for prunepytest-0.6.3-cp37-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 ca96bdb72e7ee8016b88f5a5c4613077766d1691dd854e99ba52980b9659d558
MD5 b09c5f5e3f15cd18fd0f10416f1131e7
BLAKE2b-256 a1b1ee04b1c068c75733fda132cee6f6a27dafe6a3112c3d7516a727d1c7d48e

See more details on using hashes here.

File details

Details for the file prunepytest-0.6.3-cp37-abi3-win32.whl.

File metadata

  • Download URL: prunepytest-0.6.3-cp37-abi3-win32.whl
  • Upload date:
  • Size: 1.9 MB
  • Tags: CPython 3.7+, Windows x86
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: maturin/1.8.1

File hashes

Hashes for prunepytest-0.6.3-cp37-abi3-win32.whl
Algorithm Hash digest
SHA256 52158a12b30366b46bec633d8709c2fc86694fae52e5fc49947096c449d4364c
MD5 46b5d543a265de661d71b7448225ccf6
BLAKE2b-256 10e8c58b64b31529f66b548003bed70a39c49b2f2f8bfc67dc3c8d8358c49937

See more details on using hashes here.

File details

Details for the file prunepytest-0.6.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for prunepytest-0.6.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 7231fa62eb055911338c054fd14436f39369bfaf02b73c9c625fe79f792a93d2
MD5 c15014ee6551462d99199dd1996fda9f
BLAKE2b-256 c1be57e2e53e0e8e91fcae5924c4faeb09e230bfd99b5fb07f7184f1ca829d62

See more details on using hashes here.

File details

Details for the file prunepytest-0.6.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for prunepytest-0.6.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 3abcd888a97dbb55e4154d2c5010fd6743dcd2e559da526826fda09a1be4f596
MD5 d9a8fe203effca8f524b0261f96a17ff
BLAKE2b-256 4e6df4c1c7433c4c41f4ff93a6fad8dca0be539447389fa3e3e216505169c266

See more details on using hashes here.

File details

Details for the file prunepytest-0.6.3-cp37-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for prunepytest-0.6.3-cp37-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 ec9831adb66bc97512da1aacfa72f63b167912616216e60a205ffb160244e4cc
MD5 2ff23ffce0908293786c716cb7140be6
BLAKE2b-256 1ce71c934b7cb64cc387c208f164a82b34291d838c23c4ad0bd3e09ef876db38

See more details on using hashes here.

File details

Details for the file prunepytest-0.6.3-cp37-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for prunepytest-0.6.3-cp37-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 c2edd55dc11197a4f7d25673e2a05462fbe35c81e2dbdc3f012106314624fd9a
MD5 bd3b618eb7445922e16581ced965091a
BLAKE2b-256 07074f62f2d61e0a55778c87e1475771654bb857e666b295a4ba8fa6a121bfa5

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page