schemathesis 4.1.4__py3-none-any.whl → 4.2.1__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.
Files changed (70) hide show
  1. schemathesis/cli/commands/run/executor.py +1 -1
  2. schemathesis/cli/commands/run/handlers/base.py +28 -1
  3. schemathesis/cli/commands/run/handlers/cassettes.py +109 -137
  4. schemathesis/cli/commands/run/handlers/junitxml.py +5 -6
  5. schemathesis/cli/commands/run/handlers/output.py +7 -1
  6. schemathesis/cli/ext/fs.py +1 -1
  7. schemathesis/config/_diff_base.py +3 -1
  8. schemathesis/config/_operations.py +2 -0
  9. schemathesis/config/_phases.py +21 -4
  10. schemathesis/config/_projects.py +10 -2
  11. schemathesis/core/adapter.py +34 -0
  12. schemathesis/core/errors.py +29 -5
  13. schemathesis/core/jsonschema/__init__.py +13 -0
  14. schemathesis/core/jsonschema/bundler.py +163 -0
  15. schemathesis/{specs/openapi/constants.py → core/jsonschema/keywords.py} +0 -8
  16. schemathesis/core/jsonschema/references.py +122 -0
  17. schemathesis/core/jsonschema/types.py +41 -0
  18. schemathesis/core/media_types.py +6 -4
  19. schemathesis/core/parameters.py +37 -0
  20. schemathesis/core/transforms.py +25 -2
  21. schemathesis/core/validation.py +19 -0
  22. schemathesis/engine/context.py +1 -1
  23. schemathesis/engine/errors.py +11 -18
  24. schemathesis/engine/phases/stateful/_executor.py +1 -1
  25. schemathesis/engine/phases/unit/_executor.py +30 -13
  26. schemathesis/errors.py +4 -0
  27. schemathesis/filters.py +2 -2
  28. schemathesis/generation/coverage.py +87 -11
  29. schemathesis/generation/hypothesis/__init__.py +79 -2
  30. schemathesis/generation/hypothesis/builder.py +108 -70
  31. schemathesis/generation/meta.py +5 -14
  32. schemathesis/generation/overrides.py +17 -17
  33. schemathesis/pytest/lazy.py +1 -1
  34. schemathesis/pytest/plugin.py +1 -6
  35. schemathesis/schemas.py +22 -72
  36. schemathesis/specs/graphql/schemas.py +27 -16
  37. schemathesis/specs/openapi/_hypothesis.py +83 -68
  38. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  39. schemathesis/specs/openapi/adapter/parameters.py +504 -0
  40. schemathesis/specs/openapi/adapter/protocol.py +57 -0
  41. schemathesis/specs/openapi/adapter/references.py +19 -0
  42. schemathesis/specs/openapi/adapter/responses.py +329 -0
  43. schemathesis/specs/openapi/adapter/security.py +141 -0
  44. schemathesis/specs/openapi/adapter/v2.py +28 -0
  45. schemathesis/specs/openapi/adapter/v3_0.py +28 -0
  46. schemathesis/specs/openapi/adapter/v3_1.py +28 -0
  47. schemathesis/specs/openapi/checks.py +99 -90
  48. schemathesis/specs/openapi/converter.py +114 -27
  49. schemathesis/specs/openapi/examples.py +210 -168
  50. schemathesis/specs/openapi/negative/__init__.py +12 -7
  51. schemathesis/specs/openapi/negative/mutations.py +68 -40
  52. schemathesis/specs/openapi/references.py +2 -175
  53. schemathesis/specs/openapi/schemas.py +142 -490
  54. schemathesis/specs/openapi/serialization.py +15 -7
  55. schemathesis/specs/openapi/stateful/__init__.py +17 -12
  56. schemathesis/specs/openapi/stateful/inference.py +13 -11
  57. schemathesis/specs/openapi/stateful/links.py +5 -20
  58. schemathesis/specs/openapi/types/__init__.py +3 -0
  59. schemathesis/specs/openapi/types/v3.py +68 -0
  60. schemathesis/specs/openapi/utils.py +1 -13
  61. schemathesis/transport/requests.py +3 -11
  62. schemathesis/transport/serialization.py +63 -27
  63. schemathesis/transport/wsgi.py +1 -8
  64. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/METADATA +2 -2
  65. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/RECORD +68 -53
  66. schemathesis/specs/openapi/parameters.py +0 -405
  67. schemathesis/specs/openapi/security.py +0 -162
  68. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/WHEEL +0 -0
  69. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/entry_points.txt +0 -0
  70. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, TypeVar
4
+
5
+ from schemathesis.core.parameters import ParameterLocation
6
+
7
+ T = TypeVar("T", covariant=True)
8
+
9
+
10
+ class ResponsesContainer(Protocol[T]):
11
+ def find_by_status_code(self, status_code: int) -> T | None: ... # pragma: no cover
12
+ def add(self, status_code: str, definition: dict[str, Any]) -> T: ... # pragma: no cover
13
+
14
+
15
+ class OperationParameter(Protocol):
16
+ """API parameter at a specific location (query, header, body, etc.)."""
17
+
18
+ definition: Any
19
+ """Raw parameter definition from the API spec."""
20
+
21
+ @property
22
+ def location(self) -> ParameterLocation:
23
+ """Location: "query", "header", "body", etc."""
24
+ ... # pragma: no cover
25
+
26
+ @property
27
+ def name(self) -> str:
28
+ """Parameter name."""
29
+ ... # pragma: no cover
30
+
31
+ @property
32
+ def is_required(self) -> bool:
33
+ """True if required."""
34
+ ... # pragma: no cover
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
17
17
 
18
18
  from schemathesis.config import OutputConfig
19
19
  from schemathesis.core.compat import RefResolutionError
20
+ from schemathesis.core.jsonschema import BundleError
20
21
 
21
22
 
22
23
  SCHEMA_ERROR_SUGGESTION = "Ensure that the definition complies with the OpenAPI specification"
@@ -26,11 +27,6 @@ SERIALIZATION_NOT_POSSIBLE_MESSAGE = f"No supported serializers for media types:
26
27
  SERIALIZATION_FOR_TYPE_IS_NOT_POSSIBLE_MESSAGE = (
27
28
  f"Cannot serialize to '{{}}' (unsupported media type)\n{SERIALIZERS_SUGGESTION_MESSAGE}"
28
29
  )
29
- RECURSIVE_REFERENCE_ERROR_MESSAGE = (
30
- "Currently, Schemathesis can't generate data for this operation due to "
31
- "recursive references in the operation definition. See more information in "
32
- "this issue - https://github.com/schemathesis/schemathesis/issues/947"
33
- )
34
30
 
35
31
 
36
32
  class SchemathesisError(Exception):
@@ -50,6 +46,14 @@ class InvalidSchema(SchemathesisError):
50
46
  self.path = path
51
47
  self.method = method
52
48
 
49
+ @classmethod
50
+ def from_bundle_error(cls, error: BundleError, location: str, name: str | None = None) -> InvalidSchema:
51
+ if location == "body":
52
+ message = f"Can not generate data for {location}! {error}"
53
+ else:
54
+ message = f"Can not generate data for {location} parameter `{name}`! {error}"
55
+ return InvalidSchema(message)
56
+
53
57
  @classmethod
54
58
  def from_jsonschema_error(
55
59
  cls, error: ValidationError | JsonSchemaError, path: str | None, method: str | None, config: OutputConfig
@@ -283,6 +287,26 @@ class UnboundPrefix(SerializationError):
283
287
  super().__init__(UNBOUND_PREFIX_MESSAGE_TEMPLATE.format(prefix=prefix))
284
288
 
285
289
 
290
+ class UnresolvableReference(SchemathesisError):
291
+ """A reference cannot be resolved."""
292
+
293
+ def __init__(self, reference: str) -> None:
294
+ self.reference = reference
295
+
296
+ def __str__(self) -> str:
297
+ return f"Reference `{self.reference}` cannot be resolved"
298
+
299
+
300
+ class InfiniteRecursiveReference(SchemathesisError):
301
+ """Required recursive reference creates a cycle."""
302
+
303
+ def __init__(self, reference: str) -> None:
304
+ self.reference = reference
305
+
306
+ def __str__(self) -> str:
307
+ return f"Required reference `{self.reference}` creates a cycle"
308
+
309
+
286
310
  class SerializationNotPossible(SerializationError):
287
311
  """Not possible to serialize data to specified media type(s).
288
312
 
@@ -0,0 +1,13 @@
1
+ from .bundler import BUNDLE_STORAGE_KEY, REFERENCE_TO_BUNDLE_PREFIX, BundleError, Bundler, bundle
2
+ from .keywords import ALL_KEYWORDS
3
+ from .types import get_type
4
+
5
+ __all__ = [
6
+ "ALL_KEYWORDS",
7
+ "bundle",
8
+ "Bundler",
9
+ "BundleError",
10
+ "REFERENCE_TO_BUNDLE_PREFIX",
11
+ "BUNDLE_STORAGE_KEY",
12
+ "get_type",
13
+ ]
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from schemathesis.core.errors import InfiniteRecursiveReference
6
+ from schemathesis.core.jsonschema.references import sanitize
7
+ from schemathesis.core.jsonschema.types import JsonSchema, to_json_type_name
8
+ from schemathesis.core.transforms import deepclone
9
+
10
+ if TYPE_CHECKING:
11
+ from schemathesis.core.compat import RefResolver
12
+
13
+
14
+ BUNDLE_STORAGE_KEY = "x-bundled"
15
+ REFERENCE_TO_BUNDLE_PREFIX = f"#/{BUNDLE_STORAGE_KEY}"
16
+
17
+
18
+ class BundleError(Exception):
19
+ def __init__(self, reference: str, value: Any) -> None:
20
+ self.reference = reference
21
+ self.value = value
22
+
23
+ def __str__(self) -> str:
24
+ return f"Cannot bundle `{self.reference}`: expected JSON Schema (object or boolean), got {to_json_type_name(self.value)}"
25
+
26
+
27
+ class Bundler:
28
+ """Bundler tracks schema ids stored in a bundle."""
29
+
30
+ counter: int
31
+
32
+ __slots__ = ("counter",)
33
+
34
+ def __init__(self) -> None:
35
+ self.counter = 0
36
+
37
+ def bundle(self, schema: JsonSchema, resolver: RefResolver, *, inline_recursive: bool) -> JsonSchema:
38
+ """Bundle a JSON Schema by embedding all references."""
39
+ # Inlining recursive reference is required (for now) for data generation, but is unsound for data validation
40
+ if not isinstance(schema, dict):
41
+ return schema
42
+
43
+ # Track visited URIs and their local definition names
44
+ visited: set[str] = set()
45
+ uri_to_def_name: dict[str, str] = {}
46
+ defs = {}
47
+
48
+ has_recursive_references = False
49
+ resolve = resolver.resolve
50
+ visit = visited.add
51
+
52
+ def get_def_name(uri: str) -> str:
53
+ """Generate or retrieve the local definition name for a URI."""
54
+ name = uri_to_def_name.get(uri)
55
+ if name is None:
56
+ self.counter += 1
57
+ name = f"schema{self.counter}"
58
+ uri_to_def_name[uri] = name
59
+ return name
60
+
61
+ def bundle_recursive(current: JsonSchema | list[JsonSchema]) -> JsonSchema | list[JsonSchema]:
62
+ """Recursively process and bundle references in the current schema."""
63
+ # Local lookup is cheaper and it matters for large schemas.
64
+ # It works because this recursive call goes to every nested value
65
+ nonlocal has_recursive_references
66
+ _bundle_recursive = bundle_recursive
67
+ if isinstance(current, dict):
68
+ reference = current.get("$ref")
69
+ if isinstance(reference, str) and not reference.startswith(REFERENCE_TO_BUNDLE_PREFIX):
70
+ resolved_uri, resolved_schema = resolve(reference)
71
+
72
+ if not isinstance(resolved_schema, (dict, bool)):
73
+ raise BundleError(reference, resolved_schema)
74
+ def_name = get_def_name(resolved_uri)
75
+
76
+ is_recursive_reference = resolved_uri in resolver._scopes_stack
77
+ has_recursive_references |= is_recursive_reference
78
+ if inline_recursive and is_recursive_reference:
79
+ # This is a recursive reference! As of Sep 2025, `hypothesis-jsonschema` does not support
80
+ # recursive references and Schemathesis has to remove them if possible.
81
+ #
82
+ # Cutting them of immediately would limit the quality of generated data, since it would have
83
+ # just a single level of recursion. Currently, the only way to generate recursive data is to
84
+ # inline definitions directly, which can lead to schema size explosion.
85
+ #
86
+ # To balance it, Schemathesis inlines one level, that avoids exponential blowup of O(B ^ L)
87
+ # in worst case, where B is branching factor (number of recursive references per schema), and
88
+ # L is the number of levels. Even quadratic growth can be unacceptable for large schemas.
89
+ #
90
+ # In the future, it **should** be handled by `hypothesis-jsonschema` instead.
91
+ cloned = deepclone(resolved_schema)
92
+ remaining_references = sanitize(cloned)
93
+ if remaining_references:
94
+ # This schema is either infinitely recursive or the sanitization logic misses it, in any
95
+ # event, we git up here
96
+ raise InfiniteRecursiveReference(reference)
97
+
98
+ result = {key: _bundle_recursive(value) for key, value in current.items() if key != "$ref"}
99
+ # Recursive references need `$ref` to be in them, which is only possible with `dict`
100
+ assert isinstance(cloned, dict)
101
+ result.update(cloned)
102
+ return result
103
+ elif resolved_uri not in visited:
104
+ # Bundle only new schemas
105
+ visit(resolved_uri)
106
+
107
+ # Recursively bundle the embedded schema too!
108
+ resolver.push_scope(resolved_uri)
109
+ try:
110
+ bundled_resolved = _bundle_recursive(resolved_schema)
111
+ finally:
112
+ resolver.pop_scope()
113
+
114
+ defs[def_name] = bundled_resolved
115
+
116
+ return {
117
+ key: f"{REFERENCE_TO_BUNDLE_PREFIX}/{def_name}"
118
+ if key == "$ref"
119
+ else _bundle_recursive(value)
120
+ if isinstance(value, (dict, list))
121
+ else value
122
+ for key, value in current.items()
123
+ }
124
+ else:
125
+ # Already visited - just update $ref
126
+ return {
127
+ key: f"{REFERENCE_TO_BUNDLE_PREFIX}/{def_name}"
128
+ if key == "$ref"
129
+ else _bundle_recursive(value)
130
+ if isinstance(value, (dict, list))
131
+ else value
132
+ for key, value in current.items()
133
+ }
134
+ return {
135
+ key: _bundle_recursive(value) if isinstance(value, (dict, list)) else value
136
+ for key, value in current.items()
137
+ }
138
+ elif isinstance(current, list):
139
+ return [_bundle_recursive(item) if isinstance(item, (dict, list)) else item for item in current] # type: ignore[misc]
140
+ # `isinstance` guards won't let it happen
141
+ # Otherwise is present to make type checker happy
142
+ return current # pragma: no cover
143
+
144
+ bundled = bundle_recursive(schema)
145
+
146
+ assert isinstance(bundled, dict)
147
+
148
+ # Inlining such a schema is only possible if recursive references were inlined
149
+ if (inline_recursive or not has_recursive_references) and "$ref" in bundled and len(defs) == 1:
150
+ result = {key: value for key, value in bundled.items() if key != "$ref"}
151
+ for value in defs.values():
152
+ if isinstance(value, dict):
153
+ result.update(value)
154
+ return result
155
+
156
+ if defs:
157
+ bundled[BUNDLE_STORAGE_KEY] = defs
158
+ return bundled
159
+
160
+
161
+ def bundle(schema: JsonSchema, resolver: RefResolver, *, inline_recursive: bool) -> JsonSchema:
162
+ """Bundle a JSON Schema by embedding all references."""
163
+ return Bundler().bundle(schema, resolver, inline_recursive=inline_recursive)
@@ -1,11 +1,3 @@
1
- LOCATION_TO_CONTAINER = {
2
- "path": "path_parameters",
3
- "query": "query",
4
- "header": "headers",
5
- "cookie": "cookies",
6
- "body": "body",
7
- }
8
-
9
1
  ALL_KEYWORDS = {
10
2
  "additionalItems",
11
3
  "additionalProperties",
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from schemathesis.core.jsonschema.keywords import ALL_KEYWORDS
4
+ from schemathesis.core.jsonschema.types import JsonSchema, JsonSchemaObject, get_type
5
+
6
+
7
+ def sanitize(schema: JsonSchema) -> set[str]:
8
+ """Remove optional parts of the schema that contain references.
9
+
10
+ It covers only the most popular cases, as removing all optional parts is complicated.
11
+ We might fall back to filtering out invalid cases in the future.
12
+ """
13
+ if isinstance(schema, bool):
14
+ return set()
15
+
16
+ stack = [schema]
17
+ while stack:
18
+ current = stack.pop()
19
+ if isinstance(current, dict):
20
+ # Optional properties
21
+ if "properties" in current:
22
+ properties = current["properties"]
23
+ required = current.get("required", [])
24
+ for name, value in list(properties.items()):
25
+ if isinstance(value, dict):
26
+ if name not in required and _has_references(value):
27
+ del properties[name]
28
+ elif _find_single_reference_combinators(value):
29
+ properties.pop(name, None)
30
+ else:
31
+ stack.append(value)
32
+
33
+ # Optional items
34
+ if "items" in current:
35
+ _sanitize_items(current)
36
+ # Not required additional properties
37
+ if "additionalProperties" in current:
38
+ _sanitize_additional_properties(current)
39
+ for k in _find_single_reference_combinators(current):
40
+ del current[k]
41
+
42
+ remaining: set[str] = set()
43
+ _collect_all_references(schema, remaining)
44
+ return remaining
45
+
46
+
47
+ def _collect_all_references(schema: JsonSchema | list[JsonSchema], remaining: set[str]) -> None:
48
+ """Recursively collect all $ref present in the schema."""
49
+ if isinstance(schema, dict):
50
+ reference = schema.get("$ref")
51
+ if isinstance(reference, str):
52
+ remaining.add(reference)
53
+ for value in schema.values():
54
+ _collect_all_references(value, remaining)
55
+ elif isinstance(schema, list):
56
+ for item in schema:
57
+ _collect_all_references(item, remaining)
58
+
59
+
60
+ def _has_references_in_items(items: list[JsonSchema]) -> bool:
61
+ return any("$ref" in item for item in items if isinstance(item, dict))
62
+
63
+
64
+ def _has_references(schema: JsonSchemaObject) -> bool:
65
+ if "$ref" in schema:
66
+ return True
67
+ items = schema.get("items")
68
+ return (isinstance(items, dict) and "$ref" in items) or isinstance(items, list) and _has_references_in_items(items)
69
+
70
+
71
+ def _is_optional_schema(schema: JsonSchema) -> bool:
72
+ # Whether this schema could be dropped from a list of schemas
73
+ if isinstance(schema, bool):
74
+ return True
75
+ type_ = get_type(schema)
76
+ if type_ == ["object"]:
77
+ # Empty object is valid for this schema -> could be dropped
78
+ return schema.get("required", []) == [] and schema.get("minProperties", 0) == 0
79
+ # Has at least one keyword -> should not be removed
80
+ return not any(k in ALL_KEYWORDS for k in schema)
81
+
82
+
83
+ def _find_single_reference_combinators(schema: JsonSchemaObject) -> list[str]:
84
+ # Schema example:
85
+ # {
86
+ # "type": "object",
87
+ # "properties": {
88
+ # "parent": {
89
+ # "allOf": [{"$ref": "#/components/schemas/User"}]
90
+ # }
91
+ # }
92
+ # }
93
+ found = []
94
+ for keyword in ("allOf", "oneOf", "anyOf"):
95
+ combinator = schema.get(keyword)
96
+ if combinator is not None:
97
+ optionals = [subschema for subschema in combinator if not _is_optional_schema(subschema)]
98
+ # NOTE: The first schema is not bool, hence it is safe to pass it to `_has_references`
99
+ if len(optionals) == 1 and _has_references(optionals[0]):
100
+ found.append(keyword)
101
+ return found
102
+
103
+
104
+ def _sanitize_items(schema: JsonSchemaObject) -> None:
105
+ items = schema["items"]
106
+ min_items = schema.get("minItems", 0)
107
+ if not min_items:
108
+ if isinstance(items, dict) and ("$ref" in items or _find_single_reference_combinators(items)):
109
+ _convert_to_empty_array(schema)
110
+ if isinstance(items, list) and _has_references_in_items(items):
111
+ _convert_to_empty_array(schema)
112
+
113
+
114
+ def _convert_to_empty_array(schema: JsonSchemaObject) -> None:
115
+ del schema["items"]
116
+ schema["maxItems"] = 0
117
+
118
+
119
+ def _sanitize_additional_properties(schema: JsonSchemaObject) -> None:
120
+ additional_properties = schema["additionalProperties"]
121
+ if isinstance(additional_properties, dict) and "$ref" in additional_properties:
122
+ schema["additionalProperties"] = False
@@ -0,0 +1,41 @@
1
+ from typing import Any, Union
2
+
3
+ JsonSchemaObject = dict[str, Any]
4
+ JsonSchema = Union[JsonSchemaObject, bool]
5
+
6
+ ANY_TYPE = ["null", "boolean", "number", "string", "array", "object"]
7
+ ALL_TYPES = ["null", "boolean", "integer", "number", "string", "array", "object"]
8
+
9
+
10
+ def get_type(schema: JsonSchema, *, _check_type: bool = False) -> list[str]:
11
+ if isinstance(schema, bool):
12
+ return ANY_TYPE
13
+ ty = schema.get("type", ANY_TYPE)
14
+ if isinstance(ty, str):
15
+ if _check_type and ty not in ALL_TYPES:
16
+ raise AssertionError(f"Unknown type: `{ty}`. Should be one of {', '.join(ALL_TYPES)}")
17
+ return [ty]
18
+ if ty is ANY_TYPE:
19
+ return list(ty)
20
+ return [t for t in ALL_TYPES if t in ty]
21
+
22
+
23
+ def _get_type(schema: JsonSchema) -> list[str]:
24
+ # Special version to patch `hypothesis-jsonschema`
25
+ return get_type(schema, _check_type=True)
26
+
27
+
28
+ def to_json_type_name(v: Any) -> str:
29
+ if v is None:
30
+ return "null"
31
+ if isinstance(v, bool):
32
+ return "boolean"
33
+ if isinstance(v, dict):
34
+ return "object"
35
+ if isinstance(v, list):
36
+ return "array"
37
+ if isinstance(v, (int, float)):
38
+ return "number"
39
+ if isinstance(v, str):
40
+ return "string"
41
+ return type(v).__name__
@@ -1,10 +1,12 @@
1
+ from __future__ import annotations
2
+
1
3
  from functools import lru_cache
2
- from typing import Generator, Tuple
4
+ from typing import Generator
3
5
 
4
6
  from schemathesis.core.errors import MalformedMediaType
5
7
 
6
8
 
7
- def _parseparam(s: str) -> Generator[str, None, None]:
9
+ def _parseparam(s: str) -> Generator[str]:
8
10
  while s[:1] == ";":
9
11
  s = s[1:]
10
12
  end = s.find(";")
@@ -17,7 +19,7 @@ def _parseparam(s: str) -> Generator[str, None, None]:
17
19
  s = s[end:]
18
20
 
19
21
 
20
- def _parse_header(line: str) -> Tuple[str, dict]:
22
+ def _parse_header(line: str) -> tuple[str, dict]:
21
23
  parts = _parseparam(";" + line)
22
24
  key = parts.__next__()
23
25
  pdict = {}
@@ -34,7 +36,7 @@ def _parse_header(line: str) -> Tuple[str, dict]:
34
36
 
35
37
 
36
38
  @lru_cache
37
- def parse(media_type: str) -> Tuple[str, str]:
39
+ def parse(media_type: str) -> tuple[str, str]:
38
40
  """Parse Content Type and return main type and subtype."""
39
41
  try:
40
42
  media_type, _ = _parse_header(media_type)
@@ -0,0 +1,37 @@
1
+ from enum import Enum
2
+
3
+ LOCATION_TO_CONTAINER = {
4
+ "path": "path_parameters",
5
+ "query": "query",
6
+ "header": "headers",
7
+ "cookie": "cookies",
8
+ "body": "body",
9
+ }
10
+
11
+
12
+ class ParameterLocation(str, Enum):
13
+ """API parameter location."""
14
+
15
+ QUERY = "query"
16
+ HEADER = "header"
17
+ PATH = "path"
18
+ COOKIE = "cookie"
19
+ BODY = "body"
20
+ UNKNOWN = None
21
+
22
+ @property
23
+ def container_name(self) -> str:
24
+ return {
25
+ "path": "path_parameters",
26
+ "query": "query",
27
+ "header": "headers",
28
+ "cookie": "cookies",
29
+ "body": "body",
30
+ }[self]
31
+
32
+ @property
33
+ def is_in_header(self) -> bool:
34
+ return self in HEADER_LOCATIONS
35
+
36
+
37
+ HEADER_LOCATIONS = frozenset([ParameterLocation.HEADER, ParameterLocation.COOKIE])
@@ -1,6 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Dict, List, Mapping, Union, overload
3
+ from typing import Any, Callable, Dict, List, Mapping, TypeVar, Union, overload
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ @overload
9
+ def deepclone(value: dict) -> dict: ... # pragma: no cover
10
+
11
+
12
+ @overload
13
+ def deepclone(value: list) -> list: ... # pragma: no cover
14
+
15
+
16
+ @overload
17
+ def deepclone(value: T) -> T: ... # pragma: no cover
4
18
 
5
19
 
6
20
  def deepclone(value: Any) -> Any:
@@ -11,7 +25,16 @@ def deepclone(value: Any) -> Any:
11
25
  if isinstance(value, dict):
12
26
  return {
13
27
  k1: (
14
- {k2: deepclone(v2) for k2, v2 in v1.items()}
28
+ {
29
+ k2: (
30
+ {k3: deepclone(v3) for k3, v3 in v2.items()}
31
+ if isinstance(v2, dict)
32
+ else [deepclone(v3) for v3 in v2]
33
+ if isinstance(v2, list)
34
+ else v2
35
+ )
36
+ for k2, v2 in v1.items()
37
+ }
15
38
  if isinstance(v1, dict)
16
39
  else [deepclone(v2) for v2 in v1]
17
40
  if isinstance(v1, list)
@@ -1,6 +1,8 @@
1
1
  import re
2
2
  from urllib.parse import urlparse
3
3
 
4
+ from schemathesis.core.errors import InvalidSchema
5
+
4
6
  # Adapted from http.client._is_illegal_header_value
5
7
  INVALID_HEADER_RE = re.compile(r"\n(?![ \t])|\r(?![ \t\n])")
6
8
 
@@ -29,6 +31,23 @@ def is_latin_1_encodable(value: object) -> bool:
29
31
  return False
30
32
 
31
33
 
34
+ def check_header_name(name: str) -> None:
35
+ from requests.exceptions import InvalidHeader
36
+ from requests.utils import check_header_validity
37
+
38
+ if not name:
39
+ raise InvalidSchema("Header name should not be empty")
40
+ if not name.isascii():
41
+ # `urllib3` encodes header names to ASCII
42
+ raise InvalidSchema(f"Header name should be ASCII: {name}")
43
+ try:
44
+ check_header_validity((name, ""))
45
+ except InvalidHeader as exc:
46
+ raise InvalidSchema(str(exc)) from None
47
+ if bool(INVALID_HEADER_RE.search(name)):
48
+ raise InvalidSchema(f"Invalid header name: {name}")
49
+
50
+
32
51
  SURROGATE_PAIR_RE = re.compile(r"[\ud800-\udfff]")
33
52
  _contains_surrogate_pair = SURROGATE_PAIR_RE.search
34
53
 
@@ -95,7 +95,7 @@ class EngineContext:
95
95
  # Generate links from collected Location headers
96
96
  inferencer = LinkInferencer.from_schema(self.schema)
97
97
  for operation, entries in self.observations.location_headers.items():
98
- injected += inferencer.inject_links(operation.definition.raw, entries)
98
+ injected += inferencer.inject_links(operation.responses, entries)
99
99
 
100
100
  return injected
101
101