schemathesis 4.0.26__py3-none-any.whl → 4.1.0__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.
@@ -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
 
@@ -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()
@@ -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:
schemathesis/hooks.py CHANGED
@@ -4,7 +4,7 @@ import inspect
4
4
  from collections import defaultdict
5
5
  from dataclasses import dataclass, field
6
6
  from enum import Enum, unique
7
- from functools import partial
7
+ from functools import lru_cache, partial
8
8
  from typing import TYPE_CHECKING, Any, Callable, ClassVar, cast
9
9
 
10
10
  from schemathesis.core.marks import Mark
@@ -225,12 +225,18 @@ class HookDispatcher:
225
225
  strategy = strategy.flatmap(hook)
226
226
  return strategy
227
227
 
228
- def dispatch(self, name: str, context: HookContext, *args: Any, **kwargs: Any) -> None:
228
+ def dispatch(
229
+ self, name: str, context: HookContext, *args: Any, _with_dual_style_kwargs: bool = False, **kwargs: Any
230
+ ) -> None:
229
231
  """Run all hooks for the given name."""
230
232
  for hook in self.get_all_by_name(name):
231
233
  if _should_skip_hook(hook, context):
232
234
  continue
233
- hook(context, *args, **kwargs)
235
+ # NOTE: It is a backward-compat shim to support calling `before_call` with `**kwargs` OR with `kwargs`.
236
+ if _with_dual_style_kwargs and not has_var_keyword(hook):
237
+ hook(context, *args, kwargs)
238
+ else:
239
+ hook(context, *args, **kwargs)
234
240
 
235
241
  def unregister(self, hook: Callable) -> None:
236
242
  """Unregister a specific hook."""
@@ -246,6 +252,12 @@ class HookDispatcher:
246
252
  self._hooks = defaultdict(list)
247
253
 
248
254
 
255
+ @lru_cache(maxsize=16)
256
+ def has_var_keyword(hook: Callable) -> bool:
257
+ """Check if hook function accepts **kwargs."""
258
+ return any(p.kind == inspect.Parameter.VAR_KEYWORD for p in inspect.signature(hook).parameters.values())
259
+
260
+
249
261
  def _should_skip_hook(hook: Callable, ctx: HookContext) -> bool:
250
262
  filter_set = getattr(hook, "filter_set", None)
251
263
  return filter_set is not None and ctx.operation is not None and not filter_set.match(ctx)
@@ -349,7 +361,7 @@ def before_init_operation(context: HookContext, operation: APIOperation) -> None
349
361
 
350
362
 
351
363
  @HookDispatcher.register_spec([HookScope.GLOBAL])
352
- def before_call(context: HookContext, case: Case, **kwargs: Any) -> None:
364
+ def before_call(context: HookContext, case: Case, kwargs: dict[str, Any]) -> None:
353
365
  """Called before every network call in CLI tests.
354
366
 
355
367
  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
 
@@ -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
 
@@ -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
 
@@ -232,7 +232,7 @@ class BaseOpenAPISchema(BaseSchema):
232
232
 
233
233
  return statistic
234
234
 
235
- def _operation_iter(self) -> Generator[dict[str, Any], None, None]:
235
+ def _operation_iter(self) -> Iterator[tuple[str, str, dict[str, Any]]]:
236
236
  try:
237
237
  paths = self.raw_schema["paths"]
238
238
  except KeyError:
@@ -243,13 +243,11 @@ class BaseOpenAPISchema(BaseSchema):
243
243
  try:
244
244
  if "$ref" in path_item:
245
245
  _, path_item = resolve(path_item["$ref"])
246
- # Straightforward iteration is faster than converting to a set & calculating length.
247
246
  for method, definition in path_item.items():
248
247
  if should_skip(path, method, definition):
249
248
  continue
250
- yield definition
249
+ yield (method, path, definition)
251
250
  except SCHEMA_PARSING_ERRORS:
252
- # Ignore errors
253
251
  continue
254
252
 
255
253
  def _resolve_until_no_references(self, value: dict[str, Any]) -> dict[str, Any]:
@@ -0,0 +1,250 @@
1
+ """Inferencing connections between API operations.
2
+
3
+ The current implementation extracts information from the `Location` header and
4
+ generates OpenAPI links for exact and prefix matches.
5
+
6
+ When a `Location` header points to `/users/123`, the inference:
7
+
8
+ 1. Finds the exact match: `GET /users/{userId}`
9
+ 2. Finds prefix matches: `GET /users/{userId}/posts`, `GET /users/{userId}/posts/{postId}`
10
+ 3. Generates OpenAPI links with regex parameter extractors
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, Any, Mapping, Union
18
+ from urllib.parse import urlsplit
19
+
20
+ from werkzeug.exceptions import MethodNotAllowed, NotFound
21
+ from werkzeug.routing import Map, MapAdapter, Rule
22
+
23
+ if TYPE_CHECKING:
24
+ from schemathesis.engine.observations import LocationHeaderEntry
25
+ from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
26
+
27
+
28
+ @dataclass(unsafe_hash=True)
29
+ class OperationById:
30
+ """API operation identified by operationId."""
31
+
32
+ value: str
33
+ method: str
34
+ path: str
35
+
36
+ __slots__ = ("value", "method", "path")
37
+
38
+ def to_link_base(self) -> dict[str, Any]:
39
+ return {"operationId": self.value, "x-inferred": True}
40
+
41
+
42
+ @dataclass(unsafe_hash=True)
43
+ class OperationByRef:
44
+ """API operation identified by JSON reference path."""
45
+
46
+ value: str
47
+ method: str
48
+ path: str
49
+
50
+ __slots__ = ("value", "method", "path")
51
+
52
+ def to_link_base(self) -> dict[str, Any]:
53
+ return {"operationRef": self.value, "x-inferred": True}
54
+
55
+
56
+ OperationReference = Union[OperationById, OperationByRef]
57
+ # Method, path, response code, sorted path parameter names
58
+ SeenLinkKey = tuple[str, str, int, tuple[str, ...]]
59
+
60
+
61
+ @dataclass
62
+ class MatchList:
63
+ """Results of matching a location path against API operation."""
64
+
65
+ exact: OperationReference
66
+ inexact: list[OperationReference]
67
+ parameters: Mapping[str, Any]
68
+
69
+ __slots__ = ("exact", "inexact", "parameters")
70
+
71
+
72
+ @dataclass
73
+ class LinkInferencer:
74
+ """Infer OpenAPI links from Location headers for stateful testing."""
75
+
76
+ _adapter: MapAdapter
77
+ # All API operations for prefix matching
78
+ _operations: list[OperationReference]
79
+ _base_url: str | None
80
+ _base_path: str
81
+ _links_field_name: str
82
+
83
+ __slots__ = ("_adapter", "_operations", "_base_url", "_base_path", "_links_field_name")
84
+
85
+ @classmethod
86
+ def from_schema(cls, schema: BaseOpenAPISchema) -> LinkInferencer:
87
+ # NOTE: Use `matchit` for routing in the future
88
+ rules = []
89
+ operations = []
90
+ for method, path, definition in schema._operation_iter():
91
+ operation_id = definition.get("operationId")
92
+ operation: OperationById | OperationByRef
93
+ if operation_id:
94
+ operation = OperationById(operation_id, method=method, path=path)
95
+ else:
96
+ encoded_path = path.replace("~", "~0").replace("/", "~1")
97
+ operation = OperationByRef(f"#/paths/{encoded_path}/{method}", method=method, path=path)
98
+
99
+ operations.append(operation)
100
+
101
+ # Replace `{parameter}` with `<parameter>` as angle brackets are used for parameters in werkzeug
102
+ path = re.sub(r"\{([^}]+)\}", r"<\1>", path)
103
+ rules.append(Rule(path, endpoint=operation, methods=[method.upper()]))
104
+
105
+ return cls(
106
+ _adapter=Map(rules).bind("", ""),
107
+ _operations=operations,
108
+ _base_url=schema.config.base_url,
109
+ _base_path=schema.base_path,
110
+ _links_field_name=schema.links_field,
111
+ )
112
+
113
+ def match(self, path: str) -> tuple[OperationReference, Mapping[str, str]] | None:
114
+ """Match path to API operation and extract path parameters."""
115
+ try:
116
+ return self._adapter.match(path)
117
+ except (NotFound, MethodNotAllowed):
118
+ return None
119
+
120
+ def _build_links_from_matches(self, matches: MatchList) -> list[dict]:
121
+ """Build links from already-found matches."""
122
+ exact = self._build_link_from_match(matches.exact, matches.parameters)
123
+ parameters = exact["parameters"]
124
+ links = [exact]
125
+ for inexact in matches.inexact:
126
+ link = inexact.to_link_base()
127
+ # Parameter extraction is the same, only operations are different
128
+ link["parameters"] = parameters
129
+ links.append(link)
130
+ return links
131
+
132
+ def _find_matches_from_normalized_location(self, normalized_location: str) -> MatchList | None:
133
+ """Find matches from an already-normalized location."""
134
+ match = self.match(normalized_location)
135
+ if not match:
136
+ # It may happen that there is no match, but it is unlikely as the API assumed to return a valid Location
137
+ # that points to an existing API operation. In such cases, if they appear in practice the logic here could be extended
138
+ # to support partial matches
139
+ return None
140
+ exact, parameters = match
141
+ if not parameters:
142
+ # Links without parameters don't make sense
143
+ return None
144
+ matches = MatchList(exact=exact, inexact=[], parameters=parameters)
145
+
146
+ # Find prefix matches, excluding the exact match
147
+ # For example:
148
+ #
149
+ # Location: /users/123 -> /users/{user_id} (exact match)
150
+ # /users/{user_id}/posts , /users/{user_id}/posts/{post_id} (partial matches)
151
+ #
152
+ for candidate in self._operations:
153
+ if candidate == exact:
154
+ continue
155
+ if candidate.path.startswith(exact.path):
156
+ matches.inexact.append(candidate)
157
+
158
+ return matches
159
+
160
+ def _build_link_from_match(
161
+ self, operation: OperationById | OperationByRef, path_parameters: Mapping[str, Any]
162
+ ) -> dict:
163
+ link = operation.to_link_base()
164
+
165
+ # Build regex expressions to extract path parameters
166
+ parameters = {}
167
+ for name in path_parameters:
168
+ # Replace the target parameter with capture group and others with non-slash matcher
169
+ pattern = operation.path
170
+ for candidate in path_parameters:
171
+ if candidate == name:
172
+ pattern = pattern.replace(f"{{{candidate}}}", "(.+)")
173
+ else:
174
+ pattern = pattern.replace(f"{{{candidate}}}", "[^/]+")
175
+
176
+ parameters[name] = f"$response.header.Location#regex:{pattern}"
177
+
178
+ link["parameters"] = parameters
179
+
180
+ return link
181
+
182
+ def _normalize_location(self, location: str) -> str | None:
183
+ """Normalize location header, handling both relative and absolute URLs."""
184
+ location = location.strip()
185
+ if not location:
186
+ return None
187
+
188
+ # Check if it's an absolute URL
189
+ if location.startswith(("http://", "https://")):
190
+ if not self._base_url:
191
+ # Can't validate absolute URLs without base_url
192
+ return None
193
+
194
+ parsed = urlsplit(location)
195
+ base_parsed = urlsplit(self._base_url)
196
+
197
+ # Must match scheme, netloc, and start with the base path
198
+ if parsed.scheme != base_parsed.scheme or parsed.netloc != base_parsed.netloc:
199
+ return None
200
+
201
+ return self._strip_base_path_from_location(parsed.path)
202
+
203
+ # Relative URL - strip base path if present, otherwise use as-is
204
+ stripped = self._strip_base_path_from_location(location)
205
+ return stripped if stripped is not None else location
206
+
207
+ def _strip_base_path_from_location(self, path: str) -> str | None:
208
+ """Strip base path from location path if it starts with base path."""
209
+ base_path = self._base_path.rstrip("/")
210
+ if not path.startswith(base_path):
211
+ return None
212
+
213
+ # Strip the base path to get relative path
214
+ relative_path = path[len(base_path) :]
215
+ return relative_path if relative_path.startswith("/") else "/" + relative_path
216
+
217
+ def inject_links(self, operation: dict[str, Any], entries: list[LocationHeaderEntry]) -> int:
218
+ from schemathesis.specs.openapi.schemas import _get_response_definition_by_status
219
+
220
+ responses = operation.setdefault("responses", {})
221
+ # To avoid unnecessary work, we need to skip entries that we know will produce already inferred links
222
+ seen: set[SeenLinkKey] = set()
223
+ injected = 0
224
+
225
+ for entry in entries:
226
+ location = self._normalize_location(entry.value)
227
+ if location is None:
228
+ # Skip invalid/empty locations or absolute URLs that don't match base_url
229
+ continue
230
+
231
+ matches = self._find_matches_from_normalized_location(location)
232
+ if matches is None:
233
+ # Skip locations that don't match any API apiration
234
+ continue
235
+
236
+ key = (matches.exact.method, matches.exact.path, entry.status_code, tuple(sorted(matches.parameters)))
237
+ if key in seen:
238
+ # Skip duplicate link generation for same operation/status/parameters combination
239
+ continue
240
+ seen.add(key)
241
+ # Find the right bucket for the response status or create a new one
242
+ definition = _get_response_definition_by_status(entry.status_code, responses)
243
+ if definition is None:
244
+ definition = responses.setdefault(str(entry.status_code), {})
245
+ links = definition.setdefault(self._links_field_name, {})
246
+
247
+ for idx, link in enumerate(self._build_links_from_matches(matches)):
248
+ links[f"X-Inferred-Link-{idx}"] = link
249
+ injected += 1
250
+ return injected
@@ -91,6 +91,7 @@ class RequestsTransport(BaseTransport["requests.Session"]):
91
91
 
92
92
  config = case.operation.schema.config
93
93
 
94
+ max_redirects = kwargs.pop("max_redirects", None) or config.max_redirects_for(operation=case.operation)
94
95
  timeout = config.request_timeout_for(operation=case.operation)
95
96
  verify = config.tls_verify_for(operation=case.operation)
96
97
  cert = config.request_cert_for(operation=case.operation)
@@ -131,6 +132,8 @@ class RequestsTransport(BaseTransport["requests.Session"]):
131
132
  current_session_auth = session.auth
132
133
  session.auth = None
133
134
  close_session = False
135
+ if max_redirects is not None:
136
+ session.max_redirects = max_redirects
134
137
  session.headers = {}
135
138
 
136
139
  verify = data.get("verify", True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: schemathesis
3
- Version: 4.0.26
3
+ Version: 4.1.0
4
4
  Summary: Property-based testing framework for Open API and GraphQL based apps
5
5
  Project-URL: Documentation, https://schemathesis.readthedocs.io/en/stable/
6
6
  Project-URL: Changelog, https://github.com/schemathesis/schemathesis/blob/master/CHANGELOG.md
@@ -26,12 +26,13 @@ Classifier: Programming Language :: Python :: 3.11
26
26
  Classifier: Programming Language :: Python :: 3.12
27
27
  Classifier: Programming Language :: Python :: 3.13
28
28
  Classifier: Programming Language :: Python :: Implementation :: CPython
29
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
29
30
  Classifier: Topic :: Software Development :: Testing
30
31
  Requires-Python: >=3.9
31
32
  Requires-Dist: backoff<3.0,>=2.1.2
32
33
  Requires-Dist: click<9,>=8.0
33
34
  Requires-Dist: colorama<1.0,>=0.4
34
- Requires-Dist: harfile<1.0,>=0.3.0
35
+ Requires-Dist: harfile<1.0,>=0.3.1
35
36
  Requires-Dist: httpx<1.0,>=0.22.0
36
37
  Requires-Dist: hypothesis-graphql<1,>=0.11.1
37
38
  Requires-Dist: hypothesis-jsonschema<0.24,>=0.23.1