# 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 (c) 2017-2023 # ryanss (c) 2014-2017 # Website: https://github.com/vacanza/holidays # License: MIT (see LICENSE file) import re import uuid from datetime import date, datetime, timedelta, timezone from typing import Union 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) -> Union[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. i = 0 while i < len(sorted_dates): dt = sorted_dates[i] names = self.holidays.get_list(dt) for name in names: days = 1 while ( i + days < len(sorted_dates) and sorted_dates[i + days] == sorted_dates[i] + timedelta(days=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.