schemathesis 3.35.4__py3-none-any.whl → 3.36.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/__init__.py +5 -5
- schemathesis/_hypothesis.py +12 -6
- schemathesis/_override.py +4 -4
- schemathesis/auths.py +1 -1
- schemathesis/checks.py +8 -5
- schemathesis/cli/__init__.py +23 -26
- schemathesis/cli/callbacks.py +6 -4
- schemathesis/cli/cassettes.py +67 -41
- schemathesis/cli/context.py +7 -6
- schemathesis/cli/junitxml.py +1 -1
- schemathesis/cli/options.py +7 -4
- schemathesis/cli/output/default.py +5 -5
- schemathesis/cli/reporting.py +4 -2
- schemathesis/code_samples.py +4 -3
- schemathesis/contrib/unique_data.py +1 -2
- schemathesis/exceptions.py +4 -3
- schemathesis/extra/_flask.py +4 -1
- schemathesis/extra/pytest_plugin.py +6 -3
- schemathesis/failures.py +2 -1
- schemathesis/filters.py +2 -2
- schemathesis/generation/__init__.py +2 -2
- schemathesis/generation/_hypothesis.py +1 -1
- schemathesis/generation/coverage.py +53 -12
- schemathesis/graphql.py +0 -1
- schemathesis/hooks.py +3 -3
- schemathesis/internal/checks.py +53 -0
- schemathesis/lazy.py +10 -7
- schemathesis/loaders.py +3 -3
- schemathesis/models.py +59 -23
- schemathesis/runner/__init__.py +12 -6
- schemathesis/runner/events.py +1 -1
- schemathesis/runner/impl/context.py +72 -0
- schemathesis/runner/impl/core.py +105 -67
- schemathesis/runner/impl/solo.py +17 -20
- schemathesis/runner/impl/threadpool.py +65 -72
- schemathesis/runner/serialization.py +4 -3
- schemathesis/sanitization.py +2 -1
- schemathesis/schemas.py +20 -22
- schemathesis/serializers.py +2 -0
- schemathesis/service/client.py +1 -1
- schemathesis/service/events.py +4 -1
- schemathesis/service/extensions.py +2 -2
- schemathesis/service/hosts.py +4 -2
- schemathesis/service/models.py +3 -3
- schemathesis/service/report.py +3 -3
- schemathesis/service/serialization.py +4 -2
- schemathesis/specs/graphql/loaders.py +5 -4
- schemathesis/specs/graphql/schemas.py +13 -8
- schemathesis/specs/openapi/checks.py +76 -27
- schemathesis/specs/openapi/definitions.py +1 -5
- schemathesis/specs/openapi/examples.py +92 -2
- schemathesis/specs/openapi/expressions/__init__.py +7 -0
- schemathesis/specs/openapi/expressions/extractors.py +4 -1
- schemathesis/specs/openapi/expressions/nodes.py +5 -3
- schemathesis/specs/openapi/links.py +4 -4
- schemathesis/specs/openapi/loaders.py +6 -5
- schemathesis/specs/openapi/negative/__init__.py +5 -3
- schemathesis/specs/openapi/negative/mutations.py +5 -4
- schemathesis/specs/openapi/parameters.py +4 -2
- schemathesis/specs/openapi/schemas.py +28 -13
- schemathesis/specs/openapi/security.py +6 -4
- schemathesis/specs/openapi/stateful/__init__.py +2 -2
- schemathesis/specs/openapi/stateful/statistic.py +3 -3
- schemathesis/specs/openapi/stateful/types.py +3 -2
- schemathesis/stateful/__init__.py +3 -3
- schemathesis/stateful/config.py +2 -1
- schemathesis/stateful/context.py +13 -3
- schemathesis/stateful/events.py +3 -3
- schemathesis/stateful/runner.py +24 -6
- schemathesis/stateful/sink.py +1 -1
- schemathesis/stateful/state_machine.py +7 -6
- schemathesis/stateful/statistic.py +3 -1
- schemathesis/stateful/validation.py +10 -5
- schemathesis/transports/__init__.py +2 -2
- schemathesis/transports/asgi.py +7 -0
- schemathesis/transports/auth.py +2 -1
- schemathesis/transports/content_types.py +1 -1
- schemathesis/transports/responses.py +2 -1
- schemathesis/utils.py +4 -2
- {schemathesis-3.35.4.dist-info → schemathesis-3.36.0.dist-info}/METADATA +1 -1
- schemathesis-3.36.0.dist-info/RECORD +157 -0
- schemathesis-3.35.4.dist-info/RECORD +0 -154
- {schemathesis-3.35.4.dist-info → schemathesis-3.36.0.dist-info}/WHEEL +0 -0
- {schemathesis-3.35.4.dist-info → schemathesis-3.36.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.35.4.dist-info → schemathesis-3.36.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -22,14 +22,12 @@ from urllib.parse import urlsplit, urlunsplit
|
|
|
22
22
|
|
|
23
23
|
import graphql
|
|
24
24
|
from hypothesis import strategies as st
|
|
25
|
-
from hypothesis.strategies import SearchStrategy
|
|
26
25
|
from hypothesis_graphql import strategies as gql_st
|
|
27
26
|
from requests.structures import CaseInsensitiveDict
|
|
28
27
|
|
|
29
28
|
from ... import auths
|
|
30
|
-
from ...auths import AuthStorage
|
|
31
29
|
from ...checks import not_a_server_error
|
|
32
|
-
from ...constants import NOT_SET
|
|
30
|
+
from ...constants import NOT_SET, SCHEMATHESIS_TEST_CASE_HEADER
|
|
33
31
|
from ...exceptions import OperationNotFound, OperationSchemaError
|
|
34
32
|
from ...generation import DataGenerationMethod, GenerationConfig
|
|
35
33
|
from ...hooks import (
|
|
@@ -40,15 +38,19 @@ from ...hooks import (
|
|
|
40
38
|
should_skip_operation,
|
|
41
39
|
)
|
|
42
40
|
from ...internal.result import Ok, Result
|
|
43
|
-
from ...models import APIOperation, Case,
|
|
41
|
+
from ...models import APIOperation, Case, OperationDefinition
|
|
44
42
|
from ...schemas import APIOperationMap, BaseSchema
|
|
45
|
-
from ...stateful import Stateful, StatefulTest
|
|
46
43
|
from ...types import Body, Cookies, Headers, NotSet, PathParameters, Query
|
|
47
44
|
from ..openapi.constants import LOCATION_TO_CONTAINER
|
|
48
45
|
from ._cache import OperationCache
|
|
49
46
|
from .scalars import CUSTOM_SCALARS, get_extra_scalar_strategies
|
|
50
47
|
|
|
51
48
|
if TYPE_CHECKING:
|
|
49
|
+
from hypothesis.strategies import SearchStrategy
|
|
50
|
+
|
|
51
|
+
from ...auths import AuthStorage
|
|
52
|
+
from ...internal.checks import CheckFunction
|
|
53
|
+
from ...stateful import Stateful, StatefulTest
|
|
52
54
|
from ...transports.responses import GenericResponse
|
|
53
55
|
|
|
54
56
|
|
|
@@ -60,6 +62,9 @@ class RootType(enum.Enum):
|
|
|
60
62
|
|
|
61
63
|
@dataclass(repr=False)
|
|
62
64
|
class GraphQLCase(Case):
|
|
65
|
+
def __hash__(self) -> int:
|
|
66
|
+
return hash(self.as_curl_command({SCHEMATHESIS_TEST_CASE_HEADER: "0"}))
|
|
67
|
+
|
|
63
68
|
def _get_url(self, base_url: str | None) -> str:
|
|
64
69
|
base_url = self._get_base_url(base_url)
|
|
65
70
|
# Replace the path, in case if the user provided any path parameters via hooks
|
|
@@ -77,11 +82,12 @@ class GraphQLCase(Case):
|
|
|
77
82
|
additional_checks: tuple[CheckFunction, ...] = (),
|
|
78
83
|
excluded_checks: tuple[CheckFunction, ...] = (),
|
|
79
84
|
code_sample_style: str | None = None,
|
|
85
|
+
headers: dict[str, Any] | None = None,
|
|
80
86
|
) -> None:
|
|
81
87
|
checks = checks or (not_a_server_error,)
|
|
82
88
|
checks += additional_checks
|
|
83
89
|
checks = tuple(check for check in checks if check not in excluded_checks)
|
|
84
|
-
return super().validate_response(response, checks, code_sample_style=code_sample_style)
|
|
90
|
+
return super().validate_response(response, checks, code_sample_style=code_sample_style, headers=headers)
|
|
85
91
|
|
|
86
92
|
|
|
87
93
|
C = TypeVar("C", bound=Case)
|
|
@@ -185,8 +191,7 @@ class GraphQLSchema(BaseSchema):
|
|
|
185
191
|
return 0
|
|
186
192
|
|
|
187
193
|
def get_all_operations(
|
|
188
|
-
self,
|
|
189
|
-
hooks: HookDispatcher | None = None,
|
|
194
|
+
self, hooks: HookDispatcher | None = None, generation_config: GenerationConfig | None = None
|
|
190
195
|
) -> Generator[Result[APIOperation, OperationSchemaError], None, None]:
|
|
191
196
|
schema = self.client_schema
|
|
192
197
|
for root_type, operation_type in (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
+
import enum
|
|
4
5
|
from http.cookies import SimpleCookie
|
|
5
6
|
from typing import TYPE_CHECKING, Any, Dict, Generator, NoReturn, cast
|
|
6
7
|
from urllib.parse import parse_qs, urlparse
|
|
@@ -25,11 +26,12 @@ from .utils import expand_status_code
|
|
|
25
26
|
if TYPE_CHECKING:
|
|
26
27
|
from requests import PreparedRequest
|
|
27
28
|
|
|
29
|
+
from ...internal.checks import CheckContext
|
|
28
30
|
from ...models import APIOperation, Case
|
|
29
31
|
from ...transports.responses import GenericResponse
|
|
30
32
|
|
|
31
33
|
|
|
32
|
-
def status_code_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
34
|
+
def status_code_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
33
35
|
from .schemas import BaseOpenAPISchema
|
|
34
36
|
|
|
35
37
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -60,7 +62,7 @@ def _expand_responses(responses: dict[str | int, Any]) -> Generator[int, None, N
|
|
|
60
62
|
yield from expand_status_code(code)
|
|
61
63
|
|
|
62
64
|
|
|
63
|
-
def content_type_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
65
|
+
def content_type_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
64
66
|
from .schemas import BaseOpenAPISchema
|
|
65
67
|
|
|
66
68
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -115,7 +117,7 @@ def _reraise_malformed_media_type(case: Case, exc: ValueError, location: str, ac
|
|
|
115
117
|
) from exc
|
|
116
118
|
|
|
117
119
|
|
|
118
|
-
def response_headers_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
120
|
+
def response_headers_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
119
121
|
import jsonschema
|
|
120
122
|
|
|
121
123
|
from .parameters import OpenAPI20Parameter, OpenAPI30Parameter
|
|
@@ -171,11 +173,11 @@ def response_headers_conformance(response: GenericResponse, case: Case) -> bool
|
|
|
171
173
|
)
|
|
172
174
|
except jsonschema.ValidationError as exc:
|
|
173
175
|
exc_class = get_schema_validation_error(case.operation.verbose_name, exc)
|
|
174
|
-
|
|
176
|
+
error_ctx = failures.ValidationErrorContext.from_exception(
|
|
175
177
|
exc, output_config=case.operation.schema.output_config
|
|
176
178
|
)
|
|
177
179
|
try:
|
|
178
|
-
raise exc_class("Response header does not conform to the schema", context=
|
|
180
|
+
raise exc_class("Response header does not conform to the schema", context=error_ctx) from exc
|
|
179
181
|
except Exception as exc:
|
|
180
182
|
errors.append(exc)
|
|
181
183
|
return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
|
|
@@ -203,7 +205,7 @@ def _coerce_header_value(value: str, schema: dict[str, Any]) -> str | int | floa
|
|
|
203
205
|
return value
|
|
204
206
|
|
|
205
207
|
|
|
206
|
-
def response_schema_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
208
|
+
def response_schema_conformance(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
207
209
|
from .schemas import BaseOpenAPISchema
|
|
208
210
|
|
|
209
211
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -211,7 +213,7 @@ def response_schema_conformance(response: GenericResponse, case: Case) -> bool |
|
|
|
211
213
|
return case.operation.validate_response(response)
|
|
212
214
|
|
|
213
215
|
|
|
214
|
-
def negative_data_rejection(response: GenericResponse, case: Case) -> bool | None:
|
|
216
|
+
def negative_data_rejection(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
215
217
|
from .schemas import BaseOpenAPISchema
|
|
216
218
|
|
|
217
219
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
@@ -258,7 +260,7 @@ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
|
|
|
258
260
|
return True
|
|
259
261
|
|
|
260
262
|
|
|
261
|
-
def use_after_free(response: GenericResponse, original: Case) -> bool | None:
|
|
263
|
+
def use_after_free(ctx: CheckContext, response: GenericResponse, original: Case) -> bool | None:
|
|
262
264
|
from ...transports.responses import get_reason
|
|
263
265
|
from .schemas import BaseOpenAPISchema
|
|
264
266
|
|
|
@@ -298,7 +300,7 @@ def use_after_free(response: GenericResponse, original: Case) -> bool | None:
|
|
|
298
300
|
return None
|
|
299
301
|
|
|
300
302
|
|
|
301
|
-
def ensure_resource_availability(response: GenericResponse, original: Case) -> bool | None:
|
|
303
|
+
def ensure_resource_availability(ctx: CheckContext, response: GenericResponse, original: Case) -> bool | None:
|
|
302
304
|
from ...transports.responses import get_reason
|
|
303
305
|
from .schemas import BaseOpenAPISchema
|
|
304
306
|
|
|
@@ -332,7 +334,12 @@ def ensure_resource_availability(response: GenericResponse, original: Case) -> b
|
|
|
332
334
|
return None
|
|
333
335
|
|
|
334
336
|
|
|
335
|
-
|
|
337
|
+
class AuthKind(enum.Enum):
|
|
338
|
+
EXPLICIT = "explicit"
|
|
339
|
+
GENERATED = "generated"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def ignored_auth(ctx: CheckContext, response: GenericResponse, case: Case) -> bool | None:
|
|
336
343
|
"""Check if an operation declares authentication as a requirement but does not actually enforce it."""
|
|
337
344
|
from .schemas import BaseOpenAPISchema
|
|
338
345
|
|
|
@@ -340,32 +347,49 @@ def ignored_auth(response: GenericResponse, case: Case) -> bool | None:
|
|
|
340
347
|
return True
|
|
341
348
|
security_parameters = _get_security_parameters(case.operation)
|
|
342
349
|
# Authentication is required for this API operation and response is successful
|
|
343
|
-
# Will it still be successful if there is no auth?
|
|
344
350
|
if security_parameters and 200 <= response.status_code < 300:
|
|
345
|
-
|
|
346
|
-
|
|
351
|
+
auth = _contains_auth(ctx, response.request, security_parameters)
|
|
352
|
+
if auth == AuthKind.EXPLICIT:
|
|
353
|
+
# Auth is explicitly set, it is expected to be valid
|
|
354
|
+
# Check if invalid auth will give an error
|
|
347
355
|
_remove_auth_from_case(case, security_parameters)
|
|
348
356
|
new_response = case.operation.schema.transport.send(case)
|
|
349
357
|
if 200 <= new_response.status_code < 300:
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
358
|
+
_update_response(response, new_response)
|
|
359
|
+
_raise_no_auth_error(new_response, case.operation.verbose_name, "that requires authentication")
|
|
360
|
+
# Try to set invalid auth and check if it succeeds
|
|
361
|
+
for parameter in security_parameters:
|
|
362
|
+
_set_auth_for_case(case, parameter)
|
|
363
|
+
new_response = case.operation.schema.transport.send(case)
|
|
364
|
+
if 200 <= new_response.status_code < 300:
|
|
365
|
+
_update_response(response, new_response)
|
|
366
|
+
_raise_no_auth_error(new_response, case.operation.verbose_name, "with any auth")
|
|
367
|
+
_remove_auth_from_case(case, security_parameters)
|
|
368
|
+
elif auth == AuthKind.GENERATED:
|
|
369
|
+
# If this auth is generated which means it is likely invalid, then
|
|
370
|
+
# this request should have been an error
|
|
371
|
+
_raise_no_auth_error(response, case.operation.verbose_name, "with invalid auth")
|
|
357
372
|
else:
|
|
358
373
|
# Successful response when there is no auth
|
|
359
|
-
|
|
374
|
+
_raise_no_auth_error(response, case.operation.verbose_name, "that requires authentication")
|
|
360
375
|
return None
|
|
361
376
|
|
|
362
377
|
|
|
363
|
-
def
|
|
378
|
+
def _update_response(old: GenericResponse, new: GenericResponse) -> None:
|
|
379
|
+
# Mutate the response object in place on the best effort basis
|
|
380
|
+
if hasattr(old, "__attrs__"):
|
|
381
|
+
for attribute in new.__attrs__:
|
|
382
|
+
setattr(old, attribute, getattr(new, attribute))
|
|
383
|
+
else:
|
|
384
|
+
old.__dict__.update(new.__dict__)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _raise_no_auth_error(response: GenericResponse, operation: str, suffix: str) -> NoReturn:
|
|
364
388
|
from ...transports.responses import get_reason
|
|
365
389
|
|
|
366
390
|
exc_class = get_ignored_auth_error(operation)
|
|
367
391
|
reason = get_reason(response.status_code)
|
|
368
|
-
message = f"The API returned `{response.status_code} {reason}` for `{operation}`
|
|
392
|
+
message = f"The API returned `{response.status_code} {reason}` for `{operation}` {suffix}."
|
|
369
393
|
raise exc_class(
|
|
370
394
|
failures.IgnoredAuth.title,
|
|
371
395
|
context=failures.IgnoredAuth(message=message),
|
|
@@ -387,7 +411,9 @@ def _get_security_parameters(operation: APIOperation) -> list[SecurityParameter]
|
|
|
387
411
|
]
|
|
388
412
|
|
|
389
413
|
|
|
390
|
-
def _contains_auth(
|
|
414
|
+
def _contains_auth(
|
|
415
|
+
ctx: CheckContext, request: PreparedRequest, security_parameters: list[SecurityParameter]
|
|
416
|
+
) -> AuthKind | None:
|
|
391
417
|
"""Whether a request has authentication declared in the schema."""
|
|
392
418
|
from requests.cookies import RequestsCookieJar
|
|
393
419
|
|
|
@@ -410,10 +436,20 @@ def _contains_auth(request: PreparedRequest, security_parameters: list[SecurityP
|
|
|
410
436
|
return p["in"] == "cookie" and (p["name"] in cookies or p["name"] in header_cookies)
|
|
411
437
|
|
|
412
438
|
for parameter in security_parameters:
|
|
413
|
-
if has_header(parameter)
|
|
414
|
-
|
|
439
|
+
if has_header(parameter):
|
|
440
|
+
if ctx.headers is not None and parameter["name"] in ctx.headers:
|
|
441
|
+
return AuthKind.EXPLICIT
|
|
442
|
+
return AuthKind.GENERATED
|
|
443
|
+
if has_cookie(parameter):
|
|
444
|
+
if ctx.headers is not None and "Cookie" in ctx.headers:
|
|
445
|
+
cookies = cast(RequestsCookieJar, ctx.headers["Cookie"]) # type: ignore
|
|
446
|
+
if parameter["name"] in cookies:
|
|
447
|
+
return AuthKind.EXPLICIT
|
|
448
|
+
return AuthKind.GENERATED
|
|
449
|
+
if has_query(parameter):
|
|
450
|
+
return AuthKind.GENERATED
|
|
415
451
|
|
|
416
|
-
return
|
|
452
|
+
return None
|
|
417
453
|
|
|
418
454
|
|
|
419
455
|
def _remove_auth_from_case(case: Case, security_parameters: list[SecurityParameter]) -> None:
|
|
@@ -431,6 +467,19 @@ def _remove_auth_from_case(case: Case, security_parameters: list[SecurityParamet
|
|
|
431
467
|
case.cookies.pop(name, None)
|
|
432
468
|
|
|
433
469
|
|
|
470
|
+
def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
|
|
471
|
+
name = parameter["name"]
|
|
472
|
+
for location, attr_name in (
|
|
473
|
+
("header", "headers"),
|
|
474
|
+
("query", "query"),
|
|
475
|
+
("cookie", "cookies"),
|
|
476
|
+
):
|
|
477
|
+
if parameter["in"] == location:
|
|
478
|
+
container = getattr(case, attr_name, {})
|
|
479
|
+
container[name] = "SCHEMATHESIS-INVALID-VALUE"
|
|
480
|
+
setattr(case, attr_name, container)
|
|
481
|
+
|
|
482
|
+
|
|
434
483
|
@dataclass
|
|
435
484
|
class ResourcePath:
|
|
436
485
|
"""A path to a resource with variables."""
|
|
@@ -1907,11 +1907,7 @@ _VALIDATORS = [
|
|
|
1907
1907
|
"OPENAPI_31_VALIDATOR",
|
|
1908
1908
|
]
|
|
1909
1909
|
|
|
1910
|
-
__all__ = [
|
|
1911
|
-
"SWAGGER_20",
|
|
1912
|
-
"OPENAPI_30",
|
|
1913
|
-
"OPENAPI_31",
|
|
1914
|
-
] + _VALIDATORS
|
|
1910
|
+
__all__ = ["SWAGGER_20", "OPENAPI_30", "OPENAPI_31", *_VALIDATORS]
|
|
1915
1911
|
|
|
1916
1912
|
_imports = {
|
|
1917
1913
|
"SWAGGER_20_VALIDATOR": lambda: make_validator(SWAGGER_20),
|
|
@@ -4,10 +4,9 @@ from contextlib import suppress
|
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from functools import lru_cache
|
|
6
6
|
from itertools import chain, cycle, islice
|
|
7
|
-
from typing import TYPE_CHECKING, Any, Generator, Union, cast
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Generator, Iterator, Union, cast
|
|
8
8
|
|
|
9
9
|
import requests
|
|
10
|
-
from hypothesis.strategies import SearchStrategy
|
|
11
10
|
from hypothesis_jsonschema import from_schema
|
|
12
11
|
|
|
13
12
|
from ...constants import DEFAULT_RESPONSE_TIMEOUT
|
|
@@ -20,6 +19,8 @@ from .formats import STRING_FORMATS
|
|
|
20
19
|
from .parameters import OpenAPIBody, OpenAPIParameter
|
|
21
20
|
|
|
22
21
|
if TYPE_CHECKING:
|
|
22
|
+
from hypothesis.strategies import SearchStrategy
|
|
23
|
+
|
|
23
24
|
from ...generation import GenerationConfig
|
|
24
25
|
|
|
25
26
|
|
|
@@ -77,6 +78,7 @@ def get_strategies_from_examples(
|
|
|
77
78
|
|
|
78
79
|
def extract_top_level(operation: APIOperation[OpenAPIParameter, Case]) -> Generator[Example, None, None]:
|
|
79
80
|
"""Extract top-level parameter examples from `examples` & `example` fields."""
|
|
81
|
+
responses = find_in_responses(operation)
|
|
80
82
|
for parameter in operation.iter_parameters():
|
|
81
83
|
if "schema" in parameter.definition:
|
|
82
84
|
definitions = [parameter.definition, *_expand_subschemas(parameter.definition["schema"])]
|
|
@@ -106,6 +108,10 @@ def extract_top_level(operation: APIOperation[OpenAPIParameter, Case]) -> Genera
|
|
|
106
108
|
yield ParameterExample(
|
|
107
109
|
container=LOCATION_TO_CONTAINER[parameter.location], name=parameter.name, value=value
|
|
108
110
|
)
|
|
111
|
+
for value in find_matching_in_responses(responses, parameter.name):
|
|
112
|
+
yield ParameterExample(
|
|
113
|
+
container=LOCATION_TO_CONTAINER[parameter.location], name=parameter.name, value=value
|
|
114
|
+
)
|
|
109
115
|
for alternative in operation.body:
|
|
110
116
|
alternative = cast(OpenAPIBody, alternative)
|
|
111
117
|
if "schema" in alternative.definition:
|
|
@@ -349,3 +355,87 @@ def _produce_parameter_combinations(parameters: dict[str, dict[str, list]]) -> G
|
|
|
349
355
|
}
|
|
350
356
|
for container, variants in parameters.items()
|
|
351
357
|
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def find_in_responses(operation: APIOperation) -> dict[str, list[dict[str, Any]]]:
|
|
361
|
+
"""Find schema examples in responses."""
|
|
362
|
+
output: dict[str, list[dict[str, Any]]] = {}
|
|
363
|
+
for status_code, response in operation.definition.raw.get("responses", {}).items():
|
|
364
|
+
if not str(status_code).startswith("2"):
|
|
365
|
+
# Check only 2xx responses
|
|
366
|
+
continue
|
|
367
|
+
if isinstance(response, dict) and "$ref" in response:
|
|
368
|
+
_, response = operation.schema.resolver.resolve_in_scope(response, operation.definition.scope) # type:ignore[attr-defined]
|
|
369
|
+
for media_type, definition in response.get("content", {}).items():
|
|
370
|
+
schema_ref = definition.get("schema", {}).get("$ref")
|
|
371
|
+
if schema_ref:
|
|
372
|
+
name = schema_ref.split("/")[-1]
|
|
373
|
+
else:
|
|
374
|
+
name = f"{status_code}/{media_type}"
|
|
375
|
+
for examples_field, example_field in (
|
|
376
|
+
("examples", "example"),
|
|
377
|
+
("x-examples", "x-example"),
|
|
378
|
+
):
|
|
379
|
+
examples = definition.get(examples_field, {})
|
|
380
|
+
for example in examples.values():
|
|
381
|
+
if "value" in example:
|
|
382
|
+
output.setdefault(name, []).append(example["value"])
|
|
383
|
+
if example_field in definition:
|
|
384
|
+
output.setdefault(name, []).append(definition[example_field])
|
|
385
|
+
return output
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
NOT_FOUND = object()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def find_matching_in_responses(examples: dict[str, list], param: str) -> Iterator[Any]:
|
|
392
|
+
"""Find matching parameter examples."""
|
|
393
|
+
normalized = param.lower()
|
|
394
|
+
is_id_param = normalized.endswith("id")
|
|
395
|
+
# Extract values from response examples that match input parameters.
|
|
396
|
+
# E.g., for `GET /orders/{id}/`, use "id" or "orderId" from `Order` response
|
|
397
|
+
# as examples for the "id" path parameter.
|
|
398
|
+
for schema_name, schema_examples in examples.items():
|
|
399
|
+
for example in schema_examples:
|
|
400
|
+
if not isinstance(example, dict):
|
|
401
|
+
continue
|
|
402
|
+
# Unwrapping example from `{"item": [{...}]}`
|
|
403
|
+
if isinstance(example, dict) and len(example) == 1 and list(example)[0].lower() == schema_name.lower():
|
|
404
|
+
inner = list(example.values())[0]
|
|
405
|
+
if isinstance(inner, list):
|
|
406
|
+
for sub_example in inner:
|
|
407
|
+
found = _find_matching_in_responses(sub_example, schema_name, param, normalized, is_id_param)
|
|
408
|
+
if found is not NOT_FOUND:
|
|
409
|
+
yield found
|
|
410
|
+
continue
|
|
411
|
+
example = inner
|
|
412
|
+
found = _find_matching_in_responses(example, schema_name, param, normalized, is_id_param)
|
|
413
|
+
if found is not NOT_FOUND:
|
|
414
|
+
yield found
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _find_matching_in_responses(
|
|
418
|
+
example: dict[str, Any], schema_name: str, param: str, normalized: str, is_id_param: bool
|
|
419
|
+
) -> Any:
|
|
420
|
+
# Check for exact match
|
|
421
|
+
if param in example:
|
|
422
|
+
return example[param]
|
|
423
|
+
|
|
424
|
+
# Check for case-insensitive match
|
|
425
|
+
for key in example:
|
|
426
|
+
if key.lower() == normalized:
|
|
427
|
+
return example[key]
|
|
428
|
+
else:
|
|
429
|
+
# If no match found and it's an ID parameter, try additional checks
|
|
430
|
+
if is_id_param:
|
|
431
|
+
# Check for 'id' if parameter is '{something}Id'
|
|
432
|
+
if "id" in example:
|
|
433
|
+
return example["id"]
|
|
434
|
+
# Check for '{schemaName}Id' or '{schemaName}_id'
|
|
435
|
+
if normalized == "id" or normalized.startswith(schema_name.lower()):
|
|
436
|
+
for key in (schema_name, schema_name.lower()):
|
|
437
|
+
for suffix in ("_id", "Id"):
|
|
438
|
+
with_suffix = f"{key}{suffix}"
|
|
439
|
+
if with_suffix in example:
|
|
440
|
+
return example[with_suffix]
|
|
441
|
+
return NOT_FOUND
|
|
@@ -11,6 +11,13 @@ from typing import Any
|
|
|
11
11
|
from . import lexer, nodes, parser
|
|
12
12
|
from .context import ExpressionContext
|
|
13
13
|
|
|
14
|
+
__all__ = [
|
|
15
|
+
"lexer",
|
|
16
|
+
"nodes",
|
|
17
|
+
"parser",
|
|
18
|
+
"ExpressionContext",
|
|
19
|
+
]
|
|
20
|
+
|
|
14
21
|
|
|
15
22
|
def evaluate(expr: Any, context: ExpressionContext, evaluate_nested: bool = False) -> Any:
|
|
16
23
|
"""Evaluate runtime expression in context."""
|
|
@@ -4,13 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from enum import Enum, unique
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
8
|
|
|
9
9
|
from requests.structures import CaseInsensitiveDict
|
|
10
10
|
|
|
11
11
|
from .. import references
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .context import ExpressionContext
|
|
15
|
+
from .extractors import Extractor
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
@dataclass
|
|
@@ -10,22 +10,22 @@ from difflib import get_close_matches
|
|
|
10
10
|
from types import SimpleNamespace
|
|
11
11
|
from typing import TYPE_CHECKING, Any, Generator, Literal, NoReturn, Sequence, TypedDict, Union, cast
|
|
12
12
|
|
|
13
|
-
from jsonschema import RefResolver
|
|
14
|
-
|
|
15
13
|
from ...constants import NOT_SET
|
|
16
14
|
from ...internal.copy import fast_deepcopy
|
|
17
15
|
from ...models import APIOperation, Case, TransitionId
|
|
18
|
-
from ...parameters import ParameterSet
|
|
19
16
|
from ...stateful import ParsedData, StatefulTest, UnresolvableLink
|
|
20
17
|
from ...stateful.state_machine import Direction
|
|
21
|
-
from ...types import NotSet
|
|
22
18
|
from . import expressions
|
|
23
19
|
from .constants import LOCATION_TO_CONTAINER
|
|
24
20
|
from .parameters import OpenAPI20Body, OpenAPI30Body, OpenAPIParameter
|
|
25
21
|
from .references import RECURSION_DEPTH_LIMIT, Unresolvable
|
|
26
22
|
|
|
27
23
|
if TYPE_CHECKING:
|
|
24
|
+
from jsonschema import RefResolver
|
|
25
|
+
|
|
26
|
+
from ...parameters import ParameterSet
|
|
28
27
|
from ...transports.responses import GenericResponse
|
|
28
|
+
from ...types import NotSet
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
@dataclass(repr=False)
|
|
@@ -9,7 +9,7 @@ from urllib.parse import urljoin
|
|
|
9
9
|
|
|
10
10
|
from ... import experimental, fixups
|
|
11
11
|
from ...code_samples import CodeSampleStyle
|
|
12
|
-
from ...constants import NOT_SET, WAIT_FOR_SCHEMA_INTERVAL
|
|
12
|
+
from ...constants import DEFAULT_RESPONSE_TIMEOUT, NOT_SET, WAIT_FOR_SCHEMA_INTERVAL
|
|
13
13
|
from ...exceptions import SchemaError, SchemaErrorType
|
|
14
14
|
from ...filters import filter_set_from_components
|
|
15
15
|
from ...generation import (
|
|
@@ -163,11 +163,12 @@ def from_uri(
|
|
|
163
163
|
interval=WAIT_FOR_SCHEMA_INTERVAL,
|
|
164
164
|
)
|
|
165
165
|
def _load_schema(_uri: str, **_kwargs: Any) -> requests.Response:
|
|
166
|
-
return requests.get(_uri, **
|
|
166
|
+
return requests.get(_uri, **_kwargs)
|
|
167
167
|
|
|
168
168
|
else:
|
|
169
169
|
_load_schema = requests.get
|
|
170
170
|
|
|
171
|
+
kwargs.setdefault("timeout", DEFAULT_RESPONSE_TIMEOUT / 1000)
|
|
171
172
|
response = load_schema_from_url(lambda: _load_schema(uri, **kwargs))
|
|
172
173
|
return from_file(
|
|
173
174
|
response.text,
|
|
@@ -441,7 +442,7 @@ def _format_status_codes(status_codes: list[tuple[int, list[str | int]]]) -> str
|
|
|
441
442
|
for status_code, path in status_codes:
|
|
442
443
|
buffer.write(f" - {status_code} at schema['paths']")
|
|
443
444
|
for chunk in path:
|
|
444
|
-
buffer.write(f"[{
|
|
445
|
+
buffer.write(f"[{chunk!r}]")
|
|
445
446
|
buffer.write("['responses']\n")
|
|
446
447
|
return buffer.getvalue().rstrip()
|
|
447
448
|
|
|
@@ -595,9 +596,9 @@ def from_wsgi(
|
|
|
595
596
|
|
|
596
597
|
|
|
597
598
|
def get_loader_for_app(app: Any) -> Callable:
|
|
598
|
-
from
|
|
599
|
+
from ...transports.asgi import is_asgi_app
|
|
599
600
|
|
|
600
|
-
if
|
|
601
|
+
if is_asgi_app(app):
|
|
601
602
|
return from_asgi
|
|
602
603
|
if app.__class__.__module__.startswith("aiohttp."):
|
|
603
604
|
return from_aiohttp
|
|
@@ -2,17 +2,19 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from functools import lru_cache
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
6
|
from urllib.parse import urlencode
|
|
7
7
|
|
|
8
8
|
import jsonschema
|
|
9
9
|
from hypothesis import strategies as st
|
|
10
10
|
from hypothesis_jsonschema import from_schema
|
|
11
11
|
|
|
12
|
-
from ....generation import GenerationConfig
|
|
13
12
|
from ..constants import ALL_KEYWORDS
|
|
14
13
|
from .mutations import MutationContext
|
|
15
|
-
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ....generation import GenerationConfig
|
|
17
|
+
from .types import Draw, Schema
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
@dataclass
|
|
@@ -179,7 +179,7 @@ def remove_required_property(context: MutationContext, draw: Draw, schema: Schem
|
|
|
179
179
|
else:
|
|
180
180
|
candidate = draw(st.sampled_from(sorted(required)))
|
|
181
181
|
enabled_properties = draw(st.shared(FeatureStrategy(), key="properties")) # type: ignore
|
|
182
|
-
candidates = [candidate
|
|
182
|
+
candidates = [candidate, *sorted([prop for prop in required if enabled_properties.is_enabled(prop)])]
|
|
183
183
|
property_name = draw(st.sampled_from(candidates))
|
|
184
184
|
required.remove(property_name)
|
|
185
185
|
if not required:
|
|
@@ -226,9 +226,10 @@ def change_type(context: MutationContext, draw: Draw, schema: Schema) -> Mutatio
|
|
|
226
226
|
candidate = draw(st.sampled_from(sorted(candidates)))
|
|
227
227
|
candidates.remove(candidate)
|
|
228
228
|
enabled_types = draw(st.shared(FeatureStrategy(), key="types")) # type: ignore
|
|
229
|
-
remaining_candidates = [
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
remaining_candidates = [
|
|
230
|
+
candidate,
|
|
231
|
+
*sorted([candidate for candidate in candidates if enabled_types.is_enabled(candidate)]),
|
|
232
|
+
]
|
|
232
233
|
new_type = draw(st.sampled_from(remaining_candidates))
|
|
233
234
|
schema["type"] = new_type
|
|
234
235
|
prevent_unsatisfiable_schema(schema, new_type)
|
|
@@ -2,13 +2,15 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
-
from typing import Any, ClassVar, Iterable
|
|
5
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Iterable
|
|
6
6
|
|
|
7
7
|
from ...exceptions import OperationSchemaError
|
|
8
|
-
from ...models import APIOperation
|
|
9
8
|
from ...parameters import Parameter
|
|
10
9
|
from .converter import to_json_schema_recursive
|
|
11
10
|
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ...models import APIOperation
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
@dataclass(eq=False)
|
|
14
16
|
class OpenAPIParameter(Parameter):
|