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/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()
@@ -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