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.
- icalendar_light/__init__.py +1 -0
- icalendar_light/casters.py +56 -0
- icalendar_light/cli.py +33 -0
- icalendar_light/toolbox.py +240 -0
- icalendar_light-1.1.0.dist-info/METADATA +64 -0
- icalendar_light-1.1.0.dist-info/RECORD +9 -0
- icalendar_light-1.1.0.dist-info/WHEEL +4 -0
- icalendar_light-1.1.0.dist-info/entry_points.txt +2 -0
- icalendar_light-1.1.0.dist-info/licenses/LICENSE +28 -0
|
@@ -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
|
+
[](https://pypi.python.org/pypi/icalendar_light)
|
|
32
|
+
[](https://pypi.python.org/pypi/icalendar_light)
|
|
33
|
+
[](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,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
|
+
|