import caldav
from datetime import datetime, timedelta
import pytz
import os
from urllib.parse import quote
import loggingI regularly have to count up hours for different events in my calendar, which is a bit tedious. I thought it would be nice to automate this calculation, so with Claude’s help, I wrote a Python script that reads the calendar data from Apple’s Calendar app and counts up the hours for each event. I pushed this script to a GitHub repository and created a GitHub Actions workflow to run every week and display the totals in the Actions summary view. My GitHub repo for this is private, but I’ll show the important pieces here.
The packages I use are as follows:
The key package is caldav, which is what allows you to connect to the calendar.
I set up a conda environment with the following environment.yml file:
name: calendar-automation
channels:
- conda-forge
- defaults
dependencies:
- python=3.11
- pip=23.2.1
- caldav=1.3.9
- pytz=2025.2I created a function to connect to the calendar as follows:
def connect_to_icloud_calendar():
"""Connect to iCloud calendar via CalDAV"""
# Get credentials from environment variables
username = os.getenv('ICLOUD_USERNAME') # your Apple ID
password = os.getenv('ICLOUD_APP_PASSWORD') # App-specific password
if not username or not password:
raise ValueError("Missing iCloud credentials. Set ICLOUD_USERNAME and ICLOUD_APP_PASSWORD environment variables.")
# iCloud CalDAV URL
url = f"https://{quote(username)}:{quote(password)}@caldav.icloud.com"
try:
client = caldav.DAVClient(url)
principal = client.principal()
calendars = principal.calendars()
if not calendars:
raise Exception("No calendars found")
print(f"Connected to iCloud calendar successfully")
return calendars
except Exception as e:
print(f"Failed to connect to iCloud calendar: {e}")
raiseThis function depends on two environment variables: ICLOUD_USERNAME and ICLOUD_APP_PASSWORD. You can set these variables locally by running this after you have activated your conda environment:
export ICLOUD_USERNAME="your-apple-id@email.com"
export ICLOUD_APP_PASSWORD="your-app-specific-password"The app-specific password can be generated by going to appleid.apple.com, logging in, going to the “Sign-In and Security” section, then clicking “App-Specific Passwords”. Follow the instructions to generate an app-specific password. It’ll only show it to you once. You may want to keep a note of it in a password manager so you can use it also for GitHub Actions.
You then connect to the calendar by calling the function:
calendars = connect_to_icloud_calendar()Next, I wrote a function to get the events from the calendar:
def get_work_week_events(calendars, event_name, start_date, end_date):
"""Get events within a date range"""
print(f"Searching for '{event_name}' events from {start_date.date()} ({start_date.strftime('%A')}) to {end_date.date()} ({end_date.strftime('%A')})")
print("-" * 60)
matching_events = []
total_duration = timedelta(0)
# Search through all calendars
for calendar in calendars:
try:
# Fetch events in date range
events = calendar.date_search(start=start_date, end=end_date)
for event in events:
# Parse the event data
event_data = event.icalendar_component
summary = str(event_data.get('SUMMARY', ''))
# Check if event name matches (case insensitive)
if event_name.lower() not in summary.lower():
continue
# Get event times
start_time = event_data.get('DTSTART').dt
end_time = event_data.get('DTEND').dt
# Handle all-day events (date objects vs datetime objects)
if hasattr(start_time, 'hour'): # It's a datetime
# Convert to UTC if needed
if hasattr(start_time, 'tzinfo') and start_time.tzinfo:
if start_time.tzinfo != pytz.UTC:
start_time = start_time.astimezone(pytz.UTC)
else:
start_time = pytz.UTC.localize(start_time)
if hasattr(end_time, 'tzinfo') and end_time.tzinfo:
if end_time.tzinfo != pytz.UTC:
end_time = end_time.astimezone(pytz.UTC)
else:
end_time = pytz.UTC.localize(end_time)
else: # It's an all-day event (date object)
# Convert date to datetime at start/end of day
start_time = datetime.combine(start_time, datetime.min.time())
end_time = datetime.combine(end_time, datetime.min.time())
start_time = pytz.UTC.localize(start_time)
end_time = pytz.UTC.localize(end_time)
# Calculate duration
duration = end_time - start_time
matching_events.append({
'summary': summary,
'start': start_time,
'end': end_time,
'duration': duration
})
total_duration += duration
except Exception as e:
print(f"Error processing calendar {calendar}: {e}")
continue
# Sort events by start time
matching_events.sort(key=lambda x: x['start'])
return matching_events, total_durationThis function takes the calendars data object, the name of the event to search for event_name, and a date range start_date and end_date. It returns a list of matching events and the total duration of those events. It is called like this:
events, total_duration = get_work_week_events(calendars, event_name, start_date, end_date)Next I do some processing and formatting of the results, which print to the console when run locally.
After confirming it all works as expected locally, I set up GitHub Actions by creating a workflow file at .github/workflows/weekly-calendar-report.yml:
name: Weekly Calendar Report
on:
schedule:
# Run every Sunday at 9 AM UTC
- cron: '0 9 * * 0' # 0 = Sunday
workflow_dispatch: # Allow manual trigger for testing
jobs:
calendar-report:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Conda Environment
uses: conda-incubator/setup-miniconda@v3
with:
environment-file: environment.yml
activate-environment: calendar-automation
python-version: 3.11
auto-activate-base: false
auto-update-conda: true
- name: Check conda environment
shell: bash -el {0} # This ensures conda activation works
run: |
conda info
conda list
which python
python --version
- name: Generate Calendar Report
shell: bash -el {0}
env:
ICLOUD_USERNAME: ${{ secrets.ICLOUD_USERNAME }}
ICLOUD_APP_PASSWORD: ${{ secrets.ICLOUD_APP_PASSWORD }}
EVENT_NAME: ${{ secrets.EVENT_NAME }}
run: |
echo "=== WEEKLY CALENDAR REPORT ==="
echo "Generated: $(date)"
echo "Workflow Run: ${{ github.run_id }}"
echo ""
echo "Active conda environment: $CONDA_DEFAULT_ENV"
# Run the script and capture output
python calendar_report.py > calendar_output.txt 2>&1
# Display in logs
cat calendar_output.txt
# Add to GitHub workflow summary
echo "## 📅 Weekly Calendar Report" >> $GITHUB_STEP_SUMMARY
echo "**Generated:** $(date)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Calendar Output:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat calendar_output.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo ""
echo "=== END REPORT ==="In the Generate Calendar Report step, I run the Python script calendar_report.py which contains all the code to connect to the calendar, find the right events, do the analyses, and print the results. The output is captured in a temp file calendar_output.txt, which is then displayed in the GitHub Actions logs and added to the workflow summary by echoing the output to $GITHUB_STEP_SUMMARY.
The environmental variables ICLOUD_USERNAME, ICLOUD_APP_PASSWORD, and EVENT_NAME are set as GitHub Secrets, which you can configure in your repository settings under “Settings” -> “Secrets and variables” -> “Actions” -> “Repository secrets”. EVENT_NAME is a pipe-delimited string of event names you want to search for, e.g. Work|Meeting|Project. In my full script I set it up where it searches for all event names included in this string.
That’s it! Quite a bit of work to save me about 2 minutes each week, but it’ll be worth it eventually.