From 269f0ae2070dc59caf84671dcb49b2cfea5fc1b6 Mon Sep 17 00:00:00 2001 From: Erlend Dalen Date: Thu, 5 Sep 2024 15:05:51 +0200 Subject: [PATCH] A minimal implementation of quarters so that we can use quarters in idtags. --- tests/test_ttcal_quarter.py | 93 ++++++++++++++++ ttcal/__init__.py | 4 +- ttcal/quarter.py | 210 ++++++++++++++++++++++++++++++++++++ 3 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 tests/test_ttcal_quarter.py create mode 100644 ttcal/quarter.py diff --git a/tests/test_ttcal_quarter.py b/tests/test_ttcal_quarter.py new file mode 100644 index 0000000..95f42e3 --- /dev/null +++ b/tests/test_ttcal_quarter.py @@ -0,0 +1,93 @@ +from datetime import date, datetime +import ttcal +import pytest + + +@pytest.fixture +def quarters(): + return [ + ttcal.Quarter(2005, 1), + ttcal.Quarter(), + ttcal.Quarter(2025, 4), + ] + + +def test_stringification(quarters): + assert str(quarters[0]) == '1' + + +def test_timetuple(quarters): + assert quarters[0].timetuple() == datetime(2005, 1, 1, 0, 0, 0) + + +def test_range(quarters): + assert len(list(quarters[0].range())) == 90 + + +def test_between_tuple(quarters): + a, b = quarters[0].between_tuple() + assert a < b + + +def test_middle(quarters): + assert quarters[0].middle == (ttcal.Day(2005, 2, 14)) + + +def test_unicode(quarters): + assert repr(quarters[0]) == 'Q(20051)' + assert str(quarters[0]) == '1' + + +def test_month(quarters): + assert quarters[0].Month == ttcal.Month(2005, 1) + + +def test_quarter(quarters): + assert int(quarters[0]) == int(quarters[0].Quarter) + + +def test_hash(quarters): + assert hash(quarters[0]) == hash(ttcal.Quarter(2005, 1)) + + +def test_from_idtag(quarters): + """Test of the from_idtag method. + """ + assert quarters[0].from_idtag('q20051') == quarters[0] + + +def test_idtag(quarters): + """Test of the idtag method. + """ + assert quarters[2].idtag() == 'q20254' + + +def test_add(quarters): + """Test of the __add__ method. + """ + assert quarters[0] + 2 == ttcal.Quarter(2005, 3) + assert 2 + quarters[0] == ttcal.Quarter(2005, 3) + + +def test_sub(quarters): + """Test of the __sub__ method. + """ + assert quarters[2] - 3 == ttcal.Quarter(2025, 1) + + +def test_prev(quarters): + """Test of the prev method. + """ + assert quarters[2].prev() == ttcal.Quarter(2025, 3) + + +def test_next(quarters): + """Test of the next method. + """ + assert quarters[0].next() == ttcal.Quarter(2005, 2) + + +def test_format(quarters): + assert quarters[0].format('q') == '1' + assert quarters[0].format('Q') == '2005Q1' + assert quarters[0].format() == '2005Q1' diff --git a/ttcal/__init__.py b/ttcal/__init__.py index 79d5c71..92f267a 100644 --- a/ttcal/__init__.py +++ b/ttcal/__init__.py @@ -8,17 +8,19 @@ from .month import Month from .week import Week from .year import Year +from .quarter import Quarter def from_idtag(idtag): """Return a class from idtag. """ assert len(idtag) > 1 - assert idtag[0] in 'wdmy' + assert idtag[0] in 'wdmqy' return { 'w': Week, 'd': Day, 'm': Month, + 'q': Quarter, 'y': Year, }[idtag[0]].from_idtag(idtag) diff --git a/ttcal/quarter.py b/ttcal/quarter.py new file mode 100644 index 0000000..6846a63 --- /dev/null +++ b/ttcal/quarter.py @@ -0,0 +1,210 @@ +""" +quarter class. +""" +import datetime + +from . import Month +from .calfns import chop, rangecmp, rangetuple +from .day import Day +from .year import Year + + +class Quarter: # pylint:disable=too-many-public-methods + """A single quarter. + """ + def __init__(self, year=None, quarter=None): + super().__init__() + # if quarter is None: + if year is None: + year = datetime.date.today().year + if quarter is None: + quarter = 1 + self.year = year + self.quarter = quarter + self.months = Year(year).quarters()[self.quarter-1] + + def __int__(self): + return self.quarter + + def range(self): + """Return an iterator for the range of `self`. + """ + return self.dayiter() + + def rangetuple(self): + """Return a pair of datetime objects containing quarter + (in a half-open interval). + """ + return self.first.datetime(), (self + 1).first.datetime() + + # def __lt__(self, other): + # if isinstance(other, int): + # return self.quarter < other + # othr = rangetuple(other) + # if othr is other: + # return False + # return rangecmp(self.rangetuple(), othr) < 0 + # + # def __le__(self, other): + # if isinstance(other, int): + # return self.quarter <= other + # othr = rangetuple(other) + # if othr is other: + # return False + # return rangecmp(self.rangetuple(), othr) <= 0 + + def __eq__(self, other): + if isinstance(other, int): + return self.quarter == other + othr = rangetuple(other) + if othr is other: + return False + return rangecmp(self.rangetuple(), othr) == 0 + + def __ne__(self, other): + return not self == other + + # def __gt__(self, other): + # if isinstance(other, int): + # return self.quarter > other + # othr = rangetuple(other) + # if othr is other: + # return False + # return rangecmp(self.rangetuple(), othr) > 0 + # + # def __ge__(self, other): + # if isinstance(other, int): + # return self.quarter >= other + # othr = rangetuple(other) + # if othr is other: + # return False + # return rangecmp(self.rangetuple(), othr) >= 0 + + def timetuple(self): + """Returns a datetime at 00:00:00 on January 1st. + """ + d = datetime.date(*self.first.datetuple()) + t = datetime.time() + return datetime.datetime.combine(d, t) + + @property + def first(self): + # The negative indexing here is due to the fact that the + # first quarter is list element 0 and so on. + return self.Year.quarters()[self.quarter-1][0].first + + @property + def last(self): + return self.Year.quarters()[self.quarter-1][2].last + + def between_tuple(self): # pylint:disable=E0213 + """Return a tuple of datetimes that is convenient for sql + `between` queries. + """ + return (self.first.datetime(), + (self.last + 1).datetime() - datetime.timedelta(seconds=1)) + + @property + def Year(self): + """Return the year (for api completeness). + """ + return Year(self.year) + + @property + def Month(self): + """For orthogonality in the api. + """ + return self.months[0] + + @property + def middle(self): + """Return the day that splits the date range in half. + """ + middle = (self.first.toordinal() + self.last.toordinal()) // 2 + return Day.fromordinal(middle) + + # def timetuple(self): + # """Create timetuple from datetuple. + # (to interact with datetime objects). + # """ + # d = datetime.date(*self.datetuple()) + # t = datetime.time() + # return datetime.datetime.combine(d, t) + + def __repr__(self): + return f'Q({self.year}{self.quarter})' + + def __str__(self): # pragma: nocover + return str(self.quarter) + + @property + def Quarter(self): + """Return the quarter (for api completeness). + """ + return self + + @classmethod + def from_idtag(cls, tag): + """quarter tags have the lower-case letter y + the four digit quarter, + eg. q20081. + """ + y = int(tag[1:5]) + q = int(tag[5]) + return cls(year=y, quarter=q) + + def idtag(self): + """quarter tags have the lower-case letter y + the four digit quarter, + eg. y2008. + """ + return f'q{self.year}{self.quarter}' + + def __add__(self, n): + """Add n quarters to self. + """ + return Quarter(self.year, self.quarter + n) + + def __radd__(self, n): + return self + n + + def __sub__(self, n): + return self + (-n) + + # rsub doesn't make sense + + def prev(self): + """Previous quarter. + """ + return self - 1 + + def next(self): + """Next quarter. + """ + return self + 1 + + def __hash__(self): + return self.quarter + + def dayiter(self): + """Yield all days in all months in quarter. + """ + for m in self.months: + yield from m.days() + + def _format(self, fmtchars): + # http://blog.tkbe.org/archive/date-filter-cheat-sheet/ + for ch in fmtchars: + if ch == 'q': + yield str(self.quarter) + elif ch == 'Q': + yield f'{str(self.year)}Q{self.quarter}' + else: + yield ch + + def format(self, fmt=None): + """Format according to format string. Default format is + four-digit-year and quearter-number. + """ + if fmt is None: + fmt = "Q" + tmp = list(self._format(list(fmt))) + return ''.join(tmp)