From 0fbcd84887198eea08e3679e6dd68bce5bba5207 Mon Sep 17 00:00:00 2001 From: Nic Mendoza Date: Thu, 4 Feb 2021 13:03:59 -0500 Subject: [PATCH 1/6] Fix for #15 using time delta to better account for leap years --- fiscalyear.py | 9 ++++----- test_fiscalyear.py | 3 +++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/fiscalyear.py b/fiscalyear.py index 9d5c8d8..cb0454a 100644 --- a/fiscalyear.py +++ b/fiscalyear.py @@ -1104,13 +1104,12 @@ def fiscal_day(self): """:returns: The fiscal day :rtype: int """ - day_of_calendar_year = self.timetuple().tm_yday fiscal_year = FiscalYear(self.fiscal_year) - beginning_of_fiscal_year = fiscal_year.start.timetuple().tm_yday - days_elapsed = day_of_calendar_year - beginning_of_fiscal_year + 1 - if days_elapsed < 1: + days_elapsed = (self - fiscal_year.start.date()).days + if days_elapsed < 0: days_elapsed += (365 if fiscal_year.isleap else 366) - return days_elapsed + + return days_elapsed + 1 @property def prev_fiscal_year(self): diff --git a/test_fiscalyear.py b/test_fiscalyear.py index d8ab606..f2fbae7 100644 --- a/test_fiscalyear.py +++ b/test_fiscalyear.py @@ -905,6 +905,9 @@ def test_leap_year(self): assert fiscalyear.FiscalDate(2016, 1, 1).fiscal_day == 93 assert fiscalyear.FiscalDate(2016, 2, 29).fiscal_day == 152 assert fiscalyear.FiscalDate(2017, 3, 1).fiscal_day == 152 + assert fiscalyear.FiscalDate(2016, 9, 30).fiscal_day == 366 + assert fiscalyear.FiscalDate(2017, 9, 30).fiscal_day == 365 + assert fiscalyear.FiscalDate(2018, 9, 30).fiscal_day == 365 def test_contains(self, a, b, c, f): assert b in c From d5eb43dfc6aef067ddf3634a66bb555e8f49edb0 Mon Sep 17 00:00:00 2001 From: Nic Mendoza Date: Thu, 4 Feb 2021 13:08:55 -0500 Subject: [PATCH 2/6] formatting fix --- fiscalyear.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fiscalyear.py b/fiscalyear.py index 1f8dd26..384facb 100644 --- a/fiscalyear.py +++ b/fiscalyear.py @@ -1284,7 +1284,7 @@ def fiscal_day(self): days_elapsed = (self - fiscal_year.start.date()).days if days_elapsed < 0: - days_elapsed += (365 if fiscal_year.isleap else 366) + days_elapsed += 365 if fiscal_year.isleap else 366 return days_elapsed + 1 From 232c79d6e9f20d9965f4dd966e37fea4776bc693 Mon Sep 17 00:00:00 2001 From: Nic Mendoza Date: Thu, 4 Feb 2021 13:16:44 -0500 Subject: [PATCH 3/6] eliminate needless case --- fiscalyear.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fiscalyear.py b/fiscalyear.py index 384facb..680cdf8 100644 --- a/fiscalyear.py +++ b/fiscalyear.py @@ -1282,11 +1282,12 @@ def fiscal_day(self): """ fiscal_year = FiscalYear(self.fiscal_year) - days_elapsed = (self - fiscal_year.start.date()).days - if days_elapsed < 0: - days_elapsed += 365 if fiscal_year.isleap else 366 + if isinstance(self, FiscalDateTime): + year_start = fiscal_year.start + else: + year_start = fiscal_year.start.date() - return days_elapsed + 1 + return (self - year_start).days + 1 @property def prev_fiscal_year(self): From 705d17c174ef5422d2f54e03908e92825c3b3731 Mon Sep 17 00:00:00 2001 From: Nic Mendoza Date: Thu, 4 Feb 2021 13:18:13 -0500 Subject: [PATCH 4/6] simplification --- fiscalyear.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/fiscalyear.py b/fiscalyear.py index 680cdf8..0833fef 100644 --- a/fiscalyear.py +++ b/fiscalyear.py @@ -1281,11 +1281,10 @@ def fiscal_day(self): :rtype: int """ fiscal_year = FiscalYear(self.fiscal_year) + year_start = fiscal_year.start - if isinstance(self, FiscalDateTime): - year_start = fiscal_year.start - else: - year_start = fiscal_year.start.date() + if isinstance(self, FiscalDate): + year_start = year_start.date() return (self - year_start).days + 1 From 7150066f0e8d7eecb563db47eeb31218764d868f Mon Sep 17 00:00:00 2001 From: Nic Mendoza Date: Thu, 4 Feb 2021 13:25:47 -0500 Subject: [PATCH 5/6] add fiscal_week attribute to _FiscalBase --- README.rst | 2 ++ docs/basic_usage.rst | 2 ++ fiscalyear.py | 7 +++++++ test_fiscalyear.py | 12 ++++++++++++ 4 files changed, 23 insertions(+) diff --git a/README.rst b/README.rst index 1629646..b4411cd 100644 --- a/README.rst +++ b/README.rst @@ -169,6 +169,8 @@ The start and end of each of the above objects are stored as instances of the `` FiscalQuarter(2017, 4) >>> e.fiscal_month 7 + >>> e.fiscal_week + 28 >>> e.fiscal_day 190 diff --git a/docs/basic_usage.rst b/docs/basic_usage.rst index 060bee4..3f61e49 100644 --- a/docs/basic_usage.rst +++ b/docs/basic_usage.rst @@ -139,6 +139,8 @@ The start and end of each of the above objects are stored as instances of the `` FiscalQuarter(2017, 4) >>> e.fiscal_month 7 + >>> e.fiscal_week + 28 >>> e.fiscal_day 190 diff --git a/fiscalyear.py b/fiscalyear.py index 0833fef..1bf91e5 100644 --- a/fiscalyear.py +++ b/fiscalyear.py @@ -1275,6 +1275,13 @@ def fiscal_month(self): """ return (self.month - FiscalYear(self.year).start.month) % 12 + 1 + @property + def fiscal_week(self): + """returns: The fiscal week + :rtype: int + """ + return -(-self.fiscal_day // 7) + @property def fiscal_day(self): """:returns: The fiscal day diff --git a/test_fiscalyear.py b/test_fiscalyear.py index 5a6260f..a64f90f 100644 --- a/test_fiscalyear.py +++ b/test_fiscalyear.py @@ -1027,6 +1027,18 @@ def test_next_fiscal_quarter(self, a, c): def test_prev_fiscal_month(self, a): assert a.prev_fiscal_month == fiscalyear.FiscalMonth(2017, 3) + def test_fiscal_week(self): + a = fiscalyear.FiscalDay(2017, 1).start + b = fiscalyear.FiscalDay(2017, 7).start + assert a.fiscal_week == 1 + assert b.fiscal_week == 1 + + c = fiscalyear.FiscalDay(2017, 8).start + assert c.fiscal_week == 2 + + d = fiscalyear.FiscalDay(2016, 366).start + assert d.fiscal_week == 53 + def test_next_fiscal_month(self, a): assert a.next_fiscal_month == fiscalyear.FiscalMonth(2017, 5) From 21ceed58ec4d5b425c8c2a22d089b86de671bac7 Mon Sep 17 00:00:00 2001 From: Nic Mendoza Date: Thu, 4 Feb 2021 14:24:31 -0500 Subject: [PATCH 6/6] quick implementation --- fiscalyear.py | 276 ++++++++++++++++++++++++++++++++++++++++++++- test_fiscalyear.py | 138 +++++++++++++++++++++++ 2 files changed, 411 insertions(+), 3 deletions(-) diff --git a/fiscalyear.py b/fiscalyear.py index 1bf91e5..f7eb884 100644 --- a/fiscalyear.py +++ b/fiscalyear.py @@ -191,6 +191,27 @@ def _check_day(month, day): raise ValueError("day must be in %d..%d" % (1, max_day), day) +def _check_fiscal_week(fiscal_year, fiscal_week): + """Check if week is a valid week of the fiscal year. + + :param fiscal_year: The fiscal year to test + :param fiscal_week: The fiscal week to test + :return: The fiscal week + :rtype: int + :raises TypeError: If year or week is not an int or int-like string + :raises ValueError: If year or week is out of range + """ + fiscal_year = _check_year(fiscal_year) + fiscal_week = _check_int(fiscal_week) + + # Find the length of the year + max_week = 53 if FiscalYear(fiscal_year).isleap else 52 + if 1 <= fiscal_week <= max_week: + return fiscal_week + else: + raise ValueError("week must be in %d..%d" % (1, max_week), fiscal_week) + + def _check_fiscal_day(fiscal_year, fiscal_day): """Check if day is a valid day of the fiscal year. @@ -293,7 +314,7 @@ def __contains__(self, item): """ if isinstance(item, FiscalYear): return self == item - elif isinstance(item, (FiscalQuarter, FiscalMonth, FiscalDay)): + elif isinstance(item, (FiscalQuarter, FiscalMonth, FiscalWeek, FiscalDay)): return self._fiscal_year == item.fiscal_year elif isinstance(item, datetime.datetime): return self.start <= item <= self.end @@ -522,7 +543,7 @@ def __contains__(self, item): """ if isinstance(item, FiscalQuarter): return self == item - elif isinstance(item, (FiscalMonth, FiscalDay)): + elif isinstance(item, (FiscalMonth, FiscalWeek, FiscalDay)): return self.start <= item.start and item.end <= self.end elif isinstance(item, datetime.datetime): return self.start <= item <= self.end @@ -804,7 +825,7 @@ def __contains__(self, item): """ if isinstance(item, FiscalMonth): return self == item - elif isinstance(item, FiscalDay): + elif isinstance(item, (FiscalWeek, FiscalDay)): return self.start <= item.start <= item.end <= self.end elif isinstance(item, datetime.datetime): return self.start <= item <= self.end @@ -980,6 +1001,255 @@ def __ge__(self, other): ) +class FiscalWeek(object): + """A class representing a single fiscal week.""" + + __slots__ = ["_fiscal_year", "_fiscal_week"] + + def __new__(cls, fiscal_year, fiscal_week): + """Constructor. + + :param fiscal_year: The fiscal year + :type fiscal_year: int or str + :param fiscal_week: The fiscal week + :type fiscal_week: int or str + :returns: A newly constructed FiscalWeek object + :rtype: FiscalWeek + :raises TypeError: If fiscal_year or fiscal_week is not + an int or int-like string + :raises ValueError: If fiscal_year or fiscal_week is out of range + """ + fiscal_year = _check_year(fiscal_year) + fiscal_week = _check_fiscal_week(fiscal_year, fiscal_week) + + self = super(FiscalWeek, cls).__new__(cls) + self._fiscal_year = fiscal_year + self._fiscal_week = fiscal_week + return self + + @classmethod + def current(cls): + """Alternative constructor. Returns the current FiscalWeek. + + :returns: A newly constructed FiscalWeek object + :rtype: FiscalWeek + """ + today = FiscalDate.today() + return cls(today.fiscal_year, today.fiscal_week) + + def __repr__(self): + """Convert to formal string, for repr(). + + >>> fw = FiscalWeek(2017, 1) + >>> repr(fw) + 'FiscalWeek(2017, 1)' + """ + return "%s(%d, %d)" % ( + self.__class__.__name__, + self._fiscal_year, + self._fiscal_week, + ) + + def __str__(self): + """Convert to informal string, for str(). + + >>> fw = FiscalWeek(2017, 1) + >>> str(fw) + 'FY2017 FW1' + """ + return "FY%d FW%d" % (self._fiscal_year, self._fiscal_week) + + # TODO: Implement __format__ so that you can print + # fiscal year as 17 or 2017 (%y or %Y) + + def __contains__(self, item): + """Returns True if item in self, else False. + + :param item: The item to check + :type item: FiscalWeek, FiscalDateTime, + datetime, FiscalDate, or date + :rtype: bool + """ + if isinstance(item, FiscalWeek): + return self == item + elif isinstance(item, datetime.datetime): + return self.start <= item <= self.end + elif isinstance(item, datetime.date): + return self.start.date() <= item <= self.end.date() + else: + raise TypeError( + "can't compare '%s' to '%s'" + % (type(self).__name__, type(item).__name__) + ) + + # Read-only field accessors + + @property + def fiscal_year(self): + """:returns: The fiscal year + :rtype: int + """ + return self._fiscal_year + + @property + def fiscal_quarter(self): + """:returns: The fiscal quarter + :rtype: int + """ + return self.start.fiscal_quarter + + @property + def fiscal_month(self): + """:returns: The fiscal month + :rtype: int + """ + return self.start.fiscal_month + + @property + def fiscal_week(self): + """:returns: The fiscal week + :rtype: int + """ + return self._fiscal_week + + @property + def start(self): + """:returns: Start of the fiscal week + :rtype: FiscalDateTime + """ + + fiscal_year = FiscalYear(self._fiscal_year) + weeks_elapsed = datetime.timedelta(weeks=self._fiscal_week - 1) + start = fiscal_year.start + weeks_elapsed + return FiscalDateTime(start.year, start.month, start.day, 0, 0, 0) + + @property + def end(self): + """:returns: End of the fiscal week + :rtype: FiscalDateTime + """ + # Find the start of the next fiscal quarter + next_start = self.next_fiscal_week.start + + # Substract 1 second + end = next_start - datetime.timedelta(seconds=1) + + return FiscalDateTime( + end.year, + end.month, + end.day, + end.hour, + end.minute, + end.second, + end.microsecond, + end.tzinfo, + ) + + @property + def prev_fiscal_week(self): + """:returns: The previous fiscal week + :rtype: FiscalWeek + """ + fiscal_year = self._fiscal_year + fiscal_week = self._fiscal_week - 1 + if fiscal_week == 0: + fiscal_year -= 1 + try: + fiscal_week = _check_fiscal_week(fiscal_year, 53) + except ValueError: + fiscal_week = _check_fiscal_week(fiscal_year, 52) + + return FiscalWeek(fiscal_year, fiscal_week) + + @property + def next_fiscal_week(self): + """:returns: The next fiscal week + :rtype: FiscalWeek + """ + fiscal_year = self._fiscal_year + try: + fiscal_week = _check_fiscal_week(fiscal_year, self._fiscal_week + 1) + except ValueError: + fiscal_year += 1 + fiscal_week = 1 + + return FiscalWeek(fiscal_year, fiscal_week) + + # Comparisons of FiscalWeek objects with other + + def __lt__(self, other): + if isinstance(other, FiscalWeek): + return (self._fiscal_year, self._fiscal_week) < ( + other._fiscal_year, + other._fiscal_week, + ) + else: + raise TypeError( + "can't compare '%s' to '%s'" + % (type(self).__name__, type(other).__name__) + ) + + def __le__(self, other): + if isinstance(other, FiscalWeek): + return (self._fiscal_year, self._fiscal_week) <= ( + other._fiscal_year, + other._fiscal_week, + ) + else: + raise TypeError( + "can't compare '%s' to '%s'" + % (type(self).__name__, type(other).__name__) + ) + + def __eq__(self, other): + if isinstance(other, FiscalWeek): + return (self._fiscal_year, self._fiscal_week) == ( + other._fiscal_year, + other._fiscal_week, + ) + else: + raise TypeError( + "can't compare '%s' to '%s'" + % (type(self).__name__, type(other).__name__) + ) + + def __ne__(self, other): + if isinstance(other, FiscalWeek): + return (self._fiscal_year, self._fiscal_week) != ( + other._fiscal_year, + other._fiscal_week, + ) + else: + raise TypeError( + "can't compare '%s' to '%s'" + % (type(self).__name__, type(other).__name__) + ) + + def __gt__(self, other): + if isinstance(other, FiscalWeek): + return (self._fiscal_year, self._fiscal_week) > ( + other._fiscal_year, + other._fiscal_week, + ) + else: + raise TypeError( + "can't compare '%s' to '%s'" + % (type(self).__name__, type(other).__name__) + ) + + def __ge__(self, other): + if isinstance(other, FiscalWeek): + return (self._fiscal_year, self._fiscal_week) >= ( + other._fiscal_year, + other._fiscal_week, + ) + else: + raise TypeError( + "can't compare '%s' to '%s'" + % (type(self).__name__, type(other).__name__) + ) + + class FiscalDay(object): """A class representing a single fiscal day.""" diff --git a/test_fiscalyear.py b/test_fiscalyear.py index a64f90f..7a55775 100644 --- a/test_fiscalyear.py +++ b/test_fiscalyear.py @@ -829,6 +829,144 @@ def test_greater_than_equals(self, a, b, c): a >= 1 +class TestFiscalWeek: + @pytest.fixture(scope="class") + def a(self): + return fiscalyear.FiscalWeek(2016, 1) + + @pytest.fixture(scope="class") + def b(self): + return fiscalyear.FiscalWeek(2016, 2) + + @pytest.fixture(scope="class") + def c(self): + return fiscalyear.FiscalWeek("2016", "2") + + @pytest.fixture(scope="class") + def e(self): + return fiscalyear.FiscalWeek(2016, 53) + + @pytest.fixture(scope="class") + def f(self): + return fiscalyear.FiscalWeek(2017, 1) + + def test_basic(self, a): + assert a.fiscal_year == 2016 + assert a.fiscal_week == 1 + + assert a.fiscal_month == 1 + assert a.fiscal_quarter == 1 + + def test_current(self, mocker): + mock_today = mocker.patch.object(fiscalyear.FiscalDate, "today") + mock_today.return_value = fiscalyear.FiscalDate(2016, 10, 1) + current = fiscalyear.FiscalWeek.current() + assert current == fiscalyear.FiscalWeek(2017, 1) + + def test_repr(self, a): + assert repr(a) == "FiscalWeek(2016, 1)" + + def test_str(self, a): + assert str(a) == "FY2016 FW1" + + def test_from_string(self, c): + assert c.fiscal_year == 2016 + assert c.fiscal_week == 2 + + def test_wrong_type(self): + with pytest.raises(TypeError): + fiscalyear.FiscalWeek(2016.5) + + with pytest.raises(TypeError): + fiscalyear.FiscalWeek("hello world") + + def test_out_of_range(self): + with pytest.raises(ValueError): + fiscalyear.FiscalWeek(2016, 0) + + with pytest.raises(ValueError): + fiscalyear.FiscalWeek(2016, -364) + + def test_prev_fiscal_week(self, a, b, f): + assert a == b.prev_fiscal_week + assert a.prev_fiscal_week == fiscalyear.FiscalWeek(2015, 52) + assert f.prev_fiscal_week == fiscalyear.FiscalWeek(2016, 53) + + def test_next_fiscal_week(self, a, b): + assert a.next_fiscal_week == b + + def test_start(self, a, e): + assert a.start == fiscalyear.FiscalYear(a.fiscal_year).start + assert e.start == fiscalyear.FiscalDateTime(2016, 9, 29, 0, 0, 0) + + with fiscalyear.fiscal_calendar(*US_FEDERAL): + assert a.start == datetime.datetime(2015, 10, 1, 0, 0, 0) + + with fiscalyear.fiscal_calendar(*UK_PERSONAL): + assert a.start == datetime.datetime(2016, 4, 6, 0, 0, 0) + + def test_end(self, e): + assert e.end == fiscalyear.FiscalYear(e.fiscal_year).end + + with fiscalyear.fiscal_calendar(*US_FEDERAL): + assert e.end == datetime.datetime(2016, 9, 30, 23, 59, 59) + + with fiscalyear.fiscal_calendar(*UK_PERSONAL): + assert e.end == datetime.datetime(2017, 4, 5, 23, 59, 59) + + def test_contains(self, a, b, c, f): + assert b in c + assert a not in f + + assert fiscalyear.FiscalDateTime(2015, 10, 1, 0, 0, 0) in a + assert datetime.datetime(2015, 10, 1, 0, 0, 0) in a + assert fiscalyear.FiscalDate(2015, 10, 1) in a + assert datetime.date(2015, 10, 1) in a + + assert b in fiscalyear.FiscalMonth(2016, 1) + assert b in fiscalyear.FiscalQuarter(2016, 1) + assert b in fiscalyear.FiscalYear(2016) + + with pytest.raises(TypeError): + "hello world" in a + + def test_less_than(self, a, b): + assert a < b + + with pytest.raises(TypeError): + a < 1 + + def test_less_than_equals(self, a, b, c): + assert a <= b <= c + + with pytest.raises(TypeError): + a <= 1 + + def test_equals(self, b, c): + assert b == c + + with pytest.raises(TypeError): + b == 1 + + def test_not_equals(self, a, b): + assert a != b + + with pytest.raises(TypeError): + a != 1 + + def test_greater_than(self, a, b): + assert b > a + + with pytest.raises(TypeError): + a > 1 + + def test_greater_than_equals(self, a, b, c): + assert c >= b >= a + + with pytest.raises(TypeError): + a >= 1 + + class TestFiscalDay: @pytest.fixture(scope="class") def a(self):