api-squash 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.
api_squash/__init__.py ADDED
File without changes
api_squash/cli.py ADDED
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from .extractor import extract_file
9
+ from .renderer import render_module, render_project
10
+ from .scanner import scan_directory
11
+
12
+
13
+ @click.group()
14
+ def cli() -> None:
15
+ """Extract Python API surfaces in a compact, token-efficient format."""
16
+
17
+
18
+ @cli.command()
19
+ @click.argument("path", type=click.Path(exists=True))
20
+ @click.option("--no-docstrings", is_flag=True, help="Strip all docstrings")
21
+ @click.option(
22
+ "--no-private", is_flag=True, help="Skip private methods (except __init__)"
23
+ )
24
+ def file(path: str, no_docstrings: bool, no_private: bool) -> None:
25
+ """Summarize a single Python file."""
26
+ file_path = Path(path)
27
+ if file_path.suffix != ".py":
28
+ click.echo(f"Error: {path} is not a Python file", err=True)
29
+ sys.exit(1)
30
+
31
+ try:
32
+ module = extract_file(file_path)
33
+ except SyntaxError as e:
34
+ click.echo(f"Error: Failed to parse {path}: {e}", err=True)
35
+ sys.exit(1)
36
+
37
+ module.path = Path(path).as_posix()
38
+
39
+ output = render_module(module, no_docstrings=no_docstrings, no_private=no_private)
40
+ click.echo(output, nl=False)
41
+
42
+
43
+ @cli.command()
44
+ @click.argument("path", type=click.Path(exists=True, file_okay=False))
45
+ @click.option(
46
+ "--max-depth", type=int, default=None, help="Limit directory recursion depth"
47
+ )
48
+ @click.option("--exclude", multiple=True, help="Glob patterns to exclude")
49
+ @click.option("--no-docstrings", is_flag=True, help="Strip all docstrings")
50
+ @click.option(
51
+ "--no-private", is_flag=True, help="Skip private methods (except __init__)"
52
+ )
53
+ def project(
54
+ path: str,
55
+ max_depth: int | None,
56
+ exclude: tuple[str, ...],
57
+ no_docstrings: bool,
58
+ no_private: bool,
59
+ ) -> None:
60
+ """Summarize all Python files in a directory."""
61
+ root = Path(path)
62
+ files = scan_directory(root, exclude=list(exclude), max_depth=max_depth)
63
+
64
+ if not files:
65
+ click.echo(f"No Python files found in {path}", err=True)
66
+ return
67
+
68
+ modules = []
69
+ for file_path in files:
70
+ try:
71
+ module = extract_file(file_path)
72
+ module.path = file_path.relative_to(root).as_posix()
73
+ modules.append(module)
74
+ except SyntaxError as e:
75
+ click.echo(f"Warning: Skipping {file_path}: {e}", err=True)
76
+ except Exception as e:
77
+ click.echo(f"Warning: Skipping {file_path}: {e}", err=True)
78
+
79
+ output = render_project(modules, no_docstrings=no_docstrings, no_private=no_private)
80
+ click.echo(output, nl=False)
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import Path
5
+
6
+ from .models import ClassSummary, FunctionSummary, ModuleSummary
7
+
8
+
9
+ def extract_file(path: Path) -> ModuleSummary:
10
+ source = path.read_text(encoding="utf-8")
11
+ tree = ast.parse(source)
12
+
13
+ classes: list[ClassSummary] = []
14
+ functions: list[FunctionSummary] = []
15
+
16
+ for node in ast.iter_child_nodes(tree):
17
+ if isinstance(node, ast.ClassDef):
18
+ classes.append(_extract_class(node))
19
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
20
+ functions.append(_extract_function(node))
21
+
22
+ return ModuleSummary(
23
+ path=path.as_posix(),
24
+ classes=classes,
25
+ functions=functions,
26
+ )
27
+
28
+
29
+ def _extract_class(node: ast.ClassDef) -> ClassSummary:
30
+ docstring = ast.get_docstring(node)
31
+ bases = [ast.unparse(base) for base in node.bases]
32
+ methods: list[FunctionSummary] = []
33
+
34
+ for child in ast.iter_child_nodes(node):
35
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
36
+ methods.append(_extract_function(child))
37
+
38
+ return ClassSummary(
39
+ name=node.name,
40
+ docstring=docstring,
41
+ methods=methods,
42
+ bases=bases,
43
+ )
44
+
45
+
46
+ def _extract_function(
47
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
48
+ ) -> FunctionSummary:
49
+ docstring = ast.get_docstring(node)
50
+ signature = _build_signature(node)
51
+ is_async = isinstance(node, ast.AsyncFunctionDef)
52
+
53
+ return FunctionSummary(
54
+ name=node.name,
55
+ signature=signature,
56
+ docstring=docstring,
57
+ is_async=is_async,
58
+ )
59
+
60
+
61
+ def _build_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
62
+ args = node.args
63
+ params: list[str] = []
64
+
65
+ # Combine positional-only and regular args for default alignment
66
+ all_positional = list(args.posonlyargs) + list(args.args)
67
+ num_positional = len(all_positional)
68
+ num_defaults = len(args.defaults)
69
+ default_offset = num_positional - num_defaults
70
+
71
+ for i, arg in enumerate(all_positional):
72
+ part = arg.arg
73
+ if arg.annotation:
74
+ part += f": {ast.unparse(arg.annotation)}"
75
+ default_idx = i - default_offset
76
+ if default_idx >= 0:
77
+ part += f" = {ast.unparse(args.defaults[default_idx])}"
78
+ params.append(part)
79
+ # Insert "/" separator after positional-only args
80
+ if args.posonlyargs and i == len(args.posonlyargs) - 1:
81
+ params.append("/")
82
+
83
+ if args.vararg:
84
+ part = f"*{args.vararg.arg}"
85
+ if args.vararg.annotation:
86
+ part += f": {ast.unparse(args.vararg.annotation)}"
87
+ params.append(part)
88
+ elif args.kwonlyargs:
89
+ params.append("*")
90
+
91
+ for i, arg in enumerate(args.kwonlyargs):
92
+ part = arg.arg
93
+ if arg.annotation:
94
+ part += f": {ast.unparse(arg.annotation)}"
95
+ if args.kw_defaults[i] is not None:
96
+ part += f" = {ast.unparse(args.kw_defaults[i])}"
97
+ params.append(part)
98
+
99
+ if args.kwarg:
100
+ part = f"**{args.kwarg.arg}"
101
+ if args.kwarg.annotation:
102
+ part += f": {ast.unparse(args.kwarg.annotation)}"
103
+ params.append(part)
104
+
105
+ sig = f"({', '.join(params)})"
106
+ if node.returns:
107
+ sig += f" -> {ast.unparse(node.returns)}"
108
+
109
+ return sig
api_squash/models.py ADDED
@@ -0,0 +1,24 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass
5
+ class FunctionSummary:
6
+ name: str
7
+ signature: str
8
+ docstring: str | None = None
9
+ is_async: bool = False
10
+
11
+
12
+ @dataclass
13
+ class ClassSummary:
14
+ name: str
15
+ docstring: str | None = None
16
+ methods: list[FunctionSummary] = field(default_factory=list)
17
+ bases: list[str] = field(default_factory=list)
18
+
19
+
20
+ @dataclass
21
+ class ModuleSummary:
22
+ path: str
23
+ classes: list[ClassSummary] = field(default_factory=list)
24
+ functions: list[FunctionSummary] = field(default_factory=list)
api_squash/renderer.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from .models import ClassSummary, FunctionSummary, ModuleSummary
4
+
5
+
6
+ def render_module(
7
+ module: ModuleSummary,
8
+ *,
9
+ no_docstrings: bool = False,
10
+ no_private: bool = False,
11
+ ) -> str:
12
+ lines = [f"# {module.path}"]
13
+
14
+ items: list[str] = []
15
+ for cls in module.classes:
16
+ items.append(
17
+ _render_class(cls, no_docstrings=no_docstrings, no_private=no_private)
18
+ )
19
+ for func in module.functions:
20
+ if no_private and _is_private(func.name):
21
+ continue
22
+ items.append(_render_function(func, indent=0, no_docstrings=no_docstrings))
23
+
24
+ if items:
25
+ lines.append("")
26
+ lines.append("\n\n".join(items))
27
+
28
+ return "\n".join(lines) + "\n"
29
+
30
+
31
+ def render_project(
32
+ modules: list[ModuleSummary],
33
+ *,
34
+ no_docstrings: bool = False,
35
+ no_private: bool = False,
36
+ ) -> str:
37
+ rendered = [
38
+ render_module(module, no_docstrings=no_docstrings, no_private=no_private)
39
+ for module in modules
40
+ ]
41
+ return "\n---\n\n".join(rendered)
42
+
43
+
44
+ def _render_class(
45
+ cls: ClassSummary,
46
+ *,
47
+ no_docstrings: bool = False,
48
+ no_private: bool = False,
49
+ ) -> str:
50
+ parts: list[str] = []
51
+
52
+ if cls.bases:
53
+ parts.append(f"class {cls.name}({', '.join(cls.bases)}):")
54
+ else:
55
+ parts.append(f"class {cls.name}:")
56
+
57
+ if cls.docstring and not no_docstrings:
58
+ parts.append(_format_docstring(cls.docstring, indent=2))
59
+
60
+ for method in cls.methods:
61
+ if no_private and _is_private(method.name) and method.name != "__init__":
62
+ continue
63
+ parts.append(_render_function(method, indent=2, no_docstrings=no_docstrings))
64
+
65
+ return "\n".join(parts)
66
+
67
+
68
+ def _render_function(
69
+ func: FunctionSummary,
70
+ *,
71
+ indent: int = 0,
72
+ no_docstrings: bool = False,
73
+ ) -> str:
74
+ prefix = " " * indent
75
+ parts: list[str] = []
76
+
77
+ keyword = "async def" if func.is_async else "def"
78
+ parts.append(f"{prefix}{keyword} {func.name}{func.signature}")
79
+
80
+ if func.docstring and not no_docstrings:
81
+ parts.append(_format_docstring(func.docstring, indent=indent + 2))
82
+
83
+ return "\n".join(parts)
84
+
85
+
86
+ def _format_docstring(docstring: str, *, indent: int) -> str:
87
+ lines = docstring.splitlines()
88
+ prefix = " " * indent
89
+ result: list[str] = []
90
+ prev_blank = False
91
+ for line in lines:
92
+ stripped = line.rstrip()
93
+ if not stripped:
94
+ if not prev_blank:
95
+ result.append("")
96
+ prev_blank = True
97
+ else:
98
+ result.append(f"{prefix}{stripped}")
99
+ prev_blank = False
100
+ return "\n".join(result)
101
+
102
+
103
+ def _is_private(name: str) -> bool:
104
+ return name.startswith("_")
api_squash/scanner.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ from pathlib import Path
5
+
6
+ DEFAULT_SKIP_DIRS = {
7
+ "__pycache__",
8
+ ".venv",
9
+ "venv",
10
+ ".git",
11
+ "node_modules",
12
+ ".tox",
13
+ ".eggs",
14
+ ".mypy_cache",
15
+ ".pytest_cache",
16
+ ".ruff_cache",
17
+ "build",
18
+ "dist",
19
+ }
20
+
21
+
22
+ def scan_directory(
23
+ root: Path,
24
+ *,
25
+ exclude: list[str] | None = None,
26
+ max_depth: int | None = None,
27
+ ) -> list[Path]:
28
+ exclude = exclude or []
29
+ found: list[Path] = []
30
+ _walk(root, root, found, exclude, max_depth, depth=0)
31
+ found.sort()
32
+ return found
33
+
34
+
35
+ def _walk(
36
+ root: Path,
37
+ current: Path,
38
+ found: list[Path],
39
+ exclude: list[str],
40
+ max_depth: int | None,
41
+ depth: int,
42
+ ) -> None:
43
+ if max_depth is not None and depth > max_depth:
44
+ return
45
+
46
+ try:
47
+ entries = sorted(current.iterdir())
48
+ except PermissionError:
49
+ return
50
+
51
+ for entry in entries:
52
+ relative = entry.relative_to(root).as_posix()
53
+
54
+ if entry.is_dir():
55
+ if entry.name in DEFAULT_SKIP_DIRS:
56
+ continue
57
+ if any(
58
+ fnmatch.fnmatch(relative, pat) or fnmatch.fnmatch(entry.name, pat)
59
+ for pat in exclude
60
+ ):
61
+ continue
62
+ _walk(root, entry, found, exclude, max_depth, depth + 1)
63
+ elif entry.is_file() and entry.suffix == ".py":
64
+ if any(fnmatch.fnmatch(relative, pat) for pat in exclude):
65
+ continue
66
+ found.append(entry)
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: api-squash
3
+ Version: 0.1.0
4
+ Summary: Extract Python API surfaces in a compact, token-efficient format
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.0
@@ -0,0 +1,10 @@
1
+ api_squash/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ api_squash/cli.py,sha256=7mTPmqEOo3nasDa-w5oEXBYvTSfA1mVuRfcvr__acAI,2520
3
+ api_squash/extractor.py,sha256=ArMBd1BjpEokt6OR8FqacNNtrw7TmaGiGrmEeZpsHhQ,3273
4
+ api_squash/models.py,sha256=lJNI7wfQKlj8H9y49alLae_xk0RQaxm0NsjAn3VJXU8,543
5
+ api_squash/renderer.py,sha256=ZRm3FjagIeYi0XuYg_gi5AgT4So8u-bzAfVUY9T_a-o,2689
6
+ api_squash/scanner.py,sha256=_rUQJPJ33yI0ZCWfkVq3jp4-DTJJ8KFe-ljxunewMrg,1493
7
+ api_squash-0.1.0.dist-info/METADATA,sha256=Y83EJ7L9IoQGreQ5H2UozvOlwBTAYHLclfMzBtA48bE,178
8
+ api_squash-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ api_squash-0.1.0.dist-info/entry_points.txt,sha256=5MApuY0aDocok61Pcm6VHr7atM6UlmTNAn_bXwMfho4,50
10
+ api_squash-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ api-squash = api_squash.cli:cli