Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved patch files for failing examples #3659

Merged
merged 2 commits into from
May 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 9 additions & 15 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,27 +122,21 @@ jobs:
strategy:
matrix:
include:
- task: check-py37-cover
- task: check-py37-niche
- task: check-py310-cover
- task: check-py310-niche
- task: check-py310-x86-cover
python.architecture: "x86"
- task: check-py310-x86-nocover
python.architecture: "x86"
- task: check-py310-x86-niche
python.architecture: "x86"
- task: check-py311-cover
- task: check-py311-niche
- python-version: "3.8"
- python-version: "3.9"
- python-version: "3.10"
- python-version: "3.11"
- python-version: "3.11"
python-architecture: "x86"
fail-fast: false
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python 3.10
- name: Set up Python ${{ matrix.python-version }} ${{ matrix.python-architecture }}
uses: actions/setup-python@v4
with:
python-version: "3.10"
python-version: ${{ matrix.python-version }}
architecture: ${{ matrix.python-architecture }}
- name: Restore cache
uses: actions/cache@v3
Expand All @@ -151,7 +145,7 @@ jobs:
~\appdata\local\pip\cache
vendor\bundle
.tox
key: deps-${{ runner.os }}-${{ matrix.python-architecture }}-${{ hashFiles('requirements/*.txt') }}-${{ matrix.task }}
key: deps-${{ runner.os }}-${{ matrix.python-architecture }}-${{ hashFiles('requirements/*.txt') }}-${{ matrix.python-version }}
restore-keys: |
deps-${{ runner.os }}-${{ matrix.python-architecture }}-${{ hashFiles('requirements/*.txt') }}
deps-${{ runner.os }}-${{ matrix.python-architecture }}
Expand Down
9 changes: 9 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
RELEASE_TYPE: patch

This release fixes some ``.patch``-file bugs from :ref:`version 6.75 <v6.75.0>`,
and adds automatic support for writing ``@hypothesis.example()`` or ``@example()``
depending on the current style in your test file - defaulting to the latter.

Note that this feature requires :pypi:`libcst` to be installed, and :pypi:`black`
is strongly recommended. You can ensure you have the dependencies with
``pip install "hypothesis[cli,codemods]"``.
4 changes: 3 additions & 1 deletion hypothesis-python/scripts/other-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ if [ "$(python -c $'import platform, sys; print(sys.version_info.releaselevel ==
pip install ".[codemods,cli]"
$PYTEST tests/codemods/
pip install "$(grep 'black==' ../requirements/coverage.txt)"
$PYTEST tests/patching/
if [ "$(python -c 'import sys; print(sys.version_info[:2] >= (3, 9))')" = "True" ] ; then
$PYTEST tests/patching/
fi
pip uninstall -y libcst

if [ "$(python -c 'import sys; print(sys.version_info[:2] == (3, 7))')" = "True" ] ; then
Expand Down
6 changes: 3 additions & 3 deletions hypothesis-python/src/_hypothesis_pytestplugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,11 +339,11 @@ def pytest_runtest_makereport(item, call):
failing_examples = getattr(item, FAILING_EXAMPLES_KEY, None)
if failing_examples and terminalreporter is not None:
try:
from hypothesis.extra.patching import get_patch_for
from hypothesis.extra._patching import FAIL_MSG, get_patch_for
except ImportError:
return
# We'll save this as a triple of [filename, hunk_before, hunk_after].
triple = get_patch_for(item.obj, failing_examples)
triple = get_patch_for(item.obj, [(x, FAIL_MSG) for x in failing_examples])
if triple is not None:
report.__dict__[FAILING_EXAMPLES_KEY] = json.dumps(triple)

Expand All @@ -363,7 +363,7 @@ def pytest_terminal_summary(terminalreporter):

if failing_examples:
# This must have been imported already to write the failing examples
from hypothesis.extra.patching import gc_patches, make_patch, save_patch
from hypothesis.extra._patching import gc_patches, make_patch, save_patch

patch = make_patch(failing_examples)
try:
Expand Down
88 changes: 65 additions & 23 deletions hypothesis-python/src/hypothesis/extra/_patching.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@
import inspect
import re
import sys
from ast import literal_eval
from contextlib import suppress
from datetime import date, datetime, timedelta, timezone
from pathlib import Path

import libcst as cst
from libcst import matchers as m
from libcst.codemod import CodemodContext, VisitorBasedCodemodCommand

from hypothesis.configuration import storage_directory
Expand All @@ -47,6 +49,7 @@

---
"""
FAIL_MSG = "discovered failure"
_space_only_re = re.compile("^ +$", re.MULTILINE)
_leading_space_re = re.compile("(^[ ]*)(?:[^ \n])", re.MULTILINE)

Expand All @@ -66,42 +69,61 @@ class AddExamplesCodemod(VisitorBasedCodemodCommand):
DESCRIPTION = "Add explicit examples to failing tests."

@classmethod
def refactor(cls, code: str, fn_examples: dict) -> str:
def refactor(cls, code, fn_examples, *, strip_via=(), dec="example"):
"""Add @example() decorator(s) for failing test(s).

`code` is the source code of the module where the test functions are defined.
`fn_examples` is a dict of function name to list-of-failing-examples.
"""
assert not isinstance(strip_via, str), "expected a collection of strings"
dedented, prefix = dedent(code)
with _native_parser():
mod = cst.parse_module(dedented)
modded = cls(CodemodContext(), fn_examples, prefix).transform_module(mod).code
modded = (
cls(CodemodContext(), fn_examples, prefix, strip_via, dec)
.transform_module(mod)
.code
)
return indent(modded, prefix=prefix)

def __init__(self, context, fn_examples, prefix="", via="discovered failure"):
def __init__(self, context, fn_examples, prefix="", strip_via=(), dec="example"):
assert fn_examples, "This codemod does nothing without fn_examples."
super().__init__(context)

# Codemod the failing examples to Call nodes usable as decorators
self.via = via
self.decorator_func = cst.parse_expression(dec)
self.line_length = 88 - len(prefix) # to match Black's default formatting
value_in_strip_via = m.MatchIfTrue(lambda x: literal_eval(x.value) in strip_via)
self.strip_matching = m.Call(
m.Attribute(m.Call(), m.Name("via")),
[m.Arg(m.SimpleString() & value_in_strip_via)],
)

# Codemod the failing examples to Call nodes usable as decorators
self.fn_examples = {
k: tuple(self.__call_node_to_example_dec(ex) for ex in nodes)
k: tuple(self.__call_node_to_example_dec(ex, via) for ex, via in nodes)
for k, nodes in fn_examples.items()
}

def __call_node_to_example_dec(self, node):
def __call_node_to_example_dec(self, node, via):
# If we have black installed, remove trailing comma, _unless_ there's a comment
node = node.with_changes(
func=cst.Name("example"),
args=[a.with_changes(comma=cst.MaybeSentinel.DEFAULT) for a in node.args]
func=self.decorator_func,
args=[
a.with_changes(
comma=a.comma
if m.findall(a.comma, m.Comment())
else cst.MaybeSentinel.DEFAULT
)
for a in node.args
]
if black
else node.args,
)
# Note: calling a method on a decorator requires PEP-614, i.e. Python 3.9+,
# but plumbing two cases through doesn't seem worth the trouble :-/
via = cst.Call(
func=cst.Attribute(node, cst.Name("via")),
args=[cst.Arg(cst.SimpleString(repr(self.via)))],
args=[cst.Arg(cst.SimpleString(repr(via)))],
)
if black: # pragma: no branch
pretty = black.format_str(
Expand All @@ -114,39 +136,55 @@ def __call_node_to_example_dec(self, node):
def leave_FunctionDef(self, _, updated_node):
return updated_node.with_changes(
# TODO: improve logic for where in the list to insert this decorator
decorators=updated_node.decorators
decorators=tuple(
d
for d in updated_node.decorators
# `findall()` to see through the identity function workaround on py38
if not m.findall(d, self.strip_matching)
)
+ self.fn_examples.get(updated_node.name.value, ())
)


def get_patch_for(func, failing_examples):
def get_patch_for(func, failing_examples, *, strip_via=()):
# Skip this if we're unable to find the location or source of this function.
try:
fname = Path(sys.modules[func.__module__].__file__).relative_to(Path.cwd())
module = sys.modules[func.__module__]
fname = Path(module.__file__).relative_to(Path.cwd())
before = inspect.getsource(func)
except Exception:
return None

# The printed examples might include object reprs which are invalid syntax,
# so we parse here and skip over those. If _none_ are valid, there's no patch.
call_nodes = []
for ex in failing_examples:
for ex, via in failing_examples:
with suppress(Exception):
node = cst.parse_expression(ex)
assert isinstance(node, cst.Call), node
call_nodes.append(node)
call_nodes.append((node, via))
if not call_nodes:
return None

if (
module.__dict__.get("hypothesis") is sys.modules["hypothesis"]
and "given" not in module.__dict__ # more reliably present than `example`
):
decorator_func = "hypothesis.example"
else:
decorator_func = "example"

# Do the codemod and return a triple containing location and replacement info.
after = AddExamplesCodemod.refactor(
before,
fn_examples={func.__name__: call_nodes},
strip_via=strip_via,
dec=decorator_func,
)
return (str(fname), before, after)


def make_patch(triples, *, msg="Hypothesis: add failing examples", when=None):
def make_patch(triples, *, msg="Hypothesis: add explicit examples", when=None):
"""Create a patch for (fname, before, after) triples."""
assert triples, "attempted to create empty patch"
when = when or datetime.now(tz=timezone.utc)
Expand All @@ -159,7 +197,7 @@ def make_patch(triples, *, msg="Hypothesis: add failing examples", when=None):
for fname, changes in sorted(by_fname.items()):
source_before = source_after = fname.read_text(encoding="utf-8")
for before, after in changes:
source_after = source_after.replace(before, after, 1)
source_after = source_after.replace(before.rstrip(), after.rstrip(), 1)
ud = difflib.unified_diff(
source_before.splitlines(keepends=True),
source_after.splitlines(keepends=True),
Expand All @@ -170,17 +208,21 @@ def make_patch(triples, *, msg="Hypothesis: add failing examples", when=None):
return "".join(diffs)


def save_patch(patch: str) -> Path: # pragma: no cover
today = date.today().isoformat()
hash = hashlib.sha1(patch.encode()).hexdigest()[:8]
fname = Path(storage_directory("patches", f"{today}--{hash}.patch"))
def save_patch(patch: str, *, slug: str = "") -> Path: # pragma: no cover
assert re.fullmatch(r"|[a-z]+-", slug), f"malformed slug={slug!r}"
now = date.today().isoformat()
cleaned = re.sub(r"^Date: .+?$", "", patch, count=1, flags=re.MULTILINE)
hash8 = hashlib.sha1(cleaned.encode()).hexdigest()[:8]
fname = Path(storage_directory("patches", f"{now}--{slug}{hash8}.patch"))
fname.parent.mkdir(parents=True, exist_ok=True)
fname.write_text(patch, encoding="utf-8")
return fname.relative_to(Path.cwd())


def gc_patches(): # pragma: no cover
def gc_patches(slug: str = "") -> None: # pragma: no cover
cutoff = date.today() - timedelta(days=7)
for fname in Path(storage_directory("patches")).glob("????-??-??--????????.patch"):
for fname in Path(storage_directory("patches")).glob(
f"????-??-??--{slug}????????.patch"
):
if date.fromisoformat(fname.stem.split("--")[0]) < cutoff:
fname.unlink()
1 change: 1 addition & 0 deletions hypothesis-python/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
collect_ignore_glob.append("cover/*py38*")
if sys.version_info < (3, 9):
collect_ignore_glob.append("cover/*py39*")
collect_ignore_glob.append("patching/*")
if sys.version_info < (3, 10):
collect_ignore_glob.append("cover/*py310*")

Expand Down
7 changes: 7 additions & 0 deletions hypothesis-python/tests/patching/callables.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,11 @@ def mth(self, n, label):
"""Indented method with existing example decorator."""


@given(st.integers())
@example(x=2).via("not a literal when repeated " * 2)
@example(x=1).via("covering example")
def covered(x):
"""A test function with a removable explicit example."""


# TODO: test function for insertion-order logic, once I get that set up.
Loading