datec 0.3__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.4
2
2
  Name: datec
3
- Version: 0.3
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
@@ -101,9 +101,6 @@ will cause the date to moved backwards until the date is valid. E.g.,
101
101
  if you shift by -6- with count 1 (next June) from 2019-05-31, you get
102
102
  2019-06-30. With count 2 you get 2020-06-30.
103
103
 
104
- All these functionalities are available in the constructors too. Read
105
- their docstring to find how to use them.
106
-
107
104
  This library is grown out of frustration that it is tedious to have a
108
105
  shell script or program to get a datetime like "the next 6pm from now"
109
106
  or "the next 3rd of any month from two days ago". With this module
@@ -77,9 +77,6 @@ will cause the date to moved backwards until the date is valid. E.g.,
77
77
  if you shift by -6- with count 1 (next June) from 2019-05-31, you get
78
78
  2019-06-30. With count 2 you get 2020-06-30.
79
79
 
80
- All these functionalities are available in the constructors too. Read
81
- their docstring to find how to use them.
82
-
83
80
  This library is grown out of frustration that it is tedious to have a
84
81
  shell script or program to get a datetime like "the next 6pm from now"
85
82
  or "the next 3rd of any month from two days ago". With this module
@@ -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)
@@ -35,7 +35,7 @@ until the ending datetime.
35
35
  if cmd == '-w':
36
36
  wait = True
37
37
  continue
38
- dt = dt + datec.parse(cmd.lower())
38
+ dt = dt + datec.parse(cmd)
39
39
  if wait:
40
40
  delta = (dt - datetime.datetime.now()).total_seconds()
41
41
  if delta > 0:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes