libcontext 0.7.1__tar.gz → 0.7.4__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.7.1 → libcontext-0.7.4}/PKG-INFO +1 -1
- {libcontext-0.7.1 → libcontext-0.7.4}/pyproject.toml +1 -1
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/_envsetup.py +122 -179
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/cli.py +2 -1
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/collector.py +113 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/mcp_server.py +6 -3
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_envsetup.py +37 -83
- {libcontext-0.7.1 → libcontext-0.7.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/.github/workflows/ci.yml +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/.github/workflows/release.yml +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/.gitignore +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/CHANGELOG.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/CONTRIBUTING.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/DEPENDENCIES.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/LICENSE +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/README.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/SECURITY.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/docs/adr/002-skill-plus-cli-as-primary-integration.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/docs/adr/004-ast-only-inspection.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/docs/adr/README.md +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/__init__.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/_security.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/cache.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/config.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/diff.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/exceptions.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/inspector.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/models.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/py.typed +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/renderer.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/__init__.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_cache.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_cli.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_cli_mcp_parity.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_collector.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_config.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_diff.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_inspector.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_mcp_server.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_models.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_renderer.py +0 -0
- {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: libcontext
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.4
|
|
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
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
"""Environment setup — resolve
|
|
1
|
+
"""Environment setup — resolve a target Python environment.
|
|
2
2
|
|
|
3
3
|
When libcontext is installed globally (e.g. via ``uv tool install``), it
|
|
4
4
|
runs inside its own isolated venv and cannot see packages from a project's
|
|
5
5
|
``.venv``. This module auto-detects a project venv in the current working
|
|
6
|
-
directory, or accepts an explicit ``--python`` override, and
|
|
7
|
-
target
|
|
8
|
-
|
|
6
|
+
directory, or accepts an explicit ``--python`` override, and resolves the
|
|
7
|
+
target interpreter. Package discovery is then delegated to the target
|
|
8
|
+
interpreter via subprocess (see :func:`query_target_package`), keeping the
|
|
9
|
+
tool process free from cross-version import contamination.
|
|
9
10
|
|
|
10
11
|
Detection priority:
|
|
11
12
|
1. Explicit ``--python`` argument → use that environment.
|
|
@@ -15,19 +16,15 @@ Detection priority:
|
|
|
15
16
|
5. ``.venv/`` or ``venv/`` in CWD → use the detected venv.
|
|
16
17
|
6. ``uv`` fallback: if CWD has ``pyproject.toml``, query ``uv`` for the
|
|
17
18
|
project interpreter.
|
|
18
|
-
7. Neither → use the current process's environment (no
|
|
19
|
+
7. Neither → use the current process's environment (no delegation).
|
|
19
20
|
"""
|
|
20
21
|
|
|
21
22
|
from __future__ import annotations
|
|
22
23
|
|
|
23
|
-
import importlib
|
|
24
24
|
import json
|
|
25
25
|
import logging
|
|
26
26
|
import os
|
|
27
27
|
import subprocess
|
|
28
|
-
import sys
|
|
29
|
-
from collections.abc import Generator
|
|
30
|
-
from contextlib import contextmanager
|
|
31
28
|
from pathlib import Path
|
|
32
29
|
|
|
33
30
|
from .exceptions import EnvironmentSetupError
|
|
@@ -47,6 +44,54 @@ _INTERPRETER_CANDIDATES = (
|
|
|
47
44
|
)
|
|
48
45
|
|
|
49
46
|
|
|
47
|
+
# Script executed in the *target* interpreter to discover a package.
|
|
48
|
+
# Accepts the package name as sys.argv[1]. Returns a JSON object with
|
|
49
|
+
# path, version, summary, and installed package names for suggestions.
|
|
50
|
+
_PACKAGE_DISCOVERY_SCRIPT = """\
|
|
51
|
+
import importlib.metadata, importlib.util, json, sys
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
name = sys.argv[1]
|
|
55
|
+
result = {"path": None, "version": None, "summary": None, "installed": []}
|
|
56
|
+
|
|
57
|
+
# Locate the package source via find_spec
|
|
58
|
+
try:
|
|
59
|
+
spec = importlib.util.find_spec(name)
|
|
60
|
+
except (ModuleNotFoundError, ValueError, AttributeError):
|
|
61
|
+
spec = None
|
|
62
|
+
|
|
63
|
+
if spec is not None:
|
|
64
|
+
if spec.origin and spec.origin != "frozen":
|
|
65
|
+
origin = Path(spec.origin)
|
|
66
|
+
result["path"] = str(origin.parent if origin.name == "__init__.py" else origin)
|
|
67
|
+
elif spec.submodule_search_locations:
|
|
68
|
+
locs = list(spec.submodule_search_locations)
|
|
69
|
+
if locs:
|
|
70
|
+
result["path"] = locs[0]
|
|
71
|
+
|
|
72
|
+
# Retrieve metadata (version + summary)
|
|
73
|
+
try:
|
|
74
|
+
meta = importlib.metadata.metadata(name)
|
|
75
|
+
result["version"] = meta.get("Version")
|
|
76
|
+
result["summary"] = meta.get("Summary")
|
|
77
|
+
except importlib.metadata.PackageNotFoundError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
# Collect installed package names for typo suggestions
|
|
81
|
+
installed = set()
|
|
82
|
+
for dist in importlib.metadata.distributions():
|
|
83
|
+
dn = dist.metadata.get("Name")
|
|
84
|
+
if dn:
|
|
85
|
+
installed.add(dn)
|
|
86
|
+
norm = dn.replace("-", "_").lower()
|
|
87
|
+
if norm != dn:
|
|
88
|
+
installed.add(norm)
|
|
89
|
+
result["installed"] = sorted(installed)
|
|
90
|
+
|
|
91
|
+
print(json.dumps(result))
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
50
95
|
def _has_python_interpreter(venv_dir: Path) -> bool:
|
|
51
96
|
"""Check whether a directory contains a recognisable Python interpreter."""
|
|
52
97
|
return any((venv_dir / rel).is_file() for rel in _INTERPRETER_CANDIDATES)
|
|
@@ -160,21 +205,27 @@ def setup_environment(
|
|
|
160
205
|
python_arg: str | None = None,
|
|
161
206
|
*,
|
|
162
207
|
cwd: Path | None = None,
|
|
163
|
-
) -> str | None:
|
|
164
|
-
"""
|
|
208
|
+
) -> tuple[str | None, Path | None]:
|
|
209
|
+
"""Resolve the target environment for package discovery.
|
|
165
210
|
|
|
166
211
|
Implements the detection priority:
|
|
167
|
-
1. Explicit *python_arg* →
|
|
168
|
-
2. Auto-detected venv in *cwd* →
|
|
169
|
-
3. Neither →
|
|
212
|
+
1. Explicit *python_arg* → use that environment.
|
|
213
|
+
2. Auto-detected venv in *cwd* → use it.
|
|
214
|
+
3. Neither → use the current process environment.
|
|
215
|
+
|
|
216
|
+
Returns the resolved interpreter path so that callers can delegate
|
|
217
|
+
package discovery to the target interpreter via subprocess, avoiding
|
|
218
|
+
cross-version ``importlib`` contamination when the tool and target
|
|
219
|
+
run different Python versions.
|
|
170
220
|
|
|
171
221
|
Args:
|
|
172
222
|
python_arg: Explicit ``--python`` value, or *None*.
|
|
173
223
|
cwd: Working directory for auto-detection (defaults to CWD).
|
|
174
224
|
|
|
175
225
|
Returns:
|
|
176
|
-
|
|
177
|
-
was
|
|
226
|
+
A ``(env_tag, target_python)`` tuple. Both are *None* when no
|
|
227
|
+
external environment was detected (i.e. the current process
|
|
228
|
+
environment is used).
|
|
178
229
|
|
|
179
230
|
Raises:
|
|
180
231
|
EnvironmentSetupError: If an explicit *python_arg* is invalid.
|
|
@@ -187,122 +238,44 @@ def setup_environment(
|
|
|
187
238
|
target = str(detected)
|
|
188
239
|
|
|
189
240
|
if target is None:
|
|
190
|
-
return None
|
|
241
|
+
return None, None
|
|
191
242
|
|
|
192
|
-
# Resolve once, reuse for both injection and tag computation
|
|
193
243
|
python_exe = resolve_python_executable(target)
|
|
194
|
-
target_paths = get_target_sys_path(python_exe)
|
|
195
|
-
|
|
196
|
-
current_set = set(sys.path)
|
|
197
|
-
new_paths = [p for p in target_paths if p and p not in current_set]
|
|
198
|
-
|
|
199
|
-
sys.path[:0] = new_paths
|
|
200
|
-
importlib.invalidate_caches()
|
|
201
244
|
|
|
202
245
|
logger.debug(
|
|
203
|
-
"
|
|
246
|
+
"Resolved target environment '%s' → '%s'",
|
|
204
247
|
target,
|
|
205
|
-
|
|
248
|
+
python_exe,
|
|
206
249
|
)
|
|
207
250
|
|
|
208
|
-
return _env_tag_from_resolved(python_exe)
|
|
251
|
+
return _env_tag_from_resolved(python_exe), python_exe
|
|
209
252
|
|
|
210
253
|
|
|
211
|
-
def
|
|
212
|
-
|
|
254
|
+
def query_target_package(
|
|
255
|
+
python_exe: Path,
|
|
256
|
+
package_name: str,
|
|
257
|
+
) -> dict[str, object]:
|
|
258
|
+
"""Discover a package by running the target interpreter.
|
|
213
259
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
Args:
|
|
220
|
-
python_arg: Path to a Python interpreter or venv directory.
|
|
221
|
-
|
|
222
|
-
Returns:
|
|
223
|
-
Resolved absolute path to the Python executable.
|
|
224
|
-
|
|
225
|
-
Raises:
|
|
226
|
-
EnvironmentSetupError: If the path does not exist or no
|
|
227
|
-
interpreter can be found.
|
|
228
|
-
"""
|
|
229
|
-
path = Path(python_arg)
|
|
230
|
-
|
|
231
|
-
if not path.exists():
|
|
232
|
-
raise EnvironmentSetupError(
|
|
233
|
-
python_arg,
|
|
234
|
-
f"path does not exist: {python_arg}",
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
# Direct path to an executable
|
|
238
|
-
if path.is_file():
|
|
239
|
-
return path.resolve()
|
|
240
|
-
|
|
241
|
-
# Directory — probe for interpreter
|
|
242
|
-
if path.is_dir():
|
|
243
|
-
for candidate in (path / rel for rel in _INTERPRETER_CANDIDATES):
|
|
244
|
-
if candidate.is_file():
|
|
245
|
-
logger.debug(
|
|
246
|
-
"Resolved venv directory '%s' to interpreter '%s'",
|
|
247
|
-
python_arg,
|
|
248
|
-
candidate,
|
|
249
|
-
)
|
|
250
|
-
return candidate.resolve()
|
|
251
|
-
|
|
252
|
-
raise EnvironmentSetupError(
|
|
253
|
-
python_arg,
|
|
254
|
-
f"no Python interpreter found in directory: {python_arg}. "
|
|
255
|
-
f"Expected Scripts/python.exe (Windows) or bin/python (Unix).",
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
raise EnvironmentSetupError(
|
|
259
|
-
python_arg,
|
|
260
|
-
f"path is neither a file nor a directory: {python_arg}",
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
def get_target_sys_path(python_exe: Path) -> list[str]:
|
|
265
|
-
"""Query a Python interpreter for its non-stdlib ``sys.path`` entries.
|
|
266
|
-
|
|
267
|
-
Runs the interpreter in a subprocess with a short timeout to
|
|
268
|
-
extract site-packages and user-added paths. Stdlib paths are
|
|
269
|
-
excluded to prevent cross-version import contamination when the
|
|
270
|
-
tool interpreter differs from the target (e.g. tool on 3.13,
|
|
271
|
-
target venv on 3.11).
|
|
260
|
+
Delegates ``importlib.util.find_spec`` and
|
|
261
|
+
``importlib.metadata.metadata`` to *python_exe* via subprocess,
|
|
262
|
+
keeping the tool process free from cross-version import
|
|
263
|
+
contamination.
|
|
272
264
|
|
|
273
265
|
Args:
|
|
274
266
|
python_exe: Absolute path to the target Python executable.
|
|
267
|
+
package_name: Package import name (e.g. ``openai``).
|
|
275
268
|
|
|
276
269
|
Returns:
|
|
277
|
-
|
|
270
|
+
Dict with keys ``path`` (str | None), ``version`` (str | None),
|
|
271
|
+
``summary`` (str | None), ``installed`` (list[str]).
|
|
278
272
|
|
|
279
273
|
Raises:
|
|
280
274
|
EnvironmentSetupError: If the subprocess fails or times out.
|
|
281
275
|
"""
|
|
282
|
-
# Filter out stdlib paths from the target interpreter's sys.path.
|
|
283
|
-
# When the target runs a different Python version from the tool,
|
|
284
|
-
# injecting stdlib paths causes C-extension mismatches (e.g.
|
|
285
|
-
# Python 3.13 loading csv.py from 3.11 stdlib → _csv ImportError).
|
|
286
|
-
script = (
|
|
287
|
-
"import json, os, sys\n"
|
|
288
|
-
"base = os.path.realpath(sys.base_prefix)\n"
|
|
289
|
-
"prefix = os.path.realpath(sys.prefix)\n"
|
|
290
|
-
"in_venv = prefix != base\n"
|
|
291
|
-
"def _under(p, root):\n"
|
|
292
|
-
" return p == root or p.startswith(root + os.sep)\n"
|
|
293
|
-
"paths = [\n"
|
|
294
|
-
" p for p in sys.path\n"
|
|
295
|
-
" if p and not (\n"
|
|
296
|
-
" in_venv\n"
|
|
297
|
-
" and _under(os.path.realpath(p), base)\n"
|
|
298
|
-
" and not _under(os.path.realpath(p), prefix)\n"
|
|
299
|
-
" )\n"
|
|
300
|
-
"]\n"
|
|
301
|
-
"print(json.dumps(paths))\n"
|
|
302
|
-
)
|
|
303
276
|
try:
|
|
304
277
|
result = subprocess.run(
|
|
305
|
-
[str(python_exe), "-c",
|
|
278
|
+
[str(python_exe), "-c", _PACKAGE_DISCOVERY_SCRIPT, package_name],
|
|
306
279
|
capture_output=True,
|
|
307
280
|
text=True,
|
|
308
281
|
timeout=_SUBPROCESS_TIMEOUT_SECONDS,
|
|
@@ -322,100 +295,70 @@ def get_target_sys_path(python_exe: Path) -> list[str]:
|
|
|
322
295
|
stderr = result.stderr.strip()[:200]
|
|
323
296
|
raise EnvironmentSetupError(
|
|
324
297
|
str(python_exe),
|
|
325
|
-
f"
|
|
298
|
+
f"package discovery failed (exit {result.returncode}): {stderr}",
|
|
326
299
|
)
|
|
327
300
|
|
|
328
301
|
try:
|
|
329
|
-
|
|
302
|
+
data = json.loads(result.stdout)
|
|
330
303
|
except json.JSONDecodeError as exc:
|
|
331
304
|
raise EnvironmentSetupError(
|
|
332
305
|
str(python_exe),
|
|
333
|
-
f"cannot parse
|
|
306
|
+
f"cannot parse discovery output: {exc}",
|
|
334
307
|
) from exc
|
|
335
308
|
|
|
336
|
-
|
|
337
|
-
raise EnvironmentSetupError(
|
|
338
|
-
str(python_exe),
|
|
339
|
-
"sys.path output is not a list",
|
|
340
|
-
)
|
|
309
|
+
return data # type: ignore[no-any-return]
|
|
341
310
|
|
|
342
|
-
return [str(p) for p in paths]
|
|
343
311
|
|
|
312
|
+
def resolve_python_executable(python_arg: str) -> Path:
|
|
313
|
+
"""Resolve a user-supplied path to a Python executable.
|
|
344
314
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
Resolves the Python executable, queries its ``sys.path``, prepends
|
|
350
|
-
the target paths to the current ``sys.path``, and invalidates
|
|
351
|
-
:mod:`importlib` caches so that :func:`importlib.util.find_spec`
|
|
352
|
-
and :func:`importlib.metadata.distributions` pick up the target
|
|
353
|
-
environment's packages.
|
|
354
|
-
|
|
355
|
-
The original ``sys.path`` is restored on exit.
|
|
315
|
+
Accepts either a direct path to a Python interpreter or a venv
|
|
316
|
+
directory. When given a directory, probes for the interpreter
|
|
317
|
+
in the standard locations (``Scripts/python.exe`` on Windows,
|
|
318
|
+
``bin/python`` on Unix).
|
|
356
319
|
|
|
357
320
|
Args:
|
|
358
321
|
python_arg: Path to a Python interpreter or venv directory.
|
|
359
322
|
|
|
360
|
-
|
|
361
|
-
|
|
323
|
+
Returns:
|
|
324
|
+
Resolved absolute path to the Python executable.
|
|
362
325
|
|
|
363
326
|
Raises:
|
|
364
|
-
EnvironmentSetupError: If the
|
|
365
|
-
|
|
327
|
+
EnvironmentSetupError: If the path does not exist or no
|
|
328
|
+
interpreter can be found.
|
|
366
329
|
"""
|
|
367
|
-
|
|
368
|
-
target_paths = get_target_sys_path(python_exe)
|
|
369
|
-
|
|
370
|
-
# Filter to paths that actually exist and aren't already present
|
|
371
|
-
current_set = set(sys.path)
|
|
372
|
-
new_paths = [p for p in target_paths if p and p not in current_set]
|
|
373
|
-
|
|
374
|
-
saved_path = sys.path.copy()
|
|
375
|
-
try:
|
|
376
|
-
# Prepend target paths so they take priority
|
|
377
|
-
sys.path[:0] = new_paths
|
|
378
|
-
importlib.invalidate_caches()
|
|
330
|
+
path = Path(python_arg)
|
|
379
331
|
|
|
380
|
-
|
|
381
|
-
|
|
332
|
+
if not path.exists():
|
|
333
|
+
raise EnvironmentSetupError(
|
|
382
334
|
python_arg,
|
|
383
|
-
|
|
335
|
+
f"path does not exist: {python_arg}",
|
|
384
336
|
)
|
|
385
|
-
yield python_exe
|
|
386
|
-
finally:
|
|
387
|
-
sys.path[:] = saved_path
|
|
388
|
-
importlib.invalidate_caches()
|
|
389
|
-
logger.debug("Restored original sys.path")
|
|
390
|
-
|
|
391
337
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
Unlike :func:`activate_environment`, this does **not** restore
|
|
396
|
-
``sys.path`` afterward. Suitable for CLI entry points where the
|
|
397
|
-
process exits after the command completes.
|
|
398
|
-
|
|
399
|
-
Args:
|
|
400
|
-
python_arg: Path to a Python interpreter or venv directory.
|
|
401
|
-
|
|
402
|
-
Raises:
|
|
403
|
-
EnvironmentSetupError: If the environment cannot be resolved
|
|
404
|
-
or queried.
|
|
405
|
-
"""
|
|
406
|
-
python_exe = resolve_python_executable(python_arg)
|
|
407
|
-
target_paths = get_target_sys_path(python_exe)
|
|
338
|
+
# Direct path to an executable
|
|
339
|
+
if path.is_file():
|
|
340
|
+
return path.resolve()
|
|
408
341
|
|
|
409
|
-
|
|
410
|
-
|
|
342
|
+
# Directory — probe for interpreter
|
|
343
|
+
if path.is_dir():
|
|
344
|
+
for candidate in (path / rel for rel in _INTERPRETER_CANDIDATES):
|
|
345
|
+
if candidate.is_file():
|
|
346
|
+
logger.debug(
|
|
347
|
+
"Resolved venv directory '%s' to interpreter '%s'",
|
|
348
|
+
python_arg,
|
|
349
|
+
candidate,
|
|
350
|
+
)
|
|
351
|
+
return candidate.resolve()
|
|
411
352
|
|
|
412
|
-
|
|
413
|
-
|
|
353
|
+
raise EnvironmentSetupError(
|
|
354
|
+
python_arg,
|
|
355
|
+
f"no Python interpreter found in directory: {python_arg}. "
|
|
356
|
+
f"Expected Scripts/python.exe (Windows) or bin/python (Unix).",
|
|
357
|
+
)
|
|
414
358
|
|
|
415
|
-
|
|
416
|
-
"Injected environment '%s': added %d paths",
|
|
359
|
+
raise EnvironmentSetupError(
|
|
417
360
|
python_arg,
|
|
418
|
-
|
|
361
|
+
f"path is neither a file nor a directory: {python_arg}",
|
|
419
362
|
)
|
|
420
363
|
|
|
421
364
|
|
|
@@ -259,7 +259,7 @@ def inspect(
|
|
|
259
259
|
from ._envsetup import setup_environment
|
|
260
260
|
|
|
261
261
|
try:
|
|
262
|
-
_env_tag = setup_environment(python_env)
|
|
262
|
+
_env_tag, _target_python = setup_environment(python_env)
|
|
263
263
|
except EnvironmentSetupError as exc:
|
|
264
264
|
click.echo(f"Error: {exc}", err=True)
|
|
265
265
|
sys.exit(1)
|
|
@@ -278,6 +278,7 @@ def inspect(
|
|
|
278
278
|
config_override=config,
|
|
279
279
|
no_cache=no_cache,
|
|
280
280
|
env_tag=_env_tag,
|
|
281
|
+
target_python=_target_python,
|
|
281
282
|
)
|
|
282
283
|
except PackageNotFoundError as exc:
|
|
283
284
|
click.echo(f"Error: {exc}", err=True)
|
|
@@ -636,6 +636,104 @@ def _walk_package(
|
|
|
636
636
|
return modules
|
|
637
637
|
|
|
638
638
|
|
|
639
|
+
# ---------------------------------------------------------------------------
|
|
640
|
+
# Subprocess-based package discovery
|
|
641
|
+
# ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _find_stub_package_fs(package_name: str, pkg_path: Path) -> Path | None:
|
|
645
|
+
"""Find a stub package via the filesystem, without importlib.metadata.
|
|
646
|
+
|
|
647
|
+
Derives the site-packages directory from *pkg_path* (its parent) and
|
|
648
|
+
checks for ``<name>-stubs/`` or ``<norm_name>-stubs/`` directories.
|
|
649
|
+
This works correctly in cross-venv scenarios where the tool process
|
|
650
|
+
cannot see the target's installed distributions.
|
|
651
|
+
|
|
652
|
+
Args:
|
|
653
|
+
package_name: The importable package name (e.g. ``openai``).
|
|
654
|
+
pkg_path: Resolved path to the package source directory.
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
Path to the stub package directory, or ``None`` if not found.
|
|
658
|
+
"""
|
|
659
|
+
norm_name = package_name.replace("-", "_").lower()
|
|
660
|
+
site_packages = pkg_path.parent
|
|
661
|
+
|
|
662
|
+
# Check in priority order: <name>-stubs, <norm_name>-stubs
|
|
663
|
+
for suffix in (f"{package_name}-stubs", f"{norm_name}-stubs"):
|
|
664
|
+
candidate = site_packages / suffix
|
|
665
|
+
if candidate.is_dir():
|
|
666
|
+
logger.debug("Found stub package at '%s'", candidate)
|
|
667
|
+
return candidate
|
|
668
|
+
|
|
669
|
+
return None
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _resolve_via_target(
|
|
673
|
+
package_name: str,
|
|
674
|
+
python_exe: Path,
|
|
675
|
+
) -> tuple[Path, dict[str, str | None], Path | None]:
|
|
676
|
+
"""Discover a package by querying the target interpreter.
|
|
677
|
+
|
|
678
|
+
Delegates ``importlib.util.find_spec`` and metadata retrieval to
|
|
679
|
+
*python_exe* via subprocess, avoiding cross-version contamination.
|
|
680
|
+
|
|
681
|
+
Args:
|
|
682
|
+
package_name: The importable package name.
|
|
683
|
+
python_exe: Path to the target Python interpreter.
|
|
684
|
+
|
|
685
|
+
Returns:
|
|
686
|
+
A ``(pkg_path, metadata, stub_path)`` tuple.
|
|
687
|
+
|
|
688
|
+
Raises:
|
|
689
|
+
PackageNotFoundError: If the package is not found in the target.
|
|
690
|
+
"""
|
|
691
|
+
from ._envsetup import query_target_package
|
|
692
|
+
|
|
693
|
+
data = query_target_package(python_exe, package_name)
|
|
694
|
+
|
|
695
|
+
pkg_path_str: str | None = data.get("path") # type: ignore[assignment]
|
|
696
|
+
installed: list[str] = data.get("installed", []) # type: ignore[assignment]
|
|
697
|
+
|
|
698
|
+
if pkg_path_str is None:
|
|
699
|
+
suggestions = difflib.get_close_matches(
|
|
700
|
+
package_name,
|
|
701
|
+
installed,
|
|
702
|
+
n=_SUGGESTION_MAX,
|
|
703
|
+
cutoff=_SUGGESTION_CUTOFF,
|
|
704
|
+
)
|
|
705
|
+
raise PackageNotFoundError(package_name, suggestions=suggestions)
|
|
706
|
+
|
|
707
|
+
pkg_path = Path(pkg_path_str)
|
|
708
|
+
metadata: dict[str, str | None] = {
|
|
709
|
+
"version": data.get("version"), # type: ignore[dict-item]
|
|
710
|
+
"summary": data.get("summary"), # type: ignore[dict-item]
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
# Check for compiled extension and stubs.
|
|
714
|
+
# Use filesystem-based detection: the target's site-packages is the
|
|
715
|
+
# parent of pkg_path, so we look for <name>-stubs/ there directly.
|
|
716
|
+
# This avoids using the tool's importlib.metadata which cannot see
|
|
717
|
+
# packages installed only in the target venv.
|
|
718
|
+
stub_path = _find_stub_package_fs(package_name, pkg_path)
|
|
719
|
+
if _is_compiled_extension(pkg_path):
|
|
720
|
+
if stub_path:
|
|
721
|
+
pkg_path = stub_path
|
|
722
|
+
stub_path = None
|
|
723
|
+
logger.info(
|
|
724
|
+
"Package '%s' has no Python source; using stubs as primary",
|
|
725
|
+
package_name,
|
|
726
|
+
)
|
|
727
|
+
elif stub_path:
|
|
728
|
+
logger.info(
|
|
729
|
+
"Stub package discovered for '%s' at %s",
|
|
730
|
+
package_name,
|
|
731
|
+
stub_path,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
return pkg_path, metadata, stub_path
|
|
735
|
+
|
|
736
|
+
|
|
639
737
|
# ---------------------------------------------------------------------------
|
|
640
738
|
# Public API
|
|
641
739
|
# ---------------------------------------------------------------------------
|
|
@@ -649,6 +747,7 @@ def collect_package(
|
|
|
649
747
|
config_override: LibcontextConfig | None = None,
|
|
650
748
|
no_cache: bool = False,
|
|
651
749
|
env_tag: str | None = None,
|
|
750
|
+
target_python: Path | None = None,
|
|
652
751
|
) -> PackageInfo:
|
|
653
752
|
"""Collect complete API information for a Python package.
|
|
654
753
|
|
|
@@ -663,6 +762,11 @@ def collect_package(
|
|
|
663
762
|
no_cache: Skip the disk cache (force fresh AST collection).
|
|
664
763
|
env_tag: Environment identifier for cache namespacing (from
|
|
665
764
|
``--python``).
|
|
765
|
+
target_python: Resolved path to the target Python interpreter.
|
|
766
|
+
When provided, package discovery is delegated to this
|
|
767
|
+
interpreter via subprocess instead of using in-process
|
|
768
|
+
``importlib``. This prevents cross-version contamination
|
|
769
|
+
when the tool runs a different Python than the target.
|
|
666
770
|
|
|
667
771
|
Returns:
|
|
668
772
|
:class:`~libcontext.models.PackageInfo` with all collected data.
|
|
@@ -680,6 +784,15 @@ def collect_package(
|
|
|
680
784
|
pkg_name = path.name if path.is_dir() else path.stem
|
|
681
785
|
metadata: dict[str, str | None] = {}
|
|
682
786
|
logger.debug("Resolved '%s' as local path: %s", package_name, pkg_path)
|
|
787
|
+
elif target_python is not None:
|
|
788
|
+
# Delegate discovery to the target interpreter to avoid
|
|
789
|
+
# cross-version importlib contamination.
|
|
790
|
+
pkg_path, metadata, stub_path = _resolve_via_target(
|
|
791
|
+
package_name,
|
|
792
|
+
target_python,
|
|
793
|
+
)
|
|
794
|
+
pkg_name = package_name
|
|
795
|
+
logger.debug("Resolved '%s' via target interpreter: %s", package_name, pkg_path)
|
|
683
796
|
else:
|
|
684
797
|
pkg_path_resolved = find_package_path(package_name)
|
|
685
798
|
|
|
@@ -24,6 +24,7 @@ import dataclasses
|
|
|
24
24
|
import json
|
|
25
25
|
import logging
|
|
26
26
|
from functools import lru_cache
|
|
27
|
+
from pathlib import Path
|
|
27
28
|
|
|
28
29
|
from mcp.server.fastmcp import FastMCP
|
|
29
30
|
|
|
@@ -64,8 +65,9 @@ mcp = FastMCP(
|
|
|
64
65
|
|
|
65
66
|
_CACHE_SIZE = 32
|
|
66
67
|
|
|
67
|
-
# Set by main() at startup; used by _collect_cached for cache
|
|
68
|
+
# Set by main() at startup; used by _collect_cached for cache/discovery.
|
|
68
69
|
_active_env_tag: str | None = None
|
|
70
|
+
_active_target_python: Path | None = None
|
|
69
71
|
|
|
70
72
|
|
|
71
73
|
@lru_cache(maxsize=_CACHE_SIZE)
|
|
@@ -76,6 +78,7 @@ def _collect_cached(package_name: str, include_private: bool = False) -> Package
|
|
|
76
78
|
include_private=include_private,
|
|
77
79
|
include_readme=False,
|
|
78
80
|
env_tag=_active_env_tag,
|
|
81
|
+
target_python=_active_target_python,
|
|
79
82
|
)
|
|
80
83
|
|
|
81
84
|
|
|
@@ -317,11 +320,11 @@ def main() -> None:
|
|
|
317
320
|
elif os.environ.get("LIBCONTEXT_PYTHON"):
|
|
318
321
|
python_env = os.environ["LIBCONTEXT_PYTHON"]
|
|
319
322
|
|
|
320
|
-
global _active_env_tag
|
|
323
|
+
global _active_env_tag, _active_target_python
|
|
321
324
|
|
|
322
325
|
from ._envsetup import setup_environment
|
|
323
326
|
|
|
324
|
-
_active_env_tag = setup_environment(python_env)
|
|
327
|
+
_active_env_tag, _active_target_python = setup_environment(python_env)
|
|
325
328
|
|
|
326
329
|
mcp.run()
|
|
327
330
|
|
|
@@ -2,18 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import os
|
|
6
5
|
import sys
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
|
|
9
8
|
import pytest
|
|
10
9
|
|
|
11
10
|
from libcontext._envsetup import (
|
|
12
|
-
activate_environment,
|
|
13
11
|
auto_detect_venv,
|
|
14
12
|
env_tag_for_path,
|
|
15
|
-
|
|
16
|
-
inject_target_environment,
|
|
13
|
+
query_target_package,
|
|
17
14
|
resolve_python_executable,
|
|
18
15
|
setup_environment,
|
|
19
16
|
)
|
|
@@ -59,19 +56,32 @@ def test_resolve_empty_directory_raises(tmp_path):
|
|
|
59
56
|
|
|
60
57
|
|
|
61
58
|
# ---------------------------------------------------------------------------
|
|
62
|
-
#
|
|
59
|
+
# query_target_package
|
|
63
60
|
# ---------------------------------------------------------------------------
|
|
64
61
|
|
|
65
62
|
|
|
66
|
-
def
|
|
67
|
-
"""
|
|
68
|
-
|
|
69
|
-
assert
|
|
70
|
-
assert
|
|
71
|
-
assert
|
|
63
|
+
def test_query_target_package_finds_installed():
|
|
64
|
+
"""Discovers a package known to be installed in the current interpreter."""
|
|
65
|
+
data = query_target_package(Path(sys.executable), "pytest")
|
|
66
|
+
assert data["path"] is not None
|
|
67
|
+
assert data["version"] is not None
|
|
68
|
+
assert isinstance(data["installed"], list)
|
|
69
|
+
assert len(data["installed"]) > 0
|
|
72
70
|
|
|
73
71
|
|
|
74
|
-
def
|
|
72
|
+
def test_query_target_package_missing_returns_null_path():
|
|
73
|
+
"""Returns null path for a package that does not exist."""
|
|
74
|
+
data = query_target_package(Path(sys.executable), "nonexistent_pkg_xyz")
|
|
75
|
+
assert data["path"] is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_query_target_package_returns_installed_names():
|
|
79
|
+
"""The installed list contains distribution names for suggestions."""
|
|
80
|
+
data = query_target_package(Path(sys.executable), "pytest")
|
|
81
|
+
assert "pytest" in data["installed"]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_query_target_package_bad_executable(tmp_path):
|
|
75
85
|
"""A non-Python executable raises EnvironmentSetupError."""
|
|
76
86
|
fake = tmp_path / "not_python"
|
|
77
87
|
fake.write_text("not a python interpreter", encoding="utf-8")
|
|
@@ -79,68 +89,7 @@ def test_get_target_sys_path_bad_executable(tmp_path):
|
|
|
79
89
|
fake.chmod(0o755)
|
|
80
90
|
|
|
81
91
|
with pytest.raises(EnvironmentSetupError):
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def test_get_target_sys_path_excludes_base_stdlib_in_venv():
|
|
86
|
-
"""Stdlib paths from the base interpreter are excluded when in a venv."""
|
|
87
|
-
paths = get_target_sys_path(Path(sys.executable))
|
|
88
|
-
|
|
89
|
-
base = os.path.realpath(sys.base_prefix)
|
|
90
|
-
prefix = os.path.realpath(sys.prefix)
|
|
91
|
-
if prefix == base:
|
|
92
|
-
pytest.skip("not running inside a venv")
|
|
93
|
-
|
|
94
|
-
# Paths under the base Python installation (stdlib, lib-dynload, etc.)
|
|
95
|
-
# must be excluded to prevent cross-version contamination.
|
|
96
|
-
# Paths under the venv prefix itself (e.g. site-packages) are kept.
|
|
97
|
-
for p in paths:
|
|
98
|
-
rp = os.path.realpath(p)
|
|
99
|
-
is_under_base = rp.startswith(base + os.sep) or rp == base
|
|
100
|
-
is_under_venv = rp.startswith(prefix + os.sep) or rp == prefix
|
|
101
|
-
if is_under_base and not is_under_venv:
|
|
102
|
-
pytest.fail(f"base-interpreter stdlib path leaked: {p}")
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
# ---------------------------------------------------------------------------
|
|
106
|
-
# inject_target_environment
|
|
107
|
-
# ---------------------------------------------------------------------------
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def test_inject_target_environment_adds_paths():
|
|
111
|
-
"""Injecting the current interpreter adds its paths to sys.path."""
|
|
112
|
-
original_len = len(sys.path)
|
|
113
|
-
# Inject the current interpreter (should be mostly a no-op since
|
|
114
|
-
# paths already overlap, but validates the mechanics)
|
|
115
|
-
inject_target_environment(sys.executable)
|
|
116
|
-
assert len(sys.path) >= original_len
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
# ---------------------------------------------------------------------------
|
|
120
|
-
# activate_environment (context manager)
|
|
121
|
-
# ---------------------------------------------------------------------------
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def test_activate_environment_restores_path():
|
|
125
|
-
"""sys.path is restored after the context manager exits."""
|
|
126
|
-
saved = sys.path.copy()
|
|
127
|
-
with activate_environment(sys.executable):
|
|
128
|
-
pass
|
|
129
|
-
assert sys.path == saved
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def test_activate_environment_restores_on_exception():
|
|
133
|
-
"""sys.path is restored even if an exception occurs."""
|
|
134
|
-
saved = sys.path.copy()
|
|
135
|
-
with pytest.raises(RuntimeError), activate_environment(sys.executable):
|
|
136
|
-
raise RuntimeError("boom")
|
|
137
|
-
assert sys.path == saved
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def test_activate_environment_bad_path():
|
|
141
|
-
"""EnvironmentSetupError propagates from the context manager."""
|
|
142
|
-
with pytest.raises(EnvironmentSetupError), activate_environment("/no/such/env"):
|
|
143
|
-
pass
|
|
92
|
+
query_target_package(fake, "anything")
|
|
144
93
|
|
|
145
94
|
|
|
146
95
|
# ---------------------------------------------------------------------------
|
|
@@ -291,22 +240,25 @@ def test_auto_detect_venv_ignores_dir_without_interpreter(tmp_path):
|
|
|
291
240
|
|
|
292
241
|
|
|
293
242
|
def test_setup_environment_explicit_python():
|
|
294
|
-
"""Explicit --python
|
|
295
|
-
tag = setup_environment(sys.executable)
|
|
243
|
+
"""Explicit --python returns (env_tag, target_python)."""
|
|
244
|
+
tag, target = setup_environment(sys.executable)
|
|
296
245
|
assert tag is not None
|
|
297
246
|
assert len(tag) == 8
|
|
247
|
+
assert target is not None
|
|
248
|
+
assert target.is_file()
|
|
298
249
|
|
|
299
250
|
|
|
300
251
|
@pytest.mark.usefixtures("_clean_venv_env")
|
|
301
252
|
def test_setup_environment_no_venv_returns_none(tmp_path):
|
|
302
|
-
"""No venv, no --python → returns None
|
|
303
|
-
tag = setup_environment(None, cwd=tmp_path)
|
|
253
|
+
"""No venv, no --python → returns (None, None)."""
|
|
254
|
+
tag, target = setup_environment(None, cwd=tmp_path)
|
|
304
255
|
assert tag is None
|
|
256
|
+
assert target is None
|
|
305
257
|
|
|
306
258
|
|
|
307
259
|
@pytest.mark.usefixtures("_clean_venv_env")
|
|
308
260
|
def test_setup_environment_auto_detects(tmp_path, monkeypatch):
|
|
309
|
-
"""Auto-detects .venv/ and returns
|
|
261
|
+
"""Auto-detects .venv/ and returns (env_tag, target_python)."""
|
|
310
262
|
# Point the fake venv at the real interpreter so subprocess works
|
|
311
263
|
venv = tmp_path / ".venv"
|
|
312
264
|
venv.mkdir()
|
|
@@ -314,19 +266,21 @@ def test_setup_environment_auto_detects(tmp_path, monkeypatch):
|
|
|
314
266
|
if sys.platform == "win32":
|
|
315
267
|
scripts = venv / "Scripts"
|
|
316
268
|
scripts.mkdir()
|
|
317
|
-
|
|
269
|
+
link = scripts / "python.exe"
|
|
318
270
|
else:
|
|
319
271
|
bin_dir = venv / "bin"
|
|
320
272
|
bin_dir.mkdir()
|
|
321
|
-
|
|
273
|
+
link = bin_dir / "python"
|
|
322
274
|
|
|
323
275
|
# Create a symlink (or copy) so the interpreter actually works
|
|
324
276
|
try:
|
|
325
|
-
|
|
277
|
+
link.symlink_to(real_exe)
|
|
326
278
|
except OSError:
|
|
327
279
|
# Symlinks may require privileges on Windows; skip test
|
|
328
280
|
pytest.skip("Cannot create symlink to Python interpreter")
|
|
329
281
|
|
|
330
|
-
tag = setup_environment(None, cwd=tmp_path)
|
|
282
|
+
tag, target = setup_environment(None, cwd=tmp_path)
|
|
331
283
|
assert tag is not None
|
|
332
284
|
assert len(tag) == 8
|
|
285
|
+
assert target is not None
|
|
286
|
+
assert target.is_file()
|
|
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
|
{libcontext-0.7.1 → libcontext-0.7.4}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|