schemathesis 3.25.6__py3-none-any.whl → 4.0.0a1__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 +27 -65
- schemathesis/auths.py +102 -82
- schemathesis/checks.py +126 -46
- schemathesis/cli/__init__.py +11 -1760
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +35 -0
- schemathesis/cli/commands/run/executor.py +138 -0
- schemathesis/cli/commands/run/filters.py +194 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +746 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
- schemathesis/cli/constants.py +5 -52
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +39 -10
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +285 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
- schemathesis/core/output/__init__.py +69 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +31 -0
- schemathesis/{internal → core}/result.py +1 -1
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +337 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +50 -67
- schemathesis/engine/phases/stateful/__init__.py +65 -0
- schemathesis/engine/phases/stateful/_executor.py +326 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +174 -0
- schemathesis/engine/phases/unit/_executor.py +321 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +241 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +18 -14
- schemathesis/filters.py +103 -14
- schemathesis/generation/__init__.py +21 -37
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/generation/hypothesis/__init__.py +30 -0
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/hypothesis/examples.py +50 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +115 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +99 -67
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +412 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -261
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +25 -0
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +7 -5
- schemathesis/specs/graphql/schemas.py +215 -187
- schemathesis/specs/graphql/validation.py +11 -18
- schemathesis/specs/openapi/__init__.py +7 -1
- schemathesis/specs/openapi/_cache.py +122 -0
- schemathesis/specs/openapi/_hypothesis.py +146 -165
- schemathesis/specs/openapi/checks.py +565 -67
- schemathesis/specs/openapi/converter.py +33 -6
- schemathesis/specs/openapi/definitions.py +11 -18
- schemathesis/specs/openapi/examples.py +139 -23
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +4 -6
- schemathesis/specs/openapi/expressions/extractors.py +23 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +38 -14
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +45 -0
- schemathesis/specs/openapi/links.py +65 -165
- schemathesis/specs/openapi/media_types.py +32 -0
- schemathesis/specs/openapi/negative/__init__.py +7 -3
- schemathesis/specs/openapi/negative/mutations.py +24 -8
- schemathesis/specs/openapi/parameters.py +46 -30
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +47 -57
- schemathesis/specs/openapi/schemas.py +478 -369
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +11 -6
- schemathesis/specs/openapi/stateful/__init__.py +185 -73
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +143 -28
- schemathesis/transport/wsgi.py +165 -0
- schemathesis-4.0.0a1.dist-info/METADATA +297 -0
- schemathesis-4.0.0a1.dist-info/RECORD +152 -0
- {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
- {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -17
- schemathesis/_hypothesis.py +0 -246
- schemathesis/_override.py +0 -49
- schemathesis/cli/cassettes.py +0 -375
- schemathesis/cli/context.py +0 -58
- schemathesis/cli/debug.py +0 -26
- schemathesis/cli/handlers.py +0 -16
- schemathesis/cli/junitxml.py +0 -43
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -790
- schemathesis/cli/output/short.py +0 -44
- schemathesis/cli/sanitization.py +0 -20
- schemathesis/code_samples.py +0 -149
- schemathesis/constants.py +0 -55
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -15
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -560
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -17
- schemathesis/failures.py +0 -209
- schemathesis/fixups/__init__.py +0 -36
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -29
- schemathesis/graphql.py +0 -4
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/copy.py +0 -13
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -34
- schemathesis/internal/jsonschema.py +0 -35
- schemathesis/internal/transformation.py +0 -15
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -361
- schemathesis/loaders.py +0 -120
- schemathesis/models.py +0 -1234
- schemathesis/parameters.py +0 -86
- schemathesis/runner/__init__.py +0 -570
- schemathesis/runner/events.py +0 -329
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -1035
- schemathesis/runner/impl/solo.py +0 -90
- schemathesis/runner/impl/threadpool.py +0 -400
- schemathesis/runner/serialization.py +0 -411
- schemathesis/sanitization.py +0 -248
- schemathesis/serializers.py +0 -323
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -201
- schemathesis/service/client.py +0 -100
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -57
- schemathesis/service/hosts.py +0 -107
- schemathesis/service/metadata.py +0 -46
- schemathesis/service/models.py +0 -49
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -199
- schemathesis/service/usage.py +0 -65
- schemathesis/specs/graphql/loaders.py +0 -344
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/loaders.py +0 -667
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis/specs/openapi/validation.py +0 -25
- schemathesis/stateful/__init__.py +0 -133
- schemathesis/targets.py +0 -45
- schemathesis/throttling.py +0 -41
- schemathesis/transports/__init__.py +0 -5
- schemathesis/transports/auth.py +0 -15
- schemathesis/transports/headers.py +0 -35
- schemathesis/transports/responses.py +0 -52
- schemathesis/types.py +0 -35
- schemathesis/utils.py +0 -169
- schemathesis-3.25.6.dist-info/METADATA +0 -356
- schemathesis-3.25.6.dist-info/RECORD +0 -134
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,7 @@
|
|
1
1
|
"""Schema mutations."""
|
2
|
+
|
2
3
|
from __future__ import annotations
|
4
|
+
|
3
5
|
import enum
|
4
6
|
from dataclasses import dataclass
|
5
7
|
from functools import wraps
|
@@ -9,7 +11,8 @@ from hypothesis import reject
|
|
9
11
|
from hypothesis import strategies as st
|
10
12
|
from hypothesis.strategies._internal.featureflags import FeatureStrategy
|
11
13
|
|
12
|
-
from
|
14
|
+
from schemathesis.core.transforms import deepclone
|
15
|
+
|
13
16
|
from ..utils import get_type, is_header_location
|
14
17
|
from .types import Draw, Schema
|
15
18
|
from .utils import can_negate
|
@@ -79,6 +82,10 @@ class MutationContext:
|
|
79
82
|
def is_path_location(self) -> bool:
|
80
83
|
return self.location == "path"
|
81
84
|
|
85
|
+
@property
|
86
|
+
def is_query_location(self) -> bool:
|
87
|
+
return self.location == "query"
|
88
|
+
|
82
89
|
def mutate(self, draw: Draw) -> Schema:
|
83
90
|
# On the top level, Schemathesis creates "object" schemas for all parameter "in" values except "body", which is
|
84
91
|
# taken as-is. Therefore, we can only apply mutations that won't change the Open API semantics of the schema.
|
@@ -105,7 +112,7 @@ class MutationContext:
|
|
105
112
|
# Body can be of any type and does not have any specific type semantic.
|
106
113
|
mutations = draw(ordered(get_mutations(draw, self.keywords)))
|
107
114
|
# Deep copy all keywords to avoid modifying the original schema
|
108
|
-
new_schema =
|
115
|
+
new_schema = deepclone(self.keywords)
|
109
116
|
enabled_mutations = draw(st.shared(FeatureStrategy(), key="mutations")) # type: ignore
|
110
117
|
# Always apply at least one mutation, otherwise everything is rejected, and we'd like to avoid it
|
111
118
|
# for performance reasons
|
@@ -173,7 +180,7 @@ def remove_required_property(context: MutationContext, draw: Draw, schema: Schem
|
|
173
180
|
else:
|
174
181
|
candidate = draw(st.sampled_from(sorted(required)))
|
175
182
|
enabled_properties = draw(st.shared(FeatureStrategy(), key="properties")) # type: ignore
|
176
|
-
candidates = [candidate
|
183
|
+
candidates = [candidate, *sorted([prop for prop in required if enabled_properties.is_enabled(prop)])]
|
177
184
|
property_name = draw(st.sampled_from(candidates))
|
178
185
|
required.remove(property_name)
|
179
186
|
if not required:
|
@@ -201,8 +208,11 @@ def change_type(context: MutationContext, draw: Draw, schema: Schema) -> Mutatio
|
|
201
208
|
if context.media_type == "application/x-www-form-urlencoded":
|
202
209
|
# Form data should be an object, do not change it
|
203
210
|
return MutationResult.FAILURE
|
204
|
-
#
|
205
|
-
|
211
|
+
# For headers, query and path parameters, if the current type is string, then it already
|
212
|
+
# includes all possible values as those parameters will be stringified before sending,
|
213
|
+
# therefore it can't be negated.
|
214
|
+
types = get_type(schema)
|
215
|
+
if "string" in types and (context.is_header_location or context.is_path_location or context.is_query_location):
|
206
216
|
return MutationResult.FAILURE
|
207
217
|
candidates = _get_type_candidates(context, schema)
|
208
218
|
if not candidates:
|
@@ -217,9 +227,10 @@ def change_type(context: MutationContext, draw: Draw, schema: Schema) -> Mutatio
|
|
217
227
|
candidate = draw(st.sampled_from(sorted(candidates)))
|
218
228
|
candidates.remove(candidate)
|
219
229
|
enabled_types = draw(st.shared(FeatureStrategy(), key="types")) # type: ignore
|
220
|
-
remaining_candidates = [
|
221
|
-
|
222
|
-
|
230
|
+
remaining_candidates = [
|
231
|
+
candidate,
|
232
|
+
*sorted([candidate for candidate in candidates if enabled_types.is_enabled(candidate)]),
|
233
|
+
]
|
223
234
|
new_type = draw(st.sampled_from(remaining_candidates))
|
224
235
|
schema["type"] = new_type
|
225
236
|
prevent_unsatisfiable_schema(schema, new_type)
|
@@ -362,6 +373,11 @@ def negate_constraints(context: MutationContext, draw: Draw, schema: Schema) ->
|
|
362
373
|
# Should we negate this key?
|
363
374
|
if k == "required":
|
364
375
|
return v != []
|
376
|
+
if k in ("example", "examples"):
|
377
|
+
return False
|
378
|
+
if context.is_path_location and k == "minLength" and v == 1:
|
379
|
+
# Empty path parameter will be filtered out
|
380
|
+
return False
|
365
381
|
return not (
|
366
382
|
k in ("type", "properties", "items", "minItems")
|
367
383
|
or (k == "additionalProperties" and context.is_header_location)
|
@@ -1,13 +1,17 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
+
|
2
3
|
import json
|
3
4
|
from dataclasses import dataclass
|
4
|
-
from typing import Any, ClassVar, Iterable
|
5
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Iterable
|
6
|
+
|
7
|
+
from schemathesis.core.errors import InvalidSchema
|
8
|
+
from schemathesis.schemas import Parameter
|
5
9
|
|
6
|
-
from ...exceptions import OperationSchemaError
|
7
|
-
from ...models import APIOperation
|
8
|
-
from ...parameters import Parameter
|
9
10
|
from .converter import to_json_schema_recursive
|
10
11
|
|
12
|
+
if TYPE_CHECKING:
|
13
|
+
from ...schemas import APIOperation
|
14
|
+
|
11
15
|
|
12
16
|
@dataclass(eq=False)
|
13
17
|
class OpenAPIParameter(Parameter):
|
@@ -18,6 +22,8 @@ class OpenAPIParameter(Parameter):
|
|
18
22
|
nullable_field: ClassVar[str]
|
19
23
|
supported_jsonschema_keywords: ClassVar[tuple[str, ...]]
|
20
24
|
|
25
|
+
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
26
|
+
|
21
27
|
@property
|
22
28
|
def description(self) -> str | None:
|
23
29
|
"""A brief parameter description."""
|
@@ -47,16 +53,26 @@ class OpenAPIParameter(Parameter):
|
|
47
53
|
|
48
54
|
@property
|
49
55
|
def is_header(self) -> bool:
|
50
|
-
|
56
|
+
return self.location in ("header", "cookie")
|
51
57
|
|
52
|
-
def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
|
58
|
+
def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
|
53
59
|
"""Convert parameter's definition to JSON Schema."""
|
60
|
+
# JSON Schema allows `examples` as an array
|
61
|
+
examples = []
|
62
|
+
if self.examples_field in self.definition:
|
63
|
+
examples.extend(
|
64
|
+
[example["value"] for example in self.definition[self.examples_field].values() if "value" in example]
|
65
|
+
)
|
66
|
+
if self.example_field in self.definition:
|
67
|
+
examples.append(self.definition[self.example_field])
|
54
68
|
schema = self.from_open_api_to_json_schema(operation, self.definition)
|
55
|
-
|
69
|
+
if examples:
|
70
|
+
schema["examples"] = examples
|
71
|
+
return self.transform_keywords(schema, update_quantifiers=update_quantifiers)
|
56
72
|
|
57
|
-
def transform_keywords(self, schema: dict[str, Any]) -> dict[str, Any]:
|
73
|
+
def transform_keywords(self, schema: dict[str, Any], *, update_quantifiers: bool = True) -> dict[str, Any]:
|
58
74
|
"""Transform Open API specific keywords into JSON Schema compatible form."""
|
59
|
-
definition = to_json_schema_recursive(schema, self.nullable_field)
|
75
|
+
definition = to_json_schema_recursive(schema, self.nullable_field, update_quantifiers=update_quantifiers)
|
60
76
|
# Headers are strings, but it is not always explicitly defined in the schema. By preparing them properly, we
|
61
77
|
# can achieve significant performance improvements for such cases.
|
62
78
|
# For reference (my machine) - running a single test with 100 examples with the resulting strategy:
|
@@ -116,12 +132,10 @@ class OpenAPI20Parameter(OpenAPIParameter):
|
|
116
132
|
"uniqueItems",
|
117
133
|
"enum",
|
118
134
|
"multipleOf",
|
135
|
+
"example",
|
136
|
+
"examples",
|
119
137
|
)
|
120
138
|
|
121
|
-
@property
|
122
|
-
def is_header(self) -> bool:
|
123
|
-
return self.location == "header"
|
124
|
-
|
125
139
|
|
126
140
|
@dataclass(eq=False)
|
127
141
|
class OpenAPI30Parameter(OpenAPIParameter):
|
@@ -162,12 +176,10 @@ class OpenAPI30Parameter(OpenAPIParameter):
|
|
162
176
|
"properties",
|
163
177
|
"additionalProperties",
|
164
178
|
"format",
|
179
|
+
"example",
|
180
|
+
"examples",
|
165
181
|
)
|
166
182
|
|
167
|
-
@property
|
168
|
-
def is_header(self) -> bool:
|
169
|
-
return self.location in ("header", "cookie")
|
170
|
-
|
171
183
|
def from_open_api_to_json_schema(self, operation: APIOperation, open_api_schema: dict[str, Any]) -> dict[str, Any]:
|
172
184
|
open_api_schema = get_parameter_schema(operation, open_api_schema)
|
173
185
|
return super().from_open_api_to_json_schema(operation, open_api_schema)
|
@@ -216,15 +228,17 @@ class OpenAPI20Body(OpenAPIBody, OpenAPI20Parameter):
|
|
216
228
|
"allOf",
|
217
229
|
"properties",
|
218
230
|
"additionalProperties",
|
231
|
+
"example",
|
232
|
+
"examples",
|
219
233
|
)
|
220
234
|
# NOTE. For Open API 2.0 bodies, we still give `x-example` precedence over the schema-level `example` field to keep
|
221
235
|
# the precedence rules consistent.
|
222
236
|
|
223
|
-
def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
|
237
|
+
def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
|
224
238
|
"""Convert body definition to JSON Schema."""
|
225
239
|
# `schema` is required in Open API 2.0 when the `in` keyword is `body`
|
226
240
|
schema = self.definition["schema"]
|
227
|
-
return self.transform_keywords(schema)
|
241
|
+
return self.transform_keywords(schema, update_quantifiers=update_quantifiers)
|
228
242
|
|
229
243
|
|
230
244
|
FORM_MEDIA_TYPES = ("multipart/form-data", "application/x-www-form-urlencoded")
|
@@ -243,13 +257,13 @@ class OpenAPI30Body(OpenAPIBody, OpenAPI30Parameter):
|
|
243
257
|
required: bool = False
|
244
258
|
description: str | None = None
|
245
259
|
|
246
|
-
def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
|
260
|
+
def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
|
247
261
|
"""Convert body definition to JSON Schema."""
|
248
262
|
schema = get_media_type_schema(self.definition)
|
249
|
-
return self.transform_keywords(schema)
|
263
|
+
return self.transform_keywords(schema, update_quantifiers=update_quantifiers)
|
250
264
|
|
251
|
-
def transform_keywords(self, schema: dict[str, Any]) -> dict[str, Any]:
|
252
|
-
definition = super().transform_keywords(schema)
|
265
|
+
def transform_keywords(self, schema: dict[str, Any], *, update_quantifiers: bool = True) -> dict[str, Any]:
|
266
|
+
definition = super().transform_keywords(schema, update_quantifiers=update_quantifiers)
|
253
267
|
if self.is_form:
|
254
268
|
# It significantly reduces the "filtering" part of data generation.
|
255
269
|
definition.setdefault("type", "object")
|
@@ -287,12 +301,14 @@ class OpenAPI20CompositeBody(OpenAPIBody, OpenAPI20Parameter):
|
|
287
301
|
# We generate an object for formData - it is always required.
|
288
302
|
return bool(self.definition)
|
289
303
|
|
290
|
-
def as_json_schema(self, operation: APIOperation) -> dict[str, Any]:
|
304
|
+
def as_json_schema(self, operation: APIOperation, *, update_quantifiers: bool = True) -> dict[str, Any]:
|
291
305
|
"""The composite body is transformed into an "object" JSON Schema."""
|
292
|
-
return parameters_to_json_schema(operation, self.definition)
|
306
|
+
return parameters_to_json_schema(operation, self.definition, update_quantifiers=update_quantifiers)
|
293
307
|
|
294
308
|
|
295
|
-
def parameters_to_json_schema(
|
309
|
+
def parameters_to_json_schema(
|
310
|
+
operation: APIOperation, parameters: Iterable[OpenAPIParameter], *, update_quantifiers: bool = True
|
311
|
+
) -> dict[str, Any]:
|
296
312
|
"""Create an "object" JSON schema from a list of Open API parameters.
|
297
313
|
|
298
314
|
:param List[OpenAPIParameter] parameters: A list of Open API parameters related to the same location. All of
|
@@ -332,7 +348,7 @@ def parameters_to_json_schema(operation: APIOperation, parameters: Iterable[Open
|
|
332
348
|
required = []
|
333
349
|
for parameter in parameters:
|
334
350
|
name = parameter.name
|
335
|
-
properties[name] = parameter.as_json_schema(operation)
|
351
|
+
properties[name] = parameter.as_json_schema(operation, update_quantifiers=update_quantifiers)
|
336
352
|
# If parameter names are duplicated, we need to avoid duplicate entries in `required` anyway
|
337
353
|
if parameter.is_required and name not in required:
|
338
354
|
required.append(name)
|
@@ -345,7 +361,7 @@ MISSING_SCHEMA_OR_CONTENT_MESSAGE = (
|
|
345
361
|
)
|
346
362
|
|
347
363
|
INVALID_SCHEMA_MESSAGE = (
|
348
|
-
'Can not generate data for {location} parameter "{name}"!
|
364
|
+
'Can not generate data for {location} parameter "{name}"! Its schema should be an object, got {schema}'
|
349
365
|
)
|
350
366
|
|
351
367
|
|
@@ -354,7 +370,7 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
|
|
354
370
|
# In Open API 3.0, there could be "schema" or "content" field. They are mutually exclusive.
|
355
371
|
if "schema" in data:
|
356
372
|
if not isinstance(data["schema"], dict):
|
357
|
-
raise
|
373
|
+
raise InvalidSchema(
|
358
374
|
INVALID_SCHEMA_MESSAGE.format(
|
359
375
|
location=data.get("in", ""), name=data.get("name", "<UNKNOWN>"), schema=data["schema"]
|
360
376
|
),
|
@@ -368,7 +384,7 @@ def get_parameter_schema(operation: APIOperation, data: dict[str, Any]) -> dict[
|
|
368
384
|
try:
|
369
385
|
content = data["content"]
|
370
386
|
except KeyError as exc:
|
371
|
-
raise
|
387
|
+
raise InvalidSchema(
|
372
388
|
MISSING_SCHEMA_OR_CONTENT_MESSAGE.format(location=data.get("in", ""), name=data.get("name", "<UNKNOWN>")),
|
373
389
|
path=operation.path,
|
374
390
|
method=operation.method,
|
@@ -0,0 +1,137 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import re
|
4
|
+
from functools import lru_cache
|
5
|
+
|
6
|
+
try: # pragma: no cover
|
7
|
+
import re._constants as sre
|
8
|
+
import re._parser as sre_parse
|
9
|
+
except ImportError:
|
10
|
+
import sre_constants as sre
|
11
|
+
import sre_parse
|
12
|
+
|
13
|
+
ANCHOR = sre.AT
|
14
|
+
REPEATS: tuple
|
15
|
+
if hasattr(sre, "POSSESSIVE_REPEAT"):
|
16
|
+
REPEATS = (sre.MIN_REPEAT, sre.MAX_REPEAT, sre.POSSESSIVE_REPEAT)
|
17
|
+
else:
|
18
|
+
REPEATS = (sre.MIN_REPEAT, sre.MAX_REPEAT)
|
19
|
+
LITERAL = sre.LITERAL
|
20
|
+
IN = sre.IN
|
21
|
+
MAXREPEAT = sre_parse.MAXREPEAT
|
22
|
+
|
23
|
+
|
24
|
+
@lru_cache
|
25
|
+
def update_quantifier(pattern: str, min_length: int | None, max_length: int | None) -> str:
|
26
|
+
"""Update the quantifier of a regular expression based on given min and max lengths."""
|
27
|
+
if not pattern or (min_length in (None, 0) and max_length is None):
|
28
|
+
return pattern
|
29
|
+
|
30
|
+
try:
|
31
|
+
parsed = sre_parse.parse(pattern)
|
32
|
+
return _handle_parsed_pattern(parsed, pattern, min_length, max_length)
|
33
|
+
except re.error:
|
34
|
+
# Invalid pattern
|
35
|
+
return pattern
|
36
|
+
|
37
|
+
|
38
|
+
def _handle_parsed_pattern(parsed: list, pattern: str, min_length: int | None, max_length: int | None) -> str:
|
39
|
+
"""Handle the parsed pattern and update quantifiers based on different cases."""
|
40
|
+
if len(parsed) == 1:
|
41
|
+
op, value = parsed[0]
|
42
|
+
return _update_quantifier(op, value, pattern, min_length, max_length)
|
43
|
+
elif len(parsed) == 2:
|
44
|
+
if parsed[0][0] == ANCHOR:
|
45
|
+
# Starts with an anchor
|
46
|
+
op, value = parsed[1]
|
47
|
+
anchor_length = _get_anchor_length(parsed[0][1])
|
48
|
+
leading_anchor = pattern[:anchor_length]
|
49
|
+
return leading_anchor + _update_quantifier(op, value, pattern[anchor_length:], min_length, max_length)
|
50
|
+
if parsed[1][0] == ANCHOR:
|
51
|
+
# Ends with an anchor
|
52
|
+
op, value = parsed[0]
|
53
|
+
anchor_length = _get_anchor_length(parsed[1][1])
|
54
|
+
trailing_anchor = pattern[-anchor_length:]
|
55
|
+
return _update_quantifier(op, value, pattern[:-anchor_length], min_length, max_length) + trailing_anchor
|
56
|
+
elif len(parsed) == 3 and parsed[0][0] == ANCHOR and parsed[2][0] == ANCHOR:
|
57
|
+
op, value = parsed[1]
|
58
|
+
leading_anchor_length = _get_anchor_length(parsed[0][1])
|
59
|
+
trailing_anchor_length = _get_anchor_length(parsed[2][1])
|
60
|
+
leading_anchor = pattern[:leading_anchor_length]
|
61
|
+
trailing_anchor = pattern[-trailing_anchor_length:]
|
62
|
+
return (
|
63
|
+
leading_anchor
|
64
|
+
+ _update_quantifier(
|
65
|
+
op, value, pattern[leading_anchor_length:-trailing_anchor_length], min_length, max_length
|
66
|
+
)
|
67
|
+
+ trailing_anchor
|
68
|
+
)
|
69
|
+
return pattern
|
70
|
+
|
71
|
+
|
72
|
+
def _get_anchor_length(node_type: int) -> int:
|
73
|
+
"""Determine the length of the anchor based on its type."""
|
74
|
+
if node_type in {sre.AT_BEGINNING_STRING, sre.AT_END_STRING, sre.AT_BOUNDARY, sre.AT_NON_BOUNDARY}:
|
75
|
+
return 2 # \A, \Z, \b, or \B
|
76
|
+
return 1 # ^ or $ or their multiline/locale/unicode variants
|
77
|
+
|
78
|
+
|
79
|
+
def _update_quantifier(op: int, value: tuple, pattern: str, min_length: int | None, max_length: int | None) -> str:
|
80
|
+
"""Update the quantifier based on the operation type and given constraints."""
|
81
|
+
if op in REPEATS:
|
82
|
+
return _handle_repeat_quantifier(value, pattern, min_length, max_length)
|
83
|
+
if op in (LITERAL, IN) and max_length != 0:
|
84
|
+
return _handle_literal_or_in_quantifier(pattern, min_length, max_length)
|
85
|
+
return pattern
|
86
|
+
|
87
|
+
|
88
|
+
def _handle_repeat_quantifier(
|
89
|
+
value: tuple[int, int, tuple], pattern: str, min_length: int | None, max_length: int | None
|
90
|
+
) -> str:
|
91
|
+
"""Handle repeat quantifiers (e.g., '+', '*', '?')."""
|
92
|
+
min_repeat, max_repeat, _ = value
|
93
|
+
min_length, max_length = _build_size(min_repeat, max_repeat, min_length, max_length)
|
94
|
+
if min_length > max_length:
|
95
|
+
return pattern
|
96
|
+
return f"({_strip_quantifier(pattern)})" + _build_quantifier(min_length, max_length)
|
97
|
+
|
98
|
+
|
99
|
+
def _handle_literal_or_in_quantifier(pattern: str, min_length: int | None, max_length: int | None) -> str:
|
100
|
+
"""Handle literal or character class quantifiers."""
|
101
|
+
min_length = 1 if min_length is None else max(min_length, 1)
|
102
|
+
return f"({pattern})" + _build_quantifier(min_length, max_length)
|
103
|
+
|
104
|
+
|
105
|
+
def _build_quantifier(minimum: int | None, maximum: int | None) -> str:
|
106
|
+
"""Construct a quantifier string based on min and max values."""
|
107
|
+
if maximum == MAXREPEAT or maximum is None:
|
108
|
+
return f"{{{minimum or 0},}}"
|
109
|
+
if minimum == maximum:
|
110
|
+
return f"{{{minimum}}}"
|
111
|
+
return f"{{{minimum or 0},{maximum}}}"
|
112
|
+
|
113
|
+
|
114
|
+
def _build_size(min_repeat: int, max_repeat: int, min_length: int | None, max_length: int | None) -> tuple[int, int]:
|
115
|
+
"""Merge the current repetition constraints with the provided min and max lengths."""
|
116
|
+
if min_length is not None:
|
117
|
+
min_repeat = max(min_repeat, min_length)
|
118
|
+
if max_length is not None:
|
119
|
+
if max_repeat == MAXREPEAT:
|
120
|
+
max_repeat = max_length
|
121
|
+
else:
|
122
|
+
max_repeat = min(max_repeat, max_length)
|
123
|
+
return min_repeat, max_repeat
|
124
|
+
|
125
|
+
|
126
|
+
def _strip_quantifier(pattern: str) -> str:
|
127
|
+
"""Remove quantifier from the pattern."""
|
128
|
+
# Lazy & posessive quantifiers
|
129
|
+
if pattern.endswith(("*?", "+?", "??", "*+", "?+", "++")):
|
130
|
+
return pattern[:-2]
|
131
|
+
if pattern.endswith(("?", "*", "+")):
|
132
|
+
pattern = pattern[:-1]
|
133
|
+
if pattern.endswith("}") and "{" in pattern:
|
134
|
+
# Find the start of the exact quantifier and drop everything since that index
|
135
|
+
idx = pattern.rfind("{")
|
136
|
+
pattern = pattern[:idx]
|
137
|
+
return pattern
|
@@ -1,6 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
|
3
|
+
import sys
|
4
4
|
from functools import lru_cache
|
5
5
|
from typing import Any, Callable, Dict, Union, overload
|
6
6
|
from urllib.request import urlopen
|
@@ -8,9 +8,11 @@ from urllib.request import urlopen
|
|
8
8
|
import jsonschema
|
9
9
|
import requests
|
10
10
|
|
11
|
-
from
|
12
|
-
from
|
13
|
-
from
|
11
|
+
from schemathesis.core.compat import RefResolutionError
|
12
|
+
from schemathesis.core.deserialization import deserialize_yaml
|
13
|
+
from schemathesis.core.transforms import deepclone
|
14
|
+
from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT
|
15
|
+
|
14
16
|
from .constants import ALL_KEYWORDS
|
15
17
|
from .converter import to_json_schema_recursive
|
16
18
|
from .utils import get_type
|
@@ -22,7 +24,7 @@ RECURSION_DEPTH_LIMIT = 100
|
|
22
24
|
def load_file_impl(location: str, opener: Callable) -> dict[str, Any]:
|
23
25
|
"""Load a schema from the given file."""
|
24
26
|
with opener(location) as fd:
|
25
|
-
return
|
27
|
+
return deserialize_yaml(fd)
|
26
28
|
|
27
29
|
|
28
30
|
@lru_cache
|
@@ -39,8 +41,8 @@ def load_file_uri(location: str) -> dict[str, Any]:
|
|
39
41
|
|
40
42
|
def load_remote_uri(uri: str) -> Any:
|
41
43
|
"""Load the resource and parse it as YAML / JSON."""
|
42
|
-
response = requests.get(uri, timeout=DEFAULT_RESPONSE_TIMEOUT
|
43
|
-
return
|
44
|
+
response = requests.get(uri, timeout=DEFAULT_RESPONSE_TIMEOUT)
|
45
|
+
return deserialize_yaml(response.content)
|
44
46
|
|
45
47
|
|
46
48
|
JSONType = Union[None, bool, float, str, list, Dict[str, Any]]
|
@@ -55,31 +57,57 @@ class InliningResolver(jsonschema.RefResolver):
|
|
55
57
|
)
|
56
58
|
super().__init__(*args, **kwargs)
|
57
59
|
|
60
|
+
if sys.version_info >= (3, 11):
|
61
|
+
|
62
|
+
def resolve(self, ref: str) -> tuple[str, Any]:
|
63
|
+
try:
|
64
|
+
return super().resolve(ref)
|
65
|
+
except RefResolutionError as exc:
|
66
|
+
exc.add_note(ref)
|
67
|
+
raise
|
68
|
+
else:
|
69
|
+
|
70
|
+
def resolve(self, ref: str) -> tuple[str, Any]:
|
71
|
+
try:
|
72
|
+
return super().resolve(ref)
|
73
|
+
except RefResolutionError as exc:
|
74
|
+
exc.__notes__ = [ref]
|
75
|
+
raise
|
76
|
+
|
58
77
|
@overload
|
59
|
-
def resolve_all(self, item: dict[str, Any], recursion_level: int = 0) -> dict[str, Any]:
|
60
|
-
pass
|
78
|
+
def resolve_all(self, item: dict[str, Any], recursion_level: int = 0) -> dict[str, Any]: ...
|
61
79
|
|
62
80
|
@overload
|
63
|
-
def resolve_all(self, item: list, recursion_level: int = 0) -> list:
|
64
|
-
pass
|
81
|
+
def resolve_all(self, item: list, recursion_level: int = 0) -> list: ...
|
65
82
|
|
66
83
|
def resolve_all(self, item: JSONType, recursion_level: int = 0) -> JSONType:
|
67
84
|
"""Recursively resolve all references in the given object."""
|
85
|
+
resolve = self.resolve_all
|
68
86
|
if isinstance(item, dict):
|
69
87
|
ref = item.get("$ref")
|
70
|
-
if
|
71
|
-
|
88
|
+
if isinstance(ref, str):
|
89
|
+
url, resolved = self.resolve(ref)
|
90
|
+
self.push_scope(url)
|
91
|
+
try:
|
72
92
|
# If the next level of recursion exceeds the limit, then we need to copy it explicitly
|
73
93
|
# In other cases, this method create new objects for mutable types (dict & list)
|
74
94
|
next_recursion_level = recursion_level + 1
|
75
95
|
if next_recursion_level > RECURSION_DEPTH_LIMIT:
|
76
|
-
copied =
|
96
|
+
copied = deepclone(resolved)
|
77
97
|
remove_optional_references(copied)
|
78
98
|
return copied
|
79
|
-
return
|
80
|
-
|
99
|
+
return resolve(resolved, next_recursion_level)
|
100
|
+
finally:
|
101
|
+
self.pop_scope()
|
102
|
+
return {
|
103
|
+
key: resolve(sub_item, recursion_level) if isinstance(sub_item, (dict, list)) else sub_item
|
104
|
+
for key, sub_item in item.items()
|
105
|
+
}
|
81
106
|
if isinstance(item, list):
|
82
|
-
return [
|
107
|
+
return [
|
108
|
+
self.resolve_all(sub_item, recursion_level) if isinstance(sub_item, (dict, list)) else sub_item
|
109
|
+
for sub_item in item
|
110
|
+
]
|
83
111
|
return item
|
84
112
|
|
85
113
|
def resolve_in_scope(self, definition: dict[str, Any], scope: str) -> tuple[list[str], dict[str, Any]]:
|
@@ -89,7 +117,7 @@ class InliningResolver(jsonschema.RefResolver):
|
|
89
117
|
if "$ref" in definition:
|
90
118
|
self.push_scope(scope)
|
91
119
|
try:
|
92
|
-
new_scope, definition =
|
120
|
+
new_scope, definition = self.resolve(definition["$ref"])
|
93
121
|
finally:
|
94
122
|
self.pop_scope()
|
95
123
|
scopes.append(new_scope)
|
@@ -186,7 +214,7 @@ def remove_optional_references(schema: dict[str, Any]) -> None:
|
|
186
214
|
v = s.get(keyword)
|
187
215
|
if v is not None:
|
188
216
|
elided = [sub for sub in v if not can_elide(sub)]
|
189
|
-
if len(elided) == 1 and
|
217
|
+
if len(elided) == 1 and contains_ref(elided[0]):
|
190
218
|
found.append(keyword)
|
191
219
|
return found
|
192
220
|
|
@@ -205,41 +233,3 @@ def remove_optional_references(schema: dict[str, Any]) -> None:
|
|
205
233
|
clean_additional_properties(definition)
|
206
234
|
for k in on_single_item_combinators(definition):
|
207
235
|
del definition[k]
|
208
|
-
|
209
|
-
|
210
|
-
@dataclass
|
211
|
-
class Unresolvable:
|
212
|
-
pass
|
213
|
-
|
214
|
-
|
215
|
-
UNRESOLVABLE = Unresolvable()
|
216
|
-
|
217
|
-
|
218
|
-
def resolve_pointer(document: Any, pointer: str) -> dict | list | str | int | float | None | Unresolvable:
|
219
|
-
"""Implementation is adapted from Rust's `serde-json` crate.
|
220
|
-
|
221
|
-
Ref: https://github.com/serde-rs/json/blob/master/src/value/mod.rs#L751
|
222
|
-
"""
|
223
|
-
if not pointer:
|
224
|
-
return document
|
225
|
-
if not pointer.startswith("/"):
|
226
|
-
return UNRESOLVABLE
|
227
|
-
|
228
|
-
def replace(value: str) -> str:
|
229
|
-
return value.replace("~1", "/").replace("~0", "~")
|
230
|
-
|
231
|
-
tokens = map(replace, pointer.split("/")[1:])
|
232
|
-
target = document
|
233
|
-
for token in tokens:
|
234
|
-
if isinstance(target, dict):
|
235
|
-
target = target.get(token, UNRESOLVABLE)
|
236
|
-
if target is UNRESOLVABLE:
|
237
|
-
return UNRESOLVABLE
|
238
|
-
elif isinstance(target, list):
|
239
|
-
try:
|
240
|
-
target = target[int(token)]
|
241
|
-
except IndexError:
|
242
|
-
return UNRESOLVABLE
|
243
|
-
else:
|
244
|
-
return UNRESOLVABLE
|
245
|
-
return target
|