import io import contextlib import os import sys from typing import Any, Dict, List, Union from docutils.nodes import Node from docutils.parsers.rst import directives from docutils.statemachine import StringList from sphinx import addnodes from sphinx.directives import ObjectDescription from sphinx.domains.python import PyField from sphinx.environment import BuildEnvironment from sphinx.locale import _ from sphinx.util import logging, status_iterator, ws_re from sphinx.util.docutils import switch_source_input, SphinxDirective from sphinx.util.docfields import Field from sphinx.util.nodes import make_id import jinja2 import jinja2.filters import yaml logger = logging.getLogger(__name__) TEMPLATE = ''' {% if desc %} {{ desc | wordwrap(70) | indent(3) }} {% endif %} :type: ``{{opt.type}}`` {%- if default is not none %} {%- if opt.type == 'size' %} :default: ``{{ default | eval_size | iec_size }}`` {%- elif opt.type == 'secs' %} :default: ``{{ default | readable_duration(opt.type) }}`` {%- elif opt.type in ('uint', 'int', 'float') %} :default: ``{{ default | readable_num(opt.type) }}`` {%- elif opt.type == 'millisecs' %} :default: ``{{ default }}`` milliseconds {%- elif opt.type == 'bool' %} :default: ``{{ default | string | lower }}`` {%- else %} :default: {{ default | literal }} {%- endif -%} {%- endif %} {%- if opt.enum_values %} :valid choices:{% for enum_value in opt.enum_values -%} {{" -" | indent(18, not loop.first) }} {{ enum_value | literal }} {% endfor %} {%- endif %} {%- if opt.min is defined and opt.max is defined %} :allowed range: ``[{{ opt.min }}, {{ opt.max }}]`` {%- elif opt.min is defined %} :min: ``{{ opt.min }}`` {%- elif opt.max is defined %} :max: ``{{ opt.max }}`` {%- endif %} {%- if opt.constraint %} :constraint: {{ opt.constraint }} {% endif %} {%- if opt.policies %} :policies: {{ opt.policies }} {% endif %} {%- if opt.example %} :example: {{ opt.example }} {%- endif %} {%- if opt.see_also %} :see also: {{ opt.see_also | map('ref_confval') | join(', ') }} {%- endif %} {% if opt.note %} .. note:: {{ opt.note }} {%- endif -%} {%- if opt.warning %} .. warning:: {{ opt.warning }} {%- endif %} ''' def eval_size(value) -> int: try: return int(value) except ValueError: times = dict(_K=1 << 10, _M=1 << 20, _G=1 << 30, _T=1 << 40) for unit, m in times.items(): if value.endswith(unit): return int(value[:-len(unit)]) * m raise ValueError(f'unknown value: {value}') def readable_duration(value: str, typ: str) -> str: try: if typ == 'sec': v = int(value) postfix = 'second' if v == 1 else 'seconds' return f'{v} {postfix}' elif typ == 'float': return str(float(value)) else: return str(int(value)) except ValueError: times = dict(_min=['minute', 'minutes'], _hr=['hour', 'hours'], _day=['day', 'days']) for unit, readables in times.items(): if value.endswith(unit): v = int(value[:-len(unit)]) postfix = readables[0 if v == 1 else 1] return f'{v} {postfix}' raise ValueError(f'unknown value: {value}') def do_plain_num(value: str, typ: str) -> str: if typ == 'float': return str(float(value)) else: return str(int(value)) def iec_size(value: int) -> str: if value == 0: return '0B' units = dict(Ei=60, Pi=50, Ti=40, Gi=30, Mi=20, Ki=10, B=0) for unit, bits in units.items(): m = 1 << bits if value % m == 0: value //= m return f'{value}{unit}' raise Exception(f'iec_size() failed to convert {value}') def do_fileize_num(value: str, typ: str) -> str: v = eval_size(value) return iec_size(v) def readable_num(value: str, typ: str) -> str: e = ValueError() for eval_func in [do_plain_num, readable_duration, do_fileize_num]: try: return eval_func(value, typ) except ValueError as ex: e = ex raise e def literal(name) -> str: if name: return f'``{name}``' else: return f'' def ref_confval(name) -> str: return f':confval:`{name}`' def jinja_template() -> jinja2.Template: env = jinja2.Environment() env.filters['eval_size'] = eval_size env.filters['iec_size'] = iec_size env.filters['readable_duration'] = readable_duration env.filters['readable_num'] = readable_num env.filters['literal'] = literal env.filters['ref_confval'] = ref_confval return env.from_string(TEMPLATE) FieldValueT = Union[bool, float, int, str] class CephModule(SphinxDirective): """ Directive to name the mgr module for which options are documented. """ has_content = False required_arguments = 1 optional_arguments = 0 final_argument_whitespace = False def run(self) -> List[Node]: module = self.arguments[0].strip() if module == 'None': self.env.ref_context.pop('ceph:module', None) else: self.env.ref_context['ceph:module'] = module return [] class CephOption(ObjectDescription): """ emit option loaded from given command/options/.yaml.in file """ has_content = True required_arguments = 1 optional_arguments = 0 final_argument_whitespace = False option_spec = { 'module': directives.unchanged, 'default': directives.unchanged } doc_field_types = [ Field('default', label=_('Default'), has_arg=False, names=('default',)), Field('type', label=_('Type'), has_arg=False, names=('type',), bodyrolename='class'), ] template = jinja_template() opts: Dict[str, Dict[str, FieldValueT]] = {} mgr_opts: Dict[str, # module name Dict[str, # option name Dict[str, # field_name FieldValueT]]] = {} def _load_yaml(self) -> Dict[str, Dict[str, FieldValueT]]: if CephOption.opts: return CephOption.opts opts = [] for fn in status_iterator(self.config.ceph_confval_imports, 'loading options...', 'red', len(self.config.ceph_confval_imports), self.env.app.verbosity): self.env.note_dependency(fn) try: with open(fn, 'r') as f: yaml_in = io.StringIO() for line in f: if '@' not in line: yaml_in.write(line) yaml_in.seek(0) opts += yaml.safe_load(yaml_in)['options'] except OSError as e: message = f'Unable to open option file "{fn}": {e}' raise self.error(message) CephOption.opts = dict((opt['name'], opt) for opt in opts) return CephOption.opts def _normalize_path(self, dirname): my_dir = os.path.dirname(os.path.realpath(__file__)) src_dir = os.path.abspath(os.path.join(my_dir, '../..')) return os.path.join(src_dir, dirname) def _is_mgr_module(self, dirname, name): if not os.path.isdir(os.path.join(dirname, name)): return False if not os.path.isfile(os.path.join(dirname, name, '__init__.py')): return False return name not in ['tests'] @contextlib.contextmanager def mocked_modules(self): # src/pybind/mgr/tests from tests import mock mock_imports = ['rados', 'rbd', 'cephfs', 'dateutil', 'dateutil.parser'] # make dashboard happy mock_imports += ['OpenSSL', 'jwt', 'bcrypt', 'jsonpatch', 'rook.rook_client', 'rook.rook_client.ceph', 'rook.rook_client._helper', 'cherrypy=3.2.3'] # make diskprediction_local happy mock_imports += ['numpy', 'scipy'] # make restful happy mock_imports += ['pecan', 'pecan.rest', 'pecan.hooks', 'werkzeug', 'werkzeug.serving'] for m in mock_imports: args = {} parts = m.split('=', 1) mocked = parts[0] if len(parts) > 1: args['__version__'] = parts[1] sys.modules[mocked] = mock.Mock(**args) try: yield finally: for m in mock_imports: mocked = m.split('=', 1)[0] sys.modules.pop(mocked) def _collect_options_from_module(self, name): with self.mocked_modules(): mgr_mod = __import__(name, globals(), locals(), [], 0) # import 'M' from src/pybind/mgr/tests from tests import M def subclass(x): try: return issubclass(x, M) except TypeError: return False ms = [c for c in mgr_mod.__dict__.values() if subclass(c) and 'Standby' not in c.__name__] [m] = ms assert isinstance(m.MODULE_OPTIONS, list) return m.MODULE_OPTIONS def _load_module(self, module) -> Dict[str, Dict[str, FieldValueT]]: mgr_opts = CephOption.mgr_opts.get(module) if mgr_opts is not None: return mgr_opts python_path = self.config.ceph_confval_mgr_python_path for path in python_path.split(':'): sys.path.insert(0, self._normalize_path(path)) module_path = self.env.config.ceph_confval_mgr_module_path module_path = self._normalize_path(module_path) sys.path.insert(0, module_path) if not self._is_mgr_module(module_path, module): raise self.error(f'module "{module}" not found under {module_path}') fn = os.path.join(module_path, module, 'module.py') if os.path.exists(fn): self.env.note_dependency(fn) os.environ['UNITTEST'] = 'true' opts = self._collect_options_from_module(module) CephOption.mgr_opts[module] = dict((opt['name'], opt) for opt in opts) return CephOption.mgr_opts[module] def _current_module(self) -> str: return self.options.get('module', self.env.ref_context.get('ceph:module')) def _render_option(self, name) -> str: cur_module = self._current_module() if cur_module: try: opt = self._load_module(cur_module).get(name) except Exception as e: message = f'Unable to load module "{cur_module}": {e}' raise self.error(message) else: opt = self._load_yaml().get(name) if opt is None: raise self.error(f'Option "{name}" not found!') if cur_module and 'type' not in opt: # the type of module option defaults to 'str' opt['type'] = 'str' desc = opt.get('fmt_desc') or opt.get('long_desc') or opt.get('desc') opt_default = opt.get('default') default = self.options.get('default', opt_default) try: return self.template.render(opt=opt, desc=desc, default=default) except Exception as e: message = (f'Unable to render option "{name}": {e}. ', f'opt={opt}, desc={desc}, default={default}') raise self.error(message) def handle_signature(self, sig: str, signode: addnodes.desc_signature) -> str: signode.clear() signode += addnodes.desc_name(sig, sig) # normalize whitespace like XRefRole does name = ws_re.sub(' ', sig) cur_module = self._current_module() if cur_module: return '/'.join(['mgr', cur_module, name]) else: return name def transform_content(self, contentnode: addnodes.desc_content) -> None: name = self.arguments[0] source, lineno = self.get_source_info() source = f'{source}:{lineno}:' fields = StringList(self._render_option(name).splitlines() + [''], source=source, parent_offset=lineno) with switch_source_input(self.state, fields): self.state.nested_parse(fields, 0, contentnode) def add_target_and_index(self, name: str, sig: str, signode: addnodes.desc_signature) -> None: node_id = make_id(self.env, self.state.document, self.objtype, name) signode['ids'].append(node_id) self.state.document.note_explicit_target(signode) entry = f'{name}; configuration option' self.indexnode['entries'].append(('pair', entry, node_id, '', None)) std = self.env.get_domain('std') std.note_object(self.objtype, name, node_id, location=signode) def _reset_ref_context(app, env, docname): env.ref_context.pop('ceph:module', None) def setup(app) -> Dict[str, Any]: app.add_config_value('ceph_confval_imports', default=[], rebuild='html', types=[str]) app.add_config_value('ceph_confval_mgr_module_path', default=[], rebuild='html', types=[str]) app.add_config_value('ceph_confval_mgr_python_path', default=[], rebuild='', types=[str]) app.add_object_type( 'confsec', 'confsec', objname='configuration section', indextemplate='pair: %s; configuration section', doc_field_types=[ Field( 'example', label=_('Example'), has_arg=False, )] ) app.add_object_type( 'confval', 'confval', objname='configuration option', ) app.add_directive_to_domain('std', 'mgr_module', CephModule) app.add_directive_to_domain('std', 'confval', CephOption, override=True) app.connect('env-purge-doc', _reset_ref_context) return { 'version': 'builtin', 'parallel_read_safe': True, 'parallel_write_safe': True, }