# coding: utf-8 # Copyright: (c) 2020, Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) # Make coding more python3-ish from __future__ import absolute_import, division, print_function import glob import os import os.path import pathlib import shutil from tempfile import TemporaryDirectory import yaml from ansible.release import __version__ as ansible_core__version__ # Pylint doesn't understand Python3 namespace modules. # pylint: disable=relative-beyond-top-level from ..commands import Command from ..errors import InvalidUserInput, MissingUserInput # pylint: enable=relative-beyond-top-level __metaclass__ = type DEFAULT_TOP_DIR = pathlib.Path(__file__).parents[4] DEFAULT_OUTPUT_DIR = pathlib.Path(__file__).parents[4] / 'docs/docsite' class NoSuchFile(Exception): """An expected file was not found.""" # # Helpers # def find_latest_ansible_dir(build_data_working): """Find the most recent ansible major version.""" # imports here so that they don't cause unnecessary deps for all of the plugins from packaging.version import InvalidVersion, Version ansible_directories = glob.glob(os.path.join(build_data_working, '[0-9.]*')) # Find the latest ansible version directory latest = None latest_ver = Version('0') for directory_name in (d for d in ansible_directories if os.path.isdir(d)): try: new_version = Version(os.path.basename(directory_name)) except InvalidVersion: continue # For the devel build, we only need ansible.in, so make sure it's there if not os.path.exists(os.path.join(directory_name, 'ansible.in')): continue if new_version > latest_ver: latest_ver = new_version latest = directory_name if latest is None: raise NoSuchFile('Could not find an ansible data directory in {0}'.format(build_data_working)) return latest def parse_deps_file(filename): """Parse an antsibull .deps file.""" with open(filename, 'r', encoding='utf-8') as f: contents = f.read() lines = [c for line in contents.splitlines() if (c := line.strip()) and not c.startswith('#')] return dict([entry.strip() for entry in line.split(':', 1)] for line in lines) def write_deps_file(filename, deps_data): """Write an antsibull .deps file.""" with open(filename, 'w', encoding='utf-8') as f: for key, value in deps_data.items(): f.write(f'{key}: {value}\n') def find_latest_deps_file(build_data_working, ansible_version): """Find the most recent ansible deps file for the given ansible major version.""" # imports here so that they don't cause unnecessary deps for all of the plugins from packaging.version import Version data_dir = os.path.join(build_data_working, ansible_version) deps_files = glob.glob(os.path.join(data_dir, '*.deps')) if not deps_files: raise Exception('No deps files exist for version {0}'.format(ansible_version)) # Find the latest version of the deps file for this major version latest = None latest_ver = Version('0') for filename in deps_files: deps_data = parse_deps_file(filename) new_version = Version(deps_data['_ansible_version']) if new_version > latest_ver: latest_ver = new_version latest = filename if latest is None: raise NoSuchFile('Could not find an ansible deps file in {0}'.format(data_dir)) return latest # # Subcommand core # def generate_core_docs(args): """Regenerate the documentation for all plugins listed in the plugin_to_collection_file.""" # imports here so that they don't cause unnecessary deps for all of the plugins from antsibull_docs.cli import antsibull_docs with TemporaryDirectory() as tmp_dir: # # Construct a deps file with our version of ansible_core in it # modified_deps_file = os.path.join(tmp_dir, 'ansible.deps') # The _ansible_version doesn't matter since we're only building docs for core deps_file_contents = {'_ansible_version': ansible_core__version__, '_ansible_core_version': ansible_core__version__} with open(modified_deps_file, 'w') as f: f.write(yaml.dump(deps_file_contents)) # Generate the plugin rst return antsibull_docs.run(['antsibull-docs', 'stable', '--deps-file', modified_deps_file, '--ansible-core-source', str(args.top_dir), '--dest-dir', args.output_dir]) # If we make this more than just a driver for antsibull: # Run other rst generation # Run sphinx build # # Subcommand full # def generate_full_docs(args): """Regenerate the documentation for all plugins listed in the plugin_to_collection_file.""" # imports here so that they don't cause unnecessary deps for all of the plugins import sh from antsibull_docs.cli import antsibull_docs with TemporaryDirectory() as tmp_dir: sh.git(['clone', 'https://github.com/ansible-community/ansible-build-data'], _cwd=tmp_dir) # If we want to validate that the ansible version and ansible-core branch version match, # this would be the place to do it. build_data_working = os.path.join(tmp_dir, 'ansible-build-data') if args.ansible_build_data: build_data_working = args.ansible_build_data ansible_version = args.ansible_version if ansible_version is None: ansible_version = find_latest_ansible_dir(build_data_working) params = ['devel', '--pieces-file', os.path.join(ansible_version, 'ansible.in')] else: latest_filename = find_latest_deps_file(build_data_working, ansible_version) # Make a copy of the deps file so that we can set the ansible-core version we'll use modified_deps_file = os.path.join(tmp_dir, 'ansible.deps') shutil.copyfile(latest_filename, modified_deps_file) # Put our version of ansible-core into the deps file deps_data = parse_deps_file(modified_deps_file) deps_data['_ansible_core_version'] = ansible_core__version__ # antsibull-docs will choke when a key `_python` is found. Remove it to work around # that until antsibull-docs is fixed. deps_data.pop('_python', None) write_deps_file(modified_deps_file, deps_data) params = ['stable', '--deps-file', modified_deps_file] # Generate the plugin rst return antsibull_docs.run(['antsibull-docs'] + params + ['--ansible-core-source', str(args.top_dir), '--dest-dir', args.output_dir]) # If we make this more than just a driver for antsibull: # Run other rst generation # Run sphinx build class CollectionPluginDocs(Command): name = 'docs-build' _ACTION_HELP = """Action to perform. full: Regenerate the rst for the full ansible website. core: Regenerate the rst for plugins in ansible-core and then build the website. named: Regenerate the rst for the named plugins and then build the website. """ @classmethod def init_parser(cls, add_parser): parser = add_parser(cls.name, description='Generate documentation for plugins in collections.' ' Plugins in collections will have a stub file in the normal plugin' ' documentation location that says the module is in a collection and' ' point to generated plugin documentation under the collections/' ' hierarchy.') # I think we should make the actions a subparser but need to look in git history and see if # we tried that and changed it for some reason. parser.add_argument('action', action='store', choices=('full', 'core', 'named'), default='full', help=cls._ACTION_HELP) parser.add_argument("-o", "--output-dir", action="store", dest="output_dir", default=DEFAULT_OUTPUT_DIR, help="Output directory for generated doc files") parser.add_argument("-t", "--top-dir", action="store", dest="top_dir", default=DEFAULT_TOP_DIR, help="Toplevel directory of this ansible-core checkout or expanded" " tarball.") parser.add_argument("-l", "--limit-to-modules", '--limit-to', action="store", dest="limit_to", default=None, help="Limit building module documentation to comma-separated list of" " plugins. Specify non-existing plugin name for no plugins.") parser.add_argument('--ansible-version', action='store', dest='ansible_version', default=None, help='The version of the ansible package to make documentation for.' ' This only makes sense when used with full.') parser.add_argument('--ansible-build-data', action='store', dest='ansible_build_data', default=None, help='A checkout of the ansible-build-data repo. Useful for' ' debugging.') @staticmethod def main(args): # normalize and validate CLI args if args.ansible_version and args.action != 'full': raise InvalidUserInput('--ansible-version is only for use with "full".') if not args.output_dir: args.output_dir = os.path.abspath(str(DEFAULT_OUTPUT_DIR)) if args.action == 'full': return generate_full_docs(args) if args.action == 'core': return generate_core_docs(args) # args.action == 'named' (Invalid actions are caught by argparse) raise NotImplementedError('Building docs for specific files is not yet implemented') # return 0