schemathesis 4.1.4__py3-none-any.whl → 4.2.1__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 (70) hide show
  1. schemathesis/cli/commands/run/executor.py +1 -1
  2. schemathesis/cli/commands/run/handlers/base.py +28 -1
  3. schemathesis/cli/commands/run/handlers/cassettes.py +109 -137
  4. schemathesis/cli/commands/run/handlers/junitxml.py +5 -6
  5. schemathesis/cli/commands/run/handlers/output.py +7 -1
  6. schemathesis/cli/ext/fs.py +1 -1
  7. schemathesis/config/_diff_base.py +3 -1
  8. schemathesis/config/_operations.py +2 -0
  9. schemathesis/config/_phases.py +21 -4
  10. schemathesis/config/_projects.py +10 -2
  11. schemathesis/core/adapter.py +34 -0
  12. schemathesis/core/errors.py +29 -5
  13. schemathesis/core/jsonschema/__init__.py +13 -0
  14. schemathesis/core/jsonschema/bundler.py +163 -0
  15. schemathesis/{specs/openapi/constants.py → core/jsonschema/keywords.py} +0 -8
  16. schemathesis/core/jsonschema/references.py +122 -0
  17. schemathesis/core/jsonschema/types.py +41 -0
  18. schemathesis/core/media_types.py +6 -4
  19. schemathesis/core/parameters.py +37 -0
  20. schemathesis/core/transforms.py +25 -2
  21. schemathesis/core/validation.py +19 -0
  22. schemathesis/engine/context.py +1 -1
  23. schemathesis/engine/errors.py +11 -18
  24. schemathesis/engine/phases/stateful/_executor.py +1 -1
  25. schemathesis/engine/phases/unit/_executor.py +30 -13
  26. schemathesis/errors.py +4 -0
  27. schemathesis/filters.py +2 -2
  28. schemathesis/generation/coverage.py +87 -11
  29. schemathesis/generation/hypothesis/__init__.py +79 -2
  30. schemathesis/generation/hypothesis/builder.py +108 -70
  31. schemathesis/generation/meta.py +5 -14
  32. schemathesis/generation/overrides.py +17 -17
  33. schemathesis/pytest/lazy.py +1 -1
  34. schemathesis/pytest/plugin.py +1 -6
  35. schemathesis/schemas.py +22 -72
  36. schemathesis/specs/graphql/schemas.py +27 -16
  37. schemathesis/specs/openapi/_hypothesis.py +83 -68
  38. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  39. schemathesis/specs/openapi/adapter/parameters.py +504 -0
  40. schemathesis/specs/openapi/adapter/protocol.py +57 -0
  41. schemathesis/specs/openapi/adapter/references.py +19 -0
  42. schemathesis/specs/openapi/adapter/responses.py +329 -0
  43. schemathesis/specs/openapi/adapter/security.py +141 -0
  44. schemathesis/specs/openapi/adapter/v2.py +28 -0
  45. schemathesis/specs/openapi/adapter/v3_0.py +28 -0
  46. schemathesis/specs/openapi/adapter/v3_1.py +28 -0
  47. schemathesis/specs/openapi/checks.py +99 -90
  48. schemathesis/specs/openapi/converter.py +114 -27
  49. schemathesis/specs/openapi/examples.py +210 -168
  50. schemathesis/specs/openapi/negative/__init__.py +12 -7
  51. schemathesis/specs/openapi/negative/mutations.py +68 -40
  52. schemathesis/specs/openapi/references.py +2 -175
  53. schemathesis/specs/openapi/schemas.py +142 -490
  54. schemathesis/specs/openapi/serialization.py +15 -7
  55. schemathesis/specs/openapi/stateful/__init__.py +17 -12
  56. schemathesis/specs/openapi/stateful/inference.py +13 -11
  57. schemathesis/specs/openapi/stateful/links.py +5 -20
  58. schemathesis/specs/openapi/types/__init__.py +3 -0
  59. schemathesis/specs/openapi/types/v3.py +68 -0
  60. schemathesis/specs/openapi/utils.py +1 -13
  61. schemathesis/transport/requests.py +3 -11
  62. schemathesis/transport/serialization.py +63 -27
  63. schemathesis/transport/wsgi.py +1 -8
  64. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/METADATA +2 -2
  65. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/RECORD +68 -53
  66. schemathesis/specs/openapi/parameters.py +0 -405
  67. schemathesis/specs/openapi/security.py +0 -162
  68. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/WHEEL +0 -0
  69. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/entry_points.txt +0 -0
  70. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -105,7 +105,7 @@ def initialize_handlers(
105
105
  if report.enabled:
106
106
  path = config.reports.get_path(format)
107
107
  open_file(path)
108
- handlers.append(CassetteWriter(format=format, path=path, config=config))
108
+ handlers.append(CassetteWriter(format=format, output=path, config=config))
109
109
 
110
110
  for custom_handler in CUSTOM_HANDLERS:
111
111
  handlers.append(custom_handler(*args, **params))
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING, Any
3
+ from contextlib import contextmanager
4
+ from io import StringIO
5
+ from pathlib import Path
6
+ from typing import IO, TYPE_CHECKING, Any, Generator, Protocol, Union
4
7
 
5
8
  if TYPE_CHECKING:
6
9
  from schemathesis.cli.commands.run.context import ExecutionContext
@@ -16,3 +19,27 @@ class EventHandler:
16
19
  def start(self, ctx: ExecutionContext) -> None: ...
17
20
 
18
21
  def shutdown(self, ctx: ExecutionContext) -> None: ...
22
+
23
+
24
+ class WritableText(Protocol):
25
+ """Protocol for text-writable file-like objects."""
26
+
27
+ def write(self, s: str) -> int: ... # pragma: no cover
28
+ def flush(self) -> None: ... # pragma: no cover
29
+
30
+
31
+ TextOutput = Union[IO[str], StringIO, Path]
32
+
33
+
34
+ @contextmanager
35
+ def open_text_output(output: TextOutput) -> Generator[IO[str]]:
36
+ """Open a text output, handling both Path and file-like objects."""
37
+ if isinstance(output, Path):
38
+ f = open(output, "w", encoding="utf-8")
39
+ try:
40
+ yield f
41
+ finally:
42
+ f.close()
43
+ else:
44
+ # Assume it's already a file-like object
45
+ yield output # type: ignore[misc]
@@ -6,7 +6,6 @@ import sys
6
6
  import threading
7
7
  from dataclasses import dataclass
8
8
  from http.cookies import SimpleCookie
9
- from pathlib import Path
10
9
  from queue import Queue
11
10
  from typing import IO, Callable, Iterator
12
11
  from urllib.parse import parse_qsl, urlparse
@@ -14,7 +13,7 @@ from urllib.parse import parse_qsl, urlparse
14
13
  import harfile
15
14
 
16
15
  from schemathesis.cli.commands.run.context import ExecutionContext
17
- from schemathesis.cli.commands.run.handlers.base import EventHandler
16
+ from schemathesis.cli.commands.run.handlers.base import EventHandler, TextOutput, open_text_output
18
17
  from schemathesis.config import ProjectConfig, ReportFormat, SchemathesisConfig
19
18
  from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
20
19
  from schemathesis.core.transforms import deepclone
@@ -33,27 +32,27 @@ class CassetteWriter(EventHandler):
33
32
  """Write network interactions to a cassette."""
34
33
 
35
34
  format: ReportFormat
36
- path: Path
35
+ output: TextOutput
37
36
  config: ProjectConfig
38
37
  queue: Queue
39
38
  worker: threading.Thread
40
39
 
41
- __slots__ = ("format", "path", "config", "queue", "worker")
40
+ __slots__ = ("format", "output", "config", "queue", "worker")
42
41
 
43
42
  def __init__(
44
43
  self,
45
44
  format: ReportFormat,
46
- path: Path,
45
+ output: TextOutput,
47
46
  config: ProjectConfig,
48
47
  queue: Queue | None = None,
49
48
  ) -> None:
50
49
  self.format = format
51
- self.path = path
50
+ self.output = output
52
51
  self.config = config
53
52
  self.queue = queue or Queue()
54
53
 
55
54
  kwargs = {
56
- "path": self.path,
55
+ "output": self.output,
57
56
  "config": self.config,
58
57
  "queue": self.queue,
59
58
  }
@@ -119,103 +118,85 @@ def get_command_representation() -> str:
119
118
  return f"st {args}"
120
119
 
121
120
 
122
- def vcr_writer(path: Path, config: ProjectConfig, queue: Queue) -> None:
123
- """Write YAML to a file in an incremental manner.
124
-
125
- This implementation doesn't use `pyyaml` package and composes YAML manually as string due to the following reasons:
126
- - It is much faster. The string-based approach gives only ~2.5% time overhead when `yaml.CDumper` has ~11.2%;
127
- - Implementation complexity. We have a quite simple format where almost all values are strings, and it is much
128
- simpler to implement it with string composition rather than with adjusting `yaml.Serializer` to emit explicit
129
- types. Another point is that with `pyyaml` we need to emit events and handle some low-level details like
130
- providing tags, anchors to have incremental writing, with primitive types it is much simpler.
131
- """
121
+ def vcr_writer(output: TextOutput, config: ProjectConfig, queue: Queue) -> None:
122
+ """Write YAML to a file in an incremental manner."""
132
123
  current_id = 1
133
124
 
134
- def format_header_values(values: list[str]) -> str:
135
- return "\n".join(f" - {json.dumps(v)}" for v in values)
125
+ def write_header_values(stream: IO, values: list[str]) -> None:
126
+ for v in values:
127
+ stream.write(f" - {json.dumps(v)}\n")
136
128
 
137
129
  if config.output.sanitization.enabled:
138
-
139
- def format_headers(headers: dict[str, list[str]]) -> str:
140
- headers = deepclone(headers)
141
- sanitize_value(headers, config=config.output.sanitization)
142
- return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
143
-
130
+ sanitization_keys = config.output.sanitization.keys_to_sanitize
131
+ sensitive_markers = config.output.sanitization.sensitive_markers
132
+ replacement = config.output.sanitization.replacement
133
+
134
+ def write_headers(stream: IO, headers: dict[str, list[str]]) -> None:
135
+ for name, values in headers.items():
136
+ lower_name = name.lower()
137
+ stream.write(f' "{name}":\n')
138
+
139
+ # Sanitize inline if needed
140
+ if lower_name in sanitization_keys or any(marker in lower_name for marker in sensitive_markers):
141
+ stream.write(f" - {json.dumps(replacement)}\n")
142
+ else:
143
+ write_header_values(stream, values)
144
144
  else:
145
145
 
146
- def format_headers(headers: dict[str, list[str]]) -> str:
147
- return "\n".join(f' "{name}":\n{format_header_values(values)}' for name, values in headers.items())
146
+ def write_headers(stream: IO, headers: dict[str, list[str]]) -> None:
147
+ for name, values in headers.items():
148
+ stream.write(f' "{name}":\n')
149
+ write_header_values(stream, values)
148
150
 
149
- def format_check_message(message: str | None) -> str:
150
- return "~" if message is None else f"{message!r}"
151
-
152
- def format_checks(checks: list[CheckNode]) -> str:
151
+ def write_checks(stream: IO, checks: list[CheckNode]) -> None:
153
152
  if not checks:
154
- return "\n checks: []"
155
- items = "\n".join(
156
- f" - name: '{check.name}'\n status: '{check.status.name.upper()}'\n message: {format_check_message(check.failure_info.failure.title if check.failure_info else None)}"
157
- for check in checks
158
- )
159
- return f"""
160
- checks:
161
- {items}"""
153
+ stream.write("\n checks: []")
154
+ return
155
+
156
+ stream.write("\n checks:\n")
157
+ for check in checks:
158
+ message = check.failure_info.failure.title if check.failure_info else None
159
+ message_str = "~" if message is None else repr(message)
160
+ stream.write(
161
+ f" - name: '{check.name}'\n"
162
+ f" status: '{check.status.name.upper()}'\n"
163
+ f" message: {message_str}\n"
164
+ )
162
165
 
163
166
  if config.reports.preserve_bytes:
164
167
 
165
- def format_request_body(output: IO, request: Request) -> None:
168
+ def write_request_body(stream: IO, request: Request) -> None:
166
169
  if request.encoded_body is not None:
167
- output.write(
168
- f"""
169
- body:
170
- encoding: 'utf-8'
171
- base64_string: '{request.encoded_body}'"""
172
- )
170
+ stream.write(f"\n body:\n encoding: 'utf-8'\n base64_string: '{request.encoded_body}'")
173
171
 
174
- def format_response_body(output: IO, response: Response) -> None:
172
+ def write_response_body(stream: IO, response: Response) -> None:
175
173
  if response.encoded_body is not None:
176
- output.write(
177
- f""" body:
178
- encoding: '{response.encoding}'
179
- base64_string: '{response.encoded_body}'"""
174
+ stream.write(
175
+ f" body:\n encoding: '{response.encoding}'\n base64_string: '{response.encoded_body}'"
180
176
  )
181
-
182
177
  else:
183
178
 
184
- def format_request_body(output: IO, request: Request) -> None:
179
+ def write_request_body(stream: IO, request: Request) -> None:
185
180
  if request.body is not None:
186
181
  string = request.body.decode("utf8", "replace")
187
- output.write(
188
- """
189
- body:
190
- encoding: 'utf-8'
191
- string: """
192
- )
193
- write_double_quoted(output, string)
182
+ stream.write("\n body:\n encoding: 'utf-8'\n string: ")
183
+ write_double_quoted(stream, string)
194
184
 
195
- def format_response_body(output: IO, response: Response) -> None:
185
+ def write_response_body(stream: IO, response: Response) -> None:
196
186
  if response.content is not None:
197
187
  encoding = response.encoding or "utf8"
198
188
  string = response.content.decode(encoding, "replace")
199
- output.write(
200
- f""" body:
201
- encoding: '{encoding}'
202
- string: """
203
- )
204
- write_double_quoted(output, string)
189
+ stream.write(f" body:\n encoding: '{encoding}'\n string: ")
190
+ write_double_quoted(stream, string)
205
191
 
206
- with open(path, "w", encoding="utf-8") as stream:
192
+ with open_text_output(output) as stream:
207
193
  while True:
208
194
  item = queue.get()
209
- if isinstance(item, Initialize):
210
- stream.write(
211
- f"""command: '{get_command_representation()}'
212
- recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'
213
- seed: {item.seed}
214
- http_interactions:"""
215
- )
216
- elif isinstance(item, Process):
195
+ if isinstance(item, Process):
217
196
  for case_id, interaction in item.recorder.interactions.items():
218
197
  case = item.recorder.cases[case_id]
198
+
199
+ # Determine status and checks
219
200
  if interaction.response is not None:
220
201
  if case_id in item.recorder.checks:
221
202
  checks = item.recorder.checks[case_id]
@@ -225,101 +206,93 @@ http_interactions:"""
225
206
  status = check.status
226
207
  break
227
208
  else:
228
- # NOTE: Checks recording could be skipped if Schemathesis start skipping just
229
- # discovered failures in order to get past them and potentially discover more failures
230
209
  checks = []
231
210
  status = Status.SKIP
232
211
  else:
233
212
  checks = []
234
213
  status = Status.ERROR
235
- # Body payloads are handled via separate `stream.write` calls to avoid some allocations
236
- stream.write(
237
- f"""\n- id: '{case_id}'
238
- status: '{status.name}'"""
239
- )
214
+
215
+ # Write interaction header
216
+ stream.write(f"\n- id: '{case_id}'\n status: '{status.name}'")
217
+
218
+ # Write metadata if present
240
219
  meta = case.value.meta
241
220
  if meta is not None:
242
- # Start metadata block
243
- stream.write(f"""
244
- generation:
245
- time: {meta.generation.time}
246
- mode: {meta.generation.mode.value}
247
- components:""")
248
-
249
- # Write components
221
+ stream.write(
222
+ f"\n generation:\n"
223
+ f" time: {meta.generation.time}\n"
224
+ f" mode: {meta.generation.mode.value}\n"
225
+ f" components:"
226
+ )
227
+
250
228
  for kind, info in meta.components.items():
251
- stream.write(f"""
252
- {kind.value}:
253
- mode: '{info.mode.value}'""")
254
- # Write phase info
255
- stream.write("\n phase:")
256
- stream.write(f"\n name: '{meta.phase.name.value}'")
257
- stream.write("\n data: ")
258
-
259
- # Write phase-specific data
229
+ stream.write(f"\n {kind.value}:\n mode: '{info.mode.value}'")
230
+
231
+ stream.write(f"\n phase:\n name: '{meta.phase.name.value}'\n data: ")
232
+
260
233
  if isinstance(meta.phase.data, CoveragePhaseData):
261
- stream.write("""
262
- description: """)
234
+ stream.write("\n description: ")
263
235
  write_double_quoted(stream, meta.phase.data.description)
264
- stream.write("""
265
- location: """)
236
+ stream.write("\n location: ")
266
237
  write_double_quoted(stream, meta.phase.data.location)
267
- stream.write("""
268
- parameter: """)
238
+ stream.write("\n parameter: ")
269
239
  if meta.phase.data.parameter is not None:
270
240
  write_double_quoted(stream, meta.phase.data.parameter)
271
241
  else:
272
242
  stream.write("null")
273
- stream.write("""
274
- parameter_location: """)
243
+ stream.write("\n parameter_location: ")
275
244
  if meta.phase.data.parameter_location is not None:
276
245
  write_double_quoted(stream, meta.phase.data.parameter_location)
277
246
  else:
278
247
  stream.write("null")
279
248
  else:
280
- # Empty objects for these phases
281
249
  stream.write("{}")
282
250
  else:
283
- stream.write("null")
251
+ stream.write("\n metadata: null")
284
252
 
253
+ # Sanitize URL if needed
285
254
  if config.output.sanitization.enabled:
286
255
  uri = sanitize_url(interaction.request.uri, config=config.output.sanitization)
287
256
  else:
288
257
  uri = interaction.request.uri
258
+
289
259
  recorded_at = datetime.datetime.fromtimestamp(
290
260
  interaction.timestamp, datetime.timezone.utc
291
261
  ).isoformat()
262
+
263
+ stream.write(f"\n recorded_at: '{recorded_at}'")
264
+ write_checks(stream, checks)
292
265
  stream.write(
293
- f"""
294
- recorded_at: '{recorded_at}'{format_checks(checks)}
295
- request:
296
- uri: '{uri}'
297
- method: '{interaction.request.method}'
298
- headers:
299
- {format_headers(interaction.request.headers)}"""
266
+ f"\n request:\n uri: '{uri}'\n method: '{interaction.request.method}'\n headers:\n"
300
267
  )
301
- format_request_body(stream, interaction.request)
268
+ write_headers(stream, interaction.request.headers)
269
+ write_request_body(stream, interaction.request)
270
+
271
+ # Write response
302
272
  if interaction.response is not None:
303
273
  stream.write(
304
- f"""
305
- response:
306
- status:
307
- code: '{interaction.response.status_code}'
308
- message: {json.dumps(interaction.response.message)}
309
- elapsed: '{interaction.response.elapsed}'
310
- headers:
311
- {format_headers(interaction.response.headers)}
312
- """
313
- )
314
- format_response_body(stream, interaction.response)
315
- stream.write(
316
- f"""
317
- http_version: '{interaction.response.http_version}'"""
274
+ f"\n response:\n"
275
+ f" status:\n"
276
+ f" code: '{interaction.response.status_code}'\n"
277
+ f" message: {json.dumps(interaction.response.message)}\n"
278
+ f" elapsed: '{interaction.response.elapsed}'\n"
279
+ f" headers:\n"
318
280
  )
281
+ write_headers(stream, interaction.response.headers)
282
+ stream.write("\n")
283
+ write_response_body(stream, interaction.response)
284
+ stream.write(f"\n http_version: '{interaction.response.http_version}'")
319
285
  else:
320
- stream.write("""
321
- response: null""")
286
+ stream.write("\n response: null")
287
+
322
288
  current_id += 1
289
+ elif isinstance(item, Initialize):
290
+ stream.write(
291
+ f"command: '{get_command_representation()}'\n"
292
+ f"recorded_with: 'Schemathesis {SCHEMATHESIS_VERSION}'\n"
293
+ f"seed: {item.seed}\n"
294
+ f"http_interactions:"
295
+ )
323
296
  else:
324
297
  break
325
298
 
@@ -367,8 +340,8 @@ def write_double_quoted(stream: IO, text: str | None) -> None:
367
340
  stream.write('"')
368
341
 
369
342
 
370
- def har_writer(path: Path, config: SchemathesisConfig, queue: Queue) -> None:
371
- with harfile.open(path) as har:
343
+ def har_writer(output: TextOutput, config: SchemathesisConfig, queue: Queue) -> None:
344
+ with harfile.open(output) as har:
372
345
  while True:
373
346
  item = queue.get()
374
347
  if isinstance(item, Process):
@@ -454,7 +427,6 @@ def har_writer(path: Path, config: SchemathesisConfig, queue: Queue) -> None:
454
427
  )
455
428
  elif isinstance(item, Finalize):
456
429
  break
457
- har.flush()
458
430
 
459
431
 
460
432
  HARFILE_NO_RESPONSE = harfile.Response(
@@ -2,26 +2,25 @@ from __future__ import annotations
2
2
 
3
3
  import platform
4
4
  from dataclasses import dataclass
5
- from pathlib import Path
6
5
  from typing import Iterable
7
6
 
8
7
  from junit_xml import TestCase, TestSuite, to_xml_report_file
9
8
 
10
9
  from schemathesis.cli.commands.run.context import ExecutionContext, GroupedFailures
11
- from schemathesis.cli.commands.run.handlers.base import EventHandler
10
+ from schemathesis.cli.commands.run.handlers.base import EventHandler, TextOutput, open_text_output
12
11
  from schemathesis.core.failures import format_failures
13
12
  from schemathesis.engine import Status, events
14
13
 
15
14
 
16
15
  @dataclass
17
16
  class JunitXMLHandler(EventHandler):
18
- path: Path
17
+ output: TextOutput
19
18
  test_cases: dict
20
19
 
21
20
  __slots__ = ("path", "test_cases")
22
21
 
23
- def __init__(self, path: Path, test_cases: dict | None = None) -> None:
24
- self.path = path
22
+ def __init__(self, output: TextOutput, test_cases: dict | None = None) -> None:
23
+ self.output = output
25
24
  self.test_cases = test_cases or {}
26
25
 
27
26
  def handle_event(self, ctx: ExecutionContext, event: events.EngineEvent) -> None:
@@ -40,7 +39,7 @@ class JunitXMLHandler(EventHandler):
40
39
  test_suites = [
41
40
  TestSuite("schemathesis", test_cases=list(self.test_cases.values()), hostname=platform.node())
42
41
  ]
43
- with open(self.path, "w", encoding="utf-8") as fd:
42
+ with open_text_output(self.output) as fd:
44
43
  to_xml_report_file(file_descriptor=fd, test_suites=test_suites, prettyprint=True, encoding="utf-8")
45
44
 
46
45
  def get_or_create_test_case(self, label: str) -> TestCase:
@@ -1053,6 +1053,8 @@ class OutputHandler(EventHandler):
1053
1053
  self._check_stateful_warnings(ctx, event)
1054
1054
 
1055
1055
  def _check_warnings(self, ctx: ExecutionContext, event: events.ScenarioFinished) -> None:
1056
+ from schemathesis.core.compat import RefResolutionError
1057
+
1056
1058
  statistic = aggregate_status_codes(event.recorder.interactions.values())
1057
1059
 
1058
1060
  if statistic.total == 0:
@@ -1060,7 +1062,11 @@ class OutputHandler(EventHandler):
1060
1062
 
1061
1063
  assert ctx.find_operation_by_label is not None
1062
1064
  assert event.label is not None
1063
- operation = ctx.find_operation_by_label(event.label)
1065
+ try:
1066
+ operation = ctx.find_operation_by_label(event.label)
1067
+ except RefResolutionError:
1068
+ # This error will be reported elsewhere anyway
1069
+ return None
1064
1070
 
1065
1071
  warnings = self.config.warnings_for(operation=operation)
1066
1072
 
@@ -12,5 +12,5 @@ def open_file(file: Path) -> None:
12
12
  raise click.BadParameter(f"'{file.name}': {exc.strerror}") from exc
13
13
  try:
14
14
  file.open("w", encoding="utf-8")
15
- except OSError as exc:
15
+ except (OSError, ValueError) as exc:
16
16
  raise click.BadParameter(f"Could not open file {file.name}: {exc}") from exc
@@ -69,12 +69,14 @@ class DiffBase:
69
69
  @classmethod
70
70
  def from_hierarchy(cls, configs: list[T]) -> T:
71
71
  # This config will accumulate "merged" config options
72
+ if len(configs) == 1:
73
+ return configs[0]
72
74
  output = cls()
73
75
  for option in cls.__slots__: # type: ignore
74
76
  if option.startswith("_"):
75
77
  continue
76
78
  default = getattr(output, option)
77
- if is_dataclass(default):
79
+ if hasattr(default, "__dataclass_fields__"):
78
80
  # Sub-configs require merging of nested config options
79
81
  sub_configs = [getattr(config, option) for config in configs]
80
82
  merged = type(default).from_hierarchy(sub_configs) # type: ignore[union-attr]
@@ -68,6 +68,8 @@ class OperationsConfig(DiffBase):
68
68
 
69
69
  def get_for_operation(self, operation: APIOperation) -> OperationConfig:
70
70
  configs = [config for config in self.operations if config._filter_set.applies_to(operation)]
71
+ if not configs:
72
+ return OperationConfig()
71
73
  return OperationConfig.from_hierarchy(configs)
72
74
 
73
75
  def create_filter_set(
@@ -17,7 +17,7 @@ class PhaseConfig(DiffBase):
17
17
  generation: GenerationConfig
18
18
  checks: ChecksConfig
19
19
 
20
- __slots__ = ("enabled", "generation", "checks")
20
+ __slots__ = ("enabled", "generation", "checks", "_is_default")
21
21
 
22
22
  def __init__(
23
23
  self,
@@ -29,6 +29,7 @@ class PhaseConfig(DiffBase):
29
29
  self.enabled = enabled
30
30
  self.generation = generation or GenerationConfig()
31
31
  self.checks = checks or ChecksConfig()
32
+ self._is_default = enabled and generation is None and checks is None
32
33
 
33
34
  @classmethod
34
35
  def from_dict(cls, data: dict[str, Any]) -> PhaseConfig:
@@ -46,7 +47,7 @@ class ExamplesPhaseConfig(DiffBase):
46
47
  generation: GenerationConfig
47
48
  checks: ChecksConfig
48
49
 
49
- __slots__ = ("enabled", "fill_missing", "generation", "checks")
50
+ __slots__ = ("enabled", "fill_missing", "generation", "checks", "_is_default")
50
51
 
51
52
  def __init__(
52
53
  self,
@@ -60,6 +61,7 @@ class ExamplesPhaseConfig(DiffBase):
60
61
  self.fill_missing = fill_missing
61
62
  self.generation = generation or GenerationConfig()
62
63
  self.checks = checks or ChecksConfig()
64
+ self._is_default = enabled and not fill_missing and generation is None and checks is None
63
65
 
64
66
  @classmethod
65
67
  def from_dict(cls, data: dict[str, Any]) -> ExamplesPhaseConfig:
@@ -79,7 +81,14 @@ class CoveragePhaseConfig(DiffBase):
79
81
  checks: ChecksConfig
80
82
  unexpected_methods: set[str]
81
83
 
82
- __slots__ = ("enabled", "generate_duplicate_query_parameters", "generation", "checks", "unexpected_methods")
84
+ __slots__ = (
85
+ "enabled",
86
+ "generate_duplicate_query_parameters",
87
+ "generation",
88
+ "checks",
89
+ "unexpected_methods",
90
+ "_is_default",
91
+ )
83
92
 
84
93
  def __init__(
85
94
  self,
@@ -95,6 +104,13 @@ class CoveragePhaseConfig(DiffBase):
95
104
  self.unexpected_methods = unexpected_methods or DEFAULT_UNEXPECTED_METHODS
96
105
  self.generation = generation or GenerationConfig()
97
106
  self.checks = checks or ChecksConfig()
107
+ self._is_default = (
108
+ enabled
109
+ and not generate_duplicate_query_parameters
110
+ and generation is None
111
+ and checks is None
112
+ and unexpected_methods is None
113
+ )
98
114
 
99
115
  @classmethod
100
116
  def from_dict(cls, data: dict[str, Any]) -> CoveragePhaseConfig:
@@ -142,7 +158,7 @@ class StatefulPhaseConfig(DiffBase):
142
158
  max_steps: int
143
159
  inference: InferenceConfig
144
160
 
145
- __slots__ = ("enabled", "generation", "checks", "max_steps", "inference")
161
+ __slots__ = ("enabled", "generation", "checks", "max_steps", "inference", "_is_default")
146
162
 
147
163
  def __init__(
148
164
  self,
@@ -158,6 +174,7 @@ class StatefulPhaseConfig(DiffBase):
158
174
  self.generation = generation or GenerationConfig()
159
175
  self.checks = checks or ChecksConfig()
160
176
  self.inference = inference or InferenceConfig()
177
+ self._is_default = enabled and generation is None and checks is None and max_steps is None and inference is None
161
178
 
162
179
  @classmethod
163
180
  def from_dict(cls, data: dict[str, Any]) -> StatefulPhaseConfig:
@@ -347,6 +347,8 @@ class ProjectConfig(DiffBase):
347
347
  for op in self.operations.operations:
348
348
  if op._filter_set.applies_to(operation=operation):
349
349
  configs.append(op.phases)
350
+ if not configs:
351
+ return self.phases
350
352
  configs.append(self.phases)
351
353
  return PhasesConfig.from_hierarchy(configs)
352
354
 
@@ -367,7 +369,10 @@ class ProjectConfig(DiffBase):
367
369
  if phase is not None:
368
370
  phases = self.phases_for(operation=operation)
369
371
  phase_config = phases.get_by_name(name=phase)
370
- configs.append(phase_config.generation)
372
+ if not phase_config._is_default:
373
+ configs.append(phase_config.generation)
374
+ if not configs:
375
+ return self.generation
371
376
  configs.append(self.generation)
372
377
  return GenerationConfig.from_hierarchy(configs)
373
378
 
@@ -388,7 +393,10 @@ class ProjectConfig(DiffBase):
388
393
  if phase is not None:
389
394
  phases = self.phases_for(operation=operation)
390
395
  phase_config = phases.get_by_name(name=phase)
391
- configs.append(phase_config.checks)
396
+ if not phase_config._is_default:
397
+ configs.append(phase_config.checks)
398
+ if not configs:
399
+ return self.checks
392
400
  configs.append(self.checks)
393
401
  return ChecksConfig.from_hierarchy(configs)
394
402