diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..249a557 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +*.egg-info/ +dist/ +build/ +.eggs/ +.venv/ +venv/ +env/ + +# Generated calendar output +*.ics + +# Test / coverage artefacts +.pytest_cache/ +.coverage +htmlcov/ + +# Editor / OS +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/README.md b/README.md index bab1ad8..d12af01 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,57 @@ # Calendar_Management -Add Calendar events to Outlook + +Scrape the [CinemaCon Schedule of Events](https://www.cinemacon.com/en/schedule-of-events) and generate an `.ics` calendar file that can be imported directly into Microsoft Outlook (or any iCalendar-compatible application such as Google Calendar or Apple Calendar). + +--- + +## How it works + +1. **`scraper.py`** – Fetches the CinemaCon schedule page and parses all events (title, date, start/end time, location) using three progressive strategies: table-based, card/heading-based, and a plain-text heuristic fallback. +2. **`calendar_integration.py`** – Converts the parsed events into RFC 5545 iCalendar (`VEVENT`) entries and writes them to an `.ics` file. +3. **`main.py`** – Command-line entry point that ties the two modules together. + +--- + +## Requirements + +- Python 3.10+ + +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +--- + +## Usage + +```bash +# Scrape the schedule and write to cinemacon_events.ics (default) +python main.py + +# Specify a custom output file +python main.py --output my_calendar.ics + +# Enable verbose debug logging +python main.py --verbose + +# Use a custom schedule URL +python main.py --url https://www.cinemacon.com/en/schedule-of-events +``` + +### Importing into Outlook + +1. Run `python main.py` to generate `cinemacon_events.ics`. +2. In Outlook, go to **File → Open & Export → Import/Export**. +3. Select **Import an iCalendar (.ics) or vCalendar file (.vcs)** and choose the generated file. +4. The CinemaCon events will be added to your calendar. + +--- + +## Running the tests + +```bash +pip install pytest +python -m pytest test_cinemacon.py -v +``` diff --git a/calendar_integration.py b/calendar_integration.py new file mode 100644 index 0000000..d3d8dfe --- /dev/null +++ b/calendar_integration.py @@ -0,0 +1,155 @@ +""" +Calendar Integration +Converts CinemaCon schedule events into .ics files importable by Outlook (and +any other iCalendar-compatible application such as Google Calendar or Apple +Calendar). +""" + +import logging +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional +from zoneinfo import ZoneInfo + +from icalendar import Calendar, Event, vText + +from scraper import CinemaConEvent + +logger = logging.getLogger(__name__) + +# Default event duration when an end-time cannot be parsed from the schedule +DEFAULT_DURATION_HOURS = 1 + +# Calendar metadata +CALENDAR_PRODID = "-//CinemaCon Schedule//cinemacon.com//EN" +CALENDAR_NAME = "CinemaCon Events" + + +_LAS_VEGAS_TZ = ZoneInfo("America/Los_Angeles") + + +def _make_utc(dt: datetime) -> datetime: + """Ensure a datetime is UTC-aware. Naive datetimes are assumed to be in + US/Pacific (America/Los_Angeles, i.e. Las Vegas) time and are converted + correctly for both PDT (UTC-7) and PST (UTC-8) periods.""" + if dt.tzinfo is not None: + return dt.astimezone(timezone.utc) + # Attach the Las Vegas timezone so DST rules are applied automatically + return dt.replace(tzinfo=_LAS_VEGAS_TZ).astimezone(timezone.utc) + + +def event_to_vevent(event: CinemaConEvent) -> Event: + """ + Convert a CinemaConEvent to an icalendar VEVENT component. + + Args: + event: The CinemaCon event to convert. + + Returns: + An icalendar Event object. + """ + vevent = Event() + + vevent.add("uid", str(uuid.uuid4())) + vevent.add("summary", event.title) + + if event.start_time: + start_utc = _make_utc(event.start_time) + vevent.add("dtstart", start_utc) + + if event.end_time: + end_utc = _make_utc(event.end_time) + else: + end_utc = start_utc + timedelta(hours=DEFAULT_DURATION_HOURS) + vevent.add("dtend", end_utc) + else: + # All-day placeholder when no time is available + today = datetime.now(tz=timezone.utc).date() + vevent.add("dtstart", today) + vevent.add("dtend", today + timedelta(days=1)) + + vevent.add("location", vText(event.location)) + + description_parts = [] + if event.description: + description_parts.append(event.description) + if event.tags: + description_parts.append("Tags: " + ", ".join(event.tags)) + if description_parts: + vevent.add("description", "\n".join(description_parts)) + + vevent.add("dtstamp", datetime.now(tz=timezone.utc)) + + return vevent + + +def build_calendar(events: list[CinemaConEvent], calendar_name: str = CALENDAR_NAME) -> Calendar: + """ + Build an icalendar Calendar object from a list of CinemaConEvent objects. + + Args: + events: List of events to include. + calendar_name: Display name for the calendar. + + Returns: + An icalendar Calendar object. + """ + cal = Calendar() + cal.add("prodid", CALENDAR_PRODID) + cal.add("version", "2.0") + cal.add("x-wr-calname", calendar_name) + cal.add("x-wr-timezone", "America/Los_Angeles") + cal.add("calscale", "GREGORIAN") + cal.add("method", "PUBLISH") + + for event in events: + vevent = event_to_vevent(event) + cal.add_component(vevent) + + logger.info("Built calendar with %d events", len(events)) + return cal + + +def save_calendar(cal: Calendar, output_path: Optional[Path] = None) -> Path: + """ + Serialise the calendar to an .ics file. + + Args: + cal: The icalendar Calendar object to save. + output_path: Destination file path. Defaults to + ``cinemacon_events.ics`` in the current directory. + + Returns: + The path to the saved .ics file. + """ + if output_path is None: + output_path = Path("cinemacon_events.ics") + + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + ics_bytes = cal.to_ical() + output_path.write_bytes(ics_bytes) + logger.info("Calendar saved to %s", output_path) + return output_path + + +def create_calendar_file( + events: list[CinemaConEvent], + output_path: Optional[Path] = None, + calendar_name: str = CALENDAR_NAME, +) -> Path: + """ + Convenience function: build and save a calendar from a list of events. + + Args: + events: List of CinemaCon events. + output_path: Destination .ics file path. + calendar_name: Display name for the calendar. + + Returns: + The path to the saved .ics file. + """ + cal = build_calendar(events, calendar_name=calendar_name) + return save_calendar(cal, output_path) diff --git a/main.py b/main.py new file mode 100644 index 0000000..b7a6ff8 --- /dev/null +++ b/main.py @@ -0,0 +1,118 @@ +""" +Main entry point for the CinemaCon Calendar Management tool. + +Usage: + python main.py [--output OUTPUT] [--url URL] [--verbose] + +This script: +1. Scrapes the CinemaCon schedule from https://www.cinemacon.com/en/schedule-of-events +2. Parses all events from the page +3. Generates a .ics calendar file that can be imported into Outlook + (or any iCalendar-compatible application) +""" + +import argparse +import logging +import sys +from pathlib import Path + +from calendar_integration import create_calendar_file +from scraper import CINEMACON_SCHEDULE_URL, scrape_events + + +def configure_logging(verbose: bool = False) -> None: + """Set up console logging.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)-8s %(message)s", + datefmt="%H:%M:%S", + ) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Scrape CinemaCon schedule and create an Outlook-compatible calendar file.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " python main.py\n" + " python main.py --output my_calendar.ics\n" + " python main.py --url https://www.cinemacon.com/en/schedule-of-events --verbose\n" + ), + ) + parser.add_argument( + "--output", + "-o", + default="cinemacon_events.ics", + metavar="FILE", + help="Output .ics file path (default: cinemacon_events.ics)", + ) + parser.add_argument( + "--url", + "-u", + default=CINEMACON_SCHEDULE_URL, + metavar="URL", + help=f"CinemaCon schedule URL (default: {CINEMACON_SCHEDULE_URL})", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Enable verbose debug output", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """ + Run the CinemaCon scraper and generate a calendar file. + + Returns: + 0 on success, non-zero on failure. + """ + args = parse_args(argv) + configure_logging(args.verbose) + logger = logging.getLogger(__name__) + + logger.info("=== CinemaCon Calendar Manager ===") + logger.info("Source URL : %s", args.url) + logger.info("Output file: %s", args.output) + + try: + logger.info("Step 1/2 Scraping schedule …") + events = scrape_events(args.url) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to scrape schedule: %s", exc) + return 1 + + if not events: + logger.warning( + "No events were found on the schedule page. " + "The page layout may have changed. " + "Please check %s manually.", + args.url, + ) + return 1 + + logger.info("Found %d event(s).", len(events)) + for i, evt in enumerate(events, 1): + start = evt.start_time.strftime("%a %b %d %H:%M") if evt.start_time else "TBD" + end = evt.end_time.strftime("%H:%M") if evt.end_time else "TBD" + logger.info(" %2d. [%s – %s] %s", i, start, end, evt.title) + + try: + logger.info("Step 2/2 Writing calendar file …") + output_path = create_calendar_file(events, output_path=Path(args.output)) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to write calendar file: %s", exc) + return 1 + + logger.info("Done! Import '%s' into Outlook (or any iCalendar app) to add", output_path) + logger.info(" these events to your calendar.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2c473d3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +requests>=2.31.0 +beautifulsoup4>=4.12.0 +icalendar>=5.0.0 +python-dateutil>=2.8.2 +lxml>=4.9.0 diff --git a/scraper.py b/scraper.py new file mode 100644 index 0000000..6781779 --- /dev/null +++ b/scraper.py @@ -0,0 +1,369 @@ +""" +CinemaCon Schedule Scraper +Scrapes the schedule of events from https://www.cinemacon.com/en/schedule-of-events +""" + +import logging +import re +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +import requests +from bs4 import BeautifulSoup +from dateutil import parser as date_parser + +logger = logging.getLogger(__name__) + +CINEMACON_SCHEDULE_URL = "https://www.cinemacon.com/en/schedule-of-events" + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", +} + +# Default year to use when parsing dates without an explicit year +DEFAULT_YEAR = 2026 + + +@dataclass +class CinemaConEvent: + """Represents a single CinemaCon scheduled event.""" + + title: str + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + location: str = "Caesars Palace, Las Vegas, NV" + description: str = "" + tags: list = field(default_factory=list) + + def __repr__(self) -> str: + start = self.start_time.strftime("%Y-%m-%d %H:%M") if self.start_time else "TBD" + end = self.end_time.strftime("%H:%M") if self.end_time else "TBD" + return f"CinemaConEvent(title={self.title!r}, start={start}, end={end})" + + +def fetch_schedule_page(url: str = CINEMACON_SCHEDULE_URL, timeout: int = 30) -> str: + """ + Fetch the HTML content of the CinemaCon schedule page. + + Args: + url: The URL to fetch. + timeout: Request timeout in seconds. + + Returns: + HTML content as a string. + + Raises: + requests.RequestException: If the page cannot be fetched. + """ + logger.info("Fetching schedule from %s", url) + response = requests.get(url, headers=HEADERS, timeout=timeout) + response.raise_for_status() + logger.info("Successfully fetched page (%d bytes)", len(response.content)) + return response.text + + +def _parse_time_range(time_str: str, date: datetime) -> tuple[Optional[datetime], Optional[datetime]]: + """ + Parse a time range string (e.g. '9:00 a.m. – 11:30 a.m.' or '6:30–8:45 p.m.') + and combine it with the given date. + + Returns: + A tuple of (start_datetime, end_datetime). + """ + time_str = time_str.strip() + # Normalise various dash/hyphen characters to a plain hyphen + time_str = re.sub(r"[–—−]", "-", time_str) + + # Split into start/end parts + parts = re.split(r"\s*-\s*", time_str, maxsplit=1) + + def _parse_single(t: str, fallback_period: str = "") -> Optional[datetime]: + """Parse a single time string, optionally inheriting an AM/PM indicator.""" + t = t.strip() + # Normalise a.m./p.m. → am/pm + t = re.sub(r"\ba\.m\.", "am", t, flags=re.IGNORECASE) + t = re.sub(r"\bp\.m\.", "pm", t, flags=re.IGNORECASE) + if not re.search(r"(am|pm)", t, re.IGNORECASE) and fallback_period: + t = f"{t} {fallback_period}" + try: + parsed = date_parser.parse(t, default=date) + return date.replace(hour=parsed.hour, minute=parsed.minute, second=0, microsecond=0) + except (ValueError, OverflowError): + return None + + if len(parts) == 1: + start = _parse_single(parts[0]) + return start, None + + # Determine AM/PM from end token to inherit into start when missing. + # Match both "am"/"pm" and "a.m."/"p.m." variants. + end_period_match = re.search(r"(a\.?m\.?|p\.?m\.?)", parts[1], re.IGNORECASE) + if end_period_match: + raw = end_period_match.group(0) + fallback = "pm" if raw.lower().startswith("p") else "am" + else: + fallback = "" + + start = _parse_single(parts[0], fallback) + end = _parse_single(parts[1]) + return start, end + + +def _extract_year_from_page(soup: BeautifulSoup) -> int: + """ + Try to detect the event year from the page content. + Falls back to DEFAULT_YEAR if not found. + """ + text = soup.get_text(" ", strip=True) + match = re.search(r"\b(202\d)\b", text) + if match: + return int(match.group(1)) + return DEFAULT_YEAR + + +def _build_date(day_text: str, year: int) -> Optional[datetime]: + """ + Parse a day header string such as 'Monday, March 31' into a datetime. + """ + try: + return date_parser.parse(f"{day_text} {year}") + except (ValueError, OverflowError): + return None + + +def parse_schedule(html: str) -> list[CinemaConEvent]: + """ + Parse the CinemaCon schedule HTML into a list of CinemaConEvent objects. + + The parser handles several common markup patterns: + - Day headings followed by event rows/cards + - Table-based schedules + - Simple list-based schedules + """ + soup = BeautifulSoup(html, "lxml") + year = _extract_year_from_page(soup) + events: list[CinemaConEvent] = [] + + # ------------------------------------------------------------------ # + # Strategy 1: look for structured schedule containers / data tables # + # ------------------------------------------------------------------ # + events = _parse_table_schedule(soup, year) + if events: + logger.info("Parsed %d events via table strategy", len(events)) + return events + + # ------------------------------------------------------------------ # + # Strategy 2: day-heading + event-card pattern # + # ------------------------------------------------------------------ # + events = _parse_card_schedule(soup, year) + if events: + logger.info("Parsed %d events via card strategy", len(events)) + return events + + # ------------------------------------------------------------------ # + # Strategy 3: generic heuristic – look for bold/heading day lines # + # followed by indented time/title pairs # + # ------------------------------------------------------------------ # + events = _parse_heuristic(soup, year) + logger.info("Parsed %d events via heuristic strategy", len(events)) + return events + + +# --------------------------------------------------------------------------- # +# Parsing strategies # +# --------------------------------------------------------------------------- # + +_DAY_PATTERN = re.compile( + r"\b(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)" + r"[,\s]+([A-Z][a-z]+\s+\d{1,2})\b", + re.IGNORECASE, +) + +_TIME_PATTERN = re.compile( + r"\d{1,2}:\d{2}\s*(?:a\.?m\.?|p\.?m\.?)?" + r"(?:\s*[–—−-]\s*\d{1,2}:\d{2}\s*(?:a\.?m\.?|p\.?m\.?)?)?", + re.IGNORECASE, +) + + +def _parse_table_schedule(soup: BeautifulSoup, year: int) -> list[CinemaConEvent]: + """Parse schedule from elements.""" + events: list[CinemaConEvent] = [] + for table in soup.find_all("table"): + current_date: Optional[datetime] = None + for row in table.find_all("tr"): + cells = row.find_all(["td", "th"]) + if not cells: + continue + row_text = " ".join(c.get_text(" ", strip=True) for c in cells) + + # Check if this row is a day header + day_match = _DAY_PATTERN.search(row_text) + if day_match and len(cells) <= 2: + candidate = f"{day_match.group(1)}, {day_match.group(2)}" + current_date = _build_date(candidate, year) + continue + + if current_date is None: + continue + + # Expect at least a time cell and a title cell + time_text = cells[0].get_text(" ", strip=True) if cells else "" + title_text = cells[1].get_text(" ", strip=True) if len(cells) > 1 else "" + location_text = cells[2].get_text(" ", strip=True) if len(cells) > 2 else "" + + if not _TIME_PATTERN.search(time_text) or not title_text: + continue + + start, end = _parse_time_range(time_text, current_date) + event = CinemaConEvent( + title=title_text, + start_time=start, + end_time=end, + location=location_text or "Caesars Palace, Las Vegas, NV", + description=f"Source: {CINEMACON_SCHEDULE_URL}", + ) + events.append(event) + + return events + + +def _parse_card_schedule(soup: BeautifulSoup, year: int) -> list[CinemaConEvent]: + """ + Parse schedule where events are grouped under day heading elements + (h2/h3/h4 or elements with a date class) followed by event cards/divs. + """ + events: list[CinemaConEvent] = [] + current_date: Optional[datetime] = None + + # Candidate heading tags and class keywords + heading_tags = {"h1", "h2", "h3", "h4", "h5"} + date_class_re = re.compile(r"date|day|header", re.IGNORECASE) + + for element in soup.find_all(True): + tag = element.name + if tag is None: + continue + + text = element.get_text(" ", strip=True) + + # Check if this element is a day heading + is_heading = tag in heading_tags or any( + date_class_re.search(c) for c in element.get("class", []) + ) + if is_heading: + day_match = _DAY_PATTERN.search(text) + if day_match: + candidate = f"{day_match.group(1)}, {day_match.group(2)}" + new_date = _build_date(candidate, year) + if new_date: + current_date = new_date + continue + + if current_date is None: + continue + + # Skip if this element is itself a heading + if tag in heading_tags: + continue + + # Look for time + title pattern in the element text + time_match = _TIME_PATTERN.search(text) + if not time_match: + continue + + # Skip very large containers (they contain child events) + if len(element.find_all(True)) > 10: + continue + + time_str = time_match.group(0) + title = text.replace(time_str, "").strip(" :–—-") + if not title or len(title) < 3: + continue + + # Try to find a location sub-element + location = "Caesars Palace, Las Vegas, NV" + loc_el = element.find(class_=re.compile(r"location|venue|room", re.IGNORECASE)) + if loc_el: + location = loc_el.get_text(" ", strip=True) + + start, end = _parse_time_range(time_str, current_date) + event = CinemaConEvent( + title=title, + start_time=start, + end_time=end, + location=location, + description=f"Source: {CINEMACON_SCHEDULE_URL}", + ) + events.append(event) + + return events + + +def _parse_heuristic(soup: BeautifulSoup, year: int) -> list[CinemaConEvent]: + """ + Last-resort heuristic parser: walk all text nodes, detect day lines + and time+title lines. + """ + events: list[CinemaConEvent] = [] + current_date: Optional[datetime] = None + + lines = [ + line.strip() + for line in soup.get_text("\n").splitlines() + if line.strip() + ] + + for line in lines: + day_match = _DAY_PATTERN.search(line) + if day_match: + candidate = f"{day_match.group(1)}, {day_match.group(2)}" + new_date = _build_date(candidate, year) + if new_date: + current_date = new_date + continue + + if current_date is None: + continue + + time_match = _TIME_PATTERN.search(line) + if not time_match: + continue + + time_str = time_match.group(0) + title = line.replace(time_str, "").strip(" :–—-|•") + if not title or len(title) < 3: + continue + + start, end = _parse_time_range(time_str, current_date) + event = CinemaConEvent( + title=title, + start_time=start, + end_time=end, + location="Caesars Palace, Las Vegas, NV", + description=f"Source: {CINEMACON_SCHEDULE_URL}", + ) + events.append(event) + + return events + + +def scrape_events(url: str = CINEMACON_SCHEDULE_URL) -> list[CinemaConEvent]: + """ + High-level function: fetch and parse the CinemaCon schedule. + + Args: + url: URL of the CinemaCon schedule page. + + Returns: + List of CinemaConEvent objects. + """ + html = fetch_schedule_page(url) + return parse_schedule(html) diff --git a/test_cinemacon.py b/test_cinemacon.py new file mode 100644 index 0000000..f82c2c5 --- /dev/null +++ b/test_cinemacon.py @@ -0,0 +1,317 @@ +""" +Tests for the CinemaCon calendar management scripts. +""" + +import textwrap +from datetime import datetime, timezone, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from scraper import ( + CinemaConEvent, + _build_date, + _parse_time_range, + parse_schedule, +) +from calendar_integration import ( + build_calendar, + create_calendar_file, + event_to_vevent, + save_calendar, +) +from main import main + + +# ───────────────────────────── fixtures ────────────────────────────────────── + + +@pytest.fixture() +def sample_events(): + return [ + CinemaConEvent( + title="Sony Pictures Presentation", + start_time=datetime(2025, 3, 31, 18, 30), + end_time=datetime(2025, 3, 31, 20, 45), + location="The Colosseum, Caesars Palace, Las Vegas, NV", + description="Source: https://www.cinemacon.com/en/schedule-of-events", + ), + CinemaConEvent( + title="Lionsgate Presentation", + start_time=datetime(2025, 4, 1, 9, 0), + end_time=datetime(2025, 4, 1, 11, 30), + location="The Colosseum, Caesars Palace, Las Vegas, NV", + description="Source: https://www.cinemacon.com/en/schedule-of-events", + ), + ] + + +# ────────────────────────────────────────────────────────────────────────────── +# scraper tests +# ────────────────────────────────────────────────────────────────────────────── + + +class TestBuildDate: + def test_standard_format(self): + dt = _build_date("Monday, March 31", 2025) + assert dt is not None + assert dt.month == 3 + assert dt.day == 31 + assert dt.year == 2025 + + def test_without_weekday(self): + dt = _build_date("April 1", 2025) + assert dt is not None + assert dt.month == 4 + assert dt.day == 1 + + def test_invalid_returns_none(self): + dt = _build_date("not a date", 2025) + assert dt is None + + +class TestParseTimeRange: + """Tests for the _parse_time_range helper.""" + + base = datetime(2025, 3, 31) + + def test_simple_range_am_pm(self): + start, end = _parse_time_range("9:00 a.m. – 11:30 a.m.", self.base) + assert start is not None and start.hour == 9 and start.minute == 0 + assert end is not None and end.hour == 11 and end.minute == 30 + + def test_pm_only_end(self): + start, end = _parse_time_range("6:30–8:45 p.m.", self.base) + assert start is not None and start.hour == 18 and start.minute == 30 + assert end is not None and end.hour == 20 and end.minute == 45 + + def test_single_time(self): + start, end = _parse_time_range("10:00 a.m.", self.base) + assert start is not None and start.hour == 10 + assert end is None + + def test_24h_range(self): + start, end = _parse_time_range("14:00 - 15:30", self.base) + assert start is not None and start.hour == 14 + assert end is not None and end.hour == 15 and end.minute == 30 + + def test_malformed_returns_none_pair(self): + start, end = _parse_time_range("no time here", self.base) + assert start is None + + +class TestParseSchedule: + """Tests for parse_schedule with various HTML structures.""" + + def _html_table(self) -> str: + return textwrap.dedent(""" + +
+ + + + + + + + + + + + + + +
Monday, March 31, 2025
6:30–8:45 p.m.Sony Pictures PresentationThe Colosseum
Tuesday, April 1, 2025
9:00 a.m. - 11:30 a.m.Lionsgate PresentationThe Colosseum
+ + """) + + def _html_heuristic(self) -> str: + return textwrap.dedent(""" + +

Monday, March 31, 2025

+

6:30 p.m. - 8:45 p.m. Sony Pictures

+

Tuesday, April 1, 2025

+

9:00 a.m. - 11:30 a.m. Lionsgate

+ + """) + + def test_table_schedule_parsed(self): + events = parse_schedule(self._html_table()) + assert len(events) >= 2 + titles = [e.title for e in events] + assert any("Sony" in t for t in titles) + assert any("Lionsgate" in t for t in titles) + + def test_events_have_start_times(self): + events = parse_schedule(self._html_table()) + for evt in events: + assert evt.start_time is not None, f"Event {evt.title!r} has no start time" + + def test_heuristic_fallback(self): + events = parse_schedule(self._html_heuristic()) + assert len(events) >= 2 + + def test_empty_page_returns_empty_list(self): + events = parse_schedule("

Nothing here.

") + assert events == [] + + def test_year_detected_from_page(self): + events = parse_schedule(self._html_table()) + for evt in events: + if evt.start_time: + assert evt.start_time.year == 2025 + + +# ────────────────────────────────────────────────────────────────────────────── +# calendar_integration tests +# ────────────────────────────────────────────────────────────────────────────── + + +class TestEventToVevent: + def test_basic_fields(self, sample_events): + evt = sample_events[0] + vevent = event_to_vevent(evt) + assert str(vevent.get("summary")) == "Sony Pictures Presentation" + assert vevent.get("dtstart") is not None + assert vevent.get("dtend") is not None + assert vevent.get("location") is not None + assert vevent.get("uid") is not None + + def test_no_end_time_adds_default_duration(self): + evt = CinemaConEvent( + title="Mystery Panel", + start_time=datetime(2025, 4, 2, 14, 0), + end_time=None, + ) + vevent = event_to_vevent(evt) + start = vevent.decoded("dtstart") + end = vevent.decoded("dtend") + diff = end - start + assert diff == timedelta(hours=1) + + def test_no_start_time_creates_allday_event(self): + evt = CinemaConEvent(title="TBD Event") + vevent = event_to_vevent(evt) + # All-day events use date (not datetime) objects + from datetime import date + assert isinstance(vevent.decoded("dtstart"), date) + + def test_utc_conversion(self): + evt = CinemaConEvent( + title="Evening Show", + start_time=datetime(2025, 3, 31, 18, 30), # naive → treated as PDT + ) + vevent = event_to_vevent(evt) + start = vevent.decoded("dtstart") + # PDT = UTC-7, so 18:30 PDT → 01:30 UTC next day + assert start.tzinfo == timezone.utc + assert start.hour == 1 + assert start.minute == 30 + + +class TestBuildCalendar: + def test_event_count(self, sample_events): + cal = build_calendar(sample_events) + vevents = list(cal.walk("VEVENT")) + assert len(vevents) == len(sample_events) + + def test_calendar_metadata(self, sample_events): + cal = build_calendar(sample_events, calendar_name="Test Cal") + assert cal.get("version") is not None + assert cal.get("prodid") is not None + + def test_empty_events_list(self): + cal = build_calendar([]) + vevents = list(cal.walk("VEVENT")) + assert len(vevents) == 0 + + +class TestSaveCalendar: + def test_file_created(self, tmp_path, sample_events): + cal = build_calendar(sample_events) + output = tmp_path / "test_events.ics" + result = save_calendar(cal, output) + assert result == output + assert output.exists() + assert output.stat().st_size > 0 + + def test_valid_ics_content(self, tmp_path, sample_events): + cal = build_calendar(sample_events) + output = tmp_path / "test_events.ics" + save_calendar(cal, output) + content = output.read_text() + assert "BEGIN:VCALENDAR" in content + assert "BEGIN:VEVENT" in content + assert "END:VEVENT" in content + assert "END:VCALENDAR" in content + + def test_default_path(self, tmp_path, sample_events, monkeypatch): + monkeypatch.chdir(tmp_path) + cal = build_calendar(sample_events) + result = save_calendar(cal) + assert result.name == "cinemacon_events.ics" + assert result.exists() + + +class TestCreateCalendarFile: + def test_creates_ics_file(self, tmp_path, sample_events): + output = tmp_path / "events.ics" + result = create_calendar_file(sample_events, output_path=output) + assert result == output + assert output.exists() + + def test_creates_parent_dirs(self, tmp_path, sample_events): + output = tmp_path / "nested" / "dir" / "events.ics" + result = create_calendar_file(sample_events, output_path=output) + assert result.exists() + + +# ────────────────────────────────────────────────────────────────────────────── +# main() integration tests +# ────────────────────────────────────────────────────────────────────────────── + + +class TestMain: + """Integration tests for the main() entry point (network calls are mocked).""" + + def _make_events(self): + return [ + CinemaConEvent( + title="Sony Presentation", + start_time=datetime(2025, 3, 31, 18, 30), + end_time=datetime(2025, 3, 31, 20, 45), + ) + ] + + def test_success_exit_code(self, tmp_path): + output = str(tmp_path / "out.ics") + with patch("main.scrape_events", return_value=self._make_events()): + code = main(["--output", output]) + assert code == 0 + assert Path(output).exists() + + def test_no_events_returns_nonzero(self, tmp_path): + output = str(tmp_path / "out.ics") + with patch("main.scrape_events", return_value=[]): + code = main(["--output", output]) + assert code != 0 + + def test_scrape_error_returns_nonzero(self, tmp_path): + output = str(tmp_path / "out.ics") + with patch("main.scrape_events", side_effect=ConnectionError("network down")): + code = main(["--output", output]) + assert code != 0 + + def test_custom_url_forwarded(self, tmp_path): + output = str(tmp_path / "out.ics") + custom_url = "https://example.com/schedule" + with patch("main.scrape_events", return_value=self._make_events()) as mock_scrape: + main(["--output", output, "--url", custom_url]) + mock_scrape.assert_called_once_with(custom_url) + + def test_verbose_flag_accepted(self, tmp_path): + output = str(tmp_path / "out.ics") + with patch("main.scrape_events", return_value=self._make_events()): + code = main(["--output", output, "--verbose"]) + assert code == 0