@@ -261,7 +261,7 @@ def accept(self):
261
261
accept .__name__ = fn .__name__
262
262
return property (accept )
263
263
264
- def __init__ (self , engine , initial , predicate , allow_transition ):
264
+ def __init__ (self , engine , initial , predicate , allow_transition , explain ):
265
265
"""Create a shrinker for a particular engine, with a given starting
266
266
point and predicate. When shrink() is called it will attempt to find an
267
267
example for which predicate is True and which is strictly smaller than
@@ -300,6 +300,8 @@ def __init__(self, engine, initial, predicate, allow_transition):
300
300
# testing and learning purposes.
301
301
self .extra_dfas = {}
302
302
303
+ self .should_explain = explain
304
+
303
305
@derived_value # type: ignore
304
306
def cached_calculations (self ):
305
307
return {}
@@ -437,12 +439,15 @@ def shrink(self):
437
439
if not any (self .shrink_target .buffer ) or self .incorporate_new_buffer (
438
440
bytes (len (self .shrink_target .buffer ))
439
441
):
442
+ self .explain ()
440
443
return
441
444
442
445
try :
443
446
self .greedy_shrink ()
444
447
except StopShrinking :
445
- pass
448
+ # If we stopped shrinking because we're making slow progress (instead of
449
+ # reaching a local optimum), don't run the explain-phase logic.
450
+ self .should_explain = False
446
451
finally :
447
452
if self .engine .report_debug_info :
448
453
@@ -488,6 +493,138 @@ def s(n):
488
493
)
489
494
)
490
495
self .debug ("" )
496
+ self .explain ()
497
+
498
+ def explain (self ):
499
+ if not self .should_explain or not self .shrink_target .arg_slices :
500
+ return
501
+ from hypothesis .internal .conjecture .engine import BUFFER_SIZE
502
+
503
+ self .max_stall = 1e999
504
+ shrink_target = self .shrink_target
505
+ buffer = shrink_target .buffer
506
+ chunks = defaultdict (list )
507
+
508
+ # Before we start running experiments, let's check for known inputs which would
509
+ # make them redundant. The shrinking process means that we've already tried many
510
+ # variations on the minimal example, so this can save a lot of time.
511
+ seen_passing_buffers = self .engine .passing_buffers (
512
+ prefix = buffer [: min (self .shrink_target .arg_slices )[0 ]]
513
+ )
514
+
515
+ # Now that we've shrunk to a minimal failing example, it's time to try
516
+ # varying each part that we've noted will go in the final report. Consider
517
+ # slices in largest-first order
518
+ for start , end in sorted (
519
+ self .shrink_target .arg_slices , key = lambda x : (- (x [1 ] - x [0 ]), x )
520
+ ):
521
+ # Check for any previous examples that match the prefix and suffix,
522
+ # so we can skip if we found a passing example while shrinking.
523
+ if any (
524
+ seen .startswith (buffer [:start ]) and seen .endswith (buffer [end :])
525
+ for seen in seen_passing_buffers
526
+ ):
527
+ continue
528
+
529
+ # Run our experiments
530
+ n_same_failures = 0
531
+ note = "or any other generated value"
532
+ # TODO: is 100 same-failures out of 500 attempts a good heuristic?
533
+ for n_attempt in range (500 ): # pragma: no branch
534
+ # no-branch here because we don't coverage-test the abort-at-500 logic.
535
+
536
+ if n_attempt - 10 > n_same_failures * 5 :
537
+ # stop early if we're seeing mostly invalid examples
538
+ break # pragma: no cover
539
+
540
+ buf_attempt_fixed = bytearray (buffer )
541
+ buf_attempt_fixed [start :end ] = [
542
+ self .random .randint (0 , 255 ) for _ in range (end - start )
543
+ ]
544
+ result = self .engine .cached_test_function (
545
+ buf_attempt_fixed , extend = BUFFER_SIZE - len (buf_attempt_fixed )
546
+ )
547
+
548
+ # Turns out this was a variable-length part, so grab the infix...
549
+ if (
550
+ result .status == Status .OVERRUN
551
+ or len (buf_attempt_fixed ) != len (result .buffer )
552
+ or not result .buffer .endswith (buffer [end :])
553
+ ):
554
+ for ex , res in zip (shrink_target .examples , result .examples ):
555
+ assert ex .start == res .start
556
+ assert ex .start <= start
557
+ assert ex .label == res .label
558
+ if start == ex .start and end == ex .end :
559
+ res_end = res .end
560
+ break
561
+ else :
562
+ raise NotImplementedError ("Expected matching prefixes" )
563
+
564
+ buf_attempt_fixed = (
565
+ buffer [:start ] + result .buffer [start :res_end ] + buffer [end :]
566
+ )
567
+ chunks [(start , end )].append (result .buffer [start :res_end ])
568
+ result = self .engine .cached_test_function (buf_attempt_fixed )
569
+
570
+ if (
571
+ result .status == Status .OVERRUN
572
+ or len (buf_attempt_fixed ) != len (result .buffer )
573
+ or not result .buffer .endswith (buffer [end :])
574
+ ):
575
+ raise NotImplementedError ("This should never happen" )
576
+ else :
577
+ chunks [(start , end )].append (result .buffer [start :end ])
578
+
579
+ if shrink_target is not self .shrink_target : # pragma: no cover
580
+ # If we've shrunk further without meaning to, bail out.
581
+ self .shrink_target .slice_comments .clear ()
582
+ return
583
+ if result .status == Status .VALID :
584
+ # The test passed, indicating that this param can't vary freely.
585
+ # However, it's really hard to write a simple and reliable covering
586
+ # test, because of our `seen_passing_buffers` check above.
587
+ break # pragma: no cover
588
+ elif self .__predicate (result ): # pragma: no branch
589
+ n_same_failures += 1
590
+ if n_same_failures >= 100 :
591
+ self .shrink_target .slice_comments [(start , end )] = note
592
+ break
593
+
594
+ # Finally, if we've found multiple independently-variable parts, check whether
595
+ # they can all be varied together.
596
+ if len (self .shrink_target .slice_comments ) <= 1 :
597
+ return
598
+ n_same_failures_together = 0
599
+ chunks_by_start_index = sorted (chunks .items ())
600
+ for _ in range (500 ): # pragma: no branch
601
+ # no-branch here because we don't coverage-test the abort-at-500 logic.
602
+ new_buf = bytearray ()
603
+ prev_end = 0
604
+ for (start , end ), ls in chunks_by_start_index :
605
+ assert prev_end <= start < end , "these chunks must be nonoverlapping"
606
+ new_buf .extend (buffer [prev_end :start ])
607
+ new_buf .extend (self .random .choice (ls ))
608
+ prev_end = end
609
+
610
+ result = self .engine .cached_test_function (new_buf )
611
+
612
+ # This *can't* be a shrink because none of the components were.
613
+ assert shrink_target is self .shrink_target
614
+ if result .status == Status .VALID :
615
+ # TODO: cover this branch.
616
+ # I might need to save or retrieve passing chunks too???
617
+ self .shrink_target .slice_comments [
618
+ (0 , 0 )
619
+ ] = "The test sometimes passed when commented parts were varied together."
620
+ break # Test passed, this param can't vary freely.
621
+ elif self .__predicate (result ): # pragma: no branch
622
+ n_same_failures_together += 1
623
+ if n_same_failures_together >= 100 :
624
+ self .shrink_target .slice_comments [
625
+ (0 , 0 )
626
+ ] = "The test always failed when commented parts were varied together."
627
+ break
491
628
492
629
def greedy_shrink (self ):
493
630
"""Run a full set of greedy shrinks (that is, ones that will only ever
0 commit comments