# Copyright 2018 The Meson development team # 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 # http://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. from __future__ import annotations '''This module provides helper functions for generating documentation using hotdoc''' import os import subprocess from mesonbuild import mesonlib from mesonbuild import mlog, build from mesonbuild.coredata import MesonException from . import ModuleReturnValue, ModuleInfo from . import ExtensionModule from ..dependencies import Dependency, InternalDependency from ..interpreterbase import ( InvalidArguments, noPosargs, noKwargs, typed_kwargs, FeatureDeprecated, ContainerTypeInfo, KwargInfo, typed_pos_args ) from ..interpreter import CustomTargetHolder from ..interpreter.type_checking import NoneType from ..programs import ExternalProgram def ensure_list(value): if not isinstance(value, list): return [value] return value MIN_HOTDOC_VERSION = '0.8.100' file_types = (str, mesonlib.File, build.CustomTarget, build.CustomTargetIndex) class HotdocTargetBuilder: def __init__(self, name, state, hotdoc, interpreter, kwargs): self.hotdoc = hotdoc self.build_by_default = kwargs.pop('build_by_default', False) self.kwargs = kwargs self.name = name self.state = state self.interpreter = interpreter self.include_paths = mesonlib.OrderedSet() self.builddir = state.environment.get_build_dir() self.sourcedir = state.environment.get_source_dir() self.subdir = state.subdir self.build_command = state.environment.get_build_command() self.cmd = ['conf', '--project-name', name, "--disable-incremental-build", '--output', os.path.join(self.builddir, self.subdir, self.name + '-doc')] self._extra_extension_paths = set() self.extra_assets = set() self.extra_depends = [] self._subprojects = [] def process_known_arg(self, option, argname=None, value_processor=None): if not argname: argname = option.strip("-").replace("-", "_") value = self.kwargs.pop(argname) if value is not None and value_processor: value = value_processor(value) self.set_arg_value(option, value) def set_arg_value(self, option, value): if value is None: return if isinstance(value, bool): if value: self.cmd.append(option) elif isinstance(value, list): # Do not do anything on empty lists if value: # https://bugs.python.org/issue9334 (from 2010 :( ) # The syntax with nargs=+ is inherently ambiguous # A workaround for this case is to simply prefix with a space # every value starting with a dash escaped_value = [] for e in value: if isinstance(e, str) and e.startswith('-'): escaped_value += [' %s' % e] else: escaped_value += [e] if option: self.cmd.extend([option] + escaped_value) else: self.cmd.extend(escaped_value) else: # argparse gets confused if value(s) start with a dash. # When an option expects a single value, the unambiguous way # to specify it is with = if isinstance(value, str): self.cmd.extend([f'{option}={value}']) else: self.cmd.extend([option, value]) def check_extra_arg_type(self, arg, value): if isinstance(value, list): for v in value: self.check_extra_arg_type(arg, v) return valid_types = (str, bool, mesonlib.File, build.IncludeDirs, build.CustomTarget, build.CustomTargetIndex, build.BuildTarget) if not isinstance(value, valid_types): raise InvalidArguments('Argument "{}={}" should be of type: {}.'.format( arg, value, [t.__name__ for t in valid_types])) def process_extra_args(self): for arg, value in self.kwargs.items(): option = "--" + arg.replace("_", "-") self.check_extra_arg_type(arg, value) self.set_arg_value(option, value) def get_value(self, types, argname, default=None, value_processor=None, mandatory=False, force_list=False): if not isinstance(types, list): types = [types] try: uvalue = value = self.kwargs.pop(argname) if value_processor: value = value_processor(value) for t in types: if isinstance(value, t): if force_list and not isinstance(value, list): return [value], uvalue return value, uvalue raise MesonException(f"{argname} field value {value} is not valid," f" valid types are {types}") except KeyError: if mandatory: raise MesonException(f"{argname} mandatory field not found") if default is not None: return default, default return None, None def add_extension_paths(self, paths): for path in paths: if path in self._extra_extension_paths: continue self._extra_extension_paths.add(path) self.cmd.extend(["--extra-extension-path", path]) def replace_dirs_in_string(self, string): return string.replace("@SOURCE_ROOT@", self.sourcedir).replace("@BUILD_ROOT@", self.builddir) def process_gi_c_source_roots(self): if self.hotdoc.run_hotdoc(['--has-extension=gi-extension']) != 0: return value = self.kwargs.pop('gi_c_source_roots') value.extend([ os.path.join(self.sourcedir, self.state.root_subdir), os.path.join(self.builddir, self.state.root_subdir) ]) self.cmd += ['--gi-c-source-roots'] + value def process_dependencies(self, deps): cflags = set() for dep in mesonlib.listify(ensure_list(deps)): if isinstance(dep, InternalDependency): inc_args = self.state.get_include_args(dep.include_directories) cflags.update([self.replace_dirs_in_string(x) for x in inc_args]) cflags.update(self.process_dependencies(dep.libraries)) cflags.update(self.process_dependencies(dep.sources)) cflags.update(self.process_dependencies(dep.ext_deps)) elif isinstance(dep, Dependency): cflags.update(dep.get_compile_args()) elif isinstance(dep, (build.StaticLibrary, build.SharedLibrary)): self.extra_depends.append(dep) for incd in dep.get_include_dirs(): cflags.update(incd.get_incdirs()) elif isinstance(dep, HotdocTarget): # Recurse in hotdoc target dependencies self.process_dependencies(dep.get_target_dependencies()) self._subprojects.extend(dep.subprojects) self.process_dependencies(dep.subprojects) self.include_paths.add(os.path.join(self.builddir, dep.hotdoc_conf.subdir)) self.cmd += ['--extra-assets=' + p for p in dep.extra_assets] self.add_extension_paths(dep.extra_extension_paths) elif isinstance(dep, (build.CustomTarget, build.BuildTarget)): self.extra_depends.append(dep) elif isinstance(dep, build.CustomTargetIndex): self.extra_depends.append(dep.target) return [f.strip('-I') for f in cflags] def process_extra_assets(self): self._extra_assets = self.kwargs.pop('extra_assets') for assets_path in self._extra_assets: self.cmd.extend(["--extra-assets", assets_path]) def process_subprojects(self): value = self.kwargs.pop('subprojects') self.process_dependencies(value) self._subprojects.extend(value) def flatten_config_command(self): cmd = [] for arg in mesonlib.listify(self.cmd, flatten=True): if isinstance(arg, mesonlib.File): arg = arg.absolute_path(self.state.environment.get_source_dir(), self.state.environment.get_build_dir()) elif isinstance(arg, build.IncludeDirs): for inc_dir in arg.get_incdirs(): cmd.append(os.path.join(self.sourcedir, arg.get_curdir(), inc_dir)) cmd.append(os.path.join(self.builddir, arg.get_curdir(), inc_dir)) continue elif isinstance(arg, (build.BuildTarget, build.CustomTarget)): self.extra_depends.append(arg) arg = self.interpreter.backend.get_target_filename_abs(arg) elif isinstance(arg, build.CustomTargetIndex): self.extra_depends.append(arg.target) arg = self.interpreter.backend.get_target_filename_abs(arg) cmd.append(arg) return cmd def generate_hotdoc_config(self): cwd = os.path.abspath(os.curdir) ncwd = os.path.join(self.sourcedir, self.subdir) mlog.log('Generating Hotdoc configuration for: ', mlog.bold(self.name)) os.chdir(ncwd) if self.hotdoc.run_hotdoc(self.flatten_config_command()) != 0: raise MesonException('hotdoc failed to configure') os.chdir(cwd) def ensure_file(self, value): if isinstance(value, list): res = [] for val in value: res.append(self.ensure_file(val)) return res if isinstance(value, str): return mesonlib.File.from_source_file(self.sourcedir, self.subdir, value) return value def ensure_dir(self, value): if os.path.isabs(value): _dir = value else: _dir = os.path.join(self.sourcedir, self.subdir, value) if not os.path.isdir(_dir): raise InvalidArguments(f'"{_dir}" is not a directory.') return os.path.relpath(_dir, os.path.join(self.builddir, self.subdir)) def check_forbidden_args(self): for arg in ['conf_file']: if arg in self.kwargs: raise InvalidArguments(f'Argument "{arg}" is forbidden.') def make_targets(self): self.check_forbidden_args() self.process_known_arg("--index", value_processor=self.ensure_file) self.process_known_arg("--project-version") self.process_known_arg("--sitemap", value_processor=self.ensure_file) self.process_known_arg("--html-extra-theme", value_processor=self.ensure_dir) self.include_paths.update(self.ensure_dir(v) for v in self.kwargs.pop('include_paths')) self.process_known_arg('--c-include-directories', argname="dependencies", value_processor=self.process_dependencies) self.process_gi_c_source_roots() self.process_extra_assets() self.add_extension_paths(self.kwargs.pop('extra_extension_paths')) self.process_subprojects() self.extra_depends.extend(self.kwargs.pop('depends')) install = self.kwargs.pop('install') self.process_extra_args() fullname = self.name + '-doc' hotdoc_config_name = fullname + '.json' hotdoc_config_path = os.path.join( self.builddir, self.subdir, hotdoc_config_name) with open(hotdoc_config_path, 'w', encoding='utf-8') as f: f.write('{}') self.cmd += ['--conf-file', hotdoc_config_path] self.include_paths.add(os.path.join(self.builddir, self.subdir)) self.include_paths.add(os.path.join(self.sourcedir, self.subdir)) depfile = os.path.join(self.builddir, self.subdir, self.name + '.deps') self.cmd += ['--deps-file-dest', depfile] for path in self.include_paths: self.cmd.extend(['--include-path', path]) if self.state.environment.coredata.get_option(mesonlib.OptionKey('werror', subproject=self.state.subproject)): self.cmd.append('--fatal-warnings') self.generate_hotdoc_config() target_cmd = self.build_command + ["--internal", "hotdoc"] + \ self.hotdoc.get_command() + ['run', '--conf-file', hotdoc_config_name] + \ ['--builddir', os.path.join(self.builddir, self.subdir)] target = HotdocTarget(fullname, subdir=self.subdir, subproject=self.state.subproject, environment=self.state.environment, hotdoc_conf=mesonlib.File.from_built_file( self.subdir, hotdoc_config_name), extra_extension_paths=self._extra_extension_paths, extra_assets=self._extra_assets, subprojects=self._subprojects, command=target_cmd, extra_depends=self.extra_depends, outputs=[fullname], sources=[], depfile=os.path.basename(depfile), build_by_default=self.build_by_default) install_script = None if install: datadir = os.path.join(self.state.get_option('prefix'), self.state.get_option('datadir')) devhelp = self.kwargs.get('devhelp_activate', False) if not isinstance(devhelp, bool): FeatureDeprecated.single_use('hotdoc.generate_doc() devhelp_activate must be boolean', '1.1.0', self.state.subproject) devhelp = False if devhelp: install_from = os.path.join(fullname, 'devhelp') install_to = os.path.join(datadir, 'devhelp') else: install_from = os.path.join(fullname, 'html') install_to = os.path.join(datadir, 'doc', self.name, 'html') install_script = self.state.backend.get_executable_serialisation(self.build_command + [ "--internal", "hotdoc", "--install", install_from, "--docdir", install_to, '--name', self.name, '--builddir', os.path.join(self.builddir, self.subdir)] + self.hotdoc.get_command() + ['run', '--conf-file', hotdoc_config_name]) install_script.tag = 'doc' return (target, install_script) class HotdocTargetHolder(CustomTargetHolder): def __init__(self, target, interp): super().__init__(target, interp) self.methods.update({'config_path': self.config_path_method}) @noPosargs @noKwargs def config_path_method(self, *args, **kwargs): conf = self.held_object.hotdoc_conf.absolute_path(self.interpreter.environment.source_dir, self.interpreter.environment.build_dir) return conf class HotdocTarget(build.CustomTarget): def __init__(self, name, subdir, subproject, hotdoc_conf, extra_extension_paths, extra_assets, subprojects, environment, **kwargs): super().__init__(name, subdir, subproject, environment, **kwargs, absolute_paths=True) self.hotdoc_conf = hotdoc_conf self.extra_extension_paths = extra_extension_paths self.extra_assets = extra_assets self.subprojects = subprojects def __getstate__(self): # Make sure we do not try to pickle subprojects res = self.__dict__.copy() res['subprojects'] = [] return res class HotDocModule(ExtensionModule): INFO = ModuleInfo('hotdoc', '0.48.0') def __init__(self, interpreter): super().__init__(interpreter) self.hotdoc = ExternalProgram('hotdoc') if not self.hotdoc.found(): raise MesonException('hotdoc executable not found') version = self.hotdoc.get_version(interpreter) if not mesonlib.version_compare(version, f'>={MIN_HOTDOC_VERSION}'): raise MesonException(f'hotdoc {MIN_HOTDOC_VERSION} required but not found.)') def run_hotdoc(cmd): return subprocess.run(self.hotdoc.get_command() + cmd, stdout=subprocess.DEVNULL).returncode self.hotdoc.run_hotdoc = run_hotdoc self.methods.update({ 'has_extensions': self.has_extensions, 'generate_doc': self.generate_doc, }) @noKwargs @typed_pos_args('hotdoc.has_extensions', varargs=str, min_varargs=1) def has_extensions(self, state, args, kwargs): return self.hotdoc.run_hotdoc([f'--has-extension={extension}' for extension in args[0]]) == 0 @typed_pos_args('hotdoc.generate_doc', str) @typed_kwargs( 'hotdoc.generate_doc', KwargInfo('sitemap', file_types, required=True), KwargInfo('index', file_types, required=True), KwargInfo('project_version', str, required=True), KwargInfo('html_extra_theme', (str, NoneType)), KwargInfo('include_paths', ContainerTypeInfo(list, str), listify=True, default=[]), # --c-include-directories KwargInfo( 'dependencies', ContainerTypeInfo(list, (Dependency, build.StaticLibrary, build.SharedLibrary, build.CustomTarget, build.CustomTargetIndex)), listify=True, default=[], ), KwargInfo( 'depends', ContainerTypeInfo(list, (build.CustomTarget, build.CustomTargetIndex)), listify=True, default=[], since='0.64.1', ), KwargInfo('gi_c_source_roots', ContainerTypeInfo(list, str), listify=True, default=[]), KwargInfo('extra_assets', ContainerTypeInfo(list, str), listify=True, default=[]), KwargInfo('extra_extension_paths', ContainerTypeInfo(list, str), listify=True, default=[]), KwargInfo('subprojects', ContainerTypeInfo(list, HotdocTarget), listify=True, default=[]), KwargInfo('install', bool, default=False), allow_unknown=True ) def generate_doc(self, state, args, kwargs): project_name = args[0] if any(isinstance(x, (build.CustomTarget, build.CustomTargetIndex)) for x in kwargs['dependencies']): FeatureDeprecated.single_use('hotdoc.generate_doc dependencies argument with custom_target', '0.64.1', state.subproject, 'use `depends`', state.current_node) builder = HotdocTargetBuilder(project_name, state, self.hotdoc, self.interpreter, kwargs) target, install_script = builder.make_targets() targets = [target] if install_script: targets.append(install_script) return ModuleReturnValue(targets[0], targets) def initialize(interpreter): mod = HotDocModule(interpreter) mod.interpreter.append_holder_map(HotdocTarget, HotdocTargetHolder) return mod