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.
Files changed (45) hide show
  1. {libcontext-0.7.2 → libcontext-0.7.4}/PKG-INFO +1 -1
  2. {libcontext-0.7.2 → libcontext-0.7.4}/pyproject.toml +1 -1
  3. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/_envsetup.py +115 -198
  4. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/cli.py +2 -1
  5. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/collector.py +113 -0
  6. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/mcp_server.py +6 -3
  7. {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_envsetup.py +37 -89
  8. {libcontext-0.7.2 → libcontext-0.7.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  9. {libcontext-0.7.2 → libcontext-0.7.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  10. {libcontext-0.7.2 → libcontext-0.7.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  11. {libcontext-0.7.2 → libcontext-0.7.4}/.github/workflows/ci.yml +0 -0
  12. {libcontext-0.7.2 → libcontext-0.7.4}/.github/workflows/release.yml +0 -0
  13. {libcontext-0.7.2 → libcontext-0.7.4}/.gitignore +0 -0
  14. {libcontext-0.7.2 → libcontext-0.7.4}/CHANGELOG.md +0 -0
  15. {libcontext-0.7.2 → libcontext-0.7.4}/CONTRIBUTING.md +0 -0
  16. {libcontext-0.7.2 → libcontext-0.7.4}/DEPENDENCIES.md +0 -0
  17. {libcontext-0.7.2 → libcontext-0.7.4}/LICENSE +0 -0
  18. {libcontext-0.7.2 → libcontext-0.7.4}/README.md +0 -0
  19. {libcontext-0.7.2 → libcontext-0.7.4}/SECURITY.md +0 -0
  20. {libcontext-0.7.2 → libcontext-0.7.4}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
  21. {libcontext-0.7.2 → libcontext-0.7.4}/docs/adr/002-skill-plus-cli-as-primary-integration.md +0 -0
  22. {libcontext-0.7.2 → libcontext-0.7.4}/docs/adr/004-ast-only-inspection.md +0 -0
  23. {libcontext-0.7.2 → libcontext-0.7.4}/docs/adr/README.md +0 -0
  24. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/__init__.py +0 -0
  25. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/_security.py +0 -0
  26. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/cache.py +0 -0
  27. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/config.py +0 -0
  28. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/diff.py +0 -0
  29. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/exceptions.py +0 -0
  30. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/inspector.py +0 -0
  31. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/models.py +0 -0
  32. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/py.typed +0 -0
  33. {libcontext-0.7.2 → libcontext-0.7.4}/src/libcontext/renderer.py +0 -0
  34. {libcontext-0.7.2 → libcontext-0.7.4}/tests/__init__.py +0 -0
  35. {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_cache.py +0 -0
  36. {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_cli.py +0 -0
  37. {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_cli_mcp_parity.py +0 -0
  38. {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_collector.py +0 -0
  39. {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_config.py +0 -0
  40. {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_diff.py +0 -0
  41. {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_inspector.py +0 -0
  42. {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_mcp_server.py +0 -0
  43. {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_models.py +0 -0
  44. {libcontext-0.7.2 → libcontext-0.7.4}/tests/test_renderer.py +0 -0
  45. {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.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "libcontext"
7
- version = "0.7.2"
7
+ version = "0.7.4"
8
8
  description = "Generate optimized LLM context from Python library APIs — CLI, skill, and MCP server"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,11 +1,12 @@
1
- """Environment setup — resolve and activate a target Python environment.
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 injects the
7
- target environment's paths into ``sys.path`` so that :mod:`importlib`
8
- discovery works against the target environment.
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 injection).
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
- paths = []
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
- # site-packages directories (includes system site-packages when the
58
- # venv was created with --system-site-packages)
57
+ # Locate the package source via find_spec
59
58
  try:
60
- paths.extend(site.getsitepackages())
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
- # User site-packages (e.g. ~/.local/lib/python3.x/site-packages),
65
- # honoured only when ENABLE_USER_SITE is not explicitly disabled.
66
- if site.ENABLE_USER_SITE:
67
- try:
68
- user = site.getusersitepackages()
69
- if isinstance(user, str):
70
- paths.append(user)
71
- except AttributeError:
72
- pass
73
-
74
- # .pth files in site-packages can inject arbitrary paths into sys.path
75
- # (editable installs, namespace packages, etc.). Collect them by
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
- """Set up the target environment for package discovery.
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* → inject that environment.
212
- 2. Auto-detected venv in *cwd* → inject it.
213
- 3. Neither → no injection (current process environment).
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
- The env_tag for cache namespacing, or *None* if no injection
221
- was performed.
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
- "Activated environment '%s': injected %d paths",
246
+ "Resolved target environment '%s' '%s'",
248
247
  target,
249
- len(new_paths),
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 resolve_python_executable(python_arg: str) -> Path:
256
- """Resolve a user-supplied path to a Python executable.
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
- Accepts either a direct path to a Python interpreter or a venv
259
- directory. When given a directory, probes for the interpreter
260
- in the standard locations (``Scripts/python.exe`` on Windows,
261
- ``bin/python`` on Unix).
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
- List of path strings suitable for prepending to ``sys.path``
323
- in the tool process.
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", script],
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"interpreter exited with code {result.returncode}: {stderr}",
298
+ f"package discovery failed (exit {result.returncode}): {stderr}",
352
299
  )
353
300
 
354
301
  try:
355
- paths = json.loads(result.stdout)
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 sys.path output: {exc}",
306
+ f"cannot parse discovery output: {exc}",
360
307
  ) from exc
361
308
 
362
- if not isinstance(paths, list):
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
- Resolves the Python executable, queries its ``sys.path``, prepends
376
- the target paths to the current ``sys.path``, and invalidates
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
- 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).
382
319
 
383
320
  Args:
384
321
  python_arg: Path to a Python interpreter or venv directory.
385
322
 
386
- Yields:
387
- The resolved Python executable path.
323
+ Returns:
324
+ Resolved absolute path to the Python executable.
388
325
 
389
326
  Raises:
390
- EnvironmentSetupError: If the environment cannot be resolved
391
- or queried.
327
+ EnvironmentSetupError: If the path does not exist or no
328
+ interpreter can be found.
392
329
  """
393
- python_exe = resolve_python_executable(python_arg)
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
- logger.debug(
407
- "Activated environment '%s': injected %d paths",
332
+ if not path.exists():
333
+ raise EnvironmentSetupError(
408
334
  python_arg,
409
- len(new_paths),
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
- def inject_target_environment(python_arg: str) -> None:
419
- """Inject a target environment's paths into the current process.
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
- current_set = set(sys.path)
436
- new_paths = [p for p in target_paths if p and p not in current_set]
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
- sys.path[:0] = new_paths
439
- importlib.invalidate_caches()
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
- logger.debug(
442
- "Injected environment '%s': added %d paths",
359
+ raise EnvironmentSetupError(
443
360
  python_arg,
444
- len(new_paths),
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 namespacing.
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
- get_target_sys_path,
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
- # get_target_sys_path
59
+ # query_target_package
63
60
  # ---------------------------------------------------------------------------
64
61
 
65
62
 
66
- def test_get_target_sys_path_current_interpreter():
67
- """Querying the current interpreter returns a non-empty path list."""
68
- paths = get_target_sys_path(Path(sys.executable))
69
- assert isinstance(paths, list)
70
- assert len(paths) > 0
71
- assert all(isinstance(p, str) for p in paths)
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 test_get_target_sys_path_bad_executable(tmp_path):
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
- get_target_sys_path(fake)
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 takes effect and returns an env_tag."""
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 (no injection)."""
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 an env_tag when using real interpreter."""
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
- target = scripts / "python.exe"
269
+ link = scripts / "python.exe"
324
270
  else:
325
271
  bin_dir = venv / "bin"
326
272
  bin_dir.mkdir()
327
- target = bin_dir / "python"
273
+ link = bin_dir / "python"
328
274
 
329
275
  # Create a symlink (or copy) so the interpreter actually works
330
276
  try:
331
- target.symlink_to(real_exe)
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