"""Build-time fetch and Sphinx wiring for external Anyscale example templates. Everything here exists because the docs build pulls example templates from ``templates.ci.ray.io`` at build time (via sphinx-collections) and then renders the fetched ``_collections/`` content. It is kept out of ``conf.py`` so that changes to template fetching/publishing are scoped for CI and ownership, and ``conf.py`` stays focused on Sphinx behavior. ``conf.py`` consumes this module as:: from template_collections import ( collections, collections_clean, collections_final_clean, ) import template_collections exclude_patterns += template_collections.exclude_patterns() ipython3_lexer_patterns = [*template_collections.IPYTHON3_LEXER_PATTERNS, ...] def setup(app): template_collections.register(app) """ import io import json import logging import pathlib import random import re import time import zipfile from urllib.error import HTTPError, URLError from urllib.request import urlopen logger = logging.getLogger(__name__) # -- sphinx-collections: pull external template files at build time ----------- _TEMPLATES_CI_BASE = "https://templates.ci.ray.io" _TEMPLATE_CHANNEL_API = _TEMPLATES_CI_BASE + "/templates/{name}/latest/channel.json" # Hard timeouts on the templates.ci.ray.io HTTP calls. The doc build previously # stalled when the templates host was slow or unresponsive (#63112 revert); # explicit timeouts let urlopen surface a TimeoutError instead of hanging # indefinitely so `_fetch_and_extract_zip` fails fast with a clear error # instead of blocking the build. _TEMPLATE_CHANNEL_TIMEOUT_S = 30 _TEMPLATE_DOWNLOAD_TIMEOUT_S = 90 # Retry policy for the templates.ci.ray.io HTTP calls. Many doc builds can run # concurrently against the same endpoint (PR previews + branch builds); under # that load the host returns transient errors (HTTP 5xx/429, connection resets, # read timeouts). A single urlopen with no retry would drop the template, and # since every template is wired into a toctree that broken ref fails the whole # build under `fail_on_warning`. Retry with exponential backoff plus full random # jitter so the retry bursts from many concurrent builds don't re-synchronize # and hammer the endpoint in lockstep. _TEMPLATE_FETCH_ATTEMPTS = 3 _TEMPLATE_RETRY_BASE_S = 1.0 def _urlopen_read_with_retries(url, timeout): """Fetch `url` and return its body bytes, retrying transient failures. Retries cover both the connection and the body read (a slow `read()` under load can itself time out). Only transient conditions are retried — a 4xx other than 429 won't fix itself, so it's raised immediately. After the final attempt the last exception propagates to the caller, which aborts the build with a clear, attributed error. """ last_exc = None for attempt in range(_TEMPLATE_FETCH_ATTEMPTS): try: with urlopen(url, timeout=timeout) as resp: return resp.read() except HTTPError as exc: last_exc = exc # HTTPError is a subclass of URLError, so it must be caught first. # Don't retry deterministic client errors (4xx except 429). if exc.code != 429 and not (500 <= exc.code < 600): raise except (URLError, TimeoutError, OSError) as exc: # URLError wraps DNS/refused/connection-reset; TimeoutError is the # urlopen/read timeout; OSError covers lower-level socket errors. last_exc = exc if attempt < _TEMPLATE_FETCH_ATTEMPTS - 1: base = _TEMPLATE_RETRY_BASE_S * (2 ** attempt) delay = base + random.uniform(0, base) # full jitter on the delay logger.info( "sphinx-collections: retrying %s in %.1fs " "(attempt %d/%d) after: %s", url, delay, attempt + 1, _TEMPLATE_FETCH_ATTEMPTS, last_exc, ) time.sleep(delay) raise last_exc _TEMPLATE_COLLECTIONS = { "asynchronous_inference": { "target": "serve/tutorials/asynchronous-inference", }, "audio-dataset-curation-llm-judge": { "target": "ray-overview/examples/e2e-audio", }, "deepspeed_finetune": { "target": "train/examples/pytorch/deepspeed_finetune", }, "deployment-serve-llm": { "target": "serve/tutorials/deployment-serve-llm", }, "distributing-pytorch": { "target": "train/examples/pytorch/distributing-pytorch", }, "e2e-rag-deepdive": { "target": "ray-overview/examples/e2e-rag", }, "e2e-timeseries-forecasting": { "target": "ray-overview/examples/e2e-timeseries", }, "entity-recognition-with-llms": { "target": "ray-overview/examples/entity-recognition-with-llms", }, "image-search-and-classification": { "target": "ray-overview/examples/e2e-multimodal-ai-workloads", }, "llm_batch_inference_text": { "target": "data/examples/llm_batch_inference_text", }, "llm_batch_inference_vision": { "target": "data/examples/llm_batch_inference_vision", }, "langchain-agent-ray-serve": { "target": "ray-overview/examples/langchain_agent_ray_serve/content", }, "llm_finetuning": { "target": "ray-overview/examples/llamafactory-llm-fine-tune", }, "multi_agent_a2a": { "target": "ray-overview/examples/multi_agent_a2a", }, "mcp-ray-serve": { "target": "ray-overview/examples/mcp-ray-serve", }, "model-composition-recsys": { "target": "serve/tutorials/model-composition-recsys", }, "model-multiplexing": { "target": "serve/tutorials/model_multiplexing_forecast", }, "object-detection-video-processing": { "target": "ray-overview/examples/object-detection", }, "ray_train_workloads": { "target": "train/tutorials", }, "pytorch-fsdp": { "target": "train/examples/pytorch/pytorch-fsdp", }, "pytorch-profiling": { "target": "train/examples/pytorch/pytorch-profiling", }, "tensor_parallel_autotp": { "target": "train/examples/pytorch/tensor_parallel_autotp", }, "tensor_parallel_dtensor": { "target": "train/examples/pytorch/tensor_parallel_dtensor", }, "tune_pytorch_asha": { "target": "tune/examples/tune_pytorch_asha", }, "unstructured_data_ingestion": { "target": "data/examples/unstructured_data_ingestion", }, "xgboost-training-and-serving": { "target": "ray-overview/examples/e2e-xgboost", }, } def _resolve_template_url(name): """Fetch the build zip URL for a template from the channel API.""" api_url = _TEMPLATE_CHANNEL_API.format(name=name) logger.info("sphinx-collections: resolving template URL from %s", api_url) data = json.loads( _urlopen_read_with_retries(api_url, _TEMPLATE_CHANNEL_TIMEOUT_S) ) url = data["url"] # Replace the ascommon:/// protocol with the templates.ci.ray.io base URL. url = url.replace("ascommon:///", _TEMPLATES_CI_BASE + "/") # Append /build.zip to get the docs build archive. url = url.rstrip("/") + "/build.zip" logger.info("sphinx-collections: resolved URL %s", url) return url def _fetch_and_extract_zip(config): """Download a zip archive and extract it into the collection target directory. A failure fetching, downloading, or extracting a template aborts the build immediately with an error naming the template. Every template is wired into a toctree, so a missing one fails the build anyway under `fail_on_warning`, but as a "nonexisting document" warning emitted far downstream of the real cause. Failing here, at the fetch, points straight at the actual problem (the templates host or the archive) instead of a dangling toctree ref. Transient host errors are already absorbed by the retry/backoff in `_urlopen_read_with_retries`; this fires only once those are exhausted. """ import shutil name = config["name"] target = pathlib.Path(config["target"]) try: url = _resolve_template_url(name) if target.is_dir(): shutil.rmtree(target) target.mkdir(parents=True, exist_ok=True) logger.info("sphinx-collections: downloading %s -> %s", url, target) zip_bytes = io.BytesIO( _urlopen_read_with_retries(url, _TEMPLATE_DOWNLOAD_TIMEOUT_S) ) with zipfile.ZipFile(zip_bytes) as zf: zf.extractall(target) logger.info( "sphinx-collections: extracted %d files to %s", len(zf.namelist()), target, ) except Exception as exc: # Don't leave a half-extracted archive in the tree, then abort with a # clear, attributed error. sphinx-collections runs in `safe` mode by # default, so re-raising here surfaces as a build-halting # CollectionsDriverError rather than a swallowed skip. if target.is_dir(): shutil.rmtree(target, ignore_errors=True) raise RuntimeError( f"sphinx-collections: template {name!r} failed to fetch/extract into " f"{target} after {_TEMPLATE_FETCH_ATTEMPTS} attempt(s): {exc}. This is " f"a template fetch failure (templates.ci.ray.io or the archive), not a " f"broken toctree reference — fix the fetch and rebuild; don't chase the " f"downstream 'nonexisting document' warnings it would otherwise cause." ) from exc collections = { name: { "driver": "function", "source": _fetch_and_extract_zip, "target": coll["target"], "clean": False, "final_clean": False, "write_result": False, } for name, coll in _TEMPLATE_COLLECTIONS.items() } # Don't wipe the target before build — other docs may co-exist in parent dirs. collections_clean = True # Clean up collected files after build so they don't get committed. collections_final_clean = True # External templates fetched by sphinx_collections (see #62179) land here at # build time; their notebook JSON has no language_info, so Sphinx defaults to # the python3 lexer and chokes on !pip / %magic cells. conf.py splices these # into its ipython3_lexer_patterns list alongside the in-tree content notebooks. IPYTHON3_LEXER_PATTERNS = [ "_collections/**/*.ipynb", ] def exclude_patterns(): """exclude_patterns entries for the fetched ``_collections/`` content.""" return [ "_collections/serve/tutorials/deployment-serve-llm/README.*", "_collections/serve/tutorials/deployment-serve-llm/*.ipynb", "_collections/serve/tutorials/deployment-serve-llm/**/*.ipynb", # Each template ships README.md + README.ipynb at the same docname; keep # only the .md and exclude the duplicate .ipynb at the template root and # in any sub-template directories. The template root README.md is the # actual content page that toctrees / examples.yml refer to. *[ pattern for coll in _TEMPLATE_COLLECTIONS.values() for pattern in ( f"_collections/{coll['target']}/README.ipynb", f"_collections/{coll['target']}/**/README.ipynb", ) ], # ray_train_workloads bundles sub-folder READMEs that aren't part of any # toctree (only the notebooks are). Exclude them to avoid orphan warnings. # Keep the root README.* — train.rst's toctree and tutorials button both # link to /_collections/train/tutorials/README. "_collections/train/tutorials/*/README.*", # one-level sidecars (getting-started, workload-patterns) "_collections/train/tutorials/*/**/README.*", # deeper sidecars, if any # asynchronous-inference has no docs landing page (nothing links to it), so # exclude its README.md to avoid an orphan warning. Do NOT list a template # that IS linked from a toctree here: README.ipynb is already globally # excluded above, so README.md is that template's canonical page — excluding # it deletes the page and breaks the reference (this happened to # tune_pytorch_asha when its notebook was renamed to README.ipynb). "_collections/serve/tutorials/asynchronous-inference/README.md", # llamafactory: master excludes the in-tree paths only, but this branch # also pulls a copy via sphinx-collections (see _TEMPLATE_COLLECTIONS). # Mirror the in-tree patterns under _collections/ so the fetched copy # is suppressed too. The template has no landing page on docs.ray.io. "_collections/ray-overview/examples/llamafactory-llm-fine-tune/README.*", "_collections/ray-overview/examples/llamafactory-llm-fine-tune/**/*.ipynb", ] def register(app): """Connect the Sphinx hooks that handle fetched ``_collections/`` content.""" class CollectionsFootnoteFilter(logging.Filter): # Example notebooks fetched into _collections (from templates.ci.ray.io) contain # prose that myst-parser 5.x parses as reST footnote refs/targets, which docutils # then flags as errors. That content is external (not in this repo), so it can't # be fixed here -- the fix belongs upstream. Drop these specific errors for # _collections paths so the fail_on_warning build is not blocked by fetched content. # TODO: fix the offending footnote-like text upstream and remove this filter. def filter(self, record): # INFO/DEBUG records (the bulk of build output) can't be the # footnote/target warnings below, so skip getMessage() for them. if record.levelno < logging.WARNING: return True msg = record.getMessage() if ( "autonumbered footnote references" in msg or "Unknown target name" in msg ): location = str(getattr(record, "location", "") or "") if "_collections" in location: return False return True logging.getLogger("sphinx").addFilter(CollectionsFootnoteFilter()) # Fix code-block language tags in _collections markdown files. # Notebooks converted to markdown tag cells that contain a Jupyter magic or # shell escape (e.g. ``!uv pip install ...`` / ``%matplotlib``) as # ``python`` code blocks, which Pygments can't lex as Python and which fail # the build under ``-W``. Re-tag any such block as ``ipython3`` so the # python parts stay highlighted as python and ``!``/``%`` lines render as # shell. The magic can appear anywhere in the cell (a cell often runs some # Python and then shells out), not only on the first line. _PY_CODE_FENCE_RE = re.compile(r"```python\n(.*?)```", re.DOTALL) _MAGIC_LINE_RE = re.compile(r"^[ \t]*[!%]\S", re.MULTILINE) def fix_collections_code_blocks(app, docname, source): if not docname.startswith("_collections/"): return def _retag(match): body = match.group(1) if _MAGIC_LINE_RE.search(body): return "```ipython3\n" + body + "```" return match.group(0) source[0] = _PY_CODE_FENCE_RE.sub(_retag, source[0]) app.connect('source-read', fix_collections_code_blocks)