Source code for abjad.ext.sphinx

import copy
import enum
import hashlib
import inspect
import os
import pathlib
import shutil
import subprocess
import typing

import docutils
import sphinx
import uqbar
import uqbar.apis
from uqbar.book.extensions import Extension

from .. import configuration as _configuration
from .. import contextmanagers as _contextmanagers
from .. import format as _format
from .. import illustrators as _illustrators
from .. import io as _io
from .. import lilypondfile as _lilypondfile
from .. import tag as _tag

configuration = _configuration.Configuration()
logger = sphinx.util.logging.getLogger(__name__)


[docs] class AbjadClassDocumenter(uqbar.apis.documenters.ClassDocumenter): """ Abjad class documenter. """ def __str__(self) -> str: assert isinstance(self.client, type) if issubclass(self.client, Exception): return f".. autoexception:: {self.package_path}" result = [f".. autoclass:: {self.package_path}"] attributes = [] for attribute in inspect.classify_class_attrs(self.client): if attribute.defining_class is not self.client: continue if attribute.name.startswith("_"): continue if attribute.kind in ( "class_method", "method", "property", "static_method", ): attributes.append(attribute) if issubclass(self.client, enum.Enum): result.append(" :members:") result.append(" :undoc-members:") else: strings = self._build_member_autosummary(attributes) result.extend(strings) strings = self._format_all_attributes(attributes) result.extend(strings) return "\n".join(result) def _build_member_autosummary(self, attributes) -> list[str]: all_attributes: list = [] for attribute in attributes: if attribute.name.startswith("__"): continue if attribute.defining_class is not self.client: continue all_attributes.append(attribute) all_attributes.sort(key=lambda x: x.name) result: list[str] = [] if not all_attributes: return result result.append("") result.append(" .. autosummary::") result.append("") for attribute in all_attributes: result.append(f" {attribute.name}") result.append("") return result def _format_all_attributes(self, attributes): attributes = list(attributes) attributes.sort(key=lambda _: _.name) result = [] for attribute in attributes: if attribute.defining_class is not self.client: continue if attribute.name.startswith("_"): continue if attribute.kind in ("class_method", "method", "static_method"): string = f" .. automethod:: {self.package_path}.{attribute.name}" elif attribute.kind == "property": string = f" .. autoattribute:: {self.package_path}.{attribute.name}" else: continue result.append(string) result.append("") return result
[docs] class AbjadFunctionDocumenter(uqbar.apis.documenters.FunctionDocumenter): """ Abjad function documenter. """ def __str__(self) -> str: return f".. autofunction:: {self.package_path}"
[docs] class AbjadModuleDocumenter(uqbar.apis.documenters.ModuleDocumenter): """ Abjad module documenter. Writes ... * abjad/docs/source/api/abjad/bind.rst * abjad/docs/source/api/abjad/configuration.rst * abjad/docs/source/api/abjad/contextmanagers.rst * etc. ... to disk. """ def __str__(self) -> str: result = self._build_preamble() # true for grace_corner_cases, parse if self.is_nominative: result.extend(["", str(self.member_documenters[0])]) else: # true only for abjad, abjad.ext, abjad.parsers if self.is_package: subpackage_documenters = [ _ for _ in self.module_documenters or [] if _.is_package or not _.is_nominative ] if subpackage_documenters: result.extend(self._build_toc(subpackage_documenters)) flattened_documenters: list = [] for section, documenters in self.member_documenters_by_section: flattened_documenters.extend(documenters) flattened_documenters.sort(key=lambda _: getattr(_.client, "__name__", "")) function_documenters, class_documenters = [], [] for documenter in flattened_documenters: if getattr(documenter.client, "__name__", "")[0].islower(): function_documenters.append(documenter) else: class_documenters.append(documenter) function_documenters.sort(key=lambda _: getattr(_.client, "__name__", "")) class_documenters.sort(key=lambda _: getattr(_.client, "__name__", "")) flattened_documenters = function_documenters + class_documenters local_documenters = [ documenter for documenter in flattened_documenters if documenter.client.__module__ == self.package_path ] result.extend(self._build_toc(flattened_documenters)) for local_documenter in local_documenters: result.extend(["", str(local_documenter)]) return "\n".join(result) def _build_preamble(self) -> list[str]: header = f"abjad.{self.package_name}" result: list[str] = [ f".. _{self.reference_name}:", "", header, "=" * len(header), ] return result def _build_toc(self, documenters, **kwargs) -> list[str]: result: list[str] = [] if not documenters: return result toctree_paths = set() for documenter in documenters: path = self._build_toc_path(documenter) if path: toctree_paths.add(path) # true for abjad, abjad.ext, abjad.parsers if toctree_paths: result.extend( [ "", ".. toctree::", " :hidden:", "", ] ) for toctree_path in sorted(toctree_paths): result.append(f" {toctree_path}") result.extend( [ "", ".. autosummary::", "", ] ) for documenter in documenters: result.append(f" {documenter.package_path}") return result @property def member_documenters_by_section( self, ) -> typing.Sequence[ tuple[str, typing.Sequence[uqbar.apis.documenters.MemberDocumenter]] ]: result: typing.MutableMapping[ str, list[uqbar.apis.documenters.MemberDocumenter] ] = {} for documenter in self.member_documenters: result.setdefault(documenter.documentation_section, []).append(documenter) for module_documenter in self.module_documenters or []: if not module_documenter.is_nominative: continue documenter = module_documenter.member_documenters[0] result.setdefault(documenter.documentation_section, []).append(documenter) return sorted(result.items())
[docs] class AbjadRootDocumenter(uqbar.apis.documenters.RootDocumenter): """ Abjad root documenter. Writes abjad/docs/source/api/index.rst. """ do_not_document = ( "abjad.ext", "abjad.ext.sphinx", "abjad.parsers.base", "abjad.parsers.parser", "abjad.parsers.scheme", ) def __str__(self): result = [ self.title, "=" * len(self.title), "", ".. toctree::", " :hidden:", "", ] assert len(self.module_documenters) == 1 for documenter in self.module_documenters: path = documenter.package_path.replace(".", "/") if documenter.is_package: path += "/index" result.append(f" {path}") for module_documenter, documenters_by_section in self._recurse(self): if module_documenter.package_name == "abjad": continue if module_documenter.package_path in self.do_not_document: continue package_path = module_documenter.package_path reference_name = module_documenter.reference_name result.extend( [ "", f".. rubric:: :ref:`{package_path} <{reference_name}>`", " :class: section-header", ] ) flattened_documenters = [] for section_name, documenters in documenters_by_section: flattened_documenters.extend(documenters) function_documenters, class_documenters = [], [] for documenter in flattened_documenters: if documenter.client.__name__[0].islower(): function_documenters.append(documenter) else: class_documenters.append(documenter) function_documenters.sort(key=lambda _: _.client.__name__) class_documenters.sort(key=lambda _: _.client.__name__) flattened_documenters = function_documenters + class_documenters result.extend( [ "", ".. autosummary::", "", ] ) for documenter in flattened_documenters: result.append(f" {documenter.package_path}") return "\n".join(result) def _recurse(self, documenter): result = [] if ( isinstance(documenter, uqbar.apis.documenters.ModuleDocumenter) and not documenter.is_nominative ): result.append((documenter, documenter.member_documenters_by_section)) for module_documenter in documenter.module_documenters: result.extend(self._recurse(module_documenter)) return result
[docs] class HiddenDoctestDirective(docutils.parsers.rst.Directive): """ Hidden doctest directive. Contributes no formatting to documents built by Sphinx. """ ### CLASS VARIABLES ### __documentation_ignore_inherited__ = True has_content = True required_arguments = 0 optional_arguments = 0 final_argument_whitespace = True option_spec: dict[str, str] = {} ### PUBLIC METHODS ###
[docs] def run(self): """Executes the directive.""" self.assert_has_content() return []
[docs] class ShellDirective(docutils.parsers.rst.Directive): """ Shell directive. Represents a shell session. Generates a docutils ``literal_block`` node. """ ### CLASS VARIABLES ### __documentation_ignore_inherited__ = True has_content = True required_arguments = 0 optional_arguments = 0 final_argument_whitespace = False option_spec: dict[str, str] = {} ### PUBLIC METHODS ###
[docs] def run(self): self.assert_has_content() result = [] # with _contextmanagers.TemporaryDirectoryChange( with _contextmanagers.temporary_directory_change( configuration.abjad_install_directory() ): cwd = pathlib.Path.cwd() for line in self.content: result.append(f"{cwd.name}$ {line}") completed_process = subprocess.run( line, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) result.append(completed_process.stdout) code = "\n".join(result) literal = docutils.nodes.literal_block(code, code) literal["language"] = "console" sphinx.util.nodes.set_source_info(self, literal) return [literal]
[docs] class ThumbnailDirective(docutils.parsers.rst.Directive): """ Thumbnail directive. """ ### CLASS VARIABLES ### __documentation_ignore_inherited__ = True final_argument_whitespace = True has_content = False option_spec = { "class": docutils.parsers.rst.directives.class_option, "group": docutils.parsers.rst.directives.unchanged, "title": docutils.parsers.rst.directives.unchanged, } optional_arguments = 0 required_arguments = 1 ### PUBLIC METHODS ###
[docs] def run(self): """Executes the directive.""" node = thumbnail_block() node["classes"] += self.options.get("class", "") node["group"] = self.options.get("group", "") node["title"] = self.options.get("title", "") node["uri"] = self.arguments[0] environment = self.state.document.settings.env name, suffix = os.path.splitext(node["uri"]) environment.images.add_file(environment.docname, node["uri"]) thumbnail_uri = name + "-thumbnail" + suffix environment.thumbnails[thumbnail_uri] = environment.docname # this may also work: # environment.thumbnails[environment.docname] = thumbnail_uri return [node]
[docs] class thumbnail_block( docutils.nodes.image, docutils.nodes.General, docutils.nodes.Element ): __documentation_ignore_inherited__ = True
[docs] def visit_thumbnail_block_html(self, node): template = uqbar.strings.normalize( """ <a data-lightbox="{group}" href="{target_path}" title="{title}" data-title="{title}" class="{cls}"> <img src="{thumbnail_path}" alt="{alt}"/> </a> """ ) title = node["title"] classes = " ".join(node["classes"]) group = "group-{}".format(node["group"] if node["group"] else node["uri"]) if node["uri"] in self.builder.images: node["uri"] = os.path.join( self.builder.imgpath, self.builder.images[node["uri"]] ) target_path = node["uri"] prefix, suffix = os.path.splitext(target_path) if suffix == ".svg": thumbnail_path = target_path else: thumbnail_path = f"{prefix}-thumbnail{suffix}" output = template.format( alt=title, group=group, target_path=target_path, cls=classes, thumbnail_path=thumbnail_path, title=title, ) self.body.append(output) raise docutils.nodes.SkipNode
[docs] def visit_thumbnail_block_latex(self, node): raise docutils.nodes.SkipNode
[docs] def on_builder_inited(app): app.env.thumbnails = {} # separate so Sphinx doesn't purge it install_lightbox_static_files(app) (pathlib.Path(app.builder.outdir) / "_images").mkdir(exist_ok=True)
[docs] class LilyPondExtension(Extension): class Kind(enum.Enum): IMAGE = 1 AUDIO = 2 class lilypond_block(docutils.nodes.General, docutils.nodes.FixedTextElement): pass @classmethod def setup_console(cls, console, monkeypatch): monkeypatch.setattr( _io.Illustrator, "__call__", lambda self: console.push_proxy( cls( self.illustrable, cls.Kind.IMAGE, **{ key.replace("lilypond/", "").replace("-", "_"): value for key, value in console.proxy_options.items() if key.startswith("lilypond/") }, ), ), ) monkeypatch.setattr( _io.Player, "__call__", lambda self: console.push_proxy( cls( self.illustrable, cls.Kind.AUDIO, **{ key.replace("lilypond/", "").replace("-", "_"): value for key, value in console.proxy_options.items() if key.startswith("lilypond/") }, ), ), ) @classmethod def setup_sphinx(cls, app): app.add_node( cls.lilypond_block, html=[cls.visit_block_html, None], latex=[cls.visit_block_latex, None], text=[cls.visit_block_text, cls.depart_block_text], ) cls.add_option("lilypond/no-trim", docutils.parsers.rst.directives.flag) cls.add_option("lilypond/pages", docutils.parsers.rst.directives.unchanged) cls.add_option("lilypond/with-columns", int) def __init__( self, illustrable, kind, no_trim=None, pages=None, with_columns=None, **keywords, ): self.illustrable = copy.deepcopy(illustrable) self.keywords = keywords self.kind = kind self.no_trim = no_trim self.pages = pages self.with_columns = with_columns
[docs] def to_docutils(self): if isinstance(self.illustrable, _lilypondfile.LilyPondFile): illustration = self.illustrable else: illustration = _illustrators.illustrate(self.illustrable, **self.keywords) if self.kind == self.Kind.AUDIO: block = _lilypondfile.Block("midi") illustration["score"].items.append(block) illustration.lilypond_version_token = r'\version "2.19.83"' code = illustration._get_lilypond_format() code = _format.remove_site_comments(code) code = _tag.remove_tags(code) node = self.lilypond_block(code, code) node["kind"] = self.kind.name.lower() node["no-trim"] = self.no_trim node["pages"] = self.pages node["with-columns"] = self.with_columns return [node]
@staticmethod def visit_block_html(self, node): output_directory = pathlib.Path(self.builder.outdir) / "_images" render_prefix = "lilypond-{}".format( hashlib.sha256(node[0].encode()).hexdigest() ) if node["kind"] == "audio": flags = [] glob = f"{render_prefix}.mid*" else: flags = ["-dcrop", "-dbackend=svg"] glob = f"{render_prefix}*.svg" lilypond_io = _io.LilyPondIO( None, flags=flags, output_directory=output_directory, render_prefix=render_prefix, should_copy_stylesheets=True, should_open=False, should_persist_log=False, string=node[0], ) if not list(output_directory.glob(glob)): _, _, _, success, log = lilypond_io() if not success: logger.warning(f"LilyPond render failed\n{log}", location=node) source_path = (pathlib.Path(self.builder.imgpath) / render_prefix).with_suffix( ".ly" ) if node["kind"] == "audio": pass else: embed_images(self, node, output_directory, render_prefix, source_path) raise docutils.nodes.SkipNode
table_row_open_template = '<div class="table-row">' table_row_close_template = "</div>" basic_image_template = uqbar.strings.normalize( """ <div class="uqbar-book"> <a href="{source_path}"><img src="{relative_path}"/></a> </div> """ ) thumbnail_template = uqbar.strings.normalize( """ <a data-lightbox="{group}" href="{fullsize_path}" title="{title}" data-title="{title}" class="{cls}"> <img src="{thumbnail_path}" alt="{alt}"/> </a> """ )
[docs] def embed_images(self, node, output_directory, render_prefix, source_path): paths_to_embed = [] if node.get("pages"): for page_spec in node["pages"].split(","): page_spec = page_spec.strip() if "-" in page_spec: start_spec, _, stop_spec = page_spec.partition("-") start_page, stop_page = int(start_spec), int(stop_spec) + 1 else: start_page, stop_page = int(page_spec), int(page_spec) + 1 for page_number in range(start_page, stop_page): for path in output_directory.glob(f"{render_prefix}-{page_number}.svg"): paths_to_embed.append(path) elif node.get("no-trim"): for path in output_directory.glob(f"{render_prefix}.svg"): paths_to_embed.append(path) if not paths_to_embed: page_count = len(list(output_directory.glob(f"{render_prefix}-*.svg"))) for page_number in range(1, page_count + 1): for path in output_directory.glob(f"{render_prefix}-{page_number}.svg"): paths_to_embed.append(path) else: for path in output_directory.glob(f"{render_prefix}.cropped.svg"): paths_to_embed.append(path) with_columns = node.get("with-columns") if with_columns: for i in range(0, len(paths_to_embed), with_columns): self.body.append(table_row_open_template) for path in paths_to_embed[i : i + with_columns]: relative_path = pathlib.Path(self.builder.imgpath) / path.name self.body.append( thumbnail_template.format( alt="", cls="table-cell thumbnail", group=f"group-{render_prefix}", fullsize_path=relative_path, thumbnail_path=relative_path, title="", ) ) self.body.append(table_row_close_template) else: for path in paths_to_embed: relative_path = pathlib.Path(self.builder.imgpath) / path.name self.body.append( basic_image_template.format( relative_path=relative_path, source_path=source_path ) )
[docs] def install_lightbox_static_files(app): source_static_path = os.path.join(app.builder.srcdir, "_static") target_static_path = os.path.join(app.builder.outdir, "_static") source_lightbox_path = os.path.join(source_static_path, "lightbox2") target_lightbox_path = os.path.join(target_static_path, "lightbox2") relative_file_paths = [] for root, _, file_names in os.walk(source_lightbox_path): for file_name in file_names: absolute_file_path = os.path.join(root, file_name) relative_file_path = os.path.relpath(absolute_file_path, source_static_path) relative_file_paths.append(relative_file_path) if os.path.exists(target_lightbox_path): shutil.rmtree(target_lightbox_path) for relative_file_path in sphinx.util.display.status_iterator( relative_file_paths, "installing lightbox files... ", sphinx.util.console.brown, len(relative_file_paths), ): source_path = os.path.join(source_static_path, relative_file_path) target_path = os.path.join(target_static_path, relative_file_path) target_directory = os.path.dirname(target_path) if not os.path.exists(target_directory): sphinx.util.osutil.ensuredir(target_directory) sphinx.util.osutil.copyfile(source_path, target_path) if relative_file_path.endswith(".js"): app.add_js_file(relative_file_path, defer="defer") elif relative_file_path.endswith(".css"): app.add_css_file(relative_file_path)
[docs] def on_html_collect_pages(app): for path in sphinx.util.display.status_iterator( app.env.thumbnails, "copying gallery thumbnails...", "brown", len(app.env.thumbnails), ): source_path = pathlib.Path(app.srcdir) / path target_path = pathlib.Path(app.outdir) / "_images" / source_path.name try: shutil.copy(source_path, target_path) except Exception: logger.warning(f"Could not copy {source_path}") return []
[docs] def setup(app): app.connect("builder-inited", on_builder_inited) app.connect("html-collect-pages", on_html_collect_pages) app.add_css_file("abjad.css") app.add_directive("docs", HiddenDoctestDirective) app.add_directive("shell", ShellDirective) app.add_directive("thumbnail", ThumbnailDirective) app.add_js_file("ga.js") app.add_node( thumbnail_block, html=[visit_thumbnail_block_html, None], latex=[visit_thumbnail_block_latex, None], )