clockwork 0.2.1__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: clockwork
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Toolkit for time-related operations including scheduling, logging, date manipulation, and more.
5
5
  Home-page: https://github.com/zteinck/clockwork
6
6
  License: MIT
@@ -15,12 +15,10 @@ Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Requires-Dist: holidays
18
- Requires-Dist: iterlab
19
18
  Requires-Dist: numpy
19
+ Requires-Dist: oddments (>=0.3.0)
20
20
  Requires-Dist: pandas
21
- Requires-Dist: pathpilot
22
21
  Requires-Dist: schedule
23
- Requires-Dist: textwrap3
24
22
  Project-URL: Repository, https://github.com/zteinck/clockwork
25
23
  Description-Content-Type: text/markdown
26
24
 
@@ -0,0 +1,12 @@
1
+ from .timestamp import Timestamp
2
+ from .month_end import MonthEnd
3
+ from .quarter_end import QuarterEnd
4
+ from .decorators import action_timer
5
+
6
+ from .utils import (
7
+ format_elapsed_seconds,
8
+ date_format_to_regex,
9
+ )
10
+
11
+ __version__ = '0.3.0'
12
+ __author__ = 'Zachary Einck <zacharyeinck@gmail.com>'
@@ -0,0 +1 @@
1
+ MONTHS_IN_YEAR = 12
@@ -0,0 +1,22 @@
1
+ from functools import wraps
2
+ from oddments import add_border
3
+ import time
4
+
5
+ from .utils import format_elapsed_seconds
6
+
7
+
8
+ def action_timer(func):
9
+
10
+ @wraps(func)
11
+ def wrapper(*args, **kwargs):
12
+ start_time = time.time()
13
+ action = func.__name__
14
+ header = add_border(action, width=75, fixed_width=True)
15
+ print(header + '\n')
16
+ out = func(*args, **kwargs)
17
+ duration = format_elapsed_seconds(time.time() - start_time)
18
+ trailer = add_border(f'{action} complete in {duration}.')
19
+ print(trailer + '\n')
20
+ return out
21
+
22
+ return wrapper
@@ -0,0 +1,188 @@
1
+
2
+ import datetime
3
+ import oddments as odd
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 timestamp object.
14
+
15
+ Class Attributes
16
+ --------------------
17
+ _increment : int
18
+ Number of months to increment during offsets.
19
+
20
+ Instance Attributes
21
+ --------------------
22
+ ...
23
+ '''
24
+
25
+ #╭-------------------------------------------------------------------------╮
26
+ #| Class Attributes |
27
+ #╰-------------------------------------------------------------------------╯
28
+
29
+ _increment = 1
30
+
31
+
32
+ #╭-------------------------------------------------------------------------╮
33
+ #| Initialize Instance |
34
+ #╰-------------------------------------------------------------------------╯
35
+
36
+ def __init__(self, arg=None, **kwargs):
37
+ '''
38
+ Parameters
39
+ ------------
40
+ arg : None | any
41
+ A scalar value to be interpreted as a month end date. Must be None
42
+ if 'year' or 'month' are provided. Refer to '_to_datetime()' for
43
+ supported input formats.
44
+ year : int
45
+ The calendar year of the month end date.
46
+ month : int
47
+ The calendar month of the month end date (1 to 12).
48
+ offset : int
49
+ Number of months to shift from the base month end. Base month end
50
+ defaults to the most recently completed month end when no other
51
+ parameters are provided. Use positive values to move forward and
52
+ negative values to move backward in time.
53
+ kwargs : dict
54
+ Additional keyword arguments are forwarded to the Timestamp
55
+ constructor. Refer to its documentation for details.
56
+ '''
57
+ offset = self._try_int(kwargs.pop('offset', 0))
58
+ year, month = [kwargs.pop(k, None) for k in ['year','month']]
59
+ year_or_month = not (year is None and month is None)
60
+ has_arg = arg is not None
61
+
62
+ if has_arg:
63
+ if year_or_month:
64
+ raise ValueError(
65
+ "Both 'year' and 'month' must be "
66
+ "None when 'arg' is not None."
67
+ )
68
+ else:
69
+ if year_or_month:
70
+ year, month = map(self._try_int, (year, month))
71
+ self._validate_month(month)
72
+ else:
73
+ now = datetime.datetime.now()
74
+ year = now.year
75
+ month = now.month - 1 # default to last month
76
+
77
+ year, month = self._offset(
78
+ year=year,
79
+ month=month,
80
+ offset=offset * self._increment
81
+ )
82
+
83
+ day = self.days_in_month(year, month)
84
+ arg = datetime.datetime(year, month, day)
85
+
86
+ super().__init__(arg, **kwargs)
87
+ self.validate_instance()
88
+
89
+ if has_arg and offset != 0:
90
+ self.offset(periods=offset, inplace=True)
91
+
92
+
93
+ #╭-------------------------------------------------------------------------╮
94
+ #| Properties |
95
+ #╰-------------------------------------------------------------------------╯
96
+
97
+ @property
98
+ def long_label(self):
99
+ return self.str('%Y-%m-%d')
100
+
101
+
102
+ @property
103
+ def compact_label(self):
104
+ return self.str('%Y-%m')
105
+
106
+
107
+ @property
108
+ def short_label(self):
109
+ return self.str('%b')
110
+
111
+
112
+ @property
113
+ def is_year_end(self):
114
+ return self.month == MONTHS_IN_YEAR
115
+
116
+
117
+ @property
118
+ def relative_offset(self):
119
+ '''
120
+ Description
121
+ ------------
122
+ Returns the number of periods the instance is offset relative to
123
+ the most recent period end. Periods are defined by the '_increment'
124
+ class attribute.
125
+
126
+ Returns
127
+ ------------
128
+ q : int
129
+ Number of offset periods.
130
+ '''
131
+ a, b = [
132
+ self._total_months(obj.year, obj.month)
133
+ for obj in (self, self.__class__(offset=0))
134
+ ]
135
+ q, r = divmod(a - b, self._increment)
136
+ if r == 0: return int(q)
137
+ raise ValueError(f"Unexpected remainder: {r}")
138
+
139
+
140
+ #╭-------------------------------------------------------------------------╮
141
+ #| Instance Methods |
142
+ #╰-------------------------------------------------------------------------╯
143
+
144
+ def offset(self, periods, inplace=False):
145
+ ''' offsets instance by desired number of periods '''
146
+ odd.validate_value(
147
+ value=periods,
148
+ attr='periods',
149
+ types=int
150
+ )
151
+
152
+ out = self.__class__(offset=self.relative_offset + periods)
153
+
154
+ if inplace:
155
+ self.dt = out.dt
156
+ else:
157
+ return out
158
+
159
+
160
+ def _offset(self, year, month, offset):
161
+ total_months = self._total_months(year, month + offset)
162
+ y, m = divmod(total_months, MONTHS_IN_YEAR)
163
+ return (y - 1, MONTHS_IN_YEAR) if m == 0 else (y, m)
164
+
165
+
166
+ def validate_instance(self):
167
+ ''' raises an error if the instance fails validation '''
168
+ error_msg = self._validate_instance()
169
+
170
+ if error_msg is not None:
171
+ raise ValueError(
172
+ f"Failed to initialize a {self.__class__.__name__} "
173
+ f"instance because the {error_msg}. "
174
+ )
175
+
176
+
177
+ def _validate_instance(self):
178
+ if not self.is_last_day_of_month:
179
+ return (
180
+ f"day ({self.day}) is not the last day "
181
+ f"({self.last_day_of_month}) of {self.month_name} {self.year}"
182
+ )
183
+
184
+ if not self.is_normalized:
185
+ return (
186
+ "date must have no time component. Use "
187
+ "'normalize=True' when creating the date"
188
+ )
@@ -0,0 +1,244 @@
1
+ import oddments as odd
2
+ import pandas as pd
3
+ import datetime
4
+ import re
5
+
6
+ from .month_end import MonthEnd
7
+ from .constants import MONTHS_IN_YEAR
8
+
9
+
10
+ class QuarterEnd(MonthEnd):
11
+ '''
12
+ Description
13
+ --------------------
14
+ Quarter end timestamp object.
15
+
16
+ Class Attributes
17
+ --------------------
18
+ scheme : tuple
19
+ Quarter end months.
20
+
21
+ Instance Attributes
22
+ --------------------
23
+ ...
24
+ '''
25
+
26
+ #╭-------------------------------------------------------------------------╮
27
+ #| Class Attributes |
28
+ #╰-------------------------------------------------------------------------╯
29
+
30
+ _increment = 3
31
+ scheme = (3, 6, 9, 12)
32
+
33
+
34
+ #╭-------------------------------------------------------------------------╮
35
+ #| Initialize Instance |
36
+ #╰-------------------------------------------------------------------------╯
37
+
38
+ def __init__(self, arg=None, **kwargs):
39
+ '''
40
+ Parameters
41
+ ------------
42
+ arg : None | any
43
+ A scalar value to be interpreted as a quarter end date. Must be
44
+ None if 'year', 'month', or 'quarter' are provided. Refer to
45
+ '_to_datetime()' for supported input formats.
46
+ year : int
47
+ The calendar year of the quarter end date.
48
+ month : int
49
+ The calendar month of the quarter end date (1 to 12).
50
+ quarter : int
51
+ The quarter number (1 to 4). Cannot be used together with the
52
+ 'month' argument.
53
+ offset : int
54
+ Number of quarters to shift from the base quarter end. Base
55
+ quarter end defaults to the most recently completed quarter end
56
+ when no other parameters are provided. Use positive values to move
57
+ forward and negative values to move backward in time.
58
+ kwargs : dict
59
+ Additional keyword arguments are forwarded to the Timestamp
60
+ constructor. Refer to its documentation for details.
61
+ '''
62
+ qtr = kwargs.pop('quarter', None)
63
+ parsed = self.parse_label(arg)
64
+
65
+ if parsed is not None:
66
+ for k in ['year','quarter']:
67
+ if kwargs.get(k) is not None:
68
+ raise ValueError(
69
+ f"'{k}' must be None when 'arg' "
70
+ "is a quarter label: {arg!r}."
71
+ )
72
+
73
+ kwargs['year'], qtr = parsed
74
+ arg = None
75
+
76
+ if qtr is not None:
77
+ if kwargs.get('month') is not None:
78
+ raise ValueError(
79
+ "Cannot pass 'quarter' and 'month' "
80
+ "arguments simultaneously."
81
+ )
82
+ qtr = self._try_int(qtr)
83
+ kwargs['month'] = self.scheme[qtr - 1]
84
+
85
+ super().__init__(arg, **kwargs)
86
+
87
+
88
+ #╭-------------------------------------------------------------------------╮
89
+ #| Properties |
90
+ #╰-------------------------------------------------------------------------╯
91
+
92
+ @property
93
+ def long_label(self):
94
+ return f'{self.year}Q{self.qtr}'
95
+
96
+
97
+ @property
98
+ def compact_label(self):
99
+ return f'{self.qtr}Q' + self.str('%y')
100
+
101
+
102
+ @property
103
+ def short_label(self):
104
+ return f'Q{self.qtr}'
105
+
106
+
107
+ @property
108
+ def quarter(self):
109
+ ''' the quarter number (1 to 4) '''
110
+ return int(self.scheme.index(self.month) + 1)
111
+
112
+
113
+ @property
114
+ def qtr(self):
115
+ ''' quarter alias '''
116
+ return self.quarter
117
+
118
+
119
+ #╭-------------------------------------------------------------------------╮
120
+ #| Instance Methods |
121
+ #╰-------------------------------------------------------------------------╯
122
+
123
+ def _offset(self, year, month, offset):
124
+ year, month = self._backtrack_to_scheme(year, month)
125
+ return super()._offset(year, month, offset)
126
+
127
+
128
+ def _validate_instance(self):
129
+ if self.month not in self.scheme:
130
+ return f'month ({self.month}) is not in scheme: {self.scheme}'
131
+ return super()._validate_instance()
132
+
133
+
134
+ #╭-------------------------------------------------------------------------╮
135
+ #| Class Methods |
136
+ #╰-------------------------------------------------------------------------╯
137
+
138
+ @classmethod
139
+ def _backtrack_to_scheme(cls, year, month):
140
+ ''' Backtracks from the given year and month, moving one month at a
141
+ time, until a month that is part of the scheme is found. '''
142
+ while month not in cls.scheme:
143
+ year, month = cls._get_prior_month(year, month)
144
+ return year, month
145
+
146
+
147
+ @classmethod
148
+ def set_scheme(cls, value):
149
+ ''' safely sets 'scheme' class attribute '''
150
+
151
+ odd.validate_value(
152
+ value=value,
153
+ attr='scheme',
154
+ types=tuple
155
+ )
156
+
157
+ if len(value) != 4:
158
+ raise ValueError(
159
+ "'scheme' must contain 4 elements, "
160
+ f"got: {len(value):,}"
161
+ )
162
+
163
+ value = tuple(map(cls._try_int, value))
164
+ s = pd.Series(value)
165
+
166
+ if not (s.diff().dropna() == 3).all():
167
+ raise ValueError(
168
+ "'scheme' must be ascending in "
169
+ f"increments of 3. got: {value}."
170
+ )
171
+
172
+ if not s.between(
173
+ left=1,
174
+ right=MONTHS_IN_YEAR,
175
+ inclusive='both'
176
+ ).all():
177
+ raise ValueError(
178
+ "'scheme' values must be between 1 "
179
+ f"and {MONTHS_IN_YEAR}, got: {value}."
180
+ )
181
+
182
+ cls.scheme = value
183
+
184
+
185
+ @classmethod
186
+ def parse_label(cls, x):
187
+ '''
188
+ Description
189
+ ------------
190
+ Parses a quarter end label into its year and quarter components.
191
+ Supports input patterns like YYYYQ#, #QYY, and Q#. When the year
192
+ is not provided in the label, the current year is used by default.
193
+
194
+ Parameters
195
+ ------------
196
+ x : str
197
+ Quarter end label to parse.
198
+
199
+ Returns
200
+ ------------
201
+ Returns None if input is not a string or parsing failed.
202
+ Otherwise:
203
+
204
+ out : tuple
205
+ year : int
206
+ The four-digit year.
207
+ qtr : int
208
+ The quarter number (1 to 4).
209
+ '''
210
+
211
+ def extract_year_qtr(x):
212
+ if not isinstance(x, str): return
213
+ x = x.strip().upper()
214
+
215
+ # YYYYQ#
216
+ match = re.fullmatch(r'(\d{4})Q(\d)', x)
217
+ if match: return match.groups()
218
+
219
+ now = datetime.datetime.now()
220
+
221
+ # #QYY
222
+ match = re.fullmatch(r'(\d)Q(\d{2})', x)
223
+ if match:
224
+ qtr, yy = match.groups()
225
+ return f'{now.year // 100}{yy}', qtr
226
+
227
+ # Q#
228
+ match = re.fullmatch(r'Q(\d)', x)
229
+ if match:
230
+ qtr = match.group(1)
231
+ return str(now.year), qtr
232
+
233
+
234
+ parsed = extract_year_qtr(x)
235
+ if parsed is None: return
236
+ year, qtr = map(cls._try_int, parsed)
237
+
238
+ if not (1 <= qtr <= 4):
239
+ raise ValueError(
240
+ "Quarter must be between "
241
+ f"1 and 4, got: {qtr}"
242
+ )
243
+
244
+ return year, qtr
@@ -0,0 +1,27 @@
1
+ import datetime
2
+ from schedule import Scheduler, CancelJob
3
+
4
+ from .utils import ContinueFailedJob
5
+
6
+
7
+ class TaskScheduler(Scheduler):
8
+
9
+ #╭-------------------------------------------------------------------------╮
10
+ #| Initialize Instance |
11
+ #╰-------------------------------------------------------------------------╯
12
+
13
+ def __init__(self, *args, **kwargs):
14
+ super().__init__(*args, **kwargs)
15
+
16
+
17
+ #╭-------------------------------------------------------------------------╮
18
+ #| Instance Methods |
19
+ #╰-------------------------------------------------------------------------╯
20
+
21
+ def _run_job(self, job):
22
+ ret = job.run()
23
+ if ret is CancelJob:
24
+ self.cancel_job(job)
25
+ elif ret is ContinueFailedJob:
26
+ job.last_run = datetime.datetime.now()
27
+ job._schedule_next_run()
@@ -3,12 +3,11 @@ import os
3
3
  from contextlib import redirect_stdout
4
4
  from schedule import CancelJob
5
5
 
6
- from ..core import Date
6
+ from ..timestamp import Timestamp
7
7
  from ..utils import elapsed_time
8
8
  from .utils import PrerequisiteError, ContinueFailedJob
9
9
 
10
10
 
11
-
12
11
  class Task(object):
13
12
  '''
14
13
  Description
@@ -34,8 +33,9 @@ class Task(object):
34
33
  name of job
35
34
  at : str
36
35
  at time string
37
- expiry : Date
38
- If not None, job will be set inactive and stop running after this datetime
36
+ expiry : Timestamp
37
+ If not None, job will be set inactive and stop running after this
38
+ datetime
39
39
  func : func
40
40
  function to run
41
41
  args : tuple
@@ -164,15 +164,16 @@ class Task(object):
164
164
  if logger: logger.info(f"{self.name} status update: '{self.status}'")
165
165
  self.update_table(active=0 if set_inactive else 1)
166
166
 
167
- if self.expiry and self.expiry < Date():
167
+ if self.expiry and self.expiry < Timestamp():
168
168
  update_status('cancelled on expiration', set_inactive=True)
169
169
  if self.verbose:
170
170
  print('Killing', end=' ')
171
171
  print(self.name, end='')
172
- print(f' @ {Date()} ->', end=' ')
172
+ print(f' @ {Timestamp()} ->', end=' ')
173
173
  print('Job Cancelled')
174
174
 
175
- # cancel job if it was cancelled on cascasde or if it was set inactive after being scheduled
175
+ # cancel job if it was cancelled on cascasde or
176
+ # if it was set inactive after being scheduled
176
177
  if self.status == self.cascade_status or \
177
178
  not self.master.is_active(self.name, self.at):
178
179
  return CancelJob
@@ -185,12 +186,12 @@ class Task(object):
185
186
  if self.verbose:
186
187
  print('Running', end=' ')
187
188
  print(self.name, end='')
188
- print(f' @ {Date()} ->', end=' ')
189
+ print(f' @ {Timestamp()} ->', end=' ')
189
190
 
190
- if self.restrict_to_business_hours and not Date().is_business_hours:
191
+ if self.restrict_to_business_hours and not Timestamp().is_business_hours:
191
192
  raise PrerequisiteError('cannot execute outside of business hours')
192
193
 
193
- if self.restrict_to_business_days and not Date().is_business_day:
194
+ if self.restrict_to_business_days and not Timestamp().is_business_day:
194
195
  raise PrerequisiteError('cannot execute during the weekend')
195
196
 
196
197
  if self.disable_print:
@@ -1,15 +1,14 @@
1
1
  from .utils import PrerequisiteError
2
2
 
3
3
 
4
-
5
4
  class FileMonitor(object):
6
5
  '''
7
6
  Description
8
7
  --------------------
9
8
  Monitors a folder for new files and passes latest and 2nd latest file
10
- names to user-defined function. Class is intended to be used in conjunction
11
- with TaskMaster as a func argument which allows for file monitoring at regular
12
- intervals.
9
+ names to user-defined function. Class is intended to be used in
10
+ conjunction with TaskMaster as a func argument which allows for file
11
+ monitoring at regular intervals.
13
12
 
14
13
  Class Attributes
15
14
  --------------------
@@ -18,8 +17,8 @@ class FileMonitor(object):
18
17
  Instance Attributes
19
18
  --------------------
20
19
  func : func
21
- Custom function that takes the latest and second-latest file names in a
22
- folder as the first and second arguments, respectively.
20
+ Custom function that takes the latest and second-latest file names
21
+ in a folder as the first and second arguments, respectively.
23
22
  folder : Folder object
24
23
  folder to monitor for new files.
25
24
  filter_kwargs : dict
@@ -1,6 +1,5 @@
1
1
  import logging
2
2
  import datetime
3
- from pathpilot import Folder
4
3
 
5
4
 
6
5
  #╭-------------------------------------------------------------------------╮
@@ -21,7 +20,6 @@ class CustomLogFormatter(logging.Formatter):
21
20
  .format('%03d' % record.msecs)
22
21
 
23
22
 
24
-
25
23
  class Logger(object):
26
24
 
27
25
  #╭-------------------------------------------------------------------------╮
@@ -56,12 +54,10 @@ class Logger(object):
56
54
  # create custom formatter
57
55
  # https://docs.python.org/3/library/logging.html#logrecord-attributes
58
56
  formatter = CustomLogFormatter(fmt='%(asctime)s %(levelname)s %(message)s')
59
- #formatter = CustomLogFormatter(fmt='%(asctime)s %(name)s %(levelname)s %(message)s')
57
+ # formatter = CustomLogFormatter(fmt='%(asctime)s %(name)s %(levelname)s %(message)s')
60
58
 
61
59
  # create file handler which logs even debug messages
62
- self.file = Folder().parent\
63
- .join('Data', 'Logger', read_only=False)\
64
- .join(f'{name}.log').path
60
+ self.file = self.folder.join(f'{name}.log', read_only=False).path
65
61
  if clear: self.clear()
66
62
  fh = logging.FileHandler(self.file)
67
63
  fh.setLevel(logging.DEBUG)