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.
Files changed (45) hide show
  1. {libcontext-0.7.1 → libcontext-0.7.4}/PKG-INFO +1 -1
  2. {libcontext-0.7.1 → libcontext-0.7.4}/pyproject.toml +1 -1
  3. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/_envsetup.py +122 -179
  4. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/cli.py +2 -1
  5. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/collector.py +113 -0
  6. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/mcp_server.py +6 -3
  7. {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_envsetup.py +37 -83
  8. {libcontext-0.7.1 → libcontext-0.7.4}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  9. {libcontext-0.7.1 → libcontext-0.7.4}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  10. {libcontext-0.7.1 → libcontext-0.7.4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  11. {libcontext-0.7.1 → libcontext-0.7.4}/.github/workflows/ci.yml +0 -0
  12. {libcontext-0.7.1 → libcontext-0.7.4}/.github/workflows/release.yml +0 -0
  13. {libcontext-0.7.1 → libcontext-0.7.4}/.gitignore +0 -0
  14. {libcontext-0.7.1 → libcontext-0.7.4}/CHANGELOG.md +0 -0
  15. {libcontext-0.7.1 → libcontext-0.7.4}/CONTRIBUTING.md +0 -0
  16. {libcontext-0.7.1 → libcontext-0.7.4}/DEPENDENCIES.md +0 -0
  17. {libcontext-0.7.1 → libcontext-0.7.4}/LICENSE +0 -0
  18. {libcontext-0.7.1 → libcontext-0.7.4}/README.md +0 -0
  19. {libcontext-0.7.1 → libcontext-0.7.4}/SECURITY.md +0 -0
  20. {libcontext-0.7.1 → libcontext-0.7.4}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
  21. {libcontext-0.7.1 → libcontext-0.7.4}/docs/adr/002-skill-plus-cli-as-primary-integration.md +0 -0
  22. {libcontext-0.7.1 → libcontext-0.7.4}/docs/adr/004-ast-only-inspection.md +0 -0
  23. {libcontext-0.7.1 → libcontext-0.7.4}/docs/adr/README.md +0 -0
  24. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/__init__.py +0 -0
  25. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/_security.py +0 -0
  26. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/cache.py +0 -0
  27. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/config.py +0 -0
  28. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/diff.py +0 -0
  29. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/exceptions.py +0 -0
  30. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/inspector.py +0 -0
  31. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/models.py +0 -0
  32. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/py.typed +0 -0
  33. {libcontext-0.7.1 → libcontext-0.7.4}/src/libcontext/renderer.py +0 -0
  34. {libcontext-0.7.1 → libcontext-0.7.4}/tests/__init__.py +0 -0
  35. {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_cache.py +0 -0
  36. {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_cli.py +0 -0
  37. {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_cli_mcp_parity.py +0 -0
  38. {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_collector.py +0 -0
  39. {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_config.py +0 -0
  40. {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_diff.py +0 -0
  41. {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_inspector.py +0 -0
  42. {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_mcp_server.py +0 -0
  43. {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_models.py +0 -0
  44. {libcontext-0.7.1 → libcontext-0.7.4}/tests/test_renderer.py +0 -0
  45. {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.1
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.1"
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
@@ -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
- """Set up the target environment for package discovery.
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* → inject that environment.
168
- 2. Auto-detected venv in *cwd* → inject it.
169
- 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.
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
- The env_tag for cache namespacing, or *None* if no injection
177
- 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).
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
- "Activated environment '%s': injected %d paths",
246
+ "Resolved target environment '%s' '%s'",
204
247
  target,
205
- len(new_paths),
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 resolve_python_executable(python_arg: str) -> Path:
212
- """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.
213
259
 
214
- Accepts either a direct path to a Python interpreter or a venv
215
- directory. When given a directory, probes for the interpreter
216
- in the standard locations (``Scripts/python.exe`` on Windows,
217
- ``bin/python`` on Unix).
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
- List of non-stdlib path strings from the target interpreter.
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", script],
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"interpreter exited with code {result.returncode}: {stderr}",
298
+ f"package discovery failed (exit {result.returncode}): {stderr}",
326
299
  )
327
300
 
328
301
  try:
329
- paths = json.loads(result.stdout)
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 sys.path output: {exc}",
306
+ f"cannot parse discovery output: {exc}",
334
307
  ) from exc
335
308
 
336
- if not isinstance(paths, list):
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
- @contextmanager
346
- def activate_environment(python_arg: str) -> Generator[Path, None, None]:
347
- """Temporarily inject a target environment's paths into the process.
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
- Yields:
361
- The resolved Python executable path.
323
+ Returns:
324
+ Resolved absolute path to the Python executable.
362
325
 
363
326
  Raises:
364
- EnvironmentSetupError: If the environment cannot be resolved
365
- or queried.
327
+ EnvironmentSetupError: If the path does not exist or no
328
+ interpreter can be found.
366
329
  """
367
- python_exe = resolve_python_executable(python_arg)
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
- logger.debug(
381
- "Activated environment '%s': injected %d paths",
332
+ if not path.exists():
333
+ raise EnvironmentSetupError(
382
334
  python_arg,
383
- len(new_paths),
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
- def inject_target_environment(python_arg: str) -> None:
393
- """Inject a target environment's paths into the current process.
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
- current_set = set(sys.path)
410
- 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()
411
352
 
412
- sys.path[:0] = new_paths
413
- 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
+ )
414
358
 
415
- logger.debug(
416
- "Injected environment '%s': added %d paths",
359
+ raise EnvironmentSetupError(
417
360
  python_arg,
418
- len(new_paths),
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 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,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
- get_target_sys_path(fake)
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 takes effect and returns an env_tag."""
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 (no injection)."""
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 an env_tag when using real interpreter."""
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
- target = scripts / "python.exe"
269
+ link = scripts / "python.exe"
318
270
  else:
319
271
  bin_dir = venv / "bin"
320
272
  bin_dir.mkdir()
321
- target = bin_dir / "python"
273
+ link = bin_dir / "python"
322
274
 
323
275
  # Create a symlink (or copy) so the interpreter actually works
324
276
  try:
325
- target.symlink_to(real_exe)
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