Alexander Schillemans
Alexander Schillemans

Reputation: 566

Analysing a date and printing the next dates monthly

For a functionality for a program I'm currently working on, I need to be able to analyse a date and then return all the oncoming dates monthly.

e.g. Insert: 13/04/2018 --> this is in the second week of April on a friday. Return: all the second friday's for the upcoming months (11/05/2018, 8/06/2018, 13/07/2018,...)

Another limitation is that if the first day of one of the upcoming months falls on a saturday or sunday, this is not counted as the first week. The first week is counted from the first week there are more dates than just the weekend. Example: April 1st is on a Sunday, so the first week of April starts from the 2nd to the 8th. OR June 1st is on a Friday, so the first week of June is from 28th May to June 3th.

I've tried doing some things, but it does not seem to return correct dates.

startdate = input("Enter a startdate: ")
startdate = datetime.strptime(startdate, "%Y-%m-%d").date()
dayOfTheWeek = startdate.weekday()
first_day = startdate.replace(day=1)
cal = calendar.Calendar()
weeks = cal.monthdayscalendar(startdate.year, startdate.month)
if(first_day.isoweekday() in (6,7)): adjustRange = 1
else: adjustRange = 0
for x in range(len(weeks)):
    if startdate.day in weeks[x]:
        weekNumb = x-adjustRange

for x in range(0, 5):
    tempWeek = weekNumb
    print("Date: ", startdate)
    if(startdate.month + 1 > 12): added_months = 1
    else: added_months = startdate.month + 1
    weeks = cal.monthdayscalendar(startdate.year, added_months)

    first_day = startdate.replace(day=1)
    if(first_day.isoweekday() in (6,7)): tempWeek += 1

    plannedDay = weeks[tempWeek][dayOfTheWeek]

    if(added_months < 10): added_months = str(added_months).zfill(2)
    datestr = "{}-{}-{}".format(startdate.year, added_months, plannedDay)
    startdate = datetime.strptime(datestr, "%Y-%m-%d").date()

Output:

Enter a startdate: 2018-04-13
Date:  2018-04-13
Date:  2018-05-18 --> must be 2018-05-11
Date:  2018-06-08 --> OK 
Date:  2018-07-06 --> must be 2018-07-13
Date:  2018-08-17 --> must be 2018-08-10

It looks like they're all off by one week, either one too much or one short.

Basically, I get the day of the week (0-6) and then ask the first day of the current month. Then, using the calendar module I ask for all the weeks and their dates for that month and check in which week the inserted date is located. If the first_day of a month is on a saturday or sunday (6 or 7 using .isoweekday()) then we have to subtract one from the weeknumber to get the actual weeknumber.

Then, I analyse all the further dates and if their first_day also falls on a saturday or sunday I have to add one to the tempWeek (which contains the original weekNumb) because I'm trying to extract the date from the week tuple I'm receiving from the calendar.

I hope this kind of makes sense and my code is somewhat readable.

Upvotes: 3

Views: 236

Answers (3)

Alexander Schillemans
Alexander Schillemans

Reputation: 566

I've been playing around with this some more. The answers given were all helpful and helped me understand the problem further, thank you for that. You may see some code snippets from other people here that I've used. I decided to use the Calendar module to help me in this case.

import calendar
from datetime import datetime
from datetime import timedelta
import math


cal = calendar.Calendar()

start_date = datetime.strptime(input("Enter a start date: "), "%Y-%m-%d").date()
end_date = datetime.strptime(input("Enter an end date: "), "%Y-%m-%d").date()
days_difference = (end_date - start_date).days
week_number = math.ceil((start_date - start_date.replace(day=1)).days / 7) - 1
weekday = start_date.weekday()

for _ in range(15):
    weeks = cal.monthdayscalendar(start_date.year, start_date.month)

    for x, week in enumerate(weeks):
        if(week[weekday] == 0):
            weeks.pop(x)
            break

    if(len(weeks) == 5 and (week_number+1) == 4):
        if(weeks[len(weeks)-1][weekday] != 0 and weeks[0][weekday] != 0):
            weeks.pop(0)

    new_weekday = weeks[week_number][weekday]

    start_date = datetime(start_date.year, start_date.month, new_weekday).date()
    print(start_date)

    for x in range(1, days_difference+1):
        start_date += timedelta(days=1)
        print(start_date)

    if(start_date.month + 1 > 12):
        new_year = start_date.year + 1
        new_month = 1
    else:
        new_month = start_date.month + 1
        new_year = start_date.year

    start_date = datetime(new_year, new_month, 1).date()

    print("")

Output:

Enter a start date: 2018-04-12
Enter an end date: 2018-04-13
2018-04-12
2018-04-13

2018-05-10
2018-05-11

2018-06-14
2018-06-15

2018-07-12
2018-07-13
...

What I do is ask for a start date and an end date (which follow eachother). Then, I ask the weeks and their dates using the calendar.monthdayscalendar(). I go over them and if in it finds a week where the weekday of the given date is not present, it removes it from the list because we won't need it.

Then, here is where it became tricky: the platform that I'm gathering data from converts the fourth of something to the fifth of something when there's a fifth occurence for that day in that month. Example: I insert 2018-09-25, which is the fourth of Tuesday, but the next date will be 2018-10-30 which is the fifth Tuesday because there are five occurences in that month. It took me a while myself to find this (and also why I said in the beginning the weeks that start on saturday or sunday don't count, but that was not completely accurate: my apologies).

Thus, if the month has five weeks and the original week number was 4, then we pop the first week because we won't need it and the index of the week number can stay the same to gather the right date. For the end date I simply keep on adding one day until the difference runs out.

Once again, thank you all for your contributions. I tried looking at dateutil but because of the fact that a fourth something changes to a fifth something if there are five occurences in that month, this was not a solution.

Upvotes: 1

Scott Mermelstein
Scott Mermelstein

Reputation: 15397

Your big problem is in your for loop when you're outputting the next 4 days of interest. You have a line, first_day = startdate.replace(day=1), and you're setting tempWeek by the isoweek of that result. The problem is, you don't update the month to reflect added_months. This will throw everything off. So, change the line to

first_day = startdate.replace(day=1, month=added_months)

But you also need to track years - it's great that you roll the month from 12 to 1, but when you do, you need to update the years, as well. This won't impact your immediate test, but any others you try near the end of the year will be impacted.

for x in range(0, 5):
    tempWeek = weekNumb
    print("Date: ", startdate)
    if(startdate.month + 1 > 12): 
        added_months = 1
        added_years = startdate.year + 1
    else: 
        added_months = startdate.month + 1
        added_years = startdate.year
    weeks = cal.monthdayscalendar(added_years, added_months)

    first_day = startdate.replace(day=1, month=added_months, year=added_years)
    if(first_day.isoweekday() in (6,7)): tempWeek += 1

    plannedDay = weeks[tempWeek][dayOfTheWeek]

    if(added_months < 10): added_months = str(added_months).zfill(2)
    datestr = "{}-{}-{}".format(startdate.year, added_months, plannedDay)
    startdate = datetime.strptime(datestr, "%Y-%m-%d").date()

Doing that should solve your problem.

Extra hints:

  • I solved this simply by typing each line in the interactive python shell, and any time I set a variable, I looked at it afterwards. As you get better at this, you'll likely learn to start using debuggers, which will give you the same effect but easier.

  • I would recommend not stomping on startdate. What you did is perfectly valid, but it's weird that you're keeping some variables and stomping on others. In keeping with the variable names you have, I would recommend added_date, but in my code lower down, I change it to first_date.

  • It's also weird (but allowed) to overwrite added_months (an integer) with its string representation. Again, I'd use a different variable, something like str_months. Having one variable do one thing usually makes for smoother reading. In the sample code below, I get rid of the need for the string by taking advantage of the functionality of format.

  • It's generally good style to not put the results of your if statement on the same line as the if. Whitespace is really cheap nowadays, and it makes reading easier.

  • Things are cleaner if the end result of your for loop is at the end instead of partway through.

  • Also, you should aspire to be internally consistent with your names, either camelCase or underscored_names, but ideally not both.

  • It's pythonic to avoid using for loops wherever possible, and usually if you use "for x in len(...", there's a more efficient way. As mentioned in comments, enumerate can do that.

  • A nice standard in python is to use _ for variables you don't care about, like in your fixed for loop.

The above code is the minimalist changes to getting your code working. Below are the modifications I would make, given the hints I just mentioned. Also, I included the import statements, since without them, the code is not executable. We always try to post executable code here on Stack Overflow.

from datetime import datetime
import calendar

start_date = input("Enter a startdate: ")

start_date = datetime.strptime(start_date, "%Y-%m-%d").date()
day_of_the_week = start_date.weekday()
first_day = start_date.replace(day=1)
cal = calendar.Calendar()
weeks = cal.monthdayscalendar(start_date.year, start_date.month)
# This if statement could be a single line: 
# adjust_range = 1 if first_day.isoweekday() in (6,7) else 0
if(first_day.isoweekday() in (6,7)):
    adjust_range = 1
else: 
    adjust_range = 0

for x, week in enumerate(weeks):
    if start_date.day in week:
        week_numb = x - adjust_range
        # This will happen only once, so you can break out of the loop
        break

print("Date: ", start_date)

first_date = start_date.replace(day=1)
for _ in range(4):
    temp_week = week_numb
    if(first_date.month + 1 > 12): 
        new_months = 1
        new_years = first_date.year + 1
    else: 
        new_months = first_date.month + 1
        new_years = first_date.year

    weeks = cal.monthdayscalendar(new_years, new_months)
    first_date = first_date.replace(day=1, month=new_months, year=new_years)
    if(first_date.isoweekday() in (6,7)):
        temp_week += 1

    planned_day = weeks[temp_week][day_of_the_week]

    # The if wasn't needed; let format do the work for you.
    print("{}-{:02d}-{:02d}".format(new_years, new_months, planned_day))

    # You could set something like 
    # interested_date = datetime(added_years, added_months, planned_day), 
    # but you don't seem to use it.

Upvotes: 1

Chris Wesseling
Chris Wesseling

Reputation: 6368

If you pip install python-dateutil

from dateutil.rrule import rrule, MONTHLY, weekdays
from dateutil.parser import parse
import math

def month_week(date):
    return math.ceil((date - date.replace(day=1)).days / 7 ) or 1

start_date = parse(input("Enter a startdate: "))
for day in rrule(freq=MONTHLY,
                 count=5,
                 dtstart=start_date,
                 byweekday=weekdays[start_date.weekday()](
                     month_week(start_date)),
                 ):
    print("Date: ", day.date())

will do what you expect for 2018-04-13.

From your question I'm not sure what your expected output would be for 2018-09-01 (1st Saturday of September) or 2018-01-29 (5th Friday in January). So you may want to change the month_week function to return the weeknumber of the month you expect it to be.

Upvotes: 2

Related Questions