Source code for fitzzftw.patch.utils

# File: src/fitzzftw/patch/utils.py
# Author: Fitzz TeXnik Welt
# Email: FitzzTeXnikWelt@t-online.de
# License: LGPLv2 or above

"""
General Utilities and Configuration Handling
============================================

This module provides helper functions for filesystem operations,
string normalization, and hierarchical configuration merging.

Core Functions:
---------------
* **get_string_of_type**:
    A robust helper to extract human-readable names from type objects or
    strings. It ensures consistent naming even when dealing with complex
    Protocols or edge cases where a simple `__name__` access might fail.
* **get_backup_extension**:
    Normalizes backup extensions and handles dynamic keywords like
    'timestamp' to generate ISO-compliant suffixes.
* **get_merged_config**:
    Implements the configuration layering logic, merging project-specific
    TOML files with global user preferences.

Key Features:
-------------
* **Configuration Priority**:
    Ensures that :file:`pyproject.toml` settings always override user-level
    defaults to maintain project standards.
* **Platform Awareness**:
    Uses :func:`platformdirs:platformdirs.user_config_path` to correctly resolve user
    configuration paths across different operating systems.
"""

from datetime import datetime
from pathlib import Path
from tomllib import load as tomlload

from platformdirs import user_config_path

DATETIME_KEYWORDS:set[str]=set(("auto", "date", "time", "datetime", "timestamp"))

# FUNCTION - get_string_of_type
[docs] def get_string_of_type(protocol_type) -> str: """ Extracts a human-readable string representation of a type. This helper is used during protocol introspection to handle both actual class objects and pre-defined type strings. If the input has a '__name__' attribute (e.g., a class), it returns that name; otherwise, it returns the input as is. :param protocol_type: The type object or string to convert. :returns: A string representation of the type. """ if hasattr(protocol_type, "__name__"): ret = protocol_type.__name__ else: # cov skip if >=3.14 ret = protocol_type ret = str(ret) return ret
#!FUNCTION # FUNCTION - replace_keywords_to_isodatetime
[docs] def replace_keywords_to_isodatetime(path_str:str, now:datetime)->str: """ Replace predefined placeholders in a path string with an ISO timestamp. This function searches the string for tokens defined in DATETIME_KEYWORDS enclosed by '@' characters. It replaces them with a timestamp generated from 'now' using the format YYYYMMDDTHHMMSS. :param path_str: The path or filename string to process. :param now: The datetime.datetime object used as the source for the timestamp. :returns: The string containing the resolved timestamps. """ ret:str=path_str ts_value:str = now.strftime("%Y%m%dT%H%M%S") for key in DATETIME_KEYWORDS: placeholder:str = f"@{key}@" if placeholder in ret: ret = ret.replace(placeholder, ts_value) return ret
# !FUNCTION - replace_keywords # FUNCTION - get_backup_extension
[docs] def get_backup_extension(ext: str, now:datetime=datetime.now()) -> str: # noqa: B008 """ Normalize the backup extension and handle dynamic keywords. This function cleans the input by removing outer whitespace and dots. If a keyword from DATETIME_KEYWORDS is detected, it is replaced by an ISO 8601 compliant timestamp. :param ext: The extension string or a valid datetime keyword. :param now: The datetime.datetime object used for timestamp generation. :returns: A normalized extension string starting with a dot and including the timestamp if a keyword was used. """ ext = ext.strip().strip(".").strip() # Aliases for the full ISO 8601 timestamp if ext in DATETIME_KEYWORDS: ext = f"bak_{now.strftime('%Y-%m-%dT%H%M%S')}" return f".{ext}"
#!FUNCTION # FUNCTION - get_merged_config
[docs] def get_merged_config(app_name: str = "ftw", manual_user_cfg: str = "") -> dict: """ Merge configuration from hierarchical sources into a single dictionary. This function implements the configuration layering logic before the Pimple-object (args) is fully initialized. It resolves the 'chicken-and-egg' problem of locating the user configuration file via CLI before parsing the remaining arguments. The priority order for settings (highest to lowest): 1. Project Configuration: Defined in 'pyproject.toml' under [tool.fitzzftw.patch]. This level ensures project-specific standards (e.g., .gitignore compliance) always override global user preferences. 2. Manual User Configuration: A TOML file specified via the '--userconfig' flag. 3. Platform User Configuration: The default OS-specific path (e.g., ~/.config/ftw/patch.toml on Linux). :param app_name: The application namespace used for platformdirs resolution. :param manual_user_cfg: Optional filesystem path to a custom TOML config. :raises tomllib.TOMLDecodeError: If any encountered TOML file is syntactically invalid. :raises OSError: If there are permission issues accessing the configuration files. :returns: A dictionary containing the effective configuration defaults. """ config = {} # 1. User level if manual_user_cfg: user_cfg_file = Path(manual_user_cfg) else: user_cfg_file = user_config_path(app_name) / "patch.toml" if user_cfg_file.exists(): with open(user_cfg_file, "rb") as f: config.update(tomlload(f)) # 2. Project level (always wins over user level) project_cfg_file = Path("pyproject.toml") if project_cfg_file.exists(): with open(project_cfg_file, "rb") as f: data = tomlload(f) project_cfg = data.get("tool", {}).get(app_name, {}).get("patch", {}) config.update(project_cfg) return config
#!FUNCTION if __name__ == "__main__": # pragma: no cover from doctest import FAIL_FAST, testfile from pathlib import Path be_verbose = False be_verbose = True option_flags = 0 option_flags = FAIL_FAST test_sum = 0 test_failed = 0 # Pfad zu den dokumentierenden Tests testfiles_dir = Path(__file__).parents[3] / "doc/source/devel" test_file = testfiles_dir / "get_started_utils.rst" # test_file = testfiles_dir / "get_started_ftw_patch.rst" if test_file.exists(): print("--- Running Doctest for utils ---") doctestresult = testfile( str(test_file), module_relative=False, verbose=be_verbose, optionflags=option_flags, ) test_failed += doctestresult.failed test_sum += doctestresult.attempted if test_failed == 0: print(f"DocTests passed without errors, {test_sum} tests.") else: print(f"DocTests failed: {test_failed} tests.") else: print(f"⚠️ Warning: Test file {test_file.name} not found.")