libcontext 0.7.4__tar.gz → 0.8.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 (46) hide show
  1. {libcontext-0.7.4 → libcontext-0.8.0}/.github/workflows/ci.yml +2 -2
  2. {libcontext-0.7.4 → libcontext-0.8.0}/CHANGELOG.md +28 -1
  3. {libcontext-0.7.4 → libcontext-0.8.0}/CONTRIBUTING.md +1 -1
  4. {libcontext-0.7.4 → libcontext-0.8.0}/PKG-INFO +3 -4
  5. {libcontext-0.7.4 → libcontext-0.8.0}/docs/adr/002-skill-plus-cli-as-primary-integration.md +3 -3
  6. {libcontext-0.7.4 → libcontext-0.8.0}/pyproject.toml +9 -7
  7. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/__init__.py +0 -2
  8. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/_envsetup.py +16 -7
  9. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/_security.py +0 -2
  10. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/cache.py +0 -2
  11. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/cli.py +44 -37
  12. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/collector.py +25 -13
  13. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/config.py +38 -5
  14. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/diff.py +0 -2
  15. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/exceptions.py +0 -2
  16. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/inspector.py +0 -2
  17. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/mcp_server.py +22 -12
  18. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/models.py +12 -14
  19. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/renderer.py +0 -2
  20. {libcontext-0.7.4 → libcontext-0.8.0}/tests/test_cache.py +69 -2
  21. {libcontext-0.7.4 → libcontext-0.8.0}/tests/test_cli.py +0 -2
  22. libcontext-0.8.0/tests/test_cli_mcp_parity.py +394 -0
  23. {libcontext-0.7.4 → libcontext-0.8.0}/tests/test_collector.py +483 -2
  24. {libcontext-0.7.4 → libcontext-0.8.0}/tests/test_config.py +134 -2
  25. {libcontext-0.7.4 → libcontext-0.8.0}/tests/test_diff.py +0 -2
  26. {libcontext-0.7.4 → libcontext-0.8.0}/tests/test_envsetup.py +197 -5
  27. {libcontext-0.7.4 → libcontext-0.8.0}/tests/test_inspector.py +0 -2
  28. {libcontext-0.7.4 → libcontext-0.8.0}/tests/test_mcp_server.py +0 -2
  29. {libcontext-0.7.4 → libcontext-0.8.0}/tests/test_models.py +0 -2
  30. {libcontext-0.7.4 → libcontext-0.8.0}/tests/test_renderer.py +0 -2
  31. {libcontext-0.7.4 → libcontext-0.8.0}/tests/test_security.py +0 -2
  32. libcontext-0.7.4/tests/test_cli_mcp_parity.py +0 -185
  33. {libcontext-0.7.4 → libcontext-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  34. {libcontext-0.7.4 → libcontext-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  35. {libcontext-0.7.4 → libcontext-0.8.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  36. {libcontext-0.7.4 → libcontext-0.8.0}/.github/workflows/release.yml +0 -0
  37. {libcontext-0.7.4 → libcontext-0.8.0}/.gitignore +0 -0
  38. {libcontext-0.7.4 → libcontext-0.8.0}/DEPENDENCIES.md +0 -0
  39. {libcontext-0.7.4 → libcontext-0.8.0}/LICENSE +0 -0
  40. {libcontext-0.7.4 → libcontext-0.8.0}/README.md +0 -0
  41. {libcontext-0.7.4 → libcontext-0.8.0}/SECURITY.md +0 -0
  42. {libcontext-0.7.4 → libcontext-0.8.0}/docs/adr/001-progressive-disclosure-over-always-on-context.md +0 -0
  43. {libcontext-0.7.4 → libcontext-0.8.0}/docs/adr/004-ast-only-inspection.md +0 -0
  44. {libcontext-0.7.4 → libcontext-0.8.0}/docs/adr/README.md +0 -0
  45. {libcontext-0.7.4 → libcontext-0.8.0}/src/libcontext/py.typed +0 -0
  46. {libcontext-0.7.4 → libcontext-0.8.0}/tests/__init__.py +0 -0
@@ -18,7 +18,7 @@ jobs:
18
18
  fail-fast: false
19
19
  matrix:
20
20
  os: [ubuntu-latest, windows-latest, macos-latest]
21
- python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
21
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
22
22
 
23
23
  steps:
24
24
  - uses: actions/checkout@v4
@@ -33,7 +33,7 @@ jobs:
33
33
  run: uv sync --group dev
34
34
 
35
35
  - name: Run tests
36
- run: uv run pytest -v --cov=libcontext --cov-report=xml
36
+ run: uv run pytest -v --cov=libcontext --cov-report=xml --cov-fail-under=90
37
37
 
38
38
  - name: Upload coverage
39
39
  if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest'
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-03-26
11
+
12
+ ### Breaking Changes
13
+
14
+ - Dropped Python 3.9 support. Minimum required version is now 3.10.
15
+
16
+ ### Added
17
+
18
+ - Configurable `file_size_limit`, `output_char_limit`, and `subprocess_timeout`
19
+ in `[tool.libcontext]` configuration section.
20
+ - CLI output now respects `output_char_limit` for consistency with MCP server.
21
+ - `LIBCONTEXT_OUTPUT_CHAR_LIMIT` environment variable for MCP server truncation
22
+ limit override.
23
+ - Usage examples in `libctx inspect --help` and `libctx diff --help`.
24
+ - CI enforces minimum 90% code coverage via `--cov-fail-under`.
25
+
26
+ ### Changed
27
+
28
+ - Development status upgraded from Alpha to Beta.
29
+ - CI matrix updated to test Python 3.10-3.13 only.
30
+ - Enabled `warn_unused_ignores` in mypy configuration; removed stale
31
+ `type: ignore` comments.
32
+ - Removed `from __future__ import annotations` from all files (no longer
33
+ needed with Python >=3.10 floor).
34
+ - Test coverage raised from 93% to 96% (63 new tests, 549 total).
35
+
10
36
  ## [0.7.0] - 2026-03-25
11
37
 
12
38
  ### Added
@@ -143,7 +169,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
143
169
  - Free-form `extra_context` field for library authors.
144
170
  - Python API for programmatic usage (`collect_package`, `render_package`).
145
171
 
146
- [Unreleased]: https://github.com/Syclaw/libcontext/compare/v0.7.0...HEAD
172
+ [Unreleased]: https://github.com/Syclaw/libcontext/compare/v0.8.0...HEAD
173
+ [0.8.0]: https://github.com/Syclaw/libcontext/compare/v0.7.0...v0.8.0
147
174
  [0.7.0]: https://github.com/Syclaw/libcontext/compare/v0.6.1...v0.7.0
148
175
  [0.6.1]: https://github.com/Syclaw/libcontext/compare/v0.6.0...v0.6.1
149
176
  [0.6.0]: https://github.com/Syclaw/libcontext/compare/v0.5.0...v0.6.0
@@ -11,7 +11,7 @@ By participating in this project, you agree to maintain a respectful and inclusi
11
11
  ### Prerequisites
12
12
 
13
13
  - [uv](https://docs.astral.sh/uv/) (recommended installer: `pip install uv` or see [installation docs](https://docs.astral.sh/uv/getting-started/installation/))
14
- - Python 3.9 or later
14
+ - Python 3.10 or later
15
15
  - Git
16
16
 
17
17
  ### Setting Up Your Development Environment
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: libcontext
3
- Version: 0.7.4
3
+ Version: 0.8.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,18 +9,17 @@ Author: Jonathan VARELA
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
11
  Keywords: ast,context,copilot,documentation,introspection,llm,mcp
12
- Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: MIT License
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.9
17
16
  Classifier: Programming Language :: Python :: 3.10
18
17
  Classifier: Programming Language :: Python :: 3.11
19
18
  Classifier: Programming Language :: Python :: 3.12
20
19
  Classifier: Programming Language :: Python :: 3.13
21
20
  Classifier: Topic :: Software Development :: Documentation
22
21
  Classifier: Topic :: Software Development :: Libraries
23
- Requires-Python: >=3.9
22
+ Requires-Python: >=3.10
24
23
  Requires-Dist: click>=8.0
25
24
  Requires-Dist: tomli>=1.0; python_version < '3.11'
26
25
  Provides-Extra: mcp
@@ -30,7 +30,7 @@ A skill (`.claude/skills/lib/SKILL.md` or `.github/skills/lib/SKILL.md`) instruc
30
30
  - Zero external dependencies beyond the CLI itself.
31
31
  - Works in any environment that allows shell commands — no MCP infrastructure needed.
32
32
  - Skills are static Markdown files checked into the repository. No running process, no attack surface.
33
- - Compatible with Python 3.9+.
33
+ - Compatible with Python 3.10+.
34
34
 
35
35
  **Disadvantages:**
36
36
  - Bash tool calls have no typed interface — the LLM constructs command strings.
@@ -70,7 +70,7 @@ MCP is offered as an optional extra (`pip install libcontext[mcp]`) for environm
70
70
 
71
71
  ## MCP as Optional Dependency
72
72
 
73
- The MCP server (`libctx-mcp`) depends on `mcp[cli]`, which requires Python 3.10+ and pulls in transitive dependencies (HTTP server, JSON-RPC, Pydantic). Since the core library targets Python 3.9+, MCP is an optional extra:
73
+ The MCP server (`libctx-mcp`) depends on `mcp[cli]`, which requires Python 3.10+ and pulls in transitive dependencies (HTTP server, JSON-RPC, Pydantic). Since the core library targets Python 3.10+, MCP is an optional extra:
74
74
 
75
75
  - Installed via `pip install libcontext[mcp]`.
76
76
  - `pyproject.toml` declares: `mcp = ["mcp[cli]>=1.0; python_version >= '3.10'"]`.
@@ -97,7 +97,7 @@ The `/lib` skill template is embedded as a string in `cli.py` (`_get_skill_conte
97
97
  ## Consequences
98
98
 
99
99
  - **Positive.** Works in enterprise environments that block MCP.
100
- - **Positive.** No additional runtime dependencies for the primary path. Core functionality works on Python 3.9.
100
+ - **Positive.** No additional runtime dependencies for the primary path. Core functionality works on Python 3.10+.
101
101
  - **Positive.** The skill file is a single Markdown file — easy to audit, version, and customize.
102
102
  - **Positive.** MCP users are not excluded — they install the optional extra.
103
103
  - **Negative.** The skill-based path relies on the LLM correctly constructing CLI commands. Mitigated by providing explicit command templates in the skill body.
@@ -4,22 +4,21 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "libcontext"
7
- version = "0.7.4"
7
+ version = "0.8.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"
11
- requires-python = ">=3.9"
11
+ requires-python = ">=3.10"
12
12
  authors = [
13
13
  { name = "Jonathan VARELA" },
14
14
  ]
15
15
  urls = { Homepage = "https://github.com/Syclaw/libcontext", Repository = "https://github.com/Syclaw/libcontext", Issues = "https://github.com/Syclaw/libcontext/issues" }
16
16
  keywords = ["copilot", "context", "documentation", "ast", "introspection", "mcp", "llm"]
17
17
  classifiers = [
18
- "Development Status :: 3 - Alpha",
18
+ "Development Status :: 4 - Beta",
19
19
  "Intended Audience :: Developers",
20
20
  "License :: OSI Approved :: MIT License",
21
21
  "Programming Language :: Python :: 3",
22
- "Programming Language :: Python :: 3.9",
23
22
  "Programming Language :: Python :: 3.10",
24
23
  "Programming Language :: Python :: 3.11",
25
24
  "Programming Language :: Python :: 3.12",
@@ -49,6 +48,9 @@ packages = ["src/libcontext"]
49
48
  include_modules = []
50
49
  exclude_modules = []
51
50
  include_private = false
51
+ # file_size_limit = 10485760 # 10 MiB — skip source files larger than this
52
+ # output_char_limit = 0 # 0 = unlimited; set to e.g. 120000 for ~30k tokens
53
+ # subprocess_timeout = 10 # seconds for target interpreter queries
52
54
 
53
55
  [tool.pytest.ini_options]
54
56
  testpaths = ["tests"]
@@ -58,7 +60,7 @@ pythonpath = ["src"]
58
60
  # Ruff — linter & formatter
59
61
  # ---------------------------------------------------------------------------
60
62
  [tool.ruff]
61
- target-version = "py39"
63
+ target-version = "py310"
62
64
  line-length = 88
63
65
  src = ["src", "tests"]
64
66
 
@@ -103,7 +105,7 @@ convention = "google"
103
105
  # Mypy — static type checking
104
106
  # ---------------------------------------------------------------------------
105
107
  [tool.mypy]
106
- python_version = "3.9"
108
+ python_version = "3.10"
107
109
  warn_return_any = true
108
110
  warn_unused_configs = true
109
111
  disallow_untyped_defs = true
@@ -112,7 +114,7 @@ check_untyped_defs = true
112
114
  strict_equality = true
113
115
  extra_checks = true
114
116
  warn_redundant_casts = true
115
- warn_unused_ignores = false
117
+ warn_unused_ignores = true
116
118
  show_error_codes = true
117
119
  mypy_path = "src"
118
120
 
@@ -17,8 +17,6 @@ Or from the command line::
17
17
  libctx inspect requests -o .github/copilot-instructions.md
18
18
  """
19
19
 
20
- from __future__ import annotations
21
-
22
20
  import importlib.metadata
23
21
  import logging
24
22
 
@@ -19,8 +19,6 @@ Detection priority:
19
19
  7. Neither → use the current process's environment (no delegation).
20
20
  """
21
21
 
22
- from __future__ import annotations
23
-
24
22
  import json
25
23
  import logging
26
24
  import os
@@ -254,6 +252,8 @@ def setup_environment(
254
252
  def query_target_package(
255
253
  python_exe: Path,
256
254
  package_name: str,
255
+ *,
256
+ timeout: int | None = None,
257
257
  ) -> dict[str, object]:
258
258
  """Discover a package by running the target interpreter.
259
259
 
@@ -265,6 +265,8 @@ def query_target_package(
265
265
  Args:
266
266
  python_exe: Absolute path to the target Python executable.
267
267
  package_name: Package import name (e.g. ``openai``).
268
+ timeout: Subprocess timeout in seconds. Defaults to
269
+ ``_SUBPROCESS_TIMEOUT_SECONDS`` (10s).
268
270
 
269
271
  Returns:
270
272
  Dict with keys ``path`` (str | None), ``version`` (str | None),
@@ -273,12 +275,13 @@ def query_target_package(
273
275
  Raises:
274
276
  EnvironmentSetupError: If the subprocess fails or times out.
275
277
  """
278
+ effective_timeout = timeout or _SUBPROCESS_TIMEOUT_SECONDS
276
279
  try:
277
280
  result = subprocess.run(
278
281
  [str(python_exe), "-c", _PACKAGE_DISCOVERY_SCRIPT, package_name],
279
282
  capture_output=True,
280
283
  text=True,
281
- timeout=_SUBPROCESS_TIMEOUT_SECONDS,
284
+ timeout=effective_timeout,
282
285
  )
283
286
  except (FileNotFoundError, OSError) as exc:
284
287
  raise EnvironmentSetupError(
@@ -288,7 +291,7 @@ def query_target_package(
288
291
  except subprocess.TimeoutExpired as exc:
289
292
  raise EnvironmentSetupError(
290
293
  str(python_exe),
291
- f"interpreter timed out after {_SUBPROCESS_TIMEOUT_SECONDS}s",
294
+ f"interpreter timed out after {effective_timeout}s",
292
295
  ) from exc
293
296
 
294
297
  if result.returncode != 0:
@@ -317,11 +320,17 @@ def resolve_python_executable(python_arg: str) -> Path:
317
320
  in the standard locations (``Scripts/python.exe`` on Windows,
318
321
  ``bin/python`` on Unix).
319
322
 
323
+ Returns an absolute path **without following symlinks**. This is
324
+ critical for venv interpreters: Python discovers ``pyvenv.cfg``
325
+ relative to the symlink location, not the symlink target. Following
326
+ the symlink would resolve to a global interpreter that lacks the
327
+ venv's ``site-packages``.
328
+
320
329
  Args:
321
330
  python_arg: Path to a Python interpreter or venv directory.
322
331
 
323
332
  Returns:
324
- Resolved absolute path to the Python executable.
333
+ Absolute path to the Python executable (symlinks preserved).
325
334
 
326
335
  Raises:
327
336
  EnvironmentSetupError: If the path does not exist or no
@@ -337,7 +346,7 @@ def resolve_python_executable(python_arg: str) -> Path:
337
346
 
338
347
  # Direct path to an executable
339
348
  if path.is_file():
340
- return path.resolve()
349
+ return path.absolute()
341
350
 
342
351
  # Directory — probe for interpreter
343
352
  if path.is_dir():
@@ -348,7 +357,7 @@ def resolve_python_executable(python_arg: str) -> Path:
348
357
  python_arg,
349
358
  candidate,
350
359
  )
351
- return candidate.resolve()
360
+ return candidate.absolute()
352
361
 
353
362
  raise EnvironmentSetupError(
354
363
  python_arg,
@@ -5,8 +5,6 @@ so that security invariants are enforced in one place rather than scattered
5
5
  across modules.
6
6
  """
7
7
 
8
- from __future__ import annotations
9
-
10
8
  import re
11
9
  from pathlib import Path
12
10
 
@@ -5,8 +5,6 @@ unchanged packages. Invalidation uses ``(version, max_mtime, file_count)``
5
5
  to detect source changes.
6
6
  """
7
7
 
8
- from __future__ import annotations
9
-
10
8
  import contextlib
11
9
  import dataclasses
12
10
  import datetime
@@ -32,8 +32,6 @@ Usage examples::
32
32
  libctx cache clear requests
33
33
  """
34
34
 
35
- from __future__ import annotations
36
-
37
35
  import dataclasses
38
36
  import json
39
37
  import logging
@@ -218,7 +216,16 @@ def inspect(
218
216
  quiet: bool,
219
217
  verbose: bool,
220
218
  ) -> None:
221
- """Generate LLM context for one or more Python packages."""
219
+ r"""Generate LLM context for one or more Python packages.
220
+
221
+ \b
222
+ Examples:
223
+ libctx inspect requests # full API reference
224
+ libctx inspect requests --overview -q # structural map
225
+ libctx inspect requests -m requests.api -q # single module
226
+ libctx inspect requests --search Session # search by name
227
+ libctx inspect flask --format json -o api.json
228
+ """
222
229
  # Configure logging
223
230
  if verbose:
224
231
  logging.basicConfig(
@@ -362,11 +369,13 @@ def inspect(
362
369
  if readme_lines is None:
363
370
  readme_lines = 100
364
371
 
372
+ output_limit = config.output_char_limit if config else 0
365
373
  rendered = render_package(
366
374
  pkg_info,
367
375
  include_readme=not no_readme,
368
376
  max_readme_lines=readme_lines,
369
377
  extra_context=config.extra_context if config else None,
378
+ max_output_chars=output_limit,
370
379
  )
371
380
 
372
381
  all_blocks.append((pkg_info.name, rendered))
@@ -470,9 +479,12 @@ def _get_skill_content() -> str:
470
479
  ---
471
480
  name: lib
472
481
  description: >-
473
- Load API reference for any installed Python library.
474
- Use when working with an unfamiliar, niche, or recently
475
- updated Python package that may not be in training data.
482
+ Inspect the API of an installed Python package with libcontext/libctx.
483
+ Use when you need to understand how to use a library, dependency, SDK,
484
+ client, framework, or package that is unfamiliar, niche, recently
485
+ updated, poorly documented, or not reliable in model memory. Trigger
486
+ for requests like: "check the package API", "inspect this dependency",
487
+ "find the right class/function", or "look up how this library works".
476
488
  argument-hint: "<package> [module] [--search query]"
477
489
  ---
478
490
 
@@ -487,28 +499,32 @@ def _get_skill_content() -> str:
487
499
  or any non-Python library. If the requested package is not a Python
488
500
  package, inform the user and stop — do not attempt inspection.
489
501
 
502
+ ## Prerequisites
503
+
504
+ If the project uses uv, prefix all commands with `uv run`.
505
+ The examples below omit the prefix for brevity.
506
+
490
507
  ## Workflow
491
508
 
492
509
  ### Step 1 — Verify installation
493
510
 
494
- Run `pip show $ARGUMENTS` (or `uv run pip show $ARGUMENTS`) to confirm
495
- the package is installed and note its version.
496
-
497
- If not installed, inform the user and stop.
511
+ Run `pip show <package>` to confirm the package is installed and note
512
+ its version. If not installed, inform the user and stop.
498
513
 
499
514
  ### Step 2 — Get structural overview
500
515
 
501
- Run:
502
516
  ```
503
- libctx inspect $ARGUMENTS --overview -q
517
+ libctx inspect <package> --overview -q
504
518
  ```
505
519
 
506
520
  This returns module names with class/function names (no signatures).
507
521
  Present this overview to understand the package shape.
508
522
 
523
+ If libctx cannot find the package (e.g. non-standard venv location),
524
+ pass `--python /path/to/.venv` to point it at the right environment.
525
+
509
526
  ### Step 3 — Drill into relevant modules
510
527
 
511
- Based on the task at hand, request detailed API for specific modules:
512
528
  ```
513
529
  libctx inspect <package> --module <module_name> -q
514
530
  ```
@@ -518,37 +534,23 @@ def _get_skill_content() -> str:
518
534
 
519
535
  ### Step 4 — Search when needed
520
536
 
521
- If looking for a specific class, function, or method:
522
537
  ```
523
538
  libctx inspect <package> --search <query> -q
524
539
  ```
525
540
 
526
- This searches across all modules by name and docstring (case-insensitive).
541
+ Searches across all modules by name and docstring (case-insensitive).
527
542
 
528
543
  To narrow results by type, add `--type`:
529
544
  ```
530
545
  libctx inspect <package> --search <query> --type class -q
531
- libctx inspect <package> --search <query> --type function -q
532
546
  ```
533
547
 
534
548
  Valid types: `class`, `function`, `variable`, `alias`.
535
549
 
536
- ### Step 5 — JSON and diff (advanced)
550
+ ### Step 5 — JSON output (advanced)
537
551
 
538
- For structured output suitable for comparison or programmatic use:
539
- ```
540
- libctx inspect <package> --format json -q
541
- libctx inspect <package> --module <module_name> --format json -q
542
- libctx inspect <package> --search <query> --format json -q
543
- ```
544
-
545
- To compare API versions after a package upgrade:
546
- ```
547
- libctx inspect <package> --format json -q > old.json
548
- # ... upgrade the package ...
549
- libctx inspect <package> --format json -q > new.json
550
- libctx diff old.json new.json
551
- ```
552
+ Add `--format json` to any inspect command for structured output.
553
+ Use `libctx diff old.json new.json` to compare API snapshots.
552
554
 
553
555
  ## Rules
554
556
 
@@ -556,14 +558,13 @@ def _get_skill_content() -> str:
556
558
  as the full output may be very large and saturate context.
557
559
  - Request at most 2-3 modules per invocation cycle. If more are needed,
558
560
  summarize what was learned so far, then request the next batch.
559
- - Use `-q` flag to suppress stderr noise.
561
+ - Always use `-q` to suppress stderr noise.
560
562
  - If the user specifies a module directly (e.g., `/lib requests requests.auth`),
561
563
  skip the overview and go straight to `--module`.
562
- - If a signature from the overview doesn't match what the code expects,
563
- the package may have been updated. Add `--no-cache` to force a fresh scan:
564
- `libctx inspect <package> --module <module_name> --no-cache -q`
564
+ - If a signature doesn't match what the code expects, add `--no-cache`
565
+ to force a fresh scan.
565
566
  - Use `--type` with `--search` to reduce noise when you know what kind of
566
- symbol you need (e.g., `--type class` when looking for a class).
567
+ symbol you need.
567
568
 
568
569
  ## Safety limits
569
570
 
@@ -852,7 +853,13 @@ def _format_age(iso_timestamp: str) -> str:
852
853
  help="Output format.",
853
854
  )
854
855
  def diff(old_file: Path, new_file: Path, output_format: str) -> None:
855
- """Compare two API snapshots and show what changed."""
856
+ r"""Compare two API snapshots and show what changed.
857
+
858
+ \b
859
+ Examples:
860
+ libctx diff old.json new.json # markdown diff
861
+ libctx diff old.json new.json --format json
862
+ """
856
863
  from ._security import MAX_JSON_INPUT_BYTES
857
864
 
858
865
  for label, path in (("old_file", old_file), ("new_file", new_file)):
@@ -5,8 +5,6 @@ rules from the optional ``[tool.libcontext]`` configuration, and returns a
5
5
  complete :class:`~libcontext.models.PackageInfo` data structure.
6
6
  """
7
7
 
8
- from __future__ import annotations
9
-
10
8
  import copy
11
9
  import difflib
12
10
  import importlib.metadata
@@ -164,11 +162,11 @@ def _find_stub_package(package_name: str) -> Path | None:
164
162
  if dist.files:
165
163
  for f in dist.files:
166
164
  if str(f).endswith(".pyi"):
167
- stub_root = Path(dist.locate_file(f.parts[0]))
165
+ stub_root = Path(str(dist.locate_file(f.parts[0])))
168
166
  if stub_root.is_dir():
169
167
  return stub_root
170
168
  # Fallback: convention-based path
171
- site_packages = Path(dist.locate_file(""))
169
+ site_packages = Path(str(dist.locate_file("")))
172
170
  for suffix in (f"{package_name}-stubs", f"{norm_name}-stubs"):
173
171
  candidate = site_packages / suffix
174
172
  if candidate.is_dir():
@@ -357,8 +355,8 @@ def _get_package_metadata(package_name: str) -> dict[str, str | None]:
357
355
  try:
358
356
  meta = importlib.metadata.metadata(package_name)
359
357
  return {
360
- "version": meta.get("Version"),
361
- "summary": meta.get("Summary"),
358
+ "version": meta.get("Version"), # type: ignore[attr-defined]
359
+ "summary": meta.get("Summary"), # type: ignore[attr-defined]
362
360
  }
363
361
  except importlib.metadata.PackageNotFoundError:
364
362
  logger.debug("No installed metadata for '%s'", package_name)
@@ -380,7 +378,7 @@ def _find_readme(package_name: str, package_path: Path | None) -> str | None:
380
378
  # 1. Metadata long description
381
379
  try:
382
380
  meta = importlib.metadata.metadata(package_name)
383
- body = meta.get_payload() # type: ignore[union-attr]
381
+ body = meta.get_payload() # type: ignore[attr-defined]
384
382
  if isinstance(body, str) and body.strip():
385
383
  logger.debug("README found via metadata for '%s'", package_name)
386
384
  return body.strip()
@@ -431,13 +429,15 @@ def _safe_rglob(root: Path, pattern: str) -> list[Path]:
431
429
  return results
432
430
 
433
431
 
434
- def _is_safe_source_file(file_path: Path, root: Path) -> bool:
432
+ def _is_safe_source_file(
433
+ file_path: Path, root: Path, *, file_size_limit: int = 0
434
+ ) -> bool:
435
435
  """Check that a source file is safe to read.
436
436
 
437
437
  Rejects files that escape the package boundary via symlinks and
438
438
  files larger than the configured limit (likely generated data, not API).
439
439
  """
440
- from ._security import check_file_size, is_within_boundary
440
+ from ._security import MAX_SOURCE_FILE_BYTES, check_file_size, is_within_boundary
441
441
 
442
442
  if not is_within_boundary(file_path, root):
443
443
  logger.warning(
@@ -446,7 +446,8 @@ def _is_safe_source_file(file_path: Path, root: Path) -> bool:
446
446
  root,
447
447
  )
448
448
  return False
449
- if not check_file_size(file_path):
449
+ effective_limit = file_size_limit if file_size_limit > 0 else MAX_SOURCE_FILE_BYTES
450
+ if not check_file_size(file_path, limit=effective_limit):
450
451
  logger.warning("Skipped %s: exceeds source file size limit", file_path)
451
452
  return False
452
453
  return True
@@ -546,7 +547,9 @@ def _walk_package(
546
547
  for source_file in _safe_rglob(package_path, "*.py*"):
547
548
  if source_file.suffix not in (".py", ".pyi"):
548
549
  continue
549
- if not _is_safe_source_file(source_file, package_path):
550
+ if not _is_safe_source_file(
551
+ source_file, package_path, file_size_limit=config.file_size_limit
552
+ ):
550
553
  continue
551
554
  relative = source_file.relative_to(package_path)
552
555
  parts = relative.parts
@@ -562,7 +565,9 @@ def _walk_package(
562
565
  # Standalone stub .pyi files
563
566
  if stub_path is not None:
564
567
  for pyi_file in _safe_rglob(stub_path, "*.pyi"):
565
- if not _is_safe_source_file(pyi_file, stub_path):
568
+ if not _is_safe_source_file(
569
+ pyi_file, stub_path, file_size_limit=config.file_size_limit
570
+ ):
566
571
  continue
567
572
  relative = pyi_file.relative_to(stub_path)
568
573
  parts = relative.parts
@@ -672,6 +677,8 @@ def _find_stub_package_fs(package_name: str, pkg_path: Path) -> Path | None:
672
677
  def _resolve_via_target(
673
678
  package_name: str,
674
679
  python_exe: Path,
680
+ *,
681
+ subprocess_timeout: int = 0,
675
682
  ) -> tuple[Path, dict[str, str | None], Path | None]:
676
683
  """Discover a package by querying the target interpreter.
677
684
 
@@ -681,6 +688,7 @@ def _resolve_via_target(
681
688
  Args:
682
689
  package_name: The importable package name.
683
690
  python_exe: Path to the target Python interpreter.
691
+ subprocess_timeout: Timeout in seconds (0 = use module default).
684
692
 
685
693
  Returns:
686
694
  A ``(pkg_path, metadata, stub_path)`` tuple.
@@ -690,7 +698,8 @@ def _resolve_via_target(
690
698
  """
691
699
  from ._envsetup import query_target_package
692
700
 
693
- data = query_target_package(python_exe, package_name)
701
+ timeout_kw = {"timeout": subprocess_timeout} if subprocess_timeout > 0 else {}
702
+ data = query_target_package(python_exe, package_name, **timeout_kw)
694
703
 
695
704
  pkg_path_str: str | None = data.get("path") # type: ignore[assignment]
696
705
  installed: list[str] = data.get("installed", []) # type: ignore[assignment]
@@ -790,6 +799,9 @@ def collect_package(
790
799
  pkg_path, metadata, stub_path = _resolve_via_target(
791
800
  package_name,
792
801
  target_python,
802
+ subprocess_timeout=config_override.subprocess_timeout
803
+ if config_override
804
+ else 0,
793
805
  )
794
806
  pkg_name = package_name
795
807
  logger.debug("Resolved '%s' via target interpreter: %s", package_name, pkg_path)