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/config.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Configuration reader for libcontext.
|
|
2
|
+
|
|
3
|
+
Reads optional ``[tool.libcontext]`` configuration from a package's
|
|
4
|
+
``pyproject.toml`` to allow library authors to customize which parts
|
|
5
|
+
of their API are exposed in the generated context.
|
|
6
|
+
|
|
7
|
+
Example configuration in pyproject.toml::
|
|
8
|
+
|
|
9
|
+
[tool.libcontext]
|
|
10
|
+
include_modules = ["mypackage.core", "mypackage.models"]
|
|
11
|
+
exclude_modules = ["mypackage._internal", "mypackage.tests"]
|
|
12
|
+
include_private = false
|
|
13
|
+
extra_context = "This library uses the repository pattern for data access."
|
|
14
|
+
max_readme_lines = 150
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class LibcontextConfig:
|
|
29
|
+
"""Configuration for context generation."""
|
|
30
|
+
|
|
31
|
+
include_modules: list[str] = field(default_factory=list)
|
|
32
|
+
exclude_modules: list[str] = field(default_factory=list)
|
|
33
|
+
include_private: bool = False
|
|
34
|
+
extra_context: str | None = None
|
|
35
|
+
max_readme_lines: int = 100
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_dict(cls, data: dict) -> LibcontextConfig:
|
|
39
|
+
"""Create config from a dictionary (e.g. parsed TOML section).
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
TypeError: If a value has an unexpected type.
|
|
43
|
+
"""
|
|
44
|
+
include_modules = data.get("include_modules", [])
|
|
45
|
+
exclude_modules = data.get("exclude_modules", [])
|
|
46
|
+
include_private = data.get("include_private", False)
|
|
47
|
+
extra_context = data.get("extra_context")
|
|
48
|
+
max_readme_lines = data.get("max_readme_lines", 100)
|
|
49
|
+
|
|
50
|
+
# --- Type validation ---
|
|
51
|
+
if not isinstance(include_modules, list):
|
|
52
|
+
raise TypeError(
|
|
53
|
+
f"include_modules must be a list, got {type(include_modules).__name__}"
|
|
54
|
+
)
|
|
55
|
+
if not isinstance(exclude_modules, list):
|
|
56
|
+
raise TypeError(
|
|
57
|
+
f"exclude_modules must be a list, got {type(exclude_modules).__name__}"
|
|
58
|
+
)
|
|
59
|
+
if not isinstance(include_private, bool):
|
|
60
|
+
raise TypeError(
|
|
61
|
+
f"include_private must be a bool, got {type(include_private).__name__}"
|
|
62
|
+
)
|
|
63
|
+
if extra_context is not None and not isinstance(extra_context, str):
|
|
64
|
+
raise TypeError(
|
|
65
|
+
"extra_context must be a string or null, "
|
|
66
|
+
f"got {type(extra_context).__name__}"
|
|
67
|
+
)
|
|
68
|
+
if not isinstance(max_readme_lines, int) or isinstance(max_readme_lines, bool):
|
|
69
|
+
raise TypeError(
|
|
70
|
+
"max_readme_lines must be an integer, "
|
|
71
|
+
f"got {type(max_readme_lines).__name__}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return cls(
|
|
75
|
+
include_modules=include_modules,
|
|
76
|
+
exclude_modules=exclude_modules,
|
|
77
|
+
include_private=include_private,
|
|
78
|
+
extra_context=extra_context,
|
|
79
|
+
max_readme_lines=max_readme_lines,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _load_toml(path: Path) -> dict:
|
|
84
|
+
"""Load a TOML file, using tomllib (3.11+) or tomli as fallback."""
|
|
85
|
+
if sys.version_info >= (3, 11):
|
|
86
|
+
import tomllib
|
|
87
|
+
else:
|
|
88
|
+
try:
|
|
89
|
+
import tomli as tomllib
|
|
90
|
+
except ImportError:
|
|
91
|
+
logger.warning(
|
|
92
|
+
"tomli is not installed and Python < 3.11; "
|
|
93
|
+
"TOML configuration will be ignored. "
|
|
94
|
+
"Install tomli (`pip install tomli`) to enable config support."
|
|
95
|
+
)
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
with path.open("rb") as f:
|
|
100
|
+
return dict(tomllib.load(f))
|
|
101
|
+
except OSError as exc:
|
|
102
|
+
logger.debug("Cannot read TOML file %s: %s", path, exc)
|
|
103
|
+
return {}
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
logger.warning("Invalid TOML in %s: %s", path, exc)
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_config_from_pyproject(pyproject_path: Path) -> LibcontextConfig:
|
|
110
|
+
"""Read libcontext config from a pyproject.toml file.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
pyproject_path: Path to the pyproject.toml file.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
LibcontextConfig parsed from the file, or defaults if not found.
|
|
117
|
+
"""
|
|
118
|
+
data = _load_toml(pyproject_path)
|
|
119
|
+
tool_config = data.get("tool", {}).get("libcontext", {})
|
|
120
|
+
return LibcontextConfig.from_dict(tool_config)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def find_config_for_package(package_path: Path) -> LibcontextConfig:
|
|
124
|
+
"""Search for libcontext configuration near a package directory.
|
|
125
|
+
|
|
126
|
+
Looks for ``pyproject.toml`` in the package directory and up to two
|
|
127
|
+
parent directories (to handle src layout: ``project/src/package/``).
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
package_path: Path to the package source directory.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
LibcontextConfig if found, otherwise default configuration.
|
|
134
|
+
"""
|
|
135
|
+
search_dirs = [
|
|
136
|
+
package_path,
|
|
137
|
+
package_path.parent,
|
|
138
|
+
package_path.parent.parent,
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
for search_dir in search_dirs:
|
|
142
|
+
pyproject = search_dir / "pyproject.toml"
|
|
143
|
+
if pyproject.is_file():
|
|
144
|
+
data = _load_toml(pyproject)
|
|
145
|
+
if "libcontext" in data.get("tool", {}):
|
|
146
|
+
logger.debug("Found [tool.libcontext] config in %s", pyproject)
|
|
147
|
+
tool_config = data["tool"]["libcontext"]
|
|
148
|
+
return LibcontextConfig.from_dict(tool_config)
|
|
149
|
+
|
|
150
|
+
logger.debug("No [tool.libcontext] config found near %s", package_path)
|
|
151
|
+
return LibcontextConfig()
|
libcontext/inspector.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""AST-based Python source code inspector.
|
|
2
|
+
|
|
3
|
+
Parses Python source files using the `ast` module to extract all public
|
|
4
|
+
components: classes, functions, variables, type hints, and docstrings.
|
|
5
|
+
This approach is safe (no code execution) and works with any valid Python source.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .models import (
|
|
15
|
+
ClassInfo,
|
|
16
|
+
FunctionInfo,
|
|
17
|
+
ModuleInfo,
|
|
18
|
+
ParameterInfo,
|
|
19
|
+
VariableInfo,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Dunder methods that are useful to document
|
|
25
|
+
_USEFUL_DUNDERS = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"__init__",
|
|
28
|
+
"__call__",
|
|
29
|
+
"__enter__",
|
|
30
|
+
"__exit__",
|
|
31
|
+
"__aenter__",
|
|
32
|
+
"__aexit__",
|
|
33
|
+
"__getitem__",
|
|
34
|
+
"__setitem__",
|
|
35
|
+
"__delitem__",
|
|
36
|
+
"__len__",
|
|
37
|
+
"__iter__",
|
|
38
|
+
"__next__",
|
|
39
|
+
"__aiter__",
|
|
40
|
+
"__anext__",
|
|
41
|
+
"__contains__",
|
|
42
|
+
"__eq__",
|
|
43
|
+
"__ne__",
|
|
44
|
+
"__lt__",
|
|
45
|
+
"__le__",
|
|
46
|
+
"__gt__",
|
|
47
|
+
"__ge__",
|
|
48
|
+
"__hash__",
|
|
49
|
+
"__repr__",
|
|
50
|
+
"__str__",
|
|
51
|
+
"__bool__",
|
|
52
|
+
"__add__",
|
|
53
|
+
"__sub__",
|
|
54
|
+
"__mul__",
|
|
55
|
+
"__truediv__",
|
|
56
|
+
"__floordiv__",
|
|
57
|
+
"__mod__",
|
|
58
|
+
"__pow__",
|
|
59
|
+
"__and__",
|
|
60
|
+
"__or__",
|
|
61
|
+
"__xor__",
|
|
62
|
+
"__invert__",
|
|
63
|
+
"__neg__",
|
|
64
|
+
"__pos__",
|
|
65
|
+
"__abs__",
|
|
66
|
+
"__int__",
|
|
67
|
+
"__float__",
|
|
68
|
+
"__complex__",
|
|
69
|
+
"__index__",
|
|
70
|
+
"__await__",
|
|
71
|
+
"__get__",
|
|
72
|
+
"__set__",
|
|
73
|
+
"__delete__",
|
|
74
|
+
"__init_subclass__",
|
|
75
|
+
"__class_getitem__",
|
|
76
|
+
"__missing__",
|
|
77
|
+
"__format__",
|
|
78
|
+
"__sizeof__",
|
|
79
|
+
"__reduce__",
|
|
80
|
+
"__copy__",
|
|
81
|
+
"__deepcopy__",
|
|
82
|
+
"__fspath__",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _unparse(node: ast.AST | None) -> str | None:
|
|
88
|
+
"""Convert an AST node back to source code string."""
|
|
89
|
+
if node is None:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
return ast.unparse(node)
|
|
93
|
+
except (ValueError, TypeError):
|
|
94
|
+
logger.debug("Cannot unparse AST node %s", type(node).__name__)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _extract_decorators(
|
|
99
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
|
|
100
|
+
) -> list[str]:
|
|
101
|
+
"""Extract decorator names from a decorated node."""
|
|
102
|
+
return [ast.unparse(dec) for dec in node.decorator_list]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _extract_parameters(args: ast.arguments) -> list[ParameterInfo]:
|
|
106
|
+
"""Extract parameters from function arguments AST node."""
|
|
107
|
+
params: list[ParameterInfo] = []
|
|
108
|
+
|
|
109
|
+
# --- Positional-only parameters ---
|
|
110
|
+
num_posonly = len(args.posonlyargs)
|
|
111
|
+
# defaults are shared: last N args get defaults
|
|
112
|
+
# total positional = posonlyargs + args
|
|
113
|
+
total_positional = num_posonly + len(args.args)
|
|
114
|
+
num_no_default = total_positional - len(args.defaults)
|
|
115
|
+
|
|
116
|
+
for i, arg in enumerate(args.posonlyargs):
|
|
117
|
+
default_idx = i - num_no_default
|
|
118
|
+
default = _unparse(args.defaults[default_idx]) if default_idx >= 0 else None
|
|
119
|
+
params.append(
|
|
120
|
+
ParameterInfo(
|
|
121
|
+
name=arg.arg,
|
|
122
|
+
annotation=_unparse(arg.annotation),
|
|
123
|
+
default=default,
|
|
124
|
+
kind="POSITIONAL_ONLY",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# --- Regular positional/keyword parameters ---
|
|
129
|
+
for i, arg in enumerate(args.args):
|
|
130
|
+
default_idx = (num_posonly + i) - num_no_default
|
|
131
|
+
default = _unparse(args.defaults[default_idx]) if default_idx >= 0 else None
|
|
132
|
+
params.append(
|
|
133
|
+
ParameterInfo(
|
|
134
|
+
name=arg.arg,
|
|
135
|
+
annotation=_unparse(arg.annotation),
|
|
136
|
+
default=default,
|
|
137
|
+
kind="POSITIONAL_OR_KEYWORD",
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# --- *args ---
|
|
142
|
+
if args.vararg:
|
|
143
|
+
params.append(
|
|
144
|
+
ParameterInfo(
|
|
145
|
+
name=f"*{args.vararg.arg}",
|
|
146
|
+
annotation=_unparse(args.vararg.annotation),
|
|
147
|
+
kind="VAR_POSITIONAL",
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# --- Keyword-only parameters ---
|
|
152
|
+
for i, arg in enumerate(args.kwonlyargs):
|
|
153
|
+
kw_default = args.kw_defaults[i] if i < len(args.kw_defaults) else None
|
|
154
|
+
default = _unparse(kw_default) if kw_default is not None else None
|
|
155
|
+
params.append(
|
|
156
|
+
ParameterInfo(
|
|
157
|
+
name=arg.arg,
|
|
158
|
+
annotation=_unparse(arg.annotation),
|
|
159
|
+
default=default,
|
|
160
|
+
kind="KEYWORD_ONLY",
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# --- **kwargs ---
|
|
165
|
+
if args.kwarg:
|
|
166
|
+
params.append(
|
|
167
|
+
ParameterInfo(
|
|
168
|
+
name=f"**{args.kwarg.arg}",
|
|
169
|
+
annotation=_unparse(args.kwarg.annotation),
|
|
170
|
+
kind="VAR_KEYWORD",
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return params
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _extract_function(
|
|
178
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
179
|
+
qualname_prefix: str = "",
|
|
180
|
+
) -> FunctionInfo:
|
|
181
|
+
"""Extract function/method information from an AST node."""
|
|
182
|
+
decorators = _extract_decorators(node)
|
|
183
|
+
qualname = f"{qualname_prefix}.{node.name}" if qualname_prefix else node.name
|
|
184
|
+
|
|
185
|
+
return FunctionInfo(
|
|
186
|
+
name=node.name,
|
|
187
|
+
qualname=qualname,
|
|
188
|
+
parameters=_extract_parameters(node.args),
|
|
189
|
+
return_annotation=_unparse(node.returns),
|
|
190
|
+
docstring=ast.get_docstring(node),
|
|
191
|
+
decorators=decorators,
|
|
192
|
+
is_async=isinstance(node, ast.AsyncFunctionDef),
|
|
193
|
+
is_property="property" in decorators,
|
|
194
|
+
is_classmethod="classmethod" in decorators,
|
|
195
|
+
is_staticmethod="staticmethod" in decorators,
|
|
196
|
+
line_number=node.lineno,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _extract_class(
|
|
201
|
+
node: ast.ClassDef,
|
|
202
|
+
qualname_prefix: str = "",
|
|
203
|
+
) -> ClassInfo:
|
|
204
|
+
"""Extract class information from an AST node."""
|
|
205
|
+
qualname = f"{qualname_prefix}.{node.name}" if qualname_prefix else node.name
|
|
206
|
+
bases = [ast.unparse(base) for base in node.bases]
|
|
207
|
+
|
|
208
|
+
methods: list[FunctionInfo] = []
|
|
209
|
+
class_variables: list[VariableInfo] = []
|
|
210
|
+
inner_classes: list[ClassInfo] = []
|
|
211
|
+
|
|
212
|
+
for item in node.body:
|
|
213
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
214
|
+
methods.append(_extract_function(item, qualname_prefix=qualname))
|
|
215
|
+
|
|
216
|
+
elif isinstance(item, ast.ClassDef):
|
|
217
|
+
inner_classes.append(_extract_class(item, qualname_prefix=qualname))
|
|
218
|
+
|
|
219
|
+
elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
|
|
220
|
+
class_variables.append(
|
|
221
|
+
VariableInfo(
|
|
222
|
+
name=item.target.id,
|
|
223
|
+
annotation=_unparse(item.annotation),
|
|
224
|
+
value=_unparse(item.value),
|
|
225
|
+
line_number=item.lineno,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
elif isinstance(item, ast.Assign):
|
|
230
|
+
for target in item.targets:
|
|
231
|
+
if isinstance(target, ast.Name):
|
|
232
|
+
class_variables.append(
|
|
233
|
+
VariableInfo(
|
|
234
|
+
name=target.id,
|
|
235
|
+
value=_unparse(item.value),
|
|
236
|
+
line_number=item.lineno,
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return ClassInfo(
|
|
241
|
+
name=node.name,
|
|
242
|
+
qualname=qualname,
|
|
243
|
+
bases=bases,
|
|
244
|
+
docstring=ast.get_docstring(node),
|
|
245
|
+
methods=methods,
|
|
246
|
+
class_variables=class_variables,
|
|
247
|
+
decorators=_extract_decorators(node),
|
|
248
|
+
inner_classes=inner_classes,
|
|
249
|
+
line_number=node.lineno,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _extract_list_strings(node: ast.List | ast.Tuple) -> list[str]:
|
|
254
|
+
"""Extract string constants from a list/tuple AST node."""
|
|
255
|
+
return [
|
|
256
|
+
elt.value
|
|
257
|
+
for elt in node.elts
|
|
258
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _extract_all_exports(tree: ast.Module) -> list[str] | None:
|
|
263
|
+
"""Extract the ``__all__`` list if defined at module level.
|
|
264
|
+
|
|
265
|
+
Handles both simple assignment (``__all__ = [...]``) and augmented
|
|
266
|
+
assignment (``__all__ += [...]``).
|
|
267
|
+
"""
|
|
268
|
+
exports: list[str] | None = None
|
|
269
|
+
|
|
270
|
+
for node in ast.iter_child_nodes(tree):
|
|
271
|
+
if isinstance(node, ast.Assign):
|
|
272
|
+
for target in node.targets:
|
|
273
|
+
if (
|
|
274
|
+
isinstance(target, ast.Name)
|
|
275
|
+
and target.id == "__all__"
|
|
276
|
+
and isinstance(node.value, (ast.List, ast.Tuple))
|
|
277
|
+
):
|
|
278
|
+
exports = _extract_list_strings(node.value)
|
|
279
|
+
|
|
280
|
+
elif (
|
|
281
|
+
isinstance(node, ast.AugAssign)
|
|
282
|
+
and isinstance(node.target, ast.Name)
|
|
283
|
+
and node.target.id == "__all__"
|
|
284
|
+
and isinstance(node.value, (ast.List, ast.Tuple))
|
|
285
|
+
):
|
|
286
|
+
extra = _extract_list_strings(node.value)
|
|
287
|
+
if exports is None:
|
|
288
|
+
exports = extra
|
|
289
|
+
else:
|
|
290
|
+
exports.extend(extra)
|
|
291
|
+
|
|
292
|
+
return exports
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def inspect_source(
|
|
296
|
+
source: str,
|
|
297
|
+
module_name: str = "",
|
|
298
|
+
file_path: str = "",
|
|
299
|
+
) -> ModuleInfo:
|
|
300
|
+
"""Inspect Python source code and extract all components.
|
|
301
|
+
|
|
302
|
+
Uses AST parsing (no code execution) to safely extract classes,
|
|
303
|
+
functions, variables, docstrings, and type annotations.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
source: Python source code as a string.
|
|
307
|
+
module_name: Fully qualified module name (e.g. ``mypackage.core``).
|
|
308
|
+
file_path: Path to the source file (for reference only).
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
ModuleInfo containing all extracted components.
|
|
312
|
+
"""
|
|
313
|
+
tree = ast.parse(source)
|
|
314
|
+
|
|
315
|
+
classes: list[ClassInfo] = []
|
|
316
|
+
functions: list[FunctionInfo] = []
|
|
317
|
+
variables: list[VariableInfo] = []
|
|
318
|
+
|
|
319
|
+
all_exports = _extract_all_exports(tree)
|
|
320
|
+
|
|
321
|
+
for node in ast.iter_child_nodes(tree):
|
|
322
|
+
if isinstance(node, ast.ClassDef):
|
|
323
|
+
classes.append(_extract_class(node))
|
|
324
|
+
|
|
325
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
326
|
+
functions.append(_extract_function(node))
|
|
327
|
+
|
|
328
|
+
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
329
|
+
variables.append(
|
|
330
|
+
VariableInfo(
|
|
331
|
+
name=node.target.id,
|
|
332
|
+
annotation=_unparse(node.annotation),
|
|
333
|
+
value=_unparse(node.value),
|
|
334
|
+
line_number=node.lineno,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
elif isinstance(node, ast.Assign):
|
|
339
|
+
for target in node.targets:
|
|
340
|
+
if isinstance(target, ast.Name):
|
|
341
|
+
variables.append(
|
|
342
|
+
VariableInfo(
|
|
343
|
+
name=target.id,
|
|
344
|
+
value=_unparse(node.value),
|
|
345
|
+
line_number=node.lineno,
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return ModuleInfo(
|
|
350
|
+
name=module_name,
|
|
351
|
+
path=file_path,
|
|
352
|
+
docstring=ast.get_docstring(tree),
|
|
353
|
+
classes=classes,
|
|
354
|
+
functions=functions,
|
|
355
|
+
variables=variables,
|
|
356
|
+
all_exports=all_exports,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def inspect_file(file_path: Path, module_name: str = "") -> ModuleInfo:
|
|
361
|
+
"""Inspect a Python file and extract all components.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
file_path: Path to the Python file.
|
|
365
|
+
module_name: Fully qualified module name. If empty, uses the file stem.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
ModuleInfo containing all extracted components.
|
|
369
|
+
|
|
370
|
+
Raises:
|
|
371
|
+
SyntaxError: If the file contains invalid Python syntax.
|
|
372
|
+
OSError: If the file cannot be read.
|
|
373
|
+
UnicodeDecodeError: If the file is not valid UTF-8.
|
|
374
|
+
"""
|
|
375
|
+
logger.debug(
|
|
376
|
+
"Inspecting file %s (module=%s)",
|
|
377
|
+
file_path,
|
|
378
|
+
module_name or file_path.stem,
|
|
379
|
+
)
|
|
380
|
+
source = file_path.read_text(encoding="utf-8")
|
|
381
|
+
if not module_name:
|
|
382
|
+
module_name = file_path.stem
|
|
383
|
+
return inspect_source(source, module_name=module_name, file_path=str(file_path))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def is_public_member(name: str, is_method: bool = False) -> bool:
|
|
387
|
+
"""Determine if a name should be considered public.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
name: The symbol name to check.
|
|
391
|
+
is_method: Whether this is a method (allows useful dunder methods).
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
True if the name should be included in public API documentation.
|
|
395
|
+
"""
|
|
396
|
+
if name.startswith("__") and name.endswith("__"):
|
|
397
|
+
# Dunder: only include if it's a useful one and it's a method
|
|
398
|
+
return is_method and name in _USEFUL_DUNDERS
|
|
399
|
+
return not name.startswith("_")
|
libcontext/models.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Data models for representing Python code components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ParameterInfo:
|
|
10
|
+
"""Represents a function/method parameter."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
annotation: str | None = None
|
|
14
|
+
default: str | None = None
|
|
15
|
+
kind: str = "POSITIONAL_OR_KEYWORD"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class FunctionInfo:
|
|
20
|
+
"""Represents a function or method."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
qualname: str = ""
|
|
24
|
+
parameters: list[ParameterInfo] = field(default_factory=list)
|
|
25
|
+
return_annotation: str | None = None
|
|
26
|
+
docstring: str | None = None
|
|
27
|
+
decorators: list[str] = field(default_factory=list)
|
|
28
|
+
is_async: bool = False
|
|
29
|
+
is_property: bool = False
|
|
30
|
+
is_classmethod: bool = False
|
|
31
|
+
is_staticmethod: bool = False
|
|
32
|
+
line_number: int | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class VariableInfo:
|
|
37
|
+
"""Represents a module-level or class-level variable/constant."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
annotation: str | None = None
|
|
41
|
+
value: str | None = None
|
|
42
|
+
line_number: int | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ClassInfo:
|
|
47
|
+
"""Represents a class."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
qualname: str = ""
|
|
51
|
+
bases: list[str] = field(default_factory=list)
|
|
52
|
+
docstring: str | None = None
|
|
53
|
+
methods: list[FunctionInfo] = field(default_factory=list)
|
|
54
|
+
class_variables: list[VariableInfo] = field(default_factory=list)
|
|
55
|
+
decorators: list[str] = field(default_factory=list)
|
|
56
|
+
inner_classes: list[ClassInfo] = field(default_factory=list)
|
|
57
|
+
line_number: int | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ModuleInfo:
|
|
62
|
+
"""Represents a Python module."""
|
|
63
|
+
|
|
64
|
+
name: str
|
|
65
|
+
path: str = ""
|
|
66
|
+
docstring: str | None = None
|
|
67
|
+
classes: list[ClassInfo] = field(default_factory=list)
|
|
68
|
+
functions: list[FunctionInfo] = field(default_factory=list)
|
|
69
|
+
variables: list[VariableInfo] = field(default_factory=list)
|
|
70
|
+
all_exports: list[str] | None = None # __all__ if defined
|
|
71
|
+
submodules: list[str] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_empty(self) -> bool:
|
|
75
|
+
"""Check if the module has no public content."""
|
|
76
|
+
return not (self.classes or self.functions or self.variables or self.docstring)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class PackageInfo:
|
|
81
|
+
"""Represents a complete Python package."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
version: str | None = None
|
|
85
|
+
summary: str | None = None
|
|
86
|
+
readme: str | None = None
|
|
87
|
+
modules: list[ModuleInfo] = field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def non_empty_modules(self) -> list[ModuleInfo]:
|
|
91
|
+
"""Return only modules with content."""
|
|
92
|
+
return [m for m in self.modules if not m.is_empty]
|
libcontext/py.typed
ADDED
|
File without changes
|