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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Perception CLI implementation (Phase 4).
|
|
3
|
+
|
|
4
|
+
Shell module: handles file I/O for map and sig commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from returns.result import Failure, Result, Success
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from invar.core.formatter import (
|
|
17
|
+
format_map_json,
|
|
18
|
+
format_map_text,
|
|
19
|
+
format_signatures_json,
|
|
20
|
+
format_signatures_text,
|
|
21
|
+
)
|
|
22
|
+
from invar.core.models import FileInfo
|
|
23
|
+
from invar.core.parser import parse_source
|
|
24
|
+
from invar.core.references import build_perception_map
|
|
25
|
+
from invar.shell.fs import discover_python_files
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from invar.core.models import Symbol
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_map(path: Path, top_n: int, json_output: bool) -> Result[None, str]:
|
|
34
|
+
"""
|
|
35
|
+
Run the map command.
|
|
36
|
+
|
|
37
|
+
Scans project and generates perception map with reference counts.
|
|
38
|
+
"""
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return Failure(f"Path does not exist: {path}")
|
|
41
|
+
|
|
42
|
+
# Collect all files and their sources
|
|
43
|
+
file_infos: list[FileInfo] = []
|
|
44
|
+
sources: dict[str, str] = {}
|
|
45
|
+
|
|
46
|
+
for py_file in discover_python_files(path):
|
|
47
|
+
try:
|
|
48
|
+
content = py_file.read_text(encoding="utf-8")
|
|
49
|
+
rel_path = str(py_file.relative_to(path))
|
|
50
|
+
# Skip empty files (e.g., __init__.py)
|
|
51
|
+
if not content.strip():
|
|
52
|
+
continue
|
|
53
|
+
file_info = parse_source(content, rel_path)
|
|
54
|
+
if file_info:
|
|
55
|
+
file_infos.append(file_info)
|
|
56
|
+
sources[rel_path] = content
|
|
57
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
58
|
+
console.print(f"[yellow]Warning:[/yellow] {py_file}: {e}")
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if not file_infos:
|
|
62
|
+
return Failure("No Python files found")
|
|
63
|
+
|
|
64
|
+
# Build perception map
|
|
65
|
+
perception_map = build_perception_map(file_infos, sources, str(path.absolute()))
|
|
66
|
+
|
|
67
|
+
# Output
|
|
68
|
+
if json_output:
|
|
69
|
+
output = format_map_json(perception_map, top_n)
|
|
70
|
+
console.print(json.dumps(output, indent=2))
|
|
71
|
+
else:
|
|
72
|
+
output = format_map_text(perception_map, top_n)
|
|
73
|
+
console.print(output)
|
|
74
|
+
|
|
75
|
+
return Success(None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_sig(target: str, json_output: bool) -> Result[None, str]:
|
|
79
|
+
"""
|
|
80
|
+
Run the sig command.
|
|
81
|
+
|
|
82
|
+
Extracts signatures from a file or specific symbol.
|
|
83
|
+
Target format: "path/to/file.py" or "path/to/file.py::symbol_name"
|
|
84
|
+
"""
|
|
85
|
+
# Parse target
|
|
86
|
+
if "::" in target:
|
|
87
|
+
file_path_str, symbol_name = target.split("::", 1)
|
|
88
|
+
else:
|
|
89
|
+
file_path_str = target
|
|
90
|
+
symbol_name = None
|
|
91
|
+
|
|
92
|
+
file_path = Path(file_path_str)
|
|
93
|
+
if not file_path.exists():
|
|
94
|
+
return Failure(f"File not found: {file_path}")
|
|
95
|
+
|
|
96
|
+
# Read and parse
|
|
97
|
+
try:
|
|
98
|
+
content = file_path.read_text(encoding="utf-8")
|
|
99
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
100
|
+
return Failure(f"Failed to read {file_path}: {e}")
|
|
101
|
+
|
|
102
|
+
# Handle empty files
|
|
103
|
+
if not content.strip():
|
|
104
|
+
file_info = FileInfo(path=str(file_path), lines=0, symbols=[], imports=[], source="")
|
|
105
|
+
else:
|
|
106
|
+
file_info = parse_source(content, str(file_path))
|
|
107
|
+
if file_info is None:
|
|
108
|
+
return Failure(f"Syntax error in {file_path}")
|
|
109
|
+
|
|
110
|
+
# Filter symbols
|
|
111
|
+
symbols: list[Symbol] = file_info.symbols
|
|
112
|
+
if symbol_name:
|
|
113
|
+
symbols = [s for s in symbols if s.name == symbol_name]
|
|
114
|
+
if not symbols:
|
|
115
|
+
return Failure(f"Symbol '{symbol_name}' not found in {file_path}")
|
|
116
|
+
|
|
117
|
+
# Output
|
|
118
|
+
if json_output:
|
|
119
|
+
output = format_signatures_json(symbols, str(file_path))
|
|
120
|
+
console.print(json.dumps(output, indent=2))
|
|
121
|
+
else:
|
|
122
|
+
output = format_signatures_text(symbols, str(file_path))
|
|
123
|
+
console.print(output)
|
|
124
|
+
|
|
125
|
+
return Success(None)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Property test runner for files and directories.
|
|
3
|
+
|
|
4
|
+
DX-08: Shell module for running auto-generated property tests.
|
|
5
|
+
Handles I/O and file scanning, returns Result[T, E].
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import sys
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from returns.result import Failure, Result, Success
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from invar.core.property_gen import (
|
|
18
|
+
PropertyTestReport,
|
|
19
|
+
find_contracted_functions,
|
|
20
|
+
run_property_test,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_property_tests_on_file(
|
|
30
|
+
file_path: Path,
|
|
31
|
+
max_examples: int = 100,
|
|
32
|
+
verbose: bool = False,
|
|
33
|
+
) -> Result[PropertyTestReport, str]:
|
|
34
|
+
"""
|
|
35
|
+
Run property tests on all contracted functions in a file.
|
|
36
|
+
|
|
37
|
+
Scans file for @pre/@post decorated functions, generates
|
|
38
|
+
Hypothesis tests, and runs them.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
file_path: Path to Python file
|
|
42
|
+
max_examples: Maximum Hypothesis examples per function
|
|
43
|
+
verbose: Show detailed output
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Success with PropertyTestReport or Failure with error
|
|
47
|
+
"""
|
|
48
|
+
if not file_path.exists():
|
|
49
|
+
return Failure(f"File not found: {file_path}")
|
|
50
|
+
|
|
51
|
+
if file_path.suffix != ".py":
|
|
52
|
+
return Failure(f"Not a Python file: {file_path}")
|
|
53
|
+
|
|
54
|
+
# Read and find contracted functions
|
|
55
|
+
try:
|
|
56
|
+
source = file_path.read_text()
|
|
57
|
+
except OSError as e:
|
|
58
|
+
return Failure(f"Could not read file: {e}")
|
|
59
|
+
|
|
60
|
+
# Handle empty files gracefully
|
|
61
|
+
if not source.strip():
|
|
62
|
+
return Success(PropertyTestReport())
|
|
63
|
+
|
|
64
|
+
contracted = find_contracted_functions(source)
|
|
65
|
+
if not contracted:
|
|
66
|
+
return Success(PropertyTestReport()) # No contracted functions, skip
|
|
67
|
+
|
|
68
|
+
# Import the module to get actual function objects
|
|
69
|
+
module = _import_module_from_path(file_path)
|
|
70
|
+
if module is None:
|
|
71
|
+
return Failure(f"Could not import module: {file_path}")
|
|
72
|
+
|
|
73
|
+
# Run tests on each contracted function
|
|
74
|
+
report = PropertyTestReport()
|
|
75
|
+
|
|
76
|
+
for func_info in contracted:
|
|
77
|
+
func_name = func_info["name"]
|
|
78
|
+
func = getattr(module, func_name, None)
|
|
79
|
+
|
|
80
|
+
if func is None or not callable(func):
|
|
81
|
+
report.functions_skipped += 1
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Run property test
|
|
85
|
+
result = run_property_test(func, max_examples)
|
|
86
|
+
report.results.append(result)
|
|
87
|
+
report.functions_tested += 1
|
|
88
|
+
report.total_examples += result.examples_run
|
|
89
|
+
|
|
90
|
+
if result.passed:
|
|
91
|
+
report.functions_passed += 1
|
|
92
|
+
else:
|
|
93
|
+
report.functions_failed += 1
|
|
94
|
+
|
|
95
|
+
return Success(report)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_property_tests_on_files(
|
|
99
|
+
files: list[Path],
|
|
100
|
+
max_examples: int = 100,
|
|
101
|
+
verbose: bool = False,
|
|
102
|
+
) -> Result[PropertyTestReport, str]:
|
|
103
|
+
"""
|
|
104
|
+
Run property tests on multiple files.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
files: List of Python file paths
|
|
108
|
+
max_examples: Maximum Hypothesis examples per function
|
|
109
|
+
verbose: Show detailed output
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Combined PropertyTestReport
|
|
113
|
+
"""
|
|
114
|
+
# Check hypothesis availability first
|
|
115
|
+
try:
|
|
116
|
+
import hypothesis # noqa: F401
|
|
117
|
+
except ImportError:
|
|
118
|
+
return Success(PropertyTestReport(
|
|
119
|
+
errors=["Hypothesis not installed (pip install hypothesis)"]
|
|
120
|
+
))
|
|
121
|
+
|
|
122
|
+
combined_report = PropertyTestReport()
|
|
123
|
+
|
|
124
|
+
for file_path in files:
|
|
125
|
+
result = run_property_tests_on_file(file_path, max_examples, verbose)
|
|
126
|
+
|
|
127
|
+
if isinstance(result, Failure):
|
|
128
|
+
combined_report.errors.append(result.failure())
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
file_report = result.unwrap()
|
|
132
|
+
combined_report.functions_tested += file_report.functions_tested
|
|
133
|
+
combined_report.functions_passed += file_report.functions_passed
|
|
134
|
+
combined_report.functions_failed += file_report.functions_failed
|
|
135
|
+
combined_report.functions_skipped += file_report.functions_skipped
|
|
136
|
+
combined_report.total_examples += file_report.total_examples
|
|
137
|
+
combined_report.results.extend(file_report.results)
|
|
138
|
+
combined_report.errors.extend(file_report.errors)
|
|
139
|
+
|
|
140
|
+
return Success(combined_report)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _import_module_from_path(file_path: Path) -> object | None:
|
|
144
|
+
"""
|
|
145
|
+
Import a Python module from a file path.
|
|
146
|
+
|
|
147
|
+
Returns None if import fails.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
module_name = file_path.stem
|
|
151
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
152
|
+
if spec is None or spec.loader is None:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
module = importlib.util.module_from_spec(spec)
|
|
156
|
+
sys.modules[module_name] = module
|
|
157
|
+
|
|
158
|
+
# Suppress output during import
|
|
159
|
+
spec.loader.exec_module(module)
|
|
160
|
+
return module
|
|
161
|
+
|
|
162
|
+
except Exception:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def format_property_test_report(
|
|
167
|
+
report: PropertyTestReport,
|
|
168
|
+
json_output: bool = False,
|
|
169
|
+
) -> str:
|
|
170
|
+
"""
|
|
171
|
+
Format property test report for display.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
report: The test report
|
|
175
|
+
json_output: Output as JSON
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Formatted string
|
|
179
|
+
"""
|
|
180
|
+
import json
|
|
181
|
+
|
|
182
|
+
if json_output:
|
|
183
|
+
return json.dumps({
|
|
184
|
+
"functions_tested": report.functions_tested,
|
|
185
|
+
"functions_passed": report.functions_passed,
|
|
186
|
+
"functions_failed": report.functions_failed,
|
|
187
|
+
"functions_skipped": report.functions_skipped,
|
|
188
|
+
"total_examples": report.total_examples,
|
|
189
|
+
"all_passed": report.all_passed(),
|
|
190
|
+
"results": [
|
|
191
|
+
{
|
|
192
|
+
"function": r.function_name,
|
|
193
|
+
"passed": r.passed,
|
|
194
|
+
"examples": r.examples_run,
|
|
195
|
+
"error": r.error,
|
|
196
|
+
}
|
|
197
|
+
for r in report.results
|
|
198
|
+
],
|
|
199
|
+
"errors": report.errors,
|
|
200
|
+
}, indent=2)
|
|
201
|
+
|
|
202
|
+
# Human-readable format
|
|
203
|
+
lines = []
|
|
204
|
+
|
|
205
|
+
if report.functions_tested == 0:
|
|
206
|
+
lines.append("No contracted functions found for property testing.")
|
|
207
|
+
return "\n".join(lines)
|
|
208
|
+
|
|
209
|
+
status = "✓" if report.all_passed() else "✗"
|
|
210
|
+
color = "green" if report.all_passed() else "red"
|
|
211
|
+
|
|
212
|
+
lines.append(
|
|
213
|
+
f"[{color}]{status}[/{color}] Property tests: "
|
|
214
|
+
f"{report.functions_passed}/{report.functions_tested} passed, "
|
|
215
|
+
f"{report.total_examples} examples"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Show failures
|
|
219
|
+
for result in report.results:
|
|
220
|
+
if not result.passed:
|
|
221
|
+
lines.append(f" [red]✗[/red] {result.function_name}: {result.error}")
|
|
222
|
+
|
|
223
|
+
# Show errors
|
|
224
|
+
for error in report.errors:
|
|
225
|
+
lines.append(f" [yellow]![/yellow] {error}")
|
|
226
|
+
|
|
227
|
+
return "\n".join(lines)
|