Source code for git_dag.parameters

"""Parameters.

Note
-----
``ParamsDagGlobal``, ``ParamsDagNode`` and ``ParamsDagEdge`` are directly passed to the
backend (FIXME: currently all parameters assume graphviz) and allow extra arguments --
``model_config = ConfigDict(extra="allow")``.

Warning
--------
Pydantic objects defined in this module are documented `here
<../pydantic_models.html#parameters>`_.

"""

import logging
from abc import abstractmethod
from contextlib import ContextDecorator
from pathlib import Path
from types import TracebackType
from typing import Any, ClassVar, Literal, Optional, Self

import yaml
from pydantic import BaseModel, ConfigDict, Field, SerializeAsAny, model_validator

from git_dag.constants import CONFIG_FILE

logging.basicConfig(level=logging.WARNING)
LOG = logging.getLogger(__name__)


[docs] class CustomYamlDumper(yaml.SafeDumper): """Insert empty line between top-level sections. https://github.com/yaml/pyyaml/issues/127#issuecomment-525800484 """ def write_line_break(self, data: Any = None) -> None: super().write_line_break(data) if len(self.indents) == 1: super().write_line_break()
class ParamsBase(BaseModel): """Base class for parameters.""" model_config = ConfigDict(extra="forbid") ignore_config_file: ClassVar[bool] = False @staticmethod def set_ignore_config_file(value: bool) -> None: """Set whether to ignore the config file or not. Note ----- This is a class method that can be used from any child class to ignore the config file for all child classes (note that below we set the ``ignore_config_file`` class variable directly on the base class). Warning -------- Instances of child classes of :class:`ParamsBase` created before using this method are not impacted by changes in :attr:`ignore_config_file`. The recommended way to ignore the config wile is using the context manager :class:`context_ignore_config_file`. """ ParamsBase.ignore_config_file = value @staticmethod @abstractmethod def section_in_config() -> str: """Return associated section in the config file. Warning -------- The section name has to coincide with the field names in :class:`Params`. """ @model_validator(mode="after") def set_defaults_values(self) -> Self: """Set parameter default values. Note ----- Parameters in decreasing order of priority: + user-specified parameters + parameters from the config file (if it exists) + built-in default parameters Warning -------- Calling this method from each child class would result in parsing the same config file again and again. This is acceptable as the time to parse is negligible and this simplifies the code. FIXME: maybe rework things later ... """ if not self.ignore_config_file and CONFIG_FILE.is_file(): with open(CONFIG_FILE, "r", encoding="utf-8") as h: params_from_config_file = yaml.safe_load(h) if self.section_in_config() in params_from_config_file: section_params = params_from_config_file[self.section_in_config()] fields_defined_by_user = self.model_dump(exclude_unset=True) for key, value in section_params.items(): if key not in fields_defined_by_user: setattr(self, key, value) return self class LinksTemplates(BaseModel): """Parameters of of git providers for links to commits, tags, branches.""" base: str commit: str branch: str tag: str
[docs] class ParamsStandaloneCluster(ParamsBase): """Standalone cluster parameters.""" color: str = "lightgrey" label: str = r"Standalone\nTrees & Blobs" fontname: str = "Courier"
[docs] @staticmethod def section_in_config() -> str: return "standalone_cluster"
[docs] class ParamsDagGlobal(ParamsBase): """Global DAG parameters.""" model_config = ConfigDict(extra="allow") rankdir: Literal["LR", "RL", "TB", "BT"] = "TB" dpi: str = "None" bgcolor: str = "white" # bgcolor "transparent" is inconsistent accross browsers
[docs] @staticmethod def section_in_config() -> str: return "dag_global"
[docs] class ParamsDagNode(ParamsBase): """DAG node parameters.""" model_config = ConfigDict(extra="allow") shape: str = "box" style: str = "filled" margin: str = "0.01,0.01" width: str = "0.02" height: str = "0.02" fontname: str = "Courier"
[docs] @staticmethod def section_in_config() -> str: return "dag_node"
[docs] class ParamsDagEdge(ParamsBase): """DAG edge parameters.""" model_config = ConfigDict(extra="allow") arrowsize: str = "0.5" color: str = "gray10"
[docs] @staticmethod def section_in_config() -> str: return "dag_edge"
[docs] class ParamsDagNodeColors(ParamsBase): """Colors for DAG nodes.""" commit: str = "gold3" commit_unreachable: str = "darkorange" commit_in_range: str = "red" tree: str = "deepskyblue4" the_empty_tree: str = "darkturquoise" blob: str = "gray" tag: str = "pink" tag_deleted: str = "rosybrown4" tag_lw: str = "lightcoral" head: str = "cornflowerblue" local_branches: str = "forestgreen" remote_branches: str = "firebrick" stash: str = "skyblue" notes: str = "white" annotations: str = "aquamarine3"
[docs] @staticmethod def section_in_config() -> str: return "dag_node_colors"
class ParamsMisc(ParamsBase): """Misc parameters.""" annotations_symbol: str = "&#9758;" annotations_shape: str = "cds" annotations_truncate: int = 20 sha_truncate: int = 7 @staticmethod def section_in_config() -> str: return "misc"
[docs] class ParamsPublic(ParamsBase): """Parameters exposed as command-line arguments.""" path: str = "." file: str | Path = "git-dag.gv" format: str = "svg" dag_backend: str = "graphviz" log_level: str = "WARNING" range_expr: Optional[str] = None init_refs: Optional[list[str]] = None annotations: Optional[list[list[str]]] = None max_numb_commits: int = 1000 commit_message_as_label: int = 0 html_embed_svg: bool = False show_unreachable_commits: bool = False show_tags: bool = False show_deleted_tags: bool = False show_local_branches: bool = False show_remote_branches: bool = False show_stash: bool = False show_trees: bool = False show_trees_standalone: bool = False show_blobs: bool = False show_blobs_standalone: bool = False show_head: bool = False show_prs_heads: bool = False xdg_open: bool = False
[docs] @staticmethod def section_in_config() -> str: return "public"
[docs] class context_ignore_config_file(ContextDecorator): """Context manager within which the config file is ignored. Example -------- .. code-block:: python print(ParamsPublic.ignore_config_file) # False with context_ignore_config_file(): print(ParamsPublic.ignore_config_file) # True print(ParamsPublic.ignore_config_file) # False """ def __enter__(self) -> Self: ParamsBase.ignore_config_file = True return self def __exit__( self, exc_type: Optional[type[BaseException]], exc: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: ParamsBase.ignore_config_file = False
[docs] class Params(BaseModel): """A container class for all parameters. Note ----- It is important to evaluate the default values at runtime (in order to consider potential changes in :attr:`~ParamsBase.ignore_config_file`) -- thus ``default_factory`` is used. Warning -------- Pylint complains about no-member if the fields are defined using e.g., ``public: ParamsPublic = Field(default_factory=ParamsPublic)`` -- with which mypy is happy. On the other hand, mypy complains about call-arg (Missing named argument) if we use ``public: Annotated[ParamsPublic, Field(default_factory=ParamsPublic)]`` with which pylint is happy (see first comment of https://stackoverflow.com/a/77844893). + mypy 1.15.0 + pylint 3.3.6 (astroid 3.3.9) + python 3.13.1 The former syntax is used below (i.e., mypy is prioritized) and pylint errors are suppressed by specifying ``generated-members`` in ``pyproject.toml``. """ model_config = ConfigDict(extra="forbid") public: ParamsPublic = Field(default_factory=ParamsPublic) dag_global: ParamsDagGlobal = Field(default_factory=ParamsDagGlobal) dag_node: ParamsDagNode = Field(default_factory=ParamsDagNode) dag_edge: ParamsDagEdge = Field(default_factory=ParamsDagEdge) dag_node_colors: ParamsDagNodeColors = Field(default_factory=ParamsDagNodeColors) standalone_cluster: ParamsStandaloneCluster = Field( default_factory=ParamsStandaloneCluster ) links: ParamsLinks = Field(default_factory=ParamsLinks) misc: ParamsMisc = Field(default_factory=ParamsMisc)
[docs] @staticmethod def set_ignore_config_file(value: bool) -> None: """Set whether to ignore the config file or not. Warning -------- This method is defined for convenience. See :func:`~ParamsBase.set_ignore_config_file`. """ ParamsBase.ignore_config_file = value
[docs] def create_config(self) -> None: """Create a config file from the parameters in the current instance.""" if not CONFIG_FILE.is_file(): with open(CONFIG_FILE, "w", encoding="utf-8") as h: yaml.dump( self.model_dump(), h, Dumper=CustomYamlDumper, sort_keys=False, ) print(f"Created config {CONFIG_FILE}.") else: print(f"Config file {CONFIG_FILE} already exists.")