schemathesis 4.0.0a10__py3-none-any.whl → 4.0.0a12__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 (111) hide show
  1. schemathesis/__init__.py +29 -30
  2. schemathesis/auths.py +65 -24
  3. schemathesis/checks.py +73 -39
  4. schemathesis/cli/commands/__init__.py +51 -3
  5. schemathesis/cli/commands/data.py +10 -0
  6. schemathesis/cli/commands/run/__init__.py +163 -274
  7. schemathesis/cli/commands/run/context.py +8 -4
  8. schemathesis/cli/commands/run/events.py +11 -1
  9. schemathesis/cli/commands/run/executor.py +70 -78
  10. schemathesis/cli/commands/run/filters.py +15 -165
  11. schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
  12. schemathesis/cli/commands/run/handlers/junitxml.py +5 -4
  13. schemathesis/cli/commands/run/handlers/output.py +195 -121
  14. schemathesis/cli/commands/run/loaders.py +35 -50
  15. schemathesis/cli/commands/run/validation.py +52 -162
  16. schemathesis/cli/core.py +5 -3
  17. schemathesis/cli/ext/fs.py +7 -5
  18. schemathesis/cli/ext/options.py +0 -21
  19. schemathesis/config/__init__.py +189 -0
  20. schemathesis/config/_auth.py +51 -0
  21. schemathesis/config/_checks.py +268 -0
  22. schemathesis/config/_diff_base.py +99 -0
  23. schemathesis/config/_env.py +21 -0
  24. schemathesis/config/_error.py +156 -0
  25. schemathesis/config/_generation.py +149 -0
  26. schemathesis/config/_health_check.py +24 -0
  27. schemathesis/config/_operations.py +327 -0
  28. schemathesis/config/_output.py +171 -0
  29. schemathesis/config/_parameters.py +19 -0
  30. schemathesis/config/_phases.py +187 -0
  31. schemathesis/config/_projects.py +523 -0
  32. schemathesis/config/_rate_limit.py +17 -0
  33. schemathesis/config/_report.py +120 -0
  34. schemathesis/config/_validator.py +9 -0
  35. schemathesis/config/_warnings.py +25 -0
  36. schemathesis/config/schema.json +885 -0
  37. schemathesis/core/__init__.py +2 -0
  38. schemathesis/core/compat.py +16 -9
  39. schemathesis/core/errors.py +24 -4
  40. schemathesis/core/failures.py +6 -7
  41. schemathesis/core/hooks.py +20 -0
  42. schemathesis/core/output/__init__.py +14 -37
  43. schemathesis/core/output/sanitization.py +3 -146
  44. schemathesis/core/transport.py +36 -1
  45. schemathesis/core/validation.py +16 -0
  46. schemathesis/engine/__init__.py +2 -4
  47. schemathesis/engine/context.py +42 -43
  48. schemathesis/engine/core.py +7 -5
  49. schemathesis/engine/errors.py +60 -1
  50. schemathesis/engine/events.py +10 -2
  51. schemathesis/engine/phases/__init__.py +10 -0
  52. schemathesis/engine/phases/probes.py +11 -8
  53. schemathesis/engine/phases/stateful/__init__.py +2 -1
  54. schemathesis/engine/phases/stateful/_executor.py +104 -46
  55. schemathesis/engine/phases/stateful/context.py +2 -2
  56. schemathesis/engine/phases/unit/__init__.py +23 -15
  57. schemathesis/engine/phases/unit/_executor.py +110 -21
  58. schemathesis/engine/phases/unit/_pool.py +1 -1
  59. schemathesis/errors.py +2 -0
  60. schemathesis/filters.py +2 -3
  61. schemathesis/generation/__init__.py +5 -33
  62. schemathesis/generation/case.py +6 -3
  63. schemathesis/generation/coverage.py +154 -124
  64. schemathesis/generation/hypothesis/builder.py +70 -20
  65. schemathesis/generation/meta.py +3 -3
  66. schemathesis/generation/metrics.py +93 -0
  67. schemathesis/generation/modes.py +0 -8
  68. schemathesis/generation/overrides.py +37 -1
  69. schemathesis/generation/stateful/__init__.py +4 -0
  70. schemathesis/generation/stateful/state_machine.py +9 -1
  71. schemathesis/graphql/loaders.py +159 -16
  72. schemathesis/hooks.py +62 -35
  73. schemathesis/openapi/checks.py +12 -8
  74. schemathesis/openapi/generation/filters.py +10 -8
  75. schemathesis/openapi/loaders.py +142 -17
  76. schemathesis/pytest/lazy.py +2 -5
  77. schemathesis/pytest/loaders.py +24 -0
  78. schemathesis/pytest/plugin.py +33 -2
  79. schemathesis/schemas.py +21 -66
  80. schemathesis/specs/graphql/scalars.py +37 -3
  81. schemathesis/specs/graphql/schemas.py +23 -18
  82. schemathesis/specs/openapi/_hypothesis.py +26 -28
  83. schemathesis/specs/openapi/checks.py +37 -36
  84. schemathesis/specs/openapi/examples.py +4 -3
  85. schemathesis/specs/openapi/formats.py +32 -5
  86. schemathesis/specs/openapi/media_types.py +44 -1
  87. schemathesis/specs/openapi/negative/__init__.py +2 -2
  88. schemathesis/specs/openapi/patterns.py +46 -16
  89. schemathesis/specs/openapi/references.py +2 -3
  90. schemathesis/specs/openapi/schemas.py +19 -22
  91. schemathesis/specs/openapi/stateful/__init__.py +12 -6
  92. schemathesis/transport/__init__.py +54 -16
  93. schemathesis/transport/prepare.py +38 -13
  94. schemathesis/transport/requests.py +12 -9
  95. schemathesis/transport/wsgi.py +11 -12
  96. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/METADATA +50 -97
  97. schemathesis-4.0.0a12.dist-info/RECORD +164 -0
  98. schemathesis/cli/commands/run/checks.py +0 -79
  99. schemathesis/cli/commands/run/hypothesis.py +0 -78
  100. schemathesis/cli/commands/run/reports.py +0 -72
  101. schemathesis/cli/hooks.py +0 -36
  102. schemathesis/contrib/__init__.py +0 -9
  103. schemathesis/contrib/openapi/__init__.py +0 -9
  104. schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
  105. schemathesis/engine/config.py +0 -59
  106. schemathesis/experimental/__init__.py +0 -72
  107. schemathesis/generation/targets.py +0 -69
  108. schemathesis-4.0.0a10.dist-info/RECORD +0 -153
  109. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/WHEEL +0 -0
  110. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/entry_points.txt +0 -0
  111. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/licenses/LICENSE +0 -0
@@ -1,16 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import random
4
- from dataclasses import dataclass, field
5
- from typing import TYPE_CHECKING
6
4
 
7
- from schemathesis.generation.modes import GenerationMode as GenerationMode
5
+ from schemathesis.generation.modes import GenerationMode
8
6
 
9
- if TYPE_CHECKING:
10
- from hypothesis.strategies import SearchStrategy
11
-
12
-
13
- DEFAULT_GENERATOR_MODES = (GenerationMode.default(),)
7
+ __all__ = [
8
+ "GenerationMode",
9
+ "generate_random_case_id",
10
+ ]
14
11
 
15
12
 
16
13
  CASE_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
@@ -26,28 +23,3 @@ def generate_random_case_id(length: int = 6) -> str:
26
23
  number, rem = divmod(number, BASE)
27
24
  output += CASE_ID_ALPHABET[rem]
28
25
  return output
29
-
30
-
31
- @dataclass
32
- class HeaderConfig:
33
- """Configuration for generating headers."""
34
-
35
- strategy: SearchStrategy[str] | None = None
36
-
37
-
38
- @dataclass
39
- class GenerationConfig:
40
- """Holds various configuration options relevant for data generation."""
41
-
42
- modes: list[GenerationMode] = field(default_factory=lambda: [GenerationMode.default()])
43
- # Allow generating `\x00` bytes in strings
44
- allow_x00: bool = True
45
- # Allowing using `null` for optional arguments in GraphQL queries
46
- graphql_allow_null: bool = True
47
- # Generate strings using the given codec
48
- codec: str | None = "utf-8"
49
- # Whether to generate security parameters
50
- with_security_parameters: bool = True
51
- # Header generation configuration
52
- headers: HeaderConfig = field(default_factory=HeaderConfig)
53
- unexpected_methods: set[str] | None = None
@@ -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,8 +165,9 @@ 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
  )
170
+ message += "\n\n"
168
171
  raise FailureGroup(_failures, message) from None
169
172
 
170
173
  def call_and_validate(
@@ -22,6 +22,7 @@ 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
@@ -120,7 +121,7 @@ class CoverageContext:
120
121
  path: list[str | int] | None = None,
121
122
  ) -> None:
122
123
  self.location = location
123
- self.generation_modes = generation_modes if generation_modes is not None else GenerationMode.all()
124
+ self.generation_modes = generation_modes if generation_modes is not None else list(GenerationMode)
124
125
  self.path = path or []
125
126
 
126
127
  @contextmanager
@@ -152,6 +153,8 @@ class CoverageContext:
152
153
  def is_valid_for_location(self, value: Any) -> bool:
153
154
  if self.location in ("header", "cookie") and isinstance(value, str):
154
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)
155
158
  return True
156
159
 
157
160
  def generate_from(self, strategy: st.SearchStrategy) -> Any:
@@ -252,6 +255,24 @@ def _to_hashable_key(value: T, _encode: Callable = _encode) -> tuple[type, str |
252
255
  return (type(value), value)
253
256
 
254
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
+
255
276
  def _cover_positive_for_type(
256
277
  ctx: CoverageContext, schema: dict, ty: str | None
257
278
  ) -> Generator[GeneratedValue, None, None]:
@@ -325,10 +346,10 @@ def _ignore_unfixable(
325
346
 
326
347
 
327
348
  def cover_schema_iter(
328
- ctx: CoverageContext, schema: dict | bool, seen: set[Any | tuple[type, str]] | None = None
349
+ ctx: CoverageContext, schema: dict | bool, seen: HashSet | None = None
329
350
  ) -> Generator[GeneratedValue, None, None]:
330
351
  if seen is None:
331
- seen = set()
352
+ seen = HashSet()
332
353
  if isinstance(schema, bool):
333
354
  types = ["null", "boolean", "string", "number", "array", "object"]
334
355
  schema = {}
@@ -351,12 +372,9 @@ def cover_schema_iter(
351
372
  yield from _negative_enum(ctx, value, seen)
352
373
  elif key == "const":
353
374
  for value_ in _negative_enum(ctx, [value], seen):
354
- k = _to_hashable_key(value_.value)
355
- if k not in seen:
356
- yield value_
357
- seen.add(k)
375
+ yield value_
358
376
  elif key == "type":
359
- yield from _negative_type(ctx, seen, value)
377
+ yield from _negative_type(ctx, value, seen)
360
378
  elif key == "properties":
361
379
  template = template or ctx.generate_from_schema(_get_template_schema(schema, "object"))
362
380
  yield from _negative_properties(ctx, template, value)
@@ -373,37 +391,30 @@ def cover_schema_iter(
373
391
  yield from _negative_format(ctx, schema, value)
374
392
  elif key == "maximum":
375
393
  next = value + 1
376
- if next not in seen:
394
+ if seen.insert(next):
377
395
  yield NegativeValue(next, description="Value greater than maximum", location=ctx.current_path)
378
- seen.add(next)
379
396
  elif key == "minimum":
380
397
  next = value - 1
381
- if next not in seen:
398
+ if seen.insert(next):
382
399
  yield NegativeValue(next, description="Value smaller than minimum", location=ctx.current_path)
383
- seen.add(next)
384
- elif key == "exclusiveMaximum" or key == "exclusiveMinimum" and value not in seen:
400
+ elif key == "exclusiveMaximum" or key == "exclusiveMinimum" and seen.insert(value):
385
401
  verb = "greater" if key == "exclusiveMaximum" else "smaller"
386
402
  limit = "maximum" if key == "exclusiveMaximum" else "minimum"
387
403
  yield NegativeValue(value, description=f"Value {verb} than {limit}", location=ctx.current_path)
388
- seen.add(value)
389
404
  elif key == "multipleOf":
390
405
  for value_ in _negative_multiple_of(ctx, schema, value):
391
- k = _to_hashable_key(value_.value)
392
- if k not in seen:
406
+ if seen.insert(value_.value):
393
407
  yield value_
394
- seen.add(k)
395
408
  elif key == "minLength" and 0 < value < INTERNAL_BUFFER_SIZE:
396
409
  if value == 1:
397
410
  # In this case, the only possible negative string is an empty one
398
411
  # The `pattern` value may require an non-empty one and the generation will fail
399
412
  # However, it is fine to violate `pattern` here as it is negative string generation anyway
400
413
  value = ""
401
- k = _to_hashable_key(value)
402
- if k not in seen:
414
+ if seen.insert(value):
403
415
  yield NegativeValue(
404
416
  value, description="String smaller than minLength", location=ctx.current_path
405
417
  )
406
- seen.add(k)
407
418
  else:
408
419
  with suppress(InvalidArgument):
409
420
  min_length = max_length = value - 1
@@ -420,12 +431,10 @@ def cover_schema_iter(
420
431
  value = ctx.generate_from_schema(new_schema)
421
432
  else:
422
433
  value = ctx.generate_from_schema(new_schema)
423
- k = _to_hashable_key(value)
424
- if k not in seen:
434
+ if seen.insert(value):
425
435
  yield NegativeValue(
426
436
  value, description="String smaller than minLength", location=ctx.current_path
427
437
  )
428
- seen.add(k)
429
438
  elif key == "maxLength" and value < INTERNAL_BUFFER_SIZE:
430
439
  try:
431
440
  min_length = max_length = value + 1
@@ -447,12 +456,10 @@ def cover_schema_iter(
447
456
  value = ctx.generate_from_schema(new_schema)
448
457
  else:
449
458
  value = ctx.generate_from_schema(new_schema)
450
- k = _to_hashable_key(value)
451
- if k not in seen:
459
+ if seen.insert(value):
452
460
  yield NegativeValue(
453
461
  value, description="String larger than maxLength", location=ctx.current_path
454
462
  )
455
- seen.add(k)
456
463
  except (InvalidArgument, Unsatisfiable):
457
464
  pass
458
465
  elif key == "uniqueItems" and value:
@@ -484,31 +491,27 @@ def cover_schema_iter(
484
491
 
485
492
  # Extend the array to be of length value + 1 by repeating its own elements
486
493
  diff = value + 1 - len(array_value)
487
- if diff > 0:
494
+ if diff > 0 and array_value:
488
495
  array_value += (
489
496
  array_value * (diff // len(array_value)) + array_value[: diff % len(array_value)]
490
497
  )
491
- k = _to_hashable_key(array_value)
492
- if k not in seen:
498
+ if seen.insert(array_value):
493
499
  yield NegativeValue(
494
500
  array_value,
495
501
  description="Array with more items than allowed by maxItems",
496
502
  location=ctx.current_path,
497
503
  )
498
- seen.add(k)
499
504
  else:
500
505
  try:
501
506
  # Force the array to have one more item than allowed
502
507
  new_schema = {**schema, "minItems": value + 1, "maxItems": value + 1, "type": "array"}
503
508
  array_value = ctx.generate_from_schema(new_schema)
504
- k = _to_hashable_key(array_value)
505
- if k not in seen:
509
+ if seen.insert(array_value):
506
510
  yield NegativeValue(
507
511
  array_value,
508
512
  description="Array with more items than allowed by maxItems",
509
513
  location=ctx.current_path,
510
514
  )
511
- seen.add(k)
512
515
  except (InvalidArgument, Unsatisfiable):
513
516
  pass
514
517
  elif key == "minItems" and isinstance(value, int) and value > 0:
@@ -516,14 +519,12 @@ def cover_schema_iter(
516
519
  # Force the array to have one less item than the minimum
517
520
  new_schema = {**schema, "minItems": value - 1, "maxItems": value - 1, "type": "array"}
518
521
  array_value = ctx.generate_from_schema(new_schema)
519
- k = _to_hashable_key(array_value)
520
- if k not in seen:
522
+ if seen.insert(array_value):
521
523
  yield NegativeValue(
522
524
  array_value,
523
525
  description="Array with fewer items than allowed by minItems",
524
526
  location=ctx.current_path,
525
527
  )
526
- seen.add(k)
527
528
  except (InvalidArgument, Unsatisfiable):
528
529
  pass
529
530
  elif (
@@ -584,6 +585,22 @@ def _get_template_schema(schema: dict, ty: str) -> dict:
584
585
  return {**schema, "type": ty}
585
586
 
586
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
+
587
604
  def _positive_string(ctx: CoverageContext, schema: dict) -> Generator[GeneratedValue, None, None]:
588
605
  """Generate positive string values."""
589
606
  # Boundary and near boundary values
@@ -591,67 +608,90 @@ def _positive_string(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
591
608
  if min_length == 0:
592
609
  min_length = None
593
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
+
594
617
  example = schema.get("example")
595
618
  examples = schema.get("examples")
596
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
+
597
625
  if example or examples or default:
598
- 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
599
629
  yield PositiveValue(example, description="Example value")
600
630
  if examples:
601
631
  for example in examples:
602
- 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
603
634
  yield PositiveValue(example, description="Example value")
604
635
  if (
605
636
  default
606
637
  and not (example is not None and default == example)
607
638
  and not (examples is not None and any(default == ex for ex in examples))
608
639
  and ctx.is_valid_for_location(default)
640
+ and seen_values.insert(default)
609
641
  ):
642
+ has_valid_example = True
610
643
  yield PositiveValue(default, description="Default value")
611
- elif not min_length and not max_length:
612
- # Default positive value
613
- yield PositiveValue(ctx.generate_from_schema(schema), description="Valid string")
614
- elif "pattern" in schema:
615
- yield PositiveValue(ctx.generate_from_schema(schema), description="Valid string")
616
-
617
- seen = set()
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")
618
655
 
619
656
  if min_length is not None and min_length < INTERNAL_BUFFER_SIZE:
620
657
  # Exactly the minimum length
621
- yield PositiveValue(
622
- ctx.generate_from_schema({**schema, "maxLength": min_length}), description="Minimum length string"
623
- )
624
- 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")
625
664
 
626
665
  # One character more than minimum if possible
627
666
  larger = min_length + 1
628
- if larger < INTERNAL_BUFFER_SIZE and larger not in seen and (not max_length or larger <= max_length):
629
- yield PositiveValue(
630
- ctx.generate_from_schema({**schema, "minLength": larger, "maxLength": larger}),
631
- description="Near-boundary length string",
632
- )
633
- 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")
634
673
 
635
674
  if max_length is not None:
636
675
  # Exactly the maximum length
637
- if max_length < INTERNAL_BUFFER_SIZE and max_length not in seen:
638
- yield PositiveValue(
639
- ctx.generate_from_schema({**schema, "minLength": max_length}), description="Maximum length string"
640
- )
641
- 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")
642
682
 
643
683
  # One character less than maximum if possible
644
684
  smaller = max_length - 1
685
+ key = (smaller, smaller)
645
686
  if (
646
687
  smaller < INTERNAL_BUFFER_SIZE
647
- and smaller not in seen
688
+ and key not in seen_constraints
648
689
  and (smaller > 0 and (min_length is None or smaller >= min_length))
649
690
  ):
650
- yield PositiveValue(
651
- ctx.generate_from_schema({**schema, "minLength": smaller, "maxLength": smaller}),
652
- description="Near-boundary length string",
653
- )
654
- 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")
655
695
 
656
696
 
657
697
  def closest_multiple_greater_than(y: int, x: int) -> int:
@@ -678,23 +718,26 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
678
718
  examples = schema.get("examples")
679
719
  default = schema.get("default")
680
720
 
721
+ seen = HashSet()
722
+
681
723
  if example or examples or default:
682
- if example:
724
+ if example and seen.insert(example):
683
725
  yield PositiveValue(example, description="Example value")
684
726
  if examples:
685
727
  for example in examples:
686
- yield PositiveValue(example, description="Example value")
728
+ if seen.insert(example):
729
+ yield PositiveValue(example, description="Example value")
687
730
  if (
688
731
  default
689
732
  and not (example is not None and default == example)
690
733
  and not (examples is not None and any(default == ex for ex in examples))
734
+ and seen.insert(default)
691
735
  ):
692
736
  yield PositiveValue(default, description="Default value")
693
737
  elif not minimum and not maximum:
694
- # Default positive value
695
- yield PositiveValue(ctx.generate_from_schema(schema), description="Valid number")
696
-
697
- seen = set()
738
+ value = ctx.generate_from_schema(schema)
739
+ seen.insert(value)
740
+ yield PositiveValue(value, description="Valid number")
698
741
 
699
742
  if minimum is not None:
700
743
  # Exactly the minimum
@@ -702,16 +745,15 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
702
745
  smallest = closest_multiple_greater_than(minimum, multiple_of)
703
746
  else:
704
747
  smallest = minimum
705
- seen.add(smallest)
706
- yield PositiveValue(smallest, description="Minimum value")
748
+ if seen.insert(smallest):
749
+ yield PositiveValue(smallest, description="Minimum value")
707
750
 
708
751
  # One more than minimum if possible
709
752
  if multiple_of is not None:
710
753
  larger = smallest + multiple_of
711
754
  else:
712
755
  larger = minimum + 1
713
- if larger not in seen and (not maximum or larger <= maximum):
714
- seen.add(larger)
756
+ if (not maximum or larger <= maximum) and seen.insert(larger):
715
757
  yield PositiveValue(larger, description="Near-boundary number")
716
758
 
717
759
  if maximum is not None:
@@ -720,8 +762,7 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
720
762
  largest = maximum - (maximum % multiple_of)
721
763
  else:
722
764
  largest = maximum
723
- if largest not in seen:
724
- seen.add(largest)
765
+ if seen.insert(largest):
725
766
  yield PositiveValue(largest, description="Maximum value")
726
767
 
727
768
  # One less than maximum if possible
@@ -729,57 +770,53 @@ def _positive_number(ctx: CoverageContext, schema: dict) -> Generator[GeneratedV
729
770
  smaller = largest - multiple_of
730
771
  else:
731
772
  smaller = maximum - 1
732
- if smaller not in seen and (smaller > 0 and (minimum is None or smaller >= minimum)):
733
- seen.add(smaller)
773
+ if (smaller > 0 and (minimum is None or smaller >= minimum)) and seen.insert(smaller):
734
774
  yield PositiveValue(smaller, description="Near-boundary number")
735
775
 
736
776
 
737
777
  def _positive_array(ctx: CoverageContext, schema: dict, template: list) -> Generator[GeneratedValue, None, None]:
738
- seen = set()
739
778
  example = schema.get("example")
740
779
  examples = schema.get("examples")
741
780
  default = schema.get("default")
742
781
 
782
+ seen = HashSet()
783
+ seen_constraints: set[tuple] = set()
784
+
743
785
  if example or examples or default:
744
- if example:
745
- seen.add(_to_hashable_key(example))
786
+ if example and seen.insert(example):
746
787
  yield PositiveValue(example, description="Example value")
747
788
  if examples:
748
789
  for example in examples:
749
- seen.add(_to_hashable_key(example))
750
- yield PositiveValue(example, description="Example value")
790
+ if seen.insert(example):
791
+ yield PositiveValue(example, description="Example value")
751
792
  if (
752
793
  default
753
794
  and not (example is not None and default == example)
754
795
  and not (examples is not None and any(default == ex for ex in examples))
796
+ and seen.insert(default)
755
797
  ):
756
- seen.add(_to_hashable_key(default))
757
798
  yield PositiveValue(default, description="Default value")
758
- else:
799
+ elif seen.insert(template):
759
800
  yield PositiveValue(template, description="Valid array")
760
- seen.add(_to_hashable_key(template))
761
801
 
762
802
  # Boundary and near-boundary sizes
763
803
  min_items = schema.get("minItems")
764
804
  max_items = schema.get("maxItems")
765
805
  if min_items is not None:
766
806
  # Do not generate an array with `minItems` length, because it is already covered by `template`
767
-
768
807
  # One item more than minimum if possible
769
808
  larger = min_items + 1
770
- 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)
771
811
  value = ctx.generate_from_schema({**schema, "minItems": larger, "maxItems": larger})
772
- key = _to_hashable_key(value)
773
- if key not in seen:
774
- seen.add(key)
812
+ if seen.insert(value):
775
813
  yield PositiveValue(value, description="Near-boundary items array")
776
814
 
777
815
  if max_items is not None:
778
- if max_items < INTERNAL_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)
779
818
  value = ctx.generate_from_schema({**schema, "minItems": max_items})
780
- key = _to_hashable_key(value)
781
- if key not in seen:
782
- seen.add(key)
819
+ if seen.insert(value):
783
820
  yield PositiveValue(value, description="Maximum items array")
784
821
 
785
822
  # One item smaller than maximum if possible
@@ -787,13 +824,11 @@ def _positive_array(ctx: CoverageContext, schema: dict, template: list) -> Gener
787
824
  if (
788
825
  smaller < INTERNAL_BUFFER_SIZE
789
826
  and smaller > 0
790
- and smaller not in seen
791
827
  and (min_items is None or smaller >= min_items)
828
+ and smaller not in seen_constraints
792
829
  ):
793
830
  value = ctx.generate_from_schema({**schema, "minItems": smaller, "maxItems": smaller})
794
- key = _to_hashable_key(value)
795
- if key not in seen:
796
- seen.add(key)
831
+ if seen.insert(value):
797
832
  yield PositiveValue(value, description="Near-boundary items array")
798
833
 
799
834
  if "items" in schema and "enum" in schema["items"] and isinstance(schema["items"]["enum"], list) and max_items != 0:
@@ -801,9 +836,7 @@ def _positive_array(ctx: CoverageContext, schema: dict, template: list) -> Gener
801
836
  length = min_items or 1
802
837
  for variant in schema["items"]["enum"]:
803
838
  value = [variant] * length
804
- key = _to_hashable_key(value)
805
- if key not in seen:
806
- seen.add(key)
839
+ if seen.insert(value):
807
840
  yield PositiveValue(value, description="Enum value from available for items array")
808
841
  elif min_items is None and max_items is None and "items" in schema and isinstance(schema["items"], dict):
809
842
  # Otherwise only an empty array is generated
@@ -850,16 +883,14 @@ def _positive_object(ctx: CoverageContext, schema: dict, template: dict) -> Gene
850
883
  if set(properties) != required:
851
884
  only_required = {k: v for k, v in template.items() if k in required}
852
885
  yield PositiveValue(only_required, description="Object with only required properties")
853
- seen = set()
886
+ seen = HashSet()
854
887
  for name, sub_schema in properties.items():
855
- seen.add(_to_hashable_key(template.get(name)))
888
+ seen.insert(template.get(name))
856
889
  for new in cover_schema_iter(ctx, sub_schema):
857
- key = _to_hashable_key(new.value)
858
- if key not in seen:
890
+ if seen.insert(new.value):
859
891
  yield PositiveValue(
860
892
  {**template, name: new.value}, description=f"Object with valid '{name}' value: {new.description}"
861
893
  )
862
- seen.add(key)
863
894
  seen.clear()
864
895
 
865
896
 
@@ -868,14 +899,11 @@ def select_combinations(optional: list[str]) -> Iterator[tuple[str, ...]]:
868
899
  yield next(combinations(optional, size))
869
900
 
870
901
 
871
- def _negative_enum(
872
- ctx: CoverageContext, value: list, seen: set[Any | tuple[type, str]]
873
- ) -> Generator[GeneratedValue, None, None]:
902
+ def _negative_enum(ctx: CoverageContext, value: list, seen: HashSet) -> Generator[GeneratedValue, None, None]:
874
903
  def is_not_in_value(x: Any) -> bool:
875
904
  if x in value or not ctx.is_valid_for_location(x):
876
905
  return False
877
- _hashed = _to_hashable_key(x)
878
- return _hashed not in seen
906
+ return seen.insert(x)
879
907
 
880
908
  strategy = (
881
909
  st.text(alphabet=st.characters(min_codepoint=65, max_codepoint=122, categories=["L"]), min_size=3)
@@ -883,10 +911,11 @@ def _negative_enum(
883
911
  | st.booleans()
884
912
  | NUMERIC_STRATEGY
885
913
  ).filter(is_not_in_value)
886
- value = ctx.generate_from(strategy)
887
- yield NegativeValue(value, description="Invalid enum value", location=ctx.current_path)
888
- hashed = _to_hashable_key(value)
889
- seen.add(hashed)
914
+ yield NegativeValue(
915
+ ctx.generate_from(strategy),
916
+ description="Invalid enum value",
917
+ location=ctx.current_path,
918
+ )
890
919
 
891
920
 
892
921
  def _negative_properties(
@@ -1015,7 +1044,11 @@ def _is_non_integer_float(x: float) -> bool:
1015
1044
  return x != int(x)
1016
1045
 
1017
1046
 
1018
- 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]:
1019
1052
  if isinstance(ty, str):
1020
1053
  types = [ty]
1021
1054
  else:
@@ -1027,11 +1060,8 @@ def _negative_type(ctx: CoverageContext, seen: set, ty: str | list[str]) -> Gene
1027
1060
  strategies["number"] = FLOAT_STRATEGY.filter(_is_non_integer_float)
1028
1061
  for strategy in strategies.values():
1029
1062
  value = ctx.generate_from(strategy)
1030
- hashed = _to_hashable_key(value)
1031
- if hashed in seen:
1032
- continue
1033
- yield NegativeValue(value, description="Incorrect type", location=ctx.current_path)
1034
- seen.add(hashed)
1063
+ if seen.insert(value):
1064
+ yield NegativeValue(value, description="Incorrect type", location=ctx.current_path)
1035
1065
 
1036
1066
 
1037
1067
  def push_examples_to_properties(schema: dict[str, Any]) -> None: