Source code for click_extra.config.formats

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Configuration file formats and their stateless content parsers.

Holds the :class:`ConfigFormat` enum, the optional third-party parser probes
that decide which formats are enabled, and :func:`parse_content`, the stateless
dispatch used by :class:`~click_extra.config.option.ConfigOption` for every format that
does not need the CLI parameter structure.
"""

from __future__ import annotations

import importlib.util
import json
import logging
import sys
from enum import Enum
from fnmatch import fnmatch

if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib  # type: ignore[import-not-found]


TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterable
    from pathlib import Path
    from typing import Any

logger = logging.getLogger(__name__)


_OPTIONAL_PARSERS: tuple[tuple[str, str, str], ...] = (
    # (import module name, click-extra[extra] name, display label).
    ("yaml", "yaml", "YAML"),
    ("json5", "json5", "JSON5"),
    ("jsonc", "jsonc", "JSONC"),
    ("hjson", "hjson", "Hjson"),
    ("xmltodict", "xml", "XML"),
)
"""Third-party parsers each gating one optional configuration format.

Each entry pairs the importable module name (probed without importing it) with
the ``click-extra[extra]`` install target and the human-readable format label
used in the disabled-support debug message."""

PARSER_SUPPORT: dict[str, bool] = {}
"""Availability of each optional parser, keyed by ``click-extra[extra]`` name.

Populated once at import time by probing each module in ``_OPTIONAL_PARSERS``
with :func:`importlib.util.find_spec`. Read by :class:`ConfigFormat` to mark the
matching format as enabled or disabled. The probe does not import the module, so
the actual parser is loaded lazily by :func:`parse_content` only when used."""

for _module_name, _extra, _label in _OPTIONAL_PARSERS:
    PARSER_SUPPORT[_extra] = importlib.util.find_spec(_module_name) is not None
    if not PARSER_SUPPORT[_extra]:
        logger.debug(
            f"{_label} support disabled: install click-extra[{_extra}] to enable it."
        )


[docs] class ConfigFormat(Enum): """All configuration formats, associated to their support status. The first element of the tuple is a sequence of file extensions associated to the format. Patterns are fed to ``wcmatch.glob`` for matching, and are influenced by the flags set on the ``ConfigOption`` instance. The second element indicates whether the format is supported or not, depending on the availability of the required third-party packages. This evaluation is performed at runtime when this module is imported. .. caution:: The order is important for both format members and file patterns. It defines the priority order in which formats are tried when multiple candidate files are found. .. todo:: Add support for `JWCC <https://nigeltao.github.io/blog/2021/json-with-commas-comments.html>`_ / `hujson <https://github.com/tailscale/hujson>`_ format? """ TOML = (("*.toml",), True, "TOML") YAML = (("*.yaml", "*.yml"), PARSER_SUPPORT["yaml"], "YAML") JSON = (("*.json",), True, "JSON") JSON5 = (("*.json5",), PARSER_SUPPORT["json5"], "JSON5") JSONC = (("*.jsonc",), PARSER_SUPPORT["jsonc"], "JSONC") HJSON = (("*.hjson",), PARSER_SUPPORT["hjson"], "Hjson") INI = (("*.ini",), True, "INI") XML = (("*.xml",), PARSER_SUPPORT["xml"], "XML") PYPROJECT_TOML = (("pyproject.toml",), True, "pyproject.toml") def __str__(self) -> str: return self.label @property def label(self) -> str: """Human-friendly name of the format for display in messages.""" return self.value[2] # type: ignore[no-any-return] @property def enabled(self) -> bool: """Returns ``True`` if the format is supported, ``False`` otherwise.""" return self.value[1] # type: ignore[no-any-return] @property def patterns(self) -> tuple[str, ...]: """Returns the default file patterns associated to the format.""" return self.value[0] # type: ignore[no-any-return]
[docs] def parse_content(fmt: ConfigFormat, content: str) -> Any: """Parse content with a single stateless format. INI is excluded: it needs the CLI parameter structure for type coercion and is handled by ConfigOption.load_ini_config. .. note:: Optional third-party parsers are imported lazily, at the point of use, rather than at module load. Only enabled formats reach this function (disabled ones are filtered out of ``ConfigOption.file_format_patterns``), so the import always resolves for the formats actually parsed here. """ match fmt: case ConfigFormat.TOML: return tomllib.loads(content) case ConfigFormat.YAML: import yaml return yaml.full_load(content) case ConfigFormat.JSON: return json.loads(content) case ConfigFormat.JSON5: import json5 return json5.loads(content) case ConfigFormat.JSONC: import jsonc return jsonc.loads(content) case ConfigFormat.HJSON: import hjson return hjson.loads(content) case ConfigFormat.XML: import xmltodict return xmltodict.parse(content) case ConfigFormat.PYPROJECT_TOML: return tomllib.loads(content).get("tool", {}) raise ValueError(f"{fmt!r} is not handled by parse_content().")
SERIALIZABLE_FORMATS: tuple[ConfigFormat, ...] = ( ConfigFormat.TOML, ConfigFormat.YAML, ConfigFormat.JSON, ConfigFormat.JSON5, ConfigFormat.JSONC, ConfigFormat.HJSON, ConfigFormat.XML, ) """Configuration formats :func:`serialize_content` can write, in priority order. Every :class:`ConfigFormat` except :attr:`~ConfigFormat.INI` and :attr:`~ConfigFormat.PYPROJECT_TOML`, which have no serializer. ``JSON``, ``JSON5`` and ``JSONC`` are emitted as plain JSON through the standard library, so they need no optional dependency; the others require their format's extra. .. caution:: Keep this in sync with the ``match`` statement in :func:`serialize_content`. """
[docs] def serialize_content(fmt: ConfigFormat, data: Any, **kwargs: Any) -> str: """Serialize a Python object to a string in the given format. The dumping counterpart to :func:`parse_content`. Per-format defaults can be overridden through ``kwargs`` (forwarded to the underlying serializer). JSON5 and JSONC are emitted as plain JSON, a valid subset of both. .. caution:: Not every format round-trips: ``TOML`` and ``XML`` have no null type, and ``XML`` expects a single root mapping, so the caller is responsible for shaping ``data`` accordingly. ``INI`` and ``pyproject.toml`` have no serializer here. .. note:: Optional third-party serializers are imported lazily, at the point of use. Writing ``TOML`` uses ``tomlkit`` (the ``[toml]`` extra), unlike reading which relies on the built-in ``tomllib``. :raises ValueError: the format has no serializer. """ match fmt: case ConfigFormat.JSON | ConfigFormat.JSON5 | ConfigFormat.JSONC: return ( json.dumps(data, **{"ensure_ascii": False, "indent": 2, **kwargs}) + "\n" ) case ConfigFormat.YAML: import yaml return str( yaml.dump( data, **{"allow_unicode": True, "default_flow_style": False, **kwargs}, ) ) case ConfigFormat.TOML: import tomlkit doc = tomlkit.document() for key, value in data.items(): doc.add(key, value) return tomlkit.dumps(doc) case ConfigFormat.HJSON: import hjson return str(hjson.dumps(data, **{"ensure_ascii": False, **kwargs})) + "\n" case ConfigFormat.XML: import xmltodict result: str = xmltodict.unparse( data, **{ "pretty": True, "encoding": "unicode", "full_document": False, **kwargs, }, ) return result + "\n" raise ValueError(f"{fmt!r} is not handled by serialize_content().")
[docs] def format_from_path( path: Path, formats: Iterable[ConfigFormat] | None = None, ) -> ConfigFormat | None: """Return the configuration format whose patterns match a file name. The name is matched against each format's :attr:`~click_extra.config.formats.ConfigFormat.patterns`, so ``app.toml`` resolves to ``TOML`` and ``app.yml`` to ``YAML``. ``formats`` restricts and orders the candidates (the first match wins); it defaults to every :class:`~click_extra.config.formats.ConfigFormat`. """ candidates = tuple(ConfigFormat) if formats is None else formats for fmt in candidates: if any(fnmatch(path.name, pattern) for pattern in fmt.patterns): return fmt return None
[docs] def disabled_format_message(fmt: ConfigFormat) -> str: """Build the "format support disabled, install the extra" message for a format. The single source for the :exc:`ImportError` text raised when a format whose optional parser is not installed is requested, shared by :func:`read_file` and :func:`click_extra.test_suite.parse_test_suite`. A format's :attr:`~click_extra.config.formats.ConfigFormat.label`, lower-cased, is its ``click-extra[<extra>]`` install target. """ return ( f"{fmt} support disabled: install click-extra[{fmt.label.lower()}] " "to enable it." )
[docs] def read_file(path: Path, formats: Iterable[ConfigFormat] | None = None) -> Any: """Read a file and parse it, picking the format from its name. The format is resolved with :func:`format_from_path` over ``formats`` (every :class:`~click_extra.config.formats.ConfigFormat` by default), then the content is parsed with :func:`parse_content`. :raises ValueError: the file name matches none of the candidate ``formats``. :raises ImportError: the matched format's optional parser is not installed. """ fmt = format_from_path(path, formats) if fmt is None: raise ValueError(f"Unsupported file extension: {path.name!r}") if not fmt.enabled: raise ImportError(disabled_format_message(fmt)) return parse_content(fmt, path.read_text(encoding="utf-8"))