datec 0.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.
- {datec-0.2 → datec-0.3.1}/PKG-INFO +16 -10
- {datec-0.2 → datec-0.3.1}/README.md +15 -9
- datec-0.3.1/datec/__init__.py +487 -0
- datec-0.2/datec/__init__.py → datec-0.3.1/datec/__init__.py~ +52 -30
- {datec-0.2 → datec-0.3.1}/datec/__main__.py +18 -6
- datec-0.3.1/datec/py.typed +0 -0
- {datec-0.2 → datec-0.3.1}/tests/datec_test.py +36 -0
- {datec-0.2 → datec-0.3.1}/.gitignore +0 -0
- {datec-0.2 → datec-0.3.1}/LICENSE +0 -0
- {datec-0.2 → datec-0.3.1}/pyproject.toml +0 -0
- {datec-0.2 → datec-0.3.1}/requirements-dev.in +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datec
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Date Command
|
|
5
5
|
Project-URL: Homepage, https://github.com/isaacto/datec
|
|
6
6
|
Project-URL: Repository, https://github.com/isaacto/datec.git
|
|
@@ -40,15 +40,16 @@ to them, like this:
|
|
|
40
40
|
A date command can be parsed from strings using the parse() function,
|
|
41
41
|
which create a command from a string representation. This forms the
|
|
42
42
|
basis of the datec command, which is a command-line program to output
|
|
43
|
-
datetime after applying date commands
|
|
44
|
-
|
|
45
|
-
parts are omitted
|
|
46
|
-
the
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
43
|
+
datetime after applying date commands, or sleep until that time if
|
|
44
|
+
"-w" is given. In general the date representation is
|
|
45
|
+
NxYYYY-mm-ddTHH:MM:SS.ffffff, where unspecified parts are omitted
|
|
46
|
+
leaving the symbols intact, like "2x-2-29T3::." (see the following for
|
|
47
|
+
the meaning). If the fractional part is not specified the "." may be
|
|
48
|
+
omitted, if all time parts are not specified the "T::." can be
|
|
49
|
+
omitted, if all date parts are not specified the "--T" can be omitted,
|
|
50
|
+
and if Nx may be omitted in some cases for setting a partial datetime
|
|
51
|
+
or weekday. There are a couple other more formats like +3week and
|
|
52
|
+
-2wed for shifting by period and weekday.
|
|
52
53
|
|
|
53
54
|
Date commands are in two forms: period shifting commands and partial
|
|
54
55
|
datetime shifting commands. The first type is more familiar: they
|
|
@@ -78,6 +79,11 @@ They are represented by either a Weekday object or a PartialDate
|
|
|
78
79
|
object with a count. A count of 0 means setting instead of shifting.
|
|
79
80
|
Only integer counts are acceptable.
|
|
80
81
|
|
|
82
|
+
A trailing "/" on a partial date command sets all fields after the
|
|
83
|
+
last specified field to zero. For example, `12::/` sets the hour to
|
|
84
|
+
12 and the minute, second and microsecond to 0, whereas `12::` would
|
|
85
|
+
leave those unchanged.
|
|
86
|
+
|
|
81
87
|
It is an error to set to an invalid date (e.g., --31 applied on
|
|
82
88
|
2019-06-25 is an error). The datetime parts which are specified must
|
|
83
89
|
be consecutive (it is an error to specify 12::05). It is also an
|
|
@@ -16,15 +16,16 @@ to them, like this:
|
|
|
16
16
|
A date command can be parsed from strings using the parse() function,
|
|
17
17
|
which create a command from a string representation. This forms the
|
|
18
18
|
basis of the datec command, which is a command-line program to output
|
|
19
|
-
datetime after applying date commands
|
|
20
|
-
|
|
21
|
-
parts are omitted
|
|
22
|
-
the
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
datetime after applying date commands, or sleep until that time if
|
|
20
|
+
"-w" is given. In general the date representation is
|
|
21
|
+
NxYYYY-mm-ddTHH:MM:SS.ffffff, where unspecified parts are omitted
|
|
22
|
+
leaving the symbols intact, like "2x-2-29T3::." (see the following for
|
|
23
|
+
the meaning). If the fractional part is not specified the "." may be
|
|
24
|
+
omitted, if all time parts are not specified the "T::." can be
|
|
25
|
+
omitted, if all date parts are not specified the "--T" can be omitted,
|
|
26
|
+
and if Nx may be omitted in some cases for setting a partial datetime
|
|
27
|
+
or weekday. There are a couple other more formats like +3week and
|
|
28
|
+
-2wed for shifting by period and weekday.
|
|
28
29
|
|
|
29
30
|
Date commands are in two forms: period shifting commands and partial
|
|
30
31
|
datetime shifting commands. The first type is more familiar: they
|
|
@@ -54,6 +55,11 @@ They are represented by either a Weekday object or a PartialDate
|
|
|
54
55
|
object with a count. A count of 0 means setting instead of shifting.
|
|
55
56
|
Only integer counts are acceptable.
|
|
56
57
|
|
|
58
|
+
A trailing "/" on a partial date command sets all fields after the
|
|
59
|
+
last specified field to zero. For example, `12::/` sets the hour to
|
|
60
|
+
12 and the minute, second and microsecond to 0, whereas `12::` would
|
|
61
|
+
leave those unchanged.
|
|
62
|
+
|
|
57
63
|
It is an error to set to an invalid date (e.g., --31 applied on
|
|
58
64
|
2019-06-25 is an error). The datetime parts which are specified must
|
|
59
65
|
be consecutive (it is an error to specify 12::05). It is also an
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Date command: A command-based date computation engine
|
|
2
|
+
|
|
3
|
+
datec allows you to use "date commands" to modify datetime's by adding
|
|
4
|
+
to them, like this:
|
|
5
|
+
|
|
6
|
+
datetime.datetime.now() + datec.Period(2, 'week')
|
|
7
|
+
|
|
8
|
+
A date command can be parsed from strings using the parse() function,
|
|
9
|
+
which create a command from a string representation. This forms the
|
|
10
|
+
basis of the datec command, which is a command-line program to output
|
|
11
|
+
datetime after applying date commands. In general the date
|
|
12
|
+
representation is NxYYYY-mm-ddTHH:MM:SS.ffffff, where unspecified
|
|
13
|
+
parts are omitted leaving the symbols intact, like "2x-2-29T3::." (see
|
|
14
|
+
the following for the meaning). If the fractional part is not
|
|
15
|
+
specified the "." may be omitted, if all time parts are not specified
|
|
16
|
+
the "T::." can be omitted, if all date parts are not specified the
|
|
17
|
+
"--T" can be omitted, and if Nx may be omitted in some cases for
|
|
18
|
+
setting a partial datetime or weekday. There are a couple other more
|
|
19
|
+
formats like +3week and -2wed for shifting by period and weekday.
|
|
20
|
+
|
|
21
|
+
Date commands are in two forms: period shifting commands and partial
|
|
22
|
+
datetime shifting commands. The first type is more familiar: they
|
|
23
|
+
look like
|
|
24
|
+
|
|
25
|
+
* +2week (shift the datetime forward by 2 week)
|
|
26
|
+
* -1month (shift the datetime backward by 1 month)
|
|
27
|
+
|
|
28
|
+
Period is one of year, month, week, day, hour, minute and second,
|
|
29
|
+
represented by an object of the Period class. Fractional numbers are
|
|
30
|
+
acceptable except for year and month. If shifting a period leads to
|
|
31
|
+
an invalid date (e.g., shift backward 1 month from 2019-07-31), it
|
|
32
|
+
moves backwards the closest valid date (here, 2019-06-30). In general
|
|
33
|
+
the parts finer than the shifted part is unaffected (e.g., shifting 1
|
|
34
|
+
month from 2019-07-31 02:00 gives you 2019-06-30 02:00).
|
|
35
|
+
|
|
36
|
+
Partial datetime shifting is less familiar. It looks like:
|
|
37
|
+
|
|
38
|
+
* 12:: (set the hour number to 12)
|
|
39
|
+
* +2x12:: (move forward to the second hour 12)
|
|
40
|
+
* +4x--31 (move forward to the fourth occurrence of day 31 of a month)
|
|
41
|
+
* -3x-02-29 (move backward to the third occurrence of February 29)
|
|
42
|
+
* wed (set to the Wednesday of the same week, week starts on Sunday)
|
|
43
|
+
* -3wed (move to the third Wednesday before the current datetime)
|
|
44
|
+
|
|
45
|
+
They are represented by either a Weekday object or a PartialDate
|
|
46
|
+
object with a count. A count of 0 means setting instead of shifting.
|
|
47
|
+
Only integer counts are acceptable.
|
|
48
|
+
|
|
49
|
+
A trailing "/" on a partial date command sets all fields after the
|
|
50
|
+
last specified field to zero. For example, `12::/` sets the hour to
|
|
51
|
+
12 and the minute, second and microsecond to 0, whereas `12::` would
|
|
52
|
+
leave those unchanged.
|
|
53
|
+
|
|
54
|
+
It is an error to set to an invalid date (e.g., --31 applied on
|
|
55
|
+
2019-06-25 is an error). The datetime parts which are specified must
|
|
56
|
+
be consecutive (it is an error to specify 12::05). It is also an
|
|
57
|
+
error to shift for occurrence of a partial date with year specified
|
|
58
|
+
(e.g., "+2x2019--").
|
|
59
|
+
|
|
60
|
+
On the other hand, shifting to an invalid date with day number
|
|
61
|
+
specified will shift more until a specified date is valid. For
|
|
62
|
+
example, if you add -2-29 with count 1 to 2019-01-01, you end up with
|
|
63
|
+
2020-02-29, because 2019-02-29 is not a valid date. If the count is 2
|
|
64
|
+
you get 2024-02-29 instead.
|
|
65
|
+
|
|
66
|
+
Shifting to an invalid date by a partial date with just a month number
|
|
67
|
+
will cause the date to moved backwards until the date is valid. E.g.,
|
|
68
|
+
if you shift by -6- with count 1 (next June) from 2019-05-31, you get
|
|
69
|
+
2019-06-30. With count 2 you get 2020-06-30.
|
|
70
|
+
|
|
71
|
+
This library is grown out of frustration that it is tedious to have a
|
|
72
|
+
shell script or program to get a datetime like "the next 6pm from now"
|
|
73
|
+
or "the next 3rd of any month from two days ago". With this module
|
|
74
|
+
they can be specified like "+1x18:00:00.0" and "-2day +1x--3"
|
|
75
|
+
respectively. In the expected use cases, counts are small numbers.
|
|
76
|
+
So the library is not always efficient (at times we just loop "count"
|
|
77
|
+
times to step forward or backward). Whenever it is simple to do so,
|
|
78
|
+
the implementation just forward to relativedelta, in which case they
|
|
79
|
+
are more efficient.
|
|
80
|
+
|
|
81
|
+
At present the program does not handle timezone and daylight saving.
|
|
82
|
+
This is bacause the author lives at a place where no daylight saving
|
|
83
|
+
is observed. Contributions are welcome.
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
import contextlib
|
|
88
|
+
import datetime
|
|
89
|
+
import re
|
|
90
|
+
import typing
|
|
91
|
+
|
|
92
|
+
import dateutil.relativedelta as dr
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__version__ = '0.3.1'
|
|
96
|
+
|
|
97
|
+
class ParseError(ValueError):
|
|
98
|
+
"""Represent an error in parsing."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Period:
|
|
102
|
+
"""Represent a command that shift a number of period
|
|
103
|
+
|
|
104
|
+
A period may be a year, month, week, day, hour, minute or second,
|
|
105
|
+
which is the string to be used in the period argument. If you
|
|
106
|
+
shift by month/year and it ends up into an invalid date, the
|
|
107
|
+
result is "truncated" back to the previous valid day. Shifting a
|
|
108
|
+
non-integer number of periods is supported except for months and
|
|
109
|
+
years.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
|
|
113
|
+
count (float): The number of periods to shift
|
|
114
|
+
period (str): The period
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
def __init__(self, count: float, period: str):
|
|
118
|
+
if period not in ('year', 'month', 'week', 'day',
|
|
119
|
+
'hour', 'minute', 'second'):
|
|
120
|
+
raise ValueError(f'Invalid period: {period}')
|
|
121
|
+
if period in ('year', 'month') and count != int(count):
|
|
122
|
+
raise ValueError(f'Invalid count {count} for period {period}')
|
|
123
|
+
self._count = count
|
|
124
|
+
self._period = period
|
|
125
|
+
|
|
126
|
+
def __radd__(self, dt: datetime.datetime) -> datetime.datetime:
|
|
127
|
+
return dt + dr.relativedelta(
|
|
128
|
+
**{self._period + 's': self._count}) # type: ignore
|
|
129
|
+
|
|
130
|
+
PARSE_RE = re.compile(r'''
|
|
131
|
+
^
|
|
132
|
+
(?P<count> [+-] (?: [0-9]+ | [0-9]*\.[0-9]*) )
|
|
133
|
+
(?P<period> year|month|week|day|hour|minute|second)
|
|
134
|
+
$
|
|
135
|
+
''', re.X)
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def parse(cls, cmdstr: str) -> 'Period':
|
|
139
|
+
"""Parse a command string to a Period object
|
|
140
|
+
|
|
141
|
+
The command string should be of the form "<N><period>", where
|
|
142
|
+
<N> is an explicitly signed number, and <period> is a period
|
|
143
|
+
string (case insensitive).
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
|
|
147
|
+
cmdstr (str): The command string
|
|
148
|
+
|
|
149
|
+
"""
|
|
150
|
+
match = cls.PARSE_RE.match(cmdstr.lower())
|
|
151
|
+
if not match:
|
|
152
|
+
raise ParseError('Cannot parse string %s' % cmdstr)
|
|
153
|
+
gdt = match.groupdict()
|
|
154
|
+
cnt: float
|
|
155
|
+
with contextlib.suppress(ValueError):
|
|
156
|
+
return cls(int(gdt['count']), gdt['period'])
|
|
157
|
+
try:
|
|
158
|
+
return cls(float(gdt['count']), gdt['period'])
|
|
159
|
+
except ValueError as err:
|
|
160
|
+
raise ParseError('Cannot parse string %s' % cmdstr)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_WEEKDAY_CLS = [dr.SU, dr.MO, dr.TU, dr.WE, dr.TH, dr.FR, dr.SA]
|
|
164
|
+
SUN, MON, TUE, WED, THU, FRI, SAT = range(7)
|
|
165
|
+
_WEEKDAY_NUM = {'sun': 0, 'mon': 1, 'tue': 2, 'wed': 3,
|
|
166
|
+
'thu': 4, 'fri': 5, 'sat': 6}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Weekday:
|
|
170
|
+
"""Represent a command that set or shift by weekday
|
|
171
|
+
|
|
172
|
+
A weekday is a number from 0 to 6, representing Sunday, Monday,
|
|
173
|
+
..., Friday (the constants SUN, MON, etc. are provided for
|
|
174
|
+
readability of constant weekdays). If you set a weekday, by using
|
|
175
|
+
a zero count, it moves to the weekday of the current week (week
|
|
176
|
+
always starts on Sunday). A non-zero (integer) count would
|
|
177
|
+
instead shift forward or backward by that number of occurrences of
|
|
178
|
+
that weekday. If the original date is already that weekday it is
|
|
179
|
+
not counted as one of those occurrences.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
|
|
183
|
+
count (int): The number of periods to shift
|
|
184
|
+
day (int): The weekday
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
def __init__(self, count: int, day: int):
|
|
188
|
+
self._count = count
|
|
189
|
+
if day not in range(7):
|
|
190
|
+
raise ValueError('Invalid weekday: %d' % day)
|
|
191
|
+
self._day = day
|
|
192
|
+
self._drcls = _WEEKDAY_CLS[day]
|
|
193
|
+
|
|
194
|
+
def __radd__(self, dt: datetime.datetime) -> datetime.datetime:
|
|
195
|
+
if self._count > 0:
|
|
196
|
+
return dt + dr.relativedelta(
|
|
197
|
+
days=1, weekday=self._drcls(self._count))
|
|
198
|
+
if self._count < 0:
|
|
199
|
+
return dt + dr.relativedelta(
|
|
200
|
+
days=-1, weekday=self._drcls(self._count))
|
|
201
|
+
dow = (dt.weekday() + 1) % 7
|
|
202
|
+
if self._day < dow:
|
|
203
|
+
return dt + dr.relativedelta(weekday=self._drcls(-1))
|
|
204
|
+
return dt + dr.relativedelta(weekday=self._drcls(1))
|
|
205
|
+
|
|
206
|
+
PARSE_RE = re.compile(r'''
|
|
207
|
+
^
|
|
208
|
+
(?P<count> [+-] (?: [0-9]+) )?
|
|
209
|
+
(?P<weekday> sun|mon|tue|wed|thu|fri|sat)
|
|
210
|
+
$
|
|
211
|
+
''', re.X)
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def parse(cls, cmdstr: str) -> 'Weekday':
|
|
215
|
+
"""Parse a command string to a Weekday object
|
|
216
|
+
|
|
217
|
+
The command string should be of the form "<N><weekday>", where
|
|
218
|
+
<N> is an explicitly signed number or empty string
|
|
219
|
+
(representing 0), and <weekday> is a weekday 3-letter string
|
|
220
|
+
like sun, mon, etc (case insensitive).
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
|
|
224
|
+
cmdstr (str): The command string
|
|
225
|
+
|
|
226
|
+
"""
|
|
227
|
+
match = cls.PARSE_RE.match(cmdstr.lower())
|
|
228
|
+
if not match:
|
|
229
|
+
raise ParseError('Cannot parse string %s' % cmdstr)
|
|
230
|
+
gdt = match.groupdict()
|
|
231
|
+
cnt = int(gdt['count']) if gdt['count'] else 0
|
|
232
|
+
return cls(cnt, _WEEKDAY_NUM[gdt['weekday']])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class PartialDate:
|
|
236
|
+
"""Represent a command that set or shift by partial date
|
|
237
|
+
|
|
238
|
+
A partial date command specifies a count and the values of some of
|
|
239
|
+
year, month, day, hour, minute, second and microsecond. The
|
|
240
|
+
specified value must be contiguous among the parts above.
|
|
241
|
+
|
|
242
|
+
Using a count of 0 sets the specified fields. It raises an error
|
|
243
|
+
if the result is an invalid date.
|
|
244
|
+
|
|
245
|
+
Using a positive or negative count shift the date forward or
|
|
246
|
+
backward, and in this case the year must not be specified. It
|
|
247
|
+
only counts valid dates. E.g., you can shift forward by a certain
|
|
248
|
+
number of Feb 29. The exception is when setting the month only.
|
|
249
|
+
In that case, if the result is an invalid date, the date is
|
|
250
|
+
"truncated" to the last valid date.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
|
|
254
|
+
count: The number of periods to shift
|
|
255
|
+
year: The year number
|
|
256
|
+
month: The month number (1 to 12)
|
|
257
|
+
day: The day number (1 to 31)
|
|
258
|
+
hour: The hour number (0 to 23)
|
|
259
|
+
minute: The minute number (0 to 59)
|
|
260
|
+
second: The second number (0 to smaller than 60)
|
|
261
|
+
microsecond: The microsecond number (0 to 999999)
|
|
262
|
+
zero: Whether to zero out the fields after the last specified one
|
|
263
|
+
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
_INVALID_SIG_RE = re.compile('10+1')
|
|
267
|
+
|
|
268
|
+
def __init__(
|
|
269
|
+
self, count: int = 0, year: typing.Optional[int] = None,
|
|
270
|
+
month: typing.Optional[int] = None, day: typing.Optional[int] = None,
|
|
271
|
+
hour: typing.Optional[int] = None, minute: typing.Optional[int] = None,
|
|
272
|
+
second: typing.Optional[typing.Union[int, float]] = None,
|
|
273
|
+
microsecond: typing.Optional[int] = None,
|
|
274
|
+
zero: bool = False
|
|
275
|
+
):
|
|
276
|
+
if count and year:
|
|
277
|
+
raise ValueError('Absolute date with non-zero count')
|
|
278
|
+
if isinstance(second, float) and microsecond is not None:
|
|
279
|
+
raise ValueError('Doubly specified microsecond')
|
|
280
|
+
if isinstance(second, float):
|
|
281
|
+
second, orig_second = int(second), second
|
|
282
|
+
microsecond = int((orig_second - second) * 1000000 + 0.5)
|
|
283
|
+
vals = [year, month, day, hour, minute, second, microsecond]
|
|
284
|
+
sig = ''.join([("0" if v is None else "1") for v in vals])
|
|
285
|
+
if self._INVALID_SIG_RE.search(sig):
|
|
286
|
+
raise ValueError('Non-consecutive components')
|
|
287
|
+
if zero:
|
|
288
|
+
lastset = sig.rfind('1')
|
|
289
|
+
for i in range(lastset + 1, 7):
|
|
290
|
+
vals[i] = 0
|
|
291
|
+
year, month, day, hour, minute, second, microsecond = vals
|
|
292
|
+
self._count = count
|
|
293
|
+
self._year = year
|
|
294
|
+
self._month = month
|
|
295
|
+
self._day = day
|
|
296
|
+
self._hour = hour
|
|
297
|
+
self._minute = minute
|
|
298
|
+
self._second = second
|
|
299
|
+
self._microsecond = microsecond
|
|
300
|
+
self._firstset = sig.find('1')
|
|
301
|
+
|
|
302
|
+
_FIRSTSET_MOD = [
|
|
303
|
+
'', 'years', 'months', 'days', 'hours', 'minutes', 'seconds'
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
def __radd__(self, dt: datetime.datetime) -> datetime.datetime:
|
|
307
|
+
if self._firstset == -1:
|
|
308
|
+
return dt
|
|
309
|
+
if not(self._count):
|
|
310
|
+
return self._rset(dt)
|
|
311
|
+
# modify day or finer, or day specified and is not vulnerable
|
|
312
|
+
# to variable month length
|
|
313
|
+
if self._firstset > 2 or \
|
|
314
|
+
(self._day is not None and self._day <= 28):
|
|
315
|
+
return self._simpleshift(dt)
|
|
316
|
+
if self._day is None:
|
|
317
|
+
return self._monthshift(dt)
|
|
318
|
+
return self._dayshift(dt)
|
|
319
|
+
|
|
320
|
+
def _rset(self, dt: datetime.datetime) -> datetime.datetime:
|
|
321
|
+
updater = {'year': self._year,
|
|
322
|
+
'month': self._month,
|
|
323
|
+
'day': self._day,
|
|
324
|
+
'hour': self._hour,
|
|
325
|
+
'minute': self._minute,
|
|
326
|
+
'second': self._second,
|
|
327
|
+
'microsecond': self._microsecond}
|
|
328
|
+
updater = {k: v for k, v in updater.items() if v is not None}
|
|
329
|
+
return dt.replace(**updater) # type: ignore
|
|
330
|
+
|
|
331
|
+
def _simpleshift(self, dt: datetime.datetime) -> datetime.datetime:
|
|
332
|
+
remain = self._count
|
|
333
|
+
ret = self._rset(dt)
|
|
334
|
+
if self._count < 0:
|
|
335
|
+
if ret < dt:
|
|
336
|
+
remain += 1
|
|
337
|
+
else:
|
|
338
|
+
if ret > dt:
|
|
339
|
+
remain -= 1
|
|
340
|
+
mod_field = self._FIRSTSET_MOD[self._firstset]
|
|
341
|
+
return ret + dr.relativedelta(**{mod_field: remain}) # type: ignore
|
|
342
|
+
|
|
343
|
+
def _dayshift(self, dt: datetime.datetime) -> datetime.datetime:
|
|
344
|
+
# Day specified
|
|
345
|
+
if self._firstset == 2: # modify month
|
|
346
|
+
shift = dr.relativedelta(months=1 if self._count > 0 else -1)
|
|
347
|
+
limit = 2 # Must be able to find a 31st day in 2 months
|
|
348
|
+
else:
|
|
349
|
+
shift = dr.relativedelta(years=1 if self._count > 0 else -1)
|
|
350
|
+
limit = 8 # Must be able to find a Feb 29 in 8 years
|
|
351
|
+
count = abs(self._count)
|
|
352
|
+
# Find first date
|
|
353
|
+
curr = dt
|
|
354
|
+
for _ in range(limit):
|
|
355
|
+
try:
|
|
356
|
+
ret = self._rset(curr)
|
|
357
|
+
except ValueError:
|
|
358
|
+
curr += shift
|
|
359
|
+
continue
|
|
360
|
+
if (self._count > 0) == (ret > dt):
|
|
361
|
+
count -= 1
|
|
362
|
+
break
|
|
363
|
+
else:
|
|
364
|
+
raise ValueError('Failed day shifting: invalid date?')
|
|
365
|
+
# Find count occurrences
|
|
366
|
+
while True:
|
|
367
|
+
if count == 0:
|
|
368
|
+
return ret
|
|
369
|
+
ret += shift
|
|
370
|
+
try:
|
|
371
|
+
ret = self._rset(ret)
|
|
372
|
+
except ValueError:
|
|
373
|
+
continue
|
|
374
|
+
count -= 1
|
|
375
|
+
|
|
376
|
+
def _monthshift(self, dt: datetime.datetime) -> datetime.datetime:
|
|
377
|
+
# Only month specified, shift by month rather than by year
|
|
378
|
+
assert self._month is not None
|
|
379
|
+
if self._count > 0:
|
|
380
|
+
num_months = self._month - dt.month
|
|
381
|
+
sign = 1
|
|
382
|
+
else:
|
|
383
|
+
num_months = dt.month - self._month
|
|
384
|
+
sign = -1
|
|
385
|
+
if num_months <= 0:
|
|
386
|
+
num_months += 12
|
|
387
|
+
num_months += (abs(self._count) - 1) * 12
|
|
388
|
+
return dt + dr.relativedelta(months=sign * num_months)
|
|
389
|
+
|
|
390
|
+
PARSE_RE1 = re.compile(r'''
|
|
391
|
+
^
|
|
392
|
+
(?: (?P<count> [+-]? (?: [0-9]+ | [0-9]*\.[0-9]*) ) x)?
|
|
393
|
+
(?P<year> [0-9]*)
|
|
394
|
+
-
|
|
395
|
+
(?P<month> [0-9]*)
|
|
396
|
+
-
|
|
397
|
+
(?P<day> [0-9]*)
|
|
398
|
+
$
|
|
399
|
+
''', re.X)
|
|
400
|
+
|
|
401
|
+
PARSE_RE2 = re.compile(r'''
|
|
402
|
+
^
|
|
403
|
+
(?: (?P<count> [+-] (?: [0-9]+ | [0-9]*\.[0-9]*) ) x)?
|
|
404
|
+
(?:
|
|
405
|
+
(?P<year> [0-9]*)
|
|
406
|
+
-
|
|
407
|
+
(?P<month> [0-9]*)
|
|
408
|
+
-
|
|
409
|
+
(?P<day> [0-9]*)
|
|
410
|
+
t
|
|
411
|
+
)?
|
|
412
|
+
(?P<hour> [0-9]*)
|
|
413
|
+
:
|
|
414
|
+
(?P<minute> [0-9]*)
|
|
415
|
+
:
|
|
416
|
+
(?P<second> [0-9]*)
|
|
417
|
+
(?:\. (?P<microsecond> [0-9]*) )?
|
|
418
|
+
$
|
|
419
|
+
''', re.X)
|
|
420
|
+
|
|
421
|
+
@classmethod
|
|
422
|
+
def parse(cls, cmdstr: str) -> 'PartialDate':
|
|
423
|
+
"""Parse a command string to a PartialDate object
|
|
424
|
+
|
|
425
|
+
The command string should be of the form
|
|
426
|
+
"<N>x<year>-<month>-<day>T<hour>:<minute>:<second>.<micro>",
|
|
427
|
+
where <N> is an explicitly signed number or empty string
|
|
428
|
+
(representing 0). To skip the specification of a part use
|
|
429
|
+
empty string. If all date parts are not specified the "--T"
|
|
430
|
+
may be omitted. If all the time parts are not specified the
|
|
431
|
+
"T::." may be omitted. If the microsecond part is not
|
|
432
|
+
specified the "." part may be omitted. A trailing "/" causes
|
|
433
|
+
all fields after the last specified field to be set to 0.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
|
|
437
|
+
cmdstr (str): The command string
|
|
438
|
+
|
|
439
|
+
"""
|
|
440
|
+
zero = cmdstr.endswith('/')
|
|
441
|
+
if zero:
|
|
442
|
+
cmdstr = cmdstr[:-1]
|
|
443
|
+
match = cls.PARSE_RE1.match(cmdstr.lower())
|
|
444
|
+
if not match:
|
|
445
|
+
match = cls.PARSE_RE2.match(cmdstr.lower())
|
|
446
|
+
if not match:
|
|
447
|
+
raise ParseError('Cannot parse string %s' % cmdstr)
|
|
448
|
+
gdt = match.groupdict()
|
|
449
|
+
|
|
450
|
+
def _matchval(key: str) -> typing.Optional[int]:
|
|
451
|
+
val = gdt.get(key)
|
|
452
|
+
if not val:
|
|
453
|
+
return None
|
|
454
|
+
return int(val)
|
|
455
|
+
|
|
456
|
+
microsecond = None
|
|
457
|
+
msval = gdt.get('microsecond')
|
|
458
|
+
if msval:
|
|
459
|
+
microsecond = int(msval.ljust(6, '0')[:6])
|
|
460
|
+
return cls(_matchval('count') or 0,
|
|
461
|
+
_matchval('year'),
|
|
462
|
+
_matchval('month'),
|
|
463
|
+
_matchval('day'),
|
|
464
|
+
_matchval('hour'),
|
|
465
|
+
_matchval('minute'),
|
|
466
|
+
_matchval('second'),
|
|
467
|
+
microsecond,
|
|
468
|
+
zero)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def parse(cmdstr: str) -> typing.Union[Period, Weekday, PartialDate]:
|
|
472
|
+
"""Attempt to parse one of the possible date command
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
|
|
476
|
+
cmdstr (str): The command string
|
|
477
|
+
|
|
478
|
+
"""
|
|
479
|
+
try:
|
|
480
|
+
return Period.parse(cmdstr)
|
|
481
|
+
except ParseError:
|
|
482
|
+
pass
|
|
483
|
+
try:
|
|
484
|
+
return Weekday.parse(cmdstr)
|
|
485
|
+
except ParseError:
|
|
486
|
+
pass
|
|
487
|
+
return PartialDate.parse(cmdstr)
|
|
@@ -46,6 +46,11 @@ They are represented by either a Weekday object or a PartialDate
|
|
|
46
46
|
object with a count. A count of 0 means setting instead of shifting.
|
|
47
47
|
Only integer counts are acceptable.
|
|
48
48
|
|
|
49
|
+
A trailing "/" on a partial date command sets all fields after the
|
|
50
|
+
last specified field to zero. For example, `12::/` sets the hour to
|
|
51
|
+
12 and the minute, second and microsecond to 0, whereas `12::` would
|
|
52
|
+
leave those unchanged.
|
|
53
|
+
|
|
49
54
|
It is an error to set to an invalid date (e.g., --31 applied on
|
|
50
55
|
2019-06-25 is an error). The datetime parts which are specified must
|
|
51
56
|
be consecutive (it is an error to specify 12::05). It is also an
|
|
@@ -79,6 +84,7 @@ is observed. Contributions are welcome.
|
|
|
79
84
|
|
|
80
85
|
"""
|
|
81
86
|
|
|
87
|
+
import contextlib
|
|
82
88
|
import datetime
|
|
83
89
|
import re
|
|
84
90
|
import typing
|
|
@@ -86,9 +92,7 @@ import typing
|
|
|
86
92
|
import dateutil.relativedelta as dr
|
|
87
93
|
|
|
88
94
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
__version__ = '0.2'
|
|
95
|
+
__version__ = '0.3'
|
|
92
96
|
|
|
93
97
|
class ParseError(ValueError):
|
|
94
98
|
"""Represent an error in parsing."""
|
|
@@ -111,8 +115,11 @@ class Period:
|
|
|
111
115
|
|
|
112
116
|
"""
|
|
113
117
|
def __init__(self, count: float, period: str):
|
|
114
|
-
|
|
115
|
-
'hour', 'minute', 'second')
|
|
118
|
+
if period not in ('year', 'month', 'week', 'day',
|
|
119
|
+
'hour', 'minute', 'second'):
|
|
120
|
+
raise ValueError(f'Invalid period: {period}')
|
|
121
|
+
if period in ('year', 'month') and count != int(count):
|
|
122
|
+
raise ValueError(f'Invalid count {count} for period {period}')
|
|
116
123
|
self._count = count
|
|
117
124
|
self._period = period
|
|
118
125
|
|
|
@@ -145,11 +152,12 @@ class Period:
|
|
|
145
152
|
raise ParseError('Cannot parse string %s' % cmdstr)
|
|
146
153
|
gdt = match.groupdict()
|
|
147
154
|
cnt: float
|
|
155
|
+
with contextlib.suppress(ValueError):
|
|
156
|
+
return cls(int(gdt['count']), gdt['period'])
|
|
148
157
|
try:
|
|
149
|
-
|
|
150
|
-
except
|
|
151
|
-
|
|
152
|
-
return cls(cnt, gdt['period'])
|
|
158
|
+
return cls(float(gdt['count']), gdt['period'])
|
|
159
|
+
except ValueError as err:
|
|
160
|
+
raise ParseError('Cannot parse string %s' % cmdstr)
|
|
153
161
|
|
|
154
162
|
|
|
155
163
|
_WEEKDAY_CLS = [dr.SU, dr.MO, dr.TU, dr.WE, dr.TH, dr.FR, dr.SA]
|
|
@@ -178,7 +186,8 @@ class Weekday:
|
|
|
178
186
|
"""
|
|
179
187
|
def __init__(self, count: int, day: int):
|
|
180
188
|
self._count = count
|
|
181
|
-
|
|
189
|
+
if day not in range(7):
|
|
190
|
+
raise ValueError('Invalid weekday: %d' % day)
|
|
182
191
|
self._day = day
|
|
183
192
|
self._drcls = _WEEKDAY_CLS[day]
|
|
184
193
|
|
|
@@ -196,7 +205,7 @@ class Weekday:
|
|
|
196
205
|
|
|
197
206
|
PARSE_RE = re.compile(r'''
|
|
198
207
|
^
|
|
199
|
-
(?P<count> [+-] (?: [0-9]+
|
|
208
|
+
(?P<count> [+-] (?: [0-9]+) )?
|
|
200
209
|
(?P<weekday> sun|mon|tue|wed|thu|fri|sat)
|
|
201
210
|
$
|
|
202
211
|
''', re.X)
|
|
@@ -242,14 +251,15 @@ class PartialDate:
|
|
|
242
251
|
|
|
243
252
|
Args:
|
|
244
253
|
|
|
245
|
-
count
|
|
246
|
-
year
|
|
247
|
-
month
|
|
248
|
-
day
|
|
249
|
-
hour
|
|
250
|
-
minute
|
|
251
|
-
second
|
|
252
|
-
microsecond
|
|
254
|
+
count: The number of periods to shift
|
|
255
|
+
year: The year number
|
|
256
|
+
month: The month number (1 to 12)
|
|
257
|
+
day: The day number (1 to 31)
|
|
258
|
+
hour: The hour number (0 to 23)
|
|
259
|
+
minute: The minute number (0 to 59)
|
|
260
|
+
second: The second number (0 to smaller than 60)
|
|
261
|
+
microsecond: The microsecond number (0 to 999999)
|
|
262
|
+
zero: Whether to zero out the fields after the last specified one
|
|
253
263
|
|
|
254
264
|
"""
|
|
255
265
|
|
|
@@ -259,19 +269,26 @@ class PartialDate:
|
|
|
259
269
|
self, count: int = 0, year: typing.Optional[int] = None,
|
|
260
270
|
month: typing.Optional[int] = None, day: typing.Optional[int] = None,
|
|
261
271
|
hour: typing.Optional[int] = None, minute: typing.Optional[int] = None,
|
|
262
|
-
second: typing.Optional[int] = None,
|
|
263
|
-
microsecond: typing.Optional[int] = None
|
|
272
|
+
second: typing.Optional[typing.Union[int, float]] = None,
|
|
273
|
+
microsecond: typing.Optional[int] = None,
|
|
274
|
+
zero: bool = False
|
|
264
275
|
):
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
sig = ''.join([("0" if v is None else "1") for v in vals])
|
|
270
|
-
assert not self._INVALID_SIG_RE.search(sig), \
|
|
271
|
-
'Non-consecutive components'
|
|
276
|
+
if count and year:
|
|
277
|
+
raise ValueError('Absolute date with non-zero count')
|
|
278
|
+
if isinstance(second, float) and microsecond is not None:
|
|
279
|
+
raise ValueError('Doubly specified microsecond')
|
|
272
280
|
if isinstance(second, float):
|
|
273
281
|
second, orig_second = int(second), second
|
|
274
282
|
microsecond = int((orig_second - second) * 1000000 + 0.5)
|
|
283
|
+
vals = [year, month, day, hour, minute, second, microsecond]
|
|
284
|
+
sig = ''.join([("0" if v is None else "1") for v in vals])
|
|
285
|
+
if self._INVALID_SIG_RE.search(sig):
|
|
286
|
+
raise ValueError('Non-consecutive components')
|
|
287
|
+
if zero:
|
|
288
|
+
lastset = sig.rfind('1')
|
|
289
|
+
for i in range(lastset + 1, 7):
|
|
290
|
+
vals[i] = 0
|
|
291
|
+
year, month, day, hour, minute, second, microsecond = vals
|
|
275
292
|
self._count = count
|
|
276
293
|
self._year = year
|
|
277
294
|
self._month = month
|
|
@@ -412,13 +429,17 @@ class PartialDate:
|
|
|
412
429
|
empty string. If all date parts are not specified the "--T"
|
|
413
430
|
may be omitted. If all the time parts are not specified the
|
|
414
431
|
"T::." may be omitted. If the microsecond part is not
|
|
415
|
-
specified the "." part may be omitted.
|
|
432
|
+
specified the "." part may be omitted. A trailing "/" causes
|
|
433
|
+
all fields after the last specified field to be set to 0.
|
|
416
434
|
|
|
417
435
|
Args:
|
|
418
436
|
|
|
419
437
|
cmdstr (str): The command string
|
|
420
438
|
|
|
421
439
|
"""
|
|
440
|
+
zero = cmdstr.endswith('/')
|
|
441
|
+
if zero:
|
|
442
|
+
cmdstr = cmdstr[:-1]
|
|
422
443
|
match = cls.PARSE_RE1.match(cmdstr.lower())
|
|
423
444
|
if not match:
|
|
424
445
|
match = cls.PARSE_RE2.match(cmdstr)
|
|
@@ -443,7 +464,8 @@ class PartialDate:
|
|
|
443
464
|
_matchval('hour'),
|
|
444
465
|
_matchval('minute'),
|
|
445
466
|
_matchval('second'),
|
|
446
|
-
microsecond
|
|
467
|
+
microsecond,
|
|
468
|
+
zero)
|
|
447
469
|
|
|
448
470
|
|
|
449
471
|
def parse(cmdstr: str) -> typing.Union[Period, Weekday, PartialDate]:
|
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
from __future__ import print_function
|
|
2
2
|
|
|
3
|
-
import sys
|
|
4
3
|
import datetime
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
5
6
|
|
|
6
7
|
import datec
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
def main() -> None:
|
|
10
11
|
dt = datetime.datetime.now()
|
|
12
|
+
wait = False
|
|
11
13
|
for cmd in sys.argv[1:]:
|
|
12
14
|
if cmd == '-h':
|
|
13
15
|
print('''Date command
|
|
14
16
|
|
|
15
|
-
Usage: datec [<command>] ...
|
|
17
|
+
Usage: datec [-w] [<command>] ...
|
|
16
18
|
|
|
17
|
-
Datec starts from the current time and apply commands.
|
|
18
|
-
datetime is printed in ISO YYYY-MM-DDTHH:MM:SS.ffffff format.
|
|
19
|
+
Datec starts from the current time and apply commands. Normally the
|
|
20
|
+
ending datetime is printed in ISO YYYY-MM-DDTHH:MM:SS.ffffff format.
|
|
21
|
+
But if -w is given, no output is printed; instead the program sleeps
|
|
22
|
+
until the ending datetime.
|
|
19
23
|
|
|
20
24
|
<command> may be of the following forms:
|
|
21
25
|
|
|
@@ -28,8 +32,16 @@ datetime is printed in ISO YYYY-MM-DDTHH:MM:SS.ffffff format.
|
|
|
28
32
|
|
|
29
33
|
''', file=sys.stderr)
|
|
30
34
|
sys.exit(0)
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
if cmd == '-w':
|
|
36
|
+
wait = True
|
|
37
|
+
continue
|
|
38
|
+
dt = dt + datec.parse(cmd)
|
|
39
|
+
if wait:
|
|
40
|
+
delta = (dt - datetime.datetime.now()).total_seconds()
|
|
41
|
+
if delta > 0:
|
|
42
|
+
time.sleep(delta)
|
|
43
|
+
else:
|
|
44
|
+
print(dt.isoformat())
|
|
33
45
|
|
|
34
46
|
|
|
35
47
|
if __name__ == '__main__':
|
|
File without changes
|
|
@@ -13,6 +13,8 @@ class DatecTest(unittest.TestCase):
|
|
|
13
13
|
datetime.datetime(2019, 5, 15, 0, 0, 1, 500000))
|
|
14
14
|
with self.assertRaises(datec.ParseError):
|
|
15
15
|
datec.Period.parse('+1mon')
|
|
16
|
+
with self.assertRaises(datec.ParseError):
|
|
17
|
+
datec.Period.parse('+.day')
|
|
16
18
|
|
|
17
19
|
def test_weekday(self):
|
|
18
20
|
dt = datetime.datetime(2019, 5, 15)
|
|
@@ -41,6 +43,8 @@ class DatecTest(unittest.TestCase):
|
|
|
41
43
|
datetime.datetime(2019, 5, 15, 8, 17, 16))
|
|
42
44
|
self.assertEqual(dt + datec.PartialDate(2, minute=7, second=16),
|
|
43
45
|
datetime.datetime(2019, 5, 15, 10, 7, 16))
|
|
46
|
+
self.assertEqual(dt + datec.PartialDate(2, minute=7, second=16.5),
|
|
47
|
+
datetime.datetime(2019, 5, 15, 10, 7, 16, 500000))
|
|
44
48
|
self.assertEqual(dt + datec.PartialDate.parse(':7:16'),
|
|
45
49
|
datetime.datetime(2019, 5, 15, 8, 7, 16))
|
|
46
50
|
self.assertEqual(dt + datec.PartialDate.parse('-1x:7:16'),
|
|
@@ -80,3 +84,35 @@ class DatecTest(unittest.TestCase):
|
|
|
80
84
|
datetime.datetime(2019, 3, 30))
|
|
81
85
|
with self.assertRaises(ValueError):
|
|
82
86
|
dt + datec.PartialDate(1, month=2, day=30)
|
|
87
|
+
|
|
88
|
+
def test_partialdate_zero(self):
|
|
89
|
+
dt = datetime.datetime(2019, 5, 15, 8, 15)
|
|
90
|
+
self.assertEqual(dt + datec.PartialDate(0, hour=12, zero=True),
|
|
91
|
+
datetime.datetime(2019, 5, 15, 12, 0, 0))
|
|
92
|
+
self.assertEqual(dt + datec.PartialDate(0, minute=30, zero=True),
|
|
93
|
+
datetime.datetime(2019, 5, 15, 8, 30, 0))
|
|
94
|
+
self.assertEqual(dt + datec.parse('12::/'),
|
|
95
|
+
datetime.datetime(2019, 5, 15, 12, 0, 0))
|
|
96
|
+
self.assertEqual(dt + datec.parse('12:30:/'),
|
|
97
|
+
datetime.datetime(2019, 5, 15, 12, 30, 0))
|
|
98
|
+
self.assertEqual(dt + datec.parse('2020-03-15/'),
|
|
99
|
+
datetime.datetime(2020, 3, 15, 0, 0, 0))
|
|
100
|
+
# without zero, minute/second are unchanged
|
|
101
|
+
self.assertEqual(dt + datec.PartialDate(0, hour=12),
|
|
102
|
+
datetime.datetime(2019, 5, 15, 12, 15))
|
|
103
|
+
self.assertEqual(dt + datec.parse('12::'),
|
|
104
|
+
datetime.datetime(2019, 5, 15, 12, 15))
|
|
105
|
+
|
|
106
|
+
def test_validation(self):
|
|
107
|
+
with self.assertRaises(ValueError):
|
|
108
|
+
datec.Period(1, 'invalid')
|
|
109
|
+
with self.assertRaises(ValueError):
|
|
110
|
+
datec.Period(1.5, 'month')
|
|
111
|
+
with self.assertRaises(ValueError):
|
|
112
|
+
datec.Weekday(1, 7)
|
|
113
|
+
with self.assertRaises(ValueError):
|
|
114
|
+
datec.PartialDate(1, year=2020)
|
|
115
|
+
with self.assertRaises(ValueError):
|
|
116
|
+
datec.PartialDate(second=1.5, microsecond=500)
|
|
117
|
+
with self.assertRaises(ValueError):
|
|
118
|
+
datec.PartialDate(hour=12, second=30)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|