skim-python 0.0.2__tar.gz → 0.0.3__tar.gz
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_python-0.0.2 → skim_python-0.0.3}/PKG-INFO +1 -1
- {skim_python-0.0.2 → skim_python-0.0.3}/pyproject.toml +5 -1
- {skim_python-0.0.2 → skim_python-0.0.3}/src/skim_py/cli.py +5 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/src/skim_py/parser.py +76 -29
- {skim_python-0.0.2 → skim_python-0.0.3}/tests/test_skim_py.py +48 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/.gitignore +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/LICENSE +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/README.md +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/skim-py +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/src/skim_py/__init__.py +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/src/skim_py/block.py +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/src/skim_py/glance.py +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/src/skim_py/types.py +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/tests/fixtures/casing.py +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/tests/fixtures/classes.py +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/tests/fixtures/dataclasses_enum.py +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/tests/fixtures/syntax_error.py +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/tests/fixtures/syntax_error_cascade.py +0 -0
- {skim_python-0.0.2 → skim_python-0.0.3}/tests/fixtures/tests.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: skim-python
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: Language-aware, AST-resilient outline and verbatim code extractor for Python tailored for AI agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/benedictjohannes/programming-helper-utils
|
|
6
6
|
Project-URL: Repository, https://github.com/benedictjohannes/programming-helper-utils
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "skim-python"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.3"
|
|
8
8
|
description = "Language-aware, AST-resilient outline and verbatim code extractor for Python tailored for AI agents"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -38,3 +38,7 @@ skim-py = "skim_py.cli:main"
|
|
|
38
38
|
|
|
39
39
|
[tool.hatch.build.targets.wheel]
|
|
40
40
|
packages = ["src/skim_py"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
pythonpath = ["src"]
|
|
44
|
+
testpaths = ["tests"]
|
|
@@ -68,6 +68,11 @@ Agent Operational Workflow:
|
|
|
68
68
|
|
|
69
69
|
|
|
70
70
|
def main(args: Optional[List[str]] = None) -> int:
|
|
71
|
+
if sys.version_info < (3, 10):
|
|
72
|
+
current_ver = f"{sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]}"
|
|
73
|
+
sys.stderr.write(f"Error: skim-py requires Python 3.10+ (running on Python {current_ver}).\n")
|
|
74
|
+
return 1
|
|
75
|
+
|
|
71
76
|
if args is None:
|
|
72
77
|
args = sys.argv[1:]
|
|
73
78
|
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
import ast
|
|
5
|
-
import os
|
|
6
5
|
import re
|
|
7
6
|
from pathlib import Path
|
|
8
|
-
from typing import Optional, List
|
|
7
|
+
from typing import Optional, List
|
|
9
8
|
|
|
10
9
|
from .types import OutlineItem, ParsedFile, SyntaxNotice
|
|
11
10
|
|
|
@@ -24,7 +23,11 @@ def get_indent(line: str) -> int:
|
|
|
24
23
|
|
|
25
24
|
|
|
26
25
|
def get_block_end(lines: List[str], start: int) -> int:
|
|
27
|
-
"""Find the last indented code line, ignoring blank lines and comments.
|
|
26
|
+
"""Find the last indented code line, ignoring blank lines and comments.
|
|
27
|
+
|
|
28
|
+
Known limit: a multi-line string with content at column 0, or a closing
|
|
29
|
+
``):`` at the header's indent, ends the block early.
|
|
30
|
+
"""
|
|
28
31
|
indent = get_indent(lines[start])
|
|
29
32
|
end = start
|
|
30
33
|
for idx in range(start + 1, len(lines)):
|
|
@@ -37,6 +40,49 @@ def get_block_end(lines: List[str], start: int) -> int:
|
|
|
37
40
|
return end
|
|
38
41
|
|
|
39
42
|
|
|
43
|
+
def collect_decorators(lines: List[str], header_idx: int, floor: int):
|
|
44
|
+
"""Scan upward from a header for decorators, including multi-line ones.
|
|
45
|
+
|
|
46
|
+
Returns (start_idx or None, decorator strings in source order). Multi-line
|
|
47
|
+
decorators are found by bracket balancing; brackets inside strings are not
|
|
48
|
+
handled specially, so an unbalanced one can hide the decorator.
|
|
49
|
+
"""
|
|
50
|
+
start: Optional[int] = None
|
|
51
|
+
decos: List[str] = []
|
|
52
|
+
idx = header_idx - 1
|
|
53
|
+
while idx >= floor:
|
|
54
|
+
s = lines[idx].strip()
|
|
55
|
+
if not s or s.startswith("#"):
|
|
56
|
+
idx -= 1
|
|
57
|
+
continue
|
|
58
|
+
if s.startswith("@"):
|
|
59
|
+
decos.insert(0, s)
|
|
60
|
+
start = idx
|
|
61
|
+
idx -= 1
|
|
62
|
+
continue
|
|
63
|
+
if s[-1] in ")]}":
|
|
64
|
+
depth = 0
|
|
65
|
+
found = None
|
|
66
|
+
for j in range(idx, floor - 1, -1):
|
|
67
|
+
t = lines[j].strip()
|
|
68
|
+
depth += sum(t.count(c) for c in ")]}") - sum(t.count(c) for c in "([{")
|
|
69
|
+
if depth <= 0:
|
|
70
|
+
if t.startswith("@"):
|
|
71
|
+
found = j
|
|
72
|
+
break
|
|
73
|
+
if found is not None:
|
|
74
|
+
parts = [
|
|
75
|
+
l.strip() for l in lines[found : idx + 1]
|
|
76
|
+
if l.strip() and not l.strip().startswith("#")
|
|
77
|
+
]
|
|
78
|
+
decos.insert(0, " ".join(parts))
|
|
79
|
+
start = found
|
|
80
|
+
idx = found - 1
|
|
81
|
+
continue
|
|
82
|
+
break
|
|
83
|
+
return start, decos
|
|
84
|
+
|
|
85
|
+
|
|
40
86
|
def split_source_lines(content: str, keepends: bool = False) -> List[str]:
|
|
41
87
|
"""Normalize newlines (\r\n, \r -> \n) and split into lines."""
|
|
42
88
|
norm = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
@@ -142,6 +188,7 @@ def parse_source_file(file_path: str, content: str, include_tests: bool = False)
|
|
|
142
188
|
|
|
143
189
|
# Fallback: Iterative line masking with up to 100 attempts
|
|
144
190
|
working_lines = split_source_lines(content, keepends=True)
|
|
191
|
+
original_len = len(working_lines)
|
|
145
192
|
seen_states = set()
|
|
146
193
|
max_attempts = 100
|
|
147
194
|
current_err: Optional[SyntaxError] = first_err
|
|
@@ -156,7 +203,9 @@ def parse_source_file(file_path: str, content: str, include_tests: bool = False)
|
|
|
156
203
|
line_idx = lineno - 1
|
|
157
204
|
line = working_lines[line_idx]
|
|
158
205
|
indent = get_indent(line)
|
|
159
|
-
leading_ws = line[: len(line) - len(line.lstrip())]
|
|
206
|
+
leading_ws = line[: len(line) - len(line.lstrip(" \t"))]
|
|
207
|
+
if not line.strip():
|
|
208
|
+
leading_ws = ""
|
|
160
209
|
|
|
161
210
|
state_key = (line_idx, line)
|
|
162
211
|
if state_key in seen_states:
|
|
@@ -173,7 +222,10 @@ def parse_source_file(file_path: str, content: str, include_tests: bool = False)
|
|
|
173
222
|
|
|
174
223
|
if end_idx > line_idx or m_hdr:
|
|
175
224
|
# Update notice message with masked line range if not already suffixed
|
|
176
|
-
|
|
225
|
+
if end_idx > line_idx:
|
|
226
|
+
mask_suffix = f"(L{line_idx + 1}-L{end_idx + 1} masked)"
|
|
227
|
+
else:
|
|
228
|
+
mask_suffix = f"(L{line_idx + 1} masked)"
|
|
177
229
|
for n in parsed.syntax_errors:
|
|
178
230
|
if n.line == lineno and "masked)" not in n.message:
|
|
179
231
|
n.message = f"{n.message} {mask_suffix}"
|
|
@@ -185,17 +237,9 @@ def parse_source_file(file_path: str, content: str, include_tests: bool = False)
|
|
|
185
237
|
end_l = end_idx + 1
|
|
186
238
|
|
|
187
239
|
# Scan upward for leading decorators
|
|
188
|
-
decos =
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
stripped_d = prev_d.strip()
|
|
192
|
-
if stripped_d.startswith("@"):
|
|
193
|
-
start_l = d_idx + 1
|
|
194
|
-
decos.insert(0, stripped_d)
|
|
195
|
-
elif not stripped_d or stripped_d.startswith("#"):
|
|
196
|
-
continue
|
|
197
|
-
else:
|
|
198
|
-
break
|
|
240
|
+
deco_start, decos = collect_decorators(working_lines, line_idx, 0)
|
|
241
|
+
if deco_start is not None:
|
|
242
|
+
start_l = deco_start + 1
|
|
199
243
|
|
|
200
244
|
deco_prefix = (" ".join(decos) + " ") if decos else ""
|
|
201
245
|
|
|
@@ -216,17 +260,11 @@ def parse_source_file(file_path: str, content: str, include_tests: bool = False)
|
|
|
216
260
|
ch_k_raw, ch_name = m_ch.group(1), m_ch.group(2)
|
|
217
261
|
ch_start = k + 1
|
|
218
262
|
# Scan upward for method decorators inside the class
|
|
219
|
-
ch_decos =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
ch_start = cj + 1
|
|
225
|
-
ch_decos.insert(0, str_cj)
|
|
226
|
-
elif not str_cj or str_cj.startswith("#"):
|
|
227
|
-
continue
|
|
228
|
-
else:
|
|
229
|
-
break
|
|
263
|
+
ch_deco_start, ch_decos = collect_decorators(
|
|
264
|
+
working_lines, k, line_idx + 1
|
|
265
|
+
)
|
|
266
|
+
if ch_deco_start is not None:
|
|
267
|
+
ch_start = ch_deco_start + 1
|
|
230
268
|
|
|
231
269
|
ch_deco_prefix = (" ".join(ch_decos) + " ") if ch_decos else ""
|
|
232
270
|
|
|
@@ -287,15 +325,24 @@ def parse_source_file(file_path: str, content: str, include_tests: bool = False)
|
|
|
287
325
|
working_lines[line_idx] = leading_ws + stub
|
|
288
326
|
stub_lines.add(lineno)
|
|
289
327
|
else:
|
|
290
|
-
working_lines[line_idx] = leading_ws + "
|
|
328
|
+
working_lines[line_idx] = leading_ws + "# syntax error masked\n"
|
|
291
329
|
for k in range(line_idx + 1, end_idx + 1):
|
|
292
330
|
working_lines[k] = "# syntax error masked\n"
|
|
331
|
+
assert len(working_lines) == original_len
|
|
293
332
|
|
|
294
333
|
masked_lines.update(range(line_idx + 1, end_idx + 2))
|
|
295
334
|
masked_block_ends[lineno] = end_idx + 1
|
|
296
335
|
|
|
297
336
|
try:
|
|
298
|
-
|
|
337
|
+
try:
|
|
338
|
+
tree = ast.parse("".join(working_lines), filename=file_path, type_comments=True)
|
|
339
|
+
except IndentationError as ind_err:
|
|
340
|
+
# Masking the only statement of a suite left it empty; use pass instead.
|
|
341
|
+
if m_hdr or "expected an indented block" not in (ind_err.msg or ""):
|
|
342
|
+
raise
|
|
343
|
+
working_lines[line_idx] = leading_ws + "pass\n"
|
|
344
|
+
assert len(working_lines) == original_len
|
|
345
|
+
tree = ast.parse("".join(working_lines), filename=file_path, type_comments=True)
|
|
299
346
|
break # Successful recovery!
|
|
300
347
|
except SyntaxError as next_err:
|
|
301
348
|
current_err = next_err
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import unittest
|
|
2
|
+
from unittest.mock import patch
|
|
2
3
|
import io
|
|
3
4
|
import sys
|
|
4
5
|
import tempfile
|
|
@@ -187,6 +188,45 @@ def valid_after():
|
|
|
187
188
|
self.assertEqual(parsed.syntax_errors[0].line, 4)
|
|
188
189
|
self.assertIn("(L4-L6 masked)", parsed.syntax_errors[0].message)
|
|
189
190
|
|
|
191
|
+
def test_error_inside_dict_recovers(self):
|
|
192
|
+
code = 'config = {\n "a": 1,\n "b": ,\n "c": 3,\n}\ndef valid(): ...\n'
|
|
193
|
+
parsed = parse_source_file("test.py", code)
|
|
194
|
+
self.assertIn("valid", [it.name for it in parsed.items])
|
|
195
|
+
|
|
196
|
+
def test_stray_indent_recovers(self):
|
|
197
|
+
code = "def a(): ...\n x = 1\ndef valid(): ...\n"
|
|
198
|
+
parsed = parse_source_file("test.py", code)
|
|
199
|
+
names = [it.name for it in parsed.items]
|
|
200
|
+
self.assertIn("valid", names)
|
|
201
|
+
|
|
202
|
+
def test_bad_parameter_in_multiline_def_recovers(self):
|
|
203
|
+
code = "def bad(\n a,\n 1b,\n):\n return a\n\ndef valid(): ...\n"
|
|
204
|
+
parsed = parse_source_file("test.py", code)
|
|
205
|
+
self.assertIn("valid", [it.name for it in parsed.items])
|
|
206
|
+
|
|
207
|
+
def test_multiline_decorator_is_salvaged(self):
|
|
208
|
+
code = """@foo(
|
|
209
|
+
a,
|
|
210
|
+
b,
|
|
211
|
+
)
|
|
212
|
+
def broken(x,
|
|
213
|
+
return x
|
|
214
|
+
|
|
215
|
+
class K(Base):
|
|
216
|
+
@bar(
|
|
217
|
+
1,
|
|
218
|
+
)
|
|
219
|
+
def broken_m(self,
|
|
220
|
+
return 1
|
|
221
|
+
|
|
222
|
+
def valid(): ...
|
|
223
|
+
"""
|
|
224
|
+
parsed = parse_source_file("test.py", code)
|
|
225
|
+
item = next(it for it in parsed.items if it.name == "broken")
|
|
226
|
+
self.assertEqual(item.start_line, 1)
|
|
227
|
+
self.assertIn("@foo( a, b, )", item.raw_header)
|
|
228
|
+
self.assertIn("valid", [it.name for it in parsed.items])
|
|
229
|
+
|
|
190
230
|
def test_salvaged_broken_class_methods(self):
|
|
191
231
|
code = """class BrokenClass(Base,
|
|
192
232
|
def method_one(self):
|
|
@@ -331,6 +371,14 @@ class TestSkimPyCLI(unittest.TestCase):
|
|
|
331
371
|
self.assertEqual(stdout.getvalue(), original)
|
|
332
372
|
self.assertIn("# parsed with 2 lines masked (L2, L3)", stderr.getvalue())
|
|
333
373
|
|
|
374
|
+
def test_python_version_guard(self):
|
|
375
|
+
err = io.StringIO()
|
|
376
|
+
with patch.object(sys, "version_info", (3, 9, 7)):
|
|
377
|
+
with redirect_stderr(err):
|
|
378
|
+
result = main(["glance", "foo.py"])
|
|
379
|
+
self.assertEqual(result, 1)
|
|
380
|
+
self.assertIn("Error: skim-py requires Python 3.10+", err.getvalue())
|
|
381
|
+
|
|
334
382
|
def test_block_casing_resolution(self):
|
|
335
383
|
casing_path = str(FIXTURES_DIR / "casing.py")
|
|
336
384
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|