File: x_wr_timezone.py

package info (click to toggle)
python-x-wr-timezone 2.0.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 280 kB
  • sloc: python: 568; sh: 24; makefile: 6
file content (239 lines) | stat: -rw-r--r-- 8,569 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""Bring calendars using X-WR-TIMEZONE into RFC 5545 form."""
import functools
from io import BytesIO
import sys
import zoneinfo
from icalendar.prop import vDDDTypes, vDDDLists
import datetime
import icalendar
from typing import Optional
import click

X_WR_TIMEZONE = "X-WR-TIMEZONE"


def list_is(l1, l2):
    """Return wether all contents of two lists are identical."""
    return len(l1) == len(l2) and all(e1 is e2 for e1, e2 in zip(l1, l2))


class CalendarWalker:
    """I walk along the components and values of an icalendar object.

    The idea is the same as a visitor pattern.
    """

    VALUE_ATTRIBUTES = ['DTSTART', 'DTEND', 'RDATE', 'RECURRENCE-ID', 'EXDATE']

    def copy_if_changed(self, component, attributes, subcomponents):
        """Check if an icalendar Component has changed and copy it if it has.

        atributes and subcomponents are put into the copy."""
        for key, value in attributes.items():
            if component[key] is not value:
                return self.copy_component(component, attributes, subcomponents)
        assert len(component.subcomponents) == len(subcomponents)
        for new_subcomponent, old_subcomponent in zip(subcomponents, component.subcomponents):
            if new_subcomponent is not old_subcomponent:
                return self.copy_component(component, attributes, subcomponents)
        return component

    def copy_component(self, component, attributes, subcomponents):
        """Create a copy of the component with attributes and subcomponents."""
        component = component.copy()
        for key, value in attributes.items():
            component[key] = value
        assert len(component.subcomponents) == 0
        for subcomponent in subcomponents:
             component.add_component(subcomponent)
        return component

    def walk(self, calendar):
        """Walk along the calendar and return the changed or identical object."""
        subcomponents = []
        for subcomponent in calendar.subcomponents:
            if isinstance(subcomponent, icalendar.cal.Event):
                subcomponent = self.walk_event(subcomponent)
            subcomponents.append(subcomponent)
        return self.copy_if_changed(calendar, {}, subcomponents)

    def walk_event(self, event):
        """Walk along the event and return the changed or identical object."""
        attributes = {}
        for name in self.VALUE_ATTRIBUTES:
            value = event.get(name)
            if value is not None:
                attributes[name] = self.walk_value(value)
        return self.copy_if_changed(event, attributes, event.subcomponents)

    def walk_value_default(self, value):
        """Default method for walking along a value type."""
        return value

    def walk_value(self, value):
        """Walk along a value type."""
        name = "walk_value_" + type(value).__name__
        walk = getattr(self, name, self.walk_value_default)
        return walk(value)

    def walk_value_list(self, l):
        """Walk through a list of values."""
        v = list(map(self.walk_value, l))
        if list_is(v, l):
            return l
        return v

    def walk_value_vDDDLists(self, l):
        dts = [ddd.dt for ddd in l.dts]
        new_dts = [self.walk_value(dt) for dt in dts]
        if list_is(new_dts, dts):
            return l
        return vDDDLists(new_dts)

    def walk_value_vDDDTypes(self, value):
        """Walk along an icalendar value type"""
        dt = self.walk_value(value.dt)
        if dt is value.dt:
            return value
        return vDDDTypes(dt)

    def walk_value_datetime(self, dt):
        """Walk along a datetime.datetime object."""
        return dt

    def is_UTC(self, dt):
        """Return whether the time zone is a UTC time zone."""
        if dt.tzname() is None:
            return False
        return dt.tzname().upper() == "UTC"

    def is_Floating(self, dt):
        return dt.tzname() is None


def is_pytz(tzinfo):
    """Whether the time zone requires localize() and normalize().

    pytz requires these funtions to be used in order to correctly use the
    time zones after operations.
    """
    return hasattr(tzinfo , "localize")


@functools.cache
def get_timezone_component(timezone:datetime.tzinfo) -> icalendar.Timezone:
    """Return a timezone component for the tzid and cache it.
    
    The result is cached.
    """
    return icalendar.Timezone.from_tzinfo(timezone)


class UTCChangingWalker(CalendarWalker):
    """Changes the UTC time zone into a new time zone."""

    def __init__(self, timezone):
        """Initialize the walker with the new time zone."""
        self.new_timezone = timezone

    def walk_value_datetime(self, dt):
        """Walk along a datetime.datetime object."""
        if self.is_UTC(dt):
            return dt.astimezone(self.new_timezone)
        elif self.is_Floating(dt):
            if is_pytz(self.new_timezone):
                return self.new_timezone.localize(dt)
            return dt.replace(tzinfo=self.new_timezone)
        return dt


def to_standard(
        calendar : icalendar.Calendar,
        timezone:Optional[datetime.tzinfo]=None,
        add_timezone_component:bool=False
    ) -> icalendar.Calendar:
    """Make a calendar that might use X-WR-TIMEZONE compatible with RFC 5545.

    Arguments:
    
        calendar: is an icalendar.Calendar object. It does not need to have
            the X-WR-TIMEZONE property but if it has, calendar  will be converted
            to conform to RFC 5545.
            
        timezone: an optional timezone argument if you want to override the
            existence of the actual X-WR-TIMEZONE property of the calendar.
            This can be a string like "Europe/Berlin" or "UTC" or a
            pytz.timezone or any other timezone accepted by the datetime module.
        
        add_timezone_component: whether to add a VTIMEZONE component to the result.
    """
    if timezone is None:
        timezone = calendar.get(X_WR_TIMEZONE, None)
    if timezone is not None and not isinstance(timezone, datetime.tzinfo):
        timezone = zoneinfo.ZoneInfo(str(timezone))
    result : icalendar.Calendar = calendar
    del calendar
    if timezone is not None:
        walker = UTCChangingWalker(timezone)
        result = walker.walk(result)
        if add_timezone_component:
            new_cal = result.copy()
            new_cal.subcomponents = result.subcomponents[:]
            result = new_cal
            result.subcomponents.insert(0, get_timezone_component(timezone))
    return result

@click.command()
@click.argument('in_file', type=click.File('rb'), default="-")
@click.argument('out_file', type=click.File('wb'), default="-")
@click.version_option()
@click.help_option()
@click.option('--add-timezone/--no-timezone', default=True, help="Add a VTIMEZONE component to the result.")
def main(in_file:BytesIO, out_file:BytesIO, add_timezone: bool):
    """x-wr-timezone converts ICSfiles with X-WR-TIMEZONE to use RFC 5545 instead.

    Convert input:

        cat in.ics | x-wr-timezone > out.ics
        wget -O- https://example.org/in.ics | x-wr-timezone > out.ics
        curl https://example.org/in.ics | x-wr-timezone > out.ics

    Convert files:

        x-wr-timezone in.ics out.ics

    By default, x-wr-timezone will add a VTIMEZONE component to the result.
    Use --no-vtimezone to remove it. (Added in v2.0.0)

    Get help:

        x-wr-timezone --help

    For bug reports, code and questions, visit the projet page:

        https://github.com/niccokunzmann/x-wr-timezone

    License: LPGLv3+
    """
    calendar = icalendar.Calendar.from_ical(in_file.read())
    new_cal = to_standard(calendar, add_timezone_component=add_timezone)
    out_file.write(new_cal.to_ical())
    return 0


__all__ = [
    "main", "to_standard", "UTCChangingWalker", "list_is",
    "X_WR_TIMEZONE", "CalendarWalker", "get_timezone_component",
]