schemathesis 3.39.15__py3-none-any.whl → 4.0.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.
- schemathesis/__init__.py +41 -79
- schemathesis/auths.py +111 -122
- schemathesis/checks.py +169 -60
- schemathesis/cli/__init__.py +15 -2117
- schemathesis/cli/commands/__init__.py +85 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +590 -0
- schemathesis/cli/commands/run/context.py +204 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +474 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +55 -0
- schemathesis/cli/commands/run/handlers/output.py +1628 -0
- schemathesis/cli/commands/run/loaders.py +114 -0
- schemathesis/cli/commands/run/validation.py +246 -0
- schemathesis/cli/constants.py +5 -58
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +84 -0
- schemathesis/cli/{options.py → ext/options.py} +36 -34
- schemathesis/config/__init__.py +189 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +99 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +156 -0
- schemathesis/config/_generation.py +149 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +327 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +187 -0
- schemathesis/config/_projects.py +527 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +885 -0
- schemathesis/core/__init__.py +67 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +459 -0
- schemathesis/core/failures.py +315 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +14 -12
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/{throttling.py → core/rate_limit.py} +16 -17
- schemathesis/core/registries.py +31 -0
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +54 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +118 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +169 -0
- schemathesis/engine/errors.py +464 -0
- schemathesis/engine/events.py +258 -0
- schemathesis/engine/phases/__init__.py +88 -0
- schemathesis/{runner → engine/phases}/probes.py +52 -68
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +356 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +212 -0
- schemathesis/engine/phases/unit/_executor.py +416 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +247 -0
- schemathesis/errors.py +43 -0
- schemathesis/filters.py +17 -98
- schemathesis/generation/__init__.py +5 -33
- schemathesis/generation/case.py +317 -0
- schemathesis/generation/coverage.py +282 -175
- schemathesis/generation/hypothesis/__init__.py +36 -0
- schemathesis/generation/hypothesis/builder.py +800 -0
- schemathesis/generation/{_hypothesis.py → hypothesis/examples.py} +2 -11
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +116 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +278 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +284 -0
- schemathesis/hooks.py +80 -101
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +455 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +313 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +281 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +128 -108
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -273
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +1 -2
- schemathesis/specs/graphql/scalars.py +42 -6
- schemathesis/specs/graphql/schemas.py +141 -137
- schemathesis/specs/graphql/validation.py +11 -17
- schemathesis/specs/openapi/__init__.py +6 -1
- schemathesis/specs/openapi/_cache.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +142 -156
- schemathesis/specs/openapi/checks.py +368 -257
- schemathesis/specs/openapi/converter.py +4 -4
- schemathesis/specs/openapi/definitions.py +1 -1
- schemathesis/specs/openapi/examples.py +23 -21
- schemathesis/specs/openapi/expressions/__init__.py +31 -19
- schemathesis/specs/openapi/expressions/extractors.py +1 -4
- schemathesis/specs/openapi/expressions/lexer.py +1 -1
- schemathesis/specs/openapi/expressions/nodes.py +36 -41
- schemathesis/specs/openapi/expressions/parser.py +1 -1
- schemathesis/specs/openapi/formats.py +35 -7
- schemathesis/specs/openapi/media_types.py +53 -12
- schemathesis/specs/openapi/negative/__init__.py +7 -4
- schemathesis/specs/openapi/negative/mutations.py +6 -5
- schemathesis/specs/openapi/parameters.py +7 -10
- schemathesis/specs/openapi/patterns.py +94 -31
- schemathesis/specs/openapi/references.py +12 -53
- schemathesis/specs/openapi/schemas.py +238 -308
- schemathesis/specs/openapi/security.py +1 -1
- schemathesis/specs/openapi/serialization.py +12 -6
- schemathesis/specs/openapi/stateful/__init__.py +268 -133
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/links.py +209 -0
- schemathesis/transport/__init__.py +142 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +124 -0
- schemathesis/transport/requests.py +244 -0
- schemathesis/{_xml.py → transport/serialization.py} +69 -11
- schemathesis/transport/wsgi.py +171 -0
- schemathesis-4.0.0.dist-info/METADATA +204 -0
- schemathesis-4.0.0.dist-info/RECORD +164 -0
- {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/entry_points.txt +1 -1
- {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/licenses/LICENSE +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -19
- schemathesis/_hypothesis.py +0 -712
- schemathesis/_override.py +0 -50
- schemathesis/_patches.py +0 -21
- schemathesis/_rate_limiter.py +0 -7
- schemathesis/cli/callbacks.py +0 -466
- schemathesis/cli/cassettes.py +0 -561
- schemathesis/cli/context.py +0 -75
- schemathesis/cli/debug.py +0 -27
- schemathesis/cli/handlers.py +0 -19
- schemathesis/cli/junitxml.py +0 -124
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -920
- schemathesis/cli/output/short.py +0 -59
- schemathesis/cli/reporting.py +0 -79
- schemathesis/cli/sanitization.py +0 -26
- schemathesis/code_samples.py +0 -151
- schemathesis/constants.py +0 -54
- schemathesis/contrib/__init__.py +0 -11
- schemathesis/contrib/openapi/__init__.py +0 -11
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -24
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -16
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -571
- schemathesis/experimental/__init__.py +0 -109
- schemathesis/extra/_aiohttp.py +0 -28
- schemathesis/extra/_flask.py +0 -13
- schemathesis/extra/_server.py +0 -18
- schemathesis/failures.py +0 -284
- schemathesis/fixups/__init__.py +0 -37
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -28
- schemathesis/generation/_methods.py +0 -44
- schemathesis/graphql.py +0 -3
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/checks.py +0 -86
- schemathesis/internal/copy.py +0 -32
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -37
- schemathesis/internal/diff.py +0 -15
- schemathesis/internal/extensions.py +0 -27
- schemathesis/internal/jsonschema.py +0 -36
- schemathesis/internal/output.py +0 -68
- schemathesis/internal/transformation.py +0 -26
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -474
- schemathesis/loaders.py +0 -122
- schemathesis/models.py +0 -1341
- schemathesis/parameters.py +0 -90
- schemathesis/runner/__init__.py +0 -605
- schemathesis/runner/events.py +0 -389
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/context.py +0 -88
- schemathesis/runner/impl/core.py +0 -1280
- schemathesis/runner/impl/solo.py +0 -80
- schemathesis/runner/impl/threadpool.py +0 -391
- schemathesis/runner/serialization.py +0 -544
- schemathesis/sanitization.py +0 -252
- schemathesis/serializers.py +0 -328
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -202
- schemathesis/service/client.py +0 -133
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -61
- schemathesis/service/extensions.py +0 -224
- schemathesis/service/hosts.py +0 -111
- schemathesis/service/metadata.py +0 -71
- schemathesis/service/models.py +0 -258
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -173
- schemathesis/service/usage.py +0 -66
- schemathesis/specs/graphql/loaders.py +0 -364
- schemathesis/specs/openapi/expressions/context.py +0 -16
- schemathesis/specs/openapi/links.py +0 -389
- schemathesis/specs/openapi/loaders.py +0 -707
- schemathesis/specs/openapi/stateful/statistic.py +0 -198
- schemathesis/specs/openapi/stateful/types.py +0 -14
- schemathesis/specs/openapi/validation.py +0 -26
- schemathesis/stateful/__init__.py +0 -147
- schemathesis/stateful/config.py +0 -97
- schemathesis/stateful/context.py +0 -135
- schemathesis/stateful/events.py +0 -274
- schemathesis/stateful/runner.py +0 -309
- schemathesis/stateful/sink.py +0 -68
- schemathesis/stateful/state_machine.py +0 -328
- schemathesis/stateful/statistic.py +0 -22
- schemathesis/stateful/validation.py +0 -100
- schemathesis/targets.py +0 -77
- schemathesis/transports/__init__.py +0 -369
- schemathesis/transports/asgi.py +0 -7
- schemathesis/transports/auth.py +0 -38
- schemathesis/transports/headers.py +0 -36
- schemathesis/transports/responses.py +0 -57
- schemathesis/types.py +0 -44
- schemathesis/utils.py +0 -164
- schemathesis-3.39.15.dist-info/METADATA +0 -293
- schemathesis-3.39.15.dist-info/RECORD +0 -160
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- /schemathesis/{_lazy_import.py → core/lazy_import.py} +0 -0
- /schemathesis/{internal → core}/result.py +0 -0
- {schemathesis-3.39.15.dist-info → schemathesis-4.0.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,313 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import enum
|
4
|
+
import json
|
5
|
+
import re
|
6
|
+
from os import PathLike
|
7
|
+
from pathlib import Path
|
8
|
+
from typing import IO, TYPE_CHECKING, Any, Mapping
|
9
|
+
|
10
|
+
from schemathesis.config import SchemathesisConfig
|
11
|
+
from schemathesis.core import media_types
|
12
|
+
from schemathesis.core.deserialization import deserialize_yaml
|
13
|
+
from schemathesis.core.errors import LoaderError, LoaderErrorKind
|
14
|
+
from schemathesis.core.loaders import load_from_url, prepare_request_kwargs, raise_for_status, require_relative_url
|
15
|
+
from schemathesis.hooks import HookContext, dispatch
|
16
|
+
from schemathesis.python import asgi, wsgi
|
17
|
+
|
18
|
+
if TYPE_CHECKING:
|
19
|
+
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
20
|
+
|
21
|
+
|
22
|
+
def from_asgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> BaseOpenAPISchema:
|
23
|
+
"""Load OpenAPI schema from an ASGI application.
|
24
|
+
|
25
|
+
Args:
|
26
|
+
path: Relative URL path to the OpenAPI schema endpoint (e.g., "/openapi.json")
|
27
|
+
app: ASGI application instance
|
28
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
29
|
+
**kwargs: Additional request parameters passed to the ASGI test client
|
30
|
+
|
31
|
+
Example:
|
32
|
+
```python
|
33
|
+
from fastapi import FastAPI
|
34
|
+
import schemathesis
|
35
|
+
|
36
|
+
app = FastAPI()
|
37
|
+
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
|
38
|
+
```
|
39
|
+
|
40
|
+
"""
|
41
|
+
require_relative_url(path)
|
42
|
+
client = asgi.get_client(app)
|
43
|
+
response = load_from_url(client.get, url=path, **kwargs)
|
44
|
+
content_type = detect_content_type(headers=response.headers, path=path)
|
45
|
+
schema = load_content(response.text, content_type)
|
46
|
+
loaded = from_dict(schema=schema, config=config)
|
47
|
+
loaded.app = app
|
48
|
+
loaded.location = path
|
49
|
+
return loaded
|
50
|
+
|
51
|
+
|
52
|
+
def from_wsgi(path: str, app: Any, *, config: SchemathesisConfig | None = None, **kwargs: Any) -> BaseOpenAPISchema:
|
53
|
+
"""Load OpenAPI schema from a WSGI application.
|
54
|
+
|
55
|
+
Args:
|
56
|
+
path: Relative URL path to the OpenAPI schema endpoint (e.g., "/openapi.json")
|
57
|
+
app: WSGI application instance
|
58
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
59
|
+
**kwargs: Additional request parameters passed to the WSGI test client
|
60
|
+
|
61
|
+
Example:
|
62
|
+
```python
|
63
|
+
from flask import Flask
|
64
|
+
import schemathesis
|
65
|
+
|
66
|
+
app = Flask(__name__)
|
67
|
+
schema = schemathesis.openapi.from_wsgi("/openapi.json", app)
|
68
|
+
```
|
69
|
+
|
70
|
+
"""
|
71
|
+
require_relative_url(path)
|
72
|
+
prepare_request_kwargs(kwargs)
|
73
|
+
client = wsgi.get_client(app)
|
74
|
+
response = client.get(path=path, **kwargs)
|
75
|
+
raise_for_status(response)
|
76
|
+
content_type = detect_content_type(headers=response.headers, path=path)
|
77
|
+
schema = load_content(response.text, content_type)
|
78
|
+
loaded = from_dict(schema=schema, config=config)
|
79
|
+
loaded.app = app
|
80
|
+
loaded.location = path
|
81
|
+
return loaded
|
82
|
+
|
83
|
+
|
84
|
+
def from_url(
|
85
|
+
url: str, *, config: SchemathesisConfig | None = None, wait_for_schema: float | None = None, **kwargs: Any
|
86
|
+
) -> BaseOpenAPISchema:
|
87
|
+
"""Load OpenAPI schema from a URL.
|
88
|
+
|
89
|
+
Args:
|
90
|
+
url: Full URL to the OpenAPI schema
|
91
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
92
|
+
wait_for_schema: Maximum time in seconds to wait for schema availability
|
93
|
+
**kwargs: Additional parameters passed to `requests.get()` (headers, timeout, auth, etc.)
|
94
|
+
|
95
|
+
Example:
|
96
|
+
```python
|
97
|
+
import schemathesis
|
98
|
+
|
99
|
+
# Basic usage
|
100
|
+
schema = schemathesis.openapi.from_url("https://api.example.com/openapi.json")
|
101
|
+
|
102
|
+
# With authentication and timeout
|
103
|
+
schema = schemathesis.openapi.from_url(
|
104
|
+
"https://api.example.com/openapi.json",
|
105
|
+
headers={"Authorization": "Bearer token"},
|
106
|
+
timeout=30,
|
107
|
+
wait_for_schema=10.0
|
108
|
+
)
|
109
|
+
```
|
110
|
+
|
111
|
+
"""
|
112
|
+
import requests
|
113
|
+
|
114
|
+
response = load_from_url(requests.get, url=url, wait_for_schema=wait_for_schema, **kwargs)
|
115
|
+
content_type = detect_content_type(headers=response.headers, path=url)
|
116
|
+
schema = load_content(response.text, content_type)
|
117
|
+
loaded = from_dict(schema=schema, config=config)
|
118
|
+
loaded.location = url
|
119
|
+
return loaded
|
120
|
+
|
121
|
+
|
122
|
+
def from_path(
|
123
|
+
path: PathLike | str, *, config: SchemathesisConfig | None = None, encoding: str = "utf-8"
|
124
|
+
) -> BaseOpenAPISchema:
|
125
|
+
"""Load OpenAPI schema from a filesystem path.
|
126
|
+
|
127
|
+
Args:
|
128
|
+
path: File path to the OpenAPI schema (supports JSON / YAML)
|
129
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
130
|
+
encoding: Text encoding for reading the file
|
131
|
+
|
132
|
+
Example:
|
133
|
+
```python
|
134
|
+
import schemathesis
|
135
|
+
|
136
|
+
# Load from file
|
137
|
+
schema = schemathesis.openapi.from_path("./specs/openapi.yaml")
|
138
|
+
|
139
|
+
# With custom encoding
|
140
|
+
schema = schemathesis.openapi.from_path("./specs/openapi.json", encoding="cp1252")
|
141
|
+
```
|
142
|
+
|
143
|
+
"""
|
144
|
+
with open(path, encoding=encoding) as file:
|
145
|
+
content_type = detect_content_type(headers=None, path=str(path))
|
146
|
+
schema = load_content(file.read(), content_type)
|
147
|
+
loaded = from_dict(schema=schema, config=config)
|
148
|
+
loaded.location = Path(path).absolute().as_uri()
|
149
|
+
return loaded
|
150
|
+
|
151
|
+
|
152
|
+
def from_file(file: IO[str] | str, *, config: SchemathesisConfig | None = None) -> BaseOpenAPISchema:
|
153
|
+
"""Load OpenAPI schema from a file-like object or string.
|
154
|
+
|
155
|
+
Args:
|
156
|
+
file: File-like object or raw string containing the OpenAPI schema
|
157
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
158
|
+
|
159
|
+
Example:
|
160
|
+
```python
|
161
|
+
import schemathesis
|
162
|
+
|
163
|
+
# From string
|
164
|
+
schema_content = '{"openapi": "3.0.0", "info": {"title": "API"}}'
|
165
|
+
schema = schemathesis.openapi.from_file(schema_content)
|
166
|
+
|
167
|
+
# From file object
|
168
|
+
with open("openapi.yaml") as f:
|
169
|
+
schema = schemathesis.openapi.from_file(f)
|
170
|
+
```
|
171
|
+
|
172
|
+
"""
|
173
|
+
if isinstance(file, str):
|
174
|
+
data = file
|
175
|
+
else:
|
176
|
+
data = file.read()
|
177
|
+
try:
|
178
|
+
schema = json.loads(data)
|
179
|
+
except json.JSONDecodeError:
|
180
|
+
schema = _load_yaml(data)
|
181
|
+
return from_dict(schema, config=config)
|
182
|
+
|
183
|
+
|
184
|
+
def from_dict(schema: dict[str, Any], *, config: SchemathesisConfig | None = None) -> BaseOpenAPISchema:
|
185
|
+
"""Load OpenAPI schema from a dictionary.
|
186
|
+
|
187
|
+
Args:
|
188
|
+
schema: Dictionary containing the parsed OpenAPI schema
|
189
|
+
config: Custom configuration. If `None`, uses auto-discovered config
|
190
|
+
|
191
|
+
Example:
|
192
|
+
```python
|
193
|
+
import schemathesis
|
194
|
+
|
195
|
+
schema_dict = {
|
196
|
+
"openapi": "3.0.0",
|
197
|
+
"info": {"title": "My API", "version": "1.0.0"},
|
198
|
+
"paths": {"/users": {"get": {"responses": {"200": {"description": "OK"}}}}}
|
199
|
+
}
|
200
|
+
|
201
|
+
schema = schemathesis.openapi.from_dict(schema_dict)
|
202
|
+
```
|
203
|
+
|
204
|
+
"""
|
205
|
+
from schemathesis.specs.openapi.schemas import OpenApi30, SwaggerV20
|
206
|
+
|
207
|
+
if not isinstance(schema, dict):
|
208
|
+
raise LoaderError(LoaderErrorKind.OPEN_API_INVALID_SCHEMA, SCHEMA_INVALID_ERROR)
|
209
|
+
hook_context = HookContext()
|
210
|
+
dispatch("before_load_schema", hook_context, schema)
|
211
|
+
|
212
|
+
if config is None:
|
213
|
+
config = SchemathesisConfig.discover()
|
214
|
+
project_config = config.projects.get(schema)
|
215
|
+
|
216
|
+
if "swagger" in schema:
|
217
|
+
instance = SwaggerV20(raw_schema=schema, config=project_config)
|
218
|
+
elif "openapi" in schema:
|
219
|
+
version = schema["openapi"]
|
220
|
+
if not OPENAPI_VERSION_RE.match(version):
|
221
|
+
raise LoaderError(
|
222
|
+
LoaderErrorKind.OPEN_API_UNSUPPORTED_VERSION,
|
223
|
+
f"The provided schema uses Open API {version}, which is currently not supported.",
|
224
|
+
)
|
225
|
+
instance = OpenApi30(raw_schema=schema, config=project_config)
|
226
|
+
else:
|
227
|
+
raise LoaderError(
|
228
|
+
LoaderErrorKind.OPEN_API_UNSPECIFIED_VERSION,
|
229
|
+
"Unable to determine the Open API version as it's not specified in the document.",
|
230
|
+
)
|
231
|
+
dispatch("after_load_schema", hook_context, instance)
|
232
|
+
return instance
|
233
|
+
|
234
|
+
|
235
|
+
class ContentType(enum.Enum):
|
236
|
+
"""Known content types for schema files."""
|
237
|
+
|
238
|
+
JSON = enum.auto()
|
239
|
+
YAML = enum.auto()
|
240
|
+
UNKNOWN = enum.auto()
|
241
|
+
|
242
|
+
|
243
|
+
def detect_content_type(*, headers: Mapping[str, str] | None = None, path: str | None = None) -> ContentType:
|
244
|
+
"""Detect content type from various sources."""
|
245
|
+
if headers is not None and (content_type := _detect_from_headers(headers)) != ContentType.UNKNOWN:
|
246
|
+
return content_type
|
247
|
+
if path is not None and (content_type := _detect_from_path(path)) != ContentType.UNKNOWN:
|
248
|
+
return content_type
|
249
|
+
return ContentType.UNKNOWN
|
250
|
+
|
251
|
+
|
252
|
+
def _detect_from_headers(headers: Mapping[str, str]) -> ContentType:
|
253
|
+
"""Detect content type from HTTP headers."""
|
254
|
+
content_type = headers.get("Content-Type", "").lower()
|
255
|
+
try:
|
256
|
+
if content_type and media_types.is_json(content_type):
|
257
|
+
return ContentType.JSON
|
258
|
+
if content_type and media_types.is_yaml(content_type):
|
259
|
+
return ContentType.YAML
|
260
|
+
except ValueError:
|
261
|
+
pass
|
262
|
+
return ContentType.UNKNOWN
|
263
|
+
|
264
|
+
|
265
|
+
def _detect_from_path(path: str) -> ContentType:
|
266
|
+
"""Detect content type from file path."""
|
267
|
+
suffix = Path(path).suffix.lower()
|
268
|
+
if suffix == ".json":
|
269
|
+
return ContentType.JSON
|
270
|
+
if suffix in (".yaml", ".yml"):
|
271
|
+
return ContentType.YAML
|
272
|
+
return ContentType.UNKNOWN
|
273
|
+
|
274
|
+
|
275
|
+
def load_content(content: str, content_type: ContentType) -> dict[str, Any]:
|
276
|
+
"""Load content using appropriate parser."""
|
277
|
+
if content_type == ContentType.JSON:
|
278
|
+
return _load_json(content)
|
279
|
+
if content_type == ContentType.YAML:
|
280
|
+
return _load_yaml(content)
|
281
|
+
# If type is unknown, try JSON first, then YAML
|
282
|
+
try:
|
283
|
+
return _load_json(content)
|
284
|
+
except json.JSONDecodeError:
|
285
|
+
return _load_yaml(content)
|
286
|
+
|
287
|
+
|
288
|
+
def _load_json(content: str) -> dict[str, Any]:
|
289
|
+
try:
|
290
|
+
return json.loads(content)
|
291
|
+
except json.JSONDecodeError as exc:
|
292
|
+
raise LoaderError(
|
293
|
+
LoaderErrorKind.SYNTAX_ERROR,
|
294
|
+
SCHEMA_SYNTAX_ERROR,
|
295
|
+
extras=[entry for entry in str(exc).splitlines() if entry],
|
296
|
+
) from exc
|
297
|
+
|
298
|
+
|
299
|
+
def _load_yaml(content: str) -> dict[str, Any]:
|
300
|
+
import yaml
|
301
|
+
|
302
|
+
try:
|
303
|
+
return deserialize_yaml(content)
|
304
|
+
except yaml.YAMLError as exc:
|
305
|
+
kind = LoaderErrorKind.SYNTAX_ERROR
|
306
|
+
message = SCHEMA_SYNTAX_ERROR
|
307
|
+
extras = [entry for entry in str(exc).splitlines() if entry]
|
308
|
+
raise LoaderError(kind, message, extras=extras) from exc
|
309
|
+
|
310
|
+
|
311
|
+
SCHEMA_INVALID_ERROR = "The provided API schema does not appear to be a valid OpenAPI schema"
|
312
|
+
SCHEMA_SYNTAX_ERROR = "API schema does not appear syntactically valid"
|
313
|
+
OPENAPI_VERSION_RE = re.compile(r"^3\.[01]\.[0-9](-.+)?$")
|
@@ -0,0 +1,281 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from contextlib import nullcontext
|
4
|
+
from dataclasses import dataclass, field
|
5
|
+
from inspect import signature
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, Generator, Type
|
7
|
+
|
8
|
+
import pytest
|
9
|
+
from hypothesis.core import HypothesisHandle
|
10
|
+
from pytest_subtests import SubTests
|
11
|
+
|
12
|
+
from schemathesis.core.errors import InvalidSchema
|
13
|
+
from schemathesis.core.result import Ok, Result
|
14
|
+
from schemathesis.filters import FilterSet, FilterValue, MatcherFunc, RegexValue, is_deprecated
|
15
|
+
from schemathesis.generation import overrides
|
16
|
+
from schemathesis.generation.hypothesis.builder import HypothesisTestConfig, HypothesisTestMode, create_test
|
17
|
+
from schemathesis.generation.hypothesis.given import (
|
18
|
+
GivenArgsMark,
|
19
|
+
GivenInput,
|
20
|
+
GivenKwargsMark,
|
21
|
+
given_proxy,
|
22
|
+
is_given_applied,
|
23
|
+
merge_given_args,
|
24
|
+
validate_given_args,
|
25
|
+
)
|
26
|
+
from schemathesis.pytest.control_flow import fail_on_no_matches
|
27
|
+
from schemathesis.schemas import BaseSchema
|
28
|
+
|
29
|
+
if TYPE_CHECKING:
|
30
|
+
import hypothesis
|
31
|
+
from _pytest.fixtures import FixtureRequest
|
32
|
+
|
33
|
+
from schemathesis.schemas import APIOperation
|
34
|
+
|
35
|
+
|
36
|
+
def get_all_tests(
|
37
|
+
*,
|
38
|
+
schema: BaseSchema,
|
39
|
+
test_func: Callable,
|
40
|
+
modes: list[HypothesisTestMode],
|
41
|
+
settings: hypothesis.settings | None = None,
|
42
|
+
seed: int | None = None,
|
43
|
+
as_strategy_kwargs: Callable[[APIOperation], dict[str, Any]] | None = None,
|
44
|
+
given_kwargs: dict[str, GivenInput] | None = None,
|
45
|
+
) -> Generator[Result[tuple[APIOperation, Callable], InvalidSchema], None, None]:
|
46
|
+
"""Generate all operations and Hypothesis tests for them."""
|
47
|
+
for result in schema.get_all_operations():
|
48
|
+
if isinstance(result, Ok):
|
49
|
+
operation = result.ok()
|
50
|
+
if callable(as_strategy_kwargs):
|
51
|
+
_as_strategy_kwargs = as_strategy_kwargs(operation)
|
52
|
+
else:
|
53
|
+
_as_strategy_kwargs = {}
|
54
|
+
test = create_test(
|
55
|
+
operation=operation,
|
56
|
+
test_func=test_func,
|
57
|
+
config=HypothesisTestConfig(
|
58
|
+
settings=settings,
|
59
|
+
modes=modes,
|
60
|
+
seed=seed,
|
61
|
+
project=schema.config,
|
62
|
+
as_strategy_kwargs=_as_strategy_kwargs,
|
63
|
+
given_kwargs=given_kwargs or {},
|
64
|
+
),
|
65
|
+
)
|
66
|
+
yield Ok((operation, test))
|
67
|
+
else:
|
68
|
+
yield result
|
69
|
+
|
70
|
+
|
71
|
+
@dataclass
|
72
|
+
class LazySchema:
|
73
|
+
fixture_name: str
|
74
|
+
filter_set: FilterSet = field(default_factory=FilterSet)
|
75
|
+
|
76
|
+
def include(
|
77
|
+
self,
|
78
|
+
func: MatcherFunc | None = None,
|
79
|
+
*,
|
80
|
+
name: FilterValue | None = None,
|
81
|
+
name_regex: str | None = None,
|
82
|
+
method: FilterValue | None = None,
|
83
|
+
method_regex: str | None = None,
|
84
|
+
path: FilterValue | None = None,
|
85
|
+
path_regex: str | None = None,
|
86
|
+
tag: FilterValue | None = None,
|
87
|
+
tag_regex: RegexValue | None = None,
|
88
|
+
operation_id: FilterValue | None = None,
|
89
|
+
operation_id_regex: RegexValue | None = None,
|
90
|
+
) -> LazySchema:
|
91
|
+
"""Include only operations that match the given filters."""
|
92
|
+
filter_set = self.filter_set.clone()
|
93
|
+
filter_set.include(
|
94
|
+
func,
|
95
|
+
name=name,
|
96
|
+
name_regex=name_regex,
|
97
|
+
method=method,
|
98
|
+
method_regex=method_regex,
|
99
|
+
path=path,
|
100
|
+
path_regex=path_regex,
|
101
|
+
tag=tag,
|
102
|
+
tag_regex=tag_regex,
|
103
|
+
operation_id=operation_id,
|
104
|
+
operation_id_regex=operation_id_regex,
|
105
|
+
)
|
106
|
+
return self.__class__(fixture_name=self.fixture_name, filter_set=filter_set)
|
107
|
+
|
108
|
+
def exclude(
|
109
|
+
self,
|
110
|
+
func: MatcherFunc | None = None,
|
111
|
+
*,
|
112
|
+
name: FilterValue | None = None,
|
113
|
+
name_regex: str | None = None,
|
114
|
+
method: FilterValue | None = None,
|
115
|
+
method_regex: str | None = None,
|
116
|
+
path: FilterValue | None = None,
|
117
|
+
path_regex: str | None = None,
|
118
|
+
tag: FilterValue | None = None,
|
119
|
+
tag_regex: RegexValue | None = None,
|
120
|
+
operation_id: FilterValue | None = None,
|
121
|
+
operation_id_regex: RegexValue | None = None,
|
122
|
+
deprecated: bool = False,
|
123
|
+
) -> LazySchema:
|
124
|
+
"""Exclude operations that match the given filters."""
|
125
|
+
filter_set = self.filter_set.clone()
|
126
|
+
if deprecated:
|
127
|
+
if func is None:
|
128
|
+
func = is_deprecated
|
129
|
+
else:
|
130
|
+
filter_set.exclude(is_deprecated)
|
131
|
+
filter_set.exclude(
|
132
|
+
func,
|
133
|
+
name=name,
|
134
|
+
name_regex=name_regex,
|
135
|
+
method=method,
|
136
|
+
method_regex=method_regex,
|
137
|
+
path=path,
|
138
|
+
path_regex=path_regex,
|
139
|
+
tag=tag,
|
140
|
+
tag_regex=tag_regex,
|
141
|
+
operation_id=operation_id,
|
142
|
+
operation_id_regex=operation_id_regex,
|
143
|
+
)
|
144
|
+
return self.__class__(fixture_name=self.fixture_name, filter_set=filter_set)
|
145
|
+
|
146
|
+
def parametrize(self) -> Callable:
|
147
|
+
def wrapper(test_func: Callable) -> Callable:
|
148
|
+
if is_given_applied(test_func):
|
149
|
+
# The user wrapped the test function with `@schema.given`
|
150
|
+
# These args & kwargs go as extra to the underlying test generator
|
151
|
+
given_args = GivenArgsMark.get(test_func)
|
152
|
+
given_kwargs = GivenKwargsMark.get(test_func)
|
153
|
+
assert given_args is not None
|
154
|
+
assert given_kwargs is not None
|
155
|
+
test_function = validate_given_args(test_func, given_args, given_kwargs)
|
156
|
+
if test_function is not None:
|
157
|
+
return test_function
|
158
|
+
given_kwargs = merge_given_args(test_func, given_args, given_kwargs)
|
159
|
+
del given_args
|
160
|
+
else:
|
161
|
+
given_kwargs = {}
|
162
|
+
|
163
|
+
def wrapped_test(request: FixtureRequest) -> None:
|
164
|
+
"""The actual test, which is executed by pytest."""
|
165
|
+
__tracebackhide__ = True
|
166
|
+
schema = get_schema(
|
167
|
+
request=request,
|
168
|
+
name=self.fixture_name,
|
169
|
+
test_function=test_func,
|
170
|
+
filter_set=self.filter_set,
|
171
|
+
)
|
172
|
+
fixtures = get_fixtures(test_func, request, given_kwargs)
|
173
|
+
# Changing the node id is required for better reporting - the method and path will appear there
|
174
|
+
node_id = request.node._nodeid
|
175
|
+
settings = getattr(wrapped_test, "_hypothesis_internal_use_settings", None)
|
176
|
+
|
177
|
+
def as_strategy_kwargs(_operation: APIOperation) -> dict[str, Any]:
|
178
|
+
override = overrides.for_operation(config=schema.config, operation=_operation)
|
179
|
+
|
180
|
+
return {location: entry for location, entry in override.items() if entry}
|
181
|
+
|
182
|
+
tests = list(
|
183
|
+
get_all_tests(
|
184
|
+
schema=schema,
|
185
|
+
test_func=test_func,
|
186
|
+
settings=settings,
|
187
|
+
modes=list(HypothesisTestMode),
|
188
|
+
as_strategy_kwargs=as_strategy_kwargs,
|
189
|
+
given_kwargs=given_kwargs,
|
190
|
+
)
|
191
|
+
)
|
192
|
+
if not tests:
|
193
|
+
fail_on_no_matches(node_id)
|
194
|
+
request.session.testscollected += len(tests)
|
195
|
+
suspend_capture_ctx = _get_capturemanager(request)
|
196
|
+
subtests = SubTests(request.node.ihook, suspend_capture_ctx, request)
|
197
|
+
for result in tests:
|
198
|
+
if isinstance(result, Ok):
|
199
|
+
operation, sub_test = result.ok()
|
200
|
+
subtests.item._nodeid = f"{node_id}[{operation.method.upper()} {operation.path}]"
|
201
|
+
run_subtest(operation, fixtures, sub_test, subtests)
|
202
|
+
else:
|
203
|
+
_schema_error(subtests, result.err(), node_id)
|
204
|
+
subtests.item._nodeid = node_id
|
205
|
+
|
206
|
+
wrapped_test = pytest.mark.usefixtures(self.fixture_name)(wrapped_test)
|
207
|
+
_copy_marks(test_func, wrapped_test)
|
208
|
+
|
209
|
+
# Needed to prevent a failure when settings are applied to the test function
|
210
|
+
wrapped_test.is_hypothesis_test = True # type: ignore
|
211
|
+
wrapped_test.hypothesis = HypothesisHandle(test_func, wrapped_test, given_kwargs) # type: ignore
|
212
|
+
|
213
|
+
return wrapped_test
|
214
|
+
|
215
|
+
return wrapper
|
216
|
+
|
217
|
+
def given(self, *args: GivenInput, **kwargs: GivenInput) -> Callable:
|
218
|
+
return given_proxy(*args, **kwargs)
|
219
|
+
|
220
|
+
|
221
|
+
def _copy_marks(source: Callable, target: Callable) -> None:
|
222
|
+
marks = getattr(source, "pytestmark", [])
|
223
|
+
# Pytest adds this attribute in `usefixtures`
|
224
|
+
target.pytestmark.extend(marks) # type: ignore
|
225
|
+
|
226
|
+
|
227
|
+
def _get_capturemanager(request: FixtureRequest) -> Generator | Type[nullcontext]:
|
228
|
+
capturemanager = request.node.config.pluginmanager.get_plugin("capturemanager")
|
229
|
+
if capturemanager is not None:
|
230
|
+
return capturemanager.global_and_fixture_disabled
|
231
|
+
return nullcontext
|
232
|
+
|
233
|
+
|
234
|
+
def run_subtest(operation: APIOperation, fixtures: dict[str, Any], sub_test: Callable, subtests: SubTests) -> None:
|
235
|
+
"""Run the given subtest with pytest fixtures."""
|
236
|
+
__tracebackhide__ = True
|
237
|
+
|
238
|
+
with subtests.test(label=operation.label):
|
239
|
+
sub_test(**fixtures)
|
240
|
+
|
241
|
+
|
242
|
+
SEPARATOR = "\n===================="
|
243
|
+
|
244
|
+
|
245
|
+
def _schema_error(subtests: SubTests, error: InvalidSchema, node_id: str) -> None:
|
246
|
+
"""Run a failing test, that will show the underlying problem."""
|
247
|
+
sub_test = error.as_failing_test_function()
|
248
|
+
kwargs = {"path": error.path}
|
249
|
+
if error.method:
|
250
|
+
kwargs["method"] = error.method.upper()
|
251
|
+
subtests.item._nodeid = _get_partial_node_name(node_id, **kwargs)
|
252
|
+
__tracebackhide__ = True
|
253
|
+
with subtests.test(**kwargs):
|
254
|
+
sub_test()
|
255
|
+
|
256
|
+
|
257
|
+
def _get_partial_node_name(node_id: str, **kwargs: Any) -> str:
|
258
|
+
"""Make a test node name for failing tests caused by schema errors."""
|
259
|
+
name = node_id
|
260
|
+
if "method" in kwargs:
|
261
|
+
name += f"[{kwargs['method']} {kwargs['path']}]"
|
262
|
+
else:
|
263
|
+
name += f"[{kwargs['path']}]"
|
264
|
+
return name
|
265
|
+
|
266
|
+
|
267
|
+
def get_schema(*, request: FixtureRequest, name: str, filter_set: FilterSet, test_function: Callable) -> BaseSchema:
|
268
|
+
"""Loads a schema from the fixture."""
|
269
|
+
schema = request.getfixturevalue(name)
|
270
|
+
if not isinstance(schema, BaseSchema):
|
271
|
+
raise ValueError(f"The given schema must be an instance of BaseSchema, got: {type(schema)}")
|
272
|
+
|
273
|
+
return schema.clone(filter_set=filter_set, test_function=test_function)
|
274
|
+
|
275
|
+
|
276
|
+
def get_fixtures(func: Callable, request: FixtureRequest, given_kwargs: dict[str, Any]) -> dict[str, Any]:
|
277
|
+
"""Load fixtures, needed for the test function."""
|
278
|
+
sig = signature(func)
|
279
|
+
return {
|
280
|
+
name: request.getfixturevalue(name) for name in sig.parameters if name != "case" and name not in given_kwargs
|
281
|
+
}
|
@@ -0,0 +1,36 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING
|
4
|
+
|
5
|
+
if TYPE_CHECKING:
|
6
|
+
from schemathesis.pytest.lazy import LazySchema
|
7
|
+
|
8
|
+
|
9
|
+
def from_fixture(name: str) -> LazySchema:
|
10
|
+
"""Create a lazy schema loader that resolves a pytest fixture at test runtime.
|
11
|
+
|
12
|
+
Args:
|
13
|
+
name: Name of the pytest fixture that returns a schema object
|
14
|
+
|
15
|
+
Example:
|
16
|
+
```python
|
17
|
+
import pytest
|
18
|
+
import schemathesis
|
19
|
+
|
20
|
+
@pytest.fixture
|
21
|
+
def api_schema():
|
22
|
+
return schemathesis.openapi.from_url("https://api.example.com/openapi.json")
|
23
|
+
|
24
|
+
# Create lazy schema from fixture
|
25
|
+
schema = schemathesis.pytest.from_fixture("api_schema")
|
26
|
+
|
27
|
+
# Use with parametrize to generate tests
|
28
|
+
@schema.parametrize()
|
29
|
+
def test_api(case):
|
30
|
+
case.call_and_validate()
|
31
|
+
```
|
32
|
+
|
33
|
+
"""
|
34
|
+
from schemathesis.pytest.lazy import LazySchema
|
35
|
+
|
36
|
+
return LazySchema(name)
|