Source code for gitmopy.utils

"""
Utility functions and constants for ``gitmopy``.
"""

from os.path import expandvars
from pathlib import Path
from shutil import get_terminal_size
from textwrap import dedent
from typing import Any, Dict, List, Union

import wcwidth
from InquirerPy.separator import Separator
from rich.console import Console
from yaml import safe_dump, safe_load

from gitmopy.constants import (
    APP_PATH,
    COLORS,
    DEFAULT_CHOICES,
    DEFAULT_CONFIG,
    USER_EMOJIS_PATH,
)

[docs] console = Console()
[docs] print = console.print
[docs] def resolve_path(path: Union[str, Path]) -> Path: """ Resolve a path with ``expandvars``, ``expanduser`` and ``resolve``. Args: path (Union[str, Path]): Path to resolve. Returns: Path: Resolved (absolute) path. """ return Path(expandvars(path)).expanduser().resolve()
[docs] def col(txt, color, bold=False): """ Return a coloured string with Rich. Args: txt (str): String to colour. colour (str): Colour to use. bold (bool, optional): Whether to use bold font. Defaults to False. Returns: str: Coloured string. """ return ( f"[{COLORS[color]}]{txt}[/]" if not bold else f"[{COLORS[color]} bold]{txt}[/]" )
[docs] def load_config() -> Dict[str, bool]: """ Load the configuration from ``${APP_PATH}/config.yaml``. Returns the default configuration if the file does not exist. Returns: Dict[str, bool]: User gitmopy configuration. """ yaml_path = APP_PATH / "config.yaml" if yaml_path.exists(): return {**DEFAULT_CONFIG, **safe_load(yaml_path.read_text())} return DEFAULT_CONFIG
[docs] def check_config(config: Dict[str, Union[bool, str]]) -> None: """ Check that the user configuration is valid. Args: config (Dict[str, Union[bool, str]]): Current gitmopy configuration that should be saved Raises: ValueError: Unknown config key ValueError: Wrong config value type """ assert isinstance(config, dict) conf = {**DEFAULT_CONFIG, **config} for k, v in config.items(): if k not in DEFAULT_CONFIG: raise ValueError(f"Unknown config key {k}") if not isinstance(v, type(DEFAULT_CONFIG[k])): raise ValueError( f"Config key {k} must be of type {type(DEFAULT_CONFIG[k])}" ) return conf
[docs] def save_config(config: Dict[str, Union[bool, str]]) -> None: """ Save the configuration to ``${APP_PATH}/config.yaml``. Creates the parent directory if it does not exist. Args: config (Dict[str, Union[bool, str]]): Current gitmopy configuration. """ yaml_path = APP_PATH / "config.yaml" if not yaml_path.exists(): print("[bold yellow] Creating config file [/bold yellow]", str(yaml_path)) yaml_path.parent.mkdir(exist_ok=True) check_config(config) yaml_path.write_text(safe_dump(config)) print("✅ Config updated")
[docs] def message_from_commit_dict(commit_dict: Dict[str, str]) -> str: r""" Create a commit message from a commit dictionary. Depending on whether ``scope`` is set, will look like * ``{emoji} ({scope}): {title}`` * ``{emoji}: {title}`` Then ``\n\n{message}`` is appended if a message is specified. Args: commit_dict (Dict[str, str]): Commit specs from the prompt. Returns: str: formatted commit message. """ message = f"{commit_dict['emoji']} " if commit_dict["scope"]: message += f"({commit_dict['scope']}): " message += f"{commit_dict['title']}\n\n{commit_dict['message']}" return message.strip()
[docs] def choice_separator(title: str = "", width: int = 30, sep: str = "─") -> Separator: """ Create an InquirerPy separator with a title. Args: title (str, optional): Title in-between separator characters. Defaults to ``""``. width (int, optional): Total length of sperator line. Defaults to 30. sep (str, optional): Character to use around the ``title``. Defaults to ``"─"``. Returns: Separator: _description_ """ assert sep assert width > 0 if len(title) > width - 4: width = len(title) + 4 first = (width - len(title)) // 2 line = f"{sep * first} {title} {sep * (width - first - len(title) - 2)}" return Separator(line)
[docs] def safe_capitalize(s): """ Capitalize a string if it is not empty, but keeps all-caps words. Example: ..code-block:: python >>> safe_capitalize("hello") "Hello" >>> safe_capitalize("HELLO") "HELLO" >>> "HELLO".capitalize() "Hello" Args: s (str): String to capitalize """ if not s: return s if len(s) == 1: return s.upper() return s[0].upper() + s[1:]
[docs] def terminal_separator(margin=10): """ Create a separator for the terminal. Returns: str: Terminal separator """ total = get_terminal_size().columns sep = "─" * (total - 2 * margin) return f"{' ' * margin}{sep}{' ' * margin}"
[docs] def load_user_gitmojis() -> List[Dict[str, str]]: """ Load custom gitmojis from ``${APP_PATH}/custom_gitmojis.yaml``. Returns: List[Dict[str, str]]: Custom gitmojis. """ custom_emos = [] try: if USER_EMOJIS_PATH.exists(): custom_emos = safe_load(USER_EMOJIS_PATH.read_text()) or [] else: USER_EMOJIS_PATH.write_text( dedent( """\ # A file to add your own emojies. # For instance, uncomment the following lines to add new emojis # to the list of suggestions for when you commit: # - emoji: "🧫" # description: Experimental code # - emoji: "💪" # description: Add utility functions """ ) ) custom_emos = [] validate_user_emojis(custom_emos) except Exception as e: print( col( f"Error loading custom gitmojis from {str(USER_EMOJIS_PATH)}," + " ignoring.", "red", True, ) ) print(str(e)) custom_emos = [] finally: return custom_emos
[docs] def validate_user_emojis(custom_emos: Any) -> List[Dict[str, str]]: """ Validate user emojis. * Must be a list of dictionaries (possibly empty) * Each dictionary must have an ``emoji`` key and a ``description`` key * Each key must be a string * Each key must be non-empty Raises: ValueError: If any of the above conditions are not met. args: custom_emos (Any): User defined emojis. Returns: List[Dict[str, str]]: Validated emojis. """ if not isinstance(custom_emos, list): raise ValueError("Custom gitmojis must be a list") for gitmoji in custom_emos: if not isinstance(gitmoji, dict): raise ValueError("Custom gitmojis must be a list of dictionaries") if "emoji" not in gitmoji: raise ValueError("Custom gitmojis must have an 'emoji' key") if "description" not in gitmoji: raise ValueError("Custom gitmojis must have a 'description' key") if not isinstance(gitmoji["emoji"], str): raise ValueError("Custom gitmojis must have a [b]string[b] 'emoji' key") if not isinstance(gitmoji["description"], str): raise ValueError( "Custom gitmojis must have a [b]string[b] 'description' key" ) if not gitmoji["emoji"]: raise ValueError("Custom gitmojis must have a non-empty 'emoji' key") if not gitmoji["description"]: raise ValueError("Custom gitmojis must have a non-empty 'description' key")
[docs] def clear_line_and_move_up(): """ Clear the current line and move up one line in the terminal. """ import sys sys.stdout.write("\033[2K\033[A") sys.stdout.flush()
[docs] def standard_width_emoji(emoji: str, to=2) -> str: """ Return a standard width emoji of width 2. """ assert to >= 2 if len(emoji) > 1: emoji = emoji[0] return emoji + " " * (to - wcwidth.wcwidth(emoji))
[docs] def set_start_options(): """ Set the options for the default commit arguments automatically from the ``gitmopy commit`` command's signature. """ global DEFAULT_CHOICES import inspect from gitmopy.cli import commit start_idx = next( i for i, c in enumerate(DEFAULT_CHOICES) if c["value"] == "default_commit_flags" ) DEFAULT_CHOICES[start_idx]["options"] = sorted( arg for arg in inspect.signature(commit).parameters.keys() if arg not in {"repo", "remote"} )