# $Id: frontend.py 10196 2025-08-07 06:35:37Z milde $
# Author: David Goodger 
# Copyright: This module has been placed in the public domain.

"""
Command-line and common processing for Docutils front-end tools.

This module is provisional.
Major changes will happen with the transition from the
"optparse" module to "arparse" in Docutils 2.0 or later.

Applications should use the high-level API provided by `docutils.core`.
See https://docutils.sourceforge.io/docs/api/runtime-settings.html.

Exports the following classes:

* `OptionParser`: Standard Docutils command-line processing.
  Deprecated. Will be replaced by an ArgumentParser.
* `Option`: Customized version of `optparse.Option`; validation support.
  Deprecated. Will be removed.
* `Values`: Runtime settings; objects are simple structs
  (``object.attribute``).  Supports cumulative list settings (attributes).
  Deprecated. Will be removed.
* `ConfigParser`: Standard Docutils config file processing.
  Provisional. Details will change.

Also exports the following functions:

Interface function:
  `get_default_settings()`.  New in 0.19.

Option callbacks:
  `store_multiple()`, `read_config_file()`. Deprecated. To be removed.

Setting validators:
  `validate_encoding()`, `validate_encoding_error_handler()`,
  `validate_encoding_and_error_handler()`,
  `validate_boolean()`, `validate_ternary()`,
  `validate_nonnegative_int()`, `validate_threshold()`,
  `validate_colon_separated_string_list()`,
  `validate_comma_separated_list()`,
  `validate_url_trailing_slash()`,
  `validate_dependency_file()`,
  `validate_strip_class()`
  `validate_smartquotes_locales()`.

  Provisional.

Misc:
  `make_paths_absolute()`, `filter_settings_spec()`. Provisional.
"""

from __future__ import annotations

__docformat__ = 'reStructuredText'


import codecs
import configparser
import optparse
import os
import os.path
import sys
import warnings
from optparse import SUPPRESS_HELP
from pathlib import Path

import docutils
from docutils import io, utils

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterable, Mapping, Sequence
    from typing import Any, ClassVar, Literal, Protocol

    from docutils import SettingsSpec, _OptionTuple, _SettingsSpecTuple
    from docutils.io import StrPath

    class _OptionValidator(Protocol):
        def __call__(
            self,
            setting: str,
            value: str | None,
            option_parser: OptionParser,
            /,
            config_parser: ConfigParser | None = None,
            config_section: str | None = None,
        ) -> Any:
            ...


def store_multiple(option: optparse.Option,
                   opt: str,
                   value: Any,
                   parser: OptionParser,
                   *args: str,
                   **kwargs: Any,
                   ) -> None:
    """
    Store multiple values in `parser.values`.  (Option callback.)

    Store `None` for each attribute named in `args`, and store the value for
    each key (attribute name) in `kwargs`.

    Deprecated. Will be removed with the switch to from optparse to argparse.
    """
    for attribute in args:
        setattr(parser.values, attribute, None)
    for key, value in kwargs.items():
        setattr(parser.values, key, value)


def read_config_file(option: optparse.Option,
                     opt: str,
                     value: Any,
                     parser: OptionParser,
                     ) -> None:
    """
    Read a configuration file during option processing.  (Option callback.)

    Deprecated. Will be removed with the switch to from optparse to argparse.
    """
    try:
        new_settings = parser.get_config_file_settings(value)
    except ValueError as err:
        parser.error(err)
    parser.values.update(new_settings, parser)


def validate_encoding(setting: str,
                      value: str | None = None,
                      option_parser: OptionParser | None = None,
                      config_parser: ConfigParser | None = None,
                      config_section: str | None = None,
                      ) -> str | None:
    # All arguments except `value` are ignored
    # (kept for compatibility with "optparse" module).
    # If there is only one positional argument, it is interpreted as `value`.
    if value is None:
        value = setting
    if value == '':
        warnings.warn('Input encoding detection will be removed and the '
                      'special encoding values None and "" become invalid '
                      'in Docutils 1.0.', FutureWarning, stacklevel=2)
        return None
    try:
        codecs.lookup(value)
    except LookupError:
        prefix = f'setting "{setting}":' if setting else ''
        raise LookupError(f'{prefix} unknown encoding: "{value}"')
    return value


def validate_encoding_error_handler(
        setting: str,
        value: str | None = None,
        option_parser: OptionParser | None = None,
        config_parser: ConfigParser | None = None,
        config_section: str | None = None,
        ) -> str:
    # All arguments except `value` are ignored
    # (kept for compatibility with "optparse" module).
    # If there is only one positional argument, it is interpreted as `value`.
    if value is None:
        value = setting
    try:
        codecs.lookup_error(value)
    except LookupError:
        raise LookupError(
            'unknown encoding error handler: "%s" (choices: '
            '"strict", "ignore", "replace", "backslashreplace", '
            '"xmlcharrefreplace", and possibly others; see documentation for '
            'the Python ``codecs`` module)' % value)
    return value


def validate_encoding_and_error_handler(
        setting: str,
        value: str | None = None,
        option_parser: OptionParser | None = None,
        config_parser: ConfigParser | None = None,
        config_section: str | None = None,
        ) -> str:
    """Check/normalize encoding settings

    Side-effect: if an error handler is included in the value, it is inserted
    into the appropriate place as if it were a separate setting/option.

    All arguments except `value` are ignored
    (kept for compatibility with "optparse" module).
    If there is only one positional argument, it is interpreted as `value`.
    """
    if ':' in value:
        encoding, handler = value.split(':')
        validate_encoding_error_handler(handler)
        if config_parser:
            config_parser.set(config_section, setting + '_error_handler',
                              handler)
        else:
            setattr(option_parser.values, setting + '_error_handler', handler)
    else:
        encoding = value
    return validate_encoding(encoding)


def validate_boolean(setting: str | bool,
                     value: str | None = None,
                     option_parser: OptionParser | None = None,
                     config_parser: ConfigParser | None = None,
                     config_section: str | None = None,
                     ) -> bool:
    """Check/normalize boolean settings:

    :True:  '1', 'on', 'yes', 'true'
    :False: '0', 'off', 'no','false', ''

    All arguments except `value` are ignored
    (kept for compatibility with "optparse" module).
    If there is only one positional argument, it is interpreted as `value`.
    """
    if value is None:
        value = setting
    if isinstance(value, bool):
        return value
    try:
        return OptionParser.booleans[value.strip().lower()]
    except KeyError:
        raise LookupError('unknown boolean value: "%s"' % value)


def validate_ternary(setting: str | bool,
                     value: str | None = None,
                     option_parser: OptionParser | None = None,
                     config_parser: ConfigParser | None = None,
                     config_section: str | None = None,
                     ) -> str | bool | None:
    """Check/normalize three-value settings:

    :True:  '1', 'on', 'yes', 'true'
    :False: '0', 'off', 'no','false', ''
    :any other value: returned as-is.

    All arguments except `value` are ignored
    (kept for compatibility with "optparse" module).
    If there is only one positional argument, it is interpreted as `value`.
    """
    if value is None:
        value = setting
    if isinstance(value, bool) or value is None:
        return value
    try:
        return OptionParser.booleans[value.strip().lower()]
    except KeyError:
        return value


def validate_nonnegative_int(setting: str | int,
                             value: str | None = None,
                             option_parser: OptionParser | None = None,
                             config_parser: ConfigParser | None = None,
                             config_section: str | None = None,
                             ) -> int:
    # All arguments except `value` are ignored
    # (kept for compatibility with "optparse" module).
    # If there is only one positional argument, it is interpreted as `value`.
    if value is None:
        value = setting
    value = int(value)
    if value < 0:
        raise ValueError('negative value; must be positive or zero')
    return value


def validate_threshold(setting: str | int,
                       value: str | None = None,
                       option_parser: OptionParser | None = None,
                       config_parser: ConfigParser | None = None,
                       config_section: str | None = None,
                       ) -> int:
    # All arguments except `value` are ignored
    # (kept for compatibility with "optparse" module).
    # If there is only one positional argument, it is interpreted as `value`.
    if value is None:
        value = setting
    try:
        return int(value)
    except ValueError:
        try:
            return OptionParser.thresholds[value.lower()]
        except (KeyError, AttributeError):
            raise LookupError('unknown threshold: %r.' % value)


def validate_colon_separated_string_list(
        setting: str | list[str],
        value: str | None = None,
        option_parser: OptionParser | None = None,
        config_parser: ConfigParser | None = None,
        config_section: str | None = None,
        ) -> list[str]:
    # All arguments except `value` are ignored
    # (kept for compatibility with "optparse" module).
    # If there is only one positional argument, it is interpreted as `value`.
    if value is None:
        value = setting
    if not isinstance(value, list):
        value = value.split(':')
    else:
        last = value.pop()
        value.extend(last.split(':'))
    return value


def validate_comma_separated_list(
        setting: str | list[str],
        value: str | None = None,
        option_parser: OptionParser | None = None,
        config_parser: ConfigParser | None = None,
        config_section: str | None = None,
        ) -> list[str]:
    """Check/normalize list arguments (split at "," and strip whitespace).

    All arguments except `value` are ignored
    (kept for compatibility with "optparse" module).
    If there is only one positional argument, it is interpreted as `value`.
    """
    if value is None:
        value = setting
    # `value` may be ``bytes``, ``str``, or a ``list`` (when  given as
    # command line option and "action" is "append").
    if not isinstance(value, list):
        value = [value]
    # this function is called for every option added to `value`
    # -> split the last item and append the result:
    last = value.pop()
    items = [i.strip(' \t\n') for i in last.split(',') if i.strip(' \t\n')]
    value.extend(items)
    return value


def validate_math_output(setting: str,
                         value: str | None = None,
                         option_parser: OptionParser | None = None,
                         config_parser: ConfigParser | None = None,
                         config_section: str | None = None,
                         ) -> tuple[()] | tuple[str, str]:
    """Check "math-output" setting, return list with "format" and "options".

    See also https://docutils.sourceforge.io/docs/user/config.html#math-output

    Argument list for compatibility with "optparse" module.
    All arguments except `value` are ignored.
    If there is only one positional argument, it is interpreted as `value`.
    """
    if value is None:
        value = setting

    formats = ('html', 'latex', 'mathml', 'mathjax')
    tex2mathml_converters = ('', 'latexml', 'ttm', 'blahtexml', 'pandoc')

    if not value:
        return ()
    values = value.split(maxsplit=1)
    format = values[0].lower()
    try:
        options = values[1]
    except IndexError:
        options = ''
    if format not in formats:
        raise LookupError(f'Unknown math output format: "{value}",\n'
                          f'    choose from {formats}.')
    if format == 'mathml':
        converter = options.lower()
        if converter not in tex2mathml_converters:
            raise LookupError(f'MathML converter "{options}" not supported,\n'
                              f'    choose from {tex2mathml_converters}.')
        options = converter
    return format, options


def validate_url_trailing_slash(setting: str | None,
                                value: str | None = None,
                                option_parser: OptionParser | None = None,
                                config_parser: ConfigParser | None = None,
                                config_section: str | None = None,
                                ) -> str:
    # All arguments except `value` are ignored
    # (kept for compatibility with "optparse" module).
    # If there is only one positional argument, it is interpreted as `value`.
    if value is None:
        value = setting
    if not value:
        return './'
    elif value.endswith('/'):
        return value
    else:
        return value + '/'


def validate_dependency_file(setting: str | None,
                             value: str | None = None,
                             option_parser: OptionParser | None = None,
                             config_parser: ConfigParser | None = None,
                             config_section: str | None = None,
                             ) -> utils.DependencyList:
    # All arguments except `value` are ignored
    # (kept for compatibility with "optparse" module).
    # If there is only one positional argument, it is interpreted as `value`.
    if value is None:
        value = setting
    try:
        return utils.DependencyList(value)
    except OSError:
        # TODO: warn/info?
        return utils.DependencyList(None)


def validate_strip_class(setting: str,
                         value: str | None = None,
                         option_parser: OptionParser | None = None,
                         config_parser: ConfigParser | None = None,
                         config_section: str | None = None,
                         ) -> list[str]:
    # All arguments except `value` are ignored
    # (kept for compatibility with "optparse" module).
    # If there is only one positional argument, it is interpreted as `value`.
    if value is None:
        value = setting
    # value is a comma separated string list:
    value = validate_comma_separated_list(value)
    # validate list elements:
    for cls in value:
        normalized = docutils.nodes.make_id(cls)
        if cls != normalized:
            raise ValueError('Invalid class value %r (perhaps %r?)'
                             % (cls, normalized))
    return value


def validate_smartquotes_locales(
        setting: str | list[str | tuple[str, str]],
        value: str | None = None,
        option_parser: OptionParser | None = None,
        config_parser: ConfigParser | None = None,
        config_section: str | None = None,
        ) -> list[tuple[str, Sequence[str]]]:
    """Check/normalize a comma separated list of smart quote definitions.

    Return a list of (language-tag, quotes) string tuples.

    All arguments except `value` are ignored
    (kept for compatibility with "optparse" module).
    If there is only one positional argument, it is interpreted as `value`.
    """
    if value is None:
        value = setting
    # value is a comma separated string list:
    value = validate_comma_separated_list(value)
    # validate list elements
    lc_quotes = []
    for item in value:
        try:
            lang, quotes = item.split(':', 1)
        except AttributeError:
            # this function is called for every option added to `value`
            # -> ignore if already a tuple:
            lc_quotes.append(item)
            continue
        except ValueError:
            raise ValueError('Invalid value "%s".'
                             ' Format is ":".'
                             % item.encode('ascii', 'backslashreplace'))
        # parse colon separated string list:
        quotes = quotes.strip()
        multichar_quotes = quotes.split(':')
        if len(multichar_quotes) == 4:
            quotes = multichar_quotes
        elif len(quotes) != 4:
            raise ValueError('Invalid value "%s". Please specify 4 quotes\n'
                             '    (primary open/close; secondary open/close).'
                             % item.encode('ascii', 'backslashreplace'))
        lc_quotes.append((lang, quotes))
    return lc_quotes


def make_paths_absolute(pathdict: dict[str, list[StrPath] | StrPath],
                        keys: tuple[str],
                        base_path: StrPath | None = None,
                        ) -> None:
    """
    Interpret filesystem path settings relative to the `base_path` given.

    Paths are values in `pathdict` whose keys are in `keys`.  Get `keys` from
    `OptionParser.relative_path_settings`.
    """
    if base_path is None:
        base_path = Path.cwd()
    else:
        base_path = Path(base_path)
        if sys.platform == 'win32' and sys.version_info[:2] <= (3, 9):
            base_path = base_path.absolute()
    for key in keys:
        if key in pathdict:
            value = pathdict[key]
            if isinstance(value, (list, tuple)):
                value = [str((base_path/path).resolve()) for path in value]
            elif value:
                value = str((base_path/value).resolve())
            pathdict[key] = value


def make_one_path_absolute(base_path: StrPath, path: StrPath) -> str:
    # deprecated, will be removed
    warnings.warn('frontend.make_one_path_absolute() will be removed '
                  'in Docutils 2.0 or later.',
                  DeprecationWarning, stacklevel=2)
    return os.path.abspath(os.path.join(base_path, path))


def filter_settings_spec(settings_spec: _SettingsSpecTuple,
                         *exclude: str,
                         **replace: _OptionTuple,
                         ) -> _SettingsSpecTuple:
    """Return a copy of `settings_spec` excluding/replacing some settings.

    `settings_spec` is a tuple of configuration settings
    (cf. `docutils.SettingsSpec.settings_spec`).

    Optional positional arguments are names of to-be-excluded settings.
    Keyword arguments are option specification replacements.
    (See the html4strict writer for an example.)
    """
    settings = list(settings_spec)
    # every third item is a sequence of option tuples
    for i in range(2, len(settings), 3):
        newopts: list[_OptionTuple] = []
        for opt_spec in settings[i]:
            # opt_spec is ("", [