schemathesis 3.25.6__py3-none-any.whl → 3.26.0__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 (39) hide show
  1. schemathesis/_dependency_versions.py +1 -0
  2. schemathesis/_hypothesis.py +1 -0
  3. schemathesis/_xml.py +1 -0
  4. schemathesis/auths.py +1 -0
  5. schemathesis/cli/__init__.py +26 -18
  6. schemathesis/cli/cassettes.py +4 -4
  7. schemathesis/cli/context.py +4 -1
  8. schemathesis/cli/output/default.py +154 -39
  9. schemathesis/cli/output/short.py +4 -0
  10. schemathesis/experimental/__init__.py +7 -0
  11. schemathesis/filters.py +1 -0
  12. schemathesis/parameters.py +1 -0
  13. schemathesis/runner/__init__.py +12 -0
  14. schemathesis/runner/events.py +12 -0
  15. schemathesis/runner/impl/core.py +29 -2
  16. schemathesis/runner/probes.py +1 -0
  17. schemathesis/runner/serialization.py +4 -2
  18. schemathesis/schemas.py +1 -0
  19. schemathesis/serializers.py +1 -1
  20. schemathesis/service/client.py +35 -2
  21. schemathesis/service/extensions.py +224 -0
  22. schemathesis/service/hosts.py +1 -0
  23. schemathesis/service/metadata.py +24 -0
  24. schemathesis/service/models.py +210 -2
  25. schemathesis/service/serialization.py +29 -1
  26. schemathesis/specs/openapi/__init__.py +1 -0
  27. schemathesis/specs/openapi/_hypothesis.py +8 -0
  28. schemathesis/specs/openapi/expressions/__init__.py +1 -0
  29. schemathesis/specs/openapi/expressions/lexer.py +1 -0
  30. schemathesis/specs/openapi/expressions/nodes.py +1 -0
  31. schemathesis/specs/openapi/links.py +1 -0
  32. schemathesis/specs/openapi/media_types.py +34 -0
  33. schemathesis/specs/openapi/negative/mutations.py +1 -0
  34. schemathesis/specs/openapi/security.py +5 -1
  35. {schemathesis-3.25.6.dist-info → schemathesis-3.26.0.dist-info}/METADATA +8 -5
  36. {schemathesis-3.25.6.dist-info → schemathesis-3.26.0.dist-info}/RECORD +39 -37
  37. {schemathesis-3.25.6.dist-info → schemathesis-3.26.0.dist-info}/WHEEL +0 -0
  38. {schemathesis-3.25.6.dist-info → schemathesis-3.26.0.dist-info}/entry_points.txt +0 -0
  39. {schemathesis-3.25.6.dist-info → schemathesis-3.26.0.dist-info}/licenses/LICENSE +0 -0
@@ -53,10 +53,12 @@ from ...exceptions import (
53
53
  from ...generation import DataGenerationMethod, GenerationConfig
54
54
  from ...hooks import HookContext, get_all_by_name
55
55
  from ...internal.datetime import current_datetime
56
- from ...internal.result import Ok
56
+ from ...internal.result import Err, Ok, Result
57
57
  from ...models import APIOperation, Case, Check, CheckFunction, Status, TestResult, TestResultSet
58
58
  from ...runner import events
59
59
  from ...schemas import BaseSchema
60
+ from ...service import extensions
61
+ from ...service.models import AnalysisResult, AnalysisSuccess
60
62
  from ...specs.openapi import formats
61
63
  from ...stateful import Feedback, Stateful
62
64
  from ...targets import Target, TargetContext
@@ -66,6 +68,7 @@ from .. import probes
66
68
  from ..serialization import SerializedTestResult
67
69
 
68
70
  if TYPE_CHECKING:
71
+ from ...service.client import ServiceClient
69
72
  from ...transports.responses import GenericResponse, WSGIResponse
70
73
 
71
74
 
@@ -97,6 +100,7 @@ class BaseRunner:
97
100
  stateful_recursion_limit: int = DEFAULT_STATEFUL_RECURSION_LIMIT
98
101
  count_operations: bool = True
99
102
  count_links: bool = True
103
+ service_client: ServiceClient | None = None
100
104
  _failures_counter: int = 0
101
105
 
102
106
  def execute(self) -> EventStream:
@@ -109,9 +113,10 @@ class BaseRunner:
109
113
  if self.auth is not None:
110
114
  unregister_auth()
111
115
  results = TestResultSet(seed=self.seed)
116
+ start_time = time.monotonic()
112
117
  initialized = None
113
118
  __probes = None
114
- start_time = time.monotonic()
119
+ __analysis: Result[AnalysisResult, Exception] | None = None
115
120
 
116
121
  def _initialize() -> events.Initialized:
117
122
  nonlocal initialized
@@ -142,6 +147,25 @@ class BaseRunner:
142
147
  _probes = cast(List[probes.ProbeRun], __probes)
143
148
  return events.AfterProbing(probes=_probes)
144
149
 
150
+ def _before_analysis() -> events.BeforeAnalysis:
151
+ return events.BeforeAnalysis()
152
+
153
+ def _run_analysis() -> None:
154
+ nonlocal __analysis, __probes
155
+
156
+ if self.service_client is not None:
157
+ try:
158
+ _probes = cast(List[probes.ProbeRun], __probes)
159
+ result = self.service_client.analyze_schema(_probes, self.schema.raw_schema)
160
+ if isinstance(result, AnalysisSuccess):
161
+ extensions.apply(result.extensions, self.schema)
162
+ __analysis = Ok(result)
163
+ except Exception as exc:
164
+ __analysis = Err(exc)
165
+
166
+ def _after_analysis() -> events.AfterAnalysis:
167
+ return events.AfterAnalysis(analysis=__analysis)
168
+
145
169
  if stop_event.is_set():
146
170
  yield _finish()
147
171
  return
@@ -151,6 +175,9 @@ class BaseRunner:
151
175
  _before_probes,
152
176
  _run_probes,
153
177
  _after_probes,
178
+ _before_analysis,
179
+ _run_analysis,
180
+ _after_analysis,
154
181
  ):
155
182
  event = event_factory()
156
183
  if event is not None:
@@ -5,6 +5,7 @@ the application supports certain inputs. This is done to avoid false positives i
5
5
  For example, certail web servers do not support NULL bytes in headers, in such cases, the generated test case
6
6
  will not reach the tested application at all.
7
7
  """
8
+
8
9
  from __future__ import annotations
9
10
 
10
11
  import enum
@@ -2,6 +2,7 @@
2
2
 
3
3
  They all consist of primitive types and don't have references to schemas, app, etc.
4
4
  """
5
+
5
6
  from __future__ import annotations
6
7
  import logging
7
8
  import re
@@ -226,8 +227,9 @@ class SerializedError:
226
227
  message = f"Scalar type '{scalar_name}' is not recognized"
227
228
  extras = []
228
229
  title = "Unknown GraphQL Scalar"
229
- elif isinstance(exception, hypothesis.errors.InvalidArgument) and str(exception).endswith(
230
- "larger than Hypothesis is designed to handle"
230
+ elif isinstance(exception, hypothesis.errors.InvalidArgument) and (
231
+ str(exception).endswith("larger than Hypothesis is designed to handle")
232
+ or "can neber generate an example, because min_size is larger than Hypothesis suports."
231
233
  ):
232
234
  type_ = RuntimeErrorType.HYPOTHESIS_HEALTH_CHECK_LARGE_BASE_EXAMPLE
233
235
  message = HEALTH_CHECK_MESSAGE_LARGE_BASE_EXAMPLE
schemathesis/schemas.py CHANGED
@@ -6,6 +6,7 @@ Their responsibilities:
6
6
 
7
7
  They give only static definitions of paths.
8
8
  """
9
+
9
10
  from __future__ import annotations
10
11
  from collections.abc import Mapping, MutableMapping
11
12
  from contextlib import nullcontext
@@ -173,7 +173,7 @@ def _to_yaml(value: Any) -> dict[str, Any]:
173
173
  return {"data": yaml.dump(value, Dumper=SafeDumper)}
174
174
 
175
175
 
176
- @register("text/yaml", aliases=("text/x-yaml", "application/x-yaml", "text/vnd.yaml"))
176
+ @register("text/yaml", aliases=("text/x-yaml", "text/vnd.yaml", "text/yml", "application/yaml", "application/x-yaml"))
177
177
  class YAMLSerializer:
178
178
  def as_requests(self, context: SerializerContext, value: Any) -> dict[str, Any]:
179
179
  return _to_yaml(value)
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
+ import json
2
3
  import hashlib
3
4
  import http
4
5
  from dataclasses import asdict
5
- from typing import Any
6
+ from typing import Any, TYPE_CHECKING
6
7
  from urllib.parse import urljoin
7
8
 
8
9
  import requests
@@ -11,8 +12,11 @@ from requests.adapters import HTTPAdapter, Retry
11
12
  from ..constants import USER_AGENT
12
13
  from .ci import CIProvider
13
14
  from .constants import CI_PROVIDER_HEADER, REPORT_CORRELATION_ID_HEADER, REQUEST_TIMEOUT, UPLOAD_SOURCE_HEADER
14
- from .metadata import Metadata
15
+ from .metadata import Metadata, collect_dependency_versions
15
16
  from .models import (
17
+ AnalysisSuccess,
18
+ AnalysisError,
19
+ AnalysisResult,
16
20
  ProjectDetails,
17
21
  AuthResponse,
18
22
  FailedUploadResponse,
@@ -23,6 +27,10 @@ from .models import (
23
27
  )
24
28
 
25
29
 
30
+ if TYPE_CHECKING:
31
+ from ..runner import probes
32
+
33
+
26
34
  def response_hook(response: requests.Response, **_kwargs: Any) -> None:
27
35
  if response.status_code != http.HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
28
36
  response.raise_for_status()
@@ -98,3 +106,28 @@ class ServiceClient(requests.Session):
98
106
  if response.status_code == http.HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
99
107
  return FailedUploadResponse(detail=data["detail"])
100
108
  return UploadResponse(message=data["message"], next_url=data["next"], correlation_id=data["correlation_id"])
109
+
110
+ def analyze_schema(self, probes: list[probes.ProbeRun] | None, schema: dict[str, Any]) -> AnalysisResult:
111
+ """Analyze the API schema."""
112
+ # Manual serialization reduces the size of the payload a bit
113
+ dependencies = collect_dependency_versions()
114
+ if probes is not None:
115
+ _probes = [probe.serialize() for probe in probes]
116
+ else:
117
+ _probes = []
118
+ content = json.dumps(
119
+ {
120
+ "probes": _probes,
121
+ "schema": schema,
122
+ "dependencies": list(map(asdict, dependencies)),
123
+ },
124
+ separators=(",", ":"),
125
+ )
126
+ response = self.post("/cli/analysis/", data=content, headers={"Content-Type": "application/json"}, timeout=None)
127
+ if response.status_code == http.HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
128
+ try:
129
+ message = response.json()["detail"]
130
+ except json.JSONDecodeError:
131
+ message = response.text
132
+ return AnalysisError(message=message)
133
+ return AnalysisSuccess.from_dict(response.json())
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import re
5
+ from ipaddress import IPv4Network, IPv6Network
6
+ from typing import TYPE_CHECKING, Callable, Optional, Any
7
+
8
+ from ..graphql import nodes
9
+ from ..internal.result import Result, Ok, Err
10
+ from .models import (
11
+ Extension,
12
+ SchemaPatchesExtension,
13
+ StrategyDefinition,
14
+ OpenApiStringFormatsExtension,
15
+ GraphQLScalarsExtension,
16
+ MediaTypesExtension,
17
+ TransformFunctionDefinition,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from datetime import date, datetime
22
+
23
+ from hypothesis import strategies as st
24
+
25
+ from ..schemas import BaseSchema
26
+
27
+
28
+ def apply(extensions: list[Extension], schema: BaseSchema) -> None:
29
+ """Apply the given extensions."""
30
+ for extension in extensions:
31
+ if isinstance(extension, OpenApiStringFormatsExtension):
32
+ _apply_string_formats_extension(extension)
33
+ elif isinstance(extension, GraphQLScalarsExtension):
34
+ _apply_scalars_extension(extension)
35
+ elif isinstance(extension, MediaTypesExtension):
36
+ _apply_media_types_extension(extension)
37
+ elif isinstance(extension, SchemaPatchesExtension):
38
+ _apply_schema_patches_extension(extension, schema)
39
+
40
+
41
+ def _apply_simple_extension(
42
+ extension: OpenApiStringFormatsExtension | GraphQLScalarsExtension | MediaTypesExtension,
43
+ collection: dict[str, Any],
44
+ register_strategy: Callable[[str, st.SearchStrategy], None],
45
+ ) -> None:
46
+ errors = []
47
+ for name, value in collection.items():
48
+ strategy = strategy_from_definitions(value)
49
+ if isinstance(strategy, Err):
50
+ errors.append(str(strategy.err()))
51
+ else:
52
+ register_strategy(name, strategy.ok())
53
+
54
+ if errors:
55
+ extension.set_error(errors=errors)
56
+ else:
57
+ extension.set_success()
58
+
59
+
60
+ def _apply_string_formats_extension(extension: OpenApiStringFormatsExtension) -> None:
61
+ from ..specs.openapi import formats
62
+
63
+ _apply_simple_extension(extension, extension.formats, formats.register)
64
+
65
+
66
+ def _apply_scalars_extension(extension: GraphQLScalarsExtension) -> None:
67
+ from ..specs.graphql import scalars
68
+
69
+ _apply_simple_extension(extension, extension.scalars, scalars.scalar)
70
+
71
+
72
+ def _apply_media_types_extension(extension: MediaTypesExtension) -> None:
73
+ from ..specs.openapi import media_types
74
+
75
+ _apply_simple_extension(extension, extension.media_types, media_types.register_media_type)
76
+
77
+
78
+ def _find_built_in_strategy(name: str) -> Optional[st.SearchStrategy]:
79
+ """Find a built-in Hypothesis strategy by its name."""
80
+ from hypothesis import provisional as pr
81
+ from hypothesis import strategies as st
82
+
83
+ for module in (st, pr):
84
+ if hasattr(module, name):
85
+ return getattr(module, name)
86
+ return None
87
+
88
+
89
+ def _apply_schema_patches_extension(extension: SchemaPatchesExtension, schema: BaseSchema) -> None:
90
+ """Apply a set of patches to the schema."""
91
+ for patch in extension.patches:
92
+ current: dict[str, Any] | list = schema.raw_schema
93
+ operation = patch["operation"]
94
+ path = patch["path"]
95
+ for part in path[:-1]:
96
+ if isinstance(current, dict):
97
+ if not isinstance(part, str):
98
+ extension.set_error([f"Invalid path: {path}"])
99
+ return
100
+ current = current.setdefault(part, {})
101
+ elif isinstance(current, list):
102
+ if not isinstance(part, int):
103
+ extension.set_error([f"Invalid path: {path}"])
104
+ return
105
+ try:
106
+ current = current[part]
107
+ except IndexError:
108
+ extension.set_error([f"Invalid path: {path}"])
109
+ return
110
+ if operation == "add":
111
+ # Add or replace the value at the target location.
112
+ current[path[-1]] = patch["value"] # type: ignore
113
+ elif operation == "remove":
114
+ # Remove the item at the target location if it exists.
115
+ if path:
116
+ last = path[-1]
117
+ if isinstance(current, dict) and isinstance(last, str) and last in current:
118
+ del current[last]
119
+ elif isinstance(current, list) and isinstance(last, int) and len(current) > last:
120
+ del current[last]
121
+ else:
122
+ extension.set_error([f"Invalid path: {path}"])
123
+ return
124
+ else:
125
+ current.clear()
126
+
127
+ extension.set_success()
128
+
129
+
130
+ def strategy_from_definitions(definitions: list[StrategyDefinition]) -> Result[st.SearchStrategy, Exception]:
131
+ from ..utils import combine_strategies
132
+
133
+ strategies = []
134
+ for definition in definitions:
135
+ strategy = _strategy_from_definition(definition)
136
+ if isinstance(strategy, Ok):
137
+ strategies.append(strategy.ok())
138
+ else:
139
+ return strategy
140
+ return Ok(combine_strategies(strategies))
141
+
142
+
143
+ KNOWN_ARGUMENTS = {
144
+ "IPv4Network": IPv4Network,
145
+ "IPv6Network": IPv6Network,
146
+ }
147
+
148
+
149
+ def check_regex(regex: str) -> Result[None, Exception]:
150
+ try:
151
+ re.compile(regex)
152
+ except (re.error, OverflowError, RuntimeError):
153
+ return Err(ValueError(f"Invalid regex: `{regex}`"))
154
+ return Ok(None)
155
+
156
+
157
+ def check_sampled_from(elements: list) -> Result[None, Exception]:
158
+ if not elements:
159
+ return Err(ValueError("Invalid input for `sampled_from`: Cannot sample from a length-zero sequence"))
160
+ return Ok(None)
161
+
162
+
163
+ STRATEGY_ARGUMENT_CHECKS = {
164
+ "from_regex": check_regex,
165
+ "sampled_from": check_sampled_from,
166
+ }
167
+
168
+
169
+ def _strategy_from_definition(definition: StrategyDefinition) -> Result[st.SearchStrategy, Exception]:
170
+ base = _find_built_in_strategy(definition.name)
171
+ if base is None:
172
+ return Err(ValueError(f"Unknown built-in strategy: `{definition.name}`"))
173
+ arguments = definition.arguments or {}
174
+ arguments = arguments.copy()
175
+ for key, value in arguments.items():
176
+ if isinstance(value, str):
177
+ known = KNOWN_ARGUMENTS.get(value)
178
+ if known is not None:
179
+ arguments[key] = known
180
+ check = STRATEGY_ARGUMENT_CHECKS.get(definition.name)
181
+ if check is not None:
182
+ check_result = check(**arguments) # type: ignore
183
+ if isinstance(check_result, Err):
184
+ return check_result
185
+ strategy = base(**arguments)
186
+ for transform in definition.transforms or []:
187
+ if transform["kind"] == "map":
188
+ function = _get_map_function(transform)
189
+ if isinstance(function, Ok):
190
+ strategy = strategy.map(function.ok())
191
+ else:
192
+ return function
193
+ else:
194
+ return Err(ValueError(f"Unknown transform kind: {transform['kind']}"))
195
+
196
+ return Ok(strategy)
197
+
198
+
199
+ def make_strftime(format: str) -> Callable:
200
+ def strftime(value: date | datetime) -> str:
201
+ return value.strftime(format)
202
+
203
+ return strftime
204
+
205
+
206
+ def _get_map_function(definition: TransformFunctionDefinition) -> Result[Callable | None, Exception]:
207
+ from ..specs.openapi._hypothesis import Binary
208
+
209
+ TRANSFORM_FACTORIES: dict[str, Callable] = {
210
+ "str": lambda: str,
211
+ "base64_encode": lambda: lambda x: Binary(base64.b64encode(x)),
212
+ "base64_decode": lambda: lambda x: Binary(base64.b64decode(x)),
213
+ "urlsafe_base64_encode": lambda: lambda x: Binary(base64.urlsafe_b64encode(x)),
214
+ "strftime": make_strftime,
215
+ "GraphQLBoolean": lambda: nodes.Boolean,
216
+ "GraphQLFloat": lambda: nodes.Float,
217
+ "GraphQLInt": lambda: nodes.Int,
218
+ "GraphQLString": lambda: nodes.String,
219
+ }
220
+ factory = TRANSFORM_FACTORIES.get(definition["name"])
221
+ if factory is None:
222
+ return Err(ValueError(f"Unknown transform: {definition['name']}"))
223
+ arguments = definition.get("arguments", {})
224
+ return Ok(factory(**arguments))
@@ -1,4 +1,5 @@
1
1
  """Work with stored auth data."""
2
+
2
3
  from __future__ import annotations
3
4
  import enum
4
5
  import tempfile
@@ -1,7 +1,9 @@
1
1
  """Useful info to collect from CLI usage."""
2
+
2
3
  from __future__ import annotations
3
4
  import os
4
5
  import platform
6
+ from importlib import metadata
5
7
  from dataclasses import dataclass, field
6
8
 
7
9
  from ..constants import SCHEMATHESIS_VERSION
@@ -32,6 +34,27 @@ class CliMetadata:
32
34
  version: str = SCHEMATHESIS_VERSION
33
35
 
34
36
 
37
+ DEPDENDENCY_NAMES = ["hypothesis", "hypothesis-jsonschema", "hypothesis-graphql"]
38
+
39
+
40
+ @dataclass
41
+ class Dependency:
42
+ """A single dependency."""
43
+
44
+ # Name of the package.
45
+ name: str
46
+ # Version of the package.
47
+ version: str
48
+
49
+ @classmethod
50
+ def from_name(cls, name: str) -> Dependency:
51
+ return cls(name=name, version=metadata.version(name))
52
+
53
+
54
+ def collect_dependency_versions() -> list[Dependency]:
55
+ return [Dependency.from_name(name) for name in DEPDENDENCY_NAMES]
56
+
57
+
35
58
  @dataclass
36
59
  class Metadata:
37
60
  """CLI environment metadata."""
@@ -44,3 +67,4 @@ class Metadata:
44
67
  cli: CliMetadata = field(default_factory=CliMetadata)
45
68
  # Used Docker image if any
46
69
  docker_image: str | None = field(default_factory=lambda: os.getenv(DOCKER_IMAGE_ENV_VAR))
70
+ depdenencies: list[Dependency] = field(default_factory=collect_dependency_versions)
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
- from dataclasses import dataclass
2
+ from dataclasses import dataclass, field
3
3
  from enum import Enum
4
- from typing import Any
4
+ from typing import Any, Iterable, TypedDict, Union, Literal
5
5
 
6
6
 
7
7
  class UploadSource(str, Enum):
@@ -47,3 +47,211 @@ class UploadResponse:
47
47
  @dataclass
48
48
  class FailedUploadResponse:
49
49
  detail: str
50
+
51
+
52
+ @dataclass
53
+ class NotAppliedState:
54
+ """The extension was not applied."""
55
+
56
+ def __str__(self) -> str:
57
+ return "Not Applied"
58
+
59
+
60
+ @dataclass
61
+ class SuccessState:
62
+ """The extension was applied successfully."""
63
+
64
+ def __str__(self) -> str:
65
+ return "Success"
66
+
67
+
68
+ @dataclass
69
+ class ErrorState:
70
+ """An error occurred during the extension application."""
71
+
72
+ errors: list[str] = field(default_factory=list)
73
+ exceptions: list[Exception] = field(default_factory=list)
74
+
75
+ def __str__(self) -> str:
76
+ return "Error"
77
+
78
+
79
+ ExtensionState = Union[NotAppliedState, SuccessState, ErrorState]
80
+
81
+
82
+ @dataclass
83
+ class BaseExtension:
84
+ def set_state(self, state: ExtensionState) -> None:
85
+ self.state = state
86
+
87
+ def set_success(self) -> None:
88
+ self.set_state(SuccessState())
89
+
90
+ def set_error(self, errors: list[str] | None = None, exceptions: list[Exception] | None = None) -> None:
91
+ self.set_state(ErrorState(errors=errors or [], exceptions=exceptions or []))
92
+
93
+
94
+ @dataclass
95
+ class UnknownExtension(BaseExtension):
96
+ """An unknown extension.
97
+
98
+ Likely the CLI should be updated.
99
+ """
100
+
101
+ type: str
102
+ state: ExtensionState = field(default_factory=NotAppliedState)
103
+
104
+ @property
105
+ def summary(self) -> str:
106
+ return f"`{self.type}`"
107
+
108
+
109
+ class AddPatch(TypedDict):
110
+ operation: Literal["add"]
111
+ path: list[str | int]
112
+ value: Any
113
+
114
+
115
+ class RemovePatch(TypedDict):
116
+ operation: Literal["remove"]
117
+ path: list[str | int]
118
+
119
+
120
+ Patch = Union[AddPatch, RemovePatch]
121
+
122
+
123
+ @dataclass
124
+ class SchemaPatchesExtension(BaseExtension):
125
+ """Update the schema with its optimized version."""
126
+
127
+ patches: list[Patch]
128
+ state: ExtensionState = field(default_factory=NotAppliedState)
129
+
130
+ @property
131
+ def summary(self) -> str:
132
+ count = len(self.patches)
133
+ plural = "es" if count > 1 else ""
134
+ return f"{count} schema patch{plural}"
135
+
136
+
137
+ class TransformFunctionDefinition(TypedDict):
138
+ kind: Literal["map", "filter"]
139
+ name: str
140
+ arguments: dict[str, Any]
141
+
142
+
143
+ @dataclass
144
+ class StrategyDefinition:
145
+ name: str
146
+ transforms: list[TransformFunctionDefinition] | None = None
147
+ arguments: dict[str, Any] | None = None
148
+
149
+
150
+ def _strategies_from_definition(items: dict[str, list[dict[str, Any]]]) -> dict[str, list[StrategyDefinition]]:
151
+ return {name: [StrategyDefinition(**item) for item in value] for name, value in items.items()}
152
+
153
+
154
+ def _format_items(items: Iterable[str]) -> str:
155
+ return ", ".join([f"`{item}`" for item in items])
156
+
157
+
158
+ @dataclass
159
+ class OpenApiStringFormatsExtension(BaseExtension):
160
+ """Custom string formats."""
161
+
162
+ formats: dict[str, list[StrategyDefinition]]
163
+ state: ExtensionState = field(default_factory=NotAppliedState)
164
+
165
+ @classmethod
166
+ def from_dict(cls, formats: dict[str, list[dict[str, Any]]]) -> OpenApiStringFormatsExtension:
167
+ return cls(formats=_strategies_from_definition(formats))
168
+
169
+ @property
170
+ def summary(self) -> str:
171
+ count = len(self.formats)
172
+ plural = "s" if count > 1 else ""
173
+ formats = _format_items(self.formats)
174
+ return f"Data generator{plural} for {formats} Open API format{plural}"
175
+
176
+
177
+ @dataclass
178
+ class GraphQLScalarsExtension(BaseExtension):
179
+ """Custom scalars."""
180
+
181
+ scalars: dict[str, list[StrategyDefinition]]
182
+ state: ExtensionState = field(default_factory=NotAppliedState)
183
+
184
+ @classmethod
185
+ def from_dict(cls, scalars: dict[str, list[dict[str, Any]]]) -> GraphQLScalarsExtension:
186
+ return cls(scalars=_strategies_from_definition(scalars))
187
+
188
+ @property
189
+ def summary(self) -> str:
190
+ count = len(self.scalars)
191
+ plural = "s" if count > 1 else ""
192
+ scalars = _format_items(self.scalars)
193
+ return f"Data generator{plural} for {scalars} GraphQL scalar{plural}"
194
+
195
+
196
+ @dataclass
197
+ class MediaTypesExtension(BaseExtension):
198
+ media_types: dict[str, list[StrategyDefinition]]
199
+ state: ExtensionState = field(default_factory=NotAppliedState)
200
+
201
+ @classmethod
202
+ def from_dict(cls, media_types: dict[str, list[dict[str, Any]]]) -> MediaTypesExtension:
203
+ return cls(media_types=_strategies_from_definition(media_types))
204
+
205
+ @property
206
+ def summary(self) -> str:
207
+ count = len(self.media_types)
208
+ plural = "s" if count > 1 else ""
209
+ media_types = _format_items(self.media_types)
210
+ return f"Data generator{plural} for {media_types} media type{plural}"
211
+
212
+
213
+ # A CLI extension that can be used to adjust the behavior of Schemathesis.
214
+ Extension = Union[
215
+ SchemaPatchesExtension,
216
+ OpenApiStringFormatsExtension,
217
+ GraphQLScalarsExtension,
218
+ MediaTypesExtension,
219
+ UnknownExtension,
220
+ ]
221
+
222
+
223
+ def extension_from_dict(data: dict[str, Any]) -> Extension:
224
+ if data["type"] == "schema_patches":
225
+ return SchemaPatchesExtension(patches=data["patches"])
226
+ elif data["type"] == "string_formats":
227
+ return OpenApiStringFormatsExtension.from_dict(formats=data["items"])
228
+ elif data["type"] == "scalars":
229
+ return GraphQLScalarsExtension.from_dict(scalars=data["items"])
230
+ elif data["type"] == "media_types":
231
+ return MediaTypesExtension.from_dict(media_types=data["items"])
232
+ return UnknownExtension(type=data["type"])
233
+
234
+
235
+ @dataclass
236
+ class AnalysisSuccess:
237
+ id: str
238
+ elapsed: float
239
+ message: str
240
+ extensions: list[Extension]
241
+
242
+ @classmethod
243
+ def from_dict(cls, data: dict[str, Any]) -> AnalysisSuccess:
244
+ return cls(
245
+ id=data["id"],
246
+ elapsed=data["elapsed"],
247
+ message=data["message"],
248
+ extensions=[extension_from_dict(ext) for ext in data["extensions"]],
249
+ )
250
+
251
+
252
+ @dataclass
253
+ class AnalysisError:
254
+ message: str
255
+
256
+
257
+ AnalysisResult = Union[AnalysisSuccess, AnalysisError]