schemathesis 3.25.6__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 +783 -432
  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 +22 -5
  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 +258 -112
  23. schemathesis/cli/output/short.py +23 -8
  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 +318 -211
  61. schemathesis/parameters.py +4 -0
  62. schemathesis/runner/__init__.py +50 -15
  63. schemathesis/runner/events.py +65 -5
  64. schemathesis/runner/impl/context.py +104 -0
  65. schemathesis/runner/impl/core.py +388 -177
  66. schemathesis/runner/impl/solo.py +19 -29
  67. schemathesis/runner/impl/threadpool.py +70 -79
  68. schemathesis/runner/probes.py +11 -9
  69. schemathesis/runner/serialization.py +150 -17
  70. schemathesis/sanitization.py +5 -1
  71. schemathesis/schemas.py +170 -102
  72. schemathesis/serializers.py +7 -2
  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 +45 -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 +78 -60
  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 +126 -12
  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 +360 -241
  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.6.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.6.dist-info/METADATA +0 -356
  144. schemathesis-3.25.6.dist-info/RECORD +0 -134
  145. {schemathesis-3.25.6.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
  146. {schemathesis-3.25.6.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,17 @@
1
1
  """Processing of ``securityDefinitions`` or ``securitySchemes`` keywords."""
2
+
2
3
  from __future__ import annotations
3
- from dataclasses import dataclass
4
- from typing import Any, ClassVar, Generator
5
4
 
6
- from jsonschema import RefResolver
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any, ClassVar, Generator
7
7
 
8
- from ...models import APIOperation
9
8
  from .parameters import OpenAPI20Parameter, OpenAPI30Parameter, OpenAPIParameter
10
9
 
10
+ if TYPE_CHECKING:
11
+ from jsonschema import RefResolver
12
+
13
+ from ...models import APIOperation
14
+
11
15
 
12
16
  @dataclass
13
17
  class BaseSecurityProcessor:
@@ -124,9 +128,23 @@ class OpenAPISecurityProcessor(BaseSecurityProcessor):
124
128
  """In Open API 3 security definitions are located in ``components`` and may have references inside."""
125
129
  components = schema.get("components", {})
126
130
  security_schemes = components.get("securitySchemes", {})
127
- if "$ref" in security_schemes:
128
- return resolver.resolve(security_schemes["$ref"])[1]
129
- return security_schemes
131
+ # At this point, the resolution scope could differ from the root scope, that's why we need to restore it
132
+ # as now we resolve root-level references
133
+ if len(resolver._scopes_stack) > 1:
134
+ scope = resolver.resolution_scope
135
+ resolver.pop_scope()
136
+ else:
137
+ scope = None
138
+ resolve = resolver.resolve
139
+ try:
140
+ if "$ref" in security_schemes:
141
+ return resolve(security_schemes["$ref"])[1]
142
+ return {
143
+ key: resolve(value["$ref"])[1] if "$ref" in value else value for key, value in security_schemes.items()
144
+ }
145
+ finally:
146
+ if scope is not None:
147
+ resolver._scopes_stack.append(scope)
130
148
 
131
149
  def _make_http_auth_parameter(self, definition: dict[str, Any]) -> dict[str, Any]:
132
150
  schema = make_auth_header_schema(definition)
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  import json
3
4
  from typing import Any, Callable, Dict, Generator, List
4
5
 
@@ -1,29 +1,51 @@
1
1
  from __future__ import annotations
2
+
2
3
  from collections import defaultdict
3
- from typing import TYPE_CHECKING, Any, List, cast
4
+ from functools import lru_cache
5
+ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator
4
6
 
5
7
  from hypothesis import strategies as st
6
8
  from hypothesis.stateful import Bundle, Rule, precondition, rule
7
- from requests.structures import CaseInsensitiveDict
8
9
 
10
+ from ....constants import NOT_SET
11
+ from ....generation import DataGenerationMethod, combine_strategies
9
12
  from ....internal.result import Ok
10
13
  from ....stateful.state_machine import APIStateMachine, Direction, StepResult
11
- from ....utils import combine_strategies
14
+ from ....types import NotSet
12
15
  from .. import expressions
13
- from . import links
14
- from .links import APIOperationConnections, Connection, _convert_strategy, apply, make_response_filter
16
+ from ..links import get_all_links
17
+ from ..utils import expand_status_code
18
+ from .statistic import OpenAPILinkStats
15
19
 
16
20
  if TYPE_CHECKING:
17
- from ....models import APIOperation, Case
21
+ from ....models import Case
18
22
  from ..schemas import BaseOpenAPISchema
23
+ from .types import FilterFunction, LinkName, StatusCode, TargetName
19
24
 
20
25
 
21
26
  class OpenAPIStateMachine(APIStateMachine):
27
+ _transition_stats_template: ClassVar[OpenAPILinkStats]
28
+ _response_matchers: dict[str, Callable[[StepResult], str | None]]
29
+
30
+ def _get_target_for_result(self, result: StepResult) -> str | None:
31
+ matcher = self._response_matchers.get(result.case.operation.verbose_name)
32
+ if matcher is None:
33
+ return None
34
+ return matcher(result)
35
+
22
36
  def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
23
37
  context = expressions.ExpressionContext(case=result.case, response=result.response)
24
38
  direction.set_data(case, elapsed=result.elapsed, context=context)
25
39
  return case
26
40
 
41
+ @classmethod
42
+ def format_rules(cls) -> str:
43
+ return "\n".join(item.line for item in cls._transition_stats_template.iter_with_format())
44
+
45
+
46
+ # The proportion of negative tests generated for "root" transitions
47
+ NEGATIVE_TEST_CASES_THRESHOLD = 20
48
+
27
49
 
28
50
  def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
29
51
  """Create a state machine class.
@@ -34,75 +56,181 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
34
56
 
35
57
  This state machine won't make calls to (2) without having a proper response from (1) first.
36
58
  """
37
- # Bundles are special strategies, allowing us to draw responses from previous calls
38
- bundles = init_bundles(schema)
39
- connections: APIOperationConnections = defaultdict(list)
59
+ from ....stateful.state_machine import _normalize_name
60
+
40
61
  operations = [result.ok() for result in schema.get_all_operations() if isinstance(result, Ok)]
62
+ bundles = {}
63
+ incoming_transitions = defaultdict(list)
64
+ _response_matchers: dict[str, Callable[[StepResult], str | None]] = {}
65
+ # Statistic structure follows the links and count for each response status code
66
+ transitions = {}
41
67
  for operation in operations:
42
- apply(operation, bundles, connections)
68
+ operation_links: dict[StatusCode, dict[TargetName, dict[LinkName, dict[int | None, int]]]] = {}
69
+ all_status_codes = tuple(operation.definition.raw["responses"])
70
+ bundle_matchers = []
71
+ for _, link in get_all_links(operation):
72
+ bundle_name = f"{operation.verbose_name} -> {link.status_code}"
73
+ bundles[bundle_name] = Bundle(bundle_name)
74
+ target_operation = link.get_target_operation()
75
+ incoming_transitions[target_operation.verbose_name].append(link)
76
+ response_targets = operation_links.setdefault(link.status_code, {})
77
+ target_links = response_targets.setdefault(target_operation.verbose_name, {})
78
+ target_links[link.name] = {}
79
+ bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
80
+ if operation_links:
81
+ transitions[operation.verbose_name] = operation_links
82
+ if bundle_matchers:
83
+ _response_matchers[operation.verbose_name] = make_response_matcher(bundle_matchers)
84
+ rules = {}
85
+ catch_all = Bundle("catch_all")
86
+
87
+ for target in operations:
88
+ incoming = incoming_transitions.get(target.verbose_name)
89
+ if incoming is not None:
90
+ for link in incoming:
91
+ source = link.operation
92
+ bundle_name = f"{source.verbose_name} -> {link.status_code}"
93
+ name = _normalize_name(f"{target.verbose_name} -> {link.status_code}")
94
+ case_strategy = combine_strategies(
95
+ [
96
+ target.as_strategy(data_generation_method=data_generation_method)
97
+ for data_generation_method in schema.data_generation_methods
98
+ ]
99
+ )
100
+ bundle = bundles[bundle_name]
101
+ rules[name] = transition(
102
+ name=name,
103
+ target=catch_all,
104
+ previous=bundle,
105
+ case=case_strategy,
106
+ link=st.just(link),
107
+ )
108
+ elif any(
109
+ incoming.operation.verbose_name == target.verbose_name
110
+ for transitions in incoming_transitions.values()
111
+ for incoming in transitions
112
+ ):
113
+ # No incoming transitions, but has at least one outgoing transition
114
+ # For example, POST /users/ -> GET /users/{id}/
115
+ # The source operation has no prerequisite, but we need to allow this rule to be executed
116
+ # in order to reach other transitions
117
+ name = _normalize_name(f"{target.verbose_name} -> X")
118
+ if len(schema.data_generation_methods) == 1:
119
+ case_strategy = target.as_strategy(data_generation_method=schema.data_generation_methods[0])
120
+ else:
121
+ strategies = {
122
+ method: target.as_strategy(data_generation_method=method)
123
+ for method in schema.data_generation_methods
124
+ }
125
+
126
+ @st.composite # type: ignore[misc]
127
+ def case_strategy_factory(
128
+ draw: st.DrawFn, strategies: dict[DataGenerationMethod, st.SearchStrategy] = strategies
129
+ ) -> Case:
130
+ if draw(st.integers(min_value=0, max_value=99)) < NEGATIVE_TEST_CASES_THRESHOLD:
131
+ return draw(strategies[DataGenerationMethod.negative])
132
+ return draw(strategies[DataGenerationMethod.positive])
133
+
134
+ case_strategy = case_strategy_factory()
135
+
136
+ rules[name] = precondition(ensure_links_followed)(
137
+ transition(
138
+ name=name,
139
+ target=catch_all,
140
+ previous=st.none(),
141
+ case=case_strategy,
142
+ )
143
+ )
144
+
145
+ return type(
146
+ "APIWorkflow",
147
+ (OpenAPIStateMachine,),
148
+ {
149
+ "schema": schema,
150
+ "bundles": bundles,
151
+ "_transition_stats_template": OpenAPILinkStats(transitions=transitions),
152
+ "_response_matchers": _response_matchers,
153
+ **rules,
154
+ },
155
+ )
156
+
157
+
158
+ def ensure_links_followed(machine: APIStateMachine) -> bool:
159
+ # If there are responses that have links to follow, reject any rule without incoming transitions
160
+ for bundle in machine.bundles.values():
161
+ if bundle:
162
+ return False
163
+ return True
164
+
165
+
166
+ def transition(
167
+ *,
168
+ name: str,
169
+ target: Bundle,
170
+ previous: Bundle | st.SearchStrategy,
171
+ case: st.SearchStrategy,
172
+ link: st.SearchStrategy | NotSet = NOT_SET,
173
+ ) -> Callable[[Callable], Rule]:
174
+ def step_function(*args_: Any, **kwargs_: Any) -> StepResult | None:
175
+ return APIStateMachine._step(*args_, **kwargs_)
176
+
177
+ step_function.__name__ = name
178
+
179
+ kwargs = {"target": target, "previous": previous, "case": case}
180
+ if not isinstance(link, NotSet):
181
+ kwargs["link"] = link
182
+
183
+ return rule(**kwargs)(step_function)
184
+
185
+
186
+ def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepResult], str | None]:
187
+ def compare(result: StepResult) -> str | None:
188
+ for bundle_name, response_filter in matchers:
189
+ if response_filter(result):
190
+ return bundle_name
191
+ return None
192
+
193
+ return compare
194
+
195
+
196
+ @lru_cache
197
+ def make_response_filter(status_code: str, all_status_codes: Iterator[str]) -> FilterFunction:
198
+ """Create a filter for stored responses.
199
+
200
+ This filter will decide whether some response is suitable to use as a source for requesting some API operation.
201
+ """
202
+ if status_code == "default":
203
+ return default_status_code(all_status_codes)
204
+ return match_status_code(status_code)
43
205
 
44
- rules = make_all_rules(operations, bundles, connections)
45
206
 
46
- kwargs: dict[str, Any] = {"bundles": bundles, "schema": schema}
47
- return type("APIWorkflow", (OpenAPIStateMachine,), {**kwargs, **rules})
207
+ def match_status_code(status_code: str) -> FilterFunction:
208
+ """Create a filter function that matches all responses with the given status code.
209
+
210
+ Note that the status code can contain "X", which means any digit.
211
+ For example, 50X will match all status codes from 500 to 509.
212
+ """
213
+ status_codes = set(expand_status_code(status_code))
214
+
215
+ def compare(result: StepResult) -> bool:
216
+ return result.response.status_code in status_codes
217
+
218
+ compare.__name__ = f"match_{status_code}_response"
219
+
220
+ return compare
48
221
 
49
222
 
50
- def init_bundles(schema: BaseOpenAPISchema) -> dict[str, CaseInsensitiveDict]:
51
- """Create bundles for all operations in the given schema.
223
+ def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
224
+ """Create a filter that matches all "default" responses.
52
225
 
53
- Each API operation has a bundle that stores all responses from that operation.
54
- We need to create bundles first, so they can be referred when building connections between operations.
226
+ In Open API, the "default" response is the one that is used if no other options were matched.
227
+ Therefore, we need to match only responses that were not matched by other listed status codes.
55
228
  """
56
- output: dict[str, CaseInsensitiveDict] = {}
57
- for result in schema.get_all_operations():
58
- if isinstance(result, Ok):
59
- operation = result.ok()
60
- output.setdefault(operation.path, CaseInsensitiveDict())
61
- output[operation.path][operation.method.upper()] = Bundle(operation.verbose_name) # type: ignore
62
- return output
63
-
64
-
65
- def make_all_rules(
66
- operations: list[APIOperation],
67
- bundles: dict[str, CaseInsensitiveDict],
68
- connections: APIOperationConnections,
69
- ) -> dict[str, Rule]:
70
- """Create rules for all API operations, based on the provided connections."""
71
- rules = {}
72
- for operation in operations:
73
- new_rule = make_rule(operation, bundles[operation.path][operation.method.upper()], connections)
74
- if new_rule is not None:
75
- rules[f"rule {operation.verbose_name}"] = new_rule
76
- return rules
77
-
78
-
79
- def make_rule(
80
- operation: APIOperation,
81
- bundle: Bundle,
82
- connections: APIOperationConnections,
83
- ) -> Rule | None:
84
- """Create a rule for an API operation."""
85
-
86
- def _make_rule(previous: st.SearchStrategy) -> Rule:
87
- decorator = rule(target=bundle, previous=previous, case=operation.as_strategy()) # type: ignore
88
- return decorator(APIStateMachine._step)
89
-
90
- incoming = connections.get(operation.verbose_name)
91
- if incoming is not None:
92
- incoming_connections = cast(List[Connection], incoming)
93
- strategies = [connection.strategy for connection in incoming_connections]
94
- _rule = _make_rule(combine_strategies(strategies))
95
-
96
- def has_source_response(self: OpenAPIStateMachine) -> bool:
97
- # To trigger this transition, there should be matching responses from the source operations
98
- return any(connection.source in self.bundles for connection in incoming_connections)
99
-
100
- return precondition(has_source_response)(_rule)
101
- # No incoming transitions - make rules only for operations that have at least one outgoing transition
102
- if any(
103
- connection.source == operation.verbose_name
104
- for operation_connections in connections.values()
105
- for connection in operation_connections
106
- ):
107
- return _make_rule(st.none())
108
- return None
229
+ expanded_status_codes = {
230
+ status_code for value in status_codes if value != "default" for status_code in expand_status_code(value)
231
+ }
232
+
233
+ def match_default_response(result: StepResult) -> bool:
234
+ return result.response.status_code not in expanded_status_codes
235
+
236
+ return match_default_response
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Iterator, Union
5
+
6
+ from ....internal.copy import fast_deepcopy
7
+ from ....stateful.statistic import TransitionStats
8
+
9
+ if TYPE_CHECKING:
10
+ from ....stateful import events
11
+ from .types import AggregatedResponseCounter, LinkName, ResponseCounter, SourceName, StatusCode, TargetName
12
+
13
+
14
+ @dataclass
15
+ class LinkSource:
16
+ name: str
17
+ responses: dict[StatusCode, dict[TargetName, dict[LinkName, ResponseCounter]]]
18
+ is_first: bool
19
+
20
+ __slots__ = ("name", "responses", "is_first")
21
+
22
+
23
+ @dataclass
24
+ class OperationResponse:
25
+ status_code: str
26
+ targets: dict[TargetName, dict[LinkName, ResponseCounter]]
27
+ is_last: bool
28
+
29
+ __slots__ = ("status_code", "targets", "is_last")
30
+
31
+
32
+ @dataclass
33
+ class Link:
34
+ name: str
35
+ target: str
36
+ responses: ResponseCounter
37
+ is_last: bool
38
+ is_single: bool
39
+
40
+ __slots__ = ("name", "target", "responses", "is_last", "is_single")
41
+
42
+
43
+ StatisticEntry = Union[LinkSource, OperationResponse, Link]
44
+
45
+
46
+ @dataclass
47
+ class FormattedStatisticEntry:
48
+ line: str
49
+ entry: StatisticEntry
50
+ __slots__ = ("line", "entry")
51
+
52
+
53
+ @dataclass
54
+ class OpenAPILinkStats(TransitionStats):
55
+ """Statistics about link transitions for a state machine run."""
56
+
57
+ transitions: dict[SourceName, dict[StatusCode, dict[TargetName, dict[LinkName, ResponseCounter]]]]
58
+
59
+ roots: dict[TargetName, ResponseCounter] = field(default_factory=dict)
60
+
61
+ __slots__ = ("transitions",)
62
+
63
+ def consume(self, event: events.StatefulEvent) -> None:
64
+ from ....stateful import events
65
+
66
+ if isinstance(event, events.StepFinished):
67
+ if event.transition_id is not None:
68
+ transition_id = event.transition_id
69
+ source = self.transitions[transition_id.source]
70
+ transition = source[transition_id.status_code][event.target][transition_id.name]
71
+ if event.response is not None:
72
+ key = event.response.status_code
73
+ else:
74
+ key = None
75
+ counter = transition.setdefault(key, 0)
76
+ transition[key] = counter + 1
77
+ else:
78
+ # A start of a sequence has an empty source and does not belong to any transition
79
+ target = self.roots.setdefault(event.target, {})
80
+ if event.response is not None:
81
+ key = event.response.status_code
82
+ else:
83
+ key = None
84
+ counter = target.setdefault(key, 0)
85
+ target[key] = counter + 1
86
+
87
+ def copy(self) -> OpenAPILinkStats:
88
+ return self.__class__(transitions=fast_deepcopy(self.transitions))
89
+
90
+ def iter(self) -> Iterator[StatisticEntry]:
91
+ for source_idx, (source, responses) in enumerate(self.transitions.items()):
92
+ yield LinkSource(name=source, responses=responses, is_first=source_idx == 0)
93
+ for response_idx, (status_code, targets) in enumerate(responses.items()):
94
+ yield OperationResponse(
95
+ status_code=status_code, targets=targets, is_last=response_idx == len(responses) - 1
96
+ )
97
+ for target_idx, (target, links) in enumerate(targets.items()):
98
+ for link_idx, (link_name, link_responses) in enumerate(links.items()):
99
+ yield Link(
100
+ name=link_name,
101
+ target=target,
102
+ responses=link_responses,
103
+ is_last=target_idx == len(targets) - 1 and link_idx == len(links) - 1,
104
+ is_single=len(links) == 1,
105
+ )
106
+
107
+ def iter_with_format(self) -> Iterator[FormattedStatisticEntry]:
108
+ current_response = None
109
+ for entry in self.iter():
110
+ if isinstance(entry, LinkSource):
111
+ if not entry.is_first:
112
+ yield FormattedStatisticEntry(line=f"\n{entry.name}", entry=entry)
113
+ else:
114
+ yield FormattedStatisticEntry(line=f"{entry.name}", entry=entry)
115
+ elif isinstance(entry, OperationResponse):
116
+ current_response = entry
117
+ if entry.is_last:
118
+ yield FormattedStatisticEntry(line=f"└── {entry.status_code}", entry=entry)
119
+ else:
120
+ yield FormattedStatisticEntry(line=f"├── {entry.status_code}", entry=entry)
121
+ else:
122
+ if current_response is not None and current_response.is_last:
123
+ line = " "
124
+ else:
125
+ line = "│ "
126
+ if entry.is_last:
127
+ line += "└"
128
+ else:
129
+ line += "├"
130
+ if entry.is_single or entry.name == entry.target:
131
+ line += f"── {entry.target}"
132
+ else:
133
+ line += f"── {entry.name} -> {entry.target}"
134
+ yield FormattedStatisticEntry(line=line, entry=entry)
135
+
136
+ def to_formatted_table(self, width: int) -> str:
137
+ """Format the statistic as a table."""
138
+ entries = list(self.iter_with_format())
139
+ lines: list[str | list[str]] = [HEADER, ""]
140
+ column_widths = [len(column) for column in HEADER]
141
+ for entry in entries:
142
+ if isinstance(entry.entry, Link):
143
+ aggregated = _aggregate_responses(entry.entry.responses)
144
+ values = [
145
+ entry.line,
146
+ str(aggregated["2xx"]),
147
+ str(aggregated["4xx"]),
148
+ str(aggregated["5xx"]),
149
+ str(aggregated["Total"]),
150
+ ]
151
+ column_widths = [max(column_widths[idx], len(column)) for idx, column in enumerate(values)]
152
+ lines.append(values)
153
+ else:
154
+ lines.append(entry.line)
155
+ used_width = sum(column_widths) + 4 * PADDING
156
+ max_space = width - used_width if used_width < width else 0
157
+ formatted_lines = []
158
+
159
+ for line in lines:
160
+ if isinstance(line, list):
161
+ formatted_line, *counters = line
162
+ formatted_line = formatted_line.ljust(column_widths[0] + max_space)
163
+
164
+ for column, max_width in zip(counters, column_widths[1:]):
165
+ formatted_line += f"{column:>{max_width + PADDING}}"
166
+
167
+ formatted_lines.append(formatted_line)
168
+ else:
169
+ formatted_lines.append(line)
170
+
171
+ return "\n".join(formatted_lines)
172
+
173
+
174
+ PADDING = 4
175
+ HEADER = ["Links", "2xx", "4xx", "5xx", "Total"]
176
+
177
+
178
+ def _aggregate_responses(responses: ResponseCounter) -> AggregatedResponseCounter:
179
+ """Aggregate responses by status code ranges."""
180
+ output: AggregatedResponseCounter = {
181
+ "2xx": 0,
182
+ # NOTE: 3xx responses are not counted
183
+ "4xx": 0,
184
+ "5xx": 0,
185
+ "Total": 0,
186
+ }
187
+ for status_code, count in responses.items():
188
+ if status_code is not None:
189
+ if 200 <= status_code < 300:
190
+ output["2xx"] += count
191
+ output["Total"] += count
192
+ elif 400 <= status_code < 500:
193
+ output["4xx"] += count
194
+ output["Total"] += count
195
+ elif 500 <= status_code < 600:
196
+ output["5xx"] += count
197
+ output["Total"] += count
198
+ return output
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Callable, Dict, TypedDict, Union
4
+
5
+ if TYPE_CHECKING:
6
+ from ....stateful.state_machine import StepResult
7
+
8
+ StatusCode = str
9
+ LinkName = str
10
+ TargetName = str
11
+ SourceName = str
12
+ ResponseCounter = Dict[Union[int, None], int]
13
+ FilterFunction = Callable[["StepResult"], bool]
14
+ AggregatedResponseCounter = TypedDict("AggregatedResponseCounter", {"2xx": int, "4xx": int, "5xx": int, "Total": int})
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
+
2
3
  import string
3
- from itertools import product
4
+ from itertools import chain, product
4
5
  from typing import Any, Generator
5
6
 
6
7
 
@@ -10,6 +11,10 @@ def expand_status_code(status_code: str | int) -> Generator[int, None, None]:
10
11
  yield int("".join(expanded))
11
12
 
12
13
 
14
+ def expand_status_codes(status_codes: list[str]) -> set[int]:
15
+ return set(chain.from_iterable(expand_status_code(code) for code in status_codes))
16
+
17
+
13
18
  def is_header_location(location: str) -> bool:
14
19
  """Whether this location affects HTTP headers."""
15
20
  return location in ("header", "cookie")
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+
2
3
  from typing import Any
3
4
 
4
5
  from ...constants import HTTP_METHODS