libcontext 0.3.0__tar.gz → 0.4.0__tar.gz

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