#!/usr/bin/env python3 # Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. '''Recursively check freshness of generated markdown from JSON schemas. This tool recursively checks that the markdown files generated from JSON schemas match what is generated at runtime by schema_docs based on current sources. ''' import difflib import enum import json import logging import pathlib import sys import click try: import schema_docs except ImportError: sys.path.append(str(pathlib.Path(__file__).resolve().parent)) import schema_docs BASEDIR = pathlib.Path(__file__).resolve().parents[1] class State(enum.IntEnum): SKIP = enum.auto() OK = enum.auto() FAIL_STALE_DOC = enum.auto() FAIL_MISSING_DOC = enum.auto() FAIL_ORPHAN_DOC = enum.auto() @property def failed(self): return self.value > State.OK @property def label(self): return { State.SKIP: ' ', State.OK: '✓ ', State.FAIL_STALE_DOC: '✗D', State.FAIL_MISSING_DOC: '✗M', State.FAIL_ORPHAN_DOC: '✗O', }[self.value] def _check_dir(dir_name): 'Invoke schema_docs on folder, using the relevant options.' dir_path = BASEDIR / dir_name existing_docs = set( p for p in dir_path.glob('**/*.schema.md') if '.terraform' not in str(p)) for schema_path in sorted(dir_path.glob('**/*.schema.json')): if '.terraform' in str(schema_path): continue diff = None schema_rel = str(schema_path.relative_to(BASEDIR)) doc_path = schema_path.with_suffix('.md') existing_docs.discard(doc_path) try: schema = json.load(schema_path.open()) except json.JSONDecodeError as e: raise SystemExit(f'error decoding file {schema_path}: {e.args[0]}') # schema_docs uses logging.DEBUG heavily logging.getLogger().setLevel(logging.CRITICAL) tree = schema_docs.parse_node(schema) props, defs = schema_docs.render_node(tree) doc = schema_docs.DOC.format(title=schema.get('title'), properties=props, definitions=defs or '').strip() new_doc_content = f'{doc}\n' state = State.OK if not doc_path.exists(): state = State.FAIL_MISSING_DOC diff = f'----- {schema_rel} missing doc -----\nFile {doc_path.relative_to(BASEDIR)} does not exist.' else: current_doc_content = doc_path.read_text() if new_doc_content != current_doc_content: state = State.FAIL_STALE_DOC header = f'----- {schema_rel} diff -----\n' ndiff = difflib.ndiff(current_doc_content.splitlines(keepends=True), new_doc_content.splitlines(keepends=True)) diff = ''.join([header] + [x for x in ndiff if x[0] != ' ']) yield schema_rel, state, diff for doc_path in sorted(existing_docs): doc_rel = str(doc_path.relative_to(BASEDIR)) diff = f'----- {doc_rel} orphan doc -----\nFile {doc_rel} does not have a matching schema.' yield doc_rel, State.FAIL_ORPHAN_DOC, diff @click.command() @click.argument('dirs', type=str, nargs=-1) @click.option('--show-diffs/--no-show-diffs', default=False) @click.option('--show-summary/--no-show-summary', default=True) def main(dirs, show_diffs=False, show_summary=True): 'Cycle through modules and ensure schema docs are up-to-date.' errors = [] for dir_name in dirs: result = _check_dir(dir_name) for schema_path, state, diff in result: if state.failed: errors.append((schema_path, diff)) if show_summary: print(f'[{state.label}] {schema_path}') if errors: print('\nErrored schemas:\n') for e in errors: module, diff = e print(f'- {module}') if show_diffs: print() print(''.join(diff)) print() print() raise SystemExit('Errors found.') if __name__ == '__main__': main()