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.
Files changed (45) hide show
  1. {libcontext-0.6.1 → libcontext-0.7.0}/CHANGELOG.md +37 -1
  2. {libcontext-0.6.1 → libcontext-0.7.0}/PKG-INFO +1 -1
  3. {libcontext-0.6.1 → libcontext-0.7.0}/pyproject.toml +1 -1
  4. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/_envsetup.py +107 -20
  5. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/cache.py +31 -20
  6. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/collector.py +40 -24
  7. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/config.py +4 -1
  8. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/exceptions.py +5 -1
  9. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/inspector.py +3 -0
  10. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/mcp_server.py +3 -0
  11. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/renderer.py +275 -293
  12. {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_envsetup.py +64 -29
  13. {libcontext-0.6.1 → libcontext-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  14. {libcontext-0.6.1 → libcontext-0.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  15. {libcontext-0.6.1 → libcontext-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  16. {libcontext-0.6.1 → libcontext-0.7.0}/.github/workflows/ci.yml +0 -0
  17. {libcontext-0.6.1 → libcontext-0.7.0}/.github/workflows/release.yml +0 -0
  18. {libcontext-0.6.1 → libcontext-0.7.0}/.gitignore +0 -0
  19. {libcontext-0.6.1 → libcontext-0.7.0}/CONTRIBUTING.md +0 -0
  20. {libcontext-0.6.1 → libcontext-0.7.0}/DEPENDENCIES.md +0 -0
  21. {libcontext-0.6.1 → libcontext-0.7.0}/LICENSE +0 -0
  22. {libcontext-0.6.1 → libcontext-0.7.0}/README.md +0 -0
  23. {libcontext-0.6.1 → libcontext-0.7.0}/SECURITY.md +0 -0
  24. {libcontext-0.6.1 → libcontext-0.7.0}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
  25. {libcontext-0.6.1 → libcontext-0.7.0}/docs/adr/002-skill-plus-cli-as-primary-integration.md +0 -0
  26. {libcontext-0.6.1 → libcontext-0.7.0}/docs/adr/004-ast-only-inspection.md +0 -0
  27. {libcontext-0.6.1 → libcontext-0.7.0}/docs/adr/README.md +0 -0
  28. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/__init__.py +0 -0
  29. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/_security.py +0 -0
  30. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/cli.py +0 -0
  31. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/diff.py +0 -0
  32. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/models.py +0 -0
  33. {libcontext-0.6.1 → libcontext-0.7.0}/src/libcontext/py.typed +0 -0
  34. {libcontext-0.6.1 → libcontext-0.7.0}/tests/__init__.py +0 -0
  35. {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_cache.py +0 -0
  36. {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_cli.py +0 -0
  37. {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_cli_mcp_parity.py +0 -0
  38. {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_collector.py +0 -0
  39. {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_config.py +0 -0
  40. {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_diff.py +0 -0
  41. {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_inspector.py +0 -0
  42. {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_mcp_server.py +0 -0
  43. {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_models.py +0 -0
  44. {libcontext-0.6.1 → libcontext-0.7.0}/tests/test_renderer.py +0 -0
  45. {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.6.1...HEAD
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.6.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "libcontext"
7
- version = "0.6.1"
7
+ version = "0.7.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"
@@ -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. ``.venv/`` or ``venv/`` in CWDuse the detected venv.
13
- 3. Neither use the current process's environment (no injection).
12
+ 2. ``VIRTUAL_ENV`` env varactivated 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
- Checks for ``.venv/`` then ``venv/`` in *cwd* (defaults to
41
- ``Path.cwd()``). Only considers directories that contain a
42
- recognisable Python interpreter.
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 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):
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
- 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:
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 pattern in ("*.py", "*.pyi"):
86
- for f in package_path.rglob(pattern):
87
- if not is_within_boundary(f, package_path):
88
- continue
89
- if not check_file_size(f):
90
- continue
91
- try:
92
- mtime = f.stat().st_mtime
93
- if mtime > max_mtime:
94
- max_mtime = mtime
95
- file_count += 1
96
- except OSError:
97
- continue
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
- if not cache_file.is_file():
132
- logger.debug("Cache miss for %r: file not found", package_name)
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
- while len(entries) > _MAX_CACHE_ENTRIES:
237
- oldest_path, _ = entries.pop(0)
238
- logger.debug("Evicting cache entry: %s", oldest_path.name)
239
- _safe_delete(oldest_path)
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
- for f in cache_dir.glob("*.json"):
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 (ImportError, Exception):
54
- # packages_distributions() may fail when the environment
55
- # contains distributions with broken metadata or when a
56
- # mixed Python installation causes an ImportError inside
57
- # stdlib modules (e.g. csv). Fall through to the
58
- # distributions()-based collection below which is more
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
- top_level = dist.read_text("top_level.txt")
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 py_file in sorted(package_path.rglob("*.py")):
525
- if not _is_safe_source_file(py_file, package_path):
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
- key = str(relative.with_suffix(""))
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 = pyi_file.relative_to(package_path)
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
- existing = file_map.get(key, (None, None, ""))
544
- file_map[key] = (existing[0], pyi_file, "colocated")
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 sorted(stub_path.rglob("*.pyi")):
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
- TypeError: If a value has an unexpected type.
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 += " Make sure it is installed in the current environment."
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
 
@@ -353,6 +353,9 @@ def inspect_source(
353
353
 
354
354
  Returns:
355
355
  ModuleInfo containing all extracted components.
356
+
357
+ Raises:
358
+ SyntaxError: If *source* is not valid Python.
356
359
  """
357
360
  tree = ast.parse(source)
358
361
 
@@ -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