Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add FiscalMonth #12

Merged
merged 19 commits into from
Dec 13, 2020
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions fiscalyear.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ def __contains__(self, item):
return self == item
elif isinstance(item, FiscalQuarter):
return self._fiscal_year == item.fiscal_year
elif isinstance(item, FiscalMonth):
return self._fiscal_year == item.fiscal_year
elif isinstance(item, datetime.datetime):
return self.start <= item <= self.end
elif isinstance(item, datetime.date):
Expand Down Expand Up @@ -455,6 +457,8 @@ def __contains__(self, item):
"""
if isinstance(item, FiscalQuarter):
return self == item
if isinstance(item, FiscalMonth):
return self.start <= item.start and item.end <= self.end
elif isinstance(item, datetime.datetime):
return self.start <= item <= self.end
elif isinstance(item, datetime.date):
Expand Down Expand Up @@ -603,6 +607,211 @@ def __ge__(self, other):
type(self).__name__, type(other).__name__))


class FiscalMonth(object):
"""A class representing a single fiscal month."""

__slots__ = ['_fiscal_year', '_fiscal_month']

def __new__(cls, fiscal_year, fiscal_month):
"""Constructor.

:param fiscal_year: The fiscal year
:type fiscal_year: int or str
:returns: A newly constructed FiscalMonth object
:rtype: FiscalMonth
:raises TypeError: If fiscal_year or fiscal_month is not
an int or int-like string
:raises ValueError: If fiscal_year or fiscal_month is out of range
"""
fiscal_year = _check_year(fiscal_year)
fiscal_month = _check_month(fiscal_month)

self = super(FiscalMonth, cls).__new__(cls)
self._fiscal_year = fiscal_year
self._fiscal_month = fiscal_month
return self

@classmethod
def current(cls):
"""Alternative constructor. Returns the current FiscalMonth.

:returns: A newly constructed FiscalMonth object
:rtype: FiscalMonth
"""
today = FiscalDate.today()
return cls(today.fiscal_year, today.fiscal_month)

def __repr__(self):
"""Convert to formal string, for repr().

>>> fm = FiscalMonth(2017, 1)
>>> repr(fm)
'FiscalMonth(2017,1)'
nicmendoza marked this conversation as resolved.
Show resolved Hide resolved
"""
return '%s(%d, %d)' % (self.__class__.__name__,
self._fiscal_year,
self._fiscal_month)

def __str__(self):
"""Convert to informal string, for str().

>>> fm = FiscalMonth(2017, 1)
>>> str(fy)
nicmendoza marked this conversation as resolved.
Show resolved Hide resolved
'FY2017 FM1'
"""
return 'FY%d FM%d' % (self._fiscal_year, self._fiscal_month)

# 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: FiscalYear, FiscalQuarter, FiscalDateTime,
nicmendoza marked this conversation as resolved.
Show resolved Hide resolved
datetime, FiscalDate, or date
:rtype: bool
"""
if isinstance(item, FiscalMonth):
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_month(self):
""":returns: The fiscal month
:rtype: int
"""
return self._fiscal_month

@property
def start(self):
""":returns: Start of the fiscal month
:rtype: FiscalDateTime
"""

month = ((self._fiscal_month - START_MONTH) % 12 + 6) % 12 + 1

if month >= START_MONTH:
year = self._fiscal_year - 1
else:
year = self._fiscal_year
nicmendoza marked this conversation as resolved.
Show resolved Hide resolved

return FiscalDateTime(year, month, START_DAY)

@property
def end(self):
""":returns: End of the fiscal year
nicmendoza marked this conversation as resolved.
Show resolved Hide resolved
:rtype: FiscalDateTime
"""
# Find the start of the next fiscal quarter
next_start = self.next_fiscal_month.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 year(self):
""":returns: The fiscal year for the month
:rtype: FiscalYear
"""
return FiscalYear(self._fiscal_year)
nicmendoza marked this conversation as resolved.
Show resolved Hide resolved

@property
def prev_fiscal_month(self):
""":returns: The previous fiscal month
:rtype: FiscalMonth
"""
fiscal_year = self._fiscal_year
fiscal_month = self._fiscal_month - 1
if fiscal_month == 0:
fiscal_year -= 1
fiscal_month = 12

return FiscalMonth(fiscal_year, fiscal_month)

@property
def next_fiscal_month(self):
""":returns: The next fiscal month
:rtype: FiscalMonth
"""
fiscal_year = self._fiscal_year
fiscal_month = self._fiscal_month + 1
if fiscal_month == 13:
fiscal_year += 1
fiscal_month = 1

return FiscalMonth(fiscal_year, fiscal_month)

# Comparisons of FiscalMonth objects with other

def __lt__(self, other):
if isinstance(other, FiscalMonth):
return ((self._fiscal_year, self._fiscal_month) <
(other._fiscal_year, other._fiscal_month))
else:
raise TypeError("can't compare '%s' to '%s'" % (
type(self).__name__, type(other).__name__))

def __le__(self, other):
if isinstance(other, FiscalMonth):
return ((self._fiscal_year, self._fiscal_month) <=
(other._fiscal_year, other._fiscal_month))
else:
raise TypeError("can't compare '%s' to '%s'" % (
type(self).__name__, type(other).__name__))

def __eq__(self, other):
if isinstance(other, FiscalMonth):
return ((self._fiscal_year, self._fiscal_month) ==
(other._fiscal_year, other._fiscal_month))
else:
raise TypeError("can't compare '%s' to '%s'" % (
type(self).__name__, type(other).__name__))

def __ne__(self, other):
if isinstance(other, FiscalMonth):
return ((self._fiscal_year, self._fiscal_month) !=
(other._fiscal_year, other._fiscal_month))
else:
raise TypeError("can't compare '%s' to '%s'" % (
type(self).__name__, type(other).__name__))

def __gt__(self, other):
if isinstance(other, FiscalMonth):
return ((self._fiscal_year, self._fiscal_month) >
(other._fiscal_year, other._fiscal_month))
else:
raise TypeError("can't compare '%s' to '%s'" % (
type(self).__name__, type(other).__name__))

def __ge__(self, other):
if isinstance(other, FiscalMonth):
return ((self._fiscal_year, self._fiscal_month) >=
(other._fiscal_year, other._fiscal_month))
else:
raise TypeError("can't compare '%s' to '%s'" % (
type(self).__name__, type(other).__name__))


class _FiscalBase:
"""The base class for FiscalDate and FiscalDateTime that
provides the following common attributes in addition to
Expand Down
142 changes: 141 additions & 1 deletion test_fiscalyear.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,10 @@ def c(self):
def d(self):
return fiscalyear.FiscalQuarter(2017, 2)

@pytest.fixture(scope='class')
def e(self):
return fiscalyear.FiscalMonth(2017, 1)

def test_basic(self, a):
assert a.fiscal_year == 2016

Expand Down Expand Up @@ -391,10 +395,11 @@ def test_q3(self, a):
def test_q4(self, a):
assert a.q4 == fiscalyear.FiscalQuarter(2016, 4)

def test_contains(self, a, b, c, d):
def test_contains(self, a, b, c, d, e):
assert b in c
assert d not in a
assert d in b
assert e in b

assert fiscalyear.FiscalDateTime(2016, 1, 1, 0, 0, 0) in a
assert datetime.datetime(2016, 1, 1, 0, 0, 0) in a
Expand Down Expand Up @@ -648,6 +653,141 @@ def test_greater_than_equals(self, a, b, c, d, e, f, g):
a >= 1


class TestFiscalMonth:

@pytest.fixture(scope='class')
def a(self):
return fiscalyear.FiscalMonth(2016, 1)

@pytest.fixture(scope='class')
def b(self):
return fiscalyear.FiscalMonth(2016, 2)

@pytest.fixture(scope='class')
def c(self):
return fiscalyear.FiscalMonth('2016', '2')

@pytest.fixture(scope='class')
def d(self):
return fiscalyear.FiscalQuarter(2016, 1)

@pytest.fixture(scope='class')
def e(self):
return fiscalyear.FiscalMonth(2016, 12)

@pytest.fixture(scope='class')
def f(self):
return fiscalyear.FiscalQuarter(2017, 1)

def test_basic(self, a):
assert a.fiscal_year == 2016
assert a.fiscal_month == 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.FiscalMonth.current()
assert current == fiscalyear.FiscalMonth(2017, 1)

def test_repr(self, a):
assert repr(a) == 'FiscalMonth(2016, 1)'

def test_str(self, a):
assert str(a) == 'FY2016 FM1'

def test_from_string(self, c):
assert c.fiscal_year == 2016

def test_wrong_type(self):
with pytest.raises(TypeError):
fiscalyear.FiscalMonth(2016.5)

with pytest.raises(TypeError):
fiscalyear.FiscalMonth('hello world')

def test_out_of_range(self):
with pytest.raises(ValueError):
fiscalyear.FiscalMonth(2016, 0)

with pytest.raises(ValueError):
fiscalyear.FiscalMonth(2016, -12)

def test_prev_fiscal_year(self, a, b):
assert a == b.prev_fiscal_month
assert a.prev_fiscal_month == fiscalyear.FiscalMonth(2015, 12)

def test_next_fiscal_year(self, a, b):
assert a.next_fiscal_month == b

def test_start(self, a, e):
assert a.start == a.year.start
assert e.start == fiscalyear.FiscalDateTime(2016, 9, 1, 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(2015, 4, 6, 0, 0, 0)

def test_end(self, e):
assert e.end == e.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(2016, 4, 5, 23, 59, 59)

def test_contains(self, a, b, c, d, f):
assert b in c
assert a not in f
assert b in d

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

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 TestFiscalDate:

@pytest.fixture(scope='class')
Expand Down