Coverage for arbejdstimer/arbejdstimer.py: 95.62%

238 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-04 15:36:40 +00:00

1"""Working hours (Danish arbejdstimer) or not? API.""" 

2 

3import copy 

4import datetime as dti 

5import json 

6import os 

7import pathlib 

8import sys 

9from typing import Tuple, Union, no_type_check 

10 

11from pydantic import ValidationError 

12 

13import arbejdstimer.api as api 

14 

15DEBUG_VAR = 'ARBEJDSTIMER_DEBUG' 

16DEBUG = os.getenv(DEBUG_VAR) 

17 

18ENCODING = 'utf-8' 

19ENCODING_ERRORS_POLICY = 'ignore' 

20 

21DEFAULT_CONFIG_NAME = '.arbejdstimer.json' 

22CfgType = dict[str, Union[dict[str, str], list[dict[str, Union[str, list[str]]]]]] 

23WorkingHoursType = Union[tuple[int, int], tuple[None, None]] 

24CmdType = Tuple[str, str, str, bool] 

25DATE_FMT = '%Y-%m-%d' 

26YEAR_MONTH_FORMAT = '%Y-%m' 

27DEFAULT_WORK_HOURS_MARKER = (None, None) 

28DEFAULT_WORK_HOURS_CLOSED_INTERVAL = (7, 16) 

29 

30 

31@no_type_check 

32def year_month_me(date) -> str: 

33 """DRY.""" 

34 return date.strftime(YEAR_MONTH_FORMAT) 

35 

36 

37@no_type_check 

38def load_config(path_or_str): 

39 """DRY.""" 

40 with open(path_or_str, 'rt', encoding=ENCODING) as handle: 

41 return json.load(handle) 

42 

43 

44def weekday(date: dti.date) -> int: 

45 """Return current weekday.""" 

46 return date.isoweekday() 

47 

48 

49def no_weekend(day_number: int) -> bool: 

50 """Return if day number is weekend.""" 

51 return day_number < 6 

52 

53 

54def the_hour() -> int: 

55 """Return the hour of day as integer within [0, 23].""" 

56 return dti.datetime.now().hour 

57 

58 

59@no_type_check 

60def days_of_year(day=None) -> list[dti.date]: 

61 """Return all days of the year that contains the day.""" 

62 year = dti.date.today().year if day is None else day.year 

63 d_start = dti.date(year, 1, 1) 

64 d_end = dti.date(year, 12, 31) 

65 return [d_start + dti.timedelta(days=x) for x in range((d_end - d_start).days + 1)] 

66 

67 

68@no_type_check 

69def workdays(off_days: list[dti.date], days=None) -> list[dti.date]: 

70 """Return all workdays of the year that contains the day.""" 

71 if days is None: 

72 days = days_of_year(None) 

73 return [cand for cand in days if cand not in off_days and no_weekend(weekday(cand))] 

74 

75 

76@no_type_check 

77def workdays_of_month(work_days, month) -> list[dti.date]: 

78 """Return all workdays of the month.""" 

79 return [work_day for work_day in work_days if year_month_me(work_day) == month] 

80 

81 

82@no_type_check 

83def workdays_count_per_month(work_days) -> dict[str, int]: 

84 """Return the workday count per month of the year that contains the day.""" 

85 per = {} 

86 for work_day in work_days: 

87 month = year_month_me(work_day) 

88 if month not in per: 

89 per[month] = 0 

90 per[month] += 1 

91 return per 

92 

93 

94@no_type_check 

95def cumulative_workdays_count_per_month(work_days) -> dict[str, int]: 

96 """Return the cumulative workday count per month of the year that contains the day.""" 

97 per = workdays_count_per_month(work_days) 

98 months = list(per) 

99 cum = copy.deepcopy(per) 

100 for slot, month in enumerate(months, start=1): 

101 cum[month] = sum(per[m] for m in months[:slot]) 

102 return cum 

103 

104 

105@no_type_check 

106def workdays_count_of_month_in_between(work_days, month, day, first_month, last_month) -> int: 

107 """Return the workday count of month to date for day (incl.) given first and last month.""" 

108 per = workdays_count_per_month(work_days) 

109 if month not in per or month < first_month or month > last_month: 

110 return 0 

111 

112 wds = workdays_of_month(work_days, month) 

113 return sum(1 for d in wds if d.day <= day) 

114 

115 

116@no_type_check 

117def closed_interval_months(work_days) -> tuple[str, str]: 

118 """DRY.""" 

119 return year_month_me(work_days[0]), year_month_me(work_days[-1]) 

120 

121 

122@no_type_check 

123def workdays_count_of_year_in_between(work_days, month, day, first_month=None, last_month=None) -> int: 

124 """Return the workday count of year to date for day (incl.) given first and last month.""" 

125 per = workdays_count_per_month(work_days) 

126 if any( 

127 ( 

128 month not in per, 

129 first_month and first_month not in per, 

130 last_month and last_month not in per, 

131 ) 

132 ): 

133 return 0 

134 

135 initial, final = closed_interval_months(work_days) 

136 if first_month is None: 

137 first_month = initial 

138 if last_month is None: 

139 last_month = final 

140 

141 count = 0 

142 for d in work_days: 

143 ds = year_month_me(d) 

144 if first_month <= ds <= last_month: 

145 if ds < month or ds == month and d.day <= day: 

146 count += 1 

147 

148 return count 

149 

150 

151@no_type_check 

152def remaining_workdays_count_of_year_in_between(work_days, month, day, first_month=None, last_month=None) -> int: 

153 """Return the workday count of year from date for day (incl.) given first and last month.""" 

154 per = workdays_count_per_month(work_days) 

155 if any( 

156 ( 

157 month not in per, 

158 first_month and first_month not in per, 

159 last_month and last_month not in per, 

160 ) 

161 ): 

162 return 0 

163 

164 initial, final = closed_interval_months(work_days) 

165 if first_month is None: 

166 first_month = initial 

167 if last_month is None: 

168 last_month = final 

169 

170 count = 0 

171 for d in work_days: 

172 ds = year_month_me(d) 

173 if first_month <= ds <= last_month: 

174 if ds == month and d.day > day or ds > month: 

175 count += 1 

176 

177 return count 

178 

179 

180@no_type_check 

181def workday(off_days: list[dti.date], cmd: str, date: str = '', strict: bool = False) -> Tuple[int, str]: 

182 """Apply the effective rules to the given date (default today).""" 

183 day = dti.datetime.strptime(date, DATE_FMT).date() if date else dti.date.today() 

184 if strict: 

185 if not off_days: 

186 return 2, '- empty date range of configuration' 

187 if not (off_days[0].year <= day.year < off_days[-1].year): 

188 return 2, '- Day is not within year range of configuration' 

189 else: 

190 if cmd.startswith('explain'): 190 ↛ 193line 190 didn't jump to line 193, because the condition on line 190 was never false

191 print(f'- Day ({day}) is within date range of configuration') 

192 

193 if day not in off_days: 193 ↛ 197line 193 didn't jump to line 197, because the condition on line 193 was never false

194 if cmd.startswith('explain'): 

195 print(f'- Day ({day}) is not a holiday') 

196 else: 

197 return 1, '- Day is a holiday.' 

198 

199 work_day = no_weekend(weekday(day)) 

200 if work_day: 

201 if cmd.startswith('explain'): 

202 print(f'- Day ({day}) is not a weekend') 

203 else: 

204 return 1, '- Day is weekend.' 

205 

206 return 0, '' 

207 

208 

209@no_type_check 

210def apply( 

211 off_days: list[dti.date], working_hours: WorkingHoursType, cmd: str, day: str, strict: bool 

212) -> Tuple[int, str]: 

213 """Apply the effective rules to the current date and time.""" 

214 working_hours = working_hours if working_hours != (None, None) else DEFAULT_WORK_HOURS_CLOSED_INTERVAL 

215 code, message = workday(off_days, cmd, date=str(day), strict=strict) 

216 if code: 

217 return code, message 

218 hour = the_hour() 

219 if working_hours[0] <= hour <= working_hours[1]: 219 ↛ 223line 219 didn't jump to line 223, because the condition on line 219 was never false

220 if cmd.startswith('explain'): 

221 print(f'- At this hour ({hour}) is work time') 

222 else: 

223 return 1, f'- No worktime at hour({hour}).' 

224 

225 return 0, '' 

226 

227 

228@no_type_check 

229def load(cfg: CfgType) -> Tuple[int, str, list[dti.date], WorkingHoursType]: 

230 """Load the configuration and return error, message and holidays as well as working hours list. 

231 

232 The holidays as well as non-default working hours will be ordered. 

233 """ 

234 if not cfg: 

235 return 0, 'empty configuration, using default', [], DEFAULT_WORK_HOURS_MARKER 

236 

237 try: 

238 model = api.Arbejdstimer(**cfg) 

239 except ValidationError as err: 

240 return 2, str(err), [], (None, None) 

241 

242 holidays_date_list = [] 

243 if model.holidays: 

244 holidays = model.model_dump()['holidays'] 

245 for nth, holiday in enumerate(holidays, start=1): 

246 dates = holiday['at'] 

247 if len(dates) == 1: 

248 holidays_date_list.append(dates[0]) 

249 elif len(dates) == 2: 

250 data = sorted(dates) 

251 start, end = [data[n] for n in (0, 1)] 

252 current = start 

253 holidays_date_list.append(current) 

254 while current < end: 

255 current += dti.timedelta(days=1) 

256 holidays_date_list.append(current) 

257 else: 

258 for a_date in dates: 

259 holidays_date_list.append(a_date) 

260 

261 working_hours = DEFAULT_WORK_HOURS_MARKER 

262 if model.working_hours: 

263 working_hours = tuple(sorted(model.working_hours.model_dump())) 

264 return 0, '', sorted(holidays_date_list), working_hours 

265 

266 

267@no_type_check 

268def workdays_from_config(cfg: CfgType, day=None) -> list[dti.date]: 

269 """Ja, ja, ja.""" 

270 error, _, holidays, _ = load(cfg) 

271 return [] if error else workdays(holidays, days=days_of_year(day)) 

272 

273 

274def verify_request(argv: Union[CmdType, None]) -> Tuple[int, str, CmdType]: 

275 """Fail with grace.""" 

276 err = ('', '', '', False) 

277 if not argv or len(argv) != 4: 

278 return 2, 'received wrong number of arguments', err 

279 

280 command, date, config, strict = argv 

281 

282 if command not in ('explain', 'explain_verbatim', 'now'): 

283 return 2, 'received unknown command', err 

284 

285 if not config: 

286 return 2, 'configuration missing', err 

287 

288 config_path = pathlib.Path(str(config)) 

289 if not config_path.is_file(): 

290 return 1, f'config ({config_path}) is no file', err 

291 if not ''.join(config_path.suffixes).lower().endswith('.json'): 

292 return 1, 'config has not .json extension', err 

293 

294 return 0, '', argv 

295 

296 

297def main(argv: Union[CmdType, None] = None) -> int: 

298 """Drive the lookup.""" 

299 error, message, strings = verify_request(argv) 

300 if error: 

301 print(message, file=sys.stderr) 

302 return error 

303 

304 command, date, config, strict = strings 

305 

306 configuration = load_config(config) 

307 error, message, holidays, working_hours = load(configuration) 

308 if error: 

309 if command.startswith('explain'): 

310 print('Configuration file failed to parse (INVALID)') 

311 print(message, file=sys.stderr) 

312 return error 

313 

314 if command.startswith('explain'): 

315 print(f'read valid configuration from ({config})') 

316 if command == 'explain_verbatim': 

317 lines = json.dumps(configuration, indent=2).split('\n') 

318 line_count = len(lines) 

319 counter_width = len(str(line_count)) 

320 print(f'configuration has {line_count} line{"" if line_count == 1 else "s"} of (indented) JSON content:') 

321 for line, content in enumerate(lines, start=1): 

322 print(f' {line:>{counter_width + 1}} | {content}') 

323 if command == 'explain_verbatim': 

324 if strict: 

325 print('detected strict mode (queries outside of year frame from config will fail)') 

326 else: 

327 print('detected non-strict mode (no constraints on year frame from config)') 

328 

329 if command == 'explain': 

330 print(f'consider {len(holidays)} holidays:') 

331 elif command == 'explain_verbatim': 

332 print('effective configuration:') 

333 if holidays: 

334 print(f'- given {len(holidays)} holidays within [{holidays[0]}, {holidays[-1]}]:') 

335 for holiday in holidays: 

336 print(f' + {holiday}') 

337 else: 

338 print(f'- no holidays defined in ({config}):') 

339 print('- working hours:') 

340 if working_hours != DEFAULT_WORK_HOURS_MARKER: 

341 print(f' + [{working_hours[0]}, {working_hours[1]}] (from configuration)') 

342 else: 

343 effective_range = DEFAULT_WORK_HOURS_CLOSED_INTERVAL 

344 print(f' + [{effective_range[0]}, {effective_range[1]}] (application default)') 

345 print('evaluation:') 

346 

347 if strict: 

348 print('detected strict mode (queries outside of year frame from config will fail)') 

349 

350 error, message = apply(holidays, working_hours, command, date, strict) 

351 if error: 

352 if command.startswith('explain'): 

353 print(message, file=sys.stdout) 

354 return error 

355 

356 return 0