Files
scylladb/docs/_ext/scylladb_cc_properties.py
2025-11-18 15:28:55 +01:00

298 lines
10 KiB
Python

import re
from typing import Any, Dict, List
from sphinx import addnodes
from sphinx.application import Sphinx
from sphinx.directives import ObjectDescription
from sphinx.util import logging, ws_re
from sphinx.util.docfields import Field
from sphinx.util.docutils import switch_source_input, SphinxDirective
from sphinx.util.nodes import make_id, nested_parse_with_titles
from docutils import nodes
from docutils.parsers.rst import directives
from docutils.statemachine import StringList
from utils import maybe_add_filters
logger = logging.getLogger(__name__)
class DBConfigParser:
"""
Regex pattern for parsing group comments.
"""
GROUP_REGEX_PATTERN = (
r'/\*\*\s*'
r'\*+\s*@Group\s+(.*?)\s*'
r'(?:\*+\s*@GroupDescription\s+(.*?)\s*)?'
r'\*/'
)
"""
Regex pattern for parsing the configuration properties.
"""
CONFIG_CC_REGEX_PATTERN = (
r',\s*(\w+)\(this,' # 0. Property name
r'\s*"([^"]*)",\s*' # 1. Property key
r'(?:\s*"([^"]+)",)?' # 2. Property alias (optional)
r'\s*(?:liveness::(\w+),\s*)?' # 3. Liveness (optional)
r'(?:value_status::)?(\w+),\s*' # 4. Value status
r'(\{[^{}]*\}|"[^"]*"|[^,]+)?,' # 5. Default value
r'\s*"(.*?)"' # 6. Description text, divided in multiple lines.
r'(?:,\s*(.+?))?' # 7. Available values (optional)
r'\s*\)'
)
"""
Regex pattern for parsing named values in the configuration header file:
"""
CONFIG_H_REGEX_PATTERN = r"\s*(?:named_value|log_legacy_value)<([\w:<>,\s]+)> (\w+);"
"""
Regex pattern for parsing comments.
"""
COMMENT_PATTERN = r"/\*.*?\*/|//.*?$"
all_properties = {}
def __init__(self, config_file_path, config_header_file_path):
self.config_file_path = config_file_path
self.config_header_file_path = config_header_file_path
def _clean_comments(self, content):
return re.sub(self.COMMENT_PATTERN, "", content, flags=re.DOTALL | re.MULTILINE)
def _parse_group(self, group_match, config_group_content):
group_name = group_match.group(1).strip()
group_description = group_match.group(2).strip() if group_match.group(2) else ""
current_group = {
"name": group_name,
"description": group_description,
"properties": [],
"value_status_count": {
'Used': 0,
'Unused': 0,
'Invalid': 0,
'Deprecated': 0,
},
}
cleaned_properties_content = self._clean_comments(config_group_content)
properties = self._parse_properties(cleaned_properties_content)
current_group['properties'] = properties
for prop in properties:
value_status = prop['value_status']
if value_status in current_group['value_status_count']:
current_group['value_status_count'][value_status] += 1
return current_group
def _parse_properties(self, content):
properties = []
config_matches = re.findall(self.CONFIG_CC_REGEX_PATTERN, content, re.DOTALL)
for match in config_matches:
name = match[1].strip()
liveness_value = match[3].strip() if match[3] else ""
property_data = {
"name": name,
"value_status": match[4].strip(),
"default": match[5].strip(),
"liveness": "True" if liveness_value == "LiveUpdate" else "False",
"description": match[6].strip(),
}
properties.append(property_data)
DBConfigParser.all_properties[name] = property_data
return properties
def _add_type_information(self, groups):
with open(self.config_header_file_path, "r") as file:
config_header_content = file.read()
config_header_matches = re.findall(self.CONFIG_H_REGEX_PATTERN, config_header_content)
for match in config_header_matches:
property_key = match[1].strip()
for group in groups:
for property_data in group["properties"]:
if property_data["name"] == property_key:
property_data["type"] = match[0].strip()
def parse(self):
groups = []
with open(self.config_file_path, "r", encoding='utf-8') as file:
config_content = file.read()
group_matches = list(re.finditer(self.GROUP_REGEX_PATTERN, config_content, re.DOTALL))
group_matches.append(None) # Add a sentinel to process the last group
for i in range(len(group_matches) - 1):
group_match = group_matches[i]
next_group_match = group_matches[i + 1]
end_pos = next_group_match.start() if next_group_match else len(config_content)
config_group_content = config_content[group_match.end():end_pos]
current_group = self._parse_group(group_match, config_group_content)
groups.append(current_group)
self._add_type_information(groups)
return groups
@classmethod
def get(cls, name: str):
return DBConfigParser.all_properties[name]
class ConfigOption(ObjectDescription):
has_content = True
required_arguments = 1
optional_arguments = 0
final_argument_whitespace = False
# TODO: instead of overriding transform_content(), render option properties
# as a field list.
doc_field_types = [
Field('type',
label='Type',
has_arg=False,
names=('type',)),
Field('default',
label='Default value',
has_arg=False,
names=('default',)),
Field('liveness',
label='Liveness',
has_arg=False,
names=('liveness',)),
]
def handle_signature(self,
sig: str,
signode: addnodes.desc_signature) -> str:
signode.clear()
signode += addnodes.desc_name(sig, sig)
# normalize whitespace like XRefRole does
return ws_re.sub(' ', sig)
@property
def env(self):
document = self.state.document
return document.settings.env
def before_content(self) -> None:
maybe_add_filters(self.env.app.builder)
def _render(self, name) -> str:
item = DBConfigParser.get(name)
if item is None:
raise self.error(f'Option "{name}" not found!')
builder = self.env.app.builder
template = self.config.scylladb_cc_properties_option_tmpl
return builder.templates.render(template, item)
def transform_content(self,
contentnode: addnodes.desc_content) -> None:
name = self.arguments[0]
# the source is always None here
_, lineno = self.get_source_info()
source = f'scylla_config:{lineno}:<{name}>'
fields = StringList(self._render(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)
class ConfigOptionList(SphinxDirective):
has_content = False
required_arguments = 2
optional_arguments = 0
final_argument_whitespace = True
option_spec = {
'template': directives.path,
'value_status': directives.unchanged_required,
}
@property
def env(self):
document = self.state.document
return document.settings.env
def _resolve_src_path(self, path: str) -> str:
rel_filename, filename = self.env.relfn2path(path)
self.env.note_dependency(filename)
return filename
def _render(self, context: Dict[str, Any]) -> str:
builder = self.env.app.builder
template = self.options.get('template')
if template is None:
self.error(f'Option "template" not specified!')
return builder.templates.render(template, context)
def _make_context(self) -> Dict[str, Any]:
header = self._resolve_src_path(self.arguments[0])
source = self._resolve_src_path(self.arguments[1])
db_parser = DBConfigParser(source, header)
value_status = self.options.get("value_status")
return dict(data=db_parser.parse(),
value_status=value_status)
def run(self) -> List[nodes.Node]:
maybe_add_filters(self.env.app.builder)
rendered = self._render(self._make_context())
contents = StringList(rendered.splitlines())
node = nodes.section()
node.document = self.state.document
nested_parse_with_titles(self.state, contents, node)
return node.children
def setup(app: Sphinx) -> Dict[str, Any]:
app.add_config_value(
'scylladb_cc_properties_option_tmpl',
default='db_option.tmpl',
rebuild='html',
types=[str])
app.add_object_type(
'confgroup',
'confgroup',
objname='configuration group',
indextemplate='pair: %s; configuration group',
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', 'confval', ConfigOption, override=True)
app.add_directive('scylladb_config_list', ConfigOptionList)
return {
"version": "0.1",
"parallel_read_safe": True,
"parallel_write_safe": True,
}