From 87bf11d4d2dd4f57d26bf7bad772202a2dc22aac Mon Sep 17 00:00:00 2001 From: Stefan Hendricks Date: Mon, 22 Jul 2024 13:42:18 +0200 Subject: [PATCH 1/4] Add exclude rules (only monthly implemented at the time) and improve documentation --- dateperiods/__init__.py | 219 ++++++++++++++++++++++++++---------- tests/test_exclude_rules.py | 71 ++++++++++++ 2 files changed, 230 insertions(+), 60 deletions(-) create mode 100644 tests/test_exclude_rules.py diff --git a/dateperiods/__init__.py b/dateperiods/__init__.py index 2707449..35a4792 100644 --- a/dateperiods/__init__.py +++ b/dateperiods/__init__.py @@ -5,7 +5,7 @@ """ import calendar -from typing import Dict, List, Tuple, Union +from typing import Dict, List, Tuple, Union, Optional import cftime import numpy as np @@ -31,6 +31,51 @@ __all__ = ["DatePeriod", "PeriodIterator", "DateDefinition", "DateDuration"] +class ExcludeRuleNotSet(object): + """ + Default + """ + + def __init__(self) -> None: + pass + + def __contains__(self, dt: datetime) -> Literal[False]: + return False + + def __repr__(self) -> str: + return "Exclude Rule Not Set" + + +class ExcludeMonth(object): + """ + Rule to exclude certain months from the period iterator. + + Usage: + + >>> exclude_rule = ExcludeMonth([5, 6, 7, 8, 9]) + >>> datetime(2015, 6, 10) in exclude_rule + True + + :param months: Month number of list of month numbers to exclude + from iterator + + :raises ValueError: Invalid month number input + """ + + def __init__(self, months: Union[int, List[int]]) -> None: + months = sorted(months) if isinstance(months, list) else [months] + invalid_input = any(m not in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] for m in months) + if invalid_input: + raise ValueError(f"input does contain invalid month number: {months} [1, ..., 12]") + self.months = months + + def __contains__(self, dt: datetime) -> bool: + return dt.month in self.months + + def __repr__(self) -> str: + return f"ExcludeMonths: {self.months}" + + class DatePeriod(object): """ Container for managing periods of dates and their segmentation into sub-periods @@ -40,8 +85,9 @@ def __init__( self, tcs_def: Union[List[int], "datetime", "date"], tce_def: Union[List[int], "datetime", "date"] = None, - unit: str = None, - calendar_name: str = None + exclude_rule: Optional[ExcludeMonth] = None, + unit: Optional[str] = None, + calendar_name: Optional[str] = None ) -> None: """ Establish a period defined by the start (tcs) and end (tce) of the time coverage. @@ -56,6 +102,7 @@ def __init__( :param tcs_def: The definition for the start of the time coverage. :param tce_def: The definition for the end of the time coverage. + :param unit: :param calendar_name: """ @@ -63,7 +110,7 @@ def __init__( # Process the input date definitions self._unit = unit if unit is not None else "seconds since 1970-01-01" self._calendar = calendar_name if calendar_name is not None else "standard" - + self.exclude_rule = exclude_rule if isinstance(exclude_rule, ExcludeMonth) else ExcludeRuleNotSet() # if time coverage end is omitted, use time coverage start as the definition # of the period. if tce_def is None: @@ -84,8 +131,8 @@ def __init__( def get_id(self, dt_fmt: str = "%Y%m%dT%H%M%S") -> str: """ Returns an id of the period with customizable date format - :param dt_fmt: - :return: + :param dt_fmt: + :return: """ return f"{self.tcs.dt.strftime(dt_fmt)}_{self.tce.dt.strftime(dt_fmt)}" @@ -101,7 +148,7 @@ def get_segments(self, duration_type: str, crop_to_period=False) -> "PeriodItera :param duration_type: :param crop_to_period: - :return: + :return: """ # Input validation @@ -228,7 +275,7 @@ def calendar(self) -> str: def __repr__(self) -> str: output = "DatePeriod:\n" - for field in ["tcs", "tce"]: + for field in ["tcs", "tce", "exclude_rule"]: output += "%12s: %s" % (field, getattr(self, field).dt) output += "\n" return output @@ -257,6 +304,7 @@ def __init__(self, msg = msg.format(segment_duration, ",".join(self.valid_segment_duration)) raise ValueError(msg) self.segment_duration = segment_duration + self.exclude_rule = base_period.exclude_rule # Construct the list of periods self._segment_list = [] @@ -311,12 +359,14 @@ def _get_segment_list(self) -> None: methods for each duraction type. All of these methods have to return a list of DatePeriod objects :return: """ - funcs = dict(day=self.get_day_segments, - isoweek=self.get_isoweek_segments, - month=self.get_month_segments, - year=self.get_year_segments) + funcs = dict( + day=self.get_day_segments, + isoweek=self.get_isoweek_segments, + month=self.get_month_segments, + year=self.get_year_segments + ) base_tcs, base_tce = self.base_period.tcs.dt, self.base_period.tce.dt - self._segment_list.extend(funcs[self.segment_duration](base_tcs, base_tce)) + self._segment_list.extend(funcs[self.segment_duration](base_tcs, base_tce, self.base_period.exclude_rule)) def filter_month(self, month_nums: List[int]) -> None: """ @@ -347,37 +397,52 @@ def filter_func(s): return s.tcs.month not in month_nums and s.tce.month not in self._segment_list = filter_segments @staticmethod - def days_list(start_dt: "datetime", end_dt: "datetime") -> List[List[int]]: + def days_list(start_dt: "datetime", end_dt: "datetime") -> List[datetime]: """ Return a list of all days (tuples of year, month, day) of all days between to datetimes - :param start_dt: datetime.datetime - :param end_dt: datetime.datetime + :param start_dt: `datetime.datetime` + :param end_dt: `datetime.datetime` :return: list """ return [ - [d.year, d.month, d.day] + datetime(d.year, d.month, d.day) for d in rrule(DAILY, dtstart=start_dt, until=end_dt) ] @staticmethod - def months_list(start_dt: "datetime", end_dt: "datetime") -> List[List[int]]: + def months_list( + start_dt: "datetime", + end_dt: "datetime", + exclude_rule: "ExcludeMonth" + ) -> List[List[int]]: """ Return a list of all month (tuples of year, month) of all months between to datetimes + of the specific datetime is not in exclude rule + :param start_dt: datetime.datetime :param end_dt: datetime.datetime + :param exclude_rule: + :return: list """ start = datetime(start_dt.year, start_dt.month, 1) end = datetime(end_dt.year, end_dt.month, 1) - return [[d.year, d.month] for d in rrule(MONTHLY, dtstart=start, until=end)] + return [[d.year, d.month] for d in rrule(MONTHLY, dtstart=start, until=end) if d not in exclude_rule] @staticmethod - def years_list(start_dt: "datetime", end_dt: "datetime") -> List[int]: + def years_list( + start_dt: "datetime", + end_dt: "datetime", + ) -> List[int]: """ Return a list of all month (tuples of year, month) of all months between to datetimes + + NOTE: For yearly iterations, the currently applied monthly exclude rule does not apply. + :param start_dt: datetime.datetime :param end_dt: datetime.datetime + :return: list """ start = datetime(start_dt.year, 1, 1) @@ -385,21 +450,37 @@ def years_list(start_dt: "datetime", end_dt: "datetime") -> List[int]: return [d.year for d in rrule(YEARLY, dtstart=start, until=end)] @classmethod - def get_day_segments(cls, start_dt: "datetime", end_dt: "datetime") -> List["DatePeriod"]: + def get_day_segments( + cls, + start_dt: "datetime", + end_dt: "datetime", + exclude_rule: "ExcludeMonth" + ) -> List["DatePeriod"]: """ Return a list of daily DatePeriods between to datetimes + :param start_dt: datetime.datetime :param end_dt: datetime.datetime - :return: list + :param exclude_rule: Rule to determine period in exclusion list + + :return: list of day segments """ - return [DatePeriod(d, d) for d in cls.days_list(start_dt, end_dt)] + return [DatePeriod(d, d) for d in cls.days_list(start_dt, end_dt) if d not in exclude_rule] @classmethod - def get_isoweek_segments(cls, start_dt: "datetime", end_dt: "datetime") -> List["DatePeriod"]: + def get_isoweek_segments( + cls, + start_dt: "datetime", + end_dt: "datetime", + exclude_rule: "ExcludeMonth" + ) -> List["DatePeriod"]: """ Return a list of isoweek DatePeriods between to datetimes - :param start_dt: datetime.datetime - :param end_dt: datetime.datetime + + :param start_dt: `datetime.datetime` + :param end_dt: `datetime.datetime` + :param exclude_rule: Rule to determine period in exclusion list + :return: list """ @@ -410,7 +491,7 @@ def get_isoweek_segments(cls, start_dt: "datetime", end_dt: "datetime") -> List[ # Isoweek segments are always Monday and start_dt might not be one # -> compute the offset start_day = list_of_days[0] - weekday_offset = datetime(*start_day).isoweekday() - 1 + weekday_offset = start_day.isoweekday() - 1 segments = [] for i in np.arange(n_weeks): @@ -418,28 +499,48 @@ def get_isoweek_segments(cls, start_dt: "datetime", end_dt: "datetime") -> List[ d1 = start_dt + relativedelta(days=int(i * 7) - weekday_offset) d2 = start_dt + relativedelta(days=int((i + 1) * 7 - 1) - weekday_offset) + prd = DatePeriod([d1.year, d1.month, d1.day], [d2.year, d2.month, d2.day]) + + if prd.center in exclude_rule: + continue + # store start and stop day for each week - segments.append(DatePeriod([d1.year, d1.month, d1.day], [d2.year, d2.month, d2.day])) + segments.append(prd) return segments @classmethod - def get_month_segments(cls, start_dt: "datetime", end_dt: "datetime") -> List["DatePeriod"]: + def get_month_segments( + cls, + start_dt: "datetime", + end_dt: "datetime", + exclude_month: "ExcludeMonth" + ) -> List["DatePeriod"]: """ Return a list of monthly DatePeriods between to datetimes - :param start_dt: datetime.datetime - :param end_dt: datetime.datetime + + :param start_dt: Start date of Period + :param end_dt: End date of Period + :param exclude_month: List of month to exclude from iterator + :return: list """ - return [DatePeriod(m, m) for m in cls.months_list(start_dt, end_dt)] + return [DatePeriod(m, m) for m in cls.months_list(start_dt, end_dt, exclude_month)] @classmethod - def get_year_segments(cls, start_dt: "datetime", end_dt: "datetime") -> List["DatePeriod"]: + def get_year_segments( + cls, + start_dt: "datetime", + end_dt: "datetime", + *_ # exclude rule (not used for yearly segments + ) -> List["DatePeriod"]: """ - Return a list of monthly DatePeriods between to datetimes - :param start_dt: datetime.datetime - :param end_dt: datetime.datetime - :return: list + Return a list of yearly DatePeriods between to datetimes + + :param start_dt: `datetime.datetime` + :param end_dt: `datetime.datetime` + + :return: list of yearly DatePeriod's """ return [DatePeriod([y], [y]) for y in cls.years_list(start_dt, end_dt)] @@ -467,6 +568,7 @@ def __repr__(self) -> str: output += "\n" fields = [ "segment_duration", + "exclude_rule", "n_periods" ] for field in fields: @@ -478,34 +580,30 @@ def __repr__(self) -> str: class DateDefinition(object): """ - Container for a start or end date with corresponding properties, with the functionality - to either define a date or generate from year, year+month, year+month+day lists + Creates date container from various input formats. Valid date definitions are: + 1. datetime.datetime + 2. datetime.date + 3. List/tuple of integers: [year, [month], [day]] + In case only year or only month is passed (option 3), than the date will be constructed + based on the value of tce_or_tcs: + - if day is omitted, the date will be set as first (tcs) or last (tce) day of the month + - if day and month are omitted, the date will be set to the first (tcs) or last (tce) + day of the year + + :param date_def: Variable defining the date. + :param tcs_or_tce: Flag indicating whether date marks beginning of end of + period definition. Must be one of `tcs` or `tce` (determines method + of autocompletion). + :param unit: `cftime` compliant time unit (default: `seconds since 1970-01-01`). + :param calendar_name: `cftime` compliant calendar name (default: `standard`). """ def __init__(self, date_def: Union[List[int], "datetime", "date"], - tcs_or_tce: str, - unit: str = None, - calendar_name: str = None + tcs_or_tce: Literal["tcs", "tce"], + unit: Optional[str] = None, + calendar_name: Optional[str] = None ) -> None: - """ - Creates date container from various input formats. Valid date definitions are: - 1. datetime.datetime - 2. datetime.date - 3. List/tuple of integers: [year, [month], [day]] - In case only year or only month is passed (option 3), than the date will be constructed - based on the value of tce_or_tcs: - - if day is omitted, the date will be set as first (tcs) or last (tce) day of the month - - if day and month are omitted, the date will be set to the first (tcs) or last (tce) - day of the year - - :param date_def: - :param tcs_or_tce: - :param unit: - :param calendar_name: - """ - - # Store args self._date_def = date_def if tcs_or_tce in self.valid_tcs_or_tce_values: self._tcs_or_tce = tcs_or_tce @@ -757,6 +855,7 @@ def __init__(self, ) -> None: """ Compute the duration between two dates + :param tcs: DateDefinition :param tce: DateDefinition """ diff --git a/tests/test_exclude_rules.py b/tests/test_exclude_rules.py new file mode 100644 index 0000000..d2e0eeb --- /dev/null +++ b/tests/test_exclude_rules.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +""" +Test the exclude rules +""" + +__author__ = "Stefan Hendricks " + + +import unittest + +from context import dateperiods + + +class ExcludeMonthInputTestSuite(unittest.TestCase): + """ Testing the segmentation of Periods """ + + def test_invalid_input_args(self): + self.assertRaises(ValueError, dateperiods.ExcludeMonth, 0) + self.assertRaises(ValueError, dateperiods.ExcludeMonth, 13) + self.assertRaises(ValueError, dateperiods.ExcludeMonth, [0, 1, 2]) + self.assertRaises(ValueError, dateperiods.ExcludeMonth, ["a"]) + self.assertRaises(ValueError, dateperiods.ExcludeMonth, "a") + self.assertRaises(ValueError, dateperiods.ExcludeMonth, [None]) + + def test_input_args_to_list(self): + exclude_rule = dateperiods.ExcludeMonth(1) + self.assertTrue(exclude_rule.months == [1]) + + def test_input_args_sort(self): + test_list = [7, 6] + exclude_rule = dateperiods.ExcludeMonth(test_list) + self.assertTrue(exclude_rule.months == sorted(test_list)) + + def test_correct_instance(self): + test_list = [7, 6] + exclude_rule = dateperiods.ExcludeMonth(test_list) + period = dateperiods.DatePeriod([2023, 10], exclude_rule=exclude_rule) + self.assertTrue(period.exclude_rule == exclude_rule) + + def test_correct_default_value(self): + period = dateperiods.DatePeriod([2023, 10]) + self.assertIsInstance(period.exclude_rule, dateperiods.ExcludeRuleNotSet) + + +class ExcludeMonthApplyTestSuite(unittest.TestCase): + """ Testing the segmentation of Periods """ + + def test_monthly_periods(self): + exclude_rule = dateperiods.ExcludeMonth([5, 6, 7, 8, 9]) + period = dateperiods.DatePeriod([2023, 10], [2024, 10], exclude_rule=exclude_rule) + segments = period.get_segments("month") + months = [segment.center.month for segment in segments] + self.assertTrue(months == [10, 11, 12, 1, 2, 3, 4, 10]) + + def test_daily_periods(self): + exclude_rule = dateperiods.ExcludeMonth([5, 6, 7, 8, 9]) + period = dateperiods.DatePeriod([2024, 4, 16], [2024, 10, 15], exclude_rule=exclude_rule) + segments = period.get_segments("day") + self.assertTrue(segments.n_periods == 30) + + def test_isoweekly_periods(self): + exclude_rule = dateperiods.ExcludeMonth([5, 6, 7, 8, 9]) + period = dateperiods.DatePeriod([2024, 4, 15], [2024, 10, 13], exclude_rule=exclude_rule) + segments = period.get_segments("isoweek") + print(segments) + self.assertTrue(segments.n_periods == 4) + + +if __name__ == '__main__': + unittest.main() From 9a9b20e2a08c7e920954665facae26bc996c3277 Mon Sep 17 00:00:00 2001 From: Stefan Hendricks Date: Mon, 22 Jul 2024 14:13:37 +0200 Subject: [PATCH 2/4] Update documentation and changelog --- CHANGELOG.md | 6 ++++++ README.md | 29 +++++++++++++++++++++++++++-- setup.py | 4 ++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cd45b2..92e5975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # History of changes +## [1.3.0] 2024-07-22 + +### Added +- (Monthly) exclusion rules from `PeriodIterator` +- python 3.12 tests + ## [1.2.1] 2024-04-09 ### Fixed diff --git a/README.md b/README.md index 15f04a1..8075dc5 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,15 @@ ![Python package](https://github.com/shendric/dateperiods/workflows/Python%20package/badge.svg?branch=master) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://choosealicense.com/licenses/mit/) -[![Python Version](https://img.shields.io/badge/python-3.7,_3.8,_3.9,_3.10,_3.11-blue)](https://www.python.org/downloads/) +[![Python Version](https://img.shields.io/badge/python-3.7,_3.8,_3.9,_3.10,_3.11,_3.12-blue)](https://www.python.org/downloads/) ## About dateperiods The package `dateperiods` is meant to make iterating over certain periods between two dates easy. The main `DatePeriod` class takes two dates into account, the start of the time coverage with a daily granularity, but microsecond resolution: + +### Basic Usage + ```python >>> from dateperiods import DatePeriod >>> DatePeriod([2020, 10, 1], [2021, 4, 30]) @@ -68,7 +71,7 @@ DateDuration: The period can be segmented into defined a duration type (day, isoweek, month, year): ```python ->>> dp.get_segments("month) +>>> dp.get_segments("month") PeriodIterator: tcs: 2020-10-01 00:00:00 tce: 2021-04-30 23:59:59.999999 @@ -89,6 +92,27 @@ The return value of `get_segments()` is a python iterator with each item is a `D '2021-04-01 00:00:00 till 2021-04-30 23:59:59.999999'] ``` +### Exclusion Rules + +A `DatePeriod` can be defined with rules that define if segments should be +excluded from the `PeriodIterator`. E.g. + +```python +>>> from dateperiods import ExcludeMonth +>>> period_exc = DatePeriod([2020, 9, 1], [2021, 5, 31], exclude_rules=ExcludeMonth([5, 9])) +``` +will ensure that the month of September 2020 and May 2021, will not be part +of the monthly sub-periods: + +```python +>>> period_exc.get_segments("month") +PeriodIterator: + tcs: 2020-10-01 00:00:00 + tce: 2021-04-30 23:59:59.999999 + segment_duration: month + n_periods: 7 +``` + ## Installation See the [release page](https://github.com/shendric/dateperiods/releases) of this project for the latest version of `dateperiods` and install either from the main branch @@ -115,3 +139,4 @@ Copyright (c) 2009, Gerhard Weis - [ ] Add merge (`+`) operator for `DatePeriods` - [ ] Add option to `DatePeriods.get_segments` to ensure sub-periods are fully within base period +- [ ] Add custom segments lengths using `dateutil-rrulestr()` (e.g. `RRULE:FREQ=DAILY;INTERVAL=14` for two week periods) diff --git a/setup.py b/setup.py index 2045524..bdfdc86 100644 --- a/setup.py +++ b/setup.py @@ -10,12 +10,12 @@ setup( name='dateperiods', - version='1.2.0', + version='1.3.0', description='Periods between dates in python', long_description=readme, author='Stefan Hendricks', author_email='stefan.hendricks@awi.de', - url='https://github.com/shendric/dateperiods', + url='https://github.com/pysiral/dateperiods', license=license_txt, install_requires=install_requires, packages=find_packages(exclude=('tests',)), From c17d9b42a737ab8e9c621aeb1d52876a2f33e91a Mon Sep 17 00:00:00 2001 From: Stefan Hendricks Date: Mon, 22 Jul 2024 14:16:29 +0200 Subject: [PATCH 3/4] Update pythonpackage.yml Add python 3.12 test support --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index cd71dc4..372f2ba 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 From 52a7b288b438261eeb46bd30b6cc20853b5b1469 Mon Sep 17 00:00:00 2001 From: Stefan Hendricks Date: Mon, 22 Jul 2024 14:20:43 +0200 Subject: [PATCH 4/4] Update citation.CFF --- citation.CFF | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/citation.CFF b/citation.CFF index 91127d1..8477456 100644 --- a/citation.CFF +++ b/citation.CFF @@ -5,5 +5,5 @@ authors: given-names: Stefan orcid: https://orcid.org/0000-0002-1412-3146 title: "dateperiods: Periods between dates in python" -version: v1.1.0 -date-released: 2022-02-07 +version: v1.3.0 +date-released: 2024-07-22