Source code for xflow.utils.parser

"""General puerpose file parser."""

import json
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Dict, Type

import yaml

from .typing import PathLikeStr

SUPPORTED_FORMATS: Dict[str, Type["ConfigParser"]] = {}


def register_parser(*extensions: str):
    """
    Class decorator to register a ConfigParser under one or more file extensions.
    """

    def decorator(cls: Type["ConfigParser"]) -> Type["ConfigParser"]:
        for ext in extensions:
            key = ext.lower()
            SUPPORTED_FORMATS[key] = cls
        return cls

    return decorator


class ConfigParser(ABC):
    """Abstract base for configuration parsers."""

    @abstractmethod
    def parse(self, file_path: PathLikeStr) -> Any:
        """Parse configuration file and return data."""
        ...

    @abstractmethod
    def save(self, data: Any, file_path: PathLikeStr) -> None:
        """Save data to configuration file."""
        ...


@register_parser(".yaml", ".yml")
class YAMLParser(ConfigParser):
    """YAML configuration parser."""

    def parse(self, file_path: PathLikeStr) -> Any:
        """Parse YAML configuration file."""
        with open(file_path, "r", encoding="utf-8") as f:
            return yaml.safe_load(f)

    @staticmethod
    def _add_blank_lines_between_top_level_keys(yaml_text: str) -> str:
        """
        Insert a single blank line between first-level mapping entries.
        Leaves lists and nested blocks untouched.
        """
        import re

        lines = yaml_text.splitlines()
        out = []
        seen_first_key = False
        # Match a top-level key line like: key: ...   (not starting with space/#/-)
        key_re = re.compile(r'^[^\s#-][^:]*:\s*(?:#.*)?$')

        for line in lines:
            if key_re.match(line):
                if seen_first_key:
                    # ensure exactly one blank line before the next top-level key
                    if len(out) > 0 and out[-1].strip() != "":
                        out.append("")
                seen_first_key = True
            out.append(line)

        # Ensure trailing newline at EOF
        return "\n".join(out) + "\n"

    def save(self, data: Any, file_path: PathLikeStr) -> None:
        """Save data to YAML file with blank lines between top-level blocks."""
        from pathlib import Path

        file_path = Path(file_path)
        file_path.parent.mkdir(parents=True, exist_ok=True)

        # Dump to a string first
        dumped = yaml.dump(
            data,
            default_flow_style=False,
            indent=2,
            sort_keys=False,   # Preserve order
            allow_unicode=True,
        )

        # Normalize newlines and add spacing between first-level keys
        dumped = dumped.replace("\r\n", "\n").replace("\r", "\n")
        pretty = self._add_blank_lines_between_top_level_keys(dumped)

        with open(file_path, "w", encoding="utf-8", newline="\n") as f:
            f.write(pretty)



@register_parser(".json")
class JSONParser(ConfigParser):
    """JSON configuration parser."""

    def parse(self, file_path: PathLikeStr) -> Any:
        with open(file_path, "r", encoding="utf-8") as f:
            return json.load(f)

    def save(self, data: Any, file_path: PathLikeStr) -> None:
        path = Path(file_path)
        path.parent.mkdir(parents=True, exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2, ensure_ascii=False)


def get_parser_for_file(file_path: PathLikeStr) -> ConfigParser:
    """
    Return a parser instance for the given file, based on its extension.
    Raises a ValueError listing all supported extensions if none match.
    """
    path = Path(file_path)
    suffix = path.suffix.lower()
    parser_cls = SUPPORTED_FORMATS.get(suffix)
    if not parser_cls:
        supported = ", ".join(sorted(SUPPORTED_FORMATS.keys()))
        raise ValueError(
            f"Unsupported format '{suffix}'. " f"Supported extensions: {supported}"
        )
    return parser_cls()


[docs] def load_file(file_path: PathLikeStr) -> Any: """Load config file.""" parser = get_parser_for_file(file_path) return parser.parse(file_path)
[docs] def save_file(data: Any, file_path: PathLikeStr) -> None: """Save data to file. Format determined by file extension.""" parser = get_parser_for_file(file_path) parser.save(data, file_path)