Coverage for python/lsst/daf/butler/_timespan.py: 28%

197 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-10 10:14 +0000

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27from __future__ import annotations 

28 

29__all__ = ( 

30 "SerializedTimespan", 

31 "Timespan", 

32) 

33 

34import enum 

35import warnings 

36from collections.abc import Generator 

37from typing import TYPE_CHECKING, Annotated, Any, ClassVar, TypeAlias 

38 

39import astropy.time 

40import astropy.utils.exceptions 

41import yaml 

42from pydantic import Field 

43 

44# As of astropy 4.2, the erfa interface is shipped independently and 

45# ErfaWarning is no longer an AstropyWarning 

46try: 

47 import erfa 

48except ImportError: 

49 erfa = None 

50 

51from lsst.utils.classes import cached_getter 

52 

53from .json import from_json_generic, to_json_generic 

54from .time_utils import TimeConverter 

55 

56if TYPE_CHECKING: # Imports needed only for type annotations; may be circular. 

57 from .dimensions import DimensionUniverse 

58 from .registry import Registry 

59 

60 

61_ONE_DAY = astropy.time.TimeDelta("1d", scale="tai") 

62 

63 

64class _SpecialTimespanBound(enum.Enum): 

65 """Enumeration to provide a singleton value for empty timespan bounds. 

66 

67 This enum's only member should generally be accessed via the 

68 `Timespan.EMPTY` alias. 

69 """ 

70 

71 EMPTY = enum.auto() 

72 """The value used for both `Timespan.begin` and `Timespan.end` for empty 

73 Timespans that contain no points. 

74 """ 

75 

76 

77TimespanBound: TypeAlias = astropy.time.Time | _SpecialTimespanBound | None 

78 

79SerializedTimespan = Annotated[list[int], Field(min_length=2, max_length=2)] 

80"""JSON-serializable representation of the Timespan class, as a list of two 

81integers ``[begin, end]`` in nanoseconds since the epoch. 

82""" 

83 

84 

85class Timespan: 

86 """A half-open time interval with nanosecond precision. 

87 

88 Parameters 

89 ---------- 

90 begin : `astropy.time.Time`, `Timespan.EMPTY`, or `None` 

91 Minimum timestamp in the interval (inclusive). `None` indicates that 

92 the timespan has no lower bound. `Timespan.EMPTY` indicates that the 

93 timespan contains no times; if this is used as either bound, the other 

94 bound is ignored. 

95 end : `astropy.time.Time`, `SpecialTimespanBound`, or `None` 

96 Maximum timestamp in the interval (exclusive). `None` indicates that 

97 the timespan has no upper bound. As with ``begin``, `Timespan.EMPTY` 

98 creates an empty timespan. 

99 padInstantaneous : `bool`, optional 

100 If `True` (default) and ``begin == end`` *after discretization to 

101 integer nanoseconds*, extend ``end`` by one nanosecond to yield a 

102 finite-duration timespan. If `False`, ``begin == end`` evaluates to 

103 the empty timespan. 

104 _nsec : `tuple` of `int`, optional 

105 Integer nanosecond representation, for internal use by `Timespan` and 

106 `TimespanDatabaseRepresentation` implementation only. If provided, 

107 all other arguments are are ignored. 

108 

109 Raises 

110 ------ 

111 TypeError 

112 Raised if ``begin`` or ``end`` has a type other than 

113 `astropy.time.Time`, and is not `None` or `Timespan.EMPTY`. 

114 ValueError 

115 Raised if ``begin`` or ``end`` exceeds the minimum or maximum times 

116 supported by this class. 

117 

118 Notes 

119 ----- 

120 Timespans are half-open intervals, i.e. ``[begin, end)``. 

121 

122 Any timespan with ``begin > end`` after nanosecond discretization 

123 (``begin >= end`` if ``padInstantaneous`` is `False`), or with either bound 

124 set to `Timespan.EMPTY`, is transformed into the empty timespan, with both 

125 bounds set to `Timespan.EMPTY`. The empty timespan is equal to itself, and 

126 contained by all other timespans (including itself). It is also disjoint 

127 with all timespans (including itself), and hence does not overlap any 

128 timespan - this is the only case where ``contains`` does not imply 

129 ``overlaps``. 

130 

131 Finite timespan bounds are represented internally as integer nanoseconds, 

132 and hence construction from `astropy.time.Time` (which has picosecond 

133 accuracy) can involve a loss of precision. This is of course 

134 deterministic, so any `astropy.time.Time` value is always mapped 

135 to the exact same timespan bound, but if ``padInstantaneous`` is `True`, 

136 timespans that are empty at full precision (``begin > end``, 

137 ``begin - end < 1ns``) may be finite after discretization. In all other 

138 cases, the relationships between full-precision timespans should be 

139 preserved even if the values are not. 

140 

141 The `astropy.time.Time` bounds that can be obtained after construction from 

142 `Timespan.begin` and `Timespan.end` are also guaranteed to round-trip 

143 exactly when used to construct other `Timespan` instances. 

144 """ 

145 

146 def __init__( 

147 self, 

148 begin: TimespanBound, 

149 end: TimespanBound, 

150 padInstantaneous: bool = True, 

151 _nsec: tuple[int, int] | None = None, 

152 ): 

153 converter = TimeConverter() 

154 if _nsec is None: 

155 begin_nsec: int 

156 if begin is None: 

157 begin_nsec = converter.min_nsec 

158 elif begin is self.EMPTY: 

159 begin_nsec = converter.max_nsec 

160 elif isinstance(begin, astropy.time.Time): 

161 begin_nsec = converter.astropy_to_nsec(begin) 

162 else: 

163 raise TypeError( 

164 f"Unexpected value of type {type(begin).__name__} for Timespan.begin: {begin!r}." 

165 ) 

166 end_nsec: int 

167 if end is None: 

168 end_nsec = converter.max_nsec 

169 elif end is self.EMPTY: 

170 end_nsec = converter.min_nsec 

171 elif isinstance(end, astropy.time.Time): 

172 end_nsec = converter.astropy_to_nsec(end) 

173 else: 

174 raise TypeError(f"Unexpected value of type {type(end).__name__} for Timespan.end: {end!r}.") 

175 if begin_nsec == end_nsec: 

176 if begin_nsec == converter.max_nsec or end_nsec == converter.min_nsec: 

177 with warnings.catch_warnings(): 

178 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning) 

179 if erfa is not None: 

180 warnings.simplefilter("ignore", category=erfa.ErfaWarning) 

181 if begin is not None and begin < converter.epoch: 

182 raise ValueError(f"Timespan.begin may not be earlier than {converter.epoch}.") 

183 if end is not None and end > converter.max_time: 

184 raise ValueError(f"Timespan.end may not be later than {converter.max_time}.") 

185 raise ValueError("Infinite instantaneous timespans are not supported.") 

186 elif padInstantaneous: 

187 end_nsec += 1 

188 if end_nsec == converter.max_nsec: 

189 raise ValueError( 

190 f"Cannot construct near-instantaneous timespan at {end}; " 

191 "within one ns of maximum time." 

192 ) 

193 _nsec = (begin_nsec, end_nsec) 

194 if _nsec[0] >= _nsec[1]: 

195 # Standardizing all empty timespans to the same underlying values 

196 # here simplifies all other operations (including interactions 

197 # with TimespanDatabaseRepresentation implementations). 

198 _nsec = (converter.max_nsec, converter.min_nsec) 

199 self._nsec = _nsec 

200 

201 __slots__ = ("_nsec", "_cached_begin", "_cached_end") 

202 

203 EMPTY: ClassVar[_SpecialTimespanBound] = _SpecialTimespanBound.EMPTY 

204 

205 # YAML tag name for Timespan 

206 yaml_tag = "!lsst.daf.butler.Timespan" 

207 

208 @classmethod 

209 def makeEmpty(cls) -> Timespan: 

210 """Construct an empty timespan. 

211 

212 Returns 

213 ------- 

214 empty : `Timespan` 

215 A timespan that is contained by all timespans (including itself) 

216 and overlaps no other timespans (including itself). 

217 """ 

218 converter = TimeConverter() 

219 return Timespan(None, None, _nsec=(converter.max_nsec, converter.min_nsec)) 

220 

221 @classmethod 

222 def fromInstant(cls, time: astropy.time.Time) -> Timespan: 

223 """Construct a timespan that approximates an instant in time. 

224 

225 This is done by constructing a minimum-possible (1 ns) duration 

226 timespan. 

227 

228 This is equivalent to ``Timespan(time, time, padInstantaneous=True)``, 

229 but may be slightly more efficient. 

230 

231 Parameters 

232 ---------- 

233 time : `astropy.time.Time` 

234 Time to use for the lower bound. 

235 

236 Returns 

237 ------- 

238 instant : `Timespan` 

239 A ``[time, time + 1ns)`` timespan. 

240 """ 

241 converter = TimeConverter() 

242 nsec = converter.astropy_to_nsec(time) 

243 if nsec == converter.max_nsec - 1: 

244 raise ValueError( 

245 f"Cannot construct near-instantaneous timespan at {time}; within one ns of maximum time." 

246 ) 

247 return Timespan(None, None, _nsec=(nsec, nsec + 1)) 

248 

249 @classmethod 

250 def from_day_obs(cls, day_obs: int, offset: int = 0) -> Timespan: 

251 """Construct a timespan for a 24-hour period based on the day of 

252 observation. 

253 

254 Parameters 

255 ---------- 

256 day_obs : `int` 

257 The day of observation as an integer of the form YYYYMMDD. 

258 The year must be at least 1970 since these are converted to TAI. 

259 offset : `int`, optional 

260 Offset in seconds from TAI midnight to be applied. 

261 

262 Returns 

263 ------- 

264 day_span : `Timespan` 

265 A timespan corresponding to a full day of observing. 

266 

267 Notes 

268 ----- 

269 If the observing day is 20240229 and the offset is 12 hours the 

270 resulting time span will be 2024-02-29T12:00 to 2024-03-01T12:00. 

271 """ 

272 if day_obs < 1970_00_00 or day_obs > 1_0000_00_00: 

273 raise ValueError(f"day_obs must be in form yyyyMMDD and be newer than 1970, not {day_obs}.") 

274 

275 ymd = str(day_obs) 

276 t1 = astropy.time.Time(f"{ymd[0:4]}-{ymd[4:6]}-{ymd[6:8]}T00:00:00", format="isot", scale="tai") 

277 

278 if offset != 0: 

279 t_delta = astropy.time.TimeDelta(offset, format="sec", scale="tai") 

280 t1 += t_delta 

281 

282 t2 = t1 + _ONE_DAY 

283 

284 return Timespan(t1, t2) 

285 

286 @property 

287 @cached_getter 

288 def begin(self) -> TimespanBound: 

289 """Minimum timestamp in the interval, inclusive. 

290 

291 If this bound is finite, this is an `astropy.time.Time` instance. 

292 If the timespan is unbounded from below, this is `None`. 

293 If the timespan is empty, this is the special value `Timespan.EMPTY`. 

294 """ 

295 if self.isEmpty(): 

296 return self.EMPTY 

297 elif self._nsec[0] == TimeConverter().min_nsec: 

298 return None 

299 else: 

300 return TimeConverter().nsec_to_astropy(self._nsec[0]) 

301 

302 @property 

303 @cached_getter 

304 def end(self) -> TimespanBound: 

305 """Maximum timestamp in the interval, exclusive. 

306 

307 If this bound is finite, this is an `astropy.time.Time` instance. 

308 If the timespan is unbounded from above, this is `None`. 

309 If the timespan is empty, this is the special value `Timespan.EMPTY`. 

310 """ 

311 if self.isEmpty(): 

312 return self.EMPTY 

313 elif self._nsec[1] == TimeConverter().max_nsec: 

314 return None 

315 else: 

316 return TimeConverter().nsec_to_astropy(self._nsec[1]) 

317 

318 def isEmpty(self) -> bool: 

319 """Test whether ``self`` is the empty timespan (`bool`).""" 

320 return self._nsec[0] >= self._nsec[1] 

321 

322 def __str__(self) -> str: 

323 if self.isEmpty(): 

324 return "(empty)" 

325 fmt = "%Y-%m-%dT%H:%M:%S" 

326 # Trap dubious year warnings in case we have timespans from 

327 # simulated data in the future 

328 with warnings.catch_warnings(): 

329 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning) 

330 if erfa is not None: 

331 warnings.simplefilter("ignore", category=erfa.ErfaWarning) 

332 if self.begin is None: 

333 head = "(-∞, " 

334 else: 

335 assert isinstance(self.begin, astropy.time.Time), "guaranteed by earlier checks and ctor" 

336 head = f"[{self.begin.tai.strftime(fmt)}, " 

337 if self.end is None: 

338 tail = "∞)" 

339 else: 

340 assert isinstance(self.end, astropy.time.Time), "guaranteed by earlier checks and ctor" 

341 tail = f"{self.end.tai.strftime(fmt)})" 

342 return head + tail 

343 

344 def __repr__(self) -> str: 

345 # astropy.time.Time doesn't have an eval-friendly __repr__, so we 

346 # simulate our own here to make Timespan's __repr__ eval-friendly. 

347 # Interestingly, enum.Enum has an eval-friendly __str__, but not an 

348 # eval-friendly __repr__. 

349 tmpl = "astropy.time.Time('{t}', scale='{t.scale}', format='{t.format}')" 

350 begin = tmpl.format(t=self.begin) if isinstance(self.begin, astropy.time.Time) else str(self.begin) 

351 end = tmpl.format(t=self.end) if isinstance(self.end, astropy.time.Time) else str(self.end) 

352 return f"Timespan(begin={begin}, end={end})" 

353 

354 def __eq__(self, other: Any) -> bool: 

355 if not isinstance(other, Timespan): 

356 return False 

357 # Correctness of this simple implementation depends on __init__ 

358 # standardizing all empty timespans to a single value. 

359 return self._nsec == other._nsec 

360 

361 def __hash__(self) -> int: 

362 # Correctness of this simple implementation depends on __init__ 

363 # standardizing all empty timespans to a single value. 

364 return hash(self._nsec) 

365 

366 def __reduce__(self) -> tuple: 

367 return (Timespan, (None, None, False, self._nsec)) 

368 

369 def __lt__(self, other: astropy.time.Time | Timespan) -> bool: 

370 """Test if a Timespan's bounds are strictly less than the given time. 

371 

372 Parameters 

373 ---------- 

374 other : `Timespan` or `astropy.time.Time`. 

375 Timespan or instant in time to relate to ``self``. 

376 

377 Returns 

378 ------- 

379 less : `bool` 

380 The result of the less-than test. `False` if either operand is 

381 empty. 

382 """ 

383 # First term in each expression below is the "normal" one; the second 

384 # ensures correct behavior for empty timespans. It's important that 

385 # the second uses a strict inequality to make sure inf == inf isn't in 

386 # play, and it's okay for the second to use a strict inequality only 

387 # because we know non-empty Timespans have nonzero duration, and hence 

388 # the second term is never false for non-empty timespans unless the 

389 # first term is also false. 

390 if isinstance(other, astropy.time.Time): 

391 nsec = TimeConverter().astropy_to_nsec(other) 

392 return self._nsec[1] <= nsec and self._nsec[0] < nsec 

393 else: 

394 return self._nsec[1] <= other._nsec[0] and self._nsec[0] < other._nsec[1] 

395 

396 def __gt__(self, other: astropy.time.Time | Timespan) -> bool: 

397 """Test if a Timespan's bounds are strictly greater than given time. 

398 

399 Parameters 

400 ---------- 

401 other : `Timespan` or `astropy.time.Time`. 

402 Timespan or instant in time to relate to ``self``. 

403 

404 Returns 

405 ------- 

406 greater : `bool` 

407 The result of the greater-than test. `False` if either operand is 

408 empty. 

409 """ 

410 # First term in each expression below is the "normal" one; the second 

411 # ensures correct behavior for empty timespans. It's important that 

412 # the second uses a strict inequality to make sure inf == inf isn't in 

413 # play, and it's okay for the second to use a strict inequality only 

414 # because we know non-empty Timespans have nonzero duration, and hence 

415 # the second term is never false for non-empty timespans unless the 

416 # first term is also false. 

417 if isinstance(other, astropy.time.Time): 

418 nsec = TimeConverter().astropy_to_nsec(other) 

419 return self._nsec[0] > nsec and self._nsec[1] > nsec 

420 else: 

421 return self._nsec[0] >= other._nsec[1] and self._nsec[1] > other._nsec[0] 

422 

423 def overlaps(self, other: Timespan | astropy.time.Time) -> bool: 

424 """Test if the intersection of this Timespan with another is empty. 

425 

426 Parameters 

427 ---------- 

428 other : `Timespan` or `astropy.time.Time` 

429 Timespan or time to relate to ``self``. If a single time, this is 

430 a synonym for `contains`. 

431 

432 Returns 

433 ------- 

434 overlaps : `bool` 

435 The result of the overlap test. 

436 

437 Notes 

438 ----- 

439 If either ``self`` or ``other`` is empty, the result is always `False`. 

440 In all other cases, ``self.contains(other)`` being `True` implies that 

441 ``self.overlaps(other)`` is also `True`. 

442 """ 

443 if isinstance(other, astropy.time.Time): 

444 return self.contains(other) 

445 return self._nsec[1] > other._nsec[0] and other._nsec[1] > self._nsec[0] 

446 

447 def contains(self, other: astropy.time.Time | Timespan) -> bool: 

448 """Test if the supplied timespan is within this one. 

449 

450 Tests whether the intersection of this timespan with another timespan 

451 or point is equal to the other one. 

452 

453 Parameters 

454 ---------- 

455 other : `Timespan` or `astropy.time.Time` 

456 Timespan or instant in time to relate to ``self``. 

457 

458 Returns 

459 ------- 

460 overlaps : `bool` 

461 The result of the contains test. 

462 

463 Notes 

464 ----- 

465 If ``other`` is empty, `True` is always returned. In all other cases, 

466 ``self.contains(other)`` being `True` implies that 

467 ``self.overlaps(other)`` is also `True`. 

468 

469 Testing whether an instantaneous `astropy.time.Time` value is contained 

470 in a timespan is not equivalent to testing a timespan constructed via 

471 `Timespan.fromInstant`, because Timespan cannot exactly represent 

472 zero-duration intervals. In particular, ``[a, b)`` contains the time 

473 ``b``, but not the timespan ``[b, b + 1ns)`` that would be returned 

474 by `Timespan.fromInstant(b)``. 

475 """ 

476 if isinstance(other, astropy.time.Time): 

477 nsec = TimeConverter().astropy_to_nsec(other) 

478 return self._nsec[0] <= nsec and self._nsec[1] > nsec 

479 else: 

480 return self._nsec[0] <= other._nsec[0] and self._nsec[1] >= other._nsec[1] 

481 

482 def intersection(self, *args: Timespan) -> Timespan: 

483 """Return a new `Timespan` that is contained by all of the given ones. 

484 

485 Parameters 

486 ---------- 

487 *args 

488 All positional arguments are `Timespan` instances. 

489 

490 Returns 

491 ------- 

492 intersection : `Timespan` 

493 The intersection timespan. 

494 """ 

495 if not args: 

496 return self 

497 lowers = [self._nsec[0]] 

498 lowers.extend(ts._nsec[0] for ts in args) 

499 uppers = [self._nsec[1]] 

500 uppers.extend(ts._nsec[1] for ts in args) 

501 nsec = (max(*lowers), min(*uppers)) 

502 return Timespan(begin=None, end=None, _nsec=nsec) 

503 

504 def difference(self, other: Timespan) -> Generator[Timespan, None, None]: 

505 """Return the one or two timespans that cover the interval(s). 

506 

507 The interval is defined as one that is in ``self`` but not ``other``. 

508 

509 This is implemented as a generator because the result may be zero, one, 

510 or two `Timespan` objects, depending on the relationship between the 

511 operands. 

512 

513 Parameters 

514 ---------- 

515 other : `Timespan` 

516 Timespan to subtract. 

517 

518 Yields 

519 ------ 

520 result : `Timespan` 

521 A `Timespan` that is contained by ``self`` but does not overlap 

522 ``other``. Guaranteed not to be empty. 

523 """ 

524 intersection = self.intersection(other) 

525 if intersection.isEmpty(): 

526 yield self 

527 elif intersection == self: 

528 yield from () 

529 else: 

530 if intersection._nsec[0] > self._nsec[0]: 

531 yield Timespan(None, None, _nsec=(self._nsec[0], intersection._nsec[0])) 

532 if intersection._nsec[1] < self._nsec[1]: 

533 yield Timespan(None, None, _nsec=(intersection._nsec[1], self._nsec[1])) 

534 

535 def to_simple(self, minimal: bool = False) -> SerializedTimespan: 

536 """Return simple python type form suitable for serialization. 

537 

538 Parameters 

539 ---------- 

540 minimal : `bool`, optional 

541 Use minimal serialization. Has no effect on for this class. 

542 

543 Returns 

544 ------- 

545 simple : `list` of `int` 

546 The internal span as integer nanoseconds. 

547 """ 

548 # Return the internal nanosecond form rather than astropy ISO string 

549 return list(self._nsec) 

550 

551 @classmethod 

552 def from_simple( 

553 cls, 

554 simple: SerializedTimespan | None, 

555 universe: DimensionUniverse | None = None, 

556 registry: Registry | None = None, 

557 ) -> Timespan | None: 

558 """Construct a new object from simplified form. 

559 

560 Designed to use the data returned from the `to_simple` method. 

561 

562 Parameters 

563 ---------- 

564 simple : `list` of `int`, or `None` 

565 The values returned by `to_simple()`. 

566 universe : `DimensionUniverse`, optional 

567 Unused. 

568 registry : `lsst.daf.butler.Registry`, optional 

569 Unused. 

570 

571 Returns 

572 ------- 

573 result : `Timespan` or `None` 

574 Newly-constructed object. 

575 """ 

576 if simple is None: 

577 return None 

578 nsec1, nsec2 = simple # for mypy 

579 return cls(begin=None, end=None, _nsec=(nsec1, nsec2)) 

580 

581 to_json = to_json_generic 

582 from_json: ClassVar = classmethod(from_json_generic) 

583 

584 @classmethod 

585 def to_yaml(cls, dumper: yaml.Dumper, timespan: Timespan) -> Any: 

586 """Convert Timespan into YAML format. 

587 

588 This produces a scalar node with a tag "!_SpecialTimespanBound" and 

589 value being a name of _SpecialTimespanBound enum. 

590 

591 Parameters 

592 ---------- 

593 dumper : `yaml.Dumper` 

594 YAML dumper instance. 

595 timespan : `Timespan` 

596 Data to be converted. 

597 """ 

598 if timespan.isEmpty(): 

599 return dumper.represent_scalar(cls.yaml_tag, "EMPTY") 

600 else: 

601 return dumper.represent_mapping( 

602 cls.yaml_tag, 

603 dict(begin=timespan.begin, end=timespan.end), 

604 ) 

605 

606 @classmethod 

607 def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.MappingNode) -> Timespan | None: 

608 """Convert YAML node into _SpecialTimespanBound. 

609 

610 Parameters 

611 ---------- 

612 loader : `yaml.SafeLoader` 

613 Instance of YAML loader class. 

614 node : `yaml.ScalarNode` 

615 YAML node. 

616 

617 Returns 

618 ------- 

619 value : `Timespan` 

620 Timespan instance, can be ``None``. 

621 """ 

622 if node.value is None: 

623 return None 

624 elif node.value == "EMPTY": 

625 return Timespan.makeEmpty() 

626 else: 

627 d = loader.construct_mapping(node) 

628 return Timespan(d["begin"], d["end"]) 

629 

630 

631# Register Timespan -> YAML conversion method with Dumper class 

632yaml.Dumper.add_representer(Timespan, Timespan.to_yaml) 

633 

634# Register YAML -> Timespan conversion method with Loader, for our use case we 

635# only need SafeLoader. 

636yaml.SafeLoader.add_constructor(Timespan.yaml_tag, Timespan.from_yaml)