ics-query 0.1.1a0__py3-none-any.whl → 0.3.0b0__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 (34) hide show
  1. ics_query/__init__.py +3 -2
  2. ics_query/_version.py +2 -2
  3. ics_query/cli.py +406 -23
  4. ics_query/parse.py +26 -4
  5. ics_query/query.py +54 -0
  6. ics_query/tests/runs/all --tz Singapore one-event.ics -.run +9 -0
  7. ics_query/tests/runs/all three-events.ics -.run +33 -0
  8. ics_query/tests/runs/at 2024-08-20 Berlin-Los-Angeles.ics -.run +23 -0
  9. ics_query/tests/runs/between 20240823 4d recurring-work-events.ics -.run +24 -0
  10. ics_query/tests/runs/calendars/Berlin-Los-Angeles.ics +381 -0
  11. ics_query/tests/runs/calendars/empty-calendar.ics +7 -0
  12. ics_query/tests/runs/calendars/empty-file.ics +0 -0
  13. ics_query/tests/runs/calendars/recurring-work-events.ics +223 -0
  14. ics_query/tests/runs/calendars/simple-journal.ics +15 -0
  15. ics_query/tests/runs/calendars/simple-todo.ics +15 -0
  16. ics_query/tests/runs/calendars/three-events.ics +37 -0
  17. ics_query/tests/runs/first -c VJOURNAL -c VEVENT one-event.ics -.run +9 -0
  18. ics_query/tests/runs/first -c VJOURNAL one-event.ics -.run +0 -0
  19. ics_query/tests/runs/first -c VJOURNAL simple-journal.ics -.run +12 -0
  20. ics_query/tests/runs/first -c VTODO -c VJOURNAL simple-todo.ics -.run +10 -0
  21. ics_query/tests/runs/first -c VTODO simple-todo.ics -.run +10 -0
  22. ics_query/tests/runs/first empty-calendar.ics -.run +0 -0
  23. ics_query/tests/runs/first empty-file.ics -.run +0 -0
  24. ics_query/tests/runs/first recurring-work-events.ics -.run +12 -0
  25. ics_query/tests/test_command_line.py +11 -0
  26. ics_query/tests/test_parse_date.py +8 -5
  27. ics_query/tests/test_parse_timedelta.py +25 -0
  28. ics_query/version.py +19 -0
  29. {ics_query-0.1.1a0.dist-info → ics_query-0.3.0b0.dist-info}/METADATA +194 -44
  30. ics_query-0.3.0b0.dist-info/RECORD +43 -0
  31. ics_query-0.1.1a0.dist-info/RECORD +0 -22
  32. {ics_query-0.1.1a0.dist-info → ics_query-0.3.0b0.dist-info}/WHEEL +0 -0
  33. {ics_query-0.1.1a0.dist-info → ics_query-0.3.0b0.dist-info}/entry_points.txt +0 -0
  34. {ics_query-0.1.1a0.dist-info → ics_query-0.3.0b0.dist-info}/licenses/LICENSE +0 -0
ics_query/__init__.py CHANGED
@@ -1,11 +1,12 @@
1
- from .cli import main
1
+ from .cli import cli, main
2
2
  from .version import __version__, __version_tuple__, version, version_tuple
3
3
 
4
4
  __all__ = [
5
- "main",
5
+ "cli",
6
6
  "app",
7
7
  "__version__",
8
8
  "version",
9
9
  "__version_tuple__",
10
10
  "version_tuple",
11
+ "main",
11
12
  ]
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.0b0'
16
+ __version_tuple__ = version_tuple = (0, 3, 0)
ics_query/cli.py CHANGED
@@ -8,22 +8,25 @@ import sys
8
8
  import typing as t
9
9
 
10
10
  import click
11
- import recurring_ical_events
11
+ import zoneinfo
12
12
  from icalendar.cal import Calendar, Component
13
- from recurring_ical_events import CalendarQuery
14
13
 
15
14
  from . import parse
16
- from .version import __version__
15
+ from .query import Query
16
+ from .version import cli_version
17
17
 
18
18
  if t.TYPE_CHECKING:
19
19
  from io import FileIO
20
20
 
21
+ import recurring_ical_events
21
22
  from icalendar.cal import Component
22
23
 
23
- from .parse import DateArgument
24
+ from .parse import Date
24
25
 
25
26
  print = functools.partial(print, file=sys.stderr) # noqa: A001
26
27
 
28
+ ENV_PREFIX = "ICS_QUERY"
29
+
27
30
 
28
31
  class ComponentsResult:
29
32
  """Output interface for components."""
@@ -36,6 +39,11 @@ class ComponentsResult:
36
39
  """Return a component."""
37
40
  self._file.write(component.to_ical())
38
41
 
42
+ def add_components(self, components: t.Iterable[Component]):
43
+ """Add all components."""
44
+ for component in components:
45
+ self.add_component(component)
46
+
39
47
 
40
48
  class ComponentsResultArgument(click.File):
41
49
  """Argument for the result."""
@@ -52,15 +60,37 @@ class ComponentsResultArgument(click.File):
52
60
 
53
61
 
54
62
  class JoinedCalendars:
55
- def __init__(self, calendars: list[Calendar]):
63
+ def __init__(
64
+ self, calendars: list[Calendar], timezone: str, components: t.Sequence[str]
65
+ ):
56
66
  """Join multiple calendars."""
57
- self.queries = [recurring_ical_events.of(calendar) for calendar in calendars]
67
+ self.queries = [
68
+ Query(calendar, timezone, components=components) for calendar in calendars
69
+ ]
58
70
 
59
71
  def at(self, dt: tuple[int]) -> t.Generator[Component]:
60
72
  """Return the components."""
61
73
  for query in self.queries:
62
74
  yield from query.at(dt)
63
75
 
76
+ def first(self) -> t.Generator[Component]:
77
+ """Return the first events of all calendars."""
78
+ for query in self.queries:
79
+ for component in query.all():
80
+ yield component
81
+ break
82
+
83
+ def all(self) -> t.Generator[Component]:
84
+ """Return the first events of all calendars."""
85
+ for query in self.queries:
86
+ yield from query.all()
87
+
88
+ def between(
89
+ self, start: parse.Date, end: parse.DateAndDelta
90
+ ) -> t.Generator[Component]:
91
+ for query in self.queries:
92
+ yield from query.between(start, end)
93
+
64
94
 
65
95
  class CalendarQueryInputArgument(click.File):
66
96
  """Argument for the result."""
@@ -74,16 +104,88 @@ class CalendarQueryInputArgument(click.File):
74
104
  """Return a CalendarQuery."""
75
105
  file = super().convert(value, param, ctx)
76
106
  calendars = Calendar.from_ical(file.read(), multiple=True)
77
- return JoinedCalendars(calendars)
107
+ components = ctx.params.get("component", ("VEVENT", "VTODO", "VJOURNAL"))
108
+ timezone = ctx.params.get("tz", "")
109
+ return JoinedCalendars(calendars, timezone, components)
110
+
111
+
112
+ opt_components = click.option(
113
+ "--component",
114
+ "-c",
115
+ multiple=True,
116
+ envvar=ENV_PREFIX + "_COMPONENT",
117
+ help=(
118
+ "Select the components which can be returned. "
119
+ "By default all supported components can be in the result. "
120
+ "Possible values are: VEVENT, VTODO, VJOURNAL. "
121
+ ),
122
+ )
123
+
124
+ opt_timezone = click.option(
125
+ "--tz",
126
+ envvar=ENV_PREFIX + "_TZ",
127
+ help=("Set the timezone. See also --available-timezones"),
128
+ )
129
+
130
+
131
+ def arg_calendar(func):
132
+ """Decorator for a calendar argument with all used options."""
133
+ arg = click.argument("calendar", type=CalendarQueryInputArgument("rb"))
134
+
135
+ @functools.wraps(func)
136
+ def wrapper(*args, component=(), tz="", **kw): # noqa: ARG001
137
+ """Remove some parameters."""
138
+ return func(*args, **kw)
139
+
140
+ return opt_timezone(opt_components(arg(wrapper)))
141
+
142
+
143
+ arg_output = click.argument("output", type=ComponentsResultArgument("wb"), default="-")
144
+ # Option with many values and list as result
145
+ # see https://click.palletsprojects.com/en/latest/options/#multiple-options
146
+
147
+
148
+ def opt_available_timezones(*param_decls: str, **kwargs: t.Any) -> t.Callable:
149
+ """Add a ``--help`` option which immediately prints the help page
150
+ and exits the program.
151
+
152
+ This is usually unnecessary, as the ``--help`` option is added to
153
+ each command automatically unless ``add_help_option=False`` is
154
+ passed.
155
+
156
+ :param param_decls: One or more option names. Defaults to the single
157
+ value ``"--help"``.
158
+ :param kwargs: Extra arguments are passed to :func:`option`.
159
+ """
160
+
161
+ def callback(ctx: click.Context, param: click.Parameter, value: bool) -> None: # noqa: FBT001, ARG001
162
+ if not value or ctx.resilient_parsing:
163
+ return
78
164
 
165
+ first_zones = ["localtime", "UTC"]
166
+ all_zones = zoneinfo.available_timezones()
167
+ for zone in first_zones:
168
+ if zone in all_zones:
169
+ click.echo(zone)
170
+ for zone in sorted(all_zones, key=str.lower):
171
+ click.echo(zone)
172
+ ctx.exit()
79
173
 
80
- arg_calendar = click.argument("calendar", type=CalendarQueryInputArgument("rb"))
81
- arg_output = click.argument("output", type=ComponentsResultArgument("wb"))
174
+ if not param_decls:
175
+ param_decls = ("--available-timezones",)
176
+
177
+ kwargs.setdefault("is_flag", True)
178
+ kwargs.setdefault("expose_value", False)
179
+ kwargs.setdefault("is_eager", True)
180
+ kwargs.setdefault("help", "List all available timezones and exit.")
181
+ kwargs["callback"] = callback
182
+ return click.option(*param_decls, **kwargs)
82
183
 
83
184
 
84
185
  @click.group()
85
- @click.version_option(__version__)
86
- def main():
186
+ @click.version_option(cli_version)
187
+ @opt_available_timezones()
188
+ def cli():
87
189
  """Find out what happens in ICS calendar files.
88
190
 
89
191
  ics-query can query and filter RFC 5545 compatible .ics files.
@@ -107,8 +209,8 @@ def main():
107
209
  If OUTPUT is "-", then the standard output is used.
108
210
 
109
211
  \b
110
- Notes on Calculation
111
- --------------------
212
+ Calculation
213
+ -----------
112
214
 
113
215
  An event can be very long. If you request smaller time spans or a time as
114
216
  exact as a second, the event will still occur within this time span if it
@@ -121,20 +223,72 @@ def main():
121
223
  The START is INCLUSIVE, then END is EXCLUSIVE.
122
224
 
123
225
  \b
124
- Notes on Timezones
125
- ------------------
226
+ Timezones
227
+ ---------
228
+
229
+ We have several timezones available to choose from.
230
+ While the calendar entries might use their own timezone definitions,
231
+ the timezone parameters of ics-query use the timezone definitions of
232
+ Python's tzdata package.
233
+
234
+ You can list all timezones available with this command:
235
+
236
+ \b
237
+ ics-query --available-timezones
238
+
239
+ By default the local time of the components is assumed.
240
+ In this example, two events happen at 6am, one in Berlin and one in Los Angeles.
241
+ Both are hours apart though.
242
+
243
+ \b
244
+ $ ics-query at 2024-08-20 Berlin-Los-Angeles.ics - | grep -E 'DTSTART|SUMMARY'
245
+ SUMMARY:6:00-7:00 Europe/Berlin 20th August
246
+ DTSTART;TZID=Europe/Berlin:20240820T060000
247
+ SUMMARY:6:00-7:00 Amerika/Los Angeles 20th August
248
+ DTSTART;TZID=America/Los_Angeles:20240820T060000
249
+
250
+ If you however wish to get all events in a certain timezone, use the --tz parameter.
251
+ In this example, the event that happens at the 19th of August at 21:00 in
252
+ Los Angeles is actually happening on the 20th in local Berlin time.
253
+
254
+ \b
255
+ $ ics-query at --tz=Europe/Berlin 2024-08-20 Berlin-Los-Angeles.ics - \\
256
+ | grep -E 'DTSTART|SUMMARY'
257
+ SUMMARY:6:00-7:00 Europe/Berlin 20th August
258
+ DTSTART;TZID=Europe/Berlin:20240820T060000
259
+ SUMMARY:6:00-7:00 Amerika/Los Angeles 20th August
260
+ DTSTART;TZID=Europe/Berlin:20240820T150000
261
+ SUMMARY:21:00-22:00 Amerika/Los Angeles 19th August
262
+ DTSTART;TZID=Europe/Berlin:20240820T060000
263
+
264
+ If you wish to get events in your local time, use --tz localtime.
265
+ If you like UTC, use --tz UTC.
266
+
267
+ You can also set the environment variable ICS_QUERY_TZ to the timezone instead of
268
+ passing --tz.
269
+
270
+ \b
271
+ Components
272
+ ----------
273
+
274
+ We support different types of recurring components: VEVENT, VTODO, VJOURNAL.
275
+ You can specify which can be in the result using the --component parameter.
276
+
277
+ You can also set the environment variable ICS_QUERY_COMPONENT to the timezone
278
+ instead of passing --component.
279
+
126
280
  """ # noqa: D301
127
281
 
128
282
 
129
283
  pass_datetime = click.make_pass_decorator(parse.to_time)
130
284
 
131
285
 
132
- @main.command()
286
+ @cli.command()
133
287
  @click.argument("date", type=parse.to_time)
134
288
  @arg_calendar
135
289
  @arg_output
136
- def at(calendar: CalendarQuery, output: ComponentsResult, date: DateArgument):
137
- """Occurrences at a certain dates.
290
+ def at(calendar: JoinedCalendars, output: ComponentsResult, date: Date):
291
+ """Print occurrences at a certain date or time.
138
292
 
139
293
  YEAR
140
294
 
@@ -156,7 +310,7 @@ def at(calendar: CalendarQuery, output: ComponentsResult, date: DateArgument):
156
310
 
157
311
  \b
158
312
  Formats:
159
-
313
+ \b
160
314
  YYYY-MM
161
315
  YYYY-M
162
316
  YYYYMM
@@ -175,7 +329,7 @@ def at(calendar: CalendarQuery, output: ComponentsResult, date: DateArgument):
175
329
 
176
330
  \b
177
331
  Formats:
178
-
332
+ \b
179
333
  YYYY-MM-DD
180
334
  YYYY-M-D
181
335
  YYYYMMDD
@@ -253,8 +407,237 @@ def at(calendar: CalendarQuery, output: ComponentsResult, date: DateArgument):
253
407
  ics-query at 19900101235959 # 1st January 1990, 23:59:59
254
408
  ics-query at `date +%Y%m%d%H%M%S` # now
255
409
  """ # noqa: D301
256
- for event in calendar.at(date):
257
- output.add_component(event)
410
+ output.add_components(calendar.at(date))
411
+
412
+
413
+ @cli.command()
414
+ @arg_calendar
415
+ @arg_output
416
+ def first(calendar: JoinedCalendars, output: ComponentsResult):
417
+ """Print only the first occurrence.
418
+
419
+ This prints the first occurrence in each calendar that is given.
420
+
421
+ \b
422
+ This example prints the first event in calendar.ics:
423
+ \b
424
+ ics-query first --component VEVENT calendar.ics -
425
+
426
+ """ # noqa: D301
427
+ output.add_components(calendar.first())
428
+
429
+
430
+ @cli.command()
431
+ @arg_calendar
432
+ @arg_output
433
+ def all(calendar: JoinedCalendars, output: ComponentsResult): # noqa: A001
434
+ """Print all occurrences in a calendar.
435
+
436
+ The result is ordered by the start of the occurrences.
437
+ If you have multiple calendars, the result will contain
438
+ the occurrences of the first calendar before those of the second calendar
439
+ and so on.
440
+
441
+ \b
442
+ This example prints all events in calendar.ics:
443
+ \b
444
+ ics-query all --component VEVENT calendar.ics -
445
+
446
+ Note that calendars can create hundreds of occurrences and especially
447
+ contain endless repetitions. Use this with care as the output is
448
+ potentially enourmous. You can mitigate this by closing the OUTPUT
449
+ when you have enough e.g. with a head command.
450
+ """ # noqa: D301
451
+ output.add_components(calendar.all())
452
+
453
+
454
+ @cli.command()
455
+ @click.argument("start", type=parse.to_time)
456
+ @click.argument("end", type=parse.to_time_and_delta)
457
+ @arg_calendar
458
+ @arg_output
459
+ def between(
460
+ start: parse.Date,
461
+ end: parse.DateAndDelta,
462
+ calendar: JoinedCalendars,
463
+ output: ComponentsResult,
464
+ ):
465
+ """Print occurrences between a START and an END.
466
+
467
+ The start is inclusive, the end is exclusive.
468
+
469
+ This example returns the events within the next week:
470
+
471
+ \b
472
+ ics-query between --component VEVENT `date +%Y%m%d` +7d calendar.ics -
473
+
474
+ This example saves the events from the 1st of May 2024 to the 10th of June in
475
+ events.ics:
476
+
477
+ \b
478
+ ics-query between --component VEVENT 2024-5-1 2024-6-10 calendar.ics events.ics
479
+
480
+ In this example, you can check what is happening on New Years Eve 2025 around
481
+ midnight:
482
+
483
+ \b
484
+ ics-query between 2025-12-31T21:00 +6h calendar.ics events.ics
485
+
486
+ \b
487
+ Absolute Time
488
+ -------------
489
+
490
+ START must be specified as an absolute time.
491
+ END can be absolute or relative to START, see Relative Time below.
492
+
493
+ Each of the formats specify the earliest time e.g. the start of a day.
494
+ Thus, if START == END, there are 0 seconds in between and the result is
495
+ only what happens during that time or starts exactly at that time.
496
+
497
+ YEAR
498
+
499
+ Specifiy the start of the year.
500
+
501
+ \b
502
+ Formats:
503
+ \b
504
+ YYYY
505
+ \b
506
+ Examples:
507
+ \b
508
+ 2024 # start of 2024
509
+ `date +%Y` # this year
510
+
511
+ MONTH
512
+
513
+ The start of the month.
514
+
515
+ \b
516
+ Formats:
517
+ \b
518
+ YYYY-MM
519
+ YYYY-M
520
+ YYYYMM
521
+ \b
522
+ Examples:
523
+ \b
524
+ 2019-10 # October 2019
525
+ 1990-01 # January 1990
526
+ 1990-1 # January 1990
527
+ 199001 # January 1990
528
+ `date +%Y%m` # this month
529
+
530
+ DAY
531
+
532
+ The start of the day
533
+
534
+ \b
535
+ Formats:
536
+ \b
537
+ YYYY-MM-DD
538
+ YYYY-M-D
539
+ YYYYMMDD
540
+ \b
541
+ Examples:
542
+ \b
543
+ 1990-01-01 # 1st January 1990
544
+ 1990-1-1 # 1st January 1990
545
+ 19900101 # 1st January 1990
546
+ `date +%Y%m%d` # today
547
+
548
+ HOUR
549
+
550
+ The start of the hour.
551
+
552
+ \b
553
+ Formats:
554
+ \b
555
+ YYYY-MM-DD HH
556
+ YYYY-MM-DDTHH
557
+ YYYY-M-DTH
558
+ YYYYMMDDTHH
559
+ YYYYMMDDHH
560
+ \b
561
+ Examples:
562
+ \b
563
+ 1990-01-01 01 # 1st January 1990, 1am
564
+ 1990-01-01T01 # 1st January 1990, 1am
565
+ 1990-1-1T17 # 1st January 1990, 17:00
566
+ 19900101T23 # 1st January 1990, 23:00
567
+ 1990010123 # 1st January 1990, 23:00
568
+ `date +%Y%m%d%H` # this hour
569
+
570
+ MINUTE
571
+
572
+ The start of a minute.
573
+
574
+ \b
575
+ Formats:
576
+ \b
577
+ YYYY-MM-DD HH:MM
578
+ YYYY-MM-DDTHH:MM
579
+ YYYY-M-DTH:M
580
+ YYYYMMDDTHHMM
581
+ YYYYMMDDHHMM
582
+ \b
583
+ Examples:
584
+ \b
585
+ 1990-01-01 10:10 # 1st January 1990, 10:10am
586
+ 1990-01-01T10:10 # 1st January 1990, 10:10am
587
+ 1990-1-1T7:2 # 1st January 1990, 07:02
588
+ 19900101T2359 # 1st January 1990, 23:59
589
+ 199001012359 # 1st January 1990, 23:59
590
+ `date +%Y%m%d%H%M` # this minute
591
+
592
+ SECOND
593
+
594
+ A precise time. RFC 5545 calendars are specified to the second.
595
+ This is the most precise format to specify times.
596
+
597
+ \b
598
+ Formats:
599
+ \b
600
+ YYYY-MM-DD HH:MM:SS
601
+ YYYY-MM-DDTHH:MM:SS
602
+ YYYY-M-DTH:M:S
603
+ YYYYMMDDTHHMMSS
604
+ YYYYMMDDHHMMSS
605
+ \b
606
+ Examples:
607
+ \b
608
+ 1990-01-01 10:10:00 # 1st January 1990, 10:10am
609
+ 1990-01-01T10:10:00 # 1st January 1990, 10:10am
610
+ 1990-1-1T7:2:30 # 1st January 1990, 07:02:30
611
+ 19901231T235959 # 31st December 1990, 23:59:59
612
+ 19900101235959 # 1st January 1990, 23:59:59
613
+ `date +%Y%m%d%H%M%S` # now
614
+ \b
615
+ Relative Time
616
+ -------------
617
+
618
+ The END argument can be a time range.
619
+ The + at the beginning is optional but makes for a better reading.
620
+
621
+ \b
622
+ Examples:
623
+ \b
624
+ Add 10 days to START: +10d
625
+ Add 24 hours to START: +1d or +24h
626
+ Add 3 hours to START: +3h
627
+ Add 30 minutes to START: +30m
628
+ Add 1000 seconds to START: +1000s
629
+ \b
630
+ You can also combine the ranges:
631
+ Add 1 day and 12 hours to START: +1d12h
632
+ Add 3 hours and 15 minutes to START: +3h15m
633
+
634
+ """ # noqa: D301
635
+ output.add_components(calendar.between(start, end))
636
+
637
+
638
+ def main():
639
+ """Run the program."""
640
+ cli(auto_envvar_prefix=ENV_PREFIX)
258
641
 
259
642
 
260
- __all__ = ["main"]
643
+ __all__ = ["main", "ENV_PREFIX", "cli"]
ics_query/parse.py CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import datetime
5
6
  import re
7
+ from typing import Union
6
8
 
7
- DateArgument = tuple[int]
9
+ Date = tuple[int]
10
+ DateAndDelta = Union[Date, datetime.timedelta]
8
11
 
9
12
 
10
13
  class InvalidTimeFormat(ValueError):
@@ -21,14 +24,22 @@ REGEX_TIME = re.compile(
21
24
  r"$"
22
25
  )
23
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
+
24
35
 
25
- def to_time(dt: str) -> DateArgument:
36
+ def to_time(dt: str) -> Date:
26
37
  """Parse the time and date."""
27
38
  parsed_dt = REGEX_TIME.match(dt)
28
39
  if parsed_dt is None:
29
40
  raise InvalidTimeFormat(dt)
30
41
 
31
- def group(group_name: str) -> tuple[int]:
42
+ def group(group_name: str) -> Date:
32
43
  """Return a group's value."""
33
44
  result = parsed_dt.group(group_name)
34
45
  while result and result[0] not in "0123456789":
@@ -47,4 +58,15 @@ def to_time(dt: str) -> DateArgument:
47
58
  )
48
59
 
49
60
 
50
- __all__ = ["to_time", "DateArgument"]
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)
70
+
71
+
72
+ __all__ = ["to_time", "Date", "to_time_and_delta", "DateAndDelta"]
ics_query/query.py ADDED
@@ -0,0 +1,54 @@
1
+ """This is an adaptation of the CalendarQuery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ from typing import TYPE_CHECKING, Sequence
7
+
8
+ import x_wr_timezone
9
+ import zoneinfo
10
+ from recurring_ical_events import CalendarQuery, Occurrence
11
+
12
+ if TYPE_CHECKING:
13
+ from icalendar import Calendar
14
+
15
+
16
+ class Query(CalendarQuery):
17
+ def __init__(self, calendar: Calendar, timezone: str, components: Sequence[str]):
18
+ """Create a new query."""
19
+ super().__init__(
20
+ x_wr_timezone.to_standard(calendar),
21
+ components=components,
22
+ skip_bad_series=True,
23
+ )
24
+ self.timezone = zoneinfo.ZoneInfo(timezone) if timezone else None
25
+
26
+ def with_timezone(self, dt: datetime.date | datetime.datetime):
27
+ """Add the timezone."""
28
+ if self.timezone is None:
29
+ return dt
30
+ if not isinstance(dt, datetime.datetime):
31
+ return datetime.datetime(
32
+ year=dt.year, month=dt.month, day=dt.day, tzinfo=self.timezone
33
+ )
34
+ if dt.tzinfo is None:
35
+ return dt.replace(tzinfo=self.timezone)
36
+ return dt.astimezone(self.timezone)
37
+
38
+ def _occurrences_between(
39
+ self,
40
+ start: datetime.date | datetime.datetime,
41
+ end: datetime.date | datetime.datetime,
42
+ ) -> list[Occurrence]:
43
+ """Override to adapt timezones."""
44
+ result = []
45
+ for occurrence in super()._occurrences_between(
46
+ self.with_timezone(start), self.with_timezone(end)
47
+ ):
48
+ occurrence.start = self.with_timezone(occurrence.start)
49
+ occurrence.end = self.with_timezone(occurrence.end)
50
+ result.append(occurrence)
51
+ return result
52
+
53
+
54
+ __all__ = ["Query"]
@@ -0,0 +1,9 @@
1
+ BEGIN:VEVENT
2
+ SUMMARY:test1
3
+ DTSTART;TZID=Singapore:20190304T150000
4
+ DTEND;TZID=Singapore:20190304T153000
5
+ DTSTAMP:20190303T111937
6
+ UID:UYDQSG9TH4DE0WM3QFL2J
7
+ CREATED:20190303T111937
8
+ LAST-MODIFIED:20190303T111937
9
+ END:VEVENT
@@ -0,0 +1,33 @@
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:test4
14
+ DTSTART;TZID=Europe/Berlin:20190307T000000
15
+ DTEND;TZID=Europe/Berlin:20190307T010000
16
+ DTSTAMP:20190303T111937
17
+ UID:UYDQSG9TH4DE0WM3QFL2J
18
+ CLASS:PUBLIC
19
+ CREATED:20190303T111937
20
+ LAST-MODIFIED:20190303T111937
21
+ STATUS:CONFIRMED
22
+ END:VEVENT
23
+ BEGIN:VEVENT
24
+ SUMMARY:test4
25
+ DTSTART;TZID=Europe/Berlin:20190310T000000
26
+ DTEND;TZID=Europe/Berlin:20190310T010000
27
+ DTSTAMP:20190303T111937
28
+ UID:UYDQSG9TH4DE0WM3QFL2J
29
+ CLASS:PUBLIC
30
+ CREATED:20190303T111937
31
+ LAST-MODIFIED:20190303T111937
32
+ STATUS:CONFIRMED
33
+ END:VEVENT