schemathesis 3.26.2__py3-none-any.whl → 3.27.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.
- schemathesis/__init__.py +1 -1
- schemathesis/cli/__init__.py +1 -1
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/junitxml.py +79 -10
- schemathesis/cli/output/default.py +9 -47
- schemathesis/cli/reporting.py +72 -0
- schemathesis/generation/__init__.py +14 -2
- schemathesis/models.py +51 -143
- schemathesis/runner/impl/core.py +11 -12
- schemathesis/runner/serialization.py +32 -10
- schemathesis/schemas.py +11 -7
- schemathesis/specs/graphql/loaders.py +2 -0
- schemathesis/specs/graphql/schemas.py +7 -40
- schemathesis/specs/openapi/_hypothesis.py +16 -5
- schemathesis/specs/openapi/loaders.py +3 -0
- schemathesis/stateful/state_machine.py +3 -13
- schemathesis/transports/__init__.py +307 -0
- schemathesis/transports/responses.py +2 -0
- {schemathesis-3.26.2.dist-info → schemathesis-3.27.1.dist-info}/METADATA +2 -3
- {schemathesis-3.26.2.dist-info → schemathesis-3.27.1.dist-info}/RECORD +23 -21
- {schemathesis-3.26.2.dist-info → schemathesis-3.27.1.dist-info}/WHEEL +0 -0
- {schemathesis-3.26.2.dist-info → schemathesis-3.27.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.26.2.dist-info → schemathesis-3.27.1.dist-info}/licenses/LICENSE +0 -0
schemathesis/models.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import datetime
|
|
3
4
|
import inspect
|
|
4
5
|
import textwrap
|
|
@@ -6,7 +7,7 @@ from collections import Counter
|
|
|
6
7
|
from contextlib import contextmanager
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from enum import Enum
|
|
9
|
-
from functools import
|
|
10
|
+
from functools import lru_cache, partial
|
|
10
11
|
from itertools import chain
|
|
11
12
|
from logging import LogRecord
|
|
12
13
|
from typing import (
|
|
@@ -25,51 +26,48 @@ from typing import (
|
|
|
25
26
|
)
|
|
26
27
|
from urllib.parse import quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
|
|
27
28
|
|
|
28
|
-
from
|
|
29
|
-
|
|
30
|
-
from . import failures, serializers
|
|
29
|
+
from . import serializers
|
|
31
30
|
from ._dependency_versions import IS_WERKZEUG_ABOVE_3
|
|
32
31
|
from .auths import AuthStorage
|
|
33
32
|
from .code_samples import CodeSampleStyle
|
|
34
|
-
from .generation import DataGenerationMethod, GenerationConfig
|
|
35
33
|
from .constants import (
|
|
36
|
-
|
|
34
|
+
NOT_SET,
|
|
37
35
|
SCHEMATHESIS_TEST_CASE_HEADER,
|
|
38
36
|
SERIALIZERS_SUGGESTION_MESSAGE,
|
|
39
37
|
USER_AGENT,
|
|
40
|
-
NOT_SET,
|
|
41
38
|
)
|
|
42
39
|
from .exceptions import (
|
|
43
|
-
maybe_set_assertion_message,
|
|
44
40
|
CheckFailed,
|
|
45
41
|
FailureContext,
|
|
46
42
|
OperationSchemaError,
|
|
47
43
|
SerializationNotPossible,
|
|
44
|
+
SkipTest,
|
|
48
45
|
deduplicate_failed_checks,
|
|
49
46
|
get_grouped_exception,
|
|
50
|
-
|
|
47
|
+
maybe_set_assertion_message,
|
|
51
48
|
prepare_response_payload,
|
|
52
|
-
SkipTest,
|
|
53
49
|
)
|
|
54
|
-
from .
|
|
55
|
-
from .internal.copy import fast_deepcopy
|
|
50
|
+
from .generation import DataGenerationMethod, GenerationConfig, generate_random_case_id
|
|
56
51
|
from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, dispatch
|
|
52
|
+
from .internal.copy import fast_deepcopy
|
|
53
|
+
from .internal.deprecation import deprecated_property, deprecated_function
|
|
57
54
|
from .parameters import Parameter, ParameterSet, PayloadAlternatives
|
|
58
55
|
from .sanitization import sanitize_request, sanitize_response
|
|
59
|
-
from .serializers import Serializer
|
|
60
|
-
from .transports import serialize_payload
|
|
56
|
+
from .serializers import Serializer
|
|
57
|
+
from .transports import ASGITransport, RequestsTransport, WSGITransport, serialize_payload
|
|
61
58
|
from .types import Body, Cookies, FormData, Headers, NotSet, PathParameters, Query
|
|
62
|
-
from .generation import generate_random_case_id
|
|
63
59
|
|
|
64
60
|
if TYPE_CHECKING:
|
|
65
|
-
import werkzeug
|
|
66
61
|
import unittest
|
|
67
|
-
|
|
68
|
-
from hypothesis import strategies as st
|
|
62
|
+
|
|
69
63
|
import requests.auth
|
|
70
|
-
|
|
64
|
+
import werkzeug
|
|
65
|
+
from hypothesis import strategies as st
|
|
66
|
+
from requests.structures import CaseInsensitiveDict
|
|
67
|
+
|
|
71
68
|
from .schemas import BaseSchema
|
|
72
69
|
from .stateful import Stateful, StatefulTest
|
|
70
|
+
from .transports.responses import GenericResponse, WSGIResponse
|
|
73
71
|
|
|
74
72
|
|
|
75
73
|
@dataclass
|
|
@@ -86,7 +84,7 @@ class CaseSource:
|
|
|
86
84
|
|
|
87
85
|
def cant_serialize(media_type: str) -> NoReturn: # type: ignore
|
|
88
86
|
"""Reject the current example if we don't know how to send this data to the application."""
|
|
89
|
-
from hypothesis import
|
|
87
|
+
from hypothesis import event, note, reject
|
|
90
88
|
|
|
91
89
|
event_text = f"Can't serialize data to `{media_type}`."
|
|
92
90
|
note(f"{event_text} {SERIALIZERS_SUGGESTION_MESSAGE}")
|
|
@@ -210,7 +208,7 @@ class Case:
|
|
|
210
208
|
|
|
211
209
|
def prepare_code_sample_data(self, headers: dict[str, Any] | None) -> PreparedRequestData:
|
|
212
210
|
base_url = self.get_full_base_url()
|
|
213
|
-
kwargs =
|
|
211
|
+
kwargs = RequestsTransport().serialize_case(self, base_url=base_url, headers=headers)
|
|
214
212
|
return prepare_request_data(kwargs)
|
|
215
213
|
|
|
216
214
|
def get_code_to_reproduce(
|
|
@@ -286,40 +284,17 @@ class Case:
|
|
|
286
284
|
return cls()
|
|
287
285
|
return None
|
|
288
286
|
|
|
287
|
+
def _get_body(self) -> Body | NotSet:
|
|
288
|
+
return self.body
|
|
289
|
+
|
|
290
|
+
@deprecated_function(removed_in="4.0", replacement="Case.as_transport_kwargs")
|
|
289
291
|
def as_requests_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
|
290
292
|
"""Convert the case into a dictionary acceptable by requests."""
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
base_url = self._get_base_url(base_url)
|
|
297
|
-
formatted_path = self.formatted_path.lstrip("/")
|
|
298
|
-
if not base_url.endswith("/"):
|
|
299
|
-
base_url += "/"
|
|
300
|
-
url = unquote(urljoin(base_url, quote(formatted_path)))
|
|
301
|
-
extra: dict[str, Any]
|
|
302
|
-
serializer = self._get_serializer()
|
|
303
|
-
if serializer is not None and not isinstance(self.body, NotSet):
|
|
304
|
-
context = SerializerContext(case=self)
|
|
305
|
-
extra = serializer.as_requests(context, self.body)
|
|
306
|
-
else:
|
|
307
|
-
extra = {}
|
|
308
|
-
if self._auth is not None:
|
|
309
|
-
extra["auth"] = self._auth
|
|
310
|
-
additional_headers = extra.pop("headers", None)
|
|
311
|
-
if additional_headers:
|
|
312
|
-
# Additional headers, needed for the serializer
|
|
313
|
-
for key, value in additional_headers.items():
|
|
314
|
-
final_headers.setdefault(key, value)
|
|
315
|
-
return {
|
|
316
|
-
"method": self.method,
|
|
317
|
-
"url": url,
|
|
318
|
-
"cookies": self.cookies,
|
|
319
|
-
"headers": final_headers,
|
|
320
|
-
"params": self.query,
|
|
321
|
-
**extra,
|
|
322
|
-
}
|
|
293
|
+
return RequestsTransport().serialize_case(self, base_url=base_url, headers=headers)
|
|
294
|
+
|
|
295
|
+
def as_transport_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
|
296
|
+
"""Convert the test case into a dictionary acceptable by the underlying transport call."""
|
|
297
|
+
return self.operation.schema.transport.serialize_case(self, base_url=base_url, headers=headers)
|
|
323
298
|
|
|
324
299
|
def call(
|
|
325
300
|
self,
|
|
@@ -329,83 +304,21 @@ class Case:
|
|
|
329
304
|
params: dict[str, Any] | None = None,
|
|
330
305
|
cookies: dict[str, Any] | None = None,
|
|
331
306
|
**kwargs: Any,
|
|
332
|
-
) ->
|
|
333
|
-
import requests
|
|
334
|
-
|
|
335
|
-
"""Make a network call with `requests`."""
|
|
307
|
+
) -> GenericResponse:
|
|
336
308
|
hook_context = HookContext(operation=self.operation)
|
|
337
309
|
dispatch("before_call", hook_context, self)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
_merge_dict_to(data, "params", params)
|
|
342
|
-
if cookies is not None:
|
|
343
|
-
_merge_dict_to(data, "cookies", cookies)
|
|
344
|
-
data.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
|
|
345
|
-
if session is None:
|
|
346
|
-
validate_vanilla_requests_kwargs(data)
|
|
347
|
-
session = requests.Session()
|
|
348
|
-
close_session = True
|
|
349
|
-
else:
|
|
350
|
-
close_session = False
|
|
351
|
-
verify = data.get("verify", True)
|
|
352
|
-
try:
|
|
353
|
-
with self.operation.schema.ratelimit():
|
|
354
|
-
response = session.request(**data) # type: ignore
|
|
355
|
-
except (requests.Timeout, requests.ConnectionError) as exc:
|
|
356
|
-
if isinstance(exc, requests.ConnectionError):
|
|
357
|
-
if not isinstance(exc.args[0], ReadTimeoutError):
|
|
358
|
-
raise
|
|
359
|
-
req = requests.Request(
|
|
360
|
-
method=data["method"].upper(),
|
|
361
|
-
url=data["url"],
|
|
362
|
-
headers=data["headers"],
|
|
363
|
-
files=data.get("files"),
|
|
364
|
-
data=data.get("data") or {},
|
|
365
|
-
json=data.get("json"),
|
|
366
|
-
params=data.get("params") or {},
|
|
367
|
-
auth=data.get("auth"),
|
|
368
|
-
cookies=data["cookies"],
|
|
369
|
-
hooks=data.get("hooks"),
|
|
370
|
-
)
|
|
371
|
-
request = session.prepare_request(req)
|
|
372
|
-
else:
|
|
373
|
-
request = cast(requests.PreparedRequest, exc.request)
|
|
374
|
-
timeout = 1000 * data["timeout"] # It is defined and not empty, since the exception happened
|
|
375
|
-
code_message = self._get_code_message(self.operation.schema.code_sample_style, request, verify=verify)
|
|
376
|
-
message = f"The server failed to respond within the specified limit of {timeout:.2f}ms"
|
|
377
|
-
raise get_timeout_error(timeout)(
|
|
378
|
-
f"\n\n1. {failures.RequestTimeout.title}\n\n{message}\n\n{code_message}",
|
|
379
|
-
context=failures.RequestTimeout(message=message, timeout=timeout),
|
|
380
|
-
) from None
|
|
381
|
-
response.verify = verify # type: ignore[attr-defined]
|
|
310
|
+
response = self.operation.schema.transport.send(
|
|
311
|
+
self, session=session, base_url=base_url, headers=headers, params=params, cookies=cookies, **kwargs
|
|
312
|
+
)
|
|
382
313
|
dispatch("after_call", hook_context, self, response)
|
|
383
|
-
if close_session:
|
|
384
|
-
session.close()
|
|
385
314
|
return response
|
|
386
315
|
|
|
316
|
+
@deprecated_function(removed_in="4.0", replacement="Case.as_transport_kwargs")
|
|
387
317
|
def as_werkzeug_kwargs(self, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
|
388
318
|
"""Convert the case into a dictionary acceptable by werkzeug.Client."""
|
|
389
|
-
|
|
390
|
-
if self.media_type and not isinstance(self.body, NotSet):
|
|
391
|
-
# If we need to send a payload, then the Content-Type header should be set
|
|
392
|
-
final_headers["Content-Type"] = self.media_type
|
|
393
|
-
extra: dict[str, Any]
|
|
394
|
-
serializer = self._get_serializer()
|
|
395
|
-
if serializer is not None and not isinstance(self.body, NotSet):
|
|
396
|
-
context = SerializerContext(case=self)
|
|
397
|
-
extra = serializer.as_werkzeug(context, self.body)
|
|
398
|
-
else:
|
|
399
|
-
extra = {}
|
|
400
|
-
return {
|
|
401
|
-
"method": self.method,
|
|
402
|
-
"path": self.operation.schema.get_full_path(self.formatted_path),
|
|
403
|
-
# Convert to a regular dictionary, as we use `CaseInsensitiveDict` which is not supported by Werkzeug
|
|
404
|
-
"headers": dict(final_headers),
|
|
405
|
-
"query_string": self.query,
|
|
406
|
-
**extra,
|
|
407
|
-
}
|
|
319
|
+
return WSGITransport(self.app).serialize_case(self, headers=headers)
|
|
408
320
|
|
|
321
|
+
@deprecated_function(removed_in="4.0", replacement="Case.call")
|
|
409
322
|
def call_wsgi(
|
|
410
323
|
self,
|
|
411
324
|
app: Any = None,
|
|
@@ -413,10 +326,6 @@ class Case:
|
|
|
413
326
|
query_string: dict[str, str] | None = None,
|
|
414
327
|
**kwargs: Any,
|
|
415
328
|
) -> WSGIResponse:
|
|
416
|
-
from .transports.responses import WSGIResponse
|
|
417
|
-
import werkzeug
|
|
418
|
-
import requests
|
|
419
|
-
|
|
420
329
|
application = app or self.app
|
|
421
330
|
if application is None:
|
|
422
331
|
raise RuntimeError(
|
|
@@ -425,17 +334,11 @@ class Case:
|
|
|
425
334
|
)
|
|
426
335
|
hook_context = HookContext(operation=self.operation)
|
|
427
336
|
dispatch("before_call", hook_context, self)
|
|
428
|
-
|
|
429
|
-
if query_string is not None:
|
|
430
|
-
_merge_dict_to(data, "query_string", query_string)
|
|
431
|
-
client = werkzeug.Client(application, WSGIResponse)
|
|
432
|
-
with cookie_handler(client, self.cookies), self.operation.schema.ratelimit():
|
|
433
|
-
response = client.open(**data, **kwargs)
|
|
434
|
-
requests_kwargs = self.as_requests_kwargs(base_url=self.get_full_base_url(), headers=headers)
|
|
435
|
-
response.request = requests.Request(**requests_kwargs).prepare()
|
|
337
|
+
response = WSGITransport(application).send(self, headers=headers, params=query_string, **kwargs)
|
|
436
338
|
dispatch("after_call", hook_context, self, response)
|
|
437
339
|
return response
|
|
438
340
|
|
|
341
|
+
@deprecated_function(removed_in="4.0", replacement="Case.call")
|
|
439
342
|
def call_asgi(
|
|
440
343
|
self,
|
|
441
344
|
app: Any = None,
|
|
@@ -443,19 +346,17 @@ class Case:
|
|
|
443
346
|
headers: dict[str, str] | None = None,
|
|
444
347
|
**kwargs: Any,
|
|
445
348
|
) -> requests.Response:
|
|
446
|
-
from starlette_testclient import TestClient as ASGIClient
|
|
447
|
-
|
|
448
349
|
application = app or self.app
|
|
449
350
|
if application is None:
|
|
450
351
|
raise RuntimeError(
|
|
451
352
|
"ASGI application instance is required. "
|
|
452
353
|
"Please, set `app` argument in the schema constructor or pass it to `call_asgi`"
|
|
453
354
|
)
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
355
|
+
hook_context = HookContext(operation=self.operation)
|
|
356
|
+
dispatch("before_call", hook_context, self)
|
|
357
|
+
response = ASGITransport(application).send(self, base_url=base_url, headers=headers, **kwargs)
|
|
358
|
+
dispatch("after_call", hook_context, self, response)
|
|
359
|
+
return response
|
|
459
360
|
|
|
460
361
|
def validate_response(
|
|
461
362
|
self,
|
|
@@ -560,12 +461,19 @@ class Case:
|
|
|
560
461
|
self.validate_response(response, checks, code_sample_style=code_sample_style)
|
|
561
462
|
return response
|
|
562
463
|
|
|
464
|
+
def _get_url(self, base_url: str | None) -> str:
|
|
465
|
+
base_url = self._get_base_url(base_url)
|
|
466
|
+
formatted_path = self.formatted_path.lstrip("/")
|
|
467
|
+
if not base_url.endswith("/"):
|
|
468
|
+
base_url += "/"
|
|
469
|
+
return unquote(urljoin(base_url, quote(formatted_path)))
|
|
470
|
+
|
|
563
471
|
def get_full_url(self) -> str:
|
|
564
472
|
"""Make a full URL to the current API operation, including query parameters."""
|
|
565
473
|
import requests
|
|
566
474
|
|
|
567
475
|
base_url = self.base_url or "http://127.0.0.1"
|
|
568
|
-
kwargs =
|
|
476
|
+
kwargs = RequestsTransport().serialize_case(self, base_url=base_url)
|
|
569
477
|
request = requests.Request(**kwargs)
|
|
570
478
|
prepared = requests.Session().prepare_request(request) # type: ignore
|
|
571
479
|
return cast(str, prepared.url)
|
|
@@ -922,7 +830,7 @@ class Request:
|
|
|
922
830
|
import requests
|
|
923
831
|
|
|
924
832
|
base_url = case.get_full_base_url()
|
|
925
|
-
kwargs =
|
|
833
|
+
kwargs = RequestsTransport().serialize_case(case, base_url=base_url)
|
|
926
834
|
request = requests.Request(**kwargs)
|
|
927
835
|
prepared = session.prepare_request(request) # type: ignore
|
|
928
836
|
return cls.from_prepared_request(prepared)
|
schemathesis/runner/impl/core.py
CHANGED
|
@@ -21,6 +21,8 @@ from jsonschema.exceptions import SchemaError as JsonSchemaError
|
|
|
21
21
|
from jsonschema.exceptions import ValidationError
|
|
22
22
|
from requests.auth import HTTPDigestAuth, _basic_auth_str
|
|
23
23
|
|
|
24
|
+
from schemathesis.transports import RequestsTransport
|
|
25
|
+
|
|
24
26
|
from ... import failures, hooks
|
|
25
27
|
from ..._compat import MultipleFailures
|
|
26
28
|
from ..._hypothesis import (
|
|
@@ -297,10 +299,7 @@ def run_probes(schema: BaseSchema, config: probes.ProbeConfig) -> list[probes.Pr
|
|
|
297
299
|
if isinstance(result.probe, probes.NullByteInHeader) and result.is_failure:
|
|
298
300
|
from ...specs.openapi._hypothesis import HEADER_FORMAT, header_values
|
|
299
301
|
|
|
300
|
-
formats.register(
|
|
301
|
-
HEADER_FORMAT,
|
|
302
|
-
header_values(blacklist_characters="\n\r\x00").map(str.lstrip),
|
|
303
|
-
)
|
|
302
|
+
formats.register(HEADER_FORMAT, header_values(blacklist_characters="\n\r\x00"))
|
|
304
303
|
return results
|
|
305
304
|
|
|
306
305
|
|
|
@@ -849,7 +848,7 @@ def _network_test(
|
|
|
849
848
|
response = case.call(**kwargs)
|
|
850
849
|
except CheckFailed as exc:
|
|
851
850
|
check_name = "request_timeout"
|
|
852
|
-
requests_kwargs =
|
|
851
|
+
requests_kwargs = RequestsTransport().serialize_case(case, base_url=case.get_full_base_url(), headers=headers)
|
|
853
852
|
request = requests.Request(**requests_kwargs).prepare()
|
|
854
853
|
elapsed = cast(float, timeout) # It is defined and not empty, since the exception happened
|
|
855
854
|
check_result = result.add_failure(
|
|
@@ -939,14 +938,14 @@ def _wsgi_test(
|
|
|
939
938
|
feedback: Feedback,
|
|
940
939
|
max_response_time: int | None,
|
|
941
940
|
) -> WSGIResponse:
|
|
941
|
+
from ...transports.responses import WSGIResponse
|
|
942
|
+
|
|
942
943
|
with catching_logs(LogCaptureHandler(), level=logging.DEBUG) as recorded:
|
|
943
|
-
start = time.monotonic()
|
|
944
944
|
hook_context = HookContext(operation=case.operation)
|
|
945
|
-
kwargs = {"headers": headers}
|
|
945
|
+
kwargs: dict[str, Any] = {"headers": headers}
|
|
946
946
|
hooks.dispatch("process_call_kwargs", hook_context, case, kwargs)
|
|
947
|
-
response = case.
|
|
948
|
-
|
|
949
|
-
context = TargetContext(case=case, response=response, response_time=elapsed)
|
|
947
|
+
response = cast(WSGIResponse, case.call(**kwargs))
|
|
948
|
+
context = TargetContext(case=case, response=response, response_time=response.elapsed.total_seconds())
|
|
950
949
|
run_targets(targets, context)
|
|
951
950
|
result.logs.extend(recorded.records)
|
|
952
951
|
status = Status.success
|
|
@@ -967,7 +966,7 @@ def _wsgi_test(
|
|
|
967
966
|
finally:
|
|
968
967
|
feedback.add_test_case(case, response)
|
|
969
968
|
if store_interactions:
|
|
970
|
-
result.store_wsgi_response(case, response, headers, elapsed, status, check_results)
|
|
969
|
+
result.store_wsgi_response(case, response, headers, response.elapsed.total_seconds(), status, check_results)
|
|
971
970
|
return response
|
|
972
971
|
|
|
973
972
|
|
|
@@ -1037,7 +1036,7 @@ def _asgi_test(
|
|
|
1037
1036
|
hook_context = HookContext(operation=case.operation)
|
|
1038
1037
|
kwargs: dict[str, Any] = {"headers": headers}
|
|
1039
1038
|
hooks.dispatch("process_call_kwargs", hook_context, case, kwargs)
|
|
1040
|
-
response = case.
|
|
1039
|
+
response = case.call(**kwargs)
|
|
1041
1040
|
context = TargetContext(case=case, response=response, response_time=response.elapsed.total_seconds())
|
|
1042
1041
|
run_targets(targets, context)
|
|
1043
1042
|
status = Status.success
|
|
@@ -4,28 +4,30 @@ They all consist of primitive types and don't have references to schemas, app, e
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
|
+
|
|
7
8
|
import logging
|
|
8
9
|
import re
|
|
10
|
+
import textwrap
|
|
9
11
|
from dataclasses import dataclass, field
|
|
10
|
-
from typing import
|
|
12
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
11
13
|
|
|
12
|
-
from ..transports import serialize_payload
|
|
13
14
|
from ..code_samples import get_excluded_headers
|
|
14
15
|
from ..exceptions import (
|
|
16
|
+
BodyInGetRequestError,
|
|
17
|
+
DeadlineExceeded,
|
|
15
18
|
FailureContext,
|
|
16
19
|
InternalError,
|
|
17
|
-
make_unique_by_key,
|
|
18
|
-
format_exception,
|
|
19
|
-
extract_requests_exception_details,
|
|
20
|
-
RuntimeErrorType,
|
|
21
|
-
DeadlineExceeded,
|
|
22
|
-
OperationSchemaError,
|
|
23
|
-
BodyInGetRequestError,
|
|
24
20
|
InvalidRegularExpression,
|
|
21
|
+
OperationSchemaError,
|
|
22
|
+
RuntimeErrorType,
|
|
25
23
|
SerializationError,
|
|
26
24
|
UnboundPrefixError,
|
|
25
|
+
extract_requests_exception_details,
|
|
26
|
+
format_exception,
|
|
27
|
+
make_unique_by_key,
|
|
27
28
|
)
|
|
28
29
|
from ..models import Case, Check, Interaction, Request, Response, Status, TestResult
|
|
30
|
+
from ..transports import serialize_payload
|
|
29
31
|
|
|
30
32
|
if TYPE_CHECKING:
|
|
31
33
|
import hypothesis.errors
|
|
@@ -108,6 +110,7 @@ class SerializedCheck:
|
|
|
108
110
|
@classmethod
|
|
109
111
|
def from_check(cls, check: Check) -> SerializedCheck:
|
|
110
112
|
import requests
|
|
113
|
+
|
|
111
114
|
from ..transports.responses import WSGIResponse
|
|
112
115
|
|
|
113
116
|
if check.response is not None:
|
|
@@ -140,6 +143,25 @@ class SerializedCheck:
|
|
|
140
143
|
history=history,
|
|
141
144
|
)
|
|
142
145
|
|
|
146
|
+
@property
|
|
147
|
+
def title(self) -> str:
|
|
148
|
+
if self.context is not None:
|
|
149
|
+
return self.context.title
|
|
150
|
+
return f"Custom check failed: `{self.name}`"
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def formatted_message(self) -> str | None:
|
|
154
|
+
if self.context is not None:
|
|
155
|
+
if self.context.message:
|
|
156
|
+
message = self.context.message
|
|
157
|
+
else:
|
|
158
|
+
message = None
|
|
159
|
+
else:
|
|
160
|
+
message = self.message
|
|
161
|
+
if message is not None:
|
|
162
|
+
message = textwrap.indent(message, prefix=" ")
|
|
163
|
+
return message
|
|
164
|
+
|
|
143
165
|
|
|
144
166
|
def _get_headers(headers: dict[str, Any] | CaseInsensitiveDict) -> dict[str, str]:
|
|
145
167
|
return {key: value[0] for key, value in headers.items() if key not in get_excluded_headers()}
|
|
@@ -203,8 +225,8 @@ class SerializedError:
|
|
|
203
225
|
|
|
204
226
|
@classmethod
|
|
205
227
|
def from_exception(cls, exception: Exception) -> SerializedError:
|
|
206
|
-
import requests
|
|
207
228
|
import hypothesis.errors
|
|
229
|
+
import requests
|
|
208
230
|
from hypothesis import HealthCheck
|
|
209
231
|
|
|
210
232
|
title = "Runtime Error"
|
schemathesis/schemas.py
CHANGED
|
@@ -8,11 +8,13 @@ They give only static definitions of paths.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
|
+
|
|
11
12
|
from collections.abc import Mapping, MutableMapping
|
|
12
13
|
from contextlib import nullcontext
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from functools import lru_cache
|
|
15
16
|
from typing import (
|
|
17
|
+
TYPE_CHECKING,
|
|
16
18
|
Any,
|
|
17
19
|
Callable,
|
|
18
20
|
ContextManager,
|
|
@@ -22,7 +24,6 @@ from typing import (
|
|
|
22
24
|
NoReturn,
|
|
23
25
|
Sequence,
|
|
24
26
|
TypeVar,
|
|
25
|
-
TYPE_CHECKING,
|
|
26
27
|
)
|
|
27
28
|
from urllib.parse import quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
|
|
28
29
|
|
|
@@ -30,23 +31,23 @@ import hypothesis
|
|
|
30
31
|
from hypothesis.strategies import SearchStrategy
|
|
31
32
|
from pyrate_limiter import Limiter
|
|
32
33
|
|
|
33
|
-
from .
|
|
34
|
+
from ._dependency_versions import IS_PYRATE_LIMITER_ABOVE_3
|
|
34
35
|
from ._hypothesis import create_test
|
|
35
36
|
from .auths import AuthStorage
|
|
36
37
|
from .code_samples import CodeSampleStyle
|
|
38
|
+
from .constants import NOT_SET
|
|
39
|
+
from .exceptions import OperationSchemaError, UsageError
|
|
37
40
|
from .generation import (
|
|
38
41
|
DEFAULT_DATA_GENERATION_METHODS,
|
|
39
42
|
DataGenerationMethod,
|
|
40
43
|
DataGenerationMethodInput,
|
|
41
44
|
GenerationConfig,
|
|
42
45
|
)
|
|
43
|
-
from ._dependency_versions import IS_PYRATE_LIMITER_ABOVE_3
|
|
44
|
-
from .exceptions import OperationSchemaError, UsageError
|
|
45
46
|
from .hooks import HookContext, HookDispatcher, HookScope, dispatch
|
|
46
|
-
from .internal.result import
|
|
47
|
+
from .internal.result import Ok, Result
|
|
47
48
|
from .models import APIOperation, Case
|
|
48
|
-
from .stateful.state_machine import APIStateMachine
|
|
49
49
|
from .stateful import Stateful, StatefulTest
|
|
50
|
+
from .stateful.state_machine import APIStateMachine
|
|
50
51
|
from .types import (
|
|
51
52
|
Body,
|
|
52
53
|
Cookies,
|
|
@@ -58,9 +59,10 @@ from .types import (
|
|
|
58
59
|
PathParameters,
|
|
59
60
|
Query,
|
|
60
61
|
)
|
|
61
|
-
from .utils import PARAMETRIZE_MARKER, GivenInput,
|
|
62
|
+
from .utils import PARAMETRIZE_MARKER, GivenInput, combine_strategies, given_proxy
|
|
62
63
|
|
|
63
64
|
if TYPE_CHECKING:
|
|
65
|
+
from .transports import Transport
|
|
64
66
|
from .transports.responses import GenericResponse
|
|
65
67
|
|
|
66
68
|
|
|
@@ -75,6 +77,7 @@ def get_full_path(base_path: str, path: str) -> str:
|
|
|
75
77
|
@dataclass(eq=False)
|
|
76
78
|
class BaseSchema(Mapping):
|
|
77
79
|
raw_schema: dict[str, Any]
|
|
80
|
+
transport: Transport
|
|
78
81
|
location: str | None = None
|
|
79
82
|
base_url: str | None = None
|
|
80
83
|
method: Filter | None = None
|
|
@@ -346,6 +349,7 @@ class BaseSchema(Mapping):
|
|
|
346
349
|
code_sample_style=code_sample_style, # type: ignore
|
|
347
350
|
rate_limiter=rate_limiter, # type: ignore
|
|
348
351
|
sanitize_output=sanitize_output, # type: ignore
|
|
352
|
+
transport=self.transport,
|
|
349
353
|
)
|
|
350
354
|
|
|
351
355
|
def get_local_hook_dispatcher(self) -> HookDispatcher | None:
|
|
@@ -234,6 +234,7 @@ def from_dict(
|
|
|
234
234
|
:return: GraphQLSchema
|
|
235
235
|
"""
|
|
236
236
|
from .schemas import GraphQLSchema
|
|
237
|
+
from ... import transports
|
|
237
238
|
|
|
238
239
|
_code_sample_style = CodeSampleStyle.from_str(code_sample_style)
|
|
239
240
|
hook_context = HookContext()
|
|
@@ -252,6 +253,7 @@ def from_dict(
|
|
|
252
253
|
code_sample_style=_code_sample_style,
|
|
253
254
|
rate_limiter=rate_limiter,
|
|
254
255
|
sanitize_output=sanitize_output,
|
|
256
|
+
transport=transports.get(app),
|
|
255
257
|
) # type: ignore
|
|
256
258
|
dispatch("after_load_schema", hook_context, instance)
|
|
257
259
|
return instance
|
|
@@ -20,7 +20,6 @@ from typing import (
|
|
|
20
20
|
from urllib.parse import urlsplit, urlunsplit
|
|
21
21
|
|
|
22
22
|
import graphql
|
|
23
|
-
import requests
|
|
24
23
|
from graphql import GraphQLNamedType
|
|
25
24
|
from hypothesis import strategies as st
|
|
26
25
|
from hypothesis.strategies import SearchStrategy
|
|
@@ -60,39 +59,15 @@ class RootType(enum.Enum):
|
|
|
60
59
|
|
|
61
60
|
@dataclass(repr=False)
|
|
62
61
|
class GraphQLCase(Case):
|
|
63
|
-
def
|
|
64
|
-
final_headers = self._get_headers(headers)
|
|
62
|
+
def _get_url(self, base_url: str | None) -> str:
|
|
65
63
|
base_url = self._get_base_url(base_url)
|
|
66
64
|
# Replace the path, in case if the user provided any path parameters via hooks
|
|
67
65
|
parts = list(urlsplit(base_url))
|
|
68
66
|
parts[2] = self.formatted_path
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"cookies": self.cookies,
|
|
74
|
-
"params": self.query,
|
|
75
|
-
}
|
|
76
|
-
# There is no direct way to have bytes here, but it is a useful pattern to support.
|
|
77
|
-
# It also unifies GraphQLCase with its Open API counterpart where bytes may come from external examples
|
|
78
|
-
if isinstance(self.body, bytes):
|
|
79
|
-
kwargs["data"] = self.body
|
|
80
|
-
# Assume that the payload is JSON, not raw GraphQL queries
|
|
81
|
-
kwargs["headers"].setdefault("Content-Type", "application/json")
|
|
82
|
-
else:
|
|
83
|
-
kwargs["json"] = {"query": self.body}
|
|
84
|
-
return kwargs
|
|
85
|
-
|
|
86
|
-
def as_werkzeug_kwargs(self, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
|
87
|
-
final_headers = self._get_headers(headers)
|
|
88
|
-
return {
|
|
89
|
-
"method": self.method,
|
|
90
|
-
"path": self.operation.schema.get_full_path(self.formatted_path),
|
|
91
|
-
# Convert to a regular dictionary, as we use `CaseInsensitiveDict` which is not supported by Werkzeug
|
|
92
|
-
"headers": dict(final_headers),
|
|
93
|
-
"query_string": self.query,
|
|
94
|
-
"json": {"query": self.body},
|
|
95
|
-
}
|
|
67
|
+
return urlunsplit(parts)
|
|
68
|
+
|
|
69
|
+
def _get_body(self) -> Body | NotSet:
|
|
70
|
+
return self.body if isinstance(self.body, (NotSet, bytes)) else {"query": self.body}
|
|
96
71
|
|
|
97
72
|
def validate_response(
|
|
98
73
|
self,
|
|
@@ -107,15 +82,6 @@ class GraphQLCase(Case):
|
|
|
107
82
|
checks = tuple(check for check in checks if check not in excluded_checks)
|
|
108
83
|
return super().validate_response(response, checks, code_sample_style=code_sample_style)
|
|
109
84
|
|
|
110
|
-
def call_asgi(
|
|
111
|
-
self,
|
|
112
|
-
app: Any = None,
|
|
113
|
-
base_url: str | None = None,
|
|
114
|
-
headers: dict[str, str] | None = None,
|
|
115
|
-
**kwargs: Any,
|
|
116
|
-
) -> requests.Response:
|
|
117
|
-
return super().call_asgi(app=app, base_url=base_url, headers=headers, **kwargs)
|
|
118
|
-
|
|
119
85
|
|
|
120
86
|
C = TypeVar("C", bound=Case)
|
|
121
87
|
|
|
@@ -287,7 +253,7 @@ class GraphQLSchema(BaseSchema):
|
|
|
287
253
|
cookies=cookies,
|
|
288
254
|
query=query,
|
|
289
255
|
body=body,
|
|
290
|
-
media_type=media_type,
|
|
256
|
+
media_type=media_type or "application/json",
|
|
291
257
|
generation_time=0.0,
|
|
292
258
|
)
|
|
293
259
|
|
|
@@ -373,6 +339,7 @@ def get_case_strategy(
|
|
|
373
339
|
operation=operation,
|
|
374
340
|
data_generation_method=data_generation_method,
|
|
375
341
|
generation_time=time.monotonic() - start,
|
|
342
|
+
media_type="application/json",
|
|
376
343
|
) # type: ignore
|
|
377
344
|
context = auths.AuthContext(
|
|
378
345
|
operation=operation,
|