schemathesis 4.0.0a10__py3-none-any.whl → 4.0.0a12__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 +29 -30
- schemathesis/auths.py +65 -24
- schemathesis/checks.py +73 -39
- schemathesis/cli/commands/__init__.py +51 -3
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +163 -274
- schemathesis/cli/commands/run/context.py +8 -4
- schemathesis/cli/commands/run/events.py +11 -1
- schemathesis/cli/commands/run/executor.py +70 -78
- schemathesis/cli/commands/run/filters.py +15 -165
- schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
- schemathesis/cli/commands/run/handlers/junitxml.py +5 -4
- schemathesis/cli/commands/run/handlers/output.py +195 -121
- schemathesis/cli/commands/run/loaders.py +35 -50
- schemathesis/cli/commands/run/validation.py +52 -162
- schemathesis/cli/core.py +5 -3
- schemathesis/cli/ext/fs.py +7 -5
- schemathesis/cli/ext/options.py +0 -21
- schemathesis/config/__init__.py +189 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +99 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +156 -0
- schemathesis/config/_generation.py +149 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +327 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +187 -0
- schemathesis/config/_projects.py +523 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +885 -0
- schemathesis/core/__init__.py +2 -0
- schemathesis/core/compat.py +16 -9
- schemathesis/core/errors.py +24 -4
- schemathesis/core/failures.py +6 -7
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/output/__init__.py +14 -37
- schemathesis/core/output/sanitization.py +3 -146
- schemathesis/core/transport.py +36 -1
- schemathesis/core/validation.py +16 -0
- schemathesis/engine/__init__.py +2 -4
- schemathesis/engine/context.py +42 -43
- schemathesis/engine/core.py +7 -5
- schemathesis/engine/errors.py +60 -1
- schemathesis/engine/events.py +10 -2
- schemathesis/engine/phases/__init__.py +10 -0
- schemathesis/engine/phases/probes.py +11 -8
- schemathesis/engine/phases/stateful/__init__.py +2 -1
- schemathesis/engine/phases/stateful/_executor.py +104 -46
- schemathesis/engine/phases/stateful/context.py +2 -2
- schemathesis/engine/phases/unit/__init__.py +23 -15
- schemathesis/engine/phases/unit/_executor.py +110 -21
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/errors.py +2 -0
- schemathesis/filters.py +2 -3
- schemathesis/generation/__init__.py +5 -33
- schemathesis/generation/case.py +6 -3
- schemathesis/generation/coverage.py +154 -124
- schemathesis/generation/hypothesis/builder.py +70 -20
- schemathesis/generation/meta.py +3 -3
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +0 -8
- schemathesis/generation/overrides.py +37 -1
- schemathesis/generation/stateful/__init__.py +4 -0
- schemathesis/generation/stateful/state_machine.py +9 -1
- schemathesis/graphql/loaders.py +159 -16
- schemathesis/hooks.py +62 -35
- schemathesis/openapi/checks.py +12 -8
- schemathesis/openapi/generation/filters.py +10 -8
- schemathesis/openapi/loaders.py +142 -17
- schemathesis/pytest/lazy.py +2 -5
- schemathesis/pytest/loaders.py +24 -0
- schemathesis/pytest/plugin.py +33 -2
- schemathesis/schemas.py +21 -66
- schemathesis/specs/graphql/scalars.py +37 -3
- schemathesis/specs/graphql/schemas.py +23 -18
- schemathesis/specs/openapi/_hypothesis.py +26 -28
- schemathesis/specs/openapi/checks.py +37 -36
- schemathesis/specs/openapi/examples.py +4 -3
- schemathesis/specs/openapi/formats.py +32 -5
- schemathesis/specs/openapi/media_types.py +44 -1
- schemathesis/specs/openapi/negative/__init__.py +2 -2
- schemathesis/specs/openapi/patterns.py +46 -16
- schemathesis/specs/openapi/references.py +2 -3
- schemathesis/specs/openapi/schemas.py +19 -22
- schemathesis/specs/openapi/stateful/__init__.py +12 -6
- schemathesis/transport/__init__.py +54 -16
- schemathesis/transport/prepare.py +38 -13
- schemathesis/transport/requests.py +12 -9
- schemathesis/transport/wsgi.py +11 -12
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/METADATA +50 -97
- schemathesis-4.0.0a12.dist-info/RECORD +164 -0
- schemathesis/cli/commands/run/checks.py +0 -79
- schemathesis/cli/commands/run/hypothesis.py +0 -78
- schemathesis/cli/commands/run/reports.py +0 -72
- schemathesis/cli/hooks.py +0 -36
- schemathesis/contrib/__init__.py +0 -9
- schemathesis/contrib/openapi/__init__.py +0 -9
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
- schemathesis/engine/config.py +0 -59
- schemathesis/experimental/__init__.py +0 -72
- schemathesis/generation/targets.py +0 -69
- schemathesis-4.0.0a10.dist-info/RECORD +0 -153
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a12.dist-info}/licenses/LICENSE +0 -0
@@ -22,9 +22,6 @@ from schemathesis.openapi.checks import (
|
|
22
22
|
MalformedMediaType,
|
23
23
|
MissingContentType,
|
24
24
|
MissingHeaders,
|
25
|
-
MissingRequiredHeaderConfig,
|
26
|
-
NegativeDataRejectionConfig,
|
27
|
-
PositiveDataAcceptanceConfig,
|
28
25
|
RejectedPositiveData,
|
29
26
|
UndefinedContentType,
|
30
27
|
UndefinedStatusCode,
|
@@ -185,7 +182,7 @@ def response_headers_conformance(ctx: CheckContext, response: Response, case: Ca
|
|
185
182
|
title="Response header does not conform to the schema",
|
186
183
|
operation=case.operation.label,
|
187
184
|
exc=exc,
|
188
|
-
|
185
|
+
config=case.operation.schema.config.output,
|
189
186
|
)
|
190
187
|
)
|
191
188
|
return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
|
@@ -233,8 +230,8 @@ def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -
|
|
233
230
|
):
|
234
231
|
return True
|
235
232
|
|
236
|
-
config = ctx.config.
|
237
|
-
allowed_statuses = expand_status_codes(config.
|
233
|
+
config = ctx.config.negative_data_rejection
|
234
|
+
allowed_statuses = expand_status_codes(config.expected_statuses or [])
|
238
235
|
|
239
236
|
if (
|
240
237
|
case.meta.generation.mode.is_negative
|
@@ -243,9 +240,9 @@ def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -
|
|
243
240
|
):
|
244
241
|
raise AcceptedNegativeData(
|
245
242
|
operation=case.operation.label,
|
246
|
-
message=f"Allowed statuses: {', '.join(config.
|
243
|
+
message=f"Allowed statuses: {', '.join(config.expected_statuses)}",
|
247
244
|
status_code=response.status_code,
|
248
|
-
|
245
|
+
expected_statuses=config.expected_statuses,
|
249
246
|
)
|
250
247
|
return None
|
251
248
|
|
@@ -261,21 +258,21 @@ def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case)
|
|
261
258
|
):
|
262
259
|
return True
|
263
260
|
|
264
|
-
config = ctx.config.
|
265
|
-
allowed_statuses = expand_status_codes(config.
|
261
|
+
config = ctx.config.positive_data_acceptance
|
262
|
+
allowed_statuses = expand_status_codes(config.expected_statuses or [])
|
266
263
|
|
267
264
|
if case.meta.generation.mode.is_positive and response.status_code not in allowed_statuses:
|
268
265
|
raise RejectedPositiveData(
|
269
266
|
operation=case.operation.label,
|
270
|
-
message=f"Allowed statuses: {', '.join(config.
|
267
|
+
message=f"Allowed statuses: {', '.join(config.expected_statuses)}",
|
271
268
|
status_code=response.status_code,
|
272
|
-
allowed_statuses=config.
|
269
|
+
allowed_statuses=config.expected_statuses,
|
273
270
|
)
|
274
271
|
return None
|
275
272
|
|
276
273
|
|
274
|
+
@schemathesis.check
|
277
275
|
def missing_required_header(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
278
|
-
# NOTE: This check is intentionally not registered with `@schemathesis.check` because it is experimental
|
279
276
|
meta = case.meta
|
280
277
|
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or is_unexpected_http_status_case(case):
|
281
278
|
return None
|
@@ -287,16 +284,17 @@ def missing_required_header(ctx: CheckContext, response: Response, case: Case) -
|
|
287
284
|
and data.description.startswith("Missing ")
|
288
285
|
):
|
289
286
|
if data.parameter.lower() == "authorization":
|
290
|
-
|
287
|
+
expected_statuses = {401}
|
291
288
|
else:
|
292
|
-
config = ctx.config.
|
293
|
-
|
294
|
-
if response.status_code not in
|
295
|
-
allowed = f"Allowed statuses: {', '.join(map(str,
|
289
|
+
config = ctx.config.missing_required_header
|
290
|
+
expected_statuses = expand_status_codes(config.expected_statuses or [])
|
291
|
+
if response.status_code not in expected_statuses:
|
292
|
+
allowed = f"Allowed statuses: {', '.join(map(str, expected_statuses))}"
|
296
293
|
raise AssertionError(f"Unexpected response status for a missing header: {response.status_code}\n{allowed}")
|
297
294
|
return None
|
298
295
|
|
299
296
|
|
297
|
+
@schemathesis.check
|
300
298
|
def unsupported_method(ctx: CheckContext, response: Response, case: Case) -> bool | None:
|
301
299
|
meta = case.meta
|
302
300
|
if meta is None or not isinstance(meta.phase.data, CoveragePhaseData) or response.request.method == "OPTIONS":
|
@@ -354,12 +352,12 @@ def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool |
|
|
354
352
|
if response.status_code == 404 or response.status_code >= 500:
|
355
353
|
return None
|
356
354
|
|
357
|
-
for related_case in ctx.
|
358
|
-
parent = ctx.
|
355
|
+
for related_case in ctx._find_related(case_id=case.id):
|
356
|
+
parent = ctx._find_parent(case_id=related_case.id)
|
359
357
|
if not parent:
|
360
358
|
continue
|
361
359
|
|
362
|
-
parent_response = ctx.
|
360
|
+
parent_response = ctx._find_response(case_id=parent.id)
|
363
361
|
|
364
362
|
if (
|
365
363
|
related_case.operation.method.lower() == "delete"
|
@@ -397,10 +395,10 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
|
|
397
395
|
if not (400 <= response.status_code < 500):
|
398
396
|
return None
|
399
397
|
|
400
|
-
parent = ctx.
|
398
|
+
parent = ctx._find_parent(case_id=case.id)
|
401
399
|
if parent is None:
|
402
400
|
return None
|
403
|
-
parent_response = ctx.
|
401
|
+
parent_response = ctx._find_response(case_id=parent.id)
|
404
402
|
if parent_response is None:
|
405
403
|
return None
|
406
404
|
|
@@ -426,8 +424,8 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
|
|
426
424
|
return None
|
427
425
|
|
428
426
|
# Look for any successful DELETE operations on this resource
|
429
|
-
for related_case in ctx.
|
430
|
-
related_response = ctx.
|
427
|
+
for related_case in ctx._find_related(case_id=case.id):
|
428
|
+
related_response = ctx._find_response(case_id=related_case.id)
|
431
429
|
if (
|
432
430
|
related_case.operation.method.upper() == "DELETE"
|
433
431
|
and related_response is not None
|
@@ -480,25 +478,25 @@ def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | No
|
|
480
478
|
# Auth is explicitly set, it is expected to be valid
|
481
479
|
# Check if invalid auth will give an error
|
482
480
|
no_auth_case = remove_auth(case, security_parameters)
|
483
|
-
kwargs = ctx.
|
481
|
+
kwargs = ctx._transport_kwargs or {}
|
484
482
|
kwargs.copy()
|
485
483
|
if "headers" in kwargs:
|
486
484
|
headers = kwargs["headers"].copy()
|
487
485
|
_remove_auth_from_explicit_headers(headers, security_parameters)
|
488
486
|
kwargs["headers"] = headers
|
489
487
|
kwargs.pop("session", None)
|
490
|
-
ctx.
|
488
|
+
ctx._record_case(parent_id=case.id, case=no_auth_case)
|
491
489
|
no_auth_response = case.operation.schema.transport.send(no_auth_case, **kwargs)
|
492
|
-
ctx.
|
490
|
+
ctx._record_response(case_id=no_auth_case.id, response=no_auth_response)
|
493
491
|
if no_auth_response.status_code != 401:
|
494
492
|
_raise_no_auth_error(no_auth_response, no_auth_case, "that requires authentication")
|
495
493
|
# Try to set invalid auth and check if it succeeds
|
496
494
|
for parameter in security_parameters:
|
497
495
|
invalid_auth_case = remove_auth(case, security_parameters)
|
498
496
|
_set_auth_for_case(invalid_auth_case, parameter)
|
499
|
-
ctx.
|
497
|
+
ctx._record_case(parent_id=case.id, case=invalid_auth_case)
|
500
498
|
invalid_auth_response = case.operation.schema.transport.send(invalid_auth_case, **kwargs)
|
501
|
-
ctx.
|
499
|
+
ctx._record_response(case_id=invalid_auth_case.id, response=invalid_auth_response)
|
502
500
|
if invalid_auth_response.status_code != 401:
|
503
501
|
_raise_no_auth_error(invalid_auth_response, invalid_auth_case, "with any auth")
|
504
502
|
elif auth == AuthKind.GENERATED:
|
@@ -542,7 +540,7 @@ def _contains_auth(
|
|
542
540
|
from requests.cookies import RequestsCookieJar
|
543
541
|
|
544
542
|
# If auth comes from explicit `auth` option or a custom auth, it is always explicit
|
545
|
-
if ctx.
|
543
|
+
if ctx._auth is not None or case._has_explicit_auth:
|
546
544
|
return AuthKind.EXPLICIT
|
547
545
|
parsed = urlparse(request.url)
|
548
546
|
query = parse_qs(parsed.query) # type: ignore
|
@@ -565,19 +563,19 @@ def _contains_auth(
|
|
565
563
|
for parameter in security_parameters:
|
566
564
|
name = parameter["name"]
|
567
565
|
if has_header(parameter):
|
568
|
-
if (ctx.
|
566
|
+
if (ctx._headers is not None and name in ctx._headers) or (ctx._override and name in ctx._override.headers):
|
569
567
|
return AuthKind.EXPLICIT
|
570
568
|
return AuthKind.GENERATED
|
571
569
|
if has_cookie(parameter):
|
572
|
-
if ctx.
|
573
|
-
cookies = cast(RequestsCookieJar, ctx.
|
570
|
+
if ctx._headers is not None and "Cookie" in ctx._headers:
|
571
|
+
cookies = cast(RequestsCookieJar, ctx._headers["Cookie"]) # type: ignore
|
574
572
|
if name in cookies:
|
575
573
|
return AuthKind.EXPLICIT
|
576
|
-
if ctx.
|
574
|
+
if ctx._override and name in ctx._override.cookies:
|
577
575
|
return AuthKind.EXPLICIT
|
578
576
|
return AuthKind.GENERATED
|
579
577
|
if has_query(parameter):
|
580
|
-
if ctx.
|
578
|
+
if ctx._override and name in ctx._override.query:
|
581
579
|
return AuthKind.EXPLICIT
|
582
580
|
return AuthKind.GENERATED
|
583
581
|
|
@@ -630,6 +628,9 @@ def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
|
|
630
628
|
):
|
631
629
|
if parameter["in"] == location:
|
632
630
|
container = getattr(case, attr_name, {})
|
631
|
+
# Could happen in the negative testing mode
|
632
|
+
if not isinstance(container, dict):
|
633
|
+
container = {}
|
633
634
|
container[name] = "SCHEMATHESIS-INVALID-VALUE"
|
634
635
|
setattr(case, attr_name, container)
|
635
636
|
|
@@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any, Generator, Iterator, Union, cast
|
|
9
9
|
import requests
|
10
10
|
from hypothesis_jsonschema import from_schema
|
11
11
|
|
12
|
+
from schemathesis.config import GenerationConfig
|
12
13
|
from schemathesis.core.transforms import deepclone
|
13
14
|
from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT
|
14
|
-
from schemathesis.generation import GenerationConfig
|
15
15
|
from schemathesis.generation.case import Case
|
16
16
|
from schemathesis.generation.hypothesis import examples
|
17
17
|
from schemathesis.generation.meta import TestPhase
|
@@ -68,7 +68,7 @@ def get_strategies_from_examples(
|
|
68
68
|
# Add examples from parameter's schemas
|
69
69
|
examples.extend(extract_from_schemas(operation))
|
70
70
|
return [
|
71
|
-
openapi_cases(operation=operation, **{**parameters, **kwargs, "phase": TestPhase.
|
71
|
+
openapi_cases(operation=operation, **{**parameters, **kwargs, "phase": TestPhase.EXAMPLES}).map(
|
72
72
|
serialize_components
|
73
73
|
)
|
74
74
|
for parameters in produce_combinations(examples)
|
@@ -274,12 +274,13 @@ def extract_from_schema(
|
|
274
274
|
continue
|
275
275
|
variants[name] = values
|
276
276
|
if variants:
|
277
|
+
config = operation.schema.config.generation_for(operation=operation, phase="examples")
|
277
278
|
for name, subschema in to_generate.items():
|
278
279
|
if name in variants:
|
279
280
|
# Generated by one of `anyOf` or similar sub-schemas
|
280
281
|
continue
|
281
282
|
subschema = operation.schema.prepare_schema(subschema)
|
282
|
-
generated = _generate_single_example(subschema,
|
283
|
+
generated = _generate_single_example(subschema, config)
|
283
284
|
variants[name] = [generated]
|
284
285
|
# Calculate the maximum number of examples any property has
|
285
286
|
total_combos = max(len(examples) for examples in variants.values())
|
@@ -15,10 +15,37 @@ STRING_FORMATS: dict[str, st.SearchStrategy] = {}
|
|
15
15
|
|
16
16
|
|
17
17
|
def register_string_format(name: str, strategy: st.SearchStrategy) -> None:
|
18
|
-
"""Register a
|
18
|
+
r"""Register a custom Hypothesis strategy for generating string format data.
|
19
|
+
|
20
|
+
Args:
|
21
|
+
name: String format name that matches the "format" keyword in your API schema
|
22
|
+
strategy: Hypothesis strategy to generate values for this format
|
23
|
+
|
24
|
+
Example:
|
25
|
+
```python
|
26
|
+
import schemathesis
|
27
|
+
from hypothesis import strategies as st
|
28
|
+
|
29
|
+
# Register phone number format
|
30
|
+
phone_strategy = st.from_regex(r"\+1-\d{3}-\d{3}-\d{4}")
|
31
|
+
schemathesis.openapi.format("phone", phone_strategy)
|
32
|
+
|
33
|
+
# Register email with specific domain
|
34
|
+
email_strategy = st.from_regex(r"[a-z]+@company\.com")
|
35
|
+
schemathesis.openapi.format("company-email", email_strategy)
|
36
|
+
```
|
37
|
+
|
38
|
+
Schema usage:
|
39
|
+
```yaml
|
40
|
+
properties:
|
41
|
+
phone:
|
42
|
+
type: string
|
43
|
+
format: phone # Uses your phone_strategy
|
44
|
+
contact_email:
|
45
|
+
type: string
|
46
|
+
format: company-email # Uses your email_strategy
|
47
|
+
```
|
19
48
|
|
20
|
-
:param str name: Format name. It should correspond the one used in the API schema as the "format" keyword value.
|
21
|
-
:param strategy: Hypothesis strategy you'd like to use to generate values for this format.
|
22
49
|
"""
|
23
50
|
from hypothesis.strategies import SearchStrategy
|
24
51
|
|
@@ -38,11 +65,11 @@ def unregister_string_format(name: str) -> None:
|
|
38
65
|
raise ValueError(f"Unknown Open API format: {name}") from exc
|
39
66
|
|
40
67
|
|
41
|
-
def header_values(
|
68
|
+
def header_values(exclude_characters: str = "\n\r") -> st.SearchStrategy[str]:
|
42
69
|
from hypothesis import strategies as st
|
43
70
|
|
44
71
|
return st.text(
|
45
|
-
alphabet=st.characters(min_codepoint=0, max_codepoint=255,
|
72
|
+
alphabet=st.characters(min_codepoint=0, max_codepoint=255, exclude_characters=exclude_characters)
|
46
73
|
# Header values with leading non-visible chars can't be sent with `requests`
|
47
74
|
).map(str.lstrip)
|
48
75
|
|
@@ -15,7 +15,50 @@ MEDIA_TYPES: dict[str, st.SearchStrategy[bytes]] = {}
|
|
15
15
|
|
16
16
|
|
17
17
|
def register_media_type(name: str, strategy: st.SearchStrategy[bytes], *, aliases: Collection[str] = ()) -> None:
|
18
|
-
"""Register a strategy for
|
18
|
+
r"""Register a custom Hypothesis strategy for generating media type content.
|
19
|
+
|
20
|
+
Args:
|
21
|
+
name: Media type name that matches your OpenAPI requestBody content type
|
22
|
+
strategy: Hypothesis strategy that generates bytes for this media type
|
23
|
+
aliases: Additional media type names that use the same strategy
|
24
|
+
|
25
|
+
Example:
|
26
|
+
```python
|
27
|
+
import schemathesis
|
28
|
+
from hypothesis import strategies as st
|
29
|
+
|
30
|
+
# Register PDF file strategy
|
31
|
+
pdf_strategy = st.sampled_from([
|
32
|
+
b"%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n>>\nendobj\n%%EOF",
|
33
|
+
b"%PDF-1.5\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<<\n/Type /Catalog\n>>\nendobj\n%%EOF"
|
34
|
+
])
|
35
|
+
schemathesis.openapi.media_type("application/pdf", pdf_strategy)
|
36
|
+
|
37
|
+
# Dynamic content generation
|
38
|
+
@st.composite
|
39
|
+
def xml_content(draw):
|
40
|
+
tag = draw(st.text(min_size=3, max_size=10))
|
41
|
+
content = draw(st.text(min_size=1, max_size=50))
|
42
|
+
return f"<?xml version='1.0'?><{tag}>{content}</{tag}>".encode()
|
43
|
+
|
44
|
+
schemathesis.openapi.media_type("application/xml", xml_content())
|
45
|
+
```
|
46
|
+
|
47
|
+
Schema usage:
|
48
|
+
```yaml
|
49
|
+
requestBody:
|
50
|
+
content:
|
51
|
+
application/pdf: # Uses your PDF strategy
|
52
|
+
schema:
|
53
|
+
type: string
|
54
|
+
format: binary
|
55
|
+
application/xml: # Uses your XML strategy
|
56
|
+
schema:
|
57
|
+
type: string
|
58
|
+
format: binary
|
59
|
+
```
|
60
|
+
|
61
|
+
"""
|
19
62
|
|
20
63
|
@REQUESTS_TRANSPORT.serializer(name, *aliases)
|
21
64
|
@ASGI_TRANSPORT.serializer(name, *aliases)
|
@@ -9,12 +9,12 @@ import jsonschema
|
|
9
9
|
from hypothesis import strategies as st
|
10
10
|
from hypothesis_jsonschema import from_schema
|
11
11
|
|
12
|
+
from schemathesis.config import GenerationConfig
|
13
|
+
|
12
14
|
from ..constants import ALL_KEYWORDS
|
13
15
|
from .mutations import MutationContext
|
14
16
|
|
15
17
|
if TYPE_CHECKING:
|
16
|
-
from schemathesis.generation import GenerationConfig
|
17
|
-
|
18
18
|
from .types import Draw, Schema
|
19
19
|
|
20
20
|
|
@@ -19,6 +19,7 @@ if hasattr(sre, "POSSESSIVE_REPEAT"):
|
|
19
19
|
else:
|
20
20
|
REPEATS = (sre.MIN_REPEAT, sre.MAX_REPEAT)
|
21
21
|
LITERAL = sre.LITERAL
|
22
|
+
NOT_LITERAL = sre.NOT_LITERAL
|
22
23
|
IN = sre.IN
|
23
24
|
MAXREPEAT = sre_parse.MAXREPEAT
|
24
25
|
|
@@ -114,8 +115,20 @@ def _handle_anchored_pattern(parsed: list, pattern: str, min_length: int | None,
|
|
114
115
|
|
115
116
|
pattern_parts = parsed[1:-1]
|
116
117
|
|
118
|
+
# Calculate total fixed length and per-repetition lengths
|
119
|
+
fixed_length = 0
|
120
|
+
quantifier_bounds = []
|
121
|
+
repetition_lengths = []
|
122
|
+
|
123
|
+
for op, value in pattern_parts:
|
124
|
+
if op in (LITERAL, NOT_LITERAL):
|
125
|
+
fixed_length += 1
|
126
|
+
elif op in REPEATS:
|
127
|
+
min_repeat, max_repeat, subpattern = value
|
128
|
+
quantifier_bounds.append((min_repeat, max_repeat))
|
129
|
+
repetition_lengths.append(_calculate_min_repetition_length(subpattern))
|
130
|
+
|
117
131
|
# Adjust length constraints by subtracting fixed literals length
|
118
|
-
fixed_length = sum(1 for op, _ in pattern_parts if op == LITERAL)
|
119
132
|
if min_length is not None:
|
120
133
|
min_length -= fixed_length
|
121
134
|
if min_length < 0:
|
@@ -125,13 +138,10 @@ def _handle_anchored_pattern(parsed: list, pattern: str, min_length: int | None,
|
|
125
138
|
if max_length < 0:
|
126
139
|
return pattern
|
127
140
|
|
128
|
-
# Extract only min/max bounds from quantified parts
|
129
|
-
quantifier_bounds = [value[:2] for op, value in pattern_parts if op in REPEATS]
|
130
|
-
|
131
141
|
if not quantifier_bounds:
|
132
142
|
return pattern
|
133
143
|
|
134
|
-
length_distribution = _distribute_length_constraints(quantifier_bounds, min_length, max_length)
|
144
|
+
length_distribution = _distribute_length_constraints(quantifier_bounds, repetition_lengths, min_length, max_length)
|
135
145
|
if not length_distribution:
|
136
146
|
return pattern
|
137
147
|
|
@@ -212,7 +222,7 @@ def _find_quantified_end(pattern: str, start: int) -> int:
|
|
212
222
|
|
213
223
|
|
214
224
|
def _distribute_length_constraints(
|
215
|
-
bounds: list[tuple[int, int]], min_length: int | None, max_length: int | None
|
225
|
+
bounds: list[tuple[int, int]], repetition_lengths: list[int], min_length: int | None, max_length: int | None
|
216
226
|
) -> list[tuple[int, int]] | None:
|
217
227
|
"""Distribute length constraints among quantified pattern parts."""
|
218
228
|
# Handle exact length case with dynamic programming
|
@@ -228,18 +238,22 @@ def _distribute_length_constraints(
|
|
228
238
|
if pos == len(bounds):
|
229
239
|
return [()] if remaining == 0 else None
|
230
240
|
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
241
|
+
max_repeat: int
|
242
|
+
min_repeat, max_repeat = bounds[pos]
|
243
|
+
repeat_length = repetition_lengths[pos]
|
244
|
+
|
245
|
+
if max_repeat == MAXREPEAT:
|
246
|
+
max_repeat = remaining // repeat_length + 1 if repeat_length > 0 else remaining + 1
|
237
247
|
|
238
248
|
# Try each possible length for current quantifier
|
239
|
-
for
|
240
|
-
|
249
|
+
for repeat_count in range(min_repeat, max_repeat + 1):
|
250
|
+
used_length = repeat_count * repeat_length
|
251
|
+
if used_length > remaining:
|
252
|
+
break
|
253
|
+
|
254
|
+
rest = find_valid_combination(pos + 1, remaining - used_length)
|
241
255
|
if rest is not None:
|
242
|
-
dp[(pos, remaining)] = [(
|
256
|
+
dp[(pos, remaining)] = [(repeat_count,) + r for r in rest]
|
243
257
|
return dp[(pos, remaining)]
|
244
258
|
|
245
259
|
dp[(pos, remaining)] = None
|
@@ -280,6 +294,22 @@ def _distribute_length_constraints(
|
|
280
294
|
return result
|
281
295
|
|
282
296
|
|
297
|
+
def _calculate_min_repetition_length(subpattern: list) -> int:
|
298
|
+
"""Calculate minimum length contribution per repetition of a quantified group."""
|
299
|
+
total = 0
|
300
|
+
for op, value in subpattern:
|
301
|
+
if op in [LITERAL, NOT_LITERAL, IN, sre.ANY]:
|
302
|
+
total += 1
|
303
|
+
elif op == sre.SUBPATTERN:
|
304
|
+
_, _, _, inner_pattern = value
|
305
|
+
total += _calculate_min_repetition_length(inner_pattern)
|
306
|
+
elif op in REPEATS:
|
307
|
+
min_repeat, _, inner_pattern = value
|
308
|
+
inner_min = _calculate_min_repetition_length(inner_pattern)
|
309
|
+
total += min_repeat * inner_min
|
310
|
+
return total
|
311
|
+
|
312
|
+
|
283
313
|
def _get_anchor_length(node_type: int) -> int:
|
284
314
|
"""Determine the length of the anchor based on its type."""
|
285
315
|
if node_type in {sre.AT_BEGINNING_STRING, sre.AT_END_STRING, sre.AT_BOUNDARY, sre.AT_NON_BOUNDARY}:
|
@@ -293,7 +323,7 @@ def _update_quantifier(
|
|
293
323
|
"""Update the quantifier based on the operation type and given constraints."""
|
294
324
|
if op in REPEATS and value is not None:
|
295
325
|
return _handle_repeat_quantifier(value, pattern, min_length, max_length)
|
296
|
-
if op in (LITERAL, IN) and max_length != 0:
|
326
|
+
if op in (LITERAL, NOT_LITERAL, IN) and max_length != 0:
|
297
327
|
return _handle_literal_or_in_quantifier(pattern, min_length, max_length)
|
298
328
|
if op == sre.ANY and value is None:
|
299
329
|
# Equivalent to `.` which is in turn is the same as `.{1}`
|
@@ -5,10 +5,9 @@ from functools import lru_cache
|
|
5
5
|
from typing import Any, Callable, Dict, Union, overload
|
6
6
|
from urllib.request import urlopen
|
7
7
|
|
8
|
-
import jsonschema
|
9
8
|
import requests
|
10
9
|
|
11
|
-
from schemathesis.core.compat import RefResolutionError
|
10
|
+
from schemathesis.core.compat import RefResolutionError, RefResolver
|
12
11
|
from schemathesis.core.deserialization import deserialize_yaml
|
13
12
|
from schemathesis.core.transforms import deepclone
|
14
13
|
from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT
|
@@ -48,7 +47,7 @@ def load_remote_uri(uri: str) -> Any:
|
|
48
47
|
JSONType = Union[None, bool, float, str, list, Dict[str, Any]]
|
49
48
|
|
50
49
|
|
51
|
-
class InliningResolver(
|
50
|
+
class InliningResolver(RefResolver):
|
52
51
|
"""Inlines resolved schemas."""
|
53
52
|
|
54
53
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
@@ -43,7 +43,7 @@ from schemathesis.generation.overrides import Override, OverrideMark, check_no_o
|
|
43
43
|
from schemathesis.openapi.checks import JsonSchemaError, MissingContentType
|
44
44
|
from schemathesis.specs.openapi.stateful import links
|
45
45
|
|
46
|
-
from ...generation import
|
46
|
+
from ...generation import GenerationMode
|
47
47
|
from ...hooks import HookContext, HookDispatcher
|
48
48
|
from ...schemas import APIOperation, APIOperationMap, ApiStatistic, BaseSchema, OperationDefinition
|
49
49
|
from . import serialization
|
@@ -120,13 +120,18 @@ class BaseOpenAPISchema(BaseSchema):
|
|
120
120
|
if map is not None:
|
121
121
|
return map
|
122
122
|
path_item = self.raw_schema.get("paths", {})[path]
|
123
|
-
|
123
|
+
with in_scope(self.resolver, self.location or ""):
|
124
|
+
scope, path_item = self._resolve_path_item(path_item)
|
124
125
|
self.dispatch_hook("before_process_path", HookContext(), path, path_item)
|
125
126
|
map = APIOperationMap(self, {})
|
126
127
|
map._data = MethodMap(map, scope, path, CaseInsensitiveDict(path_item))
|
127
128
|
cache.insert_map(path, map)
|
128
129
|
return map
|
129
130
|
|
131
|
+
def find_operation_by_label(self, label: str) -> APIOperation | None:
|
132
|
+
method, path = label.split(" ", maxsplit=1)
|
133
|
+
return self[path][method]
|
134
|
+
|
130
135
|
def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
|
131
136
|
matches = get_close_matches(item, list(self))
|
132
137
|
self._on_missing_operation(item, exc, matches)
|
@@ -292,9 +297,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
292
297
|
parameters = operation.get("parameters", ())
|
293
298
|
return self.collect_parameters(itertools.chain(parameters, shared_parameters), operation)
|
294
299
|
|
295
|
-
def get_all_operations(
|
296
|
-
self, generation_config: GenerationConfig | None = None
|
297
|
-
) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
|
300
|
+
def get_all_operations(self) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
|
298
301
|
"""Iterate over all operations defined in the API.
|
299
302
|
|
300
303
|
Each yielded item is either `Ok` or `Err`, depending on the presence of errors during schema processing.
|
@@ -352,9 +355,6 @@ class BaseOpenAPISchema(BaseSchema):
|
|
352
355
|
entry,
|
353
356
|
resolved,
|
354
357
|
scope,
|
355
|
-
with_security_parameters=generation_config.with_security_parameters
|
356
|
-
if generation_config
|
357
|
-
else None,
|
358
358
|
)
|
359
359
|
yield Ok(operation)
|
360
360
|
except SCHEMA_PARSING_ERRORS as exc:
|
@@ -381,7 +381,9 @@ class BaseOpenAPISchema(BaseSchema):
|
|
381
381
|
try:
|
382
382
|
self.validate()
|
383
383
|
except jsonschema.ValidationError as exc:
|
384
|
-
raise InvalidSchema.from_jsonschema_error(
|
384
|
+
raise InvalidSchema.from_jsonschema_error(
|
385
|
+
exc, path=path, method=method, config=self.config.output
|
386
|
+
) from None
|
385
387
|
raise InvalidSchema(SCHEMA_ERROR_MESSAGE, path=path, method=method) from error
|
386
388
|
|
387
389
|
def validate(self) -> None:
|
@@ -417,7 +419,6 @@ class BaseOpenAPISchema(BaseSchema):
|
|
417
419
|
raw: dict[str, Any],
|
418
420
|
resolved: dict[str, Any],
|
419
421
|
scope: str,
|
420
|
-
with_security_parameters: bool | None = None,
|
421
422
|
) -> APIOperation:
|
422
423
|
"""Create JSON schemas for the query, body, etc from Swagger parameters definitions."""
|
423
424
|
__tracebackhide__ = True
|
@@ -432,12 +433,8 @@ class BaseOpenAPISchema(BaseSchema):
|
|
432
433
|
)
|
433
434
|
for parameter in parameters:
|
434
435
|
operation.add_parameter(parameter)
|
435
|
-
|
436
|
-
|
437
|
-
if with_security_parameters is not None
|
438
|
-
else self.generation_config.with_security_parameters
|
439
|
-
)
|
440
|
-
if with_security_parameters:
|
436
|
+
config = self.config.generation_for(operation=operation)
|
437
|
+
if config.with_security_parameters:
|
441
438
|
self.security.process_definitions(self.raw_schema, operation, self.resolver)
|
442
439
|
self.dispatch_hook("before_init_operation", HookContext(operation=operation), operation)
|
443
440
|
return operation
|
@@ -544,22 +541,21 @@ class BaseOpenAPISchema(BaseSchema):
|
|
544
541
|
operation: APIOperation,
|
545
542
|
hooks: HookDispatcher | None = None,
|
546
543
|
auth_storage: AuthStorage | None = None,
|
547
|
-
generation_mode: GenerationMode = GenerationMode.
|
548
|
-
generation_config: GenerationConfig | None = None,
|
544
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
549
545
|
**kwargs: Any,
|
550
546
|
) -> SearchStrategy:
|
551
547
|
return openapi_cases(
|
552
548
|
operation=operation,
|
553
|
-
auth_storage=auth_storage,
|
554
549
|
hooks=hooks,
|
550
|
+
auth_storage=auth_storage,
|
555
551
|
generation_mode=generation_mode,
|
556
|
-
generation_config=generation_config or self.generation_config,
|
557
552
|
**kwargs,
|
558
553
|
)
|
559
554
|
|
560
555
|
def get_parameter_serializer(self, operation: APIOperation, location: str) -> Callable | None:
|
561
556
|
definitions = [item.definition for item in operation.iter_parameters() if item.location == location]
|
562
|
-
|
557
|
+
config = self.config.generation_for(operation=operation)
|
558
|
+
if config.with_security_parameters:
|
563
559
|
security_parameters = self.security.get_security_definitions_as_parameters(
|
564
560
|
self.raw_schema, operation, self.resolver, location
|
565
561
|
)
|
@@ -667,6 +663,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
667
663
|
return jsonschema.Draft4Validator
|
668
664
|
|
669
665
|
def validate_response(self, operation: APIOperation, response: Response) -> bool | None:
|
666
|
+
__tracebackhide__ = True
|
670
667
|
responses = {str(key): value for key, value in operation.definition.raw.get("responses", {}).items()}
|
671
668
|
status_code = str(response.status_code)
|
672
669
|
if status_code in responses:
|
@@ -713,7 +710,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
713
710
|
JsonSchemaError.from_exception(
|
714
711
|
operation=operation.label,
|
715
712
|
exc=exc,
|
716
|
-
|
713
|
+
config=operation.schema.config.output,
|
717
714
|
)
|
718
715
|
)
|
719
716
|
_maybe_raise_one_or_more(failures)
|