ceph/doc/_ext/ceph_confval.py

460 lines
15 KiB
Python

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'<empty string>'
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/<name>.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}:<confval>'
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,
}