Coverage for jutil/dates.py: 71%

154 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-10-07 16:40 -0500

1from datetime import datetime, timedelta 

2from typing import Tuple, Any, Optional, List 

3import pytz 

4from calendar import monthrange 

5from django.utils.text import format_lazy 

6from django.utils.translation import gettext_lazy as _ 

7 

8 

9TIME_RANGE_CHOICES = [ 

10 ("yesterday", _("yesterday")), 

11 ("today", _("today")), 

12 ("tomorrow", _("tomorrow")), 

13 ("last_week", _("last week")), 

14 ("last_month", _("last month")), 

15 ("last_year", _("last year")), 

16 ("this_week", _("this week")), 

17 ("this_month", _("this month")), 

18 ("this_year", _("this year")), 

19 ("next_week", _("next week")), 

20 ("next_month", _("next month")), 

21 ("next_year", _("next year")), 

22] 

23# plus +- date ranges from current datetime: 

24# (e.g. --yesterday is full day yesterday but --prev-1d is 24h less from current time) 

25for d in [360, 180, 90, 60, 45, 30, 15, 7, 2, 1]: 

26 TIME_RANGE_CHOICES.extend( 

27 [ 

28 ("prev_{}d".format(d), format_lazy("-{} {}", d, _("number.of.days"))), 

29 ("plus_minus_{}d".format(d), format_lazy("+-{} {}", d, _("number.of.days"))), 

30 ("next_{}d".format(d), format_lazy("+{} {}", d, _("number.of.days"))), 

31 ] 

32 ) 

33 

34TIME_RANGE_NAMES = list(zip(*TIME_RANGE_CHOICES))[0] 

35 

36TIME_STEP_DAILY = "daily" 

37TIME_STEP_WEEKLY = "weekly" 

38TIME_STEP_MONTHLY = "monthly" 

39 

40TIME_STEP_TYPES = [ 

41 TIME_STEP_DAILY, 

42 TIME_STEP_WEEKLY, 

43 TIME_STEP_MONTHLY, 

44] 

45 

46TIME_STEP_CHOICES = [ 

47 (TIME_STEP_DAILY, _("daily")), 

48 (TIME_STEP_WEEKLY, _("weekly")), 

49 (TIME_STEP_MONTHLY, _("monthly")), 

50] 

51 

52TIME_STEP_NAMES = list(zip(*TIME_STEP_CHOICES))[0] 

53 

54 

55def localize_time_range(begin: datetime, end: datetime, tz: Any = None) -> Tuple[datetime, datetime]: 

56 """ 

57 Localizes time range. Uses pytz.utc if None provided. 

58 :param begin: Begin datetime 

59 :param end: End datetime 

60 :param tz: pytz timezone or None (default UTC) 

61 :return: begin, end 

62 """ 

63 if tz is None: 63 ↛ 65line 63 didn't jump to line 65, because the condition on line 63 was never false

64 tz = pytz.utc 

65 return tz.localize(begin), tz.localize(end) 

66 

67 

68def get_last_day_of_month(today: Optional[datetime] = None) -> int: 

69 """ 

70 Returns day number of the last day of the month 

71 :param today: Default UTC now 

72 :return: int 

73 """ 

74 if today is None: 

75 today = datetime.utcnow() 

76 return monthrange(today.year, today.month)[1] 

77 

78 

79def end_of_month(today: Optional[datetime] = None, n: int = 0, tz: Any = None) -> datetime: 

80 """ 

81 Returns end-of-month (last microsecond) of given datetime (or current datetime UTC if no parameter is passed). 

82 :param today: Some date in the month (defaults current datetime) 

83 :param n: +- number of months to offset from current month. Default 0. 

84 :param tz: Timezone (defaults pytz UTC) 

85 :return: datetime 

86 """ 

87 if today is None: 87 ↛ 88line 87 didn't jump to line 88, because the condition on line 87 was never true

88 today = datetime.utcnow() 

89 last_day = monthrange(today.year, today.month)[1] 

90 end = today.replace(day=last_day, hour=0, minute=0, second=0, microsecond=0) + timedelta(hours=24) 

91 while n > 0: 

92 last_day = monthrange(end.year, end.month)[1] 

93 end = end.replace(day=last_day, hour=0, minute=0, second=0, microsecond=0) + timedelta(hours=24) 

94 n -= 1 

95 while n < 0: 

96 end -= timedelta(days=1) 

97 end = end.replace(day=1, hour=0, minute=0, second=0, microsecond=0) 

98 n += 1 

99 end_incl = end - timedelta(microseconds=1) 

100 if tz is None: 100 ↛ 101line 100 didn't jump to line 101, because the condition on line 100 was never true

101 tz = pytz.utc 

102 return tz.localize(end_incl) 

103 

104 

105def this_week(today: Optional[datetime] = None, tz: Any = None) -> Tuple[datetime, datetime]: 

106 """ 

107 Returns this week begin (inclusive) and end (exclusive). 

108 Week is assumed to start from Monday (ISO). 

109 :param today: Some date (defaults current datetime) 

110 :param tz: Timezone (defaults pytz UTC) 

111 :return: begin (inclusive), end (exclusive) 

112 """ 

113 if today is None: 113 ↛ 114line 113 didn't jump to line 114, because the condition on line 113 was never true

114 today = datetime.utcnow() 

115 begin = today - timedelta(days=today.weekday()) 

116 begin = datetime(year=begin.year, month=begin.month, day=begin.day) 

117 return localize_time_range(begin, begin + timedelta(days=7), tz) 

118 

119 

120def this_month(today: Optional[datetime] = None, tz: Any = None) -> Tuple[datetime, datetime]: 

121 """ 

122 Returns current month begin (inclusive) and end (exclusive). 

123 :param today: Some date in the month (defaults current datetime) 

124 :param tz: Timezone (defaults pytz UTC) 

125 :return: begin (inclusive), end (exclusive) 

126 """ 

127 if today is None: 127 ↛ 128line 127 didn't jump to line 128, because the condition on line 127 was never true

128 today = datetime.utcnow() 

129 begin = datetime(day=1, month=today.month, year=today.year) 

130 end = begin + timedelta(days=32) 

131 end = datetime(day=1, month=end.month, year=end.year) 

132 return localize_time_range(begin, end, tz) 

133 

134 

135def this_year(today: Optional[datetime] = None, tz: Any = None) -> Tuple[datetime, datetime]: 

136 """ 

137 Returns this year begin (inclusive) and end (exclusive). 

138 :param today: Some date (defaults current datetime) 

139 :param tz: Timezone (defaults pytz UTC) 

140 :return: begin (inclusive), end (exclusive) 

141 """ 

142 if today is None: 142 ↛ 143line 142 didn't jump to line 143, because the condition on line 142 was never true

143 today = datetime.utcnow() 

144 begin = datetime(day=1, month=1, year=today.year) 

145 end = datetime(day=1, month=1, year=begin.year + 1) 

146 return localize_time_range(begin, end, tz) 

147 

148 

149def next_year(today: Optional[datetime] = None, tz: Any = None) -> Tuple[datetime, datetime]: 

150 if today is None: 

151 today = datetime.utcnow() 

152 begin = datetime(day=1, month=1, year=today.year + 1) 

153 end = datetime(day=1, month=1, year=begin.year + 2) 

154 return localize_time_range(begin, end, tz) 

155 

156 

157def next_week(today: Optional[datetime] = None, tz: Any = None) -> Tuple[datetime, datetime]: 

158 """ 

159 Returns next week begin (inclusive) and end (exclusive). 

160 :param today: Some date (defaults current datetime) 

161 :param tz: Timezone (defaults pytz UTC) 

162 :return: begin (inclusive), end (exclusive) 

163 """ 

164 if today is None: 164 ↛ 165line 164 didn't jump to line 165, because the condition on line 164 was never true

165 today = datetime.utcnow() 

166 begin = today + timedelta(days=7 - today.weekday()) 

167 begin = datetime(year=begin.year, month=begin.month, day=begin.day) 

168 return localize_time_range(begin, begin + timedelta(days=7), tz) 

169 

170 

171def next_month(today: Optional[datetime] = None, tz: Any = None) -> Tuple[datetime, datetime]: 

172 """ 

173 Returns next month begin (inclusive) and end (exclusive). 

174 :param today: Some date in the month (defaults current datetime) 

175 :param tz: Timezone (defaults pytz UTC) 

176 :return: begin (inclusive), end (exclusive) 

177 """ 

178 if today is None: 

179 today = datetime.utcnow() 

180 begin = datetime(day=1, month=today.month, year=today.year) 

181 next_mo = begin + timedelta(days=32) 

182 begin = datetime(day=1, month=next_mo.month, year=next_mo.year) 

183 following_mo = begin + timedelta(days=32) 

184 end = datetime(day=1, month=following_mo.month, year=following_mo.year) 

185 return localize_time_range(begin, end, tz) 

186 

187 

188def last_week(today: Optional[datetime] = None, tz: Any = None) -> Tuple[datetime, datetime]: 

189 """ 

190 Returns last week begin (inclusive) and end (exclusive). 

191 :param today: Some date (defaults current datetime) 

192 :param tz: Timezone (defaults pytz UTC) 

193 :return: begin (inclusive), end (exclusive) 

194 """ 

195 if today is None: 195 ↛ 196line 195 didn't jump to line 196, because the condition on line 195 was never true

196 today = datetime.utcnow() 

197 begin = today - timedelta(weeks=1, days=today.weekday()) 

198 begin = datetime(year=begin.year, month=begin.month, day=begin.day) 

199 return localize_time_range(begin, begin + timedelta(days=7), tz) 

200 

201 

202def last_month(today: Optional[datetime] = None, tz: Any = None) -> Tuple[datetime, datetime]: 

203 """ 

204 Returns last month begin (inclusive) and end (exclusive). 

205 :param today: Some date (defaults current datetime) 

206 :param tz: Timezone (defaults pytz UTC) 

207 :return: begin (inclusive), end (exclusive) 

208 """ 

209 if today is None: 209 ↛ 210line 209 didn't jump to line 210, because the condition on line 209 was never true

210 today = datetime.utcnow() 

211 end = datetime(day=1, month=today.month, year=today.year) 

212 end_incl = end - timedelta(seconds=1) 

213 begin = datetime(day=1, month=end_incl.month, year=end_incl.year) 

214 return localize_time_range(begin, end, tz) 

215 

216 

217def last_year(today: Optional[datetime] = None, tz: Any = None) -> Tuple[datetime, datetime]: 

218 """ 

219 Returns last year begin (inclusive) and end (exclusive). 

220 :param today: Some date (defaults current datetime) 

221 :param tz: Timezone (defaults pytz UTC) 

222 :return: begin (inclusive), end (exclusive) 

223 """ 

224 if today is None: 224 ↛ 225line 224 didn't jump to line 225, because the condition on line 224 was never true

225 today = datetime.utcnow() 

226 end = datetime(day=1, month=1, year=today.year) 

227 end_incl = end - timedelta(seconds=1) 

228 begin = datetime(day=1, month=1, year=end_incl.year) 

229 return localize_time_range(begin, end, tz) 

230 

231 

232def yesterday(today: Optional[datetime] = None, tz: Any = None) -> Tuple[datetime, datetime]: 

233 """ 

234 Returns yesterday begin (inclusive) and end (exclusive). 

235 :param today: Some date (defaults current datetime) 

236 :param tz: Timezone (defaults pytz UTC) 

237 :return: begin (inclusive), end (exclusive) 

238 """ 

239 if today is None: 239 ↛ 240line 239 didn't jump to line 240, because the condition on line 239 was never true

240 today = datetime.utcnow() 

241 end = datetime(day=today.day, month=today.month, year=today.year) 

242 end_incl = end - timedelta(seconds=1) 

243 begin = datetime(day=end_incl.day, month=end_incl.month, year=end_incl.year) 

244 return localize_time_range(begin, end, tz) 

245 

246 

247def add_month(t: datetime, n: int = 1) -> datetime: 

248 """ 

249 Adds +- n months to datetime. 

250 Clamps days to number of days in given month. 

251 :param t: datetime 

252 :param n: +- number of months to offset from current month. Default 1. 

253 :return: datetime 

254 """ 

255 t2 = t 

256 for count in range(abs(n)): # pylint: disable=unused-variable 

257 if n > 0: 

258 t2 = datetime(year=t2.year, month=t2.month, day=1) + timedelta(days=32) 

259 else: 

260 t2 = datetime(year=t2.year, month=t2.month, day=1) - timedelta(days=2) 

261 try: 

262 t2 = t.replace(year=t2.year, month=t2.month) 

263 except Exception: 

264 last_day = monthrange(t2.year, t2.month)[1] 

265 t2 = t.replace(year=t2.year, month=t2.month, day=last_day) 

266 return t2 

267 

268 

269def per_delta(start: datetime, end: datetime, delta: timedelta): 

270 """ 

271 Iterates over time range in steps specified in delta. 

272 

273 :param start: Start of time range (inclusive) 

274 :param end: End of time range (exclusive) 

275 :param delta: Step interval 

276 

277 :return: Iterable collection of [(start+td*0, start+td*1), (start+td*1, start+td*2), ..., end) 

278 """ 

279 curr = start 

280 while curr < end: 

281 curr_end = curr + delta 

282 yield curr, curr_end 

283 curr = curr_end 

284 

285 

286def per_month(start: datetime, end: datetime, n: int = 1): 

287 """ 

288 Iterates over time range in one month steps. 

289 Clamps to number of days in given month. 

290 

291 :param start: Start of time range (inclusive) 

292 :param end: End of time range (exclusive) 

293 :param n: Number of months to step. Default is 1. 

294 

295 :return: Iterable collection of [(month+0, month+1), (month+1, month+2), ..., end) 

296 """ 

297 curr = start.replace(day=1, hour=0, minute=0, second=0, microsecond=0) 

298 while curr < end: 

299 curr_end = add_month(curr, n) 

300 yield curr, curr_end 

301 curr = curr_end 

302 

303 

304def get_time_steps(step_type: str, begin: datetime, end: datetime) -> List[Tuple[datetime, datetime]]: 

305 """ 

306 Returns time stamps by time step type [TIME_STEP_DAILY, TIME_STEP_WEEKLY, TIME_STEP_MONTHLY]. 

307 For example daily steps for a week returns 7 [begin, end) ranges for each day of the week. 

308 :param step_type: One of TIME_STEP_DAILY, TIME_STEP_WEEKLY, TIME_STEP_MONTHLY 

309 :param begin: datetime 

310 :param end: datetime 

311 :return: List of [begin, end), one for reach time step unit 

312 """ 

313 after_end = end 

314 if TIME_STEP_DAILY == step_type: 314 ↛ 316line 314 didn't jump to line 316, because the condition on line 314 was never false

315 after_end += timedelta(days=1) 

316 elif TIME_STEP_WEEKLY == step_type: 

317 after_end += timedelta(days=7) 

318 elif TIME_STEP_MONTHLY == step_type: 

319 after_end = add_month(end) 

320 else: 

321 raise ValueError('Time step "{}" not one of {}'.format(step_type, TIME_STEP_TYPES)) 

322 

323 begins: List[datetime] = [] 

324 t0 = t = begin 

325 n = 1 

326 while t < after_end: 

327 begins.append(t) 

328 if step_type == TIME_STEP_DAILY: 328 ↛ 330line 328 didn't jump to line 330, because the condition on line 328 was never false

329 t = t0 + timedelta(days=n) 

330 elif step_type == TIME_STEP_WEEKLY: 

331 t = t0 + timedelta(days=7 * n) 

332 elif step_type == TIME_STEP_MONTHLY: 

333 t = add_month(t0, n) 

334 n += 1 

335 return [(begins[i], begins[i + 1]) for i in range(len(begins) - 1)]