libcontext 0.6.1__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.1 → libcontext-0.7.0}/CHANGELOG.md +37 -1
- {libcontext-0.6.1 → libcontext-0.7.0}/PKG-INFO +1 -1
- {libcontext-0.6.1 → libcontext-0.7.0}/pyproject.toml +1 -1
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/_envsetup.py +107 -20
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/cache.py +31 -20
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/collector.py +40 -24
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/config.py +4 -1
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/exceptions.py +5 -1
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/inspector.py +3 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/mcp_server.py +3 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/renderer.py +275 -293
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_envsetup.py +64 -29
- {libcontext-0.6.1 → libcontext-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/.github/workflows/ci.yml +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/.github/workflows/release.yml +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/.gitignore +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/CONTRIBUTING.md +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/DEPENDENCIES.md +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/LICENSE +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/README.md +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/SECURITY.md +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/docs/adr/002-skill-plus-cli-as-primary-integration.md +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/docs/adr/004-ast-only-inspection.md +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/docs/adr/README.md +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/__init__.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/_security.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/cli.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/diff.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/models.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/py.typed +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/__init__.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_cache.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_cli.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_cli_mcp_parity.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_collector.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_config.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_diff.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_inspector.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_mcp_server.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_models.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_renderer.py +0 -0
- {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_security.py +0 -0
|
@@ -7,6 +7,41 @@ 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
|
+
|
|
10
45
|
## [0.6.1] - 2026-03-25
|
|
11
46
|
|
|
12
47
|
### Fixed
|
|
@@ -108,7 +143,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
108
143
|
- Free-form `extra_context` field for library authors.
|
|
109
144
|
- Python API for programmatic usage (`collect_package`, `render_package`).
|
|
110
145
|
|
|
111
|
-
[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
|
|
112
148
|
[0.6.1]: https://github.com/Syclaw/libcontext/compare/v0.6.0...v0.6.1
|
|
113
149
|
[0.6.0]: https://github.com/Syclaw/libcontext/compare/v0.5.0...v0.6.0
|
|
114
150
|
[0.5.0]: https://github.com/Syclaw/libcontext/compare/v0.4.0...v0.5.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
|
|
@@ -50,13 +50,13 @@ def _get_installed_package_names() -> list[str]:
|
|
|
50
50
|
try:
|
|
51
51
|
for import_name in importlib.metadata.packages_distributions():
|
|
52
52
|
names.add(import_name)
|
|
53
|
-
except
|
|
54
|
-
# packages_distributions()
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
# distributions()-based
|
|
59
|
-
# resilient.
|
|
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
60
|
logger.debug(
|
|
61
61
|
"packages_distributions() failed; falling back to distributions() only",
|
|
62
62
|
exc_info=True,
|
|
@@ -75,7 +75,10 @@ def _get_installed_package_names() -> list[str]:
|
|
|
75
75
|
if normalized != dist_name:
|
|
76
76
|
names.add(normalized)
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
try:
|
|
79
|
+
top_level = dist.read_text("top_level.txt")
|
|
80
|
+
except OSError:
|
|
81
|
+
top_level = None
|
|
79
82
|
if top_level:
|
|
80
83
|
for line in top_level.strip().splitlines():
|
|
81
84
|
entry = line.strip()
|
|
@@ -409,6 +412,25 @@ def _find_readme(package_name: str, package_path: Path | None) -> str | None:
|
|
|
409
412
|
# ---------------------------------------------------------------------------
|
|
410
413
|
|
|
411
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
|
+
|
|
412
434
|
def _is_safe_source_file(file_path: Path, root: Path) -> bool:
|
|
413
435
|
"""Check that a source file is safe to read.
|
|
414
436
|
|
|
@@ -521,31 +543,25 @@ def _walk_package(
|
|
|
521
543
|
# key = relative path without extension -> (py_path, pyi_path, stub_source)
|
|
522
544
|
file_map: dict[str, tuple[Path | None, Path | None, str]] = {}
|
|
523
545
|
|
|
524
|
-
for
|
|
525
|
-
if not
|
|
526
|
-
continue
|
|
527
|
-
relative = py_file.relative_to(package_path)
|
|
528
|
-
parts = relative.parts
|
|
529
|
-
if _should_skip_path(parts, include_private=config.include_private):
|
|
546
|
+
for source_file in _safe_rglob(package_path, "*.py*"):
|
|
547
|
+
if source_file.suffix not in (".py", ".pyi"):
|
|
530
548
|
continue
|
|
531
|
-
|
|
532
|
-
file_map[key] = (py_file, None, "")
|
|
533
|
-
|
|
534
|
-
# Colocated .pyi files
|
|
535
|
-
for pyi_file in sorted(package_path.rglob("*.pyi")):
|
|
536
|
-
if not _is_safe_source_file(pyi_file, package_path):
|
|
549
|
+
if not _is_safe_source_file(source_file, package_path):
|
|
537
550
|
continue
|
|
538
|
-
relative =
|
|
551
|
+
relative = source_file.relative_to(package_path)
|
|
539
552
|
parts = relative.parts
|
|
540
553
|
if _should_skip_path(parts, include_private=config.include_private):
|
|
541
554
|
continue
|
|
542
555
|
key = str(relative.with_suffix(""))
|
|
543
|
-
|
|
544
|
-
|
|
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")
|
|
545
561
|
|
|
546
562
|
# Standalone stub .pyi files
|
|
547
563
|
if stub_path is not None:
|
|
548
|
-
for pyi_file in
|
|
564
|
+
for pyi_file in _safe_rglob(stub_path, "*.pyi"):
|
|
549
565
|
if not _is_safe_source_file(pyi_file, stub_path):
|
|
550
566
|
continue
|
|
551
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
|