icalendar_light 1.1.0__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.
@@ -0,0 +1 @@
1
+ VERSION = '1.1.0'
@@ -0,0 +1,56 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+ from dateutil.parser import parse as parse_datestr
5
+ from dateutil.tz import gettz
6
+ from dateutil.utils import default_tzinfo
7
+ from dateutil.rrule import rrulestr
8
+
9
+
10
+ def cast_date(*, value: str, params: str, shared: dict) -> datetime:
11
+ """Cast date/datetime string into object.
12
+
13
+ :param value: Current value.
14
+ :param params: Additional value-related parameters.
15
+ :param shared: Global shared parameters.
16
+
17
+ """
18
+ param_key, _, param_val = params.partition('=')
19
+
20
+ date = parse_datestr(value)
21
+ tz = param_val if param_key == 'TZID' else shared['tz'] or 'UTC'
22
+
23
+ return default_tzinfo(date, gettz(tz))
24
+
25
+
26
+ def cast_recurrence(*, value: str, params: str, shared: dict) -> Any:
27
+ """Parses recurrence rule string.
28
+
29
+ :param value: Current value.
30
+ :param params: Additional value-related parameters.
31
+ :param shared: Global shared parameters.
32
+
33
+ """
34
+ return rrulestr(value)
35
+
36
+
37
+ def cast_default(*, value: str, params: str, shared: dict) -> str:
38
+ """Default caster, performing basic transformations.
39
+
40
+ :param value: Current value.
41
+ :param params: Additional value-related parameters.
42
+ :param shared: Global shared parameters.
43
+
44
+ """
45
+ return value.replace(r'\,', ',')
46
+
47
+
48
+ def cast_int(*, value: str, params: str, shared: dict) -> int:
49
+ """Casts value to integer.
50
+
51
+ :param value: Current value.
52
+ :param params: Additional value-related parameters.
53
+ :param shared: Global shared parameters.
54
+
55
+ """
56
+ return int(value)
icalendar_light/cli.py ADDED
@@ -0,0 +1,33 @@
1
+ import click
2
+ from icalendar_light import VERSION
3
+ from icalendar_light.toolbox import Calendar
4
+
5
+
6
+ @click.group()
7
+ @click.version_option(version=VERSION)
8
+ def entry_point():
9
+ """icalendar_light command line utilities."""
10
+
11
+
12
+ @entry_point.command()
13
+ @click.argument('source')
14
+ @click.option('--days', help='Number of days forward to show events', default=60)
15
+ def upcoming(source, days):
16
+ """Shows upcoming events from a given calendar file or URL."""
17
+
18
+ if source.startswith(("http://", "https://")):
19
+ events = Calendar.iter_events_from_url(source, upcoming_days=days)
20
+
21
+ else:
22
+ events = Calendar.iter_events_from_file(source, upcoming_days=days)
23
+
24
+ for event in events:
25
+ click.secho(Calendar.event_stringify(event))
26
+
27
+
28
+ def main():
29
+ entry_point(obj={})
30
+
31
+
32
+ if __name__ == '__main__':
33
+ main()
@@ -0,0 +1,240 @@
1
+ from collections import namedtuple
2
+ from datetime import datetime, timedelta
3
+ import io
4
+ from functools import partial
5
+ from pathlib import Path
6
+ from urllib.request import urlopen
7
+ from typing import Generator, Union, Iterable
8
+
9
+ from dateutil.tz import tzlocal
10
+
11
+ from .casters import cast_date, cast_default, cast_recurrence, cast_int
12
+
13
+
14
+ class Calendar:
15
+
16
+ _key_map = {
17
+ 'UID': ('uid', None),
18
+ 'SEQUENCE': ('sequence', cast_int),
19
+ 'STATUS': ('status', None), # CONFIRMED, TENTATIVE, CANCELLED
20
+ 'CREATED': ('dt_created', cast_date),
21
+ 'LAST-MODIFIED': ('dt_modified', cast_date),
22
+ 'DTSTART': ('dt_start', cast_date),
23
+ 'DTEND': ('dt_end', cast_date),
24
+ 'DESCRIPTION': ('description', None),
25
+ 'SUMMARY': ('summary', None),
26
+ 'LOCATION': ('location', None),
27
+ 'RRULE': ('recurrence', cast_recurrence),
28
+ }
29
+
30
+ _event_attrs = [
31
+ 'uid',
32
+ 'sequence',
33
+ 'status',
34
+ 'dt_created',
35
+ 'dt_modified',
36
+ 'dt_start',
37
+ 'dt_end',
38
+ 'description',
39
+ 'summary',
40
+ 'location',
41
+ 'recurrence',
42
+ ]
43
+
44
+ Event = namedtuple('Event', _event_attrs)
45
+
46
+ @classmethod
47
+ def event_stringify(cls, event: Event) -> str:
48
+ """Create a string representation for an event object.
49
+
50
+ :param event:
51
+
52
+ """
53
+ location = event.location
54
+ if location:
55
+ location = f' [{location}]'
56
+
57
+ status = event.status
58
+ if status:
59
+ status = f' [{status}]'
60
+
61
+ out = f"{event.dt_start} - {event.dt_end} | {event.summary}{location or ''}{status or ''}"
62
+
63
+ return out
64
+
65
+ @classmethod
66
+ def iter_events_from_file(
67
+ cls,
68
+ filepath: Union[Path, str],
69
+ upcoming_days: int | None = None
70
+ ) -> Generator[Event, None, None]:
71
+ """Yields event objects from a given file.
72
+
73
+ :param filepath:
74
+ :param upcoming_days:
75
+
76
+ """
77
+ if upcoming_days is None:
78
+ func = cls.iter_events
79
+
80
+ else:
81
+ func = partial(cls.iter_events_upcoming, days_forward=upcoming_days)
82
+
83
+ with Path(f'{filepath}').open() as f:
84
+ yield from func(f)
85
+
86
+ @classmethod
87
+ def iter_events_from_url(
88
+ cls,
89
+ url: str,
90
+ upcoming_days: int | None = None
91
+ ) -> Generator[Event, None, None]:
92
+ """Yields event objects from a given URL.
93
+
94
+ :param url:
95
+ :param upcoming_days:
96
+
97
+ """
98
+ if upcoming_days is None:
99
+ func = cls.iter_events
100
+
101
+ else:
102
+ func = partial(cls.iter_events_upcoming, days_forward=upcoming_days)
103
+
104
+ with io.TextIOWrapper(urlopen(url), encoding="utf-8") as f:
105
+ yield from func(f)
106
+
107
+ @classmethod
108
+ def iter_events(cls, source: Iterable) -> Generator[Event, None, None]:
109
+ """Yields event objects from a given source.
110
+
111
+ .. note:: This won't yield recurrent events automatically, as `.iter_events_upcoming()` does.
112
+
113
+ :param source:
114
+
115
+ """
116
+ def init_event_data():
117
+ return {}.fromkeys(cls._event_attrs)
118
+
119
+ event_data = init_event_data()
120
+ in_event = False
121
+ last_key = ''
122
+
123
+ shared_params = {
124
+ 'tz': None,
125
+ }
126
+
127
+ cls_event = cls.Event
128
+ key_map = cls._key_map
129
+
130
+ for line in source:
131
+ line = line.rstrip()
132
+
133
+ if line == 'BEGIN:VEVENT':
134
+ last_key = ''
135
+ in_event = True
136
+ event_data = init_event_data()
137
+ continue
138
+
139
+ if line == 'END:VEVENT':
140
+
141
+ yield cls_event(**event_data)
142
+
143
+ last_key = ''
144
+ in_event = False
145
+ continue
146
+
147
+ if not in_event:
148
+
149
+ if line.startswith('X-WR-TIMEZONE:'):
150
+ shared_params['tz'] = line.partition(':')[2]
151
+
152
+ continue
153
+
154
+ if line.startswith(' '):
155
+ key = last_key
156
+ value = line[1:]
157
+ params = '' # No params support for continuation.
158
+
159
+ else:
160
+ key, _, value = line.partition(':')
161
+ key, _, params = key.partition(';')
162
+
163
+ last_key = key
164
+ mapped = key_map.get(key)
165
+
166
+ if mapped is None:
167
+ continue
168
+
169
+ mapped_key, func_cast = mapped
170
+ func_cast = func_cast or cast_default
171
+
172
+ value = func_cast(value=value, params=params, shared=shared_params)
173
+
174
+ value_seen = event_data[mapped_key]
175
+
176
+ if value_seen is None:
177
+ value_seen = value
178
+
179
+ else:
180
+ # Continuation (folding) support.
181
+ value_seen = f"{value_seen}{value}"
182
+
183
+ event_data[mapped_key] = value_seen
184
+
185
+ @classmethod
186
+ def iter_events_recurrent(cls, *, event: Event, date_max: datetime) -> Generator[Event, None, None]:
187
+ """Builds and yields recurrent events for a given event till the date.
188
+
189
+ :param event:
190
+ :param date_max:
191
+
192
+ """
193
+ start = event.dt_start
194
+ end = event.dt_end
195
+
196
+ recurred = []
197
+
198
+ for recurrent_start in event.recurrence:
199
+
200
+ recurrent_start = recurrent_start.replace(
201
+ hour=start.hour,
202
+ minute=start.minute,
203
+ second=start.second,
204
+ tzinfo=start.tzinfo,
205
+ )
206
+
207
+ recurrent_end = recurrent_start.replace(
208
+ hour=end.hour,
209
+ minute=end.minute,
210
+ second=end.second,
211
+ )
212
+
213
+ recurred.append((recurrent_start, recurrent_end))
214
+
215
+ if recurrent_start >= date_max:
216
+ break
217
+
218
+ for recurrent_start, recurrent_end in recurred:
219
+ yield event._replace(dt_start=recurrent_start, dt_end=recurrent_end)
220
+
221
+ @classmethod
222
+ def iter_events_upcoming(cls, source: Iterable, *, days_forward=30) -> Generator[Event, None, None]:
223
+ """Yields upcoming event objects from a given source for nex N days.
224
+
225
+ .. note:: This will yield recurrent events automatically, in contrast to `.iter_events()`.
226
+
227
+ :param source:
228
+ :param days_forward:
229
+
230
+ """
231
+ now = datetime.now(tzlocal())
232
+ date_max = now + timedelta(days=days_forward)
233
+
234
+ for event in cls.iter_events(source):
235
+
236
+ if event.recurrence:
237
+ yield from cls.iter_events_recurrent(event=event, date_max=date_max)
238
+
239
+ elif now <= event.dt_start <= date_max:
240
+ yield event
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: icalendar_light
3
+ Version: 1.1.0
4
+ Summary: Light and easy iCalendar event reader
5
+ Project-URL: Homepage, https://github.com/idlesign/icalendar_light
6
+ Author-email: Igor Starikov <idlesign@yandex.ru>
7
+ License-Expression: BSD-3-Clause
8
+ License-File: LICENSE
9
+ Keywords: calendar,icalendar,ics
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: BSD License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: python-dateutil
23
+ Provides-Extra: cli
24
+ Requires-Dist: click; extra == 'cli'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # icalendar_light
28
+
29
+ <https://github.com/idlesign/icalendar_light>
30
+
31
+ [![release](https://img.shields.io/pypi/v/icalendar_light.svg)](https://pypi.python.org/pypi/icalendar_light)
32
+ [![lic](https://img.shields.io/pypi/l/icalendar_light.svg)](https://pypi.python.org/pypi/icalendar_light)
33
+ [![coverage](https://img.shields.io/coveralls/idlesign/icalendar_light/master.svg)](https://coveralls.io/r/idlesign/icalendar_light)
34
+
35
+ ## Description
36
+
37
+ *Light and easy iCalendar event reader*
38
+
39
+ No fancy stuff, just iCalendar (`.ics`) events reading.
40
+
41
+ * Uses `namedtuple` for event representation;
42
+ * Features iterative event reading to be fast and minimize memory usage.
43
+
44
+ ## API
45
+
46
+ ```python
47
+ from icalendar_light.toolbox import Calendar
48
+
49
+ for event in Calendar.iter_events_from_file('my_calendar.ics'):
50
+ print(Calendar.event_stringify(event))
51
+
52
+ # Or get events upcoming in next 5 days from iCalendar represented as lines:
53
+ lines = [] # Let's imagine this one is not empty %)
54
+ events = Calendar.iter_events_upcoming(lines, days_forward=5)
55
+ ```
56
+
57
+ ## CLI
58
+
59
+ Requires `click` package (can be installed with: `pip install icalendar_light[cli]`).
60
+
61
+ ```shell
62
+ # Show upcoming event for next 90 days
63
+ $ icalendar_light upcoming my_calendar.ics --days 90
64
+ ```
@@ -0,0 +1,9 @@
1
+ icalendar_light/__init__.py,sha256=mLogIM1sYODaWXnpkC3MUaWSWIRVFccFKS3YgKq5rDQ,18
2
+ icalendar_light/casters.py,sha256=jiskWL9kdRLLKyuj7IarfyN3vPK5FhaOufSj7agMnXg,1522
3
+ icalendar_light/cli.py,sha256=FOBBiPqtnZHwkXADwxzQPW-Nf5Y3A5DFj_kdX30_ODg,818
4
+ icalendar_light/toolbox.py,sha256=z3kcJlYc9fWCob2c-HcB1V6WRiLYD1Ds_UXEJpmG5lQ,6508
5
+ icalendar_light-1.1.0.dist-info/METADATA,sha256=Pia-lDhNAkGrgTn4Idf9g4KwkVDrVa8Yk3x-HOsOG1c,2226
6
+ icalendar_light-1.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ icalendar_light-1.1.0.dist-info/entry_points.txt,sha256=1Oo0oNWceK6R5sYKx7GIRFSEDOUcmN3iXpZ5LcuJTzQ,61
8
+ icalendar_light-1.1.0.dist-info/licenses/LICENSE,sha256=59Q1_jlD9UvIPZ31PWvHgYt7nA9NS7xiGMoeb6yzKIc,1508
9
+ icalendar_light-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ icalendar_light = icalendar_light.cli:main
@@ -0,0 +1,28 @@
1
+ Copyright (c) 2020-2026, Igor `idle sign` Starikov
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ * Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ * Neither the name of the icalendar_light nor the names of its
15
+ contributors may be used to endorse or promote products derived from
16
+ this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
+