libcontext 0.6.0__tar.gz → 0.7.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.6.0 → libcontext-0.7.0}/CHANGELOG.md +46 -1
- {libcontext-0.6.0 → libcontext-0.7.0}/PKG-INFO +1 -1
- {libcontext-0.6.0 → libcontext-0.7.0}/pyproject.toml +1 -1
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/_envsetup.py +107 -20
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/cache.py +31 -20
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/collector.py +47 -19
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/config.py +4 -1
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/exceptions.py +5 -1
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/inspector.py +3 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/mcp_server.py +3 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/renderer.py +275 -293
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_collector.py +50 -10
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_envsetup.py +64 -29
- {libcontext-0.6.0 → libcontext-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/.github/workflows/ci.yml +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/.github/workflows/release.yml +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/.gitignore +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/CONTRIBUTING.md +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/DEPENDENCIES.md +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/LICENSE +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/README.md +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/SECURITY.md +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/docs/adr/002-skill-plus-cli-as-primary-integration.md +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/docs/adr/004-ast-only-inspection.md +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/docs/adr/README.md +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/__init__.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/_security.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/cli.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/diff.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/models.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/src/libcontext/py.typed +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/__init__.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_cache.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_cli.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_cli_mcp_parity.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_config.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_diff.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_inspector.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_mcp_server.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_models.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_renderer.py +0 -0
- {libcontext-0.6.0 → libcontext-0.7.0}/tests/test_security.py +0 -0
|
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0] - 2026-03-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Universal venv auto-detection**: environment variable detection chain
|
|
15
|
+
(`VIRTUAL_ENV` → `CONDA_PREFIX` → `UV_PROJECT_ENVIRONMENT`) with fallback
|
|
16
|
+
to `.venv/`/`venv/` directory scan and `uv python find` query. Works for
|
|
17
|
+
virtualenv, conda, poetry, pdm, and uv users out of the box.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **`Package not found` for globally-installed tool**: `libctx inspect` now
|
|
22
|
+
correctly discovers packages from project environments when installed via
|
|
23
|
+
`uv tool install` or `pipx`.
|
|
24
|
+
- **Permission-denied crashes on file traversal**: `rglob()` generators that
|
|
25
|
+
hit `PermissionError` no longer abort collection; partial results are
|
|
26
|
+
preserved via incremental `_safe_rglob()` helper.
|
|
27
|
+
- **Cache directory access errors**: `cache load` and `cache clear` handle
|
|
28
|
+
`OSError` on cache directory access gracefully.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **Search refactoring**: duplicated traversal logic between `search_package()`
|
|
33
|
+
and `search_package_structured()` extracted into shared `_collect_search_hits()`
|
|
34
|
+
helper (~150 lines removed).
|
|
35
|
+
- **Single-pass file discovery**: `_walk_package()` and `_compute_source_stats()`
|
|
36
|
+
now use a single `rglob("*.py*")` pass instead of two separate traversals.
|
|
37
|
+
- **Single-pass variable categorisation**: `render_module()` classifies variables
|
|
38
|
+
into aliases, constants, and module vars in one loop instead of three.
|
|
39
|
+
- **Cache eviction**: replaced O(n) `pop(0)` loop with slice deletion.
|
|
40
|
+
- Cleaned up broad `except` clauses and moved late imports to module level in
|
|
41
|
+
`renderer.py`.
|
|
42
|
+
- Added missing `Raises:` docstring sections to public functions across
|
|
43
|
+
`inspector.py`, `config.py`, `_envsetup.py`, and `mcp_server.py`.
|
|
44
|
+
|
|
45
|
+
## [0.6.1] - 2026-03-25
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- **ImportError crash with `packages_distributions()`**: wrapped call in try/except to handle broken metadata or mixed Python installations (e.g. `_csv` ImportError on Python 3.13).
|
|
50
|
+
- **Python 3.9 compatibility**: fixed parenthesized context managers and `create=True` for `mock.patch`.
|
|
51
|
+
|
|
10
52
|
## [0.5.0] - 2026-03-23
|
|
11
53
|
|
|
12
54
|
### Added
|
|
@@ -101,7 +143,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
101
143
|
- Free-form `extra_context` field for library authors.
|
|
102
144
|
- Python API for programmatic usage (`collect_package`, `render_package`).
|
|
103
145
|
|
|
104
|
-
[Unreleased]: https://github.com/Syclaw/libcontext/compare/v0.
|
|
146
|
+
[Unreleased]: https://github.com/Syclaw/libcontext/compare/v0.7.0...HEAD
|
|
147
|
+
[0.7.0]: https://github.com/Syclaw/libcontext/compare/v0.6.1...v0.7.0
|
|
148
|
+
[0.6.1]: https://github.com/Syclaw/libcontext/compare/v0.6.0...v0.6.1
|
|
149
|
+
[0.6.0]: https://github.com/Syclaw/libcontext/compare/v0.5.0...v0.6.0
|
|
105
150
|
[0.5.0]: https://github.com/Syclaw/libcontext/compare/v0.4.0...v0.5.0
|
|
106
151
|
[0.4.0]: https://github.com/Syclaw/libcontext/compare/v0.3.0...v0.4.0
|
|
107
152
|
[0.3.0]: https://github.com/Syclaw/libcontext/compare/v0.2.0...v0.3.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: libcontext
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -9,8 +9,13 @@ discovery works against the target environment.
|
|
|
9
9
|
|
|
10
10
|
Detection priority:
|
|
11
11
|
1. Explicit ``--python`` argument → use that environment.
|
|
12
|
-
2.
|
|
13
|
-
3.
|
|
12
|
+
2. ``VIRTUAL_ENV`` env var → activated venv (any tool).
|
|
13
|
+
3. ``CONDA_PREFIX`` env var → activated conda environment.
|
|
14
|
+
4. ``UV_PROJECT_ENVIRONMENT`` env var → uv-specific override.
|
|
15
|
+
5. ``.venv/`` or ``venv/`` in CWD → use the detected venv.
|
|
16
|
+
6. ``uv`` fallback: if CWD has ``pyproject.toml``, query ``uv`` for the
|
|
17
|
+
project interpreter.
|
|
18
|
+
7. Neither → use the current process's environment (no injection).
|
|
14
19
|
"""
|
|
15
20
|
|
|
16
21
|
from __future__ import annotations
|
|
@@ -18,6 +23,7 @@ from __future__ import annotations
|
|
|
18
23
|
import importlib
|
|
19
24
|
import json
|
|
20
25
|
import logging
|
|
26
|
+
import os
|
|
21
27
|
import subprocess
|
|
22
28
|
import sys
|
|
23
29
|
from collections.abc import Generator
|
|
@@ -33,13 +39,36 @@ _SUBPROCESS_TIMEOUT_SECONDS = 10
|
|
|
33
39
|
|
|
34
40
|
_VENV_DIR_NAMES = (".venv", "venv")
|
|
35
41
|
|
|
42
|
+
# Relative interpreter paths inside a venv, checked in order.
|
|
43
|
+
_INTERPRETER_CANDIDATES = (
|
|
44
|
+
Path("Scripts") / "python.exe", # Windows
|
|
45
|
+
Path("bin") / "python", # Unix
|
|
46
|
+
Path("bin") / "python3", # Unix alternative
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _has_python_interpreter(venv_dir: Path) -> bool:
|
|
51
|
+
"""Check whether a directory contains a recognisable Python interpreter."""
|
|
52
|
+
return any((venv_dir / rel).is_file() for rel in _INTERPRETER_CANDIDATES)
|
|
53
|
+
|
|
36
54
|
|
|
37
55
|
def auto_detect_venv(cwd: Path | None = None) -> Path | None:
|
|
38
56
|
"""Detect a project venv in the current working directory.
|
|
39
57
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
Detection order:
|
|
59
|
+
|
|
60
|
+
1. ``VIRTUAL_ENV`` env var — set by any activated venv (virtualenv,
|
|
61
|
+
``python -m venv``, ``poetry shell``, etc.).
|
|
62
|
+
2. ``CONDA_PREFIX`` env var — set by an activated conda environment.
|
|
63
|
+
3. ``UV_PROJECT_ENVIRONMENT`` env var — used when ``uv`` is configured
|
|
64
|
+
to place the venv outside the default ``.venv/`` location.
|
|
65
|
+
4. ``.venv/`` then ``venv/`` in *cwd*.
|
|
66
|
+
5. ``uv`` fallback — if *cwd* contains a ``pyproject.toml``, query
|
|
67
|
+
``uv python find`` to locate the project interpreter and derive
|
|
68
|
+
the venv from its path.
|
|
69
|
+
|
|
70
|
+
Only considers directories that contain a recognisable Python
|
|
71
|
+
interpreter.
|
|
43
72
|
|
|
44
73
|
Args:
|
|
45
74
|
cwd: Directory to search in. Defaults to the process CWD.
|
|
@@ -50,20 +79,80 @@ def auto_detect_venv(cwd: Path | None = None) -> Path | None:
|
|
|
50
79
|
if cwd is None:
|
|
51
80
|
cwd = Path.cwd()
|
|
52
81
|
|
|
82
|
+
# 1. VIRTUAL_ENV — set by any venv activation script
|
|
83
|
+
# 2. CONDA_PREFIX — set by conda activate
|
|
84
|
+
# 3. UV_PROJECT_ENVIRONMENT — uv-specific override
|
|
85
|
+
for var in ("VIRTUAL_ENV", "CONDA_PREFIX", "UV_PROJECT_ENVIRONMENT"):
|
|
86
|
+
value = os.environ.get(var)
|
|
87
|
+
if not value:
|
|
88
|
+
continue
|
|
89
|
+
candidate = Path(value)
|
|
90
|
+
if candidate.is_dir() and _has_python_interpreter(candidate):
|
|
91
|
+
logger.debug("Auto-detected venv from %s: '%s'", var, candidate)
|
|
92
|
+
return candidate
|
|
93
|
+
logger.debug("%s='%s' set but not a valid venv", var, value)
|
|
94
|
+
|
|
95
|
+
# 4. Standard .venv/ and venv/ in CWD
|
|
53
96
|
for name in _VENV_DIR_NAMES:
|
|
54
97
|
candidate = cwd / name
|
|
55
|
-
if
|
|
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):
|
|
98
|
+
if candidate.is_dir() and _has_python_interpreter(candidate):
|
|
64
99
|
logger.debug("Auto-detected venv at '%s'", candidate)
|
|
65
100
|
return candidate
|
|
66
101
|
|
|
102
|
+
# 5. uv fallback — ask uv for the project interpreter
|
|
103
|
+
if (cwd / "pyproject.toml").is_file():
|
|
104
|
+
venv = _detect_venv_via_uv(cwd)
|
|
105
|
+
if venv is not None:
|
|
106
|
+
return venv
|
|
107
|
+
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _detect_venv_via_uv(cwd: Path) -> Path | None:
|
|
112
|
+
"""Ask ``uv`` for the project interpreter and derive the venv path.
|
|
113
|
+
|
|
114
|
+
Only called when a ``pyproject.toml`` exists in *cwd* but no
|
|
115
|
+
standard venv directory was found.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
result = subprocess.run(
|
|
119
|
+
["uv", "python", "find", "--project", str(cwd)],
|
|
120
|
+
capture_output=True,
|
|
121
|
+
text=True,
|
|
122
|
+
timeout=_SUBPROCESS_TIMEOUT_SECONDS,
|
|
123
|
+
cwd=str(cwd),
|
|
124
|
+
)
|
|
125
|
+
except FileNotFoundError:
|
|
126
|
+
logger.debug("uv not found on PATH; skipping uv-based detection")
|
|
127
|
+
return None
|
|
128
|
+
except subprocess.TimeoutExpired:
|
|
129
|
+
logger.debug("uv python find timed out; skipping uv-based detection")
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
if result.returncode != 0:
|
|
133
|
+
logger.debug(
|
|
134
|
+
"uv python find exited with code %d; skipping",
|
|
135
|
+
result.returncode,
|
|
136
|
+
)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
python_path = Path(result.stdout.strip())
|
|
140
|
+
if not python_path.is_file():
|
|
141
|
+
logger.debug("uv reported interpreter '%s' but file not found", python_path)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# Derive venv from interpreter path:
|
|
145
|
+
# .../venv/bin/python → .../venv
|
|
146
|
+
# .../venv/Scripts/python.exe → .../venv
|
|
147
|
+
venv_dir = python_path.parent.parent
|
|
148
|
+
if (venv_dir / "pyvenv.cfg").is_file():
|
|
149
|
+
logger.debug("Auto-detected venv via uv at '%s'", venv_dir)
|
|
150
|
+
return venv_dir
|
|
151
|
+
|
|
152
|
+
logger.debug(
|
|
153
|
+
"uv interpreter '%s' is not inside a venv (no pyvenv.cfg)",
|
|
154
|
+
python_path,
|
|
155
|
+
)
|
|
67
156
|
return None
|
|
68
157
|
|
|
69
158
|
|
|
@@ -151,12 +240,7 @@ def resolve_python_executable(python_arg: str) -> Path:
|
|
|
151
240
|
|
|
152
241
|
# Directory — probe for interpreter
|
|
153
242
|
if path.is_dir():
|
|
154
|
-
|
|
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:
|
|
243
|
+
for candidate in (path / rel for rel in _INTERPRETER_CANDIDATES):
|
|
160
244
|
if candidate.is_file():
|
|
161
245
|
logger.debug(
|
|
162
246
|
"Resolved venv directory '%s' to interpreter '%s'",
|
|
@@ -329,6 +413,9 @@ def env_tag_for_path(python_arg: str) -> str:
|
|
|
329
413
|
|
|
330
414
|
Returns:
|
|
331
415
|
An 8-character hex string derived from the resolved path.
|
|
416
|
+
|
|
417
|
+
Raises:
|
|
418
|
+
EnvironmentSetupError: If *python_arg* cannot be resolved.
|
|
332
419
|
"""
|
|
333
420
|
resolved = resolve_python_executable(python_arg)
|
|
334
421
|
return _env_tag_from_resolved(resolved)
|
|
@@ -82,19 +82,20 @@ def _compute_source_stats(package_path: Path) -> _SourceStats:
|
|
|
82
82
|
|
|
83
83
|
max_mtime = 0.0
|
|
84
84
|
file_count = 0
|
|
85
|
-
for
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
85
|
+
for f in package_path.rglob("*.py*"):
|
|
86
|
+
if f.suffix not in (".py", ".pyi"):
|
|
87
|
+
continue
|
|
88
|
+
if not is_within_boundary(f, package_path):
|
|
89
|
+
continue
|
|
90
|
+
if not check_file_size(f):
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
mtime = f.stat().st_mtime
|
|
94
|
+
if mtime > max_mtime:
|
|
95
|
+
max_mtime = mtime
|
|
96
|
+
file_count += 1
|
|
97
|
+
except OSError:
|
|
98
|
+
continue
|
|
98
99
|
return _SourceStats(max_mtime=max_mtime, file_count=file_count)
|
|
99
100
|
|
|
100
101
|
|
|
@@ -128,8 +129,12 @@ def load(
|
|
|
128
129
|
"""
|
|
129
130
|
cache_file = _get_cache_dir() / _cache_filename(package_name, version, env_tag)
|
|
130
131
|
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
try:
|
|
133
|
+
if not cache_file.is_file():
|
|
134
|
+
logger.debug("Cache miss for %r: file not found", package_name)
|
|
135
|
+
return None
|
|
136
|
+
except OSError:
|
|
137
|
+
logger.debug("Cache miss for %r: cannot access cache dir", package_name)
|
|
133
138
|
return None
|
|
134
139
|
|
|
135
140
|
try:
|
|
@@ -232,11 +237,13 @@ def _evict_oldest(cache_dir: Path) -> None:
|
|
|
232
237
|
entries.append((f, f.stat().st_mtime))
|
|
233
238
|
except OSError:
|
|
234
239
|
continue
|
|
240
|
+
if len(entries) <= _MAX_CACHE_ENTRIES:
|
|
241
|
+
return
|
|
235
242
|
entries.sort(key=lambda x: x[1])
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
logger.debug("Evicting cache entry: %s",
|
|
239
|
-
_safe_delete(
|
|
243
|
+
to_remove = entries[: len(entries) - _MAX_CACHE_ENTRIES]
|
|
244
|
+
for path, _ in to_remove:
|
|
245
|
+
logger.debug("Evicting cache entry: %s", path.name)
|
|
246
|
+
_safe_delete(path)
|
|
240
247
|
|
|
241
248
|
|
|
242
249
|
# ---------------------------------------------------------------------------
|
|
@@ -252,7 +259,11 @@ def clear_all() -> int:
|
|
|
252
259
|
"""
|
|
253
260
|
cache_dir = _get_cache_dir()
|
|
254
261
|
count = 0
|
|
255
|
-
|
|
262
|
+
try:
|
|
263
|
+
entries = list(cache_dir.glob("*.json"))
|
|
264
|
+
except OSError:
|
|
265
|
+
return 0
|
|
266
|
+
for f in entries:
|
|
256
267
|
_safe_delete(f)
|
|
257
268
|
count += 1
|
|
258
269
|
return count
|
|
@@ -47,8 +47,20 @@ def _get_installed_package_names() -> list[str]:
|
|
|
47
47
|
names: set[str] = set()
|
|
48
48
|
|
|
49
49
|
if sys.version_info >= (3, 11):
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
try:
|
|
51
|
+
for import_name in importlib.metadata.packages_distributions():
|
|
52
|
+
names.add(import_name)
|
|
53
|
+
except Exception:
|
|
54
|
+
# Intentionally broad: packages_distributions() can fail
|
|
55
|
+
# in unpredictable ways depending on Python version and
|
|
56
|
+
# environment state (ImportError from broken stdlib
|
|
57
|
+
# modules, RuntimeError from corrupted metadata, etc.).
|
|
58
|
+
# The distributions()-based fallback below is more
|
|
59
|
+
# resilient, so any failure here is non-critical.
|
|
60
|
+
logger.debug(
|
|
61
|
+
"packages_distributions() failed; falling back to distributions() only",
|
|
62
|
+
exc_info=True,
|
|
63
|
+
)
|
|
52
64
|
|
|
53
65
|
seen_distributions: set[str] = set()
|
|
54
66
|
for dist in importlib.metadata.distributions():
|
|
@@ -63,7 +75,10 @@ def _get_installed_package_names() -> list[str]:
|
|
|
63
75
|
if normalized != dist_name:
|
|
64
76
|
names.add(normalized)
|
|
65
77
|
|
|
66
|
-
|
|
78
|
+
try:
|
|
79
|
+
top_level = dist.read_text("top_level.txt")
|
|
80
|
+
except OSError:
|
|
81
|
+
top_level = None
|
|
67
82
|
if top_level:
|
|
68
83
|
for line in top_level.strip().splitlines():
|
|
69
84
|
entry = line.strip()
|
|
@@ -397,6 +412,25 @@ def _find_readme(package_name: str, package_path: Path | None) -> str | None:
|
|
|
397
412
|
# ---------------------------------------------------------------------------
|
|
398
413
|
|
|
399
414
|
|
|
415
|
+
def _safe_rglob(root: Path, pattern: str) -> list[Path]:
|
|
416
|
+
"""Like ``sorted(root.rglob(pattern))`` but tolerant of permission errors.
|
|
417
|
+
|
|
418
|
+
Collects as many matching files as possible before the first
|
|
419
|
+
inaccessible directory terminates the generator.
|
|
420
|
+
"""
|
|
421
|
+
results: list[Path] = []
|
|
422
|
+
try:
|
|
423
|
+
for path in root.rglob(pattern):
|
|
424
|
+
results.append(path)
|
|
425
|
+
except PermissionError:
|
|
426
|
+
logger.warning(
|
|
427
|
+
"Permission denied while traversing '%s'; some modules may be missing",
|
|
428
|
+
root,
|
|
429
|
+
)
|
|
430
|
+
results.sort()
|
|
431
|
+
return results
|
|
432
|
+
|
|
433
|
+
|
|
400
434
|
def _is_safe_source_file(file_path: Path, root: Path) -> bool:
|
|
401
435
|
"""Check that a source file is safe to read.
|
|
402
436
|
|
|
@@ -509,31 +543,25 @@ def _walk_package(
|
|
|
509
543
|
# key = relative path without extension -> (py_path, pyi_path, stub_source)
|
|
510
544
|
file_map: dict[str, tuple[Path | None, Path | None, str]] = {}
|
|
511
545
|
|
|
512
|
-
for
|
|
513
|
-
if not
|
|
546
|
+
for source_file in _safe_rglob(package_path, "*.py*"):
|
|
547
|
+
if source_file.suffix not in (".py", ".pyi"):
|
|
514
548
|
continue
|
|
515
|
-
|
|
516
|
-
parts = relative.parts
|
|
517
|
-
if _should_skip_path(parts, include_private=config.include_private):
|
|
549
|
+
if not _is_safe_source_file(source_file, package_path):
|
|
518
550
|
continue
|
|
519
|
-
|
|
520
|
-
file_map[key] = (py_file, None, "")
|
|
521
|
-
|
|
522
|
-
# Colocated .pyi files
|
|
523
|
-
for pyi_file in sorted(package_path.rglob("*.pyi")):
|
|
524
|
-
if not _is_safe_source_file(pyi_file, package_path):
|
|
525
|
-
continue
|
|
526
|
-
relative = pyi_file.relative_to(package_path)
|
|
551
|
+
relative = source_file.relative_to(package_path)
|
|
527
552
|
parts = relative.parts
|
|
528
553
|
if _should_skip_path(parts, include_private=config.include_private):
|
|
529
554
|
continue
|
|
530
555
|
key = str(relative.with_suffix(""))
|
|
531
|
-
|
|
532
|
-
|
|
556
|
+
if source_file.suffix == ".py":
|
|
557
|
+
file_map[key] = (source_file, None, "")
|
|
558
|
+
else:
|
|
559
|
+
existing = file_map.get(key, (None, None, ""))
|
|
560
|
+
file_map[key] = (existing[0], source_file, "colocated")
|
|
533
561
|
|
|
534
562
|
# Standalone stub .pyi files
|
|
535
563
|
if stub_path is not None:
|
|
536
|
-
for pyi_file in
|
|
564
|
+
for pyi_file in _safe_rglob(stub_path, "*.pyi"):
|
|
537
565
|
if not _is_safe_source_file(pyi_file, stub_path):
|
|
538
566
|
continue
|
|
539
567
|
relative = pyi_file.relative_to(stub_path)
|
|
@@ -41,7 +41,7 @@ class LibcontextConfig:
|
|
|
41
41
|
"""Create config from a dictionary (e.g. parsed TOML section).
|
|
42
42
|
|
|
43
43
|
Raises:
|
|
44
|
-
|
|
44
|
+
ConfigError: If a value has an unexpected type.
|
|
45
45
|
"""
|
|
46
46
|
include_modules = data.get("include_modules", [])
|
|
47
47
|
exclude_modules = data.get("exclude_modules", [])
|
|
@@ -120,6 +120,9 @@ def read_config_from_pyproject(pyproject_path: Path) -> LibcontextConfig:
|
|
|
120
120
|
|
|
121
121
|
Returns:
|
|
122
122
|
LibcontextConfig parsed from the file, or defaults if not found.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ConfigError: If a config value has an unexpected type.
|
|
123
126
|
"""
|
|
124
127
|
data = _load_toml(pyproject_path)
|
|
125
128
|
tool_config = data.get("tool", {}).get("libcontext", {})
|
|
@@ -36,7 +36,11 @@ class PackageNotFoundError(LibcontextError):
|
|
|
36
36
|
joined = ", ".join(self.suggestions)
|
|
37
37
|
msg += f" Did you mean: {joined}?"
|
|
38
38
|
else:
|
|
39
|
-
msg +=
|
|
39
|
+
msg += (
|
|
40
|
+
" Make sure it is installed in the current environment."
|
|
41
|
+
" If installed in a project venv, run from the project"
|
|
42
|
+
" directory or use --python <path-to-venv>."
|
|
43
|
+
)
|
|
40
44
|
super().__init__(msg)
|
|
41
45
|
|
|
42
46
|
|
|
@@ -274,6 +274,9 @@ def main() -> None:
|
|
|
274
274
|
2. ``LIBCONTEXT_PYTHON`` env var → use that environment.
|
|
275
275
|
3. Auto-detect ``.venv/`` or ``venv/`` in CWD → use the detected venv.
|
|
276
276
|
4. None of the above → use the current process's environment.
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
EnvironmentSetupError: If an explicit ``--python`` path is invalid.
|
|
277
280
|
"""
|
|
278
281
|
import os
|
|
279
282
|
import sys as _sys
|