invar-tools 1.0.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.
- invar/__init__.py +68 -0
- invar/contracts.py +152 -0
- invar/core/__init__.py +8 -0
- invar/core/contracts.py +375 -0
- invar/core/extraction.py +172 -0
- invar/core/formatter.py +281 -0
- invar/core/hypothesis_strategies.py +454 -0
- invar/core/inspect.py +154 -0
- invar/core/lambda_helpers.py +190 -0
- invar/core/models.py +289 -0
- invar/core/must_use.py +172 -0
- invar/core/parser.py +276 -0
- invar/core/property_gen.py +383 -0
- invar/core/purity.py +369 -0
- invar/core/purity_heuristics.py +184 -0
- invar/core/references.py +180 -0
- invar/core/rule_meta.py +203 -0
- invar/core/rules.py +435 -0
- invar/core/strategies.py +267 -0
- invar/core/suggestions.py +324 -0
- invar/core/tautology.py +137 -0
- invar/core/timeout_inference.py +114 -0
- invar/core/utils.py +364 -0
- invar/decorators.py +94 -0
- invar/invariant.py +57 -0
- invar/mcp/__init__.py +10 -0
- invar/mcp/__main__.py +13 -0
- invar/mcp/server.py +251 -0
- invar/py.typed +0 -0
- invar/resource.py +99 -0
- invar/shell/__init__.py +8 -0
- invar/shell/cli.py +358 -0
- invar/shell/config.py +248 -0
- invar/shell/fs.py +112 -0
- invar/shell/git.py +85 -0
- invar/shell/guard_helpers.py +324 -0
- invar/shell/guard_output.py +235 -0
- invar/shell/init_cmd.py +289 -0
- invar/shell/mcp_config.py +171 -0
- invar/shell/perception.py +125 -0
- invar/shell/property_tests.py +227 -0
- invar/shell/prove.py +460 -0
- invar/shell/prove_cache.py +133 -0
- invar/shell/prove_fallback.py +183 -0
- invar/shell/templates.py +443 -0
- invar/shell/test_cmd.py +117 -0
- invar/shell/testing.py +297 -0
- invar/shell/update_cmd.py +191 -0
- invar/templates/CLAUDE.md.template +58 -0
- invar/templates/INVAR.md +134 -0
- invar/templates/__init__.py +1 -0
- invar/templates/aider.conf.yml.template +29 -0
- invar/templates/context.md.template +51 -0
- invar/templates/cursorrules.template +28 -0
- invar/templates/examples/README.md +21 -0
- invar/templates/examples/contracts.py +111 -0
- invar/templates/examples/core_shell.py +121 -0
- invar/templates/pre-commit-config.yaml.template +44 -0
- invar/templates/proposal.md.template +93 -0
- invar_tools-1.0.0.dist-info/METADATA +321 -0
- invar_tools-1.0.0.dist-info/RECORD +64 -0
- invar_tools-1.0.0.dist-info/WHEEL +4 -0
- invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
invar/core/utils.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pure utility functions.
|
|
3
|
+
|
|
4
|
+
These functions were moved from Shell to Core because they contain
|
|
5
|
+
no I/O operations - they are pure data transformations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from deal import post, pre
|
|
14
|
+
|
|
15
|
+
from invar.core.models import GuardReport, RuleConfig, RuleExclusion
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pre(lambda report, strict: isinstance(report, GuardReport))
|
|
19
|
+
@post(lambda result: result in (0, 1))
|
|
20
|
+
def get_exit_code(report: GuardReport, strict: bool) -> int:
|
|
21
|
+
"""
|
|
22
|
+
Determine exit code based on report and strict mode.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
>>> from invar.core.models import GuardReport
|
|
26
|
+
>>> get_exit_code(GuardReport(files_checked=1), strict=False)
|
|
27
|
+
0
|
|
28
|
+
>>> report = GuardReport(files_checked=1)
|
|
29
|
+
>>> report.errors = 1
|
|
30
|
+
>>> get_exit_code(report, strict=False)
|
|
31
|
+
1
|
|
32
|
+
"""
|
|
33
|
+
if report.errors > 0:
|
|
34
|
+
return 1
|
|
35
|
+
if strict and report.warnings > 0:
|
|
36
|
+
return 1
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pre(lambda data, source: isinstance(data, dict) and isinstance(source, str))
|
|
41
|
+
@post(lambda result: isinstance(result, dict))
|
|
42
|
+
def extract_guard_section(data: dict[str, Any], source: str) -> dict[str, Any]:
|
|
43
|
+
"""
|
|
44
|
+
Extract guard config section based on source type.
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
>>> extract_guard_section({"tool": {"invar": {"guard": {"x": 1}}}}, "pyproject")
|
|
48
|
+
{'x': 1}
|
|
49
|
+
>>> extract_guard_section({"guard": {"y": 2}}, "invar")
|
|
50
|
+
{'y': 2}
|
|
51
|
+
>>> extract_guard_section({}, "default")
|
|
52
|
+
{}
|
|
53
|
+
>>> extract_guard_section({"guard": 0}, "invar") # Non-dict value returns empty
|
|
54
|
+
{}
|
|
55
|
+
"""
|
|
56
|
+
if source == "pyproject":
|
|
57
|
+
result = data.get("tool", {})
|
|
58
|
+
if not isinstance(result, dict):
|
|
59
|
+
return {}
|
|
60
|
+
result = result.get("invar", {})
|
|
61
|
+
if not isinstance(result, dict):
|
|
62
|
+
return {}
|
|
63
|
+
result = result.get("guard", {})
|
|
64
|
+
return result if isinstance(result, dict) else {}
|
|
65
|
+
# invar.toml and .invar/config.toml use [guard] directly
|
|
66
|
+
result = data.get("guard", {})
|
|
67
|
+
return result if isinstance(result, dict) else {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
|
|
71
|
+
@post(lambda result: result is None or isinstance(result, bool))
|
|
72
|
+
def _get_bool(config: dict[str, Any], key: str) -> bool | None:
|
|
73
|
+
"""
|
|
74
|
+
Safely extract a boolean from config, returning None if invalid.
|
|
75
|
+
|
|
76
|
+
>>> _get_bool({"a": True}, "a")
|
|
77
|
+
True
|
|
78
|
+
>>> _get_bool({"a": "not bool"}, "a") is None
|
|
79
|
+
True
|
|
80
|
+
"""
|
|
81
|
+
val = config.get(key)
|
|
82
|
+
if isinstance(val, bool):
|
|
83
|
+
return bool(val) # Convert to ensure real Python bool
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
|
|
88
|
+
@post(lambda result: result is None or isinstance(result, int))
|
|
89
|
+
def _get_int(config: dict[str, Any], key: str) -> int | None:
|
|
90
|
+
"""
|
|
91
|
+
Safely extract an integer from config, returning None if invalid.
|
|
92
|
+
|
|
93
|
+
>>> _get_int({"a": 42}, "a")
|
|
94
|
+
42
|
|
95
|
+
>>> _get_int({"a": "not int"}, "a") is None
|
|
96
|
+
True
|
|
97
|
+
"""
|
|
98
|
+
val = config.get(key)
|
|
99
|
+
if isinstance(val, int) and not isinstance(val, bool):
|
|
100
|
+
return int(val) # Convert to ensure real Python int
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
|
|
105
|
+
@post(lambda result: result is None or isinstance(result, float))
|
|
106
|
+
def _get_float(config: dict[str, Any], key: str) -> float | None:
|
|
107
|
+
"""
|
|
108
|
+
Safely extract a float from config, returning None if invalid.
|
|
109
|
+
|
|
110
|
+
>>> _get_float({"a": 3.14}, "a")
|
|
111
|
+
3.14
|
|
112
|
+
>>> _get_float({"a": 10}, "a")
|
|
113
|
+
10.0
|
|
114
|
+
>>> _get_float({"a": "not float"}, "a") is None
|
|
115
|
+
True
|
|
116
|
+
"""
|
|
117
|
+
val = config.get(key)
|
|
118
|
+
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
|
119
|
+
return float(val)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
|
|
124
|
+
@post(lambda result: result is None or isinstance(result, list))
|
|
125
|
+
def _get_str_list(config: dict[str, Any], key: str) -> list[str] | None:
|
|
126
|
+
"""
|
|
127
|
+
Safely extract a list of strings from config.
|
|
128
|
+
|
|
129
|
+
>>> _get_str_list({"a": ["x", "y"]}, "a")
|
|
130
|
+
['x', 'y']
|
|
131
|
+
>>> _get_str_list({"a": "not list"}, "a") is None
|
|
132
|
+
True
|
|
133
|
+
"""
|
|
134
|
+
val = config.get(key)
|
|
135
|
+
if isinstance(val, (list, tuple)):
|
|
136
|
+
return [str(x) for x in val if isinstance(x, str)]
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@pre(lambda config: isinstance(config, dict))
|
|
141
|
+
@post(lambda result: result is None or isinstance(result, list))
|
|
142
|
+
def _parse_rule_exclusions(config: dict[str, Any]) -> list[RuleExclusion] | None:
|
|
143
|
+
"""
|
|
144
|
+
Parse rule_exclusions from config.
|
|
145
|
+
|
|
146
|
+
>>> excl = _parse_rule_exclusions({"rule_exclusions": [{"pattern": "**/gen/**", "rules": ["*"]}]})
|
|
147
|
+
>>> excl[0].pattern
|
|
148
|
+
'**/gen/**'
|
|
149
|
+
>>> _parse_rule_exclusions({}) is None
|
|
150
|
+
True
|
|
151
|
+
"""
|
|
152
|
+
raw = config.get("rule_exclusions")
|
|
153
|
+
if not isinstance(raw, list):
|
|
154
|
+
return None
|
|
155
|
+
exclusions = []
|
|
156
|
+
for excl in raw:
|
|
157
|
+
if isinstance(excl, dict) and "pattern" in excl and "rules" in excl:
|
|
158
|
+
pattern, rules = excl["pattern"], excl["rules"]
|
|
159
|
+
if isinstance(pattern, str) and isinstance(rules, list):
|
|
160
|
+
exclusions.append(RuleExclusion(pattern=str(pattern), rules=[str(r) for r in rules]))
|
|
161
|
+
return exclusions if exclusions else None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pre(lambda config: isinstance(config, dict))
|
|
165
|
+
@post(lambda result: result is None or isinstance(result, dict))
|
|
166
|
+
def _parse_severity_overrides(config: dict[str, Any]) -> dict[str, str] | None:
|
|
167
|
+
"""
|
|
168
|
+
Parse severity_overrides from config (merge with defaults).
|
|
169
|
+
|
|
170
|
+
>>> _parse_severity_overrides({"severity_overrides": {"foo": "off"}})
|
|
171
|
+
{'redundant_type_contract': 'off', 'foo': 'off'}
|
|
172
|
+
>>> _parse_severity_overrides({}) is None
|
|
173
|
+
True
|
|
174
|
+
"""
|
|
175
|
+
raw = config.get("severity_overrides")
|
|
176
|
+
if not isinstance(raw, dict):
|
|
177
|
+
return None
|
|
178
|
+
defaults: dict[str, str] = {"redundant_type_contract": "off"}
|
|
179
|
+
for k, v in raw.items():
|
|
180
|
+
if isinstance(k, str) and isinstance(v, str):
|
|
181
|
+
defaults[str(k)] = str(v)
|
|
182
|
+
return defaults
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@pre(lambda guard_config: isinstance(guard_config, dict))
|
|
186
|
+
@post(lambda result: isinstance(result, RuleConfig))
|
|
187
|
+
def parse_guard_config(guard_config: dict[str, Any]) -> RuleConfig:
|
|
188
|
+
"""
|
|
189
|
+
Parse configuration from guard section.
|
|
190
|
+
|
|
191
|
+
Examples:
|
|
192
|
+
>>> cfg = parse_guard_config({"max_file_lines": 400})
|
|
193
|
+
>>> cfg.max_file_lines
|
|
194
|
+
400
|
|
195
|
+
>>> cfg = parse_guard_config({})
|
|
196
|
+
>>> cfg.max_file_lines # Phase 9 P1: Default is now 500
|
|
197
|
+
500
|
|
198
|
+
>>> cfg = parse_guard_config({"rule_exclusions": [{"pattern": "**/gen/**", "rules": ["*"]}]})
|
|
199
|
+
>>> len(cfg.rule_exclusions)
|
|
200
|
+
1
|
|
201
|
+
>>> cfg.rule_exclusions[0].pattern
|
|
202
|
+
'**/gen/**'
|
|
203
|
+
>>> cfg = parse_guard_config({"use_code_lines": "invalid"}) # Invalid type ignored
|
|
204
|
+
>>> cfg.use_code_lines # Falls back to model default (False)
|
|
205
|
+
False
|
|
206
|
+
"""
|
|
207
|
+
kwargs: dict[str, Any] = {}
|
|
208
|
+
|
|
209
|
+
# Int fields
|
|
210
|
+
for key in ("max_file_lines", "max_function_lines"):
|
|
211
|
+
if (val := _get_int(guard_config, key)) is not None:
|
|
212
|
+
kwargs[key] = val
|
|
213
|
+
|
|
214
|
+
# Bool fields
|
|
215
|
+
for key in ("require_contracts", "require_doctests", "strict_pure", "use_code_lines", "exclude_doctest_lines"):
|
|
216
|
+
if (val := _get_bool(guard_config, key)) is not None:
|
|
217
|
+
kwargs[key] = val
|
|
218
|
+
|
|
219
|
+
# Float fields
|
|
220
|
+
if (val := _get_float(guard_config, "size_warning_threshold")) is not None:
|
|
221
|
+
kwargs["size_warning_threshold"] = val
|
|
222
|
+
|
|
223
|
+
# List fields (convert to tuple for forbidden_imports)
|
|
224
|
+
if (val := _get_str_list(guard_config, "forbidden_imports")) is not None:
|
|
225
|
+
kwargs["forbidden_imports"] = tuple(val)
|
|
226
|
+
for key in ("purity_pure", "purity_impure"):
|
|
227
|
+
if (val := _get_str_list(guard_config, key)) is not None:
|
|
228
|
+
kwargs[key] = val
|
|
229
|
+
|
|
230
|
+
# Complex fields
|
|
231
|
+
if (val := _parse_rule_exclusions(guard_config)) is not None:
|
|
232
|
+
kwargs["rule_exclusions"] = val
|
|
233
|
+
if (val := _parse_severity_overrides(guard_config)) is not None:
|
|
234
|
+
kwargs["severity_overrides"] = val
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
return RuleConfig(**kwargs)
|
|
238
|
+
except Exception:
|
|
239
|
+
return RuleConfig()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@pre(lambda file_path, patterns: isinstance(file_path, str) and isinstance(patterns, list))
|
|
243
|
+
def matches_pattern(file_path: str, patterns: list[str]) -> bool:
|
|
244
|
+
"""
|
|
245
|
+
Check if a file path matches any of the glob patterns.
|
|
246
|
+
|
|
247
|
+
Examples:
|
|
248
|
+
>>> matches_pattern("src/domain/models.py", ["**/domain/**"])
|
|
249
|
+
True
|
|
250
|
+
>>> matches_pattern("src/api/views.py", ["**/domain/**"])
|
|
251
|
+
False
|
|
252
|
+
>>> matches_pattern("src/core/logic.py", ["src/core/**", "**/models/**"])
|
|
253
|
+
True
|
|
254
|
+
"""
|
|
255
|
+
for pattern in patterns:
|
|
256
|
+
if fnmatch.fnmatch(file_path, pattern):
|
|
257
|
+
return True
|
|
258
|
+
# Also check with leading path component for ** patterns
|
|
259
|
+
if pattern.startswith("**/"):
|
|
260
|
+
# Match anywhere in path
|
|
261
|
+
if fnmatch.fnmatch(file_path, pattern[3:]):
|
|
262
|
+
return True
|
|
263
|
+
# Try matching each subpath
|
|
264
|
+
parts = file_path.split("/")
|
|
265
|
+
for i in range(len(parts)):
|
|
266
|
+
subpath = "/".join(parts[i:])
|
|
267
|
+
if fnmatch.fnmatch(subpath, pattern[3:]):
|
|
268
|
+
return True
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@pre(lambda file_path, prefixes: isinstance(file_path, str) and isinstance(prefixes, list))
|
|
273
|
+
def matches_path_prefix(file_path: str, prefixes: list[str]) -> bool:
|
|
274
|
+
"""
|
|
275
|
+
Check if file_path starts with any of the given prefixes.
|
|
276
|
+
|
|
277
|
+
Examples:
|
|
278
|
+
>>> matches_path_prefix("src/core/logic.py", ["src/core", "src/domain"])
|
|
279
|
+
True
|
|
280
|
+
>>> matches_path_prefix("src/shell/cli.py", ["src/core", "src/domain"])
|
|
281
|
+
False
|
|
282
|
+
"""
|
|
283
|
+
return any(file_path.startswith(p) for p in prefixes)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@post(lambda result: isinstance(result, bool))
|
|
287
|
+
def match_glob_pattern(file_path: str, pattern: str) -> bool:
|
|
288
|
+
"""
|
|
289
|
+
Check if file path matches a glob pattern with ** support.
|
|
290
|
+
|
|
291
|
+
Uses fnmatch for single-segment wildcards, handles ** for multi-segment.
|
|
292
|
+
|
|
293
|
+
Examples:
|
|
294
|
+
>>> match_glob_pattern("src/generated/foo.py", "**/generated/**")
|
|
295
|
+
True
|
|
296
|
+
>>> match_glob_pattern("generated/foo.py", "**/generated/**")
|
|
297
|
+
True
|
|
298
|
+
>>> match_glob_pattern("src/core/calc.py", "**/generated/**")
|
|
299
|
+
False
|
|
300
|
+
>>> match_glob_pattern("src/core/data.py", "src/core/data.py")
|
|
301
|
+
True
|
|
302
|
+
>>> match_glob_pattern("src/core/calc.py", "src/core/*.py")
|
|
303
|
+
True
|
|
304
|
+
>>> match_glob_pattern("src/core/sub/calc.py", "src/core/*.py")
|
|
305
|
+
False
|
|
306
|
+
>>> match_glob_pattern("src/core/sub/calc.py", "src/core/**/*.py")
|
|
307
|
+
True
|
|
308
|
+
"""
|
|
309
|
+
file_path = file_path.replace("\\", "/")
|
|
310
|
+
pattern = pattern.replace("\\", "/")
|
|
311
|
+
if "**" not in pattern:
|
|
312
|
+
if file_path.count("/") != pattern.count("/"):
|
|
313
|
+
return False
|
|
314
|
+
return fnmatch.fnmatch(file_path, pattern)
|
|
315
|
+
path_parts = file_path.split("/")
|
|
316
|
+
if pattern.startswith("**/") and pattern.endswith("/**"):
|
|
317
|
+
middle = pattern[3:-3]
|
|
318
|
+
if "/" not in middle and "*" not in middle:
|
|
319
|
+
return middle in path_parts[:-1]
|
|
320
|
+
if pattern.startswith("**/") and not pattern.endswith("/**"):
|
|
321
|
+
suffix = pattern[3:]
|
|
322
|
+
for i in range(len(path_parts)):
|
|
323
|
+
if fnmatch.fnmatch("/".join(path_parts[i:]), suffix):
|
|
324
|
+
return True
|
|
325
|
+
return False
|
|
326
|
+
if pattern.endswith("/**") and not pattern.startswith("**/"):
|
|
327
|
+
prefix = pattern[:-3]
|
|
328
|
+
return file_path.startswith(prefix + "/") or file_path == prefix
|
|
329
|
+
parts = pattern.split("**/")
|
|
330
|
+
if len(parts) == 2:
|
|
331
|
+
prefix, suffix = parts[0].rstrip("/"), parts[1].lstrip("/").rstrip("/**")
|
|
332
|
+
for i in range(len(path_parts) + 1):
|
|
333
|
+
head = "/".join(path_parts[:i]) if i > 0 else ""
|
|
334
|
+
tail = "/".join(path_parts[i:])
|
|
335
|
+
if (not prefix or fnmatch.fnmatch(head, prefix)) and (
|
|
336
|
+
not suffix or fnmatch.fnmatch(tail, suffix) or fnmatch.fnmatch(tail, "*/" + suffix)
|
|
337
|
+
):
|
|
338
|
+
return True
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@pre(lambda file_path, config: isinstance(config, RuleConfig))
|
|
343
|
+
def get_excluded_rules(file_path: str, config: RuleConfig) -> set[str]:
|
|
344
|
+
"""
|
|
345
|
+
Get the set of rules to exclude for a given file path.
|
|
346
|
+
|
|
347
|
+
Examples:
|
|
348
|
+
>>> from invar.core.models import RuleConfig, RuleExclusion
|
|
349
|
+
>>> excl = RuleExclusion(pattern="**/generated/**", rules=["*"])
|
|
350
|
+
>>> cfg = RuleConfig(rule_exclusions=[excl])
|
|
351
|
+
>>> get_excluded_rules("src/generated/foo.py", cfg)
|
|
352
|
+
{'*'}
|
|
353
|
+
>>> get_excluded_rules("src/core/calc.py", cfg)
|
|
354
|
+
set()
|
|
355
|
+
>>> excl2 = RuleExclusion(pattern="**/data/**", rules=["file_size"])
|
|
356
|
+
>>> cfg2 = RuleConfig(rule_exclusions=[excl, excl2])
|
|
357
|
+
>>> sorted(get_excluded_rules("src/data/big.py", cfg2))
|
|
358
|
+
['file_size']
|
|
359
|
+
"""
|
|
360
|
+
excluded: set[str] = set()
|
|
361
|
+
for exclusion in config.rule_exclusions:
|
|
362
|
+
if match_glob_pattern(file_path, exclusion.pattern):
|
|
363
|
+
excluded.update(exclusion.rules)
|
|
364
|
+
return excluded
|
invar/decorators.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Invar contract decorators.
|
|
3
|
+
|
|
4
|
+
Provides decorators that extend deal's contract system for additional
|
|
5
|
+
static analysis by Guard.
|
|
6
|
+
|
|
7
|
+
DX-12-B: Added @strategy for custom Hypothesis strategies.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any, TypeVar
|
|
14
|
+
|
|
15
|
+
F = TypeVar("F", bound=Callable)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def must_use(reason: str | None = None) -> Callable[[F], F]:
|
|
19
|
+
"""
|
|
20
|
+
Mark a function's return value as must-use.
|
|
21
|
+
|
|
22
|
+
Guard will warn if calls to this function discard the return value.
|
|
23
|
+
Inspired by Move's lack of drop ability and Rust's #[must_use].
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
reason: Explanation of why the return value must be used.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
A decorator that marks the function.
|
|
30
|
+
|
|
31
|
+
>>> @must_use("Error must be handled")
|
|
32
|
+
... def may_fail() -> int:
|
|
33
|
+
... return 42
|
|
34
|
+
>>> may_fail.__invar_must_use__
|
|
35
|
+
'Error must be handled'
|
|
36
|
+
|
|
37
|
+
>>> @must_use()
|
|
38
|
+
... def important() -> str:
|
|
39
|
+
... return "result"
|
|
40
|
+
>>> important.__invar_must_use__
|
|
41
|
+
'Return value must be used'
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def decorator(func: F) -> F:
|
|
45
|
+
func.__invar_must_use__ = reason or "Return value must be used" # type: ignore[attr-defined]
|
|
46
|
+
return func
|
|
47
|
+
|
|
48
|
+
return decorator
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def strategy(**param_strategies: Any) -> Callable[[F], F]:
|
|
52
|
+
"""
|
|
53
|
+
Specify custom Hypothesis strategies for function parameters.
|
|
54
|
+
|
|
55
|
+
DX-12-B: Escape hatch when automatic strategy inference fails or
|
|
56
|
+
when you need precise control over generated values.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
**param_strategies: Mapping of parameter names to Hypothesis strategies.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A decorator that attaches strategies to the function.
|
|
63
|
+
|
|
64
|
+
Examples:
|
|
65
|
+
>>> from hypothesis import strategies as st
|
|
66
|
+
>>> @strategy(x=st.floats(min_value=1e-10, max_value=1e10))
|
|
67
|
+
... def sqrt(x: float) -> float:
|
|
68
|
+
... return x ** 0.5
|
|
69
|
+
>>> hasattr(sqrt, '__invar_strategies__')
|
|
70
|
+
True
|
|
71
|
+
>>> 'x' in sqrt.__invar_strategies__
|
|
72
|
+
True
|
|
73
|
+
|
|
74
|
+
>>> # NumPy array with specific shape
|
|
75
|
+
>>> @strategy(arr="arrays(dtype=float64, shape=(10,))")
|
|
76
|
+
... def normalize(arr):
|
|
77
|
+
... return arr / arr.sum()
|
|
78
|
+
>>> 'arr' in normalize.__invar_strategies__
|
|
79
|
+
True
|
|
80
|
+
|
|
81
|
+
Note:
|
|
82
|
+
Strategies can be either:
|
|
83
|
+
- Hypothesis strategy objects (e.g., st.floats())
|
|
84
|
+
- String representations (e.g., "floats(min_value=0)")
|
|
85
|
+
|
|
86
|
+
String representations are useful when you don't want to import
|
|
87
|
+
Hypothesis at module load time.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def decorator(func: F) -> F:
|
|
91
|
+
func.__invar_strategies__ = param_strategies # type: ignore[attr-defined]
|
|
92
|
+
return func
|
|
93
|
+
|
|
94
|
+
return decorator
|
invar/invariant.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loop invariant support for Invar.
|
|
3
|
+
|
|
4
|
+
Provides runtime-checked loop invariants inspired by Dafny.
|
|
5
|
+
Checking controlled by INVAR_CHECK environment variable (default: ON).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InvariantViolation(Exception):
|
|
14
|
+
"""Raised when a loop invariant is violated."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Read once at module load time - effectively a constant
|
|
20
|
+
_INVAR_CHECK = os.environ.get("INVAR_CHECK", "1") == "1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def invariant(condition: bool, message: str = "") -> None:
|
|
24
|
+
"""
|
|
25
|
+
Assert loop invariant. Checked at runtime when INVAR_CHECK=1.
|
|
26
|
+
|
|
27
|
+
Place at the START of loop body to check condition each iteration.
|
|
28
|
+
Invariants document what must remain true throughout loop execution.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
condition: Boolean condition that must hold
|
|
32
|
+
message: Optional message describing the invariant
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
InvariantViolation: When condition is False and INVAR_CHECK=1
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
>>> invariant(True) # OK
|
|
39
|
+
|
|
40
|
+
>>> invariant(True, "x is positive") # OK with message
|
|
41
|
+
|
|
42
|
+
>>> try:
|
|
43
|
+
... invariant(False, "x must be positive")
|
|
44
|
+
... except InvariantViolation as e:
|
|
45
|
+
... print(str(e))
|
|
46
|
+
Loop invariant violated: x must be positive
|
|
47
|
+
|
|
48
|
+
Typical usage in a binary search:
|
|
49
|
+
|
|
50
|
+
while lo < hi:
|
|
51
|
+
invariant(0 <= lo <= hi <= len(arr))
|
|
52
|
+
invariant(target not in arr[:lo]) # Already searched
|
|
53
|
+
...
|
|
54
|
+
"""
|
|
55
|
+
if _INVAR_CHECK and not condition:
|
|
56
|
+
msg = f"Loop invariant violated: {message}" if message else "Loop invariant violated"
|
|
57
|
+
raise InvariantViolation(msg)
|
invar/mcp/__init__.py
ADDED
invar/mcp/__main__.py
ADDED