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 +57 -0
- libcontext/cli.py +218 -0
- libcontext/collector.py +290 -0
- libcontext/config.py +151 -0
- libcontext/inspector.py +399 -0
- libcontext/models.py +92 -0
- libcontext/py.typed +0 -0
- libcontext/renderer.py +366 -0
- libcontext-0.1.0.dist-info/METADATA +282 -0
- libcontext-0.1.0.dist-info/RECORD +13 -0
- libcontext-0.1.0.dist-info/WHEEL +4 -0
- libcontext-0.1.0.dist-info/entry_points.txt +2 -0
- libcontext-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|
libcontext/collector.py
ADDED
|
@@ -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
|
+
)
|