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 +0 -0
- api_squash/cli.py +80 -0
- api_squash/extractor.py +109 -0
- api_squash/models.py +24 -0
- api_squash/renderer.py +104 -0
- api_squash/scanner.py +66 -0
- api_squash-0.1.0.dist-info/METADATA +6 -0
- api_squash-0.1.0.dist-info/RECORD +10 -0
- api_squash-0.1.0.dist-info/WHEEL +4 -0
- api_squash-0.1.0.dist-info/entry_points.txt +2 -0
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)
|
api_squash/extractor.py
ADDED
|
@@ -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,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,,
|