Coverage for src/git_dag/parameters.py: 99%
160 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-08 12:49 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-08 12:49 +0200
1"""Parameters.
3Note
4-----
5``ParamsDagGlobal``, ``ParamsDagNode`` and ``ParamsDagEdge`` are directly passed to the
6backend (FIXME: currently all parameters assume graphviz) and allow extra arguments --
7``model_config = ConfigDict(extra="allow")``.
9Warning
10--------
11Pydantic objects defined in this module are documented `here
12<../pydantic_models.html#parameters>`_.
14"""
16import logging
17from abc import abstractmethod
18from contextlib import ContextDecorator
19from pathlib import Path
20from types import TracebackType
21from typing import Any, ClassVar, Literal, Optional, Self
23import yaml
24from pydantic import BaseModel, ConfigDict, Field, SerializeAsAny, model_validator
26from git_dag.constants import CONFIG_FILE
28logging.basicConfig(level=logging.WARNING)
29LOG = logging.getLogger(__name__)
32class CustomYamlDumper(yaml.SafeDumper):
33 """Insert empty line between top-level sections.
35 https://github.com/yaml/pyyaml/issues/127#issuecomment-525800484
36 """
38 def write_line_break(self, data: Any = None) -> None:
39 super().write_line_break(data)
41 if len(self.indents) == 1:
42 super().write_line_break()
45class ParamsBase(BaseModel):
46 """Base class for parameters."""
48 model_config = ConfigDict(extra="forbid")
50 ignore_config_file: ClassVar[bool] = False
52 @staticmethod
53 def set_ignore_config_file(value: bool) -> None:
54 """Set whether to ignore the config file or not.
56 Note
57 -----
58 This is a class method that can be used from any child class to ignore the
59 config file for all child classes (note that below we set the
60 ``ignore_config_file`` class variable directly on the base class).
62 Warning
63 --------
64 Instances of child classes of :class:`ParamsBase` created before using this
65 method are not impacted by changes in :attr:`ignore_config_file`. The
66 recommended way to ignore the config wile is using the context manager
67 :class:`context_ignore_config_file`.
69 """
70 ParamsBase.ignore_config_file = value
72 @staticmethod
73 @abstractmethod
74 def section_in_config() -> str:
75 """Return associated section in the config file.
77 Warning
78 --------
79 The section name has to coincide with the field names in :class:`Params`.
81 """
83 @model_validator(mode="after")
84 def set_defaults_values(self) -> Self:
85 """Set parameter default values.
87 Note
88 -----
89 Parameters in decreasing order of priority:
90 + user-specified parameters
91 + parameters from the config file (if it exists)
92 + built-in default parameters
94 Warning
95 --------
96 Calling this method from each child class would result in parsing the same
97 config file again and again. This is acceptable as the time to parse is
98 negligible and this simplifies the code. FIXME: maybe rework things later ...
100 """
101 if not self.ignore_config_file and CONFIG_FILE.is_file():
102 with open(CONFIG_FILE, "r", encoding="utf-8") as h:
103 params_from_config_file = yaml.safe_load(h)
105 if self.section_in_config() in params_from_config_file:
106 section_params = params_from_config_file[self.section_in_config()]
107 fields_defined_by_user = self.model_dump(exclude_unset=True)
108 for key, value in section_params.items():
109 if key not in fields_defined_by_user:
110 setattr(self, key, value)
112 return self
115class LinksTemplates(BaseModel):
116 """Parameters of of git providers for links to commits, tags, branches."""
118 base: str
119 commit: str
120 branch: str
121 tag: str
124class ParamsLinks(ParamsBase):
125 """Parameters of of git providers for links to commits, tags, branches."""
127 # https://docs.pydantic.dev/latest/concepts/serialization/
128 # https://docs.pydantic.dev/latest/concepts/fields/#mutable-default-values
129 templates: dict[str, SerializeAsAny[LinksTemplates]] = {
130 "github": LinksTemplates(
131 base="https://github.com",
132 commit="{base}/{user}/{project}/commit/{commit}",
133 branch="{base}/{user}/{project}/tree/{branch}",
134 tag="{base}/{user}/{project}/releases/tag/{tag}",
135 ),
136 "bitbucket": LinksTemplates(
137 base="https://bitbucket.org",
138 commit="{base}/{user}/{project}/commits/{commit}",
139 branch="{base}/{user}/{project}/src/{branch}",
140 tag="{base}/{user}/{project}/src/{tag}",
141 ),
142 }
144 @staticmethod
145 def section_in_config() -> str:
146 return "links"
149class ParamsStandaloneCluster(ParamsBase):
150 """Standalone cluster parameters."""
152 color: str = "lightgrey"
153 label: str = r"Standalone\nTrees & Blobs"
154 fontname: str = "Courier"
156 @staticmethod
157 def section_in_config() -> str:
158 return "standalone_cluster"
161class ParamsDagGlobal(ParamsBase):
162 """Global DAG parameters."""
164 model_config = ConfigDict(extra="allow")
166 rankdir: Literal["LR", "RL", "TB", "BT"] = "TB"
167 dpi: str = "None"
168 bgcolor: str = "white" # bgcolor "transparent" is inconsistent accross browsers
170 @staticmethod
171 def section_in_config() -> str:
172 return "dag_global"
175class ParamsDagNode(ParamsBase):
176 """DAG node parameters."""
178 model_config = ConfigDict(extra="allow")
180 shape: str = "box"
181 style: str = "filled"
182 margin: str = "0.01,0.01"
183 width: str = "0.02"
184 height: str = "0.02"
185 fontname: str = "Courier"
187 @staticmethod
188 def section_in_config() -> str:
189 return "dag_node"
192class ParamsDagEdge(ParamsBase):
193 """DAG edge parameters."""
195 model_config = ConfigDict(extra="allow")
197 arrowsize: str = "0.5"
198 color: str = "gray10"
200 @staticmethod
201 def section_in_config() -> str:
202 return "dag_edge"
205class ParamsDagNodeColors(ParamsBase):
206 """Colors for DAG nodes."""
208 commit: str = "gold3"
209 commit_unreachable: str = "darkorange"
210 commit_in_range: str = "red"
211 tree: str = "deepskyblue4"
212 the_empty_tree: str = "darkturquoise"
213 blob: str = "gray"
214 tag: str = "pink"
215 tag_deleted: str = "rosybrown4"
216 tag_lw: str = "lightcoral"
217 head: str = "cornflowerblue"
218 local_branches: str = "forestgreen"
219 remote_branches: str = "firebrick"
220 stash: str = "skyblue"
221 notes: str = "white"
222 annotations: str = "aquamarine3"
224 @staticmethod
225 def section_in_config() -> str:
226 return "dag_node_colors"
229class ParamsMisc(ParamsBase):
230 """Misc parameters."""
232 annotations_symbol: str = "☞"
233 annotations_shape: str = "cds"
234 annotations_truncate: int = 20
235 sha_truncate: int = 7
237 @staticmethod
238 def section_in_config() -> str:
239 return "misc"
242class ParamsPublic(ParamsBase):
243 """Parameters exposed as command-line arguments."""
245 path: str = "."
246 file: str | Path = "git-dag.gv"
247 format: str = "svg"
248 dag_backend: str = "graphviz"
249 log_level: str = "WARNING"
251 range_expr: Optional[str] = None
252 init_refs: Optional[list[str]] = None
253 annotations: Optional[list[list[str]]] = None
255 max_numb_commits: int = 1000
256 commit_message_as_label: int = 0
258 html_embed_svg: bool = False
259 show_unreachable_commits: bool = False
260 show_tags: bool = False
261 show_deleted_tags: bool = False
262 show_local_branches: bool = False
263 show_remote_branches: bool = False
264 show_stash: bool = False
265 show_trees: bool = False
266 show_trees_standalone: bool = False
267 show_blobs: bool = False
268 show_blobs_standalone: bool = False
269 show_head: bool = False
270 show_prs_heads: bool = False
271 xdg_open: bool = False
273 @staticmethod
274 def section_in_config() -> str:
275 return "public"
278class context_ignore_config_file(ContextDecorator):
279 """Context manager within which the config file is ignored.
281 Example
282 --------
283 .. code-block:: python
285 print(ParamsPublic.ignore_config_file) # False
286 with context_ignore_config_file():
287 print(ParamsPublic.ignore_config_file) # True
289 print(ParamsPublic.ignore_config_file) # False
291 """
293 def __enter__(self) -> Self:
294 ParamsBase.ignore_config_file = True
295 return self
297 def __exit__(
298 self,
299 exc_type: Optional[type[BaseException]],
300 exc: Optional[BaseException],
301 traceback: Optional[TracebackType],
302 ) -> None:
303 ParamsBase.ignore_config_file = False
306class Params(BaseModel):
307 """A container class for all parameters.
309 Note
310 -----
311 It is important to evaluate the default values at runtime (in order to consider
312 potential changes in :attr:`~ParamsBase.ignore_config_file`) -- thus
313 ``default_factory`` is used.
315 Warning
316 --------
317 Pylint complains about no-member if the fields are defined using e.g.,
318 ``public: ParamsPublic = Field(default_factory=ParamsPublic)`` -- with which mypy is
319 happy. On the other hand, mypy complains about call-arg (Missing named argument) if
320 we use ``public: Annotated[ParamsPublic, Field(default_factory=ParamsPublic)]`` with
321 which pylint is happy (see first comment of https://stackoverflow.com/a/77844893).
323 + mypy 1.15.0
324 + pylint 3.3.6 (astroid 3.3.9)
325 + python 3.13.1
327 The former syntax is used below (i.e., mypy is prioritized) and pylint errors are
328 suppressed by specifying ``generated-members`` in ``pyproject.toml``.
330 """
332 model_config = ConfigDict(extra="forbid")
334 public: ParamsPublic = Field(default_factory=ParamsPublic)
335 dag_global: ParamsDagGlobal = Field(default_factory=ParamsDagGlobal)
336 dag_node: ParamsDagNode = Field(default_factory=ParamsDagNode)
337 dag_edge: ParamsDagEdge = Field(default_factory=ParamsDagEdge)
338 dag_node_colors: ParamsDagNodeColors = Field(default_factory=ParamsDagNodeColors)
339 standalone_cluster: ParamsStandaloneCluster = Field(
340 default_factory=ParamsStandaloneCluster
341 )
342 links: ParamsLinks = Field(default_factory=ParamsLinks)
343 misc: ParamsMisc = Field(default_factory=ParamsMisc)
345 @staticmethod
346 def set_ignore_config_file(value: bool) -> None:
347 """Set whether to ignore the config file or not.
349 Warning
350 --------
351 This method is defined for convenience. See
352 :func:`~ParamsBase.set_ignore_config_file`.
354 """
355 ParamsBase.ignore_config_file = value
357 def create_config(self) -> None:
358 """Create a config file from the parameters in the current instance."""
359 if not CONFIG_FILE.is_file():
360 with open(CONFIG_FILE, "w", encoding="utf-8") as h:
361 yaml.dump(
362 self.model_dump(),
363 h,
364 Dumper=CustomYamlDumper,
365 sort_keys=False,
366 )
367 print(f"Created config {CONFIG_FILE}.")
368 else:
369 print(f"Config file {CONFIG_FILE} already exists.")