libcontext 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """libcontext — Generate LLM-optimised context from Python library APIs.
2
+
3
+ This library inspects Python packages using AST-based static analysis
4
+ and produces structured Markdown documentation designed to give GitHub
5
+ Copilot (or any LLM) the best possible understanding of a library's
6
+ public API.
7
+
8
+ Quick start::
9
+
10
+ from libcontext import collect_package, render_package
11
+
12
+ pkg = collect_package("requests")
13
+ print(render_package(pkg))
14
+
15
+ Or from the command line::
16
+
17
+ libctx requests -o .github/copilot-instructions.md
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+
24
+ from .collector import collect_package, find_package_path
25
+ from .config import LibcontextConfig
26
+ from .inspector import inspect_file, inspect_source
27
+ from .models import (
28
+ ClassInfo,
29
+ FunctionInfo,
30
+ ModuleInfo,
31
+ PackageInfo,
32
+ ParameterInfo,
33
+ VariableInfo,
34
+ )
35
+ from .renderer import inject_into_file, render_package
36
+
37
+ # Library best practice: add NullHandler to prevent "No handlers could be found"
38
+ # warnings when libcontext is used as a library (not via CLI).
39
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
40
+
41
+ __version__ = "0.1.0"
42
+
43
+ __all__ = [
44
+ "ClassInfo",
45
+ "FunctionInfo",
46
+ "LibcontextConfig",
47
+ "ModuleInfo",
48
+ "PackageInfo",
49
+ "ParameterInfo",
50
+ "VariableInfo",
51
+ "collect_package",
52
+ "find_package_path",
53
+ "inject_into_file",
54
+ "inspect_file",
55
+ "inspect_source",
56
+ "render_package",
57
+ ]
libcontext/cli.py ADDED
@@ -0,0 +1,218 @@
1
+ """CLI entry point for libcontext.
2
+
3
+ Provides the ``libctx`` command that inspects an installed
4
+ Python package and generates a Markdown context file optimised for GitHub
5
+ Copilot.
6
+
7
+ Usage examples::
8
+
9
+ # Generate context for the 'requests' library → stdout
10
+ libctx requests
11
+
12
+ # Write to .github/copilot-instructions.md (with markers)
13
+ libctx requests -o .github/copilot-instructions.md
14
+
15
+ # Append context for multiple libraries
16
+ libctx requests httpx -o .github/copilot-instructions.md
17
+
18
+ # Include private API
19
+ libctx mypackage --include-private
20
+
21
+ # Skip README
22
+ libctx mypackage --no-readme
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ import click
32
+
33
+ from .collector import collect_package
34
+ from .config import LibcontextConfig, read_config_from_pyproject
35
+ from .renderer import inject_into_file, render_package
36
+
37
+
38
+ @click.command(
39
+ name="libctx",
40
+ help=(
41
+ "Generate an LLM-optimised Markdown context file from one or more "
42
+ "Python packages. The output can be written to "
43
+ ".github/copilot-instructions.md (recommended) or printed to stdout."
44
+ ),
45
+ )
46
+ @click.argument("packages", nargs=-1, required=True)
47
+ @click.option(
48
+ "-o",
49
+ "--output",
50
+ type=click.Path(path_type=Path),
51
+ default=None,
52
+ help=(
53
+ "Output file path. When targeting an existing file the generated "
54
+ "context is injected between markers so that the rest of the file "
55
+ "is preserved. Defaults to stdout."
56
+ ),
57
+ )
58
+ @click.option(
59
+ "--include-private",
60
+ is_flag=True,
61
+ default=False,
62
+ help="Include private (_-prefixed) modules and members.",
63
+ )
64
+ @click.option(
65
+ "--no-readme",
66
+ is_flag=True,
67
+ default=False,
68
+ help="Do not include the package README in the output.",
69
+ )
70
+ @click.option(
71
+ "--max-readme-lines",
72
+ type=int,
73
+ default=None,
74
+ help="Maximum number of README lines to include (default: 100).",
75
+ )
76
+ @click.option(
77
+ "--config",
78
+ "config_path",
79
+ type=click.Path(exists=True, path_type=Path),
80
+ default=None,
81
+ help="Path to a pyproject.toml with [tool.libcontext] configuration.",
82
+ )
83
+ @click.option(
84
+ "-q",
85
+ "--quiet",
86
+ is_flag=True,
87
+ default=False,
88
+ help="Suppress informational messages on stderr.",
89
+ )
90
+ @click.option(
91
+ "-v",
92
+ "--verbose",
93
+ is_flag=True,
94
+ default=False,
95
+ help="Enable debug logging (useful for troubleshooting).",
96
+ )
97
+ def main(
98
+ packages: tuple[str, ...],
99
+ output: Path | None,
100
+ include_private: bool,
101
+ no_readme: bool,
102
+ max_readme_lines: int | None,
103
+ config_path: Path | None,
104
+ quiet: bool,
105
+ verbose: bool,
106
+ ) -> None:
107
+ """Generate Copilot context for one or more Python packages."""
108
+ # Configure logging
109
+ if verbose:
110
+ logging.basicConfig(
111
+ level=logging.DEBUG,
112
+ format="%(name)s: %(message)s",
113
+ stream=sys.stderr,
114
+ )
115
+ # Resolve configuration
116
+ config: LibcontextConfig | None = None
117
+ if config_path is not None:
118
+ try:
119
+ config = read_config_from_pyproject(config_path)
120
+ except TypeError as exc:
121
+ click.echo(f"Error in config: {exc}", err=True)
122
+ sys.exit(1)
123
+
124
+ if include_private and config:
125
+ config.include_private = True
126
+
127
+ all_blocks: list[tuple[str, str]] = [] # (package_name, rendered_md)
128
+
129
+ for pkg_name in packages:
130
+ if not quiet:
131
+ click.echo(f"Inspecting {pkg_name}…", err=True)
132
+
133
+ try:
134
+ pkg_info = collect_package(
135
+ pkg_name,
136
+ include_private=include_private,
137
+ include_readme=not no_readme,
138
+ config_override=config,
139
+ )
140
+ except ValueError as exc:
141
+ click.echo(f"Error: {exc}", err=True)
142
+ sys.exit(1)
143
+ except TypeError as exc:
144
+ click.echo(f"Error in config: {exc}", err=True)
145
+ sys.exit(1)
146
+
147
+ # Determine max_readme_lines
148
+ readme_lines = max_readme_lines
149
+ if readme_lines is None and config:
150
+ readme_lines = config.max_readme_lines
151
+ if readme_lines is None:
152
+ readme_lines = 100
153
+
154
+ rendered = render_package(
155
+ pkg_info,
156
+ include_readme=not no_readme,
157
+ max_readme_lines=readme_lines,
158
+ extra_context=config.extra_context if config else None,
159
+ )
160
+
161
+ all_blocks.append((pkg_info.name, rendered))
162
+
163
+ n_modules = len(pkg_info.non_empty_modules)
164
+ n_classes = sum(len(m.classes) for m in pkg_info.modules)
165
+ n_functions = sum(len(m.functions) for m in pkg_info.modules)
166
+ if not quiet:
167
+ click.echo(
168
+ f" Found {n_modules} modules, {n_classes} classes, "
169
+ f"{n_functions} functions.",
170
+ err=True,
171
+ )
172
+
173
+ # --- Output --------------------------------------------------------
174
+ if output is None:
175
+ # stdout — force UTF-8 on Windows to avoid cp1252 issues
176
+ if hasattr(sys.stdout, "buffer"):
177
+ binary = sys.stdout.buffer
178
+ for _name, md in all_blocks:
179
+ binary.write(md.encode("utf-8", errors="replace"))
180
+ binary.write(b"\n")
181
+ binary.flush()
182
+ else:
183
+ # Test environments / non-standard stdout
184
+ for _name, md in all_blocks:
185
+ click.echo(md)
186
+ else:
187
+ # File output with marker injection
188
+ existing: str | None = None
189
+ if output.is_file():
190
+ try:
191
+ existing = output.read_text(encoding="utf-8")
192
+ except UnicodeDecodeError:
193
+ click.echo(
194
+ f"Error: cannot read {output} — file is not valid UTF-8.",
195
+ err=True,
196
+ )
197
+ sys.exit(1)
198
+ except OSError as exc:
199
+ click.echo(f"Error: cannot read {output}: {exc}", err=True)
200
+ sys.exit(1)
201
+
202
+ result = existing or ""
203
+ for pkg_name, md in all_blocks:
204
+ result = inject_into_file(md, pkg_name, existing=result)
205
+
206
+ try:
207
+ output.parent.mkdir(parents=True, exist_ok=True)
208
+ output.write_text(result, encoding="utf-8")
209
+ except OSError as exc:
210
+ click.echo(f"Error: cannot write to {output}: {exc}", err=True)
211
+ sys.exit(1)
212
+
213
+ if not quiet:
214
+ click.echo(f"Context written to {output}", err=True)
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
@@ -0,0 +1,290 @@
1
+ """Package collector — discovers and inspects all modules in a Python package.
2
+
3
+ Walks the source tree of an installed (or local) package, applies filtering
4
+ rules from the optional ``[tool.libcontext]`` configuration, and returns a
5
+ complete :class:`~libcontext.models.PackageInfo` data structure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import importlib.metadata
12
+ import importlib.util
13
+ import logging
14
+ from pathlib import Path
15
+
16
+ from .config import LibcontextConfig, find_config_for_package
17
+ from .inspector import inspect_file
18
+ from .models import ModuleInfo, PackageInfo
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Package discovery
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ def find_package_path(package_name: str) -> Path | None:
29
+ """Locate the source directory of an installed package.
30
+
31
+ Uses :func:`importlib.util.find_spec` to resolve the package location.
32
+
33
+ Args:
34
+ package_name: The importable dotted name (e.g. ``requests``).
35
+
36
+ Returns:
37
+ Path to the package directory (or single-file module), or *None*.
38
+ """
39
+ try:
40
+ spec = importlib.util.find_spec(package_name)
41
+ except (ModuleNotFoundError, ValueError, AttributeError):
42
+ return None
43
+
44
+ if spec is None:
45
+ return None
46
+
47
+ if spec.origin and spec.origin != "frozen":
48
+ origin = Path(spec.origin)
49
+ if origin.name == "__init__.py":
50
+ return origin.parent
51
+ return origin
52
+
53
+ if spec.submodule_search_locations:
54
+ locations = list(spec.submodule_search_locations)
55
+ if locations:
56
+ return Path(locations[0])
57
+
58
+ return None
59
+
60
+
61
+ def _get_package_metadata(package_name: str) -> dict[str, str | None]:
62
+ """Retrieve version and summary from installed package metadata."""
63
+ try:
64
+ meta = importlib.metadata.metadata(package_name)
65
+ return {
66
+ "version": meta.get("Version"),
67
+ "summary": meta.get("Summary"),
68
+ }
69
+ except importlib.metadata.PackageNotFoundError:
70
+ logger.debug("No installed metadata for '%s'", package_name)
71
+ return {}
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # README discovery
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def _find_readme(package_name: str, package_path: Path | None) -> str | None:
80
+ """Try to locate and read a README for the package.
81
+
82
+ Strategy:
83
+ 1. ``importlib.metadata`` long description (often contains the README).
84
+ 2. Search common README filenames near the package source directory.
85
+ """
86
+ # 1. Metadata long description
87
+ try:
88
+ meta = importlib.metadata.metadata(package_name)
89
+ body = meta.get_payload() # type: ignore[union-attr]
90
+ if isinstance(body, str) and body.strip():
91
+ logger.debug("README found via metadata for '%s'", package_name)
92
+ return body.strip()
93
+ except (importlib.metadata.PackageNotFoundError, AttributeError):
94
+ pass
95
+
96
+ # 2. Search near the source
97
+ if package_path is None:
98
+ return None
99
+
100
+ readme_names = ("README.md", "README.rst", "README.txt", "README")
101
+ for search_dir in (package_path, package_path.parent, package_path.parent.parent):
102
+ for name in readme_names:
103
+ readme = search_dir / name
104
+ if readme.is_file():
105
+ try:
106
+ content = readme.read_text(encoding="utf-8")
107
+ logger.debug("README found at %s", readme)
108
+ return content
109
+ except (OSError, UnicodeDecodeError) as exc:
110
+ logger.debug("Cannot read README %s: %s", readme, exc)
111
+ continue
112
+
113
+ return None
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Module walking
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ def _should_skip_path(parts: tuple[str, ...], include_private: bool) -> bool:
122
+ """Decide whether a file path should be skipped.
123
+
124
+ Skips ``__pycache__``, ``.git``, and (optionally) private modules.
125
+ """
126
+ for part in parts:
127
+ if part == "__pycache__" or part.startswith("."):
128
+ return True
129
+ if part == "__init__.py":
130
+ continue
131
+ if not include_private and part.startswith("_"):
132
+ return True
133
+ return False
134
+
135
+
136
+ def _module_name_from_path(
137
+ py_file: Path,
138
+ package_root: Path,
139
+ package_name: str,
140
+ ) -> str:
141
+ """Compute the fully-qualified module name from a file path."""
142
+ relative = py_file.relative_to(package_root)
143
+ parts = list(relative.parts)
144
+
145
+ if parts[-1] == "__init__.py":
146
+ if len(parts) == 1:
147
+ return package_name
148
+ return f"{package_name}.{'.'.join(parts[:-1])}"
149
+
150
+ parts[-1] = parts[-1].removesuffix(".py")
151
+ return f"{package_name}.{'.'.join(parts)}"
152
+
153
+
154
+ def _walk_package(
155
+ package_path: Path,
156
+ package_name: str,
157
+ config: LibcontextConfig,
158
+ ) -> list[ModuleInfo]:
159
+ """Walk a package source tree and inspect every Python module."""
160
+ modules: list[ModuleInfo] = []
161
+
162
+ # Single-file module
163
+ if package_path.is_file():
164
+ try:
165
+ mod = inspect_file(package_path, module_name=package_name)
166
+ modules.append(mod)
167
+ except SyntaxError as exc:
168
+ logger.warning("Syntax error in %s: %s", package_path, exc)
169
+ except UnicodeDecodeError as exc:
170
+ logger.warning("Encoding error in %s: %s", package_path, exc)
171
+ except OSError as exc:
172
+ logger.warning("Cannot read %s: %s", package_path, exc)
173
+ return modules
174
+
175
+ include_set = set(config.include_modules) if config.include_modules else None
176
+ exclude_set = set(config.exclude_modules) if config.exclude_modules else set()
177
+
178
+ for py_file in sorted(package_path.rglob("*.py")):
179
+ relative = py_file.relative_to(package_path)
180
+ parts = relative.parts
181
+
182
+ if _should_skip_path(parts, include_private=config.include_private):
183
+ continue
184
+
185
+ module_name = _module_name_from_path(py_file, package_path, package_name)
186
+
187
+ # Apply include / exclude filters
188
+ if (
189
+ include_set
190
+ and not any(
191
+ module_name == inc or module_name.startswith(f"{inc}.")
192
+ for inc in include_set
193
+ )
194
+ and module_name != package_name
195
+ ):
196
+ continue
197
+
198
+ if any(
199
+ module_name == exc or module_name.startswith(f"{exc}.")
200
+ for exc in exclude_set
201
+ ):
202
+ continue
203
+
204
+ try:
205
+ mod = inspect_file(py_file, module_name=module_name)
206
+ modules.append(mod)
207
+ except SyntaxError as exc:
208
+ logger.warning("Syntax error in %s: %s", py_file, exc)
209
+ except UnicodeDecodeError as exc:
210
+ logger.warning("Encoding error in %s: %s", py_file, exc)
211
+ except OSError as exc:
212
+ logger.warning("Cannot read %s: %s", py_file, exc)
213
+
214
+ return modules
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Public API
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ def collect_package(
223
+ package_name: str,
224
+ *,
225
+ include_private: bool = False,
226
+ include_readme: bool = True,
227
+ config_override: LibcontextConfig | None = None,
228
+ ) -> PackageInfo:
229
+ """Collect complete API information for a Python package.
230
+
231
+ Combines source inspection, metadata retrieval, README discovery, and
232
+ optional ``[tool.libcontext]`` configuration.
233
+
234
+ Args:
235
+ package_name: Importable package name **or** filesystem path.
236
+ include_private: Include private (``_``-prefixed) modules/members.
237
+ include_readme: Attach the package README to the result.
238
+ config_override: Explicit config; skips automatic discovery.
239
+
240
+ Returns:
241
+ :class:`~libcontext.models.PackageInfo` with all collected data.
242
+
243
+ Raises:
244
+ ValueError: If the package cannot be located.
245
+ """
246
+ # --- Resolve path --------------------------------------------------
247
+ path = Path(package_name)
248
+ if path.exists():
249
+ pkg_path = path.resolve()
250
+ pkg_name = path.name if path.is_dir() else path.stem
251
+ metadata: dict[str, str | None] = {}
252
+ logger.debug("Resolved '%s' as local path: %s", package_name, pkg_path)
253
+ else:
254
+ pkg_path_resolved = find_package_path(package_name)
255
+ if pkg_path_resolved is None:
256
+ raise ValueError(
257
+ f"Package '{package_name}' not found. "
258
+ "Make sure it is installed in the current environment."
259
+ )
260
+ pkg_path = pkg_path_resolved
261
+ pkg_name = package_name
262
+ metadata = _get_package_metadata(package_name)
263
+ logger.debug("Resolved '%s' as installed package: %s", package_name, pkg_path)
264
+
265
+ # --- Config --------------------------------------------------------
266
+ if config_override is not None:
267
+ config = copy.copy(config_override)
268
+ else:
269
+ config = find_config_for_package(pkg_path)
270
+
271
+ if include_private:
272
+ config.include_private = True
273
+
274
+ # --- Collect -------------------------------------------------------
275
+ modules = _walk_package(pkg_path, pkg_name, config)
276
+ readme = _find_readme(pkg_name, pkg_path) if include_readme else None
277
+
278
+ logger.debug(
279
+ "Collected %d modules for '%s'",
280
+ len(modules),
281
+ pkg_name,
282
+ )
283
+
284
+ return PackageInfo(
285
+ name=pkg_name,
286
+ version=metadata.get("version"),
287
+ summary=metadata.get("summary"),
288
+ readme=readme,
289
+ modules=modules,
290
+ )