from __future__ import annotations import os import re import shutil import subprocess import sys import tempfile def main(): base_dir = os.getcwd() keep_dirs = [ 'bin', 'docs', 'examples', 'hacking', 'lib', 'packaging', 'test/lib', 'test/sanity', ] keep_files = [ 'MANIFEST.in', 'pyproject.toml', 'requirements.txt', 'setup.cfg', 'setup.py', ] # The tests write to the source tree, which isn't permitted for sanity tests. # To work around this a temporary copy is used. current_dir = os.getcwd() with tempfile.TemporaryDirectory(prefix='docs-build-', suffix='-sanity') as temp_dir: for keep_dir in keep_dirs: shutil.copytree(os.path.join(base_dir, keep_dir), os.path.join(temp_dir, keep_dir), symlinks=True) for keep_file in keep_files: shutil.copy2(os.path.join(base_dir, keep_file), os.path.join(temp_dir, keep_file)) paths = os.environ['PATH'].split(os.pathsep) paths = [f'{temp_dir}/bin' if path == f'{current_dir}/bin' else path for path in paths] # Fix up the environment so everything runs from the temporary copy. os.environ['PATH'] = os.pathsep.join(paths) os.environ['PYTHONPATH'] = f'{temp_dir}/lib' os.chdir(temp_dir) run_test() def run_test(): base_dir = os.getcwd() + os.path.sep docs_dir = os.path.abspath('docs/docsite') cmd = ['make', 'core_singlehtmldocs'] sphinx = subprocess.run(cmd, stdin=subprocess.DEVNULL, capture_output=True, cwd=docs_dir, check=False, text=True) stdout = sphinx.stdout stderr = sphinx.stderr if sphinx.returncode != 0: sys.stderr.write("Command '%s' failed with status code: %d\n" % (' '.join(cmd), sphinx.returncode)) if stdout.strip(): stdout = simplify_stdout(stdout) sys.stderr.write("--> Standard Output\n") sys.stderr.write("%s\n" % stdout.strip()) if stderr.strip(): sys.stderr.write("--> Standard Error\n") sys.stderr.write("%s\n" % stderr.strip()) sys.exit(1) with open('docs/docsite/rst_warnings', 'r') as warnings_fd: output = warnings_fd.read().strip() lines = output.splitlines() known_warnings = { 'block-quote-missing-blank-line': r'^Block quote ends without a blank line; unexpected unindent.$', 'literal-block-lex-error': r'^Could not lex literal_block as "[^"]*". Highlighting skipped.$', 'duplicate-label': r'^duplicate label ', 'undefined-label': r'undefined label: ', 'unknown-document': r'unknown document: ', 'toc-tree-missing-document': r'toctree contains reference to nonexisting document ', 'reference-target-not-found': r'[^ ]* reference target not found: ', 'not-in-toc-tree': r"document isn't included in any toctree$", 'unexpected-indentation': r'^Unexpected indentation.$', 'definition-list-missing-blank-line': r'^Definition list ends without a blank line; unexpected unindent.$', 'explicit-markup-missing-blank-line': r'Explicit markup ends without a blank line; unexpected unindent.$', 'toc-tree-glob-pattern-no-match': r"^toctree glob pattern '[^']*' didn't match any documents$", 'unknown-interpreted-text-role': '^Unknown interpreted text role "[^"]*".$', } for line in lines: match = re.search('^(?P[^:]+):((?P[0-9]+):)?((?P[0-9]+):)? (?PWARNING|ERROR): (?P.*)$', line) if not match: path = 'docs/docsite/rst/index.rst' lineno = 0 column = 0 code = 'unknown' message = line # surface unknown lines while filtering out known lines to avoid excessive output print('%s:%d:%d: %s: %s' % (path, lineno, column, code, message)) continue path = match.group('path') lineno = int(match.group('line') or 0) column = int(match.group('column') or 0) level = match.group('level').lower() message = match.group('message') path = os.path.abspath(path) if path.startswith(base_dir): path = path[len(base_dir):] if path.startswith('rst/'): path = 'docs/docsite/' + path # fix up paths reported relative to `docs/docsite/` if level == 'warning': code = 'warning' for label, pattern in known_warnings.items(): if re.search(pattern, message): code = label break else: code = 'error' print('%s:%d:%d: %s: %s' % (path, lineno, column, code, message)) def simplify_stdout(value): """Simplify output by omitting earlier 'rendering: ...' messages.""" lines = value.strip().splitlines() rendering = [] keep = [] def truncate_rendering(): """Keep last rendering line (if any) with a message about omitted lines as needed.""" if not rendering: return notice = rendering[-1] if len(rendering) > 1: notice += ' (%d previous rendering line(s) omitted)' % (len(rendering) - 1) keep.append(notice) # Could change to rendering.clear() if we do not support python2 rendering[:] = [] for line in lines: if line.startswith('rendering: '): rendering.append(line) continue truncate_rendering() keep.append(line) truncate_rendering() result = '\n'.join(keep) return result if __name__ == '__main__': main()