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.
- {libcontext-0.3.0 → libcontext-0.5.0}/CHANGELOG.md +21 -1
- {libcontext-0.3.0 → libcontext-0.5.0}/PKG-INFO +21 -11
- {libcontext-0.3.0 → libcontext-0.5.0}/README.md +20 -10
- {libcontext-0.3.0 → libcontext-0.5.0}/pyproject.toml +1 -1
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/__init__.py +2 -0
- libcontext-0.5.0/src/libcontext/_envsetup.py +334 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/cache.py +88 -3
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/cli.py +104 -6
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/collector.py +5 -2
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/exceptions.py +12 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/mcp_server.py +29 -1
- {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_cache.py +162 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_cli.py +146 -0
- libcontext-0.5.0/tests/test_envsetup.py +276 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/.github/workflows/ci.yml +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/.github/workflows/release.yml +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/.gitignore +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/CONTRIBUTING.md +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/DEPENDENCIES.md +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/LICENSE +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/SECURITY.md +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/docs/adr/002-skill-plus-cli-as-primary-integration.md +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/docs/adr/004-ast-only-inspection.md +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/docs/adr/README.md +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/_security.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/config.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/diff.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/inspector.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/models.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/py.typed +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/src/libcontext/renderer.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/tests/__init__.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_cli_mcp_parity.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_collector.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_config.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_diff.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_inspector.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_mcp_server.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_models.py +0 -0
- {libcontext-0.3.0 → libcontext-0.5.0}/tests/test_renderer.py +0 -0
- {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.
|
|
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
|
+
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
|
[](https://github.com/astral-sh/ruff)
|
|
38
38
|
[](https://mypy-lang.org/)
|
|
39
39
|
|
|
40
|
-
>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
| **Niche open-source packages** | High | Sparse training data
|
|
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
|
-
#
|
|
142
|
-
libctx cache
|
|
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
|
[](https://github.com/astral-sh/ruff)
|
|
9
9
|
[](https://mypy-lang.org/)
|
|
10
10
|
|
|
11
|
-
>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
| **Niche open-source packages** | High | Sparse training data
|
|
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
|
-
#
|
|
113
|
-
libctx cache
|
|
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
|
|
@@ -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(
|
|
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
|
|
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"
|