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 ADDED
@@ -0,0 +1,5 @@
1
+ """skim_py package root."""
2
+
3
+ from .cli import main
4
+
5
+ __all__ = ["main"]
skim_py/block.py ADDED
@@ -0,0 +1,150 @@
1
+ """block subcommand implementation for skim-py."""
2
+
3
+ from __future__ import annotations
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+ from .types import OutlineItem, ParsedFile
10
+ from .parser import parse_source_file
11
+
12
+
13
+ def collect_all_items(items: List[OutlineItem]) -> List[OutlineItem]:
14
+ res: List[OutlineItem] = []
15
+ for it in items:
16
+ res.append(it)
17
+ if it.children:
18
+ res.extend(collect_all_items(it.children))
19
+ return res
20
+
21
+
22
+ def run_block(
23
+ file_path: str,
24
+ target: str,
25
+ raw: bool = False,
26
+ include_tests: bool = False,
27
+ ) -> int:
28
+ p = Path(file_path)
29
+ if not p.exists():
30
+ sys.stderr.write(f"Error: file not found: {file_path}\n")
31
+ return 1
32
+
33
+ try:
34
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
35
+ content = f.read()
36
+ except Exception as err:
37
+ sys.stderr.write(f"Error reading {file_path}: {err}\n")
38
+ return 1
39
+
40
+ parsed = parse_source_file(file_path, content, include_tests=include_tests)
41
+ all_items = collect_all_items(parsed.items)
42
+
43
+ matches: List[OutlineItem] = []
44
+
45
+ if "." in target:
46
+ dot_idx = target.index(".")
47
+ parent_target = target[:dot_idx]
48
+ child_target = target[dot_idx + 1 :]
49
+
50
+ parent_candidates = [it for it in all_items if it.parent_name is not None]
51
+
52
+ # Tier 1: Exact case match on both parent and child
53
+ exact_matches = [
54
+ it
55
+ for it in parent_candidates
56
+ if it.parent_name == parent_target and it.name == child_target
57
+ ]
58
+
59
+ if exact_matches:
60
+ matches = exact_matches
61
+ else:
62
+ # Tier 2: Case-insensitive match on both parent and child
63
+ p_lower = parent_target.lower()
64
+ c_lower = child_target.lower()
65
+ case_matches = [
66
+ it
67
+ for it in parent_candidates
68
+ if it.parent_name.lower() == p_lower and it.name.lower() == c_lower
69
+ ]
70
+
71
+ if case_matches:
72
+ matches = case_matches
73
+ else:
74
+ # Tier 3: Substring search
75
+ matches = [
76
+ it
77
+ for it in parent_candidates
78
+ if p_lower in it.parent_name.lower()
79
+ and (c_lower in it.name.lower() or c_lower in it.raw_header.lower())
80
+ ]
81
+ else:
82
+ # Standalone identifier
83
+ # Tier 1: Exact match on name
84
+ exact_matches = [it for it in all_items if it.name == target]
85
+
86
+ if exact_matches:
87
+ matches = exact_matches
88
+ else:
89
+ # Tier 2: Case-insensitive match on name
90
+ t_lower = target.lower()
91
+ case_matches = [it for it in all_items if it.name.lower() == t_lower]
92
+
93
+ if case_matches:
94
+ matches = case_matches
95
+ else:
96
+ # Tier 3: Case-insensitive substring match
97
+ matches = [
98
+ it
99
+ for it in all_items
100
+ if t_lower in it.name.lower() or t_lower in it.raw_header.lower()
101
+ ]
102
+
103
+ cwd = os.getcwd()
104
+ try:
105
+ rel_path = os.path.relpath(file_path, cwd)
106
+ except ValueError:
107
+ rel_path = file_path
108
+
109
+ if not matches:
110
+ sys.stderr.write(f"Error: block '{target}' not found in {rel_path}\n")
111
+ return 2
112
+
113
+ if len(matches) > 1:
114
+ sys.stderr.write(
115
+ f"Error: ambiguous target '{target}' matched {len(matches)} candidates in {rel_path}:\n"
116
+ )
117
+ for idx, m in enumerate(matches, 1):
118
+ span = f"L{m.start_line}-L{m.end_line}".ljust(10)
119
+ sys.stderr.write(f" {idx}) {span} {m.kind} {m.qualified_name}\n")
120
+ sys.stderr.write(
121
+ "\nTip: Disambiguate with Class.method (e.g. BaseModel.model_dump) or read line range directly.\n"
122
+ )
123
+ return 3
124
+
125
+ chosen = matches[0]
126
+ src_lines = content.splitlines()
127
+ block_lines = src_lines[chosen.start_line - 1 : chosen.end_line]
128
+
129
+ # Check for syntax errors within block range
130
+ block_errors = [
131
+ e for e in parsed.syntax_errors if chosen.start_line <= e.line <= chosen.end_line
132
+ ]
133
+
134
+ error_tag = ""
135
+ if block_errors:
136
+ n = len(block_errors)
137
+ error_tag = " [! 1 syntax error]" if n == 1 else f" [! {n} syntax errors]"
138
+
139
+ if raw:
140
+ print("\n".join(block_lines))
141
+ else:
142
+ print(f"=== {rel_path} (L{chosen.start_line}-L{chosen.end_line}){error_tag} ===")
143
+ for err in block_errors:
144
+ print(f" Syntax Notice: L{err.line}: {err.message}")
145
+ for idx, line in enumerate(block_lines):
146
+ line_num = chosen.start_line + idx
147
+ line_padded = str(line_num).rjust(5)
148
+ print(f"{line_padded} | {line}")
149
+
150
+ return 0
skim_py/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ """CLI entrypoint for skim-py."""
2
+
3
+ from __future__ import annotations
4
+ import sys
5
+ import argparse
6
+ from typing import List, Optional
7
+
8
+ from .glance import run_glance
9
+ from .block import run_block
10
+
11
+
12
+ USAGE_DOC = """\
13
+ skim-py: Fast Python outline & block extractor without test bloat.
14
+ Prefer this over reading entire .py files to preserve token context.
15
+
16
+ Usage:
17
+ skim-py glance [options] <path>...
18
+ skim-py block [options] <file> <target>
19
+
20
+ glance:
21
+ Outline structure, signatures, and line ranges across files/directories.
22
+
23
+ Usage:
24
+ skim-py glance [options] <path>...
25
+
26
+ Arguments:
27
+ <path>... One or more source files, directories, or globs
28
+
29
+ Options:
30
+ -s, --signatures Show declarations & types without function bodies
31
+ -d, --details Show class attributes, annotated fields, and enum members
32
+ -u, --uses, --imports Display top-level import statements
33
+ -f, --filter <text> Filter items containing <text> (case-insensitive, repeatable)
34
+ -t, --include-tests Include test files (test_*.py, etc.) and test blocks
35
+
36
+ Examples:
37
+ skim-py glance src/models.py
38
+ skim-py glance src/ -s -u
39
+ skim-py glance src/ -s -f "BaseModel"
40
+
41
+ block:
42
+ Extract verbatim code block for a class, method, function, or variable.
43
+
44
+ Usage:
45
+ skim-py block [options] <file> <target>
46
+
47
+ Arguments:
48
+ <file> Path to the source file
49
+ <target> Name of class, method, function, or Class.method
50
+
51
+ Options:
52
+ -t, --include-tests Search inside test files and test blocks
53
+ --raw Omit line number gutter
54
+
55
+ Examples:
56
+ skim-py block src/models.py BaseModel
57
+ skim-py block src/models.py BaseModel.model_dump
58
+ skim-py block src/models.py BaseModel.model_dump --raw
59
+
60
+ Disambiguation:
61
+ If multiple items match, qualify Class.method (e.g. BaseModel.model_dump) or read line range directly.
62
+
63
+ Agent Operational Workflow:
64
+ 1. Glance first: skim-py glance <path> -s
65
+ 2. Locate logic: skim-py glance <path> -f <keyword>
66
+ 3. Inspect code: Use `block <file> <Class.method>`, or read reported line spans (Lxx-Lyy)
67
+ """
68
+
69
+
70
+ def main(args: Optional[List[str]] = None) -> int:
71
+ if args is None:
72
+ args = sys.argv[1:]
73
+
74
+ if not args or args[0] in {"-h", "--help"}:
75
+ sys.stdout.write(USAGE_DOC)
76
+ return 0
77
+
78
+ subcommand = args[0]
79
+
80
+ if subcommand == "glance":
81
+ parser = argparse.ArgumentParser(
82
+ prog="skim-py glance",
83
+ description="Outline structure, signatures, and line spans.",
84
+ add_help=False,
85
+ )
86
+ parser.add_argument("-s", "--signatures", action="store_true")
87
+ parser.add_argument("-d", "--details", action="store_true")
88
+ parser.add_argument("-u", "--uses", "--imports", action="store_true", dest="uses")
89
+ parser.add_argument("-f", "--filter", action="append", default=[], dest="filter")
90
+ parser.add_argument("-t", "--include-tests", action="store_true", dest="include_tests")
91
+ parser.add_argument("-h", "--help", action="store_true")
92
+ parser.add_argument("targets", nargs="*")
93
+
94
+ parsed_args, extra = parser.parse_known_args(args[1:])
95
+ if parsed_args.help:
96
+ sys.stdout.write(USAGE_DOC)
97
+ return 0
98
+ if not parsed_args.targets:
99
+ sys.stderr.write("Error: missing target path(s) for glance.\n")
100
+ return 1
101
+
102
+ return run_glance(
103
+ targets=parsed_args.targets,
104
+ signatures=parsed_args.signatures,
105
+ details=parsed_args.details,
106
+ uses=parsed_args.uses,
107
+ filters=parsed_args.filter,
108
+ include_tests=parsed_args.include_tests,
109
+ )
110
+
111
+ elif subcommand == "block":
112
+ parser = argparse.ArgumentParser(
113
+ prog="skim-py block",
114
+ description="Extract verbatim code block.",
115
+ add_help=False,
116
+ )
117
+ parser.add_argument("-t", "--include-tests", action="store_true", dest="include_tests")
118
+ parser.add_argument("--raw", action="store_true")
119
+ parser.add_argument("-h", "--help", action="store_true")
120
+ parser.add_argument("positionals", nargs="*")
121
+
122
+ parsed_args, extra = parser.parse_known_args(args[1:])
123
+ if parsed_args.help:
124
+ sys.stdout.write(USAGE_DOC)
125
+ return 0
126
+ if len(parsed_args.positionals) < 2:
127
+ sys.stderr.write("Error: block requires <file> and <target> arguments.\n")
128
+ return 1
129
+
130
+ file_path = parsed_args.positionals[0]
131
+ target = parsed_args.positionals[1]
132
+
133
+ return run_block(
134
+ file_path=file_path,
135
+ target=target,
136
+ raw=parsed_args.raw,
137
+ include_tests=parsed_args.include_tests,
138
+ )
139
+
140
+ else:
141
+ sys.stderr.write(f"Error: unknown subcommand '{subcommand}'. Use 'glance' or 'block'.\n\n")
142
+ sys.stdout.write(USAGE_DOC)
143
+ return 1
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())
skim_py/glance.py ADDED
@@ -0,0 +1,203 @@
1
+ """glance subcommand implementation for skim-py."""
2
+
3
+ from __future__ import annotations
4
+ import os
5
+ import sys
6
+ import glob
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ from .types import OutlineItem, ParsedFile
11
+ from .parser import parse_source_file, is_test_file
12
+
13
+
14
+ IGNORED_DIRS = {
15
+ ".git",
16
+ "__pycache__",
17
+ ".pytest_cache",
18
+ ".mypy_cache",
19
+ ".ruff_cache",
20
+ ".venv",
21
+ "venv",
22
+ "env",
23
+ "node_modules",
24
+ "dist",
25
+ "build",
26
+ ".tox",
27
+ ".eggs",
28
+ }
29
+
30
+ SUPPORTED_EXTS = {".py", ".pyi"}
31
+
32
+
33
+ def is_supported_file(path_str: str) -> bool:
34
+ return Path(path_str).suffix.lower() in SUPPORTED_EXTS
35
+
36
+
37
+ def find_files_recursively(dir_path: str, include_tests: bool) -> List[str]:
38
+ results: List[str] = []
39
+ try:
40
+ with os.scandir(dir_path) as it:
41
+ for entry in it:
42
+ if entry.is_dir(follow_symlinks=False):
43
+ if entry.name in IGNORED_DIRS:
44
+ continue
45
+ if not include_tests and entry.name.lower() in {"tests", "test", "testing", "__tests__"}:
46
+ continue
47
+ results.extend(find_files_recursively(entry.path, include_tests))
48
+ elif entry.is_file(follow_symlinks=False):
49
+ if is_supported_file(entry.name):
50
+ if not include_tests and is_test_file(entry.path):
51
+ continue
52
+ results.append(entry.path)
53
+ except (PermissionError, FileNotFoundError) as err:
54
+ sys.stderr.write(f"Warning: error reading directory {dir_path}: {err}\n")
55
+ return results
56
+
57
+
58
+ def collect_files(targets: List[str], include_tests: bool) -> List[str]:
59
+ files: List[str] = []
60
+ for target in targets:
61
+ p = Path(target)
62
+ if p.exists():
63
+ if p.is_dir():
64
+ files.extend(find_files_recursively(str(p), include_tests))
65
+ elif p.is_file():
66
+ if is_supported_file(str(p)):
67
+ # When an explicit file path is directly supplied, always include it
68
+ files.append(str(p))
69
+ elif any(char in target for char in "*?[]"):
70
+ matches = glob.glob(target, recursive=True)
71
+ for m in matches:
72
+ if is_supported_file(m):
73
+ if include_tests or not is_test_file(m):
74
+ files.append(m)
75
+ else:
76
+ sys.stderr.write(f"Warning: path not found: {target}\n")
77
+
78
+ unique = sorted(set(os.path.normpath(f) for f in files))
79
+ return unique
80
+
81
+
82
+ def item_matches_filters(item: OutlineItem, content: str, lines: List[str], filters: List[str]) -> bool:
83
+ if not filters:
84
+ return True
85
+ block_text = "\n".join(lines[item.start_line - 1 : item.end_line]).lower()
86
+ return all(f.lower() in block_text for f in filters)
87
+
88
+
89
+ def has_child_matching(item: OutlineItem, content: str, lines: List[str], filters: List[str]) -> bool:
90
+ if not filters:
91
+ return True
92
+ for ch in item.children:
93
+ if item_matches_filters(ch, content, lines, filters) or has_child_matching(ch, content, lines, filters):
94
+ return True
95
+ return False
96
+
97
+
98
+ def run_glance(
99
+ targets: List[str],
100
+ signatures: bool = False,
101
+ details: bool = False,
102
+ uses: bool = False,
103
+ filters: Optional[List[str]] = None,
104
+ include_tests: bool = False,
105
+ ) -> int:
106
+ filters = filters or []
107
+ files = collect_files(targets, include_tests)
108
+
109
+ if not files:
110
+ sys.stderr.write("Error: no Python files found matching input.\n")
111
+ return 1
112
+
113
+ printed_count = 0
114
+ cwd = os.getcwd()
115
+
116
+ for file_path in files:
117
+ try:
118
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
119
+ content = f.read()
120
+ except Exception as err:
121
+ sys.stderr.write(f"Error reading {file_path}: {err}\n")
122
+ continue
123
+
124
+ src_lines = content.splitlines()
125
+ parsed = parse_source_file(file_path, content, include_tests=include_tests)
126
+
127
+ if filters:
128
+ has_match = any(
129
+ item_matches_filters(it, content, src_lines, filters)
130
+ or has_child_matching(it, content, src_lines, filters)
131
+ for it in parsed.items
132
+ )
133
+ if not has_match and not parsed.syntax_errors:
134
+ continue
135
+
136
+ if printed_count > 0:
137
+ print("\n" + "=" * 70 + "\n")
138
+ printed_count += 1
139
+
140
+ test_info = ""
141
+ if parsed.is_test_file and include_tests:
142
+ test_info = " [test]"
143
+ elif parsed.test_ranges and not include_tests:
144
+ tr_desc = ", ".join(f"{s}..{e}" for s, e in parsed.test_ranges)
145
+ test_info = f" [tests skipped: {tr_desc}]"
146
+
147
+ error_tag = ""
148
+ if parsed.syntax_errors:
149
+ n = len(parsed.syntax_errors)
150
+ error_tag = " [! 1 syntax error]" if n == 1 else f" [! {n} syntax errors]"
151
+
152
+ try:
153
+ rel_path = os.path.relpath(file_path, cwd)
154
+ except ValueError:
155
+ rel_path = file_path
156
+
157
+ print(f"=== {rel_path} ({parsed.total_lines} lines){test_info}{error_tag} ===")
158
+
159
+ for err in parsed.syntax_errors:
160
+ print(f" Syntax Notice: L{err.line}: {err.message}")
161
+
162
+ if uses:
163
+ if parsed.imports:
164
+ print("\nImports:")
165
+ for imp in parsed.imports:
166
+ for line in imp.splitlines():
167
+ print(f" {line}")
168
+ else:
169
+ print("\nImports: (none)")
170
+
171
+ print("\nOutline:")
172
+
173
+ def render_tree(items: List[OutlineItem], depth: int = 0) -> None:
174
+ for it in items:
175
+ matches = item_matches_filters(it, content, src_lines, filters)
176
+ child_matches = has_child_matching(it, content, src_lines, filters)
177
+ if not matches and not child_matches:
178
+ continue
179
+
180
+ span = f"L{it.start_line}-L{it.end_line}"
181
+ if it.kind == "test":
182
+ label = it.raw_header
183
+ elif signatures:
184
+ label = it.raw_header
185
+ else:
186
+ label = f"{it.kind} {it.name}"
187
+
188
+ child_indent = " " * depth
189
+ span_padded = span.ljust(12)
190
+ print(f" {span_padded} {child_indent}{label}")
191
+
192
+ if it.fields_or_variants and details:
193
+ f_indent = " " * (17 + depth * 2)
194
+ for fv in it.fields_or_variants:
195
+ print(f"{f_indent}| {fv}")
196
+
197
+ methods_or_subitems = [ch for ch in it.children if ch.kind in {"method", "test"}]
198
+ if methods_or_subitems:
199
+ render_tree(methods_or_subitems, depth + 1)
200
+
201
+ render_tree(parsed.items)
202
+
203
+ return 0