Coverage for pyTooling/Diagram/Gantt.py: 99%

176 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23:02 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ ____ _ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \(_) __ _ __ _ _ __ __ _ _ __ ___ / ___| __ _ _ __ | |_| |_ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | | |/ _` |/ _` | '__/ _` | '_ ` _ \ | | _ / _` | '_ \| __| __| # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| |_| | | (_| | (_| | | | (_| | | | | | || |_| | (_| | | | | |_| |_ # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/|_|\__,_|\__, |_| \__,_|_| |_| |_(_)____|\__,_|_| |_|\__|\__| # 

7# |_| |___/ |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32A Gantt chart: rows of bars on a time scale. 

33 

34.. code-block:: python 

35 

36 from datetime import datetime, timedelta 

37 from pyTooling.Diagram.Gantt import Diagram, Row, Bar 

38 

39 begin = datetime(2026, 9, 15, 8, 0) 

40 diagram = Diagram("Nightly build", begin) 

41 

42 row = Row("Compile", parent=diagram) 

43 Bar(begin, begin + timedelta(minutes=4), parent=row) 

44 

45 print(f"{row.Name}: {row.DurationInSeconds} s, beginning {row.BeginSinceOrigin} after the diagram's origin") 

46 

47Every element knows the :class:`Diagram` it belongs to and the element containing it, so an offset is answered 

48without a search: a bar reports its begin as a time, as the distance from the diagram's **origin**, and as the 

49distance from the **row** it sits in. 

50 

51The classes describe a chart; they don't draw one. A producer of data derives from them and adds what its domain 

52knows - :class:`pyTooling.Tracing.Render.GanttLayout` builds a diagram from a software execution trace - and a 

53renderer draws what any of them describe. 

54 

55.. seealso:: 

56 

57 :mod:`pyTooling.Tracing.Render` 

58 |rarr| A software execution trace as a Gantt chart, and the renderers drawing it. 

59""" 

60from __future__ import annotations 

61 

62from datetime import datetime, timedelta 

63from typing import Iterator, Optional as Nullable 

64 

65from pyTooling.Decorators import export, readonly 

66from pyTooling.MetaClasses import ExtendedType 

67from pyTooling.Common import getFullyQualifiedName 

68from pyTooling.Diagram import DiagramError 

69 

70 

71@export 

72class Bar(metaclass=ExtendedType, slots=True): 

73 """ 

74 A bar of a Gantt chart: a time range within one :class:`Row`. 

75 

76 A bar reports its position three ways - as the times themselves (:attr:`Begin`, :attr:`End`), as the distance 

77 from the diagram's origin (:attr:`BeginSinceOrigin`), and as the distance from the row it sits in 

78 (:attr:`BeginSinceParent`) - because a chart is drawn on the second and a report usually wants one of the others. 

79 """ 

80 _parent: Row #: The row this bar sits in. 

81 _diagram: Diagram #: The diagram this bar belongs to. 

82 _begin: datetime #: Begin of the bar. 

83 _end: datetime #: End of the bar. 

84 

85 def __init__(self, begin: datetime, end: datetime, *, parent: Row) -> None: 

86 """ 

87 Initializes a bar and appends it to its row. 

88 

89 :param begin: Begin of the bar. 

90 :param end: End of the bar, which may equal the begin but must not precede it. 

91 :param parent: The row the bar sits in. 

92 :raises ValueError: If parameter 'begin', 'end' or 'parent' is None. 

93 :raises TypeError: If parameter 'begin' or 'end' is not of type :class:`~datetime.datetime`. 

94 :raises TypeError: If parameter 'parent' is not of type :class:`Row`. 

95 :raises ValueError: If the end precedes the begin. 

96 """ 

97 for parameterName, parameter in (("begin", begin), ("end", end)): 

98 if parameter is None: 

99 raise ValueError(f"Parameter '{parameterName}' is None.") 

100 elif not isinstance(parameter, datetime): 

101 ex = TypeError(f"Parameter '{parameterName}' is not of type 'datetime'.") 

102 ex.add_note(f"Got type '{getFullyQualifiedName(parameter)}'.") 

103 raise ex 

104 

105 if parent is None: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true

106 raise ValueError("Parameter 'parent' is None.") 

107 elif not isinstance(parent, Row): 

108 ex = TypeError("Parameter 'parent' is not of type 'Row'.") 

109 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

110 raise ex 

111 

112 if end < begin: 

113 ex = ValueError("A bar's end precedes its begin.") 

114 ex.add_note(f"Got begin '{begin}' and end '{end}'.") 

115 raise ex 

116 

117 self._parent = parent 

118 self._diagram = parent.Diagram 

119 self._begin = begin 

120 self._end = end 

121 parent._bars.append(self) 

122 

123 @readonly 

124 def Parent(self) -> Row: 

125 """ 

126 Read-only property to access the row this bar sits in (:attr:`_parent`). 

127 

128 :returns: The row. 

129 """ 

130 return self._parent 

131 

132 @readonly 

133 def Diagram(self) -> Diagram: 

134 """ 

135 Read-only property to access the diagram this bar belongs to (:attr:`_diagram`). 

136 

137 :returns: The diagram. 

138 """ 

139 return self._diagram 

140 

141 @readonly 

142 def Begin(self) -> datetime: 

143 """ 

144 Read-only property to access the begin of the bar (:attr:`_begin`). 

145 

146 :returns: The time the bar begins. 

147 """ 

148 return self._begin 

149 

150 @readonly 

151 def End(self) -> datetime: 

152 """ 

153 Read-only property to access the end of the bar (:attr:`_end`). 

154 

155 :returns: The time the bar ends. 

156 """ 

157 return self._end 

158 

159 @readonly 

160 def Duration(self) -> timedelta: 

161 """ 

162 Read-only property to return the length of the bar. 

163 

164 :returns: The length from the bar's begin to its end. 

165 """ 

166 return self._end - self._begin 

167 

168 @readonly 

169 def DurationInSeconds(self) -> float: 

170 """ 

171 Read-only property to return the length of the bar as a number. 

172 

173 :returns: The length in seconds. 

174 """ 

175 return (self._end - self._begin).total_seconds() 

176 

177 @readonly 

178 def BeginSinceOrigin(self) -> timedelta: 

179 """ 

180 Read-only property to return how long after the diagram's origin the bar begins. 

181 

182 :returns: The distance from the origin to the bar's begin. 

183 """ 

184 return self._begin - self._diagram.Origin 

185 

186 @readonly 

187 def EndSinceOrigin(self) -> timedelta: 

188 """ 

189 Read-only property to return how long after the diagram's origin the bar ends. 

190 

191 :returns: The distance from the origin to the bar's end. 

192 """ 

193 return self._end - self._diagram.Origin 

194 

195 @readonly 

196 def BeginSinceOriginInSeconds(self) -> float: 

197 """ 

198 Read-only property to return how long after the diagram's origin the bar begins, as a number. 

199 

200 :returns: The distance from the origin to the bar's begin, in seconds. 

201 """ 

202 return (self._begin - self._diagram.Origin).total_seconds() 

203 

204 @readonly 

205 def EndSinceOriginInSeconds(self) -> float: 

206 """ 

207 Read-only property to return how long after the diagram's origin the bar ends, as a number. 

208 

209 :returns: The distance from the origin to the bar's end, in seconds. 

210 """ 

211 return (self._end - self._diagram.Origin).total_seconds() 

212 

213 @readonly 

214 def BeginSinceParent(self) -> timedelta: 

215 """ 

216 Read-only property to return how long after its row the bar begins. 

217 

218 :returns: The distance from the row's begin to the bar's begin, which is zero for the row's earliest bar. 

219 """ 

220 return self._begin - self._parent.Begin 

221 

222 @readonly 

223 def EndSinceParent(self) -> timedelta: 

224 """ 

225 Read-only property to return how long after its row's begin the bar ends. 

226 

227 :returns: The distance from the row's begin to the bar's end. 

228 """ 

229 return self._end - self._parent.Begin 

230 

231 

232@export 

233class Row(metaclass=ExtendedType, slots=True): 

234 """ 

235 A row of a Gantt chart: the bars drawn on one line, under one name. 

236 

237 A row spans its bars: it begins with its earliest bar and ends with its latest, whether or not they touch. 

238 """ 

239 _parent: Diagram #: The diagram this row belongs to. 

240 _name: str #: Name of the row, which labels it in a chart. 

241 _bars: list[Bar] #: The bars of this row, in the order they were added. 

242 

243 def __init__(self, name: str, *, parent: Diagram) -> None: 

244 """ 

245 Initializes a row without bars and appends it to its diagram. 

246 

247 :param name: Name of the row. 

248 :param parent: The diagram the row belongs to. 

249 :raises ValueError: If parameter 'name' or 'parent' is None. 

250 :raises TypeError: If parameter 'name' is not of type :class:`str`. 

251 :raises TypeError: If parameter 'parent' is not of type :class:`Diagram`. 

252 """ 

253 if name is None: 

254 raise ValueError("Parameter 'name' is None.") 

255 elif not isinstance(name, str): 

256 ex = TypeError("Parameter 'name' is not of type 'str'.") 

257 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

258 raise ex 

259 

260 if parent is None: 

261 raise ValueError("Parameter 'parent' is None.") 

262 elif not isinstance(parent, Diagram): 

263 ex = TypeError("Parameter 'parent' is not of type 'Diagram'.") 

264 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

265 raise ex 

266 

267 self._parent = parent 

268 self._name = name 

269 self._bars = [] 

270 parent._rows.append(self) 

271 

272 @readonly 

273 def Parent(self) -> Diagram: 

274 """ 

275 Read-only property to access the diagram this row belongs to (:attr:`_parent`). 

276 

277 :returns: The diagram. 

278 """ 

279 return self._parent 

280 

281 @readonly 

282 def Diagram(self) -> Diagram: 

283 """ 

284 Read-only property to access the diagram this row belongs to (:attr:`_parent`), which is what contains it. 

285 

286 :returns: The diagram. 

287 """ 

288 return self._parent 

289 

290 @readonly 

291 def Name(self) -> str: 

292 """ 

293 Read-only property to access the name of the row (:attr:`_name`). 

294 

295 :returns: The name. 

296 """ 

297 return self._name 

298 

299 @readonly 

300 def Bars(self) -> tuple[Bar, ...]: 

301 """ 

302 Read-only property to return the bars of this row (:attr:`_bars`). 

303 

304 :returns: The bars, in the order they were added. 

305 """ 

306 return tuple(self._bars) 

307 

308 @readonly 

309 def Begin(self) -> datetime: 

310 """ 

311 Read-only property to return the begin of the row's earliest bar. 

312 

313 :returns: The time the row begins. 

314 :raises DiagramError: If the row has no bars. 

315 """ 

316 if len(self._bars) == 0: 

317 raise DiagramError(f"Row '{self._name}' has no bars, so it has no begin.") 

318 

319 return min(bar.Begin for bar in self._bars) 

320 

321 @readonly 

322 def End(self) -> datetime: 

323 """ 

324 Read-only property to return the end of the row's latest bar. 

325 

326 :returns: The time the row ends. 

327 :raises DiagramError: If the row has no bars. 

328 """ 

329 if len(self._bars) == 0: 

330 raise DiagramError(f"Row '{self._name}' has no bars, so it has no end.") 

331 

332 return max(bar.End for bar in self._bars) 

333 

334 @readonly 

335 def Duration(self) -> timedelta: 

336 """ 

337 Read-only property to return the length of the row. 

338 

339 :returns: The length from the row's begin to its end, gaps between its bars included. 

340 :raises DiagramError: If the row has no bars. 

341 """ 

342 return self.End - self.Begin 

343 

344 @readonly 

345 def DurationInSeconds(self) -> float: 

346 """ 

347 Read-only property to return the length of the row as a number. 

348 

349 :returns: The length in seconds, gaps between its bars included. 

350 :raises DiagramError: If the row has no bars. 

351 """ 

352 return (self.End - self.Begin).total_seconds() 

353 

354 @readonly 

355 def BeginSinceOrigin(self) -> timedelta: 

356 """ 

357 Read-only property to return how long after the diagram's origin the row begins. 

358 

359 :returns: The distance from the origin to the row's begin. 

360 :raises DiagramError: If the row has no bars. 

361 """ 

362 return self.Begin - self._parent.Origin 

363 

364 @readonly 

365 def EndSinceOrigin(self) -> timedelta: 

366 """ 

367 Read-only property to return how long after the diagram's origin the row ends. 

368 

369 :returns: The distance from the origin to the row's end. 

370 :raises DiagramError: If the row has no bars. 

371 """ 

372 return self.End - self._parent.Origin 

373 

374 @readonly 

375 def BarCount(self) -> int: 

376 """ 

377 Read-only property to return the number of bars. 

378 

379 :returns: Number of bars. 

380 """ 

381 return len(self._bars) 

382 

383 def IterateBars(self) -> Iterator[Bar]: 

384 """ 

385 Returns an iterator to iterate the bars of this row. 

386 

387 :returns: Iterator to iterate all bars, in the order they were added. 

388 """ 

389 return iter(self._bars) 

390 

391 def __len__(self) -> int: 

392 """ 

393 Returns the number of bars in this row. 

394 

395 :returns: Number of bars. 

396 """ 

397 return len(self._bars) 

398 

399 def __iter__(self) -> Iterator[Bar]: 

400 """ 

401 Returns an iterator to iterate the bars of this row. 

402 

403 :returns: Iterator to iterate all bars, in the order they were added. 

404 """ 

405 return iter(self._bars) 

406 

407 

408@export 

409class Diagram(metaclass=ExtendedType, slots=True): 

410 """ 

411 A Gantt chart: rows of bars, and the origin their offsets are counted from. 

412 

413 The origin is stated rather than derived, because the scale a chart is drawn on usually begins before its first 

414 bar - a pipeline starts before its first job does. 

415 """ 

416 _title: str #: Title of the diagram. 

417 _origin: datetime #: The time offsets are counted from. 

418 _rows: list[Row] #: The rows of this diagram, in the order they were added. 

419 

420 def __init__(self, title: str, origin: datetime) -> None: 

421 """ 

422 Initializes a diagram without rows. 

423 

424 :param title: Title of the diagram. 

425 :param origin: The time offsets are counted from. 

426 :raises ValueError: If parameter 'title' or 'origin' is None. 

427 :raises TypeError: If parameter 'title' is not of type :class:`str`. 

428 :raises TypeError: If parameter 'origin' is not of type :class:`~datetime.datetime`. 

429 """ 

430 if title is None: 

431 raise ValueError("Parameter 'title' is None.") 

432 elif not isinstance(title, str): 

433 ex = TypeError("Parameter 'title' is not of type 'str'.") 

434 ex.add_note(f"Got type '{getFullyQualifiedName(title)}'.") 

435 raise ex 

436 

437 if origin is None: 

438 raise ValueError("Parameter 'origin' is None.") 

439 elif not isinstance(origin, datetime): 

440 ex = TypeError("Parameter 'origin' is not of type 'datetime'.") 

441 ex.add_note(f"Got type '{getFullyQualifiedName(origin)}'.") 

442 raise ex 

443 

444 self._title = title 

445 self._origin = origin 

446 self._rows = [] 

447 

448 @readonly 

449 def Title(self) -> str: 

450 """ 

451 Read-only property to access the title of the diagram (:attr:`_title`). 

452 

453 :returns: The title. 

454 """ 

455 return self._title 

456 

457 @readonly 

458 def Origin(self) -> datetime: 

459 """ 

460 Read-only property to access the time offsets are counted from (:attr:`_origin`). 

461 

462 :returns: The origin. 

463 """ 

464 return self._origin 

465 

466 @readonly 

467 def Rows(self) -> tuple[Row, ...]: 

468 """ 

469 Read-only property to return the rows of this diagram (:attr:`_rows`). 

470 

471 :returns: The rows, in the order they were added. 

472 """ 

473 return tuple(self._rows) 

474 

475 @readonly 

476 def RowCount(self) -> int: 

477 """ 

478 Read-only property to return the number of rows. 

479 

480 :returns: Number of rows. 

481 """ 

482 return len(self._rows) 

483 

484 def IterateRows(self) -> Iterator[Row]: 

485 """ 

486 Returns an iterator to iterate the rows of this diagram. 

487 

488 :returns: Iterator to iterate all rows, in the order they were added. 

489 """ 

490 return iter(self._rows) 

491 

492 def __len__(self) -> int: 

493 """ 

494 Returns the number of rows in this diagram. 

495 

496 :returns: Number of rows. 

497 """ 

498 return len(self._rows) 

499 

500 def __iter__(self) -> Iterator[Row]: 

501 """ 

502 Returns an iterator to iterate the rows of this diagram. 

503 

504 :returns: Iterator to iterate all rows, in the order they were added. 

505 """ 

506 return iter(self._rows)