import caldav
from datetime import datetime, timedelta
import pytz
import os
from urllib.parse import quote
import logging
I 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.2
I 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
= os.getenv('ICLOUD_USERNAME') # your Apple ID
username = os.getenv('ICLOUD_APP_PASSWORD') # App-specific password
password
if not username or not password:
raise ValueError("Missing iCloud credentials. Set ICLOUD_USERNAME and ICLOUD_APP_PASSWORD environment variables.")
# iCloud CalDAV URL
= f"https://{quote(username)}:{quote(password)}@caldav.icloud.com"
url
try:
= caldav.DAVClient(url)
client = client.principal()
principal = principal.calendars()
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}")
raise
This 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:
= connect_to_icloud_calendar() calendars
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 = timedelta(0)
total_duration
# Search through all calendars
for calendar in calendars:
try:
# Fetch events in date range
= calendar.date_search(start=start_date, end=end_date)
events
for event in events:
# Parse the event data
= event.icalendar_component
event_data
= str(event_data.get('SUMMARY', ''))
summary
# Check if event name matches (case insensitive)
if event_name.lower() not in summary.lower():
continue
# Get event times
= event_data.get('DTSTART').dt
start_time = event_data.get('DTEND').dt
end_time
# 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.astimezone(pytz.UTC)
start_time else:
= pytz.UTC.localize(start_time)
start_time
if hasattr(end_time, 'tzinfo') and end_time.tzinfo:
if end_time.tzinfo != pytz.UTC:
= end_time.astimezone(pytz.UTC)
end_time else:
= pytz.UTC.localize(end_time)
end_time else: # It's an all-day event (date object)
# Convert date to datetime at start/end of day
= datetime.combine(start_time, datetime.min.time())
start_time = datetime.combine(end_time, datetime.min.time())
end_time = pytz.UTC.localize(start_time)
start_time = pytz.UTC.localize(end_time)
end_time
# Calculate duration
= end_time - start_time
duration
matching_events.append({'summary': summary,
'start': start_time,
'end': end_time,
'duration': duration
})+= duration
total_duration
except Exception as e:
print(f"Error processing calendar {calendar}: {e}")
continue
# Sort events by start time
=lambda x: x['start'])
matching_events.sort(key
return matching_events, total_duration
This 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:
= get_work_week_events(calendars, event_name, start_date, end_date) events, total_duration
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 echo
ing 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.