schemathesis 3.13.0__py3-none-any.whl → 4.4.2__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 +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1016
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -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 +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +683 -247
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +27 -0
- schemathesis/specs/graphql/scalars.py +86 -0
- schemathesis/specs/graphql/schemas.py +395 -123
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +578 -317
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +753 -74
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +117 -68
- schemathesis/specs/openapi/negative/mutations.py +294 -104
- schemathesis/specs/openapi/negative/utils.py +3 -6
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +648 -650
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +404 -69
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -41
- schemathesis/_hypothesis.py +0 -115
- schemathesis/cli/callbacks.py +0 -188
- schemathesis/cli/cassettes.py +0 -253
- schemathesis/cli/context.py +0 -36
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -51
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -508
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -79
- schemathesis/exceptions.py +0 -207
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -216
- schemathesis/failures.py +0 -131
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/lazy.py +0 -227
- schemathesis/models.py +0 -1041
- schemathesis/parameters.py +0 -88
- schemathesis/runner/__init__.py +0 -460
- schemathesis/runner/events.py +0 -240
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -755
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -189
- schemathesis/serializers.py +0 -233
- schemathesis/service/__init__.py +0 -3
- schemathesis/service/client.py +0 -46
- schemathesis/service/constants.py +0 -12
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -39
- schemathesis/service/models.py +0 -7
- schemathesis/service/serialization.py +0 -153
- schemathesis/service/worker.py +0 -40
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -302
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -413
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -349
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -436
- schemathesis-3.13.0.dist-info/METADATA +0 -202
- schemathesis-3.13.0.dist-info/RECORD +0 -91
- schemathesis-3.13.0.dist-info/entry_points.txt +0 -6
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
schemathesis/__init__.py
CHANGED
|
@@ -1,28 +1,56 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
from schemathesis import errors, graphql, openapi, pytest
|
|
4
|
+
from schemathesis.auths import AuthContext, AuthProvider, auth
|
|
5
|
+
from schemathesis.checks import CheckContext, CheckFunction, check
|
|
6
|
+
from schemathesis.config import SchemathesisConfig as Config
|
|
7
|
+
from schemathesis.core.deserialization import DeserializationContext, deserializer
|
|
8
|
+
from schemathesis.core.transport import Response
|
|
9
|
+
from schemathesis.core.version import SCHEMATHESIS_VERSION
|
|
10
|
+
from schemathesis.generation import GenerationMode, stateful
|
|
11
|
+
from schemathesis.generation.case import Case
|
|
12
|
+
from schemathesis.generation.metrics import MetricContext, MetricFunction, metric
|
|
13
|
+
from schemathesis.hooks import HookContext, hook
|
|
14
|
+
from schemathesis.schemas import APIOperation, BaseSchema
|
|
15
|
+
from schemathesis.transport import SerializationContext, serializer
|
|
4
16
|
|
|
5
|
-
|
|
17
|
+
__version__ = SCHEMATHESIS_VERSION
|
|
6
18
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
# Core data structures
|
|
22
|
+
"Case",
|
|
23
|
+
"Response",
|
|
24
|
+
"APIOperation",
|
|
25
|
+
"BaseSchema",
|
|
26
|
+
"Config",
|
|
27
|
+
"GenerationMode",
|
|
28
|
+
"stateful",
|
|
29
|
+
# Public errors
|
|
30
|
+
"errors",
|
|
31
|
+
# Spec or usage specific namespaces
|
|
32
|
+
"openapi",
|
|
33
|
+
"graphql",
|
|
34
|
+
"pytest",
|
|
35
|
+
# Hooks
|
|
36
|
+
"hook",
|
|
37
|
+
"HookContext",
|
|
38
|
+
# Checks
|
|
39
|
+
"check",
|
|
40
|
+
"CheckContext",
|
|
41
|
+
"CheckFunction",
|
|
42
|
+
# Auth
|
|
43
|
+
"auth",
|
|
44
|
+
"AuthContext",
|
|
45
|
+
"AuthProvider",
|
|
46
|
+
# Targeted Property-based Testing
|
|
47
|
+
"metric",
|
|
48
|
+
"MetricContext",
|
|
49
|
+
"MetricFunction",
|
|
50
|
+
# Response deserialization
|
|
51
|
+
"deserializer",
|
|
52
|
+
"DeserializationContext",
|
|
53
|
+
# Serialization
|
|
54
|
+
"serializer",
|
|
55
|
+
"SerializationContext",
|
|
56
|
+
]
|
schemathesis/auths.py
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""Support for custom API authentication mechanisms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import (
|
|
9
|
+
TYPE_CHECKING,
|
|
10
|
+
Any,
|
|
11
|
+
Callable,
|
|
12
|
+
Generic,
|
|
13
|
+
Protocol,
|
|
14
|
+
TypeVar,
|
|
15
|
+
Union,
|
|
16
|
+
overload,
|
|
17
|
+
runtime_checkable,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from schemathesis.core.errors import AuthenticationError, IncorrectUsage
|
|
21
|
+
from schemathesis.core.marks import Mark
|
|
22
|
+
from schemathesis.filters import FilterSet, FilterValue, MatcherFunc, attach_filter_chain
|
|
23
|
+
from schemathesis.generation.case import Case
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
import requests.auth
|
|
27
|
+
|
|
28
|
+
from schemathesis.schemas import APIOperation
|
|
29
|
+
|
|
30
|
+
DEFAULT_REFRESH_INTERVAL = 300
|
|
31
|
+
AuthStorageMark = Mark["AuthStorage"](attr_name="auth_storage")
|
|
32
|
+
Auth = TypeVar("Auth")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class AuthContext:
|
|
37
|
+
"""Runtime context passed to authentication providers during token generation.
|
|
38
|
+
|
|
39
|
+
Provides access to the current API operation and application instance when
|
|
40
|
+
auth providers need operation-specific tokens or application state.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
```python
|
|
44
|
+
@schemathesis.auth()
|
|
45
|
+
class ContextAwareAuth:
|
|
46
|
+
def get(self, case, context):
|
|
47
|
+
# Access operation details
|
|
48
|
+
if "/admin/" in context.operation.path:
|
|
49
|
+
return self.get_admin_token()
|
|
50
|
+
else:
|
|
51
|
+
return self.get_user_token()
|
|
52
|
+
|
|
53
|
+
def set(self, case, data, context):
|
|
54
|
+
case.headers = {"Authorization": f"Bearer {data}"}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
operation: APIOperation
|
|
60
|
+
"""API operation currently being processed for authentication."""
|
|
61
|
+
app: Any | None
|
|
62
|
+
"""Python application instance (ASGI/WSGI app) when using app integration, `None` otherwise."""
|
|
63
|
+
|
|
64
|
+
__slots__ = ("operation", "app")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
CacheKeyFunction = Callable[["Case", "AuthContext"], Union[str, int]]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@runtime_checkable
|
|
71
|
+
class AuthProvider(Generic[Auth], Protocol):
|
|
72
|
+
"""Protocol for implementing custom authentication in API tests."""
|
|
73
|
+
|
|
74
|
+
def get(self, case: Case, ctx: AuthContext) -> Auth | None:
|
|
75
|
+
"""Obtain authentication data for the test case.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
case: Generated test case requiring authentication.
|
|
79
|
+
ctx: Authentication state and configuration.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Authentication data (e.g., token, credentials) or `None`.
|
|
83
|
+
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def set(self, case: Case, data: Auth, ctx: AuthContext) -> None:
|
|
87
|
+
"""Apply authentication data to the test case.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
case: Test case to modify.
|
|
91
|
+
data: Authentication data from the `get` method.
|
|
92
|
+
ctx: Authentication state and configuration.
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class CacheEntry(Generic[Auth]):
|
|
99
|
+
"""Cached auth data."""
|
|
100
|
+
|
|
101
|
+
data: Auth
|
|
102
|
+
expires: float
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class RequestsAuth(Generic[Auth]):
|
|
107
|
+
"""Provider that sets auth data via `requests` auth instance."""
|
|
108
|
+
|
|
109
|
+
auth: requests.auth.AuthBase
|
|
110
|
+
|
|
111
|
+
def get(self, _: Case, __: AuthContext) -> Auth | None:
|
|
112
|
+
return self.auth # type: ignore[return-value]
|
|
113
|
+
|
|
114
|
+
def set(self, case: Case, _: Auth, __: AuthContext) -> None:
|
|
115
|
+
case._auth = self.auth
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class CachingAuthProvider(Generic[Auth]):
|
|
120
|
+
"""Caches the underlying auth provider."""
|
|
121
|
+
|
|
122
|
+
provider: AuthProvider
|
|
123
|
+
refresh_interval: int = DEFAULT_REFRESH_INTERVAL
|
|
124
|
+
cache_entry: CacheEntry[Auth] | None = None
|
|
125
|
+
# The timer exists here to simplify testing
|
|
126
|
+
timer: Callable[[], float] = time.monotonic
|
|
127
|
+
_refresh_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
128
|
+
|
|
129
|
+
def get(self, case: Case, context: AuthContext) -> Auth | None:
|
|
130
|
+
"""Get cached auth value."""
|
|
131
|
+
__tracebackhide__ = True
|
|
132
|
+
cache_entry = self._get_cache_entry(case, context)
|
|
133
|
+
if cache_entry is None or self.timer() >= cache_entry.expires:
|
|
134
|
+
with self._refresh_lock:
|
|
135
|
+
cache_entry = self._get_cache_entry(case, context)
|
|
136
|
+
if not (cache_entry is None or self.timer() >= cache_entry.expires):
|
|
137
|
+
# Another thread updated the cache
|
|
138
|
+
return cache_entry.data
|
|
139
|
+
# We know that optional auth is possible only inside a higher-level wrapper
|
|
140
|
+
try:
|
|
141
|
+
data: Auth = self.provider.get(case, context) # type: ignore[assignment]
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
provider_name = self.provider.__class__.__name__
|
|
144
|
+
raise AuthenticationError(provider_name, "get", str(exc)) from exc
|
|
145
|
+
self._set_cache_entry(data, case, context)
|
|
146
|
+
return data
|
|
147
|
+
return cache_entry.data
|
|
148
|
+
|
|
149
|
+
def _get_cache_entry(self, case: Case, context: AuthContext) -> CacheEntry[Auth] | None:
|
|
150
|
+
return self.cache_entry
|
|
151
|
+
|
|
152
|
+
def _set_cache_entry(self, data: Auth, case: Case, context: AuthContext) -> None:
|
|
153
|
+
self.cache_entry = CacheEntry(data=data, expires=self.timer() + self.refresh_interval)
|
|
154
|
+
|
|
155
|
+
def set(self, case: Case, data: Auth, context: AuthContext) -> None:
|
|
156
|
+
"""Set auth data on the `Case` instance.
|
|
157
|
+
|
|
158
|
+
This implementation delegates this to the actual provider.
|
|
159
|
+
"""
|
|
160
|
+
self.provider.set(case, data, context)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _noop_key_function(case: Case, context: AuthContext) -> str:
|
|
164
|
+
# Never used
|
|
165
|
+
raise NotImplementedError
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass
|
|
169
|
+
class KeyedCachingAuthProvider(CachingAuthProvider[Auth]):
|
|
170
|
+
cache_by_key: CacheKeyFunction = _noop_key_function
|
|
171
|
+
cache_entries: dict[str | int, CacheEntry[Auth] | None] = field(default_factory=dict)
|
|
172
|
+
|
|
173
|
+
def _get_cache_entry(self, case: Case, context: AuthContext) -> CacheEntry[Auth] | None:
|
|
174
|
+
key = self.cache_by_key(case, context)
|
|
175
|
+
return self.cache_entries.get(key)
|
|
176
|
+
|
|
177
|
+
def _set_cache_entry(self, data: Auth, case: Case, context: AuthContext) -> None:
|
|
178
|
+
key = self.cache_by_key(case, context)
|
|
179
|
+
self.cache_entries[key] = CacheEntry(data=data, expires=self.timer() + self.refresh_interval)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class FilterableRegisterAuth(Protocol):
|
|
183
|
+
"""Protocol that adds filters to the return value of `register`."""
|
|
184
|
+
|
|
185
|
+
def __call__(self, provider_class: type[AuthProvider]) -> type[AuthProvider]: ...
|
|
186
|
+
|
|
187
|
+
def apply_to(
|
|
188
|
+
self,
|
|
189
|
+
func: MatcherFunc | None = None,
|
|
190
|
+
*,
|
|
191
|
+
name: FilterValue | None = None,
|
|
192
|
+
name_regex: str | None = None,
|
|
193
|
+
method: FilterValue | None = None,
|
|
194
|
+
method_regex: str | None = None,
|
|
195
|
+
path: FilterValue | None = None,
|
|
196
|
+
path_regex: str | None = None,
|
|
197
|
+
) -> FilterableRegisterAuth: ...
|
|
198
|
+
|
|
199
|
+
def skip_for(
|
|
200
|
+
self,
|
|
201
|
+
func: MatcherFunc | None = None,
|
|
202
|
+
*,
|
|
203
|
+
name: FilterValue | None = None,
|
|
204
|
+
name_regex: str | None = None,
|
|
205
|
+
method: FilterValue | None = None,
|
|
206
|
+
method_regex: str | None = None,
|
|
207
|
+
path: FilterValue | None = None,
|
|
208
|
+
path_regex: str | None = None,
|
|
209
|
+
) -> FilterableRegisterAuth: ...
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class FilterableApplyAuth(Protocol):
|
|
213
|
+
"""Protocol that adds filters to the return value of `apply`."""
|
|
214
|
+
|
|
215
|
+
def __call__(self, test: Callable) -> Callable: ...
|
|
216
|
+
|
|
217
|
+
def apply_to(
|
|
218
|
+
self,
|
|
219
|
+
func: MatcherFunc | None = None,
|
|
220
|
+
*,
|
|
221
|
+
name: FilterValue | None = None,
|
|
222
|
+
name_regex: str | None = None,
|
|
223
|
+
method: FilterValue | None = None,
|
|
224
|
+
method_regex: str | None = None,
|
|
225
|
+
path: FilterValue | None = None,
|
|
226
|
+
path_regex: str | None = None,
|
|
227
|
+
) -> FilterableApplyAuth: ...
|
|
228
|
+
|
|
229
|
+
def skip_for(
|
|
230
|
+
self,
|
|
231
|
+
func: MatcherFunc | None = None,
|
|
232
|
+
*,
|
|
233
|
+
name: FilterValue | None = None,
|
|
234
|
+
name_regex: str | None = None,
|
|
235
|
+
method: FilterValue | None = None,
|
|
236
|
+
method_regex: str | None = None,
|
|
237
|
+
path: FilterValue | None = None,
|
|
238
|
+
path_regex: str | None = None,
|
|
239
|
+
) -> FilterableApplyAuth: ...
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class FilterableRequestsAuth(Protocol):
|
|
243
|
+
"""Protocol that adds filters to the return value of `set_from_requests`."""
|
|
244
|
+
|
|
245
|
+
def apply_to(
|
|
246
|
+
self,
|
|
247
|
+
func: MatcherFunc | None = None,
|
|
248
|
+
*,
|
|
249
|
+
name: FilterValue | None = None,
|
|
250
|
+
name_regex: str | None = None,
|
|
251
|
+
method: FilterValue | None = None,
|
|
252
|
+
method_regex: str | None = None,
|
|
253
|
+
path: FilterValue | None = None,
|
|
254
|
+
path_regex: str | None = None,
|
|
255
|
+
) -> FilterableRequestsAuth: ...
|
|
256
|
+
|
|
257
|
+
def skip_for(
|
|
258
|
+
self,
|
|
259
|
+
func: MatcherFunc | None = None,
|
|
260
|
+
*,
|
|
261
|
+
name: FilterValue | None = None,
|
|
262
|
+
name_regex: str | None = None,
|
|
263
|
+
method: FilterValue | None = None,
|
|
264
|
+
method_regex: str | None = None,
|
|
265
|
+
path: FilterValue | None = None,
|
|
266
|
+
path_regex: str | None = None,
|
|
267
|
+
) -> FilterableRequestsAuth: ...
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass
|
|
271
|
+
class SelectiveAuthProvider(Generic[Auth]):
|
|
272
|
+
"""Applies auth depending on the configured filters."""
|
|
273
|
+
|
|
274
|
+
provider: AuthProvider
|
|
275
|
+
filter_set: FilterSet
|
|
276
|
+
|
|
277
|
+
def get(self, case: Case, context: AuthContext) -> Auth | None:
|
|
278
|
+
__tracebackhide__ = True
|
|
279
|
+
if self.filter_set.match(context):
|
|
280
|
+
try:
|
|
281
|
+
return self.provider.get(case, context)
|
|
282
|
+
except AuthenticationError:
|
|
283
|
+
# Already wrapped, re-raise as-is
|
|
284
|
+
raise
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
# Need to unwrap to get the actual provider class name
|
|
287
|
+
provider = self.provider
|
|
288
|
+
# Unwrap caching providers
|
|
289
|
+
while isinstance(provider, (CachingAuthProvider, KeyedCachingAuthProvider)):
|
|
290
|
+
provider = provider.provider
|
|
291
|
+
provider_name = provider.__class__.__name__
|
|
292
|
+
raise AuthenticationError(provider_name, "get", str(exc)) from exc
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
def set(self, case: Case, data: Auth, context: AuthContext) -> None:
|
|
296
|
+
__tracebackhide__ = True
|
|
297
|
+
self.provider.set(case, data, context)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass
|
|
301
|
+
class AuthStorage(Generic[Auth]):
|
|
302
|
+
"""Store and manage API authentication."""
|
|
303
|
+
|
|
304
|
+
providers: list[AuthProvider] = field(default_factory=list)
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def is_defined(self) -> bool:
|
|
308
|
+
"""Whether there is an auth provider set."""
|
|
309
|
+
return bool(self.providers)
|
|
310
|
+
|
|
311
|
+
@overload
|
|
312
|
+
def __call__(
|
|
313
|
+
self,
|
|
314
|
+
*,
|
|
315
|
+
refresh_interval: int | None = DEFAULT_REFRESH_INTERVAL,
|
|
316
|
+
cache_by_key: CacheKeyFunction | None = None,
|
|
317
|
+
) -> FilterableRegisterAuth: ...
|
|
318
|
+
|
|
319
|
+
@overload
|
|
320
|
+
def __call__(
|
|
321
|
+
self,
|
|
322
|
+
provider_class: type[AuthProvider],
|
|
323
|
+
*,
|
|
324
|
+
refresh_interval: int | None = DEFAULT_REFRESH_INTERVAL,
|
|
325
|
+
cache_by_key: CacheKeyFunction | None = None,
|
|
326
|
+
) -> FilterableApplyAuth: ...
|
|
327
|
+
|
|
328
|
+
def __call__(
|
|
329
|
+
self,
|
|
330
|
+
provider_class: type[AuthProvider] | None = None,
|
|
331
|
+
*,
|
|
332
|
+
refresh_interval: int | None = DEFAULT_REFRESH_INTERVAL,
|
|
333
|
+
cache_by_key: CacheKeyFunction | None = None,
|
|
334
|
+
) -> FilterableRegisterAuth | FilterableApplyAuth:
|
|
335
|
+
if provider_class is not None:
|
|
336
|
+
return self.apply(provider_class, refresh_interval=refresh_interval, cache_by_key=cache_by_key)
|
|
337
|
+
return self.auth(refresh_interval=refresh_interval, cache_by_key=cache_by_key)
|
|
338
|
+
|
|
339
|
+
def set_from_requests(self, auth: requests.auth.AuthBase) -> FilterableRequestsAuth:
|
|
340
|
+
"""Use `requests` auth instance as an auth provider."""
|
|
341
|
+
filter_set = FilterSet()
|
|
342
|
+
self.providers.append(SelectiveAuthProvider(provider=RequestsAuth(auth), filter_set=filter_set))
|
|
343
|
+
|
|
344
|
+
class _FilterableRequestsAuth: ...
|
|
345
|
+
|
|
346
|
+
attach_filter_chain(_FilterableRequestsAuth, "apply_to", filter_set.include)
|
|
347
|
+
attach_filter_chain(_FilterableRequestsAuth, "skip_for", filter_set.exclude)
|
|
348
|
+
|
|
349
|
+
return _FilterableRequestsAuth # type: ignore[return-value]
|
|
350
|
+
|
|
351
|
+
def _set_provider(
|
|
352
|
+
self,
|
|
353
|
+
*,
|
|
354
|
+
provider_class: type[AuthProvider],
|
|
355
|
+
refresh_interval: int | None = DEFAULT_REFRESH_INTERVAL,
|
|
356
|
+
cache_by_key: CacheKeyFunction | None = None,
|
|
357
|
+
filter_set: FilterSet,
|
|
358
|
+
) -> None:
|
|
359
|
+
if not issubclass(provider_class, AuthProvider):
|
|
360
|
+
raise TypeError(
|
|
361
|
+
f"`{provider_class.__name__}` does not implement the `AuthProvider` protocol. "
|
|
362
|
+
f"Auth providers must have `get` and `set` methods. "
|
|
363
|
+
f"See `schemathesis.AuthProvider` documentation for examples."
|
|
364
|
+
)
|
|
365
|
+
provider: AuthProvider
|
|
366
|
+
# Apply caching if desired
|
|
367
|
+
instance = provider_class()
|
|
368
|
+
if refresh_interval is not None:
|
|
369
|
+
if cache_by_key is None:
|
|
370
|
+
provider = CachingAuthProvider(instance, refresh_interval=refresh_interval)
|
|
371
|
+
else:
|
|
372
|
+
provider = KeyedCachingAuthProvider(
|
|
373
|
+
instance, refresh_interval=refresh_interval, cache_by_key=cache_by_key
|
|
374
|
+
)
|
|
375
|
+
else:
|
|
376
|
+
provider = instance
|
|
377
|
+
# Store filters if any
|
|
378
|
+
if not filter_set.is_empty():
|
|
379
|
+
provider = SelectiveAuthProvider(provider, filter_set)
|
|
380
|
+
self.providers.append(provider)
|
|
381
|
+
|
|
382
|
+
def auth(
|
|
383
|
+
self,
|
|
384
|
+
*,
|
|
385
|
+
refresh_interval: int | None = DEFAULT_REFRESH_INTERVAL,
|
|
386
|
+
cache_by_key: CacheKeyFunction | None = None,
|
|
387
|
+
) -> FilterableRegisterAuth:
|
|
388
|
+
filter_set = FilterSet()
|
|
389
|
+
|
|
390
|
+
def wrapper(provider_class: type[AuthProvider]) -> type[AuthProvider]:
|
|
391
|
+
self._set_provider(
|
|
392
|
+
provider_class=provider_class,
|
|
393
|
+
refresh_interval=refresh_interval,
|
|
394
|
+
filter_set=filter_set,
|
|
395
|
+
cache_by_key=cache_by_key,
|
|
396
|
+
)
|
|
397
|
+
return provider_class
|
|
398
|
+
|
|
399
|
+
attach_filter_chain(wrapper, "apply_to", filter_set.include)
|
|
400
|
+
attach_filter_chain(wrapper, "skip_for", filter_set.exclude)
|
|
401
|
+
|
|
402
|
+
return wrapper # type: ignore[return-value]
|
|
403
|
+
|
|
404
|
+
def unregister(self) -> None:
|
|
405
|
+
"""Unregister the currently registered auth provider.
|
|
406
|
+
|
|
407
|
+
No-op if there is no auth provider registered.
|
|
408
|
+
"""
|
|
409
|
+
self.providers = []
|
|
410
|
+
|
|
411
|
+
def apply(
|
|
412
|
+
self,
|
|
413
|
+
provider_class: type[AuthProvider],
|
|
414
|
+
*,
|
|
415
|
+
refresh_interval: int | None = DEFAULT_REFRESH_INTERVAL,
|
|
416
|
+
cache_by_key: CacheKeyFunction | None = None,
|
|
417
|
+
) -> FilterableApplyAuth:
|
|
418
|
+
filter_set = FilterSet()
|
|
419
|
+
|
|
420
|
+
def wrapper(test: Callable) -> Callable:
|
|
421
|
+
if AuthStorageMark.is_set(test):
|
|
422
|
+
raise IncorrectUsage(f"`{test.__name__}` has already been decorated with `apply`.")
|
|
423
|
+
auth_storage = self.__class__()
|
|
424
|
+
AuthStorageMark.set(test, auth_storage)
|
|
425
|
+
auth_storage._set_provider(
|
|
426
|
+
provider_class=provider_class,
|
|
427
|
+
refresh_interval=refresh_interval,
|
|
428
|
+
filter_set=filter_set,
|
|
429
|
+
cache_by_key=cache_by_key,
|
|
430
|
+
)
|
|
431
|
+
return test
|
|
432
|
+
|
|
433
|
+
attach_filter_chain(wrapper, "apply_to", filter_set.include)
|
|
434
|
+
attach_filter_chain(wrapper, "skip_for", filter_set.exclude)
|
|
435
|
+
|
|
436
|
+
return wrapper # type: ignore[return-value]
|
|
437
|
+
|
|
438
|
+
def set(self, case: Case, context: AuthContext) -> None:
|
|
439
|
+
"""Set authentication data on a generated test case."""
|
|
440
|
+
__tracebackhide__ = True
|
|
441
|
+
if not self.is_defined:
|
|
442
|
+
raise IncorrectUsage("No auth provider is defined.")
|
|
443
|
+
for provider in self.providers:
|
|
444
|
+
data: Auth | None = provider.get(case, context)
|
|
445
|
+
if data is not None:
|
|
446
|
+
provider.set(case, data, context)
|
|
447
|
+
case._has_explicit_auth = True
|
|
448
|
+
break
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def set_on_case(case: Case, context: AuthContext, auth_storage: AuthStorage | None) -> None:
|
|
452
|
+
"""Set authentication data on this case.
|
|
453
|
+
|
|
454
|
+
If there is no auth defined, then this function is no-op.
|
|
455
|
+
"""
|
|
456
|
+
__tracebackhide__ = True
|
|
457
|
+
if auth_storage is not None:
|
|
458
|
+
auth_storage.set(case, context)
|
|
459
|
+
elif case.operation.schema.auth.is_defined:
|
|
460
|
+
case.operation.schema.auth.set(case, context)
|
|
461
|
+
elif GLOBAL_AUTH_STORAGE.is_defined:
|
|
462
|
+
GLOBAL_AUTH_STORAGE.set(case, context)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# Global auth API
|
|
466
|
+
GLOBAL_AUTH_STORAGE: AuthStorage = AuthStorage()
|
|
467
|
+
unregister = GLOBAL_AUTH_STORAGE.unregister
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def auth(
|
|
471
|
+
*,
|
|
472
|
+
refresh_interval: int | None = DEFAULT_REFRESH_INTERVAL,
|
|
473
|
+
cache_by_key: CacheKeyFunction | None = None,
|
|
474
|
+
) -> FilterableRegisterAuth:
|
|
475
|
+
"""Register a dynamic authentication provider for APIs with expiring tokens.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
refresh_interval: Seconds between token refreshes. Default is `300`. Use `None` to disable caching
|
|
479
|
+
cache_by_key: Function to generate cache keys for different auth contexts (e.g., OAuth scopes)
|
|
480
|
+
|
|
481
|
+
Example:
|
|
482
|
+
```python
|
|
483
|
+
import schemathesis
|
|
484
|
+
import requests
|
|
485
|
+
|
|
486
|
+
@schemathesis.auth()
|
|
487
|
+
class TokenAuth:
|
|
488
|
+
def get(self, case, context):
|
|
489
|
+
\"\"\"Fetch fresh authentication token\"\"\"
|
|
490
|
+
response = requests.post(
|
|
491
|
+
"http://localhost:8000/auth/token",
|
|
492
|
+
json={"username": "demo", "password": "test"}
|
|
493
|
+
)
|
|
494
|
+
return response.json()["access_token"]
|
|
495
|
+
|
|
496
|
+
def set(self, case, data, context):
|
|
497
|
+
\"\"\"Apply token to test case headers\"\"\"
|
|
498
|
+
case.headers = case.headers or {}
|
|
499
|
+
case.headers["Authorization"] = f"Bearer {data}"
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
"""
|
|
503
|
+
return GLOBAL_AUTH_STORAGE.auth(refresh_interval=refresh_interval, cache_by_key=cache_by_key)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
auth.__dict__ = GLOBAL_AUTH_STORAGE.auth.__dict__
|
|
507
|
+
auth.set_from_requests = GLOBAL_AUTH_STORAGE.set_from_requests # type: ignore[attr-defined]
|