schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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 +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1219
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +682 -257
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +26 -2
- schemathesis/specs/graphql/scalars.py +77 -12
- schemathesis/specs/graphql/schemas.py +367 -148
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +555 -318
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +748 -82
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +93 -73
- schemathesis/specs/openapi/negative/mutations.py +294 -103
- schemathesis/specs/openapi/negative/utils.py +0 -9
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +647 -666
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +403 -68
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -57
- schemathesis/_hypothesis.py +0 -123
- schemathesis/auth.py +0 -214
- schemathesis/cli/callbacks.py +0 -240
- schemathesis/cli/cassettes.py +0 -351
- schemathesis/cli/context.py +0 -38
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -70
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -521
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -88
- schemathesis/exceptions.py +0 -257
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -251
- schemathesis/failures.py +0 -145
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/graphql.py +0 -5
- schemathesis/internal.py +0 -6
- schemathesis/lazy.py +0 -301
- schemathesis/models.py +0 -1113
- schemathesis/parameters.py +0 -91
- schemathesis/runner/__init__.py +0 -470
- schemathesis/runner/events.py +0 -242
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -791
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -206
- schemathesis/serializers.py +0 -253
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -10
- schemathesis/service/client.py +0 -62
- schemathesis/service/constants.py +0 -25
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -46
- schemathesis/service/hosts.py +0 -74
- schemathesis/service/metadata.py +0 -42
- schemathesis/service/models.py +0 -21
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/worker.py +0 -39
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -303
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -430
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -358
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -475
- schemathesis-3.15.4.dist-info/METADATA +0 -202
- schemathesis-3.15.4.dist-info/RECORD +0 -99
- schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
|
@@ -2,19 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
https://swagger.io/docs/specification/links/#runtime-expressions
|
|
4
4
|
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
5
9
|
from typing import Any
|
|
6
10
|
|
|
11
|
+
from schemathesis.core.transforms import UNRESOLVABLE, Unresolvable
|
|
12
|
+
from schemathesis.generation.stateful.state_machine import StepOutput
|
|
13
|
+
|
|
7
14
|
from . import lexer, nodes, parser
|
|
8
|
-
|
|
15
|
+
|
|
16
|
+
__all__ = ["lexer", "nodes", "parser"]
|
|
9
17
|
|
|
10
18
|
|
|
11
|
-
def evaluate(expr: Any,
|
|
19
|
+
def evaluate(expr: Any, output: StepOutput, evaluate_nested: bool = False) -> Any:
|
|
12
20
|
"""Evaluate runtime expression in context."""
|
|
21
|
+
if isinstance(expr, (dict, list)) and evaluate_nested:
|
|
22
|
+
return _evaluate_nested(expr, output)
|
|
13
23
|
if not isinstance(expr, str):
|
|
14
24
|
# Can be a non-string constant
|
|
15
25
|
return expr
|
|
16
|
-
parts = [node.evaluate(
|
|
26
|
+
parts = [node.evaluate(output) for node in parser.parse(expr)]
|
|
17
27
|
if len(parts) == 1:
|
|
18
28
|
return parts[0] # keep the return type the same as the internal value type
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
if any(isinstance(part, Unresolvable) for part in parts):
|
|
30
|
+
return UNRESOLVABLE
|
|
31
|
+
return "".join(str(part) for part in parts if part is not None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _evaluate_nested(expr: dict[str, Any] | list, output: StepOutput) -> Any:
|
|
35
|
+
if isinstance(expr, dict):
|
|
36
|
+
result_dict = {}
|
|
37
|
+
for key, value in expr.items():
|
|
38
|
+
new_key = _evaluate_object_key(key, output)
|
|
39
|
+
if new_key is UNRESOLVABLE:
|
|
40
|
+
return new_key
|
|
41
|
+
new_value = evaluate(value, output, evaluate_nested=True)
|
|
42
|
+
if new_value is UNRESOLVABLE:
|
|
43
|
+
return new_value
|
|
44
|
+
result_dict[new_key] = new_value
|
|
45
|
+
return result_dict
|
|
46
|
+
result_list = []
|
|
47
|
+
for item in expr:
|
|
48
|
+
new_value = evaluate(item, output, evaluate_nested=True)
|
|
49
|
+
if new_value is UNRESOLVABLE:
|
|
50
|
+
return new_value
|
|
51
|
+
result_list.append(new_value)
|
|
52
|
+
return result_list
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _evaluate_object_key(key: str, output: StepOutput) -> Any:
|
|
56
|
+
evaluated = evaluate(key, output)
|
|
57
|
+
if evaluated is UNRESOLVABLE:
|
|
58
|
+
return evaluated
|
|
59
|
+
if isinstance(evaluated, str):
|
|
60
|
+
return evaluated
|
|
61
|
+
if isinstance(evaluated, bool):
|
|
62
|
+
return "true" if evaluated else "false"
|
|
63
|
+
if isinstance(evaluated, (int, float)):
|
|
64
|
+
return str(evaluated)
|
|
65
|
+
if evaluated is None:
|
|
66
|
+
return "null"
|
|
67
|
+
return json.dumps(evaluated)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Extractor:
|
|
9
|
+
def extract(self, value: str) -> str | None:
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RegexExtractor(Extractor):
|
|
15
|
+
"""Extract value via a regex."""
|
|
16
|
+
|
|
17
|
+
value: re.Pattern
|
|
18
|
+
|
|
19
|
+
__slots__ = ("value",)
|
|
20
|
+
|
|
21
|
+
def extract(self, value: str) -> str | None:
|
|
22
|
+
match = self.value.search(value)
|
|
23
|
+
if match is None:
|
|
24
|
+
return None
|
|
25
|
+
return match.group(1)
|
|
@@ -1,52 +1,55 @@
|
|
|
1
1
|
"""Lexical analysis of runtime expressions."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
2
4
|
from enum import Enum, unique
|
|
3
5
|
from typing import Callable, Generator
|
|
4
6
|
|
|
5
|
-
import attr
|
|
6
|
-
|
|
7
7
|
|
|
8
|
-
@unique
|
|
9
|
-
class TokenType(Enum):
|
|
10
|
-
VARIABLE = 1
|
|
11
|
-
STRING = 2
|
|
12
|
-
POINTER = 3
|
|
13
|
-
DOT = 4
|
|
14
|
-
LBRACKET = 5
|
|
15
|
-
RBRACKET = 6
|
|
8
|
+
@unique
|
|
9
|
+
class TokenType(int, Enum):
|
|
10
|
+
VARIABLE = 1
|
|
11
|
+
STRING = 2
|
|
12
|
+
POINTER = 3
|
|
13
|
+
DOT = 4
|
|
14
|
+
LBRACKET = 5
|
|
15
|
+
RBRACKET = 6
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
@
|
|
18
|
+
@dataclass
|
|
19
19
|
class Token:
|
|
20
20
|
"""Lexical token that may occur in a runtime expression."""
|
|
21
21
|
|
|
22
|
-
value: str
|
|
23
|
-
|
|
22
|
+
value: str
|
|
23
|
+
end: int
|
|
24
|
+
type_: TokenType
|
|
25
|
+
|
|
26
|
+
__slots__ = ("value", "end", "type_")
|
|
24
27
|
|
|
25
28
|
# Helpers for cleaner instantiation
|
|
26
29
|
|
|
27
30
|
@classmethod
|
|
28
|
-
def variable(cls, value: str) -> "Token":
|
|
29
|
-
return cls(value, TokenType.VARIABLE)
|
|
31
|
+
def variable(cls, value: str, end: int) -> "Token":
|
|
32
|
+
return cls(value, end, TokenType.VARIABLE)
|
|
30
33
|
|
|
31
34
|
@classmethod
|
|
32
|
-
def string(cls, value: str) -> "Token":
|
|
33
|
-
return cls(value, TokenType.STRING)
|
|
35
|
+
def string(cls, value: str, end: int) -> "Token":
|
|
36
|
+
return cls(value, end, TokenType.STRING)
|
|
34
37
|
|
|
35
38
|
@classmethod
|
|
36
|
-
def pointer(cls, value: str) -> "Token":
|
|
37
|
-
return cls(value, TokenType.POINTER)
|
|
39
|
+
def pointer(cls, value: str, end: int) -> "Token":
|
|
40
|
+
return cls(value, end, TokenType.POINTER)
|
|
38
41
|
|
|
39
42
|
@classmethod
|
|
40
|
-
def lbracket(cls) -> "Token":
|
|
41
|
-
return cls("{", TokenType.LBRACKET)
|
|
43
|
+
def lbracket(cls, end: int) -> "Token":
|
|
44
|
+
return cls("{", end, TokenType.LBRACKET)
|
|
42
45
|
|
|
43
46
|
@classmethod
|
|
44
|
-
def rbracket(cls) -> "Token":
|
|
45
|
-
return cls("}", TokenType.RBRACKET)
|
|
47
|
+
def rbracket(cls, end: int) -> "Token":
|
|
48
|
+
return cls("}", end, TokenType.RBRACKET)
|
|
46
49
|
|
|
47
50
|
@classmethod
|
|
48
|
-
def dot(cls) -> "Token":
|
|
49
|
-
return cls(".", TokenType.DOT)
|
|
51
|
+
def dot(cls, end: int) -> "Token":
|
|
52
|
+
return cls(".", end, TokenType.DOT)
|
|
50
53
|
|
|
51
54
|
# Helpers for simpler type comparison
|
|
52
55
|
|
|
@@ -103,15 +106,15 @@ def tokenize(expression: str) -> TokenGenerator:
|
|
|
103
106
|
if current_symbol() == "$":
|
|
104
107
|
start = cursor
|
|
105
108
|
move_until(lambda: is_eol() or current_symbol() in stop_symbols)
|
|
106
|
-
yield Token.variable(expression[start:cursor])
|
|
109
|
+
yield Token.variable(expression[start:cursor], cursor - 1)
|
|
107
110
|
elif current_symbol() == ".":
|
|
108
|
-
yield Token.dot()
|
|
111
|
+
yield Token.dot(cursor)
|
|
109
112
|
move()
|
|
110
113
|
elif current_symbol() == "{":
|
|
111
|
-
yield Token.lbracket()
|
|
114
|
+
yield Token.lbracket(cursor)
|
|
112
115
|
move()
|
|
113
116
|
elif current_symbol() == "}":
|
|
114
|
-
yield Token.rbracket()
|
|
117
|
+
yield Token.rbracket(cursor)
|
|
115
118
|
move()
|
|
116
119
|
elif current_symbol() == "#":
|
|
117
120
|
start = cursor
|
|
@@ -126,8 +129,8 @@ def tokenize(expression: str) -> TokenGenerator:
|
|
|
126
129
|
# `ID_{$response.body#/foo}_{$response.body#/bar}`
|
|
127
130
|
# Which is much easier if we treat `}` as a closing bracket of an embedded runtime expression
|
|
128
131
|
move_until(lambda: is_eol() or current_symbol() == "}")
|
|
129
|
-
yield Token.pointer(expression[start:cursor])
|
|
132
|
+
yield Token.pointer(expression[start:cursor], cursor - 1)
|
|
130
133
|
else:
|
|
131
134
|
start = cursor
|
|
132
135
|
move_until(lambda: is_eol() or current_symbol() in stop_symbols)
|
|
133
|
-
yield Token.string(expression[start:cursor])
|
|
136
|
+
yield Token.string(expression[start:cursor], cursor - 1)
|
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
"""Expression nodes description and evaluation logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
2
6
|
from enum import Enum, unique
|
|
3
|
-
from typing import
|
|
7
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
4
8
|
|
|
5
|
-
import attr
|
|
6
9
|
from requests.structures import CaseInsensitiveDict
|
|
7
10
|
|
|
8
|
-
from
|
|
9
|
-
from . import
|
|
10
|
-
from .
|
|
11
|
+
from schemathesis.core.transforms import UNRESOLVABLE, Unresolvable, resolve_pointer
|
|
12
|
+
from schemathesis.generation.stateful.state_machine import StepOutput
|
|
13
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
|
11
14
|
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .extractors import Extractor
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
14
20
|
class Node:
|
|
15
21
|
"""Generic expression node."""
|
|
16
22
|
|
|
17
|
-
def evaluate(self,
|
|
23
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
|
18
24
|
raise NotImplementedError
|
|
19
25
|
|
|
20
26
|
|
|
21
27
|
@unique
|
|
22
|
-
class NodeType(Enum):
|
|
28
|
+
class NodeType(str, Enum):
|
|
23
29
|
URL = "$url"
|
|
24
30
|
METHOD = "$method"
|
|
25
31
|
STATUS_CODE = "$statusCode"
|
|
@@ -27,13 +33,15 @@ class NodeType(Enum):
|
|
|
27
33
|
RESPONSE = "$response"
|
|
28
34
|
|
|
29
35
|
|
|
30
|
-
@
|
|
36
|
+
@dataclass
|
|
31
37
|
class String(Node):
|
|
32
38
|
"""A simple string that is not evaluated somehow specifically."""
|
|
33
39
|
|
|
34
|
-
value: str
|
|
40
|
+
value: str
|
|
41
|
+
|
|
42
|
+
__slots__ = ("value",)
|
|
35
43
|
|
|
36
|
-
def evaluate(self,
|
|
44
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
|
37
45
|
"""String tokens are passed as they are.
|
|
38
46
|
|
|
39
47
|
``foo{$request.path.id}``
|
|
@@ -43,83 +51,126 @@ class String(Node):
|
|
|
43
51
|
return self.value
|
|
44
52
|
|
|
45
53
|
|
|
46
|
-
@
|
|
54
|
+
@dataclass
|
|
47
55
|
class URL(Node):
|
|
48
56
|
"""A node for `$url` expression."""
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
return context.case.get_full_url()
|
|
58
|
+
__slots__ = ()
|
|
52
59
|
|
|
60
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
|
61
|
+
import requests
|
|
53
62
|
|
|
54
|
-
|
|
63
|
+
base_url = output.case.operation.base_url or "http://127.0.0.1"
|
|
64
|
+
kwargs = REQUESTS_TRANSPORT.serialize_case(output.case, base_url=base_url)
|
|
65
|
+
prepared = requests.Request(**kwargs).prepare()
|
|
66
|
+
return cast(str, prepared.url)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
55
70
|
class Method(Node):
|
|
56
71
|
"""A node for `$method` expression."""
|
|
57
72
|
|
|
58
|
-
|
|
59
|
-
return context.case.operation.method.upper()
|
|
73
|
+
__slots__ = ()
|
|
60
74
|
|
|
75
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
|
76
|
+
return output.case.operation.method.upper()
|
|
61
77
|
|
|
62
|
-
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
63
80
|
class StatusCode(Node):
|
|
64
81
|
"""A node for `$statusCode` expression."""
|
|
65
82
|
|
|
66
|
-
|
|
67
|
-
|
|
83
|
+
__slots__ = ()
|
|
84
|
+
|
|
85
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
|
86
|
+
return str(output.response.status_code)
|
|
68
87
|
|
|
69
88
|
|
|
70
|
-
@
|
|
89
|
+
@dataclass
|
|
71
90
|
class NonBodyRequest(Node):
|
|
72
91
|
"""A node for `$request` expressions where location is not `body`."""
|
|
73
92
|
|
|
74
|
-
location: str
|
|
75
|
-
parameter: str
|
|
93
|
+
location: str
|
|
94
|
+
parameter: str
|
|
95
|
+
extractor: Extractor | None
|
|
76
96
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
__slots__ = ("location", "parameter", "extractor")
|
|
98
|
+
|
|
99
|
+
def __init__(self, location: str, parameter: str, extractor: Extractor | None = None) -> None:
|
|
100
|
+
self.location = location
|
|
101
|
+
self.parameter = parameter
|
|
102
|
+
self.extractor = extractor
|
|
103
|
+
|
|
104
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
|
105
|
+
container = {
|
|
106
|
+
"query": output.case.query,
|
|
107
|
+
"path": output.case.path_parameters,
|
|
108
|
+
"header": output.case.headers,
|
|
82
109
|
}[self.location] or {}
|
|
83
110
|
if self.location == "header":
|
|
84
111
|
container = CaseInsensitiveDict(container)
|
|
85
|
-
|
|
112
|
+
value = container.get(self.parameter)
|
|
113
|
+
if value is None:
|
|
114
|
+
return UNRESOLVABLE
|
|
115
|
+
if self.extractor is not None:
|
|
116
|
+
return self.extractor.extract(value) or UNRESOLVABLE
|
|
117
|
+
return value
|
|
86
118
|
|
|
87
119
|
|
|
88
|
-
@
|
|
120
|
+
@dataclass
|
|
89
121
|
class BodyRequest(Node):
|
|
90
122
|
"""A node for `$request` expressions where location is `body`."""
|
|
91
123
|
|
|
92
|
-
pointer:
|
|
124
|
+
pointer: str | None
|
|
125
|
+
|
|
126
|
+
__slots__ = ("pointer",)
|
|
127
|
+
|
|
128
|
+
def __init__(self, pointer: str | None = None) -> None:
|
|
129
|
+
self.pointer = pointer
|
|
93
130
|
|
|
94
|
-
def evaluate(self,
|
|
95
|
-
document =
|
|
131
|
+
def evaluate(self, output: StepOutput) -> Any | Unresolvable:
|
|
132
|
+
document = output.case.body
|
|
96
133
|
if self.pointer is None:
|
|
97
134
|
return document
|
|
98
|
-
return
|
|
135
|
+
return resolve_pointer(document, self.pointer[1:])
|
|
99
136
|
|
|
100
137
|
|
|
101
|
-
@
|
|
138
|
+
@dataclass
|
|
102
139
|
class HeaderResponse(Node):
|
|
103
140
|
"""A node for `$response.header` expressions."""
|
|
104
141
|
|
|
105
|
-
parameter: str
|
|
142
|
+
parameter: str
|
|
143
|
+
extractor: Extractor | None
|
|
106
144
|
|
|
107
|
-
|
|
108
|
-
return context.response.headers[self.parameter]
|
|
145
|
+
__slots__ = ("parameter", "extractor")
|
|
109
146
|
|
|
147
|
+
def __init__(self, parameter: str, extractor: Extractor | None = None) -> None:
|
|
148
|
+
self.parameter = parameter
|
|
149
|
+
self.extractor = extractor
|
|
110
150
|
|
|
111
|
-
|
|
151
|
+
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
|
152
|
+
value = output.response.headers.get(self.parameter.lower())
|
|
153
|
+
if value is None:
|
|
154
|
+
return UNRESOLVABLE
|
|
155
|
+
if self.extractor is not None:
|
|
156
|
+
return self.extractor.extract(value[0]) or UNRESOLVABLE
|
|
157
|
+
return value[0]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
112
161
|
class BodyResponse(Node):
|
|
113
162
|
"""A node for `$response.body` expressions."""
|
|
114
163
|
|
|
115
|
-
pointer:
|
|
164
|
+
pointer: str | None
|
|
165
|
+
|
|
166
|
+
__slots__ = ("pointer",)
|
|
167
|
+
|
|
168
|
+
def __init__(self, pointer: str | None = None) -> None:
|
|
169
|
+
self.pointer = pointer
|
|
116
170
|
|
|
117
|
-
def evaluate(self,
|
|
118
|
-
|
|
119
|
-
document = context.response.json
|
|
120
|
-
else:
|
|
121
|
-
document = context.response.json()
|
|
171
|
+
def evaluate(self, output: StepOutput) -> Any:
|
|
172
|
+
document = output.response.json()
|
|
122
173
|
if self.pointer is None:
|
|
123
174
|
# We need the parsed document - data will be serialized before sending to the application
|
|
124
175
|
return document
|
|
125
|
-
return
|
|
176
|
+
return resolve_pointer(document, self.pointer[1:])
|
|
@@ -1,21 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
1
4
|
from functools import lru_cache
|
|
2
|
-
from typing import Generator
|
|
5
|
+
from typing import Generator
|
|
3
6
|
|
|
4
|
-
from . import lexer, nodes
|
|
7
|
+
from . import extractors, lexer, nodes
|
|
5
8
|
from .errors import RuntimeExpressionError, UnknownToken
|
|
6
9
|
|
|
7
10
|
|
|
8
|
-
@lru_cache
|
|
9
|
-
def parse(expr: str) ->
|
|
11
|
+
@lru_cache
|
|
12
|
+
def parse(expr: str) -> list[nodes.Node]:
|
|
10
13
|
"""Parse lexical tokens into concrete expression nodes."""
|
|
11
14
|
return list(_parse(expr))
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
def _parse(expr: str) -> Generator[nodes.Node, None, None]:
|
|
15
18
|
tokens = lexer.tokenize(expr)
|
|
16
|
-
brackets_stack:
|
|
19
|
+
brackets_stack: list[str] = []
|
|
17
20
|
for token in tokens:
|
|
18
|
-
if token.is_string:
|
|
21
|
+
if token.is_string or token.is_dot:
|
|
19
22
|
yield nodes.String(token.value)
|
|
20
23
|
elif token.is_variable:
|
|
21
24
|
yield from _parse_variable(tokens, token, expr)
|
|
@@ -43,16 +46,17 @@ def _parse_variable(tokens: lexer.TokenGenerator, token: lexer.Token, expr: str)
|
|
|
43
46
|
elif token.value == nodes.NodeType.RESPONSE.value:
|
|
44
47
|
yield _parse_response(tokens, expr)
|
|
45
48
|
else:
|
|
46
|
-
raise UnknownToken(token.value)
|
|
49
|
+
raise UnknownToken(f"Invalid expression `{expr}`. Unknown token: `{token.value}`")
|
|
47
50
|
|
|
48
51
|
|
|
49
|
-
def _parse_request(tokens: lexer.TokenGenerator, expr: str) ->
|
|
52
|
+
def _parse_request(tokens: lexer.TokenGenerator, expr: str) -> nodes.BodyRequest | nodes.NonBodyRequest:
|
|
50
53
|
skip_dot(tokens, "$request")
|
|
51
54
|
location = next(tokens)
|
|
52
55
|
if location.value in ("query", "path", "header"):
|
|
53
56
|
skip_dot(tokens, f"$request.{location.value}")
|
|
54
57
|
parameter = take_string(tokens, expr)
|
|
55
|
-
|
|
58
|
+
extractor = take_extractor(tokens, expr, parameter.end)
|
|
59
|
+
return nodes.NonBodyRequest(location.value, parameter.value, extractor)
|
|
56
60
|
if location.value == "body":
|
|
57
61
|
try:
|
|
58
62
|
token = next(tokens)
|
|
@@ -63,13 +67,14 @@ def _parse_request(tokens: lexer.TokenGenerator, expr: str) -> Union[nodes.BodyR
|
|
|
63
67
|
raise RuntimeExpressionError(f"Invalid expression: {expr}")
|
|
64
68
|
|
|
65
69
|
|
|
66
|
-
def _parse_response(tokens: lexer.TokenGenerator, expr: str) ->
|
|
70
|
+
def _parse_response(tokens: lexer.TokenGenerator, expr: str) -> nodes.HeaderResponse | nodes.BodyResponse:
|
|
67
71
|
skip_dot(tokens, "$response")
|
|
68
72
|
location = next(tokens)
|
|
69
73
|
if location.value == "header":
|
|
70
74
|
skip_dot(tokens, f"$response.{location.value}")
|
|
71
75
|
parameter = take_string(tokens, expr)
|
|
72
|
-
|
|
76
|
+
extractor = take_extractor(tokens, expr, parameter.end)
|
|
77
|
+
return nodes.HeaderResponse(parameter.value, extractor=extractor)
|
|
73
78
|
if location.value == "body":
|
|
74
79
|
try:
|
|
75
80
|
token = next(tokens)
|
|
@@ -86,8 +91,25 @@ def skip_dot(tokens: lexer.TokenGenerator, name: str) -> None:
|
|
|
86
91
|
raise RuntimeExpressionError(f"`{name}` expression should be followed by a dot (`.`). Got: {token.value}")
|
|
87
92
|
|
|
88
93
|
|
|
89
|
-
def take_string(tokens: lexer.TokenGenerator, expr: str) ->
|
|
94
|
+
def take_string(tokens: lexer.TokenGenerator, expr: str) -> lexer.Token:
|
|
90
95
|
parameter = next(tokens)
|
|
91
96
|
if not parameter.is_string:
|
|
92
97
|
raise RuntimeExpressionError(f"Invalid expression: {expr}")
|
|
93
|
-
return parameter
|
|
98
|
+
return parameter
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def take_extractor(tokens: lexer.TokenGenerator, expr: str, current_end: int) -> extractors.Extractor | None:
|
|
102
|
+
rest = expr[current_end + 1 :]
|
|
103
|
+
if not rest or rest.startswith("}"):
|
|
104
|
+
return None
|
|
105
|
+
extractor = next(tokens)
|
|
106
|
+
if not extractor.value.startswith("#regex:"):
|
|
107
|
+
raise RuntimeExpressionError(f"Invalid extractor: {expr}")
|
|
108
|
+
pattern = extractor.value[len("#regex:") :]
|
|
109
|
+
try:
|
|
110
|
+
compiled = re.compile(pattern)
|
|
111
|
+
except re.error as exc:
|
|
112
|
+
raise RuntimeExpressionError(f"Invalid regex extractor: {exc}") from None
|
|
113
|
+
if compiled.groups != 1:
|
|
114
|
+
raise RuntimeExpressionError("Regex extractor should have exactly one capturing group")
|
|
115
|
+
return extractors.RegexExtractor(compiled)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import string
|
|
5
|
+
from base64 import b64encode
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from schemathesis.transport.serialization import Binary
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from hypothesis import strategies as st
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
IS_PYPY = platform.python_implementation() == "PyPy"
|
|
16
|
+
STRING_FORMATS: dict[str, st.SearchStrategy] = {}
|
|
17
|
+
# For some reason PyPy can't send header values with codepoints > 128, while CPython can
|
|
18
|
+
if IS_PYPY:
|
|
19
|
+
MAX_HEADER_CODEPOINT = 128
|
|
20
|
+
DEFAULT_HEADER_EXCLUDE_CHARACTERS = "\n\r\x1f\x1e\x1d\x1c"
|
|
21
|
+
else:
|
|
22
|
+
MAX_HEADER_CODEPOINT = 255
|
|
23
|
+
DEFAULT_HEADER_EXCLUDE_CHARACTERS = "\n\r"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register_string_format(name: str, strategy: st.SearchStrategy) -> None:
|
|
27
|
+
r"""Register a custom Hypothesis strategy for generating string format data.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
name: String format name that matches the "format" keyword in your API schema
|
|
31
|
+
strategy: Hypothesis strategy to generate values for this format
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
```python
|
|
35
|
+
import schemathesis
|
|
36
|
+
from hypothesis import strategies as st
|
|
37
|
+
|
|
38
|
+
# Register phone number format
|
|
39
|
+
phone_strategy = st.from_regex(r"\+1-\d{3}-\d{3}-\d{4}")
|
|
40
|
+
schemathesis.openapi.format("phone", phone_strategy)
|
|
41
|
+
|
|
42
|
+
# Register email with specific domain
|
|
43
|
+
email_strategy = st.from_regex(r"[a-z]+@company\.com")
|
|
44
|
+
schemathesis.openapi.format("company-email", email_strategy)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Schema usage:
|
|
48
|
+
```yaml
|
|
49
|
+
properties:
|
|
50
|
+
phone:
|
|
51
|
+
type: string
|
|
52
|
+
format: phone # Uses your phone_strategy
|
|
53
|
+
contact_email:
|
|
54
|
+
type: string
|
|
55
|
+
format: company-email # Uses your email_strategy
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
from hypothesis.strategies import SearchStrategy
|
|
60
|
+
|
|
61
|
+
if not isinstance(name, str):
|
|
62
|
+
raise TypeError(f"name must be of type {str}, not {type(name)}")
|
|
63
|
+
if not isinstance(strategy, SearchStrategy):
|
|
64
|
+
raise TypeError(f"strategy must be of type {SearchStrategy}, not {type(strategy)}")
|
|
65
|
+
|
|
66
|
+
STRING_FORMATS[name] = strategy
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def unregister_string_format(name: str) -> None:
|
|
70
|
+
"""Remove format strategy from the registry."""
|
|
71
|
+
try:
|
|
72
|
+
del STRING_FORMATS[name]
|
|
73
|
+
except KeyError as exc:
|
|
74
|
+
raise ValueError(f"Unknown Open API format: {name}") from exc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def header_values(
|
|
78
|
+
codec: str | None = None, exclude_characters: str = DEFAULT_HEADER_EXCLUDE_CHARACTERS
|
|
79
|
+
) -> st.SearchStrategy[str]:
|
|
80
|
+
from hypothesis import strategies as st
|
|
81
|
+
|
|
82
|
+
return st.text(
|
|
83
|
+
alphabet=st.characters(
|
|
84
|
+
min_codepoint=0, max_codepoint=MAX_HEADER_CODEPOINT, codec=codec, exclude_characters=exclude_characters
|
|
85
|
+
)
|
|
86
|
+
# Header values with leading non-visible chars can't be sent with `requests`
|
|
87
|
+
).map(str.lstrip)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
HEADER_FORMAT = "_header_value"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@lru_cache
|
|
94
|
+
def get_default_format_strategies() -> dict[str, st.SearchStrategy]:
|
|
95
|
+
"""Get all default "format" strategies."""
|
|
96
|
+
from hypothesis import strategies as st
|
|
97
|
+
from requests.auth import _basic_auth_str
|
|
98
|
+
|
|
99
|
+
def make_basic_auth_str(item: tuple[str, str]) -> str:
|
|
100
|
+
return _basic_auth_str(*item)
|
|
101
|
+
|
|
102
|
+
latin1_text = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255))
|
|
103
|
+
|
|
104
|
+
# Define valid characters here to avoid filtering them out in `is_valid_header` later
|
|
105
|
+
header_value = header_values()
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"binary": st.binary().map(Binary),
|
|
109
|
+
"byte": st.binary().map(lambda x: b64encode(x).decode()),
|
|
110
|
+
"uuid": st.uuids().map(str),
|
|
111
|
+
# RFC 7230, Section 3.2.6
|
|
112
|
+
"_header_name": st.text(
|
|
113
|
+
min_size=1, alphabet=st.sampled_from("!#$%&'*+-.^_`|~" + string.digits + string.ascii_letters)
|
|
114
|
+
),
|
|
115
|
+
HEADER_FORMAT: header_value,
|
|
116
|
+
"_basic_auth": st.tuples(latin1_text, latin1_text).map(make_basic_auth_str),
|
|
117
|
+
"_bearer_auth": header_value.map("Bearer {}".format),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
register = register_string_format
|
|
122
|
+
unregister = unregister_string_format
|