Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2# cardinal_pythonlib/datetimefunc.py 

3 

4""" 

5=============================================================================== 

6 

7 Original code copyright (C) 2009-2021 Rudolf Cardinal (rudolf@pobox.com). 

8 

9 This file is part of cardinal_pythonlib. 

10 

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

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

13 You may obtain a copy of the License at 

14 

15 https://www.apache.org/licenses/LICENSE-2.0 

16 

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

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

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

20 See the License for the specific language governing permissions and 

21 limitations under the License. 

22 

23=============================================================================== 

24 

25**Support functions for date/time.** 

26""" 

27 

28import datetime 

29import logging 

30import sys 

31from string import Formatter 

32from typing import Any, Optional, Union 

33import unittest 

34 

35try: 

36 from arrow import Arrow 

37except ImportError: 

38 Arrow = None 

39 

40try: 

41 import dateutil.parser 

42except ImportError: 

43 dateutil = None 

44 

45from isodate.isoduration import parse_duration, Duration as IsodateDuration 

46import pendulum 

47from pendulum import Date, DateTime, Duration, Time 

48from pendulum.tz import local_timezone 

49from pendulum.tz.timezone import Timezone 

50 

51from cardinal_pythonlib.logs import main_only_quicksetup_rootlogger 

52 

53PotentialDatetimeType = Union[None, datetime.datetime, datetime.date, 

54 DateTime, str, Arrow] 

55DateTimeLikeType = Union[datetime.datetime, DateTime, Arrow] 

56DateLikeType = Union[datetime.date, DateTime, Arrow] 

57 

58log = logging.getLogger(__name__) 

59 

60 

61# ============================================================================= 

62# Coerce things to our favourite datetime class 

63# ... including adding timezone information to timezone-naive objects 

64# ============================================================================= 

65 

66def coerce_to_pendulum(x: PotentialDatetimeType, 

67 assume_local: bool = False) -> Optional[DateTime]: 

68 """ 

69 Converts something to a :class:`pendulum.DateTime`. 

70 

71 Args: 

72 x: something that may be coercible to a datetime 

73 assume_local: if ``True``, assume local timezone; if ``False``, assume 

74 UTC 

75 

76 Returns: 

77 a :class:`pendulum.DateTime`, or ``None``. 

78 

79 Raises: 

80 pendulum.parsing.exceptions.ParserError: if a string fails to parse 

81 ValueError: if no conversion possible 

82 """ 

83 if not x: # None and blank string 

84 return None 

85 if isinstance(x, DateTime): 

86 return x 

87 tz = get_tz_local() if assume_local else get_tz_utc() 

88 if isinstance(x, datetime.datetime): 

89 # noinspection PyTypeChecker 

90 return pendulum.instance(x, tz=tz) # (*) 

91 elif isinstance(x, datetime.date): 

92 # BEWARE: datetime subclasses date. The order is crucial here. 

93 # Can also use: type(x) is datetime.date 

94 # noinspection PyUnresolvedReferences 

95 midnight = DateTime.min.time() 

96 # We use the standard python datetime.combine rather than the pendulum 

97 # DateTime.combine so that the tz will not be ignored in the call to 

98 # pendulum.instance 

99 dt = datetime.datetime.combine(x, midnight) 

100 # noinspection PyTypeChecker 

101 return pendulum.instance(dt, tz=tz) # (*) 

102 elif isinstance(x, str): 

103 # noinspection PyTypeChecker 

104 return pendulum.parse(x, tz=tz) # (*) # may raise 

105 else: 

106 raise ValueError(f"Don't know how to convert to DateTime: {x!r}") 

107 # (*) If x already knew its timezone, it will not 

108 # be altered; "tz" will only be applied in the absence of other info. 

109 

110 

111def coerce_to_pendulum_date(x: PotentialDatetimeType, 

112 assume_local: bool = False) -> Optional[Date]: 

113 """ 

114 Converts something to a :class:`pendulum.Date`. 

115 

116 Args: 

117 x: something that may be coercible to a date 

118 assume_local: if ``True``, assume local timezone; if ``False``, assume 

119 UTC 

120 

121 Returns: 

122 a :class:`pendulum.Date`, or ``None``. 

123 

124 Raises: 

125 pendulum.parsing.exceptions.ParserError: if a string fails to parse 

126 ValueError: if no conversion possible 

127 """ 

128 p = coerce_to_pendulum(x, assume_local=assume_local) 

129 return None if p is None else p.date() 

130 

131 

132def pendulum_to_datetime(x: DateTime) -> datetime.datetime: 

133 """ 

134 Used, for example, where a database backend insists on datetime.datetime. 

135 

136 Compare code in :meth:`pendulum.datetime.DateTime.int_timestamp`. 

137 """ 

138 return datetime.datetime( 

139 x.year, x.month, x.day, 

140 x.hour, x.minute, x.second, x.microsecond, 

141 tzinfo=x.tzinfo 

142 ) 

143 

144 

145def pendulum_to_datetime_stripping_tz(x: DateTime) -> datetime.datetime: 

146 """ 

147 Converts a Pendulum ``DateTime`` to a ``datetime.datetime`` that has had 

148 timezone information stripped. 

149 """ 

150 return datetime.datetime( 

151 x.year, x.month, x.day, 

152 x.hour, x.minute, x.second, x.microsecond, 

153 tzinfo=None 

154 ) 

155 

156 

157def pendulum_to_utc_datetime_without_tz(x: DateTime) -> datetime.datetime: 

158 """ 

159 Converts a Pendulum ``DateTime`` (which will have timezone information) to 

160 a ``datetime.datetime`` that (a) has no timezone information, and (b) is 

161 in UTC. 

162 

163 Example: 

164 

165 .. code-block:: python 

166 

167 import pendulum 

168 from cardinal_pythonlib.datetimefunc import * 

169 in_moscow = pendulum.parse("2018-01-01T09:00+0300") # 9am in Moscow 

170 in_london = pendulum.UTC.convert(in_moscow) # 6am in UTC 

171 dt_utc_from_moscow = pendulum_to_utc_datetime_without_tz(in_moscow) # 6am, no timezone info 

172 dt_utc_from_london = pendulum_to_utc_datetime_without_tz(in_london) # 6am, no timezone info 

173 

174 """ # noqa 

175 pendulum_in_utc = pendulum.UTC.convert(x) 

176 return pendulum_to_datetime_stripping_tz(pendulum_in_utc) 

177 

178 

179def pendulum_date_to_datetime_date(x: Date) -> datetime.date: 

180 """ 

181 Takes a :class:`pendulum.Date` and returns a :class:`datetime.date`. 

182 Used, for example, where a database backend insists on 

183 :class:`datetime.date`. 

184 """ 

185 return datetime.date(year=x.year, month=x.month, day=x.day) 

186 

187 

188def pendulum_time_to_datetime_time(x: Time) -> datetime.time: 

189 """ 

190 Takes a :class:`pendulum.Time` and returns a :class:`datetime.time`. 

191 Used, for example, where a database backend insists on 

192 :class:`datetime.time`. 

193 """ 

194 return datetime.time( 

195 hour=x.hour, minute=x.minute, second=x.second, 

196 microsecond=x.microsecond, 

197 tzinfo=x.tzinfo 

198 ) 

199 

200 

201# ============================================================================= 

202# Format dates/times/timedelta to strings 

203# ============================================================================= 

204 

205def format_datetime(d: PotentialDatetimeType, 

206 fmt: str, 

207 default: str = None) -> Optional[str]: 

208 """ 

209 Format a datetime with a ``strftime`` format specification string, or 

210 return ``default`` if the input is ``None``. 

211 """ 

212 d = coerce_to_pendulum(d) 

213 if d is None: 

214 return default 

215 return d.strftime(fmt) 

216 

217 

218def strfdelta(tdelta: Union[datetime.timedelta, int, float, str], 

219 fmt='{D:02}d {H:02}h {M:02}m {S:02}s', 

220 inputtype='timedelta'): 

221 """ 

222 Convert a ``datetime.timedelta`` object or a regular number to a custom- 

223 formatted string, just like the ``strftime()`` method does for 

224 ``datetime.datetime`` objects. 

225 

226 The ``fmt`` argument allows custom formatting to be specified. Fields can 

227 include ``seconds``, ``minutes``, ``hours``, ``days``, and ``weeks``. Each 

228 field is optional. 

229 

230 Some examples: 

231 

232 .. code-block:: none 

233 

234 '{D:02}d {H:02}h {M:02}m {S:02}s' --> '05d 08h 04m 02s' (default) 

235 '{W}w {D}d {H}:{M:02}:{S:02}' --> '4w 5d 8:04:02' 

236 '{D:2}d {H:2}:{M:02}:{S:02}' --> ' 5d 8:04:02' 

237 '{H}h {S}s' --> '72h 800s' 

238 

239 The ``inputtype`` argument allows ``tdelta`` to be a regular number, 

240 instead of the default behaviour of treating it as a ``datetime.timedelta`` 

241 object. Valid ``inputtype`` strings: 

242 

243 .. code-block:: none 

244 

245 'timedelta', # treats input as a datetime.timedelta 

246 's', 'seconds', 

247 'm', 'minutes', 

248 'h', 'hours', 

249 'd', 'days', 

250 'w', 'weeks' 

251 

252 Modified from 

253 https://stackoverflow.com/questions/538666/python-format-timedelta-to-string 

254 """ # noqa 

255 

256 # Convert tdelta to integer seconds. 

257 if inputtype == 'timedelta': 

258 remainder = int(tdelta.total_seconds()) 

259 elif inputtype in ['s', 'seconds']: 

260 remainder = int(tdelta) 

261 elif inputtype in ['m', 'minutes']: 

262 remainder = int(tdelta) * 60 

263 elif inputtype in ['h', 'hours']: 

264 remainder = int(tdelta) * 3600 

265 elif inputtype in ['d', 'days']: 

266 remainder = int(tdelta) * 86400 

267 elif inputtype in ['w', 'weeks']: 

268 remainder = int(tdelta) * 604800 

269 else: 

270 raise ValueError(f"Bad inputtype: {inputtype}") 

271 

272 f = Formatter() 

273 desired_fields = [field_tuple[1] for field_tuple in f.parse(fmt)] 

274 possible_fields = ('W', 'D', 'H', 'M', 'S') 

275 constants = {'W': 604800, 'D': 86400, 'H': 3600, 'M': 60, 'S': 1} 

276 values = {} 

277 for field in possible_fields: 

278 if field in desired_fields and field in constants: 

279 values[field], remainder = divmod(remainder, constants[field]) 

280 return f.format(fmt, **values) 

281 

282 

283# ============================================================================= 

284# Time zones themselves 

285# ============================================================================= 

286 

287def get_tz_local() -> Timezone: # datetime.tzinfo: 

288 """ 

289 Returns the local timezone, in :class:`pendulum.Timezone`` format. 

290 (This is a subclass of :class:`datetime.tzinfo`.) 

291 """ 

292 return local_timezone() 

293 

294 

295def get_tz_utc() -> Timezone: # datetime.tzinfo: 

296 """ 

297 Returns the UTC timezone. 

298 """ 

299 return pendulum.UTC 

300 

301 

302# ============================================================================= 

303# Now 

304# ============================================================================= 

305 

306def get_now_localtz_pendulum() -> DateTime: 

307 """ 

308 Get the time now in the local timezone, as a :class:`pendulum.DateTime`. 

309 """ 

310 tz = get_tz_local() 

311 return pendulum.now().in_tz(tz) 

312 

313 

314def get_now_utc_pendulum() -> DateTime: 

315 """ 

316 Get the time now in the UTC timezone, as a :class:`pendulum.DateTime`. 

317 """ 

318 tz = get_tz_utc() 

319 return DateTime.utcnow().in_tz(tz) 

320 

321 

322def get_now_utc_datetime() -> datetime.datetime: 

323 """ 

324 Get the time now in the UTC timezone, as a :class:`datetime.datetime`. 

325 """ 

326 return datetime.datetime.now(pendulum.UTC) 

327 

328 

329# ============================================================================= 

330# From one timezone to another 

331# ============================================================================= 

332 

333def convert_datetime_to_utc(dt: PotentialDatetimeType) -> DateTime: 

334 """ 

335 Convert date/time with timezone to UTC (with UTC timezone). 

336 """ 

337 dt = coerce_to_pendulum(dt) 

338 tz = get_tz_utc() 

339 return dt.in_tz(tz) 

340 

341 

342def convert_datetime_to_local(dt: PotentialDatetimeType) -> DateTime: 

343 """ 

344 Convert date/time with timezone to local timezone. 

345 """ 

346 dt = coerce_to_pendulum(dt) 

347 tz = get_tz_local() 

348 return dt.in_tz(tz) 

349 

350 

351# ============================================================================= 

352# Time differences 

353# ============================================================================= 

354 

355def get_duration_h_m(start: Union[str, DateTime], 

356 end: Union[str, DateTime], 

357 default: str = "N/A") -> str: 

358 """ 

359 Calculate the time between two dates/times expressed as strings. 

360 

361 Args: 

362 start: start date/time 

363 end: end date/time 

364 default: string value to return in case either of the inputs is 

365 ``None`` 

366 

367 Returns: 

368 a string that is one of 

369 

370 .. code-block: 

371 

372 'hh:mm' 

373 '-hh:mm' 

374 default 

375 

376 """ 

377 start = coerce_to_pendulum(start) 

378 end = coerce_to_pendulum(end) 

379 if start is None or end is None: 

380 return default 

381 duration = end - start 

382 minutes = duration.in_minutes() 

383 (hours, minutes) = divmod(minutes, 60) 

384 if hours < 0: 

385 # negative... trickier 

386 # Python's divmod does interesting things with negative numbers: 

387 # Hours will be negative, and minutes always positive 

388 hours += 1 

389 minutes = 60 - minutes 

390 return "-{}:{}".format(hours, "00" if minutes == 0 else minutes) 

391 else: 

392 return "{}:{}".format(hours, "00" if minutes == 0 else minutes) 

393 

394 

395def get_age(dob: PotentialDatetimeType, 

396 when: PotentialDatetimeType, 

397 default: str = "") -> Union[int, str]: 

398 """ 

399 Age (in whole years) at a particular date, or ``default``. 

400 

401 Args: 

402 dob: date of birth 

403 when: date/time at which to calculate age 

404 default: value to return if either input is ``None`` 

405 

406 Returns: 

407 age in whole years (rounded down), or ``default`` 

408 

409 """ 

410 dob = coerce_to_pendulum_date(dob) 

411 when = coerce_to_pendulum_date(when) 

412 if dob is None or when is None: 

413 return default 

414 return (when - dob).years 

415 

416 

417def pendulum_duration_from_timedelta(td: datetime.timedelta) -> Duration: 

418 """ 

419 Converts a :class:`datetime.timedelta` into a :class:`pendulum.Duration`. 

420 

421 .. code-block:: python 

422 

423 from cardinal_pythonlib.datetimefunc import pendulum_duration_from_timedelta 

424 from datetime import timedelta 

425 from pendulum import Duration 

426 

427 td1 = timedelta(days=5, hours=3, minutes=2, microseconds=5) 

428 d1 = pendulum_duration_from_timedelta(td1) 

429 

430 td2 = timedelta(microseconds=5010293989234) 

431 d2 = pendulum_duration_from_timedelta(td2) 

432 

433 td3 = timedelta(days=5000) 

434 d3 = pendulum_duration_from_timedelta(td3) 

435 """ # noqa 

436 return Duration(seconds=td.total_seconds()) 

437 

438 

439def pendulum_duration_from_isodate_duration(dur: IsodateDuration) -> Duration: 

440 """ 

441 Converts a :class:`isodate.isoduration.Duration` into a 

442 :class:`pendulum.Duration`. 

443 

444 Both :class:`isodate.isoduration.Duration` and :class:`pendulum.Duration` 

445 incorporate an internal representation of a :class:`datetime.timedelta` 

446 (weeks, days, hours, minutes, seconds, milliseconds, microseconds) and 

447 separate representations of years and months. 

448 

449 The :class:`isodate.isoduration.Duration` year/month elements are both of 

450 type :class:`decimal.Decimal` -- although its ``str()`` representation 

451 converts these silently to integer, which is quite nasty. 

452 

453 If you create a Pendulum Duration it normalizes within its timedelta parts, 

454 but not across years and months. That is obviously because neither years 

455 and months are of exactly fixed duration. 

456 

457 Raises: 

458 

459 :exc:`ValueError` if the year or month component is not an integer 

460 

461 .. code-block:: python 

462 

463 from cardinal_pythonlib.datetimefunc import pendulum_duration_from_isodate_duration 

464 from isodate.isoduration import Duration as IsodateDuration 

465 from pendulum import Duration as PendulumDuration 

466 

467 td1 = IsodateDuration(days=5, hours=3, minutes=2, microseconds=5) 

468 d1 = pendulum_duration_from_isodate_duration(td1) 

469 

470 td2 = IsodateDuration(microseconds=5010293989234) 

471 d2 = pendulum_duration_from_isodate_duration(td2) 

472 

473 td3 = IsodateDuration(days=5000) 

474 d3 = pendulum_duration_from_isodate_duration(td3) 

475 

476 td4 = IsodateDuration(days=5000, years=5, months=2) 

477 d4 = pendulum_duration_from_isodate_duration(td4) 

478 # ... doesn't normalize across years/months; see explanation above 

479 

480 td5 = IsodateDuration(days=5000, years=5.1, months=2.2) 

481 d5 = pendulum_duration_from_isodate_duration(td5) # will raise 

482 """ # noqa 

483 y = dur.years 

484 if y.to_integral_value() != y: 

485 raise ValueError(f"Can't handle non-integer years {y!r}") 

486 m = dur.months 

487 if m.to_integral_value() != m: 

488 raise ValueError(f"Can't handle non-integer months {y!r}") 

489 return Duration(seconds=dur.tdelta.total_seconds(), 

490 years=int(y), 

491 months=int(m)) 

492 

493 

494def duration_from_iso(iso_duration: str) -> Duration: 

495 """ 

496 Converts an ISO-8601 format duration into a :class:`pendulum.Duration`. 

497 

498 Raises: 

499 

500 - :exc:`isodate.isoerror.ISO8601Error` for bad input 

501 - :exc:`ValueError` if the input had non-integer year or month values 

502 

503 - The ISO-8601 duration format is ``P[n]Y[n]M[n]DT[n]H[n]M[n]S``; see 

504 https://en.wikipedia.org/wiki/ISO_8601#Durations. 

505 

506 - ``pendulum.Duration.min`` and ``pendulum.Duration.max`` values are 

507 ``Duration(weeks=-142857142, days=-5)`` and ``Duration(weeks=142857142, 

508 days=6)`` respectively. 

509 

510 - ``isodate`` supports negative durations of the format ``-P<something>``, 

511 such as ``-PT5S`` for "minus 5 seconds", but not e.g. ``PT-5S``. 

512 

513 - I'm not clear if ISO-8601 itself supports negative durations. This 

514 suggests not: https://github.com/moment/moment/issues/2408. But lots of 

515 implementations (including to some limited extent ``isodate``) do support 

516 this concept. 

517 

518 .. code-block:: python 

519 

520 from pendulum import DateTime 

521 from cardinal_pythonlib.datetimefunc import duration_from_iso 

522 from cardinal_pythonlib.logs import main_only_quicksetup_rootlogger 

523 main_only_quicksetup_rootlogger() 

524 

525 d1 = duration_from_iso("P5W") 

526 d2 = duration_from_iso("P3Y1DT3H1M2S") 

527 d3 = duration_from_iso("P7000D") 

528 d4 = duration_from_iso("P1Y7000D") 

529 d5 = duration_from_iso("PT10053.22S") 

530 d6 = duration_from_iso("PT-10053.22S") # raises ISO8601 error 

531 d7 = duration_from_iso("-PT5S") 

532 d7 = duration_from_iso("PT-5S") # raises ISO8601 error 

533 now = DateTime.now() 

534 print(now) 

535 print(now + d1) 

536 print(now + d2) 

537 print(now + d3) 

538 print(now + d4) 

539 

540 """ 

541 duration = parse_duration(iso_duration) # type: Union[datetime.timedelta, IsodateDuration] # noqa 

542 if isinstance(duration, datetime.timedelta): 

543 result = pendulum_duration_from_timedelta(duration) 

544 elif isinstance(duration, IsodateDuration): 

545 result = pendulum_duration_from_isodate_duration(duration) 

546 else: 

547 raise AssertionError( 

548 f"Bug in isodate.parse_duration, which returned unknown duration " 

549 f"type: {duration!r}") 

550 # log.debug("Converted {!r} -> {!r} -> {!r}".format( 

551 # iso_duration, duration, result)) 

552 return result 

553 

554 

555def duration_to_iso(d: Duration, permit_years_months: bool = True, 

556 minus_sign_at_front: bool = True) -> str: 

557 """ 

558 Converts a :class:`pendulum.Duration` into an ISO-8601 formatted string. 

559 

560 Args: 

561 d: 

562 the duration 

563 

564 permit_years_months: 

565 - if ``False``, durations with non-zero year or month components 

566 will raise a :exc:`ValueError`; otherwise, the ISO format will 

567 always be ``PT<seconds>S``. 

568 - if ``True``, year/month components will be accepted, and the 

569 ISO format will be ``P<years>Y<months>MT<seconds>S``. 

570 

571 minus_sign_at_front: 

572 Applies to negative durations, which probably aren't part of the 

573 ISO standard. 

574 

575 - if ``True``, the format ``-P<positive_duration>`` is used, i.e. 

576 with a minus sign at the front and individual components 

577 positive. 

578 - if ``False``, the format ``PT-<positive_seconds>S`` (etc.) is 

579 used, i.e. with a minus sign for each component. This format is 

580 not re-parsed successfully by ``isodate`` and will therefore 

581 fail :func:`duration_from_iso`. 

582 

583 Raises: 

584 

585 :exc:`ValueError` for bad input 

586 

587 The maximum length of the resulting string (see test code below) is: 

588 

589 - 21 if years/months are not permitted; 

590 - ill-defined if years/months are permitted, but 29 for much more than is 

591 realistic (negative, 1000 years, 11 months, and the maximum length for 

592 seconds/microseconds). 

593 

594 .. code-block:: python 

595 

596 from pendulum import DateTime, Duration 

597 from cardinal_pythonlib.datetimefunc import duration_from_iso, duration_to_iso 

598 from cardinal_pythonlib.logs import main_only_quicksetup_rootlogger 

599 main_only_quicksetup_rootlogger() 

600 

601 d1 = duration_from_iso("P5W") 

602 d2 = duration_from_iso("P3Y1DT3H1M2S") 

603 d3 = duration_from_iso("P7000D") 

604 d4 = duration_from_iso("P1Y7000D") 

605 d5 = duration_from_iso("PT10053.22S") 

606 print(duration_to_iso(d1)) 

607 print(duration_to_iso(d2)) 

608 print(duration_to_iso(d3)) 

609 print(duration_to_iso(d4)) 

610 print(duration_to_iso(d5)) 

611 assert d1 == duration_from_iso(duration_to_iso(d1)) 

612 assert d2 == duration_from_iso(duration_to_iso(d2)) 

613 assert d3 == duration_from_iso(duration_to_iso(d3)) 

614 assert d4 == duration_from_iso(duration_to_iso(d4)) 

615 assert d5 == duration_from_iso(duration_to_iso(d5)) 

616 strmin = duration_to_iso(Duration.min) # '-P0Y0MT86399999913600.0S' 

617 strmax = duration_to_iso(Duration.max) # 'P0Y0MT86400000000000.0S' 

618 duration_from_iso(strmin) # raises ISO8601Error from isodate package (bug?) 

619 duration_from_iso(strmax) # raises OverflowError from isodate package 

620 print(strmin) # P0Y0MT-86399999913600.0S 

621 print(strmax) # P0Y0MT86400000000000.0S 

622 d6 = duration_from_iso("P100Y999MT86400000000000.0S") # OverflowError 

623 d7 = duration_from_iso("P0Y1MT86400000000000.0S") # OverflowError 

624 d8 = duration_from_iso("P0Y1111111111111111MT76400000000000.0S") # accepted! 

625 # ... length e.g. 38; see len(duration_to_iso(d8)) 

626 

627 # So the maximum string length may be ill-defined if years/months are 

628 # permitted (since Python 3 integers are unbounded; try 99 ** 10000). 

629 # But otherwise: 

630 

631 d9longest = duration_from_iso("-P0Y0MT10000000000000.000009S") 

632 d10toolong = duration_from_iso("-P0Y0MT100000000000000.000009S") # fails, too many days 

633 assert d9longest == duration_from_iso(duration_to_iso(d9longest)) 

634 

635 d11longest_with_us = duration_from_iso("-P0Y0MT1000000000.000009S") # microseconds correct 

636 d12toolong_rounds_us = duration_from_iso("-P0Y0MT10000000000.000009S") # error in microseconds 

637 d13toolong_drops_us = duration_from_iso("-P0Y0MT10000000000000.000009S") # drops microseconds (within datetime.timedelta) 

638 d14toolong_parse_fails = duration_from_iso("-P0Y0MT100000000000000.000009S") # fails, too many days 

639 assert d11longest_with_us == duration_from_iso(duration_to_iso(d11longest_with_us)) 

640 assert d12toolong_rounds_us == duration_from_iso(duration_to_iso(d12toolong_rounds_us)) 

641 assert d13toolong_drops_us == duration_from_iso(duration_to_iso(d13toolong_drops_us)) 

642 

643 longest_without_ym = duration_to_iso(d11longest_with_us, permit_years_months=False) 

644 print(longest_without_ym) # -PT1000000000.000009S 

645 print(len(longest_without_ym)) # 21 

646 

647 d15longest_realistic_with_ym_us = duration_from_iso("-P1000Y11MT1000000000.000009S") # microseconds correct 

648 longest_realistic_with_ym = duration_to_iso(d15longest_realistic_with_ym_us) 

649 print(longest_realistic_with_ym) # -P1000Y11MT1000000000.000009S 

650 print(len(longest_realistic_with_ym)) # 29 

651 

652 # Now, double-check how the Pendulum classes handle year/month 

653 # calculations: 

654 basedate1 = DateTime(year=2000, month=1, day=1) # 2000-01-01 

655 print(basedate1 + Duration(years=1)) # 2001-01-01; OK 

656 print(basedate1 + Duration(months=1)) # 2000-02-01; OK 

657 basedate2 = DateTime(year=2004, month=2, day=1) # 2004-02-01; leap year 

658 print(basedate2 + Duration(years=1)) # 2005-01-01; OK 

659 print(basedate2 + Duration(months=1)) # 2000-03-01; OK 

660 print(basedate2 + Duration(months=1, days=1)) # 2000-03-02; OK 

661 

662 """ # noqa 

663 prefix = "" 

664 negative = d < Duration() 

665 if negative and minus_sign_at_front: 

666 prefix = "-" 

667 d = -d 

668 if permit_years_months: 

669 return prefix + "P{years}Y{months}MT{seconds}S".format( 

670 years=d.years, 

671 months=d.months, 

672 seconds=d.total_seconds(), # float 

673 ) 

674 else: 

675 if d.years != 0: 

676 raise ValueError( 

677 f"Duration has non-zero years: {d.years!r}") 

678 if d.months != 0: 

679 raise ValueError( 

680 f"Duration has non-zero months: {d.months!r}") 

681 return prefix + f"PT{d.total_seconds()}S" 

682 

683 

684# ============================================================================= 

685# Other manipulations 

686# ============================================================================= 

687 

688def truncate_date_to_first_of_month( 

689 dt: Optional[DateLikeType]) -> Optional[DateLikeType]: 

690 """ 

691 Change the day to the first of the month. 

692 """ 

693 if dt is None: 

694 return None 

695 return dt.replace(day=1) 

696 

697 

698# ============================================================================= 

699# Older date/time functions for native Python datetime objects 

700# ============================================================================= 

701 

702def get_now_utc_notz_datetime() -> datetime.datetime: 

703 """ 

704 Get the UTC time now, but with no timezone information, 

705 in :class:`datetime.datetime` format. 

706 """ 

707 now = datetime.datetime.utcnow() 

708 return now.replace(tzinfo=None) 

709 

710 

711def coerce_to_datetime(x: Any) -> Optional[datetime.datetime]: 

712 """ 

713 Ensure an object is a :class:`datetime.datetime`, or coerce to one, or 

714 raise :exc:`ValueError` or :exc:`OverflowError` (as per 

715 https://dateutil.readthedocs.org/en/latest/parser.html). 

716 """ 

717 if x is None: 

718 return None 

719 elif isinstance(x, DateTime): 

720 return pendulum_to_datetime(x) 

721 elif isinstance(x, datetime.datetime): 

722 return x 

723 elif isinstance(x, datetime.date): 

724 return datetime.datetime(x.year, x.month, x.day) 

725 else: 

726 return dateutil.parser.parse(x) # may raise 

727 

728 

729# ============================================================================= 

730# Unit testing 

731# ============================================================================= 

732 

733class TestCoerceToPendulum(unittest.TestCase): 

734 def test_returns_none_if_falsey(self) -> None: 

735 self.assertIsNone(coerce_to_pendulum('')) 

736 

737 def test_returns_input_if_pendulum_datetime(self) -> None: 

738 datetime_in = DateTime.now() 

739 datetime_out = coerce_to_pendulum(datetime_in) 

740 

741 self.assertIs(datetime_in, datetime_out) 

742 

743 def test_converts_python_datetime_with_local_tz(self) -> None: 

744 datetime_in = datetime.datetime(2020, 6, 15, hour=15, minute=42) 

745 datetime_out = coerce_to_pendulum(datetime_in, assume_local=True) 

746 

747 self.assertIsInstance(datetime_out, DateTime) 

748 self.assertTrue(datetime_out.is_local()) 

749 

750 def test_converts_python_datetime_with_utc_tz(self) -> None: 

751 datetime_in = datetime.datetime(2020, 6, 15, hour=15, minute=42) 

752 datetime_out = coerce_to_pendulum(datetime_in) 

753 

754 self.assertIsInstance(datetime_out, DateTime) 

755 self.assertTrue(datetime_out.is_utc()) 

756 

757 def test_converts_python_datetime_with_tz(self) -> None: 

758 utc_offset = datetime.timedelta(hours=5, minutes=30) 

759 datetime_in = datetime.datetime( 

760 2020, 6, 15, hour=15, minute=42, 

761 tzinfo=datetime.timezone(utc_offset) 

762 ) 

763 datetime_out = coerce_to_pendulum(datetime_in) 

764 

765 self.assertIsInstance(datetime_out, DateTime) 

766 self.assertEqual(datetime_out.utcoffset(), utc_offset) 

767 

768 def test_converts_python_date_with_local_tz(self) -> None: 

769 date_in = datetime.date(2020, 6, 15) 

770 datetime_out = coerce_to_pendulum(date_in, assume_local=True) 

771 

772 self.assertIsInstance(datetime_out, DateTime) 

773 self.assertTrue(datetime_out.is_local()) 

774 

775 def test_converts_python_date_with_utc_tz(self) -> None: 

776 date_in = datetime.date(2020, 6, 15) 

777 datetime_out = coerce_to_pendulum(date_in) 

778 

779 self.assertIsInstance(datetime_out, DateTime) 

780 self.assertTrue(datetime_out.is_utc()) 

781 

782 def test_parses_datetime_string_with_tz(self) -> None: 

783 datetime_in = "2020-06-15T14:52:36+05:30" 

784 datetime_out = coerce_to_pendulum(datetime_in) 

785 

786 self.assertIsInstance(datetime_out, DateTime) 

787 self.assertEqual( 

788 datetime_out.utcoffset(), 

789 datetime.timedelta(hours=5, minutes=30) 

790 ) 

791 

792 def test_parses_datetime_string_with_utc_tz(self) -> None: 

793 datetime_in = "2020-06-15T14:52:36" 

794 datetime_out = coerce_to_pendulum(datetime_in) 

795 

796 self.assertIsInstance(datetime_out, DateTime) 

797 self.assertTrue(datetime_out.is_utc()) 

798 

799 def test_parses_datetime_string_with_local_tz(self) -> None: 

800 datetime_in = "2020-06-15T14:52:36" 

801 datetime_out = coerce_to_pendulum(datetime_in, assume_local=True) 

802 

803 self.assertIsInstance(datetime_out, DateTime) 

804 self.assertTrue(datetime_out.is_local()) 

805 

806 def test_raises_if_type_invalid(self) -> None: 

807 with self.assertRaises(ValueError) as cm: 

808 # noinspection PyTypeChecker 

809 coerce_to_pendulum(12345) 

810 

811 self.assertIn( 

812 "Don't know how to convert to DateTime", str(cm.exception) 

813 ) 

814 

815 

816# ============================================================================= 

817# main 

818# ============================================================================= 

819 

820if __name__ == "__main__": 

821 main_only_quicksetup_rootlogger(level=logging.DEBUG) 

822 log.info("Running unit tests") 

823 unittest.main(argv=[sys.argv[0]]) 

824 sys.exit(0)