libcontext 0.3.0__tar.gz → 0.5.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.5.0}/CHANGELOG.md +21 -1
  2. {libcontext-0.3.0 → libcontext-0.5.0}/PKG-INFO +21 -11
  3. {libcontext-0.3.0 → libcontext-0.5.0}/README.md +20 -10
  4. {libcontext-0.3.0 → libcontext-0.5.0}/pyproject.toml +1 -1
  5. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/__init__.py +2 -0
  6. libcontext-0.5.0/src/libcontext/_envsetup.py +334 -0
  7. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/cache.py +88 -3
  8. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/cli.py +104 -6
  9. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/collector.py +5 -2
  10. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/exceptions.py +12 -0
  11. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/mcp_server.py +29 -1
  12. {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_cache.py +162 -0
  13. {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_cli.py +146 -0
  14. libcontext-0.5.0/tests/test_envsetup.py +276 -0
  15. {libcontext-0.3.0 → libcontext-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  16. {libcontext-0.3.0 → libcontext-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {libcontext-0.3.0 → libcontext-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {libcontext-0.3.0 → libcontext-0.5.0}/.github/workflows/ci.yml +0 -0
  19. {libcontext-0.3.0 → libcontext-0.5.0}/.github/workflows/release.yml +0 -0
  20. {libcontext-0.3.0 → libcontext-0.5.0}/.gitignore +0 -0
  21. {libcontext-0.3.0 → libcontext-0.5.0}/CONTRIBUTING.md +0 -0
  22. {libcontext-0.3.0 → libcontext-0.5.0}/DEPENDENCIES.md +0 -0
  23. {libcontext-0.3.0 → libcontext-0.5.0}/LICENSE +0 -0
  24. {libcontext-0.3.0 → libcontext-0.5.0}/SECURITY.md +0 -0
  25. {libcontext-0.3.0 → libcontext-0.5.0}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
  26. {libcontext-0.3.0 → libcontext-0.5.0}/docs/adr/002-skill-plus-cli-as-primary-integration.md +0 -0
  27. {libcontext-0.3.0 → libcontext-0.5.0}/docs/adr/004-ast-only-inspection.md +0 -0
  28. {libcontext-0.3.0 → libcontext-0.5.0}/docs/adr/README.md +0 -0
  29. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/_security.py +0 -0
  30. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/config.py +0 -0
  31. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/diff.py +0 -0
  32. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/inspector.py +0 -0
  33. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/models.py +0 -0
  34. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/py.typed +0 -0
  35. {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/renderer.py +0 -0
  36. {libcontext-0.3.0 → libcontext-0.5.0}/tests/__init__.py +0 -0
  37. {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_cli_mcp_parity.py +0 -0
  38. {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_collector.py +0 -0
  39. {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_config.py +0 -0
  40. {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_diff.py +0 -0
  41. {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_inspector.py +0 -0
  42. {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_mcp_server.py +0 -0
  43. {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_models.py +0 -0
  44. {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_renderer.py +0 -0
  45. {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_security.py +0 -0
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-03-23
11
+
12
+ ### Added
13
+
14
+ - **`--version` flag**: `libctx --version` displays the installed version, read from package metadata.
15
+ - **`cache list`**: new subcommand showing cached packages with version, size, and relative age.
16
+ - **`cache clear <package>`**: selective cache clearing by package name (normalises hyphens and case). Without argument, `cache clear` still removes all entries as before.
17
+
18
+ ## [0.4.0] - 2026-03-23
19
+
20
+ ### Added
21
+
22
+ - **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.
23
+ - **`--python` CLI option**: explicit override for targeting a specific Python interpreter or venv directory (e.g. `--python /path/to/other/venv`).
24
+ - **`LIBCONTEXT_PYTHON` env var**: configure the MCP server's target environment via environment variable or `--python` argument.
25
+ - **`EnvironmentSetupError` exception**: raised when a target environment cannot be resolved or queried.
26
+ - **Cache namespacing by environment**: packages from different environments get separate cache entries, preventing cross-environment cache collisions.
27
+
10
28
  ## [0.3.0] - 2026-03-23
11
29
 
12
30
  ### Added
@@ -83,7 +101,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
83
101
  - Free-form `extra_context` field for library authors.
84
102
  - Python API for programmatic usage (`collect_package`, `render_package`).
85
103
 
86
- [Unreleased]: https://github.com/Syclaw/libcontext/compare/v0.3.0...HEAD
104
+ [Unreleased]: https://github.com/Syclaw/libcontext/compare/v0.5.0...HEAD
105
+ [0.5.0]: https://github.com/Syclaw/libcontext/compare/v0.4.0...v0.5.0
106
+ [0.4.0]: https://github.com/Syclaw/libcontext/compare/v0.3.0...v0.4.0
87
107
  [0.3.0]: https://github.com/Syclaw/libcontext/compare/v0.2.0...v0.3.0
88
108
  [0.2.0]: https://github.com/Syclaw/libcontext/compare/v0.1.0...v0.2.0
89
109
  [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.5.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
@@ -37,30 +37,38 @@ Description-Content-Type: text/markdown
37
37
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
38
38
  [![Typed](https://img.shields.io/badge/typed-mypy-blue.svg)](https://mypy-lang.org/)
39
39
 
40
- > Make your AI coding assistant aware of any Python library's API — on demand, not always-on.
40
+ > Context-efficient API references for LLM toolchains structured, on-demand, not always-on.
41
41
 
42
42
  **libcontext** inspects any installed Python package via static AST analysis (no code execution) and generates compact Markdown API references. It integrates with Claude Code (via a `/lib` skill) and VS Code Copilot (via an MCP server) to provide **progressive disclosure** — only loading API context when you actually need it, avoiding context window pollution.
43
43
 
44
44
  ## Why This Exists
45
45
 
46
- When you ask an AI assistant how to use a library, the quality of the output depends entirely on what the model knows about that library's API. For many real-world scenarios, the model is working blind:
46
+ LLMs can often use popular libraries correctly from training data alone. For well-known packages like `requests` or `flask`, libcontext adds little value. The real problems arise in specific scenarios:
47
47
 
48
48
  - **Internal / private libraries** — Zero training data exists. The model has never seen the API.
49
- - **Niche open-source packages** — Sparse or outdated training data leads to hallucinated methods and wrong signatures.
49
+ - **Niche open-source packages** — Sparse or outdated training data leads to hallucinated methods and wrong signatures. GPT-4o achieves only 38% valid invocations on low-frequency APIs ([Amazon Science, ICSE 2025](https://www.amazon.science/publications/on-mitigating-code-llm-hallucinations-with-api-documentation)).
50
50
  - **New versions of any library** — Training data has a cutoff. The model knows v2, you're using v3.
51
51
 
52
- Dumping entire API references into always-on instruction files (like `copilot-instructions.md` or `CLAUDE.md`) wastes context window on every interaction even when you're not using that library. Research ([ReadMe.LLM, UC Berkeley 2025](https://arxiv.org/abs/2504.15870)) confirms that excessive context triggers hallucinations and degrades output quality.
52
+ Even when an LLM could read source files directly, structured API summaries are more context-efficient: providing API documentation via retrieval improves pass rates by 83–220% compared to no documentation, while consuming far fewer tokens than raw source code ([arXiv 2503.15231, March 2025](https://arxiv.org/abs/2503.15231)).
53
53
 
54
- libcontext solves this with **progressive disclosure**: overview first, then drill into specific modules only when needed.
54
+ Dumping entire API references into always-on instruction files wastes context window on every interaction. Selective retrieval outperforms always-on injection — always-on docs actually hurt performance on well-known APIs ([Amazon Science, ICSE 2025](https://www.amazon.science/publications/on-mitigating-code-llm-hallucinations-with-api-documentation)).
55
+
56
+ libcontext addresses this with **progressive disclosure**: overview first, then drill into specific modules only when needed.
55
57
 
56
58
  ## When libcontext makes the biggest difference
57
59
 
58
60
  | Scenario | Impact | Why |
59
61
  |---|---|---|
60
- | **Internal / private libraries** | Critical | Zero training data exists for proprietary code |
61
- | **Niche open-source packages** | High | Sparse training data leads to hallucinated methods |
62
+ | **Internal / private libraries** | Critical | Zero training data the model has never seen the API |
63
+ | **Niche open-source packages** | High | Sparse training data; 19.7% of LLM package suggestions are hallucinated ([USENIX Security 2025](https://www.usenix.org/publications/loginonline/we-have-package-you-comprehensive-analysis-package-hallucinations-code)) |
62
64
  | **New versions of any library** | High | Training cutoff — the LLM knows v2, you're using v3 |
63
- | **Popular, stable libraries** | Low | The LLM already has good knowledge from training data |
65
+ | **Popular, stable libraries** | Low | The LLM already has good knowledge from training data — libcontext adds little here |
66
+
67
+ ### What libcontext does NOT do
68
+
69
+ - **Replace reading source code** — LLMs with tool access (Claude Code, Cursor) can read files directly. For popular libraries, that's often sufficient.
70
+ - **Guarantee correctness** — Even with perfect API docs, LLMs still make errors. Research shows pass rates of 74–91% with target documentation, not 100% ([arXiv 2503.15231](https://arxiv.org/abs/2503.15231)).
71
+ - **Provide usage examples** — libcontext extracts signatures and docstrings, not example code. Research indicates examples have the highest impact on code generation quality.
64
72
 
65
73
  ## Quick Start
66
74
 
@@ -138,8 +146,10 @@ libctx diff old.json new.json
138
146
  # Bypass disk cache
139
147
  libctx inspect requests --no-cache
140
148
 
141
- # Clear all cached API data
142
- libctx cache clear
149
+ # Cache management
150
+ libctx cache list # show cached packages with size and age
151
+ libctx cache clear # clear all cached API data
152
+ libctx cache clear requests # clear only the entries for one package
143
153
  ```
144
154
 
145
155
  ### AST Analysis
@@ -8,30 +8,38 @@
8
8
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
9
9
  [![Typed](https://img.shields.io/badge/typed-mypy-blue.svg)](https://mypy-lang.org/)
10
10
 
11
- > Make your AI coding assistant aware of any Python library's API — on demand, not always-on.
11
+ > Context-efficient API references for LLM toolchains structured, on-demand, not always-on.
12
12
 
13
13
  **libcontext** inspects any installed Python package via static AST analysis (no code execution) and generates compact Markdown API references. It integrates with Claude Code (via a `/lib` skill) and VS Code Copilot (via an MCP server) to provide **progressive disclosure** — only loading API context when you actually need it, avoiding context window pollution.
14
14
 
15
15
  ## Why This Exists
16
16
 
17
- When you ask an AI assistant how to use a library, the quality of the output depends entirely on what the model knows about that library's API. For many real-world scenarios, the model is working blind:
17
+ LLMs can often use popular libraries correctly from training data alone. For well-known packages like `requests` or `flask`, libcontext adds little value. The real problems arise in specific scenarios:
18
18
 
19
19
  - **Internal / private libraries** — Zero training data exists. The model has never seen the API.
20
- - **Niche open-source packages** — Sparse or outdated training data leads to hallucinated methods and wrong signatures.
20
+ - **Niche open-source packages** — Sparse or outdated training data leads to hallucinated methods and wrong signatures. GPT-4o achieves only 38% valid invocations on low-frequency APIs ([Amazon Science, ICSE 2025](https://www.amazon.science/publications/on-mitigating-code-llm-hallucinations-with-api-documentation)).
21
21
  - **New versions of any library** — Training data has a cutoff. The model knows v2, you're using v3.
22
22
 
23
- Dumping entire API references into always-on instruction files (like `copilot-instructions.md` or `CLAUDE.md`) wastes context window on every interaction even when you're not using that library. Research ([ReadMe.LLM, UC Berkeley 2025](https://arxiv.org/abs/2504.15870)) confirms that excessive context triggers hallucinations and degrades output quality.
23
+ Even when an LLM could read source files directly, structured API summaries are more context-efficient: providing API documentation via retrieval improves pass rates by 83–220% compared to no documentation, while consuming far fewer tokens than raw source code ([arXiv 2503.15231, March 2025](https://arxiv.org/abs/2503.15231)).
24
24
 
25
- libcontext solves this with **progressive disclosure**: overview first, then drill into specific modules only when needed.
25
+ Dumping entire API references into always-on instruction files wastes context window on every interaction. Selective retrieval outperforms always-on injection — always-on docs actually hurt performance on well-known APIs ([Amazon Science, ICSE 2025](https://www.amazon.science/publications/on-mitigating-code-llm-hallucinations-with-api-documentation)).
26
+
27
+ libcontext addresses this with **progressive disclosure**: overview first, then drill into specific modules only when needed.
26
28
 
27
29
  ## When libcontext makes the biggest difference
28
30
 
29
31
  | Scenario | Impact | Why |
30
32
  |---|---|---|
31
- | **Internal / private libraries** | Critical | Zero training data exists for proprietary code |
32
- | **Niche open-source packages** | High | Sparse training data leads to hallucinated methods |
33
+ | **Internal / private libraries** | Critical | Zero training data the model has never seen the API |
34
+ | **Niche open-source packages** | High | Sparse training data; 19.7% of LLM package suggestions are hallucinated ([USENIX Security 2025](https://www.usenix.org/publications/loginonline/we-have-package-you-comprehensive-analysis-package-hallucinations-code)) |
33
35
  | **New versions of any library** | High | Training cutoff — the LLM knows v2, you're using v3 |
34
- | **Popular, stable libraries** | Low | The LLM already has good knowledge from training data |
36
+ | **Popular, stable libraries** | Low | The LLM already has good knowledge from training data — libcontext adds little here |
37
+
38
+ ### What libcontext does NOT do
39
+
40
+ - **Replace reading source code** — LLMs with tool access (Claude Code, Cursor) can read files directly. For popular libraries, that's often sufficient.
41
+ - **Guarantee correctness** — Even with perfect API docs, LLMs still make errors. Research shows pass rates of 74–91% with target documentation, not 100% ([arXiv 2503.15231](https://arxiv.org/abs/2503.15231)).
42
+ - **Provide usage examples** — libcontext extracts signatures and docstrings, not example code. Research indicates examples have the highest impact on code generation quality.
35
43
 
36
44
  ## Quick Start
37
45
 
@@ -109,8 +117,10 @@ libctx diff old.json new.json
109
117
  # Bypass disk cache
110
118
  libctx inspect requests --no-cache
111
119
 
112
- # Clear all cached API data
113
- libctx cache clear
120
+ # Cache management
121
+ libctx cache list # show cached packages with size and age
122
+ libctx cache clear # clear all cached API data
123
+ libctx cache clear requests # clear only the entries for one package
114
124
  ```
115
125
 
116
126
  ### AST Analysis
@@ -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.5.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,93 @@ 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 clear_package(package_name: str) -> int:
262
+ """Remove cached entries matching a package name.
263
+
264
+ Matches entries whose embedded ``name`` field equals *package_name*
265
+ (case-insensitive, normalised with hyphens → underscores).
266
+
267
+ Args:
268
+ package_name: Package name to clear.
269
+
270
+ Returns:
271
+ Number of entries removed.
272
+ """
273
+ target = package_name.lower().replace("-", "_")
274
+ cache_dir = _get_cache_dir()
275
+ count = 0
276
+ for f in cache_dir.glob("*.json"):
277
+ try:
278
+ raw = json.loads(f.read_text(encoding="utf-8"))
279
+ data = _deserialize_envelope(raw)
280
+ except (json.JSONDecodeError, ValueError, OSError):
281
+ continue
282
+ name = data.get("name", "")
283
+ if isinstance(name, str) and name.lower().replace("-", "_") == target:
284
+ _safe_delete(f)
285
+ count += 1
286
+ return count
287
+
288
+
289
+ @dataclass
290
+ class CacheEntry:
291
+ """Summary of a single cache entry for display."""
292
+
293
+ package: str
294
+ version: str
295
+ cached_at: str
296
+ size_bytes: int
297
+ file_path: Path
298
+
299
+
300
+ def list_entries() -> list[CacheEntry]:
301
+ """List all cached API snapshots with metadata.
302
+
303
+ Returns:
304
+ List of :class:`CacheEntry` sorted by package name then version.
305
+ """
306
+ cache_dir = _get_cache_dir()
307
+ entries: list[CacheEntry] = []
308
+ for f in cache_dir.glob("*.json"):
309
+ try:
310
+ size = f.stat().st_size
311
+ raw = json.loads(f.read_text(encoding="utf-8"))
312
+ data = _deserialize_envelope(raw)
313
+ except (json.JSONDecodeError, ValueError, OSError):
314
+ continue
315
+ meta = data.get("_cache_meta", {})
316
+ entries.append(
317
+ CacheEntry(
318
+ package=data.get("name", "unknown"),
319
+ version=data.get("version", "unknown"),
320
+ cached_at=meta.get("cached_at", "unknown"),
321
+ size_bytes=size,
322
+ file_path=f,
323
+ )
324
+ )
325
+ entries.sort(key=lambda e: (e.package.lower(), e.version))
326
+ return entries
327
+
328
+
329
+ def _cache_filename(
330
+ package_name: str,
331
+ version: str | None,
332
+ env_tag: str | None = None,
333
+ ) -> str:
256
334
  """Build the cache filename for a package.
257
335
 
258
336
  Sanitises both components to prevent path traversal via crafted
259
337
  package names (e.g. ``../../etc/cron.d/evil``).
338
+
339
+ When *env_tag* is provided (from ``--python``), it is appended to
340
+ the filename so that packages from different environments get
341
+ separate cache entries.
260
342
  """
261
343
  from ._security import sanitize_filename
262
344
 
263
345
  safe_name = sanitize_filename(package_name)
264
346
  safe_version = sanitize_filename(version) if version else "unknown"
347
+ if env_tag:
348
+ safe_tag = sanitize_filename(env_tag)
349
+ return f"{safe_name}-{safe_version}-{safe_tag}.json"
265
350
  return f"{safe_name}-{safe_version}.json"