schemathesis 4.0.0a9__py3-none-any.whl → 4.0.0a11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. schemathesis/__init__.py +3 -7
  2. schemathesis/checks.py +17 -7
  3. schemathesis/cli/commands/__init__.py +51 -3
  4. schemathesis/cli/commands/data.py +10 -0
  5. schemathesis/cli/commands/run/__init__.py +147 -260
  6. schemathesis/cli/commands/run/context.py +2 -3
  7. schemathesis/cli/commands/run/events.py +4 -0
  8. schemathesis/cli/commands/run/executor.py +60 -73
  9. schemathesis/cli/commands/run/filters.py +15 -165
  10. schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
  11. schemathesis/cli/commands/run/handlers/junitxml.py +6 -5
  12. schemathesis/cli/commands/run/handlers/output.py +26 -47
  13. schemathesis/cli/commands/run/loaders.py +35 -50
  14. schemathesis/cli/commands/run/validation.py +36 -161
  15. schemathesis/cli/core.py +5 -3
  16. schemathesis/cli/ext/fs.py +7 -5
  17. schemathesis/cli/ext/options.py +0 -21
  18. schemathesis/config/__init__.py +188 -0
  19. schemathesis/config/_auth.py +51 -0
  20. schemathesis/config/_checks.py +268 -0
  21. schemathesis/config/_diff_base.py +99 -0
  22. schemathesis/config/_env.py +21 -0
  23. schemathesis/config/_error.py +156 -0
  24. schemathesis/config/_generation.py +150 -0
  25. schemathesis/config/_health_check.py +24 -0
  26. schemathesis/config/_operations.py +313 -0
  27. schemathesis/config/_output.py +171 -0
  28. schemathesis/config/_parameters.py +19 -0
  29. schemathesis/config/_phases.py +151 -0
  30. schemathesis/config/_projects.py +495 -0
  31. schemathesis/config/_rate_limit.py +17 -0
  32. schemathesis/config/_report.py +116 -0
  33. schemathesis/config/_validator.py +9 -0
  34. schemathesis/config/schema.json +837 -0
  35. schemathesis/core/__init__.py +3 -0
  36. schemathesis/core/compat.py +16 -9
  37. schemathesis/core/errors.py +19 -2
  38. schemathesis/core/failures.py +6 -7
  39. schemathesis/core/hooks.py +20 -0
  40. schemathesis/core/output/__init__.py +14 -37
  41. schemathesis/core/output/sanitization.py +3 -146
  42. schemathesis/core/validation.py +16 -0
  43. schemathesis/engine/__init__.py +2 -4
  44. schemathesis/engine/context.py +41 -43
  45. schemathesis/engine/core.py +7 -5
  46. schemathesis/engine/phases/__init__.py +10 -0
  47. schemathesis/engine/phases/probes.py +8 -8
  48. schemathesis/engine/phases/stateful/_executor.py +68 -43
  49. schemathesis/engine/phases/unit/__init__.py +23 -15
  50. schemathesis/engine/phases/unit/_executor.py +77 -17
  51. schemathesis/engine/phases/unit/_pool.py +1 -1
  52. schemathesis/errors.py +2 -0
  53. schemathesis/filters.py +2 -3
  54. schemathesis/generation/__init__.py +6 -31
  55. schemathesis/generation/case.py +5 -3
  56. schemathesis/generation/coverage.py +174 -134
  57. schemathesis/generation/hypothesis/__init__.py +7 -1
  58. schemathesis/generation/hypothesis/builder.py +40 -14
  59. schemathesis/generation/meta.py +3 -3
  60. schemathesis/generation/overrides.py +37 -1
  61. schemathesis/generation/stateful/state_machine.py +8 -1
  62. schemathesis/graphql/loaders.py +21 -12
  63. schemathesis/openapi/checks.py +12 -8
  64. schemathesis/openapi/generation/filters.py +10 -8
  65. schemathesis/openapi/loaders.py +22 -13
  66. schemathesis/pytest/lazy.py +2 -5
  67. schemathesis/pytest/plugin.py +11 -2
  68. schemathesis/schemas.py +13 -61
  69. schemathesis/specs/graphql/schemas.py +11 -15
  70. schemathesis/specs/openapi/_hypothesis.py +12 -8
  71. schemathesis/specs/openapi/checks.py +16 -18
  72. schemathesis/specs/openapi/examples.py +4 -3
  73. schemathesis/specs/openapi/formats.py +2 -2
  74. schemathesis/specs/openapi/negative/__init__.py +2 -2
  75. schemathesis/specs/openapi/patterns.py +46 -16
  76. schemathesis/specs/openapi/references.py +2 -3
  77. schemathesis/specs/openapi/schemas.py +11 -20
  78. schemathesis/specs/openapi/stateful/__init__.py +10 -5
  79. schemathesis/transport/prepare.py +7 -6
  80. schemathesis/transport/requests.py +3 -1
  81. schemathesis/transport/wsgi.py +3 -4
  82. {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/METADATA +7 -8
  83. schemathesis-4.0.0a11.dist-info/RECORD +166 -0
  84. schemathesis/cli/commands/run/checks.py +0 -79
  85. schemathesis/cli/commands/run/hypothesis.py +0 -78
  86. schemathesis/cli/commands/run/reports.py +0 -72
  87. schemathesis/cli/hooks.py +0 -36
  88. schemathesis/engine/config.py +0 -59
  89. schemathesis/experimental/__init__.py +0 -72
  90. schemathesis-4.0.0a9.dist-info/RECORD +0 -153
  91. {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/WHEEL +0 -0
  92. {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/entry_points.txt +0 -0
  93. {schemathesis-4.0.0a9.dist-info → schemathesis-4.0.0a11.dist-info}/licenses/LICENSE +0 -0
@@ -71,7 +71,7 @@ class Case:
71
71
 
72
72
  def as_curl_command(self, headers: Mapping[str, Any] | None = None, verify: bool = True) -> str:
73
73
  """Construct a curl command for a given case."""
74
- request_data = prepare_request(self, headers, self.operation.schema.output_config.sanitize)
74
+ request_data = prepare_request(self, headers, config=self.operation.schema.config.output.sanitization)
75
75
  return curl.generate(
76
76
  method=str(request_data.method),
77
77
  url=str(request_data.url),
@@ -142,7 +142,9 @@ class Case:
142
142
  override=self._override,
143
143
  auth=None,
144
144
  headers=CaseInsensitiveDict(headers) if headers else None,
145
- config={},
145
+ config=self.operation.schema.config.checks_config_for(
146
+ operation=self.operation, phase=self.meta.phase.name.value if self.meta is not None else None
147
+ ),
146
148
  transport_kwargs=transport_kwargs,
147
149
  recorder=None,
148
150
  )
@@ -163,7 +165,7 @@ class Case:
163
165
  response=response,
164
166
  failures=_failures,
165
167
  curl=curl,
166
- config=self.operation.schema.output_config,
168
+ config=self.operation.schema.config.output,
167
169
  )
168
170
  raise FailureGroup(_failures, message) from None
169
171
 
@@ -16,12 +16,13 @@ from hypothesis_jsonschema import from_schema
16
16
  from hypothesis_jsonschema._canonicalise import canonicalish
17
17
  from hypothesis_jsonschema._from_schema import STRING_FORMATS as BUILT_IN_STRING_FORMATS
18
18
 
19
- from schemathesis.core import NOT_SET
19
+ from schemathesis.core import INTERNAL_BUFFER_SIZE, NOT_SET
20
20
  from schemathesis.core.compat import RefResolutionError
21
21
  from schemathesis.core.transforms import deepclone
22
22
  from schemathesis.core.validation import has_invalid_characters, is_latin_1_encodable
23
23
  from schemathesis.generation import GenerationMode
24
24
  from schemathesis.generation.hypothesis import examples
25
+ from schemathesis.openapi.generation.filters import is_invalid_path_parameter
25
26
 
26
27
  from ..specs.openapi.converter import update_pattern_in_schema
27
28
  from ..specs.openapi.formats import STRING_FORMATS, get_default_format_strategies
@@ -36,7 +37,6 @@ def json_recursive_strategy(strategy: st.SearchStrategy) -> st.SearchStrategy:
36
37
  return st.lists(strategy, max_size=3) | st.dictionaries(st.text(), strategy, max_size=3)
37
38
 
38
39
 
39
- BUFFER_SIZE = 8 * 1024
40
40
  NEGATIVE_MODE_MAX_LENGTH_WITH_PATTERN = 100
41
41
  NEGATIVE_MODE_MAX_ITEMS = 15
42
42
  FLOAT_STRATEGY: st.SearchStrategy = st.floats(allow_nan=False, allow_infinity=False).map(_replace_zero_with_nonzero)
@@ -153,6 +153,8 @@ class CoverageContext:
153
153
  def is_valid_for_location(self, value: Any) -> bool:
154
154
  if self.location in ("header", "cookie") and isinstance(value, str):
155
155
  return not value or (is_latin_1_encodable(value) and not has_invalid_characters("", value))
156
+ elif self.location == "path":
157
+ return not is_invalid_path_parameter(value)
156
158
  return True
157
159
 
158
160
  def generate_from(self, strategy: st.SearchStrategy) -> Any:
@@ -161,7 +163,7 @@ class CoverageContext:
161
163
  def generate_from_schema(self, schema: dict | bool) -> Any:
162
164
  if isinstance(schema, bool):
163
165
  return 0
164
- keys = sorted([k for k in schema if not k.startswith("x-") and k not in ["description", "example"]])
166
+ keys = sorted([k for k in schema if not k.startswith("x-") and k not in ["description", "example", "examples"]])
165
167
  if keys == ["type"] and isinstance(schema["type"], str) and schema["type"] in STRATEGIES_FOR_TYPE:
166
168
  return cached_draw(STRATEGIES_FOR_TYPE[schema["type"]])
167
169
  if keys == ["format", "type"]:
@@ -253,6 +255,24 @@ def _to_hashable_key(value: T, _encode: Callable = _encode) -> tuple[type, str |
253
255
  return (type(value), value)
254
256
 
255
257
 
258
+ class HashSet:
259
+ """Helper to track already generated values."""
260
+
261
+ __slots__ = ("_data",)
262
+
263
+ def __init__(self) -> None:
264
+ self._data: set[tuple] = set()
265
+
266
+ def insert(self, value: Any) -> bool:
267
+ key = _to_hashable_key(value)
268
+ before = len(self._data)
269
+ self._data.add(key)
270
+ return len(self._data) > before
271
+
272
+ def clear(self) -> None:
273
+ self._data.clear()
274
+
275
+
256
276
  def _cover_positive_for_type(
257
277
  ctx: CoverageContext, schema: dict, ty: str | None
258
278
  ) -> Generator[GeneratedValue, None, None]:
@@ -326,10 +346,10 @@ def _ignore_unfixable(
326
346
 
327
347
 
328
348
  def cover_schema_iter(
329
- ctx: CoverageContext, schema: dict | bool, seen: set[Any | tuple[type, str]] | None = None
349
+ ctx: CoverageContext, schema: dict | bool, seen: HashSet | None = None
330
350
  ) -> Generator[GeneratedValue, None, None]:
331
351
  if seen is None:
332
- seen = set()
352
+ seen = HashSet()
333
353
  if isinstance(schema, bool):
334
354
  types = ["null", "boolean", "string", "number", "array", "object"]
335
355
  schema = {}
@@ -352,12 +372,9 @@ def cover_schema_iter(
352
372
  yield from _negative_enum(ctx, value, seen)
353
373
  elif key == "const":
354
374
  for value_ in _negative_enum(ctx, [value], seen):
355
- k = _to_hashable_key(value_.value)
356
- if k not in seen:
357
- yield value_
358
- seen.add(k)
375
+ yield value_
359
376
  elif key == "type":
360
- yield from _negative_type(ctx, seen, value)
377
+ yield from _negative_type(ctx, value, seen)
361
378
  elif key == "properties":
362
379
  template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
363
380
  yield from _negative_properties(ctx, template, value)
@@ -374,37 +391,30 @@ def cover_schema_iter(
374
391
  yield from _negative_format(ctx, schema, value)
375
392
  elif key == "maximum":
376
393
  next = value + 1
377
- if next not in seen:
394
+ if seen.insert(next):
378
395
  yield NegativeValue(next, description="Value greater than maximum", location=ctx.current_path)
379
- seen.add(next)
380
396
  elif key == "minimum":
381
397
  next = value - 1
382
- if next not in seen:
398
+ if seen.insert(next):
383
399
  yield NegativeValue(next, description="Value smaller than minimum", location=ctx.current_path)
384
- seen.add(next)
385
- elif key == "exclusiveMaximum" or key == "exclusiveMinimum" and value not in seen:
400
+ elif key == "exclusiveMaximum" or key == "exclusiveMinimum" and seen.insert(value):
386
401
  verb = "greater" if key == "exclusiveMaximum" else "smaller"
387
402
  limit = "maximum" if key == "exclusiveMaximum" else "minimum"
388
403
  yield NegativeValue(value, description=f"Value {verb} than {limit}", location=ctx.current_path)
389
- seen.add(value)
390
404
  elif key == "multipleOf":
391
405
  for value_ in _negative_multiple_of(ctx, schema, value):
392
- k = _to_hashable_key(value_.value)
393
- if k not in seen:
406
+ if seen.insert(value_.value):
394
407
  yield value_
395
- seen.add(k)
396
- elif key == "minLength" and 0 < value < BUFFER_SIZE:
408
+ elif key == "minLength" and 0 < value < INTERNAL_BUFFER_SIZE:
397
409
  if value == 1:
398
410
  # In this case, the only possible negative string is an empty one
399
411
  # The `pattern` value may require an non-empty one and the generation will fail
400
412
  # However, it is fine to violate `pattern` here as it is negative string generation anyway
401
413
  value = ""
402
- k = _to_hashable_key(value)
403
- if k not in seen:
414
+ if seen.insert(value):
404
415
  yield NegativeValue(
405
416
  value, description="String smaller than minLength", location=ctx.current_path
406
417
  )
407
- seen.add(k)
408
418
  else:
409
419
  with suppress(InvalidArgument):
410
420
  min_length = max_length = value - 1
@@ -421,13 +431,11 @@ def cover_schema_iter(
421
431
  value = ctx.generate_from_schema(new_schema)
422
432
  else:
423
433
  value = ctx.generate_from_schema(new_schema)
424
- k = _to_hashable_key(value)
425
- if k not in seen:
434
+ if seen.insert(value):
426
435
  yield NegativeValue(
427
436
  value, description="String smaller than minLength", location=ctx.current_path
428
437
  )
429
- seen.add(k)
430
- elif key == "maxLength" and value < BUFFER_SIZE:
438
+ elif key == "maxLength" and value < INTERNAL_BUFFER_SIZE:
431
439
  try:
432
440
  min_length = max_length = value + 1
433
441
  new_schema = {**schema, "minLength": min_length, "maxLength": max_length}
@@ -448,12 +456,10 @@ def cover_schema_iter(
448
456
  value = ctx.generate_from_schema(new_schema)
449
457
  else:
450
458
  value = ctx.generate_from_schema(new_schema)
451
- k = _to_hashable_key(value)
452
- if k not in seen:
459
+ if seen.insert(value):
453
460
  yield NegativeValue(
454
461
  value, description="String larger than maxLength", location=ctx.current_path
455
462
  )
456
- seen.add(k)
457
463
  except (InvalidArgument, Unsatisfiable):
458
464
  pass
459
465
  elif key == "uniqueItems" and value:
@@ -461,7 +467,7 @@ def cover_schema_iter(
461
467
  elif key == "required":
462
468
  template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
463
469
  yield from _negative_required(ctx, template, value)
464
- elif key == "maxItems" and isinstance(value, int) and value < BUFFER_SIZE:
470
+ elif key == "maxItems" and isinstance(value, int) and value < INTERNAL_BUFFER_SIZE:
465
471
  if value > NEGATIVE_MODE_MAX_ITEMS:
466
472
  # It could be extremely slow to generate large arrays
467
473
  # Generate values up to the limit and reuse them to construct the final array
@@ -471,34 +477,41 @@ def cover_schema_iter(
471
477
  "maxItems": NEGATIVE_MODE_MAX_ITEMS,
472
478
  "type": "array",
473
479
  }
474
- array_value = ctx.generate_from_schema(new_schema)
480
+ if "items" in schema and isinstance(schema["items"], dict):
481
+ # The schema may have another large array nested, therefore generate covering cases
482
+ # and use them to build an array for the current schema
483
+ negative = [case.value for case in cover_schema_iter(ctx, schema["items"])]
484
+ positive = [case.value for case in cover_schema_iter(ctx.with_positive(), schema["items"])]
485
+ # Interleave positive & negative values
486
+ array_value = [value for pair in zip(positive, negative) for value in pair][
487
+ :NEGATIVE_MODE_MAX_ITEMS
488
+ ]
489
+ else:
490
+ array_value = ctx.generate_from_schema(new_schema)
491
+
475
492
  # Extend the array to be of length value + 1 by repeating its own elements
476
493
  diff = value + 1 - len(array_value)
477
- if diff > 0:
494
+ if diff > 0 and array_value:
478
495
  array_value += (
479
496
  array_value * (diff // len(array_value)) + array_value[: diff % len(array_value)]
480
497
  )
481
- k = _to_hashable_key(array_value)
482
- if k not in seen:
498
+ if seen.insert(array_value):
483
499
  yield NegativeValue(
484
500
  array_value,
485
501
  description="Array with more items than allowed by maxItems",
486
502
  location=ctx.current_path,
487
503
  )
488
- seen.add(k)
489
504
  else:
490
505
  try:
491
506
  # Force the array to have one more item than allowed
492
507
  new_schema = {**schema, "minItems": value + 1, "maxItems": value + 1, "type": "array"}
493
508
  array_value = ctx.generate_from_schema(new_schema)
494
- k = _to_hashable_key(array_value)
495
- if k not in seen:
509
+ if seen.insert(array_value):
496
510
  yield NegativeValue(
497
511
  array_value,
498
512
  description="Array with more items than allowed by maxItems",
499
513
  location=ctx.current_path,
500
514
  )
501
- seen.add(k)
502
515
  except (InvalidArgument, Unsatisfiable):
503
516
  pass
504
517
  elif key == "minItems" and isinstance(value, int) and value > 0:
@@ -506,14 +519,12 @@ def cover_schema_iter(
506
519
  # Force the array to have one less item than the minimum
507
520
  new_schema = {**schema, "minItems": value - 1, "maxItems": value - 1, "type": "array"}
508
521
  array_value = ctx.generate_from_schema(new_schema)
509
- k = _to_hashable_key(array_value)
510
- if k not in seen:
522
+ if seen.insert(array_value):
511
523
  yield NegativeValue(
512
524
  array_value,
513
525
  description="Array with fewer items than allowed by minItems",
514
526
  location=ctx.current_path,
515
527
  )
516
- seen.add(k)
517
528
  except (InvalidArgument, Unsatisfiable):
518
529
  pass
519
530
  elif (
@@ -574,6 +585,22 @@ def _get_template_schema(schema: dict, ty: str) -> dict:
574
585
  return {**schema, "type": ty}
575
586
 
576
587
 
588
+ def _ensure_valid_path_parameter_schema(schema: dict[str, Any]) -> dict[str, Any]:
589
+ # Path parameters should have at least 1 character length and don't contain any characters with special treatment
590
+ # on the transport level.
591
+ # The implementation below sneaks into `not` to avoid clashing with existing `pattern` keyword
592
+ not_ = schema.get("not", {}).copy()
593
+ not_["pattern"] = r"[/{}]"
594
+ return {**schema, "minLength": 1, "not": not_}
595
+
596
+
597
+ def _ensure_valid_headers_schema(schema: dict[str, Any]) -> dict[str, Any]:
598
+ # Reject any character that is not A-Z, a-z, or 0-9 for simplicity
599
+ not_ = schema.get("not", {}).copy()
600
+ not_["pattern"] = r"[^A-Za-z0-9]"
601
+ return {**schema, "not": not_}
602
+
603
+
577
604
  def _positive_string(ctx: CoverageContext, schema: dict) -> Generator[GeneratedValue, None, None]:
578
605
  """Generate positive string values."""
579
606
  # Boundary and near boundary values
@@ -581,67 +608,90 @@ def _positive_string(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
581
608
  if min_length == 0:
582
609
  min_length = None
583
610
  max_length = schema.get("maxLength")
611
+ if ctx.location == "path":
612
+ schema = _ensure_valid_path_parameter_schema(schema)
613
+ elif ctx.location in ("header", "cookie") and not ("format" in schema and schema["format"] in FORMAT_STRATEGIES):
614
+ # Don't apply it for known formats - they will insure the correct format during generation
615
+ schema = _ensure_valid_headers_schema(schema)
616
+
584
617
  example = schema.get("example")
585
618
  examples = schema.get("examples")
586
619
  default = schema.get("default")
620
+
621
+ # Two-layer check to avoid potentially expensive data generation using schema constraints as a key
622
+ seen_values = HashSet()
623
+ seen_constraints: set[tuple] = set()
624
+
587
625
  if example or examples or default:
588
- if example and ctx.is_valid_for_location(example):
626
+ has_valid_example = False
627
+ if example and ctx.is_valid_for_location(example) and seen_values.insert(example):
628
+ has_valid_example = True
589
629
  yield PositiveValue(example, description="Example value")
590
630
  if examples:
591
631
  for example in examples:
592
- if ctx.is_valid_for_location(example):
632
+ if ctx.is_valid_for_location(example) and seen_values.insert(example):
633
+ has_valid_example = True
593
634
  yield PositiveValue(example, description="Example value")
594
635
  if (
595
636
  default
596
637
  and not (example is not None and default == example)
597
638
  and not (examples is not None and any(default == ex for ex in examples))
598
639
  and ctx.is_valid_for_location(default)
640
+ and seen_values.insert(default)
599
641
  ):
642
+ has_valid_example = True
600
643
  yield PositiveValue(default, description="Default value")
601
- elif not min_length and not max_length:
602
- # Default positive value
603
- yield PositiveValue(ctx.generate_from_schema(schema), description="Valid string")
604
- elif "pattern" in schema:
605
- yield PositiveValue(ctx.generate_from_schema(schema), description="Valid string")
606
-
607
- seen = set()
608
-
609
- if min_length is not None and min_length < BUFFER_SIZE:
644
+ if not has_valid_example:
645
+ if not min_length and not max_length or "pattern" in schema:
646
+ value = ctx.generate_from_schema(schema)
647
+ seen_values.insert(value)
648
+ seen_constraints.add((min_length, max_length))
649
+ yield PositiveValue(value, description="Valid string")
650
+ elif not min_length and not max_length or "pattern" in schema:
651
+ value = ctx.generate_from_schema(schema)
652
+ seen_values.insert(value)
653
+ seen_constraints.add((min_length, max_length))
654
+ yield PositiveValue(value, description="Valid string")
655
+
656
+ if min_length is not None and min_length < INTERNAL_BUFFER_SIZE:
610
657
  # Exactly the minimum length
611
- yield PositiveValue(
612
- ctx.generate_from_schema({**schema, "maxLength": min_length}), description="Minimum length string"
613
- )
614
- seen.add(min_length)
658
+ key = (min_length, min_length)
659
+ if key not in seen_constraints:
660
+ seen_constraints.add(key)
661
+ value = ctx.generate_from_schema({**schema, "maxLength": min_length})
662
+ if seen_values.insert(value):
663
+ yield PositiveValue(value, description="Minimum length string")
615
664
 
616
665
  # One character more than minimum if possible
617
666
  larger = min_length + 1
618
- if larger < BUFFER_SIZE and larger not in seen and (not max_length or larger <= max_length):
619
- yield PositiveValue(
620
- ctx.generate_from_schema({**schema, "minLength": larger, "maxLength": larger}),
621
- description="Near-boundary length string",
622
- )
623
- seen.add(larger)
667
+ key = (larger, larger)
668
+ if larger < INTERNAL_BUFFER_SIZE and key not in seen_constraints and (not max_length or larger <= max_length):
669
+ seen_constraints.add(key)
670
+ value = ctx.generate_from_schema({**schema, "minLength": larger, "maxLength": larger})
671
+ if seen_values.insert(value):
672
+ yield PositiveValue(value, description="Near-boundary length string")
624
673
 
625
674
  if max_length is not None:
626
675
  # Exactly the maximum length
627
- if max_length < BUFFER_SIZE and max_length not in seen:
628
- yield PositiveValue(
629
- ctx.generate_from_schema({**schema, "minLength": max_length}), description="Maximum length string"
630
- )
631
- seen.add(max_length)
676
+ key = (max_length, max_length)
677
+ if max_length < INTERNAL_BUFFER_SIZE and key not in seen_constraints:
678
+ seen_constraints.add(key)
679
+ value = ctx.generate_from_schema({**schema, "minLength": max_length, "maxLength": max_length})
680
+ if seen_values.insert(value):
681
+ yield PositiveValue(value, description="Maximum length string")
632
682
 
633
683
  # One character less than maximum if possible
634
684
  smaller = max_length - 1
685
+ key = (smaller, smaller)
635
686
  if (
636
- smaller < BUFFER_SIZE
637
- and smaller not in seen
687
+ smaller < INTERNAL_BUFFER_SIZE
688
+ and key not in seen_constraints
638
689
  and (smaller > 0 and (min_length is None or smaller >= min_length))
639
690
  ):
640
- yield PositiveValue(
641
- ctx.generate_from_schema({**schema, "minLength": smaller, "maxLength": smaller}),
642
- description="Near-boundary length string",
643
- )
644
- seen.add(smaller)
691
+ seen_constraints.add(key)
692
+ value = ctx.generate_from_schema({**schema, "minLength": smaller, "maxLength": smaller})
693
+ if seen_values.insert(value):
694
+ yield PositiveValue(value, description="Near-boundary length string")
645
695
 
646
696
 
647
697
  def closest_multiple_greater_than(y: int, x: int) -> int:
@@ -668,23 +718,26 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
668
718
  examples = schema.get("examples")
669
719
  default = schema.get("default")
670
720
 
721
+ seen = HashSet()
722
+
671
723
  if example or examples or default:
672
- if example:
724
+ if example and seen.insert(example):
673
725
  yield PositiveValue(example, description="Example value")
674
726
  if examples:
675
727
  for example in examples:
676
- yield PositiveValue(example, description="Example value")
728
+ if seen.insert(example):
729
+ yield PositiveValue(example, description="Example value")
677
730
  if (
678
731
  default
679
732
  and not (example is not None and default == example)
680
733
  and not (examples is not None and any(default == ex for ex in examples))
734
+ and seen.insert(default)
681
735
  ):
682
736
  yield PositiveValue(default, description="Default value")
683
737
  elif not minimum and not maximum:
684
- # Default positive value
685
- yield PositiveValue(ctx.generate_from_schema(schema), description="Valid number")
686
-
687
- seen = set()
738
+ value = ctx.generate_from_schema(schema)
739
+ seen.insert(value)
740
+ yield PositiveValue(value, description="Valid number")
688
741
 
689
742
  if minimum is not None:
690
743
  # Exactly the minimum
@@ -692,16 +745,15 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
692
745
  smallest = closest_multiple_greater_than(minimum, multiple_of)
693
746
  else:
694
747
  smallest = minimum
695
- seen.add(smallest)
696
- yield PositiveValue(smallest, description="Minimum value")
748
+ if seen.insert(smallest):
749
+ yield PositiveValue(smallest, description="Minimum value")
697
750
 
698
751
  # One more than minimum if possible
699
752
  if multiple_of is not None:
700
753
  larger = smallest + multiple_of
701
754
  else:
702
755
  larger = minimum + 1
703
- if larger not in seen and (not maximum or larger <= maximum):
704
- seen.add(larger)
756
+ if (not maximum or larger <= maximum) and seen.insert(larger):
705
757
  yield PositiveValue(larger, description="Near-boundary number")
706
758
 
707
759
  if maximum is not None:
@@ -710,8 +762,7 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
710
762
  largest = maximum - (maximum % multiple_of)
711
763
  else:
712
764
  largest = maximum
713
- if largest not in seen:
714
- seen.add(largest)
765
+ if seen.insert(largest):
715
766
  yield PositiveValue(largest, description="Maximum value")
716
767
 
717
768
  # One less than maximum if possible
@@ -719,71 +770,65 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
719
770
  smaller = largest - multiple_of
720
771
  else:
721
772
  smaller = maximum - 1
722
- if smaller not in seen and (smaller > 0 and (minimum is None or smaller >= minimum)):
723
- seen.add(smaller)
773
+ if (smaller > 0 and (minimum is None or smaller >= minimum)) and seen.insert(smaller):
724
774
  yield PositiveValue(smaller, description="Near-boundary number")
725
775
 
726
776
 
727
777
  def _positive_array(ctx: CoverageContext, schema: dict, template: list) -> Generator[GeneratedValue, None, None]:
728
- seen = set()
729
778
  example = schema.get("example")
730
779
  examples = schema.get("examples")
731
780
  default = schema.get("default")
732
781
 
782
+ seen = HashSet()
783
+ seen_constraints: set[tuple] = set()
784
+
733
785
  if example or examples or default:
734
- if example:
735
- seen.add(_to_hashable_key(example))
786
+ if example and seen.insert(example):
736
787
  yield PositiveValue(example, description="Example value")
737
788
  if examples:
738
789
  for example in examples:
739
- seen.add(_to_hashable_key(example))
740
- yield PositiveValue(example, description="Example value")
790
+ if seen.insert(example):
791
+ yield PositiveValue(example, description="Example value")
741
792
  if (
742
793
  default
743
794
  and not (example is not None and default == example)
744
795
  and not (examples is not None and any(default == ex for ex in examples))
796
+ and seen.insert(default)
745
797
  ):
746
- seen.add(_to_hashable_key(default))
747
798
  yield PositiveValue(default, description="Default value")
748
- else:
799
+ elif seen.insert(template):
749
800
  yield PositiveValue(template, description="Valid array")
750
- seen.add(_to_hashable_key(template))
751
801
 
752
802
  # Boundary and near-boundary sizes
753
803
  min_items = schema.get("minItems")
754
804
  max_items = schema.get("maxItems")
755
805
  if min_items is not None:
756
806
  # Do not generate an array with `minItems` length, because it is already covered by `template`
757
-
758
807
  # One item more than minimum if possible
759
808
  larger = min_items + 1
760
- if larger not in seen and (max_items is None or larger <= max_items):
809
+ if (max_items is None or larger <= max_items) and larger not in seen_constraints:
810
+ seen_constraints.add(larger)
761
811
  value = ctx.generate_from_schema({**schema, "minItems": larger, "maxItems": larger})
762
- key = _to_hashable_key(value)
763
- if key not in seen:
764
- seen.add(key)
812
+ if seen.insert(value):
765
813
  yield PositiveValue(value, description="Near-boundary items array")
766
814
 
767
815
  if max_items is not None:
768
- if max_items < BUFFER_SIZE and max_items not in seen:
816
+ if max_items < INTERNAL_BUFFER_SIZE and max_items not in seen_constraints:
817
+ seen_constraints.add(max_items)
769
818
  value = ctx.generate_from_schema({**schema, "minItems": max_items})
770
- key = _to_hashable_key(value)
771
- if key not in seen:
772
- seen.add(key)
819
+ if seen.insert(value):
773
820
  yield PositiveValue(value, description="Maximum items array")
774
821
 
775
822
  # One item smaller than maximum if possible
776
823
  smaller = max_items - 1
777
824
  if (
778
- smaller < BUFFER_SIZE
825
+ smaller < INTERNAL_BUFFER_SIZE
779
826
  and smaller > 0
780
- and smaller not in seen
781
827
  and (min_items is None or smaller >= min_items)
828
+ and smaller not in seen_constraints
782
829
  ):
783
830
  value = ctx.generate_from_schema({**schema, "minItems": smaller, "maxItems": smaller})
784
- key = _to_hashable_key(value)
785
- if key not in seen:
786
- seen.add(key)
831
+ if seen.insert(value):
787
832
  yield PositiveValue(value, description="Near-boundary items array")
788
833
 
789
834
  if "items" in schema and "enum" in schema["items"] and isinstance(schema["items"]["enum"], list) and max_items != 0:
@@ -791,9 +836,7 @@ def _positive_array(ctx: CoverageContext, schema: dict, template: list) -> Gener
791
836
  length = min_items or 1
792
837
  for variant in schema["items"]["enum"]:
793
838
  value = [variant] * length
794
- key = _to_hashable_key(value)
795
- if key not in seen:
796
- seen.add(key)
839
+ if seen.insert(value):
797
840
  yield PositiveValue(value, description="Enum value from available for items array")
798
841
  elif min_items is None and max_items is None and "items" in schema and isinstance(schema["items"], dict):
799
842
  # Otherwise only an empty array is generated
@@ -840,16 +883,14 @@ def _positive_object(ctx: CoverageContext, schema: dict, template: dict) -> Gene
840
883
  if set(properties) != required:
841
884
  only_required = {k: v for k, v in template.items() if k in required}
842
885
  yield PositiveValue(only_required, description="Object with only required properties")
843
- seen = set()
886
+ seen = HashSet()
844
887
  for name, sub_schema in properties.items():
845
- seen.add(_to_hashable_key(template.get(name)))
888
+ seen.insert(template.get(name))
846
889
  for new in cover_schema_iter(ctx, sub_schema):
847
- key = _to_hashable_key(new.value)
848
- if key not in seen:
890
+ if seen.insert(new.value):
849
891
  yield PositiveValue(
850
892
  {**template, name: new.value}, description=f"Object with valid '{name}' value: {new.description}"
851
893
  )
852
- seen.add(key)
853
894
  seen.clear()
854
895
 
855
896
 
@@ -858,14 +899,11 @@ def select_combinations(optional: list[str]) -> Iterator[tuple[str, ...]]:
858
899
  yield next(combinations(optional, size))
859
900
 
860
901
 
861
- def _negative_enum(
862
- ctx: CoverageContext, value: list, seen: set[Any | tuple[type, str]]
863
- ) -> Generator[GeneratedValue, None, None]:
902
+ def _negative_enum(ctx: CoverageContext, value: list, seen: HashSet) -> Generator[GeneratedValue, None, None]:
864
903
  def is_not_in_value(x: Any) -> bool:
865
904
  if x in value or not ctx.is_valid_for_location(x):
866
905
  return False
867
- _hashed = _to_hashable_key(x)
868
- return _hashed not in seen
906
+ return seen.insert(x)
869
907
 
870
908
  strategy = (
871
909
  st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=122, categories=["L"]), min_size=3)
@@ -873,10 +911,11 @@ def _negative_enum(
873
911
  | st.booleans()
874
912
  | NUMERIC_STRATEGY
875
913
  ).filter(is_not_in_value)
876
- value = ctx.generate_from(strategy)
877
- yield NegativeValue(value, description="Invalid enum value", location=ctx.current_path)
878
- hashed = _to_hashable_key(value)
879
- seen.add(hashed)
914
+ yield NegativeValue(
915
+ ctx.generate_from(strategy),
916
+ description="Invalid enum value",
917
+ location=ctx.current_path,
918
+ )
880
919
 
881
920
 
882
921
  def _negative_properties(
@@ -1005,7 +1044,11 @@ def _is_non_integer_float(x: float) -> bool:
1005
1044
  return x != int(x)
1006
1045
 
1007
1046
 
1008
- def _negative_type(ctx: CoverageContext, seen: set, ty: str | list[str]) -> Generator[GeneratedValue, None, None]:
1047
+ def _negative_type(
1048
+ ctx: CoverageContext,
1049
+ ty: str | list[str],
1050
+ seen: HashSet,
1051
+ ) -> Generator[GeneratedValue, None, None]:
1009
1052
  if isinstance(ty, str):
1010
1053
  types = [ty]
1011
1054
  else:
@@ -1017,11 +1060,8 @@ def _negative_type(ctx: CoverageContext, seen: set, ty: str | list[str]) -> Gene
1017
1060
  strategies["number"] = FLOAT_STRATEGY.filter(_is_non_integer_float)
1018
1061
  for strategy in strategies.values():
1019
1062
  value = ctx.generate_from(strategy)
1020
- hashed = _to_hashable_key(value)
1021
- if hashed in seen:
1022
- continue
1023
- yield NegativeValue(value, description="Incorrect type", location=ctx.current_path)
1024
- seen.add(hashed)
1063
+ if seen.insert(value):
1064
+ yield NegativeValue(value, description="Incorrect type", location=ctx.current_path)
1025
1065
 
1026
1066
 
1027
1067
  def push_examples_to_properties(schema: dict[str, Any]) -> None: