Python API#
Basic Usage#
Use icalendar_anonymizer.anonymize() to anonymize a icalendar.Calendar object:
from icalendar import Calendar
from icalendar_anonymizer import anonymize
# Load your calendar
with open('calendar.ics', 'rb') as f:
cal = Calendar.from_ical(f.read())
# Anonymize it
anonymized_cal = anonymize(cal)
# Save the result
with open('anonymized.ics', 'wb') as f:
f.write(anonymized_cal.to_ical())
Function Signature#
- icalendar_anonymizer.anonymize(cal: Calendar, salt: bytes | None = None, preserve: set[str] | None = None, field_modes: dict[str, str] | None = None) Calendar[source]#
Anonymize an iCalendar object.
Removes personal data (names, emails, locations, descriptions) while preserving technical properties (dates, recurrence, timezones). Uses deterministic hashing so the same input produces the same output with the same salt.
- Parameters:
cal – The Calendar object to anonymize
salt – Optional salt for hashing. If None, generates random salt. Pass the same salt to get consistent output across runs.
preserve – Optional set of additional property names to preserve. Case-insensitive. User must ensure these don’t contain sensitive data. Example: {“CATEGORIES”, “COMMENT”} Cannot be used with field_modes.
field_modes –
Optional dict mapping field names to anonymization modes.
- Modes
”keep”, “remove”, “randomize”, “replace”.
- Fields
SUMMARY, DESCRIPTION, LOCATION, COMMENT, CONTACT, RESOURCES, CATEGORIES, ATTENDEE, ORGANIZER, UID.
Cannot be used with preserve.
- Returns:
New anonymized Calendar object
- Raises:
TypeError – If cal is not a Calendar object or salt is not bytes
ValueError – If both preserve and field_modes are specified, or if field_modes contains invalid fields/modes
Using Custom Salt#
Provide your own salt for reproducible output:
# Anonymize with custom salt
anonymized_cal = anonymize(cal, salt=b"my-secret-salt-12345678901234567890")
# Same input + same salt = same output
anonymized_again = anonymize(cal, salt=b"my-secret-salt-12345678901234567890")
assert anonymized_cal.to_ical() == anonymized_again.to_ical()
Use cases:
Reproducible output across runs
Testing and debugging
Consistent hashing when sharing calendars
Warning
Keep your custom salt secret if you need to prevent others from testing potential matches against the hashed values.
Configurable Field Anonymization#
Control how each field is anonymized using the field_modes parameter. Four modes available:
- keep
Preserve the original value unchanged
- remove
Strip the property entirely from the output
- randomize (default)
Hash to a deterministic random value
- replace
Replace with a fixed placeholder text
# Keep SUMMARY, remove LOCATION, replace DESCRIPTION
anonymized_cal = anonymize(cal, field_modes={
"SUMMARY": "keep",
"LOCATION": "remove",
"DESCRIPTION": "replace"
})
Configurable fields (10 total):
Text:
SUMMARY,DESCRIPTION,LOCATION,COMMENT,CONTACT,RESOURCES,CATEGORIESEmail:
ATTENDEE,ORGANIZERID:
UID
# Example: Preserve summaries, remove locations, randomize everything else
anonymized_cal = anonymize(cal, field_modes={
"SUMMARY": "keep",
"LOCATION": "remove"
})
Important notes:
Field names are case-insensitive:
{"summary": "keep"}and{"SUMMARY": "keep"}are equivalentMode values are case-insensitive:
"Keep","KEEP", and"keep"all workMode values also accept
icalendar_anonymizer._config.AnonymizeModemembers directly, such as{"SUMMARY": AnonymizeMode.KEEP}Only configured fields are affected - others use the default randomize behavior
Applies recursively to all components (VEVENT, VTODO, VJOURNAL, VALARM)
UID constraint: Cannot use
removemode (would break recurring events)
Replace Mode Placeholders#
When using replace mode, these placeholders are used:
SUMMARY: "[Redacted]"
DESCRIPTION: "[Content removed]"
LOCATION: "[Location removed]"
COMMENT: "[Comment removed]"
CONTACT: "[Contact removed]"
RESOURCES: "[Resources removed]"
CATEGORIES: "REDACTED"
ATTENDEE: "mailto:redacted@example.local"
ORGANIZER: "mailto:redacted@example.local"
UID: "redacted-N@anonymous.local" # N = counter for uniqueness
# Example: Replace sensitive fields with placeholders
anonymized_cal = anonymize(cal, field_modes={
"DESCRIPTION": "replace",
"LOCATION": "replace",
"ATTENDEE": "replace"
})
Preserving Additional Properties (Legacy)#
The preserve parameter is still supported for backward compatibility:
# Legacy method - still works
anonymized_cal = anonymize(cal, preserve={"CATEGORIES", "LOCATION"})
# Equivalent using field_modes
anonymized_cal = anonymize(cal, field_modes={
"CATEGORIES": "keep",
"LOCATION": "keep"
})
Mutual exclusion: Cannot specify both preserve and field_modes in the same call.
# ❌ Error: cannot use both
anonymized_cal = anonymize(cal,
preserve={"SUMMARY"},
field_modes={"LOCATION": "keep"}
)
Important notes:
Property names are case-insensitive:
{"summary"}and{"SUMMARY"}are equivalentThe
preserveset is additive - it adds to the default preserved properties, not replaces themApplies recursively to all components (VEVENT, VTODO, VJOURNAL, VALARM)
Use this when you’ve confirmed the properties contain no sensitive data
# Example: Preserving categories for bug reproduction
# After confirming categories contain no personal data
anonymized_cal = anonymize(cal, preserve={"CATEGORIES"})
Property Handling Reference#
This table shows which properties are anonymized vs. preserved by default.
Preserved Properties (Technical)#
These properties are preserved to enable bug reproduction:
Property |
Notes |
|---|---|
Datetime Properties |
|
DTSTART |
Start date/time - critical for scheduling bugs |
DTEND |
End date/time |
DUE |
Due date for TODOs |
DURATION |
Event duration |
DTSTAMP |
Timestamp |
CREATED |
Creation timestamp |
LAST-MODIFIED |
Last modification timestamp |
Recurrence Properties |
|
RRULE |
Recurrence rule - critical for recurrence bugs |
RDATE |
Recurrence dates |
EXDATE |
Exception dates |
RECURRENCE-ID |
Identifies which recurrence instance this component modifies |
Timezone Properties |
|
TZID |
Timezone identifier |
TZOFFSETFROM |
UTC offset before a transition |
TZOFFSETTO |
UTC offset after a transition |
TZNAME |
Timezone abbreviation |
TZURL |
Reference URL for timezone data |
X-WR-TIMEZONE |
Calendar-level timezone (non-standard but widely used) |
Metadata Properties |
|
SEQUENCE |
Modification sequence number |
STATUS |
Event status (CONFIRMED, TENTATIVE, CANCELLED) |
TRANSP |
Transparency (OPAQUE, TRANSPARENT) |
CLASS |
Classification (PUBLIC, PRIVATE, CONFIDENTIAL) |
PRIORITY |
Priority level (0-9) |
Alarm Properties |
|
ACTION |
Alarm action type (AUDIO, DISPLAY, EMAIL) |
TRIGGER |
When the alarm fires, relative or absolute |
REPEAT |
Number of times to repeat the alarm |
Free/Busy Properties |
|
FREEBUSY |
Free/busy time periods |
FBTYPE |
Free/busy type (FREE, BUSY, BUSY-UNAVAILABLE, BUSY-TENTATIVE) |
Relationship Properties |
|
RELATED-TO |
Reference to a related component’s UID |
REQUEST-STATUS |
Scheduling response status code |
Calendar-Level Properties |
|
VERSION |
iCalendar version |
PRODID |
Product identifier |
CALSCALE |
Calendar scale |
METHOD |
Calendar method (REQUEST, REPLY, etc.) |
Components |
|
VTIMEZONE, STANDARD, DAYLIGHT |
Preserved in full, including every property inside them |
Note
ATTACH, URL, and GEO look technical but are anonymized, not preserved.
ATTACH/URL can carry personal data in a path or query string, and GEO reveals a home or work location.
Note
COMPLETED and PERCENT-COMPLETE (VTODO progress properties) are not yet in the preserved set.
Anonymizing a VTODO that has either one currently raises an error instead of producing output. See #168.
Anonymized Properties (Personal Data)#
These properties contain personal data and are hashed:
Property |
Anonymization Method |
|---|---|
Text Fields |
|
SUMMARY |
Hashed with word count preservation |
DESCRIPTION |
Hashed with word count preservation |
LOCATION |
Hashed with word count preservation |
COMMENT |
Hashed with word count preservation |
CONTACT |
Hashed with word count preservation |
CATEGORIES |
Each category hashed individually (list property) |
RESOURCES |
Each resource hashed individually (list property) |
Person Identifiers |
|
ATTENDEE |
CN parameter and email both hashed, |
ORGANIZER |
CN parameter and email both hashed, |
Unique Identifiers |
|
UID |
Hashed to |
Unknown Properties |
|
Any other property, including |
Anonymized by default (secure default-deny model) |
A property not listed in either table above is anonymized, not preserved, even if it looks structural.
This includes custom X- properties, which some calendar apps use to carry personal data (X-ALT-DESC, for example).
Word-level hashing (SUMMARY, DESCRIPTION, and the rest of the text fields) hashes each word to a 16-character hex string and joins them with spaces, so a 3-word summary anonymizes to 3 hashed words.
An empty or whitespace-only value is left unchanged rather than hashed.
An ATTENDEE/ORGANIZER value with no @ is hashed as opaque text instead of email-shaped output.
Special Handling Examples#
ATTENDEE and ORGANIZER#
The CN (Common Name) parameter and the email address are both hashed, keeping the mailto:local@domain shape:
# Original
ATTENDEE;CN=John Doe:mailto:john@example.com
# Anonymized
ATTENDEE;CN=a1b2c3d4e5f6g7h8:mailto:b2c3d4e5f6g7h8i9@a1b2c3d4e5f6g7h8.local
Every other parameter, such as ROLE, PARTSTAT, RSVP, and CUTYPE, is preserved unchanged.
The mailto: prefix’s case is preserved too: a value written as MAILTO: stays MAILTO:.
UID Uniqueness#
UIDs are hashed but uniqueness is maintained across the calendar:
# Original calendar with recurring event
Event 1: UID=abc123
Event 2: UID=abc123 # Same event, recurrence exception
Event 3: UID=xyz789
# Anonymized calendar
Event 1: UID=hash-of-abc123
Event 2: UID=hash-of-abc123 # Same hash - uniqueness preserved!
Event 3: UID=hash-of-xyz789
Word Count Preservation#
Text properties preserve word count to maintain structure:
# Original
SUMMARY:Team meeting about Q4 planning
# Anonymized (6 words → 6 hashes)
SUMMARY:a1b2c3 d4e5f6 g7h8i9 j0k1l2 m3n4o5 p6q7r8
List Properties#
CATEGORIES and RESOURCES are list properties - each value is hashed individually:
# Original
CATEGORIES:Work,Meeting,Important
# Anonymized
CATEGORIES:hash1,hash2,hash3
Working with Different Component Types#
The anonymization works recursively on all component types:
Events (VEVENT)#
from icalendar import Calendar, Event
from icalendar_anonymizer import anonymize
from datetime import datetime
# Create an event
event = Event()
event.add('summary', 'Project meeting')
event.add('dtstart', datetime(2025, 1, 15, 10, 0))
event.add('dtend', datetime(2025, 1, 15, 11, 0))
cal = Calendar()
cal.add_component(event)
# Anonymize
anonymized_cal = anonymize(cal)
TODOs (VTODO)#
from icalendar import Calendar, Todo
from icalendar_anonymizer import anonymize
from datetime import datetime
# Create a TODO
todo = Todo()
todo.add('summary', 'Fix bug in authentication')
todo.add('due', datetime(2025, 1, 20))
todo.add('priority', 1)
cal = Calendar()
cal.add_component(todo)
# Anonymize (PRIORITY preserved, SUMMARY anonymized)
anonymized_cal = anonymize(cal)
Journals (VJOURNAL)#
from icalendar import Calendar, Journal
from icalendar_anonymizer import anonymize
from datetime import datetime
# Create a journal entry
journal = Journal()
journal.add('summary', 'Daily standup notes')
journal.add('description', 'Discussed blockers and next steps')
journal.add('dtstart', datetime(2025, 1, 15))
cal = Calendar()
cal.add_component(journal)
# Anonymize
anonymized_cal = anonymize(cal)
Alarms (VALARM)#
Alarms within events are also processed:
from icalendar import Calendar, Event, Alarm
from icalendar_anonymizer import anonymize
from datetime import timedelta
# Event with alarm
event = Event()
event.add('summary', 'Important meeting')
alarm = Alarm()
alarm.add('description', 'Meeting reminder') # Will be anonymized
alarm.add('trigger', timedelta(minutes=-15)) # Preserved
event.add_component(alarm)
cal = Calendar()
cal.add_component(event)
# Anonymize
anonymized_cal = anonymize(cal)
Supported Components#
Note
This library supports standard iCalendar components: VEVENT, VTODO, VJOURNAL, and VALARM. Components from other standards or extensions may not be fully supported.
Error Handling#
The function performs strict type checking:
TypeError for Invalid Calendar#
from icalendar_anonymizer import anonymize
# Wrong: passing a string instead of Calendar
try:
anonymized = anonymize("BEGIN:VCALENDAR...")
except TypeError as e:
print(e) # "Expected Calendar, got str"
TypeError for Invalid Salt#
# Wrong: passing a string instead of bytes
try:
anonymized = anonymize(cal, salt="my-salt")
except TypeError as e:
print(e) # "salt must be bytes, got str"
TypeError for Invalid Preserve#
# Wrong: passing a list instead of set
try:
anonymized = anonymize(cal, preserve=["SUMMARY", "DESCRIPTION"])
except TypeError as e:
print(e) # "preserve must be a set or None, got list"
# Correct: use a set
anonymized = anonymize(cal, preserve={"SUMMARY", "DESCRIPTION"})
TypeError/ValueError for Invalid field_modes#
# Wrong: passing a list instead of dict
try:
anonymized = anonymize(cal, field_modes=["SUMMARY"])
except TypeError as e:
print(e) # "field_modes must be dict or None, got list"
# Wrong: invalid field name
try:
anonymized = anonymize(cal, field_modes={"INVALID": "keep"})
except ValueError as e:
print(e) # "Unknown field 'INVALID'. Valid: ['ATTENDEE', 'CATEGORIES', ...]"
# Wrong: invalid mode
try:
anonymized = anonymize(cal, field_modes={"SUMMARY": "invalid"})
except ValueError as e:
print(e) # "Invalid mode 'invalid'. Valid: ..."
# Wrong: trying to remove UID
try:
anonymized = anonymize(cal, field_modes={"UID": "remove"})
except ValueError as e:
print(e) # "UID cannot be removed (would break recurring events)"
# Correct: valid field_modes
anonymized = anonymize(cal, field_modes={"SUMMARY": "keep"})
Best Practices#
Load from Files
Always load calendars using the icalendar library:
from icalendar import Calendar with open('calendar.ics', 'rb') as f: cal = Calendar.from_ical(f.read())
Verify Before Preserving
Only use
preserveafter confirming properties contain no sensitive data:# ❌ Don't blindly preserve anonymized = anonymize(cal, preserve={"SUMMARY"}) # ✅ Verify first, then preserve if safe # (After manual inspection confirms SUMMARY has no personal data) anonymized = anonymize(cal, preserve={"CATEGORIES"})
Use Custom Salt for Reproducibility
If you need consistent output across runs:
SALT = b"my-project-salt-" + b"0" * 16 # 32 bytes total anonymized = anonymize(cal, salt=SALT)
Don’t Modify Original
The function returns a new Calendar object - the original is not modified:
anonymized = anonymize(cal) # cal is unchanged # anonymized is the new anonymized calendar
Save with Binary Mode
Always save iCalendar files in binary mode:
with open('anonymized.ics', 'wb') as f: # Note: 'wb' not 'w' f.write(anonymized_cal.to_ical())
Complete Example#
Here’s a complete example putting it all together:
from icalendar import Calendar, Event
from icalendar_anonymizer import anonymize
from datetime import datetime
# Create a calendar
cal = Calendar()
cal.add('prodid', '-//My App//My Calendar//EN')
cal.add('version', '2.0')
# Add an event with personal data
event = Event()
event.add('summary', 'Dentist appointment with Dr. Smith')
event.add('description', 'Regular checkup at 123 Main St')
event.add('location', 'Downtown Dental Clinic')
event.add('dtstart', datetime(2025, 1, 15, 14, 0))
event.add('dtend', datetime(2025, 1, 15, 15, 0))
event.add('status', 'CONFIRMED')
attendee = 'mailto:patient@example.com'
event.add('attendee', attendee, parameters={'CN': 'Jane Doe'})
cal.add_component(event)
# Anonymize with custom salt
SALT = b"my-secret-salt-for-testing-12345"
anonymized_cal = anonymize(cal, salt=SALT)
# Save the anonymized calendar
with open('anonymized.ics', 'wb') as f:
f.write(anonymized_cal.to_ical())
print("Anonymization complete!")
print(f"Original UID: {event['uid']}")
print(f"Anonymized UID: {list(anonymized_cal.walk('VEVENT'))[0]['uid']}")
JSCalendar and jCal support#
icalendar_anonymizer also anonymizes JSCalendar (RFC 8984) and jCal (RFC 7265) documents, the JSON formats used by JMAP calendar servers.
See #159.
jCal is a JSON encoding of the same property model as iCalendar. icalendar_anonymizer.anonymize_jcal() reuses icalendar_anonymizer.anonymize() directly.
- icalendar_anonymizer.anonymize_jcal(data: list, salt: bytes | None = None, field_modes: dict[str, str] | None = None) list[source]#
Anonymize a jCal (RFC 7265) document.
jCal is a direct JSON encoding of iCalendar’s property model, so this reuses the same field names and modes as anonymize() for .ics input (SUMMARY, DESCRIPTION, ATTENDEE, etc.). jCal property names are lowercase per RFC 7265, but anonymize() already treats field names case-insensitively.
- Parameters:
data – Parsed jCal document, e.g. [“vcalendar”, […], […]] as produced by json.loads() of jCal JSON text, or an equivalent Python list structure.
salt – Optional salt for hashing. If None, generates random salt.
field_modes – Optional dict mapping field names to anonymization modes. Same shape and vocabulary as anonymize()’s field_modes.
- Returns:
New anonymized jCal document (same list-of-lists shape).
- Raises:
TypeError – If data is not a list, or salt is not bytes.
ValueError – If data is not a valid jCal document, or field_modes contains invalid fields/modes.
from icalendar import Calendar
from icalendar_anonymizer import anonymize_jcal
with open('calendar.ics', 'rb') as f:
cal = Calendar.from_ical(f.read())
jcal_document = cal.to_jcal()
anonymized = anonymize_jcal(jcal_document)
JSCalendar has a different shape from iCalendar.
Participants are one map covering both organizers and attendees, rather than separate ATTENDEE and ORGANIZER properties, and locations are a map rather than a single value.
icalendar_anonymizer.anonymize_jscal() walks a JSCalendar document directly to handle this, instead of converting it to iCalendar first.
- icalendar_anonymizer.anonymize_jscal(data: dict, salt: bytes | None = None, field_modes: dict[str, str] | None = None) dict[source]#
Anonymize a JSCalendar (RFC 8984) document.
Walks the JSCalendar JSON structure directly. Never converts to or from iCalendar, since round-tripping through iCalendar silently drops ATTACH/URL/CONFERENCE-equivalent data and per-instance recurrence override fields (verified against calendaring-jmap 1.0.0, tracked as calendaring-jmap issues #19 and #20).
- Parameters:
data – Parsed JSCalendar document (a dict, e.g. an Event object per RFC 8984, as produced by json.loads()).
salt – Optional salt for hashing. If None, generates random salt.
field_modes – Optional dict mapping JSCalendar field names to anonymization modes. Fields: TITLE, DESCRIPTION, LOCATIONS, PARTICIPANTS, KEYWORDS, LINKS, VIRTUALLOCATIONS, UID. Modes: keep, remove, randomize, replace.
- Returns:
New anonymized JSCalendar document (same dict shape).
- Raises:
TypeError – If data is not a dict, or salt is not bytes.
ValueError – If field_modes contains invalid fields/modes.
from icalendar_anonymizer import anonymize_jscal
event = {
"@type": "Event",
"uid": "meeting-with-dr-smith@example.com",
"title": "Dentist appointment with Dr. Smith",
"start": "2025-01-15T14:00:00",
"duration": "PT1H",
}
anonymized = anonymize_jscal(event)
field_modes works the same way it does for icalendar_anonymizer.anonymize(), but with JSCalendar’s own field names instead of iCalendar’s.
- icalendar_anonymizer.JSCAL_CONFIGURABLE_FIELDS = frozenset({'DESCRIPTION', 'KEYWORDS', 'LINKS', 'LOCATIONS', 'PARTICIPANTS', 'TITLE', 'UID', 'VIRTUALLOCATIONS'})#
Build an immutable unordered collection of unique elements.
# Keep the title, remove the locations
anonymized = anonymize_jscal(event, field_modes={
"TITLE": "keep",
"LOCATIONS": "remove",
})
jCal reuses iCalendar’s own field names instead.
Use icalendar_anonymizer.anonymize()’s CONFIGURABLE_FIELDS (SUMMARY, DESCRIPTION, and so on, see Configuration) with anonymize_jcal, not JSCalendar’s field names.
See Also#
API Reference - Complete API reference
Installation - Installation instructions
Contributing - Development guide