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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: skim-python
3
- Version: 0.0.2
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.2"
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, Tuple
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
- mask_suffix = f"(L{line_idx + 1}-L{end_idx + 1} masked)"
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
- for d_idx in range(line_idx - 1, -1, -1):
190
- prev_d = working_lines[d_idx]
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
- for cj in range(k - 1, line_idx, -1):
221
- prev_cj = working_lines[cj]
222
- str_cj = prev_cj.strip()
223
- if str_cj.startswith("@"):
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 + "pass\n"
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
- tree = ast.parse("".join(working_lines), filename=file_path, type_comments=True)
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