invar-tools 1.2.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 +10 -10
- invar/core/entry_points.py +105 -32
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +1 -2
- invar/core/formatter.py +6 -7
- invar/core/hypothesis_strategies.py +5 -7
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -3
- invar/core/models.py +7 -1
- invar/core/must_use.py +2 -1
- invar/core/parser.py +7 -4
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +8 -5
- invar/core/purity.py +3 -3
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +78 -6
- invar/core/rule_meta.py +8 -0
- invar/core/rules.py +18 -19
- invar/core/shell_analysis.py +5 -10
- invar/core/shell_architecture.py +2 -2
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +86 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +102 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +13 -15
- invar/core/verification_routing.py +4 -7
- invar/mcp/server.py +100 -17
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +94 -14
- invar/shell/{init_cmd.py → commands/init.py} +179 -27
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +12 -24
- invar/shell/coverage.py +351 -0
- invar/shell/guard_helpers.py +38 -17
- invar/shell/guard_output.py +7 -1
- invar/shell/property_tests.py +58 -22
- invar/shell/prove/__init__.py +9 -0
- invar/shell/{prove.py → prove/crosshair.py} +40 -33
- invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +19 -0
- invar/shell/testing.py +71 -20
- invar/templates/CLAUDE.md.template +38 -17
- invar/templates/aider.conf.yml.template +2 -2
- invar/templates/commands/{review.md → audit.md} +20 -82
- 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 +7 -4
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +5 -5
- invar/templates/examples/core_shell.py +11 -7
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
- 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.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -58
- invar/resource.py +0 -99
- invar/shell/update_cmd.py +0 -193
- invar_tools-1.2.0.dist-info/RECORD +0 -77
- invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
- /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
- /invar/shell/{perception.py → commands/perception.py} +0 -0
- /invar/shell/{test_cmd.py → commands/test.py} +0 -0
- /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -18,23 +18,22 @@ from typing import TYPE_CHECKING
|
|
|
18
18
|
from returns.result import Failure, Result, Success
|
|
19
19
|
from rich.console import Console
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
from invar.shell.prove_cache import ProveCache # noqa: TC001 - runtime usage
|
|
21
|
+
from invar.shell.prove.cache import ProveCache # noqa: TC001 - runtime usage
|
|
23
22
|
|
|
24
|
-
# DX-12: Hypothesis fallback
|
|
25
|
-
from invar.shell.
|
|
23
|
+
# DX-12: Hypothesis fallback
|
|
24
|
+
from invar.shell.prove.hypothesis import (
|
|
26
25
|
run_hypothesis_fallback as run_hypothesis_fallback,
|
|
27
26
|
)
|
|
28
|
-
from invar.shell.
|
|
27
|
+
from invar.shell.prove.hypothesis import (
|
|
29
28
|
run_prove_with_fallback as run_prove_with_fallback,
|
|
30
29
|
)
|
|
30
|
+
from invar.shell.subprocess_env import build_subprocess_env # DX-52
|
|
31
31
|
|
|
32
32
|
if TYPE_CHECKING:
|
|
33
33
|
from typing import Any
|
|
34
34
|
|
|
35
35
|
console = Console()
|
|
36
36
|
|
|
37
|
-
|
|
38
37
|
# ============================================================
|
|
39
38
|
# CrossHair Status Codes
|
|
40
39
|
# ============================================================
|
|
@@ -59,17 +58,7 @@ class CrossHairStatus:
|
|
|
59
58
|
# @shell_orchestration: Contract detection for CrossHair prove module
|
|
60
59
|
# @shell_complexity: AST traversal for contract detection
|
|
61
60
|
def has_verifiable_contracts(source: str) -> bool:
|
|
62
|
-
"""
|
|
63
|
-
Check if source has verifiable contracts.
|
|
64
|
-
|
|
65
|
-
DX-13: Hybrid detection - fast string check + AST validation.
|
|
66
|
-
|
|
67
|
-
Args:
|
|
68
|
-
source: Python source code
|
|
69
|
-
|
|
70
|
-
Returns:
|
|
71
|
-
True if file has @pre/@post contracts worth verifying
|
|
72
|
-
"""
|
|
61
|
+
"""Check if source has @pre/@post contracts (DX-13: fast string + AST check)."""
|
|
73
62
|
# Fast path: no contract keywords at all
|
|
74
63
|
if "@pre" not in source and "@post" not in source:
|
|
75
64
|
return False
|
|
@@ -111,6 +100,8 @@ def has_verifiable_contracts(source: str) -> bool:
|
|
|
111
100
|
def _verify_single_file(
|
|
112
101
|
file_path: str,
|
|
113
102
|
max_iterations: int = 5,
|
|
103
|
+
timeout: int = 300,
|
|
104
|
+
per_condition_timeout: int = 30,
|
|
114
105
|
) -> dict[str, Any]:
|
|
115
106
|
"""
|
|
116
107
|
Verify a single file with CrossHair.
|
|
@@ -120,6 +111,8 @@ def _verify_single_file(
|
|
|
120
111
|
Args:
|
|
121
112
|
file_path: Path to Python file
|
|
122
113
|
max_iterations: Maximum uninteresting iterations (default: 5)
|
|
114
|
+
timeout: Max time per file in seconds (default: 300)
|
|
115
|
+
per_condition_timeout: Max time per contract in seconds (default: 30)
|
|
123
116
|
|
|
124
117
|
Returns:
|
|
125
118
|
Verification result dict
|
|
@@ -135,15 +128,18 @@ def _verify_single_file(
|
|
|
135
128
|
"check",
|
|
136
129
|
file_path,
|
|
137
130
|
f"--max_uninteresting_iterations={max_iterations}",
|
|
131
|
+
f"--per_condition_timeout={per_condition_timeout}",
|
|
138
132
|
"--analysis_kind=deal",
|
|
139
133
|
]
|
|
140
134
|
|
|
141
135
|
try:
|
|
136
|
+
# DX-52: Inject project venv site-packages for uvx compatibility
|
|
142
137
|
result = subprocess.run(
|
|
143
138
|
cmd,
|
|
144
139
|
capture_output=True,
|
|
145
140
|
text=True,
|
|
146
|
-
timeout=
|
|
141
|
+
timeout=timeout,
|
|
142
|
+
env=build_subprocess_env(),
|
|
147
143
|
)
|
|
148
144
|
|
|
149
145
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
|
@@ -159,14 +155,17 @@ def _verify_single_file(
|
|
|
159
155
|
# Check if this is an execution error vs actual counterexample
|
|
160
156
|
# CrossHair reports TypeError/AttributeError when it can't
|
|
161
157
|
# symbolically execute C extensions like ast.parse()
|
|
162
|
-
stdout
|
|
158
|
+
# Check both stdout and stderr for error patterns
|
|
159
|
+
output = result.stdout + "\n" + result.stderr
|
|
163
160
|
execution_errors = [
|
|
164
161
|
"TypeError:",
|
|
165
162
|
"AttributeError:",
|
|
166
163
|
"NotImplementedError:",
|
|
167
164
|
"compile() arg 1 must be", # ast.parse limitation
|
|
165
|
+
"ValueError: wrong parameter order", # CrossHair signature bug
|
|
166
|
+
"ValueError: cannot determine truth", # Symbolic execution limit
|
|
168
167
|
]
|
|
169
|
-
is_execution_error = any(err in
|
|
168
|
+
is_execution_error = any(err in output for err in execution_errors)
|
|
170
169
|
|
|
171
170
|
if is_execution_error:
|
|
172
171
|
# Treat as skipped - function uses unsupported operations
|
|
@@ -174,15 +173,15 @@ def _verify_single_file(
|
|
|
174
173
|
"file": file_path,
|
|
175
174
|
"status": CrossHairStatus.SKIPPED,
|
|
176
175
|
"time_ms": elapsed_ms,
|
|
177
|
-
"reason": "uses unsupported operations (ast/compile)",
|
|
178
|
-
"stdout":
|
|
176
|
+
"reason": "uses unsupported operations (ast/compile/signature)",
|
|
177
|
+
"stdout": output,
|
|
179
178
|
}
|
|
180
179
|
|
|
181
180
|
# Extract counterexample lines - CrossHair format: "file:line: error: Err when calling func(...)"
|
|
182
181
|
# Include lines with "error:" as they contain the actual counterexamples
|
|
183
182
|
counterexamples = [
|
|
184
183
|
line.strip()
|
|
185
|
-
for line in
|
|
184
|
+
for line in output.split("\n")
|
|
186
185
|
if line.strip() and ": error:" in line.lower()
|
|
187
186
|
]
|
|
188
187
|
return {
|
|
@@ -190,14 +189,14 @@ def _verify_single_file(
|
|
|
190
189
|
"status": CrossHairStatus.COUNTEREXAMPLE,
|
|
191
190
|
"time_ms": elapsed_ms,
|
|
192
191
|
"counterexamples": counterexamples,
|
|
193
|
-
"stdout":
|
|
192
|
+
"stdout": output,
|
|
194
193
|
}
|
|
195
194
|
|
|
196
195
|
except subprocess.TimeoutExpired:
|
|
197
196
|
return {
|
|
198
197
|
"file": file_path,
|
|
199
198
|
"status": CrossHairStatus.TIMEOUT,
|
|
200
|
-
"time_ms":
|
|
199
|
+
"time_ms": timeout * 1000,
|
|
201
200
|
}
|
|
202
201
|
except Exception as e:
|
|
203
202
|
return {
|
|
@@ -218,17 +217,18 @@ def run_crosshair_parallel(
|
|
|
218
217
|
max_iterations: int = 5,
|
|
219
218
|
max_workers: int | None = None,
|
|
220
219
|
cache: ProveCache | None = None,
|
|
220
|
+
timeout: int = 300,
|
|
221
|
+
per_condition_timeout: int = 30,
|
|
221
222
|
) -> Result[dict, str]:
|
|
222
|
-
"""
|
|
223
|
-
Run CrossHair on multiple files in parallel.
|
|
224
|
-
|
|
225
|
-
DX-13: Parallel execution with caching support.
|
|
223
|
+
"""Run CrossHair on multiple files in parallel (DX-13).
|
|
226
224
|
|
|
227
225
|
Args:
|
|
228
226
|
files: List of Python file paths to verify
|
|
229
227
|
max_iterations: Maximum uninteresting iterations per condition
|
|
230
228
|
max_workers: Number of parallel workers (default: CPU count)
|
|
231
229
|
cache: Optional verification cache
|
|
230
|
+
timeout: Max time per file in seconds (default: 300)
|
|
231
|
+
per_condition_timeout: Max time per contract in seconds (default: 30)
|
|
232
232
|
|
|
233
233
|
Returns:
|
|
234
234
|
Success with verification results or Failure with error message
|
|
@@ -327,7 +327,9 @@ def run_crosshair_parallel(
|
|
|
327
327
|
# Parallel execution
|
|
328
328
|
with ProcessPoolExecutor(max_workers=max_workers) as executor:
|
|
329
329
|
futures = {
|
|
330
|
-
executor.submit(
|
|
330
|
+
executor.submit(
|
|
331
|
+
_verify_single_file, str(f), max_iterations, timeout, per_condition_timeout
|
|
332
|
+
): f
|
|
331
333
|
for f in files_to_verify
|
|
332
334
|
}
|
|
333
335
|
|
|
@@ -349,7 +351,9 @@ def run_crosshair_parallel(
|
|
|
349
351
|
else:
|
|
350
352
|
# Sequential execution (single file or max_workers=1)
|
|
351
353
|
for py_file in files_to_verify:
|
|
352
|
-
result = _verify_single_file(
|
|
354
|
+
result = _verify_single_file(
|
|
355
|
+
str(py_file), max_iterations, timeout, per_condition_timeout
|
|
356
|
+
)
|
|
353
357
|
_process_verification_result(
|
|
354
358
|
result,
|
|
355
359
|
py_file,
|
|
@@ -419,7 +423,7 @@ def _process_verification_result(
|
|
|
419
423
|
|
|
420
424
|
|
|
421
425
|
def run_crosshair_on_files(
|
|
422
|
-
files: list[Path], timeout: int =
|
|
426
|
+
files: list[Path], timeout: int = 300, per_condition_timeout: int = 30
|
|
423
427
|
) -> Result[dict, str]:
|
|
424
428
|
"""
|
|
425
429
|
Run CrossHair symbolic verification on a list of Python files.
|
|
@@ -428,7 +432,8 @@ def run_crosshair_on_files(
|
|
|
428
432
|
|
|
429
433
|
Args:
|
|
430
434
|
files: List of Python file paths to verify
|
|
431
|
-
timeout:
|
|
435
|
+
timeout: Max time per file in seconds (default: 300)
|
|
436
|
+
per_condition_timeout: Max time per contract in seconds (default: 30)
|
|
432
437
|
|
|
433
438
|
Returns:
|
|
434
439
|
Success with verification results or Failure with error message
|
|
@@ -439,6 +444,8 @@ def run_crosshair_on_files(
|
|
|
439
444
|
max_iterations=5, # Fast mode
|
|
440
445
|
max_workers=None, # Auto-detect
|
|
441
446
|
cache=None, # No cache for basic API
|
|
447
|
+
timeout=timeout,
|
|
448
|
+
per_condition_timeout=per_condition_timeout,
|
|
442
449
|
)
|
|
443
450
|
|
|
444
451
|
|
|
@@ -18,6 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
from returns.result import Failure, Result, Success
|
|
19
19
|
|
|
20
20
|
from invar.core.verification_routing import get_incompatible_imports
|
|
21
|
+
from invar.shell.subprocess_env import build_subprocess_env
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
@dataclass
|
|
@@ -84,7 +85,7 @@ def run_hypothesis_fallback(
|
|
|
84
85
|
Success with test results or Failure with error message
|
|
85
86
|
"""
|
|
86
87
|
# Import CrossHairStatus here to avoid circular import
|
|
87
|
-
from invar.shell.prove import CrossHairStatus
|
|
88
|
+
from invar.shell.prove.crosshair import CrossHairStatus
|
|
88
89
|
|
|
89
90
|
# Check if hypothesis is available
|
|
90
91
|
try:
|
|
@@ -134,7 +135,14 @@ def run_hypothesis_fallback(
|
|
|
134
135
|
cmd.extend(str(f) for f in py_files)
|
|
135
136
|
|
|
136
137
|
try:
|
|
137
|
-
|
|
138
|
+
# DX-52: Inject project venv site-packages for uvx compatibility
|
|
139
|
+
result = subprocess.run(
|
|
140
|
+
cmd,
|
|
141
|
+
capture_output=True,
|
|
142
|
+
text=True,
|
|
143
|
+
timeout=300,
|
|
144
|
+
env=build_subprocess_env(),
|
|
145
|
+
)
|
|
138
146
|
# Pytest exit codes: 0=passed, 5=no tests collected
|
|
139
147
|
is_passed = result.returncode in (0, 5)
|
|
140
148
|
return Success(
|
|
@@ -186,8 +194,8 @@ def run_prove_with_fallback(
|
|
|
186
194
|
Success with verification results including routing statistics
|
|
187
195
|
"""
|
|
188
196
|
# Import here to avoid circular import
|
|
189
|
-
from invar.shell.prove import
|
|
190
|
-
from invar.shell.
|
|
197
|
+
from invar.shell.prove.cache import ProveCache
|
|
198
|
+
from invar.shell.prove.crosshair import CrossHairStatus, run_crosshair_parallel
|
|
191
199
|
|
|
192
200
|
# DX-22: Smart routing - classify files before verification
|
|
193
201
|
routing = classify_files_for_verification(files)
|
|
@@ -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)
|