datec 0.2__py3-none-any.whl
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/__init__.py +465 -0
- datec/__main__.py +36 -0
- datec-0.2.dist-info/METADATA +110 -0
- datec-0.2.dist-info/RECORD +7 -0
- datec-0.2.dist-info/WHEEL +4 -0
- datec-0.2.dist-info/entry_points.txt +2 -0
- datec-0.2.dist-info/licenses/LICENSE +21 -0
datec/__init__.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
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
|
+
It is an error to set to an invalid date (e.g., --31 applied on
|
|
50
|
+
2019-06-25 is an error). The datetime parts which are specified must
|
|
51
|
+
be consecutive (it is an error to specify 12::05). It is also an
|
|
52
|
+
error to shift for occurrence of a partial date with year specified
|
|
53
|
+
(e.g., "+2x2019--").
|
|
54
|
+
|
|
55
|
+
On the other hand, shifting to an invalid date with day number
|
|
56
|
+
specified will shift more until a specified date is valid. For
|
|
57
|
+
example, if you add -2-29 with count 1 to 2019-01-01, you end up with
|
|
58
|
+
2020-02-29, because 2019-02-29 is not a valid date. If the count is 2
|
|
59
|
+
you get 2024-02-29 instead.
|
|
60
|
+
|
|
61
|
+
Shifting to an invalid date by a partial date with just a month number
|
|
62
|
+
will cause the date to moved backwards until the date is valid. E.g.,
|
|
63
|
+
if you shift by -6- with count 1 (next June) from 2019-05-31, you get
|
|
64
|
+
2019-06-30. With count 2 you get 2020-06-30.
|
|
65
|
+
|
|
66
|
+
This library is grown out of frustration that it is tedious to have a
|
|
67
|
+
shell script or program to get a datetime like "the next 6pm from now"
|
|
68
|
+
or "the next 3rd of any month from two days ago". With this module
|
|
69
|
+
they can be specified like "+1x18:00:00.0" and "-2day +1x--3"
|
|
70
|
+
respectively. In the expected use cases, counts are small numbers.
|
|
71
|
+
So the library is not always efficient (at times we just loop "count"
|
|
72
|
+
times to step forward or backward). Whenever it is simple to do so,
|
|
73
|
+
the implementation just forward to relativedelta, in which case they
|
|
74
|
+
are more efficient.
|
|
75
|
+
|
|
76
|
+
At present the program does not handle timezone and daylight saving.
|
|
77
|
+
This is bacause the author lives at a place where no daylight saving
|
|
78
|
+
is observed. Contributions are welcome.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
import datetime
|
|
83
|
+
import re
|
|
84
|
+
import typing
|
|
85
|
+
|
|
86
|
+
import dateutil.relativedelta as dr
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__metaclass__ = type
|
|
90
|
+
|
|
91
|
+
__version__ = '0.2'
|
|
92
|
+
|
|
93
|
+
class ParseError(ValueError):
|
|
94
|
+
"""Represent an error in parsing."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Period:
|
|
98
|
+
"""Represent a command that shift a number of period
|
|
99
|
+
|
|
100
|
+
A period may be a year, month, week, day, hour, minute or second,
|
|
101
|
+
which is the string to be used in the period argument. If you
|
|
102
|
+
shift by month/year and it ends up into an invalid date, the
|
|
103
|
+
result is "truncated" back to the previous valid day. Shifting a
|
|
104
|
+
non-integer number of periods is supported except for months and
|
|
105
|
+
years.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
|
|
109
|
+
count (float): The number of periods to shift
|
|
110
|
+
period (str): The period
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
def __init__(self, count: float, period: str):
|
|
114
|
+
assert period in ('year', 'month', 'week', 'day',
|
|
115
|
+
'hour', 'minute', 'second')
|
|
116
|
+
self._count = count
|
|
117
|
+
self._period = period
|
|
118
|
+
|
|
119
|
+
def __radd__(self, dt: datetime.datetime) -> datetime.datetime:
|
|
120
|
+
return dt + dr.relativedelta(
|
|
121
|
+
**{self._period + 's': self._count}) # type: ignore
|
|
122
|
+
|
|
123
|
+
PARSE_RE = re.compile(r'''
|
|
124
|
+
^
|
|
125
|
+
(?P<count> [+-] (?: [0-9]+ | [0-9]*\.[0-9]*) )
|
|
126
|
+
(?P<period> year|month|week|day|hour|minute|second)
|
|
127
|
+
$
|
|
128
|
+
''', re.X)
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def parse(cls, cmdstr: str) -> 'Period':
|
|
132
|
+
"""Parse a command string to a Period object
|
|
133
|
+
|
|
134
|
+
The command string should be of the form "<N><period>", where
|
|
135
|
+
<N> is an explicitly signed number, and <period> is a period
|
|
136
|
+
string (case insensitive).
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
|
|
140
|
+
cmdstr (str): The command string
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
match = cls.PARSE_RE.match(cmdstr.lower())
|
|
144
|
+
if not match:
|
|
145
|
+
raise ParseError('Cannot parse string %s' % cmdstr)
|
|
146
|
+
gdt = match.groupdict()
|
|
147
|
+
cnt: float
|
|
148
|
+
try:
|
|
149
|
+
cnt = int(gdt['count'])
|
|
150
|
+
except Exception:
|
|
151
|
+
cnt = float(gdt['count'])
|
|
152
|
+
return cls(cnt, gdt['period'])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_WEEKDAY_CLS = [dr.SU, dr.MO, dr.TU, dr.WE, dr.TH, dr.FR, dr.SA]
|
|
156
|
+
SUN, MON, TUE, WED, THU, FRI, SAT = range(7)
|
|
157
|
+
_WEEKDAY_NUM = {'sun': 0, 'mon': 1, 'tue': 2, 'wed': 3,
|
|
158
|
+
'thu': 4, 'fri': 5, 'sat': 6}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Weekday:
|
|
162
|
+
"""Represent a command that set or shift by weekday
|
|
163
|
+
|
|
164
|
+
A weekday is a number from 0 to 6, representing Sunday, Monday,
|
|
165
|
+
..., Friday (the constants SUN, MON, etc. are provided for
|
|
166
|
+
readability of constant weekdays). If you set a weekday, by using
|
|
167
|
+
a zero count, it moves to the weekday of the current week (week
|
|
168
|
+
always starts on Sunday). A non-zero (integer) count would
|
|
169
|
+
instead shift forward or backward by that number of occurrences of
|
|
170
|
+
that weekday. If the original date is already that weekday it is
|
|
171
|
+
not counted as one of those occurrences.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
|
|
175
|
+
count (int): The number of periods to shift
|
|
176
|
+
day (int): The weekday
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
def __init__(self, count: int, day: int):
|
|
180
|
+
self._count = count
|
|
181
|
+
assert day in range(7)
|
|
182
|
+
self._day = day
|
|
183
|
+
self._drcls = _WEEKDAY_CLS[day]
|
|
184
|
+
|
|
185
|
+
def __radd__(self, dt: datetime.datetime) -> datetime.datetime:
|
|
186
|
+
if self._count > 0:
|
|
187
|
+
return dt + dr.relativedelta(
|
|
188
|
+
days=1, weekday=self._drcls(self._count))
|
|
189
|
+
if self._count < 0:
|
|
190
|
+
return dt + dr.relativedelta(
|
|
191
|
+
days=-1, weekday=self._drcls(self._count))
|
|
192
|
+
dow = (dt.weekday() + 1) % 7
|
|
193
|
+
if self._day < dow:
|
|
194
|
+
return dt + dr.relativedelta(weekday=self._drcls(-1))
|
|
195
|
+
return dt + dr.relativedelta(weekday=self._drcls(1))
|
|
196
|
+
|
|
197
|
+
PARSE_RE = re.compile(r'''
|
|
198
|
+
^
|
|
199
|
+
(?P<count> [+-] (?: [0-9]+ | [0-9]*\.[0-9]*) )?
|
|
200
|
+
(?P<weekday> sun|mon|tue|wed|thu|fri|sat)
|
|
201
|
+
$
|
|
202
|
+
''', re.X)
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def parse(cls, cmdstr: str) -> 'Weekday':
|
|
206
|
+
"""Parse a command string to a Weekday object
|
|
207
|
+
|
|
208
|
+
The command string should be of the form "<N><weekday>", where
|
|
209
|
+
<N> is an explicitly signed number or empty string
|
|
210
|
+
(representing 0), and <weekday> is a weekday 3-letter string
|
|
211
|
+
like sun, mon, etc (case insensitive).
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
|
|
215
|
+
cmdstr (str): The command string
|
|
216
|
+
|
|
217
|
+
"""
|
|
218
|
+
match = cls.PARSE_RE.match(cmdstr.lower())
|
|
219
|
+
if not match:
|
|
220
|
+
raise ParseError('Cannot parse string %s' % cmdstr)
|
|
221
|
+
gdt = match.groupdict()
|
|
222
|
+
cnt = int(gdt['count']) if gdt['count'] else 0
|
|
223
|
+
return cls(cnt, _WEEKDAY_NUM[gdt['weekday']])
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class PartialDate:
|
|
227
|
+
"""Represent a command that set or shift by partial date
|
|
228
|
+
|
|
229
|
+
A partial date command specifies a count and the values of some of
|
|
230
|
+
year, month, day, hour, minute, second and microsecond. The
|
|
231
|
+
specified value must be contiguous among the parts above.
|
|
232
|
+
|
|
233
|
+
Using a count of 0 sets the specified fields. It raises an error
|
|
234
|
+
if the result is an invalid date.
|
|
235
|
+
|
|
236
|
+
Using a positive or negative count shift the date forward or
|
|
237
|
+
backward, and in this case the year must not be specified. It
|
|
238
|
+
only counts valid dates. E.g., you can shift forward by a certain
|
|
239
|
+
number of Feb 29. The exception is when setting the month only.
|
|
240
|
+
In that case, if the result is an invalid date, the date is
|
|
241
|
+
"truncated" to the last valid date.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
|
|
245
|
+
count (int): The number of periods to shift
|
|
246
|
+
year (int): The year number
|
|
247
|
+
month (int): The month number (1 to 12)
|
|
248
|
+
day (int): The day number (1 to 31)
|
|
249
|
+
hour (int): The hour number (0 to 23)
|
|
250
|
+
minute (int): The minute number (0 to 59)
|
|
251
|
+
second (int or float): The second number (0 to smaller than 60)
|
|
252
|
+
microsecond (int): The microsecond number (0 to 999999)
|
|
253
|
+
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
_INVALID_SIG_RE = re.compile('10+1')
|
|
257
|
+
|
|
258
|
+
def __init__(
|
|
259
|
+
self, count: int = 0, year: typing.Optional[int] = None,
|
|
260
|
+
month: typing.Optional[int] = None, day: typing.Optional[int] = None,
|
|
261
|
+
hour: typing.Optional[int] = None, minute: typing.Optional[int] = None,
|
|
262
|
+
second: typing.Optional[int] = None,
|
|
263
|
+
microsecond: typing.Optional[int] = None
|
|
264
|
+
):
|
|
265
|
+
assert not count or not year, 'Absolute date with non-zero count'
|
|
266
|
+
assert not isinstance(second, float) or \
|
|
267
|
+
microsecond is None, 'Doubly specified microsecond'
|
|
268
|
+
vals = [year, month, day, hour, minute, second, microsecond]
|
|
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'
|
|
272
|
+
if isinstance(second, float):
|
|
273
|
+
second, orig_second = int(second), second
|
|
274
|
+
microsecond = int((orig_second - second) * 1000000 + 0.5)
|
|
275
|
+
self._count = count
|
|
276
|
+
self._year = year
|
|
277
|
+
self._month = month
|
|
278
|
+
self._day = day
|
|
279
|
+
self._hour = hour
|
|
280
|
+
self._minute = minute
|
|
281
|
+
self._second = second
|
|
282
|
+
self._microsecond = microsecond
|
|
283
|
+
self._firstset = sig.find('1')
|
|
284
|
+
|
|
285
|
+
_FIRSTSET_MOD = [
|
|
286
|
+
'', 'years', 'months', 'days', 'hours', 'minutes', 'seconds'
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
def __radd__(self, dt: datetime.datetime) -> datetime.datetime:
|
|
290
|
+
if self._firstset == -1:
|
|
291
|
+
return dt
|
|
292
|
+
if not(self._count):
|
|
293
|
+
return self._rset(dt)
|
|
294
|
+
# modify day or finer, or day specified and is not vulnerable
|
|
295
|
+
# to variable month length
|
|
296
|
+
if self._firstset > 2 or \
|
|
297
|
+
(self._day is not None and self._day <= 28):
|
|
298
|
+
return self._simpleshift(dt)
|
|
299
|
+
if self._day is None:
|
|
300
|
+
return self._monthshift(dt)
|
|
301
|
+
return self._dayshift(dt)
|
|
302
|
+
|
|
303
|
+
def _rset(self, dt: datetime.datetime) -> datetime.datetime:
|
|
304
|
+
updater = {'year': self._year,
|
|
305
|
+
'month': self._month,
|
|
306
|
+
'day': self._day,
|
|
307
|
+
'hour': self._hour,
|
|
308
|
+
'minute': self._minute,
|
|
309
|
+
'second': self._second,
|
|
310
|
+
'microsecond': self._microsecond}
|
|
311
|
+
updater = {k: v for k, v in updater.items() if v is not None}
|
|
312
|
+
return dt.replace(**updater) # type: ignore
|
|
313
|
+
|
|
314
|
+
def _simpleshift(self, dt: datetime.datetime) -> datetime.datetime:
|
|
315
|
+
remain = self._count
|
|
316
|
+
ret = self._rset(dt)
|
|
317
|
+
if self._count < 0:
|
|
318
|
+
if ret < dt:
|
|
319
|
+
remain += 1
|
|
320
|
+
else:
|
|
321
|
+
if ret > dt:
|
|
322
|
+
remain -= 1
|
|
323
|
+
mod_field = self._FIRSTSET_MOD[self._firstset]
|
|
324
|
+
return ret + dr.relativedelta(**{mod_field: remain}) # type: ignore
|
|
325
|
+
|
|
326
|
+
def _dayshift(self, dt: datetime.datetime) -> datetime.datetime:
|
|
327
|
+
# Day specified
|
|
328
|
+
if self._firstset == 2: # modify month
|
|
329
|
+
shift = dr.relativedelta(months=1 if self._count > 0 else -1)
|
|
330
|
+
limit = 2 # Must be able to find a 31st day in 2 months
|
|
331
|
+
else:
|
|
332
|
+
shift = dr.relativedelta(years=1 if self._count > 0 else -1)
|
|
333
|
+
limit = 8 # Must be able to find a Feb 29 in 8 years
|
|
334
|
+
count = abs(self._count)
|
|
335
|
+
# Find first date
|
|
336
|
+
curr = dt
|
|
337
|
+
for _ in range(limit):
|
|
338
|
+
try:
|
|
339
|
+
ret = self._rset(curr)
|
|
340
|
+
except ValueError:
|
|
341
|
+
curr += shift
|
|
342
|
+
continue
|
|
343
|
+
if (self._count > 0) == (ret > dt):
|
|
344
|
+
count -= 1
|
|
345
|
+
break
|
|
346
|
+
else:
|
|
347
|
+
raise ValueError('Failed day shifting: invalid date?')
|
|
348
|
+
# Find count occurrences
|
|
349
|
+
while True:
|
|
350
|
+
if count == 0:
|
|
351
|
+
return ret
|
|
352
|
+
ret += shift
|
|
353
|
+
try:
|
|
354
|
+
ret = self._rset(ret)
|
|
355
|
+
except ValueError:
|
|
356
|
+
continue
|
|
357
|
+
count -= 1
|
|
358
|
+
|
|
359
|
+
def _monthshift(self, dt: datetime.datetime) -> datetime.datetime:
|
|
360
|
+
# Only month specified, shift by month rather than by year
|
|
361
|
+
assert self._month is not None
|
|
362
|
+
if self._count > 0:
|
|
363
|
+
num_months = self._month - dt.month
|
|
364
|
+
sign = 1
|
|
365
|
+
else:
|
|
366
|
+
num_months = dt.month - self._month
|
|
367
|
+
sign = -1
|
|
368
|
+
if num_months <= 0:
|
|
369
|
+
num_months += 12
|
|
370
|
+
num_months += (abs(self._count) - 1) * 12
|
|
371
|
+
return dt + dr.relativedelta(months=sign * num_months)
|
|
372
|
+
|
|
373
|
+
PARSE_RE1 = re.compile(r'''
|
|
374
|
+
^
|
|
375
|
+
(?: (?P<count> [+-] (?: [0-9]+ | [0-9]*\.[0-9]*) ) x)?
|
|
376
|
+
(?P<year> [0-9]*)
|
|
377
|
+
-
|
|
378
|
+
(?P<month> [0-9]*)
|
|
379
|
+
-
|
|
380
|
+
(?P<day> [0-9]*)
|
|
381
|
+
$
|
|
382
|
+
''', re.X)
|
|
383
|
+
|
|
384
|
+
PARSE_RE2 = re.compile(r'''
|
|
385
|
+
^
|
|
386
|
+
(?: (?P<count> [+-] (?: [0-9]+ | [0-9]*\.[0-9]*) ) x)?
|
|
387
|
+
(?:
|
|
388
|
+
(?P<year> [0-9]*)
|
|
389
|
+
-
|
|
390
|
+
(?P<month> [0-9]*)
|
|
391
|
+
-
|
|
392
|
+
(?P<day> [0-9]*)
|
|
393
|
+
t
|
|
394
|
+
)?
|
|
395
|
+
(?P<hour> [0-9]*)
|
|
396
|
+
:
|
|
397
|
+
(?P<minute> [0-9]*)
|
|
398
|
+
:
|
|
399
|
+
(?P<second> [0-9]*)
|
|
400
|
+
(?:\. (?P<microsecond> [0-9]*) )?
|
|
401
|
+
$
|
|
402
|
+
''', re.X)
|
|
403
|
+
|
|
404
|
+
@classmethod
|
|
405
|
+
def parse(cls, cmdstr: str) -> 'PartialDate':
|
|
406
|
+
"""Parse a command string to a PartialDate object
|
|
407
|
+
|
|
408
|
+
The command string should be of the form
|
|
409
|
+
"<N>x<year>-<month>-<day>T<hour>:<minute>:<second>.<micro>",
|
|
410
|
+
where <N> is an explicitly signed number or empty string
|
|
411
|
+
(representing 0). To skip the specification of a part use
|
|
412
|
+
empty string. If all date parts are not specified the "--T"
|
|
413
|
+
may be omitted. If all the time parts are not specified the
|
|
414
|
+
"T::." may be omitted. If the microsecond part is not
|
|
415
|
+
specified the "." part may be omitted.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
|
|
419
|
+
cmdstr (str): The command string
|
|
420
|
+
|
|
421
|
+
"""
|
|
422
|
+
match = cls.PARSE_RE1.match(cmdstr.lower())
|
|
423
|
+
if not match:
|
|
424
|
+
match = cls.PARSE_RE2.match(cmdstr)
|
|
425
|
+
if not match:
|
|
426
|
+
raise ParseError('Cannot parse string %s' % cmdstr)
|
|
427
|
+
gdt = match.groupdict()
|
|
428
|
+
|
|
429
|
+
def _matchval(key: str) -> typing.Optional[int]:
|
|
430
|
+
val = gdt.get(key)
|
|
431
|
+
if not val:
|
|
432
|
+
return None
|
|
433
|
+
return int(val)
|
|
434
|
+
|
|
435
|
+
microsecond = None
|
|
436
|
+
msval = gdt.get('microsecond')
|
|
437
|
+
if msval:
|
|
438
|
+
microsecond = int(msval.ljust(6, '0')[:6])
|
|
439
|
+
return cls(_matchval('count') or 0,
|
|
440
|
+
_matchval('year'),
|
|
441
|
+
_matchval('month'),
|
|
442
|
+
_matchval('day'),
|
|
443
|
+
_matchval('hour'),
|
|
444
|
+
_matchval('minute'),
|
|
445
|
+
_matchval('second'),
|
|
446
|
+
microsecond)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def parse(cmdstr: str) -> typing.Union[Period, Weekday, PartialDate]:
|
|
450
|
+
"""Attempt to parse one of the possible date command
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
|
|
454
|
+
cmdstr (str): The command string
|
|
455
|
+
|
|
456
|
+
"""
|
|
457
|
+
try:
|
|
458
|
+
return Period.parse(cmdstr)
|
|
459
|
+
except ParseError:
|
|
460
|
+
pass
|
|
461
|
+
try:
|
|
462
|
+
return Weekday.parse(cmdstr)
|
|
463
|
+
except ParseError:
|
|
464
|
+
pass
|
|
465
|
+
return PartialDate.parse(cmdstr)
|
datec/__main__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import print_function
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import datetime
|
|
5
|
+
|
|
6
|
+
import datec
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
dt = datetime.datetime.now()
|
|
11
|
+
for cmd in sys.argv[1:]:
|
|
12
|
+
if cmd == '-h':
|
|
13
|
+
print('''Date command
|
|
14
|
+
|
|
15
|
+
Usage: datec [<command>] ...
|
|
16
|
+
|
|
17
|
+
Datec starts from the current time and apply commands. The ending
|
|
18
|
+
datetime is printed in ISO YYYY-MM-DDTHH:MM:SS.ffffff format.
|
|
19
|
+
|
|
20
|
+
<command> may be of the following forms:
|
|
21
|
+
|
|
22
|
+
* +2week: shift two weeks ahead
|
|
23
|
+
* mon: set to to the Monday of the current week
|
|
24
|
+
* +2mon: shift two Mondays ahead
|
|
25
|
+
* -3-7T14:: set month to 3, day to 7, and hour to 14
|
|
26
|
+
* +2x--31: move forward by 2 month day 31 (months without a 31st
|
|
27
|
+
day does not count.
|
|
28
|
+
|
|
29
|
+
''', file=sys.stderr)
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
dt = dt + datec.parse(cmd.lower())
|
|
32
|
+
print(dt.isoformat())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == '__main__':
|
|
36
|
+
main()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datec
|
|
3
|
+
Version: 0.2
|
|
4
|
+
Summary: Date Command
|
|
5
|
+
Project-URL: Homepage, https://github.com/isaacto/datec
|
|
6
|
+
Project-URL: Repository, https://github.com/isaacto/datec.git
|
|
7
|
+
Project-URL: Issues, https://github.com/isaacto/datec/issues
|
|
8
|
+
Author-email: Isaac To <isaac.to@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: python-dateutil
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Date command: A command-based date computation engine
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
You can install the package simply by
|
|
30
|
+
|
|
31
|
+
pip install datec
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
datec allows you to use "date commands" to modify datetime's by adding
|
|
36
|
+
to them, like this:
|
|
37
|
+
|
|
38
|
+
datetime.datetime.now() + datec.Period(2, 'week')
|
|
39
|
+
|
|
40
|
+
A date command can be parsed from strings using the parse() function,
|
|
41
|
+
which create a command from a string representation. This forms the
|
|
42
|
+
basis of the datec command, which is a command-line program to output
|
|
43
|
+
datetime after applying date commands. In general the date
|
|
44
|
+
representation is NxYYYY-mm-ddTHH:MM:SS.ffffff, where unspecified
|
|
45
|
+
parts are omitted leaving the symbols intact, like "2x-2-29T3::." (see
|
|
46
|
+
the following for the meaning). If the fractional part is not
|
|
47
|
+
specified the "." may be omitted, if all time parts are not specified
|
|
48
|
+
the "T::." can be omitted, if all date parts are not specified the
|
|
49
|
+
"--T" can be omitted, and if Nx may be omitted in some cases for
|
|
50
|
+
setting a partial datetime or weekday. There are a couple other more
|
|
51
|
+
formats like +3week and -2wed for shifting by period and weekday.
|
|
52
|
+
|
|
53
|
+
Date commands are in two forms: period shifting commands and partial
|
|
54
|
+
datetime shifting commands. The first type is more familiar: they
|
|
55
|
+
look like
|
|
56
|
+
|
|
57
|
+
* +2week (shift the datetime forward by 2 week)
|
|
58
|
+
* -1month (shift the datetime backward by 1 month)
|
|
59
|
+
|
|
60
|
+
Period is one of year, month, week, day, hour, minute and second,
|
|
61
|
+
represented by an object of the Period class. Fractional numbers are
|
|
62
|
+
acceptable except for year and month. If shifting a period leads to
|
|
63
|
+
an invalid date (e.g., shift backward 1 month from 2019-07-31), it
|
|
64
|
+
moves backwards the closest valid date (here, 2019-06-30). In general
|
|
65
|
+
the parts finer than the shifted part is unaffected (e.g., shifting 1
|
|
66
|
+
month from 2019-07-31 02:00 gives you 2019-06-30 02:00).
|
|
67
|
+
|
|
68
|
+
Partial datetime shifting is less familiar. It looks like:
|
|
69
|
+
|
|
70
|
+
* 12:: (set the hour number to 12)
|
|
71
|
+
* +2x12:: (move forward to the second hour 12)
|
|
72
|
+
* +4x--31 (move forward to the fourth occurrence of day 31 of a month)
|
|
73
|
+
* -3x-02-29 (move backward to the third occurrence of February 29)
|
|
74
|
+
* wed (set to the Wednesday of the same week, week starts on Sunday)
|
|
75
|
+
* -3wed (move to the third Wednesday before the current datetime)
|
|
76
|
+
|
|
77
|
+
They are represented by either a Weekday object or a PartialDate
|
|
78
|
+
object with a count. A count of 0 means setting instead of shifting.
|
|
79
|
+
Only integer counts are acceptable.
|
|
80
|
+
|
|
81
|
+
It is an error to set to an invalid date (e.g., --31 applied on
|
|
82
|
+
2019-06-25 is an error). The datetime parts which are specified must
|
|
83
|
+
be consecutive (it is an error to specify 12::05). It is also an
|
|
84
|
+
error to shift for occurrence of a partial date with year specified
|
|
85
|
+
(e.g., "+2x2019--").
|
|
86
|
+
|
|
87
|
+
On the other hand, shifting to an invalid date with day number
|
|
88
|
+
specified will shift more until a specified date is valid. For
|
|
89
|
+
example, if you add -2-29 with count 1 to 2019-01-01, you end up with
|
|
90
|
+
2020-02-29, because 2019-02-29 is not a valid date. If the count is 2
|
|
91
|
+
you get 2024-02-29 instead.
|
|
92
|
+
|
|
93
|
+
Shifting to an invalid date by a partial date with just a month number
|
|
94
|
+
will cause the date to moved backwards until the date is valid. E.g.,
|
|
95
|
+
if you shift by -6- with count 1 (next June) from 2019-05-31, you get
|
|
96
|
+
2019-06-30. With count 2 you get 2020-06-30.
|
|
97
|
+
|
|
98
|
+
This library is grown out of frustration that it is tedious to have a
|
|
99
|
+
shell script or program to get a datetime like "the next 6pm from now"
|
|
100
|
+
or "the next 3rd of any month from two days ago". With this module
|
|
101
|
+
they can be specified like "+1x18:00:00.0" and "-2day +1x--3"
|
|
102
|
+
respectively. In the expected use cases, counts are small numbers.
|
|
103
|
+
So the library is not always efficient (at times we just loop "count"
|
|
104
|
+
times to step forward or backward). Whenever it is simple to do so,
|
|
105
|
+
the implementation just forward to relativedelta, in which case they
|
|
106
|
+
are more efficient.
|
|
107
|
+
|
|
108
|
+
At present the program does not handle timezone and daylight saving.
|
|
109
|
+
This is bacause the author lives at a place where no daylight saving
|
|
110
|
+
is observed. Contributions are welcome.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
datec/__init__.py,sha256=Uc9-7TJIm3FTeUdE9Z8zrhK3Nt5Fp1mVBTs6fALJEMw,16510
|
|
2
|
+
datec/__main__.py,sha256=tVFpFXkXb_BWoigg1iDMb7J3DS_DxbZFA7bfFtLGjhA,846
|
|
3
|
+
datec-0.2.dist-info/METADATA,sha256=-abLLdsNDTQ52oLUyMsjOMtFC-p4lC5O3xvggr8oAW0,4945
|
|
4
|
+
datec-0.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
5
|
+
datec-0.2.dist-info/entry_points.txt,sha256=X3JE4JFOFKiDVJ93KVOzE9y63rMPwtRnF3mzO8nGb_o,46
|
|
6
|
+
datec-0.2.dist-info/licenses/LICENSE,sha256=lar4g9VcHbwsiTsRFqX2M5p5Y08mTLLJ4cSLTmbb1cc,1064
|
|
7
|
+
datec-0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 isaacto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|