From b89ad34f6e85eea873f2f81600b66e4caa748d4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 03:46:11 +0000 Subject: [PATCH 1/2] Initial plan From 392bad4738f8c282e4273e0080c8286c1ed8f3dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 03:53:02 +0000 Subject: [PATCH 2/2] Add CinemaCon schedule scraper and Outlook calendar generator Agent-Logs-Url: https://github.com/OyeThakkar/Calendar_Management/sessions/aa2b3157-035f-490e-9474-df91022d7b01 Co-authored-by: OyeThakkar <133673260+OyeThakkar@users.noreply.github.com> --- .gitignore | 26 +++ README.md | 57 ++++++- calendar_integration.py | 155 +++++++++++++++++ main.py | 118 +++++++++++++ requirements.txt | 5 + scraper.py | 369 ++++++++++++++++++++++++++++++++++++++++ test_cinemacon.py | 317 ++++++++++++++++++++++++++++++++++ 7 files changed, 1046 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 calendar_integration.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 scraper.py create mode 100644 test_cinemacon.py 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
| Monday, March 31, 2025 | ||
| 6:30–8:45 p.m. | +Sony Pictures Presentation | +The Colosseum | +
| Tuesday, April 1, 2025 | +||
| 9:00 a.m. - 11:30 a.m. | +Lionsgate Presentation | +The Colosseum | +
6:30 p.m. - 8:45 p.m. Sony Pictures
+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