ics-query 0.1.dev8__py3-none-any.whl → 0.2.0a0__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.
- ics_query/_version.py +2 -2
- ics_query/cli.py +395 -12
- ics_query/parse.py +63 -4
- ics_query/tests/conftest.py +24 -12
- ics_query/tests/runs/at 2019-03-04 multiple-calendars.ics -.run +20 -0
- ics_query/tests/runs/at 2019-03-04 one-event-twice.ics -.run +18 -0
- ics_query/tests/runs/at 2019-03-07 multiple-calendars.ics -.run +11 -0
- ics_query/tests/runs/between 20240823 4d recurring-work-events.ics -.run +24 -0
- ics_query/tests/runs/calendars/empty-calendar.ics +7 -0
- ics_query/tests/runs/calendars/empty-file.ics +0 -0
- ics_query/tests/runs/calendars/multiple-calendars.ics +71 -0
- ics_query/tests/runs/calendars/one-event-twice.ics +68 -0
- ics_query/tests/runs/calendars/recurring-work-events.ics +223 -0
- ics_query/tests/runs/first empty-calendar.ics -.run +0 -0
- ics_query/tests/runs/first empty-file.ics -.run +0 -0
- ics_query/tests/runs/first recurring-work-events.ics -.run +12 -0
- ics_query/tests/test_command_line.py +9 -0
- ics_query/tests/test_parse_date.py +66 -0
- ics_query/tests/test_parse_timedelta.py +25 -0
- {ics_query-0.1.dev8.dist-info → ics_query-0.2.0a0.dist-info}/METADATA +222 -20
- ics_query-0.2.0a0.dist-info/RECORD +30 -0
- ics_query-0.1.dev8.dist-info/RECORD +0 -16
- {ics_query-0.1.dev8.dist-info → ics_query-0.2.0a0.dist-info}/WHEEL +0 -0
- {ics_query-0.1.dev8.dist-info → ics_query-0.2.0a0.dist-info}/entry_points.txt +0 -0
- {ics_query-0.1.dev8.dist-info → ics_query-0.2.0a0.dist-info}/licenses/LICENSE +0 -0
ics_query/_version.py
CHANGED
|
@@ -12,5 +12,5 @@ __version__: str
|
|
|
12
12
|
__version_tuple__: VERSION_TUPLE
|
|
13
13
|
version_tuple: VERSION_TUPLE
|
|
14
14
|
|
|
15
|
-
__version__ = version = '0.
|
|
16
|
-
__version_tuple__ = version_tuple = (0,
|
|
15
|
+
__version__ = version = '0.2.0a0'
|
|
16
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
ics_query/cli.py
CHANGED
|
@@ -9,17 +9,17 @@ import typing as t
|
|
|
9
9
|
|
|
10
10
|
import click
|
|
11
11
|
import recurring_ical_events
|
|
12
|
-
from icalendar import Calendar
|
|
13
|
-
from recurring_ical_events import CalendarQuery
|
|
12
|
+
from icalendar.cal import Calendar, Component
|
|
14
13
|
|
|
15
14
|
from . import parse
|
|
15
|
+
from .version import __version__
|
|
16
16
|
|
|
17
17
|
if t.TYPE_CHECKING:
|
|
18
18
|
from io import FileIO
|
|
19
19
|
|
|
20
20
|
from icalendar.cal import Component
|
|
21
21
|
|
|
22
|
-
from .parse import
|
|
22
|
+
from .parse import Date
|
|
23
23
|
|
|
24
24
|
print = functools.partial(print, file=sys.stderr) # noqa: A001
|
|
25
25
|
|
|
@@ -35,6 +35,11 @@ class ComponentsResult:
|
|
|
35
35
|
"""Return a component."""
|
|
36
36
|
self._file.write(component.to_ical())
|
|
37
37
|
|
|
38
|
+
def add_components(self, components: t.Iterable[Component]):
|
|
39
|
+
"""Add all components."""
|
|
40
|
+
for component in components:
|
|
41
|
+
self.add_component(component)
|
|
42
|
+
|
|
38
43
|
|
|
39
44
|
class ComponentsResultArgument(click.File):
|
|
40
45
|
"""Argument for the result."""
|
|
@@ -50,6 +55,30 @@ class ComponentsResultArgument(click.File):
|
|
|
50
55
|
return ComponentsResult(file)
|
|
51
56
|
|
|
52
57
|
|
|
58
|
+
class JoinedCalendars:
|
|
59
|
+
def __init__(self, calendars: list[Calendar]):
|
|
60
|
+
"""Join multiple calendars."""
|
|
61
|
+
self.queries = [recurring_ical_events.of(calendar) for calendar in calendars]
|
|
62
|
+
|
|
63
|
+
def at(self, dt: tuple[int]) -> t.Generator[Component]:
|
|
64
|
+
"""Return the components."""
|
|
65
|
+
for query in self.queries:
|
|
66
|
+
yield from query.at(dt)
|
|
67
|
+
|
|
68
|
+
def first(self) -> t.Generator[Component]:
|
|
69
|
+
"""Return the first events of all calendars."""
|
|
70
|
+
for query in self.queries:
|
|
71
|
+
for component in query.all():
|
|
72
|
+
yield component
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
def between(
|
|
76
|
+
self, start: parse.Date, end: parse.DateAndDelta
|
|
77
|
+
) -> t.Generator[Component]:
|
|
78
|
+
for query in self.queries:
|
|
79
|
+
yield from query.between(start, end)
|
|
80
|
+
|
|
81
|
+
|
|
53
82
|
class CalendarQueryInputArgument(click.File):
|
|
54
83
|
"""Argument for the result."""
|
|
55
84
|
|
|
@@ -61,8 +90,8 @@ class CalendarQueryInputArgument(click.File):
|
|
|
61
90
|
) -> recurring_ical_events.CalendarQuery:
|
|
62
91
|
"""Return a CalendarQuery."""
|
|
63
92
|
file = super().convert(value, param, ctx)
|
|
64
|
-
|
|
65
|
-
return
|
|
93
|
+
calendars = Calendar.from_ical(file.read(), multiple=True)
|
|
94
|
+
return JoinedCalendars(calendars)
|
|
66
95
|
|
|
67
96
|
|
|
68
97
|
arg_calendar = click.argument("calendar", type=CalendarQueryInputArgument("rb"))
|
|
@@ -70,9 +99,48 @@ arg_output = click.argument("output", type=ComponentsResultArgument("wb"))
|
|
|
70
99
|
|
|
71
100
|
|
|
72
101
|
@click.group()
|
|
102
|
+
@click.version_option(__version__)
|
|
73
103
|
def main():
|
|
74
|
-
"""
|
|
75
|
-
|
|
104
|
+
"""Find out what happens in ICS calendar files.
|
|
105
|
+
|
|
106
|
+
ics-query can query and filter RFC 5545 compatible .ics files.
|
|
107
|
+
Components are events, journal entries and TODOs.
|
|
108
|
+
|
|
109
|
+
\b
|
|
110
|
+
Common Parameters
|
|
111
|
+
-----------------
|
|
112
|
+
|
|
113
|
+
Common parameters are described below.
|
|
114
|
+
|
|
115
|
+
CALENDAR
|
|
116
|
+
|
|
117
|
+
The CALENDAR is a readable file with one or more ICS calendars in it.
|
|
118
|
+
If CALENDAR is "-", then the standard input is used.
|
|
119
|
+
|
|
120
|
+
OUTPUT
|
|
121
|
+
|
|
122
|
+
This is the OUTPUT file for the result.
|
|
123
|
+
It is usually a path to a file that can be written to.
|
|
124
|
+
If OUTPUT is "-", then the standard output is used.
|
|
125
|
+
|
|
126
|
+
\b
|
|
127
|
+
Notes on Calculation
|
|
128
|
+
--------------------
|
|
129
|
+
|
|
130
|
+
An event can be very long. If you request smaller time spans or a time as
|
|
131
|
+
exact as a second, the event will still occur within this time span if it
|
|
132
|
+
happens during that time.
|
|
133
|
+
|
|
134
|
+
Generally, an event occurs within a time span if this applies:
|
|
135
|
+
|
|
136
|
+
event.DTSTART <= span.DTEND and span.DTSTART < event.DTEND
|
|
137
|
+
|
|
138
|
+
The START is INCLUSIVE, then END is EXCLUSIVE.
|
|
139
|
+
|
|
140
|
+
\b
|
|
141
|
+
Notes on Timezones
|
|
142
|
+
------------------
|
|
143
|
+
""" # noqa: D301
|
|
76
144
|
|
|
77
145
|
|
|
78
146
|
pass_datetime = click.make_pass_decorator(parse.to_time)
|
|
@@ -82,11 +150,326 @@ pass_datetime = click.make_pass_decorator(parse.to_time)
|
|
|
82
150
|
@click.argument("date", type=parse.to_time)
|
|
83
151
|
@arg_calendar
|
|
84
152
|
@arg_output
|
|
85
|
-
def at(calendar:
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
153
|
+
def at(calendar: JoinedCalendars, output: ComponentsResult, date: Date):
|
|
154
|
+
"""Occurrences at a certain dates.
|
|
155
|
+
|
|
156
|
+
YEAR
|
|
157
|
+
|
|
158
|
+
All occurrences in this year.
|
|
159
|
+
|
|
160
|
+
\b
|
|
161
|
+
Formats:
|
|
162
|
+
\b
|
|
163
|
+
YYYY
|
|
164
|
+
\b
|
|
165
|
+
Examples:
|
|
166
|
+
\b
|
|
167
|
+
ics-query at 2024 # all occurrences in year 2024
|
|
168
|
+
ics-query at `date +%Y` # all occurrences in this year
|
|
169
|
+
|
|
170
|
+
MONTH
|
|
171
|
+
|
|
172
|
+
All occurrences in this month.
|
|
173
|
+
|
|
174
|
+
\b
|
|
175
|
+
Formats:
|
|
176
|
+
\b
|
|
177
|
+
YYYY-MM
|
|
178
|
+
YYYY-M
|
|
179
|
+
YYYYMM
|
|
180
|
+
\b
|
|
181
|
+
Examples:
|
|
182
|
+
\b
|
|
183
|
+
ics-query at 2019-10 # October 2019
|
|
184
|
+
ics-query at 1990-01 # January 1990
|
|
185
|
+
ics-query at 1990-1 # January 1990
|
|
186
|
+
ics-query at 199001 # January 1990
|
|
187
|
+
ics-query at `date +%Y%m` # this month
|
|
188
|
+
|
|
189
|
+
DAY
|
|
190
|
+
|
|
191
|
+
All occurrences in one day.
|
|
192
|
+
|
|
193
|
+
\b
|
|
194
|
+
Formats:
|
|
195
|
+
\b
|
|
196
|
+
YYYY-MM-DD
|
|
197
|
+
YYYY-M-D
|
|
198
|
+
YYYYMMDD
|
|
199
|
+
\b
|
|
200
|
+
Examples:
|
|
201
|
+
\b
|
|
202
|
+
ics-query at 1990-01-01 # 1st January 1990
|
|
203
|
+
ics-query at 1990-1-1 # 1st January 1990
|
|
204
|
+
ics-query at 19900101 # 1st January 1990
|
|
205
|
+
ics-query at `date +%Y%m%d` # today
|
|
206
|
+
|
|
207
|
+
HOUR
|
|
208
|
+
|
|
209
|
+
All occurrences within one hour.
|
|
210
|
+
|
|
211
|
+
\b
|
|
212
|
+
Formats:
|
|
213
|
+
\b
|
|
214
|
+
YYYY-MM-DD HH
|
|
215
|
+
YYYY-MM-DDTHH
|
|
216
|
+
YYYY-M-DTH
|
|
217
|
+
YYYYMMDDTHH
|
|
218
|
+
YYYYMMDDHH
|
|
219
|
+
\b
|
|
220
|
+
Examples:
|
|
221
|
+
\b
|
|
222
|
+
ics-query at 1990-01-01 00 # 1st January 1990, 12am - 1am
|
|
223
|
+
ics-query at 1990-01-01T00 # 1st January 1990, 12am - 1am
|
|
224
|
+
ics-query at 1990-1-1T17 # 1st January 1990, 17:00 - 18:00
|
|
225
|
+
ics-query at 19900101T23 # 1st January 1990, 23:00 - midnight
|
|
226
|
+
ics-query at 1990010123 # 1st January 1990, 23:00 - midnight
|
|
227
|
+
ics-query at `date +%Y%m%d%H` # this hour
|
|
228
|
+
|
|
229
|
+
MINUTE
|
|
230
|
+
|
|
231
|
+
All occurrences within one minute.
|
|
232
|
+
|
|
233
|
+
\b
|
|
234
|
+
Formats:
|
|
235
|
+
\b
|
|
236
|
+
YYYY-MM-DD HH:MM
|
|
237
|
+
YYYY-MM-DDTHH:MM
|
|
238
|
+
YYYY-M-DTH:M
|
|
239
|
+
YYYYMMDDTHHMM
|
|
240
|
+
YYYYMMDDHHMM
|
|
241
|
+
\b
|
|
242
|
+
Examples:
|
|
243
|
+
\b
|
|
244
|
+
ics-query at 1990-01-01 10:10 # 1st January 1990, 10:10am - 10:11am
|
|
245
|
+
ics-query at 1990-01-01T10:10 # 1st January 1990, 10:10am - 10:11am
|
|
246
|
+
ics-query at 1990-1-1T7:2 # 1st January 1990, 07:02 - 07:03
|
|
247
|
+
ics-query at 19900101T2359 # 1st January 1990, 23:59 - midnight
|
|
248
|
+
ics-query at 199001012359 # 1st January 1990, 23:59 - midnight
|
|
249
|
+
ics-query at `date +%Y%m%d%H%M` # this minute
|
|
250
|
+
|
|
251
|
+
SECOND
|
|
252
|
+
|
|
253
|
+
All occurrences at a precise time.
|
|
254
|
+
|
|
255
|
+
\b
|
|
256
|
+
Formats:
|
|
257
|
+
\b
|
|
258
|
+
YYYY-MM-DD HH:MM:SS
|
|
259
|
+
YYYY-MM-DDTHH:MM:SS
|
|
260
|
+
YYYY-M-DTH:M:S
|
|
261
|
+
YYYYMMDDTHHMMSS
|
|
262
|
+
YYYYMMDDHHMMSS
|
|
263
|
+
\b
|
|
264
|
+
Examples:
|
|
265
|
+
\b
|
|
266
|
+
ics-query at 1990-01-01 10:10:00 # 1st January 1990, 10:10am
|
|
267
|
+
ics-query at 1990-01-01T10:10:00 # 1st January 1990, 10:10am
|
|
268
|
+
ics-query at 1990-1-1T7:2:30 # 1st January 1990, 07:02:30
|
|
269
|
+
ics-query at 19901231T235959 # 31st December 1990, 23:59:59
|
|
270
|
+
ics-query at 19900101235959 # 1st January 1990, 23:59:59
|
|
271
|
+
ics-query at `date +%Y%m%d%H%M%S` # now
|
|
272
|
+
""" # noqa: D301
|
|
273
|
+
output.add_components(calendar.at(date))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@main.command()
|
|
277
|
+
@arg_calendar
|
|
278
|
+
@arg_output
|
|
279
|
+
def first(calendar: JoinedCalendars, output: ComponentsResult):
|
|
280
|
+
"""Print only the first occurrence in each calendar.
|
|
281
|
+
|
|
282
|
+
\b
|
|
283
|
+
This example prints the first event in calendar.ics:
|
|
284
|
+
\b
|
|
285
|
+
ics-query first calendar.ics -
|
|
286
|
+
|
|
287
|
+
""" # noqa: D301
|
|
288
|
+
output.add_components(calendar.first())
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@main.command()
|
|
292
|
+
@click.argument("start", type=parse.to_time)
|
|
293
|
+
@click.argument("end", type=parse.to_time_and_delta)
|
|
294
|
+
@arg_calendar
|
|
295
|
+
@arg_output
|
|
296
|
+
def between(
|
|
297
|
+
start: parse.Date,
|
|
298
|
+
end: parse.DateAndDelta,
|
|
299
|
+
calendar: JoinedCalendars,
|
|
300
|
+
output: ComponentsResult,
|
|
301
|
+
):
|
|
302
|
+
"""Print all occurrences between the START and the END.
|
|
303
|
+
|
|
304
|
+
The start is inclusive, the end is exclusive.
|
|
305
|
+
|
|
306
|
+
This example returns the events within the next week:
|
|
307
|
+
|
|
308
|
+
\b
|
|
309
|
+
ics-query between `date +%Y%m%d` +7d calendar.ics -
|
|
310
|
+
|
|
311
|
+
This example saves the events from the 1st of May 2024 to the 10th of June in
|
|
312
|
+
events.ics:
|
|
313
|
+
|
|
314
|
+
\b
|
|
315
|
+
ics-query between 2024-5-1 2024-6-10 calendar.ics events.ics
|
|
316
|
+
|
|
317
|
+
In this example, you can check what is happening on New Years Eve 2025 around
|
|
318
|
+
midnight:
|
|
319
|
+
|
|
320
|
+
\b
|
|
321
|
+
ics-query between 2025-12-31T21:00 +6h calendar.ics events.ics
|
|
322
|
+
|
|
323
|
+
\b
|
|
324
|
+
Absolute Time
|
|
325
|
+
-------------
|
|
326
|
+
|
|
327
|
+
START must be specified as an absolute time.
|
|
328
|
+
END can be absolute or relative to START, see Relative Time below.
|
|
329
|
+
|
|
330
|
+
Each of the formats specify the earliest time e.g. the start of a day.
|
|
331
|
+
Thus, if START == END, there are 0 seconds in between and the result is
|
|
332
|
+
only what happens during that time or starts exactly at that time.
|
|
333
|
+
|
|
334
|
+
YEAR
|
|
335
|
+
|
|
336
|
+
Specifiy the start of the year.
|
|
337
|
+
|
|
338
|
+
\b
|
|
339
|
+
Formats:
|
|
340
|
+
\b
|
|
341
|
+
YYYY
|
|
342
|
+
\b
|
|
343
|
+
Examples:
|
|
344
|
+
\b
|
|
345
|
+
2024 # start of 2024
|
|
346
|
+
`date +%Y` # this year
|
|
347
|
+
|
|
348
|
+
MONTH
|
|
349
|
+
|
|
350
|
+
The start of the month.
|
|
351
|
+
|
|
352
|
+
\b
|
|
353
|
+
Formats:
|
|
354
|
+
\b
|
|
355
|
+
YYYY-MM
|
|
356
|
+
YYYY-M
|
|
357
|
+
YYYYMM
|
|
358
|
+
\b
|
|
359
|
+
Examples:
|
|
360
|
+
\b
|
|
361
|
+
2019-10 # October 2019
|
|
362
|
+
1990-01 # January 1990
|
|
363
|
+
1990-1 # January 1990
|
|
364
|
+
199001 # January 1990
|
|
365
|
+
`date +%Y%m` # this month
|
|
366
|
+
|
|
367
|
+
DAY
|
|
368
|
+
|
|
369
|
+
The start of the day
|
|
370
|
+
|
|
371
|
+
\b
|
|
372
|
+
Formats:
|
|
373
|
+
\b
|
|
374
|
+
YYYY-MM-DD
|
|
375
|
+
YYYY-M-D
|
|
376
|
+
YYYYMMDD
|
|
377
|
+
\b
|
|
378
|
+
Examples:
|
|
379
|
+
\b
|
|
380
|
+
1990-01-01 # 1st January 1990
|
|
381
|
+
1990-1-1 # 1st January 1990
|
|
382
|
+
19900101 # 1st January 1990
|
|
383
|
+
`date +%Y%m%d` # today
|
|
384
|
+
|
|
385
|
+
HOUR
|
|
386
|
+
|
|
387
|
+
The start of the hour.
|
|
388
|
+
|
|
389
|
+
\b
|
|
390
|
+
Formats:
|
|
391
|
+
\b
|
|
392
|
+
YYYY-MM-DD HH
|
|
393
|
+
YYYY-MM-DDTHH
|
|
394
|
+
YYYY-M-DTH
|
|
395
|
+
YYYYMMDDTHH
|
|
396
|
+
YYYYMMDDHH
|
|
397
|
+
\b
|
|
398
|
+
Examples:
|
|
399
|
+
\b
|
|
400
|
+
1990-01-01 01 # 1st January 1990, 1am
|
|
401
|
+
1990-01-01T01 # 1st January 1990, 1am
|
|
402
|
+
1990-1-1T17 # 1st January 1990, 17:00
|
|
403
|
+
19900101T23 # 1st January 1990, 23:00
|
|
404
|
+
1990010123 # 1st January 1990, 23:00
|
|
405
|
+
`date +%Y%m%d%H` # this hour
|
|
406
|
+
|
|
407
|
+
MINUTE
|
|
408
|
+
|
|
409
|
+
The start of a minute.
|
|
410
|
+
|
|
411
|
+
\b
|
|
412
|
+
Formats:
|
|
413
|
+
\b
|
|
414
|
+
YYYY-MM-DD HH:MM
|
|
415
|
+
YYYY-MM-DDTHH:MM
|
|
416
|
+
YYYY-M-DTH:M
|
|
417
|
+
YYYYMMDDTHHMM
|
|
418
|
+
YYYYMMDDHHMM
|
|
419
|
+
\b
|
|
420
|
+
Examples:
|
|
421
|
+
\b
|
|
422
|
+
1990-01-01 10:10 # 1st January 1990, 10:10am
|
|
423
|
+
1990-01-01T10:10 # 1st January 1990, 10:10am
|
|
424
|
+
1990-1-1T7:2 # 1st January 1990, 07:02
|
|
425
|
+
19900101T2359 # 1st January 1990, 23:59
|
|
426
|
+
199001012359 # 1st January 1990, 23:59
|
|
427
|
+
`date +%Y%m%d%H%M` # this minute
|
|
428
|
+
|
|
429
|
+
SECOND
|
|
430
|
+
|
|
431
|
+
A precise time. RFC 5545 calendars are specified to the second.
|
|
432
|
+
This is the most precise format to specify times.
|
|
433
|
+
|
|
434
|
+
\b
|
|
435
|
+
Formats:
|
|
436
|
+
\b
|
|
437
|
+
YYYY-MM-DD HH:MM:SS
|
|
438
|
+
YYYY-MM-DDTHH:MM:SS
|
|
439
|
+
YYYY-M-DTH:M:S
|
|
440
|
+
YYYYMMDDTHHMMSS
|
|
441
|
+
YYYYMMDDHHMMSS
|
|
442
|
+
\b
|
|
443
|
+
Examples:
|
|
444
|
+
\b
|
|
445
|
+
1990-01-01 10:10:00 # 1st January 1990, 10:10am
|
|
446
|
+
1990-01-01T10:10:00 # 1st January 1990, 10:10am
|
|
447
|
+
1990-1-1T7:2:30 # 1st January 1990, 07:02:30
|
|
448
|
+
19901231T235959 # 31st December 1990, 23:59:59
|
|
449
|
+
19900101235959 # 1st January 1990, 23:59:59
|
|
450
|
+
`date +%Y%m%d%H%M%S` # now
|
|
451
|
+
\b
|
|
452
|
+
Relative Time
|
|
453
|
+
-------------
|
|
454
|
+
|
|
455
|
+
The END argument can be a time range.
|
|
456
|
+
The + at the beginning is optional but makes for a better reading.
|
|
457
|
+
|
|
458
|
+
\b
|
|
459
|
+
Examples:
|
|
460
|
+
\b
|
|
461
|
+
Add 10 days to START: +10d
|
|
462
|
+
Add 24 hours to START: +1d or +24h
|
|
463
|
+
Add 3 hours to START: +3h
|
|
464
|
+
Add 30 minutes to START: +30m
|
|
465
|
+
Add 1000 seconds to START: +1000s
|
|
466
|
+
\b
|
|
467
|
+
You can also combine the ranges:
|
|
468
|
+
Add 1 day and 12 hours to START: +1d12h
|
|
469
|
+
Add 3 hours and 15 minutes to START: +3h15m
|
|
470
|
+
|
|
471
|
+
""" # noqa: D301
|
|
472
|
+
output.add_components(calendar.between(start, end))
|
|
90
473
|
|
|
91
474
|
|
|
92
475
|
__all__ = ["main"]
|
ics_query/parse.py
CHANGED
|
@@ -2,12 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import datetime
|
|
6
|
+
import re
|
|
7
|
+
from typing import Union
|
|
6
8
|
|
|
9
|
+
Date = tuple[int]
|
|
10
|
+
DateAndDelta = Union[Date, datetime.timedelta]
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
|
|
13
|
+
class InvalidTimeFormat(ValueError):
|
|
14
|
+
"""The value provided does not yield a precise time."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
REGEX_TIME = re.compile(
|
|
18
|
+
r"^(?P<year>\d\d\d\d)"
|
|
19
|
+
r"(?P<month>-\d?\d|\d\d)?"
|
|
20
|
+
r"(?P<day>-\d?\d|\d\d)?"
|
|
21
|
+
r"(?P<hour>[ T]\d?\d|\d\d)?"
|
|
22
|
+
r"(?P<minute>:\d?\d|\d\d)?"
|
|
23
|
+
r"(?P<second>:\d?\d|\d\d)?"
|
|
24
|
+
r"$"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
REGEX_TIMEDELTA = re.compile(
|
|
28
|
+
r"^\+?(?:(?P<days>\d+)d)?"
|
|
29
|
+
r"(?:(?P<hours>\d+)h)?"
|
|
30
|
+
r"(?:(?P<minutes>\d+)m)?"
|
|
31
|
+
r"(?:(?P<seconds>\d+)s)?"
|
|
32
|
+
r"$"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def to_time(dt: str) -> Date:
|
|
9
37
|
"""Parse the time and date."""
|
|
10
|
-
|
|
38
|
+
parsed_dt = REGEX_TIME.match(dt)
|
|
39
|
+
if parsed_dt is None:
|
|
40
|
+
raise InvalidTimeFormat(dt)
|
|
41
|
+
|
|
42
|
+
def group(group_name: str) -> Date:
|
|
43
|
+
"""Return a group's value."""
|
|
44
|
+
result = parsed_dt.group(group_name)
|
|
45
|
+
while result and result[0] not in "0123456789":
|
|
46
|
+
result = result[1:]
|
|
47
|
+
if result is None:
|
|
48
|
+
return ()
|
|
49
|
+
return (int(result),)
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
group("year")
|
|
53
|
+
+ group("month")
|
|
54
|
+
+ group("day")
|
|
55
|
+
+ group("hour")
|
|
56
|
+
+ group("minute")
|
|
57
|
+
+ group("second")
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def to_time_and_delta(dt: str) -> DateAndDelta:
|
|
62
|
+
"""Parse to a absolute time or timedelta."""
|
|
63
|
+
parsed_td = REGEX_TIMEDELTA.match(dt)
|
|
64
|
+
if parsed_td is None:
|
|
65
|
+
return to_time(dt)
|
|
66
|
+
kw = {k: int(v) for k, v in parsed_td.groupdict().items() if v is not None}
|
|
67
|
+
if not kw:
|
|
68
|
+
raise InvalidTimeFormat(dt)
|
|
69
|
+
return datetime.timedelta(**kw)
|
|
11
70
|
|
|
12
71
|
|
|
13
|
-
__all__ = ["to_time", "
|
|
72
|
+
__all__ = ["to_time", "Date", "to_time_and_delta", "DateAndDelta"]
|
ics_query/tests/conftest.py
CHANGED
|
@@ -5,12 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
import subprocess
|
|
6
6
|
from copy import deepcopy
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import NamedTuple
|
|
8
|
+
from typing import Callable, NamedTuple
|
|
9
9
|
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
12
12
|
HERE = Path(__file__).parent
|
|
13
13
|
IO_DIRECTORY = HERE / "runs"
|
|
14
|
+
CALENDARS_DIRECTORY = IO_DIRECTORY / "calendars"
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class TestRun(NamedTuple):
|
|
@@ -34,12 +35,26 @@ class TestRun(NamedTuple):
|
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
def run_ics_query(*command, cwd=CALENDARS_DIRECTORY) -> TestRun:
|
|
39
|
+
"""Run ics-qeury with a command."""
|
|
40
|
+
cmd = ["ics-query", *command]
|
|
41
|
+
print(" ".join(cmd))
|
|
42
|
+
completed_process = subprocess.run( # noqa: S603, RUF100
|
|
43
|
+
cmd, # noqa: S603, RUF100
|
|
44
|
+
capture_output=True,
|
|
45
|
+
timeout=3,
|
|
46
|
+
check=False,
|
|
47
|
+
cwd=cwd,
|
|
48
|
+
)
|
|
49
|
+
return TestRun.from_completed_process(completed_process)
|
|
50
|
+
|
|
51
|
+
|
|
37
52
|
class IOTestCase(NamedTuple):
|
|
38
53
|
"""An example test case."""
|
|
39
54
|
|
|
40
55
|
name: str
|
|
41
56
|
command: list[str]
|
|
42
|
-
|
|
57
|
+
location: Path
|
|
43
58
|
expected_output: str
|
|
44
59
|
|
|
45
60
|
@classmethod
|
|
@@ -50,16 +65,7 @@ class IOTestCase(NamedTuple):
|
|
|
50
65
|
|
|
51
66
|
def run(self) -> TestRun:
|
|
52
67
|
"""Run this test case and return the result."""
|
|
53
|
-
|
|
54
|
-
print(" ".join(command))
|
|
55
|
-
completed_process = subprocess.run( # noqa: S603, RUF100
|
|
56
|
-
command, # noqa: S603, RUF100
|
|
57
|
-
capture_output=True,
|
|
58
|
-
timeout=3,
|
|
59
|
-
check=False,
|
|
60
|
-
cwd=self.cwd / "calendars",
|
|
61
|
-
)
|
|
62
|
-
return TestRun.from_completed_process(completed_process)
|
|
68
|
+
return run_ics_query(*self.command)
|
|
63
69
|
|
|
64
70
|
|
|
65
71
|
io_test_cases = [
|
|
@@ -75,4 +81,10 @@ def io_testcase(request) -> IOTestCase:
|
|
|
75
81
|
return deepcopy(request.param)
|
|
76
82
|
|
|
77
83
|
|
|
84
|
+
@pytest.fixture
|
|
85
|
+
def run() -> Callable[..., TestRun]:
|
|
86
|
+
"""Return a runner function."""
|
|
87
|
+
return run_ics_query
|
|
88
|
+
|
|
89
|
+
|
|
78
90
|
__all__ = ["IOTestCase", "TestRun"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
BEGIN:VEVENT
|
|
2
|
+
SUMMARY:test4
|
|
3
|
+
DTSTART;TZID=Europe/Berlin:20190304T000000
|
|
4
|
+
DTEND;TZID=Europe/Berlin:20190304T010000
|
|
5
|
+
DTSTAMP:20190303T111937
|
|
6
|
+
UID:UYDQSG9TH4DE0WM3QFL2J
|
|
7
|
+
CLASS:PUBLIC
|
|
8
|
+
CREATED:20190303T111937
|
|
9
|
+
LAST-MODIFIED:20190303T111937
|
|
10
|
+
STATUS:CONFIRMED
|
|
11
|
+
END:VEVENT
|
|
12
|
+
BEGIN:VEVENT
|
|
13
|
+
SUMMARY:test1
|
|
14
|
+
DTSTART;TZID=Europe/Berlin:20190304T080000
|
|
15
|
+
DTEND;TZID=Europe/Berlin:20190304T083000
|
|
16
|
+
DTSTAMP:20190303T111937
|
|
17
|
+
UID:UYDQSG9TH4DE0WM3QFL2J
|
|
18
|
+
CREATED:20190303T111937
|
|
19
|
+
LAST-MODIFIED:20190303T111937
|
|
20
|
+
END:VEVENT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
BEGIN:VEVENT
|
|
2
|
+
SUMMARY:test1
|
|
3
|
+
DTSTART;TZID=Europe/Berlin:20190304T080000
|
|
4
|
+
DTEND;TZID=Europe/Berlin:20190304T083000
|
|
5
|
+
DTSTAMP:20190303T111937
|
|
6
|
+
UID:UYDQSG9TH4DE0WM3QFL2J
|
|
7
|
+
CREATED:20190303T111937
|
|
8
|
+
LAST-MODIFIED:20190303T111937
|
|
9
|
+
END:VEVENT
|
|
10
|
+
BEGIN:VEVENT
|
|
11
|
+
SUMMARY:test1
|
|
12
|
+
DTSTART;TZID=Europe/Berlin:20190304T080000
|
|
13
|
+
DTEND;TZID=Europe/Berlin:20190304T083000
|
|
14
|
+
DTSTAMP:20190303T111937
|
|
15
|
+
UID:UYDQSG9TH4DE0WM3QFL2J
|
|
16
|
+
CREATED:20190303T111937
|
|
17
|
+
LAST-MODIFIED:20190303T111937
|
|
18
|
+
END:VEVENT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
BEGIN:VEVENT
|
|
2
|
+
SUMMARY:test4
|
|
3
|
+
DTSTART;TZID=Europe/Berlin:20190307T000000
|
|
4
|
+
DTEND;TZID=Europe/Berlin:20190307T010000
|
|
5
|
+
DTSTAMP:20190303T111937
|
|
6
|
+
UID:UYDQSG9TH4DE0WM3QFL2J
|
|
7
|
+
CLASS:PUBLIC
|
|
8
|
+
CREATED:20190303T111937
|
|
9
|
+
LAST-MODIFIED:20190303T111937
|
|
10
|
+
STATUS:CONFIRMED
|
|
11
|
+
END:VEVENT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
BEGIN:VEVENT
|
|
2
|
+
SUMMARY:Work
|
|
3
|
+
DTSTART;TZID=Europe/Berlin:20240823T090000
|
|
4
|
+
DTEND;TZID=Europe/Berlin:20240823T170000
|
|
5
|
+
DTSTAMP:20240823T082915Z
|
|
6
|
+
UID:6b85b60c-eb1a-4338-9ece-33541b95bf17
|
|
7
|
+
SEQUENCE:1
|
|
8
|
+
CREATED:20240823T082829Z
|
|
9
|
+
LAST-MODIFIED:20240823T082915Z
|
|
10
|
+
TRANSP:OPAQUE
|
|
11
|
+
X-MOZ-GENERATION:2
|
|
12
|
+
END:VEVENT
|
|
13
|
+
BEGIN:VEVENT
|
|
14
|
+
SUMMARY:Work
|
|
15
|
+
DTSTART;TZID=Europe/Berlin:20240826T090000
|
|
16
|
+
DTEND;TZID=Europe/Berlin:20240826T170000
|
|
17
|
+
DTSTAMP:20240823T082915Z
|
|
18
|
+
UID:6b85b60c-eb1a-4338-9ece-33541b95bf17
|
|
19
|
+
SEQUENCE:1
|
|
20
|
+
CREATED:20240823T082829Z
|
|
21
|
+
LAST-MODIFIED:20240823T082915Z
|
|
22
|
+
TRANSP:OPAQUE
|
|
23
|
+
X-MOZ-GENERATION:2
|
|
24
|
+
END:VEVENT
|
|
File without changes
|