invar-tools 1.0.0__py3-none-any.whl → 1.3.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 +1 -0
- invar/core/contracts.py +80 -10
- invar/core/entry_points.py +367 -0
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +195 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +32 -10
- invar/core/hypothesis_strategies.py +50 -10
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -2
- invar/core/models.py +30 -18
- invar/core/must_use.py +2 -1
- invar/core/parser.py +13 -6
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +86 -42
- invar/core/purity.py +13 -7
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +370 -0
- invar/core/rule_meta.py +69 -2
- invar/core/rules.py +91 -28
- invar/core/shell_analysis.py +247 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +92 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +103 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +63 -18
- invar/core/verification_routing.py +155 -0
- invar/mcp/server.py +113 -13
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +152 -44
- invar/shell/{init_cmd.py → commands/init.py} +200 -28
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/mutate.py +184 -0
- invar/shell/{perception.py → commands/perception.py} +2 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/{test_cmd.py → commands/test.py} +3 -1
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +247 -10
- invar/shell/coverage.py +351 -0
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +116 -20
- invar/shell/guard_output.py +106 -24
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutation.py +314 -0
- invar/shell/property_tests.py +75 -24
- invar/shell/prove/__init__.py +9 -0
- invar/shell/prove/accept.py +113 -0
- invar/shell/{prove.py → prove/crosshair.py} +69 -30
- invar/shell/prove/hypothesis.py +293 -0
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +53 -0
- invar/shell/testing.py +77 -37
- invar/templates/CLAUDE.md.template +86 -9
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/audit.md +138 -0
- invar/templates/commands/guard.md +77 -0
- invar/templates/config/CLAUDE.md.jinja +206 -0
- invar/templates/config/context.md.jinja +92 -0
- invar/templates/config/pre-commit.yaml.jinja +44 -0
- invar/templates/context.md.template +33 -0
- invar/templates/cursorrules.template +25 -13
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +4 -2
- invar/templates/examples/core_shell.py +10 -4
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/protocol/INVAR.md +210 -0
- invar/templates/skills/develop/SKILL.md.jinja +318 -0
- invar/templates/skills/investigate/SKILL.md.jinja +106 -0
- invar/templates/skills/propose/SKILL.md.jinja +104 -0
- invar/templates/skills/review/SKILL.md.jinja +125 -0
- invar_tools-1.3.0.dist-info/METADATA +377 -0
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -57
- invar/resource.py +0 -99
- invar/shell/prove_fallback.py +0 -183
- invar/shell/update_cmd.py +0 -191
- invar/templates/INVAR.md +0 -134
- invar_tools-1.0.0.dist-info/METADATA +0 -321
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Subprocess environment preparation with PYTHONPATH injection.
|
|
2
|
+
|
|
3
|
+
DX-52: Enable uvx-based invar to access project dependencies.
|
|
4
|
+
|
|
5
|
+
This module provides three phases of dependency injection:
|
|
6
|
+
- Phase 1: PYTHONPATH injection for immediate compatibility
|
|
7
|
+
- Phase 2: Re-spawn detection for perfect compatibility
|
|
8
|
+
- Phase 3: Version mismatch detection for smart upgrade prompts
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from deal import post, pre
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"build_subprocess_env",
|
|
23
|
+
"check_version_mismatch",
|
|
24
|
+
"detect_project_python_with_invar",
|
|
25
|
+
"detect_project_venv",
|
|
26
|
+
"find_site_packages",
|
|
27
|
+
"get_venv_python_version",
|
|
28
|
+
"maybe_show_upgrade_prompt",
|
|
29
|
+
"should_respawn",
|
|
30
|
+
"should_suppress_prompt",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# =============================================================================
|
|
35
|
+
# Phase 1: PYTHONPATH Injection
|
|
36
|
+
# =============================================================================
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
VENV_NAMES: tuple[str, ...] = (".venv", "venv", ".env", "env")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pre(lambda cwd: isinstance(cwd, Path))
|
|
43
|
+
@post(lambda result: result is None or result.exists())
|
|
44
|
+
def detect_project_venv(cwd: Path) -> Path | None:
|
|
45
|
+
"""Detect project's virtual environment.
|
|
46
|
+
|
|
47
|
+
Searches for common venv directory names with pyvenv.cfg marker.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
cwd: Current working directory (project root)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Path to venv directory, or None if not found
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
>>> from pathlib import Path
|
|
57
|
+
>>> detect_project_venv(Path("/nonexistent")) is None
|
|
58
|
+
True
|
|
59
|
+
"""
|
|
60
|
+
for name in VENV_NAMES:
|
|
61
|
+
venv_path = cwd / name
|
|
62
|
+
if (venv_path / "pyvenv.cfg").exists():
|
|
63
|
+
return venv_path
|
|
64
|
+
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# @shell_complexity: Cross-platform venv layout detection (Unix vs Windows)
|
|
69
|
+
@pre(lambda venv_path: isinstance(venv_path, Path))
|
|
70
|
+
@post(lambda result: result is None or result.exists())
|
|
71
|
+
def find_site_packages(venv_path: Path) -> Path | None:
|
|
72
|
+
"""Find site-packages directory within a venv.
|
|
73
|
+
|
|
74
|
+
Handles both Unix and Windows layouts.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
venv_path: Path to virtual environment
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Path to site-packages, or None if not found
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
>>> from pathlib import Path
|
|
84
|
+
>>> find_site_packages(Path("/nonexistent")) is None
|
|
85
|
+
True
|
|
86
|
+
"""
|
|
87
|
+
if not venv_path.exists():
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
# Unix layout: lib/pythonX.Y/site-packages
|
|
91
|
+
lib_path = venv_path / "lib"
|
|
92
|
+
if lib_path.exists():
|
|
93
|
+
for python_dir in lib_path.glob("python*"):
|
|
94
|
+
site_packages = python_dir / "site-packages"
|
|
95
|
+
if site_packages.exists():
|
|
96
|
+
return site_packages
|
|
97
|
+
|
|
98
|
+
# Windows layout: Lib/site-packages
|
|
99
|
+
lib_path_win = venv_path / "Lib" / "site-packages"
|
|
100
|
+
if lib_path_win.exists():
|
|
101
|
+
return lib_path_win
|
|
102
|
+
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# @shell_complexity: Environment construction with optional PYTHONPATH injection
|
|
107
|
+
@post(lambda result: isinstance(result, dict))
|
|
108
|
+
def build_subprocess_env(cwd: Path | None = None) -> dict[str, str]:
|
|
109
|
+
"""Build environment dict with project's site-packages in PYTHONPATH.
|
|
110
|
+
|
|
111
|
+
This enables uvx-based invar to import project dependencies
|
|
112
|
+
when running doctests, property tests, and CrossHair.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
cwd: Project root directory (defaults to current directory)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Environment dict suitable for subprocess.run(env=...)
|
|
119
|
+
|
|
120
|
+
Examples:
|
|
121
|
+
>>> env = build_subprocess_env()
|
|
122
|
+
>>> isinstance(env, dict)
|
|
123
|
+
True
|
|
124
|
+
>>> "PATH" in env # Inherits from current env
|
|
125
|
+
True
|
|
126
|
+
"""
|
|
127
|
+
env = os.environ.copy()
|
|
128
|
+
project_root = cwd or Path.cwd()
|
|
129
|
+
|
|
130
|
+
venv = detect_project_venv(project_root)
|
|
131
|
+
if venv is None:
|
|
132
|
+
return env
|
|
133
|
+
|
|
134
|
+
site_packages = find_site_packages(venv)
|
|
135
|
+
if site_packages is None:
|
|
136
|
+
return env
|
|
137
|
+
|
|
138
|
+
# Prepend to PYTHONPATH (project packages have priority)
|
|
139
|
+
current = env.get("PYTHONPATH", "")
|
|
140
|
+
separator = ";" if os.name == "nt" else ":"
|
|
141
|
+
if current:
|
|
142
|
+
env["PYTHONPATH"] = f"{site_packages}{separator}{current}"
|
|
143
|
+
else:
|
|
144
|
+
env["PYTHONPATH"] = str(site_packages)
|
|
145
|
+
|
|
146
|
+
return env
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# =============================================================================
|
|
150
|
+
# Phase 2: Smart Re-spawn
|
|
151
|
+
# =============================================================================
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# @shell_complexity: Cross-platform Python detection with subprocess check
|
|
155
|
+
@pre(lambda cwd: isinstance(cwd, Path))
|
|
156
|
+
@post(lambda result: result is None or result.exists())
|
|
157
|
+
def detect_project_python_with_invar(cwd: Path) -> Path | None:
|
|
158
|
+
"""Detect project Python that has invar installed.
|
|
159
|
+
|
|
160
|
+
Used by MCP server to decide whether to re-spawn with project Python.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
cwd: Project root directory
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Path to Python executable if invar is installed, None otherwise
|
|
167
|
+
|
|
168
|
+
Examples:
|
|
169
|
+
>>> from pathlib import Path
|
|
170
|
+
>>> detect_project_python_with_invar(Path("/nonexistent")) is None
|
|
171
|
+
True
|
|
172
|
+
"""
|
|
173
|
+
venv = detect_project_venv(cwd)
|
|
174
|
+
if venv is None:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
# Find Python executable (Unix vs Windows)
|
|
178
|
+
python_path = venv / "bin" / "python"
|
|
179
|
+
if not python_path.exists():
|
|
180
|
+
python_path = venv / "Scripts" / "python.exe"
|
|
181
|
+
if not python_path.exists():
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
# Check if invar is installed in this venv
|
|
185
|
+
try:
|
|
186
|
+
result = subprocess.run(
|
|
187
|
+
[str(python_path), "-c", "import invar"],
|
|
188
|
+
capture_output=True,
|
|
189
|
+
timeout=5,
|
|
190
|
+
)
|
|
191
|
+
if result.returncode == 0:
|
|
192
|
+
return python_path
|
|
193
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@pre(lambda cwd: isinstance(cwd, Path))
|
|
200
|
+
def should_respawn(cwd: Path) -> tuple[bool, Path | None]:
|
|
201
|
+
"""Check if MCP server should re-spawn with project Python.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
(should_respawn, project_python_path)
|
|
205
|
+
|
|
206
|
+
Examples:
|
|
207
|
+
>>> from pathlib import Path
|
|
208
|
+
>>> should, python = should_respawn(Path("/nonexistent"))
|
|
209
|
+
>>> should
|
|
210
|
+
False
|
|
211
|
+
"""
|
|
212
|
+
project_python = detect_project_python_with_invar(cwd)
|
|
213
|
+
|
|
214
|
+
if project_python is None:
|
|
215
|
+
return (False, None)
|
|
216
|
+
|
|
217
|
+
# Don't respawn if already running with project Python
|
|
218
|
+
if str(project_python.resolve()) == str(Path(sys.executable).resolve()):
|
|
219
|
+
return (False, None)
|
|
220
|
+
|
|
221
|
+
return (True, project_python)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# =============================================================================
|
|
225
|
+
# Phase 3: Smart Upgrade Prompt
|
|
226
|
+
# =============================================================================
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# @shell_complexity: Config file parsing with error handling
|
|
230
|
+
@pre(lambda venv_path: isinstance(venv_path, Path))
|
|
231
|
+
def get_venv_python_version(venv_path: Path) -> tuple[int, int] | None:
|
|
232
|
+
"""Read Python version from venv's pyvenv.cfg.
|
|
233
|
+
|
|
234
|
+
Avoids spawning a subprocess by parsing the config file directly.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
venv_path: Path to virtual environment
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
(major, minor) version tuple, or None if not found
|
|
241
|
+
|
|
242
|
+
Examples:
|
|
243
|
+
>>> from pathlib import Path
|
|
244
|
+
>>> get_venv_python_version(Path("/nonexistent")) is None
|
|
245
|
+
True
|
|
246
|
+
"""
|
|
247
|
+
cfg_path = venv_path / "pyvenv.cfg"
|
|
248
|
+
if not cfg_path.exists():
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
for line in cfg_path.read_text().splitlines():
|
|
253
|
+
# Look for "version = X.Y.Z" or "version_info = X.Y.Z"
|
|
254
|
+
if line.startswith("version"):
|
|
255
|
+
# version = 3.11.5 or version_info = 3.11.5
|
|
256
|
+
parts = line.split("=")
|
|
257
|
+
if len(parts) != 2:
|
|
258
|
+
continue
|
|
259
|
+
version_str = parts[1].strip()
|
|
260
|
+
version_parts = version_str.split(".")
|
|
261
|
+
if len(version_parts) >= 2:
|
|
262
|
+
return (int(version_parts[0]), int(version_parts[1]))
|
|
263
|
+
except (ValueError, OSError):
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@pre(lambda cwd: isinstance(cwd, Path))
|
|
270
|
+
def check_version_mismatch(cwd: Path) -> tuple[bool, str]:
|
|
271
|
+
"""Check if Python versions mismatch between venv and current interpreter.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
cwd: Project root directory
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
(is_mismatched, warning_message)
|
|
278
|
+
|
|
279
|
+
Examples:
|
|
280
|
+
>>> from pathlib import Path
|
|
281
|
+
>>> mismatch, msg = check_version_mismatch(Path("/nonexistent"))
|
|
282
|
+
>>> mismatch
|
|
283
|
+
False
|
|
284
|
+
"""
|
|
285
|
+
venv = detect_project_venv(cwd)
|
|
286
|
+
if venv is None:
|
|
287
|
+
return (False, "")
|
|
288
|
+
|
|
289
|
+
venv_version = get_venv_python_version(venv)
|
|
290
|
+
if venv_version is None:
|
|
291
|
+
return (False, "")
|
|
292
|
+
|
|
293
|
+
current_version = (sys.version_info.major, sys.version_info.minor)
|
|
294
|
+
|
|
295
|
+
if venv_version != current_version:
|
|
296
|
+
msg = f"""
|
|
297
|
+
[yellow]Python version mismatch detected[/yellow]
|
|
298
|
+
Project venv: {venv_version[0]}.{venv_version[1]}
|
|
299
|
+
uvx invar: {current_version[0]}.{current_version[1]}
|
|
300
|
+
|
|
301
|
+
C extension modules (numpy, pandas, etc.) may fail to load.
|
|
302
|
+
|
|
303
|
+
To fix, install invar in your project:
|
|
304
|
+
[cyan]pip install invar-tools[/cyan]
|
|
305
|
+
|
|
306
|
+
This enables automatic Python version matching.
|
|
307
|
+
"""
|
|
308
|
+
return (True, msg)
|
|
309
|
+
|
|
310
|
+
return (False, "")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# @shell_complexity: File system checks with timestamp handling
|
|
314
|
+
@pre(lambda project_root: isinstance(project_root, Path))
|
|
315
|
+
def should_suppress_prompt(project_root: Path) -> bool:
|
|
316
|
+
"""Check if upgrade prompt should be suppressed (pure check, no side effects).
|
|
317
|
+
|
|
318
|
+
Strategies:
|
|
319
|
+
- Per-project daily limit (avoid spam)
|
|
320
|
+
- User can permanently disable via .invar/no-upgrade-prompt
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
project_root: Project root directory
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
True if prompt should be suppressed
|
|
327
|
+
|
|
328
|
+
Examples:
|
|
329
|
+
>>> from pathlib import Path
|
|
330
|
+
>>> should_suppress_prompt(Path("/nonexistent"))
|
|
331
|
+
False
|
|
332
|
+
"""
|
|
333
|
+
invar_dir = project_root / ".invar"
|
|
334
|
+
|
|
335
|
+
# Permanent disable file
|
|
336
|
+
if (invar_dir / "no-upgrade-prompt").exists():
|
|
337
|
+
return True
|
|
338
|
+
|
|
339
|
+
# Daily limit per project
|
|
340
|
+
marker = invar_dir / ".last-upgrade-prompt"
|
|
341
|
+
if marker.exists():
|
|
342
|
+
try:
|
|
343
|
+
last_time = datetime.fromtimestamp(marker.stat().st_mtime)
|
|
344
|
+
if datetime.now() - last_time < timedelta(days=1):
|
|
345
|
+
return True
|
|
346
|
+
except OSError:
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _update_prompt_marker(project_root: Path) -> None:
|
|
353
|
+
"""Update the prompt marker timestamp (called after showing prompt).
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
project_root: Project root directory
|
|
357
|
+
"""
|
|
358
|
+
invar_dir = project_root / ".invar"
|
|
359
|
+
marker = invar_dir / ".last-upgrade-prompt"
|
|
360
|
+
try:
|
|
361
|
+
invar_dir.mkdir(exist_ok=True)
|
|
362
|
+
marker.touch()
|
|
363
|
+
except OSError:
|
|
364
|
+
pass
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@pre(lambda project_root, console: isinstance(project_root, Path))
|
|
368
|
+
def maybe_show_upgrade_prompt(project_root: Path, console: object) -> None:
|
|
369
|
+
"""Show upgrade prompt if conditions are met.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
project_root: Project root directory
|
|
373
|
+
console: Rich console for output
|
|
374
|
+
|
|
375
|
+
Examples:
|
|
376
|
+
>>> from pathlib import Path
|
|
377
|
+
>>> # No-op for non-existent paths
|
|
378
|
+
>>> maybe_show_upgrade_prompt(Path("/nonexistent"), None)
|
|
379
|
+
"""
|
|
380
|
+
is_mismatched, msg = check_version_mismatch(project_root)
|
|
381
|
+
|
|
382
|
+
if not is_mismatched:
|
|
383
|
+
return # Versions match, no prompt needed
|
|
384
|
+
|
|
385
|
+
if should_suppress_prompt(project_root):
|
|
386
|
+
return # Already prompted recently
|
|
387
|
+
|
|
388
|
+
# Update marker before showing (prevents spam on failures)
|
|
389
|
+
_update_prompt_marker(project_root)
|
|
390
|
+
|
|
391
|
+
# Print warning if console is available
|
|
392
|
+
if console is not None and hasattr(console, "print"):
|
|
393
|
+
console.print(msg)
|