155 *, no_diagnostics: bool =
False) -> str:
156 """Reconstruct a class definition with methods and nested classes."""
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}:"
172 docstring = ast.get_docstring(node, clean=
False)
176 parts.append(f
'{indent} """{docstring}"""')
178 inner_indent = indent +
" "
181 for child
in node.body:
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)):
189 if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
193 elif isinstance(child, ast.ClassDef):
196 no_diagnostics=no_diagnostics))
198 elif isinstance(child, ast.AnnAssign)
and child.target:
199 parts.append(f
"{inner_indent}{ast.unparse(child)}")
201 elif isinstance(child, ast.Assign):
203 names = [t.id
for t
in child.targets
204 if isinstance(t, ast.Name)
and t.id.lstrip(
"_").isupper()]
206 parts.append(f
"{inner_indent}{' = '.join(names)} = ...")
210 parts.append(f
"{inner_indent}...")
212 return "\n".join(parts)
216 no_diagnostics: bool =
False) -> str:
217 """Extract structural skeleton from a Python file's source text.
219 Returns a string containing headers, module docstring, and
220 class/function signatures - no implementation bodies.
222 lines = text.splitlines()
223 parts: list[str] = []
226 header_lines = lines[:3]
if len(lines) >= 3
else lines[:]
227 parts.append(
"\n".join(header_lines))
230 tree, _warnings = safe_parse(text)
233 parts.append(
"# [syntax error - file could not be parsed]")
234 return "\n".join(parts) +
"\n"
237 docstring = ast.get_docstring(tree, clean=
False)
242 parts.append(f
'"""{docstring}"""')
245 for node
in tree.body:
247 if (isinstance(node, ast.Expr)
and isinstance(node.value, ast.Constant)
248 and isinstance(node.value.value, str)
and docstring):
251 if isinstance(node, ast.ClassDef):
253 parts.append(
_format_class(node, no_diagnostics=no_diagnostics))
254 elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
257 elif isinstance(node, ast.AnnAssign)
and node.target:
258 target_name = getattr(node.target,
"id",
"")
259 if target_name.lstrip(
"_").isupper():
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()]
267 parts.append(f
"{' = '.join(names)} = ...")
269 return "\n".join(parts) +
"\n"
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)
295 fh = output_path.open(
"w", encoding=
"utf-8")
297 print(f
" Warning: cannot write '{output_path.name}': {e}")
298 return 0, 0, 0, 0, files
301 out.write(_HEADER_TEXT.format(directory=folder))
302 out.write(_ROOT_HEADER)
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)))
308 if file_path.suffix ==
".py":
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",
316 no_diagnostics=no_diagnostics)
324 total_bytes += file_path.stat().st_size
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):
332 return file_count, total_lines, total_words, total_bytes, files
361 json_mode: bool =
False,
363 """Execute the skeleton exporter with the given root directory and arguments.
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}``
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
381 cfg = load_exporter_config(root)
383 timestamp = datetime.now().strftime(
"%Y%m%d-%H%M")
384 root_name = root.name
386 summary: Dict[str, Tuple[int, int, int, int]] = {}
387 per_dir_files: Dict[str, List[Path]] = {}
388 skipped: List[str] = []
390 single_out_root: Path |
None =
None
392 single_out_root = root / f
"_skeleton_code-{timestamp}"
394 single_out_root.mkdir(parents=
True, exist_ok=
True)
396 print(f
" Error: cannot create single output dir: {e}")
400 for folder
in walk_included_dirs(root, cfg):
401 if should_skip_dirname(folder.name, cfg):
405 rel_to_root = folder.relative_to(root)
if folder != root
else Path(
".")
406 out_dir = single_out_root / rel_to_root
408 out_dir = folder / f
"_skeleton_code-{timestamp}"
411 out_dir.mkdir(parents=
True, exist_ok=
True)
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)
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,
424 file_count, total_lines, total_words, total_bytes, files = (
426 no_diagnostics=no_diagnostics)
428 except Exception
as e:
429 print(f
" Warning: export failed for '{rel}': {e}")
433 summary[rel] = (file_count, total_lines, total_words, total_bytes)
434 per_dir_files[rel] = files
438 root_out_dir = single_out_root
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")
444 all_files = collect_recursive_files(root, cfg)
445 grouped: Dict[str, List[Path]] = {}
447 parent_rel = p.parent.relative_to(root)
448 key =
"." if str(parent_rel) ==
"." else str(parent_rel)
449 grouped.setdefault(key, []).append(p)
452 fh = full_output_path.open(
"w", encoding=
"utf-8")
454 print(f
" Warning: cannot write full-skeleton file: {e}")
459 out.write(_HEADER_TEXT.format(directory=root))
460 out.write(_ROOT_HEADER)
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,
469 if file_path.suffix ==
".py":
471 text = file_path.read_text(encoding=
"utf-8",
473 except (OSError, UnicodeDecodeError):
476 no_diagnostics=no_diagnostics))
484 total_files, total_lines, total_words, total_bytes = summary[
"."]
486 total_files = total_lines = total_words = total_bytes = 0
488 flines, fwords, fbytes = get_file_stats(p)
490 total_lines += flines
491 total_words += fwords
492 total_bytes += fbytes
496 output_dirs: List[str] = []
497 if single_dir_mode
and single_out_root
is not None:
498 output_dirs = [str(single_out_root)]
501 str(root / f
"_skeleton_code-{timestamp}"),
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,
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]
520 print(f
"Directory '{dirname}': {files_count} files, "
521 f
"{lines} lines, {words} words, {kb:.1f} KB")
523 for p
in per_dir_files.get(dirname, []):
524 if p
not in seen_files_v:
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")
537 print(f
"Output stored in single directory: {single_out_root}")
539 print(f
"Output stored under _skeleton_code-{timestamp} directories")
542 print(f
"\nSkipped {n} director{'y' if n == 1 else 'ies'} due to errors:")
545 print(
"======================================\n")