Gems all the way down

Wednesday 19 September 2012This is more than 12 years old. Be careful.

Today I had to update a function that computed a date range for charting. It accepted a parameter of how many days back to draw the chart. I needed to adapt it to do months too.

Days are easy because datetime.timedelta() accepts a days= argument, and then you can subtract the timedelta from your date. But timedelta does not accept a months= argument, because the date N months before the current date is not well-defined, and depends on exactly what you mean by “month.” For example, what is the date one month before March 30th?

So I had to write my own code to subtract months from a date just as I wanted. Since I was charting months, I actually only needed the answer to be the first day of the month N months ago, which simplified the problem.

Rather than drop the logic right into the compute_chart_dates() function, I pulled it out into its own function. This made it easy to test the month subtraction logic directly. Thinking about testing often leads to better-structured code, because of the extra demands of having usable surface area for the tests to attach to.

Here’s my code:

def subtract_months(when, months):
    """Return the first of the month a certain number of months ago.

    `when`: the datetime.date to count back from.

    `months`: the number of months to subtract.
    
    """
    when = when.replace(day=1)
    years, months = divmod(months, 12)
    if years:
        when = when.replace(year=when.year-years)
    for _ in range(months):
        when -= datetime.timedelta(days=1)
        when = when.replace(day=1)
    return when

When I was done, I had a function that did just what I wanted. I also had a handful of tests of the tricky edge cases, to be sure I had gotten those right. Then I could very simply use that function to extend my chart date function.

Afterward, I thought about how pleasing it was to write the subtract_months() function, and to consider all of its aspects, and to get it just right. It felt like a gem in my hand.

I felt a little bad, too, because it felt indulgent to focus so much effort on this one small function. Shouldn’t I be concentrating more on the rest of the code?

But I realized, this code is the way it’s supposed to be: tight, focused, solid. It’s a pure function, which makes it easy to reason about, and easy to test. This code is the ideal to aim for. Rather than scolding myself for thinking about the gem, I should be trying to make all the code gems.

Of course, the little utilities like subtract_months are easier to make gem-like than the twisty traffic jams at the heart of any real piece of software. But just because it’s hard doesn’t mean you shouldn’t try. The best code will be gems all the way down.

Comments

[gravatar]
It is also worth looking at the SQLite date time functions - see http://www.sqlite.org/lang_datefunc.html

Note how you can say stuff like date('now','start of month','+1 month','-1 day'); - the C code that implements this is public domain, and there is also a test suite.
[gravatar]
I don't understand why having a for loop like that can be considered "tight" code. Isn't the only edge case the case where the number of months after the divmod() is >= to when.month (in this case you have to do year++ and months-=12)? You don't have to care about days-in-month edge cases because you always return the first of every month. Wouldn't treating such a edge case without the for loop have been more tight?
[gravatar]
Exactly right. When you have a solid set of building blocks, then you can create larger structures quickly and easily.
Don't feel guilty for spending time making your tools work well.
[gravatar]
def subtract_months(when, months):
    years, months = divmod(months + 12, 12)
    add_years, months = divmod(when.month + 12 - months - 1, 12)
    return when.replace(year=when.year - years + add_years, month=months + 1, day=1)
That's the tight code.
[gravatar]
You could also use the excellent dateutil library for that.
[gravatar]
@Virgil + @Anton: thanks for the updates to the code. By "tight" I meant "tight enough," and I didn't even mean it literally as "fast," but a more subjective quality. I considered getting rid of the loop in favor of a closed-form solution, but this was good enough for my needs, and I can understand it. @Anton: your code may go faster, but I have no idea why it works.
[gravatar]
I love this post, Ned. Getting something exactly right is always such a good feeling (even if other people don't agree with your "exactly right"... which is almost always the case ;)

And I think your point in the above comment is a very good one - if you can't read the code, you won't be able to maintain it later. While I don't really like the loop, I can at least tell what it's doing, and in any realistic application, it'll never be the thing slowing down your code.
[gravatar]
Just so you guys don't get me wrong: I also agree that readability comes first and Ned's snippet is readable. The for loop is simply an aesthetic annoyance for me (as well as a performance problem, but this is probably not important).

However, I believe that it's possible to keep the readability and remove the loop with a modified version of Anton's code (to make the mathematical operations more self-explanatory).
[gravatar]
The loop could also be replaced by
when -= datetime.timedelta(days=28*months)
when = when.replace(day=1)
It might be easier to see why that works (knowing months < 12 here)
[gravatar]
I'm only superficially familiar with Python, but wouldn't the following be simpler and clearer? (I think the comments are superfluous, but decided to include anyway)
def subtract_months(when, months):
    #addition is more general and easier to think about, so defer to add_months
    return add_months(when, -months)

def add_months(when, months):
    #the new month will be when's month + months (taken mod 12 with appropriate offset)
    #subtracting 1 shifts the month numbers to 0 - 11, making mod work correctly
    year_diff, month_num = divmod(when.month - 1 + months, 12)
    #adding back in the 1 month we subtracted above
    month_num = month_num + 1
    return when.replace(year=when.year + year_diff, month=month_num, day=1)
[gravatar]
The good news it that all of these other suggestions work just as well as my code! :)
[gravatar]
@eric, yeah, my code was written at early morning, and I missed this simple symmetry. However I tested it with negative values ))) You are a winner!

Add a comment:

Ignore this:
Leave this empty:
Name is required. Either email or web are required. Email won't be displayed and I won't spam you. Your web site won't be indexed by search engines.
Don't put anything here:
Leave this empty:
Comment text is Markdown.