blob: fc8832efcba8fd4187d3a8d236f0c6e15d638ddd [file] [log] [blame]
# SPDX-License-Identifier: Apache-2.0
# Copyright 2018 The Meson development team
from __future__ import annotations
'''This module provides helper functions for generating documentation using hotdoc'''
import os, subprocess
import typing as T
from . import ExtensionModule, ModuleReturnValue, ModuleInfo
from .. import build, mesonlib, mlog
from ..build import CustomTarget, CustomTargetIndex
from ..dependencies import Dependency, InternalDependency
from ..interpreterbase import (
InvalidArguments, noPosargs, noKwargs, typed_kwargs, FeatureDeprecated,
ContainerTypeInfo, KwargInfo, typed_pos_args
)
from ..interpreter.interpreterobjects import _CustomTargetHolder
from ..interpreter.type_checking import NoneType
from ..mesonlib import File, MesonException
from ..programs import ExternalProgram
if T.TYPE_CHECKING:
from typing_extensions import TypedDict
from . import ModuleState
from ..environment import Environment
from ..interpreter import Interpreter
from ..interpreterbase import TYPE_kwargs, TYPE_var
_T = T.TypeVar('_T')
class GenerateDocKwargs(TypedDict):
sitemap: T.Union[str, File, CustomTarget, CustomTargetIndex]
index: T.Union[str, File, CustomTarget, CustomTargetIndex]
project_version: str
html_extra_theme: T.Optional[str]
include_paths: T.List[str]
dependencies: T.List[T.Union[Dependency, build.StaticLibrary, build.SharedLibrary, CustomTarget, CustomTargetIndex]]
depends: T.List[T.Union[CustomTarget, CustomTargetIndex]]
gi_c_source_roots: T.List[str]
extra_assets: T.List[str]
extra_extension_paths: T.List[str]
subprojects: T.List['HotdocTarget']
install: bool
def ensure_list(value: T.Union[_T, T.List[_T]]) -> T.List[_T]:
if not isinstance(value, list):
return [value]
return value
MIN_HOTDOC_VERSION = '0.8.100'
file_types = (str, File, CustomTarget, CustomTargetIndex)
class HotdocExternalProgram(ExternalProgram):
def run_hotdoc(self, cmd: T.List[str]) -> int:
return subprocess.run(self.get_command() + cmd, stdout=subprocess.DEVNULL).returncode
class HotdocTargetBuilder:
def __init__(self, name: str, state: ModuleState, hotdoc: HotdocExternalProgram, interpreter: 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[str] = 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: T.List[TYPE_var] = ['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: str, argname: T.Optional[str] = None, value_processor: T.Optional[T.Callable] = None) -> 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: str, value: TYPE_var) -> None:
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: str, value: TYPE_var) -> None:
if isinstance(value, list):
for v in value:
self.check_extra_arg_type(arg, v)
return
valid_types = (str, bool, File, build.IncludeDirs, CustomTarget, 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) -> None:
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: T.Union[T.List[str], T.Set[str]]) -> None:
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: str) -> str:
return string.replace("@SOURCE_ROOT@", self.sourcedir).replace("@BUILD_ROOT@", self.builddir)
def process_gi_c_source_roots(self) -> None:
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: T.List[T.Union[Dependency, build.StaticLibrary, build.SharedLibrary, CustomTarget, CustomTargetIndex]]) -> T.List[str]:
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, (CustomTarget, build.BuildTarget)):
self.extra_depends.append(dep)
elif isinstance(dep, CustomTargetIndex):
self.extra_depends.append(dep.target)
return [f.strip('-I') for f in cflags]
def process_extra_assets(self) -> None:
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) -> None:
value = self.kwargs.pop('subprojects')
self.process_dependencies(value)
self._subprojects.extend(value)
def flatten_config_command(self) -> T.List[str]:
cmd = []
for arg in mesonlib.listify(self.cmd, flatten=True):
if isinstance(arg, 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, CustomTarget)):
self.extra_depends.append(arg)
arg = self.interpreter.backend.get_target_filename_abs(arg)
elif isinstance(arg, 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) -> None:
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: T.Union[str, File, CustomTarget, CustomTargetIndex]) -> T.Union[File, CustomTarget, CustomTargetIndex]:
if isinstance(value, list):
res = []
for val in value:
res.append(self.ensure_file(val))
return res
if isinstance(value, str):
return File.from_source_file(self.sourcedir, self.subdir, value)
return value
def ensure_dir(self, value: str) -> str:
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) -> None:
for arg in ['conf_file']:
if arg in self.kwargs:
raise InvalidArguments(f'Argument "{arg}" is forbidden.')
def make_targets(self) -> T.Tuple[HotdocTarget, mesonlib.ExecutableSerialisation]:
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=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['HotdocTarget']):
def __init__(self, target: HotdocTarget, interp: Interpreter):
super().__init__(target, interp)
self.methods.update({'config_path': self.config_path_method})
@noPosargs
@noKwargs
def config_path_method(self, *args: T.Any, **kwargs: T.Any) -> str:
conf = self.held_object.hotdoc_conf.absolute_path(self.interpreter.environment.source_dir,
self.interpreter.environment.build_dir)
return conf
class HotdocTarget(CustomTarget):
def __init__(self, name: str, subdir: str, subproject: str, hotdoc_conf: File,
extra_extension_paths: T.Set[str], extra_assets: T.List[str],
subprojects: T.List['HotdocTarget'], environment: Environment, **kwargs: T.Any):
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) -> dict:
# 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: Interpreter):
super().__init__(interpreter)
self.hotdoc = HotdocExternalProgram('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.)')
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: ModuleState, args: T.Tuple[T.List[str]], kwargs: TYPE_kwargs) -> bool:
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,
CustomTarget, CustomTargetIndex)),
listify=True,
default=[],
),
KwargInfo(
'depends',
ContainerTypeInfo(list, (CustomTarget, 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: ModuleState, args: T.Tuple[str], kwargs: GenerateDocKwargs) -> ModuleReturnValue:
project_name = args[0]
if any(isinstance(x, (CustomTarget, 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: T.List[T.Union[HotdocTarget, mesonlib.ExecutableSerialisation]] = [target]
if install_script:
targets.append(install_script)
return ModuleReturnValue(target, targets)
def initialize(interpreter: Interpreter) -> HotDocModule:
mod = HotDocModule(interpreter)
mod.interpreter.append_holder_map(HotdocTarget, HotdocTargetHolder)
return mod