import importlib.util import logging import os import re from dataclasses import dataclass from itertools import chain, groupby from pathlib import Path from typing import Optional import pygit2 def underline(title: str, character: str = "=") -> str: return f"{title}\n{character * len(title)}" def generate_title(filename: str) -> str: with open(filename) as f: # fine the first line that contains '###' for line in f: if '###' in line: title = line[3:].strip() break assert title is not None, f"No title found in {filename}" return underline(title) @dataclass class DocMeta: title: str order: int section: str filename: Path def extract_meta_info(filename: str) -> Optional[DocMeta]: """Extract metadata from file following the pattern ### :[a-zA-Z_]+[0-9]* """ metadata_pattern = re.compile(r'^### :([a-zA-Z_]+[0-9]*)\s+(.+)$') with open(filename) as f: metadata = DocMeta(title="", order=0, section="", filename=Path(filename)) for line in f: line = line.strip() match = metadata_pattern.match(line) if match: key = match.group(1).strip() value = match.group(2).strip() setattr(metadata, key, value) elif not line.startswith('###'): continue if metadata.title == "": return None return metadata # NOTE: Update here to keep consistent with the examples LLMAPI_SECTIONS = ["Basics", "Customization", "Slurm"] def generate_examples(): root_dir = Path(__file__).parent.parent.parent.resolve() ignore_list = { '__init__.py', 'quickstart_example.py', 'quickstart_advanced.py', 'quickstart_multimodal.py', 'star_attention.py' } doc_dir = root_dir / "docs/source/examples" def collect_script_paths(examples_subdir: str) -> list[Path]: """Collect Python and shell script paths from an examples subdirectory.""" script_dir = root_dir / f"examples/{examples_subdir}" script_paths = list( chain(script_dir.glob("*.py"), script_dir.glob("*.sh"))) return [ path for path in sorted(script_paths) if path.name not in ignore_list ] # Collect source paths for LLMAPI examples llmapi_script_paths = collect_script_paths("llm-api") llmapi_doc_paths = [ doc_dir / f"{path.stem}.rst" for path in llmapi_script_paths ] repo = pygit2.Repository('.') commit_hash = str(repo.head.target) llmapi_script_base_url = f"https://github.com/NVIDIA/TensorRT-LLM/blob/{commit_hash}/examples/llm-api" # Collect source paths for trtllm-serve examples serve_script_paths = collect_script_paths("serve") serve_doc_paths = [ doc_dir / f"{path.stem}.rst" for path in serve_script_paths ] serve_script_base_url = f"https://github.com/NVIDIA/TensorRT-LLM/blob/{commit_hash}/examples/serve" def _get_lines_without_metadata(filename: str) -> str: """Get line ranges that exclude metadata lines. Returns a string like "5-10,15-20" for use in :lines: directive. """ with open(filename) as f: metadata_pattern = re.compile(r'^### :([a-zA-Z_]+[0-9]*)\s+(.+)$') all_lines = f.readlines() # Find line numbers that are NOT metadata (1-indexed) content_lines = [] for line_num, line in enumerate(all_lines, 1): line_stripped = line.strip() # Include line if it's not empty and not metadata if not metadata_pattern.match(line_stripped): content_lines.append(line_num) if not content_lines: return "" # No content lines found # Group consecutive line numbers into ranges ranges = [] start = content_lines[0] end = start for line_num in content_lines[1:]: if line_num == end + 1: # Consecutive line, extend current range end = line_num else: # Gap found, close current range and start new one if start == end: ranges.append(str(start)) else: ranges.append(f"{start}-{end}") start = line_num end = line_num # Add the final range if start == end: ranges.append(str(start)) else: ranges.append(f"{start}-{end}") return ",".join(ranges) # Generate the example docs for each example script def write_scripts(base_url: str, example_script_paths: list[Path], doc_paths: list[Path], extra_content="") -> list[DocMeta]: metas = [] for script_path, doc_path in zip(example_script_paths, doc_paths): if script_path.name in ignore_list: logging.warning(f"Ignoring file: {script_path.name}") continue script_url = f"{base_url}/{script_path.name}" # Determine language based on file extension language = "python" if script_path.suffix == ".py" else "bash" # Make script_path relative to doc_path and call it include_path include_path = '../../..' / script_path.relative_to(root_dir) # Extract metadata from the script file if meta := extract_meta_info(str(script_path)): title = underline(meta.title) else: logging.warning( f"No metadata found for {script_path.name}, using filename as title" ) title = script_path.stem.replace('_', ' ').title() meta = DocMeta(title=title, order=0, section="", filename=script_path) title = underline(title) metas.append(meta) # Get line ranges excluding metadata lines_without_metadata = _get_lines_without_metadata( str(script_path)) # Build literalinclude directive literalinclude_lines = [f".. literalinclude:: {include_path}"] if lines_without_metadata: literalinclude_lines.append( f" :lines: {lines_without_metadata}") literalinclude_lines.extend( [f" :language: {language}", f" :linenos:"]) content = (f"{title}\n" f"{extra_content}" f"Source {script_url}.\n\n" f"{chr(10).join(literalinclude_lines)}\n") with open(doc_path, "w+") as f: logging.warning(f"Writing {doc_path}") f.write(content) return metas def write_index(metas: list[DocMeta], doc_template_path: Path, doc_path: Path, example_name: str, section_order: list[str]): """Write the index file for the examples. Args: metas: The metadata for the examples. doc_template_path: The path to the template file. doc_path: The path to the output file. example_name: The name of the examples. section_order: The order of sections to display. The template file is expected to have the following placeholders: - %EXAMPLE_DOCS%: The documentation for the examples. - %EXAMPLE_NAME%: The name of the examples. """ with open(doc_template_path) as f: template_content = f.read() # Sort metadata by section order and example order sort_key = lambda x: (section_order.index(x.section) if section_order and x.section in section_order else 0, int(x.order)) metas.sort(key=sort_key) content = [] for section, group in groupby(metas, key=lambda x: x.section): if section_order and section not in section_order: raise ValueError( f"Section '{section}' not in section_order {section_order}") group_list = list(group) content.extend([ section, "_" * len(section), "", ".. toctree::", " :maxdepth: 2", "" ]) for meta in group_list: content.append(f" {meta.filename.stem}") content.append("") example_docs = "\n".join(content) # Replace placeholders and write to file output_content = template_content.replace("%EXAMPLE_DOCS%", example_docs).replace( "%EXAMPLE_NAME%", example_name) with open(doc_path, "w") as f: f.write(output_content) # Generate the toctree for LLMAPI example scripts llmapi_metas = write_scripts(llmapi_script_base_url, llmapi_script_paths, llmapi_doc_paths) write_index(metas=llmapi_metas, doc_template_path=doc_dir / "llm_examples_index.template.rst_", doc_path=doc_dir / "llm_api_examples.rst", example_name="LLM Examples", section_order=LLMAPI_SECTIONS) # Generate the toctree for trtllm-serve example scripts serve_extra_content = ( "Refer to the `trtllm-serve documentation " "`_ " "for starting a server.\n\n") serve_metas = write_scripts(serve_script_base_url, serve_script_paths, serve_doc_paths, serve_extra_content) write_index(metas=serve_metas, doc_template_path=doc_dir / "llm_examples_index.template.rst_", doc_path=doc_dir / "trtllm_serve_examples.rst", example_name="Online Serving Examples", section_order=[]) def extract_all_and_eval(file_path): ''' Extract the __all__ variable from a Python file. This is a trick to make the CI happy even the tensorrt_llm lib is not available. NOTE: This requires the __all__ variable to be defined at the end of the file. ''' with open(file_path, 'r') as file: content = file.read() lines = content.split('\n') filtered_line_begin = 0 for i, line in enumerate(lines): if line.startswith("__all__"): filtered_line_begin = i break code_to_eval = '\n'.join(lines[filtered_line_begin:]) local_vars = {} exec(code_to_eval, {}, local_vars) return local_vars def get_pydantic_methods() -> list[str]: from pydantic import BaseModel class Dummy(BaseModel): pass methods = set( [method for method in dir(Dummy) if not method.startswith('_')]) methods.discard("__init__") return list(methods) def generate_llmapi(): root_dir = Path(__file__).parent.parent.parent.resolve() # Set up destination paths doc_dir = root_dir / "docs/source/llm-api" doc_dir.mkdir(exist_ok=True) doc_path = doc_dir / "reference.rst" llmapi_all_file = root_dir / "tensorrt_llm/llmapi/__init__.py" public_classes_names = extract_all_and_eval(llmapi_all_file)['__all__'] content = underline("API Reference", "-") + "\n\n" content += ".. note::\n" content += " Since version 1.0, we have attached a status label to `LLM`, `LlmArgs` and `TorchLlmArgs` Classes.\n\n" content += " 1. :tag:`stable` - The item is stable and will keep consistent.\n" content += ' 2. :tag:`prototype` - The item is a prototype and is subject to change.\n' content += ' 3. :tag:`beta` - The item is in beta and approaching stability.\n' content += ' 4. :tag:`deprecated` - The item is deprecated and will be removed in a future release.\n' content += "\n" for cls_name in public_classes_names: cls_name = cls_name.strip() options = [ " :members:", " :undoc-members:", " :show-inheritance:", " :special-members: __init__", " :member-order: groupwise", ] options.append(" :inherited-members:") if cls_name in ["TorchLlmArgs", "TrtLlmArgs"]: # exclude tons of methods from Pydantic options.append( f" :exclude-members: {','.join(get_pydantic_methods())}") content += f".. autoclass:: tensorrt_llm.llmapi.{cls_name}\n" content += "\n".join(options) + "\n\n" with open(doc_path, "w+") as f: f.write(content) def update_version(): """Replace the placeholder container version in all docs source files.""" version_path = (Path(__file__).parent.parent.parent / "tensorrt_llm" / "version.py").resolve() spec = importlib.util.spec_from_file_location("version_module", version_path) version_module = importlib.util.module_from_spec(spec) spec.loader.exec_module(version_module) version = version_module.__version__ docs_source_dir = Path(__file__).parent.resolve() md_files = list(docs_source_dir.rglob("*.md")) # Default is to replace `release:x.y.z` placeholders; set to 0 to disable. if os.environ.get("TRTLLM_DOCS_REPLACE_CONTAINER_TAG", "1") != "1": return for file_path in md_files: with open(file_path, "r") as f: content = f.read() updated = content.replace( "nvcr.io/nvidia/tensorrt-llm/release:x.y.z", f"nvcr.io/nvidia/tensorrt-llm/release:{version}", ) if updated != content: with open(file_path, "w") as f: f.write(updated) if __name__ == "__main__": import os path = os.environ["TEKIT_ROOT"] + "/examples/llm-api/llm_inference.py" #print(extract_meta_info(path)) generate_examples()