File: ical.py

package info (click to toggle)
python-holidays 0.86-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 57,296 kB
  • sloc: python: 117,830; javascript: 85; makefile: 59
file content (229 lines) | stat: -rw-r--r-- 8,469 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
#  holidays
#  --------
#  A fast, efficient Python library for generating country, province and state
#  specific sets of holidays on the fly. It aims to make determining whether a
#  specific date is a holiday as fast and flexible as possible.
#
#  Authors: Vacanza Team and individual contributors (see CONTRIBUTORS file)
#           dr-prodigy <dr.prodigy.github@gmail.com> (c) 2017-2023
#           ryanss <ryanssdev@icloud.com> (c) 2014-2017
#  Website: https://github.com/vacanza/holidays
#  License: MIT (see LICENSE file)

import re
import uuid
from datetime import date, datetime, timezone

from holidays.calendars.gregorian import _timedelta
from holidays.holiday_base import HolidayBase
from holidays.version import __version__

# iCal-specific constants
CONTENT_LINE_MAX_LENGTH = 75
CONTENT_LINE_DELIMITER = "\r\n"
CONTENT_LINE_DELIMITER_WRAP = f"{CONTENT_LINE_DELIMITER} "


class ICalExporter:
    def __init__(self, instance: HolidayBase, show_language: bool = False) -> None:
        """Initialize iCalendar exporter.

        Args:
            show_language:
                Determines whether to include the `;LANGUAGE=` attribute in the
                `SUMMARY` field. Defaults to `False`.

                If the `HolidaysBase` object has a `language` attribute, it will
                be used. Otherwise, `default_language` will be used if available.

                If neither attribute exists and `show_language=True`, an
                exception will be raised.

            instance:
                `HolidaysBase` object containing holiday data.
        """
        self.holidays = instance
        self.show_language = show_language
        self.ical_timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        self.holidays_version = __version__
        language = getattr(self.holidays, "language", None) or getattr(
            self.holidays, "default_language", None
        )
        self.language = (
            self._validate_language(language)
            if isinstance(language, str)
            and language in getattr(self.holidays, "supported_languages", [])
            else None
        )

        if self.show_language and self.language is None:
            raise ValueError("LANGUAGE cannot be included because the language code is missing.")

    def _validate_language(self, language: str) -> str:
        """Validate the language code to ensure it complies with RFC 5646.

        In the current implementation, all languages must comply with
        either ISO 639-1 or ISO 639-2 if specified (part of RFC 5646).

        Args:
            language:
                The language code to validate.

        Returns:
            Validated language code.
        """
        # Remove whitespace (if any), transforms HolidaysBase default to RFC 5646 compliant
        # i.e. `en_US` to `en-US`.
        language = language.strip().replace("_", "-")

        # ISO 639-1 and ISO 639-2 patterns, in compliance with RFC 5646.
        iso639_pattern = re.compile(r"^[a-z]{2,3}(?:-[A-Z]{2})?$")

        if not iso639_pattern.fullmatch(language):
            raise ValueError(
                f"Invalid language tag: '{language}'. Expected format follows "
                "ISO 639-1 or ISO 639-2, e.g., 'en', 'en-US'. For more details, "
                "refer to: https://www.loc.gov/standards/iso639-2/php/code_list.php."
            )
        return language

    def _fold_line(self, line: str) -> str:
        """Fold long lines according to RFC 5545.

        Content lines SHOULD NOT exceed 75 octets. If a line is too long,
        it must be split into multiple lines, with each continuation line
        starting with a space.

        Args:
            line:
                The content line to be folded.

        Returns:
            The folded content line.
        """
        if line.isascii():
            # Simple split for ASCII: every (CONTENT_LINE_MAX_LENGTH - 1) chars,
            # as first char of the next line is space
            if len(line) > CONTENT_LINE_MAX_LENGTH:
                return CONTENT_LINE_DELIMITER_WRAP.join(
                    line[i : i + CONTENT_LINE_MAX_LENGTH - 1]
                    for i in range(0, len(line), CONTENT_LINE_MAX_LENGTH - 1)
                )

        elif len(line.encode()) > CONTENT_LINE_MAX_LENGTH:
            # Handle non-ASCII text while respecting byte length
            parts = []
            part_start = 0
            part_len = 0
            for i, char in enumerate(line):
                char_byte_len = len(char.encode())
                part_len += char_byte_len

                if part_len > CONTENT_LINE_MAX_LENGTH:
                    parts.append(line[part_start:i])
                    part_start = i
                    part_len = char_byte_len + 1  # line start with space

            parts.append(line[part_start:])
            return CONTENT_LINE_DELIMITER_WRAP.join(parts)

        # Return as-is if it doesn't exceed the limit
        return line

    def _generate_event(self, dt: date, holiday_name: str, holiday_length: int = 1) -> list[str]:
        """Generate a single holiday event.

        Args:
            dt:
                Holiday date.

            holiday_name:
                Holiday name.

            holiday_length:
                Holiday length in days, default to 1.

        Returns:
            List of iCalendar format event lines.
        """
        # Escape special characters per RFC 5545.
        # SEMICOLON is used as a delimiter in HolidayBase (HOLIDAY_NAME_DELIMITER = "; "),
        # so a name with a semicolon gets split into two separate `VEVENT`s.
        sanitized_holiday_name = (
            holiday_name.replace("\\", "\\\\").replace(",", "\\,").replace(":", "\\:")
        )
        event_uid = f"{uuid.uuid4()}@{self.holidays_version}.holidays.local"
        language_tag = f";LANGUAGE={self.language}" if self.show_language else ""

        return [
            "BEGIN:VEVENT",
            f"DTSTAMP:{self.ical_timestamp}",
            f"UID:{event_uid}",
            self._fold_line(f"SUMMARY{language_tag}:{sanitized_holiday_name}"),
            f"DTSTART;VALUE=DATE:{dt:%Y%m%d}",
            f"DURATION:P{holiday_length}D",
            "END:VEVENT",
        ]

    def generate(self, return_bytes: bool = False) -> str | bytes:
        """Generate iCalendar data.

        Args:
            return_bytes:
                If True, return bytes instead of string.

        Returns:
            The complete iCalendar data
            (string or UTF-8 bytes depending on return_bytes).
        """
        lines = [
            "BEGIN:VCALENDAR",
            f"PRODID:-//Vacanza//Open World Holidays Framework v{self.holidays_version}//EN",
            "VERSION:2.0",
            "CALSCALE:GREGORIAN",
        ]

        sorted_dates = sorted(self.holidays.keys())
        # Merged continuous holiday with the same name and use `DURATION` instead.
        n = len(sorted_dates)
        i = 0
        while i < n:
            dt = sorted_dates[i]
            names = self.holidays.get_list(dt)

            for name in names:
                days = 1
                while (
                    i + days < n
                    and sorted_dates[i + days] == _timedelta(sorted_dates[i], days)
                    and name in self.holidays.get_list(sorted_dates[i + days])
                ):
                    days += 1

                lines.extend(self._generate_event(dt, name, days))

            i += days

        lines.append("END:VCALENDAR")
        lines.append("")

        output = CONTENT_LINE_DELIMITER.join(lines)
        return output.encode() if return_bytes else output

    def save_ics(self, file_path: str) -> None:
        """Export the calendar data to a .ics file.

        While RFC 5545 does not specifically forbid filenames for .ics files, but it's advisable
        to follow general filesystem conventions and avoid using problematic characters.

        Args:
            file_path:
                Path to save the .ics file, including the filename (with extension).
        """
        # Generate and write out content (always in bytes for .ics)
        content = self.generate(return_bytes=True)
        if not content:
            raise ValueError("Generated content is empty or invalid.")

        with open(file_path, "wb") as file:
            file.write(content)  # type: ignore  # this is always bytes, ignoring mypy error.