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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datec
3
- Version: 0.2
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. 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.
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. In general the date
20
- representation is NxYYYY-mm-ddTHH:MM:SS.ffffff, where unspecified
21
- parts are omitted leaving the symbols intact, like "2x-2-29T3::." (see
22
- the following for the meaning). If the fractional part is not
23
- specified the "." may be omitted, if all time parts are not specified
24
- the "T::." can be omitted, if all date parts are not specified the
25
- "--T" can be omitted, and if Nx may be omitted in some cases for
26
- setting a partial datetime or weekday. There are a couple other more
27
- formats like +3week and -2wed for shifting by period and weekday.
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
- __metaclass__ = type
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
- assert period in ('year', 'month', 'week', 'day',
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
- cnt = int(gdt['count'])
150
- except Exception:
151
- cnt = float(gdt['count'])
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
- assert day in range(7)
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]+ | [0-9]*\.[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 (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)
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
- 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'
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. The ending
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
- dt = dt + datec.parse(cmd.lower())
32
- print(dt.isoformat())
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