1# Copyright 2018 The Meson development team 2 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6 7# http://www.apache.org/licenses/LICENSE-2.0 8 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15'''This module provides helper functions for generating documentation using hotdoc''' 16 17import os 18from collections import OrderedDict 19 20from mesonbuild import mesonlib 21from mesonbuild import mlog, build 22from mesonbuild.coredata import MesonException 23from . import ModuleReturnValue 24from . import ExtensionModule 25from ..dependencies import Dependency, InternalDependency 26from ..interpreterbase import FeatureNew, InvalidArguments, noPosargs, noKwargs 27from ..interpreter import CustomTargetHolder 28from ..programs import ExternalProgram 29 30 31def ensure_list(value): 32 if not isinstance(value, list): 33 return [value] 34 return value 35 36 37MIN_HOTDOC_VERSION = '0.8.100' 38 39 40class HotdocTargetBuilder: 41 def __init__(self, name, state, hotdoc, interpreter, kwargs): 42 self.hotdoc = hotdoc 43 self.build_by_default = kwargs.pop('build_by_default', False) 44 self.kwargs = kwargs 45 self.name = name 46 self.state = state 47 self.interpreter = interpreter 48 self.include_paths = OrderedDict() 49 50 self.builddir = state.environment.get_build_dir() 51 self.sourcedir = state.environment.get_source_dir() 52 self.subdir = state.subdir 53 self.build_command = state.environment.get_build_command() 54 55 self.cmd = ['conf', '--project-name', name, "--disable-incremental-build", 56 '--output', os.path.join(self.builddir, self.subdir, self.name + '-doc')] 57 58 self._extra_extension_paths = set() 59 self.extra_assets = set() 60 self._dependencies = [] 61 self._subprojects = [] 62 63 def process_known_arg(self, option, types, argname=None, 64 value_processor=None, mandatory=False, 65 force_list=False): 66 if not argname: 67 argname = option.strip("-").replace("-", "_") 68 69 value, _ = self.get_value( 70 types, argname, None, value_processor, mandatory, force_list) 71 72 self.set_arg_value(option, value) 73 74 def set_arg_value(self, option, value): 75 if value is None: 76 return 77 78 if isinstance(value, bool): 79 if value: 80 self.cmd.append(option) 81 elif isinstance(value, list): 82 # Do not do anything on empty lists 83 if value: 84 # https://bugs.python.org/issue9334 (from 2010 :( ) 85 # The syntax with nargs=+ is inherently ambiguous 86 # A workaround for this case is to simply prefix with a space 87 # every value starting with a dash 88 escaped_value = [] 89 for e in value: 90 if isinstance(e, str) and e.startswith('-'): 91 escaped_value += [' %s' % e] 92 else: 93 escaped_value += [e] 94 if option: 95 self.cmd.extend([option] + escaped_value) 96 else: 97 self.cmd.extend(escaped_value) 98 else: 99 # argparse gets confused if value(s) start with a dash. 100 # When an option expects a single value, the unambiguous way 101 # to specify it is with = 102 if isinstance(value, str): 103 self.cmd.extend([f'{option}={value}']) 104 else: 105 self.cmd.extend([option, value]) 106 107 def check_extra_arg_type(self, arg, value): 108 if isinstance(value, list): 109 for v in value: 110 self.check_extra_arg_type(arg, v) 111 return 112 113 valid_types = (str, bool, mesonlib.File, build.IncludeDirs, build.CustomTarget, build.CustomTargetIndex, build.BuildTarget) 114 if not isinstance(value, valid_types): 115 raise InvalidArguments('Argument "{}={}" should be of type: {}.'.format( 116 arg, value, [t.__name__ for t in valid_types])) 117 118 def process_extra_args(self): 119 for arg, value in self.kwargs.items(): 120 option = "--" + arg.replace("_", "-") 121 self.check_extra_arg_type(arg, value) 122 self.set_arg_value(option, value) 123 124 def get_value(self, types, argname, default=None, value_processor=None, 125 mandatory=False, force_list=False): 126 if not isinstance(types, list): 127 types = [types] 128 try: 129 uvalue = value = self.kwargs.pop(argname) 130 if value_processor: 131 value = value_processor(value) 132 133 for t in types: 134 if isinstance(value, t): 135 if force_list and not isinstance(value, list): 136 return [value], uvalue 137 return value, uvalue 138 raise MesonException("%s field value %s is not valid," 139 " valid types are %s" % (argname, value, 140 types)) 141 except KeyError: 142 if mandatory: 143 raise MesonException("%s mandatory field not found" % argname) 144 145 if default is not None: 146 return default, default 147 148 return None, None 149 150 def setup_extension_paths(self, paths): 151 if not isinstance(paths, list): 152 paths = [paths] 153 154 for path in paths: 155 self.add_extension_paths([path]) 156 157 return [] 158 159 def add_extension_paths(self, paths): 160 for path in paths: 161 if path in self._extra_extension_paths: 162 continue 163 164 self._extra_extension_paths.add(path) 165 self.cmd.extend(["--extra-extension-path", path]) 166 167 def process_extra_extension_paths(self): 168 self.get_value([list, str], 'extra_extensions_paths', 169 default="", value_processor=self.setup_extension_paths) 170 171 def replace_dirs_in_string(self, string): 172 return string.replace("@SOURCE_ROOT@", self.sourcedir).replace("@BUILD_ROOT@", self.builddir) 173 174 def process_gi_c_source_roots(self): 175 if self.hotdoc.run_hotdoc(['--has-extension=gi-extension']) != 0: 176 return 177 178 value, _ = self.get_value([list, str], 'gi_c_source_roots', default=[], force_list=True) 179 value.extend([ 180 os.path.join(self.state.environment.get_source_dir(), 181 self.interpreter.subproject_dir, self.state.subproject), 182 os.path.join(self.state.environment.get_build_dir(), self.interpreter.subproject_dir, self.state.subproject) 183 ]) 184 185 self.cmd += ['--gi-c-source-roots'] + value 186 187 def process_dependencies(self, deps): 188 cflags = set() 189 for dep in mesonlib.listify(ensure_list(deps)): 190 if isinstance(dep, InternalDependency): 191 inc_args = self.state.get_include_args(dep.include_directories) 192 cflags.update([self.replace_dirs_in_string(x) 193 for x in inc_args]) 194 cflags.update(self.process_dependencies(dep.libraries)) 195 cflags.update(self.process_dependencies(dep.sources)) 196 cflags.update(self.process_dependencies(dep.ext_deps)) 197 elif isinstance(dep, Dependency): 198 cflags.update(dep.get_compile_args()) 199 elif isinstance(dep, (build.StaticLibrary, build.SharedLibrary)): 200 self._dependencies.append(dep) 201 for incd in dep.get_include_dirs(): 202 cflags.update(incd.get_incdirs()) 203 elif isinstance(dep, HotdocTarget): 204 # Recurse in hotdoc target dependencies 205 self.process_dependencies(dep.get_target_dependencies()) 206 self._subprojects.extend(dep.subprojects) 207 self.process_dependencies(dep.subprojects) 208 self.add_include_path(os.path.join(self.builddir, dep.hotdoc_conf.subdir)) 209 self.cmd += ['--extra-assets=' + p for p in dep.extra_assets] 210 self.add_extension_paths(dep.extra_extension_paths) 211 elif isinstance(dep, build.CustomTarget) or isinstance(dep, build.BuildTarget): 212 self._dependencies.append(dep) 213 elif isinstance(dep, build.CustomTargetIndex): 214 self._dependencies.append(dep.target) 215 216 return [f.strip('-I') for f in cflags] 217 218 def process_extra_assets(self): 219 self._extra_assets, _ = self.get_value("--extra-assets", (str, list), default=[], 220 force_list=True) 221 for assets_path in self._extra_assets: 222 self.cmd.extend(["--extra-assets", assets_path]) 223 224 def process_subprojects(self): 225 _, value = self.get_value([ 226 list, HotdocTarget], argname="subprojects", 227 force_list=True, value_processor=self.process_dependencies) 228 229 if value is not None: 230 self._subprojects.extend(value) 231 232 def flatten_config_command(self): 233 cmd = [] 234 for arg in mesonlib.listify(self.cmd, flatten=True): 235 if isinstance(arg, mesonlib.File): 236 arg = arg.absolute_path(self.state.environment.get_source_dir(), 237 self.state.environment.get_build_dir()) 238 elif isinstance(arg, build.IncludeDirs): 239 for inc_dir in arg.get_incdirs(): 240 cmd.append(os.path.join(self.sourcedir, arg.get_curdir(), inc_dir)) 241 cmd.append(os.path.join(self.builddir, arg.get_curdir(), inc_dir)) 242 243 continue 244 elif isinstance(arg, (build.BuildTarget, build.CustomTarget)): 245 self._dependencies.append(arg) 246 arg = self.interpreter.backend.get_target_filename_abs(arg) 247 elif isinstance(arg, build.CustomTargetIndex): 248 self._dependencies.append(arg.target) 249 arg = self.interpreter.backend.get_target_filename_abs(arg) 250 251 cmd.append(arg) 252 253 return cmd 254 255 def generate_hotdoc_config(self): 256 cwd = os.path.abspath(os.curdir) 257 ncwd = os.path.join(self.sourcedir, self.subdir) 258 mlog.log('Generating Hotdoc configuration for: ', mlog.bold(self.name)) 259 os.chdir(ncwd) 260 self.hotdoc.run_hotdoc(self.flatten_config_command()) 261 os.chdir(cwd) 262 263 def ensure_file(self, value): 264 if isinstance(value, list): 265 res = [] 266 for val in value: 267 res.append(self.ensure_file(val)) 268 return res 269 270 if isinstance(value, str): 271 return mesonlib.File.from_source_file(self.sourcedir, self.subdir, value) 272 273 return value 274 275 def ensure_dir(self, value): 276 if os.path.isabs(value): 277 _dir = value 278 else: 279 _dir = os.path.join(self.sourcedir, self.subdir, value) 280 281 if not os.path.isdir(_dir): 282 raise InvalidArguments('"%s" is not a directory.' % _dir) 283 284 return os.path.relpath(_dir, os.path.join(self.builddir, self.subdir)) 285 286 def check_forbidden_args(self): 287 for arg in ['conf_file']: 288 if arg in self.kwargs: 289 raise InvalidArguments('Argument "%s" is forbidden.' % arg) 290 291 def add_include_path(self, path): 292 self.include_paths[path] = path 293 294 def make_targets(self): 295 self.check_forbidden_args() 296 file_types = (str, mesonlib.File, build.CustomTarget, build.CustomTargetIndex) 297 self.process_known_arg("--index", file_types, mandatory=True, value_processor=self.ensure_file) 298 self.process_known_arg("--project-version", str, mandatory=True) 299 self.process_known_arg("--sitemap", file_types, mandatory=True, value_processor=self.ensure_file) 300 self.process_known_arg("--html-extra-theme", str, value_processor=self.ensure_dir) 301 self.process_known_arg(None, list, "include_paths", force_list=True, 302 value_processor=lambda x: [self.add_include_path(self.ensure_dir(v)) for v in ensure_list(x)]) 303 self.process_known_arg('--c-include-directories', 304 [Dependency, build.StaticLibrary, build.SharedLibrary, list], argname="dependencies", 305 force_list=True, value_processor=self.process_dependencies) 306 self.process_gi_c_source_roots() 307 self.process_extra_assets() 308 self.process_extra_extension_paths() 309 self.process_subprojects() 310 311 install, install = self.get_value(bool, "install", mandatory=False) 312 self.process_extra_args() 313 314 fullname = self.name + '-doc' 315 hotdoc_config_name = fullname + '.json' 316 hotdoc_config_path = os.path.join( 317 self.builddir, self.subdir, hotdoc_config_name) 318 with open(hotdoc_config_path, 'w', encoding='utf-8') as f: 319 f.write('{}') 320 321 self.cmd += ['--conf-file', hotdoc_config_path] 322 self.add_include_path(os.path.join(self.builddir, self.subdir)) 323 self.add_include_path(os.path.join(self.sourcedir, self.subdir)) 324 325 depfile = os.path.join(self.builddir, self.subdir, self.name + '.deps') 326 self.cmd += ['--deps-file-dest', depfile] 327 328 for path in self.include_paths.keys(): 329 self.cmd.extend(['--include-path', path]) 330 331 if self.state.environment.coredata.get_option(mesonlib.OptionKey('werror', subproject=self.state.subproject)): 332 self.cmd.append('--fatal-warning') 333 self.generate_hotdoc_config() 334 335 target_cmd = self.build_command + ["--internal", "hotdoc"] + \ 336 self.hotdoc.get_command() + ['run', '--conf-file', hotdoc_config_name] + \ 337 ['--builddir', os.path.join(self.builddir, self.subdir)] 338 339 target = HotdocTarget(fullname, 340 subdir=self.subdir, 341 subproject=self.state.subproject, 342 hotdoc_conf=mesonlib.File.from_built_file( 343 self.subdir, hotdoc_config_name), 344 extra_extension_paths=self._extra_extension_paths, 345 extra_assets=self._extra_assets, 346 subprojects=self._subprojects, 347 command=target_cmd, 348 depends=self._dependencies, 349 output=fullname, 350 depfile=os.path.basename(depfile), 351 build_by_default=self.build_by_default) 352 353 install_script = None 354 if install is True: 355 install_script = self.state.backend.get_executable_serialisation(self.build_command + [ 356 "--internal", "hotdoc", 357 "--install", os.path.join(fullname, 'html'), 358 '--name', self.name, 359 '--builddir', os.path.join(self.builddir, self.subdir)] + 360 self.hotdoc.get_command() + 361 ['run', '--conf-file', hotdoc_config_name]) 362 install_script.tag = 'doc' 363 364 return (target, install_script) 365 366 367class HotdocTargetHolder(CustomTargetHolder): 368 def __init__(self, target, interp): 369 super().__init__(target, interp) 370 self.methods.update({'config_path': self.config_path_method}) 371 372 @noPosargs 373 @noKwargs 374 def config_path_method(self, *args, **kwargs): 375 conf = self.held_object.hotdoc_conf.absolute_path(self.interpreter.environment.source_dir, 376 self.interpreter.environment.build_dir) 377 return conf 378 379 380class HotdocTarget(build.CustomTarget): 381 def __init__(self, name, subdir, subproject, hotdoc_conf, extra_extension_paths, extra_assets, 382 subprojects, **kwargs): 383 super().__init__(name, subdir, subproject, kwargs, absolute_paths=True) 384 self.hotdoc_conf = hotdoc_conf 385 self.extra_extension_paths = extra_extension_paths 386 self.extra_assets = extra_assets 387 self.subprojects = subprojects 388 389 def __getstate__(self): 390 # Make sure we do not try to pickle subprojects 391 res = self.__dict__.copy() 392 res['subprojects'] = [] 393 394 return res 395 396 397class HotDocModule(ExtensionModule): 398 @FeatureNew('Hotdoc Module', '0.48.0') 399 def __init__(self, interpreter): 400 super().__init__(interpreter) 401 self.hotdoc = ExternalProgram('hotdoc') 402 if not self.hotdoc.found(): 403 raise MesonException('hotdoc executable not found') 404 405 try: 406 from hotdoc.run_hotdoc import run # noqa: F401 407 self.hotdoc.run_hotdoc = run 408 except Exception as e: 409 raise MesonException(f'hotdoc {MIN_HOTDOC_VERSION} required but not found. ({e})') 410 self.methods.update({ 411 'has_extensions': self.has_extensions, 412 'generate_doc': self.generate_doc, 413 }) 414 415 @noKwargs 416 def has_extensions(self, state, args, kwargs): 417 return self.hotdoc.run_hotdoc(['--has-extension=%s' % extension for extension in args]) == 0 418 419 def generate_doc(self, state, args, kwargs): 420 if len(args) != 1: 421 raise MesonException('One positional argument is' 422 ' required for the project name.') 423 424 project_name = args[0] 425 builder = HotdocTargetBuilder(project_name, state, self.hotdoc, self.interpreter, kwargs) 426 target, install_script = builder.make_targets() 427 targets = [target] 428 if install_script: 429 targets.append(install_script) 430 431 return ModuleReturnValue(targets[0], targets) 432 433 434def initialize(interpreter): 435 mod = HotDocModule(interpreter) 436 mod.interpreter.append_holder_map(HotdocTarget, HotdocTargetHolder) 437 return mod 438