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