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,37 +1,34 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import enum
4
+ import time
4
5
  from dataclasses import dataclass, field
5
6
  from difflib import get_close_matches
6
7
  from enum import unique
8
+ from types import SimpleNamespace
7
9
  from typing import (
10
+ TYPE_CHECKING,
8
11
  Any,
9
12
  Callable,
10
13
  Generator,
14
+ Iterator,
15
+ Mapping,
16
+ NoReturn,
11
17
  Sequence,
12
18
  TypeVar,
13
19
  cast,
14
- TYPE_CHECKING,
15
- NoReturn,
16
- MutableMapping,
17
- Iterator,
18
20
  )
19
21
  from urllib.parse import urlsplit, urlunsplit
20
22
 
21
23
  import graphql
22
- import requests
23
- from graphql import GraphQLNamedType
24
24
  from hypothesis import strategies as st
25
- from hypothesis.strategies import SearchStrategy
26
25
  from hypothesis_graphql import strategies as gql_st
27
26
  from requests.structures import CaseInsensitiveDict
28
27
 
29
- from ..openapi.constants import LOCATION_TO_CONTAINER
30
28
  from ... import auths
31
- from ...auths import AuthStorage
32
29
  from ...checks import not_a_server_error
33
- from ...constants import NOT_SET
34
- from ...exceptions import OperationSchemaError, OperationNotFound
30
+ from ...constants import NOT_SET, SCHEMATHESIS_TEST_CASE_HEADER
31
+ from ...exceptions import OperationNotFound, OperationSchemaError
35
32
  from ...generation import DataGenerationMethod, GenerationConfig
36
33
  from ...hooks import (
37
34
  GLOBAL_HOOK_DISPATCHER,
@@ -41,13 +38,19 @@ from ...hooks import (
41
38
  should_skip_operation,
42
39
  )
43
40
  from ...internal.result import Ok, Result
44
- from ...models import APIOperation, Case, CheckFunction, OperationDefinition
45
- from ...schemas import BaseSchema, APIOperationMap
46
- from ...stateful import Stateful, StatefulTest
41
+ from ...models import APIOperation, Case, OperationDefinition
42
+ from ...schemas import APIOperationMap, BaseSchema
47
43
  from ...types import Body, Cookies, Headers, NotSet, PathParameters, Query
44
+ from ..openapi.constants import LOCATION_TO_CONTAINER
45
+ from ._cache import OperationCache
48
46
  from .scalars import CUSTOM_SCALARS, get_extra_scalar_strategies
49
47
 
50
48
  if TYPE_CHECKING:
49
+ from hypothesis.strategies import SearchStrategy
50
+
51
+ from ...auths import AuthStorage
52
+ from ...internal.checks import CheckFunction
53
+ from ...stateful import Stateful, StatefulTest
51
54
  from ...transports.responses import GenericResponse
52
55
 
53
56
 
@@ -59,39 +62,18 @@ class RootType(enum.Enum):
59
62
 
60
63
  @dataclass(repr=False)
61
64
  class GraphQLCase(Case):
62
- def as_requests_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
63
- final_headers = self._get_headers(headers)
65
+ def __hash__(self) -> int:
66
+ return hash(self.as_curl_command({SCHEMATHESIS_TEST_CASE_HEADER: "0"}))
67
+
68
+ def _get_url(self, base_url: str | None) -> str:
64
69
  base_url = self._get_base_url(base_url)
65
70
  # Replace the path, in case if the user provided any path parameters via hooks
66
71
  parts = list(urlsplit(base_url))
67
72
  parts[2] = self.formatted_path
68
- kwargs: dict[str, Any] = {
69
- "method": self.method,
70
- "url": urlunsplit(parts),
71
- "headers": final_headers,
72
- "cookies": self.cookies,
73
- "params": self.query,
74
- }
75
- # There is no direct way to have bytes here, but it is a useful pattern to support.
76
- # It also unifies GraphQLCase with its Open API counterpart where bytes may come from external examples
77
- if isinstance(self.body, bytes):
78
- kwargs["data"] = self.body
79
- # Assume that the payload is JSON, not raw GraphQL queries
80
- kwargs["headers"].setdefault("Content-Type", "application/json")
81
- else:
82
- kwargs["json"] = {"query": self.body}
83
- return kwargs
84
-
85
- def as_werkzeug_kwargs(self, headers: dict[str, str] | None = None) -> dict[str, Any]:
86
- final_headers = self._get_headers(headers)
87
- return {
88
- "method": self.method,
89
- "path": self.operation.schema.get_full_path(self.formatted_path),
90
- # Convert to a regular dictionary, as we use `CaseInsensitiveDict` which is not supported by Werkzeug
91
- "headers": dict(final_headers),
92
- "query_string": self.query,
93
- "json": {"query": self.body},
94
- }
73
+ return urlunsplit(parts)
74
+
75
+ def _get_body(self) -> Body | NotSet:
76
+ return self.body if isinstance(self.body, (NotSet, bytes)) else {"query": self.body}
95
77
 
96
78
  def validate_response(
97
79
  self,
@@ -100,20 +82,15 @@ class GraphQLCase(Case):
100
82
  additional_checks: tuple[CheckFunction, ...] = (),
101
83
  excluded_checks: tuple[CheckFunction, ...] = (),
102
84
  code_sample_style: str | None = None,
85
+ headers: dict[str, Any] | None = None,
86
+ transport_kwargs: dict[str, Any] | None = None,
103
87
  ) -> None:
104
88
  checks = checks or (not_a_server_error,)
105
89
  checks += additional_checks
106
90
  checks = tuple(check for check in checks if check not in excluded_checks)
107
- return super().validate_response(response, checks, code_sample_style=code_sample_style)
108
-
109
- def call_asgi(
110
- self,
111
- app: Any = None,
112
- base_url: str | None = None,
113
- headers: dict[str, str] | None = None,
114
- **kwargs: Any,
115
- ) -> requests.Response:
116
- return super().call_asgi(app=app, base_url=base_url, headers=headers, **kwargs)
91
+ return super().validate_response(
92
+ response, checks, code_sample_style=code_sample_style, headers=headers, transport_kwargs=transport_kwargs
93
+ )
117
94
 
118
95
 
119
96
  C = TypeVar("C", bound=Case)
@@ -136,9 +113,37 @@ class GraphQLOperationDefinition(OperationDefinition):
136
113
 
137
114
  @dataclass
138
115
  class GraphQLSchema(BaseSchema):
116
+ _operation_cache: OperationCache = field(default_factory=OperationCache)
117
+
139
118
  def __repr__(self) -> str:
140
119
  return f"<{self.__class__.__name__}>"
141
120
 
121
+ def __iter__(self) -> Iterator[str]:
122
+ schema = self.client_schema
123
+ for operation_type in (
124
+ schema.query_type,
125
+ schema.mutation_type,
126
+ ):
127
+ if operation_type is not None:
128
+ yield operation_type.name
129
+
130
+ def _get_operation_map(self, key: str) -> APIOperationMap:
131
+ cache = self._operation_cache
132
+ map = cache.get_map(key)
133
+ if map is not None:
134
+ return map
135
+ schema = self.client_schema
136
+ for root_type, operation_type in (
137
+ (RootType.QUERY, schema.query_type),
138
+ (RootType.MUTATION, schema.mutation_type),
139
+ ):
140
+ if operation_type and operation_type.name == key:
141
+ map = APIOperationMap(self, {})
142
+ map._data = FieldMap(map, root_type, operation_type)
143
+ cache.insert_map(key, map)
144
+ return map
145
+ raise KeyError(key)
146
+
142
147
  def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
143
148
  raw_schema = self.raw_schema["__schema"]
144
149
  type_names = [type_def["name"] for type_def in raw_schema.get("types", [])]
@@ -148,19 +153,6 @@ class GraphQLSchema(BaseSchema):
148
153
  message += f". Did you mean `{matches[0]}`?"
149
154
  raise OperationNotFound(message=message, item=item) from exc
150
155
 
151
- def _store_operations(
152
- self, operations: Generator[Result[APIOperation, OperationSchemaError], None, None]
153
- ) -> dict[str, APIOperationMap]:
154
- output: dict[str, APIOperationMap] = {}
155
- for result in operations:
156
- if isinstance(result, Ok):
157
- operation = result.ok()
158
- definition = cast(GraphQLOperationDefinition, operation.definition)
159
- type_name = definition.type_.name if isinstance(definition.type_, GraphQLNamedType) else "Unknown"
160
- for_type = output.setdefault(type_name, APIOperationMap(FieldMap()))
161
- for_type[definition.field_name] = operation
162
- return output
163
-
164
156
  def get_full_path(self, path: str) -> str:
165
157
  return self.base_path
166
158
 
@@ -202,7 +194,7 @@ class GraphQLSchema(BaseSchema):
202
194
  return 0
203
195
 
204
196
  def get_all_operations(
205
- self, hooks: HookDispatcher | None = None
197
+ self, hooks: HookDispatcher | None = None, generation_config: GenerationConfig | None = None
206
198
  ) -> Generator[Result[APIOperation, OperationSchemaError], None, None]:
207
199
  schema = self.client_schema
208
200
  for root_type, operation_type in (
@@ -211,26 +203,10 @@ class GraphQLSchema(BaseSchema):
211
203
  ):
212
204
  if operation_type is None:
213
205
  continue
214
- for field_name, definition in operation_type.fields.items():
215
- operation: APIOperation = APIOperation(
216
- base_url=self.get_base_url(),
217
- path=self.base_path,
218
- verbose_name=f"{operation_type.name}.{field_name}",
219
- method="POST",
220
- app=self.app,
221
- schema=self,
222
- # Parameters are not yet supported
223
- definition=GraphQLOperationDefinition(
224
- raw=definition,
225
- resolved=definition,
226
- scope="",
227
- parameters=[],
228
- type_=operation_type,
229
- field_name=field_name,
230
- root_type=root_type,
231
- ),
232
- case_cls=GraphQLCase,
233
- )
206
+ for field_name, field_ in operation_type.fields.items():
207
+ operation = self._build_operation(root_type, operation_type, field_name, field_)
208
+ if self._should_skip(operation):
209
+ continue
234
210
  context = HookContext(operation=operation)
235
211
  if (
236
212
  should_skip_operation(GLOBAL_HOOK_DISPATCHER, context)
@@ -240,6 +216,40 @@ class GraphQLSchema(BaseSchema):
240
216
  continue
241
217
  yield Ok(operation)
242
218
 
219
+ def _should_skip(
220
+ self,
221
+ operation: APIOperation,
222
+ _ctx_cache: SimpleNamespace = SimpleNamespace(operation=None),
223
+ ) -> bool:
224
+ _ctx_cache.operation = operation
225
+ return not self.filter_set.match(_ctx_cache)
226
+
227
+ def _build_operation(
228
+ self,
229
+ root_type: RootType,
230
+ operation_type: graphql.GraphQLObjectType,
231
+ field_name: str,
232
+ field: graphql.GraphQlField,
233
+ ) -> APIOperation:
234
+ return APIOperation(
235
+ base_url=self.get_base_url(),
236
+ path=self.base_path,
237
+ verbose_name=f"{operation_type.name}.{field_name}",
238
+ method="POST",
239
+ app=self.app,
240
+ schema=self,
241
+ # Parameters are not yet supported
242
+ definition=GraphQLOperationDefinition(
243
+ raw=field,
244
+ resolved=field,
245
+ scope="",
246
+ type_=operation_type,
247
+ field_name=field_name,
248
+ root_type=root_type,
249
+ ),
250
+ case_cls=GraphQLCase,
251
+ )
252
+
243
253
  def get_case_strategy(
244
254
  self,
245
255
  operation: APIOperation,
@@ -259,7 +269,9 @@ class GraphQLSchema(BaseSchema):
259
269
  **kwargs,
260
270
  )
261
271
 
262
- def get_strategies_from_examples(self, operation: APIOperation) -> list[SearchStrategy[Case]]:
272
+ def get_strategies_from_examples(
273
+ self, operation: APIOperation, as_strategy_kwargs: dict[str, Any] | None = None
274
+ ) -> list[SearchStrategy[Case]]:
263
275
  return []
264
276
 
265
277
  def get_stateful_tests(
@@ -286,39 +298,53 @@ class GraphQLSchema(BaseSchema):
286
298
  cookies=cookies,
287
299
  query=query,
288
300
  body=body,
289
- media_type=media_type,
301
+ media_type=media_type or "application/json",
302
+ generation_time=0.0,
290
303
  )
291
304
 
292
305
  def get_tags(self, operation: APIOperation) -> list[str] | None:
293
306
  return None
294
307
 
308
+ def validate(self) -> None:
309
+ return None
310
+
295
311
 
296
312
  @dataclass
297
- class FieldMap(MutableMapping):
313
+ class FieldMap(Mapping):
298
314
  """Container for accessing API operations.
299
315
 
300
316
  Provides a more specific error message if API operation is not found.
301
317
  """
302
318
 
303
- data: dict[str, APIOperation] = field(default_factory=dict)
304
-
305
- def __setitem__(self, key: str, value: APIOperation) -> None:
306
- self.data[key] = value
319
+ _parent: APIOperationMap
320
+ _root_type: RootType
321
+ _operation_type: graphql.GraphQLObjectType
307
322
 
308
- def __delitem__(self, key: str) -> None:
309
- del self.data[key]
323
+ __slots__ = ("_parent", "_root_type", "_operation_type")
310
324
 
311
325
  def __len__(self) -> int:
312
- return len(self.data)
326
+ return len(self._operation_type.fields)
313
327
 
314
328
  def __iter__(self) -> Iterator[str]:
315
- return iter(self.data)
329
+ return iter(self._operation_type.fields)
330
+
331
+ def _init_operation(self, field_name: str) -> APIOperation:
332
+ schema = cast(GraphQLSchema, self._parent._schema)
333
+ cache = schema._operation_cache
334
+ operation = cache.get_operation(field_name)
335
+ if operation is not None:
336
+ return operation
337
+ operation_type = self._operation_type
338
+ field_ = operation_type.fields[field_name]
339
+ operation = schema._build_operation(self._root_type, operation_type, field_name, field_)
340
+ cache.insert_operation(field_name, operation)
341
+ return operation
316
342
 
317
343
  def __getitem__(self, item: str) -> APIOperation:
318
344
  try:
319
- return self.data[item]
345
+ return self._init_operation(item)
320
346
  except KeyError as exc:
321
- field_names = [operation.definition.field_name for operation in self.data.values()] # type: ignore[attr-defined]
347
+ field_names = list(self._operation_type.fields)
322
348
  matches = get_close_matches(item, field_names)
323
349
  message = f"`{item}` field not found"
324
350
  if matches:
@@ -337,6 +363,7 @@ def get_case_strategy(
337
363
  generation_config: GenerationConfig | None = None,
338
364
  **kwargs: Any,
339
365
  ) -> Any:
366
+ start = time.monotonic()
340
367
  definition = cast(GraphQLOperationDefinition, operation.definition)
341
368
  strategy_factory = {
342
369
  RootType.QUERY: gql_st.queries,
@@ -351,6 +378,7 @@ def get_case_strategy(
351
378
  custom_scalars=custom_scalars,
352
379
  print_ast=_noop, # type: ignore
353
380
  allow_x00=generation_config.allow_x00,
381
+ allow_null=generation_config.graphql_allow_null,
354
382
  codec=generation_config.codec,
355
383
  )
356
384
  strategy = apply_to_all_dispatchers(operation, hook_context, hooks, strategy, "body").map(graphql.print_ast)
@@ -369,6 +397,8 @@ def get_case_strategy(
369
397
  body=body,
370
398
  operation=operation,
371
399
  data_generation_method=data_generation_method,
400
+ generation_time=time.monotonic() - start,
401
+ media_type="application/json",
372
402
  ) # type: ignore
373
403
  context = auths.AuthContext(
374
404
  operation=operation,
@@ -1,5 +1,4 @@
1
- from typing import Any, cast, List
2
-
1
+ from typing import Any, List, cast
3
2
 
4
3
  from ... import failures
5
4
  from ...exceptions import get_grouped_graphql_error, get_unexpected_graphql_response_error
@@ -1,3 +1,4 @@
1
1
  from .formats import register_string_format as format
2
2
  from .formats import unregister_string_format
3
3
  from .loaders import from_aiohttp, from_asgi, from_dict, from_file, from_path, from_pytest_fixture, from_uri, from_wsgi
4
+ from .media_types import register_media_type as media_type
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Any, Tuple
5
+
6
+ if TYPE_CHECKING:
7
+ from ...models import APIOperation
8
+ from ...schemas import APIOperationMap
9
+
10
+
11
+ @dataclass
12
+ class OperationCacheEntry:
13
+ path: str
14
+ method: str
15
+ # The resolution scope of the operation
16
+ scope: str
17
+ # Parent path item
18
+ path_item: dict[str, Any]
19
+ # Unresolved operation definition
20
+ operation: dict[str, Any]
21
+ __slots__ = ("path", "method", "scope", "path_item", "operation")
22
+
23
+
24
+ # During traversal, we need to keep track of the scope, path, and method
25
+ TraversalKey = Tuple[str, str, str]
26
+ OperationId = str
27
+ Reference = str
28
+
29
+
30
+ @dataclass
31
+ class OperationCache:
32
+ """Cache for Open API operations.
33
+
34
+ This cache contains multiple levels to avoid unnecessary parsing of the schema.
35
+ """
36
+
37
+ # Cache to avoid schema traversal on every access
38
+ _id_to_definition: dict[OperationId, OperationCacheEntry] = field(default_factory=dict)
39
+ # Map map between 1st & 2nd level cache keys
40
+ # Even though 1st level keys could be directly mapped to Python objects in memory, we need to keep them separate
41
+ # to ensure a single owner of the operation instance.
42
+ _id_to_operation: dict[OperationId, int] = field(default_factory=dict)
43
+ _traversal_key_to_operation: dict[TraversalKey, int] = field(default_factory=dict)
44
+ _reference_to_operation: dict[Reference, int] = field(default_factory=dict)
45
+ # The actual operations
46
+ _operations: list[APIOperation] = field(default_factory=list)
47
+ # Cache for operation maps
48
+ _maps: dict[str, APIOperationMap] = field(default_factory=dict)
49
+
50
+ @property
51
+ def known_operation_ids(self) -> list[str]:
52
+ return list(self._id_to_definition)
53
+
54
+ @property
55
+ def has_ids_to_definitions(self) -> bool:
56
+ return bool(self._id_to_definition)
57
+
58
+ def _append_operation(self, operation: APIOperation) -> int:
59
+ idx = len(self._operations)
60
+ self._operations.append(operation)
61
+ return idx
62
+
63
+ def insert_definition_by_id(
64
+ self,
65
+ operation_id: str,
66
+ path: str,
67
+ method: str,
68
+ scope: str,
69
+ path_item: dict[str, Any],
70
+ operation: dict[str, Any],
71
+ ) -> None:
72
+ """Insert a new operation definition into cache."""
73
+ self._id_to_definition[operation_id] = OperationCacheEntry(
74
+ path=path, method=method, scope=scope, path_item=path_item, operation=operation
75
+ )
76
+
77
+ def get_definition_by_id(self, operation_id: str) -> OperationCacheEntry:
78
+ """Get an operation definition by its ID."""
79
+ # TODO: Avoid KeyError in the future
80
+ return self._id_to_definition[operation_id]
81
+
82
+ def insert_operation(
83
+ self,
84
+ operation: APIOperation,
85
+ *,
86
+ traversal_key: TraversalKey,
87
+ operation_id: str | None = None,
88
+ reference: str | None = None,
89
+ ) -> None:
90
+ """Insert a new operation into cache by one or multiple keys."""
91
+ idx = self._append_operation(operation)
92
+ self._traversal_key_to_operation[traversal_key] = idx
93
+ if operation_id is not None:
94
+ self._id_to_operation[operation_id] = idx
95
+ if reference is not None:
96
+ self._reference_to_operation[reference] = idx
97
+
98
+ def get_operation_by_id(self, operation_id: str) -> APIOperation | None:
99
+ """Get an operation by its ID."""
100
+ idx = self._id_to_operation.get(operation_id)
101
+ if idx is not None:
102
+ return self._operations[idx]
103
+ return None
104
+
105
+ def get_operation_by_reference(self, reference: str) -> APIOperation | None:
106
+ """Get an operation by its reference."""
107
+ idx = self._reference_to_operation.get(reference)
108
+ if idx is not None:
109
+ return self._operations[idx]
110
+ return None
111
+
112
+ def get_operation_by_traversal_key(self, key: TraversalKey) -> APIOperation | None:
113
+ """Get an operation by its traverse key."""
114
+ idx = self._traversal_key_to_operation.get(key)
115
+ if idx is not None:
116
+ return self._operations[idx]
117
+ return None
118
+
119
+ def get_map(self, key: str) -> APIOperationMap | None:
120
+ return self._maps.get(key)
121
+
122
+ def insert_map(self, key: str, value: APIOperationMap) -> None:
123
+ self._maps[key] = value