serenecode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- serenecode/__init__.py +281 -0
- serenecode/adapters/__init__.py +6 -0
- serenecode/adapters/coverage_adapter.py +1173 -0
- serenecode/adapters/crosshair_adapter.py +1069 -0
- serenecode/adapters/hypothesis_adapter.py +1824 -0
- serenecode/adapters/local_fs.py +169 -0
- serenecode/adapters/module_loader.py +492 -0
- serenecode/adapters/mypy_adapter.py +161 -0
- serenecode/checker/__init__.py +6 -0
- serenecode/checker/compositional.py +2216 -0
- serenecode/checker/coverage.py +186 -0
- serenecode/checker/properties.py +154 -0
- serenecode/checker/structural.py +1504 -0
- serenecode/checker/symbolic.py +178 -0
- serenecode/checker/types.py +148 -0
- serenecode/cli.py +478 -0
- serenecode/config.py +711 -0
- serenecode/contracts/__init__.py +6 -0
- serenecode/contracts/predicates.py +176 -0
- serenecode/core/__init__.py +6 -0
- serenecode/core/exceptions.py +38 -0
- serenecode/core/pipeline.py +807 -0
- serenecode/init.py +307 -0
- serenecode/models.py +308 -0
- serenecode/ports/__init__.py +6 -0
- serenecode/ports/coverage_analyzer.py +124 -0
- serenecode/ports/file_system.py +95 -0
- serenecode/ports/property_tester.py +69 -0
- serenecode/ports/symbolic_checker.py +70 -0
- serenecode/ports/type_checker.py +66 -0
- serenecode/reporter.py +346 -0
- serenecode/source_discovery.py +319 -0
- serenecode/templates/__init__.py +5 -0
- serenecode/templates/content.py +337 -0
- serenecode-0.1.0.dist-info/METADATA +298 -0
- serenecode-0.1.0.dist-info/RECORD +39 -0
- serenecode-0.1.0.dist-info/WHEEL +4 -0
- serenecode-0.1.0.dist-info/entry_points.txt +2 -0
- serenecode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1824 @@
|
|
|
1
|
+
"""Hypothesis adapter for property-based testing (Level 3).
|
|
2
|
+
|
|
3
|
+
This adapter implements the PropertyTester protocol by running
|
|
4
|
+
Hypothesis tests against functions that have icontract decorators.
|
|
5
|
+
It uses icontract's runtime checking to detect postcondition violations.
|
|
6
|
+
|
|
7
|
+
This is an adapter module — it handles I/O (module importing, test
|
|
8
|
+
execution) and is exempt from full contract requirements.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import collections.abc
|
|
15
|
+
import enum
|
|
16
|
+
import inspect
|
|
17
|
+
import re
|
|
18
|
+
import traceback
|
|
19
|
+
import types
|
|
20
|
+
import typing
|
|
21
|
+
from typing import Callable, cast
|
|
22
|
+
|
|
23
|
+
import icontract
|
|
24
|
+
|
|
25
|
+
from serenecode.adapters.module_loader import load_python_module
|
|
26
|
+
from serenecode.contracts.predicates import is_non_empty_string, is_positive_int
|
|
27
|
+
from serenecode.core.exceptions import ToolNotInstalledError, UnsafeCodeExecutionError
|
|
28
|
+
from serenecode.ports.property_tester import PropertyFinding
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from hypothesis import given, settings, HealthCheck, Verbosity
|
|
32
|
+
from hypothesis import strategies as st
|
|
33
|
+
from hypothesis.strategies import SearchStrategy
|
|
34
|
+
_HYPOTHESIS_AVAILABLE = True
|
|
35
|
+
except ImportError:
|
|
36
|
+
_HYPOTHESIS_AVAILABLE = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_TRUST_REQUIRED_MESSAGE = (
|
|
40
|
+
"Level 3 property testing imports and executes project modules. "
|
|
41
|
+
"Re-run with allow_code_execution=True only for trusted code."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@icontract.require(
|
|
46
|
+
lambda annotation: annotation is None or isinstance(annotation, object),
|
|
47
|
+
"annotation must be a Python object or None",
|
|
48
|
+
)
|
|
49
|
+
@icontract.ensure(
|
|
50
|
+
lambda result: result is None or hasattr(result, "map"),
|
|
51
|
+
"result must be a Hypothesis strategy or None",
|
|
52
|
+
)
|
|
53
|
+
def _get_strategy_for_annotation(annotation: type | None) -> SearchStrategy | None:
|
|
54
|
+
return _get_strategy_for_annotation_with_seen(annotation, frozenset())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@icontract.require(
|
|
58
|
+
lambda seen_classes: isinstance(seen_classes, frozenset),
|
|
59
|
+
"seen_classes must be a frozenset",
|
|
60
|
+
)
|
|
61
|
+
@icontract.ensure(
|
|
62
|
+
lambda result: result is None or hasattr(result, "map"),
|
|
63
|
+
"result must be a Hypothesis strategy or None",
|
|
64
|
+
)
|
|
65
|
+
def _get_strategy_for_annotation_with_seen(
|
|
66
|
+
annotation: type | None,
|
|
67
|
+
seen_classes: frozenset[type],
|
|
68
|
+
) -> SearchStrategy | None:
|
|
69
|
+
"""Derive a Hypothesis strategy from a type annotation.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
annotation: A Python type annotation.
|
|
73
|
+
seen_classes: Classes already visited while deriving nested strategies.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A Hypothesis strategy, or None if the type is unsupported.
|
|
77
|
+
"""
|
|
78
|
+
if not _HYPOTHESIS_AVAILABLE:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
if annotation is None:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
if annotation is type(None):
|
|
85
|
+
return st.none()
|
|
86
|
+
|
|
87
|
+
known_strategy = _strategy_for_known_annotation(annotation, seen_classes)
|
|
88
|
+
if known_strategy is not None:
|
|
89
|
+
return known_strategy
|
|
90
|
+
|
|
91
|
+
# Handle basic types
|
|
92
|
+
strategy_map: dict[type, SearchStrategy] = {
|
|
93
|
+
int: st.integers(min_value=-1000, max_value=1000),
|
|
94
|
+
float: st.floats(
|
|
95
|
+
min_value=-1e6, max_value=1e6,
|
|
96
|
+
allow_nan=False, allow_infinity=False,
|
|
97
|
+
),
|
|
98
|
+
str: st.text(min_size=0, max_size=100),
|
|
99
|
+
bool: st.booleans(),
|
|
100
|
+
bytes: st.binary(min_size=0, max_size=100),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if annotation in strategy_map:
|
|
104
|
+
return strategy_map[annotation]
|
|
105
|
+
|
|
106
|
+
if annotation is object:
|
|
107
|
+
return st.one_of(
|
|
108
|
+
st.none(),
|
|
109
|
+
st.booleans(),
|
|
110
|
+
st.integers(min_value=-10, max_value=10),
|
|
111
|
+
st.text(min_size=0, max_size=20),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Handle generic types (list[int], etc.)
|
|
115
|
+
origin = typing.get_origin(annotation)
|
|
116
|
+
args = typing.get_args(annotation)
|
|
117
|
+
|
|
118
|
+
if origin in (typing.Union, types.UnionType):
|
|
119
|
+
strategies = [
|
|
120
|
+
strategy
|
|
121
|
+
for arg in args
|
|
122
|
+
if (strategy := _get_strategy_for_annotation_with_seen(arg, seen_classes)) is not None
|
|
123
|
+
]
|
|
124
|
+
if strategies:
|
|
125
|
+
return st.one_of(*strategies)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
if origin is typing.Literal:
|
|
129
|
+
return st.sampled_from(args)
|
|
130
|
+
|
|
131
|
+
if origin is list and args:
|
|
132
|
+
inner = _get_strategy_for_annotation_with_seen(args[0], seen_classes)
|
|
133
|
+
if inner is not None:
|
|
134
|
+
return st.lists(inner, min_size=0, max_size=20)
|
|
135
|
+
|
|
136
|
+
if origin is set and args:
|
|
137
|
+
inner = _get_strategy_for_annotation_with_seen(args[0], seen_classes)
|
|
138
|
+
if inner is not None:
|
|
139
|
+
return st.sets(inner, max_size=20)
|
|
140
|
+
|
|
141
|
+
if origin is frozenset and args:
|
|
142
|
+
inner = _get_strategy_for_annotation_with_seen(args[0], seen_classes)
|
|
143
|
+
if inner is not None:
|
|
144
|
+
return st.frozensets(inner, max_size=20)
|
|
145
|
+
|
|
146
|
+
if origin is tuple and args:
|
|
147
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
148
|
+
inner = _get_strategy_for_annotation_with_seen(args[0], seen_classes)
|
|
149
|
+
if inner is not None:
|
|
150
|
+
return st.lists(inner, min_size=0, max_size=8).map(tuple)
|
|
151
|
+
|
|
152
|
+
inner_strats = []
|
|
153
|
+
# Loop invariant: inner_strats contains strategies for args[0..i]
|
|
154
|
+
for arg in args:
|
|
155
|
+
s = _get_strategy_for_annotation_with_seen(arg, seen_classes)
|
|
156
|
+
if s is None:
|
|
157
|
+
return None
|
|
158
|
+
inner_strats.append(s)
|
|
159
|
+
return st.tuples(*inner_strats)
|
|
160
|
+
|
|
161
|
+
if origin is dict and args and len(args) == 2:
|
|
162
|
+
key_strat = _get_strategy_for_annotation_with_seen(args[0], seen_classes)
|
|
163
|
+
val_strat = _get_strategy_for_annotation_with_seen(args[1], seen_classes)
|
|
164
|
+
if key_strat is not None and val_strat is not None:
|
|
165
|
+
return st.dictionaries(key_strat, val_strat, max_size=10)
|
|
166
|
+
|
|
167
|
+
if origin in (Callable, collections.abc.Callable):
|
|
168
|
+
return st.just(_make_callable_stub(annotation))
|
|
169
|
+
|
|
170
|
+
if inspect.isclass(annotation):
|
|
171
|
+
if _is_protocol_class(annotation):
|
|
172
|
+
return _strategy_for_protocol(annotation)
|
|
173
|
+
if issubclass(annotation, enum.Enum):
|
|
174
|
+
members = list(annotation)
|
|
175
|
+
if members:
|
|
176
|
+
return st.sampled_from(members)
|
|
177
|
+
return None
|
|
178
|
+
if issubclass(annotation, ast.AST):
|
|
179
|
+
return _strategy_for_ast(annotation)
|
|
180
|
+
if annotation in seen_classes:
|
|
181
|
+
return None
|
|
182
|
+
return _strategy_for_class(annotation, seen_classes | frozenset({annotation}))
|
|
183
|
+
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@icontract.require(lambda func: callable(func), "func must be callable")
|
|
188
|
+
@icontract.require(
|
|
189
|
+
lambda seen_classes: isinstance(seen_classes, frozenset),
|
|
190
|
+
"seen_classes must be a frozenset",
|
|
191
|
+
)
|
|
192
|
+
@icontract.ensure(
|
|
193
|
+
lambda result: result is None or isinstance(result, dict),
|
|
194
|
+
"result must be a strategy dictionary or None",
|
|
195
|
+
)
|
|
196
|
+
def _build_strategies_from_signature(
|
|
197
|
+
func: Callable[..., object],
|
|
198
|
+
seen_classes: frozenset[type] = frozenset(),
|
|
199
|
+
) -> dict[str, SearchStrategy] | None:
|
|
200
|
+
"""Build Hypothesis strategies from a function's type annotations.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
func: The function to build strategies for.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
A dict mapping parameter names to strategies, or None if
|
|
207
|
+
strategies cannot be derived for all parameters.
|
|
208
|
+
"""
|
|
209
|
+
if not _HYPOTHESIS_AVAILABLE:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
# Use get_type_hints to resolve string annotations (from __future__ import annotations)
|
|
213
|
+
try:
|
|
214
|
+
hints = typing.get_type_hints(func)
|
|
215
|
+
except Exception:
|
|
216
|
+
hints = {}
|
|
217
|
+
|
|
218
|
+
sig = inspect.signature(func)
|
|
219
|
+
strategies: dict[str, SearchStrategy] = {}
|
|
220
|
+
annotations: dict[str, object] = {}
|
|
221
|
+
# Loop invariant: strategies contains entries for all processable params[0..i]
|
|
222
|
+
for name, param in sig.parameters.items():
|
|
223
|
+
if name in ("self", "cls"):
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
# Prefer resolved type hints over raw annotations
|
|
227
|
+
annotation = hints.get(name, param.annotation)
|
|
228
|
+
if annotation is inspect.Parameter.empty:
|
|
229
|
+
return None # can't derive strategy without annotation
|
|
230
|
+
|
|
231
|
+
strategy = _get_strategy_for_annotation_with_seen(annotation, seen_classes)
|
|
232
|
+
if strategy is None:
|
|
233
|
+
return None # unsupported type
|
|
234
|
+
|
|
235
|
+
strategies[name] = strategy
|
|
236
|
+
annotations[name] = annotation
|
|
237
|
+
|
|
238
|
+
if not strategies:
|
|
239
|
+
return {}
|
|
240
|
+
|
|
241
|
+
# Refine strategies using icontract preconditions
|
|
242
|
+
strategies = _refine_strategies_with_preconditions(
|
|
243
|
+
func,
|
|
244
|
+
strategies,
|
|
245
|
+
annotations,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return strategies
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@icontract.require(lambda annotation: inspect.isclass(annotation), "annotation must be a class")
|
|
252
|
+
@icontract.require(
|
|
253
|
+
lambda seen_classes: isinstance(seen_classes, frozenset),
|
|
254
|
+
"seen_classes must be a frozenset",
|
|
255
|
+
)
|
|
256
|
+
@icontract.ensure(
|
|
257
|
+
lambda result: result is None or hasattr(result, "map"),
|
|
258
|
+
"result must be a Hypothesis strategy or None",
|
|
259
|
+
)
|
|
260
|
+
def _strategy_for_class(
|
|
261
|
+
annotation: type,
|
|
262
|
+
seen_classes: frozenset[type],
|
|
263
|
+
) -> SearchStrategy | None:
|
|
264
|
+
"""Derive a strategy for a user-defined class from its constructor."""
|
|
265
|
+
init = getattr(annotation, "__init__", None)
|
|
266
|
+
if init is None or init is object.__init__:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
constructor_strategies = _build_strategies_from_signature(
|
|
270
|
+
init,
|
|
271
|
+
seen_classes=seen_classes,
|
|
272
|
+
)
|
|
273
|
+
if constructor_strategies is None:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
kwargs_strategy = st.fixed_dictionaries(constructor_strategies)
|
|
277
|
+
kwargs_strategy = kwargs_strategy.filter(
|
|
278
|
+
lambda kwargs: _check_preconditions(init, dict(kwargs))
|
|
279
|
+
)
|
|
280
|
+
kwargs_strategy = kwargs_strategy.filter(
|
|
281
|
+
lambda kwargs: _can_construct_class(annotation, kwargs)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return kwargs_strategy.map(lambda kwargs: annotation(**kwargs))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@icontract.require(
|
|
288
|
+
lambda seen_classes: isinstance(seen_classes, frozenset),
|
|
289
|
+
"seen_classes must be a frozenset",
|
|
290
|
+
)
|
|
291
|
+
@icontract.ensure(
|
|
292
|
+
lambda result: result is None or hasattr(result, "map"),
|
|
293
|
+
"result must be a Hypothesis strategy or None",
|
|
294
|
+
)
|
|
295
|
+
def _strategy_for_known_annotation(
|
|
296
|
+
annotation: type | object,
|
|
297
|
+
seen_classes: frozenset[type],
|
|
298
|
+
) -> SearchStrategy | None:
|
|
299
|
+
"""Return tailored strategies for Serenecode's own domain types."""
|
|
300
|
+
module_name = getattr(annotation, "__module__", "")
|
|
301
|
+
type_name = getattr(annotation, "__name__", "")
|
|
302
|
+
|
|
303
|
+
if module_name == "serenecode.config" and type_name == "SerenecodeConfig":
|
|
304
|
+
from serenecode.config import default_config, minimal_config, strict_config
|
|
305
|
+
|
|
306
|
+
return st.sampled_from([
|
|
307
|
+
default_config(),
|
|
308
|
+
strict_config(),
|
|
309
|
+
minimal_config(),
|
|
310
|
+
])
|
|
311
|
+
|
|
312
|
+
if module_name == "serenecode.core.pipeline" and type_name == "SourceFile":
|
|
313
|
+
return _strategy_for_source_file()
|
|
314
|
+
|
|
315
|
+
if module_name == "serenecode.models":
|
|
316
|
+
return _strategy_for_model_type(annotation, type_name)
|
|
317
|
+
|
|
318
|
+
if module_name == "core.models":
|
|
319
|
+
return _strategy_for_example_model_type(annotation, type_name)
|
|
320
|
+
|
|
321
|
+
if module_name == "serenecode.checker.structural" and type_name == "IcontractNames":
|
|
322
|
+
return _strategy_for_icontract_names()
|
|
323
|
+
|
|
324
|
+
if module_name == "serenecode.checker.compositional":
|
|
325
|
+
return _strategy_for_compositional_type(type_name, seen_classes)
|
|
326
|
+
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@icontract.require(
|
|
331
|
+
lambda annotation: annotation is not None,
|
|
332
|
+
"annotation must be provided",
|
|
333
|
+
)
|
|
334
|
+
@icontract.require(
|
|
335
|
+
lambda type_name: is_non_empty_string(type_name),
|
|
336
|
+
"type_name must be a non-empty string",
|
|
337
|
+
)
|
|
338
|
+
@icontract.ensure(
|
|
339
|
+
lambda result: result is None or hasattr(result, "map"),
|
|
340
|
+
"result must be a Hypothesis strategy or None",
|
|
341
|
+
)
|
|
342
|
+
def _strategy_for_example_model_type(
|
|
343
|
+
annotation: type | object,
|
|
344
|
+
type_name: str,
|
|
345
|
+
) -> SearchStrategy | None:
|
|
346
|
+
"""Return efficient strategies for the dosage example model types."""
|
|
347
|
+
if type_name == "Patient" and inspect.isclass(annotation):
|
|
348
|
+
return st.builds(
|
|
349
|
+
annotation,
|
|
350
|
+
weight_kg=st.floats(min_value=0.1, max_value=300.0, allow_nan=False, allow_infinity=False),
|
|
351
|
+
age_years=st.floats(min_value=0.0, max_value=150.0, allow_nan=False, allow_infinity=False),
|
|
352
|
+
creatinine_clearance=st.floats(min_value=1.0, max_value=200.0, allow_nan=False, allow_infinity=False),
|
|
353
|
+
current_medications=st.lists(st.text(min_size=1, max_size=20), max_size=5),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
if type_name == "Drug" and inspect.isclass(annotation):
|
|
357
|
+
def _build_drug(
|
|
358
|
+
drug_cls: type,
|
|
359
|
+
drug_id: str,
|
|
360
|
+
dose_per_kg: float,
|
|
361
|
+
concentration_mg_per_ml: float,
|
|
362
|
+
max_single_dose_mg: float,
|
|
363
|
+
extra_daily_mg: float,
|
|
364
|
+
doses_per_day: int,
|
|
365
|
+
contraindicated_with: set[str],
|
|
366
|
+
) -> object:
|
|
367
|
+
return drug_cls(
|
|
368
|
+
drug_id=drug_id,
|
|
369
|
+
dose_per_kg=dose_per_kg,
|
|
370
|
+
concentration_mg_per_ml=concentration_mg_per_ml,
|
|
371
|
+
max_single_dose_mg=max_single_dose_mg,
|
|
372
|
+
max_daily_dose_mg=max_single_dose_mg + extra_daily_mg,
|
|
373
|
+
doses_per_day=doses_per_day,
|
|
374
|
+
contraindicated_with=contraindicated_with,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
return st.builds(
|
|
378
|
+
_build_drug,
|
|
379
|
+
drug_cls=st.just(annotation),
|
|
380
|
+
drug_id=st.text(min_size=1, max_size=20),
|
|
381
|
+
dose_per_kg=st.floats(min_value=0.001, max_value=100.0, allow_nan=False, allow_infinity=False),
|
|
382
|
+
concentration_mg_per_ml=st.floats(min_value=0.001, max_value=1000.0, allow_nan=False, allow_infinity=False),
|
|
383
|
+
max_single_dose_mg=st.floats(min_value=0.001, max_value=10000.0, allow_nan=False, allow_infinity=False),
|
|
384
|
+
extra_daily_mg=st.floats(min_value=0.0, max_value=40000.0, allow_nan=False, allow_infinity=False),
|
|
385
|
+
doses_per_day=st.integers(min_value=1, max_value=24),
|
|
386
|
+
contraindicated_with=st.sets(st.text(min_size=1, max_size=20), max_size=5),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@icontract.require(
|
|
393
|
+
lambda annotation: annotation is not None,
|
|
394
|
+
"annotation must be provided",
|
|
395
|
+
)
|
|
396
|
+
@icontract.require(
|
|
397
|
+
lambda type_name: is_non_empty_string(type_name),
|
|
398
|
+
"type_name must be a non-empty string",
|
|
399
|
+
)
|
|
400
|
+
@icontract.ensure(
|
|
401
|
+
lambda result: result is None or hasattr(result, "map"),
|
|
402
|
+
"result must be a Hypothesis strategy or None",
|
|
403
|
+
)
|
|
404
|
+
def _strategy_for_model_type(
|
|
405
|
+
annotation: type | object,
|
|
406
|
+
type_name: str,
|
|
407
|
+
) -> SearchStrategy | None:
|
|
408
|
+
"""Return strategies for result models that have cross-field invariants."""
|
|
409
|
+
from serenecode.models import (
|
|
410
|
+
CheckStatus as CanonicalCheckStatus,
|
|
411
|
+
CheckSummary as CanonicalCheckSummary,
|
|
412
|
+
Detail as CanonicalDetail,
|
|
413
|
+
FunctionResult as CanonicalFunctionResult,
|
|
414
|
+
VerificationLevel as CanonicalVerificationLevel,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
module_globals: dict[str, object] = {}
|
|
418
|
+
annotation_globals = getattr(getattr(annotation, "to_dict", None), "__globals__", None)
|
|
419
|
+
if isinstance(annotation_globals, dict):
|
|
420
|
+
module_globals = annotation_globals
|
|
421
|
+
|
|
422
|
+
check_status_values = list(cast(type[enum.Enum], module_globals.get("CheckStatus", CanonicalCheckStatus)))
|
|
423
|
+
detail_factory = cast(
|
|
424
|
+
Callable[..., object],
|
|
425
|
+
annotation if type_name == "Detail" else module_globals.get("Detail", CanonicalDetail),
|
|
426
|
+
)
|
|
427
|
+
function_result_factory = cast(
|
|
428
|
+
Callable[..., object],
|
|
429
|
+
annotation if type_name == "FunctionResult" else module_globals.get("FunctionResult", CanonicalFunctionResult),
|
|
430
|
+
)
|
|
431
|
+
verification_level_values = list(cast(
|
|
432
|
+
type[enum.Enum],
|
|
433
|
+
module_globals.get("VerificationLevel", CanonicalVerificationLevel),
|
|
434
|
+
))
|
|
435
|
+
non_empty_text = st.text(
|
|
436
|
+
alphabet=st.characters(blacklist_categories=("C", "Z")),
|
|
437
|
+
min_size=1,
|
|
438
|
+
max_size=120,
|
|
439
|
+
)
|
|
440
|
+
path_text = st.text(
|
|
441
|
+
alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./-",
|
|
442
|
+
min_size=1,
|
|
443
|
+
max_size=80,
|
|
444
|
+
)
|
|
445
|
+
name_text = st.text(
|
|
446
|
+
alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_",
|
|
447
|
+
min_size=1,
|
|
448
|
+
max_size=40,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
detail_strategy = st.builds(
|
|
452
|
+
detail_factory,
|
|
453
|
+
level=st.sampled_from(verification_level_values),
|
|
454
|
+
tool=st.sampled_from(["structural", "mypy", "hypothesis", "crosshair", "compositional"]),
|
|
455
|
+
finding_type=st.sampled_from(["verified", "violation", "timeout", "error", "unavailable"]),
|
|
456
|
+
message=non_empty_text,
|
|
457
|
+
counterexample=st.one_of(
|
|
458
|
+
st.none(),
|
|
459
|
+
st.dictionaries(
|
|
460
|
+
name_text,
|
|
461
|
+
st.one_of(
|
|
462
|
+
st.integers(min_value=-10, max_value=10),
|
|
463
|
+
st.text(min_size=0, max_size=20),
|
|
464
|
+
st.booleans(),
|
|
465
|
+
),
|
|
466
|
+
max_size=3,
|
|
467
|
+
),
|
|
468
|
+
),
|
|
469
|
+
suggestion=st.one_of(st.none(), non_empty_text.map(lambda value: value[:80])),
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
@st.composite
|
|
473
|
+
def _function_result_strategy(draw: st.DrawFn) -> object:
|
|
474
|
+
"""Build valid FunctionResult objects whose achieved level does not exceed the request."""
|
|
475
|
+
level_requested = draw(st.integers(min_value=1, max_value=5))
|
|
476
|
+
level_achieved = draw(st.integers(min_value=0, max_value=level_requested))
|
|
477
|
+
return function_result_factory(
|
|
478
|
+
function=draw(name_text),
|
|
479
|
+
file=draw(path_text),
|
|
480
|
+
line=draw(st.integers(min_value=1, max_value=1000)),
|
|
481
|
+
level_requested=level_requested,
|
|
482
|
+
level_achieved=level_achieved,
|
|
483
|
+
status=draw(st.sampled_from(check_status_values)),
|
|
484
|
+
details=draw(st.lists(detail_strategy, max_size=3).map(tuple)),
|
|
485
|
+
)
|
|
486
|
+
function_result_strategy = _function_result_strategy()
|
|
487
|
+
|
|
488
|
+
if type_name == "Detail":
|
|
489
|
+
return detail_strategy
|
|
490
|
+
|
|
491
|
+
if type_name == "FunctionResult":
|
|
492
|
+
return function_result_strategy
|
|
493
|
+
|
|
494
|
+
if type_name == "CheckResult":
|
|
495
|
+
check_result_factory = cast(Callable[..., object], annotation)
|
|
496
|
+
check_result_hints = typing.get_type_hints(annotation)
|
|
497
|
+
summary_factory = cast(
|
|
498
|
+
Callable[..., object],
|
|
499
|
+
check_result_hints.get("summary", CanonicalCheckSummary),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
def _build_check_result(
|
|
503
|
+
results: list[object],
|
|
504
|
+
duration_seconds: float,
|
|
505
|
+
) -> object:
|
|
506
|
+
level_requested = max(
|
|
507
|
+
(int(getattr(r, "level_requested", 1)) for r in results),
|
|
508
|
+
default=1,
|
|
509
|
+
)
|
|
510
|
+
level_achieved = min(
|
|
511
|
+
(int(getattr(r, "level_achieved", level_requested)) for r in results),
|
|
512
|
+
default=level_requested,
|
|
513
|
+
)
|
|
514
|
+
passed_count = 0
|
|
515
|
+
failed_count = 0
|
|
516
|
+
skipped_count = 0
|
|
517
|
+
# Loop invariant: counts reflect the statuses in results[0..i].
|
|
518
|
+
for result in results:
|
|
519
|
+
status_value = getattr(getattr(result, "status", None), "value", None)
|
|
520
|
+
if status_value == "passed":
|
|
521
|
+
passed_count += 1
|
|
522
|
+
elif status_value == "failed":
|
|
523
|
+
failed_count += 1
|
|
524
|
+
else:
|
|
525
|
+
skipped_count += 1
|
|
526
|
+
|
|
527
|
+
summary = summary_factory(
|
|
528
|
+
total_functions=len(results),
|
|
529
|
+
passed_count=passed_count,
|
|
530
|
+
failed_count=failed_count,
|
|
531
|
+
skipped_count=skipped_count,
|
|
532
|
+
duration_seconds=duration_seconds,
|
|
533
|
+
)
|
|
534
|
+
passed = (
|
|
535
|
+
failed_count == 0
|
|
536
|
+
and skipped_count == 0
|
|
537
|
+
and level_achieved == level_requested
|
|
538
|
+
)
|
|
539
|
+
return check_result_factory(
|
|
540
|
+
passed=passed,
|
|
541
|
+
level_requested=level_requested,
|
|
542
|
+
level_achieved=level_achieved,
|
|
543
|
+
results=tuple(results),
|
|
544
|
+
summary=summary,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
return st.builds(
|
|
548
|
+
_build_check_result,
|
|
549
|
+
results=st.lists(function_result_strategy, max_size=6),
|
|
550
|
+
duration_seconds=st.floats(
|
|
551
|
+
min_value=0.0,
|
|
552
|
+
max_value=10.0,
|
|
553
|
+
allow_nan=False,
|
|
554
|
+
allow_infinity=False,
|
|
555
|
+
),
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@icontract.ensure(
|
|
562
|
+
lambda result: hasattr(result, "map"),
|
|
563
|
+
"result must be a Hypothesis strategy",
|
|
564
|
+
)
|
|
565
|
+
def _strategy_for_source_file() -> SearchStrategy:
|
|
566
|
+
"""Build valid SourceFile instances for pipeline property tests."""
|
|
567
|
+
from serenecode.core.pipeline import SourceFile
|
|
568
|
+
|
|
569
|
+
return st.builds(
|
|
570
|
+
SourceFile,
|
|
571
|
+
file_path=st.text(min_size=1, max_size=80),
|
|
572
|
+
module_path=st.text(min_size=1, max_size=80),
|
|
573
|
+
source=st.text(min_size=0, max_size=200),
|
|
574
|
+
importable_module=st.one_of(
|
|
575
|
+
st.none(),
|
|
576
|
+
st.text(
|
|
577
|
+
alphabet="abcdefghijklmnopqrstuvwxyz._",
|
|
578
|
+
min_size=1,
|
|
579
|
+
max_size=40,
|
|
580
|
+
).filter(lambda value: not value.startswith(".")),
|
|
581
|
+
),
|
|
582
|
+
import_search_paths=st.lists(
|
|
583
|
+
st.text(min_size=1, max_size=60),
|
|
584
|
+
max_size=4,
|
|
585
|
+
).map(tuple),
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@icontract.ensure(
|
|
590
|
+
lambda result: hasattr(result, "map"),
|
|
591
|
+
"result must be a Hypothesis strategy",
|
|
592
|
+
)
|
|
593
|
+
def _strategy_for_icontract_names() -> SearchStrategy:
|
|
594
|
+
"""Build valid icontract alias sets for structural helper tests."""
|
|
595
|
+
from serenecode.checker.structural import IcontractNames
|
|
596
|
+
|
|
597
|
+
names = st.lists(
|
|
598
|
+
st.sampled_from([
|
|
599
|
+
"require",
|
|
600
|
+
"ensure",
|
|
601
|
+
"invariant",
|
|
602
|
+
"icontract.require",
|
|
603
|
+
"icontract.ensure",
|
|
604
|
+
"icontract.invariant",
|
|
605
|
+
]),
|
|
606
|
+
unique=True,
|
|
607
|
+
max_size=3,
|
|
608
|
+
).map(frozenset)
|
|
609
|
+
|
|
610
|
+
return st.builds(
|
|
611
|
+
IcontractNames,
|
|
612
|
+
module_alias=st.one_of(st.none(), st.text(min_size=1, max_size=12)),
|
|
613
|
+
require_names=names,
|
|
614
|
+
ensure_names=names,
|
|
615
|
+
invariant_names=names,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@icontract.require(
|
|
620
|
+
lambda type_name: is_non_empty_string(type_name),
|
|
621
|
+
"type_name must be a non-empty string",
|
|
622
|
+
)
|
|
623
|
+
@icontract.require(
|
|
624
|
+
lambda seen_classes: isinstance(seen_classes, frozenset),
|
|
625
|
+
"seen_classes must be a frozenset",
|
|
626
|
+
)
|
|
627
|
+
@icontract.ensure(
|
|
628
|
+
lambda result: result is None or hasattr(result, "map"),
|
|
629
|
+
"result must be a Hypothesis strategy or None",
|
|
630
|
+
)
|
|
631
|
+
def _strategy_for_compositional_type(
|
|
632
|
+
type_name: str,
|
|
633
|
+
seen_classes: frozenset[type],
|
|
634
|
+
) -> SearchStrategy | None:
|
|
635
|
+
"""Return strategies for compositional-analysis dataclasses."""
|
|
636
|
+
from serenecode.checker.compositional import (
|
|
637
|
+
ClassInfo,
|
|
638
|
+
FunctionInfo,
|
|
639
|
+
MethodSignature,
|
|
640
|
+
ModuleInfo,
|
|
641
|
+
ParameterInfo,
|
|
642
|
+
ProtocolInfo,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
method_signature = st.builds(
|
|
646
|
+
MethodSignature,
|
|
647
|
+
name=st.text(min_size=1, max_size=20),
|
|
648
|
+
parameters=st.lists(st.text(min_size=1, max_size=16), max_size=4).map(tuple),
|
|
649
|
+
has_return_annotation=st.booleans(),
|
|
650
|
+
)
|
|
651
|
+
parameter_info = st.builds(
|
|
652
|
+
ParameterInfo,
|
|
653
|
+
name=st.text(min_size=1, max_size=20),
|
|
654
|
+
annotation=st.one_of(st.none(), st.text(min_size=1, max_size=20)),
|
|
655
|
+
)
|
|
656
|
+
function_info = st.builds(
|
|
657
|
+
FunctionInfo,
|
|
658
|
+
name=st.text(min_size=1, max_size=20),
|
|
659
|
+
line=st.integers(min_value=1, max_value=500),
|
|
660
|
+
is_public=st.booleans(),
|
|
661
|
+
parameters=st.lists(parameter_info, max_size=4).map(tuple),
|
|
662
|
+
return_annotation=st.one_of(st.none(), st.text(min_size=1, max_size=20)),
|
|
663
|
+
has_require=st.booleans(),
|
|
664
|
+
has_ensure=st.booleans(),
|
|
665
|
+
calls=st.lists(st.text(min_size=1, max_size=20), max_size=4).map(tuple),
|
|
666
|
+
)
|
|
667
|
+
class_info = st.builds(
|
|
668
|
+
ClassInfo,
|
|
669
|
+
name=st.text(min_size=1, max_size=20),
|
|
670
|
+
line=st.integers(min_value=1, max_value=500),
|
|
671
|
+
bases=st.lists(st.text(min_size=1, max_size=20), max_size=3).map(tuple),
|
|
672
|
+
methods=st.lists(st.text(min_size=1, max_size=20), max_size=4).map(tuple),
|
|
673
|
+
is_protocol=st.booleans(),
|
|
674
|
+
method_signatures=st.lists(method_signature, max_size=4).map(tuple),
|
|
675
|
+
has_invariant=st.booleans(),
|
|
676
|
+
)
|
|
677
|
+
protocol_info = st.builds(
|
|
678
|
+
ProtocolInfo,
|
|
679
|
+
name=st.text(min_size=1, max_size=20),
|
|
680
|
+
line=st.integers(min_value=1, max_value=500),
|
|
681
|
+
methods=st.lists(method_signature, max_size=4).map(tuple),
|
|
682
|
+
)
|
|
683
|
+
module_info = st.builds(
|
|
684
|
+
ModuleInfo,
|
|
685
|
+
file_path=st.text(min_size=1, max_size=60),
|
|
686
|
+
module_path=st.text(min_size=1, max_size=60),
|
|
687
|
+
imports=st.lists(st.text(min_size=1, max_size=20), max_size=4).map(tuple),
|
|
688
|
+
from_imports=st.lists(
|
|
689
|
+
st.tuples(
|
|
690
|
+
st.text(min_size=1, max_size=20),
|
|
691
|
+
st.text(min_size=1, max_size=20),
|
|
692
|
+
),
|
|
693
|
+
max_size=4,
|
|
694
|
+
).map(tuple),
|
|
695
|
+
classes=st.lists(class_info, max_size=3).map(tuple),
|
|
696
|
+
functions=st.lists(st.text(min_size=1, max_size=20), max_size=4).map(tuple),
|
|
697
|
+
protocols=st.lists(protocol_info, max_size=3).map(tuple),
|
|
698
|
+
function_infos=st.lists(function_info, max_size=4).map(tuple),
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
strategies = {
|
|
702
|
+
"MethodSignature": method_signature,
|
|
703
|
+
"ParameterInfo": parameter_info,
|
|
704
|
+
"FunctionInfo": function_info,
|
|
705
|
+
"ClassInfo": class_info,
|
|
706
|
+
"ProtocolInfo": protocol_info,
|
|
707
|
+
"ModuleInfo": module_info,
|
|
708
|
+
}
|
|
709
|
+
return strategies.get(type_name)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@icontract.require(lambda annotation: annotation is not None, "annotation must be provided")
|
|
713
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
714
|
+
def _is_protocol_class(annotation: type) -> bool:
|
|
715
|
+
"""Check whether an annotation is a typing.Protocol-derived class."""
|
|
716
|
+
return bool(getattr(annotation, "_is_protocol", False))
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@icontract.require(lambda annotation: annotation is not None, "annotation must be provided")
|
|
720
|
+
@icontract.ensure(
|
|
721
|
+
lambda result: result is None or hasattr(result, "map"),
|
|
722
|
+
"result must be a Hypothesis strategy or None",
|
|
723
|
+
)
|
|
724
|
+
def _strategy_for_protocol(annotation: type) -> SearchStrategy | None:
|
|
725
|
+
"""Build stubs for protocol-typed dependencies used in Serenecode."""
|
|
726
|
+
module_name = getattr(annotation, "__module__", "")
|
|
727
|
+
type_name = getattr(annotation, "__name__", "")
|
|
728
|
+
|
|
729
|
+
if module_name == "serenecode.ports.file_system" and type_name == "FileReader":
|
|
730
|
+
class _ReaderStub:
|
|
731
|
+
def read_file(self, path: str) -> str:
|
|
732
|
+
return "def generated() -> int:\n return 1\n"
|
|
733
|
+
|
|
734
|
+
def file_exists(self, path: str) -> bool:
|
|
735
|
+
return False
|
|
736
|
+
|
|
737
|
+
def list_python_files(self, directory: str) -> list[str]:
|
|
738
|
+
return []
|
|
739
|
+
|
|
740
|
+
return st.just(_ReaderStub())
|
|
741
|
+
|
|
742
|
+
if module_name == "serenecode.ports.file_system" and type_name == "FileWriter":
|
|
743
|
+
class _WriterStub:
|
|
744
|
+
def write_file(self, path: str, content: str) -> None:
|
|
745
|
+
return None
|
|
746
|
+
|
|
747
|
+
def ensure_directory(self, path: str) -> None:
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
return st.just(_WriterStub())
|
|
751
|
+
|
|
752
|
+
if module_name == "serenecode.ports.type_checker" and type_name == "TypeChecker":
|
|
753
|
+
class _TypeCheckerStub:
|
|
754
|
+
def check(
|
|
755
|
+
self,
|
|
756
|
+
file_paths: list[str],
|
|
757
|
+
strict: bool = True,
|
|
758
|
+
search_paths: tuple[str, ...] = (),
|
|
759
|
+
) -> list[object]:
|
|
760
|
+
return []
|
|
761
|
+
|
|
762
|
+
return st.just(_TypeCheckerStub())
|
|
763
|
+
|
|
764
|
+
if module_name == "serenecode.ports.property_tester" and type_name == "PropertyTester":
|
|
765
|
+
class _PropertyTesterStub:
|
|
766
|
+
def test_module(
|
|
767
|
+
self,
|
|
768
|
+
module_path: str,
|
|
769
|
+
max_examples: int | None = None,
|
|
770
|
+
search_paths: tuple[str, ...] = (),
|
|
771
|
+
) -> list[object]:
|
|
772
|
+
return []
|
|
773
|
+
|
|
774
|
+
return st.just(_PropertyTesterStub())
|
|
775
|
+
|
|
776
|
+
if module_name == "serenecode.ports.symbolic_checker" and type_name == "SymbolicChecker":
|
|
777
|
+
class _SymbolicCheckerStub:
|
|
778
|
+
def verify_module(
|
|
779
|
+
self,
|
|
780
|
+
module_path: str,
|
|
781
|
+
per_condition_timeout: int | None = None,
|
|
782
|
+
per_path_timeout: int | None = None,
|
|
783
|
+
search_paths: tuple[str, ...] = (),
|
|
784
|
+
) -> list[object]:
|
|
785
|
+
return []
|
|
786
|
+
|
|
787
|
+
return st.just(_SymbolicCheckerStub())
|
|
788
|
+
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
@icontract.require(lambda annotation: annotation is not None, "annotation must be provided")
|
|
793
|
+
@icontract.ensure(
|
|
794
|
+
lambda result: result is None or hasattr(result, "map"),
|
|
795
|
+
"result must be a Hypothesis strategy or None",
|
|
796
|
+
)
|
|
797
|
+
def _strategy_for_ast(annotation: type[ast.AST]) -> SearchStrategy | None:
|
|
798
|
+
"""Build simple AST nodes for structural helper property tests."""
|
|
799
|
+
module_samples = [
|
|
800
|
+
ast.parse("import icontract"),
|
|
801
|
+
ast.parse(
|
|
802
|
+
"from icontract import require, ensure\n"
|
|
803
|
+
"@require(lambda x: x > 0, 'positive')\n"
|
|
804
|
+
"@ensure(lambda result: result >= 0, 'non-negative')\n"
|
|
805
|
+
"def square(x: int) -> int:\n"
|
|
806
|
+
" return x * x\n"
|
|
807
|
+
),
|
|
808
|
+
ast.parse(
|
|
809
|
+
"@decorator\n"
|
|
810
|
+
"class Demo:\n"
|
|
811
|
+
" pass\n"
|
|
812
|
+
),
|
|
813
|
+
]
|
|
814
|
+
expr_samples = [
|
|
815
|
+
ast.parse("require", mode="eval").body,
|
|
816
|
+
ast.parse("icontract.require", mode="eval").body,
|
|
817
|
+
ast.parse("require(x)", mode="eval").body,
|
|
818
|
+
]
|
|
819
|
+
stmt_samples: list[ast.AST] = []
|
|
820
|
+
# Loop invariant: stmt_samples contains all statement nodes from module_samples[0..i]
|
|
821
|
+
for module in module_samples:
|
|
822
|
+
stmt_samples.extend(module.body)
|
|
823
|
+
|
|
824
|
+
all_samples: list[ast.AST] = []
|
|
825
|
+
all_samples.extend(module_samples)
|
|
826
|
+
all_samples.extend(expr_samples)
|
|
827
|
+
all_samples.extend(stmt_samples)
|
|
828
|
+
|
|
829
|
+
matching = [sample for sample in all_samples if isinstance(sample, annotation)]
|
|
830
|
+
if not matching:
|
|
831
|
+
return None
|
|
832
|
+
return st.sampled_from(matching)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@icontract.require(lambda annotation: annotation is not None, "annotation must be provided")
|
|
836
|
+
@icontract.ensure(lambda result: callable(result), "result must be callable")
|
|
837
|
+
def _make_callable_stub(annotation: object) -> Callable[..., object]:
|
|
838
|
+
"""Build a simple callable returning a valid value for the annotation."""
|
|
839
|
+
args = typing.get_args(annotation)
|
|
840
|
+
return_annotation = args[1] if len(args) == 2 else None
|
|
841
|
+
return_value = _sample_value_for_annotation(return_annotation)
|
|
842
|
+
|
|
843
|
+
def _stub(*_args: object, **_kwargs: object) -> object:
|
|
844
|
+
return return_value
|
|
845
|
+
|
|
846
|
+
return _stub
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
@icontract.require(
|
|
850
|
+
lambda value: isinstance(value, object),
|
|
851
|
+
"value must be a Python object",
|
|
852
|
+
)
|
|
853
|
+
@icontract.ensure(
|
|
854
|
+
lambda result: isinstance(result, bool),
|
|
855
|
+
"result must be a bool",
|
|
856
|
+
)
|
|
857
|
+
def _is_placeholder_value(value: object) -> bool:
|
|
858
|
+
"""Check whether a sampled placeholder value is deterministic and safe."""
|
|
859
|
+
if value is None:
|
|
860
|
+
return True
|
|
861
|
+
if isinstance(value, (bool, int, float, str, bytes, list, tuple, dict, set, frozenset)):
|
|
862
|
+
return True
|
|
863
|
+
return (
|
|
864
|
+
value.__class__.__module__ == "serenecode.models"
|
|
865
|
+
and value.__class__.__name__ == "CheckResult"
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
@icontract.require(
|
|
870
|
+
lambda annotation: annotation is None or isinstance(annotation, object),
|
|
871
|
+
"annotation must be a Python object or None",
|
|
872
|
+
)
|
|
873
|
+
@icontract.ensure(
|
|
874
|
+
lambda result: _is_placeholder_value(result),
|
|
875
|
+
"result must be a deterministic placeholder value",
|
|
876
|
+
)
|
|
877
|
+
def _sample_value_for_annotation(annotation: object) -> object:
|
|
878
|
+
"""Return a deterministic placeholder value for a type annotation."""
|
|
879
|
+
# Variant: recursive calls peel off a union branch or container wrapper.
|
|
880
|
+
if annotation in (None, type(None)):
|
|
881
|
+
return None
|
|
882
|
+
if annotation is bool:
|
|
883
|
+
return True
|
|
884
|
+
if annotation is int:
|
|
885
|
+
return 1
|
|
886
|
+
if annotation is float:
|
|
887
|
+
return 1.0
|
|
888
|
+
if annotation is str:
|
|
889
|
+
return "x"
|
|
890
|
+
if annotation is bytes:
|
|
891
|
+
return b"x"
|
|
892
|
+
|
|
893
|
+
module_name = getattr(annotation, "__module__", "")
|
|
894
|
+
type_name = getattr(annotation, "__name__", "")
|
|
895
|
+
if module_name == "serenecode.models" and type_name == "CheckResult":
|
|
896
|
+
from serenecode.models import make_check_result
|
|
897
|
+
|
|
898
|
+
return make_check_result((), level_requested=1, duration_seconds=0.0)
|
|
899
|
+
|
|
900
|
+
origin = typing.get_origin(annotation)
|
|
901
|
+
args = typing.get_args(annotation)
|
|
902
|
+
|
|
903
|
+
if origin in (typing.Union, types.UnionType) and args:
|
|
904
|
+
if type(None) in args:
|
|
905
|
+
return None
|
|
906
|
+
return _sample_value_for_annotation(args[0])
|
|
907
|
+
if origin is list:
|
|
908
|
+
return []
|
|
909
|
+
if origin is tuple:
|
|
910
|
+
return ()
|
|
911
|
+
if origin is dict:
|
|
912
|
+
return {}
|
|
913
|
+
if origin is set:
|
|
914
|
+
return set()
|
|
915
|
+
if origin is frozenset:
|
|
916
|
+
return frozenset()
|
|
917
|
+
|
|
918
|
+
return None
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
@icontract.require(lambda annotation: inspect.isclass(annotation), "annotation must be a class")
|
|
922
|
+
@icontract.require(lambda kwargs: isinstance(kwargs, dict), "kwargs must be a dict")
|
|
923
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
924
|
+
def _can_construct_class(annotation: type, kwargs: dict[str, object]) -> bool:
|
|
925
|
+
"""Check whether a class constructor accepts the given keyword arguments."""
|
|
926
|
+
try:
|
|
927
|
+
annotation(**kwargs)
|
|
928
|
+
except Exception:
|
|
929
|
+
return False
|
|
930
|
+
return True
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
@icontract.require(lambda func: callable(func), "func must be callable")
|
|
934
|
+
@icontract.require(lambda strategies: isinstance(strategies, dict), "strategies must be a dict")
|
|
935
|
+
@icontract.require(lambda annotations: isinstance(annotations, dict), "annotations must be a dict")
|
|
936
|
+
@icontract.ensure(lambda result: isinstance(result, dict), "result must be a dict")
|
|
937
|
+
def _refine_strategies_with_preconditions(
|
|
938
|
+
func: Callable[..., object],
|
|
939
|
+
strategies: dict[str, SearchStrategy],
|
|
940
|
+
annotations: dict[str, object],
|
|
941
|
+
) -> dict[str, SearchStrategy]:
|
|
942
|
+
"""Refine strategies using icontract preconditions.
|
|
943
|
+
|
|
944
|
+
Inspects precondition lambdas to derive tighter strategies:
|
|
945
|
+
- Detects `x in (...)` patterns → st.sampled_from()
|
|
946
|
+
- Detects `x > 0`, `x >= 0` bounds → filtered integers
|
|
947
|
+
- Applies remaining preconditions as .filter() on strategies
|
|
948
|
+
|
|
949
|
+
Args:
|
|
950
|
+
func: The contracted function.
|
|
951
|
+
strategies: Base strategies derived from type annotations.
|
|
952
|
+
|
|
953
|
+
Returns:
|
|
954
|
+
Refined strategies dict.
|
|
955
|
+
"""
|
|
956
|
+
preconditions = getattr(func, "__preconditions__", None)
|
|
957
|
+
if not preconditions:
|
|
958
|
+
return strategies
|
|
959
|
+
|
|
960
|
+
refined = dict(strategies)
|
|
961
|
+
|
|
962
|
+
# Loop invariant: refined contains strategies for all params, updated for groups[0..i]
|
|
963
|
+
for group in preconditions:
|
|
964
|
+
for contract in group:
|
|
965
|
+
condition = contract.condition
|
|
966
|
+
_try_refine_from_condition(condition, refined, annotations)
|
|
967
|
+
|
|
968
|
+
return refined
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
@icontract.require(lambda condition: callable(condition), "condition must be callable")
|
|
972
|
+
@icontract.require(lambda strategies: isinstance(strategies, dict), "strategies must be a dict")
|
|
973
|
+
@icontract.require(lambda annotations: isinstance(annotations, dict), "annotations must be a dict")
|
|
974
|
+
@icontract.ensure(lambda result: result is None, "refinement happens in place")
|
|
975
|
+
def _try_refine_from_condition(
|
|
976
|
+
condition: Callable[..., bool],
|
|
977
|
+
strategies: dict[str, SearchStrategy],
|
|
978
|
+
annotations: dict[str, object],
|
|
979
|
+
) -> None:
|
|
980
|
+
"""Try to refine strategies based on a single precondition.
|
|
981
|
+
|
|
982
|
+
Inspects the condition's source code to detect common patterns
|
|
983
|
+
and replaces broad strategies with targeted ones.
|
|
984
|
+
|
|
985
|
+
Args:
|
|
986
|
+
condition: A precondition lambda/function.
|
|
987
|
+
strategies: Mutable dict of strategies to refine in place.
|
|
988
|
+
"""
|
|
989
|
+
# Get the condition's parameter names
|
|
990
|
+
try:
|
|
991
|
+
cond_sig = inspect.signature(condition)
|
|
992
|
+
cond_params = [p for p in cond_sig.parameters if p not in ("self", "cls")]
|
|
993
|
+
except (ValueError, TypeError):
|
|
994
|
+
return
|
|
995
|
+
|
|
996
|
+
# Only refine single-parameter conditions (simple predicates)
|
|
997
|
+
if len(cond_params) != 1:
|
|
998
|
+
# For multi-param conditions, apply as a joint filter later
|
|
999
|
+
return
|
|
1000
|
+
|
|
1001
|
+
param_name = cond_params[0]
|
|
1002
|
+
if param_name not in strategies:
|
|
1003
|
+
return
|
|
1004
|
+
|
|
1005
|
+
# Try to extract source code of the condition lambda only
|
|
1006
|
+
source = _get_lambda_source(condition)
|
|
1007
|
+
|
|
1008
|
+
# Pattern: `x in ("a", "b", "c")` or `x in {...}` → sampled_from
|
|
1009
|
+
import re
|
|
1010
|
+
effective_source = source
|
|
1011
|
+
|
|
1012
|
+
# If the condition calls a predicate function, try to get that function's source too
|
|
1013
|
+
func_call_match = re.search(r'(\w+)\s*\(\s*' + re.escape(param_name) + r'\s*\)', source)
|
|
1014
|
+
if func_call_match:
|
|
1015
|
+
called_name = func_call_match.group(1)
|
|
1016
|
+
# Try to resolve the called function from the condition's globals
|
|
1017
|
+
called_func = getattr(condition, "__globals__", {}).get(called_name)
|
|
1018
|
+
if called_func and callable(called_func):
|
|
1019
|
+
try:
|
|
1020
|
+
called_source = inspect.getsource(called_func)
|
|
1021
|
+
effective_source = source + "\n" + called_source
|
|
1022
|
+
except (OSError, TypeError):
|
|
1023
|
+
pass
|
|
1024
|
+
|
|
1025
|
+
# Recognize common Serenecode contract predicates and synthesize
|
|
1026
|
+
# directly bounded strategies instead of filtering broad primitives.
|
|
1027
|
+
if called_name == "is_valid_verification_level":
|
|
1028
|
+
strategies[param_name] = st.integers(min_value=1, max_value=6)
|
|
1029
|
+
return
|
|
1030
|
+
if called_name == "is_non_negative_int":
|
|
1031
|
+
strategies[param_name] = st.integers(min_value=0, max_value=1000)
|
|
1032
|
+
return
|
|
1033
|
+
if called_name == "is_positive_int":
|
|
1034
|
+
strategies[param_name] = st.integers(min_value=1, max_value=1000)
|
|
1035
|
+
return
|
|
1036
|
+
if called_name == "is_non_empty_string":
|
|
1037
|
+
strategies[param_name] = st.text(min_size=1, max_size=100).filter(
|
|
1038
|
+
lambda value: len(value.strip()) > 0
|
|
1039
|
+
)
|
|
1040
|
+
return
|
|
1041
|
+
if called_name == "is_valid_template_name":
|
|
1042
|
+
strategies[param_name] = st.sampled_from(["default", "strict", "minimal"])
|
|
1043
|
+
return
|
|
1044
|
+
|
|
1045
|
+
in_match = re.search(
|
|
1046
|
+
r'in\s*[\(\[\{]\s*(.+?)\s*[\)\]\}]',
|
|
1047
|
+
effective_source,
|
|
1048
|
+
)
|
|
1049
|
+
if in_match:
|
|
1050
|
+
items_str = in_match.group(1)
|
|
1051
|
+
items = _parse_literal_collection(items_str)
|
|
1052
|
+
if items:
|
|
1053
|
+
strategies[param_name] = st.sampled_from(items)
|
|
1054
|
+
return
|
|
1055
|
+
|
|
1056
|
+
# Pattern: `x > N` or `x >= N` → integers with min_value
|
|
1057
|
+
gt_match = re.search(r'>\s*(\d+)', source)
|
|
1058
|
+
ge_match = re.search(r'>=\s*(\d+)', source)
|
|
1059
|
+
lt_match = re.search(r'<\s*(\d+)', source)
|
|
1060
|
+
le_match = re.search(r'<=\s*(\d+)', source)
|
|
1061
|
+
|
|
1062
|
+
current = strategies[param_name]
|
|
1063
|
+
annotation = annotations.get(param_name)
|
|
1064
|
+
is_numeric = annotation in (int, float)
|
|
1065
|
+
is_literal_like = annotation in (str, int, float)
|
|
1066
|
+
|
|
1067
|
+
# Apply bound constraints for integer/float strategies
|
|
1068
|
+
if is_numeric and (ge_match or gt_match or le_match or lt_match):
|
|
1069
|
+
min_val = None
|
|
1070
|
+
max_val = None
|
|
1071
|
+
if ge_match:
|
|
1072
|
+
min_val = int(ge_match.group(1))
|
|
1073
|
+
if gt_match:
|
|
1074
|
+
min_val = int(gt_match.group(1)) + 1
|
|
1075
|
+
if le_match:
|
|
1076
|
+
max_val = int(le_match.group(1))
|
|
1077
|
+
if lt_match:
|
|
1078
|
+
max_val = int(lt_match.group(1)) - 1
|
|
1079
|
+
|
|
1080
|
+
# Replace with bounded strategy
|
|
1081
|
+
if min_val is not None or max_val is not None:
|
|
1082
|
+
if annotation is float:
|
|
1083
|
+
strategies[param_name] = st.floats(
|
|
1084
|
+
min_value=float(min_val) if min_val is not None else -1e6,
|
|
1085
|
+
max_value=float(max_val) if max_val is not None else 1e6,
|
|
1086
|
+
allow_nan=False,
|
|
1087
|
+
allow_infinity=False,
|
|
1088
|
+
)
|
|
1089
|
+
else:
|
|
1090
|
+
strategies[param_name] = st.integers(
|
|
1091
|
+
min_value=min_val if min_val is not None else -1000,
|
|
1092
|
+
max_value=max_val if max_val is not None else 1000,
|
|
1093
|
+
)
|
|
1094
|
+
return
|
|
1095
|
+
|
|
1096
|
+
if not is_literal_like and not inspect.isclass(annotation):
|
|
1097
|
+
return
|
|
1098
|
+
|
|
1099
|
+
# Fallback: apply the condition as a filter on the existing strategy
|
|
1100
|
+
try:
|
|
1101
|
+
strategies[param_name] = current.filter(condition)
|
|
1102
|
+
except Exception:
|
|
1103
|
+
pass # keep the original strategy
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
@icontract.require(lambda condition: callable(condition), "condition must be callable")
|
|
1107
|
+
@icontract.ensure(lambda result: isinstance(result, str), "result must be a string")
|
|
1108
|
+
def _get_lambda_source(condition: Callable[..., bool]) -> str:
|
|
1109
|
+
"""Extract the source of a lambda condition only.
|
|
1110
|
+
|
|
1111
|
+
inspect.getsource on a lambda inside a decorator returns the entire
|
|
1112
|
+
decorated function. This function extracts just the lambda expression.
|
|
1113
|
+
|
|
1114
|
+
Args:
|
|
1115
|
+
condition: A callable (typically a lambda).
|
|
1116
|
+
|
|
1117
|
+
Returns:
|
|
1118
|
+
The lambda source string, or empty string if not extractable.
|
|
1119
|
+
"""
|
|
1120
|
+
try:
|
|
1121
|
+
full_source = inspect.getsource(condition).strip()
|
|
1122
|
+
except (OSError, TypeError):
|
|
1123
|
+
return ""
|
|
1124
|
+
|
|
1125
|
+
# If it's a named function (not a lambda), return the first line
|
|
1126
|
+
if condition.__name__ != "<lambda>":
|
|
1127
|
+
return full_source.split("\n")[0]
|
|
1128
|
+
|
|
1129
|
+
# For single-line lambdas (typical in icontract decorators),
|
|
1130
|
+
# find the line containing "lambda" and extract it
|
|
1131
|
+
import re
|
|
1132
|
+
|
|
1133
|
+
# Get the parameter name(s) of our condition
|
|
1134
|
+
try:
|
|
1135
|
+
cond_params = list(inspect.signature(condition).parameters.keys())
|
|
1136
|
+
except (ValueError, TypeError):
|
|
1137
|
+
cond_params = []
|
|
1138
|
+
|
|
1139
|
+
# Search each line for a lambda matching our parameters
|
|
1140
|
+
# Loop invariant: checked lines[0..i] for matching lambda
|
|
1141
|
+
for line in full_source.split("\n"):
|
|
1142
|
+
line = line.strip()
|
|
1143
|
+
if "lambda" not in line:
|
|
1144
|
+
continue
|
|
1145
|
+
|
|
1146
|
+
# Extract from "lambda" to end of meaningful content
|
|
1147
|
+
lambda_match = re.search(r"lambda\s+.+", line)
|
|
1148
|
+
if lambda_match:
|
|
1149
|
+
lambda_text = lambda_match.group(0)
|
|
1150
|
+
# Strip only trailing decorator syntax (comma after closing paren)
|
|
1151
|
+
lambda_text = re.sub(r",\s*$", "", lambda_text).strip()
|
|
1152
|
+
if cond_params and all(p in lambda_text for p in cond_params):
|
|
1153
|
+
return lambda_text
|
|
1154
|
+
|
|
1155
|
+
return ""
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
@icontract.require(lambda items_str: isinstance(items_str, str), "items_str must be a string")
|
|
1159
|
+
@icontract.ensure(
|
|
1160
|
+
lambda result: result is None or isinstance(result, list),
|
|
1161
|
+
"result must be a list or None",
|
|
1162
|
+
)
|
|
1163
|
+
def _parse_literal_collection(items_str: str) -> list[object] | None:
|
|
1164
|
+
"""Parse a string of literal values from source code.
|
|
1165
|
+
|
|
1166
|
+
Handles strings like '"a", "b", "c"' or '1, 2, 3'.
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
items_str: Comma-separated literal values.
|
|
1170
|
+
|
|
1171
|
+
Returns:
|
|
1172
|
+
List of parsed values, or None if parsing fails.
|
|
1173
|
+
"""
|
|
1174
|
+
items: list[object] = []
|
|
1175
|
+
# Loop invariant: items contains parsed values for parts[0..i]
|
|
1176
|
+
for part in items_str.split(","):
|
|
1177
|
+
part = part.strip()
|
|
1178
|
+
if not part:
|
|
1179
|
+
continue
|
|
1180
|
+
# Try string literals
|
|
1181
|
+
if (part.startswith('"') and part.endswith('"')) or \
|
|
1182
|
+
(part.startswith("'") and part.endswith("'")):
|
|
1183
|
+
items.append(part[1:-1])
|
|
1184
|
+
# Try integers
|
|
1185
|
+
elif part.lstrip("-").isdigit():
|
|
1186
|
+
items.append(int(part))
|
|
1187
|
+
# Try floats
|
|
1188
|
+
else:
|
|
1189
|
+
try:
|
|
1190
|
+
items.append(float(part))
|
|
1191
|
+
except ValueError:
|
|
1192
|
+
return None # can't parse
|
|
1193
|
+
return items if items else None
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
@icontract.require(lambda func: callable(func), "func must be callable")
|
|
1197
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
1198
|
+
def _has_icontract_decorators(func: Callable[..., object]) -> bool:
|
|
1199
|
+
"""Check if a function has icontract decorators.
|
|
1200
|
+
|
|
1201
|
+
Args:
|
|
1202
|
+
func: Function to check.
|
|
1203
|
+
|
|
1204
|
+
Returns:
|
|
1205
|
+
True if the function has icontract require or ensure decorators.
|
|
1206
|
+
"""
|
|
1207
|
+
return (
|
|
1208
|
+
hasattr(func, "__preconditions__")
|
|
1209
|
+
or hasattr(func, "__postconditions__")
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
@icontract.require(lambda func: callable(func), "func must be callable")
|
|
1214
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
1215
|
+
def _is_property_friendly_function(func: Callable[..., object]) -> bool:
|
|
1216
|
+
"""Check whether a function is a good fit for property-based fuzzing."""
|
|
1217
|
+
module_name = getattr(func, "__module__", "")
|
|
1218
|
+
if module_name in {"serenecode", "serenecode.cli", "serenecode.init"}:
|
|
1219
|
+
return False
|
|
1220
|
+
if module_name.startswith("serenecode.adapters"):
|
|
1221
|
+
return False
|
|
1222
|
+
return True
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
@icontract.require(
|
|
1226
|
+
lambda annotation: annotation is inspect.Parameter.empty or isinstance(annotation, object),
|
|
1227
|
+
"annotation must be a Python annotation object",
|
|
1228
|
+
)
|
|
1229
|
+
@icontract.require(
|
|
1230
|
+
lambda globalns: globalns is None or isinstance(globalns, dict),
|
|
1231
|
+
"globalns must be a globals dictionary when provided",
|
|
1232
|
+
)
|
|
1233
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
1234
|
+
def _uses_result_model_annotation(
|
|
1235
|
+
annotation: object,
|
|
1236
|
+
globalns: dict[str, object] | None = None,
|
|
1237
|
+
) -> bool:
|
|
1238
|
+
"""Check whether an annotation references Serenecode's result-model graph."""
|
|
1239
|
+
# Variant: the remaining annotation nesting decreases on each recursive call into args.
|
|
1240
|
+
if annotation is inspect.Parameter.empty:
|
|
1241
|
+
return False
|
|
1242
|
+
if isinstance(annotation, typing.ForwardRef):
|
|
1243
|
+
return _uses_result_model_annotation(annotation.__forward_arg__, globalns)
|
|
1244
|
+
if isinstance(annotation, str):
|
|
1245
|
+
return _string_annotation_uses_result_model(annotation, globalns)
|
|
1246
|
+
|
|
1247
|
+
origin = typing.get_origin(annotation)
|
|
1248
|
+
args = typing.get_args(annotation)
|
|
1249
|
+
if origin in (typing.Union, types.UnionType):
|
|
1250
|
+
return any(_uses_result_model_annotation(arg, globalns) for arg in args)
|
|
1251
|
+
if origin is not None:
|
|
1252
|
+
return any(_uses_result_model_annotation(arg, globalns) for arg in args if arg is not Ellipsis)
|
|
1253
|
+
|
|
1254
|
+
module_name = getattr(annotation, "__module__", "")
|
|
1255
|
+
return module_name == "serenecode.models"
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
@icontract.require(lambda annotation: isinstance(annotation, str), "annotation must be a string")
|
|
1259
|
+
@icontract.require(
|
|
1260
|
+
lambda globalns: globalns is None or isinstance(globalns, dict),
|
|
1261
|
+
"globalns must be a globals dictionary when provided",
|
|
1262
|
+
)
|
|
1263
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
1264
|
+
def _string_annotation_uses_result_model(
|
|
1265
|
+
annotation: str,
|
|
1266
|
+
globalns: dict[str, object] | None = None,
|
|
1267
|
+
) -> bool:
|
|
1268
|
+
"""Check whether a raw string annotation clearly references serenecode.models."""
|
|
1269
|
+
result_model_names = _result_model_public_names()
|
|
1270
|
+
|
|
1271
|
+
# Loop invariant: no previously-seen dotted name referenced Serenecode's result models.
|
|
1272
|
+
for dotted_name in re.findall(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*", annotation):
|
|
1273
|
+
if dotted_name.startswith("serenecode.models."):
|
|
1274
|
+
if dotted_name.rsplit(".", 1)[-1] in result_model_names:
|
|
1275
|
+
return True
|
|
1276
|
+
continue
|
|
1277
|
+
|
|
1278
|
+
if "." not in dotted_name:
|
|
1279
|
+
if globalns is not None and _is_result_model_object(globalns.get(dotted_name)):
|
|
1280
|
+
return True
|
|
1281
|
+
continue
|
|
1282
|
+
|
|
1283
|
+
root_name, _, remainder = dotted_name.partition(".")
|
|
1284
|
+
if globalns is None or not _is_result_model_module(globalns.get(root_name)):
|
|
1285
|
+
continue
|
|
1286
|
+
remainder_parts = tuple(part for part in remainder.split(".") if part)
|
|
1287
|
+
if remainder_parts and remainder_parts[0] in result_model_names:
|
|
1288
|
+
return True
|
|
1289
|
+
|
|
1290
|
+
return False
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
@icontract.ensure(lambda result: isinstance(result, frozenset), "result must be a frozenset")
|
|
1294
|
+
def _result_model_public_names() -> frozenset[str]:
|
|
1295
|
+
"""Return the public names exported from serenecode.models."""
|
|
1296
|
+
from serenecode import models as result_models
|
|
1297
|
+
|
|
1298
|
+
return frozenset(
|
|
1299
|
+
name
|
|
1300
|
+
for name in dir(result_models)
|
|
1301
|
+
if not name.startswith("_")
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
@icontract.require(
|
|
1306
|
+
lambda value: isinstance(value, object),
|
|
1307
|
+
"value must be a Python object",
|
|
1308
|
+
)
|
|
1309
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
1310
|
+
def _is_result_model_module(value: object) -> bool:
|
|
1311
|
+
"""Check whether a runtime value is the serenecode.models module."""
|
|
1312
|
+
return isinstance(value, types.ModuleType) and value.__name__ == "serenecode.models"
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
@icontract.require(
|
|
1316
|
+
lambda value: isinstance(value, object),
|
|
1317
|
+
"value must be a Python object",
|
|
1318
|
+
)
|
|
1319
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
1320
|
+
def _is_result_model_object(value: object) -> bool:
|
|
1321
|
+
"""Check whether a runtime value comes from serenecode.models."""
|
|
1322
|
+
return getattr(value, "__module__", "") == "serenecode.models"
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
@icontract.require(
|
|
1326
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
1327
|
+
"module_path must be a non-empty string",
|
|
1328
|
+
)
|
|
1329
|
+
@icontract.require(
|
|
1330
|
+
lambda search_paths: isinstance(search_paths, tuple),
|
|
1331
|
+
"search_paths must be a tuple",
|
|
1332
|
+
)
|
|
1333
|
+
@icontract.ensure(lambda result: isinstance(result, tuple) and len(result) == 2, "result must be a 2-tuple")
|
|
1334
|
+
def _get_contracted_functions(
|
|
1335
|
+
module_path: str,
|
|
1336
|
+
search_paths: tuple[str, ...] = (),
|
|
1337
|
+
) -> tuple[list[tuple[str, Callable[..., object]]], list[str]]:
|
|
1338
|
+
"""Import a module and find all functions with icontract decorators.
|
|
1339
|
+
|
|
1340
|
+
Args:
|
|
1341
|
+
module_path: Importable Python module path.
|
|
1342
|
+
|
|
1343
|
+
Returns:
|
|
1344
|
+
A tuple of (testable_functions, excluded_function_names).
|
|
1345
|
+
"""
|
|
1346
|
+
try:
|
|
1347
|
+
module = load_python_module(module_path, search_paths)
|
|
1348
|
+
except ImportError as exc:
|
|
1349
|
+
raise ToolNotInstalledError(
|
|
1350
|
+
f"Cannot import module '{module_path}': {exc}"
|
|
1351
|
+
) from exc
|
|
1352
|
+
|
|
1353
|
+
functions: list[tuple[str, Callable[..., object]]] = []
|
|
1354
|
+
excluded: list[str] = []
|
|
1355
|
+
|
|
1356
|
+
# Loop invariant: functions + excluded accounts for all contracted public functions in dir(module)[0..i]
|
|
1357
|
+
for name in dir(module):
|
|
1358
|
+
if name.startswith("_"):
|
|
1359
|
+
continue
|
|
1360
|
+
obj = getattr(module, name)
|
|
1361
|
+
if callable(obj) and inspect.isfunction(obj):
|
|
1362
|
+
if getattr(obj, "__module__", None) != module.__name__:
|
|
1363
|
+
continue
|
|
1364
|
+
if not _has_icontract_decorators(obj):
|
|
1365
|
+
continue
|
|
1366
|
+
if _is_property_friendly_function(obj):
|
|
1367
|
+
functions.append((name, obj))
|
|
1368
|
+
else:
|
|
1369
|
+
excluded.append(name)
|
|
1370
|
+
|
|
1371
|
+
return (functions, excluded)
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
@icontract.invariant(
|
|
1375
|
+
lambda self: is_positive_int(self._max_examples),
|
|
1376
|
+
"max_examples must remain positive",
|
|
1377
|
+
)
|
|
1378
|
+
class HypothesisPropertyTester:
|
|
1379
|
+
"""Property tester implementation using Hypothesis.
|
|
1380
|
+
|
|
1381
|
+
Runs Hypothesis tests against functions with icontract decorators,
|
|
1382
|
+
using icontract's runtime contract checking to detect violations.
|
|
1383
|
+
"""
|
|
1384
|
+
|
|
1385
|
+
@icontract.require(
|
|
1386
|
+
lambda max_examples: is_positive_int(max_examples),
|
|
1387
|
+
"max_examples must be positive",
|
|
1388
|
+
)
|
|
1389
|
+
@icontract.ensure(lambda result: result is None, "initialization returns None")
|
|
1390
|
+
def __init__(
|
|
1391
|
+
self,
|
|
1392
|
+
max_examples: int = 100,
|
|
1393
|
+
allow_code_execution: bool = False,
|
|
1394
|
+
) -> None:
|
|
1395
|
+
"""Initialize the tester.
|
|
1396
|
+
|
|
1397
|
+
Args:
|
|
1398
|
+
max_examples: Default maximum examples per function.
|
|
1399
|
+
"""
|
|
1400
|
+
self._max_examples = max_examples
|
|
1401
|
+
self._allow_code_execution = allow_code_execution
|
|
1402
|
+
|
|
1403
|
+
@icontract.require(
|
|
1404
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
1405
|
+
"module_path must be a non-empty string",
|
|
1406
|
+
)
|
|
1407
|
+
@icontract.require(
|
|
1408
|
+
lambda max_examples: max_examples is None or is_positive_int(max_examples),
|
|
1409
|
+
"max_examples must be positive when provided",
|
|
1410
|
+
)
|
|
1411
|
+
@icontract.require(
|
|
1412
|
+
lambda search_paths: isinstance(search_paths, tuple),
|
|
1413
|
+
"search_paths must be a tuple",
|
|
1414
|
+
)
|
|
1415
|
+
@icontract.ensure(lambda result: isinstance(result, list), "result must be a list")
|
|
1416
|
+
def test_module(
|
|
1417
|
+
self,
|
|
1418
|
+
module_path: str,
|
|
1419
|
+
max_examples: int | None = None,
|
|
1420
|
+
search_paths: tuple[str, ...] = (),
|
|
1421
|
+
) -> list[PropertyFinding]:
|
|
1422
|
+
"""Run property-based tests on all contracted functions in a module.
|
|
1423
|
+
|
|
1424
|
+
Args:
|
|
1425
|
+
module_path: Importable Python module path to test.
|
|
1426
|
+
max_examples: Maximum number of test examples per function.
|
|
1427
|
+
|
|
1428
|
+
Returns:
|
|
1429
|
+
List of property findings.
|
|
1430
|
+
"""
|
|
1431
|
+
if not _HYPOTHESIS_AVAILABLE:
|
|
1432
|
+
raise ToolNotInstalledError(
|
|
1433
|
+
"Hypothesis is not installed. Install with: pip install hypothesis"
|
|
1434
|
+
)
|
|
1435
|
+
if not self._allow_code_execution:
|
|
1436
|
+
raise UnsafeCodeExecutionError(_TRUST_REQUIRED_MESSAGE)
|
|
1437
|
+
|
|
1438
|
+
effective_max = self._max_examples if max_examples is None else max_examples
|
|
1439
|
+
functions, excluded = _get_contracted_functions(module_path, search_paths)
|
|
1440
|
+
findings: list[PropertyFinding] = []
|
|
1441
|
+
|
|
1442
|
+
# Report excluded functions so they are visible in the output
|
|
1443
|
+
# Loop invariant: findings contains exclusion records for excluded[0..i]
|
|
1444
|
+
for excluded_name in excluded:
|
|
1445
|
+
findings.append(PropertyFinding(
|
|
1446
|
+
function_name=excluded_name,
|
|
1447
|
+
module_path=module_path,
|
|
1448
|
+
passed=True,
|
|
1449
|
+
finding_type="excluded",
|
|
1450
|
+
message=f"Function '{excluded_name}' excluded from property testing (adapter/composition-root code)",
|
|
1451
|
+
))
|
|
1452
|
+
|
|
1453
|
+
# Loop invariant: findings contains test results for functions[0..i]
|
|
1454
|
+
for func_name, func in functions:
|
|
1455
|
+
finding = self._test_single_function(func_name, func, module_path, effective_max)
|
|
1456
|
+
findings.append(finding)
|
|
1457
|
+
|
|
1458
|
+
return findings
|
|
1459
|
+
|
|
1460
|
+
@icontract.require(
|
|
1461
|
+
lambda func_name: is_non_empty_string(func_name),
|
|
1462
|
+
"func_name must be a non-empty string",
|
|
1463
|
+
)
|
|
1464
|
+
@icontract.require(lambda func: callable(func), "func must be callable")
|
|
1465
|
+
@icontract.require(
|
|
1466
|
+
lambda module_path: is_non_empty_string(module_path),
|
|
1467
|
+
"module_path must be a non-empty string",
|
|
1468
|
+
)
|
|
1469
|
+
@icontract.require(
|
|
1470
|
+
lambda max_examples: is_positive_int(max_examples),
|
|
1471
|
+
"max_examples must be positive",
|
|
1472
|
+
)
|
|
1473
|
+
@icontract.ensure(
|
|
1474
|
+
lambda result: isinstance(result, PropertyFinding),
|
|
1475
|
+
"result must be a PropertyFinding",
|
|
1476
|
+
)
|
|
1477
|
+
def _test_single_function(
|
|
1478
|
+
self,
|
|
1479
|
+
func_name: str,
|
|
1480
|
+
func: Callable[..., object],
|
|
1481
|
+
module_path: str,
|
|
1482
|
+
max_examples: int,
|
|
1483
|
+
) -> PropertyFinding:
|
|
1484
|
+
"""Test a single function with Hypothesis.
|
|
1485
|
+
|
|
1486
|
+
Args:
|
|
1487
|
+
func_name: Name of the function.
|
|
1488
|
+
func: The function to test.
|
|
1489
|
+
module_path: Module path for reporting.
|
|
1490
|
+
max_examples: Max examples to generate.
|
|
1491
|
+
|
|
1492
|
+
Returns:
|
|
1493
|
+
A PropertyFinding for this function.
|
|
1494
|
+
"""
|
|
1495
|
+
strategies = _build_strategies_from_signature(func)
|
|
1496
|
+
|
|
1497
|
+
if strategies is None:
|
|
1498
|
+
return PropertyFinding(
|
|
1499
|
+
function_name=func_name,
|
|
1500
|
+
module_path=module_path,
|
|
1501
|
+
passed=True,
|
|
1502
|
+
finding_type="skipped",
|
|
1503
|
+
message=f"Cannot derive strategies for '{func_name}' — unsupported parameter types",
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
try:
|
|
1507
|
+
self._run_hypothesis_test(func, strategies, max_examples)
|
|
1508
|
+
return PropertyFinding(
|
|
1509
|
+
function_name=func_name,
|
|
1510
|
+
module_path=module_path,
|
|
1511
|
+
passed=True,
|
|
1512
|
+
finding_type="verified",
|
|
1513
|
+
message=f"Property tests passed for '{func_name}' ({max_examples} examples)",
|
|
1514
|
+
)
|
|
1515
|
+
except icontract.ViolationError as exc:
|
|
1516
|
+
# Distinguish precondition vs postcondition violations
|
|
1517
|
+
error_str = str(exc)
|
|
1518
|
+
if "Precondition" in error_str:
|
|
1519
|
+
# Precondition violation means the input doesn't satisfy the
|
|
1520
|
+
# contract — this is expected and not a real failure.
|
|
1521
|
+
# But if it happens consistently, it means strategies are bad.
|
|
1522
|
+
return PropertyFinding(
|
|
1523
|
+
function_name=func_name,
|
|
1524
|
+
module_path=module_path,
|
|
1525
|
+
passed=True,
|
|
1526
|
+
finding_type="skipped",
|
|
1527
|
+
message=(
|
|
1528
|
+
f"Cannot generate valid inputs for '{func_name}' — "
|
|
1529
|
+
"preconditions too restrictive for derived strategies"
|
|
1530
|
+
),
|
|
1531
|
+
)
|
|
1532
|
+
else:
|
|
1533
|
+
# Postcondition violation — real failure
|
|
1534
|
+
counterexample = _extract_counterexample(exc)
|
|
1535
|
+
condition = _extract_violated_condition(error_str)
|
|
1536
|
+
inputs_str = (
|
|
1537
|
+
", ".join(f"{k}={v}" for k, v in counterexample.items())
|
|
1538
|
+
if counterexample
|
|
1539
|
+
else "unknown"
|
|
1540
|
+
)
|
|
1541
|
+
message = (
|
|
1542
|
+
f"Postcondition violated for '{func_name}': "
|
|
1543
|
+
f"condition '{condition}' failed with inputs: {inputs_str}"
|
|
1544
|
+
if condition
|
|
1545
|
+
else f"Postcondition violated for '{func_name}': {error_str}"
|
|
1546
|
+
)
|
|
1547
|
+
return PropertyFinding(
|
|
1548
|
+
function_name=func_name,
|
|
1549
|
+
module_path=module_path,
|
|
1550
|
+
passed=False,
|
|
1551
|
+
finding_type="postcondition_violated",
|
|
1552
|
+
message=message,
|
|
1553
|
+
counterexample=counterexample,
|
|
1554
|
+
)
|
|
1555
|
+
except Exception as exc:
|
|
1556
|
+
# Check if a ViolationError is nested inside (Hypothesis wraps exceptions)
|
|
1557
|
+
violation = _find_nested_violation(exc)
|
|
1558
|
+
if violation is not None:
|
|
1559
|
+
counterexample = _extract_counterexample(violation)
|
|
1560
|
+
violation_str = str(violation)
|
|
1561
|
+
condition = _extract_violated_condition(violation_str)
|
|
1562
|
+
inputs_str = (
|
|
1563
|
+
", ".join(f"{k}={v}" for k, v in counterexample.items())
|
|
1564
|
+
if counterexample
|
|
1565
|
+
else "unknown"
|
|
1566
|
+
)
|
|
1567
|
+
message = (
|
|
1568
|
+
f"Postcondition violated for '{func_name}': "
|
|
1569
|
+
f"condition '{condition}' failed with inputs: {inputs_str}"
|
|
1570
|
+
if condition
|
|
1571
|
+
else f"Postcondition violated for '{func_name}': {violation_str}"
|
|
1572
|
+
)
|
|
1573
|
+
return PropertyFinding(
|
|
1574
|
+
function_name=func_name,
|
|
1575
|
+
module_path=module_path,
|
|
1576
|
+
passed=False,
|
|
1577
|
+
finding_type="postcondition_violated",
|
|
1578
|
+
message=message,
|
|
1579
|
+
counterexample=counterexample,
|
|
1580
|
+
)
|
|
1581
|
+
return PropertyFinding(
|
|
1582
|
+
function_name=func_name,
|
|
1583
|
+
module_path=module_path,
|
|
1584
|
+
passed=False,
|
|
1585
|
+
finding_type="crash",
|
|
1586
|
+
message=f"Function '{func_name}' crashed during testing: {exc}",
|
|
1587
|
+
exception_type=type(exc).__name__,
|
|
1588
|
+
exception_message=str(exc),
|
|
1589
|
+
)
|
|
1590
|
+
|
|
1591
|
+
@icontract.require(lambda func: callable(func), "func must be callable")
|
|
1592
|
+
@icontract.require(lambda strategies: isinstance(strategies, dict), "strategies must be a dict")
|
|
1593
|
+
@icontract.require(
|
|
1594
|
+
lambda max_examples: is_positive_int(max_examples),
|
|
1595
|
+
"max_examples must be positive",
|
|
1596
|
+
)
|
|
1597
|
+
@icontract.ensure(lambda result: result is None, "test execution returns None")
|
|
1598
|
+
def _run_hypothesis_test(
|
|
1599
|
+
self,
|
|
1600
|
+
func: Callable[..., object],
|
|
1601
|
+
strategies: dict[str, SearchStrategy],
|
|
1602
|
+
max_examples: int,
|
|
1603
|
+
) -> None:
|
|
1604
|
+
"""Run a Hypothesis test on a function.
|
|
1605
|
+
|
|
1606
|
+
Args:
|
|
1607
|
+
func: The function to test.
|
|
1608
|
+
strategies: Mapping of parameter names to strategies.
|
|
1609
|
+
max_examples: Max examples to generate.
|
|
1610
|
+
|
|
1611
|
+
Raises:
|
|
1612
|
+
Any exception raised by the function or its contracts.
|
|
1613
|
+
"""
|
|
1614
|
+
if not strategies:
|
|
1615
|
+
func()
|
|
1616
|
+
return
|
|
1617
|
+
|
|
1618
|
+
test_settings = settings(
|
|
1619
|
+
max_examples=max_examples,
|
|
1620
|
+
deadline=None,
|
|
1621
|
+
suppress_health_check=[
|
|
1622
|
+
HealthCheck.too_slow,
|
|
1623
|
+
HealthCheck.filter_too_much,
|
|
1624
|
+
],
|
|
1625
|
+
verbosity=Verbosity.quiet,
|
|
1626
|
+
database=None,
|
|
1627
|
+
)
|
|
1628
|
+
|
|
1629
|
+
@test_settings
|
|
1630
|
+
@given(**strategies)
|
|
1631
|
+
def test_wrapper(**kwargs: object) -> None:
|
|
1632
|
+
"""Wrapper that calls the function with generated inputs."""
|
|
1633
|
+
# First check if inputs satisfy preconditions by testing them
|
|
1634
|
+
if not _check_preconditions(func, kwargs):
|
|
1635
|
+
from hypothesis import assume
|
|
1636
|
+
assume(False)
|
|
1637
|
+
return
|
|
1638
|
+
|
|
1639
|
+
try:
|
|
1640
|
+
func(**kwargs)
|
|
1641
|
+
except icontract.ViolationError:
|
|
1642
|
+
# If preconditions passed but ViolationError is raised,
|
|
1643
|
+
# it must be a postcondition or invariant violation
|
|
1644
|
+
raise
|
|
1645
|
+
|
|
1646
|
+
test_wrapper()
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
@icontract.require(lambda exc: isinstance(exc, BaseException), "exc must be an exception")
|
|
1650
|
+
@icontract.ensure(
|
|
1651
|
+
lambda result: result is None or isinstance(result, icontract.ViolationError),
|
|
1652
|
+
"result must be a ViolationError or None",
|
|
1653
|
+
)
|
|
1654
|
+
def _find_nested_violation(exc: BaseException) -> icontract.ViolationError | None:
|
|
1655
|
+
"""Search exception chain for an icontract ViolationError.
|
|
1656
|
+
|
|
1657
|
+
Hypothesis wraps exceptions in MultipleFailures or other wrapper
|
|
1658
|
+
types. This function walks the exception chain to find the
|
|
1659
|
+
underlying ViolationError.
|
|
1660
|
+
|
|
1661
|
+
Args:
|
|
1662
|
+
exc: The exception to search.
|
|
1663
|
+
|
|
1664
|
+
Returns:
|
|
1665
|
+
The ViolationError if found, None otherwise.
|
|
1666
|
+
"""
|
|
1667
|
+
# Check the exception itself
|
|
1668
|
+
if isinstance(exc, icontract.ViolationError):
|
|
1669
|
+
return exc
|
|
1670
|
+
|
|
1671
|
+
# Check __cause__ and __context__
|
|
1672
|
+
# Variant: depth of exception chain decreases
|
|
1673
|
+
seen: set[int] = set()
|
|
1674
|
+
current: BaseException | None = exc
|
|
1675
|
+
# Loop invariant: seen contains every exception object already traversed in the chain
|
|
1676
|
+
while current is not None and id(current) not in seen:
|
|
1677
|
+
seen.add(id(current))
|
|
1678
|
+
if isinstance(current, icontract.ViolationError):
|
|
1679
|
+
return current
|
|
1680
|
+
# Also check sub-exceptions (Hypothesis MultipleFailures)
|
|
1681
|
+
sub_exceptions = getattr(current, "exceptions", None)
|
|
1682
|
+
if sub_exceptions:
|
|
1683
|
+
# Loop invariant: checked sub_exceptions[0..i]
|
|
1684
|
+
for sub in sub_exceptions:
|
|
1685
|
+
if isinstance(sub, icontract.ViolationError):
|
|
1686
|
+
return sub
|
|
1687
|
+
nested = _find_nested_violation(sub)
|
|
1688
|
+
if nested is not None:
|
|
1689
|
+
return nested
|
|
1690
|
+
current = current.__cause__ or current.__context__
|
|
1691
|
+
|
|
1692
|
+
return None
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
@icontract.require(lambda func: callable(func), "func must be callable")
|
|
1696
|
+
@icontract.require(lambda kwargs: isinstance(kwargs, dict), "kwargs must be a dict")
|
|
1697
|
+
@icontract.ensure(lambda result: isinstance(result, bool), "result must be a bool")
|
|
1698
|
+
def _check_preconditions(
|
|
1699
|
+
func: Callable[..., object],
|
|
1700
|
+
kwargs: dict[str, object],
|
|
1701
|
+
) -> bool:
|
|
1702
|
+
"""Check if inputs satisfy a function's icontract preconditions.
|
|
1703
|
+
|
|
1704
|
+
Evaluates each precondition lambda without calling the full function.
|
|
1705
|
+
|
|
1706
|
+
Args:
|
|
1707
|
+
func: The contracted function.
|
|
1708
|
+
kwargs: The keyword arguments to check.
|
|
1709
|
+
|
|
1710
|
+
Returns:
|
|
1711
|
+
True if all preconditions are satisfied.
|
|
1712
|
+
"""
|
|
1713
|
+
preconditions = getattr(func, "__preconditions__", None)
|
|
1714
|
+
if not preconditions:
|
|
1715
|
+
return True
|
|
1716
|
+
|
|
1717
|
+
# Loop invariant: all precondition contracts in preconditions[0..i] are satisfied
|
|
1718
|
+
for group in preconditions:
|
|
1719
|
+
# Loop invariant: every contract in group[0..j] is satisfied
|
|
1720
|
+
for contract in group:
|
|
1721
|
+
condition = contract.condition
|
|
1722
|
+
try:
|
|
1723
|
+
# Get the parameters the condition expects
|
|
1724
|
+
import inspect
|
|
1725
|
+
sig = inspect.signature(condition)
|
|
1726
|
+
parameter_names = [
|
|
1727
|
+
name
|
|
1728
|
+
for name in sig.parameters
|
|
1729
|
+
if name not in ("self", "cls", "result")
|
|
1730
|
+
]
|
|
1731
|
+
if not parameter_names:
|
|
1732
|
+
continue
|
|
1733
|
+
if any(name not in kwargs for name in parameter_names):
|
|
1734
|
+
continue
|
|
1735
|
+
condition_kwargs = {
|
|
1736
|
+
name: kwargs[name]
|
|
1737
|
+
for name in parameter_names
|
|
1738
|
+
}
|
|
1739
|
+
if not condition(**condition_kwargs):
|
|
1740
|
+
return False
|
|
1741
|
+
except Exception:
|
|
1742
|
+
return False
|
|
1743
|
+
|
|
1744
|
+
return True
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
@icontract.require(
|
|
1748
|
+
lambda exc: isinstance(exc, icontract.ViolationError),
|
|
1749
|
+
"exc must be an icontract violation",
|
|
1750
|
+
)
|
|
1751
|
+
@icontract.ensure(
|
|
1752
|
+
lambda result: result is None or isinstance(result, dict),
|
|
1753
|
+
"result must be a dictionary or None",
|
|
1754
|
+
)
|
|
1755
|
+
@icontract.require(
|
|
1756
|
+
lambda error_str: isinstance(error_str, str),
|
|
1757
|
+
"error_str must be a string",
|
|
1758
|
+
)
|
|
1759
|
+
@icontract.ensure(
|
|
1760
|
+
lambda result: result is None or isinstance(result, str),
|
|
1761
|
+
"result must be a string or None",
|
|
1762
|
+
)
|
|
1763
|
+
def _extract_violated_condition(error_str: str) -> str | None:
|
|
1764
|
+
"""Extract the violated condition text from an icontract error message.
|
|
1765
|
+
|
|
1766
|
+
icontract formats the second line as "description: condition_expression:".
|
|
1767
|
+
|
|
1768
|
+
Args:
|
|
1769
|
+
error_str: The full icontract error string.
|
|
1770
|
+
|
|
1771
|
+
Returns:
|
|
1772
|
+
The condition expression, or None.
|
|
1773
|
+
"""
|
|
1774
|
+
lines = error_str.split("\n")
|
|
1775
|
+
if len(lines) < 2:
|
|
1776
|
+
return None
|
|
1777
|
+
# Second line is typically "description: condition:"
|
|
1778
|
+
condition_line = lines[1].strip()
|
|
1779
|
+
if ": " in condition_line:
|
|
1780
|
+
# Extract everything after the description
|
|
1781
|
+
_, _, condition_part = condition_line.partition(": ")
|
|
1782
|
+
# Remove trailing colon
|
|
1783
|
+
return condition_part.rstrip(":")
|
|
1784
|
+
return None
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
@icontract.require(
|
|
1788
|
+
lambda exc: isinstance(exc, icontract.ViolationError),
|
|
1789
|
+
"exc must be an icontract violation",
|
|
1790
|
+
)
|
|
1791
|
+
@icontract.ensure(
|
|
1792
|
+
lambda result: result is None or isinstance(result, dict),
|
|
1793
|
+
"result must be a dictionary or None",
|
|
1794
|
+
)
|
|
1795
|
+
def _extract_counterexample(exc: icontract.ViolationError) -> dict[str, object] | None:
|
|
1796
|
+
"""Extract counterexample data from an icontract violation.
|
|
1797
|
+
|
|
1798
|
+
icontract formats variable values as "<name> was <value>" lines in the
|
|
1799
|
+
error message. This function parses those lines into a dict.
|
|
1800
|
+
|
|
1801
|
+
Args:
|
|
1802
|
+
exc: The violation error to extract from.
|
|
1803
|
+
|
|
1804
|
+
Returns:
|
|
1805
|
+
A dict mapping argument names to their values, or None.
|
|
1806
|
+
"""
|
|
1807
|
+
error_str = str(exc)
|
|
1808
|
+
try:
|
|
1809
|
+
lines = error_str.split("\n")
|
|
1810
|
+
counterexample: dict[str, object] = {}
|
|
1811
|
+
# Loop invariant: counterexample contains parsed "X was Y" bindings from lines[0..i]
|
|
1812
|
+
for line in lines:
|
|
1813
|
+
stripped = line.strip()
|
|
1814
|
+
# icontract uses "<name> was <value>" format for variable bindings
|
|
1815
|
+
if " was " in stripped:
|
|
1816
|
+
name, _, value = stripped.partition(" was ")
|
|
1817
|
+
name = name.strip()
|
|
1818
|
+
value = value.strip()
|
|
1819
|
+
# Skip the condition description line (contains ":")
|
|
1820
|
+
if name and ":" not in name and not name.startswith("File "):
|
|
1821
|
+
counterexample[name] = value
|
|
1822
|
+
return counterexample if counterexample else None
|
|
1823
|
+
except Exception:
|
|
1824
|
+
return None
|