libcontext 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {libcontext-0.3.0 → libcontext-0.4.0}/CHANGELOG.md +12 -1
- {libcontext-0.3.0 → libcontext-0.4.0}/PKG-INFO +1 -1
- {libcontext-0.3.0 → libcontext-0.4.0}/pyproject.toml +1 -1
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/__init__.py +2 -0
- libcontext-0.4.0/src/libcontext/_envsetup.py +334 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/cache.py +20 -3
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/cli.py +28 -1
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/collector.py +5 -2
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/exceptions.py +12 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/mcp_server.py +29 -1
- libcontext-0.4.0/tests/test_envsetup.py +276 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/.github/workflows/ci.yml +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/.github/workflows/release.yml +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/.gitignore +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/CONTRIBUTING.md +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/DEPENDENCIES.md +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/LICENSE +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/README.md +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/SECURITY.md +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/docs/adr/002-skill-plus-cli-as-primary-integration.md +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/docs/adr/004-ast-only-inspection.md +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/docs/adr/README.md +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/_security.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/config.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/diff.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/inspector.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/models.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/py.typed +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/src/libcontext/renderer.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/__init__.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/test_cache.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/test_cli.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/test_cli_mcp_parity.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/test_collector.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/test_config.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/test_diff.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/test_inspector.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/test_mcp_server.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/test_models.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/test_renderer.py +0 -0
- {libcontext-0.3.0 → libcontext-0.4.0}/tests/test_security.py +0 -0
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-03-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Auto-detect project venv**: libcontext now automatically detects `.venv/` or `venv/` in the current directory and uses it for package discovery. This fixes the core issue where `uv tool install libcontext` could not see packages from project environments.
|
|
15
|
+
- **`--python` CLI option**: explicit override for targeting a specific Python interpreter or venv directory (e.g. `--python /path/to/other/venv`).
|
|
16
|
+
- **`LIBCONTEXT_PYTHON` env var**: configure the MCP server's target environment via environment variable or `--python` argument.
|
|
17
|
+
- **`EnvironmentSetupError` exception**: raised when a target environment cannot be resolved or queried.
|
|
18
|
+
- **Cache namespacing by environment**: packages from different environments get separate cache entries, preventing cross-environment cache collisions.
|
|
19
|
+
|
|
10
20
|
## [0.3.0] - 2026-03-23
|
|
11
21
|
|
|
12
22
|
### Added
|
|
@@ -83,7 +93,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
83
93
|
- Free-form `extra_context` field for library authors.
|
|
84
94
|
- Python API for programmatic usage (`collect_package`, `render_package`).
|
|
85
95
|
|
|
86
|
-
[Unreleased]: https://github.com/Syclaw/libcontext/compare/v0.
|
|
96
|
+
[Unreleased]: https://github.com/Syclaw/libcontext/compare/v0.4.0...HEAD
|
|
97
|
+
[0.4.0]: https://github.com/Syclaw/libcontext/compare/v0.3.0...v0.4.0
|
|
87
98
|
[0.3.0]: https://github.com/Syclaw/libcontext/compare/v0.2.0...v0.3.0
|
|
88
99
|
[0.2.0]: https://github.com/Syclaw/libcontext/compare/v0.1.0...v0.2.0
|
|
89
100
|
[0.1.0]: https://github.com/Syclaw/libcontext/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: libcontext
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Generate optimized LLM context from Python library APIs — CLI, skill, and MCP server
|
|
5
5
|
Project-URL: Homepage, https://github.com/Syclaw/libcontext
|
|
6
6
|
Project-URL: Repository, https://github.com/Syclaw/libcontext
|
|
@@ -27,6 +27,7 @@ from .config import LibcontextConfig
|
|
|
27
27
|
from .diff import diff_packages
|
|
28
28
|
from .exceptions import (
|
|
29
29
|
ConfigError,
|
|
30
|
+
EnvironmentSetupError,
|
|
30
31
|
InspectionError,
|
|
31
32
|
LibcontextError,
|
|
32
33
|
PackageNotFoundError,
|
|
@@ -69,6 +70,7 @@ __all__ = [
|
|
|
69
70
|
"ClassInfo",
|
|
70
71
|
"ConfigError",
|
|
71
72
|
"DiffResult",
|
|
73
|
+
"EnvironmentSetupError",
|
|
72
74
|
"FunctionDiff",
|
|
73
75
|
"FunctionInfo",
|
|
74
76
|
"InspectionError",
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Environment setup — resolve and activate a target Python environment.
|
|
2
|
+
|
|
3
|
+
When libcontext is installed globally (e.g. via ``uv tool install``), it
|
|
4
|
+
runs inside its own isolated venv and cannot see packages from a project's
|
|
5
|
+
``.venv``. This module auto-detects a project venv in the current working
|
|
6
|
+
directory, or accepts an explicit ``--python`` override, and injects the
|
|
7
|
+
target environment's paths into ``sys.path`` so that :mod:`importlib`
|
|
8
|
+
discovery works against the target environment.
|
|
9
|
+
|
|
10
|
+
Detection priority:
|
|
11
|
+
1. Explicit ``--python`` argument → use that environment.
|
|
12
|
+
2. ``.venv/`` or ``venv/`` in CWD → use the detected venv.
|
|
13
|
+
3. Neither → use the current process's environment (no injection).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from collections.abc import Generator
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .exceptions import EnvironmentSetupError
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_SUBPROCESS_TIMEOUT_SECONDS = 10
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_VENV_DIR_NAMES = (".venv", "venv")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def auto_detect_venv(cwd: Path | None = None) -> Path | None:
|
|
38
|
+
"""Detect a project venv in the current working directory.
|
|
39
|
+
|
|
40
|
+
Checks for ``.venv/`` then ``venv/`` in *cwd* (defaults to
|
|
41
|
+
``Path.cwd()``). Only considers directories that contain a
|
|
42
|
+
recognisable Python interpreter.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
cwd: Directory to search in. Defaults to the process CWD.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Path to the venv directory, or *None* if no venv is found.
|
|
49
|
+
"""
|
|
50
|
+
if cwd is None:
|
|
51
|
+
cwd = Path.cwd()
|
|
52
|
+
|
|
53
|
+
for name in _VENV_DIR_NAMES:
|
|
54
|
+
candidate = cwd / name
|
|
55
|
+
if not candidate.is_dir():
|
|
56
|
+
continue
|
|
57
|
+
# Verify it actually contains an interpreter
|
|
58
|
+
interpreters = [
|
|
59
|
+
candidate / "Scripts" / "python.exe",
|
|
60
|
+
candidate / "bin" / "python",
|
|
61
|
+
candidate / "bin" / "python3",
|
|
62
|
+
]
|
|
63
|
+
if any(exe.is_file() for exe in interpreters):
|
|
64
|
+
logger.debug("Auto-detected venv at '%s'", candidate)
|
|
65
|
+
return candidate
|
|
66
|
+
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def setup_environment(
|
|
71
|
+
python_arg: str | None = None,
|
|
72
|
+
*,
|
|
73
|
+
cwd: Path | None = None,
|
|
74
|
+
) -> str | None:
|
|
75
|
+
"""Set up the target environment for package discovery.
|
|
76
|
+
|
|
77
|
+
Implements the detection priority:
|
|
78
|
+
1. Explicit *python_arg* → inject that environment.
|
|
79
|
+
2. Auto-detected venv in *cwd* → inject it.
|
|
80
|
+
3. Neither → no injection (current process environment).
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
python_arg: Explicit ``--python`` value, or *None*.
|
|
84
|
+
cwd: Working directory for auto-detection (defaults to CWD).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The env_tag for cache namespacing, or *None* if no injection
|
|
88
|
+
was performed.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
EnvironmentSetupError: If an explicit *python_arg* is invalid.
|
|
92
|
+
"""
|
|
93
|
+
target: str | None = python_arg
|
|
94
|
+
|
|
95
|
+
if target is None:
|
|
96
|
+
detected = auto_detect_venv(cwd)
|
|
97
|
+
if detected is not None:
|
|
98
|
+
target = str(detected)
|
|
99
|
+
|
|
100
|
+
if target is None:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Resolve once, reuse for both injection and tag computation
|
|
104
|
+
python_exe = resolve_python_executable(target)
|
|
105
|
+
target_paths = get_target_sys_path(python_exe)
|
|
106
|
+
|
|
107
|
+
current_set = set(sys.path)
|
|
108
|
+
new_paths = [p for p in target_paths if p and p not in current_set]
|
|
109
|
+
|
|
110
|
+
sys.path[:0] = new_paths
|
|
111
|
+
importlib.invalidate_caches()
|
|
112
|
+
|
|
113
|
+
logger.debug(
|
|
114
|
+
"Activated environment '%s': injected %d paths",
|
|
115
|
+
target,
|
|
116
|
+
len(new_paths),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return _env_tag_from_resolved(python_exe)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def resolve_python_executable(python_arg: str) -> Path:
|
|
123
|
+
"""Resolve a user-supplied path to a Python executable.
|
|
124
|
+
|
|
125
|
+
Accepts either a direct path to a Python interpreter or a venv
|
|
126
|
+
directory. When given a directory, probes for the interpreter
|
|
127
|
+
in the standard locations (``Scripts/python.exe`` on Windows,
|
|
128
|
+
``bin/python`` on Unix).
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
python_arg: Path to a Python interpreter or venv directory.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Resolved absolute path to the Python executable.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
EnvironmentSetupError: If the path does not exist or no
|
|
138
|
+
interpreter can be found.
|
|
139
|
+
"""
|
|
140
|
+
path = Path(python_arg)
|
|
141
|
+
|
|
142
|
+
if not path.exists():
|
|
143
|
+
raise EnvironmentSetupError(
|
|
144
|
+
python_arg,
|
|
145
|
+
f"path does not exist: {python_arg}",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Direct path to an executable
|
|
149
|
+
if path.is_file():
|
|
150
|
+
return path.resolve()
|
|
151
|
+
|
|
152
|
+
# Directory — probe for interpreter
|
|
153
|
+
if path.is_dir():
|
|
154
|
+
candidates = [
|
|
155
|
+
path / "Scripts" / "python.exe", # Windows venv
|
|
156
|
+
path / "bin" / "python", # Unix venv
|
|
157
|
+
path / "bin" / "python3", # Unix alternative
|
|
158
|
+
]
|
|
159
|
+
for candidate in candidates:
|
|
160
|
+
if candidate.is_file():
|
|
161
|
+
logger.debug(
|
|
162
|
+
"Resolved venv directory '%s' to interpreter '%s'",
|
|
163
|
+
python_arg,
|
|
164
|
+
candidate,
|
|
165
|
+
)
|
|
166
|
+
return candidate.resolve()
|
|
167
|
+
|
|
168
|
+
raise EnvironmentSetupError(
|
|
169
|
+
python_arg,
|
|
170
|
+
f"no Python interpreter found in directory: {python_arg}. "
|
|
171
|
+
f"Expected Scripts/python.exe (Windows) or bin/python (Unix).",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
raise EnvironmentSetupError(
|
|
175
|
+
python_arg,
|
|
176
|
+
f"path is neither a file nor a directory: {python_arg}",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def get_target_sys_path(python_exe: Path) -> list[str]:
|
|
181
|
+
"""Query a Python interpreter for its ``sys.path``.
|
|
182
|
+
|
|
183
|
+
Runs the interpreter in a subprocess with a short timeout to
|
|
184
|
+
extract the full search path, including ``.pth`` expansions and
|
|
185
|
+
site-packages.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
python_exe: Absolute path to the target Python executable.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of path strings from the target interpreter's ``sys.path``.
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
EnvironmentSetupError: If the subprocess fails or times out.
|
|
195
|
+
"""
|
|
196
|
+
script = "import sys, json; print(json.dumps(sys.path))"
|
|
197
|
+
try:
|
|
198
|
+
result = subprocess.run(
|
|
199
|
+
[str(python_exe), "-c", script],
|
|
200
|
+
capture_output=True,
|
|
201
|
+
text=True,
|
|
202
|
+
timeout=_SUBPROCESS_TIMEOUT_SECONDS,
|
|
203
|
+
)
|
|
204
|
+
except (FileNotFoundError, OSError) as exc:
|
|
205
|
+
raise EnvironmentSetupError(
|
|
206
|
+
str(python_exe),
|
|
207
|
+
f"cannot execute interpreter: {exc}",
|
|
208
|
+
) from exc
|
|
209
|
+
except subprocess.TimeoutExpired as exc:
|
|
210
|
+
raise EnvironmentSetupError(
|
|
211
|
+
str(python_exe),
|
|
212
|
+
f"interpreter timed out after {_SUBPROCESS_TIMEOUT_SECONDS}s",
|
|
213
|
+
) from exc
|
|
214
|
+
|
|
215
|
+
if result.returncode != 0:
|
|
216
|
+
stderr = result.stderr.strip()[:200]
|
|
217
|
+
raise EnvironmentSetupError(
|
|
218
|
+
str(python_exe),
|
|
219
|
+
f"interpreter exited with code {result.returncode}: {stderr}",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
paths = json.loads(result.stdout)
|
|
224
|
+
except json.JSONDecodeError as exc:
|
|
225
|
+
raise EnvironmentSetupError(
|
|
226
|
+
str(python_exe),
|
|
227
|
+
f"cannot parse sys.path output: {exc}",
|
|
228
|
+
) from exc
|
|
229
|
+
|
|
230
|
+
if not isinstance(paths, list):
|
|
231
|
+
raise EnvironmentSetupError(
|
|
232
|
+
str(python_exe),
|
|
233
|
+
"sys.path output is not a list",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return [str(p) for p in paths]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@contextmanager
|
|
240
|
+
def activate_environment(python_arg: str) -> Generator[Path, None, None]:
|
|
241
|
+
"""Temporarily inject a target environment's paths into the process.
|
|
242
|
+
|
|
243
|
+
Resolves the Python executable, queries its ``sys.path``, prepends
|
|
244
|
+
the target paths to the current ``sys.path``, and invalidates
|
|
245
|
+
:mod:`importlib` caches so that :func:`importlib.util.find_spec`
|
|
246
|
+
and :func:`importlib.metadata.distributions` pick up the target
|
|
247
|
+
environment's packages.
|
|
248
|
+
|
|
249
|
+
The original ``sys.path`` is restored on exit.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
python_arg: Path to a Python interpreter or venv directory.
|
|
253
|
+
|
|
254
|
+
Yields:
|
|
255
|
+
The resolved Python executable path.
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
EnvironmentSetupError: If the environment cannot be resolved
|
|
259
|
+
or queried.
|
|
260
|
+
"""
|
|
261
|
+
python_exe = resolve_python_executable(python_arg)
|
|
262
|
+
target_paths = get_target_sys_path(python_exe)
|
|
263
|
+
|
|
264
|
+
# Filter to paths that actually exist and aren't already present
|
|
265
|
+
current_set = set(sys.path)
|
|
266
|
+
new_paths = [p for p in target_paths if p and p not in current_set]
|
|
267
|
+
|
|
268
|
+
saved_path = sys.path.copy()
|
|
269
|
+
try:
|
|
270
|
+
# Prepend target paths so they take priority
|
|
271
|
+
sys.path[:0] = new_paths
|
|
272
|
+
importlib.invalidate_caches()
|
|
273
|
+
|
|
274
|
+
logger.debug(
|
|
275
|
+
"Activated environment '%s': injected %d paths",
|
|
276
|
+
python_arg,
|
|
277
|
+
len(new_paths),
|
|
278
|
+
)
|
|
279
|
+
yield python_exe
|
|
280
|
+
finally:
|
|
281
|
+
sys.path[:] = saved_path
|
|
282
|
+
importlib.invalidate_caches()
|
|
283
|
+
logger.debug("Restored original sys.path")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def inject_target_environment(python_arg: str) -> None:
|
|
287
|
+
"""Inject a target environment's paths into the current process.
|
|
288
|
+
|
|
289
|
+
Unlike :func:`activate_environment`, this does **not** restore
|
|
290
|
+
``sys.path`` afterward. Suitable for CLI entry points where the
|
|
291
|
+
process exits after the command completes.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
python_arg: Path to a Python interpreter or venv directory.
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
EnvironmentSetupError: If the environment cannot be resolved
|
|
298
|
+
or queried.
|
|
299
|
+
"""
|
|
300
|
+
python_exe = resolve_python_executable(python_arg)
|
|
301
|
+
target_paths = get_target_sys_path(python_exe)
|
|
302
|
+
|
|
303
|
+
current_set = set(sys.path)
|
|
304
|
+
new_paths = [p for p in target_paths if p and p not in current_set]
|
|
305
|
+
|
|
306
|
+
sys.path[:0] = new_paths
|
|
307
|
+
importlib.invalidate_caches()
|
|
308
|
+
|
|
309
|
+
logger.debug(
|
|
310
|
+
"Injected environment '%s': added %d paths",
|
|
311
|
+
python_arg,
|
|
312
|
+
len(new_paths),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _env_tag_from_resolved(python_exe: Path) -> str:
|
|
317
|
+
"""Compute a short tag from an already-resolved interpreter path."""
|
|
318
|
+
import hashlib
|
|
319
|
+
|
|
320
|
+
digest = hashlib.sha256(str(python_exe).encode()).hexdigest()
|
|
321
|
+
return digest[:8]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def env_tag_for_path(python_arg: str) -> str:
|
|
325
|
+
"""Compute a short tag identifying a target environment for cache keys.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
python_arg: The original ``--python`` argument (before resolution).
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
An 8-character hex string derived from the resolved path.
|
|
332
|
+
"""
|
|
333
|
+
resolved = resolve_python_executable(python_arg)
|
|
334
|
+
return _env_tag_from_resolved(resolved)
|
|
@@ -113,6 +113,7 @@ def load(
|
|
|
113
113
|
package_name: str,
|
|
114
114
|
version: str | None,
|
|
115
115
|
package_path: Path,
|
|
116
|
+
env_tag: str | None = None,
|
|
116
117
|
) -> PackageInfo | None:
|
|
117
118
|
"""Load a cached PackageInfo if still valid.
|
|
118
119
|
|
|
@@ -120,11 +121,12 @@ def load(
|
|
|
120
121
|
package_name: Package name.
|
|
121
122
|
version: Current installed version (from metadata).
|
|
122
123
|
package_path: Path to the package source (for mtime check).
|
|
124
|
+
env_tag: Environment identifier (from ``--python``).
|
|
123
125
|
|
|
124
126
|
Returns:
|
|
125
127
|
Cached PackageInfo if valid, None on miss or invalidation.
|
|
126
128
|
"""
|
|
127
|
-
cache_file = _get_cache_dir() / _cache_filename(package_name, version)
|
|
129
|
+
cache_file = _get_cache_dir() / _cache_filename(package_name, version, env_tag)
|
|
128
130
|
|
|
129
131
|
if not cache_file.is_file():
|
|
130
132
|
logger.debug("Cache miss for %r: file not found", package_name)
|
|
@@ -178,6 +180,7 @@ def save(
|
|
|
178
180
|
package_info: PackageInfo,
|
|
179
181
|
package_path: Path,
|
|
180
182
|
source_stats: _SourceStats | None = None,
|
|
183
|
+
env_tag: str | None = None,
|
|
181
184
|
) -> None:
|
|
182
185
|
"""Save a PackageInfo to the disk cache.
|
|
183
186
|
|
|
@@ -185,6 +188,7 @@ def save(
|
|
|
185
188
|
package_info: The collected package data.
|
|
186
189
|
package_path: Path to the package source (for mtime computation).
|
|
187
190
|
source_stats: Pre-computed stats. If None, stats are computed fresh.
|
|
191
|
+
env_tag: Environment identifier (from ``--python``).
|
|
188
192
|
"""
|
|
189
193
|
if source_stats is None:
|
|
190
194
|
source_stats = _compute_source_stats(package_path)
|
|
@@ -198,7 +202,9 @@ def save(
|
|
|
198
202
|
envelope = _serialize_envelope(data)
|
|
199
203
|
|
|
200
204
|
cache_dir = _get_cache_dir()
|
|
201
|
-
cache_file = cache_dir / _cache_filename(
|
|
205
|
+
cache_file = cache_dir / _cache_filename(
|
|
206
|
+
package_info.name, package_info.version, env_tag
|
|
207
|
+
)
|
|
202
208
|
|
|
203
209
|
try:
|
|
204
210
|
cache_file.write_text(
|
|
@@ -252,14 +258,25 @@ def clear_all() -> int:
|
|
|
252
258
|
return count
|
|
253
259
|
|
|
254
260
|
|
|
255
|
-
def _cache_filename(
|
|
261
|
+
def _cache_filename(
|
|
262
|
+
package_name: str,
|
|
263
|
+
version: str | None,
|
|
264
|
+
env_tag: str | None = None,
|
|
265
|
+
) -> str:
|
|
256
266
|
"""Build the cache filename for a package.
|
|
257
267
|
|
|
258
268
|
Sanitises both components to prevent path traversal via crafted
|
|
259
269
|
package names (e.g. ``../../etc/cron.d/evil``).
|
|
270
|
+
|
|
271
|
+
When *env_tag* is provided (from ``--python``), it is appended to
|
|
272
|
+
the filename so that packages from different environments get
|
|
273
|
+
separate cache entries.
|
|
260
274
|
"""
|
|
261
275
|
from ._security import sanitize_filename
|
|
262
276
|
|
|
263
277
|
safe_name = sanitize_filename(package_name)
|
|
264
278
|
safe_version = sanitize_filename(version) if version else "unknown"
|
|
279
|
+
if env_tag:
|
|
280
|
+
safe_tag = sanitize_filename(env_tag)
|
|
281
|
+
return f"{safe_name}-{safe_version}-{safe_tag}.json"
|
|
265
282
|
return f"{safe_name}-{safe_version}.json"
|
|
@@ -35,7 +35,12 @@ from . import cache as _cache
|
|
|
35
35
|
from .collector import collect_package
|
|
36
36
|
from .config import LibcontextConfig, read_config_from_pyproject
|
|
37
37
|
from .diff import diff_packages
|
|
38
|
-
from .exceptions import
|
|
38
|
+
from .exceptions import (
|
|
39
|
+
ConfigError,
|
|
40
|
+
EnvironmentSetupError,
|
|
41
|
+
InspectionError,
|
|
42
|
+
PackageNotFoundError,
|
|
43
|
+
)
|
|
39
44
|
from .models import PackageInfo, _deserialize_envelope, _serialize_envelope
|
|
40
45
|
from .renderer import (
|
|
41
46
|
inject_into_file,
|
|
@@ -165,6 +170,17 @@ def _write_stdout(text: str) -> None:
|
|
|
165
170
|
default=False,
|
|
166
171
|
help="Force fresh collection, bypass disk cache.",
|
|
167
172
|
)
|
|
173
|
+
@click.option(
|
|
174
|
+
"--python",
|
|
175
|
+
"python_env",
|
|
176
|
+
type=str,
|
|
177
|
+
default=None,
|
|
178
|
+
help=(
|
|
179
|
+
"Override environment for package discovery. Accepts a venv "
|
|
180
|
+
"directory or Python interpreter path. By default, libcontext "
|
|
181
|
+
"auto-detects .venv/ or venv/ in the current directory."
|
|
182
|
+
),
|
|
183
|
+
)
|
|
168
184
|
@click.option(
|
|
169
185
|
"-v",
|
|
170
186
|
"--verbose",
|
|
@@ -185,6 +201,7 @@ def inspect(
|
|
|
185
201
|
max_readme_lines: int | None,
|
|
186
202
|
config_path: Path | None,
|
|
187
203
|
no_cache: bool,
|
|
204
|
+
python_env: str | None,
|
|
188
205
|
quiet: bool,
|
|
189
206
|
verbose: bool,
|
|
190
207
|
) -> None:
|
|
@@ -225,6 +242,15 @@ def inspect(
|
|
|
225
242
|
# --overview and --search don't need README
|
|
226
243
|
skip_readme = no_readme or overview or search_query is not None
|
|
227
244
|
|
|
245
|
+
# Resolve target environment (--python override, auto-detected venv, or current)
|
|
246
|
+
from ._envsetup import setup_environment
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
_env_tag = setup_environment(python_env)
|
|
250
|
+
except EnvironmentSetupError as exc:
|
|
251
|
+
click.echo(f"Error: {exc}", err=True)
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
|
|
228
254
|
all_blocks: list[tuple[str, str]] = []
|
|
229
255
|
|
|
230
256
|
for pkg_name in packages:
|
|
@@ -238,6 +264,7 @@ def inspect(
|
|
|
238
264
|
include_readme=not skip_readme,
|
|
239
265
|
config_override=config,
|
|
240
266
|
no_cache=no_cache,
|
|
267
|
+
env_tag=_env_tag,
|
|
241
268
|
)
|
|
242
269
|
except PackageNotFoundError as exc:
|
|
243
270
|
click.echo(f"Error: {exc}", err=True)
|
|
@@ -620,6 +620,7 @@ def collect_package(
|
|
|
620
620
|
include_readme: bool = True,
|
|
621
621
|
config_override: LibcontextConfig | None = None,
|
|
622
622
|
no_cache: bool = False,
|
|
623
|
+
env_tag: str | None = None,
|
|
623
624
|
) -> PackageInfo:
|
|
624
625
|
"""Collect complete API information for a Python package.
|
|
625
626
|
|
|
@@ -632,6 +633,8 @@ def collect_package(
|
|
|
632
633
|
include_readme: Attach the package README to the result.
|
|
633
634
|
config_override: Explicit config; skips automatic discovery.
|
|
634
635
|
no_cache: Skip the disk cache (force fresh AST collection).
|
|
636
|
+
env_tag: Environment identifier for cache namespacing (from
|
|
637
|
+
``--python``).
|
|
635
638
|
|
|
636
639
|
Returns:
|
|
637
640
|
:class:`~libcontext.models.PackageInfo` with all collected data.
|
|
@@ -695,7 +698,7 @@ def collect_package(
|
|
|
695
698
|
source_stats: _cache._SourceStats | None = None
|
|
696
699
|
|
|
697
700
|
if use_cache:
|
|
698
|
-
cached = _cache.load(pkg_name, metadata.get("version"), pkg_path)
|
|
701
|
+
cached = _cache.load(pkg_name, metadata.get("version"), pkg_path, env_tag)
|
|
699
702
|
if cached is not None:
|
|
700
703
|
if include_readme:
|
|
701
704
|
cached.readme = _find_readme(pkg_name, pkg_path)
|
|
@@ -722,6 +725,6 @@ def collect_package(
|
|
|
722
725
|
|
|
723
726
|
# --- Cache save ----------------------------------------------------
|
|
724
727
|
if use_cache:
|
|
725
|
-
_cache.save(pkg_info, pkg_path, source_stats=source_stats)
|
|
728
|
+
_cache.save(pkg_info, pkg_path, source_stats=source_stats, env_tag=env_tag)
|
|
726
729
|
|
|
727
730
|
return pkg_info
|
|
@@ -52,6 +52,18 @@ class ConfigError(LibcontextError):
|
|
|
52
52
|
super().__init__(detail)
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
class EnvironmentSetupError(LibcontextError):
|
|
56
|
+
"""Raised when a target Python environment cannot be resolved or queried.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
python_path: The path that was supplied by the user.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, python_path: str, reason: str) -> None:
|
|
63
|
+
self.python_path = python_path
|
|
64
|
+
super().__init__(f"Cannot use environment '{python_path}': {reason}")
|
|
65
|
+
|
|
66
|
+
|
|
55
67
|
class InspectionError(LibcontextError):
|
|
56
68
|
"""Raised when a source file cannot be parsed or read.
|
|
57
69
|
|
|
@@ -59,6 +59,9 @@ mcp = FastMCP(
|
|
|
59
59
|
|
|
60
60
|
_CACHE_SIZE = 32
|
|
61
61
|
|
|
62
|
+
# Set by main() at startup; used by _collect_cached for cache namespacing.
|
|
63
|
+
_active_env_tag: str | None = None
|
|
64
|
+
|
|
62
65
|
|
|
63
66
|
@lru_cache(maxsize=_CACHE_SIZE)
|
|
64
67
|
def _collect_cached(package_name: str, include_private: bool = False) -> PackageInfo:
|
|
@@ -67,6 +70,7 @@ def _collect_cached(package_name: str, include_private: bool = False) -> Package
|
|
|
67
70
|
package_name,
|
|
68
71
|
include_private=include_private,
|
|
69
72
|
include_readme=False,
|
|
73
|
+
env_tag=_active_env_tag,
|
|
70
74
|
)
|
|
71
75
|
|
|
72
76
|
|
|
@@ -262,7 +266,31 @@ def refresh_cache() -> str:
|
|
|
262
266
|
|
|
263
267
|
|
|
264
268
|
def main() -> None:
|
|
265
|
-
"""Run the libcontext MCP server (stdio transport).
|
|
269
|
+
"""Run the libcontext MCP server (stdio transport).
|
|
270
|
+
|
|
271
|
+
Environment resolution (in priority order):
|
|
272
|
+
|
|
273
|
+
1. ``--python <path>`` CLI argument → use that environment.
|
|
274
|
+
2. ``LIBCONTEXT_PYTHON`` env var → use that environment.
|
|
275
|
+
3. Auto-detect ``.venv/`` or ``venv/`` in CWD → use the detected venv.
|
|
276
|
+
4. None of the above → use the current process's environment.
|
|
277
|
+
"""
|
|
278
|
+
import os
|
|
279
|
+
import sys as _sys
|
|
280
|
+
|
|
281
|
+
python_env = None
|
|
282
|
+
args = _sys.argv[1:]
|
|
283
|
+
if args and args[0] == "--python" and len(args) >= 2:
|
|
284
|
+
python_env = args[1]
|
|
285
|
+
elif os.environ.get("LIBCONTEXT_PYTHON"):
|
|
286
|
+
python_env = os.environ["LIBCONTEXT_PYTHON"]
|
|
287
|
+
|
|
288
|
+
global _active_env_tag
|
|
289
|
+
|
|
290
|
+
from ._envsetup import setup_environment
|
|
291
|
+
|
|
292
|
+
_active_env_tag = setup_environment(python_env)
|
|
293
|
+
|
|
266
294
|
mcp.run()
|
|
267
295
|
|
|
268
296
|
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Tests for the _envsetup module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from libcontext._envsetup import (
|
|
11
|
+
activate_environment,
|
|
12
|
+
auto_detect_venv,
|
|
13
|
+
env_tag_for_path,
|
|
14
|
+
get_target_sys_path,
|
|
15
|
+
inject_target_environment,
|
|
16
|
+
resolve_python_executable,
|
|
17
|
+
setup_environment,
|
|
18
|
+
)
|
|
19
|
+
from libcontext.exceptions import EnvironmentSetupError
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# resolve_python_executable
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_resolve_direct_interpreter():
|
|
27
|
+
"""Passing the current interpreter returns its resolved path."""
|
|
28
|
+
result = resolve_python_executable(sys.executable)
|
|
29
|
+
assert result == Path(sys.executable).resolve()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_resolve_venv_directory(tmp_path):
|
|
33
|
+
"""Passing a venv-like directory finds the interpreter."""
|
|
34
|
+
if sys.platform == "win32":
|
|
35
|
+
scripts = tmp_path / "Scripts"
|
|
36
|
+
scripts.mkdir()
|
|
37
|
+
exe = scripts / "python.exe"
|
|
38
|
+
else:
|
|
39
|
+
bin_dir = tmp_path / "bin"
|
|
40
|
+
bin_dir.mkdir()
|
|
41
|
+
exe = bin_dir / "python"
|
|
42
|
+
|
|
43
|
+
exe.write_text("fake", encoding="utf-8")
|
|
44
|
+
result = resolve_python_executable(str(tmp_path))
|
|
45
|
+
assert result == exe.resolve()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_resolve_nonexistent_raises():
|
|
49
|
+
"""A nonexistent path raises EnvironmentSetupError."""
|
|
50
|
+
with pytest.raises(EnvironmentSetupError, match="does not exist"):
|
|
51
|
+
resolve_python_executable("/no/such/path/python")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_resolve_empty_directory_raises(tmp_path):
|
|
55
|
+
"""A directory without a Python interpreter raises."""
|
|
56
|
+
with pytest.raises(EnvironmentSetupError, match="no Python interpreter"):
|
|
57
|
+
resolve_python_executable(str(tmp_path))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# get_target_sys_path
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_get_target_sys_path_current_interpreter():
|
|
66
|
+
"""Querying the current interpreter returns a non-empty path list."""
|
|
67
|
+
paths = get_target_sys_path(Path(sys.executable))
|
|
68
|
+
assert isinstance(paths, list)
|
|
69
|
+
assert len(paths) > 0
|
|
70
|
+
assert all(isinstance(p, str) for p in paths)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_get_target_sys_path_bad_executable(tmp_path):
|
|
74
|
+
"""A non-Python executable raises EnvironmentSetupError."""
|
|
75
|
+
fake = tmp_path / "not_python"
|
|
76
|
+
fake.write_text("not a python interpreter", encoding="utf-8")
|
|
77
|
+
if sys.platform != "win32":
|
|
78
|
+
fake.chmod(0o755)
|
|
79
|
+
|
|
80
|
+
with pytest.raises(EnvironmentSetupError):
|
|
81
|
+
get_target_sys_path(fake)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# inject_target_environment
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_inject_target_environment_adds_paths():
|
|
90
|
+
"""Injecting the current interpreter adds its paths to sys.path."""
|
|
91
|
+
original_len = len(sys.path)
|
|
92
|
+
# Inject the current interpreter (should be mostly a no-op since
|
|
93
|
+
# paths already overlap, but validates the mechanics)
|
|
94
|
+
inject_target_environment(sys.executable)
|
|
95
|
+
assert len(sys.path) >= original_len
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# activate_environment (context manager)
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_activate_environment_restores_path():
|
|
104
|
+
"""sys.path is restored after the context manager exits."""
|
|
105
|
+
saved = sys.path.copy()
|
|
106
|
+
with activate_environment(sys.executable):
|
|
107
|
+
pass
|
|
108
|
+
assert sys.path == saved
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_activate_environment_restores_on_exception():
|
|
112
|
+
"""sys.path is restored even if an exception occurs."""
|
|
113
|
+
saved = sys.path.copy()
|
|
114
|
+
with pytest.raises(RuntimeError), activate_environment(sys.executable):
|
|
115
|
+
raise RuntimeError("boom")
|
|
116
|
+
assert sys.path == saved
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_activate_environment_bad_path():
|
|
120
|
+
"""EnvironmentSetupError propagates from the context manager."""
|
|
121
|
+
with pytest.raises(EnvironmentSetupError), activate_environment("/no/such/env"):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# env_tag_for_path
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_env_tag_for_path_returns_hex_string():
|
|
131
|
+
"""env_tag returns an 8-char hex string."""
|
|
132
|
+
tag = env_tag_for_path(sys.executable)
|
|
133
|
+
assert len(tag) == 8
|
|
134
|
+
int(tag, 16) # validates hex
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_env_tag_deterministic():
|
|
138
|
+
"""Same input produces the same tag."""
|
|
139
|
+
tag1 = env_tag_for_path(sys.executable)
|
|
140
|
+
tag2 = env_tag_for_path(sys.executable)
|
|
141
|
+
assert tag1 == tag2
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Cache filename with env_tag
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_cache_filename_with_env_tag():
|
|
150
|
+
"""Cache filename includes env_tag when provided."""
|
|
151
|
+
from libcontext.cache import _cache_filename
|
|
152
|
+
|
|
153
|
+
without = _cache_filename("requests", "2.31.0")
|
|
154
|
+
with_tag = _cache_filename("requests", "2.31.0", env_tag="abcd1234")
|
|
155
|
+
assert "abcd1234" in with_tag
|
|
156
|
+
assert "abcd1234" not in without
|
|
157
|
+
assert with_tag != without
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# auto_detect_venv
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _make_fake_venv(parent: Path) -> Path:
|
|
166
|
+
"""Create a fake venv directory with a recognisable interpreter."""
|
|
167
|
+
venv = parent / ".venv"
|
|
168
|
+
venv.mkdir()
|
|
169
|
+
if sys.platform == "win32":
|
|
170
|
+
scripts = venv / "Scripts"
|
|
171
|
+
scripts.mkdir()
|
|
172
|
+
(scripts / "python.exe").write_text("fake", encoding="utf-8")
|
|
173
|
+
else:
|
|
174
|
+
bin_dir = venv / "bin"
|
|
175
|
+
bin_dir.mkdir()
|
|
176
|
+
(bin_dir / "python").write_text("fake", encoding="utf-8")
|
|
177
|
+
return venv
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_auto_detect_venv_finds_dotvenv(tmp_path):
|
|
181
|
+
"""Detects .venv/ in the given directory."""
|
|
182
|
+
_make_fake_venv(tmp_path)
|
|
183
|
+
result = auto_detect_venv(tmp_path)
|
|
184
|
+
assert result is not None
|
|
185
|
+
assert result.name == ".venv"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_auto_detect_venv_finds_venv(tmp_path):
|
|
189
|
+
"""Detects venv/ when .venv/ is absent."""
|
|
190
|
+
venv = tmp_path / "venv"
|
|
191
|
+
venv.mkdir()
|
|
192
|
+
if sys.platform == "win32":
|
|
193
|
+
scripts = venv / "Scripts"
|
|
194
|
+
scripts.mkdir()
|
|
195
|
+
(scripts / "python.exe").write_text("fake", encoding="utf-8")
|
|
196
|
+
else:
|
|
197
|
+
bin_dir = venv / "bin"
|
|
198
|
+
bin_dir.mkdir()
|
|
199
|
+
(bin_dir / "python").write_text("fake", encoding="utf-8")
|
|
200
|
+
|
|
201
|
+
result = auto_detect_venv(tmp_path)
|
|
202
|
+
assert result is not None
|
|
203
|
+
assert result.name == "venv"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_auto_detect_venv_prefers_dotvenv(tmp_path):
|
|
207
|
+
""".venv/ takes priority over venv/."""
|
|
208
|
+
_make_fake_venv(tmp_path)
|
|
209
|
+
venv = tmp_path / "venv"
|
|
210
|
+
venv.mkdir()
|
|
211
|
+
if sys.platform == "win32":
|
|
212
|
+
(venv / "Scripts").mkdir()
|
|
213
|
+
(venv / "Scripts" / "python.exe").write_text("fake", encoding="utf-8")
|
|
214
|
+
else:
|
|
215
|
+
(venv / "bin").mkdir()
|
|
216
|
+
(venv / "bin" / "python").write_text("fake", encoding="utf-8")
|
|
217
|
+
|
|
218
|
+
result = auto_detect_venv(tmp_path)
|
|
219
|
+
assert result is not None
|
|
220
|
+
assert result.name == ".venv"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_auto_detect_venv_returns_none_when_absent(tmp_path):
|
|
224
|
+
"""Returns None when no venv directory exists."""
|
|
225
|
+
assert auto_detect_venv(tmp_path) is None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_auto_detect_venv_ignores_dir_without_interpreter(tmp_path):
|
|
229
|
+
"""A .venv/ directory without an interpreter is ignored."""
|
|
230
|
+
(tmp_path / ".venv").mkdir()
|
|
231
|
+
assert auto_detect_venv(tmp_path) is None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# setup_environment
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_setup_environment_explicit_python():
|
|
240
|
+
"""Explicit --python takes effect and returns an env_tag."""
|
|
241
|
+
tag = setup_environment(sys.executable)
|
|
242
|
+
assert tag is not None
|
|
243
|
+
assert len(tag) == 8
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_setup_environment_no_venv_returns_none(tmp_path):
|
|
247
|
+
"""No venv, no --python → returns None (no injection)."""
|
|
248
|
+
tag = setup_environment(None, cwd=tmp_path)
|
|
249
|
+
assert tag is None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_setup_environment_auto_detects(tmp_path, monkeypatch):
|
|
253
|
+
"""Auto-detects .venv/ and returns an env_tag when using real interpreter."""
|
|
254
|
+
# Point the fake venv at the real interpreter so subprocess works
|
|
255
|
+
venv = tmp_path / ".venv"
|
|
256
|
+
venv.mkdir()
|
|
257
|
+
real_exe = Path(sys.executable)
|
|
258
|
+
if sys.platform == "win32":
|
|
259
|
+
scripts = venv / "Scripts"
|
|
260
|
+
scripts.mkdir()
|
|
261
|
+
target = scripts / "python.exe"
|
|
262
|
+
else:
|
|
263
|
+
bin_dir = venv / "bin"
|
|
264
|
+
bin_dir.mkdir()
|
|
265
|
+
target = bin_dir / "python"
|
|
266
|
+
|
|
267
|
+
# Create a symlink (or copy) so the interpreter actually works
|
|
268
|
+
try:
|
|
269
|
+
target.symlink_to(real_exe)
|
|
270
|
+
except OSError:
|
|
271
|
+
# Symlinks may require privileges on Windows; skip test
|
|
272
|
+
pytest.skip("Cannot create symlink to Python interpreter")
|
|
273
|
+
|
|
274
|
+
tag = setup_environment(None, cwd=tmp_path)
|
|
275
|
+
assert tag is not None
|
|
276
|
+
assert len(tag) == 8
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{libcontext-0.3.0 → libcontext-0.4.0}/docs/adr/001-progressive-disclosure-over-always-on-context.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|