schemathesis 4.0.0a10__py3-none-any.whl → 4.0.0a11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. schemathesis/__init__.py +3 -7
  2. schemathesis/checks.py +17 -7
  3. schemathesis/cli/commands/__init__.py +51 -3
  4. schemathesis/cli/commands/data.py +10 -0
  5. schemathesis/cli/commands/run/__init__.py +147 -260
  6. schemathesis/cli/commands/run/context.py +2 -3
  7. schemathesis/cli/commands/run/events.py +4 -0
  8. schemathesis/cli/commands/run/executor.py +60 -73
  9. schemathesis/cli/commands/run/filters.py +15 -165
  10. schemathesis/cli/commands/run/handlers/cassettes.py +105 -104
  11. schemathesis/cli/commands/run/handlers/junitxml.py +5 -4
  12. schemathesis/cli/commands/run/handlers/output.py +26 -47
  13. schemathesis/cli/commands/run/loaders.py +35 -50
  14. schemathesis/cli/commands/run/validation.py +36 -161
  15. schemathesis/cli/core.py +5 -3
  16. schemathesis/cli/ext/fs.py +7 -5
  17. schemathesis/cli/ext/options.py +0 -21
  18. schemathesis/config/__init__.py +188 -0
  19. schemathesis/config/_auth.py +51 -0
  20. schemathesis/config/_checks.py +268 -0
  21. schemathesis/config/_diff_base.py +99 -0
  22. schemathesis/config/_env.py +21 -0
  23. schemathesis/config/_error.py +156 -0
  24. schemathesis/config/_generation.py +150 -0
  25. schemathesis/config/_health_check.py +24 -0
  26. schemathesis/config/_operations.py +313 -0
  27. schemathesis/config/_output.py +171 -0
  28. schemathesis/config/_parameters.py +19 -0
  29. schemathesis/config/_phases.py +151 -0
  30. schemathesis/config/_projects.py +495 -0
  31. schemathesis/config/_rate_limit.py +17 -0
  32. schemathesis/config/_report.py +116 -0
  33. schemathesis/config/_validator.py +9 -0
  34. schemathesis/config/schema.json +837 -0
  35. schemathesis/core/__init__.py +2 -0
  36. schemathesis/core/compat.py +16 -9
  37. schemathesis/core/errors.py +19 -2
  38. schemathesis/core/failures.py +6 -7
  39. schemathesis/core/hooks.py +20 -0
  40. schemathesis/core/output/__init__.py +14 -37
  41. schemathesis/core/output/sanitization.py +3 -146
  42. schemathesis/core/validation.py +16 -0
  43. schemathesis/engine/__init__.py +2 -4
  44. schemathesis/engine/context.py +41 -43
  45. schemathesis/engine/core.py +7 -5
  46. schemathesis/engine/phases/__init__.py +10 -0
  47. schemathesis/engine/phases/probes.py +8 -8
  48. schemathesis/engine/phases/stateful/_executor.py +68 -43
  49. schemathesis/engine/phases/unit/__init__.py +23 -15
  50. schemathesis/engine/phases/unit/_executor.py +77 -17
  51. schemathesis/engine/phases/unit/_pool.py +1 -1
  52. schemathesis/errors.py +2 -0
  53. schemathesis/filters.py +2 -3
  54. schemathesis/generation/__init__.py +6 -31
  55. schemathesis/generation/case.py +5 -3
  56. schemathesis/generation/coverage.py +153 -123
  57. schemathesis/generation/hypothesis/builder.py +40 -14
  58. schemathesis/generation/meta.py +3 -3
  59. schemathesis/generation/overrides.py +37 -1
  60. schemathesis/generation/stateful/state_machine.py +8 -1
  61. schemathesis/graphql/loaders.py +21 -12
  62. schemathesis/openapi/checks.py +12 -8
  63. schemathesis/openapi/generation/filters.py +10 -8
  64. schemathesis/openapi/loaders.py +22 -13
  65. schemathesis/pytest/lazy.py +2 -5
  66. schemathesis/pytest/plugin.py +11 -2
  67. schemathesis/schemas.py +13 -61
  68. schemathesis/specs/graphql/schemas.py +11 -15
  69. schemathesis/specs/openapi/_hypothesis.py +12 -8
  70. schemathesis/specs/openapi/checks.py +16 -18
  71. schemathesis/specs/openapi/examples.py +4 -3
  72. schemathesis/specs/openapi/formats.py +2 -2
  73. schemathesis/specs/openapi/negative/__init__.py +2 -2
  74. schemathesis/specs/openapi/patterns.py +46 -16
  75. schemathesis/specs/openapi/references.py +2 -3
  76. schemathesis/specs/openapi/schemas.py +11 -20
  77. schemathesis/specs/openapi/stateful/__init__.py +10 -5
  78. schemathesis/transport/prepare.py +7 -6
  79. schemathesis/transport/requests.py +3 -1
  80. schemathesis/transport/wsgi.py +3 -4
  81. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/METADATA +7 -8
  82. schemathesis-4.0.0a11.dist-info/RECORD +166 -0
  83. schemathesis/cli/commands/run/checks.py +0 -79
  84. schemathesis/cli/commands/run/hypothesis.py +0 -78
  85. schemathesis/cli/commands/run/reports.py +0 -72
  86. schemathesis/cli/hooks.py +0 -36
  87. schemathesis/engine/config.py +0 -59
  88. schemathesis/experimental/__init__.py +0 -72
  89. schemathesis-4.0.0a10.dist-info/RECORD +0 -153
  90. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/WHEEL +0 -0
  91. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/entry_points.txt +0 -0
  92. {schemathesis-4.0.0a10.dist-info → schemathesis-4.0.0a11.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ from typing import TYPE_CHECKING
5
+
6
+ from schemathesis.core.errors import SchemathesisError
7
+
8
+ if TYPE_CHECKING:
9
+ from jsonschema import ValidationError
10
+
11
+
12
+ class ConfigError(SchemathesisError):
13
+ """Invalid configuration."""
14
+
15
+ @classmethod
16
+ def from_validation_error(cls, error: ValidationError) -> ConfigError:
17
+ message = error.message
18
+ if error.validator == "enum":
19
+ message = _format_enum_error(error)
20
+ elif error.validator == "minimum":
21
+ message = _format_minimum_error(error)
22
+ elif error.validator == "required":
23
+ message = _format_required_error(error)
24
+ elif error.validator == "type":
25
+ message = _format_type_error(error)
26
+ elif error.validator == "additionalProperties":
27
+ message = _format_additional_properties_error(error)
28
+ elif error.validator == "anyOf":
29
+ message = _format_anyof_error(error)
30
+ return cls(message)
31
+
32
+
33
+ def _format_minimum_error(error: ValidationError) -> str:
34
+ assert isinstance(error.validator_value, (int, float))
35
+ section = path_to_section_name(list(error.path)[:-1] if error.path else [])
36
+ assert error.path
37
+
38
+ prop_name = error.path[-1]
39
+ min_value = error.validator_value
40
+ actual_value = error.instance
41
+
42
+ return (
43
+ f"Error in {section} section:\n Value too low:\n\n"
44
+ f" - '{prop_name}' → Must be at least {min_value}, but got {actual_value}."
45
+ )
46
+
47
+
48
+ def _format_required_error(error: ValidationError) -> str:
49
+ assert isinstance(error.validator_value, list)
50
+ missing_keys = sorted(set(error.validator_value) - set(error.instance))
51
+
52
+ section = path_to_section_name(list(error.path))
53
+
54
+ details = "\n".join(f" - '{key}'" for key in missing_keys)
55
+ return f"Error in {section} section:\n Missing required properties:\n\n{details}\n\n"
56
+
57
+
58
+ def _format_enum_error(error: ValidationError) -> str:
59
+ assert isinstance(error.validator_value, list)
60
+ valid_values = sorted(error.validator_value)
61
+
62
+ path = list(error.path)
63
+
64
+ if path and isinstance(path[-1], int):
65
+ idx = path[-1]
66
+ prop_name = path[-2]
67
+ section_path = path[:-2]
68
+ description = f"Item #{idx} in the '{prop_name}' array"
69
+ else:
70
+ prop_name = path[-1] if path else "value"
71
+ section_path = path[:-1]
72
+ description = f"'{prop_name}'"
73
+
74
+ suggestion = ""
75
+ if isinstance(error.instance, str) and all(isinstance(v, str) for v in valid_values):
76
+ match = _find_closest_match(error.instance, valid_values)
77
+ if match:
78
+ suggestion = f" Did you mean '{match}'?"
79
+
80
+ section = path_to_section_name(section_path)
81
+ valid_values_str = ", ".join(repr(v) for v in valid_values)
82
+ return (
83
+ f"Error in {section} section:\n Invalid value:\n\n"
84
+ f" - {description} → '{error.instance}' is not a valid value.{suggestion}\n\n"
85
+ f"Valid values are: {valid_values_str}."
86
+ )
87
+
88
+
89
+ def _format_type_error(error: ValidationError) -> str:
90
+ expected = error.validator_value
91
+ assert isinstance(expected, (str, list))
92
+ section = path_to_section_name(list(error.path)[:-1] if error.path else [])
93
+ assert error.path
94
+
95
+ type_phrases = {
96
+ "object": "an object",
97
+ "array": "an array",
98
+ "number": "a number",
99
+ "boolean": "a boolean",
100
+ "string": "a string",
101
+ "integer": "an integer",
102
+ "null": "null",
103
+ }
104
+ message = f"Error in {section} section:\n Type error:\n\n - '{error.path[-1]}' → Must be "
105
+
106
+ if isinstance(expected, list):
107
+ message += f"one of: {' or '.join(expected)}"
108
+ else:
109
+ message += type_phrases[expected]
110
+ actual = type(error.instance).__name__
111
+ message += f", but got {actual}: {error.instance}"
112
+ return message
113
+
114
+
115
+ def _format_additional_properties_error(error: ValidationError) -> str:
116
+ valid = list(error.schema.get("properties", {}))
117
+ unknown = sorted(set(error.instance) - set(valid))
118
+ valid_list = ", ".join(f"'{prop}'" for prop in valid)
119
+ section = path_to_section_name(list(error.path))
120
+
121
+ details = []
122
+ for prop in unknown:
123
+ match = _find_closest_match(prop, valid)
124
+ if match:
125
+ details.append(f"- '{prop}' → Did you mean '{match}'?")
126
+ else:
127
+ details.append(f"- '{prop}'")
128
+
129
+ return (
130
+ f"Error in {section} section:\n Unknown properties:\n\n"
131
+ + "\n".join(f" {detail}" for detail in details)
132
+ + f"\n\nValid properties for {section} are: {valid_list}."
133
+ )
134
+
135
+
136
+ def _format_anyof_error(error: ValidationError) -> str:
137
+ if list(error.schema_path) == ["properties", "operations", "items", "anyOf"]:
138
+ section = path_to_section_name(list(error.path))
139
+ return (
140
+ f"Error in {section} section:\n At least one filter is required when defining [[operations]].\n\n"
141
+ "Please specify at least one include or exclude filter property (e.g., include-path, exclude-tag, etc.)."
142
+ )
143
+ return error.message
144
+
145
+
146
+ def path_to_section_name(path: list[int | str]) -> str:
147
+ """Convert a JSON path to a TOML-like section name."""
148
+ if not path:
149
+ return "root"
150
+
151
+ return f"[{'.'.join(str(p) for p in path)}]"
152
+
153
+
154
+ def _find_closest_match(value: str, variants: list[str]) -> str | None:
155
+ matches = difflib.get_close_matches(value, variants, n=1, cutoff=0.6)
156
+ return matches[0] if matches else None
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from schemathesis.config._diff_base import DiffBase
7
+ from schemathesis.generation.modes import GenerationMode
8
+
9
+ if TYPE_CHECKING:
10
+ from schemathesis.generation.targets import TargetFunction
11
+
12
+
13
+ @dataclass(repr=False)
14
+ class GenerationConfig(DiffBase):
15
+ modes: list[GenerationMode]
16
+ max_examples: int | None
17
+ no_shrink: bool
18
+ deterministic: bool
19
+ # Allow generating `\x00` bytes in strings
20
+ allow_x00: bool
21
+ # Generate strings using the given codec
22
+ codec: str | None
23
+ maximize: list[TargetFunction]
24
+ # Whether to generate security parameters
25
+ with_security_parameters: bool
26
+ # Allowing using `null` for optional arguments in GraphQL queries
27
+ graphql_allow_null: bool
28
+ database: str | None
29
+ unique_inputs: bool
30
+ exclude_header_characters: str | None
31
+
32
+ __slots__ = (
33
+ "modes",
34
+ "max_examples",
35
+ "no_shrink",
36
+ "deterministic",
37
+ "allow_x00",
38
+ "codec",
39
+ "maximize",
40
+ "with_security_parameters",
41
+ "graphql_allow_null",
42
+ "database",
43
+ "unique_inputs",
44
+ "exclude_header_characters",
45
+ )
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ modes: list[GenerationMode] | None = None,
51
+ max_examples: int | None = None,
52
+ no_shrink: bool = False,
53
+ deterministic: bool = False,
54
+ allow_x00: bool = True,
55
+ codec: str | None = "utf-8",
56
+ maximize: list[TargetFunction] | None = None,
57
+ with_security_parameters: bool = True,
58
+ graphql_allow_null: bool = True,
59
+ database: str | None = None,
60
+ unique_inputs: bool = False,
61
+ exclude_header_characters: str | None = None,
62
+ ) -> None:
63
+ from schemathesis.generation import GenerationMode
64
+
65
+ # TODO: Switch to `all` by default.
66
+ self.modes = modes or [GenerationMode.POSITIVE]
67
+ self.max_examples = max_examples
68
+ self.no_shrink = no_shrink
69
+ self.deterministic = deterministic
70
+ self.allow_x00 = allow_x00
71
+ self.codec = codec
72
+ self.maximize = maximize or []
73
+ self.with_security_parameters = with_security_parameters
74
+ self.graphql_allow_null = graphql_allow_null
75
+ self.database = database
76
+ self.unique_inputs = unique_inputs
77
+ self.exclude_header_characters = exclude_header_characters
78
+
79
+ @classmethod
80
+ def from_dict(cls, data: dict[str, Any]) -> GenerationConfig:
81
+ mode_raw = data.get("mode")
82
+ if mode_raw == "all":
83
+ modes = GenerationMode.all()
84
+ elif mode_raw is not None:
85
+ modes = [GenerationMode(mode_raw)]
86
+ else:
87
+ modes = None
88
+ maximize = _get_maximize(data.get("maximize"))
89
+ return cls(
90
+ modes=modes,
91
+ max_examples=data.get("max-examples"),
92
+ no_shrink=data.get("no-shrink", False),
93
+ deterministic=data.get("deterministic", False),
94
+ allow_x00=data.get("allow-x00", True),
95
+ codec=data.get("codec", "utf-8"),
96
+ maximize=maximize,
97
+ with_security_parameters=data.get("with-security-parameters", True),
98
+ graphql_allow_null=data.get("graphql-allow-null", True),
99
+ database=data.get("database"),
100
+ unique_inputs=data.get("unique-inputs", False),
101
+ exclude_header_characters=data.get("exclude-header-characters"),
102
+ )
103
+
104
+ def update(
105
+ self,
106
+ *,
107
+ modes: list[GenerationMode] | None = None,
108
+ max_examples: int | None = None,
109
+ no_shrink: bool = False,
110
+ deterministic: bool | None = None,
111
+ allow_x00: bool = True,
112
+ codec: str | None = None,
113
+ maximize: list[TargetFunction] | None = None,
114
+ with_security_parameters: bool | None = None,
115
+ graphql_allow_null: bool = True,
116
+ database: str | None = None,
117
+ unique_inputs: bool = False,
118
+ exclude_header_characters: str | None = None,
119
+ ) -> None:
120
+ if modes is not None:
121
+ self.modes = modes
122
+ if max_examples is not None:
123
+ self.max_examples = max_examples
124
+ self.no_shrink = no_shrink
125
+ self.deterministic = deterministic or False
126
+ self.allow_x00 = allow_x00
127
+ if codec is not None:
128
+ self.codec = codec
129
+ if maximize is not None:
130
+ self.maximize = maximize
131
+ if with_security_parameters is not None:
132
+ self.with_security_parameters = with_security_parameters
133
+ self.graphql_allow_null = graphql_allow_null
134
+ if database is not None:
135
+ self.database = database
136
+ self.unique_inputs = unique_inputs
137
+ if exclude_header_characters is not None:
138
+ self.exclude_header_characters = exclude_header_characters
139
+
140
+
141
+ def _get_maximize(value: Any) -> list[TargetFunction]:
142
+ from schemathesis.generation.targets import TARGETS
143
+
144
+ if isinstance(value, list):
145
+ targets = value
146
+ elif isinstance(value, str):
147
+ targets = [value]
148
+ else:
149
+ targets = []
150
+ return TARGETS.get_by_names(targets)
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum, unique
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ import hypothesis
8
+
9
+
10
+ @unique
11
+ class HealthCheck(str, Enum):
12
+ data_too_large = "data_too_large"
13
+ filter_too_much = "filter_too_much"
14
+ too_slow = "too_slow"
15
+ large_base_example = "large_base_example"
16
+ all = "all"
17
+
18
+ def as_hypothesis(self) -> list[hypothesis.HealthCheck]:
19
+ from hypothesis import HealthCheck
20
+
21
+ if self.name == "all":
22
+ return list(HealthCheck)
23
+
24
+ return [HealthCheck[self.name]]
@@ -0,0 +1,313 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any, Callable, Generator
7
+
8
+ from schemathesis.config._auth import AuthConfig
9
+ from schemathesis.config._checks import ChecksConfig
10
+ from schemathesis.config._diff_base import DiffBase
11
+ from schemathesis.config._env import resolve
12
+ from schemathesis.config._error import ConfigError
13
+ from schemathesis.config._generation import GenerationConfig
14
+ from schemathesis.config._parameters import load_parameters
15
+ from schemathesis.config._phases import PhasesConfig
16
+ from schemathesis.config._rate_limit import build_limiter
17
+ from schemathesis.core.errors import IncorrectUsage
18
+ from schemathesis.filters import FilterSet, HasAPIOperation, expression_to_filter_function, is_deprecated
19
+
20
+ if TYPE_CHECKING:
21
+ from pyrate_limiter import Limiter
22
+
23
+ from schemathesis.schemas import APIOperation
24
+
25
+ FILTER_ATTRIBUTES = [
26
+ ("name", "name"),
27
+ ("method", "method"),
28
+ ("path", "path"),
29
+ ("tag", "tag"),
30
+ ("operation-id", "operation_id"),
31
+ ]
32
+
33
+
34
+ @contextmanager
35
+ def reraise_filter_error(attr: str) -> Generator:
36
+ try:
37
+ yield
38
+ except IncorrectUsage as exc:
39
+ if str(exc) == "Filter already exists":
40
+ raise ConfigError(
41
+ f"Filter for '{attr}' already exists. You can't simultaneously include and exclude the same thing."
42
+ ) from None
43
+ raise
44
+ except re.error as exc:
45
+ raise ConfigError(
46
+ f"Filter for '{attr}' contains an invalid regular expression: {exc.pattern!r}\n\n {exc}"
47
+ ) from None
48
+
49
+
50
+ @dataclass
51
+ class OperationsConfig(DiffBase):
52
+ operations: list[OperationConfig]
53
+
54
+ __slots__ = ("operations",)
55
+
56
+ def __init__(self, *, operations: list[OperationConfig] | None = None):
57
+ self.operations = operations or []
58
+
59
+ def __repr__(self) -> str:
60
+ if self.operations:
61
+ return f"[{', '.join(DiffBase.__repr__(cfg) for cfg in self.operations)}]"
62
+ return "[]"
63
+
64
+ @classmethod
65
+ def from_hierarchy(cls, configs: list[OperationsConfig]) -> OperationsConfig: # type: ignore
66
+ return cls(operations=sum([config.operations for config in reversed(configs)], []))
67
+
68
+ def get_for_operation(self, operation: APIOperation) -> OperationConfig:
69
+ configs = [config for config in self.operations if config._filter_set.applies_to(operation)]
70
+ return OperationConfig.from_hierarchy(configs)
71
+
72
+ def create_filter_set(
73
+ self,
74
+ *,
75
+ include_path: tuple[str, ...],
76
+ include_method: tuple[str, ...],
77
+ include_name: tuple[str, ...],
78
+ include_tag: tuple[str, ...],
79
+ include_operation_id: tuple[str, ...],
80
+ include_path_regex: str | None,
81
+ include_method_regex: str | None,
82
+ include_name_regex: str | None,
83
+ include_tag_regex: str | None,
84
+ include_operation_id_regex: str | None,
85
+ exclude_path: tuple[str, ...],
86
+ exclude_method: tuple[str, ...],
87
+ exclude_name: tuple[str, ...],
88
+ exclude_tag: tuple[str, ...],
89
+ exclude_operation_id: tuple[str, ...],
90
+ exclude_path_regex: str | None,
91
+ exclude_method_regex: str | None,
92
+ exclude_name_regex: str | None,
93
+ exclude_tag_regex: str | None,
94
+ exclude_operation_id_regex: str | None,
95
+ include_by: Callable | None,
96
+ exclude_by: Callable | None,
97
+ exclude_deprecated: bool,
98
+ ) -> FilterSet:
99
+ # Build explicit include filters
100
+ include_set = FilterSet()
101
+ if include_by:
102
+ include_set.include(include_by)
103
+ for name_ in include_name:
104
+ include_set.include(name=name_)
105
+ for method in include_method:
106
+ include_set.include(method=method)
107
+ for path in include_path:
108
+ include_set.include(path=path)
109
+ for tag in include_tag:
110
+ include_set.include(tag=tag)
111
+ for operation_id in include_operation_id:
112
+ include_set.include(operation_id=operation_id)
113
+ if (
114
+ include_name_regex
115
+ or include_method_regex
116
+ or include_path_regex
117
+ or include_tag_regex
118
+ or include_operation_id_regex
119
+ ):
120
+ include_set.include(
121
+ name_regex=include_name_regex,
122
+ method_regex=include_method_regex,
123
+ path_regex=include_path_regex,
124
+ tag_regex=include_tag_regex,
125
+ operation_id_regex=include_operation_id_regex,
126
+ )
127
+
128
+ # Build explicit exclude filters
129
+ exclude_set = FilterSet()
130
+ if exclude_by:
131
+ exclude_set.include(exclude_by)
132
+ for name_ in exclude_name:
133
+ exclude_set.include(name=name_)
134
+ for method in exclude_method:
135
+ exclude_set.include(method=method)
136
+ for path in exclude_path:
137
+ exclude_set.include(path=path)
138
+ for tag in exclude_tag:
139
+ exclude_set.include(tag=tag)
140
+ for operation_id in exclude_operation_id:
141
+ exclude_set.include(operation_id=operation_id)
142
+ if (
143
+ exclude_name_regex
144
+ or exclude_method_regex
145
+ or exclude_path_regex
146
+ or exclude_tag_regex
147
+ or exclude_operation_id_regex
148
+ ):
149
+ exclude_set.include(
150
+ name_regex=exclude_name_regex,
151
+ method_regex=exclude_method_regex,
152
+ path_regex=exclude_path_regex,
153
+ tag_regex=exclude_tag_regex,
154
+ operation_id_regex=exclude_operation_id_regex,
155
+ )
156
+
157
+ # Add deprecated operations to exclude filters if requested
158
+ if exclude_deprecated:
159
+ exclude_set.include(is_deprecated)
160
+
161
+ # Also update operations list for consistency with config structure
162
+ if not include_set.is_empty():
163
+ self.operations.insert(0, OperationConfig(filter_set=include_set, enabled=True))
164
+ if not exclude_set.is_empty():
165
+ self.operations.insert(0, OperationConfig(filter_set=exclude_set, enabled=False))
166
+
167
+ final = FilterSet()
168
+
169
+ # Get a stable reference to operations
170
+ operations = list(self.operations)
171
+
172
+ # Define a closure that implements our priority logic
173
+ def priority_filter(ctx: HasAPIOperation) -> bool:
174
+ """Filter operations according to CLI and config priority."""
175
+ # 1. CLI includes override everything if present
176
+ if not include_set.is_empty():
177
+ return include_set.match(ctx)
178
+
179
+ # 2. CLI excludes take precedence over config
180
+ if not exclude_set.is_empty() and exclude_set.match(ctx):
181
+ return False
182
+
183
+ # 3. Check config operations in priority order (first match wins)
184
+ for op_config in operations:
185
+ if op_config._filter_set.match(ctx):
186
+ return op_config.enabled
187
+
188
+ # 4. Default to include if no rule matches
189
+ return True
190
+
191
+ # Add our priority function as the filter
192
+ final.include(priority_filter)
193
+
194
+ return final
195
+
196
+
197
+ @dataclass
198
+ class OperationConfig(DiffBase):
199
+ _filter_set: FilterSet
200
+ enabled: bool
201
+ headers: dict | None
202
+ proxy: str | None
203
+ continue_on_failure: bool | None
204
+ tls_verify: bool | str | None
205
+ rate_limit: Limiter | None
206
+ request_timeout: float | int | None
207
+ request_cert: str | None
208
+ request_cert_key: str | None
209
+ parameters: dict[str, Any]
210
+ auth: AuthConfig
211
+ checks: ChecksConfig
212
+ phases: PhasesConfig
213
+ generation: GenerationConfig
214
+
215
+ __slots__ = (
216
+ "_filter_set",
217
+ "enabled",
218
+ "headers",
219
+ "proxy",
220
+ "continue_on_failure",
221
+ "tls_verify",
222
+ "rate_limit",
223
+ "_rate_limit",
224
+ "request_timeout",
225
+ "request_cert",
226
+ "request_cert_key",
227
+ "parameters",
228
+ "auth",
229
+ "checks",
230
+ "phases",
231
+ "generation",
232
+ )
233
+
234
+ def __init__(
235
+ self,
236
+ *,
237
+ filter_set: FilterSet | None = None,
238
+ enabled: bool = True,
239
+ headers: dict | None = None,
240
+ proxy: str | None = None,
241
+ continue_on_failure: bool | None = None,
242
+ tls_verify: bool | str | None = None,
243
+ rate_limit: str | None = None,
244
+ request_timeout: float | int | None = None,
245
+ request_cert: str | None = None,
246
+ request_cert_key: str | None = None,
247
+ parameters: dict[str, Any] | None = None,
248
+ auth: AuthConfig | None = None,
249
+ checks: ChecksConfig | None = None,
250
+ phases: PhasesConfig | None = None,
251
+ generation: GenerationConfig | None = None,
252
+ ) -> None:
253
+ self._filter_set = filter_set or FilterSet()
254
+ self.enabled = enabled
255
+ self.headers = headers
256
+ self.proxy = proxy
257
+ self.continue_on_failure = continue_on_failure
258
+ self.tls_verify = tls_verify
259
+ if rate_limit is not None:
260
+ self.rate_limit = build_limiter(rate_limit)
261
+ else:
262
+ self.rate_limit = rate_limit
263
+ self._rate_limit = rate_limit
264
+ self.request_timeout = request_timeout
265
+ self.request_cert = request_cert
266
+ self.request_cert_key = request_cert_key
267
+ self.parameters = parameters or {}
268
+ self.auth = auth or AuthConfig()
269
+ self.checks = checks or ChecksConfig()
270
+ self.phases = phases or PhasesConfig()
271
+ self.generation = generation or GenerationConfig()
272
+
273
+ @classmethod
274
+ def from_dict(cls, data: dict[str, Any]) -> OperationConfig:
275
+ filter_set = FilterSet()
276
+ for key_suffix, arg_suffix in (("", ""), ("-regex", "_regex")):
277
+ for attr, arg_name in FILTER_ATTRIBUTES:
278
+ key = f"include-{attr}{key_suffix}"
279
+ if key in data:
280
+ with reraise_filter_error(attr):
281
+ filter_set.include(**{f"{arg_name}{arg_suffix}": data[key]})
282
+ key = f"exclude-{attr}{key_suffix}"
283
+ if key in data:
284
+ with reraise_filter_error(attr):
285
+ filter_set.exclude(**{f"{arg_name}{arg_suffix}": data[key]})
286
+ for key, method in (("include-by", filter_set.include), ("exclude-by", filter_set.exclude)):
287
+ if key in data:
288
+ expression = data[key]
289
+ try:
290
+ func = expression_to_filter_function(expression)
291
+ method(func)
292
+ except ValueError:
293
+ raise ConfigError(f"Invalid filter expression: '{expression}'") from None
294
+
295
+ return cls(
296
+ filter_set=filter_set,
297
+ enabled=data.get("enabled", True),
298
+ headers={resolve(key): resolve(value) for key, value in data.get("headers", {}).items()}
299
+ if "headers" in data
300
+ else None,
301
+ proxy=resolve(data.get("proxy")),
302
+ continue_on_failure=data.get("continue-on-failure", None),
303
+ tls_verify=resolve(data.get("tls-verify")),
304
+ rate_limit=resolve(data.get("rate-limit")),
305
+ request_timeout=data.get("request-timeout"),
306
+ request_cert=resolve(data.get("request-cert")),
307
+ request_cert_key=resolve(data.get("request-cert-key")),
308
+ parameters=load_parameters(data),
309
+ auth=AuthConfig.from_dict(data.get("auth", {})),
310
+ checks=ChecksConfig.from_dict(data.get("checks", {})),
311
+ phases=PhasesConfig.from_dict(data.get("phases", {})),
312
+ generation=GenerationConfig.from_dict(data.get("generation", {})),
313
+ )