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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from hypothesis import settings
|
|
9
|
+
from hypothesis import strategies as st
|
|
10
|
+
|
|
11
|
+
SCHEMATHESIS_BENCHMARK_SEED = os.environ.get("SCHEMATHESIS_BENCHMARK_SEED")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@lru_cache
|
|
15
|
+
def default_settings() -> settings:
|
|
16
|
+
from hypothesis import HealthCheck, Phase, Verbosity, settings
|
|
17
|
+
|
|
18
|
+
return settings(
|
|
19
|
+
database=None,
|
|
20
|
+
max_examples=1,
|
|
21
|
+
deadline=None,
|
|
22
|
+
verbosity=Verbosity.quiet,
|
|
23
|
+
phases=(Phase.generate,),
|
|
24
|
+
suppress_health_check=list(HealthCheck),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
T = TypeVar("T")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def generate_one(strategy: st.SearchStrategy[T], suppress_health_check: list | None = None) -> T: # type: ignore[type-var]
|
|
32
|
+
examples: list[T] = []
|
|
33
|
+
add_single_example(strategy, examples, suppress_health_check)
|
|
34
|
+
return examples[0]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def add_single_example(
|
|
38
|
+
strategy: st.SearchStrategy[T], examples: list[T], suppress_health_check: list | None = None
|
|
39
|
+
) -> None:
|
|
40
|
+
from hypothesis import given, seed, settings
|
|
41
|
+
|
|
42
|
+
applied_settings = default_settings()
|
|
43
|
+
if suppress_health_check is not None:
|
|
44
|
+
applied_settings = settings(applied_settings, suppress_health_check=suppress_health_check)
|
|
45
|
+
|
|
46
|
+
@given(strategy) # type: ignore[misc]
|
|
47
|
+
@applied_settings # type: ignore[misc]
|
|
48
|
+
def example_generating_inner_function(ex: T) -> None:
|
|
49
|
+
examples.append(ex)
|
|
50
|
+
|
|
51
|
+
example_generating_inner_function._hypothesis_internal_database_key = b""
|
|
52
|
+
|
|
53
|
+
if SCHEMATHESIS_BENCHMARK_SEED is not None:
|
|
54
|
+
example_generating_inner_function = seed(SCHEMATHESIS_BENCHMARK_SEED)(example_generating_inner_function)
|
|
55
|
+
|
|
56
|
+
example_generating_inner_function()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Integrating `hypothesis.given` into Schemathesis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from inspect import getfullargspec
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, NoReturn, Union
|
|
7
|
+
|
|
8
|
+
from schemathesis.core.errors import IncorrectUsage
|
|
9
|
+
from schemathesis.core.marks import Mark
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from hypothesis.strategies import SearchStrategy
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = ["is_given_applied", "given_proxy", "merge_given_args", "GivenInput", "GivenArgsMark", "GivenKwargsMark"]
|
|
16
|
+
|
|
17
|
+
EllipsisType = type(...)
|
|
18
|
+
GivenInput = Union["SearchStrategy", EllipsisType] # type: ignore[valid-type]
|
|
19
|
+
|
|
20
|
+
GivenArgsMark = Mark[tuple](attr_name="given_args", default=())
|
|
21
|
+
GivenKwargsMark = Mark[dict[str, Any]](attr_name="given_kwargs", default=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_given_applied(func: Callable) -> bool:
|
|
25
|
+
return GivenArgsMark.is_set(func) or GivenKwargsMark.is_set(func)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def given_proxy(*args: GivenInput, **kwargs: GivenInput) -> Callable[[Callable], Callable]:
|
|
29
|
+
"""Proxy Hypothesis strategies to ``hypothesis.given``."""
|
|
30
|
+
|
|
31
|
+
def wrapper(func: Callable) -> Callable:
|
|
32
|
+
if is_given_applied(func):
|
|
33
|
+
|
|
34
|
+
def wrapped_test(*_: Any, **__: Any) -> NoReturn:
|
|
35
|
+
raise IncorrectUsage(
|
|
36
|
+
f"You have applied `given` to the `{func.__name__}` test more than once, which "
|
|
37
|
+
"overrides the previous decorator. You need to pass all arguments to the same `given` call."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return wrapped_test
|
|
41
|
+
|
|
42
|
+
GivenArgsMark.set(func, args)
|
|
43
|
+
GivenKwargsMark.set(func, kwargs)
|
|
44
|
+
return func
|
|
45
|
+
|
|
46
|
+
return wrapper
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def merge_given_args(func: Callable, args: tuple, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
50
|
+
"""Merge positional arguments to ``@schema.given`` into a dictionary with keyword arguments.
|
|
51
|
+
|
|
52
|
+
Kwargs are modified inplace.
|
|
53
|
+
"""
|
|
54
|
+
if args:
|
|
55
|
+
argspec = getfullargspec(func)
|
|
56
|
+
for name, strategy in zip(reversed([arg for arg in argspec.args if arg != "case"]), reversed(args)):
|
|
57
|
+
kwargs[name] = strategy
|
|
58
|
+
return kwargs
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_given_args(func: Callable, args: tuple, kwargs: dict[str, Any]) -> Callable | None:
|
|
62
|
+
from hypothesis.core import is_invalid_test
|
|
63
|
+
from hypothesis.internal.reflection import get_signature
|
|
64
|
+
|
|
65
|
+
signature = get_signature(func)
|
|
66
|
+
return is_invalid_test(func, signature, args, kwargs)
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Generator
|
|
7
|
+
|
|
8
|
+
from hypothesis import HealthCheck
|
|
9
|
+
from hypothesis.errors import FailedHealthCheck, InvalidArgument, Unsatisfiable
|
|
10
|
+
from hypothesis.reporting import with_reporter
|
|
11
|
+
|
|
12
|
+
from schemathesis.config import OutputConfig
|
|
13
|
+
from schemathesis.core.jsonschema.types import JsonSchema
|
|
14
|
+
from schemathesis.core.output import truncate_json
|
|
15
|
+
from schemathesis.core.parameters import ParameterLocation
|
|
16
|
+
from schemathesis.generation.hypothesis.examples import generate_one
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from schemathesis.schemas import APIOperation
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ignore(_: str) -> None:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@contextmanager
|
|
27
|
+
def ignore_hypothesis_output() -> Generator:
|
|
28
|
+
with with_reporter(ignore):
|
|
29
|
+
yield
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
UNSATISFIABILITY_CAUSE = """ - Type mismatch (e.g., enum with strings but type: integer)
|
|
33
|
+
- Contradictory constraints (e.g., minimum > maximum)
|
|
34
|
+
- Regex that's too complex to generate values for"""
|
|
35
|
+
|
|
36
|
+
GENERIC_UNSATISFIABLE_MESSAGE = f"""Cannot generate test data for this operation
|
|
37
|
+
|
|
38
|
+
Unable to identify the specific parameter. Common causes:
|
|
39
|
+
{UNSATISFIABILITY_CAUSE}"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class UnsatisfiableParameter:
|
|
44
|
+
location: ParameterLocation
|
|
45
|
+
name: str
|
|
46
|
+
schema: JsonSchema
|
|
47
|
+
|
|
48
|
+
__slots__ = ("location", "name", "schema")
|
|
49
|
+
|
|
50
|
+
def get_error_message(self, config: OutputConfig) -> str:
|
|
51
|
+
formatted_schema = truncate_json(self.schema, config=config)
|
|
52
|
+
|
|
53
|
+
if self.location == ParameterLocation.BODY:
|
|
54
|
+
# For body, name is the media type
|
|
55
|
+
location = f"request body ({self.name})"
|
|
56
|
+
else:
|
|
57
|
+
location = f"{self.location.value} parameter '{self.name}'"
|
|
58
|
+
|
|
59
|
+
return f"""Cannot generate test data for {location}
|
|
60
|
+
Schema:
|
|
61
|
+
|
|
62
|
+
{formatted_schema}
|
|
63
|
+
|
|
64
|
+
This usually means:
|
|
65
|
+
{UNSATISFIABILITY_CAUSE}"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def find_unsatisfiable_parameter(operation: APIOperation) -> UnsatisfiableParameter | None:
|
|
69
|
+
from hypothesis_jsonschema import from_schema
|
|
70
|
+
|
|
71
|
+
for location, container in (
|
|
72
|
+
(ParameterLocation.QUERY, operation.query),
|
|
73
|
+
(ParameterLocation.PATH, operation.path_parameters),
|
|
74
|
+
(ParameterLocation.HEADER, operation.headers),
|
|
75
|
+
(ParameterLocation.COOKIE, operation.cookies),
|
|
76
|
+
(ParameterLocation.BODY, operation.body),
|
|
77
|
+
):
|
|
78
|
+
for parameter in container:
|
|
79
|
+
try:
|
|
80
|
+
generate_one(from_schema(parameter.optimized_schema))
|
|
81
|
+
except Unsatisfiable:
|
|
82
|
+
if location == ParameterLocation.BODY:
|
|
83
|
+
name = parameter.media_type
|
|
84
|
+
else:
|
|
85
|
+
name = parameter.name
|
|
86
|
+
schema = unbundle_schema_refs(parameter.optimized_schema, parameter.name_to_uri)
|
|
87
|
+
return UnsatisfiableParameter(location=location, name=name, schema=schema)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def unbundle_schema_refs(schema: JsonSchema | list[JsonSchema], name_to_uri: dict[str, str]) -> JsonSchema:
|
|
92
|
+
if isinstance(schema, dict):
|
|
93
|
+
result: dict[str, Any] = {}
|
|
94
|
+
for key, value in schema.items():
|
|
95
|
+
if key == "$ref" and isinstance(value, str) and value.startswith("#/x-bundled/"):
|
|
96
|
+
# Extract bundled name (e.g., "schema1" from "#/x-bundled/schema1")
|
|
97
|
+
bundled_name = value.split("/")[-1]
|
|
98
|
+
if bundled_name in name_to_uri:
|
|
99
|
+
original_uri = name_to_uri[bundled_name]
|
|
100
|
+
# Extract fragment after # (e.g., "#/components/schemas/ObjectType")
|
|
101
|
+
if "#" in original_uri:
|
|
102
|
+
result[key] = "#" + original_uri.split("#", 1)[1]
|
|
103
|
+
else:
|
|
104
|
+
# Fallback if no fragment
|
|
105
|
+
result[key] = value
|
|
106
|
+
else:
|
|
107
|
+
result[key] = value
|
|
108
|
+
elif key == "x-bundled" and isinstance(value, dict):
|
|
109
|
+
# Replace x-bundled with proper components/schemas structure
|
|
110
|
+
components: dict[str, dict[str, Any]] = {"schemas": {}}
|
|
111
|
+
for bundled_name, bundled_schema in value.items():
|
|
112
|
+
if bundled_name in name_to_uri:
|
|
113
|
+
original_uri = name_to_uri[bundled_name]
|
|
114
|
+
# Extract schema name (e.g., "ObjectType" from "...#/components/schemas/ObjectType")
|
|
115
|
+
if "#/components/schemas/" in original_uri:
|
|
116
|
+
schema_name = original_uri.split("#/components/schemas/")[1]
|
|
117
|
+
components["schemas"][schema_name] = unbundle_schema_refs(bundled_schema, name_to_uri)
|
|
118
|
+
else:
|
|
119
|
+
# Fallback: keep bundled name if URI doesn't match expected pattern
|
|
120
|
+
components["schemas"][bundled_name] = unbundle_schema_refs(bundled_schema, name_to_uri)
|
|
121
|
+
else:
|
|
122
|
+
components["schemas"][bundled_name] = unbundle_schema_refs(bundled_schema, name_to_uri)
|
|
123
|
+
result["components"] = components
|
|
124
|
+
elif isinstance(value, (dict, list)):
|
|
125
|
+
# Recursively process all other values
|
|
126
|
+
result[key] = unbundle_schema_refs(value, name_to_uri)
|
|
127
|
+
else:
|
|
128
|
+
result[key] = value
|
|
129
|
+
return result
|
|
130
|
+
elif isinstance(schema, list):
|
|
131
|
+
return [unbundle_schema_refs(item, name_to_uri) for item in schema] # type: ignore[return-value]
|
|
132
|
+
return schema
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_unsatisfiable_error(operation: APIOperation, *, with_tip: bool) -> Unsatisfiable:
|
|
136
|
+
__tracebackhide__ = True
|
|
137
|
+
unsatisfiable = find_unsatisfiable_parameter(operation)
|
|
138
|
+
|
|
139
|
+
if unsatisfiable is not None:
|
|
140
|
+
message = unsatisfiable.get_error_message(operation.schema.config.output)
|
|
141
|
+
else:
|
|
142
|
+
message = GENERIC_UNSATISFIABLE_MESSAGE
|
|
143
|
+
|
|
144
|
+
if with_tip:
|
|
145
|
+
message += "\n\nTip: Review all parameters and request body schemas for conflicting constraints"
|
|
146
|
+
|
|
147
|
+
return Unsatisfiable(message)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
HEALTH_CHECK_CAUSES = {
|
|
151
|
+
HealthCheck.data_too_large: """ - Arrays with large minItems (e.g., minItems: 1000)
|
|
152
|
+
- Strings with large minLength (e.g., minLength: 10000)
|
|
153
|
+
- Deeply nested objects with many required properties""",
|
|
154
|
+
HealthCheck.filter_too_much: """ - Complex regex patterns that match few strings
|
|
155
|
+
- Multiple overlapping constraints (pattern + format + enum)""",
|
|
156
|
+
HealthCheck.too_slow: """ - Regex with excessive backtracking (e.g., (a+)+b)
|
|
157
|
+
- Many interdependent constraints
|
|
158
|
+
- Large combinatorial complexity""",
|
|
159
|
+
HealthCheck.large_base_example: """ - Arrays with large minimum size (e.g., minItems: 100)
|
|
160
|
+
- Many required properties with their own large minimums
|
|
161
|
+
- Nested structures that multiply size requirements""",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
HEALTH_CHECK_ACTIONS = {
|
|
165
|
+
HealthCheck.data_too_large: "Reduce minItems, minLength, or size constraints to realistic values",
|
|
166
|
+
HealthCheck.filter_too_much: "Simplify constraints or widen acceptable value ranges",
|
|
167
|
+
HealthCheck.too_slow: "Simplify regex patterns or reduce constraint complexity",
|
|
168
|
+
HealthCheck.large_base_example: "Reduce minimum size requirements or number of required properties",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
HEALTH_CHECK_TITLES = {
|
|
172
|
+
HealthCheck.data_too_large: "Generated examples exceed size limits",
|
|
173
|
+
HealthCheck.filter_too_much: "Too many generated examples are filtered out",
|
|
174
|
+
HealthCheck.too_slow: "Data generation is too slow",
|
|
175
|
+
HealthCheck.large_base_example: "Minimum possible example is too large",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class SlowParameter:
|
|
181
|
+
"""Information about a parameter with slow or problematic data generation."""
|
|
182
|
+
|
|
183
|
+
location: ParameterLocation
|
|
184
|
+
name: str
|
|
185
|
+
schema: JsonSchema
|
|
186
|
+
original: HealthCheck
|
|
187
|
+
|
|
188
|
+
__slots__ = ("location", "name", "schema", "original")
|
|
189
|
+
|
|
190
|
+
def get_error_message(self, config: OutputConfig) -> str:
|
|
191
|
+
formatted_schema = truncate_json(self.schema, config=config)
|
|
192
|
+
if self.location == ParameterLocation.BODY:
|
|
193
|
+
# For body, name is the media type
|
|
194
|
+
location = f"request body ({self.name})"
|
|
195
|
+
else:
|
|
196
|
+
location = f"{self.location.value} parameter '{self.name}'"
|
|
197
|
+
title = HEALTH_CHECK_TITLES[self.original]
|
|
198
|
+
causes = HEALTH_CHECK_CAUSES[self.original]
|
|
199
|
+
|
|
200
|
+
return f"""{title} for {location}
|
|
201
|
+
Schema:
|
|
202
|
+
|
|
203
|
+
{formatted_schema}
|
|
204
|
+
|
|
205
|
+
This usually means:
|
|
206
|
+
{causes}"""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _extract_health_check_reason(exc: FailedHealthCheck | InvalidArgument) -> HealthCheck | None:
|
|
210
|
+
message = str(exc).lower()
|
|
211
|
+
if "data_too_large" in message or "too large" in message:
|
|
212
|
+
return HealthCheck.data_too_large
|
|
213
|
+
elif "filter_too_much" in message or "filtered out" in message:
|
|
214
|
+
return HealthCheck.filter_too_much
|
|
215
|
+
elif "too_slow" in message or "too slow" in message:
|
|
216
|
+
return HealthCheck.too_slow
|
|
217
|
+
elif ("large_base_example" in message or "can never generate an example, because min_size" in message) or (
|
|
218
|
+
isinstance(exc, InvalidArgument)
|
|
219
|
+
and message.endswith("larger than hypothesis is designed to handle")
|
|
220
|
+
or "can never generate an example, because min_size is larger than hypothesis supports" in message
|
|
221
|
+
):
|
|
222
|
+
return HealthCheck.large_base_example
|
|
223
|
+
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def find_slow_parameter(operation: APIOperation, reason: HealthCheck) -> SlowParameter | None:
|
|
228
|
+
from hypothesis.errors import FailedHealthCheck
|
|
229
|
+
from hypothesis_jsonschema import from_schema
|
|
230
|
+
|
|
231
|
+
for location, container in (
|
|
232
|
+
(ParameterLocation.QUERY, operation.query),
|
|
233
|
+
(ParameterLocation.PATH, operation.path_parameters),
|
|
234
|
+
(ParameterLocation.HEADER, operation.headers),
|
|
235
|
+
(ParameterLocation.COOKIE, operation.cookies),
|
|
236
|
+
(ParameterLocation.BODY, operation.body),
|
|
237
|
+
):
|
|
238
|
+
for parameter in container:
|
|
239
|
+
try:
|
|
240
|
+
generate_one(from_schema(parameter.optimized_schema), suppress_health_check=[])
|
|
241
|
+
except (FailedHealthCheck, Unsatisfiable, InvalidArgument):
|
|
242
|
+
if location == ParameterLocation.BODY:
|
|
243
|
+
name = parameter.media_type
|
|
244
|
+
else:
|
|
245
|
+
name = parameter.name
|
|
246
|
+
|
|
247
|
+
schema = unbundle_schema_refs(parameter.optimized_schema, parameter.name_to_uri)
|
|
248
|
+
return SlowParameter(location=location, name=name, schema=schema, original=reason)
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _get_generic_health_check_message(reason: HealthCheck) -> str:
|
|
253
|
+
title = HEALTH_CHECK_TITLES[reason]
|
|
254
|
+
causes = HEALTH_CHECK_CAUSES[reason]
|
|
255
|
+
return f"{title} for this operation\n\nUnable to identify the specific parameter. Common causes:\n{causes}"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class HealthCheckTipStyle(Enum):
|
|
259
|
+
DEFAULT = "default"
|
|
260
|
+
PYTEST = "pytest"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def build_health_check_error(
|
|
264
|
+
operation: APIOperation,
|
|
265
|
+
original: FailedHealthCheck | InvalidArgument,
|
|
266
|
+
with_tip: bool,
|
|
267
|
+
tip_style: HealthCheckTipStyle = HealthCheckTipStyle.DEFAULT,
|
|
268
|
+
) -> FailedHealthCheck | InvalidArgument:
|
|
269
|
+
__tracebackhide__ = True
|
|
270
|
+
reason = _extract_health_check_reason(original)
|
|
271
|
+
if reason is None:
|
|
272
|
+
return original
|
|
273
|
+
slow_param = find_slow_parameter(operation, reason)
|
|
274
|
+
|
|
275
|
+
if slow_param is not None:
|
|
276
|
+
message = slow_param.get_error_message(operation.schema.config.output)
|
|
277
|
+
else:
|
|
278
|
+
message = _get_generic_health_check_message(reason)
|
|
279
|
+
|
|
280
|
+
if with_tip:
|
|
281
|
+
message += f"\n\nTip: {HEALTH_CHECK_ACTIONS[reason]}"
|
|
282
|
+
if tip_style == HealthCheckTipStyle.PYTEST:
|
|
283
|
+
message += f". You can disable this health check with @settings(suppress_health_check=[{reason!r}])"
|
|
284
|
+
|
|
285
|
+
return FailedHealthCheck(message)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from schemathesis.core.parameters import ParameterLocation
|
|
7
|
+
from schemathesis.generation import GenerationMode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestPhase(str, Enum):
|
|
11
|
+
__test__ = False
|
|
12
|
+
|
|
13
|
+
EXAMPLES = "examples"
|
|
14
|
+
COVERAGE = "coverage"
|
|
15
|
+
FUZZING = "fuzzing"
|
|
16
|
+
STATEFUL = "stateful"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CoverageScenario(str, Enum):
|
|
20
|
+
"""Coverage test scenario types."""
|
|
21
|
+
|
|
22
|
+
# Positive scenarios - Valid values
|
|
23
|
+
EXAMPLE_VALUE = "example_value"
|
|
24
|
+
DEFAULT_VALUE = "default_value"
|
|
25
|
+
ENUM_VALUE = "enum_value"
|
|
26
|
+
CONST_VALUE = "const_value"
|
|
27
|
+
VALID_STRING = "valid_string"
|
|
28
|
+
VALID_NUMBER = "valid_number"
|
|
29
|
+
VALID_BOOLEAN = "valid_boolean"
|
|
30
|
+
VALID_ARRAY = "valid_array"
|
|
31
|
+
VALID_OBJECT = "valid_object"
|
|
32
|
+
NULL_VALUE = "null_value"
|
|
33
|
+
|
|
34
|
+
# Positive scenarios - Boundary values for strings
|
|
35
|
+
MINIMUM_LENGTH_STRING = "minimum_length_string"
|
|
36
|
+
MAXIMUM_LENGTH_STRING = "maximum_length_string"
|
|
37
|
+
NEAR_BOUNDARY_LENGTH_STRING = "near_boundary_length_string"
|
|
38
|
+
|
|
39
|
+
# Positive scenarios - Boundary values for numbers
|
|
40
|
+
MINIMUM_VALUE = "minimum_value"
|
|
41
|
+
MAXIMUM_VALUE = "maximum_value"
|
|
42
|
+
NEAR_BOUNDARY_NUMBER = "near_boundary_number"
|
|
43
|
+
|
|
44
|
+
# Positive scenarios - Boundary values for arrays
|
|
45
|
+
MINIMUM_ITEMS_ARRAY = "minimum_items_array"
|
|
46
|
+
MAXIMUM_ITEMS_ARRAY = "maximum_items_array"
|
|
47
|
+
NEAR_BOUNDARY_ITEMS_ARRAY = "near_boundary_items_array"
|
|
48
|
+
ENUM_VALUE_ITEMS_ARRAY = "enum_value_items_array"
|
|
49
|
+
|
|
50
|
+
# Positive scenarios - Objects
|
|
51
|
+
OBJECT_ONLY_REQUIRED = "object_only_required"
|
|
52
|
+
OBJECT_REQUIRED_AND_OPTIONAL = "object_required_and_optional"
|
|
53
|
+
|
|
54
|
+
# Positive scenarios - Default test case
|
|
55
|
+
DEFAULT_POSITIVE_TEST = "default_positive_test"
|
|
56
|
+
|
|
57
|
+
# Negative scenarios - Boundary violations for numbers
|
|
58
|
+
VALUE_ABOVE_MAXIMUM = "value_above_maximum"
|
|
59
|
+
VALUE_BELOW_MINIMUM = "value_below_minimum"
|
|
60
|
+
|
|
61
|
+
# Negative scenarios - Boundary violations for strings
|
|
62
|
+
STRING_ABOVE_MAX_LENGTH = "string_above_max_length"
|
|
63
|
+
STRING_BELOW_MIN_LENGTH = "string_below_min_length"
|
|
64
|
+
|
|
65
|
+
# Negative scenarios - Boundary violations for arrays
|
|
66
|
+
ARRAY_ABOVE_MAX_ITEMS = "array_above_max_items"
|
|
67
|
+
ARRAY_BELOW_MIN_ITEMS = "array_below_min_items"
|
|
68
|
+
|
|
69
|
+
# Negative scenarios - Constraint violations
|
|
70
|
+
OBJECT_UNEXPECTED_PROPERTIES = "object_unexpected_properties"
|
|
71
|
+
OBJECT_MISSING_REQUIRED_PROPERTY = "object_missing_required_property"
|
|
72
|
+
INCORRECT_TYPE = "incorrect_type"
|
|
73
|
+
INVALID_ENUM_VALUE = "invalid_enum_value"
|
|
74
|
+
INVALID_FORMAT = "invalid_format"
|
|
75
|
+
INVALID_PATTERN = "invalid_pattern"
|
|
76
|
+
NOT_MULTIPLE_OF = "not_multiple_of"
|
|
77
|
+
NON_UNIQUE_ITEMS = "non_unique_items"
|
|
78
|
+
|
|
79
|
+
# Negative scenarios - Missing parameters
|
|
80
|
+
MISSING_PARAMETER = "missing_parameter"
|
|
81
|
+
DUPLICATE_PARAMETER = "duplicate_parameter"
|
|
82
|
+
|
|
83
|
+
# Negative scenarios - Unsupported patterns
|
|
84
|
+
UNSUPPORTED_PATH_PATTERN = "unsupported_path_pattern"
|
|
85
|
+
UNSPECIFIED_HTTP_METHOD = "unspecified_http_method"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class ComponentInfo:
|
|
90
|
+
"""Information about how a specific component was generated."""
|
|
91
|
+
|
|
92
|
+
mode: GenerationMode
|
|
93
|
+
|
|
94
|
+
__slots__ = ("mode",)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class FuzzingPhaseData:
|
|
99
|
+
"""Metadata specific to fuzzing phase."""
|
|
100
|
+
|
|
101
|
+
description: str
|
|
102
|
+
parameter: str | None
|
|
103
|
+
parameter_location: ParameterLocation | None
|
|
104
|
+
location: str | None
|
|
105
|
+
|
|
106
|
+
__slots__ = ("description", "parameter", "parameter_location", "location")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class StatefulPhaseData:
|
|
111
|
+
"""Metadata specific to stateful phase."""
|
|
112
|
+
|
|
113
|
+
description: str | None
|
|
114
|
+
parameter: str | None
|
|
115
|
+
parameter_location: ParameterLocation | None
|
|
116
|
+
location: str | None
|
|
117
|
+
|
|
118
|
+
__slots__ = ("description", "parameter", "parameter_location", "location")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class ExamplesPhaseData:
|
|
123
|
+
"""Metadata specific to examples phase."""
|
|
124
|
+
|
|
125
|
+
description: str | None
|
|
126
|
+
parameter: str | None
|
|
127
|
+
parameter_location: ParameterLocation | None
|
|
128
|
+
location: str | None
|
|
129
|
+
|
|
130
|
+
__slots__ = ("description", "parameter", "parameter_location", "location")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class CoveragePhaseData:
|
|
135
|
+
"""Metadata specific to coverage phase."""
|
|
136
|
+
|
|
137
|
+
scenario: CoverageScenario
|
|
138
|
+
description: str
|
|
139
|
+
location: str | None
|
|
140
|
+
parameter: str | None
|
|
141
|
+
parameter_location: ParameterLocation | None
|
|
142
|
+
|
|
143
|
+
__slots__ = ("scenario", "description", "location", "parameter", "parameter_location")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass
|
|
147
|
+
class PhaseInfo:
|
|
148
|
+
"""Phase-specific information."""
|
|
149
|
+
|
|
150
|
+
name: TestPhase
|
|
151
|
+
data: CoveragePhaseData | ExamplesPhaseData | FuzzingPhaseData | StatefulPhaseData
|
|
152
|
+
|
|
153
|
+
__slots__ = ("name", "data")
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def coverage(
|
|
157
|
+
cls,
|
|
158
|
+
scenario: CoverageScenario,
|
|
159
|
+
description: str,
|
|
160
|
+
location: str | None = None,
|
|
161
|
+
parameter: str | None = None,
|
|
162
|
+
parameter_location: ParameterLocation | None = None,
|
|
163
|
+
) -> PhaseInfo:
|
|
164
|
+
return cls(
|
|
165
|
+
name=TestPhase.COVERAGE,
|
|
166
|
+
data=CoveragePhaseData(
|
|
167
|
+
scenario=scenario,
|
|
168
|
+
description=description,
|
|
169
|
+
location=location,
|
|
170
|
+
parameter=parameter,
|
|
171
|
+
parameter_location=parameter_location,
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class GenerationInfo:
|
|
178
|
+
"""Information about test case generation."""
|
|
179
|
+
|
|
180
|
+
time: float
|
|
181
|
+
mode: GenerationMode
|
|
182
|
+
|
|
183
|
+
__slots__ = ("time", "mode")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class CaseMetadata:
|
|
188
|
+
"""Complete metadata for generated cases."""
|
|
189
|
+
|
|
190
|
+
generation: GenerationInfo
|
|
191
|
+
components: dict[ParameterLocation, ComponentInfo]
|
|
192
|
+
phase: PhaseInfo
|
|
193
|
+
|
|
194
|
+
# Dirty tracking for revalidation
|
|
195
|
+
_dirty: set[ParameterLocation]
|
|
196
|
+
_last_validated_hashes: dict[ParameterLocation, int]
|
|
197
|
+
|
|
198
|
+
__slots__ = ("generation", "components", "phase", "_dirty", "_last_validated_hashes")
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
generation: GenerationInfo,
|
|
203
|
+
components: dict[ParameterLocation, ComponentInfo],
|
|
204
|
+
phase: PhaseInfo,
|
|
205
|
+
) -> None:
|
|
206
|
+
self.generation = generation
|
|
207
|
+
self.components = components
|
|
208
|
+
self.phase = phase
|
|
209
|
+
# Initialize dirty tracking
|
|
210
|
+
self._dirty = set()
|
|
211
|
+
self._last_validated_hashes = {}
|
|
212
|
+
|
|
213
|
+
def mark_dirty(self, location: ParameterLocation) -> None:
|
|
214
|
+
"""Mark a component as modified and needing revalidation."""
|
|
215
|
+
self._dirty.add(location)
|
|
216
|
+
|
|
217
|
+
def clear_dirty(self, location: ParameterLocation) -> None:
|
|
218
|
+
"""Clear dirty flag for a component after revalidation."""
|
|
219
|
+
self._dirty.discard(location)
|
|
220
|
+
|
|
221
|
+
def is_dirty(self) -> bool:
|
|
222
|
+
"""Check if any component needs revalidation."""
|
|
223
|
+
return len(self._dirty) > 0
|
|
224
|
+
|
|
225
|
+
def update_validated_hash(self, location: ParameterLocation, value: int) -> None:
|
|
226
|
+
"""Store hash after validation to detect future changes."""
|
|
227
|
+
self._last_validated_hashes[location] = value
|