Search code examples
pythondatepython-datetimepython-dateutil

How to get the week number of the current quarter in Python?


I've been through every question and every third party library trying to figure out a way to do this where I don't have to manually map dates.

I'm trying to get the week number of the current fiscal quarter. Each quarter starts on the 1st of either January, April, July or October.

Given a date (string or object, it doesn't matter), I need to be able to calculate the week number of the fiscal quarter that it's in.

To make matters a little more complicated, the Fiscal year starts in April.

So for example, today, July 9th 2020 is week 2 of this Fiscal quarter (Q2), because the quarter starts in April. Similarly, the 29 and 30th of June 2020 are week 14 of quarter 1.

Most time formatting libraries and even the standard library ones have methods like ISO date where I can extract the week number fine. But it's the week number from the 1st day of the year.

I can't use arithmetic to simply remove the number of weeks to the current date as there are a different number of weeks in each quarter. Quarters can have 12, 13 or 14 weeks depending on the year.

The closest I've gotten is using the FiscalYear library which is great as it has a Fiscal Quarter class with it. Unfortunately, the inherited method isoformat() doesn't apply to it. Only the FiscalDate class, which doesn't give me the quarter which I need.

Has anyone run into this? Can someone point me in the right direction?

I'd post code snippets but it's just the 100 ways there are in Python to get the current week number (as of today, that's 28).

I've tried using rrules and deltas in dateutils but the closest I can get is the week number of the 1st quarter using offsets. The second quarter, it falls apart.

I'm happy to use pandas or any other 3rd party library if it will help me avoid hard coding the quarter dates or, god forbid, the week number to dates mappings.

Any help in the right direction would be very much appreciated.


Edit: All three answers below solved this issue for me in different ways. I struggled with which one to give the correct answer to, but I gave it to @Paul's answer as it was the one that I could follow most as someone who isn't a senior. It was also the answer that fit in with my personal use case (that I didn't mention), which was receiving a datetime object and getting the results. So that gave it the edge. Sorry to the others who provided amazing answers. I'm thrilled to have gotten the code what all I was hoping for was a nudge in the right direction. Thank you all.


Solution

  • Unless this is a very common way to reckon week numbering, I don't know if you are going to find a library that will do this exactly for you, but it's easy enough to accomplish using dateutil's relativedelta and a little logic. Here's a simple implementation that returns a tuple (quarter, week). Since you said that Q1 starts April 1st, I am assuming that the period from January 1st to April 1st is called Q0:

    from datetime import date, datetime, timedelta
    import typing
    
    from dateutil import relativedelta
    
    NEXT_MONDAY = relativedelta.relativedelta(weekday=relativedelta.MO)
    LAST_MONDAY = relativedelta.relativedelta(weekday=relativedelta.MO(-1))
    ONE_WEEK = timedelta(weeks=1)
    
    
    def week_in_quarter(dt: datetime) -> typing.Tuple[int, int]:
        d: date = dt.date()
        year = d.year
    
        # Q0 = January 1, Q1 = April 1, Q2 = July 1, Q3 = October 1
        quarter = ((d.month - 1) // 3)
        quarter_start = date(year, (quarter * 3) + 1, 1)
        quarter_week_2_monday = quarter_start + NEXT_MONDAY
    
        if d < quarter_week_2_monday:
            week = 1
        else:
            cur_week_monday = d + LAST_MONDAY
            week = int((cur_week_monday - quarter_week_2_monday) / ONE_WEEK) + 2
    
        return quarter, week
    

    Which returns:

    $ python week_in_quarter.py 
    2020-01-01: Q0-W01
    2020-02-01: Q0-W05
    2020-02-29: Q0-W09
    2020-03-01: Q0-W09
    2020-06-30: Q1-W14
    2020-07-01: Q2-W01
    2020-09-04: Q2-W10
    2020-12-31: Q3-W14
    

    If I've misunderstood the first quarter of the calendar year, and it's actually the case that January 1–April 1 of year X is considered Q4 of year X-1, then you can change the return quarter, week line at the end to this (and change the return type annotation):

    if quarter == 0:
        year -= 1
        quarter = 4
    
    return year, quarter, week
    

    Which changes the return values to:

    $ python week_in_quarter.py 
    2020-01-01: FY2019-Q4-W01
    2020-02-01: FY2019-Q4-W05
    2020-02-29: FY2019-Q4-W09
    2020-03-01: FY2019-Q4-W09
    2020-06-30: FY2020-Q1-W14
    2020-07-01: FY2020-Q2-W01
    2020-09-04: FY2020-Q2-W10
    2020-12-31: FY2020-Q3-W14
    

    If this is something that is a speed bottleneck, it should probably be easy to write an optimized version of this that does not use dateutil.relativedelta, but instead calculates this based on day of week, day of year and whether or not this is a leap year (calendar calculations in Python usually go faster if you can turn it into integer operations as early in the process as possible), but I suspect that in most cases this version should be the easiest to read and understand.

    If you would like to avoid the dependency on dateutil, you can replace NEXT_MONDAY and LAST_MONDAY with simple functions:

    def next_monday(dt: date) -> date:
        weekday = dt.weekday()
        return dt + timedelta(days=(7 - weekday) % 7)
    
    def last_monday(dt: date) -> date:
        weekday = dt.weekday()
        return dt - timedelta(days=weekday)
    

    In which case you would assign the two _monday variables as quarter_week_2_monday = next_monday(quarter_start) and cur_week_monday = last_monday(dt), respectively.

    As a note: if I were writing this function, I'd probably not have it return a bare tuple of integers, but instead use attrs or a dataclass to create a simple class for the purpose, like so:

    import attr
    
    @attr.s(auto_attribs=True, frozen=True, slots=True)
    class QuarterInWeek:
        year: int
        quarter: int
        week: int
    
        def __str__(self):
            return f"FY{self.year}-Q{self.quarter}-W{self.week:02d}"
    

    (Note that slots=True is optional, and I think not available if you use dataclasses.dataclass instead — it's just that this is a simple struct and I tend to use slots classes for simple structs).