Coverage for bzfs_main/period_anchors.py: 100%
105 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 18:44 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-19 18:44 +0000
1# Copyright 2024 Wolfgang Hoschek AT mac DOT com
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14#
15"""Utility that snaps datetimes to calendar periods.
17Anchors specify offsets within yearly, monthly and smaller cycles. These values are used by
18``round_datetime_up_to_duration_multiple`` to snap datetimes to the next boundary. Keeping anchors in a dataclass simplifies
19argument handling and makes the rounding logic reusable.
20"""
22from __future__ import (
23 annotations,
24)
25import argparse
26import calendar
27import dataclasses
28from dataclasses import (
29 dataclass,
30 field,
31)
32from datetime import (
33 datetime,
34 timedelta,
35)
36from typing import (
37 Final,
38 final,
39)
41# constants:
42METADATA_YEAR: Final = {"min": 1, "max": 9999, "help": None}
43METADATA_MONTH: Final = {"min": 1, "max": 12, "help": "The month within a year"}
44METADATA_WEEKDAY: Final = {"min": 0, "max": 6, "help": "The weekday within a week: 0=Sunday, 1=Monday, ..., 6=Saturday"}
45METADATA_DAY: Final = {"min": 1, "max": 31, "help": "The day within a month"}
46METADATA_HOUR: Final = {"min": 0, "max": 23, "help": "The hour within a day"}
47METADATA_MINUTE: Final = {"min": 0, "max": 59, "help": "The minute within an hour"}
48METADATA_SECOND: Final = {"min": 0, "max": 59, "help": "The second within a minute"}
49METADATA_MILLISECOND: Final = {"min": 0, "max": 999, "help": "The millisecond within a second"}
50METADATA_MICROSECOND: Final = {"min": 0, "max": 999, "help": "The microsecond within a millisecond"}
53@dataclass(frozen=True)
54@final
55class PeriodAnchors:
56 """Anchor offsets used to round datetimes up to periodic boundaries; Immutable."""
58 # The anchors for a given duration unit are computed as follows:
59 # yearly: Count from anchor.yearly_* vars
60 yearly_year: int = field(default=2025, metadata={"min": 1, "max": 9999, "help": "The anchor year of multi-year periods"})
61 yearly_month: int = field(default=1, metadata=METADATA_MONTH) # 1 <= x <= 12
62 yearly_monthday: int = field(default=1, metadata=METADATA_DAY) # 1 <= x <= 31
63 yearly_hour: int = field(default=0, metadata=METADATA_HOUR) # 0 <= x <= 23
64 yearly_minute: int = field(default=0, metadata=METADATA_MINUTE) # 0 <= x <= 59
65 yearly_second: int = field(default=0, metadata=METADATA_SECOND) # 0 <= x <= 59
67 # monthly: Count months from anchor.monthly_* vars
68 monthly_year: int = field(default=2025, metadata=METADATA_YEAR) # 1 <= x <= 9999
69 monthly_month: int = field(default=1, metadata={"min": 1, "max": 12, "help": "The anchor month of multi-month periods"})
70 monthly_monthday: int = field(default=1, metadata=METADATA_DAY) # 1 <= x <= 31
71 monthly_hour: int = field(default=0, metadata=METADATA_HOUR) # 0 <= x <= 23
72 monthly_minute: int = field(default=0, metadata=METADATA_MINUTE) # 0 <= x <= 59
73 monthly_second: int = field(default=0, metadata=METADATA_SECOND) # 0 <= x <= 59
75 # weekly: Count weeks from the first weekly_weekday on or after January 1 of weekly_year
76 weekly_year: int = field(default=2025, metadata=METADATA_YEAR) # 1 <= x <= 9999
77 weekly_weekday: int = field(default=0, metadata=METADATA_WEEKDAY) # 0 <= x <= 6 (0=Sunday, ..., 6=Saturday)
78 weekly_hour: int = field(default=0, metadata=METADATA_HOUR) # 0 <= x <= 23
79 weekly_minute: int = field(default=0, metadata=METADATA_MINUTE) # 0 <= x <= 59
80 weekly_second: int = field(default=0, metadata=METADATA_SECOND) # 0 <= x <= 59
82 # daily: Count days from January 1 of daily_year
83 daily_year: int = field(default=2025, metadata=METADATA_YEAR) # 1 <= x <= 9999
84 daily_hour: int = field(default=0, metadata=METADATA_HOUR) # 0 <= x <= 23
85 daily_minute: int = field(default=0, metadata=METADATA_MINUTE) # 0 <= x <= 59
86 daily_second: int = field(default=0, metadata=METADATA_SECOND) # 0 <= x <= 59
88 # hourly: Anchor(dt) = midnight of dt + anchor.hourly_* vars
89 hourly_minute: int = field(default=0, metadata=METADATA_MINUTE) # 0 <= x <= 59
90 hourly_second: int = field(default=0, metadata=METADATA_SECOND) # 0 <= x <= 59
92 # minutely: Anchor(dt) = midnight of dt + anchor.minutely_* vars
93 minutely_second: int = field(default=0, metadata=METADATA_SECOND) # 0 <= x <= 59
95 # secondly: Anchor(dt) = midnight of dt + anchor.secondly_* vars
96 secondly_millisecond: int = field(default=0, metadata=METADATA_MILLISECOND) # 0 <= x <= 999
98 # millisecondly: Anchor(dt) = midnight of dt + anchor.millisecondly_* vars
99 millisecondly_microsecond: int = field(default=0, metadata=METADATA_MICROSECOND) # 0 <= x <= 999
101 @classmethod
102 def parse(cls, args: argparse.Namespace) -> PeriodAnchors:
103 """Creates a ``PeriodAnchors`` instance from parsed CLI arguments."""
104 kwargs: dict[str, int] = {f.name: getattr(args, f.name) for f in dataclasses.fields(cls)}
105 return cls(**kwargs)
107 def round_datetime_up_to_duration_multiple(self, dt: datetime, duration_amount: int, duration_unit: str) -> datetime:
108 """Given a timezone-aware datetime and a duration, returns a datetime (in the same timezone) that is greater than or
109 equal to dt, and rounded up (ceiled) and snapped to an anchor plus a multiple of the duration.
111 The snapping is done relative to the anchors object and the rules defined therein.
112 Supported units: "millisecondly", "secondly", "minutely", "hourly", "daily", "weekly", "monthly", "yearly".
113 If dt is already exactly on a boundary (i.e. exactly on a multiple), it is returned unchanged.
114 Examples:
115 Default hourly anchor is midnight
116 14:00:00, 1 hours --> 14:00:00
117 14:05:01, 1 hours --> 15:00:00
118 15:05:01, 1 hours --> 16:00:00
119 16:05:01, 1 hours --> 17:00:00
120 23:55:01, 1 hours --> 00:00:00 on the next day
121 14:05:01, 2 hours --> 16:00:00
122 15:00:00, 2 hours --> 16:00:00
123 15:05:01, 2 hours --> 16:00:00
124 16:00:00, 2 hours --> 16:00:00
125 16:05:01, 2 hours --> 18:00:00
126 23:55:01, 2 hours --> 00:00:00 on the next day
127 """
129 if duration_amount == 0:
130 return dt
132 assert duration_amount > 0
133 period: timedelta
134 anchor: datetime
135 if duration_unit == "millisecondly":
136 anchor = dt.replace(hour=0, minute=0, second=0, microsecond=self.millisecondly_microsecond)
137 period = timedelta(milliseconds=duration_amount)
139 elif duration_unit == "secondly":
140 anchor = dt.replace(hour=0, minute=0, second=0, microsecond=self.secondly_millisecond * 1000)
141 period = timedelta(seconds=duration_amount)
143 elif duration_unit == "minutely":
144 anchor = dt.replace(hour=0, minute=0, second=self.minutely_second, microsecond=0)
145 period = timedelta(minutes=duration_amount)
147 elif duration_unit == "hourly":
148 anchor = dt.replace(hour=0, minute=self.hourly_minute, second=self.hourly_second, microsecond=0)
149 period = timedelta(hours=duration_amount)
151 elif duration_unit == "daily":
152 anchor = dt.replace(
153 year=self.daily_year,
154 month=1,
155 day=1,
156 hour=self.daily_hour,
157 minute=self.daily_minute,
158 second=self.daily_second,
159 microsecond=0,
160 )
161 period = timedelta(days=duration_amount)
163 elif duration_unit == "weekly":
164 anchor = dt.replace(
165 year=self.weekly_year,
166 month=1,
167 day=1,
168 hour=self.weekly_hour,
169 minute=self.weekly_minute,
170 second=self.weekly_second,
171 microsecond=0,
172 )
173 # Convert cron weekday (0=Sunday, 1=Monday, ..., 6=Saturday) to Python's weekday (0=Monday, ..., 6=Sunday)
174 target_py_weekday: int = (self.weekly_weekday - 1) % 7
175 diff_days: int = (target_py_weekday - anchor.weekday()) % 7
176 anchor = anchor + timedelta(days=diff_days)
177 period = timedelta(weeks=duration_amount)
179 elif duration_unit == "monthly":
180 months_since_anchor: int = (dt.year - self.monthly_year) * 12 + dt.month - self.monthly_month
181 offset_months: int = -months_since_anchor % duration_amount
182 anchor = self._add_months(dt, offset_months)
183 if anchor < dt:
184 anchor = self._add_months(dt, offset_months + duration_amount)
185 return anchor
187 elif duration_unit == "yearly":
188 offset_years: int = (self.yearly_year - dt.year) % duration_amount
189 anchor = self._add_years(dt, offset_years)
190 if anchor < dt:
191 anchor = self._add_years(dt, offset_years + duration_amount)
192 return anchor
194 else:
195 raise ValueError(f"Unsupported duration unit: {duration_unit}")
197 # "millisecondly", "secondly", "minutely", "hourly", "daily", "weekly"
198 delta: timedelta = dt - anchor
199 period_micros: int = (period.days * 86400 + period.seconds) * 1_000_000 + period.microseconds
200 delta_micros: int = (delta.days * 86400 + delta.seconds) * 1_000_000 + delta.microseconds
201 remainder: int = delta_micros % period_micros
202 if remainder == 0:
203 return dt
204 return dt + timedelta(microseconds=period_micros - remainder)
206 def _add_months(self, dt: datetime, months: int) -> datetime:
207 """Build a boundary offset from dt's month, clamping the configured day only against the target month's end."""
208 total_month: int = dt.month - 1 + months
209 new_year: int = dt.year + total_month // 12
210 new_month: int = total_month % 12 + 1
211 last_day: int = calendar.monthrange(new_year, new_month)[1] # last valid day of the target month
212 return dt.replace(
213 year=new_year,
214 month=new_month,
215 day=min(self.monthly_monthday, last_day),
216 hour=self.monthly_hour,
217 minute=self.monthly_minute,
218 second=self.monthly_second,
219 microsecond=0,
220 )
222 def _add_years(self, dt: datetime, years: int) -> datetime:
223 """Build a boundary offset from dt's year using yearly anchors, clamping the configured day to the target month's end."""
224 new_year: int = dt.year + years
225 last_day: int = calendar.monthrange(new_year, self.yearly_month)[1] # last valid day of the target month
226 return dt.replace(
227 year=new_year,
228 month=self.yearly_month,
229 day=min(self.yearly_monthday, last_day),
230 hour=self.yearly_hour,
231 minute=self.yearly_minute,
232 second=self.yearly_second,
233 microsecond=0,
234 )