schemathesis 4.0.26__py3-none-any.whl → 4.1.1__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 (37) hide show
  1. schemathesis/cli/commands/run/__init__.py +8 -0
  2. schemathesis/cli/commands/run/handlers/cassettes.py +1 -0
  3. schemathesis/cli/commands/run/handlers/output.py +27 -17
  4. schemathesis/config/_operations.py +5 -0
  5. schemathesis/config/_phases.py +43 -5
  6. schemathesis/config/_projects.py +18 -0
  7. schemathesis/config/schema.json +31 -0
  8. schemathesis/core/__init__.py +1 -0
  9. schemathesis/core/failures.py +2 -1
  10. schemathesis/engine/context.py +39 -4
  11. schemathesis/engine/core.py +30 -9
  12. schemathesis/engine/events.py +12 -2
  13. schemathesis/engine/observations.py +42 -0
  14. schemathesis/engine/phases/__init__.py +5 -0
  15. schemathesis/engine/phases/probes.py +9 -3
  16. schemathesis/engine/phases/stateful/_executor.py +9 -1
  17. schemathesis/engine/phases/unit/__init__.py +1 -0
  18. schemathesis/engine/phases/unit/_executor.py +13 -0
  19. schemathesis/filters.py +4 -0
  20. schemathesis/generation/case.py +1 -1
  21. schemathesis/generation/coverage.py +16 -2
  22. schemathesis/generation/hypothesis/builder.py +14 -1
  23. schemathesis/hooks.py +40 -14
  24. schemathesis/openapi/loaders.py +1 -1
  25. schemathesis/pytest/plugin.py +6 -0
  26. schemathesis/schemas.py +9 -1
  27. schemathesis/specs/openapi/_hypothesis.py +16 -3
  28. schemathesis/specs/openapi/checks.py +4 -1
  29. schemathesis/specs/openapi/formats.py +15 -2
  30. schemathesis/specs/openapi/schemas.py +26 -6
  31. schemathesis/specs/openapi/stateful/inference.py +250 -0
  32. schemathesis/transport/requests.py +3 -0
  33. {schemathesis-4.0.26.dist-info → schemathesis-4.1.1.dist-info}/METADATA +3 -2
  34. {schemathesis-4.0.26.dist-info → schemathesis-4.1.1.dist-info}/RECORD +37 -35
  35. {schemathesis-4.0.26.dist-info → schemathesis-4.1.1.dist-info}/WHEEL +0 -0
  36. {schemathesis-4.0.26.dist-info → schemathesis-4.1.1.dist-info}/entry_points.txt +0 -0
  37. {schemathesis-4.0.26.dist-info → schemathesis-4.1.1.dist-info}/licenses/LICENSE +0 -0
@@ -77,6 +77,11 @@ class Phase:
77
77
  """Determine if phase should run based on context & configuration."""
78
78
  return self.is_enabled and not ctx.has_to_stop
79
79
 
80
+ def enable(self) -> None:
81
+ """Enable this test phase."""
82
+ self.is_enabled = True
83
+ self.skip_reason = None
84
+
80
85
 
81
86
  def execute(ctx: EngineContext, phase: Phase) -> EventGenerator:
82
87
  from urllib3.exceptions import InsecureRequestWarning
@@ -42,9 +42,15 @@ def execute(ctx: EngineContext, phase: Phase) -> EventGenerator:
42
42
  for result in probes:
43
43
  if isinstance(result.probe, NullByteInHeader) and result.is_failure:
44
44
  from schemathesis.specs.openapi import formats
45
- from schemathesis.specs.openapi.formats import HEADER_FORMAT, header_values
46
-
47
- formats.register(HEADER_FORMAT, header_values(exclude_characters="\n\r\x00"))
45
+ from schemathesis.specs.openapi.formats import (
46
+ DEFAULT_HEADER_EXCLUDE_CHARACTERS,
47
+ HEADER_FORMAT,
48
+ header_values,
49
+ )
50
+
51
+ formats.register(
52
+ HEADER_FORMAT, header_values(exclude_characters=DEFAULT_HEADER_EXCLUDE_CHARACTERS + "\x00")
53
+ )
48
54
  payload = Ok(ProbePayload(probes=probes))
49
55
  yield events.PhaseFinished(phase=phase, status=status, payload=payload)
50
56
 
@@ -330,10 +330,18 @@ def validate_response(
330
330
  if stateful_ctx.is_seen_in_suite(failure) or stateful_ctx.is_seen_in_run(failure):
331
331
  return
332
332
  failure_data = recorder.find_failure_data(parent_id=case.id, failure=failure)
333
+
334
+ # Collect the whole chain of cURL commands
335
+ commands = []
336
+ parent = recorder.find_parent(case_id=failure_data.case.id)
337
+ while parent is not None:
338
+ commands.append(parent.as_curl_command(headers=failure_data.headers, verify=failure_data.verify))
339
+ parent = recorder.find_parent(case_id=parent.id)
340
+ commands.append(failure_data.case.as_curl_command(headers=failure_data.headers, verify=failure_data.verify))
333
341
  recorder.record_check_failure(
334
342
  name=name,
335
343
  case_id=failure_data.case.id,
336
- code_sample=failure_data.case.as_curl_command(headers=failure_data.headers, verify=failure_data.verify),
344
+ code_sample="\n".join(commands),
337
345
  failure=failure,
338
346
  )
339
347
  control.count_failure()
@@ -76,6 +76,7 @@ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
76
76
  status = event.status
77
77
  if event.status in (Status.ERROR, Status.FAILURE):
78
78
  engine.control.count_failure()
79
+ engine.record_observations(event.recorder)
79
80
  if isinstance(event, events.Interrupted) or engine.is_interrupted:
80
81
  status = Status.INTERRUPTED
81
82
  engine.stop()
@@ -49,6 +49,7 @@ from schemathesis.generation.case import Case
49
49
  from schemathesis.generation.hypothesis.builder import (
50
50
  InvalidHeadersExampleMark,
51
51
  InvalidRegexMark,
52
+ MissingPathParameters,
52
53
  NonSerializableMark,
53
54
  UnsatisfiableExampleMark,
54
55
  )
@@ -73,6 +74,7 @@ def run_test(
73
74
  yield scenario_started
74
75
  errors: list[Exception] = []
75
76
  skip_reason = None
77
+ error: Exception
76
78
  test_start_time = time.monotonic()
77
79
  recorder = ScenarioRecorder(label=operation.label)
78
80
  state = TestingState()
@@ -227,11 +229,15 @@ def run_test(
227
229
  and any(check.status == Status.FAILURE for checks in recorder.checks.values() for check in checks)
228
230
  ):
229
231
  status = Status.FAILURE
232
+
233
+ # Check for various errors during generation (tests may still have been generated)
234
+
230
235
  if UnsatisfiableExampleMark.is_set(test_function):
231
236
  status = Status.ERROR
232
237
  yield non_fatal_error(
233
238
  hypothesis.errors.Unsatisfiable("Failed to generate test cases from examples for this API operation")
234
239
  )
240
+
235
241
  non_serializable = NonSerializableMark.get(test_function)
236
242
  if non_serializable is not None and status != Status.ERROR:
237
243
  status = Status.ERROR
@@ -248,10 +254,17 @@ def run_test(
248
254
  if invalid_regex is not None and status != Status.ERROR:
249
255
  status = Status.ERROR
250
256
  yield non_fatal_error(InvalidRegexPattern.from_schema_error(invalid_regex, from_examples=True))
257
+
251
258
  invalid_headers = InvalidHeadersExampleMark.get(test_function)
252
259
  if invalid_headers:
253
260
  status = Status.ERROR
254
261
  yield non_fatal_error(InvalidHeadersExample.from_headers(invalid_headers))
262
+
263
+ missing_path_parameters = MissingPathParameters.get(test_function)
264
+ if missing_path_parameters:
265
+ status = Status.ERROR
266
+ yield non_fatal_error(missing_path_parameters)
267
+
255
268
  for error in deduplicate_errors(errors):
256
269
  yield non_fatal_error(error)
257
270
 
schemathesis/filters.py CHANGED
@@ -175,6 +175,10 @@ class FilterSet:
175
175
  """Whether the filter set does not contain any filters."""
176
176
  return not self._includes and not self._excludes
177
177
 
178
+ def clear(self) -> None:
179
+ self._includes.clear()
180
+ self._excludes.clear()
181
+
178
182
  def include(
179
183
  self,
180
184
  func: MatcherFunc | None = None,
@@ -200,7 +200,7 @@ class Case:
200
200
 
201
201
  """
202
202
  hook_context = HookContext(operation=self.operation)
203
- dispatch("before_call", hook_context, self, **kwargs)
203
+ dispatch("before_call", hook_context, self, _with_dual_style_kwargs=True, **kwargs)
204
204
  if self.operation.app is not None:
205
205
  kwargs.setdefault("app", self.operation.app)
206
206
  if "app" in kwargs:
@@ -6,7 +6,18 @@ from contextlib import contextmanager, suppress
6
6
  from dataclasses import dataclass
7
7
  from functools import lru_cache, partial
8
8
  from itertools import combinations
9
- from json.encoder import _make_iterencode, c_make_encoder, encode_basestring_ascii # type: ignore
9
+
10
+ try:
11
+ from json.encoder import _make_iterencode # type: ignore[attr-defined]
12
+ except ImportError:
13
+ _make_iterencode = None
14
+
15
+ try:
16
+ from json.encoder import c_make_encoder # type: ignore[attr-defined]
17
+ except ImportError:
18
+ c_make_encoder = None
19
+
20
+ from json.encoder import JSONEncoder, encode_basestring_ascii # type: ignore
10
21
  from typing import Any, Callable, Generator, Iterator, TypeVar, cast
11
22
  from urllib.parse import quote_plus
12
23
 
@@ -285,10 +296,13 @@ T = TypeVar("T")
285
296
 
286
297
  if c_make_encoder is not None:
287
298
  _iterencode = c_make_encoder(None, None, encode_basestring_ascii, None, ":", ",", True, False, False)
288
- else:
299
+ elif _make_iterencode is not None:
289
300
  _iterencode = _make_iterencode(
290
301
  None, None, encode_basestring_ascii, None, float.__repr__, ":", ",", True, False, True
291
302
  )
303
+ else:
304
+ encoder = JSONEncoder(skipkeys=False, sort_keys=False, indent=None, separators=(":", ","))
305
+ _iterencode = encoder.iterencode
292
306
 
293
307
 
294
308
  def _encode(o: Any) -> str:
@@ -20,7 +20,7 @@ from requests.models import CaseInsensitiveDict
20
20
  from schemathesis import auths
21
21
  from schemathesis.auths import AuthStorage, AuthStorageMark
22
22
  from schemathesis.config import GenerationConfig, ProjectConfig
23
- from schemathesis.core import NOT_SET, NotSet, SpecificationFeature, media_types
23
+ from schemathesis.core import INJECTED_PATH_PARAMETER_KEY, NOT_SET, NotSet, SpecificationFeature, media_types
24
24
  from schemathesis.core.errors import InvalidSchema, SerializationNotPossible
25
25
  from schemathesis.core.marks import Mark
26
26
  from schemathesis.core.transforms import deepclone
@@ -185,6 +185,18 @@ def create_test(
185
185
  generation_config=generation,
186
186
  )
187
187
 
188
+ injected_path_parameter_names = [
189
+ parameter.name
190
+ for parameter in operation.path_parameters
191
+ if parameter.definition.get(INJECTED_PATH_PARAMETER_KEY)
192
+ ]
193
+ if injected_path_parameter_names:
194
+ names = ", ".join(f"'{name}'" for name in injected_path_parameter_names)
195
+ plural = "s" if len(injected_path_parameter_names) > 1 else ""
196
+ verb = "are" if len(injected_path_parameter_names) > 1 else "is"
197
+ error = InvalidSchema(f"Path parameter{plural} {names} {verb} not defined")
198
+ MissingPathParameters.set(hypothesis_test, error)
199
+
188
200
  setattr(hypothesis_test, SETTINGS_ATTRIBUTE_NAME, settings)
189
201
 
190
202
  return hypothesis_test
@@ -902,3 +914,4 @@ UnsatisfiableExampleMark = Mark[Unsatisfiable](attr_name="unsatisfiable_example"
902
914
  NonSerializableMark = Mark[SerializationNotPossible](attr_name="non_serializable")
903
915
  InvalidRegexMark = Mark[SchemaError](attr_name="invalid_regex")
904
916
  InvalidHeadersExampleMark = Mark[dict[str, str]](attr_name="invalid_example_header")
917
+ MissingPathParameters = Mark[InvalidSchema](attr_name="missing_path_parameters")
schemathesis/hooks.py CHANGED
@@ -2,10 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  import inspect
4
4
  from collections import defaultdict
5
+ from contextlib import contextmanager
5
6
  from dataclasses import dataclass, field
6
7
  from enum import Enum, unique
7
- from functools import partial
8
- from typing import TYPE_CHECKING, Any, Callable, ClassVar, cast
8
+ from functools import lru_cache, partial
9
+ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generator, cast
9
10
 
10
11
  from schemathesis.core.marks import Mark
11
12
  from schemathesis.core.transport import Response
@@ -54,18 +55,28 @@ def to_filterable_hook(dispatcher: HookDispatcher) -> Callable:
54
55
  filter_used = False
55
56
  filter_set = FilterSet()
56
57
 
58
+ @contextmanager
59
+ def _reset_on_error() -> Generator:
60
+ try:
61
+ yield
62
+ except Exception:
63
+ filter_set.clear()
64
+ raise
65
+
57
66
  def register(hook: str | Callable) -> Callable:
58
67
  nonlocal filter_set
59
68
 
60
69
  if filter_used:
61
- validate_filterable_hook(hook)
70
+ with _reset_on_error():
71
+ validate_filterable_hook(hook)
62
72
 
63
73
  if isinstance(hook, str):
64
74
 
65
75
  def decorator(func: Callable) -> Callable:
66
76
  hook_name = cast(str, hook)
67
77
  if filter_used:
68
- validate_filterable_hook(hook)
78
+ with _reset_on_error():
79
+ validate_filterable_hook(hook)
69
80
  func.filter_set = filter_set # type: ignore[attr-defined]
70
81
  return dispatcher.register_hook_with_name(func, hook_name)
71
82
 
@@ -86,13 +97,15 @@ def to_filterable_hook(dispatcher: HookDispatcher) -> Callable:
86
97
  nonlocal filter_used
87
98
 
88
99
  filter_used = True
89
- filter_set.include(*args, **kwargs)
100
+ with _reset_on_error():
101
+ filter_set.include(*args, **kwargs)
90
102
 
91
103
  def exclude(*args: Any, **kwargs: Any) -> None:
92
104
  nonlocal filter_used
93
105
 
94
106
  filter_used = True
95
- filter_set.exclude(*args, **kwargs)
107
+ with _reset_on_error():
108
+ filter_set.exclude(*args, **kwargs)
96
109
 
97
110
  attach_filter_chain(target, "apply_to", include)
98
111
  attach_filter_chain(target, "skip_for", exclude)
@@ -113,11 +126,9 @@ class HookDispatcher:
113
126
  _hooks: defaultdict[str, list[Callable]] = field(default_factory=lambda: defaultdict(list))
114
127
  _specs: ClassVar[dict[str, RegisteredHook]] = {}
115
128
 
116
- def __post_init__(self) -> None:
117
- self.hook = to_filterable_hook(self) # type: ignore[method-assign]
118
-
119
- def hook(self, hook: str | Callable) -> Callable:
120
- raise NotImplementedError
129
+ @property
130
+ def hook(self) -> Callable:
131
+ return to_filterable_hook(self)
121
132
 
122
133
  def apply(self, hook: Callable, *, name: str | None = None) -> Callable[[Callable], Callable]:
123
134
  """Register hook to run only on one test function.
@@ -201,6 +212,9 @@ class HookDispatcher:
201
212
  """Get a list of hooks registered for a name."""
202
213
  return self._hooks.get(name, [])
203
214
 
215
+ def get_all(self) -> dict[str, list[Callable]]:
216
+ return self._hooks
217
+
204
218
  def apply_to_container(
205
219
  self, strategy: st.SearchStrategy, container: str, context: HookContext
206
220
  ) -> st.SearchStrategy:
@@ -225,12 +239,18 @@ class HookDispatcher:
225
239
  strategy = strategy.flatmap(hook)
226
240
  return strategy
227
241
 
228
- def dispatch(self, name: str, context: HookContext, *args: Any, **kwargs: Any) -> None:
242
+ def dispatch(
243
+ self, name: str, context: HookContext, *args: Any, _with_dual_style_kwargs: bool = False, **kwargs: Any
244
+ ) -> None:
229
245
  """Run all hooks for the given name."""
230
246
  for hook in self.get_all_by_name(name):
231
247
  if _should_skip_hook(hook, context):
232
248
  continue
233
- hook(context, *args, **kwargs)
249
+ # NOTE: It is a backward-compat shim to support calling `before_call` with `**kwargs` OR with `kwargs`.
250
+ if _with_dual_style_kwargs and not has_var_keyword(hook):
251
+ hook(context, *args, kwargs)
252
+ else:
253
+ hook(context, *args, **kwargs)
234
254
 
235
255
  def unregister(self, hook: Callable) -> None:
236
256
  """Unregister a specific hook."""
@@ -246,6 +266,12 @@ class HookDispatcher:
246
266
  self._hooks = defaultdict(list)
247
267
 
248
268
 
269
+ @lru_cache(maxsize=16)
270
+ def has_var_keyword(hook: Callable) -> bool:
271
+ """Check if hook function accepts **kwargs."""
272
+ return any(p.kind == inspect.Parameter.VAR_KEYWORD for p in inspect.signature(hook).parameters.values())
273
+
274
+
249
275
  def _should_skip_hook(hook: Callable, ctx: HookContext) -> bool:
250
276
  filter_set = getattr(hook, "filter_set", None)
251
277
  return filter_set is not None and ctx.operation is not None and not filter_set.match(ctx)
@@ -349,7 +375,7 @@ def before_init_operation(context: HookContext, operation: APIOperation) -> None
349
375
 
350
376
 
351
377
  @HookDispatcher.register_spec([HookScope.GLOBAL])
352
- def before_call(context: HookContext, case: Case, **kwargs: Any) -> None:
378
+ def before_call(context: HookContext, case: Case, kwargs: dict[str, Any]) -> None:
353
379
  """Called before every network call in CLI tests.
354
380
 
355
381
  Use cases:
@@ -282,7 +282,7 @@ def load_content(content: str, content_type: ContentType) -> dict[str, Any]:
282
282
  # If type is unknown, try JSON first, then YAML
283
283
  try:
284
284
  return _load_json(content)
285
- except json.JSONDecodeError:
285
+ except LoaderError:
286
286
  return _load_yaml(content)
287
287
 
288
288
 
@@ -298,6 +298,7 @@ def pytest_pyfunc_call(pyfuncitem): # type:ignore
298
298
  from schemathesis.generation.hypothesis.builder import (
299
299
  InvalidHeadersExampleMark,
300
300
  InvalidRegexMark,
301
+ MissingPathParameters,
301
302
  NonSerializableMark,
302
303
  UnsatisfiableExampleMark,
303
304
  )
@@ -333,8 +334,13 @@ def pytest_pyfunc_call(pyfuncitem): # type:ignore
333
334
  pytest.skip(exc.args[0])
334
335
  except SchemaError as exc:
335
336
  raise InvalidRegexPattern.from_schema_error(exc, from_examples=False) from exc
337
+
336
338
  invalid_headers = InvalidHeadersExampleMark.get(pyfuncitem.obj)
337
339
  if invalid_headers is not None:
338
340
  raise InvalidHeadersExample.from_headers(invalid_headers) from None
341
+
342
+ missing_path_parameters = MissingPathParameters.get(pyfuncitem.obj)
343
+ if missing_path_parameters:
344
+ raise missing_path_parameters from None
339
345
  else:
340
346
  yield
schemathesis/schemas.py CHANGED
@@ -27,7 +27,7 @@ from schemathesis.generation.case import Case
27
27
  from schemathesis.generation.hypothesis import strategies
28
28
  from schemathesis.generation.hypothesis.given import GivenInput, given_proxy
29
29
  from schemathesis.generation.meta import CaseMetadata
30
- from schemathesis.hooks import HookDispatcherMark
30
+ from schemathesis.hooks import HookDispatcherMark, _should_skip_hook
31
31
 
32
32
  from .auths import AuthStorage
33
33
  from .filters import (
@@ -712,14 +712,22 @@ class APIOperation(Generic[P]):
712
712
  def _apply_hooks(dispatcher: HookDispatcher, _strategy: SearchStrategy[Case]) -> SearchStrategy[Case]:
713
713
  context = HookContext(operation=self)
714
714
  for hook in dispatcher.get_all_by_name("before_generate_case"):
715
+ if _should_skip_hook(hook, context):
716
+ continue
715
717
  _strategy = hook(context, _strategy)
716
718
  for hook in dispatcher.get_all_by_name("filter_case"):
719
+ if _should_skip_hook(hook, context):
720
+ continue
717
721
  hook = partial(hook, context)
718
722
  _strategy = _strategy.filter(hook)
719
723
  for hook in dispatcher.get_all_by_name("map_case"):
724
+ if _should_skip_hook(hook, context):
725
+ continue
720
726
  hook = partial(hook, context)
721
727
  _strategy = _strategy.map(hook)
722
728
  for hook in dispatcher.get_all_by_name("flatmap_case"):
729
+ if _should_skip_hook(hook, context):
730
+ continue
723
731
  hook = partial(hook, context)
724
732
  _strategy = _strategy.flatmap(hook)
725
733
  return _strategy
@@ -36,7 +36,13 @@ from ... import auths
36
36
  from ...generation import GenerationMode
37
37
  from ...hooks import HookContext, HookDispatcher, apply_to_all_dispatchers
38
38
  from .constants import LOCATION_TO_CONTAINER
39
- from .formats import HEADER_FORMAT, STRING_FORMATS, get_default_format_strategies, header_values
39
+ from .formats import (
40
+ DEFAULT_HEADER_EXCLUDE_CHARACTERS,
41
+ HEADER_FORMAT,
42
+ STRING_FORMATS,
43
+ get_default_format_strategies,
44
+ header_values,
45
+ )
40
46
  from .media_types import MEDIA_TYPES
41
47
  from .negative import negative_schema
42
48
  from .negative.utils import can_negate
@@ -410,10 +416,17 @@ def jsonify_python_specific_types(value: dict[str, Any]) -> dict[str, Any]:
410
416
 
411
417
  def _build_custom_formats(generation_config: GenerationConfig) -> dict[str, st.SearchStrategy]:
412
418
  custom_formats = {**get_default_format_strategies(), **STRING_FORMATS}
419
+ header_values_kwargs = {}
413
420
  if generation_config.exclude_header_characters is not None:
414
- custom_formats[HEADER_FORMAT] = header_values(exclude_characters=generation_config.exclude_header_characters)
421
+ header_values_kwargs["exclude_characters"] = generation_config.exclude_header_characters
422
+ if not generation_config.allow_x00:
423
+ header_values_kwargs["exclude_characters"] += "\x00"
415
424
  elif not generation_config.allow_x00:
416
- custom_formats[HEADER_FORMAT] = header_values(exclude_characters="\n\r\x00")
425
+ header_values_kwargs["exclude_characters"] = DEFAULT_HEADER_EXCLUDE_CHARACTERS + "\x00"
426
+ if generation_config.codec is not None:
427
+ header_values_kwargs["codec"] = generation_config.codec
428
+ if header_values_kwargs:
429
+ custom_formats[HEADER_FORMAT] = header_values(**header_values_kwargs)
417
430
  return custom_formats
418
431
 
419
432
 
@@ -366,7 +366,10 @@ def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool |
366
366
 
367
367
  if not isinstance(case.operation.schema, BaseOpenAPISchema) or is_unexpected_http_status_case(case):
368
368
  return True
369
- if response.status_code == 404 or response.status_code >= 500:
369
+
370
+ # Only check for use-after-free on successful responses (2xx) or redirects (3xx)
371
+ # Other status codes indicate request-level issues / server errors, not successful resource access
372
+ if not (200 <= response.status_code < 400):
370
373
  return None
371
374
 
372
375
  for related_case in ctx._find_related(case_id=case.id):
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import platform
3
4
  import string
4
5
  from base64 import b64encode
5
6
  from functools import lru_cache
@@ -11,7 +12,15 @@ if TYPE_CHECKING:
11
12
  from hypothesis import strategies as st
12
13
 
13
14
 
15
+ IS_PYPY = platform.python_implementation() == "PyPy"
14
16
  STRING_FORMATS: dict[str, st.SearchStrategy] = {}
17
+ # For some reason PyPy can't send header values with codepoints > 128, while CPython can
18
+ if IS_PYPY:
19
+ MAX_HEADER_CODEPOINT = 128
20
+ DEFAULT_HEADER_EXCLUDE_CHARACTERS = "\n\r\x1f\x1e\x1d\x1c"
21
+ else:
22
+ MAX_HEADER_CODEPOINT = 255
23
+ DEFAULT_HEADER_EXCLUDE_CHARACTERS = "\n\r"
15
24
 
16
25
 
17
26
  def register_string_format(name: str, strategy: st.SearchStrategy) -> None:
@@ -65,11 +74,15 @@ def unregister_string_format(name: str) -> None:
65
74
  raise ValueError(f"Unknown Open API format: {name}") from exc
66
75
 
67
76
 
68
- def header_values(exclude_characters: str = "\n\r") -> st.SearchStrategy[str]:
77
+ def header_values(
78
+ codec: str | None = None, exclude_characters: str = DEFAULT_HEADER_EXCLUDE_CHARACTERS
79
+ ) -> st.SearchStrategy[str]:
69
80
  from hypothesis import strategies as st
70
81
 
71
82
  return st.text(
72
- alphabet=st.characters(min_codepoint=0, max_codepoint=255, exclude_characters=exclude_characters)
83
+ alphabet=st.characters(
84
+ min_codepoint=0, max_codepoint=MAX_HEADER_CODEPOINT, codec=codec, exclude_characters=exclude_characters
85
+ )
73
86
  # Header values with leading non-visible chars can't be sent with `requests`
74
87
  ).map(str.lstrip)
75
88
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import itertools
4
+ import string
4
5
  from collections import defaultdict
5
6
  from contextlib import ExitStack, contextmanager, suppress
6
7
  from dataclasses import dataclass, field
@@ -29,7 +30,7 @@ from requests.exceptions import InvalidHeader
29
30
  from requests.structures import CaseInsensitiveDict
30
31
  from requests.utils import check_header_validity
31
32
 
32
- from schemathesis.core import NOT_SET, NotSet, Specification, deserialization, media_types
33
+ from schemathesis.core import INJECTED_PATH_PARAMETER_KEY, NOT_SET, NotSet, Specification, deserialization, media_types
33
34
  from schemathesis.core.compat import RefResolutionError
34
35
  from schemathesis.core.errors import InternalError, InvalidSchema, LoaderError, LoaderErrorKind, OperationNotFound
35
36
  from schemathesis.core.failures import Failure, FailureGroup, MalformedJson
@@ -89,6 +90,17 @@ def check_header(parameter: dict[str, Any]) -> None:
89
90
  raise InvalidSchema(f"Invalid header name: {name}")
90
91
 
91
92
 
93
+ def get_template_fields(template: str) -> set[str]:
94
+ """Extract named placeholders from a string template."""
95
+ try:
96
+ parameters = {name for _, name, _, _ in string.Formatter().parse(template) if name is not None}
97
+ # Check for malformed params to avoid injecting them - they will be checked later on in the workflow
98
+ template.format(**dict.fromkeys(parameters, ""))
99
+ return parameters
100
+ except (ValueError, IndexError):
101
+ return set()
102
+
103
+
92
104
  @dataclass(eq=False, repr=False)
93
105
  class BaseOpenAPISchema(BaseSchema):
94
106
  nullable_name: ClassVar[str] = ""
@@ -100,6 +112,7 @@ class BaseOpenAPISchema(BaseSchema):
100
112
  # excessive resolving
101
113
  _inline_reference_cache_lock: RLock = field(default_factory=RLock)
102
114
  component_locations: ClassVar[tuple[tuple[str, ...], ...]] = ()
115
+ _path_parameter_template: ClassVar[dict[str, Any]] = None # type: ignore
103
116
 
104
117
  @property
105
118
  def specification(self) -> Specification:
@@ -232,7 +245,7 @@ class BaseOpenAPISchema(BaseSchema):
232
245
 
233
246
  return statistic
234
247
 
235
- def _operation_iter(self) -> Generator[dict[str, Any], None, None]:
248
+ def _operation_iter(self) -> Iterator[tuple[str, str, dict[str, Any]]]:
236
249
  try:
237
250
  paths = self.raw_schema["paths"]
238
251
  except KeyError:
@@ -243,13 +256,11 @@ class BaseOpenAPISchema(BaseSchema):
243
256
  try:
244
257
  if "$ref" in path_item:
245
258
  _, path_item = resolve(path_item["$ref"])
246
- # Straightforward iteration is faster than converting to a set & calculating length.
247
259
  for method, definition in path_item.items():
248
260
  if should_skip(path, method, definition):
249
261
  continue
250
- yield definition
262
+ yield (method, path, definition)
251
263
  except SCHEMA_PARSING_ERRORS:
252
- # Ignore errors
253
264
  continue
254
265
 
255
266
  def _resolve_until_no_references(self, value: dict[str, Any]) -> dict[str, Any]:
@@ -393,7 +404,6 @@ class BaseOpenAPISchema(BaseSchema):
393
404
  resolved: dict[str, Any],
394
405
  scope: str,
395
406
  ) -> APIOperation:
396
- """Create JSON schemas for the query, body, etc from Swagger parameters definitions."""
397
407
  __tracebackhide__ = True
398
408
  base_url = self.get_base_url()
399
409
  operation: APIOperation[OpenAPIParameter] = APIOperation(
@@ -406,6 +416,14 @@ class BaseOpenAPISchema(BaseSchema):
406
416
  )
407
417
  for parameter in parameters:
408
418
  operation.add_parameter(parameter)
419
+ # Inject unconstrained path parameters if any is missing
420
+ missing_parameter_names = get_template_fields(operation.path) - {
421
+ parameter.name for parameter in operation.path_parameters
422
+ }
423
+ for name in missing_parameter_names:
424
+ definition = {"name": name, INJECTED_PATH_PARAMETER_KEY: True, **deepclone(self._path_parameter_template)}
425
+ for parameter in self.collect_parameters([definition], resolved):
426
+ operation.add_parameter(parameter)
409
427
  config = self.config.generation_for(operation=operation)
410
428
  if config.with_security_parameters:
411
429
  self.security.process_definitions(self.raw_schema, operation, self.resolver)
@@ -837,6 +855,7 @@ class SwaggerV20(BaseOpenAPISchema):
837
855
  security = SwaggerSecurityProcessor()
838
856
  component_locations: ClassVar[tuple[tuple[str, ...], ...]] = (("definitions",),)
839
857
  links_field = "x-links"
858
+ _path_parameter_template = {"in": "path", "required": True, "type": "string"}
840
859
 
841
860
  @property
842
861
  def specification(self) -> Specification:
@@ -1005,6 +1024,7 @@ class OpenApi30(SwaggerV20):
1005
1024
  security = OpenAPISecurityProcessor()
1006
1025
  component_locations = (("components", "schemas"),)
1007
1026
  links_field = "links"
1027
+ _path_parameter_template = {"in": "path", "required": True, "schema": {"type": "string"}}
1008
1028
 
1009
1029
  @property
1010
1030
  def specification(self) -> Specification: