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

1"""Parameters. 

2 

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")``. 

8 

9Warning 

10-------- 

11Pydantic objects defined in this module are documented `here 

12<../pydantic_models.html#parameters>`_. 

13 

14""" 

15 

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 

22 

23import yaml 

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

25 

26from git_dag.constants import CONFIG_FILE 

27 

28logging.basicConfig(level=logging.WARNING) 

29LOG = logging.getLogger(__name__) 

30 

31 

32class CustomYamlDumper(yaml.SafeDumper): 

33 """Insert empty line between top-level sections. 

34 

35 https://github.com/yaml/pyyaml/issues/127#issuecomment-525800484 

36 """ 

37 

38 def write_line_break(self, data: Any = None) -> None: 

39 super().write_line_break(data) 

40 

41 if len(self.indents) == 1: 

42 super().write_line_break() 

43 

44 

45class ParamsBase(BaseModel): 

46 """Base class for parameters.""" 

47 

48 model_config = ConfigDict(extra="forbid") 

49 

50 ignore_config_file: ClassVar[bool] = False 

51 

52 @staticmethod 

53 def set_ignore_config_file(value: bool) -> None: 

54 """Set whether to ignore the config file or not. 

55 

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). 

61 

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`. 

68 

69 """ 

70 ParamsBase.ignore_config_file = value 

71 

72 @staticmethod 

73 @abstractmethod 

74 def section_in_config() -> str: 

75 """Return associated section in the config file. 

76 

77 Warning 

78 -------- 

79 The section name has to coincide with the field names in :class:`Params`. 

80 

81 """ 

82 

83 @model_validator(mode="after") 

84 def set_defaults_values(self) -> Self: 

85 """Set parameter default values. 

86 

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 

93 

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 ... 

99 

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) 

104 

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) 

111 

112 return self 

113 

114 

115class LinksTemplates(BaseModel): 

116 """Parameters of of git providers for links to commits, tags, branches.""" 

117 

118 base: str 

119 commit: str 

120 branch: str 

121 tag: str 

122 

123 

124class ParamsLinks(ParamsBase): 

125 """Parameters of of git providers for links to commits, tags, branches.""" 

126 

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 } 

143 

144 @staticmethod 

145 def section_in_config() -> str: 

146 return "links" 

147 

148 

149class ParamsStandaloneCluster(ParamsBase): 

150 """Standalone cluster parameters.""" 

151 

152 color: str = "lightgrey" 

153 label: str = r"Standalone\nTrees & Blobs" 

154 fontname: str = "Courier" 

155 

156 @staticmethod 

157 def section_in_config() -> str: 

158 return "standalone_cluster" 

159 

160 

161class ParamsDagGlobal(ParamsBase): 

162 """Global DAG parameters.""" 

163 

164 model_config = ConfigDict(extra="allow") 

165 

166 rankdir: Literal["LR", "RL", "TB", "BT"] = "TB" 

167 dpi: str = "None" 

168 bgcolor: str = "white" # bgcolor "transparent" is inconsistent accross browsers 

169 

170 @staticmethod 

171 def section_in_config() -> str: 

172 return "dag_global" 

173 

174 

175class ParamsDagNode(ParamsBase): 

176 """DAG node parameters.""" 

177 

178 model_config = ConfigDict(extra="allow") 

179 

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" 

186 

187 @staticmethod 

188 def section_in_config() -> str: 

189 return "dag_node" 

190 

191 

192class ParamsDagEdge(ParamsBase): 

193 """DAG edge parameters.""" 

194 

195 model_config = ConfigDict(extra="allow") 

196 

197 arrowsize: str = "0.5" 

198 color: str = "gray10" 

199 

200 @staticmethod 

201 def section_in_config() -> str: 

202 return "dag_edge" 

203 

204 

205class ParamsDagNodeColors(ParamsBase): 

206 """Colors for DAG nodes.""" 

207 

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" 

223 

224 @staticmethod 

225 def section_in_config() -> str: 

226 return "dag_node_colors" 

227 

228 

229class ParamsMisc(ParamsBase): 

230 """Misc parameters.""" 

231 

232 annotations_symbol: str = "&#9758;" 

233 annotations_shape: str = "cds" 

234 annotations_truncate: int = 20 

235 sha_truncate: int = 7 

236 

237 @staticmethod 

238 def section_in_config() -> str: 

239 return "misc" 

240 

241 

242class ParamsPublic(ParamsBase): 

243 """Parameters exposed as command-line arguments.""" 

244 

245 path: str = "." 

246 file: str | Path = "git-dag.gv" 

247 format: str = "svg" 

248 dag_backend: str = "graphviz" 

249 log_level: str = "WARNING" 

250 

251 range_expr: Optional[str] = None 

252 init_refs: Optional[list[str]] = None 

253 annotations: Optional[list[list[str]]] = None 

254 

255 max_numb_commits: int = 1000 

256 commit_message_as_label: int = 0 

257 

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 

272 

273 @staticmethod 

274 def section_in_config() -> str: 

275 return "public" 

276 

277 

278class context_ignore_config_file(ContextDecorator): 

279 """Context manager within which the config file is ignored. 

280 

281 Example 

282 -------- 

283 .. code-block:: python 

284 

285 print(ParamsPublic.ignore_config_file) # False 

286 with context_ignore_config_file(): 

287 print(ParamsPublic.ignore_config_file) # True 

288 

289 print(ParamsPublic.ignore_config_file) # False 

290 

291 """ 

292 

293 def __enter__(self) -> Self: 

294 ParamsBase.ignore_config_file = True 

295 return self 

296 

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 

304 

305 

306class Params(BaseModel): 

307 """A container class for all parameters. 

308 

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. 

314 

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). 

322 

323 + mypy 1.15.0 

324 + pylint 3.3.6 (astroid 3.3.9) 

325 + python 3.13.1 

326 

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``. 

329 

330 """ 

331 

332 model_config = ConfigDict(extra="forbid") 

333 

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) 

344 

345 @staticmethod 

346 def set_ignore_config_file(value: bool) -> None: 

347 """Set whether to ignore the config file or not. 

348 

349 Warning 

350 -------- 

351 This method is defined for convenience. See 

352 :func:`~ParamsBase.set_ignore_config_file`. 

353 

354 """ 

355 ParamsBase.ignore_config_file = value 

356 

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.")