ics-query 0.1.1a0__py3-none-any.whl → 0.3.2b0__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.
Files changed (37) hide show
  1. ics_query/__init__.py +18 -2
  2. ics_query/__main__.py +15 -0
  3. ics_query/_version.py +2 -2
  4. ics_query/cli.py +457 -23
  5. ics_query/parse.py +41 -4
  6. ics_query/query.py +78 -0
  7. ics_query/tests/conftest.py +52 -16
  8. ics_query/tests/runs/all --tz Singapore one-event.ics -.run +9 -0
  9. ics_query/tests/runs/all three-events.ics -.run +33 -0
  10. ics_query/tests/runs/at 2024-08-20 Berlin-Los-Angeles.ics -.run +23 -0
  11. ics_query/tests/runs/between 20240823 4d recurring-work-events.ics -.run +24 -0
  12. ics_query/tests/runs/calendars/Berlin-Los-Angeles.ics +381 -0
  13. ics_query/tests/runs/calendars/empty-calendar.ics +7 -0
  14. ics_query/tests/runs/calendars/empty-file.ics +0 -0
  15. ics_query/tests/runs/calendars/one-event-without-timezone.ics +14 -0
  16. ics_query/tests/runs/calendars/recurring-work-events.ics +223 -0
  17. ics_query/tests/runs/calendars/simple-journal.ics +15 -0
  18. ics_query/tests/runs/calendars/simple-todo.ics +15 -0
  19. ics_query/tests/runs/calendars/three-events.ics +37 -0
  20. ics_query/tests/runs/first -c VJOURNAL -c VEVENT one-event.ics -.run +9 -0
  21. ics_query/tests/runs/first -c VJOURNAL one-event.ics -.run +0 -0
  22. ics_query/tests/runs/first -c VJOURNAL simple-journal.ics -.run +12 -0
  23. ics_query/tests/runs/first -c VTODO -c VJOURNAL simple-todo.ics -.run +10 -0
  24. ics_query/tests/runs/first -c VTODO simple-todo.ics -.run +10 -0
  25. ics_query/tests/runs/first empty-calendar.ics -.run +0 -0
  26. ics_query/tests/runs/first empty-file.ics -.run +0 -0
  27. ics_query/tests/runs/first recurring-work-events.ics -.run +12 -0
  28. ics_query/tests/test_command_line.py +53 -0
  29. ics_query/tests/test_parse_date.py +23 -5
  30. ics_query/tests/test_parse_timedelta.py +40 -0
  31. ics_query/version.py +33 -3
  32. {ics_query-0.1.1a0.dist-info → ics_query-0.3.2b0.dist-info}/METADATA +257 -51
  33. ics_query-0.3.2b0.dist-info/RECORD +44 -0
  34. ics_query-0.1.1a0.dist-info/RECORD +0 -22
  35. {ics_query-0.1.1a0.dist-info → ics_query-0.3.2b0.dist-info}/WHEEL +0 -0
  36. {ics_query-0.1.1a0.dist-info → ics_query-0.3.2b0.dist-info}/entry_points.txt +0 -0
  37. {ics_query-0.1.1a0.dist-info → ics_query-0.3.2b0.dist-info}/licenses/LICENSE +0 -0
ics_query/__init__.py CHANGED
@@ -1,11 +1,27 @@
1
- from .cli import main
1
+ # ics-query
2
+ # Copyright (C) 2024 Nicco Kunzmann
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ from .cli import cli, main
2
17
  from .version import __version__, __version_tuple__, version, version_tuple
3
18
 
4
19
  __all__ = [
5
- "main",
20
+ "cli",
6
21
  "app",
7
22
  "__version__",
8
23
  "version",
9
24
  "__version_tuple__",
10
25
  "version_tuple",
26
+ "main",
11
27
  ]
ics_query/__main__.py CHANGED
@@ -1,3 +1,18 @@
1
+ # ics-query
2
+ # Copyright (C) 2024 Nicco Kunzmann
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
1
16
  import sys
2
17
 
3
18
  from .cli import main
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.1.1a0'
16
- __version_tuple__ = version_tuple = (0, 1, 1)
15
+ __version__ = version = '0.3.2b0'
16
+ __version_tuple__ = version_tuple = (0, 3, 2)
ics_query/cli.py CHANGED
@@ -1,3 +1,18 @@
1
+ # ics-query
2
+ # Copyright (C) 2024 Nicco Kunzmann
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
1
16
  """The command line interface."""
2
17
 
3
18
  from __future__ import annotations
@@ -6,24 +21,45 @@ import functools
6
21
  import os # noqa: TCH003
7
22
  import sys
8
23
  import typing as t
24
+ import zoneinfo
9
25
 
10
26
  import click
11
- import recurring_ical_events
12
27
  from icalendar.cal import Calendar, Component
13
- from recurring_ical_events import CalendarQuery
28
+ from tzlocal import get_localzone_name
14
29
 
15
30
  from . import parse
16
- from .version import __version__
31
+ from .query import Query
32
+ from .version import cli_version
17
33
 
18
34
  if t.TYPE_CHECKING:
19
35
  from io import FileIO
20
36
 
37
+ import recurring_ical_events
21
38
  from icalendar.cal import Component
22
39
 
23
- from .parse import DateArgument
40
+ from .parse import Date
24
41
 
25
42
  print = functools.partial(print, file=sys.stderr) # noqa: A001
26
43
 
44
+ ENV_PREFIX = "ICS_QUERY"
45
+ LICENSE = """
46
+ ics-query
47
+ Copyright (C) 2024 Nicco Kunzmann
48
+
49
+ This program is free software: you can redistribute it and/or modify
50
+ it under the terms of the GNU General Public License as published by
51
+ the Free Software Foundation, either version 3 of the License, or
52
+ (at your option) any later version.
53
+
54
+ This program is distributed in the hope that it will be useful,
55
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
56
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
57
+ GNU General Public License for more details.
58
+
59
+ You should have received a copy of the GNU General Public License
60
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
61
+ """
62
+
27
63
 
28
64
  class ComponentsResult:
29
65
  """Output interface for components."""
@@ -36,6 +72,11 @@ class ComponentsResult:
36
72
  """Return a component."""
37
73
  self._file.write(component.to_ical())
38
74
 
75
+ def add_components(self, components: t.Iterable[Component]):
76
+ """Add all components."""
77
+ for component in components:
78
+ self.add_component(component)
79
+
39
80
 
40
81
  class ComponentsResultArgument(click.File):
41
82
  """Argument for the result."""
@@ -52,15 +93,37 @@ class ComponentsResultArgument(click.File):
52
93
 
53
94
 
54
95
  class JoinedCalendars:
55
- def __init__(self, calendars: list[Calendar]):
96
+ def __init__(
97
+ self, calendars: list[Calendar], timezone: str, components: t.Sequence[str]
98
+ ):
56
99
  """Join multiple calendars."""
57
- self.queries = [recurring_ical_events.of(calendar) for calendar in calendars]
100
+ self.queries = [
101
+ Query(calendar, timezone, components=components) for calendar in calendars
102
+ ]
58
103
 
59
104
  def at(self, dt: tuple[int]) -> t.Generator[Component]:
60
105
  """Return the components."""
61
106
  for query in self.queries:
62
107
  yield from query.at(dt)
63
108
 
109
+ def first(self) -> t.Generator[Component]:
110
+ """Return the first events of all calendars."""
111
+ for query in self.queries:
112
+ for component in query.all():
113
+ yield component
114
+ break
115
+
116
+ def all(self) -> t.Generator[Component]:
117
+ """Return the first events of all calendars."""
118
+ for query in self.queries:
119
+ yield from query.all()
120
+
121
+ def between(
122
+ self, start: parse.Date, end: parse.DateAndDelta
123
+ ) -> t.Generator[Component]:
124
+ for query in self.queries:
125
+ yield from query.between(start, end)
126
+
64
127
 
65
128
  class CalendarQueryInputArgument(click.File):
66
129
  """Argument for the result."""
@@ -74,16 +137,106 @@ class CalendarQueryInputArgument(click.File):
74
137
  """Return a CalendarQuery."""
75
138
  file = super().convert(value, param, ctx)
76
139
  calendars = Calendar.from_ical(file.read(), multiple=True)
77
- return JoinedCalendars(calendars)
140
+ components = ctx.params.get("component", ("VEVENT", "VTODO", "VJOURNAL"))
141
+ timezone = ctx.params.get("tz", "")
142
+ return JoinedCalendars(calendars, timezone, components)
143
+
144
+
145
+ opt_components = click.option(
146
+ "--component",
147
+ "-c",
148
+ multiple=True,
149
+ envvar=ENV_PREFIX + "_COMPONENT",
150
+ help=(
151
+ "Select the components which can be returned. "
152
+ "By default all supported components can be in the result. "
153
+ "Possible values are: VEVENT, VTODO, VJOURNAL. "
154
+ ),
155
+ )
156
+
157
+ opt_timezone = click.option(
158
+ "--tz",
159
+ envvar=ENV_PREFIX + "_TZ",
160
+ help=("Set the timezone. See also --available-timezones"),
161
+ )
162
+
163
+
164
+ def arg_calendar(func):
165
+ """Decorator for a calendar argument with all used options."""
166
+ arg = click.argument("calendar", type=CalendarQueryInputArgument("rb"))
167
+
168
+ @functools.wraps(func)
169
+ def wrapper(*args, component=(), tz="", **kw): # noqa: ARG001
170
+ """Remove some parameters."""
171
+ return func(*args, **kw)
172
+
173
+ return opt_timezone(opt_components(arg(wrapper)))
174
+
175
+
176
+ arg_output = click.argument("output", type=ComponentsResultArgument("wb"), default="-")
177
+ # Option with many values and list as result
178
+ # see https://click.palletsprojects.com/en/latest/options/#multiple-options
179
+
180
+
181
+ def opt_available_timezones(*param_decls: str, **kwargs: t.Any) -> t.Callable:
182
+ """List available timezones.
183
+
184
+ This is copied from the --help option.
185
+
186
+ Commonly used timezone names are added first.
187
+ """
78
188
 
189
+ def callback(ctx: click.Context, param: click.Parameter, value: bool) -> None: # noqa: FBT001, ARG001
190
+ if not value or ctx.resilient_parsing:
191
+ return
79
192
 
80
- arg_calendar = click.argument("calendar", type=CalendarQueryInputArgument("rb"))
81
- arg_output = click.argument("output", type=ComponentsResultArgument("wb"))
193
+ click.echo("localtime") # special local time handle
194
+ click.echo(get_localzone_name())
195
+ click.echo("UTC")
196
+ all_zones = zoneinfo.available_timezones()
197
+ for zone in sorted(all_zones, key=str.lower):
198
+ click.echo(zone)
199
+ ctx.exit()
200
+
201
+ if not param_decls:
202
+ param_decls = ("--available-timezones",)
203
+
204
+ kwargs.setdefault("is_flag", True)
205
+ kwargs.setdefault("expose_value", False)
206
+ kwargs.setdefault("is_eager", True)
207
+ kwargs.setdefault("help", "List all available timezones and exit.")
208
+ kwargs["callback"] = callback
209
+ return click.option(*param_decls, **kwargs)
210
+
211
+
212
+ def opt_license(*param_decls: str, **kwargs: t.Any) -> t.Callable:
213
+ """List available timezones.
214
+
215
+ This is copied from the --help option.
216
+ """
217
+
218
+ def callback(ctx: click.Context, param: click.Parameter, value: bool) -> None: # noqa: FBT001, ARG001
219
+ if not value or ctx.resilient_parsing:
220
+ return
221
+ click.echo(LICENSE)
222
+ ctx.exit()
223
+
224
+ if not param_decls:
225
+ param_decls = ("--license",)
226
+
227
+ kwargs.setdefault("is_flag", True)
228
+ kwargs.setdefault("expose_value", False)
229
+ kwargs.setdefault("is_eager", True)
230
+ kwargs.setdefault("help", "Show the license and exit.")
231
+ kwargs["callback"] = callback
232
+ return click.option(*param_decls, **kwargs)
82
233
 
83
234
 
84
235
  @click.group()
85
- @click.version_option(__version__)
86
- def main():
236
+ @click.version_option(cli_version)
237
+ @opt_available_timezones()
238
+ @opt_license()
239
+ def cli():
87
240
  """Find out what happens in ICS calendar files.
88
241
 
89
242
  ics-query can query and filter RFC 5545 compatible .ics files.
@@ -107,8 +260,8 @@ def main():
107
260
  If OUTPUT is "-", then the standard output is used.
108
261
 
109
262
  \b
110
- Notes on Calculation
111
- --------------------
263
+ Calculation
264
+ -----------
112
265
 
113
266
  An event can be very long. If you request smaller time spans or a time as
114
267
  exact as a second, the event will still occur within this time span if it
@@ -121,20 +274,72 @@ def main():
121
274
  The START is INCLUSIVE, then END is EXCLUSIVE.
122
275
 
123
276
  \b
124
- Notes on Timezones
125
- ------------------
277
+ Timezones
278
+ ---------
279
+
280
+ We have several timezones available to choose from.
281
+ While the calendar entries might use their own timezone definitions,
282
+ the timezone parameters of ics-query use the timezone definitions of
283
+ Python's tzdata package.
284
+
285
+ You can list all timezones available with this command:
286
+
287
+ \b
288
+ ics-query --available-timezones
289
+
290
+ By default the local time of the components is assumed.
291
+ In this example, two events happen at 6am, one in Berlin and one in Los Angeles.
292
+ Both are hours apart though.
293
+
294
+ \b
295
+ $ ics-query at 2024-08-20 Berlin-Los-Angeles.ics - | grep -E 'DTSTART|SUMMARY'
296
+ SUMMARY:6:00-7:00 Europe/Berlin 20th August
297
+ DTSTART;TZID=Europe/Berlin:20240820T060000
298
+ SUMMARY:6:00-7:00 Amerika/Los Angeles 20th August
299
+ DTSTART;TZID=America/Los_Angeles:20240820T060000
300
+
301
+ If you however wish to get all events in a certain timezone, use the --tz parameter.
302
+ In this example, the event that happens at the 19th of August at 21:00 in
303
+ Los Angeles is actually happening on the 20th in local Berlin time.
304
+
305
+ \b
306
+ $ ics-query at --tz=Europe/Berlin 2024-08-20 Berlin-Los-Angeles.ics - \\
307
+ | grep -E 'DTSTART|SUMMARY'
308
+ SUMMARY:6:00-7:00 Europe/Berlin 20th August
309
+ DTSTART;TZID=Europe/Berlin:20240820T060000
310
+ SUMMARY:6:00-7:00 Amerika/Los Angeles 20th August
311
+ DTSTART;TZID=Europe/Berlin:20240820T150000
312
+ SUMMARY:21:00-22:00 Amerika/Los Angeles 19th August
313
+ DTSTART;TZID=Europe/Berlin:20240820T060000
314
+
315
+ If you wish to get events in your local time, use --tz localtime.
316
+ If you like UTC, use --tz UTC.
317
+
318
+ You can also set the environment variable ICS_QUERY_TZ to the timezone instead of
319
+ passing --tz.
320
+
321
+ \b
322
+ Components
323
+ ----------
324
+
325
+ We support different types of recurring components: VEVENT, VTODO, VJOURNAL.
326
+ You can specify which can be in the result using the --component parameter.
327
+
328
+ You can also set the environment variable ICS_QUERY_COMPONENT to the timezone
329
+ instead of passing --component.
330
+
126
331
  """ # noqa: D301
127
332
 
128
333
 
129
334
  pass_datetime = click.make_pass_decorator(parse.to_time)
130
335
 
131
336
 
132
- @main.command()
337
+ @cli.command()
133
338
  @click.argument("date", type=parse.to_time)
134
339
  @arg_calendar
135
340
  @arg_output
136
- def at(calendar: CalendarQuery, output: ComponentsResult, date: DateArgument):
137
- """Occurrences at a certain dates.
341
+ def at(calendar: JoinedCalendars, output: ComponentsResult, date: Date):
342
+ """Print occurrences at a certain date or time.
138
343
 
139
344
  YEAR
140
345
 
@@ -156,7 +361,7 @@ def at(calendar: CalendarQuery, output: ComponentsResult, date: DateArgument):
156
361
 
157
362
  \b
158
363
  Formats:
159
-
364
+ \b
160
365
  YYYY-MM
161
366
  YYYY-M
162
367
  YYYYMM
@@ -175,7 +380,7 @@ def at(calendar: CalendarQuery, output: ComponentsResult, date: DateArgument):
175
380
 
176
381
  \b
177
382
  Formats:
178
-
383
+ \b
179
384
  YYYY-MM-DD
180
385
  YYYY-M-D
181
386
  YYYYMMDD
@@ -253,8 +458,237 @@ def at(calendar: CalendarQuery, output: ComponentsResult, date: DateArgument):
253
458
  ics-query at 19900101235959 # 1st January 1990, 23:59:59
254
459
  ics-query at `date +%Y%m%d%H%M%S` # now
255
460
  """ # noqa: D301
256
- for event in calendar.at(date):
257
- output.add_component(event)
461
+ output.add_components(calendar.at(date))
462
+
463
+
464
+ @cli.command()
465
+ @arg_calendar
466
+ @arg_output
467
+ def first(calendar: JoinedCalendars, output: ComponentsResult):
468
+ """Print only the first occurrence.
469
+
470
+ This prints the first occurrence in each calendar that is given.
471
+
472
+ \b
473
+ This example prints the first event in calendar.ics:
474
+ \b
475
+ ics-query first --component VEVENT calendar.ics -
476
+
477
+ """ # noqa: D301
478
+ output.add_components(calendar.first())
479
+
480
+
481
+ @cli.command()
482
+ @arg_calendar
483
+ @arg_output
484
+ def all(calendar: JoinedCalendars, output: ComponentsResult): # noqa: A001
485
+ """Print all occurrences in a calendar.
486
+
487
+ The result is ordered by the start of the occurrences.
488
+ If you have multiple calendars, the result will contain
489
+ the occurrences of the first calendar before those of the second calendar
490
+ and so on.
491
+
492
+ \b
493
+ This example prints all events in calendar.ics:
494
+ \b
495
+ ics-query all --component VEVENT calendar.ics -
496
+
497
+ Note that calendars can create hundreds of occurrences and especially
498
+ contain endless repetitions. Use this with care as the output is
499
+ potentially enourmous. You can mitigate this by closing the OUTPUT
500
+ when you have enough e.g. with a head command.
501
+ """ # noqa: D301
502
+ output.add_components(calendar.all())
503
+
504
+
505
+ @cli.command()
506
+ @click.argument("start", type=parse.to_time)
507
+ @click.argument("end", type=parse.to_time_and_delta)
508
+ @arg_calendar
509
+ @arg_output
510
+ def between(
511
+ start: parse.Date,
512
+ end: parse.DateAndDelta,
513
+ calendar: JoinedCalendars,
514
+ output: ComponentsResult,
515
+ ):
516
+ """Print occurrences between a START and an END.
517
+
518
+ The start is inclusive, the end is exclusive.
519
+
520
+ This example returns the events within the next week:
521
+
522
+ \b
523
+ ics-query between --component VEVENT `date +%Y%m%d` +7d calendar.ics -
524
+
525
+ This example saves the events from the 1st of May 2024 to the 10th of June in
526
+ events.ics:
527
+
528
+ \b
529
+ ics-query between --component VEVENT 2024-5-1 2024-6-10 calendar.ics events.ics
530
+
531
+ In this example, you can check what is happening on New Years Eve 2025 around
532
+ midnight:
533
+
534
+ \b
535
+ ics-query between 2025-12-31T21:00 +6h calendar.ics events.ics
536
+
537
+ \b
538
+ Absolute Time
539
+ -------------
540
+
541
+ START must be specified as an absolute time.
542
+ END can be absolute or relative to START, see Relative Time below.
543
+
544
+ Each of the formats specify the earliest time e.g. the start of a day.
545
+ Thus, if START == END, there are 0 seconds in between and the result is
546
+ only what happens during that time or starts exactly at that time.
547
+
548
+ YEAR
549
+
550
+ Specifiy the start of the year.
551
+
552
+ \b
553
+ Formats:
554
+ \b
555
+ YYYY
556
+ \b
557
+ Examples:
558
+ \b
559
+ 2024 # start of 2024
560
+ `date +%Y` # this year
561
+
562
+ MONTH
563
+
564
+ The start of the month.
565
+
566
+ \b
567
+ Formats:
568
+ \b
569
+ YYYY-MM
570
+ YYYY-M
571
+ YYYYMM
572
+ \b
573
+ Examples:
574
+ \b
575
+ 2019-10 # October 2019
576
+ 1990-01 # January 1990
577
+ 1990-1 # January 1990
578
+ 199001 # January 1990
579
+ `date +%Y%m` # this month
580
+
581
+ DAY
582
+
583
+ The start of the day
584
+
585
+ \b
586
+ Formats:
587
+ \b
588
+ YYYY-MM-DD
589
+ YYYY-M-D
590
+ YYYYMMDD
591
+ \b
592
+ Examples:
593
+ \b
594
+ 1990-01-01 # 1st January 1990
595
+ 1990-1-1 # 1st January 1990
596
+ 19900101 # 1st January 1990
597
+ `date +%Y%m%d` # today
598
+
599
+ HOUR
600
+
601
+ The start of the hour.
602
+
603
+ \b
604
+ Formats:
605
+ \b
606
+ YYYY-MM-DD HH
607
+ YYYY-MM-DDTHH
608
+ YYYY-M-DTH
609
+ YYYYMMDDTHH
610
+ YYYYMMDDHH
611
+ \b
612
+ Examples:
613
+ \b
614
+ 1990-01-01 01 # 1st January 1990, 1am
615
+ 1990-01-01T01 # 1st January 1990, 1am
616
+ 1990-1-1T17 # 1st January 1990, 17:00
617
+ 19900101T23 # 1st January 1990, 23:00
618
+ 1990010123 # 1st January 1990, 23:00
619
+ `date +%Y%m%d%H` # this hour
620
+
621
+ MINUTE
622
+
623
+ The start of a minute.
624
+
625
+ \b
626
+ Formats:
627
+ \b
628
+ YYYY-MM-DD HH:MM
629
+ YYYY-MM-DDTHH:MM
630
+ YYYY-M-DTH:M
631
+ YYYYMMDDTHHMM
632
+ YYYYMMDDHHMM
633
+ \b
634
+ Examples:
635
+ \b
636
+ 1990-01-01 10:10 # 1st January 1990, 10:10am
637
+ 1990-01-01T10:10 # 1st January 1990, 10:10am
638
+ 1990-1-1T7:2 # 1st January 1990, 07:02
639
+ 19900101T2359 # 1st January 1990, 23:59
640
+ 199001012359 # 1st January 1990, 23:59
641
+ `date +%Y%m%d%H%M` # this minute
642
+
643
+ SECOND
644
+
645
+ A precise time. RFC 5545 calendars are specified to the second.
646
+ This is the most precise format to specify times.
647
+
648
+ \b
649
+ Formats:
650
+ \b
651
+ YYYY-MM-DD HH:MM:SS
652
+ YYYY-MM-DDTHH:MM:SS
653
+ YYYY-M-DTH:M:S
654
+ YYYYMMDDTHHMMSS
655
+ YYYYMMDDHHMMSS
656
+ \b
657
+ Examples:
658
+ \b
659
+ 1990-01-01 10:10:00 # 1st January 1990, 10:10am
660
+ 1990-01-01T10:10:00 # 1st January 1990, 10:10am
661
+ 1990-1-1T7:2:30 # 1st January 1990, 07:02:30
662
+ 19901231T235959 # 31st December 1990, 23:59:59
663
+ 19900101235959 # 1st January 1990, 23:59:59
664
+ `date +%Y%m%d%H%M%S` # now
665
+ \b
666
+ Relative Time
667
+ -------------
668
+
669
+ The END argument can be a time range.
670
+ The + at the beginning is optional but makes for a better reading.
671
+
672
+ \b
673
+ Examples:
674
+ \b
675
+ Add 10 days to START: +10d
676
+ Add 24 hours to START: +1d or +24h
677
+ Add 3 hours to START: +3h
678
+ Add 30 minutes to START: +30m
679
+ Add 1000 seconds to START: +1000s
680
+ \b
681
+ You can also combine the ranges:
682
+ Add 1 day and 12 hours to START: +1d12h
683
+ Add 3 hours and 15 minutes to START: +3h15m
684
+
685
+ """ # noqa: D301
686
+ output.add_components(calendar.between(start, end))
687
+
688
+
689
+ def main():
690
+ """Run the program."""
691
+ cli(auto_envvar_prefix=ENV_PREFIX)
258
692
 
259
693
 
260
- __all__ = ["main"]
694
+ __all__ = ["main", "ENV_PREFIX", "cli"]
ics_query/parse.py CHANGED
@@ -1,10 +1,28 @@
1
+ # ics-query
2
+ # Copyright (C) 2024 Nicco Kunzmann
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
1
16
  """Functions for parsing the content."""
2
17
 
3
18
  from __future__ import annotations
4
19
 
20
+ import datetime
5
21
  import re
22
+ from typing import Union
6
23
 
7
- DateArgument = tuple[int]
24
+ Date = tuple[int]
25
+ DateAndDelta = Union[Date, datetime.timedelta]
8
26
 
9
27
 
10
28
  class InvalidTimeFormat(ValueError):
@@ -21,14 +39,22 @@ REGEX_TIME = re.compile(
21
39
  r"$"
22
40
  )
23
41
 
42
+ REGEX_TIMEDELTA = re.compile(
43
+ r"^\+?(?:(?P<days>\d+)d)?"
44
+ r"(?:(?P<hours>\d+)h)?"
45
+ r"(?:(?P<minutes>\d+)m)?"
46
+ r"(?:(?P<seconds>\d+)s)?"
47
+ r"$"
48
+ )
49
+
24
50
 
25
- def to_time(dt: str) -> DateArgument:
51
+ def to_time(dt: str) -> Date:
26
52
  """Parse the time and date."""
27
53
  parsed_dt = REGEX_TIME.match(dt)
28
54
  if parsed_dt is None:
29
55
  raise InvalidTimeFormat(dt)
30
56
 
31
- def group(group_name: str) -> tuple[int]:
57
+ def group(group_name: str) -> Date:
32
58
  """Return a group's value."""
33
59
  result = parsed_dt.group(group_name)
34
60
  while result and result[0] not in "0123456789":
@@ -47,4 +73,15 @@ def to_time(dt: str) -> DateArgument:
47
73
  )
48
74
 
49
75
 
50
- __all__ = ["to_time", "DateArgument"]
76
+ def to_time_and_delta(dt: str) -> DateAndDelta:
77
+ """Parse to a absolute time or timedelta."""
78
+ parsed_td = REGEX_TIMEDELTA.match(dt)
79
+ if parsed_td is None:
80
+ return to_time(dt)
81
+ kw = {k: int(v) for k, v in parsed_td.groupdict().items() if v is not None}
82
+ if not kw:
83
+ raise InvalidTimeFormat(dt)
84
+ return datetime.timedelta(**kw)
85
+
86
+
87
+ __all__ = ["to_time", "Date", "to_time_and_delta", "DateAndDelta"]