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 . import get_include_args 26from ..dependencies import Dependency, InternalDependency, ExternalProgram 27from ..interpreterbase import FeatureNew, InvalidArguments, noPosargs, noKwargs 28from ..interpreter import CustomTargetHolder 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(['%s=%s' % (option, value)]) 104 else: 105 self.cmd.extend([option, value]) 106 107 def check_extra_arg_type(self, arg, value): 108 value = getattr(value, 'held_object', value) 109 if isinstance(value, list): 110 for v in value: 111 self.check_extra_arg_type(arg, v) 112 return 113 114 valid_types = (str, bool, mesonlib.File, build.IncludeDirs, build.CustomTarget, build.BuildTarget) 115 if not isinstance(value, valid_types): 116 raise InvalidArguments('Argument "%s=%s" should be of type: %s.' % ( 117 arg, value, [t.__name__ for t in valid_types])) 118 119 def process_extra_args(self): 120 for arg, value in self.kwargs.items(): 121 option = "--" + arg.replace("_", "-") 122 self.check_extra_arg_type(arg, value) 123 self.set_arg_value(option, value) 124 125 def get_value(self, types, argname, default=None, value_processor=None, 126 mandatory=False, force_list=False): 127 if not isinstance(types, list): 128 types = [types] 129 try: 130 uvalue = value = self.kwargs.pop(argname) 131 if value_processor: 132 value = value_processor(value) 133 134 for t in types: 135 if isinstance(value, t): 136 if force_list and not isinstance(value, list): 137 return [value], uvalue 138 return value, uvalue 139 raise MesonException("%s field value %s is not valid," 140 " valid types are %s" % (argname, value, 141 types)) 142 except KeyError: 143 if mandatory: 144 raise MesonException("%s mandatory field not found" % argname) 145 146 if default is not None: 147 return default, default 148 149 return None, None 150 151 def setup_extension_paths(self, paths): 152 if not isinstance(paths, list): 153 paths = [paths] 154 155 for path in paths: 156 self.add_extension_paths([path]) 157 158 return [] 159 160 def add_extension_paths(self, paths): 161 for path in paths: 162 if path in self._extra_extension_paths: 163 continue 164 165 self._extra_extension_paths.add(path) 166 self.cmd.extend(["--extra-extension-path", path]) 167 168 def process_extra_extension_paths(self): 169 self.get_value([list, str], 'extra_extensions_paths', 170 default="", value_processor=self.setup_extension_paths) 171 172 def replace_dirs_in_string(self, string): 173 return string.replace("@SOURCE_ROOT@", self.sourcedir).replace("@BUILD_ROOT@", self.builddir) 174 175 def process_gi_c_source_roots(self): 176 if self.hotdoc.run_hotdoc(['--has-extension=gi-extension']) != 0: 177 return 178 179 value, _ = self.get_value([list, str], 'gi_c_source_roots', default=[], force_list=True) 180 value.extend([ 181 os.path.join(self.state.environment.get_source_dir(), 182 self.interpreter.subproject_dir, self.state.subproject), 183 os.path.join(self.state.environment.get_build_dir(), self.interpreter.subproject_dir, self.state.subproject) 184 ]) 185 186 self.cmd += ['--gi-c-source-roots'] + value 187 188 def process_dependencies(self, deps): 189 cflags = set() 190 for dep in mesonlib.listify(ensure_list(deps)): 191 dep = getattr(dep, "held_object", dep) 192 if isinstance(dep, InternalDependency): 193 inc_args = get_include_args(dep.include_directories) 194 cflags.update([self.replace_dirs_in_string(x) 195 for x in inc_args]) 196 cflags.update(self.process_dependencies(dep.libraries)) 197 cflags.update(self.process_dependencies(dep.sources)) 198 cflags.update(self.process_dependencies(dep.ext_deps)) 199 elif isinstance(dep, Dependency): 200 cflags.update(dep.get_compile_args()) 201 elif isinstance(dep, (build.StaticLibrary, build.SharedLibrary)): 202 self._dependencies.append(dep) 203 for incd in dep.get_include_dirs(): 204 cflags.update(incd.get_incdirs()) 205 elif isinstance(dep, HotdocTarget): 206 # Recurse in hotdoc target dependencies 207 self.process_dependencies(dep.get_target_dependencies()) 208 self._subprojects.extend(dep.subprojects) 209 self.process_dependencies(dep.subprojects) 210 self.add_include_path(os.path.join(self.builddir, dep.hotdoc_conf.subdir)) 211 self.cmd += ['--extra-assets=' + p for p in dep.extra_assets] 212 self.add_extension_paths(dep.extra_extension_paths) 213 elif isinstance(dep, build.CustomTarget) or isinstance(dep, build.BuildTarget): 214 self._dependencies.append(dep) 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 arg = getattr(arg, 'held_object', arg) 236 if isinstance(arg, mesonlib.File): 237 arg = arg.absolute_path(self.state.environment.get_source_dir(), 238 self.state.environment.get_build_dir()) 239 elif isinstance(arg, build.IncludeDirs): 240 for inc_dir in arg.get_incdirs(): 241 cmd.append(os.path.join(self.sourcedir, arg.get_curdir(), inc_dir)) 242 cmd.append(os.path.join(self.builddir, arg.get_curdir(), inc_dir)) 243 244 continue 245 elif isinstance(arg, build.CustomTarget) or isinstance(arg, build.BuildTarget): 246 self._dependencies.append(arg) 247 arg = self.interpreter.backend.get_target_filename_abs(arg) 248 249 cmd.append(arg) 250 251 return cmd 252 253 def generate_hotdoc_config(self): 254 cwd = os.path.abspath(os.curdir) 255 ncwd = os.path.join(self.sourcedir, self.subdir) 256 mlog.log('Generating Hotdoc configuration for: ', mlog.bold(self.name)) 257 os.chdir(ncwd) 258 self.hotdoc.run_hotdoc(self.flatten_config_command()) 259 os.chdir(cwd) 260 261 def ensure_file(self, value): 262 if isinstance(value, list): 263 res = [] 264 for val in value: 265 res.append(self.ensure_file(val)) 266 return res 267 268 if not isinstance(value, mesonlib.File): 269 return mesonlib.File.from_source_file(self.sourcedir, self.subdir, value) 270 271 return value 272 273 def ensure_dir(self, value): 274 if os.path.isabs(value): 275 _dir = value 276 else: 277 _dir = os.path.join(self.sourcedir, self.subdir, value) 278 279 if not os.path.isdir(_dir): 280 raise InvalidArguments('"%s" is not a directory.' % _dir) 281 282 return os.path.relpath(_dir, os.path.join(self.builddir, self.subdir)) 283 284 def check_forbiden_args(self): 285 for arg in ['conf_file']: 286 if arg in self.kwargs: 287 raise InvalidArguments('Argument "%s" is forbidden.' % arg) 288 289 def add_include_path(self, path): 290 self.include_paths[path] = path 291 292 def make_targets(self): 293 self.check_forbiden_args() 294 file_types = (str, mesonlib.File) 295 self.process_known_arg("--index", file_types, mandatory=True, value_processor=self.ensure_file) 296 self.process_known_arg("--project-version", str, mandatory=True) 297 self.process_known_arg("--sitemap", file_types, mandatory=True, value_processor=self.ensure_file) 298 self.process_known_arg("--html-extra-theme", str, value_processor=self.ensure_dir) 299 self.process_known_arg(None, list, "include_paths", force_list=True, 300 value_processor=lambda x: [self.add_include_path(self.ensure_dir(v)) for v in ensure_list(x)]) 301 self.process_known_arg('--c-include-directories', 302 [Dependency, build.StaticLibrary, build.SharedLibrary, list], argname="dependencies", 303 force_list=True, value_processor=self.process_dependencies) 304 self.process_gi_c_source_roots() 305 self.process_extra_assets() 306 self.process_extra_extension_paths() 307 self.process_subprojects() 308 309 install, install = self.get_value(bool, "install", mandatory=False) 310 self.process_extra_args() 311 312 fullname = self.name + '-doc' 313 hotdoc_config_name = fullname + '.json' 314 hotdoc_config_path = os.path.join( 315 self.builddir, self.subdir, hotdoc_config_name) 316 with open(hotdoc_config_path, 'w') as f: 317 f.write('{}') 318 319 self.cmd += ['--conf-file', hotdoc_config_path] 320 self.add_include_path(os.path.join(self.builddir, self.subdir)) 321 self.add_include_path(os.path.join(self.sourcedir, self.subdir)) 322 323 depfile = os.path.join(self.builddir, self.subdir, self.name + '.deps') 324 self.cmd += ['--deps-file-dest', depfile] 325 326 for path in self.include_paths.keys(): 327 self.cmd.extend(['--include-path', path]) 328 329 if self.state.environment.coredata.get_builtin_option('werror', self.state.subproject): 330 self.cmd.append('--fatal-warning') 331 self.generate_hotdoc_config() 332 333 target_cmd = self.build_command + ["--internal", "hotdoc"] + \ 334 self.hotdoc.get_command() + ['run', '--conf-file', hotdoc_config_name] + \ 335 ['--builddir', os.path.join(self.builddir, self.subdir)] 336 337 target = HotdocTarget(fullname, 338 subdir=self.subdir, 339 subproject=self.state.subproject, 340 hotdoc_conf=mesonlib.File.from_built_file( 341 self.subdir, hotdoc_config_name), 342 extra_extension_paths=self._extra_extension_paths, 343 extra_assets=self._extra_assets, 344 subprojects=self._subprojects, 345 command=target_cmd, 346 depends=self._dependencies, 347 output=fullname, 348 depfile=os.path.basename(depfile), 349 build_by_default=self.build_by_default) 350 351 install_script = None 352 if install is True: 353 install_script = HotdocRunScript(self.build_command, [ 354 "--internal", "hotdoc", 355 "--install", os.path.join(fullname, 'html'), 356 '--name', self.name, 357 '--builddir', os.path.join(self.builddir, self.subdir)] + 358 self.hotdoc.get_command() + 359 ['run', '--conf-file', hotdoc_config_name]) 360 361 return (target, install_script) 362 363 364class HotdocTargetHolder(CustomTargetHolder): 365 def __init__(self, target, interp): 366 super().__init__(target, interp) 367 self.methods.update({'config_path': self.config_path_method}) 368 369 @noPosargs 370 @noKwargs 371 def config_path_method(self, *args, **kwargs): 372 conf = self.held_object.hotdoc_conf.absolute_path(self.interpreter.environment.source_dir, 373 self.interpreter.environment.build_dir) 374 return self.interpreter.holderify(conf) 375 376 377class HotdocTarget(build.CustomTarget): 378 def __init__(self, name, subdir, subproject, hotdoc_conf, extra_extension_paths, extra_assets, 379 subprojects, **kwargs): 380 super().__init__(name, subdir, subproject, kwargs, absolute_paths=True) 381 self.hotdoc_conf = hotdoc_conf 382 self.extra_extension_paths = extra_extension_paths 383 self.extra_assets = extra_assets 384 self.subprojects = subprojects 385 386 def __getstate__(self): 387 # Make sure we do not try to pickle subprojects 388 res = self.__dict__.copy() 389 res['subprojects'] = [] 390 391 return res 392 393 394class HotdocRunScript(build.RunScript): 395 def __init__(self, script, args): 396 super().__init__(script, args) 397 398 399class HotDocModule(ExtensionModule): 400 @FeatureNew('Hotdoc Module', '0.48.0') 401 def __init__(self, interpreter): 402 super().__init__(interpreter) 403 self.hotdoc = ExternalProgram('hotdoc') 404 if not self.hotdoc.found(): 405 raise MesonException('hotdoc executable not found') 406 407 try: 408 from hotdoc.run_hotdoc import run # noqa: F401 409 self.hotdoc.run_hotdoc = run 410 except Exception as e: 411 raise MesonException('hotdoc %s required but not found. (%s)' % ( 412 MIN_HOTDOC_VERSION, e)) 413 414 @noKwargs 415 def has_extensions(self, state, args, kwargs): 416 res = self.hotdoc.run_hotdoc(['--has-extension=%s' % extension for extension in args]) == 0 417 return ModuleReturnValue(res, [res]) 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 = [HotdocTargetHolder(target, self.interpreter)] 428 if install_script: 429 targets.append(install_script) 430 431 return ModuleReturnValue(targets[0], targets) 432 433 434def initialize(interpreter): 435 return HotDocModule(interpreter) 436