"""Util functions for generic date and datetime operations.""" import calendar from datetime import date from datetime import datetime from datetime import timedelta from enum import Enum import itertools from typing import Union from dateutil.relativedelta import relativedelta ONE_DAY = timedelta(days=1) def calculate_first_and_last_day_of_release_week(year, week_number): """Calculate the first and the last day of a release week. Release week starts at Friday and ends on Thursday. Args: year (int): A year, e.g. 2018. week_number (int): A week_number (from 1 to 52 or 53 in some cases). Returns: dict: A dict with first_day and last_day keys and YYYY-MM-DD values. Raises: Exception: If provided week number isn't valid. """ c = calendar.Calendar(firstweekday=calendar.FRIDAY) week_number_counter = 1 for month in range(1, 13): weeks = c.monthdatescalendar(year, month) for week in weeks: if week[-1] <= date( year, month, calendar.monthrange(year, month)[1]): if week_number == week_number_counter: return { 'first_day': datetime.strftime(week[0], '%Y-%m-%d'), 'last_day': datetime.strftime(week[-1], '%Y-%m-%d')} else: week_number_counter += 1 raise Exception('week_number {} is invalid'.format(week_number)) def calculate_release_week_number_from_date(date_obj): """Calculate a release week number from a date. Release week starts at Friday and ends on Thursday. Because of that we can't use isocalendar() to get a week number from a date. E.g. the first release week of 2014 is "2013-12-27 - 2014-01-02", 10th week is "2014-02-28 - 2014-03-06", and the last week (52th) of 2014 is "2014-12-19 - 2014-12-25". Normally a year has 52 release weeks, but 2015, for example, had 53. Args: date_obj (datetime): A datetime object. Returns: dict: A dict with a year and a week number. """ year = date_obj.year c = calendar.Calendar(firstweekday=calendar.FRIDAY) # this is week number which we increment and return in the end week_number_counter = 1 for month in range(1, 13): last_day_of_month = date( year, month, calendar.monthrange(year, month)[1]) # 'weeks' is a list of all the weeks of a month, including overlapping, # so typically 'weeks' contains 6 weeks weeks = c.monthdatescalendar(date_obj.year, month) for week in weeks: # check if it's a real next week of a year, not overlapping one if week[-1] <= last_day_of_month: # check if a given date is within a week if week[-1] >= date_obj: # if so, return a dict with a previous week number return {'year': str(date_obj.year), 'week_number': str(week_number_counter).zfill(2)} # increase counter if only it's a 'real' week, not overlapping week_number_counter += 1 # if the release week for this date isn't within a given year return {'year': str(date_obj.replace(year=date_obj.year + 1).year), 'week_number': '01'} def add_months(dt, months): """Add months to date. Args: dt (datetime): Initial date. months (int): Number of months to add. Returns: datetime: Incremented date. """ return dt + timedelta(months * 365 / 12) def get_first_weekday_before_date(dt, weekday): """Get first weekday date before given date. E.g. if given date is Thu and weekday is Mon then return date of the first Mon before give date. Useful to get reporting date if report is being released on weekly basis. Args: dt (datetime): Date. weekday (int): Weekday. Returns: datetime: Date of the first weekday before given date. """ return dt - timedelta((dt.weekday() - weekday) % 7) def iterate_time( start_from: Union[date, datetime], increment: Union[timedelta, relativedelta]): """Iterate time by delta.""" current_date = start_from while True: yield current_date current_date += increment def generate_dates(initial_date: date, skip: int, days: int): """Generate days back from initial date with skips.""" if days <= 0: raise ValueError('days should be positive') return itertools.islice( iterate_time(initial_date, -ONE_DAY), skip, skip + days ) def generate_dates_between( initial_date: date, end_date: date): """Generate days back from initial date with skips.""" if not initial_date or not end_date: raise ValueError('Missing initial_date or end_date') if initial_date > end_date: return itertools.takewhile( lambda x: x >= end_date, iterate_time(initial_date, -ONE_DAY) ) else: return itertools.takewhile( lambda x: x <= end_date, iterate_time(initial_date, ONE_DAY) ) def generate_months(initial_date: date, skip: int, months: int): """Generate months back from initial date with skips.""" if months <= 0: raise ValueError('months should be positive') begin_of_month = initial_date.replace(day=1) return itertools.islice( iterate_time(begin_of_month, relativedelta(months=-1)), skip, skip + months ) def generate_weeks( initial_date: date, skip: int, weeks: int, weekday: int = None): """Generate weeks back from initial date with skips. Args: initial_date (date): Initial date. skip (int): Number of weeks to skip. weeks (int): Number of weeks to generate. weekday (int): Optional. Weekday to adjust the initial date to. Returns: iterator: Iterator of datetime.date. """ if weeks <= 0: raise ValueError('weeks should be positive') if weekday is not None: adjusted_date = get_first_weekday_before_date( initial_date, weekday) else: adjusted_date = initial_date return itertools.islice( iterate_time(adjusted_date, -ONE_DAY*7), skip, skip + weeks ) class Weekday(Enum): """Enum for weekdays.""" MONDAY = 0 TUESDAY = 1 WEDNESDAY = 2 THURSDAY = 3 FRIDAY = 4 SATURDAY = 5 SUNDAY = 6 def __str__(self): """Return capitalized name.""" return self.name.capitalize()