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