clockwork 0.6.1__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {clockwork-0.6.1 → clockwork-0.7.0}/.python-version +1 -1
  2. {clockwork-0.6.1 → clockwork-0.7.0}/PKG-INFO +4 -3
  3. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/__init__.py +1 -1
  4. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/decorators.py +12 -13
  5. clockwork-0.7.0/clockwork/month_end.py +243 -0
  6. clockwork-0.7.0/clockwork/quarter_end.py +314 -0
  7. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/taskmaster/_task.py +4 -4
  8. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/taskmaster/file_monitor.py +1 -1
  9. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/taskmaster/logger.py +3 -3
  10. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/taskmaster/taskmaster.py +7 -3
  11. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/taskmaster/utils.py +1 -1
  12. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/timestamp/_decorators.py +40 -40
  13. clockwork-0.7.0/clockwork/timestamp/core.py +995 -0
  14. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/utils.py +9 -8
  15. {clockwork-0.6.1 → clockwork-0.7.0}/pyproject.toml +4 -3
  16. clockwork-0.7.0/uv.lock +274 -0
  17. clockwork-0.6.1/clockwork/month_end.py +0 -187
  18. clockwork-0.6.1/clockwork/quarter_end.py +0 -256
  19. clockwork-0.6.1/clockwork/timestamp/core.py +0 -769
  20. clockwork-0.6.1/uv.lock +0 -258
  21. {clockwork-0.6.1 → clockwork-0.7.0}/.gitignore +0 -0
  22. {clockwork-0.6.1 → clockwork-0.7.0}/LICENSE +0 -0
  23. {clockwork-0.6.1 → clockwork-0.7.0}/README.md +0 -0
  24. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/constants.py +0 -0
  25. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/taskmaster/__init__.py +0 -0
  26. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/taskmaster/_scheduler.py +0 -0
  27. {clockwork-0.6.1 → clockwork-0.7.0}/clockwork/timestamp/__init__.py +0 -0
@@ -1 +1 @@
1
- 3.13
1
+ 3.13
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: clockwork
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: Toolkit for time-related operations including scheduling, logging, date manipulation, and more.
5
5
  Project-URL: repository, https://github.com/zteinck/clockwork
6
6
  Project-URL: homepage, https://github.com/zteinck/clockwork
@@ -10,9 +10,10 @@ License-File: LICENSE
10
10
  Requires-Python: >=3.13
11
11
  Requires-Dist: holidays>=0.94
12
12
  Requires-Dist: numpy>=2.4.4
13
- Requires-Dist: oddments>=0.9.1
13
+ Requires-Dist: oddments>=0.10.0
14
14
  Requires-Dist: pandas>=3.0.2
15
15
  Requires-Dist: polars>=1.39.3
16
+ Requires-Dist: python-dateutil>=2.9.0.post0
16
17
  Requires-Dist: schedule>=1.2.2
17
18
  Description-Content-Type: text/markdown
18
19
 
@@ -8,5 +8,5 @@ from .utils import (
8
8
  temporal_format_to_regex,
9
9
  )
10
10
 
11
- __version__ = '0.6.1'
11
+ __version__ = '0.7.0'
12
12
  __author__ = 'Zachary Einck <zacharyeinck@gmail.com>'
@@ -1,7 +1,7 @@
1
1
  from functools import wraps
2
2
  from time import perf_counter
3
3
 
4
- from oddments import validate_value
4
+ from oddments import Validator
5
5
 
6
6
  from .utils import format_duration
7
7
 
@@ -25,7 +25,7 @@ def print_duration(indents=1, in_place=True):
25
25
 
26
26
  Returns
27
27
  ------------
28
- out : any
28
+ result : any
29
29
  The decorated method's return value.
30
30
  '''
31
31
 
@@ -95,19 +95,18 @@ def print_duration(indents=1, in_place=True):
95
95
 
96
96
 
97
97
  # validate decorator arguments
98
- validate_value(
99
- value=indents,
100
- name='indents',
98
+ (
99
+ Validator(
101
100
  types=int,
102
101
  min_value=0,
103
- min_inclusive=True
102
+ min_inclusive=True,
104
103
  )
105
-
106
- validate_value(
107
- value=in_place,
108
- name='in_place',
109
- types=bool,
104
+ .validate(
105
+ indents=indents
110
106
  )
107
+ )
108
+
109
+ Validator(types=bool).validate(in_place=in_place)
111
110
 
112
111
  if self.verbose:
113
112
  words = [
@@ -119,13 +118,13 @@ def print_duration(indents=1, in_place=True):
119
118
  print_status()
120
119
  start_time = perf_counter()
121
120
 
122
- out = func(self, *args, **kwargs)
121
+ result = func(self, *args, **kwargs)
123
122
 
124
123
  if self.verbose:
125
124
  duration = perf_counter() - start_time
126
125
  print_status(duration)
127
126
 
128
- return out
127
+ return result
129
128
 
130
129
  return wrapper
131
130
 
@@ -0,0 +1,243 @@
1
+ import datetime
2
+
3
+ from oddments import Validator, UNSET
4
+
5
+ from .timestamp import Timestamp
6
+ from .constants import MONTHS_IN_YEAR
7
+
8
+
9
+ class MonthEnd(Timestamp):
10
+ '''
11
+ Description
12
+ --------------------
13
+ Month end date.
14
+
15
+ Class Attributes
16
+ --------------------
17
+ _increment : int
18
+ Number of months to increment during offsets.
19
+
20
+ Instance Attributes
21
+ --------------------
22
+ None
23
+ '''
24
+
25
+ #╭-------------------------------------------------------------------------╮
26
+ #| Class Attributes |
27
+ #╰-------------------------------------------------------------------------╯
28
+
29
+ repr_format = '%Y-%m-%d'
30
+ _increment = 1
31
+
32
+
33
+ #╭-------------------------------------------------------------------------╮
34
+ #| Initialize Instance |
35
+ #╰-------------------------------------------------------------------------╯
36
+
37
+ def __init__(self, *args, **kwargs):
38
+ super().__init__(*args, **kwargs)
39
+
40
+
41
+ #╭-------------------------------------------------------------------------╮
42
+ #| Properties |
43
+ #╰-------------------------------------------------------------------------╯
44
+
45
+ @property
46
+ def long_label(self):
47
+ return self.to_string('%Y-%m-%d')
48
+
49
+
50
+ @property
51
+ def compact_label(self):
52
+ return self.to_string('%Y-%m')
53
+
54
+
55
+ @property
56
+ def short_label(self):
57
+ return self.to_string('%b')
58
+
59
+
60
+ @property
61
+ def is_year_end(self):
62
+ return self.month == MONTHS_IN_YEAR
63
+
64
+
65
+ @property
66
+ def relative_offset(self):
67
+ return self._compute_relative_offset()
68
+
69
+
70
+ #╭-------------------------------------------------------------------------╮
71
+ #| Instance Methods |
72
+ #╰-------------------------------------------------------------------------╯
73
+
74
+ def offset(self, periods):
75
+ ''' offsets instance by desired number of periods '''
76
+ Validator(types=int).validate(periods=periods)
77
+ offset = self.relative_offset + periods
78
+ result = self._spawn(offset=offset, target_tz=self.tz)
79
+ return result
80
+
81
+
82
+ def _offset(self, year, month, offset):
83
+ total_months = self._total_months(
84
+ years=year,
85
+ months=(month + offset),
86
+ )
87
+
88
+ y, m = divmod(total_months, MONTHS_IN_YEAR)
89
+
90
+ if m == 0:
91
+ return y - 1, MONTHS_IN_YEAR
92
+
93
+ return y, m
94
+
95
+
96
+ def _compute_relative_offset(self):
97
+ '''
98
+ Description
99
+ ------------
100
+ Returns the number of periods the instance is offset relative to
101
+ the most recent period end. Periods are defined by the '_increment'
102
+ class attribute.
103
+
104
+ Returns
105
+ ------------
106
+ q : int
107
+ Number of offset periods.
108
+ '''
109
+ a, b = (
110
+ self._total_months(
111
+ years=obj.year,
112
+ months=obj.month,
113
+ )
114
+ for obj in (
115
+ self,
116
+ self._spawn(
117
+ offset=0,
118
+ target_tz=self.tz,
119
+ )
120
+ )
121
+ )
122
+
123
+ q, r = divmod(a - b, self._increment)
124
+
125
+ if r != 0:
126
+ raise AssertionError(
127
+ f'Unexpected remainder: {r}'
128
+ )
129
+
130
+ return int(q)
131
+
132
+
133
+ def _validate(self):
134
+ ''' raises an error if the instance fails validation '''
135
+ msg = self._find_error()
136
+
137
+ if msg is not None:
138
+ raise ValueError(
139
+ f'{self.__class__.__name__} instance is invalid because {msg}'
140
+ )
141
+
142
+
143
+ def _find_error(self):
144
+ if not self.is_last_day_of_month:
145
+ return (
146
+ f'{self.day} is not the last day ({self.last_day_of_month}) '
147
+ f'of {self.month_name} {self.year}'
148
+ )
149
+
150
+ if not self.is_normalized:
151
+ return (
152
+ 'the time component must be normalized (i.e. all zoroes), '
153
+ f'got: {self.time}'
154
+ )
155
+
156
+
157
+ def _init_dt(
158
+ self,
159
+ source,
160
+ target_tz,
161
+ offset,
162
+ year=None,
163
+ month=None,
164
+ **kwargs
165
+ ):
166
+ '''
167
+ Parameters
168
+ ------------
169
+ source : None | any
170
+ A value representing a month end date. Must be None if 'year' or
171
+ 'month' arguments are provided.
172
+ year : int
173
+ The calendar year of the month end date.
174
+ month : int
175
+ The calendar month of the month end date (1 to 12).
176
+ offset : int
177
+ Number of months to shift from the base month end. Base month end
178
+ defaults to the most recently completed month end when no other
179
+ parameters are provided. Use positive values to move forward in
180
+ time and negative values to move backward in time.
181
+ kwargs : dict
182
+ Additional keyword arguments are forwarded to the Timestamp
183
+ constructor.
184
+ '''
185
+
186
+ has_source = source is not None
187
+ has_year_or_month = year is not None or month is not None
188
+
189
+ if has_source:
190
+ if has_year_or_month:
191
+ raise ValueError(
192
+ "Both 'year' and 'month' must be None when 'source' is "
193
+ "not None."
194
+ )
195
+
196
+ self._dt = self._resolve_dt(
197
+ source=source,
198
+ target_tz=target_tz,
199
+ offset=offset,
200
+ **kwargs
201
+ )
202
+
203
+ if offset != 0:
204
+ self._dt = self.offset(periods=offset).to_datetime()
205
+
206
+ else:
207
+ _target_tz = None if target_tz is UNSET else target_tz
208
+
209
+ if has_year_or_month:
210
+ year, month = (
211
+ self._ensure_int(value=v, name=k)
212
+ for k, v in {
213
+ 'year': year,
214
+ 'month': month,
215
+ }.items()
216
+ )
217
+ self._validate_month(month)
218
+
219
+ else:
220
+ now = datetime.datetime.now(tz=_target_tz)
221
+
222
+ year, month = self._get_prior_month(
223
+ year=now.year,
224
+ month=now.month,
225
+ )
226
+
227
+ year, month = self._offset(
228
+ year=year,
229
+ month=month,
230
+ offset=(offset * self._increment),
231
+ )
232
+
233
+ day = self.days_in_month(year, month)
234
+
235
+ self._dt = datetime.datetime(
236
+ year=year,
237
+ month=month,
238
+ day=day,
239
+ tzinfo=_target_tz,
240
+ )
241
+
242
+ self._dt = self._normalize(self._dt)
243
+ self._validate()
@@ -0,0 +1,314 @@
1
+ import datetime
2
+ import re
3
+
4
+ from oddments import Validator, UNSET
5
+ import polars as pl
6
+
7
+ from .month_end import MonthEnd
8
+ from .constants import MONTHS_IN_YEAR
9
+
10
+
11
+ class QuarterEnd(MonthEnd):
12
+ '''
13
+ Description
14
+ --------------------
15
+ Quarter end date.
16
+
17
+ Class Attributes
18
+ --------------------
19
+ _scheme : tuple
20
+ Quarter end months.
21
+
22
+ Instance Attributes
23
+ --------------------
24
+ None
25
+ '''
26
+
27
+ #╭-------------------------------------------------------------------------╮
28
+ #| Class Attributes |
29
+ #╰-------------------------------------------------------------------------╯
30
+
31
+ _increment = 3
32
+ _scheme = (3, 6, 9, 12)
33
+
34
+
35
+ #╭-------------------------------------------------------------------------╮
36
+ #| Initialize Instance |
37
+ #╰-------------------------------------------------------------------------╯
38
+
39
+ def __init__(self, *args, **kwargs):
40
+ super().__init__(*args, **kwargs)
41
+
42
+
43
+ #╭-------------------------------------------------------------------------╮
44
+ #| Properties |
45
+ #╰-------------------------------------------------------------------------╯
46
+
47
+ @property
48
+ def scheme(self):
49
+ return self._scheme
50
+
51
+
52
+ @property
53
+ def long_label(self):
54
+ return f'{self.year}Q{self.quarter}'
55
+
56
+
57
+ @property
58
+ def compact_label(self):
59
+ return f'{self.quarter}Q' + self.to_string('%y')
60
+
61
+
62
+ @property
63
+ def short_label(self):
64
+ return f'Q{self.quarter}'
65
+
66
+
67
+ @property
68
+ def quarter(self):
69
+ ''' the quarter number (1 to 4) '''
70
+ return int(self.scheme.index(self.month) + 1)
71
+
72
+
73
+ #╭-------------------------------------------------------------------------╮
74
+ #| Instance Methods |
75
+ #╰-------------------------------------------------------------------------╯
76
+
77
+ def _init_dt(
78
+ self,
79
+ source,
80
+ target_tz,
81
+ year=None,
82
+ month=None,
83
+ quarter=None,
84
+ **kwargs
85
+ ):
86
+ '''
87
+ Parameters
88
+ ------------
89
+ source : None | any
90
+ A value representing a quarter end date. Must be None if 'year',
91
+ 'month', or 'quarter' are provided.
92
+ year : int
93
+ The calendar year of the quarter end date.
94
+ month : int
95
+ The calendar month of the quarter end date (1 to 12).
96
+ quarter : int
97
+ The quarter number (1 to 4). Cannot be used together with the
98
+ 'month' argument.
99
+ offset : int
100
+ Number of quarters to shift from the base quarter end. Base
101
+ quarter end defaults to the most recently completed quarter end
102
+ when no other parameters are provided. Use positive values to move
103
+ forward in time and negative values to move backward in time.
104
+ kwargs : dict
105
+ Additional keyword arguments forwarded to the Timestamp
106
+ constructor.
107
+ '''
108
+
109
+ parsed_label = self._parse_label(source, target_tz)
110
+
111
+ if parsed_label is not None:
112
+ for k, v in {
113
+ 'year': year,
114
+ 'month': month,
115
+ 'quarter': quarter,
116
+ }.items():
117
+ if v is not None:
118
+ raise ValueError(
119
+ f"{k!r} must be None when 'source' is a quarter end "
120
+ f"label: {source!r}."
121
+ )
122
+
123
+ year, quarter = parsed_label
124
+ source = None
125
+
126
+ has_quarter = quarter is not None
127
+
128
+ if month is not None:
129
+ if has_quarter:
130
+ raise ValueError(
131
+ "Cannot pass 'quarter' and 'month' arguments "
132
+ "simultaneously."
133
+ )
134
+
135
+ month = self._ensure_int(month)
136
+
137
+ # validate month
138
+ (
139
+ Validator(
140
+ types=int,
141
+ whitelist=self.scheme,
142
+ )
143
+ .validate(
144
+ month=month
145
+ )
146
+ )
147
+
148
+ if has_quarter:
149
+ quarter = self._ensure_int(
150
+ value=quarter,
151
+ name='quarter',
152
+ )
153
+ month = self.scheme[quarter - 1]
154
+
155
+ super()._init_dt(
156
+ source=source,
157
+ target_tz=target_tz,
158
+ year=year,
159
+ month=month,
160
+ **kwargs
161
+ )
162
+
163
+
164
+ def _offset(self, year, month, offset):
165
+ year, month = self._backtrack_to_scheme(
166
+ year=year,
167
+ month=month,
168
+ )
169
+
170
+ result = super()._offset(
171
+ year=year,
172
+ month=month,
173
+ offset=offset,
174
+ )
175
+
176
+ return result
177
+
178
+
179
+ def _find_error(self):
180
+ if self.month not in self.scheme:
181
+ return f'the month ({self.month}) is not in scheme: {self.scheme}'
182
+ return super()._find_error()
183
+
184
+
185
+ #╭-------------------------------------------------------------------------╮
186
+ #| Class Methods |
187
+ #╰-------------------------------------------------------------------------╯
188
+
189
+ @classmethod
190
+ def set_scheme(cls, value):
191
+ ''' safely sets 'scheme' class attribute '''
192
+
193
+ name = 'scheme'
194
+
195
+ Validator(types=tuple).validate(value, name)
196
+
197
+ if len(value) != 4:
198
+ raise ValueError(
199
+ f"'scheme' must contain 4 elements, got: {len(value):,}"
200
+ )
201
+
202
+ value = tuple(map(cls._ensure_int, value))
203
+ s = pl.Series(name=name, values=value)
204
+
205
+ valid_increments = (
206
+ s.diff(null_behavior='drop') == 3
207
+ ).all()
208
+
209
+ if not valid_increments:
210
+ raise ValueError(
211
+ "'scheme' must be ascending in increments of 3, got: "
212
+ f"{value!r}"
213
+ )
214
+
215
+ valid_months = s.is_between(
216
+ lower_bound=1,
217
+ upper_bound=MONTHS_IN_YEAR,
218
+ closed='both'
219
+ ).all()
220
+
221
+ if not valid_months:
222
+ raise ValueError(
223
+ f"'scheme' values must be between 1 and {MONTHS_IN_YEAR}, "
224
+ f"got: {value!r}."
225
+ )
226
+
227
+ cls._scheme = value
228
+
229
+
230
+ @classmethod
231
+ def _backtrack_to_scheme(cls, year, month):
232
+ ''' Backtracks from the given year and month, moving one month at a
233
+ time, until a month that is part of the scheme is found. '''
234
+
235
+ while month not in cls._scheme:
236
+ year, month = cls._get_prior_month(
237
+ year=year,
238
+ month=month,
239
+ )
240
+
241
+ return year, month
242
+
243
+
244
+ @classmethod
245
+ def _parse_label(cls, source, target_tz):
246
+ '''
247
+ Description
248
+ ------------
249
+ Parses a quarter end label into its year and quarter components.
250
+ Supports input patterns like YYYYQ#, #QYY, and Q#. When the year
251
+ is not provided in the label, the current year is used by default.
252
+
253
+ Parameters
254
+ ------------
255
+ source : str
256
+ Quarter end label to parse.
257
+ target_tz : str | datetime.timezone
258
+ Refer to '_resolve_dt()' documentation.
259
+
260
+ Returns
261
+ ------------
262
+ Returns None if input is not a string or parsing failed.
263
+ Otherwise:
264
+
265
+ result : tuple
266
+ year : int
267
+ The four-digit year.
268
+ quarter : int
269
+ The quarter number (1 to 4).
270
+ '''
271
+
272
+ def extract_year_and_quarter(value):
273
+ if not isinstance(value, str):
274
+ return None
275
+
276
+ value = value.strip().upper()
277
+
278
+ # 'YYYYQ#' | 'YYYY Q#'
279
+ match = re.fullmatch(r'(\d{4})\s?Q(\d)', value)
280
+
281
+ if match:
282
+ return match.groups()
283
+
284
+ tz = None if target_tz is UNSET else target_tz
285
+ now = datetime.datetime.now(tz=tz)
286
+
287
+ # '#QYY'
288
+ match = re.fullmatch(r'(\d)Q(\d{2})', value)
289
+
290
+ if match:
291
+ quarter, year = match.groups()
292
+ return f'{now.year // 100}{year}', quarter
293
+
294
+ # 'Q#'
295
+ match = re.fullmatch(r'Q(\d)', value)
296
+
297
+ if match:
298
+ quarter = match.group(1)
299
+ return f'{now.year}', quarter
300
+
301
+
302
+ parsed = extract_year_and_quarter(source)
303
+
304
+ if parsed is None:
305
+ return None
306
+
307
+ year, quarter = (cls._ensure_int(x) for x in parsed)
308
+
309
+ if not (1 <= quarter <= 4):
310
+ raise ValueError(
311
+ f"Quarter must be between 1 and 4, got: {quarter}"
312
+ )
313
+
314
+ return year, quarter
@@ -13,7 +13,7 @@ from .utils import (
13
13
  )
14
14
 
15
15
 
16
- class Task(object):
16
+ class Task:
17
17
  '''
18
18
  Description
19
19
  --------------------
@@ -211,9 +211,9 @@ class Task(object):
211
211
 
212
212
  if self.disable_print:
213
213
  with redirect_stdout(open(os.devnull, 'w')):
214
- out = self.func(*self.args, **self.kwargs)
214
+ result = self.func(*self.args, **self.kwargs)
215
215
  else:
216
- out = self.func(*self.args, **self.kwargs)
216
+ result = self.func(*self.args, **self.kwargs)
217
217
 
218
218
  if self.verbose:
219
219
  elapsed = format_duration(time.time() - start)
@@ -230,7 +230,7 @@ class Task(object):
230
230
  if self.cancel_on_completion:
231
231
  update_status('cancelled on completion', set_inactive=True)
232
232
 
233
- return CancelJob if self.cancel_on_completion else out
233
+ return CancelJob if self.cancel_on_completion else result
234
234
 
235
235
  except Exception as e:
236
236
 
@@ -1,7 +1,7 @@
1
1
  from .utils import PrerequisiteError
2
2
 
3
3
 
4
- class FileMonitor(object):
4
+ class FileMonitor:
5
5
  '''
6
6
  Description
7
7
  --------------------