Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
.PHONY: docs serve-docs clean install

install:
python3 -m pip install -r requirements.txt

clean:
for name in .pytest_cache __pycache__ .vsidea .idea .mypy_cache; do find . -name $$name -exec rm -rf {} \;; done

docs:
@mkdir -p docs
python3 bin/gen_docs.py
@echo "Docs generated in ./docs"

serve-docs:
python3 -m http.server --directory docs 8000 | cat

344 changes: 344 additions & 0 deletions bin/gen_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""
Generate Markdown documentation for all public APIs, functions, and components
in this repository without external dependencies. The script uses the Python
standard library `ast` to parse modules and extract docstrings and signatures.

Outputs a `docs/` tree mirroring the source layout and an `docs/index.md`
containing links, examples, and usage guidance.
"""

from __future__ import annotations

import ast
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Optional, Tuple


REPO_ROOT = Path(__file__).resolve().parents[1]
DOCS_DIR = REPO_ROOT / "docs"


@dataclass
class ArgumentSpec:
flags: List[str]
metavar: Optional[str]
help_text: Optional[str]


@dataclass
class FunctionDoc:
name: str
args: str
docstring: Optional[str]


@dataclass
class ClassDoc:
name: str
docstring: Optional[str]
methods: List[FunctionDoc]


@dataclass
class ModuleDoc:
rel_path: Path
module_docstring: Optional[str]
functions: List[FunctionDoc]
classes: List[ClassDoc]
cli_arguments: List[ArgumentSpec]
has_argparse: bool


PYTHON_FILE_EXCLUDES = {
"test.py",
}

DIR_EXCLUDES = {
".git",
"__pycache__",
".pytest_cache",
".mypy_cache",
".idea",
".vsidea",
"docker",
"inputs",
"docs",
}


def iter_python_files(root: Path) -> Iterable[Path]:
for path in root.rglob("*.py"):
if any(part in DIR_EXCLUDES for part in path.parts):
continue
if path.name in PYTHON_FILE_EXCLUDES:
continue
# Skip this generator
if path == (REPO_ROOT / "bin" / "gen_docs.py"):
continue
yield path


def get_function_signature(node: ast.FunctionDef) -> str:
args: List[str] = []
a = node.args

def fmt(name: str, default: Optional[str] = None) -> str:
return f"{name}={default}" if default is not None else name

# Positional-only (Python 3.8+)
for idx, arg in enumerate(a.posonlyargs):
default_idx = idx - (len(a.posonlyargs) - len(a.defaults))
default_val = None
if default_idx >= 0:
default_val = ast.unparse(a.defaults[default_idx]) if hasattr(ast, "unparse") else "…"
args.append(fmt(arg.arg, default_val))
if a.posonlyargs:
args.append("/")

# Regular args
num_regular = len(a.args)
num_defaults = len(a.defaults)
for idx, arg in enumerate(a.args):
default_idx = idx - (num_regular - num_defaults)
default_val = None
if default_idx >= 0:
default_val = ast.unparse(a.defaults[default_idx]) if hasattr(ast, "unparse") else "…"
args.append(fmt(arg.arg, default_val))

# Vararg
if a.vararg:
args.append(f"*{a.vararg.arg}")
elif a.kwonlyargs:
args.append("*")

# Keyword-only
for idx, arg in enumerate(a.kwonlyargs):
default_val = None
if a.kw_defaults[idx] is not None:
default_val = ast.unparse(a.kw_defaults[idx]) if hasattr(ast, "unparse") else "…"
args.append(fmt(arg.arg, default_val))

# Kwarg
if a.kwarg:
args.append(f"**{a.kwarg.arg}")

return f"({', '.join(args)})"


def extract_argparse_specs(source: str) -> Tuple[bool, List[ArgumentSpec]]:
has_argparse = "argparse" in source or "ArgumentParser(" in source
specs: List[ArgumentSpec] = []
if not has_argparse:
return False, specs

# Very simple heuristic to capture add_argument flag patterns
add_arg_pattern = re.compile(r"add_argument\(([^\)]*)\)")
for m in add_arg_pattern.finditer(source):
arg_str = m.group(1)
# Split on commas that are not inside quotes
parts = re.findall(r"'(?:\\'|[^'])*'|\"(?:\\\"|[^\"])*\"|[^,]+", arg_str)
parts = [p.strip() for p in parts if p.strip()]
flags: List[str] = []
metavar: Optional[str] = None
help_text: Optional[str] = None
for p in parts:
if p.startswith(("'", '"')):
val = p.strip("'\"")
if val.startswith("-"):
flags.append(val)
elif p.startswith("metavar="):
metavar = p.split("=", 1)[1].strip().strip("'\"")
elif p.startswith("help="):
help_text = p.split("=", 1)[1].strip().strip("'\"")
if flags or help_text or metavar:
specs.append(ArgumentSpec(flags=flags, metavar=metavar, help_text=help_text))
return True, specs


def parse_module(path: Path) -> ModuleDoc:
source = path.read_text(encoding="utf-8")
tree = ast.parse(source)
module_docstring = ast.get_docstring(tree)

functions: List[FunctionDoc] = []
classes: List[ClassDoc] = []

for node in tree.body:
if isinstance(node, ast.FunctionDef):
if node.name.startswith("_"):
continue
functions.append(
FunctionDoc(
name=node.name,
args=get_function_signature(node),
docstring=ast.get_docstring(node),
)
)
elif isinstance(node, ast.ClassDef):
if node.name.startswith("_"):
continue
methods: List[FunctionDoc] = []
for sub in node.body:
if isinstance(sub, ast.FunctionDef) and not sub.name.startswith("_"):
methods.append(
FunctionDoc(
name=sub.name,
args=get_function_signature(sub),
docstring=ast.get_docstring(sub),
)
)
classes.append(
ClassDoc(name=node.name, docstring=ast.get_docstring(node), methods=methods)
)

has_argparse, cli_args = extract_argparse_specs(source)

return ModuleDoc(
rel_path=path.relative_to(REPO_ROOT),
module_docstring=module_docstring,
functions=functions,
classes=classes,
cli_arguments=cli_args,
has_argparse=has_argparse,
)


def read_chapter_readme(path: Path) -> Optional[str]:
for name in ("README.md", "README.adoc"):
readme = path.parent / name
if readme.exists():
try:
content = readme.read_text(encoding="utf-8")
return "\n".join(content.splitlines()[:25])
except Exception:
return None
return None


def write_module_markdown(doc: ModuleDoc) -> Path:
out_path = DOCS_DIR / doc.rel_path.with_suffix(".md")
out_path.parent.mkdir(parents=True, exist_ok=True)
lines: List[str] = []
lines.append(f"# {doc.rel_path}")
lines.append("")
if doc.module_docstring:
lines.append(doc.module_docstring.strip())
lines.append("")

# CLI usage
if doc.has_argparse:
lines.append("## CLI Usage")
lines.append("")
lines.append("Run this program and see usage:")
lines.append("")
lines.append("```bash")
lines.append(f"python {doc.rel_path} --help")
lines.append("```")
if doc.cli_arguments:
lines.append("")
lines.append("Recognized arguments (parsed heuristically):")
for spec in doc.cli_arguments:
flag_str = ", ".join(spec.flags) if spec.flags else "(positional)"
detail: List[str] = [f"- {flag_str}"]
if spec.metavar:
detail.append(f"metavar: {spec.metavar}")
if spec.help_text:
detail.append(f"help: {spec.help_text}")
lines.append(" - " + "; ".join(detail))
lines.append("")

# Public functions
if doc.functions:
lines.append("## Functions")
lines.append("")
for fn in doc.functions:
lines.append(f"### {fn.name}{fn.args}")
if fn.docstring:
lines.append("")
lines.append(fn.docstring.strip())
lines.append("")

# Public classes
if doc.classes:
lines.append("## Classes")
lines.append("")
for cl in doc.classes:
lines.append(f"### class {cl.name}")
if cl.docstring:
lines.append("")
lines.append(cl.docstring.strip())
lines.append("")
if cl.methods:
lines.append("Methods:")
for m in cl.methods:
lines.append(f"- {m.name}{m.args}")
lines.append("")

# Chapter README snippet
snippet = read_chapter_readme(doc.rel_path)
if snippet:
lines.append("## Chapter Overview (excerpt)")
lines.append("")
lines.append("```")
lines.extend(snippet.splitlines())
lines.append("```")

out_path.write_text("\n".join(lines), encoding="utf-8")
return out_path


def write_index(modules: List[ModuleDoc]) -> Path:
index = DOCS_DIR / "index.md"
lines: List[str] = []
lines.append("# Tiny Python Projects – API and Usage Documentation")
lines.append("")
lines.append("This documentation is auto-generated. For each chapter, explore functions, classes, and CLI usage.")
lines.append("")

# Group by top-level directory (e.g., 01_hello)
by_top: dict[str, List[ModuleDoc]] = {}
for m in modules:
top = m.rel_path.parts[0] if len(m.rel_path.parts) > 1 else "."
by_top.setdefault(top, []).append(m)

for top in sorted(by_top.keys()):
lines.append(f"## {top}")
lines.append("")
for m in sorted(by_top[top], key=lambda d: str(d.rel_path)):
rel_md = m.rel_path.with_suffix(".md")
lines.append(f"- [{m.rel_path}]({rel_md.as_posix()})")
lines.append("")

lines.append("---")
lines.append("Generated by `bin/gen_docs.py`.")
index.parent.mkdir(parents=True, exist_ok=True)
index.write_text("\n".join(lines), encoding="utf-8")
return index


def main() -> int:
modules: List[ModuleDoc] = []
for py in iter_python_files(REPO_ROOT):
try:
modules.append(parse_module(py))
except SyntaxError as ex: # Skip unparsable files
print(f"Skipping {py}: {ex}")
except Exception as ex:
print(f"Error parsing {py}: {ex}")

written: List[Path] = []
for m in modules:
written.append(write_module_markdown(m))

idx = write_index(modules)
print(f"Wrote {len(written)} module docs and index at {idx}")
return 0


if __name__ == "__main__":
raise SystemExit(main())

31 changes: 31 additions & 0 deletions docs/01_hello/hello01_print.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 01_hello/hello01_print.py

## Chapter Overview (excerpt)

```
# Chapter 1: Hello, World!

https://www.youtube.com/playlist?list=PLhOuww6rJJNP7UvTeF6_tQ1xcubAs9hvO

Write a program to enthusiastically greet the world:

```
$ ./hello.py
Hello, World!
```

The program should also accept a name given as an optional `--name` parameter:

```
$ ./hello.py --name Universe
Hello, Universe!
```

The program should produce documentation for `-h` or `--help`:

```
$ ./hello.py -h
usage: hello.py [-h] [-n str]

Say hello
```
Loading