skim-python 0.0.1__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.
- skim_py/__init__.py +5 -0
- skim_py/block.py +150 -0
- skim_py/cli.py +147 -0
- skim_py/glance.py +203 -0
- skim_py/parser.py +300 -0
- skim_py/types.py +40 -0
- skim_python-0.0.1.dist-info/METADATA +523 -0
- skim_python-0.0.1.dist-info/RECORD +11 -0
- skim_python-0.0.1.dist-info/WHEEL +4 -0
- skim_python-0.0.1.dist-info/entry_points.txt +2 -0
- skim_python-0.0.1.dist-info/licenses/LICENSE +21 -0
skim_py/parser.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""AST parsing and outline extraction for Python files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import ast
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, List, Tuple
|
|
9
|
+
|
|
10
|
+
from .types import OutlineItem, ParsedFile, SyntaxNotice
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
TEST_DIR_NAMES = {"tests", "test", "testing", "__tests__"}
|
|
14
|
+
TEST_FILE_PATTERN = re.compile(r"^(test_.*|.*_test|conftest)\.py$", re.IGNORECASE)
|
|
15
|
+
TEST_CLASS_PATTERN = re.compile(r"^Test[A-Z0-9_].*")
|
|
16
|
+
TEST_FUNC_PATTERN = re.compile(r"^test(_|[A-Z0-9_]).*")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_test_file(file_path: str) -> bool:
|
|
20
|
+
p = Path(file_path)
|
|
21
|
+
if TEST_FILE_PATTERN.match(p.name):
|
|
22
|
+
return True
|
|
23
|
+
for part in p.parts[:-1]:
|
|
24
|
+
if part.lower() in TEST_DIR_NAMES:
|
|
25
|
+
return True
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_start_line(node: ast.AST) -> int:
|
|
30
|
+
"""Return the 1-based start line, including decorators if present."""
|
|
31
|
+
if hasattr(node, "decorator_list") and node.decorator_list:
|
|
32
|
+
return node.decorator_list[0].lineno
|
|
33
|
+
return getattr(node, "lineno", 1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_end_line(node: ast.AST) -> int:
|
|
37
|
+
"""Return the 1-based end line."""
|
|
38
|
+
return getattr(node, "end_lineno", get_start_line(node))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_fn_signature(node: ast.FunctionDef | ast.AsyncFunctionDef, is_async: bool = False) -> str:
|
|
42
|
+
prefix = "async def" if is_async else "def"
|
|
43
|
+
type_params = ""
|
|
44
|
+
if hasattr(node, "type_params") and node.type_params:
|
|
45
|
+
params_str = ", ".join(ast.unparse(tp) for tp in node.type_params)
|
|
46
|
+
type_params = f"[{params_str}]"
|
|
47
|
+
args_str = ast.unparse(node.args)
|
|
48
|
+
returns_str = f" -> {ast.unparse(node.returns)}" if node.returns else ""
|
|
49
|
+
return f"{prefix} {node.name}{type_params}({args_str}){returns_str}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_class_signature(node: ast.ClassDef) -> str:
|
|
53
|
+
type_params = ""
|
|
54
|
+
if hasattr(node, "type_params") and node.type_params:
|
|
55
|
+
params_str = ", ".join(ast.unparse(tp) for tp in node.type_params)
|
|
56
|
+
type_params = f"[{params_str}]"
|
|
57
|
+
bases = [ast.unparse(b) for b in node.bases]
|
|
58
|
+
for kw in node.keywords:
|
|
59
|
+
if kw.arg:
|
|
60
|
+
bases.append(f"{kw.arg}={ast.unparse(kw.value)}")
|
|
61
|
+
else:
|
|
62
|
+
bases.append(f"**{ast.unparse(kw.value)}")
|
|
63
|
+
bases_str = f"({', '.join(bases)})" if bases else ""
|
|
64
|
+
return f"class {node.name}{type_params}{bases_str}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_test_class(node: ast.ClassDef) -> bool:
|
|
68
|
+
if TEST_CLASS_PATTERN.match(node.name):
|
|
69
|
+
return True
|
|
70
|
+
for base in node.bases:
|
|
71
|
+
base_name = ast.unparse(base)
|
|
72
|
+
if "TestCase" in base_name:
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_test_func(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
78
|
+
return bool(TEST_FUNC_PATTERN.match(node.name))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_source_file(file_path: str, content: str, include_tests: bool = False) -> ParsedFile:
|
|
82
|
+
src_lines = content.splitlines()
|
|
83
|
+
total_lines = len(src_lines)
|
|
84
|
+
is_test = is_test_file(file_path)
|
|
85
|
+
|
|
86
|
+
parsed = ParsedFile(
|
|
87
|
+
file_path=file_path,
|
|
88
|
+
total_lines=total_lines,
|
|
89
|
+
is_test_file=is_test,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
tree: Optional[ast.AST] = None
|
|
93
|
+
try:
|
|
94
|
+
tree = ast.parse(content, filename=file_path, type_comments=True)
|
|
95
|
+
except SyntaxError as first_err:
|
|
96
|
+
parsed.syntax_errors.append(
|
|
97
|
+
SyntaxNotice(line=first_err.lineno or 1, message=first_err.msg or "syntax error")
|
|
98
|
+
)
|
|
99
|
+
# Fallback: Iterative line masking with up to 100 attempts
|
|
100
|
+
working_lines = content.splitlines(keepends=True)
|
|
101
|
+
seen_states = set()
|
|
102
|
+
max_attempts = 100
|
|
103
|
+
current_err: Optional[SyntaxError] = first_err
|
|
104
|
+
|
|
105
|
+
for _ in range(max_attempts):
|
|
106
|
+
if current_err is None:
|
|
107
|
+
break
|
|
108
|
+
lineno = current_err.lineno
|
|
109
|
+
if lineno is None or lineno < 1 or lineno > len(working_lines):
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
line_idx = lineno - 1
|
|
113
|
+
line = working_lines[line_idx]
|
|
114
|
+
indent = len(line) - len(line.lstrip())
|
|
115
|
+
|
|
116
|
+
state_key = (line_idx, line)
|
|
117
|
+
if state_key in seen_states:
|
|
118
|
+
# Cannot make further progress by masking this line
|
|
119
|
+
break
|
|
120
|
+
seen_states.add(state_key)
|
|
121
|
+
|
|
122
|
+
if "# syntax error masked" not in line:
|
|
123
|
+
working_lines[line_idx] = " " * indent + "# syntax error masked\n"
|
|
124
|
+
else:
|
|
125
|
+
working_lines[line_idx] = " " * indent + "pass\n"
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
tree = ast.parse("".join(working_lines), filename=file_path, type_comments=True)
|
|
129
|
+
break # Successful recovery!
|
|
130
|
+
except SyntaxError as next_err:
|
|
131
|
+
current_err = next_err
|
|
132
|
+
# Only record distinct line notices
|
|
133
|
+
if not any(n.line == next_err.lineno for n in parsed.syntax_errors):
|
|
134
|
+
parsed.syntax_errors.append(
|
|
135
|
+
SyntaxNotice(line=next_err.lineno or 1, message=next_err.msg or "syntax error")
|
|
136
|
+
)
|
|
137
|
+
except Exception:
|
|
138
|
+
break
|
|
139
|
+
except Exception as e:
|
|
140
|
+
parsed.syntax_errors.append(
|
|
141
|
+
SyntaxNotice(line=1, message=str(e))
|
|
142
|
+
)
|
|
143
|
+
return parsed
|
|
144
|
+
|
|
145
|
+
if tree is None:
|
|
146
|
+
return parsed
|
|
147
|
+
|
|
148
|
+
# 1. Collect imports
|
|
149
|
+
for node in tree.body:
|
|
150
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
151
|
+
start = (node.lineno or 1) - 1
|
|
152
|
+
end = node.end_lineno or (start + 1)
|
|
153
|
+
raw_imp = "\n".join(src_lines[start:end])
|
|
154
|
+
parsed.imports.append(raw_imp)
|
|
155
|
+
|
|
156
|
+
# 2. Extract outline items
|
|
157
|
+
for node in tree.body:
|
|
158
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
159
|
+
is_async = isinstance(node, ast.AsyncFunctionDef)
|
|
160
|
+
is_test_fn = is_test_func(node)
|
|
161
|
+
start_l = get_start_line(node)
|
|
162
|
+
end_l = get_end_line(node)
|
|
163
|
+
|
|
164
|
+
if is_test_fn:
|
|
165
|
+
parsed.test_ranges.append((start_l, end_l))
|
|
166
|
+
if not include_tests:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
kind = "test" if is_test_fn else ("async def" if is_async else "function")
|
|
170
|
+
sig = get_fn_signature(node, is_async=is_async)
|
|
171
|
+
|
|
172
|
+
# Prepend decorators in signature if any
|
|
173
|
+
if node.decorator_list:
|
|
174
|
+
decos = ["@" + ast.unparse(d) for d in node.decorator_list]
|
|
175
|
+
sig = " ".join(decos) + " " + sig
|
|
176
|
+
|
|
177
|
+
item = OutlineItem(
|
|
178
|
+
name=node.name,
|
|
179
|
+
kind=kind,
|
|
180
|
+
start_line=start_l,
|
|
181
|
+
end_line=end_l,
|
|
182
|
+
raw_header=sig,
|
|
183
|
+
)
|
|
184
|
+
parsed.items.append(item)
|
|
185
|
+
|
|
186
|
+
elif isinstance(node, ast.ClassDef):
|
|
187
|
+
is_test_cls = is_test_class(node)
|
|
188
|
+
start_l = get_start_line(node)
|
|
189
|
+
end_l = get_end_line(node)
|
|
190
|
+
|
|
191
|
+
if is_test_cls:
|
|
192
|
+
parsed.test_ranges.append((start_l, end_l))
|
|
193
|
+
if not include_tests:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
class_sig = get_class_signature(node)
|
|
197
|
+
if node.decorator_list:
|
|
198
|
+
decos = ["@" + ast.unparse(d) for d in node.decorator_list]
|
|
199
|
+
class_sig = " ".join(decos) + " " + class_sig
|
|
200
|
+
|
|
201
|
+
class_item = OutlineItem(
|
|
202
|
+
name=node.name,
|
|
203
|
+
kind="test" if is_test_cls else "class",
|
|
204
|
+
start_line=start_l,
|
|
205
|
+
end_line=end_l,
|
|
206
|
+
raw_header=class_sig,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Process class body
|
|
210
|
+
for child in node.body:
|
|
211
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
212
|
+
child_async = isinstance(child, ast.AsyncFunctionDef)
|
|
213
|
+
child_test = is_test_func(child)
|
|
214
|
+
ch_start = get_start_line(child)
|
|
215
|
+
ch_end = get_end_line(child)
|
|
216
|
+
|
|
217
|
+
if child_test:
|
|
218
|
+
parsed.test_ranges.append((ch_start, ch_end))
|
|
219
|
+
if not include_tests:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
ch_sig = get_fn_signature(child, is_async=child_async)
|
|
223
|
+
if child.decorator_list:
|
|
224
|
+
decos = ["@" + ast.unparse(d) for d in child.decorator_list]
|
|
225
|
+
ch_sig = " ".join(decos) + " " + ch_sig
|
|
226
|
+
|
|
227
|
+
method_item = OutlineItem(
|
|
228
|
+
name=child.name,
|
|
229
|
+
kind="test" if child_test else "method",
|
|
230
|
+
start_line=ch_start,
|
|
231
|
+
end_line=ch_end,
|
|
232
|
+
raw_header=ch_sig,
|
|
233
|
+
parent_name=node.name,
|
|
234
|
+
)
|
|
235
|
+
class_item.children.append(method_item)
|
|
236
|
+
|
|
237
|
+
elif isinstance(child, ast.AnnAssign):
|
|
238
|
+
val_str = f" = {ast.unparse(child.value)}" if child.value else ""
|
|
239
|
+
class_item.fields_or_variants.append(
|
|
240
|
+
f"{ast.unparse(child.target)}: {ast.unparse(child.annotation)}{val_str}"
|
|
241
|
+
)
|
|
242
|
+
elif isinstance(child, ast.Assign):
|
|
243
|
+
for target in child.targets:
|
|
244
|
+
class_item.fields_or_variants.append(
|
|
245
|
+
f"{ast.unparse(target)} = {ast.unparse(child.value)}"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
parsed.items.append(class_item)
|
|
249
|
+
|
|
250
|
+
elif getattr(ast, "TypeAlias", None) and isinstance(node, ast.TypeAlias): # PEP 695 type X = ...
|
|
251
|
+
start_l = get_start_line(node)
|
|
252
|
+
end_l = get_end_line(node)
|
|
253
|
+
sig = f"type {ast.unparse(node.name)} = {ast.unparse(node.value)}"
|
|
254
|
+
parsed.items.append(
|
|
255
|
+
OutlineItem(
|
|
256
|
+
name=ast.unparse(node.name),
|
|
257
|
+
kind="type",
|
|
258
|
+
start_line=start_l,
|
|
259
|
+
end_line=end_l,
|
|
260
|
+
raw_header=sig,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
elif isinstance(node, ast.AnnAssign):
|
|
265
|
+
# Top-level annotated constant / variable
|
|
266
|
+
name = ast.unparse(node.target)
|
|
267
|
+
start_l = get_start_line(node)
|
|
268
|
+
end_l = get_end_line(node)
|
|
269
|
+
val_str = f" = {ast.unparse(node.value)}" if node.value else ""
|
|
270
|
+
sig = f"{name}: {ast.unparse(node.annotation)}{val_str}"
|
|
271
|
+
parsed.items.append(
|
|
272
|
+
OutlineItem(
|
|
273
|
+
name=name,
|
|
274
|
+
kind="var",
|
|
275
|
+
start_line=start_l,
|
|
276
|
+
end_line=end_l,
|
|
277
|
+
raw_header=sig,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
elif isinstance(node, ast.Assign):
|
|
282
|
+
# Top-level simple constant (e.g. UPPER_CASE = ...)
|
|
283
|
+
# Only record if simple uppercase variable or type-like
|
|
284
|
+
for target in node.targets:
|
|
285
|
+
if isinstance(target, ast.Name) and (target.id.isupper() or target.id.startswith("__")):
|
|
286
|
+
name = target.id
|
|
287
|
+
start_l = get_start_line(node)
|
|
288
|
+
end_l = get_end_line(node)
|
|
289
|
+
sig = f"{name} = {ast.unparse(node.value)}"
|
|
290
|
+
parsed.items.append(
|
|
291
|
+
OutlineItem(
|
|
292
|
+
name=name,
|
|
293
|
+
kind="var",
|
|
294
|
+
start_line=start_l,
|
|
295
|
+
end_line=end_l,
|
|
296
|
+
raw_header=sig,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
return parsed
|
skim_py/types.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Type definitions and models for skim-py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class SyntaxNotice:
|
|
10
|
+
line: int
|
|
11
|
+
message: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class OutlineItem:
|
|
16
|
+
name: str
|
|
17
|
+
kind: str # 'function', 'async def', 'class', 'method', 'test', 'var', 'type'
|
|
18
|
+
start_line: int
|
|
19
|
+
end_line: int
|
|
20
|
+
raw_header: str
|
|
21
|
+
parent_name: Optional[str] = None
|
|
22
|
+
fields_or_variants: List[str] = field(default_factory=list)
|
|
23
|
+
children: List[OutlineItem] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def qualified_name(self) -> str:
|
|
27
|
+
if self.parent_name:
|
|
28
|
+
return f"{self.parent_name}.{self.name}"
|
|
29
|
+
return self.name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ParsedFile:
|
|
34
|
+
file_path: str
|
|
35
|
+
total_lines: int
|
|
36
|
+
items: List[OutlineItem] = field(default_factory=list)
|
|
37
|
+
imports: List[str] = field(default_factory=list)
|
|
38
|
+
syntax_errors: List[SyntaxNotice] = field(default_factory=list)
|
|
39
|
+
is_test_file: bool = False
|
|
40
|
+
test_ranges: List[tuple[int, int]] = field(default_factory=list)
|