Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Code documentation support #471

Open
wants to merge 33 commits into
base: glad2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
768fda8
add docs flag
paoloose May 13, 2024
67a5683
add utils
paoloose May 14, 2024
283ce5b
update .gitignore
paoloose May 14, 2024
148070e
integrate code documentation
paoloose May 14, 2024
0034c22
add support for doc comments on template
paoloose May 14, 2024
4129015
add docsgl documentation
paoloose May 17, 2024
7fca80c
improve templating
paoloose May 17, 2024
c1f7e86
make text parsing utilities more versatile
paoloose May 18, 2024
a8fc033
support custom break logic for templates
paoloose May 18, 2024
ec699c1
implement DocsGL documentation
paoloose May 18, 2024
5977606
fix parsing for descriptions with multiple parameters
paoloose May 18, 2024
f2c4fbb
fully parse doc params description
paoloose May 18, 2024
0e267f7
fix crash when no --with-docs is present
paoloose May 18, 2024
947a8f0
fix pipe format for templates
paoloose May 18, 2024
48d8bab
rename ApiDocumentation to SpecificationDocs
paoloose May 18, 2024
3a15146
only load documentation if --with-docs is present
paoloose May 18, 2024
3cd03ec
fix <dt> listing format
paoloose May 19, 2024
d87dfc4
improve parsing and add basic support for equations
paoloose May 22, 2024
300b6fe
revert specification load change
paoloose May 25, 2024
5b5798c
remove specification-docs dependency
paoloose May 25, 2024
c71a789
refactor: pass docs to generator.generate instead of the specification
paoloose May 26, 2024
0c956e2
change docs out dir from .cached to .docs
paoloose May 26, 2024
29a8ee7
make --with-docs a global option since it's language agnostic
paoloose May 26, 2024
9aa3dbd
fix: log warning when no documentation is found for the spec
paoloose May 26, 2024
b728972
fix docs rendering
paoloose May 26, 2024
5b73a9f
prefer lxml instead of xml package
paoloose May 26, 2024
13e26fd
replace DocsGL with Khronos refpages
paoloose May 26, 2024
57c5ead
fix parameters parsing and math namespacing
paoloose Jun 2, 2024
cbdb5a6
rename --with-docs to --docs
paoloose Jun 2, 2024
70740de
don't xinclude by default when parsing xml from file
paoloose Jun 2, 2024
37409b4
drop dependency lxml
paoloose Jun 2, 2024
4a24403
implement requested changes
paoloose Jun 2, 2024
4ac233b
improve readability and add comments for reference
paoloose Jun 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ dist/
/rust/
target/
Cargo.lock
.vscode/
.vscode/
.docs/
.venv/
49 changes: 44 additions & 5 deletions glad/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from glad.sink import LoggingSink
from glad.opener import URLOpener
from glad.parse import FeatureSet
from glad.plugin import find_specifications, find_generators
from glad.plugin import find_specification_docs, find_specifications, find_generators
from glad.util import parse_apis


Expand Down Expand Up @@ -71,6 +71,11 @@ class GlobalConfig(Config):
description='Makes the build reproducible by not fetching the latest '
'specification from Khronos.'
)
DOCS = ConfigOption(
converter=bool,
default=False,
description='Include inline documentation in the generated files.'
)


def load_specifications(specification_names, opener, specification_classes=None):
Expand All @@ -95,6 +100,32 @@ def load_specifications(specification_names, opener, specification_classes=None)
return specifications


def load_documentations(specification_names, opener, spec_docs_classes=None):
specifications_docs = dict()

if spec_docs_classes is None:
spec_docs_classes = find_specification_docs()

for name in set(specification_names):
if name not in spec_docs_classes:
logger.warning('no documentation available for %r', name)
continue

SpecificationDocs = spec_docs_classes[name]
spec_docs_file = SpecificationDocs.default_out_dir()

if os.path.isfile(spec_docs_file):
logger.info("using local documentation: '%s'", spec_docs_file)
spec_docs = SpecificationDocs.from_file(spec_docs_file, opener=opener)
else:
logger.info('getting %r documentation from remote location', name)
spec_docs = SpecificationDocs.from_remote(opener=opener)

specifications_docs[name] = spec_docs

return specifications_docs


def apis_by_specification(api_info, specifications):
return groupby(api_info.items(),
key=lambda api_info: specifications[api_info[1].specification])
Expand Down Expand Up @@ -153,9 +184,13 @@ def main(args=None):
opener = URLOpener()
gen_info_factory = GenerationInfo.create

specifications = load_specifications(
[value[0] for value in global_config['API'].values()], opener=opener
)
spec_names = [value[0] for value in global_config['API'].values()]
specifications = load_specifications(spec_names, opener=opener)

if global_config['DOCS']:
documentations = load_documentations(spec_names, opener=opener)
else:
documentations = None

generator = generators[ns.subparser_name](
global_config['OUT_PATH'], opener=opener, gen_info_factory=gen_info_factory
Expand Down Expand Up @@ -189,8 +224,12 @@ def select(specification, api, info):
logging_sink.info('merged into {}'.format(feature_sets[0]))

for feature_set in feature_sets:
api = feature_set.info.apis[0]
spec_docs = None
if documentations and api in documentations:
spec_docs = documentations[api].select(feature_set)
logging_sink.info('generating feature set {}'.format(feature_set))
generator.generate(specification, feature_set, config, sink=logging_sink)
generator.generate(specification, feature_set, config, sink=logging_sink, spec_docs=spec_docs)


if __name__ == '__main__':
Expand Down
226 changes: 226 additions & 0 deletions glad/documentation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import re
import zipfile
import glad.util
from pathlib import Path
from glad.parse import DocumentationSet, SpecificationDocs, CommandDocs, xml_fromstring
from glad.util import resolve_entities, suffix, raw_text

class OpenGLRefpages(SpecificationDocs):
DOCS_NAME = 'opengl_refpages'

URL = 'https://github.com/KhronosGroup/OpenGL-Refpages/archive/refs/heads/main.zip'
SPEC = 'gl'

def select(self, feature_set):
current_major = max(info.version.major for info in feature_set.info)
commands = dict()

# At the time of writing Khronos hosts documentations for gl4 and gl2.1.
available_versions = ['gl2.1']
if current_major >= 4:
available_versions.insert(0, 'gl4')

zf = zipfile.ZipFile(self.docs_file, 'r')
xml_files_in_version = {
version: [Path(name) for name in zf.namelist() if name.endswith('.xml') and version in name]
for version in available_versions
}

for version_dir in available_versions:
for xml_file in xml_files_in_version[version_dir]:
with zf.open(str(xml_file)) as f:
xml_text = f.read().decode('utf-8')
parsed_docs = OpenGLRefpages.docs_from_xml(
xml_text,
version=version_dir,
filename=xml_file.stem,
)
for command, docs in parsed_docs.items():
commands.setdefault(command, docs)

zf.close()
return DocumentationSet(commands=commands)

@classmethod
def docs_from_xml(cls, xml_text, version=None, filename=None):
commands_parsed = dict()

# Some files don't have the proper namespace declaration for MathML.
xml_text = xml_text.replace('<mml:math>', '<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML">')
# Entities need to be resolved before parsing.
xml_text = resolve_entities(xml_text)

tree = xml_fromstring(xml_text.encode('utf-8'))

# gl4 files contain large namespace declarations that pollute the tags. So we remove them.
for elem in tree.iter():
try:
if elem.tag.startswith('{'):
elem.tag = elem.tag.split('}')[-1]
if elem.tag.contains(':'):
elem.tag = elem.tag.split(':')[-1]
for key in elem.attrib:
if key.startswith('{'):
elem.attrib[key.split('}')[-1]] = elem.attrib.pop(key)
except:
pass

# Each refsect1 block contains a section of the documentation.
# Sections groups Description, Parameters, Notes, etc.
sections = tree.findall('.//refsect1')

# Brief parsing
# Command brief description appears in the first 'refnamediv' block
brief_block = tree.find('.//refnamediv//refpurpose')

if brief_block is None:
# No brief means file doesn't contain any command definitions.
return dict()
brief = suffix(".", cls.xml_text(brief_block))

if version == 'gl2.1':
docs_url = f'https://registry.khronos.org/OpenGL-Refpages/{version}/xhtml/{filename}.xml'
else:
docs_url = f'https://registry.khronos.org/OpenGL-Refpages/{version}/html/{filename}.xhtml'

# Description parsing
description = []
description_blocks = next(
(s for s in sections if raw_text(s.find('title')) == 'Description'),
None,
)
if description_blocks is not None:
blocks = description_blocks.findall('./*')
description = list(
filter(
bool,
(re.sub(f'^{CommandDocs.BREAK}', '', cls.xml_text(p)) for p in blocks if p.tag != 'title'),
),
)

# Notes parsing
notes = []
notes_blocks = next((s for s in sections if raw_text(s.find('title')) == 'Notes'), None)

if notes_blocks is not None:
blocks = notes_blocks.findall('./*')
notes = list(
filter(
bool,
(cls.xml_text(p) for p in blocks if p.tag != 'title'),
),
)

# Parameters parsing
# Khronos specs puts all the function definitions inside funcsynopsis/funcdef blocks.
#
# However, instead of describing each function on a separate file, they group multiple
# related function definitions, whose parameters may be different, into a single file.
# This means that we have to find the correct block of parameters for each definition.
funcdefs = [
d for d in tree.findall('.//funcsynopsis/*')
if d.find('.//funcdef') is not None
]

for func_def in funcdefs:
func_name = func_def.find('.//function').text
func_params = [raw_text(s) for s in func_def.findall('.//parameter')]


# Params are defined in a separate section, called 'Parameters for <func_name>'
# or just 'Parameters'.
params_block = next(
(s for s in sections if raw_text(s.find('title')) == f'Parameters for {func_name}'),
None,
)
if params_block is None:
for p in (s for s in sections if raw_text(s.find('title')) == 'Parameters'):
block_params = [raw_text(n) for n in p.findall('.//term//parameter')]
# If all our func_params are contained in this block, we choose it.
if all(func_param in block_params for func_param in func_params):
params_block = p
break

# At this point we interpret params_block=None as a void parameter list.
is_void = params_block is None
params_entries = params_block.findall('.//varlistentry/*') if not is_void else []

params = []
# A description can apply for more than one param (term), so we stack them until
# we find a listitem, which is a description of a param.
terms_stack = []
for param_or_desc in params_entries:
if param_or_desc.tag == 'term':
terms_stack.append(param_or_desc)
continue
if param_or_desc.tag == 'listitem':
# Now that we have a listitem, it is considered that all the terms
# on the stack have the same parameter description.
param_desc = cls.xml_text(param_or_desc).replace(CommandDocs.BREAK, '')
for terms in terms_stack:
param_names = [
p.text for p in terms.findall('.//parameter') if p.text in func_params
]

for param_name in param_names:
params.append(CommandDocs.Param(param_name, param_desc))
terms_stack.clear()

commands_parsed[func_name] = CommandDocs(
func_name, brief, params, description, notes, None, None, docs_url,
)
return commands_parsed

@classmethod
def format(cls, e, parent=None, is_tail=False):
if is_tail:
if e.tag in ('term', 'mn', 'msub'):
return ''
return re.sub(r'\n+', '', e.tail)

if e.tag == 'constant':
return f'`{e.text}`'
if e.tag == 'function':
return f'`{e.text}`'
if e.tag == 'emphasis':
return f'*{e.text}*'
if e.tag == 'term':
return f'\n{CommandDocs.BREAK}- {e.text.strip()}'
if e.tag == 'listitem':
if parent is None:
return f'\n{CommandDocs.BREAK}{e.text.strip()}'
if parent is not None and parent.tag == 'varlistentry':
return f'\n{CommandDocs.BREAK}{e.text.strip()}'
return f'\n{CommandDocs.BREAK}- {e.text.strip()}'
return re.sub(r'\n+', '', e.text)

@classmethod
def xml_text(cls, e):
def paren(expr):
if re.match(r'^[a-zA-Z0-9_]+$', expr):
return expr
return f'({expr})'

def mfenced(e):
if e.attrib['close']:
return f'{e.attrib["open"]}{", ".join(cls.xml_text(c) for c in e)}{e.attrib["close"]}'
return f'{e.attrib["open"]}{" ".join(cls.xml_text(c) for c in e)}'

text = ''.join(glad.util.itertext(
e,
convert={
'table': lambda _: '(table omitted)',
'informaltable': lambda _: '(table omitted)',
'include': lambda _: '(table omitted)',
'programlisting': lambda _: '(code omitted)',
'mfrac': lambda e, : f'{paren(cls.xml_text(e[0]))}/{paren(cls.xml_text(e[1]))}',
'msup': lambda e: f'{paren(cls.xml_text(e[0]))}^{paren(cls.xml_text(e[1]))}',
'msub': lambda e: f'{paren(cls.xml_text(e[0]))}_{paren(cls.xml_text(e[1]))}',
'mtd': lambda e: f'{cls.xml_text(e[0])}; ',
'mfenced': mfenced,
},
format=cls.format,
))
# \u2062, \u2062,
# Invisible characters used by docs.gl to separate words.
return re.sub(r'\n?[ \u2062\u2061\t]+', ' ', text.strip())
11 changes: 6 additions & 5 deletions glad/generator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,16 @@ def modify_feature_set(self, spec, feature_set, config):
"""
return feature_set

def get_template_arguments(self, spec, feature_set, config):
def get_template_arguments(self, spec, feature_set, config, spec_docs=None):
return dict(
spec=spec,
feature_set=feature_set,
spec_docs=spec_docs,
options=config.to_dict(transform=lambda x: x.lower()),
gen_info=self.gen_info_factory(self, spec, feature_set, config)
)

def generate(self, spec, feature_set, config, sink=LoggingSink(__name__)):
def generate(self, spec, feature_set, config, spec_docs=None, sink=LoggingSink(__name__)):
feature_set = self.modify_feature_set(spec, feature_set, config)
for template, output_path in self.get_templates(spec, feature_set, config):
#try:
Expand All @@ -156,17 +157,17 @@ def generate(self, spec, feature_set, config, sink=LoggingSink(__name__)):
# raise ValueError('Unsupported specification/configuration')

result = template.render(
**self.get_template_arguments(spec, feature_set, config)
**self.get_template_arguments(spec, feature_set, config, spec_docs=spec_docs)
)

output_path = os.path.join(self.path, output_path)
makefiledir(output_path)
with open(output_path, 'w') as f:
f.write(result)

self.post_generate(spec, feature_set, config)
self.post_generate(spec, feature_set, config, spec_docs=None)

def post_generate(self, spec, feature_set, config):
def post_generate(self, spec, feature_set, config, spec_docs=None):
pass


Expand Down
6 changes: 3 additions & 3 deletions glad/generator/c/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,8 +378,8 @@ def select(self, spec, api, version, profile, extensions, config, sink=LoggingSi

return JinjaGenerator.select(self, spec, api, version, profile, extensions, config, sink=sink)

def get_template_arguments(self, spec, feature_set, config):
args = JinjaGenerator.get_template_arguments(self, spec, feature_set, config)
def get_template_arguments(self, spec, feature_set, config, spec_docs=None):
args = JinjaGenerator.get_template_arguments(self, spec, feature_set, config, spec_docs=spec_docs)

# TODO allow MX for every specification/api
if spec.name not in (VK.NAME, GL.NAME):
Expand Down Expand Up @@ -412,7 +412,7 @@ def get_templates(self, spec, feature_set, config):

return templates

def post_generate(self, spec, feature_set, config):
def post_generate(self, spec, feature_set, config, spec_docs=None):
self._add_additional_headers(feature_set, config)

def modify_feature_set(self, spec, feature_set, config):
Expand Down
Loading