libcontext 0.7.2__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.2 → libcontext-0.7.4}/PKG-INFO +1 -1
- {libcontext-0.7.2 → libcontext-0.7.4}/pyproject.toml +1 -1
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/_envsetup.py +115 -198
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/cli.py +2 -1
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/collector.py +113 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/mcp_server.py +6 -3
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_envsetup.py +37 -89
- {libcontext-0.7.2 → libcontext-0.7.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/.github/workflows/ci.yml +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/.github/workflows/release.yml +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/.gitignore +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/CHANGELOG.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/CONTRIBUTING.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/DEPENDENCIES.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/LICENSE +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/README.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/SECURITY.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/docs/adr/002-skill-plus-cli-as-primary-integration.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/docs/adr/004-ast-only-inspection.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/docs/adr/README.md +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/__init__.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/_security.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/cache.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/config.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/diff.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/exceptions.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/inspector.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/models.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/py.typed +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/renderer.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/__init__.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_cache.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_cli.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_cli_mcp_parity.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_collector.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_config.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_diff.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_inspector.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_mcp_server.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_models.py +0 -0
- {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_renderer.py +0 -0
- {libcontext-0.7.2 → 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
|
|
@@ -46,48 +43,52 @@ _INTERPRETER_CANDIDATES = (
|
|
|
46
43
|
Path("bin") / "python3", # Unix alternative
|
|
47
44
|
)
|
|
48
45
|
|
|
49
|
-
# Script executed in the *target* interpreter to collect site-packages
|
|
50
|
-
# directories. Kept as a module constant so it can be tested directly.
|
|
51
|
-
# Uses only stdlib modules guaranteed present in Python 3.9+.
|
|
52
|
-
_SITE_PACKAGES_SCRIPT = """\
|
|
53
|
-
import json, site, sys, os
|
|
54
46
|
|
|
55
|
-
|
|
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
56
|
|
|
57
|
-
#
|
|
58
|
-
# venv was created with --system-site-packages)
|
|
57
|
+
# Locate the package source via find_spec
|
|
59
58
|
try:
|
|
60
|
-
|
|
61
|
-
except AttributeError:
|
|
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:
|
|
62
78
|
pass
|
|
63
79
|
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
# diffing sys.path against site-packages — any path that is not a
|
|
77
|
-
# site-packages dir and not under sys.base_prefix is .pth-injected.
|
|
78
|
-
site_set = set(os.path.realpath(p) for p in paths)
|
|
79
|
-
base = os.path.realpath(sys.base_prefix)
|
|
80
|
-
for p in sys.path:
|
|
81
|
-
if not p:
|
|
82
|
-
continue
|
|
83
|
-
rp = os.path.realpath(p)
|
|
84
|
-
if rp in site_set:
|
|
85
|
-
continue
|
|
86
|
-
if rp == base or rp.startswith(base + os.sep):
|
|
87
|
-
continue
|
|
88
|
-
paths.append(p)
|
|
89
|
-
|
|
90
|
-
print(json.dumps(paths))
|
|
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))
|
|
91
92
|
"""
|
|
92
93
|
|
|
93
94
|
|
|
@@ -204,21 +205,27 @@ def setup_environment(
|
|
|
204
205
|
python_arg: str | None = None,
|
|
205
206
|
*,
|
|
206
207
|
cwd: Path | None = None,
|
|
207
|
-
) -> str | None:
|
|
208
|
-
"""
|
|
208
|
+
) -> tuple[str | None, Path | None]:
|
|
209
|
+
"""Resolve the target environment for package discovery.
|
|
209
210
|
|
|
210
211
|
Implements the detection priority:
|
|
211
|
-
1. Explicit *python_arg* →
|
|
212
|
-
2. Auto-detected venv in *cwd* →
|
|
213
|
-
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.
|
|
214
220
|
|
|
215
221
|
Args:
|
|
216
222
|
python_arg: Explicit ``--python`` value, or *None*.
|
|
217
223
|
cwd: Working directory for auto-detection (defaults to CWD).
|
|
218
224
|
|
|
219
225
|
Returns:
|
|
220
|
-
|
|
221
|
-
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).
|
|
222
229
|
|
|
223
230
|
Raises:
|
|
224
231
|
EnvironmentSetupError: If an explicit *python_arg* is invalid.
|
|
@@ -231,104 +238,44 @@ def setup_environment(
|
|
|
231
238
|
target = str(detected)
|
|
232
239
|
|
|
233
240
|
if target is None:
|
|
234
|
-
return None
|
|
241
|
+
return None, None
|
|
235
242
|
|
|
236
|
-
# Resolve once, reuse for both injection and tag computation
|
|
237
243
|
python_exe = resolve_python_executable(target)
|
|
238
|
-
target_paths = get_target_sys_path(python_exe)
|
|
239
|
-
|
|
240
|
-
current_set = set(sys.path)
|
|
241
|
-
new_paths = [p for p in target_paths if p and p not in current_set]
|
|
242
|
-
|
|
243
|
-
sys.path[:0] = new_paths
|
|
244
|
-
importlib.invalidate_caches()
|
|
245
244
|
|
|
246
245
|
logger.debug(
|
|
247
|
-
"
|
|
246
|
+
"Resolved target environment '%s' → '%s'",
|
|
248
247
|
target,
|
|
249
|
-
|
|
248
|
+
python_exe,
|
|
250
249
|
)
|
|
251
250
|
|
|
252
|
-
return _env_tag_from_resolved(python_exe)
|
|
251
|
+
return _env_tag_from_resolved(python_exe), python_exe
|
|
253
252
|
|
|
254
253
|
|
|
255
|
-
def
|
|
256
|
-
|
|
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.
|
|
257
259
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
Args:
|
|
264
|
-
python_arg: Path to a Python interpreter or venv directory.
|
|
265
|
-
|
|
266
|
-
Returns:
|
|
267
|
-
Resolved absolute path to the Python executable.
|
|
268
|
-
|
|
269
|
-
Raises:
|
|
270
|
-
EnvironmentSetupError: If the path does not exist or no
|
|
271
|
-
interpreter can be found.
|
|
272
|
-
"""
|
|
273
|
-
path = Path(python_arg)
|
|
274
|
-
|
|
275
|
-
if not path.exists():
|
|
276
|
-
raise EnvironmentSetupError(
|
|
277
|
-
python_arg,
|
|
278
|
-
f"path does not exist: {python_arg}",
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
# Direct path to an executable
|
|
282
|
-
if path.is_file():
|
|
283
|
-
return path.resolve()
|
|
284
|
-
|
|
285
|
-
# Directory — probe for interpreter
|
|
286
|
-
if path.is_dir():
|
|
287
|
-
for candidate in (path / rel for rel in _INTERPRETER_CANDIDATES):
|
|
288
|
-
if candidate.is_file():
|
|
289
|
-
logger.debug(
|
|
290
|
-
"Resolved venv directory '%s' to interpreter '%s'",
|
|
291
|
-
python_arg,
|
|
292
|
-
candidate,
|
|
293
|
-
)
|
|
294
|
-
return candidate.resolve()
|
|
295
|
-
|
|
296
|
-
raise EnvironmentSetupError(
|
|
297
|
-
python_arg,
|
|
298
|
-
f"no Python interpreter found in directory: {python_arg}. "
|
|
299
|
-
f"Expected Scripts/python.exe (Windows) or bin/python (Unix).",
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
raise EnvironmentSetupError(
|
|
303
|
-
python_arg,
|
|
304
|
-
f"path is neither a file nor a directory: {python_arg}",
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def get_target_sys_path(python_exe: Path) -> list[str]:
|
|
309
|
-
"""Query a target interpreter for its package-discovery paths.
|
|
310
|
-
|
|
311
|
-
Collects site-packages directories (including system site-packages
|
|
312
|
-
for ``--system-site-packages`` venvs), user site-packages, and
|
|
313
|
-
any paths injected by ``.pth`` files (editable installs, namespace
|
|
314
|
-
packages). Stdlib paths are **excluded** to prevent cross-version
|
|
315
|
-
import contamination when tool and target run different Python
|
|
316
|
-
versions.
|
|
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.
|
|
317
264
|
|
|
318
265
|
Args:
|
|
319
266
|
python_exe: Absolute path to the target Python executable.
|
|
267
|
+
package_name: Package import name (e.g. ``openai``).
|
|
320
268
|
|
|
321
269
|
Returns:
|
|
322
|
-
|
|
323
|
-
|
|
270
|
+
Dict with keys ``path`` (str | None), ``version`` (str | None),
|
|
271
|
+
``summary`` (str | None), ``installed`` (list[str]).
|
|
324
272
|
|
|
325
273
|
Raises:
|
|
326
274
|
EnvironmentSetupError: If the subprocess fails or times out.
|
|
327
275
|
"""
|
|
328
|
-
script = _SITE_PACKAGES_SCRIPT
|
|
329
276
|
try:
|
|
330
277
|
result = subprocess.run(
|
|
331
|
-
[str(python_exe), "-c",
|
|
278
|
+
[str(python_exe), "-c", _PACKAGE_DISCOVERY_SCRIPT, package_name],
|
|
332
279
|
capture_output=True,
|
|
333
280
|
text=True,
|
|
334
281
|
timeout=_SUBPROCESS_TIMEOUT_SECONDS,
|
|
@@ -348,100 +295,70 @@ def get_target_sys_path(python_exe: Path) -> list[str]:
|
|
|
348
295
|
stderr = result.stderr.strip()[:200]
|
|
349
296
|
raise EnvironmentSetupError(
|
|
350
297
|
str(python_exe),
|
|
351
|
-
f"
|
|
298
|
+
f"package discovery failed (exit {result.returncode}): {stderr}",
|
|
352
299
|
)
|
|
353
300
|
|
|
354
301
|
try:
|
|
355
|
-
|
|
302
|
+
data = json.loads(result.stdout)
|
|
356
303
|
except json.JSONDecodeError as exc:
|
|
357
304
|
raise EnvironmentSetupError(
|
|
358
305
|
str(python_exe),
|
|
359
|
-
f"cannot parse
|
|
306
|
+
f"cannot parse discovery output: {exc}",
|
|
360
307
|
) from exc
|
|
361
308
|
|
|
362
|
-
|
|
363
|
-
raise EnvironmentSetupError(
|
|
364
|
-
str(python_exe),
|
|
365
|
-
"sys.path output is not a list",
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
return [str(p) for p in paths]
|
|
369
|
-
|
|
309
|
+
return data # type: ignore[no-any-return]
|
|
370
310
|
|
|
371
|
-
@contextmanager
|
|
372
|
-
def activate_environment(python_arg: str) -> Generator[Path, None, None]:
|
|
373
|
-
"""Temporarily inject a target environment's paths into the process.
|
|
374
311
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
:mod:`importlib` caches so that :func:`importlib.util.find_spec`
|
|
378
|
-
and :func:`importlib.metadata.distributions` pick up the target
|
|
379
|
-
environment's packages.
|
|
312
|
+
def resolve_python_executable(python_arg: str) -> Path:
|
|
313
|
+
"""Resolve a user-supplied path to a Python executable.
|
|
380
314
|
|
|
381
|
-
|
|
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).
|
|
382
319
|
|
|
383
320
|
Args:
|
|
384
321
|
python_arg: Path to a Python interpreter or venv directory.
|
|
385
322
|
|
|
386
|
-
|
|
387
|
-
|
|
323
|
+
Returns:
|
|
324
|
+
Resolved absolute path to the Python executable.
|
|
388
325
|
|
|
389
326
|
Raises:
|
|
390
|
-
EnvironmentSetupError: If the
|
|
391
|
-
|
|
327
|
+
EnvironmentSetupError: If the path does not exist or no
|
|
328
|
+
interpreter can be found.
|
|
392
329
|
"""
|
|
393
|
-
|
|
394
|
-
target_paths = get_target_sys_path(python_exe)
|
|
395
|
-
|
|
396
|
-
# Filter to paths that actually exist and aren't already present
|
|
397
|
-
current_set = set(sys.path)
|
|
398
|
-
new_paths = [p for p in target_paths if p and p not in current_set]
|
|
399
|
-
|
|
400
|
-
saved_path = sys.path.copy()
|
|
401
|
-
try:
|
|
402
|
-
# Prepend target paths so they take priority
|
|
403
|
-
sys.path[:0] = new_paths
|
|
404
|
-
importlib.invalidate_caches()
|
|
330
|
+
path = Path(python_arg)
|
|
405
331
|
|
|
406
|
-
|
|
407
|
-
|
|
332
|
+
if not path.exists():
|
|
333
|
+
raise EnvironmentSetupError(
|
|
408
334
|
python_arg,
|
|
409
|
-
|
|
335
|
+
f"path does not exist: {python_arg}",
|
|
410
336
|
)
|
|
411
|
-
yield python_exe
|
|
412
|
-
finally:
|
|
413
|
-
sys.path[:] = saved_path
|
|
414
|
-
importlib.invalidate_caches()
|
|
415
|
-
logger.debug("Restored original sys.path")
|
|
416
|
-
|
|
417
337
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
Unlike :func:`activate_environment`, this does **not** restore
|
|
422
|
-
``sys.path`` afterward. Suitable for CLI entry points where the
|
|
423
|
-
process exits after the command completes.
|
|
424
|
-
|
|
425
|
-
Args:
|
|
426
|
-
python_arg: Path to a Python interpreter or venv directory.
|
|
427
|
-
|
|
428
|
-
Raises:
|
|
429
|
-
EnvironmentSetupError: If the environment cannot be resolved
|
|
430
|
-
or queried.
|
|
431
|
-
"""
|
|
432
|
-
python_exe = resolve_python_executable(python_arg)
|
|
433
|
-
target_paths = get_target_sys_path(python_exe)
|
|
338
|
+
# Direct path to an executable
|
|
339
|
+
if path.is_file():
|
|
340
|
+
return path.resolve()
|
|
434
341
|
|
|
435
|
-
|
|
436
|
-
|
|
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()
|
|
437
352
|
|
|
438
|
-
|
|
439
|
-
|
|
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
|
+
)
|
|
440
358
|
|
|
441
|
-
|
|
442
|
-
"Injected environment '%s': added %d paths",
|
|
359
|
+
raise EnvironmentSetupError(
|
|
443
360
|
python_arg,
|
|
444
|
-
|
|
361
|
+
f"path is neither a file nor a directory: {python_arg}",
|
|
445
362
|
)
|
|
446
363
|
|
|
447
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,74 +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_returns_site_packages_not_stdlib():
|
|
86
|
-
"""Returns site-packages paths and excludes base-interpreter stdlib."""
|
|
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
|
-
# At least one site-packages directory must be present.
|
|
95
|
-
assert any("site-packages" in p for p in paths), (
|
|
96
|
-
f"no site-packages found in returned paths: {paths}"
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
# No path should resolve under the base Python installation
|
|
100
|
-
# (stdlib, lib-dynload, zip archives) unless it is also under
|
|
101
|
-
# the venv prefix.
|
|
102
|
-
def _under(child: str, root: str) -> bool:
|
|
103
|
-
return child == root or child.startswith(root + os.sep)
|
|
104
|
-
|
|
105
|
-
for p in paths:
|
|
106
|
-
rp = os.path.realpath(p)
|
|
107
|
-
if _under(rp, base) and not _under(rp, prefix):
|
|
108
|
-
pytest.fail(f"base-interpreter stdlib path leaked: {p}")
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
# ---------------------------------------------------------------------------
|
|
112
|
-
# inject_target_environment
|
|
113
|
-
# ---------------------------------------------------------------------------
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def test_inject_target_environment_adds_paths():
|
|
117
|
-
"""Injecting the current interpreter adds its paths to sys.path."""
|
|
118
|
-
original_len = len(sys.path)
|
|
119
|
-
# Inject the current interpreter (should be mostly a no-op since
|
|
120
|
-
# paths already overlap, but validates the mechanics)
|
|
121
|
-
inject_target_environment(sys.executable)
|
|
122
|
-
assert len(sys.path) >= original_len
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
# ---------------------------------------------------------------------------
|
|
126
|
-
# activate_environment (context manager)
|
|
127
|
-
# ---------------------------------------------------------------------------
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def test_activate_environment_restores_path():
|
|
131
|
-
"""sys.path is restored after the context manager exits."""
|
|
132
|
-
saved = sys.path.copy()
|
|
133
|
-
with activate_environment(sys.executable):
|
|
134
|
-
pass
|
|
135
|
-
assert sys.path == saved
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def test_activate_environment_restores_on_exception():
|
|
139
|
-
"""sys.path is restored even if an exception occurs."""
|
|
140
|
-
saved = sys.path.copy()
|
|
141
|
-
with pytest.raises(RuntimeError), activate_environment(sys.executable):
|
|
142
|
-
raise RuntimeError("boom")
|
|
143
|
-
assert sys.path == saved
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def test_activate_environment_bad_path():
|
|
147
|
-
"""EnvironmentSetupError propagates from the context manager."""
|
|
148
|
-
with pytest.raises(EnvironmentSetupError), activate_environment("/no/such/env"):
|
|
149
|
-
pass
|
|
92
|
+
query_target_package(fake, "anything")
|
|
150
93
|
|
|
151
94
|
|
|
152
95
|
# ---------------------------------------------------------------------------
|
|
@@ -297,22 +240,25 @@ def test_auto_detect_venv_ignores_dir_without_interpreter(tmp_path):
|
|
|
297
240
|
|
|
298
241
|
|
|
299
242
|
def test_setup_environment_explicit_python():
|
|
300
|
-
"""Explicit --python
|
|
301
|
-
tag = setup_environment(sys.executable)
|
|
243
|
+
"""Explicit --python returns (env_tag, target_python)."""
|
|
244
|
+
tag, target = setup_environment(sys.executable)
|
|
302
245
|
assert tag is not None
|
|
303
246
|
assert len(tag) == 8
|
|
247
|
+
assert target is not None
|
|
248
|
+
assert target.is_file()
|
|
304
249
|
|
|
305
250
|
|
|
306
251
|
@pytest.mark.usefixtures("_clean_venv_env")
|
|
307
252
|
def test_setup_environment_no_venv_returns_none(tmp_path):
|
|
308
|
-
"""No venv, no --python → returns None
|
|
309
|
-
tag = setup_environment(None, cwd=tmp_path)
|
|
253
|
+
"""No venv, no --python → returns (None, None)."""
|
|
254
|
+
tag, target = setup_environment(None, cwd=tmp_path)
|
|
310
255
|
assert tag is None
|
|
256
|
+
assert target is None
|
|
311
257
|
|
|
312
258
|
|
|
313
259
|
@pytest.mark.usefixtures("_clean_venv_env")
|
|
314
260
|
def test_setup_environment_auto_detects(tmp_path, monkeypatch):
|
|
315
|
-
"""Auto-detects .venv/ and returns
|
|
261
|
+
"""Auto-detects .venv/ and returns (env_tag, target_python)."""
|
|
316
262
|
# Point the fake venv at the real interpreter so subprocess works
|
|
317
263
|
venv = tmp_path / ".venv"
|
|
318
264
|
venv.mkdir()
|
|
@@ -320,19 +266,21 @@ def test_setup_environment_auto_detects(tmp_path, monkeypatch):
|
|
|
320
266
|
if sys.platform == "win32":
|
|
321
267
|
scripts = venv / "Scripts"
|
|
322
268
|
scripts.mkdir()
|
|
323
|
-
|
|
269
|
+
link = scripts / "python.exe"
|
|
324
270
|
else:
|
|
325
271
|
bin_dir = venv / "bin"
|
|
326
272
|
bin_dir.mkdir()
|
|
327
|
-
|
|
273
|
+
link = bin_dir / "python"
|
|
328
274
|
|
|
329
275
|
# Create a symlink (or copy) so the interpreter actually works
|
|
330
276
|
try:
|
|
331
|
-
|
|
277
|
+
link.symlink_to(real_exe)
|
|
332
278
|
except OSError:
|
|
333
279
|
# Symlinks may require privileges on Windows; skip test
|
|
334
280
|
pytest.skip("Cannot create symlink to Python interpreter")
|
|
335
281
|
|
|
336
|
-
tag = setup_environment(None, cwd=tmp_path)
|
|
282
|
+
tag, target = setup_environment(None, cwd=tmp_path)
|
|
337
283
|
assert tag is not None
|
|
338
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.2 → 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
|