clockwork 0.2.2__tar.gz → 0.3.1__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.2
3
+ Version: 0.3.1
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
@@ -16,11 +16,9 @@ Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Requires-Dist: holidays
18
18
  Requires-Dist: numpy
19
- Requires-Dist: oddments
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.1'
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
@@ -3,7 +3,7 @@ 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
 
@@ -33,7 +33,7 @@ class Task(object):
33
33
  name of job
34
34
  at : str
35
35
  at time string
36
- expiry : Date
36
+ expiry : Timestamp
37
37
  If not None, job will be set inactive and stop running after this
38
38
  datetime
39
39
  func : func
@@ -164,12 +164,12 @@ 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
175
  # cancel job if it was cancelled on cascasde or
@@ -186,12 +186,12 @@ class Task(object):
186
186
  if self.verbose:
187
187
  print('Running', end=' ')
188
188
  print(self.name, end='')
189
- print(f' @ {Date()} ->', end=' ')
189
+ print(f' @ {Timestamp()} ->', end=' ')
190
190
 
191
- 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:
192
192
  raise PrerequisiteError('cannot execute outside of business hours')
193
193
 
194
- 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:
195
195
  raise PrerequisiteError('cannot execute during the weekend')
196
196
 
197
197
  if self.disable_print:
@@ -1,6 +1,5 @@
1
1
  import logging
2
2
  import datetime
3
- from pathpilot import Folder
4
3
 
5
4
 
6
5
  #╭-------------------------------------------------------------------------╮
@@ -55,12 +54,10 @@ class Logger(object):
55
54
  # create custom formatter
56
55
  # https://docs.python.org/3/library/logging.html#logrecord-attributes
57
56
  formatter = CustomLogFormatter(fmt='%(asctime)s %(levelname)s %(message)s')
58
- #formatter = CustomLogFormatter(fmt='%(asctime)s %(name)s %(levelname)s %(message)s')
57
+ # formatter = CustomLogFormatter(fmt='%(asctime)s %(name)s %(levelname)s %(message)s')
59
58
 
60
59
  # create file handler which logs even debug messages
61
- self.file = Folder().parent\
62
- .join('Data', 'Logger', read_only=False)\
63
- .join(f'{name}.log').path
60
+ self.file = self.folder.join(f'{name}.log', read_only=False).path
64
61
  if clear: self.clear()
65
62
  fh = logging.FileHandler(self.file)
66
63
  fh.setLevel(logging.DEBUG)
@@ -1,10 +1,9 @@
1
1
  import datetime
2
2
  import time
3
3
  import uuid
4
- from oddments import to_iter
5
- from pathpilot import Folder
4
+ import oddments as odd
6
5
 
7
- from ..core import Date
6
+ from ..timestamp import Timestamp
8
7
  from ._scheduler import TaskScheduler
9
8
  from ._task import Task
10
9
 
@@ -146,9 +145,9 @@ class TaskMaster(object):
146
145
  constituent time.
147
146
  interval : int
148
147
  schedule.Scheduler.every interval argument
149
- start : Date
148
+ start : Timestamp
150
149
  If not None, job will be not be added until this datetime
151
- expiry : Date
150
+ expiry : Timestamp
152
151
  If not None, job will be set inactive and stop running after this
153
152
  datetime
154
153
  kwargs : keyword arguments
@@ -160,15 +159,14 @@ class TaskMaster(object):
160
159
  '''
161
160
 
162
161
  if not hasattr(cls, 'db'):
163
- cls.db = Folder().parent.join('Data', 'SQLite', read_only=False)\
164
- .join('taskmaster.sqlite')
165
- cls.db.connect()
166
- cls.db.enable_foreign_keys()
162
+ raise NotImplementedError
163
+ # cls.db.connect()
164
+ # cls.db.enable_foreign_keys()
167
165
 
168
166
  if not hasattr(cls, 'scheduler'):
169
167
  cls.scheduler = TaskScheduler()
170
168
 
171
- now = Date()
169
+ now = Timestamp()
172
170
  if (start and start > now) or (expiry and expiry < now): return
173
171
  expiry_str = expiry.dt.strftime('%Y-%m-%d %I:%M:%S.{} %p')\
174
172
  .format('%03d' % (expiry.dt.microsecond / 1000))\
@@ -183,7 +181,7 @@ class TaskMaster(object):
183
181
  else:
184
182
  raise Exception("'every' argument cannot be None")
185
183
 
186
- for at in to_iter(at):
184
+ for at in odd.to_iter(at):
187
185
  job = getattr(cls.scheduler.every(interval), every)
188
186
  if at is not None:
189
187
  try: