Path: blob/main/resources/build_docs.py
798 views
#!/usr/bin/env python31"""2Unified documentation build script for SingleStoreDB Python SDK.34Usage:5python build_docs.py [target] [options]67Can be run from the repository root or resources directory.8The script automatically detects its location and works with the docs/src directory.910Examples:11python resources/build_docs.py html # From repo root12python build_docs.py html # From resources directory13python build_docs.py help # Show Sphinx help14python build_docs.py html --verbose # Build with verbose output15python build_docs.py html --no-docker # Build without starting Docker1617"""18from __future__ import annotations1920import argparse21import glob22import os23import re24import shutil25import subprocess26import sys27import time28from pathlib import Path29from re import Match30from typing import Any31from typing import Dict32from typing import List33from typing import Optional3435# Detect script location and set up paths36def get_repo_paths() -> tuple[Path, Path]:37"""Determine the repository root and docs source directory based on script location."""38script_dir = Path(__file__).parent.absolute()3940# Check if we're in the resources directory or repo root41if script_dir.name == 'resources':42repo_root = script_dir.parent43docs_src = repo_root / 'docs' / 'src'44elif (script_dir / 'singlestoredb').exists():45# We're in the repo root46repo_root = script_dir47docs_src = repo_root / 'docs' / 'src'48else:49# Try to find the repo root by looking for singlestoredb directory50current = script_dir51while current != current.parent:52if (current / 'singlestoredb').exists():53repo_root = current54docs_src = repo_root / 'docs' / 'src'55break56current = current.parent57else:58raise RuntimeError(f'Could not find repository root from {script_dir}')5960return repo_root, docs_src6162REPO_ROOT, DOCS_SRC = get_repo_paths()6364# Add the repo root to the path so we can import singlestoredb65sys.path.insert(0, str(REPO_ROOT))6667from singlestoredb.server import docker6869# Constants using absolute paths70CONTAINER_INFO_FILE = DOCS_SRC / '.singlestore_docs_container.txt'71BUILD_DIR = DOCS_SRC / '_build'72SOURCE_DIR = DOCS_SRC73CUSTOM_CSS_FILE = DOCS_SRC / 'custom.css'747576class DocBuilder:77"""Main documentation builder class."""7879def __init__(80self, target: str = 'html', verbose: bool = False,81use_docker: bool = True, sphinx_opts: str = '',82):83self.target = target84self.verbose = verbose85self.use_docker = use_docker86self.sphinx_opts = sphinx_opts87self.container_started = False88self.container_url: Optional[str] = None8990# Store paths for operations91self.docs_src = DOCS_SRC92self.build_dir = BUILD_DIR93self.source_dir = SOURCE_DIR94self.custom_css_file = CUSTOM_CSS_FILE95self.container_info_file = CONTAINER_INFO_FILE9697# Ensure docs/src directory exists98if not self.docs_src.exists():99raise RuntimeError(f'Documentation source directory not found: {self.docs_src}')100101def log(self, message: str, error: bool = False) -> None:102"""Log a message to stderr if verbose mode is enabled."""103if self.verbose or error:104print(message, file=sys.stderr)105106def status(self, message: str) -> None:107"""Always show status messages to indicate progress."""108print(f'๐ {message}', file=sys.stderr)109110def step(self, step_num: int, total_steps: int, message: str) -> None:111"""Show a numbered step with progress."""112print(f'๐ Step {step_num}/{total_steps}: {message}', file=sys.stderr)113114# Docker Management Functions115def start_docker_container(self) -> bool:116"""Start SingleStoreDB Docker container if needed."""117# Check if SINGLESTOREDB_URL is already set118if os.environ.get('SINGLESTOREDB_URL'):119self.status(f"Using existing database: {os.environ['SINGLESTOREDB_URL']}")120return True121122if not self.use_docker:123self.status('Docker disabled, skipping container start')124return True125126self.status('๐ณ Starting SingleStoreDB Docker container...')127start_time = time.time()128129try:130# Start the container131server = docker.start()132self.container_url = server.connection_url133134# Save the container info for stopping later135with open(self.container_info_file, 'w') as f:136f.write(f'{server.container.name}\n')137f.write(f'{self.container_url}\n')138139# Set environment variable140os.environ['SINGLESTOREDB_URL'] = self.container_url141self.container_started = True142143# Wait for the container to be ready144if not self.wait_for_container_ready(server):145return False146147elapsed = time.time() - start_time148self.status(f'โ Database ready in {elapsed:.1f}s: {self.container_url}')149return True150151except Exception as e:152self.log(f'Error starting Docker container: {e}', error=True)153return False154155def wait_for_container_ready(self, server: Any, max_retries: int = 30) -> bool:156"""Wait for the Docker container to be ready."""157print(' โณ Waiting for database to be ready...', end='', flush=True, file=sys.stderr)158159for attempt in range(max_retries):160try:161test_conn = server.connect()162test_conn.close()163print('', file=sys.stderr) # New line after dots164return True165except Exception as e:166if attempt == max_retries - 1:167print('', file=sys.stderr) # New line after dots168self.log(f'โ Container failed to start after {max_retries} seconds', error=True)169self.log(f'Last error: {e}', error=True)170return False171print('.', end='', flush=True, file=sys.stderr)172time.sleep(1)173return False174175def stop_docker_container(self) -> bool:176"""Stop and remove the SingleStoreDB Docker container."""177if not self.container_started or not self.container_info_file.exists():178return True179180try:181with open(self.container_info_file, 'r') as f:182lines = f.readlines()183if not lines:184return True185container_name = lines[0].strip()186187self.status(f'๐ Stopping container: {container_name}')188189# Stop the container190subprocess.run(191['docker', 'stop', container_name],192stdout=subprocess.DEVNULL,193stderr=subprocess.DEVNULL,194)195196# Remove the container197subprocess.run(198['docker', 'rm', container_name],199stdout=subprocess.DEVNULL,200stderr=subprocess.DEVNULL,201)202203# Remove the info file204self.container_info_file.unlink()205self.log('SingleStoreDB container stopped and removed.')206return True207208except Exception as e:209self.log(f'Error stopping container: {e}', error=True)210# Still try to remove the info file211if self.container_info_file.exists():212self.container_info_file.unlink()213return False214215# Build Functions216def clean_build_directory(self) -> None:217"""Clean the build directory."""218html_build_dir = self.build_dir / 'html'219if html_build_dir.exists():220self.log(f'Cleaning build directory: {html_build_dir}')221shutil.rmtree(html_build_dir)222223def copy_custom_css(self) -> bool:224"""Copy custom CSS to the _static directory."""225try:226static_dir = self.build_dir / 'html' / '_static'227static_dir.mkdir(parents=True, exist_ok=True)228229custom_css_dest = static_dir / 'custom.css'230with open(custom_css_dest, 'w') as dest_file:231with open(self.custom_css_file, 'r') as src_file:232dest_file.write(src_file.read().strip())233234self.log(f'Copied {self.custom_css_file} to {custom_css_dest}')235return True236237except Exception as e:238self.log(f'Error copying custom CSS: {e}', error=True)239return False240241def run_sphinx_build(self) -> bool:242"""Run sphinx-build with the specified target."""243if self.target == 'help':244# Show Sphinx help245cmd = ['sphinx-build', '-M', 'help', str(self.source_dir), str(self.build_dir)]246try:247subprocess.run(cmd, check=True)248return True249except subprocess.CalledProcessError as e:250self.log(f'Error running Sphinx help: {e}', error=True)251return False252253# Clean build directory first254self.status('๐งน Cleaning build directory...')255self.clean_build_directory()256257# Build the command258cmd = ['sphinx-build', '-M', self.target, str(self.source_dir), str(self.build_dir)]259260# Add any additional Sphinx options261if self.sphinx_opts:262cmd.extend(self.sphinx_opts.split())263264self.status(f'๐จ Building {self.target} documentation...')265self.log(f'Command: {" ".join(cmd)}')266267start_time = time.time()268269try:270# Run sphinx-build - always show output for better feedback271result = subprocess.run(cmd, check=True)272273elapsed = time.time() - start_time274self.status(f'โ Sphinx build completed in {elapsed:.1f}s')275return True276277except subprocess.CalledProcessError as e:278elapsed = time.time() - start_time279self.log(f'โ Sphinx build failed after {elapsed:.1f}s (exit code: {e.returncode})', error=True)280return False281282# HTML Post-processing Functions283def collect_generated_links(self, build_html_dir: Path) -> Dict[str, str]:284"""Collect links from generated HTML files."""285links: Dict[str, str] = {}286generated_dir = build_html_dir / 'generated'287288if not generated_dir.exists():289return links290291for html_file in generated_dir.glob('*.html'):292# Match class names like "ClassName.html"293m = re.search(r'([A-Z]\w+)\.html$', html_file.name)294if m:295links[m.group(1)] = html_file.name296continue297298# Match method names like "ClassName.method_name.html"299m = re.search(r'([A-Z]\w+\.[a-z]\w+)\.html$', html_file.name)300if m:301links[m.group(1)] = html_file.name302303if links:304self.log(f'Found {len(links)} generated API reference links')305return links306307def check_link(self, match: Match[str], links: Dict[str, str]) -> str:308"""Check and fix links in HTML content."""309link, pre, txt, post = match.groups()310if not link and txt in links:311return f'<a href="{links[txt]}">{pre}{txt}{post}</a>'312return match.group(0)313314def process_html_files(self, build_html_dir: Path) -> bool:315"""Post-process HTML files with various transformations."""316try:317self.status('๐ Collecting generated API links...')318start_time = time.time()319320# Collect generated links321links = self.collect_generated_links(build_html_dir)322323# Copy custom CSS to _static directory324self.status('๐จ Copying custom CSS...')325if not self.copy_custom_css():326return False327328# Get list of files to process329self.status('๐ Scanning files for post-processing...')330files_to_process: List[Path] = []331for ext in ['html', 'txt', 'svg', 'js', 'css', 'rst']:332files_to_process.extend(build_html_dir.rglob(f'*.{ext}'))333334total_files = len(files_to_process)335self.status(f'โ๏ธ Processing {total_files} files...')336337# Process each file with progress indicator338for i, file_path in enumerate(sorted(files_to_process), 1):339try:340if i % 50 == 0 or i == total_files: # Show progress every 50 files or at end341print(f' ๐ {i}/{total_files} files processed', file=sys.stderr)342343with open(file_path, 'r', encoding='utf-8') as f:344content = f.read()345346# Apply transformations347content = self.apply_content_transformations(content, links)348349with open(file_path, 'w', encoding='utf-8') as f:350f.write(content)351352except Exception as e:353self.log(f'Error processing file {file_path}: {e}', error=True)354# Continue processing other files355356elapsed = time.time() - start_time357self.status(f'โ Post-processing completed in {elapsed:.1f}s')358return True359360except Exception as e:361self.log(f'โ Error in HTML post-processing: {e}', error=True)362return False363364def apply_content_transformations(self, content: str, links: Dict[str, str]) -> str:365"""Apply all content transformations to a text file."""366# Remove module names from hidden modules367content = re.sub(368r'(">)singlestoredb\.(connection|management)\.([\w\.]+</a>)',369r'\1\3',370content,371)372373# Remove singleton representations374content = re.sub(375r'Organization\(name=.+?\)',376r'<em class="property"><span class="w"> </span><span class="p"></span><span class="w"> </span><span class="pre"><singlestoredb.notebook._objects.Organization</span> <span class="pre">object></span></em>',377content,378)379380# Change ShowAccessor to Connection.show381content = re.sub(r'>ShowAccessor\.', r'>Connection.show.', content)382383# Change workspace.Stage to workspace.stage384content = re.sub(r'>workspace\.Stage\.', r'>workspace.stage.', content)385386# Fix class/method links387content = re.sub(388r'(<a\s+[^>]+>)?(\s*<code[^>]*>\s*<span\s+class="pre">\s*)([\w\.]+)(\s*</span>\s*</code>)',389lambda m: self.check_link(m, links),390content,391)392393# Trim trailing whitespace394content = re.sub(r'\s+\n', r'\n', content)395396# Fix end-of-files397content = re.sub(r'\s*$', r'', content) + '\n'398399return content400401# File Management Functions402def cleanup_old_files(self) -> None:403"""Remove old documentation files from parent directory."""404parent_dir = self.docs_src.parent # docs directory405dirs_to_remove = ['_images', '_sources', '_static', 'generated']406407for dir_name in dirs_to_remove:408dir_path = parent_dir / dir_name409if dir_path.exists():410self.log(f'Removing old directory: {dir_path}')411shutil.rmtree(dir_path)412413def move_generated_files(self, build_html_dir: Path) -> bool:414"""Move generated files to parent directory."""415try:416self.status('๐ Moving documentation files to docs/ directory...')417start_time = time.time()418419parent_dir = self.docs_src.parent # docs directory420421# First cleanup old files422self.cleanup_old_files()423424# Count items to move425items = list(build_html_dir.iterdir())426total_items = len(items)427428# Move all files from build HTML directory to parent429for i, item in enumerate(items, 1):430dest_path = parent_dir / item.name431432if item.is_dir():433if dest_path.exists():434shutil.rmtree(dest_path)435shutil.copytree(item, dest_path)436self.log(f'Moved directory: {item.name}')437else:438shutil.copy2(item, dest_path)439self.log(f'Moved file: {item.name}')440441# Show progress442if i % 10 == 0 or i == total_items:443print(f' ๐ฆ {i}/{total_items} items moved', file=sys.stderr)444445elapsed = time.time() - start_time446self.status(f'โ File movement completed in {elapsed:.1f}s')447return True448449except Exception as e:450self.log(f'โ Error moving generated files: {e}', error=True)451return False452453# Main Orchestration454def build_docs(self) -> int:455"""Main function to build documentation."""456try:457total_start_time = time.time()458459# Determine total steps460total_steps = 4 if self.target == 'html' else 2461462print(f'๐ Starting SingleStoreDB documentation build ({self.target})', file=sys.stderr)463print(f'๐ Working directory: {self.docs_src}', file=sys.stderr)464print('=' * 60, file=sys.stderr)465466# Step 1: Start Docker container if needed467self.step(1, total_steps, 'Database setup')468if not self.start_docker_container():469return 1470471# Step 2: Run Sphinx build472self.step(2, total_steps, f'Sphinx {self.target} build')473if not self.run_sphinx_build():474return 1475476# For HTML builds, do post-processing and file movement477if self.target == 'html':478build_html_dir = self.build_dir / 'html'479480# Step 3: Post-process HTML files481self.step(3, total_steps, 'HTML post-processing')482if not self.process_html_files(build_html_dir):483return 1484485# Step 4: Move generated files to parent directory486self.step(4, total_steps, 'File deployment')487if not self.move_generated_files(build_html_dir):488return 1489490total_elapsed = time.time() - total_start_time491print('=' * 60, file=sys.stderr)492print(f'๐ Documentation build completed successfully in {total_elapsed:.1f}s!', file=sys.stderr)493return 0494495except KeyboardInterrupt:496print('\nโ Build interrupted by user', file=sys.stderr)497return 1498except Exception as e:499print(f'\nโ Unexpected error during build: {e}', file=sys.stderr)500return 1501finally:502# Always try to stop the container if we started it503if self.container_started:504self.stop_docker_container()505506507def main() -> int:508"""Main entry point for the script."""509parser = argparse.ArgumentParser(510description='Build SingleStoreDB Python SDK documentation',511formatter_class=argparse.RawDescriptionHelpFormatter,512epilog="""513Examples:514%(prog)s html Build HTML documentation515%(prog)s help Show Sphinx help516%(prog)s html --verbose Build with verbose output517%(prog)s html --no-docker Build without starting Docker518%(prog)s latexpdf Build PDF documentation519%(prog)s html --sphinx-opts "-W --keep-going" Pass options to Sphinx520""".strip(),521)522523parser.add_argument(524'target',525nargs='?',526default='html',527help='Sphinx build target (default: html)',528)529530parser.add_argument(531'-v', '--verbose',532action='store_true',533help='Enable verbose output',534)535536parser.add_argument(537'--no-docker',538action='store_true',539help='Do not start/stop Docker container',540)541542parser.add_argument(543'--sphinx-opts',544default='',545help='Additional options to pass to sphinx-build',546)547548args = parser.parse_args()549550# Create builder instance551builder = DocBuilder(552target=args.target,553verbose=args.verbose,554use_docker=not args.no_docker,555sphinx_opts=args.sphinx_opts,556)557558# Build documentation559return builder.build_docs()560561562if __name__ == '__main__':563sys.exit(main())564565566