doc: build mon_command_api.rst using a homebrew extension

so we can build the command doc using ReadTheDoc infra, without adding
the generated rst file to the source repo.

before this change, all the commands are ordered alphabetically. after
this change, command docs are generated by two directives, and are
ordered separately. we could restructure the directives and merge them.

but let's leave it for a future change if this is important.

for more details on writing sphinx directives, see
https://www.sphinx-doc.org/en/master/extdev/markupapi.html and
https://docutils.sourceforge.io/docs/howto/rst-directives.html

Signed-off-by: Kefu Chai <kchai@redhat.com>
This commit is contained in:
Kefu Chai 2021-01-07 23:43:26 +08:00
parent b84ff2b744
commit 202b805aaf
9 changed files with 434 additions and 383 deletions

View File

@ -64,11 +64,6 @@ install -d -m0755 \
$TOPDIR/build-doc/output/html \
$TOPDIR/build-doc/output/man
# required by script/gen_static_command_descriptions.py, which imports ceph_argparse
export PYTHONPATH=$TOPDIR/src/pybind
$vdir/bin/python $TOPDIR/doc/scripts/gen_mon_command_api.py > $TOPDIR/doc/api/mon_command_api.rst
for opt in "$@"; do
case $opt in
html|man|livehtml)

View File

@ -1,3 +1 @@
pcpp
Jinja2
src/python-common

View File

@ -1,8 +1,10 @@
Sphinx == 3.2.1
git+https://github.com/ceph/sphinx-ditaa.git@py3#egg=sphinx-ditaa
breathe >= 4.20.0
Jinja2
pyyaml >= 5.1.2
Cython
pcpp
prettytable
sphinx-autodoc-typehints
sphinx-prompt

1
doc/.gitignore vendored
View File

@ -1,3 +1,2 @@
/overview.png
/object_store.png
/api/mon_command_api.rst

429
doc/_ext/ceph_commands.py Normal file
View File

@ -0,0 +1,429 @@
import io
import os
import sys
import contextlib
from docutils import nodes
from docutils.parsers.rst import directives
from docutils.parsers.rst import Directive
from jinja2 import Template
from pcpp.preprocessor import Preprocessor
from sphinx.util import logging
from sphinx.util.console import bold
logger = logging.getLogger(__name__)
class Flags:
NOFORWARD = (1 << 0)
OBSOLETE = (1 << 1)
DEPRECATED = (1 << 2)
MGR = (1 << 3)
POLL = (1 << 4)
HIDDEN = (1 << 5)
VALS = {
NOFORWARD: 'no_forward',
OBSOLETE: 'obsolete',
DEPRECATED: 'deprecated',
MGR: 'mgr',
POLL: 'poll',
HIDDEN: 'hidden',
}
def __init__(self, fs):
self.fs = fs
def __contains__(self, other):
return other in str(self)
def __str__(self):
keys = Flags.VALS.keys()
es = {Flags.VALS[k] for k in keys if self.fs & k == k}
return ', '.join(sorted(es))
def __bool__(self):
return bool(str(self))
class CmdParam(object):
t = {
'CephInt': 'int',
'CephString': 'str',
'CephChoices': 'str',
'CephPgid': 'str',
'CephOsdName': 'str',
'CephPoolname': 'str',
'CephObjectname': 'str',
'CephUUID': 'str',
'CephEntityAddr': 'str',
'CephIPAddr': 'str',
'CephName': 'str',
'CephBool': 'bool',
'CephFloat': 'float',
'CephFilepath': 'str',
}
bash_example = {
'CephInt': '1',
'CephString': 'string',
'CephChoices': 'choice',
'CephPgid': '0',
'CephOsdName': 'osd.0',
'CephPoolname': 'poolname',
'CephObjectname': 'objectname',
'CephUUID': 'uuid',
'CephEntityAddr': 'entityaddr',
'CephIPAddr': '0.0.0.0',
'CephName': 'name',
'CephBool': 'true',
'CephFloat': '0.0',
'CephFilepath': '/path/to/file',
}
def __init__(self, type, name, who=None, n=None, req=True, range=None, strings=None,
goodchars=None):
self.type = type
self.name = name
self.who = who
self.n = n == 'N'
self.req = req != 'false'
self.range = range.split('|') if range else []
self.strings = strings.split('|') if strings else []
self.goodchars = goodchars
assert who == None
def help(self):
advanced = []
if self.type != 'CephString':
advanced.append(self.type + ' ')
if self.range:
advanced.append('range= ``{}`` '.format('..'.join(self.range)))
if self.strings:
advanced.append('strings=({}) '.format(' '.join(self.strings)))
if self.goodchars:
advanced.append('goodchars= ``{}`` '.format(self.goodchars))
if self.n:
advanced.append('(can be repeated)')
advanced = advanced or ["(string)"]
return ' '.join(advanced)
def mk_example_value(self):
if self.type == 'CephChoices' and self.strings:
return self.strings[0]
if self.range:
return self.range[0]
return CmdParam.bash_example[self.type]
def mk_bash_example(self, simple):
val = self.mk_example_value()
if self.type == 'CephBool':
return '--' + self.name
if simple:
if self.type == "CephChoices" and self.strings:
return val
elif self.type == "CephString" and self.name != 'who':
return 'my_' + self.name
else:
return CmdParam.bash_example[self.type]
else:
return '--{}={}'.format(self.name, val)
class CmdCommand(object):
def __init__(self, sig, desc, module=None, perm=None, flags=0, poll=None):
self.sig = [s for s in sig if isinstance(s, str)]
self.params = sorted([CmdParam(**s) for s in sig if not isinstance(s, str)],
key=lambda p: p.req, reverse=True)
self.help = desc
self.module = module
self.perm = perm
self.flags = Flags(flags)
self.needs_overload = False
def prefix(self):
return ' '.join(self.sig)
def is_reasonably_simple(self):
if len(self.params) > 3:
return False
if any(p.n for p in self.params):
return False
return True
def mk_bash_example(self):
simple = self.is_reasonably_simple()
line = ' '.join(['ceph', self.prefix()] + [p.mk_bash_example(simple) for p in self.params])
return line
class Sig:
@staticmethod
def _param_to_sig(p):
try:
return {kv.split('=')[0]: kv.split('=')[1] for kv in p.split(',')}
except IndexError:
return p
@staticmethod
def from_cmd(cmd):
sig = cmd.split()
return [Sig._param_to_sig(s) or s for s in sig]
TEMPLATE = '''
.. This file is automatically generated. do not modify
{% for command in commands %}
{{ command.prefix() }}
{{ command.prefix() | length * '^' }}
{{ command.help | wordwrap(70)}}
Example command:
.. code-block:: bash
{{ command.mk_bash_example() }}
{% if command.params %}
Parameters:
{% for param in command.params %}* **{{param.name}}**: {{ param.help() | wordwrap(70) | indent(2) }}
{% endfor %}{% endif %}
Ceph Module:
* *{{ command.module }}*
Required Permissions:
* *{{ command.perm }}*
{% if command.flags %}Command Flags:
* *{{ command.flags }}*
{% endif %}
{% endfor %}
'''
class CephMgrCommands(Directive):
"""
extracts commands from specified mgr modules
"""
has_content = True
required_arguments = 1
optional_arguments = 0
final_argument_whitespace = False
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 += ['ceph_argparse',
'OpenSSL',
'jwt',
'bcrypt',
'scipy',
'jsonpatch',
'rook.rook_client',
'rook.rook_client.ceph',
'cherrypy=3.2.3']
# 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_module_commands(self, name):
with self.mocked_modules():
logger.info(bold(f"loading mgr module '{name}'..."))
mgr_mod = __import__(name, globals(), locals(), [], 0)
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.COMMANDS, list)
return m.COMMANDS
def _normalize_command(self, command):
if 'handler' in command:
del command['handler']
command['sig'] = Sig.from_cmd(command['cmd'])
del command['cmd']
command['flags'] = (1 << 3)
command['module'] = 'mgr'
return command
def _render_cmds(self, commands):
rendered = Template(TEMPLATE).render(commands=list(commands))
lines = rendered.split("\n")
assert lines
source = self.state_machine.input_lines.source(self.lineno -
self.state_machine.input_offset - 1)
self.state_machine.insert_input(lines, source)
def run(self):
module_path = self._normalize_path(self.arguments[0])
sys.path.insert(0, module_path)
os.environ['UNITTEST'] = 'true'
modules = [name for name in os.listdir(module_path)
if self._is_mgr_module(module_path, name)]
commands = sum([self._collect_module_commands(name) for name in modules], [])
cmds = [CmdCommand(**self._normalize_command(c)) for c in commands]
cmds = [cmd for cmd in cmds if 'hidden' not in cmd.flags]
cmds = sorted(cmds, key=lambda cmd: cmd.sig)
self._render_cmds(cmds)
return []
class MyProcessor(Preprocessor):
def __init__(self):
super().__init__()
self.cmds = []
self.undef('__DATE__')
self.undef('__TIME__')
self.expand_linemacro = False
self.expand_filemacro = False
self.expand_countermacro = False
self.line_directive = '#line'
self.define("__PCPP_VERSION__ " + '')
self.define("__PCPP_ALWAYS_FALSE__ 0")
self.define("__PCPP_ALWAYS_TRUE__ 1")
def eval(self, src):
_cmds = []
NONE = 0
NOFORWARD = (1 << 0)
OBSOLETE = (1 << 1)
DEPRECATED = (1 << 2)
MGR = (1 << 3)
POLL = (1 << 4)
HIDDEN = (1 << 5)
TELL = (1 << 6)
def FLAG(a):
return a
def COMMAND(cmd, desc, module, perm):
_cmds.append({
'cmd': cmd,
'desc': desc,
'module': module,
'perm': perm
})
def COMMAND_WITH_FLAG(cmd, desc, module, perm, flag):
_cmds.append({
'cmd': cmd,
'desc': desc,
'module': module,
'perm': perm,
'flags': flag
})
self.parse(src)
out = io.StringIO()
self.write(out)
out.seek(0)
s = out.read()
exec(s, globals(), locals())
return _cmds
class CephMonCommands(Directive):
"""
extracts commands from specified header file
"""
has_content = True
required_arguments = 1
optional_arguments = 0
final_argument_whitespace = True
def _src_dir(self):
my_dir = os.path.dirname(os.path.realpath(__file__))
return os.path.abspath(os.path.join(my_dir, '../..'))
def _parse_headers(self, headers):
src_dir = self._src_dir()
src = '\n'.join(f'#include "{src_dir}/{header}"' for header in headers)
return MyProcessor().eval(src)
def _normalize_command(self, command):
if 'handler' in command:
del command['handler']
command['sig'] = Sig.from_cmd(command['cmd'])
del command['cmd']
return command
def _render_cmds(self, commands):
rendered = Template(TEMPLATE).render(commands=list(commands))
lines = rendered.split("\n")
assert lines
source = self.state_machine.input_lines.source(self.lineno -
self.state_machine.input_offset - 1)
self.state_machine.insert_input(lines, source)
def run(self):
headers = self.arguments[0].split()
commands = self._parse_headers(headers)
cmds = [CmdCommand(**self._normalize_command(c)) for c in commands]
cmds = [cmd for cmd in cmds if 'hidden' not in cmd.flags]
cmds = sorted(cmds, key=lambda cmd: cmd.sig)
self._render_cmds(cmds)
return []
def setup(app):
app.add_directive("ceph-mgr-commands", CephMgrCommands)
app.add_directive("ceph-mon-commands", CephMonCommands)
return {
'version': '0.1',
'parallel_read_safe': True,
'parallel_write_safe': True,
}

View File

@ -0,0 +1,2 @@
.. ceph-mgr-commands:: src/pybind/mgr
.. ceph-mon-commands:: src/mon/MonCommands.h src/mgr/MgrCommands.h

View File

@ -102,6 +102,7 @@ extensions = [
'sphinx_autodoc_typehints',
'sphinx_substitution_extensions',
'breathe',
'ceph_commands',
'ceph_releases',
'sphinxcontrib.openapi'
]

View File

@ -1,206 +0,0 @@
import os
import json
import sys
from subprocess import check_output
from jinja2 import Template
class Flags:
NOFORWARD = (1 << 0)
OBSOLETE = (1 << 1)
DEPRECATED = (1 << 2)
MGR = (1 << 3)
POLL = (1 << 4)
HIDDEN = (1 << 5)
VALS = {
NOFORWARD: 'no_forward',
OBSOLETE: 'obsolete',
DEPRECATED: 'deprecated',
MGR: 'mgr',
POLL: 'poll',
HIDDEN: 'hidden',
}
def __init__(self, fs):
self.fs = fs
def __contains__(self, other):
return other in str(self)
def __str__(self):
keys = Flags.VALS.keys()
es = {Flags.VALS[k] for k in keys if self.fs & k == k}
return ', '.join(sorted(es))
def __bool__(self):
return bool(str(self))
class CmdParam(object):
t = {
'CephInt': 'int',
'CephString': 'str',
'CephChoices': 'str',
'CephPgid': 'str',
'CephOsdName': 'str',
'CephPoolname': 'str',
'CephObjectname': 'str',
'CephUUID': 'str',
'CephEntityAddr': 'str',
'CephIPAddr': 'str',
'CephName': 'str',
'CephBool': 'bool',
'CephFloat': 'float',
'CephFilepath': 'str',
}
bash_example = {
'CephInt': '1',
'CephString': 'string',
'CephChoices': 'choice',
'CephPgid': '0',
'CephOsdName': 'osd.0',
'CephPoolname': 'poolname',
'CephObjectname': 'objectname',
'CephUUID': 'uuid',
'CephEntityAddr': 'entityaddr',
'CephIPAddr': '0.0.0.0',
'CephName': 'name',
'CephBool': 'true',
'CephFloat': '0.0',
'CephFilepath': '/path/to/file',
}
def __init__(self, type, name, who=None, n=None, req=True, range=None, strings=None,
goodchars=None):
self.type = type
self.name = name
self.who = who
self.n = n == 'N'
self.req = req != 'false'
self.range = range.split('|') if range else []
self.strings = strings.split('|') if strings else []
self.goodchars = goodchars
assert who == None
def help(self):
advanced = []
if self.type != 'CephString':
advanced.append(self.type + ' ')
if self.range:
advanced.append('range= ``{}`` '.format('..'.join(self.range)))
if self.strings:
advanced.append('strings=({}) '.format(' '.join(self.strings)))
if self.goodchars:
advanced.append('goodchars= ``{}`` '.format(self.goodchars))
if self.n:
advanced.append('(can be repeated)')
advanced = advanced or ["(string)"]
return ' '.join(advanced)
def mk_example_value(self):
if self.type == 'CephChoices' and self.strings:
return self.strings[0]
if self.range:
return self.range[0]
return CmdParam.bash_example[self.type]
def mk_bash_example(self, simple):
val = self.mk_example_value()
if self.type == 'CephBool':
return '--' + self.name
if simple:
if self.type == "CephChoices" and self.strings:
return val
elif self.type == "CephString" and self.name != 'who':
return 'my_' + self.name
else:
return CmdParam.bash_example[self.type]
else:
return '--{}={}'.format(self.name, val)
class CmdCommand(object):
def __init__(self, sig, desc, module=None, perm=None, flags=0, poll=None):
self.sig = [s for s in sig if isinstance(s, str)]
self.params = sorted([CmdParam(**s) for s in sig if not isinstance(s, str)],
key=lambda p: p.req, reverse=True)
self.help = desc
self.module = module
self.perm = perm
self.flags = Flags(flags)
self.needs_overload = False
def prefix(self):
return ' '.join(self.sig)
def is_reasonably_simple(self):
if len(self.params) > 3:
return False
if any(p.n for p in self.params):
return False
return True
def mk_bash_example(self):
simple = self.is_reasonably_simple()
line = ' '.join(['ceph', self.prefix()] + [p.mk_bash_example(simple) for p in self.params])
return line
tpl = '''
.. This file is automatically generated. do not modify
{% for command in commands %}
{{ command.prefix() }}
{{ command.prefix() | length * '^' }}
{{ command.help | wordwrap(70)}}
Example command:
.. code-block:: bash
{{ command.mk_bash_example() }}
{% if command.params %}
Parameters:
{% for param in command.params %}* **{{param.name}}**: {{ param.help() | wordwrap(70) | indent(2) }}
{% endfor %}{% endif %}
Ceph Module:
* *{{ command.module }}*
Required Permissions:
* *{{ command.perm }}*
{% if command.flags %}Command Flags:
* *{{ command.flags }}*
{% endif %}
{% endfor %}
'''
def mk_sigs(all):
sigs = [CmdCommand(**e) for e in all]
sigs = [s for s in sigs if 'hidden' not in s.flags]
sigs = sorted(sigs, key=lambda f: f.sig)
tm = Template(tpl)
msg = tm.render(commands=list(sigs))
print(msg)
if __name__ == '__main__':
script_dir = os.path.dirname(os.path.realpath(__file__))
commands = json.loads(check_output([sys.executable, script_dir + '/../../src/script/gen_static_command_descriptions.py']))
mk_sigs(commands)

View File

@ -1,169 +0,0 @@
"""
Prints a statically compiled list of all commands.
See
* /admin/doc-requirements.txt
* /doc/scripts/gen_mon_command_api.py
Rational for putting this file here is to allow others to make use of this output.
"""
import json
import os
import io
import sys
from pcpp.preprocessor import Preprocessor, OutputDirective, Action
os.environ['UNITTEST'] = 'true'
script_dir = os.path.dirname(os.path.realpath(__file__))
mgr_dir = os.path.abspath(script_dir + '/../../src/pybind/mgr')
sys.path.insert(0, mgr_dir)
from tests import mock, M
def param_to_sig(p):
try:
return {kv.split('=')[0]: kv.split('=')[1] for kv in p.split(',')}
except IndexError:
return p
def cmd_to_sig(cmd):
sig = cmd.split()
return [param_to_sig(s) or s for s in sig]
def list_mgr_module(m_name):
sys.modules['rados'] = mock.Mock()
sys.modules['rbd'] = mock.Mock()
sys.modules['cephfs'] = mock.Mock()
sys.modules['dateutil'] = mock.Mock()
sys.modules['dateutil.parser'] = mock.Mock()
# make dashboard happy
sys.modules['OpenSSL'] = mock.Mock()
sys.modules['jwt'] = mock.Mock()
sys.modules['bcrypt'] = mock.Mock()
sys.modules['scipy'] = mock.Mock()
sys.modules['jsonpatch'] = mock.Mock()
sys.modules['rook.rook_client'] = mock.Mock()
sys.modules['rook.rook_client.ceph'] = mock.Mock()
sys.modules['cherrypy'] = mock.Mock(__version__="3.2.3")
# make restful happy:
sys.modules['pecan'] = mock.Mock()
sys.modules['pecan.rest'] = mock.Mock()
sys.modules['pecan.hooks'] = mock.Mock()
sys.modules['werkzeug'] = mock.Mock()
sys.modules['werkzeug.serving'] = mock.Mock()
mgr_mod = __import__(m_name, globals(), locals(), [], 0)
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.COMMANDS, list)
return m.COMMANDS
def from_mgr_modules():
names = [name for name in os.listdir(mgr_dir)
if os.path.isdir(os.path.join(mgr_dir, name)) and
os.path.isfile(os.path.join(mgr_dir, name, '__init__.py')) and
name not in ['tests']]
comms = sum([list_mgr_module(name) for name in names], [])
for c in comms:
if 'handler' in c:
del c['handler']
c['sig'] = cmd_to_sig(c['cmd'])
del c['cmd']
c['flags'] = (1 << 3)
c['module'] = 'mgr'
return comms
def from_mon_commands_h():
input_str = """
#include "{script_dir}/../mon/MonCommands.h"
#include "{script_dir}/../mgr/MgrCommands.h"
""".format(script_dir=script_dir)
cmds = []
class MyProcessor(Preprocessor):
def __init__(self):
super(MyProcessor, self).__init__()
self.undef('__DATE__')
self.undef('__TIME__')
self.expand_linemacro = False
self.expand_filemacro = False
self.expand_countermacro = False
self.line_directive = '#line'
self.define("__PCPP_VERSION__ " + '')
self.define("__PCPP_ALWAYS_FALSE__ 0")
self.define("__PCPP_ALWAYS_TRUE__ 1")
self.parse(input_str)
out = io.StringIO()
self.write(out)
out.seek(0)
s = out.read()
NONE = 0
NOFORWARD = (1 << 0)
OBSOLETE = (1 << 1)
DEPRECATED = (1 << 2)
MGR = (1 << 3)
POLL = (1 << 4)
HIDDEN = (1 << 5)
TELL = (1 << 6)
def FLAG(a):
return a
def COMMAND(cmd, desc, module, perm):
cmds.append({
'cmd': cmd,
'desc': desc,
'module': module,
'perm': perm
})
def COMMAND_WITH_FLAG(cmd, desc, module, perm, flag):
cmds.append({
'cmd': cmd,
'desc': desc,
'module': module,
'perm': perm,
'flags': flag
})
exec(s, globals(), locals())
MyProcessor()
for c in cmds:
if 'handler' in c:
del c['handler']
c['sig'] = cmd_to_sig(c['cmd'])
del c['cmd']
return cmds
def gen_commands_dicts():
comms = from_mon_commands_h() + from_mgr_modules()
comms = sorted(comms, key=lambda c: [e for e in c['sig'] if isinstance(e, str)])
return comms
if __name__ == '__main__':
print(json.dumps(gen_commands_dicts(), indent=2, sort_keys=True))