Coverage for jutil/dates.py: 73%

# Coverage for jutil/dates.py : 73%

## 148 statements

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 from datetime import datetime, timedelta

2 from typing import Tuple, Any, Optional, List

3 import pytz

4 from calendar import monthrange

5 from django.utils.text import format_lazy

6 from django.utils.translation import gettext_lazy as _

7

8

9 TIME_RANGE_CHOICES = [

10 ('last_month', _('last month')),

11 ('last_year', _('last year')),

12 ('this_month', _('this month')),

13 ('this_year', _('this year')),

14 ('last_week', _('last week')),

15 ('this_week', _('this week')),

16 ('yesterday', _('yesterday')),

17 ('today', _('today')),

18 ('prev_90d', format_lazy('-90 {}', _('number.of.days'))),

19 ('plus_minus_90d', format_lazy('+-90 {}', _('number.of.days'))),

20 ('next_90d', format_lazy('+90 {}', _('number.of.days'))),

21 ('prev_60d', format_lazy('-60 {}', _('number.of.days'))),

22 ('plus_minus_60d', format_lazy('+-60 {}', _('number.of.days'))),

23 ('next_60d', format_lazy('+60 {}', _('number.of.days'))),

24 ('prev_30d', format_lazy('-30 {}', _('number.of.days'))),

25 ('plus_minus_30d', format_lazy('+-30 {}', _('number.of.days'))),

26 ('next_30d', format_lazy('+30 {}', _('number.of.days'))),

27 ('prev_15d', format_lazy('-15 {}', _('number.of.days'))),

28 ('plus_minus_15d', format_lazy('+-15 {}', _('number.of.days'))),

29 ('next_15d', format_lazy('+15 {}', _('number.of.days'))),

30 ('prev_7d', format_lazy('-7 {}', _('number.of.days'))),

31 ('plus_minus_7d', format_lazy('+-7 {}', _('number.of.days'))),

32 ('next_7d', format_lazy('+7 {}', _('number.of.days'))),

33]

34

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

36

37 TIME_STEP_DAILY = 'daily'

38 TIME_STEP_WEEKLY = 'weekly'

39 TIME_STEP_MONTHLY = 'monthly'

40

41 TIME_STEP_TYPES = [

42 TIME_STEP_DAILY,

43 TIME_STEP_WEEKLY,

44 TIME_STEP_MONTHLY,

45]

46

47 TIME_STEP_CHOICES = [

48 (TIME_STEP_DAILY, _('daily')),

49 (TIME_STEP_WEEKLY, _('weekly')),

50 (TIME_STEP_MONTHLY, _('monthly')),

51]

52

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

54

55

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

57 """

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

59 :param begin: Begin datetime

60 :param end: End datetime

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

62 :return: begin, end

63 """

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

65 tz = pytz.utc

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

67

68

69 def get_last_day_of_month(t: datetime) -> int:

70 """

71 Returns day number of the last day of the month

72 :param t: datetime

73 :return: int

74 """

75 tn = t + timedelta(days=32)

76 tn = datetime(year=tn.year, month=tn.month, day=1)

77 tt = tn - timedelta(hours=1)

78 return tt.day

79

80

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

82 """

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

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

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

86 :param tz: Timezone (defaults pytz UTC)

87 :return: datetime

88 """

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

90 today = datetime.utcnow()

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

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

93 while n > 0:

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

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

96 n -= 1

97 while n < 0:

98 end -= timedelta(days=1)

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

100 n += 1

101 end_incl = end - timedelta(microseconds=1)

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

103 tz = pytz.utc

104 return tz.localize(end_incl)

105

106

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

108 """

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

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

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

112 :param tz: Timezone (defaults pytz UTC)

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

114 """

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

116 today = datetime.utcnow()

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

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

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

120

121

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

123 """

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

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

126 :param tz: Timezone (defaults pytz UTC)

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

128 """

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

130 today = datetime.utcnow()

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

132 end = begin + timedelta(days=32)

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

134 return localize_time_range(begin, end, tz)

135

136

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

138 """

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

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

141 :param tz: Timezone (defaults pytz UTC)

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

143 """

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

145 today = datetime.utcnow()

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

147 next_year = today + timedelta(days=365)

148 end = datetime(day=1, month=1, year=next_year.year)

149 return localize_time_range(begin, end, tz)

150

151

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

153 """

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

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

156 :param tz: Timezone (defaults pytz UTC)

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

158 """

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

160 today = datetime.utcnow()

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

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

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

164

165

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

167 """

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

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

170 :param tz: Timezone (defaults pytz UTC)

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

172 """

173 if today is None:

174 today = datetime.utcnow()

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

176 next_mo = begin + timedelta(days=32)

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

178 following_mo = begin + timedelta(days=32)

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

180 return localize_time_range(begin, end, tz)

181

182

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

184 """

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

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

187 :param tz: Timezone (defaults pytz UTC)

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

189 """

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

191 today = datetime.utcnow()

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

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

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

195

196

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

198 """

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

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

201 :param tz: Timezone (defaults pytz UTC)

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

203 """

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

205 today = datetime.utcnow()

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

207 end_incl = end - timedelta(seconds=1)

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

209 return localize_time_range(begin, end, tz)

210

211

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

213 """

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

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

216 :param tz: Timezone (defaults pytz UTC)

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

218 """

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

220 today = datetime.utcnow()

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

222 end_incl = end - timedelta(seconds=1)

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

224 return localize_time_range(begin, end, tz)

225

226

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

228 """

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

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

231 :param tz: Timezone (defaults pytz UTC)

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

233 """

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

235 today = datetime.utcnow()

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

237 end_incl = end - timedelta(seconds=1)

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

239 return localize_time_range(begin, end, tz)

240

241

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

243 """

244 Adds +- n months to datetime.

245 Clamps days to number of days in given month.

246 :param t: datetime

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

248 :return: datetime

249 """

250 t2 = t

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

252 if n > 0:

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

254 else:

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

256 try:

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

258 except Exception:

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

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

261 return t2

262

263

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

265 """

266 Iterates over time range in steps specified in delta.

267

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

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

270 :param delta: Step interval

271

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

273 """

274 curr = start

275 while curr < end:

276 curr_end = curr + delta

277 yield curr, curr_end

278 curr = curr_end

279

280

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

282 """

283 Iterates over time range in one month steps.

284 Clamps to number of days in given month.

285

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

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

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

289

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

291 """

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

293 while curr < end:

294 curr_end = add_month(curr, n)

295 yield curr, curr_end

296 curr = curr_end

297

298

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

300 """

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

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

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

304 :param begin: datetime

305 :param end: datetime

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

307 """

308 after_end = end

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

310 after_end += timedelta(days=1)

311 elif TIME_STEP_WEEKLY == step_type:

312 after_end += timedelta(days=7)

313 elif TIME_STEP_MONTHLY == step_type:

314 after_end = add_month(end)

315 else:

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

317

318 begins: List[datetime] = []

319 t0 = t = begin

320 n = 1

321 while t < after_end:

322 begins.append(t)

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

324 t = t0 + timedelta(days=n)

325 elif step_type == TIME_STEP_WEEKLY:

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

327 elif step_type == TIME_STEP_MONTHLY:

328 t = add_month(t0, n)

329 n += 1

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