ics-query 0.2.0a0__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.
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.2.0a0'
16
- __version_tuple__ = version_tuple = (0, 2, 0)
15
+ __version__ = version = '0.3.0b0'
16
+ __version_tuple__ = version_tuple = (0, 3, 0)
ics_query/cli.py CHANGED
@@ -8,21 +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
13
 
14
14
  from . import parse
15
- from .version import __version__
15
+ from .query import Query
16
+ from .version import cli_version
16
17
 
17
18
  if t.TYPE_CHECKING:
18
19
  from io import FileIO
19
20
 
21
+ import recurring_ical_events
20
22
  from icalendar.cal import Component
21
23
 
22
24
  from .parse import Date
23
25
 
24
26
  print = functools.partial(print, file=sys.stderr) # noqa: A001
25
27
 
28
+ ENV_PREFIX = "ICS_QUERY"
29
+
26
30
 
27
31
  class ComponentsResult:
28
32
  """Output interface for components."""
@@ -56,9 +60,13 @@ class ComponentsResultArgument(click.File):
56
60
 
57
61
 
58
62
  class JoinedCalendars:
59
- def __init__(self, calendars: list[Calendar]):
63
+ def __init__(
64
+ self, calendars: list[Calendar], timezone: str, components: t.Sequence[str]
65
+ ):
60
66
  """Join multiple calendars."""
61
- 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
+ ]
62
70
 
63
71
  def at(self, dt: tuple[int]) -> t.Generator[Component]:
64
72
  """Return the components."""
@@ -72,6 +80,11 @@ class JoinedCalendars:
72
80
  yield component
73
81
  break
74
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
+
75
88
  def between(
76
89
  self, start: parse.Date, end: parse.DateAndDelta
77
90
  ) -> t.Generator[Component]:
@@ -91,16 +104,88 @@ class CalendarQueryInputArgument(click.File):
91
104
  """Return a CalendarQuery."""
92
105
  file = super().convert(value, param, ctx)
93
106
  calendars = Calendar.from_ical(file.read(), multiple=True)
94
- 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
95
146
 
96
147
 
97
- arg_calendar = click.argument("calendar", type=CalendarQueryInputArgument("rb"))
98
- arg_output = click.argument("output", type=ComponentsResultArgument("wb"))
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
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()
173
+
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)
99
183
 
100
184
 
101
185
  @click.group()
102
- @click.version_option(__version__)
103
- def main():
186
+ @click.version_option(cli_version)
187
+ @opt_available_timezones()
188
+ def cli():
104
189
  """Find out what happens in ICS calendar files.
105
190
 
106
191
  ics-query can query and filter RFC 5545 compatible .ics files.
@@ -124,8 +209,8 @@ def main():
124
209
  If OUTPUT is "-", then the standard output is used.
125
210
 
126
211
  \b
127
- Notes on Calculation
128
- --------------------
212
+ Calculation
213
+ -----------
129
214
 
130
215
  An event can be very long. If you request smaller time spans or a time as
131
216
  exact as a second, the event will still occur within this time span if it
@@ -138,20 +223,72 @@ def main():
138
223
  The START is INCLUSIVE, then END is EXCLUSIVE.
139
224
 
140
225
  \b
141
- Notes on Timezones
142
- ------------------
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
+
143
280
  """ # noqa: D301
144
281
 
145
282
 
146
283
  pass_datetime = click.make_pass_decorator(parse.to_time)
147
284
 
148
285
 
149
- @main.command()
286
+ @cli.command()
150
287
  @click.argument("date", type=parse.to_time)
151
288
  @arg_calendar
152
289
  @arg_output
153
290
  def at(calendar: JoinedCalendars, output: ComponentsResult, date: Date):
154
- """Occurrences at a certain dates.
291
+ """Print occurrences at a certain date or time.
155
292
 
156
293
  YEAR
157
294
 
@@ -273,22 +410,48 @@ def at(calendar: JoinedCalendars, output: ComponentsResult, date: Date):
273
410
  output.add_components(calendar.at(date))
274
411
 
275
412
 
276
- @main.command()
413
+ @cli.command()
277
414
  @arg_calendar
278
415
  @arg_output
279
416
  def first(calendar: JoinedCalendars, output: ComponentsResult):
280
- """Print only the first occurrence in each calendar.
417
+ """Print only the first occurrence.
418
+
419
+ This prints the first occurrence in each calendar that is given.
281
420
 
282
421
  \b
283
422
  This example prints the first event in calendar.ics:
284
423
  \b
285
- ics-query first calendar.ics -
424
+ ics-query first --component VEVENT calendar.ics -
286
425
 
287
426
  """ # noqa: D301
288
427
  output.add_components(calendar.first())
289
428
 
290
429
 
291
- @main.command()
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()
292
455
  @click.argument("start", type=parse.to_time)
293
456
  @click.argument("end", type=parse.to_time_and_delta)
294
457
  @arg_calendar
@@ -299,20 +462,20 @@ def between(
299
462
  calendar: JoinedCalendars,
300
463
  output: ComponentsResult,
301
464
  ):
302
- """Print all occurrences between the START and the END.
465
+ """Print occurrences between a START and an END.
303
466
 
304
467
  The start is inclusive, the end is exclusive.
305
468
 
306
469
  This example returns the events within the next week:
307
470
 
308
471
  \b
309
- ics-query between `date +%Y%m%d` +7d calendar.ics -
472
+ ics-query between --component VEVENT `date +%Y%m%d` +7d calendar.ics -
310
473
 
311
474
  This example saves the events from the 1st of May 2024 to the 10th of June in
312
475
  events.ics:
313
476
 
314
477
  \b
315
- ics-query between 2024-5-1 2024-6-10 calendar.ics events.ics
478
+ ics-query between --component VEVENT 2024-5-1 2024-6-10 calendar.ics events.ics
316
479
 
317
480
  In this example, you can check what is happening on New Years Eve 2025 around
318
481
  midnight:
@@ -472,4 +635,9 @@ def between(
472
635
  output.add_components(calendar.between(start, end))
473
636
 
474
637
 
475
- __all__ = ["main"]
638
+ def main():
639
+ """Run the program."""
640
+ cli(auto_envvar_prefix=ENV_PREFIX)
641
+
642
+
643
+ __all__ = ["main", "ENV_PREFIX", "cli"]
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
@@ -0,0 +1,23 @@
1
+ BEGIN:VEVENT
2
+ SUMMARY:6:00-7:00 Europe/Berlin 20th August
3
+ DTSTART;TZID=Europe/Berlin:20240820T060000
4
+ DTEND;TZID=Europe/Berlin:20240820T070000
5
+ DTSTAMP:20240823T130444Z
6
+ UID:b27ea261-f23d-4e03-a7ac-f8cb0d00f07f
7
+ CREATED:20240823T120639Z
8
+ LAST-MODIFIED:20240823T130444Z
9
+ TRANSP:OPAQUE
10
+ X-MOZ-GENERATION:3
11
+ END:VEVENT
12
+ BEGIN:VEVENT
13
+ SUMMARY:6:00-7:00 Amerika/Los Angeles 20th August
14
+ DTSTART;TZID=America/Los_Angeles:20240820T060000
15
+ DTEND;TZID=America/Los_Angeles:20240820T070000
16
+ DTSTAMP:20240823T130711Z
17
+ UID:6d7ff7f3-4bc4-4d89-afa0-771bd690518a
18
+ SEQUENCE:1
19
+ CREATED:20240823T120639Z
20
+ LAST-MODIFIED:20240823T130711Z
21
+ TRANSP:OPAQUE
22
+ X-MOZ-GENERATION:4
23
+ END:VEVENT