Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/site-packages/pendulum/parsing/iso8601.py: 97%

237 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-09-30 06:11 +0000

1from __future__ import annotations 

2 

3import datetime 

4import re 

5 

6from typing import cast 

7 

8from pendulum.constants import HOURS_PER_DAY 

9from pendulum.constants import MINUTES_PER_HOUR 

10from pendulum.constants import MONTHS_OFFSETS 

11from pendulum.constants import SECONDS_PER_MINUTE 

12from pendulum.duration import Duration 

13from pendulum.helpers import days_in_year 

14from pendulum.helpers import is_leap 

15from pendulum.helpers import is_long_year 

16from pendulum.helpers import week_day 

17from pendulum.parsing.exceptions import ParserError 

18from pendulum.tz.timezone import UTC 

19from pendulum.tz.timezone import FixedTimezone 

20from pendulum.tz.timezone import Timezone 

21 

22 

23ISO8601_DT = re.compile( 

24 # Date (optional) # noqa: ERA001 

25 "^" 

26 "(?P<date>" 

27 " (?P<classic>" # Classic date (YYYY-MM-DD) or ordinal (YYYY-DDD) 

28 r" (?P<year>\d{4})" # Year 

29 " (?P<monthday>" 

30 r" (?P<monthsep>-)?(?P<month>\d{2})" # Month (optional) 

31 r" ((?P<daysep>-)?(?P<day>\d{1,2}))?" # Day (optional) 

32 " )?" 

33 " )" 

34 " |" 

35 " (?P<isocalendar>" # Calendar date (2016-W05 or 2016-W05-5) 

36 r" (?P<isoyear>\d{4})" # Year 

37 " (?P<weeksep>-)?" # Separator (optional) 

38 " W" # W separator 

39 r" (?P<isoweek>\d{2})" # Week number 

40 " (?P<weekdaysep>-)?" # Separator (optional) 

41 r" (?P<isoweekday>\d)?" # Weekday (optional) 

42 " )" 

43 ")?" 

44 # Time (optional) # noqa: ERA001 

45 "(?P<time>" r" (?P<timesep>[T\ ])?" # Separator (T or space) 

46 # HH:mm:ss (optional mm and ss) 

47 r" (?P<hour>\d{1,2})(?P<minsep>:)?(?P<minute>\d{1,2})?(?P<secsep>:)?(?P<second>\d{1,2})?" # noqa: E501 

48 # Subsecond part (optional) 

49 " (?P<subsecondsection>" 

50 " (?:[.,])" # Subsecond separator (optional) 

51 r" (?P<subsecond>\d{1,9})" # Subsecond 

52 " )?" 

53 # Timezone offset 

54 " (?P<tz>" 

55 r" (?:[-+])\d{2}:?(?:\d{2})?|Z" # Offset (+HH:mm or +HHmm or +HH or Z) 

56 " )?" 

57 ")?" 

58 "$", 

59 re.VERBOSE, 

60) 

61 

62ISO8601_DURATION = re.compile( 

63 "^P" # Duration P indicator 

64 # Years, months and days (optional) # noqa: ERA001 

65 "(?P<w>" 

66 r" (?P<weeks>\d+(?:[.,]\d+)?W)" 

67 ")?" 

68 "(?P<ymd>" 

69 r" (?P<years>\d+(?:[.,]\d+)?Y)?" 

70 r" (?P<months>\d+(?:[.,]\d+)?M)?" 

71 r" (?P<days>\d+(?:[.,]\d+)?D)?" 

72 ")?" 

73 "(?P<hms>" 

74 " (?P<timesep>T)" # Separator (T) 

75 r" (?P<hours>\d+(?:[.,]\d+)?H)?" 

76 r" (?P<minutes>\d+(?:[.,]\d+)?M)?" 

77 r" (?P<seconds>\d+(?:[.,]\d+)?S)?" 

78 ")?" 

79 "$", 

80 re.VERBOSE, 

81) 

82 

83 

84def parse_iso8601( 

85 text: str, 

86) -> datetime.datetime | datetime.date | datetime.time | Duration: 

87 """ 

88 ISO 8601 compliant parser. 

89 

90 :param text: The string to parse 

91 :type text: str 

92 

93 :rtype: datetime.datetime or datetime.time or datetime.date 

94 """ 

95 parsed = _parse_iso8601_duration(text) 

96 if parsed is not None: 

97 return parsed 

98 

99 m = ISO8601_DT.match(text) 

100 if not m: 

101 raise ParserError("Invalid ISO 8601 string") 

102 

103 ambiguous_date = False 

104 is_date = False 

105 is_time = False 

106 year = 0 

107 month = 1 

108 day = 1 

109 minute = 0 

110 second = 0 

111 microsecond = 0 

112 tzinfo: FixedTimezone | Timezone | None = None 

113 

114 if m.group("date"): 

115 # A date has been specified 

116 is_date = True 

117 

118 if m.group("isocalendar"): 

119 # We have a ISO 8601 string defined 

120 # by week number 

121 if ( 

122 m.group("weeksep") 

123 and not m.group("weekdaysep") 

124 and m.group("isoweekday") 

125 ): 

126 raise ParserError(f"Invalid date string: {text}") 

127 

128 if not m.group("weeksep") and m.group("weekdaysep"): 

129 raise ParserError(f"Invalid date string: {text}") 

130 

131 try: 

132 date = _get_iso_8601_week( 

133 m.group("isoyear"), m.group("isoweek"), m.group("isoweekday") 

134 ) 

135 except ParserError: 

136 raise 

137 except ValueError: 

138 raise ParserError(f"Invalid date string: {text}") 

139 

140 year = date["year"] 

141 month = date["month"] 

142 day = date["day"] 

143 else: 

144 # We have a classic date representation 

145 year = int(m.group("year")) 

146 

147 if not m.group("monthday"): 

148 # No month and day 

149 month = 1 

150 day = 1 

151 else: 

152 if m.group("month") and m.group("day"): 

153 # Month and day 

154 if not m.group("daysep") and len(m.group("day")) == 1: 

155 # Ordinal day 

156 ordinal = int(m.group("month") + m.group("day")) 

157 leap = is_leap(year) 

158 months_offsets = MONTHS_OFFSETS[leap] 

159 

160 if ordinal > months_offsets[13]: 

161 raise ParserError("Ordinal day is out of range") 

162 

163 for i in range(1, 14): 

164 if ordinal <= months_offsets[i]: 

165 day = ordinal - months_offsets[i - 1] 

166 month = i - 1 

167 

168 break 

169 else: 

170 month = int(m.group("month")) 

171 day = int(m.group("day")) 

172 else: 

173 # Only month 

174 if not m.group("monthsep"): 

175 # The date looks like 201207 

176 # which is invalid for a date 

177 # But it might be a time in the form hhmmss 

178 ambiguous_date = True 

179 

180 month = int(m.group("month")) 

181 day = 1 

182 

183 if not m.group("time"): 

184 # No time has been specified 

185 if ambiguous_date: 

186 # We can "safely" assume that the ambiguous date 

187 # was actually a time in the form hhmmss 

188 hhmmss = f"{year!s}{month!s:0>2}" 

189 

190 return datetime.time(int(hhmmss[:2]), int(hhmmss[2:4]), int(hhmmss[4:])) 

191 

192 return datetime.date(year, month, day) 

193 

194 if ambiguous_date: 

195 raise ParserError(f"Invalid date string: {text}") 

196 

197 if is_date and not m.group("timesep"): 

198 raise ParserError(f"Invalid date string: {text}") 

199 

200 if not is_date: 

201 is_time = True 

202 

203 # Grabbing hh:mm:ss 

204 hour = int(m.group("hour")) 

205 minsep = m.group("minsep") 

206 

207 if m.group("minute"): 

208 minute = int(m.group("minute")) 

209 elif minsep: 

210 raise ParserError("Invalid ISO 8601 time part") 

211 

212 secsep = m.group("secsep") 

213 if secsep and not minsep and m.group("minute"): 

214 # minute/second separator but no hour/minute separator 

215 raise ParserError("Invalid ISO 8601 time part") 

216 

217 if m.group("second"): 

218 if not secsep and minsep: 

219 # No minute/second separator but hour/minute separator 

220 raise ParserError("Invalid ISO 8601 time part") 

221 

222 second = int(m.group("second")) 

223 elif secsep: 

224 raise ParserError("Invalid ISO 8601 time part") 

225 

226 # Grabbing subseconds, if any 

227 if m.group("subsecondsection"): 

228 # Limiting to 6 chars 

229 subsecond = m.group("subsecond")[:6] 

230 

231 microsecond = int(f"{subsecond:0<6}") 

232 

233 # Grabbing timezone, if any 

234 tz = m.group("tz") 

235 if tz: 

236 if tz == "Z": 

237 tzinfo = UTC 

238 else: 

239 negative = bool(tz.startswith("-")) 

240 tz = tz[1:] 

241 if ":" not in tz: 

242 if len(tz) == 2: 

243 tz = f"{tz}00" 

244 

245 off_hour = tz[0:2] 

246 off_minute = tz[2:4] 

247 else: 

248 off_hour, off_minute = tz.split(":") 

249 

250 offset = ((int(off_hour) * 60) + int(off_minute)) * 60 

251 

252 if negative: 

253 offset = -1 * offset 

254 

255 tzinfo = FixedTimezone(offset) 

256 

257 if is_time: 

258 return datetime.time(hour, minute, second, microsecond, tzinfo=tzinfo) 

259 

260 return datetime.datetime( 

261 year, month, day, hour, minute, second, microsecond, tzinfo=tzinfo 

262 ) 

263 

264 

265def _parse_iso8601_duration(text: str, **options: str) -> Duration | None: 

266 m = ISO8601_DURATION.match(text) 

267 if not m: 

268 return None 

269 

270 years = 0 

271 months = 0 

272 weeks = 0 

273 days: int | float = 0 

274 hours: int | float = 0 

275 minutes: int | float = 0 

276 seconds: int | float = 0 

277 microseconds: int | float = 0 

278 fractional = False 

279 

280 _days: str | float 

281 _hour: str | int | None 

282 _minutes: str | int | None 

283 _seconds: str | int | None 

284 if m.group("w"): 

285 # Weeks 

286 if m.group("ymd") or m.group("hms"): 

287 # Specifying anything more than weeks is not supported 

288 raise ParserError("Invalid duration string") 

289 

290 _weeks = m.group("weeks") 

291 if not _weeks: 

292 raise ParserError("Invalid duration string") 

293 

294 _weeks = _weeks.replace(",", ".").replace("W", "") 

295 if "." in _weeks: 

296 _weeks, portion = _weeks.split(".") 

297 weeks = int(_weeks) 

298 _days = int(portion) / 10 * 7 

299 days, hours = int(_days // 1), int(_days % 1 * HOURS_PER_DAY) 

300 else: 

301 weeks = int(_weeks) 

302 

303 if m.group("ymd"): 

304 # Years, months and/or days 

305 _years = m.group("years") 

306 _months = m.group("months") 

307 _days = m.group("days") 

308 

309 # Checking order 

310 years_start = m.start("years") if _years else -3 

311 months_start = m.start("months") if _months else years_start + 1 

312 days_start = m.start("days") if _days else months_start + 1 

313 

314 # Check correct order 

315 if not (years_start < months_start < days_start): 

316 raise ParserError("Invalid duration") 

317 

318 if _years: 

319 _years = _years.replace(",", ".").replace("Y", "") 

320 if "." in _years: 

321 raise ParserError("Float years in duration are not supported") 

322 else: 

323 years = int(_years) 

324 

325 if _months: 

326 if fractional: 

327 raise ParserError("Invalid duration") 

328 

329 _months = _months.replace(",", ".").replace("M", "") 

330 if "." in _months: 

331 raise ParserError("Float months in duration are not supported") 

332 else: 

333 months = int(_months) 

334 

335 if _days: 

336 if fractional: 

337 raise ParserError("Invalid duration") 

338 

339 _days = _days.replace(",", ".").replace("D", "") 

340 

341 if "." in _days: 

342 fractional = True 

343 

344 _days, _hours = _days.split(".") 

345 days = int(_days) 

346 hours = int(_hours) / 10 * HOURS_PER_DAY 

347 else: 

348 days = int(_days) 

349 

350 if m.group("hms"): 

351 # Hours, minutes and/or seconds 

352 _hours = m.group("hours") or 0 

353 _minutes = m.group("minutes") or 0 

354 _seconds = m.group("seconds") or 0 

355 

356 # Checking order 

357 hours_start = m.start("hours") if _hours else -3 

358 minutes_start = m.start("minutes") if _minutes else hours_start + 1 

359 seconds_start = m.start("seconds") if _seconds else minutes_start + 1 

360 

361 # Check correct order 

362 if not (hours_start < minutes_start < seconds_start): 

363 raise ParserError("Invalid duration") 

364 

365 if _hours: 

366 if fractional: 

367 raise ParserError("Invalid duration") 

368 

369 _hours = cast(str, _hours).replace(",", ".").replace("H", "") 

370 

371 if "." in _hours: 

372 fractional = True 

373 

374 _hours, _mins = _hours.split(".") 

375 hours += int(_hours) 

376 minutes += int(_mins) / 10 * MINUTES_PER_HOUR 

377 else: 

378 hours += int(_hours) 

379 

380 if _minutes: 

381 if fractional: 

382 raise ParserError("Invalid duration") 

383 

384 _minutes = cast(str, _minutes).replace(",", ".").replace("M", "") 

385 

386 if "." in _minutes: 

387 fractional = True 

388 

389 _minutes, _secs = _minutes.split(".") 

390 minutes += int(_minutes) 

391 seconds += int(_secs) / 10 * SECONDS_PER_MINUTE 

392 else: 

393 minutes += int(_minutes) 

394 

395 if _seconds: 

396 if fractional: 

397 raise ParserError("Invalid duration") 

398 

399 _seconds = cast(str, _seconds).replace(",", ".").replace("S", "") 

400 

401 if "." in _seconds: 

402 _seconds, _microseconds = _seconds.split(".") 

403 seconds += int(_seconds) 

404 microseconds += int(f"{_microseconds[:6]:0<6}") 

405 else: 

406 seconds += int(_seconds) 

407 

408 return Duration( 

409 years=years, 

410 months=months, 

411 weeks=weeks, 

412 days=days, 

413 hours=hours, 

414 minutes=minutes, 

415 seconds=seconds, 

416 microseconds=microseconds, 

417 ) 

418 

419 

420def _get_iso_8601_week( 

421 year: int | str, week: int | str, weekday: int | str 

422) -> dict[str, int]: 

423 weekday = 1 if not weekday else int(weekday) 

424 

425 year = int(year) 

426 week = int(week) 

427 

428 if week > 53 or week > 52 and not is_long_year(year): 

429 raise ParserError("Invalid week for week date") 

430 

431 if weekday > 7: 

432 raise ParserError("Invalid weekday for week date") 

433 

434 # We can't rely on strptime directly here since 

435 # it does not support ISO week date 

436 ordinal = week * 7 + weekday - (week_day(year, 1, 4) + 3) 

437 

438 if ordinal < 1: 

439 # Previous year 

440 ordinal += days_in_year(year - 1) 

441 year -= 1 

442 

443 if ordinal > days_in_year(year): 

444 # Next year 

445 ordinal -= days_in_year(year) 

446 year += 1 

447 

448 fmt = "%Y-%j" 

449 string = f"{year}-{ordinal}" 

450 

451 dt = datetime.datetime.strptime(string, fmt) 

452 

453 return {"year": dt.year, "month": dt.month, "day": dt.day}