about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives
diff options
context:
space:
mode:
authorS. Solomon Darnell2025-03-28 21:52:21 -0500
committerS. Solomon Darnell2025-03-28 21:52:21 -0500
commit4a52a71956a8d46fcb7294ac71734504bb09bcc2 (patch)
treeee3dc5af3b6313e921cd920906356f5d4febc4ed /.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives
parentcc961e04ba734dd72309fb548a2f97d67d578813 (diff)
downloadgn-ai-master.tar.gz
two version of R2R are here HEAD master
Diffstat (limited to '.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives')
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/__init__.py466
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/admonitions.py101
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/body.py305
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/html.py21
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/images.py173
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/misc.py642
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/parts.py126
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/references.py29
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/tables.py538
9 files changed, 2401 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/__init__.py b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/__init__.py
new file mode 100644
index 00000000..ebbdfe3b
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/__init__.py
@@ -0,0 +1,466 @@
+# $Id: __init__.py 9426 2023-07-03 12:38:54Z milde $
+# Author: David Goodger <goodger@python.org>
+# Copyright: This module has been placed in the public domain.
+
+"""
+This package contains directive implementation modules.
+"""
+
+__docformat__ = 'reStructuredText'
+
+import re
+import codecs
+from importlib import import_module
+
+from docutils import nodes, parsers
+from docutils.utils import split_escaped_whitespace, escape2null
+from docutils.parsers.rst.languages import en as _fallback_language_module
+
+
+_directive_registry = {
+      'attention': ('admonitions', 'Attention'),
+      'caution': ('admonitions', 'Caution'),
+      'code': ('body', 'CodeBlock'),
+      'danger': ('admonitions', 'Danger'),
+      'error': ('admonitions', 'Error'),
+      'important': ('admonitions', 'Important'),
+      'note': ('admonitions', 'Note'),
+      'tip': ('admonitions', 'Tip'),
+      'hint': ('admonitions', 'Hint'),
+      'warning': ('admonitions', 'Warning'),
+      'admonition': ('admonitions', 'Admonition'),
+      'sidebar': ('body', 'Sidebar'),
+      'topic': ('body', 'Topic'),
+      'line-block': ('body', 'LineBlock'),
+      'parsed-literal': ('body', 'ParsedLiteral'),
+      'math': ('body', 'MathBlock'),
+      'rubric': ('body', 'Rubric'),
+      'epigraph': ('body', 'Epigraph'),
+      'highlights': ('body', 'Highlights'),
+      'pull-quote': ('body', 'PullQuote'),
+      'compound': ('body', 'Compound'),
+      'container': ('body', 'Container'),
+      # 'questions': ('body', 'question_list'),
+      'table': ('tables', 'RSTTable'),
+      'csv-table': ('tables', 'CSVTable'),
+      'list-table': ('tables', 'ListTable'),
+      'image': ('images', 'Image'),
+      'figure': ('images', 'Figure'),
+      'contents': ('parts', 'Contents'),
+      'sectnum': ('parts', 'Sectnum'),
+      'header': ('parts', 'Header'),
+      'footer': ('parts', 'Footer'),
+      # 'footnotes': ('parts', 'footnotes'),
+      # 'citations': ('parts', 'citations'),
+      'target-notes': ('references', 'TargetNotes'),
+      'meta': ('misc', 'Meta'),
+      # 'imagemap': ('html', 'imagemap'),
+      'raw': ('misc', 'Raw'),
+      'include': ('misc', 'Include'),
+      'replace': ('misc', 'Replace'),
+      'unicode': ('misc', 'Unicode'),
+      'class': ('misc', 'Class'),
+      'role': ('misc', 'Role'),
+      'default-role': ('misc', 'DefaultRole'),
+      'title': ('misc', 'Title'),
+      'date': ('misc', 'Date'),
+      'restructuredtext-test-directive': ('misc', 'TestDirective'),
+      }
+"""Mapping of directive name to (module name, class name).  The
+directive name is canonical & must be lowercase.  Language-dependent
+names are defined in the ``language`` subpackage."""
+
+_directives = {}
+"""Cache of imported directives."""
+
+
+def directive(directive_name, language_module, document):
+    """
+    Locate and return a directive function from its language-dependent name.
+    If not found in the current language, check English.  Return None if the
+    named directive cannot be found.
+    """
+    normname = directive_name.lower()
+    messages = []
+    msg_text = []
+    if normname in _directives:
+        return _directives[normname], messages
+    canonicalname = None
+    try:
+        canonicalname = language_module.directives[normname]
+    except AttributeError as error:
+        msg_text.append('Problem retrieving directive entry from language '
+                        'module %r: %s.' % (language_module, error))
+    except KeyError:
+        msg_text.append('No directive entry for "%s" in module "%s".'
+                        % (directive_name, language_module.__name__))
+    if not canonicalname:
+        try:
+            canonicalname = _fallback_language_module.directives[normname]
+            msg_text.append('Using English fallback for directive "%s".'
+                            % directive_name)
+        except KeyError:
+            msg_text.append('Trying "%s" as canonical directive name.'
+                            % directive_name)
+            # The canonical name should be an English name, but just in case:
+            canonicalname = normname
+    if msg_text:
+        message = document.reporter.info(
+            '\n'.join(msg_text), line=document.current_line)
+        messages.append(message)
+    try:
+        modulename, classname = _directive_registry[canonicalname]
+    except KeyError:
+        # Error handling done by caller.
+        return None, messages
+    try:
+        module = import_module('docutils.parsers.rst.directives.'+modulename)
+    except ImportError as detail:
+        messages.append(document.reporter.error(
+            'Error importing directive module "%s" (directive "%s"):\n%s'
+            % (modulename, directive_name, detail),
+            line=document.current_line))
+        return None, messages
+    try:
+        directive = getattr(module, classname)
+        _directives[normname] = directive
+    except AttributeError:
+        messages.append(document.reporter.error(
+            'No directive class "%s" in module "%s" (directive "%s").'
+            % (classname, modulename, directive_name),
+            line=document.current_line))
+        return None, messages
+    return directive, messages
+
+
+def register_directive(name, directive):
+    """
+    Register a nonstandard application-defined directive function.
+    Language lookups are not needed for such functions.
+    """
+    _directives[name] = directive
+
+
+# conversion functions for `Directive.option_spec`
+# ------------------------------------------------
+#
+# see also `parsers.rst.Directive` in ../__init__.py.
+
+
+def flag(argument):
+    """
+    Check for a valid flag option (no argument) and return ``None``.
+    (Directive option conversion function.)
+
+    Raise ``ValueError`` if an argument is found.
+    """
+    if argument and argument.strip():
+        raise ValueError('no argument is allowed; "%s" supplied' % argument)
+    else:
+        return None
+
+
+def unchanged_required(argument):
+    """
+    Return the argument text, unchanged.
+    (Directive option conversion function.)
+
+    Raise ``ValueError`` if no argument is found.
+    """
+    if argument is None:
+        raise ValueError('argument required but none supplied')
+    else:
+        return argument  # unchanged!
+
+
+def unchanged(argument):
+    """
+    Return the argument text, unchanged.
+    (Directive option conversion function.)
+
+    No argument implies empty string ("").
+    """
+    if argument is None:
+        return ''
+    else:
+        return argument  # unchanged!
+
+
+def path(argument):
+    """
+    Return the path argument unwrapped (with newlines removed).
+    (Directive option conversion function.)
+
+    Raise ``ValueError`` if no argument is found.
+    """
+    if argument is None:
+        raise ValueError('argument required but none supplied')
+    else:
+        return ''.join(s.strip() for s in argument.splitlines())
+
+
+def uri(argument):
+    """
+    Return the URI argument with unescaped whitespace removed.
+    (Directive option conversion function.)
+
+    Raise ``ValueError`` if no argument is found.
+    """
+    if argument is None:
+        raise ValueError('argument required but none supplied')
+    else:
+        parts = split_escaped_whitespace(escape2null(argument))
+        return ' '.join(''.join(nodes.unescape(part).split())
+                        for part in parts)
+
+
+def nonnegative_int(argument):
+    """
+    Check for a nonnegative integer argument; raise ``ValueError`` if not.
+    (Directive option conversion function.)
+    """
+    value = int(argument)
+    if value < 0:
+        raise ValueError('negative value; must be positive or zero')
+    return value
+
+
+def percentage(argument):
+    """
+    Check for an integer percentage value with optional percent sign.
+    (Directive option conversion function.)
+    """
+    try:
+        argument = argument.rstrip(' %')
+    except AttributeError:
+        pass
+    return nonnegative_int(argument)
+
+
+length_units = ['em', 'ex', 'px', 'in', 'cm', 'mm', 'pt', 'pc']
+
+
+def get_measure(argument, units):
+    """
+    Check for a positive argument of one of the units and return a
+    normalized string of the form "<value><unit>" (without space in
+    between).
+    (Directive option conversion function.)
+
+    To be called from directive option conversion functions.
+    """
+    match = re.match(r'^([0-9.]+) *(%s)$' % '|'.join(units), argument)
+    try:
+        float(match.group(1))
+    except (AttributeError, ValueError):
+        raise ValueError(
+            'not a positive measure of one of the following units:\n%s'
+            % ' '.join('"%s"' % i for i in units))
+    return match.group(1) + match.group(2)
+
+
+def length_or_unitless(argument):
+    return get_measure(argument, length_units + [''])
+
+
+def length_or_percentage_or_unitless(argument, default=''):
+    """
+    Return normalized string of a length or percentage unit.
+    (Directive option conversion function.)
+
+    Add <default> if there is no unit. Raise ValueError if the argument is not
+    a positive measure of one of the valid CSS units (or without unit).
+
+    >>> length_or_percentage_or_unitless('3 pt')
+    '3pt'
+    >>> length_or_percentage_or_unitless('3%', 'em')
+    '3%'
+    >>> length_or_percentage_or_unitless('3')
+    '3'
+    >>> length_or_percentage_or_unitless('3', 'px')
+    '3px'
+    """
+    try:
+        return get_measure(argument, length_units + ['%'])
+    except ValueError:
+        try:
+            return get_measure(argument, ['']) + default
+        except ValueError:
+            # raise ValueError with list of valid units:
+            return get_measure(argument, length_units + ['%'])
+
+
+def class_option(argument):
+    """
+    Convert the argument into a list of ID-compatible strings and return it.
+    (Directive option conversion function.)
+
+    Raise ``ValueError`` if no argument is found.
+    """
+    if argument is None:
+        raise ValueError('argument required but none supplied')
+    names = argument.split()
+    class_names = []
+    for name in names:
+        class_name = nodes.make_id(name)
+        if not class_name:
+            raise ValueError('cannot make "%s" into a class name' % name)
+        class_names.append(class_name)
+    return class_names
+
+
+unicode_pattern = re.compile(
+    r'(?:0x|x|\\x|U\+?|\\u)([0-9a-f]+)$|&#x([0-9a-f]+);$', re.IGNORECASE)
+
+
+def unicode_code(code):
+    r"""
+    Convert a Unicode character code to a Unicode character.
+    (Directive option conversion function.)
+
+    Codes may be decimal numbers, hexadecimal numbers (prefixed by ``0x``,
+    ``x``, ``\x``, ``U+``, ``u``, or ``\u``; e.g. ``U+262E``), or XML-style
+    numeric character entities (e.g. ``&#x262E;``).  Other text remains as-is.
+
+    Raise ValueError for illegal Unicode code values.
+    """
+    try:
+        if code.isdigit():                  # decimal number
+            return chr(int(code))
+        else:
+            match = unicode_pattern.match(code)
+            if match:                       # hex number
+                value = match.group(1) or match.group(2)
+                return chr(int(value, 16))
+            else:                           # other text
+                return code
+    except OverflowError as detail:
+        raise ValueError('code too large (%s)' % detail)
+
+
+def single_char_or_unicode(argument):
+    """
+    A single character is returned as-is.  Unicode character codes are
+    converted as in `unicode_code`.  (Directive option conversion function.)
+    """
+    char = unicode_code(argument)
+    if len(char) > 1:
+        raise ValueError('%r invalid; must be a single character or '
+                         'a Unicode code' % char)
+    return char
+
+
+def single_char_or_whitespace_or_unicode(argument):
+    """
+    As with `single_char_or_unicode`, but "tab" and "space" are also supported.
+    (Directive option conversion function.)
+    """
+    if argument == 'tab':
+        char = '\t'
+    elif argument == 'space':
+        char = ' '
+    else:
+        char = single_char_or_unicode(argument)
+    return char
+
+
+def positive_int(argument):
+    """
+    Converts the argument into an integer.  Raises ValueError for negative,
+    zero, or non-integer values.  (Directive option conversion function.)
+    """
+    value = int(argument)
+    if value < 1:
+        raise ValueError('negative or zero value; must be positive')
+    return value
+
+
+def positive_int_list(argument):
+    """
+    Converts a space- or comma-separated list of values into a Python list
+    of integers.
+    (Directive option conversion function.)
+
+    Raises ValueError for non-positive-integer values.
+    """
+    if ',' in argument:
+        entries = argument.split(',')
+    else:
+        entries = argument.split()
+    return [positive_int(entry) for entry in entries]
+
+
+def encoding(argument):
+    """
+    Verifies the encoding argument by lookup.
+    (Directive option conversion function.)
+
+    Raises ValueError for unknown encodings.
+    """
+    try:
+        codecs.lookup(argument)
+    except LookupError:
+        raise ValueError('unknown encoding: "%s"' % argument)
+    return argument
+
+
+def choice(argument, values):
+    """
+    Directive option utility function, supplied to enable options whose
+    argument must be a member of a finite set of possible values (must be
+    lower case).  A custom conversion function must be written to use it.  For
+    example::
+
+        from docutils.parsers.rst import directives
+
+        def yesno(argument):
+            return directives.choice(argument, ('yes', 'no'))
+
+    Raise ``ValueError`` if no argument is found or if the argument's value is
+    not valid (not an entry in the supplied list).
+    """
+    try:
+        value = argument.lower().strip()
+    except AttributeError:
+        raise ValueError('must supply an argument; choose from %s'
+                         % format_values(values))
+    if value in values:
+        return value
+    else:
+        raise ValueError('"%s" unknown; choose from %s'
+                         % (argument, format_values(values)))
+
+
+def format_values(values):
+    return '%s, or "%s"' % (', '.join('"%s"' % s for s in values[:-1]),
+                            values[-1])
+
+
+def value_or(values, other):
+    """
+    Directive option conversion function.
+
+    The argument can be any of `values` or `argument_type`.
+    """
+    def auto_or_other(argument):
+        if argument in values:
+            return argument
+        else:
+            return other(argument)
+    return auto_or_other
+
+
+def parser_name(argument):
+    """
+    Return a docutils parser whose name matches the argument.
+    (Directive option conversion function.)
+
+    Return `None`, if the argument evaluates to `False`.
+    Raise `ValueError` if importing the parser module fails.
+    """
+    if not argument:
+        return None
+    try:
+        return parsers.get_parser_class(argument)
+    except ImportError as err:
+        raise ValueError(str(err))
diff --git a/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/admonitions.py b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/admonitions.py
new file mode 100644
index 00000000..1990099e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/admonitions.py
@@ -0,0 +1,101 @@
+# $Id: admonitions.py 9475 2023-11-13 22:30:00Z milde $
+# Author: David Goodger <goodger@python.org>
+# Copyright: This module has been placed in the public domain.
+
+"""
+Admonition directives.
+"""
+
+__docformat__ = 'reStructuredText'
+
+
+from docutils.parsers.rst import Directive
+from docutils.parsers.rst import directives
+from docutils.parsers.rst.roles import set_classes
+from docutils import nodes
+
+
+class BaseAdmonition(Directive):
+
+    final_argument_whitespace = True
+    option_spec = {'class': directives.class_option,
+                   'name': directives.unchanged}
+    has_content = True
+
+    node_class = None
+    """Subclasses must set this to the appropriate admonition node class."""
+
+    def run(self):
+        set_classes(self.options)
+        self.assert_has_content()
+        text = '\n'.join(self.content)
+        admonition_node = self.node_class(text, **self.options)
+        self.add_name(admonition_node)
+        admonition_node.source, admonition_node.line = \
+            self.state_machine.get_source_and_line(self.lineno)
+        if self.node_class is nodes.admonition:
+            title_text = self.arguments[0]
+            textnodes, messages = self.state.inline_text(title_text,
+                                                         self.lineno)
+            title = nodes.title(title_text, '', *textnodes)
+            title.source, title.line = (
+                    self.state_machine.get_source_and_line(self.lineno))
+            admonition_node += title
+            admonition_node += messages
+            if 'classes' not in self.options:
+                admonition_node['classes'] += ['admonition-'
+                                               + nodes.make_id(title_text)]
+        self.state.nested_parse(self.content, self.content_offset,
+                                admonition_node)
+        return [admonition_node]
+
+
+class Admonition(BaseAdmonition):
+
+    required_arguments = 1
+    node_class = nodes.admonition
+
+
+class Attention(BaseAdmonition):
+
+    node_class = nodes.attention
+
+
+class Caution(BaseAdmonition):
+
+    node_class = nodes.caution
+
+
+class Danger(BaseAdmonition):
+
+    node_class = nodes.danger
+
+
+class Error(BaseAdmonition):
+
+    node_class = nodes.error
+
+
+class Hint(BaseAdmonition):
+
+    node_class = nodes.hint
+
+
+class Important(BaseAdmonition):
+
+    node_class = nodes.important
+
+
+class Note(BaseAdmonition):
+
+    node_class = nodes.note
+
+
+class Tip(BaseAdmonition):
+
+    node_class = nodes.tip
+
+
+class Warning(BaseAdmonition):
+
+    node_class = nodes.warning
diff --git a/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/body.py b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/body.py
new file mode 100644
index 00000000..5cb90416
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/body.py
@@ -0,0 +1,305 @@
+# $Id: body.py 9500 2023-12-14 22:38:49Z milde $
+# Author: David Goodger <goodger@python.org>
+# Copyright: This module has been placed in the public domain.
+
+"""
+Directives for additional body elements.
+
+See `docutils.parsers.rst.directives` for API details.
+"""
+
+__docformat__ = 'reStructuredText'
+
+
+from docutils import nodes
+from docutils.parsers.rst import Directive
+from docutils.parsers.rst import directives
+from docutils.parsers.rst.roles import set_classes
+from docutils.utils.code_analyzer import Lexer, LexerError, NumberLines
+
+
+class BasePseudoSection(Directive):
+
+    required_arguments = 1
+    optional_arguments = 0
+    final_argument_whitespace = True
+    option_spec = {'class': directives.class_option,
+                   'name': directives.unchanged}
+    has_content = True
+
+    node_class = None
+    """Node class to be used (must be set in subclasses)."""
+
+    def run(self):
+        if not (self.state_machine.match_titles
+                or isinstance(self.state_machine.node, nodes.sidebar)):
+            raise self.error('The "%s" directive may not be used within '
+                             'topics or body elements.' % self.name)
+        self.assert_has_content()
+        if self.arguments:  # title (in sidebars optional)
+            title_text = self.arguments[0]
+            textnodes, messages = self.state.inline_text(
+                                      title_text, self.lineno)
+            titles = [nodes.title(title_text, '', *textnodes)]
+            # Sidebar uses this code.
+            if 'subtitle' in self.options:
+                textnodes, more_messages = self.state.inline_text(
+                    self.options['subtitle'], self.lineno)
+                titles.append(nodes.subtitle(self.options['subtitle'], '',
+                                             *textnodes))
+                messages.extend(more_messages)
+        else:
+            titles = []
+            messages = []
+        text = '\n'.join(self.content)
+        node = self.node_class(text, *(titles + messages))
+        node['classes'] += self.options.get('class', [])
+        self.add_name(node)
+        if text:
+            self.state.nested_parse(self.content, self.content_offset, node)
+        return [node]
+
+
+class Topic(BasePseudoSection):
+
+    node_class = nodes.topic
+
+
+class Sidebar(BasePseudoSection):
+
+    node_class = nodes.sidebar
+
+    required_arguments = 0
+    optional_arguments = 1
+    option_spec = BasePseudoSection.option_spec.copy()
+    option_spec['subtitle'] = directives.unchanged_required
+
+    def run(self):
+        if isinstance(self.state_machine.node, nodes.sidebar):
+            raise self.error('The "%s" directive may not be used within a '
+                             'sidebar element.' % self.name)
+        if 'subtitle' in self.options and not self.arguments:
+            raise self.error('The "subtitle" option may not be used '
+                             'without a title.')
+
+        return BasePseudoSection.run(self)
+
+
+class LineBlock(Directive):
+
+    option_spec = {'class': directives.class_option,
+                   'name': directives.unchanged}
+    has_content = True
+
+    def run(self):
+        self.assert_has_content()
+        block = nodes.line_block(classes=self.options.get('class', []))
+        self.add_name(block)
+        node_list = [block]
+        for line_text in self.content:
+            text_nodes, messages = self.state.inline_text(
+                line_text.strip(), self.lineno + self.content_offset)
+            line = nodes.line(line_text, '', *text_nodes)
+            if line_text.strip():
+                line.indent = len(line_text) - len(line_text.lstrip())
+            block += line
+            node_list.extend(messages)
+            self.content_offset += 1
+        self.state.nest_line_block_lines(block)
+        return node_list
+
+
+class ParsedLiteral(Directive):
+
+    option_spec = {'class': directives.class_option,
+                   'name': directives.unchanged}
+    has_content = True
+
+    def run(self):
+        set_classes(self.options)
+        self.assert_has_content()
+        text = '\n'.join(self.content)
+        text_nodes, messages = self.state.inline_text(text, self.lineno)
+        node = nodes.literal_block(text, '', *text_nodes, **self.options)
+        node.line = self.content_offset + 1
+        self.add_name(node)
+        return [node] + messages
+
+
+class CodeBlock(Directive):
+    """Parse and mark up content of a code block.
+
+    Configuration setting: syntax_highlight
+       Highlight Code content with Pygments?
+       Possible values: ('long', 'short', 'none')
+
+    """
+    optional_arguments = 1
+    option_spec = {'class': directives.class_option,
+                   'name': directives.unchanged,
+                   'number-lines': directives.unchanged  # integer or None
+                   }
+    has_content = True
+
+    def run(self):
+        self.assert_has_content()
+        if self.arguments:
+            language = self.arguments[0]
+        else:
+            language = ''
+        set_classes(self.options)
+        classes = ['code']
+        if language:
+            classes.append(language)
+        if 'classes' in self.options:
+            classes.extend(self.options['classes'])
+
+        # set up lexical analyzer
+        try:
+            tokens = Lexer('\n'.join(self.content), language,
+                           self.state.document.settings.syntax_highlight)
+        except LexerError as error:
+            if self.state.document.settings.report_level > 2:
+                # don't report warnings -> insert without syntax highlight
+                tokens = Lexer('\n'.join(self.content), language, 'none')
+            else:
+                raise self.warning(error)
+
+        if 'number-lines' in self.options:
+            # optional argument `startline`, defaults to 1
+            try:
+                startline = int(self.options['number-lines'] or 1)
+            except ValueError:
+                raise self.error(':number-lines: with non-integer start value')
+            endline = startline + len(self.content)
+            # add linenumber filter:
+            tokens = NumberLines(tokens, startline, endline)
+
+        node = nodes.literal_block('\n'.join(self.content), classes=classes)
+        self.add_name(node)
+        # if called from "include", set the source
+        if 'source' in self.options:
+            node.attributes['source'] = self.options['source']
+        # analyze content and add nodes for every token
+        for classes, value in tokens:
+            if classes:
+                node += nodes.inline(value, value, classes=classes)
+            else:
+                # insert as Text to decrease the verbosity of the output
+                node += nodes.Text(value)
+
+        return [node]
+
+
+class MathBlock(Directive):
+
+    option_spec = {'class': directives.class_option,
+                   'name': directives.unchanged,
+                   # TODO: Add Sphinx' ``mathbase.py`` option 'nowrap'?
+                   # 'nowrap': directives.flag,
+                   }
+    has_content = True
+
+    def run(self):
+        set_classes(self.options)
+        self.assert_has_content()
+        # join lines, separate blocks
+        content = '\n'.join(self.content).split('\n\n')
+        _nodes = []
+        for block in content:
+            if not block:
+                continue
+            node = nodes.math_block(self.block_text, block, **self.options)
+            (node.source,
+             node.line) = self.state_machine.get_source_and_line(self.lineno)
+            self.add_name(node)
+            _nodes.append(node)
+        return _nodes
+
+
+class Rubric(Directive):
+
+    required_arguments = 1
+    optional_arguments = 0
+    final_argument_whitespace = True
+    option_spec = {'class': directives.class_option,
+                   'name': directives.unchanged}
+
+    def run(self):
+        set_classes(self.options)
+        rubric_text = self.arguments[0]
+        textnodes, messages = self.state.inline_text(rubric_text, self.lineno)
+        rubric = nodes.rubric(rubric_text, '', *textnodes, **self.options)
+        self.add_name(rubric)
+        return [rubric] + messages
+
+
+class BlockQuote(Directive):
+
+    has_content = True
+    classes = []
+
+    def run(self):
+        self.assert_has_content()
+        elements = self.state.block_quote(self.content, self.content_offset)
+        for element in elements:
+            if isinstance(element, nodes.block_quote):
+                element['classes'] += self.classes
+        return elements
+
+
+class Epigraph(BlockQuote):
+
+    classes = ['epigraph']
+
+
+class Highlights(BlockQuote):
+
+    classes = ['highlights']
+
+
+class PullQuote(BlockQuote):
+
+    classes = ['pull-quote']
+
+
+class Compound(Directive):
+
+    option_spec = {'class': directives.class_option,
+                   'name': directives.unchanged}
+    has_content = True
+
+    def run(self):
+        self.assert_has_content()
+        text = '\n'.join(self.content)
+        node = nodes.compound(text)
+        node['classes'] += self.options.get('class', [])
+        self.add_name(node)
+        self.state.nested_parse(self.content, self.content_offset, node)
+        return [node]
+
+
+class Container(Directive):
+
+    optional_arguments = 1
+    final_argument_whitespace = True
+    option_spec = {'name': directives.unchanged}
+    has_content = True
+
+    def run(self):
+        self.assert_has_content()
+        text = '\n'.join(self.content)
+        try:
+            if self.arguments:
+                classes = directives.class_option(self.arguments[0])
+            else:
+                classes = []
+        except ValueError:
+            raise self.error(
+                'Invalid class attribute value for "%s" directive: "%s".'
+                % (self.name, self.arguments[0]))
+        node = nodes.container(text)
+        node['classes'].extend(classes)
+        self.add_name(node)
+        self.state.nested_parse(self.content, self.content_offset, node)
+        return [node]
diff --git a/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/html.py b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/html.py
new file mode 100644
index 00000000..c22a26f2
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/html.py
@@ -0,0 +1,21 @@
+# $Id: html.py 9062 2022-05-30 21:09:09Z milde $
+# Author: David Goodger <goodger@python.org>
+# Copyright: This module has been placed in the public domain.
+
+"""
+Dummy module for backwards compatibility.
+
+This module is provisional: it will be removed in Docutils 2.0.
+"""
+
+__docformat__ = 'reStructuredText'
+
+import warnings
+
+from docutils.parsers.rst.directives.misc import MetaBody, Meta  # noqa: F401
+
+warnings.warn('The `docutils.parsers.rst.directive.html` module'
+              ' will be removed in Docutils 2.0.'
+              ' Since Docutils 0.18, the "Meta" node is defined in'
+              ' `docutils.parsers.rst.directives.misc`.',
+              DeprecationWarning, stacklevel=2)
diff --git a/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/images.py b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/images.py
new file mode 100644
index 00000000..bcde3a39
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/images.py
@@ -0,0 +1,173 @@
+# $Id: images.py 9500 2023-12-14 22:38:49Z milde $
+# Author: David Goodger <goodger@python.org>
+# Copyright: This module has been placed in the public domain.
+
+"""
+Directives for figures and simple images.
+"""
+
+__docformat__ = 'reStructuredText'
+
+from urllib.request import url2pathname
+
+try:  # check for the Python Imaging Library
+    import PIL.Image
+except ImportError:
+    try:  # sometimes PIL modules are put in PYTHONPATH's root
+        import Image
+        class PIL: pass  # noqa:E701  dummy wrapper
+        PIL.Image = Image
+    except ImportError:
+        PIL = None
+
+from docutils import nodes
+from docutils.nodes import fully_normalize_name, whitespace_normalize_name
+from docutils.parsers.rst import Directive
+from docutils.parsers.rst import directives, states
+from docutils.parsers.rst.roles import set_classes
+
+
+class Image(Directive):
+
+    align_h_values = ('left', 'center', 'right')
+    align_v_values = ('top', 'middle', 'bottom')
+    align_values = align_v_values + align_h_values
+    loading_values = ('embed', 'link', 'lazy')
+
+    def align(argument):
+        # This is not callable as `self.align()`.  We cannot make it a
+        # staticmethod because we're saving an unbound method in
+        # option_spec below.
+        return directives.choice(argument, Image.align_values)
+
+    def loading(argument):
+        # This is not callable as `self.loading()` (see above).
+        return directives.choice(argument, Image.loading_values)
+
+    required_arguments = 1
+    optional_arguments = 0
+    final_argument_whitespace = True
+    option_spec = {'alt': directives.unchanged,
+                   'height': directives.length_or_unitless,
+                   'width': directives.length_or_percentage_or_unitless,
+                   'scale': directives.percentage,
+                   'align': align,
+                   'target': directives.unchanged_required,
+                   'loading': loading,
+                   'class': directives.class_option,
+                   'name': directives.unchanged}
+
+    def run(self):
+        if 'align' in self.options:
+            if isinstance(self.state, states.SubstitutionDef):
+                # Check for align_v_values.
+                if self.options['align'] not in self.align_v_values:
+                    raise self.error(
+                        'Error in "%s" directive: "%s" is not a valid value '
+                        'for the "align" option within a substitution '
+                        'definition.  Valid values for "align" are: "%s".'
+                        % (self.name, self.options['align'],
+                           '", "'.join(self.align_v_values)))
+            elif self.options['align'] not in self.align_h_values:
+                raise self.error(
+                    'Error in "%s" directive: "%s" is not a valid value for '
+                    'the "align" option.  Valid values for "align" are: "%s".'
+                    % (self.name, self.options['align'],
+                       '", "'.join(self.align_h_values)))
+        messages = []
+        reference = directives.uri(self.arguments[0])
+        self.options['uri'] = reference
+        reference_node = None
+        if 'target' in self.options:
+            block = states.escape2null(
+                self.options['target']).splitlines()
+            block = [line for line in block]
+            target_type, data = self.state.parse_target(
+                block, self.block_text, self.lineno)
+            if target_type == 'refuri':
+                reference_node = nodes.reference(refuri=data)
+            elif target_type == 'refname':
+                reference_node = nodes.reference(
+                    refname=fully_normalize_name(data),
+                    name=whitespace_normalize_name(data))
+                reference_node.indirect_reference_name = data
+                self.state.document.note_refname(reference_node)
+            else:                           # malformed target
+                messages.append(data)       # data is a system message
+            del self.options['target']
+        set_classes(self.options)
+        image_node = nodes.image(self.block_text, **self.options)
+        (image_node.source,
+         image_node.line) = self.state_machine.get_source_and_line(self.lineno)
+        self.add_name(image_node)
+        if reference_node:
+            reference_node += image_node
+            return messages + [reference_node]
+        else:
+            return messages + [image_node]
+
+
+class Figure(Image):
+
+    def align(argument):
+        return directives.choice(argument, Figure.align_h_values)
+
+    def figwidth_value(argument):
+        if argument.lower() == 'image':
+            return 'image'
+        else:
+            return directives.length_or_percentage_or_unitless(argument, 'px')
+
+    option_spec = Image.option_spec.copy()
+    option_spec['figwidth'] = figwidth_value
+    option_spec['figclass'] = directives.class_option
+    option_spec['align'] = align
+    has_content = True
+
+    def run(self):
+        figwidth = self.options.pop('figwidth', None)
+        figclasses = self.options.pop('figclass', None)
+        align = self.options.pop('align', None)
+        (image_node,) = Image.run(self)
+        if isinstance(image_node, nodes.system_message):
+            return [image_node]
+        figure_node = nodes.figure('', image_node)
+        (figure_node.source, figure_node.line
+         ) = self.state_machine.get_source_and_line(self.lineno)
+        if figwidth == 'image':
+            if PIL and self.state.document.settings.file_insertion_enabled:
+                imagepath = url2pathname(image_node['uri'])
+                try:
+                    with PIL.Image.open(imagepath) as img:
+                        figure_node['width'] = '%dpx' % img.size[0]
+                except (OSError, UnicodeEncodeError):
+                    pass  # TODO: warn/info?
+                else:
+                    self.state.document.settings.record_dependencies.add(
+                        imagepath.replace('\\', '/'))
+        elif figwidth is not None:
+            figure_node['width'] = figwidth
+        if figclasses:
+            figure_node['classes'] += figclasses
+        if align:
+            figure_node['align'] = align
+        if self.content:
+            node = nodes.Element()          # anonymous container for parsing
+            self.state.nested_parse(self.content, self.content_offset, node)
+            first_node = node[0]
+            if isinstance(first_node, nodes.paragraph):
+                caption = nodes.caption(first_node.rawsource, '',
+                                        *first_node.children)
+                caption.source = first_node.source
+                caption.line = first_node.line
+                figure_node += caption
+            elif not (isinstance(first_node, nodes.comment)
+                      and len(first_node) == 0):
+                error = self.reporter.error(
+                      'Figure caption must be a paragraph or empty comment.',
+                      nodes.literal_block(self.block_text, self.block_text),
+                      line=self.lineno)
+                return [figure_node, error]
+            if len(node) > 1:
+                figure_node += nodes.legend('', *node[1:])
+        return [figure_node]
diff --git a/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/misc.py b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/misc.py
new file mode 100644
index 00000000..c16e9430
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/misc.py
@@ -0,0 +1,642 @@
+# $Id: misc.py 9492 2023-11-29 16:58:13Z milde $
+# Authors: David Goodger <goodger@python.org>; Dethe Elza
+# Copyright: This module has been placed in the public domain.
+
+"""Miscellaneous directives."""
+
+__docformat__ = 'reStructuredText'
+
+from pathlib import Path
+import re
+import time
+from urllib.request import urlopen
+from urllib.error import URLError
+
+from docutils import io, nodes, statemachine, utils
+from docutils.parsers.rst import Directive, convert_directive_function
+from docutils.parsers.rst import directives, roles, states
+from docutils.parsers.rst.directives.body import CodeBlock, NumberLines
+from docutils.transforms import misc
+
+
+def adapt_path(path, source='', root_prefix='/'):
+    # Adapt path to files to include or embed.
+    # `root_prefix` is prepended to absolute paths (cf. root_prefix setting),
+    # `source` is the `current_source` of the including directive (which may
+    # be a file included by the main document).
+    if path.startswith('/'):
+        base = Path(root_prefix)
+        path = path[1:]
+    else:
+        base = Path(source).parent
+    # pepend "base" and convert to relative path for shorter system messages
+    return utils.relative_path(None, base/path)
+
+
+class Include(Directive):
+
+    """
+    Include content read from a separate source file.
+
+    Content may be parsed by the parser, or included as a literal
+    block.  The encoding of the included file can be specified.  Only
+    a part of the given file argument may be included by specifying
+    start and end line or text to match before and/or after the text
+    to be used.
+
+    https://docutils.sourceforge.io/docs/ref/rst/directives.html#including-an-external-document-fragment
+    """
+
+    required_arguments = 1
+    optional_arguments = 0
+    final_argument_whitespace = True
+    option_spec = {'literal': directives.flag,
+                   'code': directives.unchanged,
+                   'encoding': directives.encoding,
+                   'parser': directives.parser_name,
+                   'tab-width': int,
+                   'start-line': int,
+                   'end-line': int,
+                   'start-after': directives.unchanged_required,
+                   'end-before': directives.unchanged_required,
+                   # ignored except for 'literal' or 'code':
+                   'number-lines': directives.unchanged,  # integer or None
+                   'class': directives.class_option,
+                   'name': directives.unchanged}
+
+    standard_include_path = Path(states.__file__).parent / 'include'
+
+    def run(self):
+        """Include a file as part of the content of this reST file.
+
+        Depending on the options, the file (or a clipping) is
+        converted to nodes and returned or inserted into the input stream.
+        """
+        settings = self.state.document.settings
+        if not settings.file_insertion_enabled:
+            raise self.warning('"%s" directive disabled.' % self.name)
+        tab_width = self.options.get('tab-width', settings.tab_width)
+        current_source = self.state.document.current_source
+        path = directives.path(self.arguments[0])
+        if path.startswith('<') and path.endswith('>'):
+            path = '/' + path[1:-1]
+            root_prefix = self.standard_include_path
+        else:
+            root_prefix = settings.root_prefix
+        path = adapt_path(path, current_source, root_prefix)
+        encoding = self.options.get('encoding', settings.input_encoding)
+        error_handler = settings.input_encoding_error_handler
+        try:
+            include_file = io.FileInput(source_path=path,
+                                        encoding=encoding,
+                                        error_handler=error_handler)
+        except UnicodeEncodeError:
+            raise self.severe(f'Problems with "{self.name}" directive path:\n'
+                              f'Cannot encode input file path "{path}" '
+                              '(wrong locale?).')
+        except OSError as error:
+            raise self.severe(f'Problems with "{self.name}" directive '
+                              f'path:\n{io.error_string(error)}.')
+        else:
+            settings.record_dependencies.add(path)
+
+        # Get to-be-included content
+        startline = self.options.get('start-line', None)
+        endline = self.options.get('end-line', None)
+        try:
+            if startline or (endline is not None):
+                lines = include_file.readlines()
+                rawtext = ''.join(lines[startline:endline])
+            else:
+                rawtext = include_file.read()
+        except UnicodeError as error:
+            raise self.severe(f'Problem with "{self.name}" directive:\n'
+                              + io.error_string(error))
+        # start-after/end-before: no restrictions on newlines in match-text,
+        # and no restrictions on matching inside lines vs. line boundaries
+        after_text = self.options.get('start-after', None)
+        if after_text:
+            # skip content in rawtext before *and incl.* a matching text
+            after_index = rawtext.find(after_text)
+            if after_index < 0:
+                raise self.severe('Problem with "start-after" option of "%s" '
+                                  'directive:\nText not found.' % self.name)
+            rawtext = rawtext[after_index + len(after_text):]
+        before_text = self.options.get('end-before', None)
+        if before_text:
+            # skip content in rawtext after *and incl.* a matching text
+            before_index = rawtext.find(before_text)
+            if before_index < 0:
+                raise self.severe('Problem with "end-before" option of "%s" '
+                                  'directive:\nText not found.' % self.name)
+            rawtext = rawtext[:before_index]
+
+        include_lines = statemachine.string2lines(rawtext, tab_width,
+                                                  convert_whitespace=True)
+        for i, line in enumerate(include_lines):
+            if len(line) > settings.line_length_limit:
+                raise self.warning('"%s": line %d exceeds the'
+                                   ' line-length-limit.' % (path, i+1))
+
+        if 'literal' in self.options:
+            # Don't convert tabs to spaces, if `tab_width` is negative.
+            if tab_width >= 0:
+                text = rawtext.expandtabs(tab_width)
+            else:
+                text = rawtext
+            literal_block = nodes.literal_block(
+                                rawtext, source=path,
+                                classes=self.options.get('class', []))
+            literal_block.line = 1
+            self.add_name(literal_block)
+            if 'number-lines' in self.options:
+                try:
+                    startline = int(self.options['number-lines'] or 1)
+                except ValueError:
+                    raise self.error(':number-lines: with non-integer '
+                                     'start value')
+                endline = startline + len(include_lines)
+                if text.endswith('\n'):
+                    text = text[:-1]
+                tokens = NumberLines([([], text)], startline, endline)
+                for classes, value in tokens:
+                    if classes:
+                        literal_block += nodes.inline(value, value,
+                                                      classes=classes)
+                    else:
+                        literal_block += nodes.Text(value)
+            else:
+                literal_block += nodes.Text(text)
+            return [literal_block]
+
+        if 'code' in self.options:
+            self.options['source'] = path
+            # Don't convert tabs to spaces, if `tab_width` is negative:
+            if tab_width < 0:
+                include_lines = rawtext.splitlines()
+            codeblock = CodeBlock(self.name,
+                                  [self.options.pop('code')],  # arguments
+                                  self.options,
+                                  include_lines,  # content
+                                  self.lineno,
+                                  self.content_offset,
+                                  self.block_text,
+                                  self.state,
+                                  self.state_machine)
+            return codeblock.run()
+
+        # Prevent circular inclusion:
+        clip_options = (startline, endline, before_text, after_text)
+        include_log = self.state.document.include_log
+        # log entries are tuples (<source>, <clip-options>)
+        if not include_log:  # new document, initialize with document source
+            include_log.append((utils.relative_path(None, current_source),
+                                (None, None, None, None)))
+        if (path, clip_options) in include_log:
+            master_paths = (pth for (pth, opt) in reversed(include_log))
+            inclusion_chain = '\n> '.join((path, *master_paths))
+            raise self.warning('circular inclusion in "%s" directive:\n%s'
+                               % (self.name, inclusion_chain))
+
+        if 'parser' in self.options:
+            # parse into a dummy document and return created nodes
+            document = utils.new_document(path, settings)
+            document.include_log = include_log + [(path, clip_options)]
+            parser = self.options['parser']()
+            parser.parse('\n'.join(include_lines), document)
+            # clean up doctree and complete parsing
+            document.transformer.populate_from_components((parser,))
+            document.transformer.apply_transforms()
+            return document.children
+
+        # Include as rST source:
+        #
+        # mark end (cf. parsers.rst.states.Body.comment())
+        include_lines += ['', '.. end of inclusion from "%s"' % path]
+        self.state_machine.insert_input(include_lines, path)
+        # update include-log
+        include_log.append((path, clip_options))
+        return []
+
+
+class Raw(Directive):
+
+    """
+    Pass through content unchanged
+
+    Content is included in output based on type argument
+
+    Content may be included inline (content section of directive) or
+    imported from a file or url.
+    """
+
+    required_arguments = 1
+    optional_arguments = 0
+    final_argument_whitespace = True
+    option_spec = {'file': directives.path,
+                   'url': directives.uri,
+                   'encoding': directives.encoding,
+                   'class': directives.class_option}
+    has_content = True
+
+    def run(self):
+        settings = self.state.document.settings
+        if (not settings.raw_enabled
+            or (not settings.file_insertion_enabled
+                and ('file' in self.options or 'url' in self.options))):
+            raise self.warning('"%s" directive disabled.' % self.name)
+        attributes = {'format': ' '.join(self.arguments[0].lower().split())}
+        encoding = self.options.get('encoding', settings.input_encoding)
+        error_handler = settings.input_encoding_error_handler
+        if self.content:
+            if 'file' in self.options or 'url' in self.options:
+                raise self.error(
+                    '"%s" directive may not both specify an external file '
+                    'and have content.' % self.name)
+            text = '\n'.join(self.content)
+        elif 'file' in self.options:
+            if 'url' in self.options:
+                raise self.error(
+                    'The "file" and "url" options may not be simultaneously '
+                    'specified for the "%s" directive.' % self.name)
+            path = adapt_path(self.options['file'],
+                              self.state.document.current_source,
+                              settings.root_prefix)
+            try:
+                raw_file = io.FileInput(source_path=path,
+                                        encoding=encoding,
+                                        error_handler=error_handler)
+            except OSError as error:
+                raise self.severe(f'Problems with "{self.name}" directive '
+                                  f'path:\n{io.error_string(error)}.')
+            else:
+                # TODO: currently, raw input files are recorded as
+                # dependencies even if not used for the chosen output format.
+                settings.record_dependencies.add(path)
+            try:
+                text = raw_file.read()
+            except UnicodeError as error:
+                raise self.severe(f'Problem with "{self.name}" directive:\n'
+                                  + io.error_string(error))
+            attributes['source'] = path
+        elif 'url' in self.options:
+            source = self.options['url']
+            try:
+                raw_text = urlopen(source).read()
+            except (URLError, OSError) as error:
+                raise self.severe(f'Problems with "{self.name}" directive URL '
+                                  f'"{self.options["url"]}":\n'
+                                  f'{io.error_string(error)}.')
+            raw_file = io.StringInput(source=raw_text, source_path=source,
+                                      encoding=encoding,
+                                      error_handler=error_handler)
+            try:
+                text = raw_file.read()
+            except UnicodeError as error:
+                raise self.severe(f'Problem with "{self.name}" directive:\n'
+                                  + io.error_string(error))
+            attributes['source'] = source
+        else:
+            # This will always fail because there is no content.
+            self.assert_has_content()
+        raw_node = nodes.raw('', text, classes=self.options.get('class', []),
+                             **attributes)
+        (raw_node.source,
+         raw_node.line) = self.state_machine.get_source_and_line(self.lineno)
+        return [raw_node]
+
+
+class Replace(Directive):
+
+    has_content = True
+
+    def run(self):
+        if not isinstance(self.state, states.SubstitutionDef):
+            raise self.error(
+                'Invalid context: the "%s" directive can only be used within '
+                'a substitution definition.' % self.name)
+        self.assert_has_content()
+        text = '\n'.join(self.content)
+        element = nodes.Element(text)
+        self.state.nested_parse(self.content, self.content_offset,
+                                element)
+        # element might contain [paragraph] + system_message(s)
+        node = None
+        messages = []
+        for elem in element:
+            if not node and isinstance(elem, nodes.paragraph):
+                node = elem
+            elif isinstance(elem, nodes.system_message):
+                elem['backrefs'] = []
+                messages.append(elem)
+            else:
+                return [
+                    self.reporter.error(
+                        f'Error in "{self.name}" directive: may contain '
+                        'a single paragraph only.', line=self.lineno)]
+        if node:
+            return messages + node.children
+        return messages
+
+
+class Unicode(Directive):
+
+    r"""
+    Convert Unicode character codes (numbers) to characters.  Codes may be
+    decimal numbers, hexadecimal numbers (prefixed by ``0x``, ``x``, ``\x``,
+    ``U+``, ``u``, or ``\u``; e.g. ``U+262E``), or XML-style numeric character
+    entities (e.g. ``&#x262E;``).  Text following ".." is a comment and is
+    ignored.  Spaces are ignored, and any other text remains as-is.
+    """
+
+    required_arguments = 1
+    optional_arguments = 0
+    final_argument_whitespace = True
+    option_spec = {'trim': directives.flag,
+                   'ltrim': directives.flag,
+                   'rtrim': directives.flag}
+
+    comment_pattern = re.compile(r'( |\n|^)\.\. ')
+
+    def run(self):
+        if not isinstance(self.state, states.SubstitutionDef):
+            raise self.error(
+                'Invalid context: the "%s" directive can only be used within '
+                'a substitution definition.' % self.name)
+        substitution_definition = self.state_machine.node
+        if 'trim' in self.options:
+            substitution_definition.attributes['ltrim'] = 1
+            substitution_definition.attributes['rtrim'] = 1
+        if 'ltrim' in self.options:
+            substitution_definition.attributes['ltrim'] = 1
+        if 'rtrim' in self.options:
+            substitution_definition.attributes['rtrim'] = 1
+        codes = self.comment_pattern.split(self.arguments[0])[0].split()
+        element = nodes.Element()
+        for code in codes:
+            try:
+                decoded = directives.unicode_code(code)
+            except ValueError as error:
+                raise self.error('Invalid character code: %s\n%s'
+                                 % (code, io.error_string(error)))
+            element += nodes.Text(decoded)
+        return element.children
+
+
+class Class(Directive):
+
+    """
+    Set a "class" attribute on the directive content or the next element.
+    When applied to the next element, a "pending" element is inserted, and a
+    transform does the work later.
+    """
+
+    required_arguments = 1
+    optional_arguments = 0
+    final_argument_whitespace = True
+    has_content = True
+
+    def run(self):
+        try:
+            class_value = directives.class_option(self.arguments[0])
+        except ValueError:
+            raise self.error(
+                'Invalid class attribute value for "%s" directive: "%s".'
+                % (self.name, self.arguments[0]))
+        node_list = []
+        if self.content:
+            container = nodes.Element()
+            self.state.nested_parse(self.content, self.content_offset,
+                                    container)
+            for node in container:
+                node['classes'].extend(class_value)
+            node_list.extend(container.children)
+        else:
+            pending = nodes.pending(
+                misc.ClassAttribute,
+                {'class': class_value, 'directive': self.name},
+                self.block_text)
+            self.state_machine.document.note_pending(pending)
+            node_list.append(pending)
+        return node_list
+
+
+class Role(Directive):
+
+    has_content = True
+
+    argument_pattern = re.compile(r'(%s)\s*(\(\s*(%s)\s*\)\s*)?$'
+                                  % ((states.Inliner.simplename,) * 2))
+
+    def run(self):
+        """Dynamically create and register a custom interpreted text role."""
+        if self.content_offset > self.lineno or not self.content:
+            raise self.error('"%s" directive requires arguments on the first '
+                             'line.' % self.name)
+        args = self.content[0]
+        match = self.argument_pattern.match(args)
+        if not match:
+            raise self.error('"%s" directive arguments not valid role names: '
+                             '"%s".' % (self.name, args))
+        new_role_name = match.group(1)
+        base_role_name = match.group(3)
+        messages = []
+        if base_role_name:
+            base_role, messages = roles.role(
+                base_role_name, self.state_machine.language, self.lineno,
+                self.state.reporter)
+            if base_role is None:
+                error = self.state.reporter.error(
+                    'Unknown interpreted text role "%s".' % base_role_name,
+                    nodes.literal_block(self.block_text, self.block_text),
+                    line=self.lineno)
+                return messages + [error]
+        else:
+            base_role = roles.generic_custom_role
+        assert not hasattr(base_role, 'arguments'), (
+            'Supplemental directive arguments for "%s" directive not '
+            'supported (specified by "%r" role).' % (self.name, base_role))
+        try:
+            converted_role = convert_directive_function(base_role)
+            (arguments, options, content, content_offset
+             ) = self.state.parse_directive_block(
+                    self.content[1:], self.content_offset,
+                    converted_role, option_presets={})
+        except states.MarkupError as detail:
+            error = self.reporter.error(
+                'Error in "%s" directive:\n%s.' % (self.name, detail),
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            return messages + [error]
+        if 'class' not in options:
+            try:
+                options['class'] = directives.class_option(new_role_name)
+            except ValueError as detail:
+                error = self.reporter.error(
+                    'Invalid argument for "%s" directive:\n%s.'
+                    % (self.name, detail),
+                    nodes.literal_block(self.block_text, self.block_text),
+                    line=self.lineno)
+                return messages + [error]
+        role = roles.CustomRole(new_role_name, base_role, options, content)
+        roles.register_local_role(new_role_name, role)
+        return messages
+
+
+class DefaultRole(Directive):
+
+    """Set the default interpreted text role."""
+
+    optional_arguments = 1
+    final_argument_whitespace = False
+
+    def run(self):
+        if not self.arguments:
+            if '' in roles._roles:
+                # restore the "default" default role
+                del roles._roles['']
+            return []
+        role_name = self.arguments[0]
+        role, messages = roles.role(role_name, self.state_machine.language,
+                                    self.lineno, self.state.reporter)
+        if role is None:
+            error = self.state.reporter.error(
+                'Unknown interpreted text role "%s".' % role_name,
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            return messages + [error]
+        roles._roles[''] = role
+        return messages
+
+
+class Title(Directive):
+
+    required_arguments = 1
+    optional_arguments = 0
+    final_argument_whitespace = True
+
+    def run(self):
+        self.state_machine.document['title'] = self.arguments[0]
+        return []
+
+
+class MetaBody(states.SpecializedBody):
+
+    def field_marker(self, match, context, next_state):
+        """Meta element."""
+        node, blank_finish = self.parsemeta(match)
+        self.parent += node
+        return [], next_state, []
+
+    def parsemeta(self, match):
+        name = self.parse_field_marker(match)
+        name = nodes.unescape(utils.escape2null(name))
+        (indented, indent, line_offset, blank_finish
+         ) = self.state_machine.get_first_known_indented(match.end())
+        node = nodes.meta()
+        node['content'] = nodes.unescape(utils.escape2null(
+                                            ' '.join(indented)))
+        if not indented:
+            line = self.state_machine.line
+            msg = self.reporter.info(
+                  'No content for meta tag "%s".' % name,
+                  nodes.literal_block(line, line))
+            return msg, blank_finish
+        tokens = name.split()
+        try:
+            attname, val = utils.extract_name_value(tokens[0])[0]
+            node[attname.lower()] = val
+        except utils.NameValueError:
+            node['name'] = tokens[0]
+        for token in tokens[1:]:
+            try:
+                attname, val = utils.extract_name_value(token)[0]
+                node[attname.lower()] = val
+            except utils.NameValueError as detail:
+                line = self.state_machine.line
+                msg = self.reporter.error(
+                      'Error parsing meta tag attribute "%s": %s.'
+                      % (token, detail), nodes.literal_block(line, line))
+                return msg, blank_finish
+        return node, blank_finish
+
+
+class Meta(Directive):
+
+    has_content = True
+
+    SMkwargs = {'state_classes': (MetaBody,)}
+
+    def run(self):
+        self.assert_has_content()
+        node = nodes.Element()
+        new_line_offset, blank_finish = self.state.nested_list_parse(
+            self.content, self.content_offset, node,
+            initial_state='MetaBody', blank_finish=True,
+            state_machine_kwargs=self.SMkwargs)
+        if (new_line_offset - self.content_offset) != len(self.content):
+            # incomplete parse of block?
+            error = self.reporter.error(
+                'Invalid meta directive.',
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            node += error
+        # insert at begin of document
+        index = self.state.document.first_child_not_matching_class(
+                                        (nodes.Titular, nodes.meta)) or 0
+        self.state.document[index:index] = node.children
+        return []
+
+
+class Date(Directive):
+
+    has_content = True
+
+    def run(self):
+        if not isinstance(self.state, states.SubstitutionDef):
+            raise self.error(
+                'Invalid context: the "%s" directive can only be used within '
+                'a substitution definition.' % self.name)
+        format_str = '\n'.join(self.content) or '%Y-%m-%d'
+        # @@@
+        # Use timestamp from the `SOURCE_DATE_EPOCH`_ environment variable?
+        # Pro: Docutils-generated documentation
+        #      can easily be part of `reproducible software builds`__
+        #
+        #      __ https://reproducible-builds.org/
+        #
+        # Con: Changes the specs, hard to predict behaviour,
+        #
+        # See also the discussion about \date \time \year in TeX
+        # http://tug.org/pipermail/tex-k/2016-May/002704.html
+        # source_date_epoch = os.environ.get('SOURCE_DATE_EPOCH')
+        # if (source_date_epoch):
+        #     text = time.strftime(format_str,
+        #                          time.gmtime(int(source_date_epoch)))
+        # else:
+        text = time.strftime(format_str)
+        return [nodes.Text(text)]
+
+
+class TestDirective(Directive):
+
+    """This directive is useful only for testing purposes."""
+
+    optional_arguments = 1
+    final_argument_whitespace = True
+    option_spec = {'option': directives.unchanged_required}
+    has_content = True
+
+    def run(self):
+        if self.content:
+            text = '\n'.join(self.content)
+            info = self.reporter.info(
+                'Directive processed. Type="%s", arguments=%r, options=%r, '
+                'content:' % (self.name, self.arguments, self.options),
+                nodes.literal_block(text, text), line=self.lineno)
+        else:
+            info = self.reporter.info(
+                'Directive processed. Type="%s", arguments=%r, options=%r, '
+                'content: None' % (self.name, self.arguments, self.options),
+                line=self.lineno)
+        return [info]
diff --git a/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/parts.py b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/parts.py
new file mode 100644
index 00000000..adb01d03
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/parts.py
@@ -0,0 +1,126 @@
+# $Id: parts.py 8993 2022-01-29 13:20:04Z milde $
+# Authors: David Goodger <goodger@python.org>; Dmitry Jemerov
+# Copyright: This module has been placed in the public domain.
+
+"""
+Directives for document parts.
+"""
+
+__docformat__ = 'reStructuredText'
+
+from docutils import nodes, languages
+from docutils.transforms import parts
+from docutils.parsers.rst import Directive
+from docutils.parsers.rst import directives
+
+
+class Contents(Directive):
+
+    """
+    Table of contents.
+
+    The table of contents is generated in two passes: initial parse and
+    transform.  During the initial parse, a 'pending' element is generated
+    which acts as a placeholder, storing the TOC title and any options
+    internally.  At a later stage in the processing, the 'pending' element is
+    replaced by a 'topic' element, a title and the table of contents proper.
+    """
+
+    backlinks_values = ('top', 'entry', 'none')
+
+    def backlinks(arg):
+        value = directives.choice(arg, Contents.backlinks_values)
+        if value == 'none':
+            return None
+        else:
+            return value
+
+    optional_arguments = 1
+    final_argument_whitespace = True
+    option_spec = {'depth': directives.nonnegative_int,
+                   'local': directives.flag,
+                   'backlinks': backlinks,
+                   'class': directives.class_option}
+
+    def run(self):
+        if not (self.state_machine.match_titles
+                or isinstance(self.state_machine.node, nodes.sidebar)):
+            raise self.error('The "%s" directive may not be used within '
+                             'topics or body elements.' % self.name)
+        document = self.state_machine.document
+        language = languages.get_language(document.settings.language_code,
+                                          document.reporter)
+        if self.arguments:
+            title_text = self.arguments[0]
+            text_nodes, messages = self.state.inline_text(title_text,
+                                                          self.lineno)
+            title = nodes.title(title_text, '', *text_nodes)
+        else:
+            messages = []
+            if 'local' in self.options:
+                title = None
+            else:
+                title = nodes.title('', language.labels['contents'])
+        topic = nodes.topic(classes=['contents'])
+        topic['classes'] += self.options.get('class', [])
+        # the latex2e writer needs source and line for a warning:
+        topic.source, topic.line = self.state_machine.get_source_and_line()
+        topic.line -= 1
+        if 'local' in self.options:
+            topic['classes'].append('local')
+        if title:
+            name = title.astext()
+            topic += title
+        else:
+            name = language.labels['contents']
+        name = nodes.fully_normalize_name(name)
+        if not document.has_name(name):
+            topic['names'].append(name)
+        document.note_implicit_target(topic)
+        pending = nodes.pending(parts.Contents, rawsource=self.block_text)
+        pending.details.update(self.options)
+        document.note_pending(pending)
+        topic += pending
+        return [topic] + messages
+
+
+class Sectnum(Directive):
+
+    """Automatic section numbering."""
+
+    option_spec = {'depth': int,
+                   'start': int,
+                   'prefix': directives.unchanged_required,
+                   'suffix': directives.unchanged_required}
+
+    def run(self):
+        pending = nodes.pending(parts.SectNum)
+        pending.details.update(self.options)
+        self.state_machine.document.note_pending(pending)
+        return [pending]
+
+
+class Header(Directive):
+
+    """Contents of document header."""
+
+    has_content = True
+
+    def run(self):
+        self.assert_has_content()
+        header = self.state_machine.document.get_decoration().get_header()
+        self.state.nested_parse(self.content, self.content_offset, header)
+        return []
+
+
+class Footer(Directive):
+
+    """Contents of document footer."""
+
+    has_content = True
+
+    def run(self):
+        self.assert_has_content()
+        footer = self.state_machine.document.get_decoration().get_footer()
+        self.state.nested_parse(self.content, self.content_offset, footer)
+        return []
diff --git a/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/references.py b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/references.py
new file mode 100644
index 00000000..96921f9d
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/references.py
@@ -0,0 +1,29 @@
+# $Id: references.py 7062 2011-06-30 22:14:29Z milde $
+# Authors: David Goodger <goodger@python.org>; Dmitry Jemerov
+# Copyright: This module has been placed in the public domain.
+
+"""
+Directives for references and targets.
+"""
+
+__docformat__ = 'reStructuredText'
+
+from docutils import nodes
+from docutils.transforms import references
+from docutils.parsers.rst import Directive
+from docutils.parsers.rst import directives
+
+
+class TargetNotes(Directive):
+
+    """Target footnote generation."""
+
+    option_spec = {'class': directives.class_option,
+                   'name': directives.unchanged}
+
+    def run(self):
+        pending = nodes.pending(references.TargetNotes)
+        self.add_name(pending)
+        pending.details.update(self.options)
+        self.state_machine.document.note_pending(pending)
+        return [pending]
diff --git a/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/tables.py b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/tables.py
new file mode 100644
index 00000000..2cc266ff
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/parsers/rst/directives/tables.py
@@ -0,0 +1,538 @@
+# $Id: tables.py 9492 2023-11-29 16:58:13Z milde $
+# Authors: David Goodger <goodger@python.org>; David Priest
+# Copyright: This module has been placed in the public domain.
+
+"""
+Directives for table elements.
+"""
+
+__docformat__ = 'reStructuredText'
+
+
+import csv
+from urllib.request import urlopen
+from urllib.error import URLError
+import warnings
+
+from docutils import nodes, statemachine
+from docutils.io import FileInput, StringInput
+from docutils.parsers.rst import Directive
+from docutils.parsers.rst import directives
+from docutils.parsers.rst.directives.misc import adapt_path
+from docutils.utils import SystemMessagePropagation
+
+
+def align(argument):
+    return directives.choice(argument, ('left', 'center', 'right'))
+
+
+class Table(Directive):
+
+    """
+    Generic table base class.
+    """
+
+    optional_arguments = 1
+    final_argument_whitespace = True
+    option_spec = {'class': directives.class_option,
+                   'name': directives.unchanged,
+                   'align': align,
+                   'width': directives.length_or_percentage_or_unitless,
+                   'widths': directives.value_or(('auto', 'grid'),
+                                                 directives.positive_int_list)}
+    has_content = True
+
+    def make_title(self):
+        if self.arguments:
+            title_text = self.arguments[0]
+            text_nodes, messages = self.state.inline_text(title_text,
+                                                          self.lineno)
+            title = nodes.title(title_text, '', *text_nodes)
+            (title.source,
+             title.line) = self.state_machine.get_source_and_line(self.lineno)
+        else:
+            title = None
+            messages = []
+        return title, messages
+
+    def check_table_dimensions(self, rows, header_rows, stub_columns):
+        if len(rows) < header_rows:
+            error = self.reporter.error('%s header row(s) specified but '
+                'only %s row(s) of data supplied ("%s" directive).'
+                % (header_rows, len(rows), self.name),
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            raise SystemMessagePropagation(error)
+        if len(rows) == header_rows > 0:
+            error = self.reporter.error(
+                f'Insufficient data supplied ({len(rows)} row(s)); '
+                'no data remaining for table body, '
+                f'required by "{self.name}" directive.',
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            raise SystemMessagePropagation(error)
+        for row in rows:
+            if len(row) < stub_columns:
+                error = self.reporter.error(
+                    f'{stub_columns} stub column(s) specified '
+                    f'but only {len(row)} columns(s) of data supplied '
+                    f'("{self.name}" directive).',
+                    nodes.literal_block(self.block_text, self.block_text),
+                    line=self.lineno)
+                raise SystemMessagePropagation(error)
+            if len(row) == stub_columns > 0:
+                error = self.reporter.error(
+                    'Insufficient data supplied (%s columns(s)); '
+                    'no data remaining for table body, required '
+                    'by "%s" directive.' % (len(row), self.name),
+                    nodes.literal_block(self.block_text, self.block_text),
+                    line=self.lineno)
+                raise SystemMessagePropagation(error)
+
+    def set_table_width(self, table_node):
+        if 'width' in self.options:
+            table_node['width'] = self.options.get('width')
+
+    @property
+    def widths(self):
+        return self.options.get('widths', '')
+
+    def get_column_widths(self, n_cols):
+        if isinstance(self.widths, list):
+            if len(self.widths) != n_cols:
+                # TODO: use last value for missing columns?
+                error = self.reporter.error('"%s" widths do not match the '
+                    'number of columns in table (%s).' % (self.name, n_cols),
+                    nodes.literal_block(self.block_text, self.block_text),
+                    line=self.lineno)
+                raise SystemMessagePropagation(error)
+            col_widths = self.widths
+        elif n_cols:
+            col_widths = [100 // n_cols] * n_cols
+        else:
+            error = self.reporter.error('No table data detected in CSV file.',
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            raise SystemMessagePropagation(error)
+        return col_widths
+
+    def extend_short_rows_with_empty_cells(self, columns, parts):
+        for part in parts:
+            for row in part:
+                if len(row) < columns:
+                    row.extend([(0, 0, 0, [])] * (columns - len(row)))
+
+
+class RSTTable(Table):
+    """
+    Class for the `"table" directive`__ for formal tables using rST syntax.
+
+    __ https://docutils.sourceforge.io/docs/ref/rst/directives.html
+    """
+
+    def run(self):
+        if not self.content:
+            warning = self.reporter.warning('Content block expected '
+                'for the "%s" directive; none found.' % self.name,
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            return [warning]
+        title, messages = self.make_title()
+        node = nodes.Element()          # anonymous container for parsing
+        self.state.nested_parse(self.content, self.content_offset, node)
+        if len(node) != 1 or not isinstance(node[0], nodes.table):
+            error = self.reporter.error('Error parsing content block for the '
+                '"%s" directive: exactly one table expected.' % self.name,
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            return [error]
+        table_node = node[0]
+        table_node['classes'] += self.options.get('class', [])
+        self.set_table_width(table_node)
+        if 'align' in self.options:
+            table_node['align'] = self.options.get('align')
+        if isinstance(self.widths, list):
+            tgroup = table_node[0]
+            try:
+                col_widths = self.get_column_widths(tgroup["cols"])
+            except SystemMessagePropagation as detail:
+                return [detail.args[0]]
+            colspecs = [child for child in tgroup.children
+                        if child.tagname == 'colspec']
+            for colspec, col_width in zip(colspecs, col_widths):
+                colspec['colwidth'] = col_width
+        if self.widths == 'auto':
+            table_node['classes'] += ['colwidths-auto']
+        elif self.widths:  # "grid" or list of integers
+            table_node['classes'] += ['colwidths-given']
+        self.add_name(table_node)
+        if title:
+            table_node.insert(0, title)
+        return [table_node] + messages
+
+
+class CSVTable(Table):
+
+    option_spec = {'header-rows': directives.nonnegative_int,
+                   'stub-columns': directives.nonnegative_int,
+                   'header': directives.unchanged,
+                   'width': directives.length_or_percentage_or_unitless,
+                   'widths': directives.value_or(('auto', ),
+                                                 directives.positive_int_list),
+                   'file': directives.path,
+                   'url': directives.uri,
+                   'encoding': directives.encoding,
+                   'class': directives.class_option,
+                   'name': directives.unchanged,
+                   'align': align,
+                   # field delimiter char
+                   'delim': directives.single_char_or_whitespace_or_unicode,
+                   # treat whitespace after delimiter as significant
+                   'keepspace': directives.flag,
+                   # text field quote/unquote char:
+                   'quote': directives.single_char_or_unicode,
+                   # char used to escape delim & quote as-needed:
+                   'escape': directives.single_char_or_unicode}
+
+    class DocutilsDialect(csv.Dialect):
+
+        """CSV dialect for `csv_table` directive."""
+
+        delimiter = ','
+        quotechar = '"'
+        doublequote = True
+        skipinitialspace = True
+        strict = True
+        lineterminator = '\n'
+        quoting = csv.QUOTE_MINIMAL
+
+        def __init__(self, options):
+            if 'delim' in options:
+                self.delimiter = options['delim']
+            if 'keepspace' in options:
+                self.skipinitialspace = False
+            if 'quote' in options:
+                self.quotechar = options['quote']
+            if 'escape' in options:
+                self.doublequote = False
+                self.escapechar = options['escape']
+            super().__init__()
+
+    class HeaderDialect(csv.Dialect):
+        """
+        CSV dialect used for the "header" option data.
+
+        Deprecated. Will be removed in Docutils 0.22.
+        """
+        # The separate HeaderDialect was introduced in revision 2294
+        # (2004-06-17) in the sandbox before the "csv-table" directive moved
+        # to the trunk in r2309. Discussion in docutils-devel around this time
+        # did not mention a rationale (part of the discussion was in private
+        # mail).
+        # This is in conflict with the documentation, which always said:
+        # "Must use the same CSV format as the main CSV data."
+        # and did not change in this aspect.
+        #
+        # Maybe it was intended to have similar escape rules for rST and CSV,
+        # however with the current implementation this means we need
+        # `\\` for rST markup and ``\\\\`` for a literal backslash
+        # in the "option" header but ``\`` and ``\\`` in the header-lines and
+        # table cells of the main CSV data.
+        delimiter = ','
+        quotechar = '"'
+        escapechar = '\\'
+        doublequote = False
+        skipinitialspace = True
+        strict = True
+        lineterminator = '\n'
+        quoting = csv.QUOTE_MINIMAL
+
+        def __init__(self):
+            warnings.warn('CSVTable.HeaderDialect will be removed '
+                          'in Docutils 0.22.',
+                          PendingDeprecationWarning, stacklevel=2)
+            super().__init__()
+
+    @staticmethod
+    def check_requirements():
+        warnings.warn('CSVTable.check_requirements()'
+                      ' is not required with Python 3'
+                      ' and will be removed in Docutils 0.22.',
+                      DeprecationWarning, stacklevel=2)
+
+    def process_header_option(self):
+        source = self.state_machine.get_source(self.lineno - 1)
+        table_head = []
+        max_header_cols = 0
+        if 'header' in self.options:   # separate table header in option
+            rows, max_header_cols = self.parse_csv_data_into_rows(
+                                        self.options['header'].split('\n'),
+                                        self.DocutilsDialect(self.options),
+                                        source)
+            table_head.extend(rows)
+        return table_head, max_header_cols
+
+    def run(self):
+        try:
+            if (not self.state.document.settings.file_insertion_enabled
+                and ('file' in self.options
+                     or 'url' in self.options)):
+                warning = self.reporter.warning('File and URL access '
+                    'deactivated; ignoring "%s" directive.' % self.name,
+                    nodes.literal_block(self.block_text, self.block_text),
+                    line=self.lineno)
+                return [warning]
+            title, messages = self.make_title()
+            csv_data, source = self.get_csv_data()
+            table_head, max_header_cols = self.process_header_option()
+            rows, max_cols = self.parse_csv_data_into_rows(
+                csv_data, self.DocutilsDialect(self.options), source)
+            max_cols = max(max_cols, max_header_cols)
+            header_rows = self.options.get('header-rows', 0)
+            stub_columns = self.options.get('stub-columns', 0)
+            self.check_table_dimensions(rows, header_rows, stub_columns)
+            table_head.extend(rows[:header_rows])
+            table_body = rows[header_rows:]
+            col_widths = self.get_column_widths(max_cols)
+            self.extend_short_rows_with_empty_cells(max_cols,
+                                                    (table_head, table_body))
+        except SystemMessagePropagation as detail:
+            return [detail.args[0]]
+        except csv.Error as detail:
+            message = str(detail)
+            error = self.reporter.error('Error with CSV data'
+                ' in "%s" directive:\n%s' % (self.name, message),
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            return [error]
+        table = (col_widths, table_head, table_body)
+        table_node = self.state.build_table(table, self.content_offset,
+                                            stub_columns, widths=self.widths)
+        table_node['classes'] += self.options.get('class', [])
+        if 'align' in self.options:
+            table_node['align'] = self.options.get('align')
+        self.set_table_width(table_node)
+        self.add_name(table_node)
+        if title:
+            table_node.insert(0, title)
+        return [table_node] + messages
+
+    def get_csv_data(self):
+        """
+        Get CSV data from the directive content, from an external
+        file, or from a URL reference.
+        """
+        settings = self.state.document.settings
+        encoding = self.options.get('encoding', settings.input_encoding)
+        error_handler = settings.input_encoding_error_handler
+        if self.content:
+            # CSV data is from directive content.
+            if 'file' in self.options or 'url' in self.options:
+                error = self.reporter.error('"%s" directive may not both '
+                    'specify an external file and have content.' % self.name,
+                    nodes.literal_block(self.block_text, self.block_text),
+                    line=self.lineno)
+                raise SystemMessagePropagation(error)
+            source = self.content.source(0)
+            csv_data = self.content
+        elif 'file' in self.options:
+            # CSV data is from an external file.
+            if 'url' in self.options:
+                error = self.reporter.error('The "file" and "url" options '
+                    'may not be simultaneously specified '
+                    'for the "%s" directive.' % self.name,
+                    nodes.literal_block(self.block_text, self.block_text),
+                    line=self.lineno)
+                raise SystemMessagePropagation(error)
+            source = adapt_path(self.options['file'],
+                                self.state.document.current_source,
+                                settings.root_prefix)
+            try:
+                csv_file = FileInput(source_path=source,
+                                     encoding=encoding,
+                                     error_handler=error_handler)
+                csv_data = csv_file.read().splitlines()
+            except OSError as error:
+                severe = self.reporter.severe(
+                    'Problems with "%s" directive path:\n%s.'
+                    % (self.name, error),
+                    nodes.literal_block(self.block_text, self.block_text),
+                    line=self.lineno)
+                raise SystemMessagePropagation(severe)
+            else:
+                settings.record_dependencies.add(source)
+        elif 'url' in self.options:
+            source = self.options['url']
+            try:
+                with urlopen(source) as response:
+                    csv_text = response.read()
+            except (URLError, OSError, ValueError) as error:
+                severe = self.reporter.severe(
+                      'Problems with "%s" directive URL "%s":\n%s.'
+                      % (self.name, self.options['url'], error),
+                      nodes.literal_block(self.block_text, self.block_text),
+                      line=self.lineno)
+                raise SystemMessagePropagation(severe)
+            csv_file = StringInput(source=csv_text, source_path=source,
+                                   encoding=encoding,
+                                   error_handler=error_handler)
+            csv_data = csv_file.read().splitlines()
+        else:
+            error = self.reporter.warning(
+                'The "%s" directive requires content; none supplied.'
+                % self.name,
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            raise SystemMessagePropagation(error)
+        return csv_data, source
+
+    @staticmethod
+    def decode_from_csv(s):
+        warnings.warn('CSVTable.decode_from_csv()'
+                  ' is not required with Python 3'
+                  ' and will be removed in Docutils 0.21 or later.',
+                  DeprecationWarning, stacklevel=2)
+        return s
+
+    @staticmethod
+    def encode_for_csv(s):
+        warnings.warn('CSVTable.encode_from_csv()'
+                  ' is not required with Python 3'
+                  ' and will be removed in Docutils 0.21 or later.',
+                  DeprecationWarning, stacklevel=2)
+        return s
+
+    def parse_csv_data_into_rows(self, csv_data, dialect, source):
+        csv_reader = csv.reader((line + '\n' for line in csv_data),
+                                dialect=dialect)
+        rows = []
+        max_cols = 0
+        for row in csv_reader:
+            row_data = []
+            for cell in row:
+                cell_data = (0, 0, 0, statemachine.StringList(
+                    cell.splitlines(), source=source))
+                row_data.append(cell_data)
+            rows.append(row_data)
+            max_cols = max(max_cols, len(row))
+        return rows, max_cols
+
+
+class ListTable(Table):
+
+    """
+    Implement tables whose data is encoded as a uniform two-level bullet list.
+    For further ideas, see
+    https://docutils.sourceforge.io/docs/dev/rst/alternatives.html#list-driven-tables
+    """
+
+    option_spec = {'header-rows': directives.nonnegative_int,
+                   'stub-columns': directives.nonnegative_int,
+                   'width': directives.length_or_percentage_or_unitless,
+                   'widths': directives.value_or(('auto', ),
+                                                 directives.positive_int_list),
+                   'class': directives.class_option,
+                   'name': directives.unchanged,
+                   'align': align}
+
+    def run(self):
+        if not self.content:
+            error = self.reporter.error('The "%s" directive is empty; '
+                'content required.' % self.name,
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            return [error]
+        title, messages = self.make_title()
+        node = nodes.Element()          # anonymous container for parsing
+        self.state.nested_parse(self.content, self.content_offset, node)
+        try:
+            num_cols, col_widths = self.check_list_content(node)
+            table_data = [[item.children for item in row_list[0]]
+                          for row_list in node[0]]
+            header_rows = self.options.get('header-rows', 0)
+            stub_columns = self.options.get('stub-columns', 0)
+            self.check_table_dimensions(table_data, header_rows, stub_columns)
+        except SystemMessagePropagation as detail:
+            return [detail.args[0]]
+        table_node = self.build_table_from_list(table_data, col_widths,
+                                                header_rows, stub_columns)
+        if 'align' in self.options:
+            table_node['align'] = self.options.get('align')
+        table_node['classes'] += self.options.get('class', [])
+        self.set_table_width(table_node)
+        self.add_name(table_node)
+        if title:
+            table_node.insert(0, title)
+        return [table_node] + messages
+
+    def check_list_content(self, node):
+        if len(node) != 1 or not isinstance(node[0], nodes.bullet_list):
+            error = self.reporter.error(
+                'Error parsing content block for the "%s" directive: '
+                'exactly one bullet list expected.' % self.name,
+                nodes.literal_block(self.block_text, self.block_text),
+                line=self.lineno)
+            raise SystemMessagePropagation(error)
+        list_node = node[0]
+        num_cols = 0
+        # Check for a uniform two-level bullet list:
+        for item_index in range(len(list_node)):
+            item = list_node[item_index]
+            if len(item) != 1 or not isinstance(item[0], nodes.bullet_list):
+                error = self.reporter.error(
+                    'Error parsing content block for the "%s" directive: '
+                    'two-level bullet list expected, but row %s does not '
+                    'contain a second-level bullet list.'
+                    % (self.name, item_index + 1),
+                    nodes.literal_block(self.block_text, self.block_text),
+                    line=self.lineno)
+                raise SystemMessagePropagation(error)
+            elif item_index:
+                if len(item[0]) != num_cols:
+                    error = self.reporter.error(
+                        'Error parsing content block for the "%s" directive: '
+                        'uniform two-level bullet list expected, but row %s '
+                        'does not contain the same number of items as row 1 '
+                        '(%s vs %s).'
+                        % (self.name, item_index + 1, len(item[0]), num_cols),
+                        nodes.literal_block(self.block_text, self.block_text),
+                        line=self.lineno)
+                    raise SystemMessagePropagation(error)
+            else:
+                num_cols = len(item[0])
+        col_widths = self.get_column_widths(num_cols)
+        return num_cols, col_widths
+
+    def build_table_from_list(self, table_data,
+                              col_widths, header_rows, stub_columns):
+        table = nodes.table()
+        if self.widths == 'auto':
+            table['classes'] += ['colwidths-auto']
+        elif self.widths:  # explicitly set column widths
+            table['classes'] += ['colwidths-given']
+        tgroup = nodes.tgroup(cols=len(col_widths))
+        table += tgroup
+        for col_width in col_widths:
+            colspec = nodes.colspec()
+            if col_width is not None:
+                colspec.attributes['colwidth'] = col_width
+            if stub_columns:
+                colspec.attributes['stub'] = 1
+                stub_columns -= 1
+            tgroup += colspec
+        rows = []
+        for row in table_data:
+            row_node = nodes.row()
+            for cell in row:
+                entry = nodes.entry()
+                entry += cell
+                row_node += entry
+            rows.append(row_node)
+        if header_rows:
+            thead = nodes.thead()
+            thead.extend(rows[:header_rows])
+            tgroup += thead
+        tbody = nodes.tbody()
+        tbody.extend(rows[header_rows:])
+        tgroup += tbody
+        return table