14
\$\begingroup\$

How can I get the date of the next occurrence of Friday the 13th?

Below is my attempt. It returns the correct results. I think there must be a better way of writing this without checking thirteenth > start_date for every month. It might also be possible to exploit the fact that every calendar year has a Friday 13th.

import itertools
from datetime import date


def next_fri_13(start_date):
    '''Get the next Friday 13th after the specified start_date.

    Args:
        start_date (datetime.date)

    Returns:
        datetime.date: The first Friday 13th after the start_date.
    '''
    for year in itertools.count(start_date.year):
        for month in range(1, 13):
            thirteenth = date(year, month, 13)
            # Ensure that the 13th is after the start date, and is a Friday.
            if thirteenth > start_date and thirteenth.weekday() == 4:
                return thirteenth


r = next_fri_13(date(2020, 2, 11))
print(r)  # Prints: 2020-03-13
\$\endgroup\$
5
  • \$\begingroup\$ There may be a way to solve Zeller's Congruence for the months if you put in the year, but keep in mind that because it uses mod 7 there can be multiple solutions (which you can account for by knowing that there are 12 months in a year). You would have to plug in the day of the month (13th) and day of the week (6, Friday). \$\endgroup\$ – jreese Feb 11 '20 at 18:35
  • \$\begingroup\$ codegolf.stackexchange.com/q/976/9516 There's some perfectly "good" examples to reference here! \$\endgroup\$ – Cruncher Feb 12 '20 at 14:27
  • \$\begingroup\$ You could find every Friday the 13th for as long as you care about in the future and store those static dates someplace (database, XML, static class, etc). One time calculation and all subsequent calls are reads. \$\endgroup\$ – UnhandledExcepSean Feb 12 '20 at 15:04
  • \$\begingroup\$ If you have an easier way to get the first day of each month, then checking if that it is a Sunday should be better \$\endgroup\$ – RedLaser Feb 12 '20 at 17:36
  • \$\begingroup\$ Note also that there are only 14 variations on the calendar -- January 1st can be one of seven days, and the year can be a leap year or not a leap year, so that's 14. You can list all the Friday the 13ths in each of those 14 variations, and then all you need to do to find the next one is (1) know which of the 14 variations we're in now, and (2) search the appropriate list for the first one following the current date. \$\endgroup\$ – Eric Lippert Feb 12 '20 at 18:04
12
\$\begingroup\$

I think this can be slightly simplified by using dateutils.relativedelta (since datetime.timedelta does not support months, unfortunately). This way you don't need to manually keep track of the year and month and if you ever need to implement something for e.g. next end of a month that is on a Sunday, it is way easier, because it takes care of the peculiarities for you, like months ending on different days of the month.

I would also make the magic constant 4 into a global constant called FRIDAY.

from datetime import date
from dateutil.relativedelta import relativedelta

FRIDAY = 4    # Monday is 0

def next_fri_13(start_date):
    '''Get the next Friday 13th after the specified start_date.

    Args:
        start_date (datetime.date)

    Returns:
        datetime.date: The first Friday 13th after the start_date.
    '''
    thirteenth = date(start_date.year, start_date.month, 13)
    if start_date.day >= 13:
        thirteenth += relativedelta(months=1)

    while thirteenth.weekday() != FRIDAY:
        thirteenth += relativedelta(months=1)
    return thirteenth

Alternatively, you could make a generator of thirteenths days and drop all that are not a Friday using filter and use the fact that relativedelta does not only take offsets, but can also replace attributes at the same time (using the keywords without a trailing "s"):

from itertools import count

def next_fri_13(start_date):
    '''Get the next Friday 13th after the specified start_date.

    Args:
        start_date (datetime.date)

    Returns:
        datetime.date: The first Friday 13th after the start_date.
    '''
    thirteenths = (start_date + relativedelta(months=n, day=13)
                   for n in count(start_date.day >= 13))
    return next(filter(lambda d: d.weekday() == FRIDAY, thirteenths))

IMO the first is easier to read, though, while this one is slightly easier to convert into a generator yielding all Friday the 13ths after start_date (just replace return next(...) with yield from ...).

In any case, there is a bit of a philosophical debate about doing

import itertools

itertools.count()

or

from itertools import count

count()

I personally tend to favor the latter, as long as it is still somewhat easy to figure out which module a function comes from, and there is no shadowing. This makes the lines a bit shorter and easier to read, especially if you use one particular module quite heavily (which the itertools module is a bit predestined for). It is also slightly faster, because you only need to do a global lookup instead of a global lookup and an attribute lookup, but that is negligible most of the time (and if it is not, you should make it a local variable anyway). But in the end the choice is yours.

\$\endgroup\$
2
  • \$\begingroup\$ What format are your docstring's arguments and returns? \$\endgroup\$ – Peilonrayz Feb 11 '20 at 9:59
  • 1
    \$\begingroup\$ @Peilonrayz: I copied the docstring from the OP. It looks like the Google docstring conventions, as far as I can tell. \$\endgroup\$ – Graipher Feb 11 '20 at 10:00
7
\$\begingroup\$

This is already very nice looking code. The documentation is good and the algorithm used is clear. The points I would do different are really minor, and also depend on preference

  • Instead of from datetime import date, I would just import datetime, and use datetime.date. This makes it more clear where date comes from, and I find date a very useful variable name, so I would like to keep the option open to use that
  • function name: the 3 characters save by not spelling out friday completely are not worth it
  • the comment # Ensure that the 13th is after the start date, and is a Friday. is superfluous. The code itself expresses that exactly. Inline comments should explain why something is coded the way it is, not what the code does. If that is not clear, then you should rather choose a different code path
  • type annotate your function def next_friday_13(start_date: datetime.date) -> datetime.date:
  • main guard: Put the code that actually executes something behine aan if __name__ == "__main__:. That way you can later import this code as a module

test

Write a simple test routine that you can use:

def test_friday_13th():
    tests = {
        datetime.date(2020, 3, 12): datetime.date(2020, 3, 13),
        datetime.date(2020, 3, 13): datetime.date(2020, 11, 13),
        datetime.date(2020, 1, 1): datetime.date(2020, 3, 13),
        datetime.date(2020, 12, 13): datetime.date(2021, 8, 13)
        # TODO: add more test cases
    }
    for startdate, thirtheenth in tests.items():
        assert next_friday_13(startdate) == thirtheenth

or use doctest

\$\endgroup\$
6
\$\begingroup\$

While working on my answer, @Graipher already introduced the idea of a generator.

This generator variant does not rely on an external library, but should still be robust enough:

import itertools
import datetime
from typing import Iterator


FRIDAY = 4

def friday_13_generator(start_date: datetime.date) -> datetime.date:
    year = start_date.year
    month = start_date.month

    def increment_month(year, month):
        """Helper function to increase the year on month 'overflow'"""
        month += 1
        if month > 12:
            month = 1
            year += 1
        return year, month

    if start_date.day >= 13:
        year, month = increment_month(year, month)

    while True:
        candidate = datetime.date(year, month, 13)
        if candidate.weekday() == FRIDAY:
            yield candidate

        year, month = increment_month(year, month)


def next_friday_13(start_date: datetime.date) -> datetime.date:
    '''Get the next Friday 13th after the specified start_date.

    Args:
        start_date (datetime.date)

    Returns:
        datetime.date: The first Friday 13th after the start_date.
    '''
    return next(friday_13_generator(start_date))

if candidate.weekday() == FRIDAY: could also be moved out of candidate_generator using itertools.dropwhile as presented by @Graipher.

Edit: Thanks to Maarten Fabré for spotting an error when start_date was after or on Dec 13.

You can check the correctness of this implementation using the tests provided by Maarten Fabré in his answer. gazoh also outlined an additional test case in his answer that you should take care of.

\$\endgroup\$
5
  • 1
    \$\begingroup\$ I like the idea of friday_13_generator and then using next in next_friday_13. \$\endgroup\$ – Peilonrayz Feb 11 '20 at 10:11
  • \$\begingroup\$ Thanks for fixing the type hint :-) I'm still getting used to them. \$\endgroup\$ – AlexV Feb 11 '20 at 10:12
  • 1
    \$\begingroup\$ No problem, it could be changed to Generator[datetime.date, None, None], but since you're not using it as a coroutine and instead as an Iterator it makes more sense. \$\endgroup\$ – Peilonrayz Feb 11 '20 at 10:14
  • \$\begingroup\$ Don't forget to increment the year when getting a start date after December 13 \$\endgroup\$ – Maarten Fabré Feb 11 '20 at 12:00
  • \$\begingroup\$ @MaartenFabré: Hopefully fixed now :-) I think datetime.date(2020, 12, 13): datetime.date(2021, 8, 13) could be added as test case for # TODO: add more test cases in your answer. \$\endgroup\$ – AlexV Feb 11 '20 at 13:15

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.