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.
- schemathesis/cli/commands/run/__init__.py +8 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +1 -0
- schemathesis/cli/commands/run/handlers/output.py +27 -17
- schemathesis/config/_operations.py +5 -0
- schemathesis/config/_phases.py +43 -5
- schemathesis/config/_projects.py +18 -0
- schemathesis/config/schema.json +31 -0
- schemathesis/engine/context.py +39 -4
- schemathesis/engine/core.py +30 -9
- schemathesis/engine/events.py +12 -2
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +5 -0
- schemathesis/engine/phases/probes.py +9 -3
- schemathesis/engine/phases/unit/__init__.py +1 -0
- schemathesis/generation/case.py +1 -1
- schemathesis/generation/coverage.py +16 -2
- schemathesis/hooks.py +16 -4
- schemathesis/openapi/loaders.py +1 -1
- schemathesis/specs/openapi/_hypothesis.py +16 -3
- schemathesis/specs/openapi/formats.py +15 -2
- schemathesis/specs/openapi/schemas.py +2 -4
- schemathesis/specs/openapi/stateful/inference.py +250 -0
- schemathesis/transport/requests.py +3 -0
- {schemathesis-4.0.26.dist-info → schemathesis-4.1.0.dist-info}/METADATA +3 -2
- {schemathesis-4.0.26.dist-info → schemathesis-4.1.0.dist-info}/RECORD +28 -26
- {schemathesis-4.0.26.dist-info → schemathesis-4.1.0.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.26.dist-info → schemathesis-4.1.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.26.dist-info → schemathesis-4.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
|
46
|
-
|
47
|
-
|
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()
|
schemathesis/generation/case.py
CHANGED
@@ -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
|
-
|
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
|
-
|
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(
|
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
|
-
|
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,
|
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:
|
schemathesis/openapi/loaders.py
CHANGED
@@ -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
|
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
|
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
|
-
|
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
|
-
|
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(
|
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(
|
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) ->
|
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
|
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.
|
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
|