Option C Tools
Loading...
Searching...
No Matches
skeleton_exporter.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/tools/skeleton_exporter.py
4
5"""
6Purpose
7-------
8Export structural skeletons of source files for AI inspection, code review,
9or project overview. Emits headers, module docstrings, and function/class
10signatures without implementation bodies.
11
12Responsibilities
13----------------
14- Extract Python file skeletons via AST: headers, docstrings, signatures.
15- Produce one-line placeholders for non-Python files.
16- Reuse the source exporter's config, exclusion, and output infrastructure.
17- Support ``--no-diagnostics`` to omit Diagnostics docstring sections.
18
19Diagnostics
20-----------
21Domain: OCT-SKELETON
22Levels:
23 L2 — lifecycle
24 L3 — semantic details
25 L4 — deep tracing
26
27Contracts
28---------
29- All public helpers from ``source_exporter`` are reused, not duplicated.
30- Skeleton output never contains implementation bodies.
31- Non-Python files produce a single-line size summary.
32"""
33
34from __future__ import annotations
35
36import ast
37import os
38import re
39import shutil
40import sys
41from datetime import datetime
42from pathlib import Path
43from typing import Dict, List, Tuple
44
45from oct.core.parsing import safe_parse
46from oct.tools.source_exporter import (
47 load_exporter_config,
48 should_skip_dirname,
49 _is_never_export_file,
50 collect_recursive_files,
51 walk_included_dirs,
52 get_file_stats,
53 _make_safe_output_path,
54)
55
56
57# ============================================================
58# Output formatting constants
59# ============================================================
60
61_HEADER_TEXT = (
62 "This file contains structural skeletons (headers, docstrings, signatures) "
63 "of all source files in the directory {directory}.\n"
64 "Implementation bodies are omitted — only the public API surface is shown.\n\n"
65)
66
67_SEPARATOR_FILE = "##### File name {num:02d}: {path}\n"
68_SEPARATOR_SUBDIR = "####### Subdirectory name {num:02d}: {dirname}\n"
69_ROOT_HEADER = "####### Root directory: .\n\n"
70
71
72# ============================================================
73# Docstring section stripping
74# ============================================================
75
76def _strip_docstring_section(docstring: str, section_name: str) -> str:
77 """Remove a named RST-style section from a docstring.
78
79 Sections are identified by the pattern::
80
81 SectionName
82 -----------
83 (content until next section header or end of docstring)
84 """
85 # Match: section header + underline + everything until the next section
86 # header (non-blank line followed by dashes) or end of string.
87 # Uses \S (any non-whitespace start) instead of [A-Za-z] to correctly
88 # handle headers with numbers, unicode characters, or multi-word titles
89 # starting with any printable character (OI-415).
90 pattern = (
91 rf"(?m)^{re.escape(section_name)}[ \t]*\n"
92 r"-{3,}[ \t]*\n"
93 r"(?:(?!^\S.*\n-{3,})[\s\S])*?"
94 r"(?=^\S.*\n-{3,}|\Z)"
95 )
96 result = re.sub(pattern, "", docstring, flags=re.MULTILINE)
97 result = re.sub(r"\n{3,}", "\n\n", result)
98 return result.rstrip() + "\n"
99
100
101# ============================================================
102# AST-based skeleton extraction
103# ============================================================
104
105def _format_decorators(node: ast.AST, indent: str = "") -> str:
106 """Reconstruct decorator lines from an AST node's decorator_list."""
107 lines = []
108 for dec in getattr(node, "decorator_list", []):
109 lines.append(f"{indent}@{ast.unparse(dec)}")
110 return "\n".join(lines)
111
112
113def _format_function(node: ast.FunctionDef | ast.AsyncFunctionDef,
114 indent: str = "") -> str:
115 """Reconstruct a function/method signature with decorators and docstring."""
116 parts = []
117
118 # Decorators
119 decs = _format_decorators(node, indent)
120 if decs:
121 parts.append(decs)
122
123 # Signature
124 prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
125 args_str = ast.unparse(node.args)
126 ret = f" -> {ast.unparse(node.returns)}" if node.returns else ""
127 parts.append(f"{indent}{prefix} {node.name}({args_str}){ret}:")
128
129 # Docstring
130 docstring = ast.get_docstring(node, clean=False)
131 if docstring:
132 parts.append(f'{indent} """{docstring}"""')
133
134 # Ellipsis body placeholder
135 parts.append(f"{indent} ...")
136
137 return "\n".join(parts)
138
139
140def _format_class(node: ast.ClassDef, indent: str = "",
141 *, no_diagnostics: bool = False) -> str:
142 """Reconstruct a class definition with methods and nested classes."""
143 parts = []
144
145 # Decorators
146 decs = _format_decorators(node, indent)
147 if decs:
148 parts.append(decs)
149
150 # Class signature
151 bases = ", ".join(ast.unparse(b) for b in node.bases)
152 keywords = ", ".join(ast.unparse(kw) for kw in node.keywords)
153 all_args = ", ".join(filter(None, [bases, keywords]))
154 sig = f"{indent}class {node.name}({all_args}):" if all_args else f"{indent}class {node.name}:"
155 parts.append(sig)
156
157 # Class docstring
158 docstring = ast.get_docstring(node, clean=False)
159 if docstring:
160 if no_diagnostics:
161 docstring = _strip_docstring_section(docstring, "Diagnostics")
162 parts.append(f'{indent} """{docstring}"""')
163
164 inner_indent = indent + " "
165 has_body = False
166
167 for child in node.body:
168 # Skip the docstring node (already handled above)
169 if (isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant)
170 and isinstance(child.value.value, str) and not has_body
171 and ast.get_docstring(node, clean=False)):
172 has_body = True
173 continue
174
175 if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
176 parts.append("")
177 parts.append(_format_function(child, inner_indent))
178 has_body = True
179 elif isinstance(child, ast.ClassDef):
180 parts.append("")
181 parts.append(_format_class(child, inner_indent,
182 no_diagnostics=no_diagnostics))
183 has_body = True
184 elif isinstance(child, ast.AnnAssign) and child.target:
185 parts.append(f"{inner_indent}{ast.unparse(child)}")
186 has_body = True
187 elif isinstance(child, ast.Assign):
188 # Include module-level / class-level constants (ALL_CAPS or _ALL_CAPS)
189 names = [t.id for t in child.targets
190 if isinstance(t, ast.Name) and t.id.lstrip("_").isupper()]
191 if names:
192 parts.append(f"{inner_indent}{' = '.join(names)} = ...")
193 has_body = True
194
195 if not has_body:
196 parts.append(f"{inner_indent}...")
197
198 return "\n".join(parts)
199
200
201def extract_skeleton(text: str, filepath: str, *,
202 no_diagnostics: bool = False) -> str:
203 """Extract structural skeleton from a Python file's source text.
204
205 Returns a string containing headers, module docstring, and
206 class/function signatures — no implementation bodies.
207 """
208 lines = text.splitlines()
209 parts: list[str] = []
210
211 # Header block (first 3 lines)
212 header_lines = lines[:3] if len(lines) >= 3 else lines[:]
213 parts.append("\n".join(header_lines))
214
215 # Parse AST
216 tree, _warnings = safe_parse(text)
217 if tree is None:
218 parts.append("")
219 parts.append("# [syntax error — file could not be parsed]")
220 return "\n".join(parts) + "\n"
221
222 # Module docstring
223 docstring = ast.get_docstring(tree, clean=False)
224 if docstring:
225 if no_diagnostics:
226 docstring = _strip_docstring_section(docstring, "Diagnostics")
227 parts.append("")
228 parts.append(f'"""{docstring}"""')
229
230 # Walk top-level body
231 for node in tree.body:
232 # Skip the module docstring node
233 if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)
234 and isinstance(node.value.value, str) and docstring):
235 continue
236
237 if isinstance(node, ast.ClassDef):
238 parts.append("")
239 parts.append(_format_class(node, no_diagnostics=no_diagnostics))
240 elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
241 parts.append("")
242 parts.append(_format_function(node))
243 elif isinstance(node, ast.AnnAssign) and node.target:
244 target_name = getattr(node.target, "id", "")
245 if target_name.lstrip("_").isupper():
246 parts.append("")
247 parts.append(ast.unparse(node))
248 elif isinstance(node, ast.Assign):
249 names = [t.id for t in node.targets
250 if isinstance(t, ast.Name) and t.id.lstrip("_").isupper()]
251 if names:
252 parts.append("")
253 parts.append(f"{' = '.join(names)} = ...")
254
255 return "\n".join(parts) + "\n"
256
257
258def _skeleton_for_non_python(filepath: Path) -> str:
259 """Return a one-line placeholder for a non-Python file."""
260 lines, _words, bytes_ = get_file_stats(filepath)
261 kb = bytes_ / 1024.0
262 return f"# [{filepath.name}] {lines} lines, {kb:.1f} KB\n"
263
264
265# ============================================================
266# Directory-level skeleton writing
267# ============================================================
268
269def write_skeleton_file_for_dir(folder: Path, output_path: Path, root: Path,
270 cfg: dict, *,
271 no_diagnostics: bool = False) -> tuple:
272 """Write a skeleton export file for all source files in a directory."""
273 files = collect_recursive_files(folder, cfg)
274
275 file_count = 0
276 total_lines = 0
277 total_words = 0
278 total_bytes = 0
279
280 try:
281 fh = output_path.open("w", encoding="utf-8")
282 except OSError as e:
283 print(f" Warning: cannot write '{output_path.name}': {e}")
284 return 0, 0, 0, 0, files
285
286 with fh as out:
287 out.write(_HEADER_TEXT.format(directory=folder))
288 out.write(_ROOT_HEADER)
289
290 for idx, file_path in enumerate(files, start=1):
291 rel_path = file_path.relative_to(folder)
292 out.write(_SEPARATOR_FILE.format(num=idx, path=str(rel_path)))
293
294 if file_path.suffix == ".py":
295 try:
296 text = file_path.read_text(encoding="utf-8", errors="replace")
297 except (OSError, UnicodeDecodeError):
298 print(f"Warning: Could not read {file_path}: skipping",
299 file=sys.stderr)
300 text = ""
301 skeleton = extract_skeleton(text, str(rel_path),
302 no_diagnostics=no_diagnostics)
303 out.write(skeleton)
304 else:
305 out.write(_skeleton_for_non_python(file_path))
306
307 out.write("\n\n")
308
309 file_count += 1
310 total_bytes += file_path.stat().st_size
311 try:
312 content = file_path.read_text(encoding="utf-8", errors="replace")
313 total_lines += content.count("\n") + 1
314 total_words += len(content.split())
315 except (OSError, UnicodeDecodeError):
316 pass
317
318 return file_count, total_lines, total_words, total_bytes, files
319
320
321# ============================================================
322# Cleanup
323# ============================================================
324
325def clean_skeleton_dirs(root: Path, dry_run: bool = False) -> None:
326 """Remove all _skeleton_code-* directories under root."""
327 removed = 0
328 for dirpath, dirs, _ in os.walk(root, topdown=True):
329 for d in list(dirs):
330 if d.startswith("_skeleton_code-"):
331 target = Path(dirpath) / d
332 if dry_run:
333 print(f"[DRY RUN] Would remove: {target}")
334 else:
335 shutil.rmtree(target, ignore_errors=True)
336 dirs.remove(d)
337 removed += 1
338 if dry_run:
339 print(f"[DRY RUN] Would remove {removed} _skeleton_code-* directories under {root}")
340 else:
341 print(f"Removed {removed} _skeleton_code-* directories under {root}")
342
343
344# ============================================================
345# Public entry point used by OCT CLI
346# ============================================================
347
349 root: Path,
350 args: list[str],
351 json_mode: bool = False,
352) -> None:
353 """Execute the skeleton exporter with the given root directory and arguments.
354
355 When *json_mode* is True, emits a machine-readable JSON summary to stdout
356 instead of the plain-text summary:
357 ``{"tool": "export-skeleton", "directories_processed": N,
358 "files_exported": N, "output_dirs": [...], "exit_code": 0}``
359 """
360 do_clean = "--clean" in args
361 verbose = "--verbose" in args
362 single_dir_mode = "--single-dir" in args
363 no_diagnostics = "--no-diagnostics" in args
364
365 if do_clean:
367 return
368
369 cfg = load_exporter_config(root)
370
371 timestamp = datetime.now().strftime("%Y%m%d-%H%M")
372 root_name = root.name
373
374 summary: Dict[str, Tuple[int, int, int, int]] = {}
375 per_dir_files: Dict[str, List[Path]] = {}
376 skipped: List[str] = []
377
378 single_out_root: Path | None = None
379 if single_dir_mode:
380 single_out_root = root / f"_skeleton_code-{timestamp}"
381 try:
382 single_out_root.mkdir(parents=True, exist_ok=True)
383 except OSError as e:
384 print(f" Error: cannot create single output dir: {e}")
385 return
386
387 # Per-directory exports
388 for folder in walk_included_dirs(root, cfg):
389 if should_skip_dirname(folder.name, cfg):
390 continue
391
392 if single_dir_mode:
393 rel_to_root = folder.relative_to(root) if folder != root else Path(".")
394 out_dir = single_out_root / rel_to_root
395 else:
396 out_dir = folder / f"_skeleton_code-{timestamp}"
397
398 try:
399 out_dir.mkdir(parents=True, exist_ok=True)
400 except OSError as e:
401 rel_err = "." if folder == root else str(folder.relative_to(root))
402 print(f" Warning: cannot create output dir for '{folder.name}': {e}")
403 skipped.append(rel_err)
404 continue
405
406 rel = "." if folder == root else str(folder.relative_to(root))
407 dotpath = root_name if folder == root else f"{root_name}.{rel.replace(os.sep, '.')}"
408 out_path = _make_safe_output_path(out_dir, dotpath, cfg,
409 prefix="_skeleton")
410
411 try:
412 file_count, total_lines, total_words, total_bytes, files = (
413 write_skeleton_file_for_dir(folder, out_path, root, cfg,
414 no_diagnostics=no_diagnostics)
415 )
416 except Exception as e:
417 print(f" Warning: export failed for '{rel}': {e}")
418 skipped.append(rel)
419 continue
420
421 summary[rel] = (file_count, total_lines, total_words, total_bytes)
422 per_dir_files[rel] = files
423
424 # Full project skeleton
425 if single_dir_mode:
426 root_out_dir = single_out_root
427 else:
428 root_out_dir = root / f"_skeleton_code-{timestamp}"
429 full_output_path = _make_safe_output_path(root_out_dir, root_name, cfg,
430 prefix="_full_skeleton")
431
432 all_files = collect_recursive_files(root, cfg)
433 grouped: Dict[str, List[Path]] = {}
434 for p in all_files:
435 parent_rel = p.parent.relative_to(root)
436 key = "." if str(parent_rel) == "." else str(parent_rel)
437 grouped.setdefault(key, []).append(p)
438
439 try:
440 fh = full_output_path.open("w", encoding="utf-8")
441 except OSError as e:
442 print(f" Warning: cannot write full-skeleton file: {e}")
443 fh = None
444
445 if fh is not None:
446 with fh as out:
447 out.write(_HEADER_TEXT.format(directory=root))
448 out.write(_ROOT_HEADER)
449
450 for idx_dir, dirname in enumerate(sorted(grouped.keys()), start=1):
451 out.write(_SEPARATOR_SUBDIR.format(num=idx_dir, dirname=dirname))
452 for idx_file, file_path in enumerate(grouped[dirname], start=1):
453 rel_path = file_path.relative_to(root)
454 out.write(_SEPARATOR_FILE.format(num=idx_file,
455 path=str(rel_path)))
456
457 if file_path.suffix == ".py":
458 try:
459 text = file_path.read_text(encoding="utf-8",
460 errors="replace")
461 except (OSError, UnicodeDecodeError):
462 text = ""
463 out.write(extract_skeleton(text, str(rel_path),
464 no_diagnostics=no_diagnostics))
465 else:
466 out.write(_skeleton_for_non_python(file_path))
467
468 out.write("\n\n")
469
470 # Summary
471 if "." in summary:
472 total_files, total_lines, total_words, total_bytes = summary["."]
473 else:
474 total_files = total_lines = total_words = total_bytes = 0
475 for p in all_files:
476 flines, fwords, fbytes = get_file_stats(p)
477 total_files += 1
478 total_lines += flines
479 total_words += fwords
480 total_bytes += fbytes
481
482 if json_mode:
483 import json as _json
484 output_dirs: List[str] = []
485 if single_dir_mode and single_out_root is not None:
486 output_dirs = [str(single_out_root)]
487 else:
488 output_dirs = [
489 str(root / f"_skeleton_code-{timestamp}"),
490 ]
491 print(_json.dumps({
492 "tool": "export-skeleton",
493 "directories_processed": len(summary),
494 "files_exported": total_files,
495 "total_lines": total_lines,
496 "total_words": total_words,
497 "total_bytes": total_bytes,
498 "output_dirs": output_dirs,
499 "skipped": skipped,
500 "exit_code": 0,
501 }))
502 else:
503 print(f"\n========== SKELETON SUMMARY ==========")
504 seen_files_v: set[Path] = set()
505 for dirname in sorted(summary.keys()):
506 files_count, lines, words, bytes_ = summary[dirname]
507 kb = bytes_ / 1024.0
508 print(f"Directory '{dirname}': {files_count} files, "
509 f"{lines} lines, {words} words, {kb:.1f} KB")
510 if verbose:
511 for p in per_dir_files.get(dirname, []):
512 if p not in seen_files_v:
513 seen_files_v.add(p)
514 flines, fwords, fbytes = get_file_stats(p)
515 fkb = fbytes / 1024.0
516 rel_path = p.relative_to(root)
517 print(f" {rel_path} -- {flines} lines, "
518 f"{fwords} words, {fkb:.1f} KB")
519 print(f"\nTotal directories processed: {len(summary)}")
520 print(f"Total source files processed: {total_files}")
521 print(f"Total lines (original): {total_lines}")
522 print(f"Total words (original): {total_words}")
523 print(f"Total size (original): {total_bytes / 1024.0:.1f} KB")
524 if single_dir_mode:
525 print(f"Output stored in single directory: {single_out_root}")
526 else:
527 print(f"Output stored under _skeleton_code-{timestamp} directories")
528 if skipped:
529 n = len(skipped)
530 print(f"\nSkipped {n} director{'y' if n == 1 else 'ies'} due to errors:")
531 for s in skipped:
532 print(f" - {s}")
533 print("======================================\n")
str extract_skeleton(str text, str filepath, *, bool no_diagnostics=False)
str _format_decorators(ast.AST node, str indent="")
str _format_function(ast.FunctionDef|ast.AsyncFunctionDef node, str indent="")
tuple write_skeleton_file_for_dir(Path folder, Path output_path, Path root, dict cfg, *, bool no_diagnostics=False)
str _skeleton_for_non_python(Path filepath)
None run_skeleton_exporter(Path root, list[str] args, bool json_mode=False)
None clean_skeleton_dirs(Path root, bool dry_run=False)
str _format_class(ast.ClassDef node, str indent="", *, bool no_diagnostics=False)
str _strip_docstring_section(str docstring, str section_name)