diff options
-rw-r--r-- | CHANGELOG.md | 6 | ||||
-rw-r--r-- | datamaps/__init__.py | 2 | ||||
-rw-r--r-- | datamaps/api/__init__.py | 1 | ||||
-rw-r--r-- | datamaps/api/api.py | 29 | ||||
-rw-r--r-- | datamaps/core/temporal.py | 124 | ||||
-rw-r--r-- | datamaps/plugins/dft/master.py | 111 | ||||
-rw-r--r-- | datamaps/tests/test_api.py | 35 | ||||
-rw-r--r-- | datamaps/tests/test_month.py | 42 | ||||
-rw-r--r-- | pyproject.toml | 2 |
9 files changed, 302 insertions, 50 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f13ed2..ad1a09a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v1.1.6 + +* Added the `Month()` object to `datamaps.api`. You can now assign a month + to a `Master()` object using the function + `datamaps.api.project_data_from_master_month`. + ## v1.1.5 * Additional version bump to bring into line with `bcompiler-engine`. diff --git a/datamaps/__init__.py b/datamaps/__init__.py index 9b102be..1436d8f 100644 --- a/datamaps/__init__.py +++ b/datamaps/__init__.py @@ -1 +1 @@ -__version__ = "1.1.5" +__version__ = "1.1.6" diff --git a/datamaps/api/__init__.py b/datamaps/api/__init__.py index 3f6a35c..947f8a6 100644 --- a/datamaps/api/__init__.py +++ b/datamaps/api/__init__.py @@ -1 +1,2 @@ from .api import project_data_from_master_api as project_data_from_master +from .api import project_data_from_master_month_api as project_data_from_master_month diff --git a/datamaps/api/api.py b/datamaps/api/api.py index 6cc6f4e..072d95f 100644 --- a/datamaps/api/api.py +++ b/datamaps/api/api.py @@ -13,3 +13,32 @@ def project_data_from_master_api(master_file: str, quarter: int, year: int): """ m = Master(Quarter(quarter, year), master_file) return m + + +def project_data_from_master_month_api(master_file: str, month: int, year: int) -> Master: + """Create a Master object directly without the need to explicitly pass + a Month object. + + Args: + master_file (str): the path to a master file + month (int): an integer representing the month + year (int): an integer representing the year + """ + # we need to work out what Quarter we are dealing with from the month + if month in [1, 2, 3]: + quarter = 4 + elif month in [4, 5, 6]: + quarter = 1 + elif month in [7, 8, 9]: + quarter = 2 + elif month in [10, 11, 12]: + quarter = 3 + else: + pass + # TODO: raise exception here + + # from that, we can work out what quarter year we are dealing with + if quarter == 4: + year = year - 1 + m = Master(Quarter(quarter, year), master_file, month) + return m diff --git a/datamaps/core/temporal.py b/datamaps/core/temporal.py index ddb7004..a927b23 100644 --- a/datamaps/core/temporal.py +++ b/datamaps/core/temporal.py @@ -1,4 +1,67 @@ +import calendar import datetime +import itertools +from typing import List + +MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +] + + +class Month: + """An object representing a calendar Month.""" + + _end_ints = { + 1: 31, + 2: 28, + 3: 31, + 4: 30, + 5: 31, + 6: 30, + 7: 31, + 8: 31, + 9: 30, + 10: 31, + 11: 30, + 12: 31, + } + + def __init__(self, month: int, year: int): + self.month_int = month + self.year = year + + @property + def start_date(self): + return datetime.date(self.year, self.month_int, 1) + + @property + def end_date(self): + if self.month_int == 2 and calendar.isleap(self.year): + return datetime.date( + self.year, self.month_int, Month._end_ints[self.month_int] + 1 + ) + else: + return datetime.date( + self.year, self.month_int, Month._end_ints[self.month_int] + ) + + @property + def name(self): + return MONTHS[self.month_int - 1] + + def __repr__(self): + return f"Month({self.name})" class FinancialYear: @@ -26,27 +89,23 @@ class FinancialYear: self.end_date = self.q4.end_date @property - def q1(self) -> datetime.date: - """Quarter 1 as a :py:class:`datetime.date` object - """ + def q1(self): + """Quarter 1 as a :py:class:`datetime.date` object""" return self._q1 @property def q2(self): - """Quarter 2 as a :py:class:`datetime.date` object - """ + """Quarter 2 as a :py:class:`datetime.date` object""" return self._q2 @property def q3(self): - """Quarter 3 as a :py:class:`datetime.date` object - """ + """Quarter 3 as a :py:class:`datetime.date` object""" return self._q3 @property def q4(self): - """Quarter 4 as a :py:class:`datetime.date` object - """ + """Quarter 4 as a :py:class:`datetime.date` object""" return self._q4 def __str__(self): @@ -55,34 +114,35 @@ class FinancialYear: def _generate_quarters(self): self.quarters = [Quarter(x, self.year) for x in range(1, 5)] - def __repr__(self): return f"FinancialYear({self.year})" class Quarter: - """An object representing a financial quarter. This is mainly required for building - a :py:class:`core.master.Master` object. + """An object representing a financial quarter. + + This is mainly required for building a :py:class:`core.master.Master` + object. Args: quarter (int): e.g.1, 2, 3 or 4 year (int): e.g. 2013 """ + _start_months = { - 1: (4, 'April'), - 2: (7, 'July'), - 3: (10, 'October'), - 4: (1, 'January') + 1: (4, "April"), + 2: (7, "July"), + 3: (10, "October"), + 4: (1, "January"), } _end_months = { - 1: (6, 'June', 30), - 2: (9, 'September', 30), - 3: (12, 'December', 31), - 4: (3, 'March', 31), + 1: (6, "June", 30), + 2: (9, "September", 30), + 3: (12, "December", 31), + 4: (3, "March", 31), } - def __init__(self, quarter: int, year: int) -> None: if isinstance(quarter, int) and (quarter >= 1 and quarter <= 4): @@ -93,10 +153,27 @@ class Quarter: if isinstance(year, int) and (year in range(1950, 2100)): self.year = year else: - raise ValueError("Year must be between 1950 and 2100 - surely that will do?") + raise ValueError( + "Year must be between 1950 and 2100 - surely that will do?" + ) self.start_date = self._start_date(self.quarter, self.year) self.end_date = self._end_date(self.quarter, self.year) + self.months: List[Month] = [] + self._populate_months() + + def _populate_months(self): + months_ints = [] + start_int = Quarter._start_months[self.quarter][0] + strt = itertools.count(start_int) + for x in range(3): + months_ints.append(next(strt)) + for m in months_ints: + if self.quarter == 4: + year = self.year + 1 + else: + year = self.year + self.months.append(Month(m, year)) def __str__(self): return f"Q{self.quarter} {str(self.year)[2:]}/{str(self.year + 1)[2:]}" @@ -116,6 +193,5 @@ class Quarter: @property def fy(self): - """Return a :py:class:`core.temporal.FinancialYear` object. - """ + """Return a :py:class:`core.temporal.FinancialYear` object.""" return FinancialYear(self.year) diff --git a/datamaps/plugins/dft/master.py b/datamaps/plugins/dft/master.py index 744cc27..324b63d 100644 --- a/datamaps/plugins/dft/master.py +++ b/datamaps/plugins/dft/master.py @@ -1,23 +1,23 @@ -import re import datetime import logging +import re import unicodedata from pathlib import Path -from typing import List, Tuple, Iterable, Optional, Any +from typing import Any, Iterable, List, Optional, Tuple +from datamaps.core.temporal import Quarter from datamaps.plugins.dft.portfolio import project_data_from_master from datamaps.process.cleansers import DATE_REGEX_4 -from datamaps.core.temporal import Quarter - from openpyxl import load_workbook -logger = logging.getLogger('bcompiler.utils') +logger = logging.getLogger("bcompiler.utils") class ProjectData: """ ProjectData class """ + def __init__(self, d: dict) -> None: """ :py:func:`OrderedDict` is easiest to get from project_data_from_master[x] @@ -37,7 +37,7 @@ class ProjectData: data = [item for item in self._data.items() if key in item[0]] if not data: raise KeyError("Sorry, there is no matching data") - return (data) + return data def pull_keys(self, input_iter: Iterable, flat=False) -> List[Tuple[Any, ...]]: """ @@ -46,19 +46,54 @@ class ProjectData: """ if flat is True: # search and replace troublesome EN DASH character - xs = [item for item in self._data.items() - for i in input_iter if item[0].strip().replace(unicodedata.lookup('EN DASH'), unicodedata.lookup('HYPHEN-MINUS')) == i] + xs = [ + item + for item in self._data.items() + for i in input_iter + if item[0] + .strip() + .replace( + unicodedata.lookup("EN DASH"), unicodedata.lookup("HYPHEN-MINUS") + ) + == i + ] xs = [_convert_str_date_to_object(x) for x in xs] - ts = sorted(xs, key=lambda x: input_iter.index(x[0].strip().replace(unicodedata.lookup('EN DASH'), unicodedata.lookup('HYPHEN-MINUS')))) + ts = sorted( + xs, + key=lambda x: input_iter.index( + x[0] + .strip() + .replace( + unicodedata.lookup("EN DASH"), + unicodedata.lookup("HYPHEN-MINUS"), + ) + ), + ) ts = [item[1] for item in ts] return ts else: - xs = [item for item in self._data.items() - for i in input_iter if item[0].replace(unicodedata.lookup('EN DASH'), unicodedata.lookup('HYPHEN-MINUS')) == i] - xs = [item for item in self._data.items() - for i in input_iter if item[0] == i] + xs = [ + item + for item in self._data.items() + for i in input_iter + if item[0].replace( + unicodedata.lookup("EN DASH"), unicodedata.lookup("HYPHEN-MINUS") + ) + == i + ] + xs = [ + item for item in self._data.items() for i in input_iter if item[0] == i + ] xs = [_convert_str_date_to_object(x) for x in xs] - ts = sorted(xs, key=lambda x: input_iter.index(x[0].replace(unicodedata.lookup('EN DASH'), unicodedata.lookup('HYPHEN-MINUS')))) + ts = sorted( + xs, + key=lambda x: input_iter.index( + x[0].replace( + unicodedata.lookup("EN DASH"), + unicodedata.lookup("HYPHEN-MINUS"), + ) + ), + ) return ts def __repr__(self): @@ -69,7 +104,7 @@ def _convert_str_date_to_object(d_str: tuple) -> Tuple[str, Optional[datetime.da try: if re.match(DATE_REGEX_4, d_str[1]): try: - ds = d_str[1].split('-') + ds = d_str[1].split("-") return (d_str[0], datetime.date(int(ds[0]), int(ds[1]), int(ds[2]))) except TypeError: return d_str @@ -114,12 +149,21 @@ class Master: filename = m1.filename ..etc """ - def __init__(self, quarter: Quarter, path: str) -> None: + + def __init__( + self, quarter: Quarter, path: str, declared_month: Optional[int] = None + ) -> None: self._quarter = quarter + self._declared_month = declared_month self.path = path + if self._declared_month: + idxs = [x.month_int for x in self._quarter.months] + m_idx = idxs.index(self._declared_month) + self.year = self._quarter.months[m_idx].year + else: + self.year = self._quarter.year self._data = project_data_from_master(self.path) self._project_titles = [item for item in self.data.keys()] - self.year = self._quarter.year def __getitem__(self, project_name): return ProjectData(self._data[project_name]) @@ -158,16 +202,37 @@ class Master: return self._quarter @property - def filename(self): - """The filename of the master xlsx file, e.g. ``master_1_2017.xlsx``. + def month(self): """ + Returns the ``Month`` object associated with the ``Master``. + """ + months = { + 1: "January", + 2: "February", + 3: "March", + 4: "April", + 5: "May", + 6: "June", + 7: "July", + 8: "August", + 9: "September", + 10: "October", + 11: "November", + 12: "December", + } + return [ + m for m in self.quarter.months if m.name == months[self._declared_month] + ][0] + + @property + def filename(self): + """The filename of the master xlsx file, e.g. ``master_1_2017.xlsx``.""" p = Path(self.path) return p.name @property def projects(self): - """A list of project titles derived from the master xlsx. - """ + """A list of project titles derived from the master xlsx.""" return self._project_titles def duplicate_keys(self, to_log=None): @@ -194,7 +259,9 @@ class Master: dups.add(x) if to_log and len(dups) > 0: for x in dups: - logger.warning(f"{self.path} contains duplicate key: \"{x}\". Masters cannot contain duplicate keys. Rename them.") + logger.warning( + f'{self.path} contains duplicate key: "{x}". Masters cannot contain duplicate keys. Rename them.' + ) return True elif to_log and len(dups) == 0: logger.info(f"No duplicate keys in {self.path}") diff --git a/datamaps/tests/test_api.py b/datamaps/tests/test_api.py index 273f984..295f1fb 100644 --- a/datamaps/tests/test_api.py +++ b/datamaps/tests/test_api.py @@ -1,10 +1,41 @@ import datetime -from ..api import project_data_from_master +from ..api import project_data_from_master, project_data_from_master_month +from ..core.temporal import Month def test_get_project_data(master): master = project_data_from_master(master, 1, 2019) - assert master["Chutney Bridge.xlsm"]["Project/Programme Name"] == "Chutney Bridge Ltd" + assert ( + master["Chutney Bridge.xlsm"]["Project/Programme Name"] == "Chutney Bridge Ltd" + ) assert master.quarter.quarter == 1 assert master.quarter.end_date == datetime.date(2019, 6, 30) + + +def test_get_project_data_using_month(master): + m = project_data_from_master_month(master, 7, 2021) + m2 = project_data_from_master_month(master, 8, 2021) + m3 = project_data_from_master_month(master, 9, 2021) + m4 = project_data_from_master_month(master, 10, 2021) + m5 = project_data_from_master_month(master, 2, 2021) # this is q4 + assert m["Chutney Bridge.xlsm"]["Project/Programme Name"] == "Chutney Bridge Ltd" + assert isinstance(m.month, Month) + assert m.month.name == "July" + assert m2.month.name == "August" + assert m3.month.name == "September" + assert m4.month.name == "October" + assert m.quarter.quarter == 2 + assert m2.quarter.quarter == 2 + assert m3.quarter.quarter == 2 + assert m4.quarter.quarter == 3 + assert m.quarter.end_date == datetime.date(2021, 9, 30) + assert m2.quarter.end_date == datetime.date(2021, 9, 30) + assert m3.quarter.end_date == datetime.date(2021, 9, 30) + assert m4.quarter.end_date == datetime.date(2021, 12, 31) + + # year should be different if using this func + assert m.year == 2021 + assert m2.year == 2021 + assert m3.year == 2021 + assert m5.year == 2021 diff --git a/datamaps/tests/test_month.py b/datamaps/tests/test_month.py new file mode 100644 index 0000000..de53545 --- /dev/null +++ b/datamaps/tests/test_month.py @@ -0,0 +1,42 @@ +import datetime + +import pytest + +from ..core.temporal import Month, Quarter + + +def test_quarter_month_objects_leap_years(): + q = Quarter(4, 2023) + assert q.months[1].start_date == datetime.date(2024, 2, 1) + assert q.months[1].end_date == datetime.date(2024, 2, 29) + + +def test_quarter_objects_have_months(): + q = Quarter(1, 2021) + q2 = Quarter(4, 2021) + assert isinstance(q.months[0], Month) + assert q.months[0].start_date == datetime.date(2021, 4, 1) + assert q.months[1].start_date == datetime.date(2021, 5, 1) + assert q.months[2].start_date == datetime.date(2021, 6, 1) + with pytest.raises(IndexError): + q.months[4].start_date == datetime.date(2021, 6, 1) + assert q2.months[0].start_date == datetime.date(2022, 1, 1) + assert q2.months[1].start_date == datetime.date(2022, 2, 1) + assert q2.months[2].start_date == datetime.date(2022, 3, 1) + with pytest.raises(IndexError): + q2.months[4].start_date == datetime.date(2022, 6, 1) + + +def test_month(): + m1 = Month(1, 2021) + assert m1.name == "January" + assert m1.year == 2021 + m2 = Month(9, 2021) + assert m2.name == "September" + assert m2.start_date == datetime.date(2021, 9, 1) + assert m2.end_date == datetime.date(2021, 9, 30) + # test leap year + m3 = Month(2, 2024) + m4 = Month(2, 2028) + assert m3.end_date == datetime.date(2024, 2, 29) + assert m4.end_date == datetime.date(2028, 2, 29) diff --git a/pyproject.toml b/pyproject.toml index 4241d53..87d347a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools", "wheel"] [tool.poetry] name = "datamaps" -version = "1.1.4" +version = "1.1.6" homepage = "https://github.com/hammerheadlemon/datamaps" repository = "https://github.com/hammerheadlemon/datamaps" license = "MIT" |