schemathesis 3.25.5__py3-none-any.whl → 3.39.7__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 (146) hide show
  1. schemathesis/__init__.py +6 -6
  2. schemathesis/_compat.py +2 -2
  3. schemathesis/_dependency_versions.py +4 -2
  4. schemathesis/_hypothesis.py +369 -56
  5. schemathesis/_lazy_import.py +1 -0
  6. schemathesis/_override.py +5 -4
  7. schemathesis/_patches.py +21 -0
  8. schemathesis/_rate_limiter.py +7 -0
  9. schemathesis/_xml.py +75 -22
  10. schemathesis/auths.py +78 -16
  11. schemathesis/checks.py +21 -9
  12. schemathesis/cli/__init__.py +793 -448
  13. schemathesis/cli/__main__.py +4 -0
  14. schemathesis/cli/callbacks.py +58 -13
  15. schemathesis/cli/cassettes.py +233 -47
  16. schemathesis/cli/constants.py +8 -2
  17. schemathesis/cli/context.py +24 -4
  18. schemathesis/cli/debug.py +2 -1
  19. schemathesis/cli/handlers.py +4 -1
  20. schemathesis/cli/junitxml.py +103 -22
  21. schemathesis/cli/options.py +15 -4
  22. schemathesis/cli/output/default.py +286 -115
  23. schemathesis/cli/output/short.py +25 -6
  24. schemathesis/cli/reporting.py +79 -0
  25. schemathesis/cli/sanitization.py +6 -0
  26. schemathesis/code_samples.py +5 -3
  27. schemathesis/constants.py +1 -0
  28. schemathesis/contrib/openapi/__init__.py +1 -1
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
  30. schemathesis/contrib/openapi/formats/uuid.py +2 -1
  31. schemathesis/contrib/unique_data.py +3 -3
  32. schemathesis/exceptions.py +76 -65
  33. schemathesis/experimental/__init__.py +35 -0
  34. schemathesis/extra/_aiohttp.py +1 -0
  35. schemathesis/extra/_flask.py +4 -1
  36. schemathesis/extra/_server.py +1 -0
  37. schemathesis/extra/pytest_plugin.py +17 -25
  38. schemathesis/failures.py +77 -9
  39. schemathesis/filters.py +185 -8
  40. schemathesis/fixups/__init__.py +1 -0
  41. schemathesis/fixups/fast_api.py +2 -2
  42. schemathesis/fixups/utf8_bom.py +1 -2
  43. schemathesis/generation/__init__.py +20 -36
  44. schemathesis/generation/_hypothesis.py +59 -0
  45. schemathesis/generation/_methods.py +44 -0
  46. schemathesis/generation/coverage.py +931 -0
  47. schemathesis/graphql.py +0 -1
  48. schemathesis/hooks.py +89 -12
  49. schemathesis/internal/checks.py +84 -0
  50. schemathesis/internal/copy.py +22 -3
  51. schemathesis/internal/deprecation.py +6 -2
  52. schemathesis/internal/diff.py +15 -0
  53. schemathesis/internal/extensions.py +27 -0
  54. schemathesis/internal/jsonschema.py +2 -1
  55. schemathesis/internal/output.py +68 -0
  56. schemathesis/internal/result.py +1 -1
  57. schemathesis/internal/transformation.py +11 -0
  58. schemathesis/lazy.py +138 -25
  59. schemathesis/loaders.py +7 -5
  60. schemathesis/models.py +323 -213
  61. schemathesis/parameters.py +4 -0
  62. schemathesis/runner/__init__.py +72 -22
  63. schemathesis/runner/events.py +86 -6
  64. schemathesis/runner/impl/context.py +104 -0
  65. schemathesis/runner/impl/core.py +447 -187
  66. schemathesis/runner/impl/solo.py +19 -29
  67. schemathesis/runner/impl/threadpool.py +70 -79
  68. schemathesis/{cli → runner}/probes.py +37 -25
  69. schemathesis/runner/serialization.py +150 -17
  70. schemathesis/sanitization.py +5 -1
  71. schemathesis/schemas.py +170 -102
  72. schemathesis/serializers.py +17 -4
  73. schemathesis/service/ci.py +1 -0
  74. schemathesis/service/client.py +39 -6
  75. schemathesis/service/events.py +5 -1
  76. schemathesis/service/extensions.py +224 -0
  77. schemathesis/service/hosts.py +6 -2
  78. schemathesis/service/metadata.py +25 -0
  79. schemathesis/service/models.py +211 -2
  80. schemathesis/service/report.py +6 -6
  81. schemathesis/service/serialization.py +60 -71
  82. schemathesis/service/usage.py +1 -0
  83. schemathesis/specs/graphql/_cache.py +26 -0
  84. schemathesis/specs/graphql/loaders.py +25 -5
  85. schemathesis/specs/graphql/nodes.py +1 -0
  86. schemathesis/specs/graphql/scalars.py +2 -2
  87. schemathesis/specs/graphql/schemas.py +130 -100
  88. schemathesis/specs/graphql/validation.py +1 -2
  89. schemathesis/specs/openapi/__init__.py +1 -0
  90. schemathesis/specs/openapi/_cache.py +123 -0
  91. schemathesis/specs/openapi/_hypothesis.py +79 -61
  92. schemathesis/specs/openapi/checks.py +504 -25
  93. schemathesis/specs/openapi/converter.py +31 -4
  94. schemathesis/specs/openapi/definitions.py +10 -17
  95. schemathesis/specs/openapi/examples.py +143 -31
  96. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  97. schemathesis/specs/openapi/expressions/context.py +1 -1
  98. schemathesis/specs/openapi/expressions/extractors.py +26 -0
  99. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  100. schemathesis/specs/openapi/expressions/nodes.py +29 -6
  101. schemathesis/specs/openapi/expressions/parser.py +26 -5
  102. schemathesis/specs/openapi/formats.py +44 -0
  103. schemathesis/specs/openapi/links.py +125 -42
  104. schemathesis/specs/openapi/loaders.py +77 -36
  105. schemathesis/specs/openapi/media_types.py +34 -0
  106. schemathesis/specs/openapi/negative/__init__.py +6 -3
  107. schemathesis/specs/openapi/negative/mutations.py +21 -6
  108. schemathesis/specs/openapi/parameters.py +39 -25
  109. schemathesis/specs/openapi/patterns.py +137 -0
  110. schemathesis/specs/openapi/references.py +37 -7
  111. schemathesis/specs/openapi/schemas.py +368 -242
  112. schemathesis/specs/openapi/security.py +25 -7
  113. schemathesis/specs/openapi/serialization.py +1 -0
  114. schemathesis/specs/openapi/stateful/__init__.py +198 -70
  115. schemathesis/specs/openapi/stateful/statistic.py +198 -0
  116. schemathesis/specs/openapi/stateful/types.py +14 -0
  117. schemathesis/specs/openapi/utils.py +6 -1
  118. schemathesis/specs/openapi/validation.py +1 -0
  119. schemathesis/stateful/__init__.py +35 -21
  120. schemathesis/stateful/config.py +97 -0
  121. schemathesis/stateful/context.py +135 -0
  122. schemathesis/stateful/events.py +274 -0
  123. schemathesis/stateful/runner.py +309 -0
  124. schemathesis/stateful/sink.py +68 -0
  125. schemathesis/stateful/state_machine.py +67 -38
  126. schemathesis/stateful/statistic.py +22 -0
  127. schemathesis/stateful/validation.py +100 -0
  128. schemathesis/targets.py +33 -1
  129. schemathesis/throttling.py +25 -5
  130. schemathesis/transports/__init__.py +354 -0
  131. schemathesis/transports/asgi.py +7 -0
  132. schemathesis/transports/auth.py +25 -2
  133. schemathesis/transports/content_types.py +3 -1
  134. schemathesis/transports/headers.py +2 -1
  135. schemathesis/transports/responses.py +9 -4
  136. schemathesis/types.py +9 -0
  137. schemathesis/utils.py +11 -16
  138. schemathesis-3.39.7.dist-info/METADATA +293 -0
  139. schemathesis-3.39.7.dist-info/RECORD +160 -0
  140. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
  141. schemathesis/specs/openapi/filters.py +0 -49
  142. schemathesis/specs/openapi/stateful/links.py +0 -92
  143. schemathesis-3.25.5.dist-info/METADATA +0 -356
  144. schemathesis-3.25.5.dist-info/RECORD +0 -134
  145. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
  146. {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,7 @@
1
1
  # These schemas are copied from https://github.com/OAI/OpenAPI-Specification/tree/master/schemas
2
2
  from __future__ import annotations
3
- from typing import Any, TYPE_CHECKING
3
+
4
+ from typing import TYPE_CHECKING, Any
4
5
 
5
6
  from ..._lazy_import import lazy_import
6
7
 
@@ -1329,6 +1330,8 @@ OPENAPI_30 = {
1329
1330
  },
1330
1331
  },
1331
1332
  }
1333
+ # Generated from the updated schema.yaml from 0035208, which includes unpublished bugfixes
1334
+ # https://github.com/OAI/OpenAPI-Specification/blob/0035208611701b4f7f2c959eb99a8725cca41e6e/schemas/v3.1/schema.yaml
1332
1335
  OPENAPI_31 = {
1333
1336
  "$id": "https://spec.openapis.org/oas/3.1/schema/2022-10-07",
1334
1337
  "$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -1344,7 +1347,7 @@ OPENAPI_31 = {
1344
1347
  },
1345
1348
  "servers": {"type": "array", "items": {"$ref": "#/$defs/server"}, "default": [{"url": "/"}]},
1346
1349
  "paths": {"$ref": "#/$defs/paths"},
1347
- "webhooks": {"type": "object", "additionalProperties": {"$ref": "#/$defs/path-item-or-reference"}},
1350
+ "webhooks": {"type": "object", "additionalProperties": {"$ref": "#/$defs/path-item"}},
1348
1351
  "components": {"$ref": "#/$defs/components"},
1349
1352
  "security": {"type": "array", "items": {"$ref": "#/$defs/security-requirement"}},
1350
1353
  "tags": {"type": "array", "items": {"$ref": "#/$defs/tag"}},
@@ -1399,7 +1402,7 @@ OPENAPI_31 = {
1399
1402
  "$comment": "https://spec.openapis.org/oas/v3.1.0#server-object",
1400
1403
  "type": "object",
1401
1404
  "properties": {
1402
- "url": {"type": "string", "format": "uri-reference"},
1405
+ "url": {"type": "string"},
1403
1406
  "description": {"type": "string"},
1404
1407
  "variables": {"type": "object", "additionalProperties": {"$ref": "#/$defs/server-variable"}},
1405
1408
  },
@@ -1438,7 +1441,7 @@ OPENAPI_31 = {
1438
1441
  },
1439
1442
  "links": {"type": "object", "additionalProperties": {"$ref": "#/$defs/link-or-reference"}},
1440
1443
  "callbacks": {"type": "object", "additionalProperties": {"$ref": "#/$defs/callbacks-or-reference"}},
1441
- "pathItems": {"type": "object", "additionalProperties": {"$ref": "#/$defs/path-item-or-reference"}},
1444
+ "pathItems": {"type": "object", "additionalProperties": {"$ref": "#/$defs/path-item"}},
1442
1445
  },
1443
1446
  "patternProperties": {
1444
1447
  "^(schemas|responses|parameters|examples|requestBodies|headers|securitySchemes|links|callbacks|pathItems)$": {
@@ -1460,6 +1463,7 @@ OPENAPI_31 = {
1460
1463
  "$comment": "https://spec.openapis.org/oas/v3.1.0#path-item-object",
1461
1464
  "type": "object",
1462
1465
  "properties": {
1466
+ "$ref": {"type": "string", "format": "uri-reference"},
1463
1467
  "summary": {"type": "string"},
1464
1468
  "description": {"type": "string"},
1465
1469
  "servers": {"type": "array", "items": {"$ref": "#/$defs/server"}},
@@ -1476,11 +1480,6 @@ OPENAPI_31 = {
1476
1480
  "$ref": "#/$defs/specification-extensions",
1477
1481
  "unevaluatedProperties": False,
1478
1482
  },
1479
- "path-item-or-reference": {
1480
- "if": {"type": "object", "required": ["$ref"]},
1481
- "then": {"$ref": "#/$defs/reference"},
1482
- "else": {"$ref": "#/$defs/path-item"},
1483
- },
1484
1483
  "operation": {
1485
1484
  "$comment": "https://spec.openapis.org/oas/v3.1.0#operation-object",
1486
1485
  "type": "object",
@@ -1541,7 +1540,6 @@ OPENAPI_31 = {
1541
1540
  "if": {"properties": {"in": {"const": "path"}}, "required": ["in"]},
1542
1541
  "then": {
1543
1542
  "properties": {
1544
- "name": {"pattern": "[^/#?]+$"},
1545
1543
  "style": {"default": "simple", "enum": ["matrix", "label", "simple"]},
1546
1544
  "required": {"const": True},
1547
1545
  },
@@ -1661,7 +1659,7 @@ OPENAPI_31 = {
1661
1659
  "$comment": "https://spec.openapis.org/oas/v3.1.0#callback-object",
1662
1660
  "type": "object",
1663
1661
  "$ref": "#/$defs/specification-extensions",
1664
- "additionalProperties": {"$ref": "#/$defs/path-item-or-reference"},
1662
+ "additionalProperties": {"$ref": "#/$defs/path-item"},
1665
1663
  },
1666
1664
  "callbacks-or-reference": {
1667
1665
  "if": {"type": "object", "required": ["$ref"]},
@@ -1754,7 +1752,6 @@ OPENAPI_31 = {
1754
1752
  "summary": {"type": "string"},
1755
1753
  "description": {"type": "string"},
1756
1754
  },
1757
- "unevaluatedProperties": False,
1758
1755
  },
1759
1756
  "schema": {
1760
1757
  "$comment": "https://spec.openapis.org/oas/v3.1.0#schema-object",
@@ -1906,11 +1903,7 @@ _VALIDATORS = [
1906
1903
  "OPENAPI_31_VALIDATOR",
1907
1904
  ]
1908
1905
 
1909
- __all__ = [
1910
- "SWAGGER_20",
1911
- "OPENAPI_30",
1912
- "OPENAPI_31",
1913
- ] + _VALIDATORS
1906
+ __all__ = ["SWAGGER_20", "OPENAPI_30", "OPENAPI_31", *_VALIDATORS]
1914
1907
 
1915
1908
  _imports = {
1916
1909
  "SWAGGER_20_VALIDATOR": lambda: make_validator(SWAGGER_20),
@@ -3,19 +3,25 @@ from __future__ import annotations
3
3
  from contextlib import suppress
4
4
  from dataclasses import dataclass
5
5
  from functools import lru_cache
6
- from itertools import islice, cycle, chain
7
- from typing import Any, Generator, Union, cast
6
+ from itertools import chain, cycle, islice
7
+ from typing import TYPE_CHECKING, Any, Generator, Iterator, Union, cast
8
8
 
9
9
  import requests
10
- import hypothesis
11
10
  from hypothesis_jsonschema import from_schema
12
- from hypothesis.strategies import SearchStrategy
13
11
 
14
- from .parameters import OpenAPIParameter, OpenAPIBody
15
12
  from ...constants import DEFAULT_RESPONSE_TIMEOUT
16
- from ...models import APIOperation, Case
17
- from ._hypothesis import get_case_strategy
13
+ from ...generation import get_single_example
14
+ from ...internal.copy import fast_deepcopy
15
+ from ...models import APIOperation, Case, TestPhase
16
+ from ._hypothesis import get_case_strategy, get_default_format_strategies
18
17
  from .constants import LOCATION_TO_CONTAINER
18
+ from .formats import STRING_FORMATS
19
+ from .parameters import OpenAPIBody, OpenAPIParameter
20
+
21
+ if TYPE_CHECKING:
22
+ from hypothesis.strategies import SearchStrategy
23
+
24
+ from ...generation import GenerationConfig
19
25
 
20
26
 
21
27
  @dataclass
@@ -39,7 +45,7 @@ Example = Union[ParameterExample, BodyExample]
39
45
 
40
46
 
41
47
  def get_strategies_from_examples(
42
- operation: APIOperation[OpenAPIParameter, Case], examples_field: str = "examples"
48
+ operation: APIOperation[OpenAPIParameter, Case], as_strategy_kwargs: dict[str, Any] | None = None
43
49
  ) -> list[SearchStrategy[Case]]:
44
50
  """Build a set of strategies that generate test cases based on explicit examples in the schema."""
45
51
  maps = {}
@@ -62,14 +68,17 @@ def get_strategies_from_examples(
62
68
  examples = list(extract_top_level(operation))
63
69
  # Add examples from parameter's schemas
64
70
  examples.extend(extract_from_schemas(operation))
71
+ as_strategy_kwargs = as_strategy_kwargs or {}
72
+ as_strategy_kwargs["phase"] = TestPhase.EXPLICIT
65
73
  return [
66
- get_case_strategy(operation=operation, **parameters).map(serialize_components)
74
+ get_case_strategy(operation=operation, **{**parameters, **(as_strategy_kwargs or {})}).map(serialize_components)
67
75
  for parameters in produce_combinations(examples)
68
76
  ]
69
77
 
70
78
 
71
79
  def extract_top_level(operation: APIOperation[OpenAPIParameter, Case]) -> Generator[Example, None, None]:
72
80
  """Extract top-level parameter examples from `examples` & `example` fields."""
81
+ responses = find_in_responses(operation)
73
82
  for parameter in operation.iter_parameters():
74
83
  if "schema" in parameter.definition:
75
84
  definitions = [parameter.definition, *_expand_subschemas(parameter.definition["schema"])]
@@ -99,6 +108,10 @@ def extract_top_level(operation: APIOperation[OpenAPIParameter, Case]) -> Genera
99
108
  yield ParameterExample(
100
109
  container=LOCATION_TO_CONTAINER[parameter.location], name=parameter.name, value=value
101
110
  )
111
+ for value in find_matching_in_responses(responses, parameter.name):
112
+ yield ParameterExample(
113
+ container=LOCATION_TO_CONTAINER[parameter.location], name=parameter.name, value=value
114
+ )
102
115
  for alternative in operation.body:
103
116
  alternative = cast(OpenAPIBody, alternative)
104
117
  if "schema" in alternative.definition:
@@ -126,10 +139,26 @@ def extract_top_level(operation: APIOperation[OpenAPIParameter, Case]) -> Genera
126
139
  def _expand_subschemas(schema: dict[str, Any] | bool) -> Generator[dict[str, Any] | bool, None, None]:
127
140
  yield schema
128
141
  if isinstance(schema, dict):
129
- for key in ("anyOf", "oneOf", "allOf"):
142
+ for key in ("anyOf", "oneOf"):
130
143
  if key in schema:
131
144
  for subschema in schema[key]:
132
145
  yield subschema
146
+ if "allOf" in schema:
147
+ subschema = fast_deepcopy(schema["allOf"][0])
148
+ for sub in schema["allOf"][1:]:
149
+ if isinstance(sub, dict):
150
+ for key, value in sub.items():
151
+ if key == "properties":
152
+ subschema.setdefault("properties", {}).update(value)
153
+ elif key == "required":
154
+ subschema.setdefault("required", []).extend(value)
155
+ elif key == "examples":
156
+ subschema.setdefault("examples", []).extend(value)
157
+ elif key == "example":
158
+ subschema.setdefault("examples", []).append(value)
159
+ else:
160
+ subschema[key] = value
161
+ yield subschema
133
162
 
134
163
 
135
164
  def _find_parameter_examples_definition(
@@ -178,7 +207,7 @@ def extract_inner_examples(
178
207
  ) -> Generator[Any, None, None]:
179
208
  """Extract exact examples values from the `examples` dictionary."""
180
209
  for name, example in examples.items():
181
- if "$ref" in unresolved_definition[name]:
210
+ if "$ref" in unresolved_definition[name] and "value" not in example and "externalValue" not in example:
182
211
  # The example here is a resolved example and should be yielded as is
183
212
  yield example
184
213
  if isinstance(example, dict):
@@ -209,8 +238,9 @@ def extract_from_schemas(operation: APIOperation[OpenAPIParameter, Case]) -> Gen
209
238
  for alternative in operation.body:
210
239
  alternative = cast(OpenAPIBody, alternative)
211
240
  schema = alternative.as_json_schema(operation)
212
- for value in extract_from_schema(operation, schema, alternative.example_field, alternative.examples_field):
213
- yield BodyExample(value=value, media_type=alternative.media_type)
241
+ for example_field, examples_field in (("example", "examples"), ("x-example", "x-examples")):
242
+ for value in extract_from_schema(operation, schema, example_field, examples_field):
243
+ yield BodyExample(value=value, media_type=alternative.media_type)
214
244
 
215
245
 
216
246
  def extract_from_schema(
@@ -236,6 +266,8 @@ def extract_from_schema(
236
266
  if examples_field_name in subsubschema and isinstance(subsubschema[examples_field_name], list):
237
267
  # These are JSON Schema examples, which is an array of values
238
268
  values.extend(subsubschema[examples_field_name])
269
+ # Check nested examples as well
270
+ values.extend(extract_from_schema(operation, subsubschema, example_field_name, examples_field_name))
239
271
  if not values:
240
272
  if name in required:
241
273
  # Defer generation to only generate these variants if at least one property has examples
@@ -248,7 +280,7 @@ def extract_from_schema(
248
280
  # Generated by one of `anyOf` or similar sub-schemas
249
281
  continue
250
282
  subschema = operation.schema.prepare_schema(subschema)
251
- generated = _generate_single_example(subschema)
283
+ generated = _generate_single_example(subschema, operation.schema.generation_config)
252
284
  variants[name] = [generated]
253
285
  # Calculate the maximum number of examples any property has
254
286
  total_combos = max(len(examples) for examples in variants.values())
@@ -264,24 +296,17 @@ def extract_from_schema(
264
296
  yield [value]
265
297
 
266
298
 
267
- def _generate_single_example(schema: dict[str, Any]) -> Any:
268
- examples = []
269
-
270
- @hypothesis.given(from_schema(schema)) # type: ignore
271
- @hypothesis.settings( # type: ignore
272
- database=None,
273
- max_examples=1,
274
- deadline=None,
275
- verbosity=hypothesis.Verbosity.quiet,
276
- phases=(hypothesis.Phase.generate,),
277
- suppress_health_check=list(hypothesis.HealthCheck),
299
+ def _generate_single_example(
300
+ schema: dict[str, Any],
301
+ generation_config: GenerationConfig,
302
+ ) -> Any:
303
+ strategy = from_schema(
304
+ schema,
305
+ custom_formats={**get_default_format_strategies(), **STRING_FORMATS},
306
+ allow_x00=generation_config.allow_x00,
307
+ codec=generation_config.codec,
278
308
  )
279
- def example_generating_inner_function(ex: Any) -> None:
280
- examples.append(ex)
281
-
282
- example_generating_inner_function()
283
-
284
- return examples[0]
309
+ return get_single_example(strategy)
285
310
 
286
311
 
287
312
  def produce_combinations(examples: list[Example]) -> Generator[dict[str, Any], None, None]:
@@ -330,3 +355,90 @@ def _produce_parameter_combinations(parameters: dict[str, dict[str, list]]) -> G
330
355
  }
331
356
  for container, variants in parameters.items()
332
357
  }
358
+
359
+
360
+ def find_in_responses(operation: APIOperation) -> dict[str, list[dict[str, Any]]]:
361
+ """Find schema examples in responses."""
362
+ output: dict[str, list[dict[str, Any]]] = {}
363
+ for status_code, response in operation.definition.raw.get("responses", {}).items():
364
+ if not str(status_code).startswith("2"):
365
+ # Check only 2xx responses
366
+ continue
367
+ if isinstance(response, dict) and "$ref" in response:
368
+ _, response = operation.schema.resolver.resolve_in_scope(response, operation.definition.scope) # type:ignore[attr-defined]
369
+ for media_type, definition in response.get("content", {}).items():
370
+ schema_ref = definition.get("schema", {}).get("$ref")
371
+ if schema_ref:
372
+ name = schema_ref.split("/")[-1]
373
+ else:
374
+ name = f"{status_code}/{media_type}"
375
+ for examples_field, example_field in (
376
+ ("examples", "example"),
377
+ ("x-examples", "x-example"),
378
+ ):
379
+ examples = definition.get(examples_field, {})
380
+ for example in examples.values():
381
+ if "value" in example:
382
+ output.setdefault(name, []).append(example["value"])
383
+ if example_field in definition:
384
+ output.setdefault(name, []).append(definition[example_field])
385
+ return output
386
+
387
+
388
+ NOT_FOUND = object()
389
+
390
+
391
+ def find_matching_in_responses(examples: dict[str, list], param: str) -> Iterator[Any]:
392
+ """Find matching parameter examples."""
393
+ normalized = param.lower()
394
+ is_id_param = normalized.endswith("id")
395
+ # Extract values from response examples that match input parameters.
396
+ # E.g., for `GET /orders/{id}/`, use "id" or "orderId" from `Order` response
397
+ # as examples for the "id" path parameter.
398
+ for schema_name, schema_examples in examples.items():
399
+ for example in schema_examples:
400
+ if not isinstance(example, dict):
401
+ continue
402
+ # Unwrapping example from `{"item": [{...}]}`
403
+ if isinstance(example, dict) and len(example) == 1 and list(example)[0].lower() == schema_name.lower():
404
+ inner = list(example.values())[0]
405
+ if isinstance(inner, list):
406
+ for sub_example in inner:
407
+ found = _find_matching_in_responses(sub_example, schema_name, param, normalized, is_id_param)
408
+ if found is not NOT_FOUND:
409
+ yield found
410
+ continue
411
+ if isinstance(inner, dict):
412
+ example = inner
413
+ found = _find_matching_in_responses(example, schema_name, param, normalized, is_id_param)
414
+ if found is not NOT_FOUND:
415
+ yield found
416
+
417
+
418
+ def _find_matching_in_responses(
419
+ example: dict[str, Any], schema_name: str, param: str, normalized: str, is_id_param: bool
420
+ ) -> Any:
421
+ # Check for exact match
422
+ if param in example:
423
+ return example[param]
424
+ if is_id_param and param[:-2] in example:
425
+ return example[param[:-2]]
426
+
427
+ # Check for case-insensitive match
428
+ for key in example:
429
+ if key.lower() == normalized:
430
+ return example[key]
431
+ else:
432
+ # If no match found and it's an ID parameter, try additional checks
433
+ if is_id_param:
434
+ # Check for 'id' if parameter is '{something}Id'
435
+ if "id" in example:
436
+ return example["id"]
437
+ # Check for '{schemaName}Id' or '{schemaName}_id'
438
+ if normalized == "id" or normalized.startswith(schema_name.lower()):
439
+ for key in (schema_name, schema_name.lower()):
440
+ for suffix in ("_id", "Id"):
441
+ with_suffix = f"{key}{suffix}"
442
+ if with_suffix in example:
443
+ return example[with_suffix]
444
+ return NOT_FOUND
@@ -2,14 +2,27 @@
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
 
7
11
  from . import lexer, nodes, parser
8
12
  from .context import ExpressionContext
9
13
 
14
+ __all__ = [
15
+ "lexer",
16
+ "nodes",
17
+ "parser",
18
+ "ExpressionContext",
19
+ ]
10
20
 
11
- def evaluate(expr: Any, context: ExpressionContext) -> str:
21
+
22
+ def evaluate(expr: Any, context: ExpressionContext, evaluate_nested: bool = False) -> Any:
12
23
  """Evaluate runtime expression in context."""
24
+ if isinstance(expr, (dict, list)) and evaluate_nested:
25
+ return _evaluate_nested(expr, context)
13
26
  if not isinstance(expr, str):
14
27
  # Can be a non-string constant
15
28
  return expr
@@ -17,4 +30,26 @@ def evaluate(expr: Any, context: ExpressionContext) -> str:
17
30
  if len(parts) == 1:
18
31
  return parts[0] # keep the return type the same as the internal value type
19
32
  # otherwise, concatenate into a string
20
- return "".join(map(str, parts))
33
+ return "".join(str(part) for part in parts if part is not None)
34
+
35
+
36
+ def _evaluate_nested(expr: dict[str, Any] | list, context: ExpressionContext) -> Any:
37
+ if isinstance(expr, dict):
38
+ return {
39
+ _evaluate_object_key(key, context): evaluate(value, context, evaluate_nested=True)
40
+ for key, value in expr.items()
41
+ }
42
+ return [evaluate(item, context, evaluate_nested=True) for item in expr]
43
+
44
+
45
+ def _evaluate_object_key(key: str, context: ExpressionContext) -> Any:
46
+ evaluated = evaluate(key, context)
47
+ if isinstance(evaluated, str):
48
+ return evaluated
49
+ if isinstance(evaluated, bool):
50
+ return "true" if evaluated else "false"
51
+ if isinstance(evaluated, (int, float)):
52
+ return str(evaluated)
53
+ if evaluated is None:
54
+ return "null"
55
+ return json.dumps(evaluated)
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
+
2
3
  from dataclasses import dataclass
3
4
  from typing import TYPE_CHECKING
4
5
 
5
-
6
6
  if TYPE_CHECKING:
7
7
  from ....models import Case
8
8
  from ....transports.responses import GenericResponse
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ import re
8
+
9
+
10
+ @dataclass
11
+ class Extractor:
12
+ def extract(self, value: str) -> str | None:
13
+ raise NotImplementedError
14
+
15
+
16
+ @dataclass
17
+ class RegexExtractor(Extractor):
18
+ """Extract value via a regex."""
19
+
20
+ value: re.Pattern
21
+
22
+ def extract(self, value: str) -> str | None:
23
+ match = self.value.search(value)
24
+ if match is None:
25
+ return None
26
+ return match.group(1)
@@ -1,4 +1,5 @@
1
1
  """Lexical analysis of runtime expressions."""
2
+
2
3
  from dataclasses import dataclass
3
4
  from enum import Enum, unique
4
5
  from typing import Callable, Generator
@@ -19,33 +20,34 @@ class Token:
19
20
  """Lexical token that may occur in a runtime expression."""
20
21
 
21
22
  value: str
23
+ end: int
22
24
  type_: TokenType
23
25
 
24
26
  # Helpers for cleaner instantiation
25
27
 
26
28
  @classmethod
27
- def variable(cls, value: str) -> "Token":
28
- return cls(value, TokenType.VARIABLE)
29
+ def variable(cls, value: str, end: int) -> "Token":
30
+ return cls(value, end, TokenType.VARIABLE)
29
31
 
30
32
  @classmethod
31
- def string(cls, value: str) -> "Token":
32
- return cls(value, TokenType.STRING)
33
+ def string(cls, value: str, end: int) -> "Token":
34
+ return cls(value, end, TokenType.STRING)
33
35
 
34
36
  @classmethod
35
- def pointer(cls, value: str) -> "Token":
36
- return cls(value, TokenType.POINTER)
37
+ def pointer(cls, value: str, end: int) -> "Token":
38
+ return cls(value, end, TokenType.POINTER)
37
39
 
38
40
  @classmethod
39
- def lbracket(cls) -> "Token":
40
- return cls("{", TokenType.LBRACKET)
41
+ def lbracket(cls, end: int) -> "Token":
42
+ return cls("{", end, TokenType.LBRACKET)
41
43
 
42
44
  @classmethod
43
- def rbracket(cls) -> "Token":
44
- return cls("}", TokenType.RBRACKET)
45
+ def rbracket(cls, end: int) -> "Token":
46
+ return cls("}", end, TokenType.RBRACKET)
45
47
 
46
48
  @classmethod
47
- def dot(cls) -> "Token":
48
- return cls(".", TokenType.DOT)
49
+ def dot(cls, end: int) -> "Token":
50
+ return cls(".", end, TokenType.DOT)
49
51
 
50
52
  # Helpers for simpler type comparison
51
53
 
@@ -102,15 +104,15 @@ def tokenize(expression: str) -> TokenGenerator:
102
104
  if current_symbol() == "$":
103
105
  start = cursor
104
106
  move_until(lambda: is_eol() or current_symbol() in stop_symbols)
105
- yield Token.variable(expression[start:cursor])
107
+ yield Token.variable(expression[start:cursor], cursor - 1)
106
108
  elif current_symbol() == ".":
107
- yield Token.dot()
109
+ yield Token.dot(cursor)
108
110
  move()
109
111
  elif current_symbol() == "{":
110
- yield Token.lbracket()
112
+ yield Token.lbracket(cursor)
111
113
  move()
112
114
  elif current_symbol() == "}":
113
- yield Token.rbracket()
115
+ yield Token.rbracket(cursor)
114
116
  move()
115
117
  elif current_symbol() == "#":
116
118
  start = cursor
@@ -125,8 +127,8 @@ def tokenize(expression: str) -> TokenGenerator:
125
127
  # `ID_{$response.body#/foo}_{$response.body#/bar}`
126
128
  # Which is much easier if we treat `}` as a closing bracket of an embedded runtime expression
127
129
  move_until(lambda: is_eol() or current_symbol() == "}")
128
- yield Token.pointer(expression[start:cursor])
130
+ yield Token.pointer(expression[start:cursor], cursor - 1)
129
131
  else:
130
132
  start = cursor
131
133
  move_until(lambda: is_eol() or current_symbol() in stop_symbols)
132
- yield Token.string(expression[start:cursor])
134
+ yield Token.string(expression[start:cursor], cursor - 1)
@@ -1,13 +1,18 @@
1
1
  """Expression nodes description and evaluation logic."""
2
+
2
3
  from __future__ import annotations
4
+
3
5
  from dataclasses import dataclass
4
6
  from enum import Enum, unique
5
- from typing import Any
7
+ from typing import TYPE_CHECKING, Any
6
8
 
7
9
  from requests.structures import CaseInsensitiveDict
8
10
 
9
11
  from .. import references
10
- from .context import ExpressionContext
12
+
13
+ if TYPE_CHECKING:
14
+ from .context import ExpressionContext
15
+ from .extractors import Extractor
11
16
 
12
17
 
13
18
  @dataclass
@@ -73,6 +78,7 @@ class NonBodyRequest(Node):
73
78
 
74
79
  location: str
75
80
  parameter: str
81
+ extractor: Extractor | None = None
76
82
 
77
83
  def evaluate(self, context: ExpressionContext) -> str:
78
84
  container: dict | CaseInsensitiveDict = {
@@ -82,7 +88,12 @@ class NonBodyRequest(Node):
82
88
  }[self.location] or {}
83
89
  if self.location == "header":
84
90
  container = CaseInsensitiveDict(container)
85
- return container[self.parameter]
91
+ value = container.get(self.parameter)
92
+ if value is None:
93
+ return ""
94
+ if self.extractor is not None:
95
+ return self.extractor.extract(value) or ""
96
+ return value
86
97
 
87
98
 
88
99
  @dataclass
@@ -95,7 +106,10 @@ class BodyRequest(Node):
95
106
  document = context.case.body
96
107
  if self.pointer is None:
97
108
  return document
98
- return references.resolve_pointer(document, self.pointer[1:])
109
+ resolved = references.resolve_pointer(document, self.pointer[1:])
110
+ if resolved is references.UNRESOLVABLE:
111
+ return None
112
+ return resolved
99
113
 
100
114
 
101
115
  @dataclass
@@ -103,9 +117,15 @@ class HeaderResponse(Node):
103
117
  """A node for `$response.header` expressions."""
104
118
 
105
119
  parameter: str
120
+ extractor: Extractor | None = None
106
121
 
107
122
  def evaluate(self, context: ExpressionContext) -> str:
108
- return context.response.headers[self.parameter]
123
+ value = context.response.headers.get(self.parameter)
124
+ if value is None:
125
+ return ""
126
+ if self.extractor is not None:
127
+ return self.extractor.extract(value) or ""
128
+ return value
109
129
 
110
130
 
111
131
  @dataclass
@@ -124,4 +144,7 @@ class BodyResponse(Node):
124
144
  if self.pointer is None:
125
145
  # We need the parsed document - data will be serialized before sending to the application
126
146
  return document
127
- return references.resolve_pointer(document, self.pointer[1:])
147
+ resolved = references.resolve_pointer(document, self.pointer[1:])
148
+ if resolved is references.UNRESOLVABLE:
149
+ return None
150
+ return resolved