libcontext 0.7.0__tar.gz → 0.7.2__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.7.0 → libcontext-0.7.2}/PKG-INFO +1 -1
  2. {libcontext-0.7.0 → libcontext-0.7.2}/pyproject.toml +1 -1
  3. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/_envsetup.py +54 -6
  4. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/mcp_server.py +32 -3
  5. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_envsetup.py +27 -0
  6. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_mcp_server.py +54 -0
  7. {libcontext-0.7.0 → libcontext-0.7.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  8. {libcontext-0.7.0 → libcontext-0.7.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  9. {libcontext-0.7.0 → libcontext-0.7.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  10. {libcontext-0.7.0 → libcontext-0.7.2}/.github/workflows/ci.yml +0 -0
  11. {libcontext-0.7.0 → libcontext-0.7.2}/.github/workflows/release.yml +0 -0
  12. {libcontext-0.7.0 → libcontext-0.7.2}/.gitignore +0 -0
  13. {libcontext-0.7.0 → libcontext-0.7.2}/CHANGELOG.md +0 -0
  14. {libcontext-0.7.0 → libcontext-0.7.2}/CONTRIBUTING.md +0 -0
  15. {libcontext-0.7.0 → libcontext-0.7.2}/DEPENDENCIES.md +0 -0
  16. {libcontext-0.7.0 → libcontext-0.7.2}/LICENSE +0 -0
  17. {libcontext-0.7.0 → libcontext-0.7.2}/README.md +0 -0
  18. {libcontext-0.7.0 → libcontext-0.7.2}/SECURITY.md +0 -0
  19. {libcontext-0.7.0 → libcontext-0.7.2}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
  20. {libcontext-0.7.0 → libcontext-0.7.2}/docs/adr/002-skill-plus-cli-as-primary-integration.md +0 -0
  21. {libcontext-0.7.0 → libcontext-0.7.2}/docs/adr/004-ast-only-inspection.md +0 -0
  22. {libcontext-0.7.0 → libcontext-0.7.2}/docs/adr/README.md +0 -0
  23. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/__init__.py +0 -0
  24. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/_security.py +0 -0
  25. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/cache.py +0 -0
  26. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/cli.py +0 -0
  27. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/collector.py +0 -0
  28. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/config.py +0 -0
  29. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/diff.py +0 -0
  30. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/exceptions.py +0 -0
  31. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/inspector.py +0 -0
  32. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/models.py +0 -0
  33. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/py.typed +0 -0
  34. {libcontext-0.7.0 → libcontext-0.7.2}/src/libcontext/renderer.py +0 -0
  35. {libcontext-0.7.0 → libcontext-0.7.2}/tests/__init__.py +0 -0
  36. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_cache.py +0 -0
  37. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_cli.py +0 -0
  38. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_cli_mcp_parity.py +0 -0
  39. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_collector.py +0 -0
  40. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_config.py +0 -0
  41. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_diff.py +0 -0
  42. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_inspector.py +0 -0
  43. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_models.py +0 -0
  44. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_renderer.py +0 -0
  45. {libcontext-0.7.0 → libcontext-0.7.2}/tests/test_security.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: libcontext
3
- Version: 0.7.0
3
+ Version: 0.7.2
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.7.0"
7
+ version = "0.7.2"
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"
@@ -46,6 +46,50 @@ _INTERPRETER_CANDIDATES = (
46
46
  Path("bin") / "python3", # Unix alternative
47
47
  )
48
48
 
49
+ # Script executed in the *target* interpreter to collect site-packages
50
+ # directories. Kept as a module constant so it can be tested directly.
51
+ # Uses only stdlib modules guaranteed present in Python 3.9+.
52
+ _SITE_PACKAGES_SCRIPT = """\
53
+ import json, site, sys, os
54
+
55
+ paths = []
56
+
57
+ # site-packages directories (includes system site-packages when the
58
+ # venv was created with --system-site-packages)
59
+ try:
60
+ paths.extend(site.getsitepackages())
61
+ except AttributeError:
62
+ pass
63
+
64
+ # User site-packages (e.g. ~/.local/lib/python3.x/site-packages),
65
+ # honoured only when ENABLE_USER_SITE is not explicitly disabled.
66
+ if site.ENABLE_USER_SITE:
67
+ try:
68
+ user = site.getusersitepackages()
69
+ if isinstance(user, str):
70
+ paths.append(user)
71
+ except AttributeError:
72
+ pass
73
+
74
+ # .pth files in site-packages can inject arbitrary paths into sys.path
75
+ # (editable installs, namespace packages, etc.). Collect them by
76
+ # diffing sys.path against site-packages — any path that is not a
77
+ # site-packages dir and not under sys.base_prefix is .pth-injected.
78
+ site_set = set(os.path.realpath(p) for p in paths)
79
+ base = os.path.realpath(sys.base_prefix)
80
+ for p in sys.path:
81
+ if not p:
82
+ continue
83
+ rp = os.path.realpath(p)
84
+ if rp in site_set:
85
+ continue
86
+ if rp == base or rp.startswith(base + os.sep):
87
+ continue
88
+ paths.append(p)
89
+
90
+ print(json.dumps(paths))
91
+ """
92
+
49
93
 
50
94
  def _has_python_interpreter(venv_dir: Path) -> bool:
51
95
  """Check whether a directory contains a recognisable Python interpreter."""
@@ -262,22 +306,26 @@ def resolve_python_executable(python_arg: str) -> Path:
262
306
 
263
307
 
264
308
  def get_target_sys_path(python_exe: Path) -> list[str]:
265
- """Query a Python interpreter for its ``sys.path``.
309
+ """Query a target interpreter for its package-discovery paths.
266
310
 
267
- Runs the interpreter in a subprocess with a short timeout to
268
- extract the full search path, including ``.pth`` expansions and
269
- site-packages.
311
+ Collects site-packages directories (including system site-packages
312
+ for ``--system-site-packages`` venvs), user site-packages, and
313
+ any paths injected by ``.pth`` files (editable installs, namespace
314
+ packages). Stdlib paths are **excluded** to prevent cross-version
315
+ import contamination when tool and target run different Python
316
+ versions.
270
317
 
271
318
  Args:
272
319
  python_exe: Absolute path to the target Python executable.
273
320
 
274
321
  Returns:
275
- List of path strings from the target interpreter's ``sys.path``.
322
+ List of path strings suitable for prepending to ``sys.path``
323
+ in the tool process.
276
324
 
277
325
  Raises:
278
326
  EnvironmentSetupError: If the subprocess fails or times out.
279
327
  """
280
- script = "import sys, json; print(json.dumps(sys.path))"
328
+ script = _SITE_PACKAGES_SCRIPT
281
329
  try:
282
330
  result = subprocess.run(
283
331
  [str(python_exe), "-c", script],
@@ -32,7 +32,12 @@ from ._security import truncate_output
32
32
  from .collector import collect_package
33
33
  from .diff import diff_packages
34
34
  from .exceptions import PackageNotFoundError
35
- from .models import PackageInfo, _deserialize_envelope, _serialize_envelope
35
+ from .models import (
36
+ ModuleInfo,
37
+ PackageInfo,
38
+ _deserialize_envelope,
39
+ _serialize_envelope,
40
+ )
36
41
  from .renderer import (
37
42
  render_diff,
38
43
  render_module,
@@ -79,6 +84,30 @@ def _invalidate_cache() -> None:
79
84
  _collect_cached.cache_clear()
80
85
 
81
86
 
87
+ def _is_module_snapshot(data: dict[str, object]) -> bool:
88
+ """Return True if *data* looks like a ModuleInfo dict, not PackageInfo."""
89
+ return "modules" not in data and (
90
+ "functions" in data or "classes" in data or "variables" in data
91
+ )
92
+
93
+
94
+ def _coerce_to_package(data: dict[str, object]) -> PackageInfo:
95
+ """Build a PackageInfo from either a package or module snapshot dict.
96
+
97
+ When ``get_api_json`` is called with a ``module_name``, the envelope
98
+ contains a single ModuleInfo dict. This helper wraps it in a
99
+ synthetic PackageInfo so that ``diff_packages`` can compare them.
100
+ """
101
+ if _is_module_snapshot(data):
102
+ module_name: str = data.get("name", "unknown") # type: ignore[assignment]
103
+ pkg_name = module_name.split(".")[0] if "." in module_name else module_name
104
+ return PackageInfo(
105
+ name=pkg_name,
106
+ modules=[ModuleInfo.from_dict(data)], # type: ignore[arg-type]
107
+ )
108
+ return PackageInfo.from_dict(data)
109
+
110
+
82
111
  # ---------------------------------------------------------------------------
83
112
  # MCP Tools
84
113
  # ---------------------------------------------------------------------------
@@ -237,8 +266,8 @@ def diff_api(old_json: str, new_json: str, output_format: str = "markdown") -> s
237
266
  except ValueError as exc:
238
267
  return f"Error: {exc}"
239
268
 
240
- old_pkg = PackageInfo.from_dict(old_data)
241
- new_pkg = PackageInfo.from_dict(new_data)
269
+ old_pkg = _coerce_to_package(old_data)
270
+ new_pkg = _coerce_to_package(new_data)
242
271
  result = diff_packages(old_pkg, new_pkg)
243
272
 
244
273
  if output_format == "json":
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import sys
6
7
  from pathlib import Path
7
8
 
@@ -81,6 +82,32 @@ def test_get_target_sys_path_bad_executable(tmp_path):
81
82
  get_target_sys_path(fake)
82
83
 
83
84
 
85
+ def test_get_target_sys_path_returns_site_packages_not_stdlib():
86
+ """Returns site-packages paths and excludes base-interpreter stdlib."""
87
+ paths = get_target_sys_path(Path(sys.executable))
88
+
89
+ base = os.path.realpath(sys.base_prefix)
90
+ prefix = os.path.realpath(sys.prefix)
91
+ if prefix == base:
92
+ pytest.skip("not running inside a venv")
93
+
94
+ # At least one site-packages directory must be present.
95
+ assert any("site-packages" in p for p in paths), (
96
+ f"no site-packages found in returned paths: {paths}"
97
+ )
98
+
99
+ # No path should resolve under the base Python installation
100
+ # (stdlib, lib-dynload, zip archives) unless it is also under
101
+ # the venv prefix.
102
+ def _under(child: str, root: str) -> bool:
103
+ return child == root or child.startswith(root + os.sep)
104
+
105
+ for p in paths:
106
+ rp = os.path.realpath(p)
107
+ if _under(rp, base) and not _under(rp, prefix):
108
+ pytest.fail(f"base-interpreter stdlib path leaked: {p}")
109
+
110
+
84
111
  # ---------------------------------------------------------------------------
85
112
  # inject_target_environment
86
113
  # ---------------------------------------------------------------------------
@@ -362,6 +362,60 @@ class TestDiffApi:
362
362
  result = mcp_server.diff_api("{}", "{}")
363
363
  assert "Error:" in result
364
364
 
365
+ def test_module_level_snapshots(self):
366
+ """diff_api accepts module-level JSON produced by get_api_json."""
367
+ import dataclasses
368
+
369
+ from libcontext.models import _serialize_envelope
370
+
371
+ old_mod = ModuleInfo(
372
+ name="fakepkg.utils",
373
+ functions=[
374
+ FunctionInfo(
375
+ name="parse",
376
+ parameters=[ParameterInfo(name="text", annotation="str")],
377
+ return_annotation="dict",
378
+ ),
379
+ ],
380
+ )
381
+ new_mod = ModuleInfo(
382
+ name="fakepkg.utils",
383
+ functions=[
384
+ FunctionInfo(
385
+ name="parse",
386
+ parameters=[
387
+ ParameterInfo(name="text", annotation="str"),
388
+ ParameterInfo(name="strict", annotation="bool"),
389
+ ],
390
+ return_annotation="dict",
391
+ ),
392
+ ],
393
+ )
394
+
395
+ old_json = json.dumps(_serialize_envelope(dataclasses.asdict(old_mod)))
396
+ new_json = json.dumps(_serialize_envelope(dataclasses.asdict(new_mod)))
397
+ result = mcp_server.diff_api(old_json, new_json)
398
+
399
+ assert "strict" in result
400
+ assert "Breaking" in result
401
+
402
+ def test_module_level_no_changes(self):
403
+ """Identical module snapshots produce 'No changes'."""
404
+ import dataclasses
405
+
406
+ from libcontext.models import _serialize_envelope
407
+
408
+ mod = ModuleInfo(
409
+ name="fakepkg.utils",
410
+ functions=[
411
+ FunctionInfo(name="parse", parameters=[], return_annotation="dict"),
412
+ ],
413
+ )
414
+ mod_json = json.dumps(_serialize_envelope(dataclasses.asdict(mod)))
415
+ result = mcp_server.diff_api(mod_json, mod_json)
416
+
417
+ assert "No changes" in result
418
+
365
419
 
366
420
  # ---------------------------------------------------------------------------
367
421
  # refresh_cache
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes