schemathesis 4.3.4__py3-none-any.whl → 4.3.6__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.
Potentially problematic release.
This version of schemathesis might be problematic. Click here for more details.
- schemathesis/cli/commands/run/context.py +25 -2
- schemathesis/cli/commands/run/handlers/output.py +4 -3
- schemathesis/core/jsonschema/bundler.py +18 -10
- schemathesis/generation/hypothesis/builder.py +0 -1
- schemathesis/specs/openapi/_hypothesis.py +14 -5
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +40 -2
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +68 -21
- schemathesis/specs/openapi/stateful/dependencies/models.py +10 -3
- schemathesis/specs/openapi/stateful/dependencies/naming.py +25 -6
- schemathesis/specs/openapi/stateful/dependencies/resources.py +6 -2
- {schemathesis-4.3.4.dist-info → schemathesis-4.3.6.dist-info}/METADATA +1 -1
- {schemathesis-4.3.4.dist-info → schemathesis-4.3.6.dist-info}/RECORD +15 -15
- {schemathesis-4.3.4.dist-info → schemathesis-4.3.6.dist-info}/WHEEL +0 -0
- {schemathesis-4.3.4.dist-info → schemathesis-4.3.6.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.3.4.dist-info → schemathesis-4.3.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
5
|
from typing import TYPE_CHECKING, Callable, Generator
|
|
5
6
|
|
|
@@ -121,10 +122,32 @@ class Statistic:
|
|
|
121
122
|
# We need a response to get there, so it should be present
|
|
122
123
|
assert response is not None
|
|
123
124
|
|
|
125
|
+
history = None
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
transition.request_body is not None
|
|
129
|
+
and isinstance(transition.request_body.value, Ok)
|
|
130
|
+
and transition.request_body.value.ok() is UNRESOLVABLE
|
|
131
|
+
):
|
|
132
|
+
history = collect_history(parent, response)
|
|
133
|
+
extraction_failures.add(
|
|
134
|
+
ExtractionFailure(
|
|
135
|
+
id=transition.id,
|
|
136
|
+
case_id=case_id,
|
|
137
|
+
source=parent.value.operation.label,
|
|
138
|
+
target=case.value.operation.label,
|
|
139
|
+
parameter_name="body",
|
|
140
|
+
expression=json.dumps(transition.request_body.definition),
|
|
141
|
+
history=history,
|
|
142
|
+
response=response,
|
|
143
|
+
error=None,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
124
147
|
for params in transition.parameters.values():
|
|
125
148
|
for parameter, extracted in params.items():
|
|
126
149
|
if isinstance(extracted.value, Ok) and extracted.value.ok() is UNRESOLVABLE:
|
|
127
|
-
history = collect_history(parent, response)
|
|
150
|
+
history = history or collect_history(parent, response)
|
|
128
151
|
extraction_failures.add(
|
|
129
152
|
ExtractionFailure(
|
|
130
153
|
id=transition.id,
|
|
@@ -139,7 +162,7 @@ class Statistic:
|
|
|
139
162
|
)
|
|
140
163
|
)
|
|
141
164
|
elif isinstance(extracted.value, Err):
|
|
142
|
-
history = collect_history(parent, response)
|
|
165
|
+
history = history or collect_history(parent, response)
|
|
143
166
|
extraction_failures.add(
|
|
144
167
|
ExtractionFailure(
|
|
145
168
|
id=transition.id,
|
|
@@ -1295,9 +1295,10 @@ class OutputHandler(EventHandler):
|
|
|
1295
1295
|
else:
|
|
1296
1296
|
click.echo(f"\n{indent}{failure.error.__class__.__name__}: {failure.error}")
|
|
1297
1297
|
else:
|
|
1298
|
-
|
|
1299
|
-
f"\n{indent}Could not resolve
|
|
1300
|
-
|
|
1298
|
+
if failure.parameter_name == "body":
|
|
1299
|
+
description = f"\n{indent}Could not resolve request body via {failure.expression}"
|
|
1300
|
+
else:
|
|
1301
|
+
description = f"\n{indent}Could not resolve parameter `{failure.parameter_name}` via `{failure.expression}`"
|
|
1301
1302
|
prefix = "$response.body"
|
|
1302
1303
|
if failure.expression.startswith(prefix):
|
|
1303
1304
|
description += f"\n{indent}Path `{failure.expression[len(prefix) :]}` not found in response"
|
|
@@ -41,6 +41,7 @@ class Bundler:
|
|
|
41
41
|
return schema
|
|
42
42
|
|
|
43
43
|
# Track visited URIs and their local definition names
|
|
44
|
+
inlining_for_recursion: set[str] = set()
|
|
44
45
|
visited: set[str] = set()
|
|
45
46
|
uri_to_def_name: dict[str, str] = {}
|
|
46
47
|
defs = {}
|
|
@@ -90,19 +91,26 @@ class Bundler:
|
|
|
90
91
|
# L is the number of levels. Even quadratic growth can be unacceptable for large schemas.
|
|
91
92
|
#
|
|
92
93
|
# In the future, it **should** be handled by `hypothesis-jsonschema` instead.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# This schema is either infinitely recursive or the sanitization logic misses it
|
|
94
|
+
if resolved_uri in inlining_for_recursion:
|
|
95
|
+
# Check if we're already trying to inline this schema
|
|
96
|
+
# If yes, it means we have an unbreakable cycle
|
|
97
97
|
cycle = scopes[scopes.index(resolved_uri) :]
|
|
98
98
|
raise InfiniteRecursiveReference(reference, cycle)
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
100
|
+
# Track that we're inlining this schema
|
|
101
|
+
inlining_for_recursion.add(resolved_uri)
|
|
102
|
+
try:
|
|
103
|
+
cloned = deepclone(resolved_schema)
|
|
104
|
+
# Sanitize to remove optional recursive references
|
|
105
|
+
sanitize(cloned)
|
|
106
|
+
|
|
107
|
+
result = {key: _bundle_recursive(value) for key, value in current.items() if key != "$ref"}
|
|
108
|
+
bundled_clone = _bundle_recursive(cloned)
|
|
109
|
+
assert isinstance(bundled_clone, dict)
|
|
110
|
+
result.update(bundled_clone)
|
|
111
|
+
return result
|
|
112
|
+
finally:
|
|
113
|
+
inlining_for_recursion.discard(resolved_uri)
|
|
106
114
|
elif resolved_uri not in visited:
|
|
107
115
|
# Bundle only new schemas
|
|
108
116
|
visit(resolved_uri)
|
|
@@ -530,7 +530,6 @@ def _iter_coverage_cases(
|
|
|
530
530
|
instant = Instant()
|
|
531
531
|
responses = list(operation.responses.iter_examples())
|
|
532
532
|
# NOTE: The HEAD method is excluded
|
|
533
|
-
unexpected_methods = unexpected_methods or {"get", "put", "post", "delete", "options", "patch", "trace"}
|
|
534
533
|
custom_formats = _build_custom_formats(generation_config)
|
|
535
534
|
|
|
536
535
|
seen_negative = coverage.HashSet()
|
|
@@ -56,7 +56,7 @@ StrategyFactory = Callable[
|
|
|
56
56
|
|
|
57
57
|
@st.composite # type: ignore
|
|
58
58
|
def openapi_cases(
|
|
59
|
-
draw:
|
|
59
|
+
draw: st.DrawFn,
|
|
60
60
|
*,
|
|
61
61
|
operation: APIOperation,
|
|
62
62
|
hooks: HookDispatcher | None = None,
|
|
@@ -116,7 +116,7 @@ def openapi_cases(
|
|
|
116
116
|
else:
|
|
117
117
|
candidates = operation.body.items
|
|
118
118
|
parameter = draw(st.sampled_from(candidates))
|
|
119
|
-
strategy = _get_body_strategy(parameter, strategy_factory, operation, generation_config)
|
|
119
|
+
strategy = _get_body_strategy(parameter, strategy_factory, operation, generation_config, draw)
|
|
120
120
|
strategy = apply_hooks(operation, ctx, hooks, strategy, ParameterLocation.BODY)
|
|
121
121
|
# Parameter may have a wildcard media type. In this case, choose any supported one
|
|
122
122
|
possible_media_types = sorted(
|
|
@@ -200,11 +200,15 @@ def openapi_cases(
|
|
|
200
200
|
return instance
|
|
201
201
|
|
|
202
202
|
|
|
203
|
+
OPTIONAL_BODY_RATE = 0.05
|
|
204
|
+
|
|
205
|
+
|
|
203
206
|
def _get_body_strategy(
|
|
204
207
|
parameter: OpenApiBody,
|
|
205
208
|
strategy_factory: StrategyFactory,
|
|
206
209
|
operation: APIOperation,
|
|
207
210
|
generation_config: GenerationConfig,
|
|
211
|
+
draw: st.DrawFn,
|
|
208
212
|
) -> st.SearchStrategy:
|
|
209
213
|
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
|
210
214
|
|
|
@@ -220,7 +224,12 @@ def _get_body_strategy(
|
|
|
220
224
|
generation_config,
|
|
221
225
|
operation.schema.adapter.jsonschema_validator_cls,
|
|
222
226
|
)
|
|
223
|
-
|
|
227
|
+
# It is likely will be rejected, hence choose it rarely
|
|
228
|
+
if (
|
|
229
|
+
not parameter.is_required
|
|
230
|
+
and draw(st.floats(min_value=0.0, max_value=1.0, allow_infinity=False, allow_nan=False, allow_subnormal=False))
|
|
231
|
+
< OPTIONAL_BODY_RATE
|
|
232
|
+
):
|
|
224
233
|
strategy |= st.just(NOT_SET)
|
|
225
234
|
return strategy
|
|
226
235
|
|
|
@@ -228,7 +237,7 @@ def _get_body_strategy(
|
|
|
228
237
|
def get_parameters_value(
|
|
229
238
|
value: dict[str, Any] | None,
|
|
230
239
|
location: ParameterLocation,
|
|
231
|
-
draw:
|
|
240
|
+
draw: st.DrawFn,
|
|
232
241
|
operation: APIOperation,
|
|
233
242
|
ctx: HookContext,
|
|
234
243
|
hooks: HookDispatcher | None,
|
|
@@ -279,7 +288,7 @@ def generate_parameter(
|
|
|
279
288
|
location: ParameterLocation,
|
|
280
289
|
explicit: dict[str, Any] | None,
|
|
281
290
|
operation: APIOperation,
|
|
282
|
-
draw:
|
|
291
|
+
draw: st.DrawFn,
|
|
283
292
|
ctx: HookContext,
|
|
284
293
|
hooks: HookDispatcher | None,
|
|
285
294
|
generator: GenerationMode,
|
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
from typing import TYPE_CHECKING, Any
|
|
9
9
|
|
|
10
|
+
from schemathesis.core import NOT_SET
|
|
10
11
|
from schemathesis.core.compat import RefResolutionError
|
|
11
12
|
from schemathesis.core.result import Ok
|
|
12
13
|
from schemathesis.specs.openapi.stateful.dependencies.inputs import extract_inputs, update_input_field_bindings
|
|
@@ -107,9 +108,10 @@ def inject_links(schema: BaseOpenAPISchema) -> int:
|
|
|
107
108
|
for link_name, definition in response_links.links.items():
|
|
108
109
|
inferred_link = definition.to_openapi()
|
|
109
110
|
|
|
110
|
-
# Check if duplicate exists
|
|
111
|
+
# Check if duplicate / subsets exists
|
|
111
112
|
if normalized_existing:
|
|
112
|
-
|
|
113
|
+
normalized = _normalize_link(inferred_link, schema)
|
|
114
|
+
if any(_is_subset_link(normalized, existing) for existing in normalized_existing):
|
|
113
115
|
continue
|
|
114
116
|
|
|
115
117
|
# Find unique name if collision exists
|
|
@@ -170,3 +172,39 @@ def _resolve_link_name_collision(proposed_name: str, existing_links: dict[str, A
|
|
|
170
172
|
if candidate not in existing_links:
|
|
171
173
|
return candidate
|
|
172
174
|
suffix += 1
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _is_subset_link(inferred: NormalizedLink, existing: NormalizedLink) -> bool:
|
|
178
|
+
"""Check if inferred link is a subset of existing link."""
|
|
179
|
+
# Must target the same operation
|
|
180
|
+
if inferred.path != existing.path or inferred.method != existing.method:
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
# Inferred parameters must be subset of existing parameters
|
|
184
|
+
if not inferred.parameters.issubset(existing.parameters):
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
# Inferred request body must be subset of existing body
|
|
188
|
+
return _is_request_body_subset(inferred.request_body, existing.request_body)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_request_body_subset(inferred_body: Any, existing_body: Any) -> bool:
|
|
192
|
+
"""Check if inferred body is a subset of existing body."""
|
|
193
|
+
# Empty inferred body is always a subset
|
|
194
|
+
if not inferred_body:
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
# If existing is empty but inferred isn't, not a subset
|
|
198
|
+
if not existing_body:
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
# Both must be dicts for subset comparison, otherwise check for equality
|
|
202
|
+
if not isinstance(inferred_body, dict) or not isinstance(existing_body, dict):
|
|
203
|
+
return inferred_body == existing_body
|
|
204
|
+
|
|
205
|
+
# Check if all inferred fields exist in existing with same values
|
|
206
|
+
for key, value in inferred_body.items():
|
|
207
|
+
if existing_body.get(key, NOT_SET) != value:
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
return True
|
|
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Iterator
|
|
|
5
5
|
from schemathesis.core import media_types
|
|
6
6
|
from schemathesis.core.errors import MalformedMediaType
|
|
7
7
|
from schemathesis.core.jsonschema.bundler import BUNDLE_STORAGE_KEY
|
|
8
|
+
from schemathesis.core.jsonschema.types import get_type
|
|
8
9
|
from schemathesis.core.parameters import ParameterLocation
|
|
9
10
|
from schemathesis.specs.openapi.stateful.dependencies import naming
|
|
10
11
|
from schemathesis.specs.openapi.stateful.dependencies.models import (
|
|
@@ -36,6 +37,7 @@ def extract_inputs(
|
|
|
36
37
|
Connects each parameter (e.g., `userId`) to its resource definition (`User`),
|
|
37
38
|
creating placeholder resources if not yet discovered from their schemas.
|
|
38
39
|
"""
|
|
40
|
+
known_dependencies = set()
|
|
39
41
|
for param in operation.path_parameters:
|
|
40
42
|
input_slot = _resolve_parameter_dependency(
|
|
41
43
|
parameter_name=param.name,
|
|
@@ -47,12 +49,16 @@ def extract_inputs(
|
|
|
47
49
|
canonicalization_cache=canonicalization_cache,
|
|
48
50
|
)
|
|
49
51
|
if input_slot is not None:
|
|
52
|
+
if input_slot.resource.source >= DefinitionSource.SCHEMA_WITH_PROPERTIES:
|
|
53
|
+
known_dependencies.add(input_slot.resource.name)
|
|
50
54
|
yield input_slot
|
|
51
55
|
|
|
52
56
|
for body in operation.body:
|
|
53
57
|
try:
|
|
54
58
|
if media_types.is_json(body.media_type):
|
|
55
|
-
yield from _resolve_body_dependencies(
|
|
59
|
+
yield from _resolve_body_dependencies(
|
|
60
|
+
body=body, operation=operation, resources=resources, known_dependencies=known_dependencies
|
|
61
|
+
)
|
|
56
62
|
except MalformedMediaType:
|
|
57
63
|
continue
|
|
58
64
|
|
|
@@ -162,8 +168,23 @@ def _find_resource_in_responses(
|
|
|
162
168
|
return None
|
|
163
169
|
|
|
164
170
|
|
|
171
|
+
GENERIC_FIELD_NAMES = frozenset(
|
|
172
|
+
{
|
|
173
|
+
"body",
|
|
174
|
+
"text",
|
|
175
|
+
"content",
|
|
176
|
+
"message",
|
|
177
|
+
"description",
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
165
182
|
def _resolve_body_dependencies(
|
|
166
|
-
*,
|
|
183
|
+
*,
|
|
184
|
+
body: OpenApiBody,
|
|
185
|
+
operation: APIOperation,
|
|
186
|
+
resources: ResourceMap,
|
|
187
|
+
known_dependencies: set[str],
|
|
167
188
|
) -> Iterator[InputSlot]:
|
|
168
189
|
schema = body.raw_schema
|
|
169
190
|
if not isinstance(schema, dict):
|
|
@@ -178,31 +199,57 @@ def _resolve_body_dependencies(
|
|
|
178
199
|
|
|
179
200
|
# Inspect each property that could be a part of some other resource
|
|
180
201
|
properties = resolved.get("properties", {})
|
|
202
|
+
required = resolved.get("required", [])
|
|
181
203
|
path = operation.path
|
|
182
|
-
for property_name in properties:
|
|
204
|
+
for property_name, subschema in properties.items():
|
|
183
205
|
resource_name = naming.from_parameter(property_name, path)
|
|
184
|
-
if resource_name is None:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
if resource_name is not None:
|
|
207
|
+
resource = resources.get(resource_name)
|
|
208
|
+
if resource is None:
|
|
209
|
+
resource = ResourceDefinition.inferred_from_parameter(
|
|
210
|
+
name=resource_name,
|
|
211
|
+
parameter_name=property_name,
|
|
212
|
+
)
|
|
213
|
+
resources[resource_name] = resource
|
|
214
|
+
field = property_name
|
|
215
|
+
else:
|
|
216
|
+
field = (
|
|
217
|
+
naming.find_matching_field(
|
|
218
|
+
parameter=property_name,
|
|
219
|
+
resource=resource_name,
|
|
220
|
+
fields=resource.fields,
|
|
221
|
+
)
|
|
222
|
+
or "id"
|
|
200
223
|
)
|
|
201
|
-
|
|
224
|
+
yield InputSlot(
|
|
225
|
+
resource=resource,
|
|
226
|
+
resource_field=field,
|
|
227
|
+
parameter_name=property_name,
|
|
228
|
+
parameter_location=ParameterLocation.BODY,
|
|
202
229
|
)
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# Skip generic property names & optional fields (at least for now)
|
|
233
|
+
if property_name in GENERIC_FIELD_NAMES or property_name not in required:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Find candidate resources among known dependencies that actually have this field
|
|
237
|
+
candidates = [
|
|
238
|
+
resources[dep] for dep in known_dependencies if dep in resources and property_name in resources[dep].fields
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
# Skip ambiguous cases when multiple resources have same field name
|
|
242
|
+
if len(candidates) != 1:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
resource = candidates[0]
|
|
246
|
+
# Ensure the target field supports the same type
|
|
247
|
+
if not resource.types[property_name] & set(get_type(subschema)):
|
|
248
|
+
continue
|
|
249
|
+
|
|
203
250
|
yield InputSlot(
|
|
204
251
|
resource=resource,
|
|
205
|
-
resource_field=
|
|
252
|
+
resource_field=property_name,
|
|
206
253
|
parameter_name=property_name,
|
|
207
254
|
parameter_location=ParameterLocation.BODY,
|
|
208
255
|
)
|
|
@@ -73,6 +73,11 @@ class DependencyGraph:
|
|
|
73
73
|
parameters = {
|
|
74
74
|
f"{input_slot.parameter_location.value}.{input_slot.parameter_name}": f"$response.body#{body_pointer}",
|
|
75
75
|
}
|
|
76
|
+
existing = links.get(link_name)
|
|
77
|
+
if existing is not None:
|
|
78
|
+
existing.parameters.update(parameters)
|
|
79
|
+
existing.request_body.update(request_body)
|
|
80
|
+
continue
|
|
76
81
|
links[link_name] = LinkDefinition(
|
|
77
82
|
operation_ref=f"#/paths/{consumer_path}/{consumer.method}",
|
|
78
83
|
parameters=parameters,
|
|
@@ -267,18 +272,20 @@ class ResourceDefinition:
|
|
|
267
272
|
name: str
|
|
268
273
|
# A sorted list of resource fields
|
|
269
274
|
fields: list[str]
|
|
275
|
+
# Field types mapping
|
|
276
|
+
types: dict[str, set[str]]
|
|
270
277
|
# How this resource was created
|
|
271
278
|
source: DefinitionSource
|
|
272
279
|
|
|
273
|
-
__slots__ = ("name", "fields", "source")
|
|
280
|
+
__slots__ = ("name", "fields", "types", "source")
|
|
274
281
|
|
|
275
282
|
@classmethod
|
|
276
283
|
def without_properties(cls, name: str) -> ResourceDefinition:
|
|
277
|
-
return cls(name=name, fields=[], source=DefinitionSource.SCHEMA_WITHOUT_PROPERTIES)
|
|
284
|
+
return cls(name=name, fields=[], types={}, source=DefinitionSource.SCHEMA_WITHOUT_PROPERTIES)
|
|
278
285
|
|
|
279
286
|
@classmethod
|
|
280
287
|
def inferred_from_parameter(cls, name: str, parameter_name: str) -> ResourceDefinition:
|
|
281
|
-
return cls(name=name, fields=[parameter_name], source=DefinitionSource.PARAMETER_INFERENCE)
|
|
288
|
+
return cls(name=name, fields=[parameter_name], types={}, source=DefinitionSource.PARAMETER_INFERENCE)
|
|
282
289
|
|
|
283
290
|
|
|
284
291
|
class DefinitionSource(enum.IntEnum):
|
|
@@ -4,26 +4,45 @@ from __future__ import annotations
|
|
|
4
4
|
def from_parameter(parameter: str, path: str) -> str | None:
|
|
5
5
|
# TODO: support other naming patterns
|
|
6
6
|
# Named like "userId" -> look for "User" resource
|
|
7
|
-
if parameter.endswith("Id"):
|
|
7
|
+
if parameter.endswith("Id") and len(parameter) > 2:
|
|
8
8
|
return to_pascal_case(parameter[:-2])
|
|
9
9
|
# Named like "user_id" -> look for "User" resource
|
|
10
10
|
elif parameter.endswith("_id"):
|
|
11
11
|
return to_pascal_case(parameter[:-3])
|
|
12
12
|
# Just "id" -> infer from path context
|
|
13
13
|
elif parameter == "id":
|
|
14
|
-
return from_path(path)
|
|
14
|
+
return from_path(path, parameter_name=parameter)
|
|
15
15
|
return None
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def from_path(path: str) -> str | None:
|
|
19
|
-
|
|
18
|
+
def from_path(path: str, parameter_name: str | None = None) -> str | None:
|
|
19
|
+
"""Detect resource name from OpenAPI path."""
|
|
20
|
+
segments = [s for s in path.split("/") if s]
|
|
20
21
|
|
|
21
22
|
if not segments:
|
|
22
23
|
# API Root
|
|
23
24
|
return None
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
# If parameter name provided, find the resource it refers to
|
|
27
|
+
if parameter_name:
|
|
28
|
+
placeholder = f"{{{parameter_name}}}"
|
|
29
|
+
try:
|
|
30
|
+
param_index = segments.index(placeholder)
|
|
31
|
+
if param_index > 0:
|
|
32
|
+
resource_segment = segments[param_index - 1]
|
|
33
|
+
if "{" not in resource_segment:
|
|
34
|
+
singular = to_singular(resource_segment)
|
|
35
|
+
return to_pascal_case(singular)
|
|
36
|
+
except ValueError:
|
|
37
|
+
pass # Parameter not found in path
|
|
38
|
+
|
|
39
|
+
# Fallback to last non-parameter segment
|
|
40
|
+
non_param_segments = [s for s in segments if "{" not in s]
|
|
41
|
+
if non_param_segments:
|
|
42
|
+
singular = to_singular(non_param_segments[-1])
|
|
43
|
+
return to_pascal_case(singular)
|
|
44
|
+
|
|
45
|
+
return None
|
|
27
46
|
|
|
28
47
|
|
|
29
48
|
IRREGULAR_TO_PLURAL = {
|
|
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, Iterator, Mapping, cast
|
|
|
5
5
|
|
|
6
6
|
from schemathesis.core.errors import InfiniteRecursiveReference
|
|
7
7
|
from schemathesis.core.jsonschema.bundler import BundleError
|
|
8
|
+
from schemathesis.core.jsonschema.types import get_type
|
|
8
9
|
from schemathesis.specs.openapi.adapter.parameters import resource_name_from_ref
|
|
9
10
|
from schemathesis.specs.openapi.adapter.references import maybe_resolve
|
|
10
11
|
from schemathesis.specs.openapi.stateful.dependencies import naming
|
|
@@ -265,18 +266,21 @@ def _extract_resource_from_schema(
|
|
|
265
266
|
|
|
266
267
|
properties = resolved.get("properties")
|
|
267
268
|
if properties:
|
|
268
|
-
fields =
|
|
269
|
+
fields = sorted(properties)
|
|
270
|
+
types = {field: set(get_type(subschema)) for field, subschema in properties.items()}
|
|
269
271
|
source = DefinitionSource.SCHEMA_WITH_PROPERTIES
|
|
270
272
|
else:
|
|
271
273
|
fields = []
|
|
274
|
+
types = {}
|
|
272
275
|
source = DefinitionSource.SCHEMA_WITHOUT_PROPERTIES
|
|
273
276
|
if resource is not None:
|
|
274
277
|
if resource.source < source:
|
|
275
278
|
resource.source = source
|
|
276
279
|
resource.fields = fields
|
|
280
|
+
resource.types = types
|
|
277
281
|
updated_resources.add(resource_name)
|
|
278
282
|
else:
|
|
279
|
-
resource = ResourceDefinition(name=resource_name, fields=fields, source=source)
|
|
283
|
+
resource = ResourceDefinition(name=resource_name, fields=fields, types=types, source=source)
|
|
280
284
|
resources[resource_name] = resource
|
|
281
285
|
|
|
282
286
|
return resource
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: schemathesis
|
|
3
|
-
Version: 4.3.
|
|
3
|
+
Version: 4.3.6
|
|
4
4
|
Summary: Property-based testing framework for Open API and GraphQL based apps
|
|
5
5
|
Project-URL: Documentation, https://schemathesis.readthedocs.io/en/stable/
|
|
6
6
|
Project-URL: Changelog, https://github.com/schemathesis/schemathesis/blob/master/CHANGELOG.md
|
|
@@ -13,7 +13,7 @@ schemathesis/cli/core.py,sha256=ue7YUdVo3YvuzGL4s6i62NL6YqNDeVPBSnQ1znrvG2w,480
|
|
|
13
13
|
schemathesis/cli/commands/__init__.py,sha256=DNzKEnXu7GjGSVe0244ZErmygUBA3nGSyVY6JP3ixD0,3740
|
|
14
14
|
schemathesis/cli/commands/data.py,sha256=_ALywjIeCZjuaoDQFy-Kj8RZkEGqXd-Y95O47h8Jszs,171
|
|
15
15
|
schemathesis/cli/commands/run/__init__.py,sha256=_ApiSVh9q-TsJQ_-IiVBNnLCtTCDMTnOLwuJhOvbCp4,18925
|
|
16
|
-
schemathesis/cli/commands/run/context.py,sha256=
|
|
16
|
+
schemathesis/cli/commands/run/context.py,sha256=vej33l5yOhlJ5gLXDwat9WCW_XdhrHNc9pdIQQYddoY,9004
|
|
17
17
|
schemathesis/cli/commands/run/events.py,sha256=ew0TQOc9T2YBZynYWv95k9yfAk8-hGuZDLMxjT8EhvY,1595
|
|
18
18
|
schemathesis/cli/commands/run/executor.py,sha256=_koznTX0DoELPN_1mxr9K_Qg7-9MPXWdld1MFn3YG_Y,5329
|
|
19
19
|
schemathesis/cli/commands/run/filters.py,sha256=pzkNRcf5vLPSsMfnvt711GNzRSBK5iZIFjPA0fiH1N4,1701
|
|
@@ -23,7 +23,7 @@ schemathesis/cli/commands/run/handlers/__init__.py,sha256=TPZ3KdGi8m0fjlN0GjA31M
|
|
|
23
23
|
schemathesis/cli/commands/run/handlers/base.py,sha256=qUtDvtr3F6were_BznfnaPpMibGJMnQ5CA9aEzcIUBc,1306
|
|
24
24
|
schemathesis/cli/commands/run/handlers/cassettes.py,sha256=LzvQp--Ub5MXF7etet7fQD0Ufloh1R0j2X1o9dT8Z4k,19253
|
|
25
25
|
schemathesis/cli/commands/run/handlers/junitxml.py,sha256=qiFvM4-SlM67sep003SkLqPslzaEb4nOm3bkzw-DO-Q,2602
|
|
26
|
-
schemathesis/cli/commands/run/handlers/output.py,sha256=
|
|
26
|
+
schemathesis/cli/commands/run/handlers/output.py,sha256=pPp5-lJP3Zir1sTA7fmlhc-u1Jn17enXZNUerQMr56M,64166
|
|
27
27
|
schemathesis/cli/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
28
|
schemathesis/cli/ext/fs.py,sha256=dHQYBjQozQmuSSfXVp-2KWFK0ESOb_w-lV2SptfMfco,461
|
|
29
29
|
schemathesis/cli/ext/groups.py,sha256=kQ37t6qeArcKaY2y5VxyK3_KwAkBKCVm58IYV8gewds,2720
|
|
@@ -69,7 +69,7 @@ schemathesis/core/transport.py,sha256=LQcamAkFqJ0HuXQzepevAq2MCJW-uq5Nm-HE9yc7HM
|
|
|
69
69
|
schemathesis/core/validation.py,sha256=b0USkKzkWvdz3jOW1JXYc_TfYshfKZeP7xAUnMqcNoc,2303
|
|
70
70
|
schemathesis/core/version.py,sha256=dOBUWrY3-uA2NQXJp9z7EtZgkR6jYeLg8sMhQCL1mcI,205
|
|
71
71
|
schemathesis/core/jsonschema/__init__.py,sha256=gBZGsXIpK2EFfcp8x0b69dqzWAm2OeZHepKImkkLvoE,320
|
|
72
|
-
schemathesis/core/jsonschema/bundler.py,sha256=
|
|
72
|
+
schemathesis/core/jsonschema/bundler.py,sha256=rHaNAVgBn0XvAk3t9dHsyym1xK4FyBW7zR1GLejPD0A,8204
|
|
73
73
|
schemathesis/core/jsonschema/keywords.py,sha256=pjseXTfH9OItNs_Qq6ubkhNWQOrxTnwHmrP_jxrHeJU,631
|
|
74
74
|
schemathesis/core/jsonschema/references.py,sha256=c2Q4IKWUbwENNtkbFaqf8r3LLZu6GFE5YLnYQlg5tPg,6069
|
|
75
75
|
schemathesis/core/jsonschema/types.py,sha256=C7f9g8yKFuoxC5_0YNIh8QAyGU0-tj8pzTMfMDjjjVM,1248
|
|
@@ -99,7 +99,7 @@ schemathesis/generation/metrics.py,sha256=cZU5HdeAMcLFEDnTbNE56NuNq4P0N4ew-g1NEz
|
|
|
99
99
|
schemathesis/generation/modes.py,sha256=Q1fhjWr3zxabU5qdtLvKfpMFZJAwlW9pnxgenjeXTyU,481
|
|
100
100
|
schemathesis/generation/overrides.py,sha256=xI2djHsa42fzP32xpxgxO52INixKagf5DjDAWJYswM8,3890
|
|
101
101
|
schemathesis/generation/hypothesis/__init__.py,sha256=68BHULoXQC1WjFfw03ga5lvDGZ-c-J7H_fNEuUzFWRw,4976
|
|
102
|
-
schemathesis/generation/hypothesis/builder.py,sha256=
|
|
102
|
+
schemathesis/generation/hypothesis/builder.py,sha256=j7R_X9Z_50xjwIl_Z8DVBe6P1r8leVNvQME7mRLXM1A,38520
|
|
103
103
|
schemathesis/generation/hypothesis/examples.py,sha256=6eGaKUEC3elmKsaqfKj1sLvM8EHc-PWT4NRBq4NI0Rs,1409
|
|
104
104
|
schemathesis/generation/hypothesis/given.py,sha256=sTZR1of6XaHAPWtHx2_WLlZ50M8D5Rjux0GmWkWjDq4,2337
|
|
105
105
|
schemathesis/generation/hypothesis/reporting.py,sha256=uDVow6Ya8YFkqQuOqRsjbzsbyP4KKfr3jA7ZaY4FuKY,279
|
|
@@ -129,7 +129,7 @@ schemathesis/specs/graphql/scalars.py,sha256=6lew8mnwhrtg23leiEbG43mLGPLlRln8mCl
|
|
|
129
129
|
schemathesis/specs/graphql/schemas.py,sha256=GKJcnTAT1wUzzUr3r6wiTfiAdFLcgFQjYRRz7x4VQl0,14457
|
|
130
130
|
schemathesis/specs/graphql/validation.py,sha256=-W1Noc1MQmTb4RX-gNXMeU2qkgso4mzVfHxtdLkCPKM,1422
|
|
131
131
|
schemathesis/specs/openapi/__init__.py,sha256=C5HOsfuDJGq_3mv8CRBvRvb0Diy1p0BFdqyEXMS-loE,238
|
|
132
|
-
schemathesis/specs/openapi/_hypothesis.py,sha256=
|
|
132
|
+
schemathesis/specs/openapi/_hypothesis.py,sha256=O8vN-koBjzBVZfpD3pmgIt6ecU4ddAPHOxTAORd23Lo,22642
|
|
133
133
|
schemathesis/specs/openapi/checks.py,sha256=YYV6j6idyw2ubY4sLp-avs2OVEkAWeIihjT0xiV1RRA,30669
|
|
134
134
|
schemathesis/specs/openapi/converter.py,sha256=4a6-8STT5snF7B-t6IsOIGdK5rV16oNqsdvWL7VFf2M,6472
|
|
135
135
|
schemathesis/specs/openapi/definitions.py,sha256=8htclglV3fW6JPBqs59lgM4LnA25Mm9IptXBPb_qUT0,93949
|
|
@@ -164,12 +164,12 @@ schemathesis/specs/openapi/stateful/__init__.py,sha256=CQx2WJ3mKn5qmYRc90DqsG9w3
|
|
|
164
164
|
schemathesis/specs/openapi/stateful/control.py,sha256=QaXLSbwQWtai5lxvvVtQV3BLJ8n5ePqSKB00XFxp-MA,3695
|
|
165
165
|
schemathesis/specs/openapi/stateful/inference.py,sha256=B99jSTDVi2yKxU7-raIb91xpacOrr0nZkEZY5Ej3eCY,9783
|
|
166
166
|
schemathesis/specs/openapi/stateful/links.py,sha256=SSA66mU50FFBz7e6sA37CfL-Vt0OY3gont72oFSvZYU,8163
|
|
167
|
-
schemathesis/specs/openapi/stateful/dependencies/__init__.py,sha256=
|
|
168
|
-
schemathesis/specs/openapi/stateful/dependencies/inputs.py,sha256=
|
|
169
|
-
schemathesis/specs/openapi/stateful/dependencies/models.py,sha256=
|
|
170
|
-
schemathesis/specs/openapi/stateful/dependencies/naming.py,sha256=
|
|
167
|
+
schemathesis/specs/openapi/stateful/dependencies/__init__.py,sha256=0JM-FrY6Awv6gl-qDHaaK7pXbt_GKutBKPyIaph8apA,7842
|
|
168
|
+
schemathesis/specs/openapi/stateful/dependencies/inputs.py,sha256=1qVVIlzx52qsy55Pht9dYNtn2dewRSiHegfrBO1RD8c,10347
|
|
169
|
+
schemathesis/specs/openapi/stateful/dependencies/models.py,sha256=HxdVcVebjUFhSlSs_M8vDB-BnYfYwLGceIQAzytawrs,11324
|
|
170
|
+
schemathesis/specs/openapi/stateful/dependencies/naming.py,sha256=HfpkCB1GglX1BAKXer3llvPkQsk8wx0QZhZq7ANcdMM,12214
|
|
171
171
|
schemathesis/specs/openapi/stateful/dependencies/outputs.py,sha256=zvVUfQWNIuhMkKDpz5hsVGkkvkefLt1EswpJAnHajOw,1186
|
|
172
|
-
schemathesis/specs/openapi/stateful/dependencies/resources.py,sha256=
|
|
172
|
+
schemathesis/specs/openapi/stateful/dependencies/resources.py,sha256=4bgXILFgC1_y9aU_4scaNw3lkJ6laW5MMkLYh3Ph4Hg,9894
|
|
173
173
|
schemathesis/specs/openapi/stateful/dependencies/schemas.py,sha256=yMu13RsXIPDeZT1tATTxI1vkpYhjs-XFSFEvx3_Xh_Q,14094
|
|
174
174
|
schemathesis/specs/openapi/types/__init__.py,sha256=VPsWtLJle__Kodw_QqtQ3OuvBzBcCIKsTOrXy3eA7OU,66
|
|
175
175
|
schemathesis/specs/openapi/types/v3.py,sha256=Vondr9Amk6JKCIM6i6RGcmTUjFfPgOOqzBXqerccLpo,1468
|
|
@@ -179,8 +179,8 @@ schemathesis/transport/prepare.py,sha256=erYXRaxpQokIDzaIuvt_csHcw72iHfCyNq8VNEz
|
|
|
179
179
|
schemathesis/transport/requests.py,sha256=wriRI9fprTplE_qEZLEz1TerX6GwkE3pwr6ZnU2o6vQ,10648
|
|
180
180
|
schemathesis/transport/serialization.py,sha256=GwO6OAVTmL1JyKw7HiZ256tjV4CbrRbhQN0ep1uaZwI,11157
|
|
181
181
|
schemathesis/transport/wsgi.py,sha256=kQtasFre6pjdJWRKwLA_Qb-RyQHCFNpaey9ubzlFWKI,5907
|
|
182
|
-
schemathesis-4.3.
|
|
183
|
-
schemathesis-4.3.
|
|
184
|
-
schemathesis-4.3.
|
|
185
|
-
schemathesis-4.3.
|
|
186
|
-
schemathesis-4.3.
|
|
182
|
+
schemathesis-4.3.6.dist-info/METADATA,sha256=fSBJbdXmS3wufMePS7qdtcEmUy4eMoiV28sqkp0db68,8540
|
|
183
|
+
schemathesis-4.3.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
184
|
+
schemathesis-4.3.6.dist-info/entry_points.txt,sha256=hiK3un-xfgPdwj9uj16YVDtTNpO128bmk0U82SMv8ZQ,152
|
|
185
|
+
schemathesis-4.3.6.dist-info/licenses/LICENSE,sha256=2Ve4J8v5jMQAWrT7r1nf3bI8Vflk3rZVQefiF2zpxwg,1121
|
|
186
|
+
schemathesis-4.3.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|