outlook-caldav-sync 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: outlook-caldav-sync
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A Python tool to sync calendar events from an Outlook JSON export to a CalDAV server
|
|
5
|
+
Author: Max Mehl
|
|
6
|
+
Author-email: Max Mehl <mail@mehl.mx>
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Requires-Dist: icalendar>=7.2.0,<7.3.0
|
|
12
|
+
Requires-Dist: caldav>=3.2.1,<3.3.0
|
|
13
|
+
Requires-Python: >=3.12, <4.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Outlook CalDAV Sync
|
|
17
|
+
|
|
18
|
+
[](https://pypi.org/project/outlook-caldav-sync/)
|
|
19
|
+
[](https://pypi.org/project/outlook-caldav-sync/)
|
|
20
|
+
|
|
21
|
+
A Python tool to sync calendar events from an **Outlook JSON export** to a **CalDAV server** (e.g. Nextcloud Calendar, Radicale, Baikal).
|
|
22
|
+
|
|
23
|
+
## Overview
|
|
24
|
+
|
|
25
|
+
Outlook does not speak CalDAV natively. If you need your Outlook calendar available in a CalDAV-based application — whether for privacy, interoperability, or self-hosting reasons — the usual path involves manual exports or fragile third-party connectors.
|
|
26
|
+
|
|
27
|
+
This tool bridges that gap: it reads the JSON format that Microsoft's Power Automate (or a custom script) can export from an Outlook calendar and pushes the events to any CalDAV-compatible server. Only events that have actually changed are uploaded, keeping the sync efficient. Events that have disappeared from Outlook can optionally be removed from the CalDAV calendar as well.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Sync Outlook JSON calendar exports to any CalDAV server
|
|
32
|
+
- Skip unchanged events using SHA-256 hash comparison — only changed events are uploaded
|
|
33
|
+
- Configurable time window: how many days into the past and future to sync
|
|
34
|
+
- **Filter events** by Outlook category or subject regex — excluded events are also removed from the remote calendar
|
|
35
|
+
- **Anonymize attendee e-mail addresses** (replace domain with `@invalid.invalid`)
|
|
36
|
+
- Mark all synced events as `CONFIDENTIAL` (private) on the CalDAV side
|
|
37
|
+
- Dry-run mode: preview what would happen without making any changes
|
|
38
|
+
- Force-update mode: re-upload all events even if they appear unchanged
|
|
39
|
+
- Reset remote: wipe all events from the CalDAV calendar before a fresh sync
|
|
40
|
+
- Optional log file in addition to stdout
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
Requires at least **Python 3.12**.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install outlook-caldav-sync
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The command `outlook-caldav-sync` is then available. Run `outlook-caldav-sync --help` for a full list of options.
|
|
51
|
+
|
|
52
|
+
For development installs, use [uv](https://github.com/astral-sh/uv):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv sync
|
|
56
|
+
uv run outlook-caldav-sync --help
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Outlook JSON export
|
|
60
|
+
|
|
61
|
+
The tool expects a JSON file containing an array of calendar event objects in the format that **Microsoft Power Automate** produces when reading from the Outlook calendar connector. Each event object should contain at least the following fields:
|
|
62
|
+
|
|
63
|
+
| Field | Description |
|
|
64
|
+
| ------------------- | ------------------------------------------------------- |
|
|
65
|
+
| `iCalUId` | Unique identifier of the event |
|
|
66
|
+
| `subject` | Event title |
|
|
67
|
+
| `startWithTimeZone` | ISO 8601 start datetime with timezone |
|
|
68
|
+
| `endWithTimeZone` | ISO 8601 end datetime with timezone |
|
|
69
|
+
| `location` | (optional) Location string |
|
|
70
|
+
| `organizer` | (optional) Semicolon-separated organizer email(s) |
|
|
71
|
+
| `requiredAttendees` | (optional) Semicolon-separated required attendee emails |
|
|
72
|
+
| `optionalAttendees` | (optional) Semicolon-separated optional attendee emails |
|
|
73
|
+
| `categories` | (optional) List of Outlook category strings |
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
Configuration is provided as a JSON file passed via `--config`. Example
|
|
78
|
+
(`config.json`):
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
dav_url: "https://nextcloud.example.com/remote.php/dav/",
|
|
83
|
+
dav_calendar: "my-calendar-id",
|
|
84
|
+
dav_username: "myuser",
|
|
85
|
+
dav_password: "secret",
|
|
86
|
+
dav_past_days: 3,
|
|
87
|
+
dav_future_days: 400,
|
|
88
|
+
log_file: "/var/log/outlook-caldav-sync.log",
|
|
89
|
+
anonymize_email: true,
|
|
90
|
+
private: false,
|
|
91
|
+
delete_missing: true,
|
|
92
|
+
nosync_categories: ["Reminder/Blocker"],
|
|
93
|
+
nosync_subject_regex: ["^Declined:"],
|
|
94
|
+
nosync_showas: ["free"]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| Key | Required | Default | Description |
|
|
99
|
+
| ---------------------- | -------- | -------- | --------------------------------------------------------- |
|
|
100
|
+
| `dav_url` | yes | — | Base URL of the CalDAV server |
|
|
101
|
+
| `dav_calendar` | yes | — | Calendar ID or name on the server |
|
|
102
|
+
| `dav_username` | yes | — | CalDAV username |
|
|
103
|
+
| `dav_password` | yes | — | CalDAV password |
|
|
104
|
+
| `dav_past_days` | no | `3` | Days into the past to include in the sync window |
|
|
105
|
+
| `dav_future_days` | no | `365` | Days into the future to include in the sync window |
|
|
106
|
+
| `log_file` | no | _(none)_ | Path to an optional log file (appended to) |
|
|
107
|
+
| `anonymize_email` | no | `false` | Replace attendee email domains with `@invalid.invalid` |
|
|
108
|
+
| `private` | no | `false` | Mark all synced events as `CONFIDENTIAL` |
|
|
109
|
+
| `delete_missing` | no | `true` | Delete CalDAV events absent from the Outlook JSON |
|
|
110
|
+
| `nosync_categories` | no | `[]` | Outlook categories whose events are skipped and removed |
|
|
111
|
+
| `nosync_subject_regex` | no | `[]` | Regex patterns; matching subjects are skipped and removed |
|
|
112
|
+
| `nosync_showas` | no | `[]` | `showAs` values whose events are skipped and removed (e.g. `free`, `tentative`) |
|
|
113
|
+
|
|
114
|
+
## Usage
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
outlook-caldav-sync -c config.json -i calendar-export.json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Full list of CLI options:
|
|
121
|
+
|
|
122
|
+
| Flag | Description |
|
|
123
|
+
| ------------------------ | ---------------------------------------------------------- |
|
|
124
|
+
| `-c`, `--config` | Path to the JSON configuration file (**required**) |
|
|
125
|
+
| `-i`, `--calendar-input` | Path to the Outlook JSON export file (**required**) |
|
|
126
|
+
| `-f`, `--force` | Re-upload all events even if they appear unchanged |
|
|
127
|
+
| `--dry` | Dry-run mode — no changes are written to the CalDAV server |
|
|
128
|
+
| `--reset-remote` | Interactively delete **all** events in the remote calendar |
|
|
129
|
+
| `-vv`, `--debug` | Enable DEBUG-level logging |
|
|
130
|
+
|
|
131
|
+
## Filtering / no-sync
|
|
132
|
+
|
|
133
|
+
Events can be excluded from syncing in two ways:
|
|
134
|
+
|
|
135
|
+
- **By category**: add Outlook category names to `nosync_categories`. Events with those categories are skipped during upload _and_ removed from the remote if they exist there.
|
|
136
|
+
- **By subject regex**: add regular expressions to `nosync_subject_regex`. Events whose subject matches any pattern are treated the same way.
|
|
137
|
+
- **By showAs value**: add Outlook `showAs` values to `nosync_showas`. Known values include `free`, `busy`, `oof`, `tentative`. Events matching are skipped and removed from the remote.
|
|
138
|
+
|
|
139
|
+
This is useful for blocking personal reminders, declined meetings, or any category you do not want mirrored to your CalDAV calendar.
|
|
140
|
+
|
|
141
|
+
## Logging
|
|
142
|
+
|
|
143
|
+
The tool logs to stdout by default. Set `log_file` in the config to additionally write to a file (append mode). Use `-vv` / `--debug` to enable verbose DEBUG output.
|
|
144
|
+
|
|
145
|
+
## Contribute and Development
|
|
146
|
+
|
|
147
|
+
Contributions are welcome! The development is easiest with [uv](https://github.com/astral-sh/uv): `uv sync` installs all dependencies and `uv run outlook-caldav-sync` runs the tool directly.
|
|
148
|
+
|
|
149
|
+
Run the test suite with `uv run pytest`, linting with `uv run ruff check`, and type checking with `uv run ty check`.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
Apache-2.0, Copyright Max Mehl. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Outlook CalDAV Sync
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/outlook-caldav-sync/)
|
|
4
|
+
[](https://pypi.org/project/outlook-caldav-sync/)
|
|
5
|
+
|
|
6
|
+
A Python tool to sync calendar events from an **Outlook JSON export** to a **CalDAV server** (e.g. Nextcloud Calendar, Radicale, Baikal).
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Outlook does not speak CalDAV natively. If you need your Outlook calendar available in a CalDAV-based application — whether for privacy, interoperability, or self-hosting reasons — the usual path involves manual exports or fragile third-party connectors.
|
|
11
|
+
|
|
12
|
+
This tool bridges that gap: it reads the JSON format that Microsoft's Power Automate (or a custom script) can export from an Outlook calendar and pushes the events to any CalDAV-compatible server. Only events that have actually changed are uploaded, keeping the sync efficient. Events that have disappeared from Outlook can optionally be removed from the CalDAV calendar as well.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Sync Outlook JSON calendar exports to any CalDAV server
|
|
17
|
+
- Skip unchanged events using SHA-256 hash comparison — only changed events are uploaded
|
|
18
|
+
- Configurable time window: how many days into the past and future to sync
|
|
19
|
+
- **Filter events** by Outlook category or subject regex — excluded events are also removed from the remote calendar
|
|
20
|
+
- **Anonymize attendee e-mail addresses** (replace domain with `@invalid.invalid`)
|
|
21
|
+
- Mark all synced events as `CONFIDENTIAL` (private) on the CalDAV side
|
|
22
|
+
- Dry-run mode: preview what would happen without making any changes
|
|
23
|
+
- Force-update mode: re-upload all events even if they appear unchanged
|
|
24
|
+
- Reset remote: wipe all events from the CalDAV calendar before a fresh sync
|
|
25
|
+
- Optional log file in addition to stdout
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Requires at least **Python 3.12**.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install outlook-caldav-sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The command `outlook-caldav-sync` is then available. Run `outlook-caldav-sync --help` for a full list of options.
|
|
36
|
+
|
|
37
|
+
For development installs, use [uv](https://github.com/astral-sh/uv):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv sync
|
|
41
|
+
uv run outlook-caldav-sync --help
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Outlook JSON export
|
|
45
|
+
|
|
46
|
+
The tool expects a JSON file containing an array of calendar event objects in the format that **Microsoft Power Automate** produces when reading from the Outlook calendar connector. Each event object should contain at least the following fields:
|
|
47
|
+
|
|
48
|
+
| Field | Description |
|
|
49
|
+
| ------------------- | ------------------------------------------------------- |
|
|
50
|
+
| `iCalUId` | Unique identifier of the event |
|
|
51
|
+
| `subject` | Event title |
|
|
52
|
+
| `startWithTimeZone` | ISO 8601 start datetime with timezone |
|
|
53
|
+
| `endWithTimeZone` | ISO 8601 end datetime with timezone |
|
|
54
|
+
| `location` | (optional) Location string |
|
|
55
|
+
| `organizer` | (optional) Semicolon-separated organizer email(s) |
|
|
56
|
+
| `requiredAttendees` | (optional) Semicolon-separated required attendee emails |
|
|
57
|
+
| `optionalAttendees` | (optional) Semicolon-separated optional attendee emails |
|
|
58
|
+
| `categories` | (optional) List of Outlook category strings |
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
Configuration is provided as a JSON file passed via `--config`. Example
|
|
63
|
+
(`config.json`):
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
dav_url: "https://nextcloud.example.com/remote.php/dav/",
|
|
68
|
+
dav_calendar: "my-calendar-id",
|
|
69
|
+
dav_username: "myuser",
|
|
70
|
+
dav_password: "secret",
|
|
71
|
+
dav_past_days: 3,
|
|
72
|
+
dav_future_days: 400,
|
|
73
|
+
log_file: "/var/log/outlook-caldav-sync.log",
|
|
74
|
+
anonymize_email: true,
|
|
75
|
+
private: false,
|
|
76
|
+
delete_missing: true,
|
|
77
|
+
nosync_categories: ["Reminder/Blocker"],
|
|
78
|
+
nosync_subject_regex: ["^Declined:"],
|
|
79
|
+
nosync_showas: ["free"]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Key | Required | Default | Description |
|
|
84
|
+
| ---------------------- | -------- | -------- | --------------------------------------------------------- |
|
|
85
|
+
| `dav_url` | yes | — | Base URL of the CalDAV server |
|
|
86
|
+
| `dav_calendar` | yes | — | Calendar ID or name on the server |
|
|
87
|
+
| `dav_username` | yes | — | CalDAV username |
|
|
88
|
+
| `dav_password` | yes | — | CalDAV password |
|
|
89
|
+
| `dav_past_days` | no | `3` | Days into the past to include in the sync window |
|
|
90
|
+
| `dav_future_days` | no | `365` | Days into the future to include in the sync window |
|
|
91
|
+
| `log_file` | no | _(none)_ | Path to an optional log file (appended to) |
|
|
92
|
+
| `anonymize_email` | no | `false` | Replace attendee email domains with `@invalid.invalid` |
|
|
93
|
+
| `private` | no | `false` | Mark all synced events as `CONFIDENTIAL` |
|
|
94
|
+
| `delete_missing` | no | `true` | Delete CalDAV events absent from the Outlook JSON |
|
|
95
|
+
| `nosync_categories` | no | `[]` | Outlook categories whose events are skipped and removed |
|
|
96
|
+
| `nosync_subject_regex` | no | `[]` | Regex patterns; matching subjects are skipped and removed |
|
|
97
|
+
| `nosync_showas` | no | `[]` | `showAs` values whose events are skipped and removed (e.g. `free`, `tentative`) |
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
outlook-caldav-sync -c config.json -i calendar-export.json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Full list of CLI options:
|
|
106
|
+
|
|
107
|
+
| Flag | Description |
|
|
108
|
+
| ------------------------ | ---------------------------------------------------------- |
|
|
109
|
+
| `-c`, `--config` | Path to the JSON configuration file (**required**) |
|
|
110
|
+
| `-i`, `--calendar-input` | Path to the Outlook JSON export file (**required**) |
|
|
111
|
+
| `-f`, `--force` | Re-upload all events even if they appear unchanged |
|
|
112
|
+
| `--dry` | Dry-run mode — no changes are written to the CalDAV server |
|
|
113
|
+
| `--reset-remote` | Interactively delete **all** events in the remote calendar |
|
|
114
|
+
| `-vv`, `--debug` | Enable DEBUG-level logging |
|
|
115
|
+
|
|
116
|
+
## Filtering / no-sync
|
|
117
|
+
|
|
118
|
+
Events can be excluded from syncing in two ways:
|
|
119
|
+
|
|
120
|
+
- **By category**: add Outlook category names to `nosync_categories`. Events with those categories are skipped during upload _and_ removed from the remote if they exist there.
|
|
121
|
+
- **By subject regex**: add regular expressions to `nosync_subject_regex`. Events whose subject matches any pattern are treated the same way.
|
|
122
|
+
- **By showAs value**: add Outlook `showAs` values to `nosync_showas`. Known values include `free`, `busy`, `oof`, `tentative`. Events matching are skipped and removed from the remote.
|
|
123
|
+
|
|
124
|
+
This is useful for blocking personal reminders, declined meetings, or any category you do not want mirrored to your CalDAV calendar.
|
|
125
|
+
|
|
126
|
+
## Logging
|
|
127
|
+
|
|
128
|
+
The tool logs to stdout by default. Set `log_file` in the config to additionally write to a file (append mode). Use `-vv` / `--debug` to enable verbose DEBUG output.
|
|
129
|
+
|
|
130
|
+
## Contribute and Development
|
|
131
|
+
|
|
132
|
+
Contributions are welcome! The development is easiest with [uv](https://github.com/astral-sh/uv): `uv sync` installs all dependencies and `uv run outlook-caldav-sync` runs the tool directly.
|
|
133
|
+
|
|
134
|
+
Run the test suite with `uv run pytest`, linting with `uv run ruff check`, and type checking with `uv run ty check`.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
Apache-2.0, Copyright Max Mehl. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Outlook CalDAV Sync package."""
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""Sync Outlook calendar with CalDAV server using ICS export and JSON import."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from caldav.calendarobjectresource import Event as CalDAVEvent
|
|
12
|
+
from caldav.collection import Calendar as CalDAVCalendar
|
|
13
|
+
from caldav.davclient import DAVClient
|
|
14
|
+
from icalendar import Calendar, Event
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def configure_logger(log_file: str = "", verbose: bool = False) -> logging.Logger:
|
|
18
|
+
"""Set logging options."""
|
|
19
|
+
log_handlers = [logging.StreamHandler()]
|
|
20
|
+
if log_file:
|
|
21
|
+
log_handlers.append(logging.FileHandler(log_file, mode="a", encoding="utf-8"))
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger()
|
|
24
|
+
logging.basicConfig(
|
|
25
|
+
format="[%(asctime)s] %(levelname)s: %(message)s",
|
|
26
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
27
|
+
handlers=log_handlers,
|
|
28
|
+
)
|
|
29
|
+
if verbose:
|
|
30
|
+
log.setLevel(logging.DEBUG)
|
|
31
|
+
else:
|
|
32
|
+
log.setLevel(logging.INFO)
|
|
33
|
+
|
|
34
|
+
return log
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ask_user_yes_no(prompt: str) -> bool:
|
|
38
|
+
"""Ask the user a yes/no question and return their choice as a boolean."""
|
|
39
|
+
while True:
|
|
40
|
+
choice = input(f"{prompt} (y/n): ").lower()
|
|
41
|
+
if choice == "y":
|
|
42
|
+
return True
|
|
43
|
+
if choice == "n":
|
|
44
|
+
return False
|
|
45
|
+
print("Invalid choice. Please enter 'y' or 'n'.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def hash_event(event: Event) -> str:
|
|
49
|
+
"""Generate a SHA256 hash of an event's relevant parts.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
event (Event): iCalendar event.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
str: Hex digest of the event hash.
|
|
56
|
+
"""
|
|
57
|
+
keys = ["summary", "dtstart", "dtend"]
|
|
58
|
+
content = "|".join(str(event.get(k)) for k in keys)
|
|
59
|
+
hashsum = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
60
|
+
logging.debug("Hash of event %s is %s", event.get("summary"), hashsum)
|
|
61
|
+
return hashsum
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_ics_events(ics_data: str) -> dict:
|
|
65
|
+
"""Parse .ics content and return a UID-to-event dictionary.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
ics_data (str): Raw .ics content.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
dict: Mapping of UID to iCalendar events.
|
|
72
|
+
"""
|
|
73
|
+
parsed = Calendar.from_ical(ics_data)
|
|
74
|
+
uid_map = {}
|
|
75
|
+
for component in parsed.walk():
|
|
76
|
+
if component.name == "VEVENT" and component.get("uid"):
|
|
77
|
+
uid_map[component.get("uid")] = component
|
|
78
|
+
return uid_map
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_short_info_from_event_dict(
|
|
82
|
+
event: dict[str, str], title_key: str = "subject", start_key: str = "start"
|
|
83
|
+
) -> tuple[str, str]:
|
|
84
|
+
"""Extract short info from a JSON event.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
event (dict): Event data from Outlook JSON export.
|
|
88
|
+
title_key (str): Key for the event title. Default is "subject".
|
|
89
|
+
start_key (str): Key for the event start time. Default is "start".
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
tuple: Tuple containing UID and description (summary + start time).
|
|
93
|
+
"""
|
|
94
|
+
uid = event.get("iCalUId") or event.get("UID", "")
|
|
95
|
+
subject = event.get(title_key, "")
|
|
96
|
+
start_time = event.get(start_key, "")
|
|
97
|
+
description = f"{subject} ({start_time})"
|
|
98
|
+
if not uid:
|
|
99
|
+
logging.warning("Event without UID: %s", description)
|
|
100
|
+
return "", description
|
|
101
|
+
return uid, description
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def add_attendees_to_event(event: Event, attendees: str, role: str, anonymize_email: bool) -> None:
|
|
105
|
+
"""Add attendees to an iCalendar event.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
event (Event): iCalendar event.
|
|
109
|
+
attendees (str): Semicolon-separated list of attendee email addresses.
|
|
110
|
+
role (str): Role of the attendees ("ORGANIZER", "REQ-PARTICIPANT", "OPT-PARTICIPANT").
|
|
111
|
+
anonymize_email (bool): Whether to convert email addresses to a name, e.g.
|
|
112
|
+
"john.doe@example.com" to "john.doe@invalid.invalid"
|
|
113
|
+
"""
|
|
114
|
+
for attendee in attendees.split(";"):
|
|
115
|
+
if attendee := attendee.strip():
|
|
116
|
+
parameters = {}
|
|
117
|
+
# If the attendee is an email address, format it correctly
|
|
118
|
+
if "@" in attendee:
|
|
119
|
+
if anonymize_email:
|
|
120
|
+
# Replace domain part for name-only format
|
|
121
|
+
attendee_name = attendee.split("@")[0]
|
|
122
|
+
parameters["CN"] = attendee_name
|
|
123
|
+
attendee = attendee_name + "@invalid.invalid" # noqa: PLW2901
|
|
124
|
+
else:
|
|
125
|
+
# Ensure email format for iCalendar
|
|
126
|
+
attendee = f"mailto:{attendee}" # noqa: PLW2901
|
|
127
|
+
# Add attendee to the event. If the role is ORGANIZER, use the organizer field;
|
|
128
|
+
# otherwise, use attendee
|
|
129
|
+
if role == "ORGANIZER":
|
|
130
|
+
event.add("organizer", attendee, parameters=parameters)
|
|
131
|
+
else:
|
|
132
|
+
parameters["ROLE"] = role
|
|
133
|
+
event.add(
|
|
134
|
+
name="attendee",
|
|
135
|
+
value=attendee,
|
|
136
|
+
parameters=parameters,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def create_icalendar_event(
|
|
141
|
+
json_event: dict[str, str], anonymize_email: bool, private: bool
|
|
142
|
+
) -> Event:
|
|
143
|
+
"""Convert a JSON calendar entry to an iCalendar VEVENT.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
json_event (dict): Event data from Outlook JSON export.
|
|
147
|
+
anonymize_email (bool): Whether to anonymize email addresses.
|
|
148
|
+
private (bool): Whether the calendar event is private.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Event: Constructed iCalendar event.
|
|
152
|
+
"""
|
|
153
|
+
event = Event()
|
|
154
|
+
event.add("uid", json_event["iCalUId"])
|
|
155
|
+
event.add("summary", json_event["subject"])
|
|
156
|
+
event.add("dtstart", datetime.fromisoformat(json_event["startWithTimeZone"]).astimezone(UTC))
|
|
157
|
+
event.add("dtend", datetime.fromisoformat(json_event["endWithTimeZone"]).astimezone(UTC))
|
|
158
|
+
event.add("location", json_event.get("location", ""))
|
|
159
|
+
|
|
160
|
+
# Add organizer and attendees
|
|
161
|
+
add_attendees_to_event(
|
|
162
|
+
event,
|
|
163
|
+
json_event.get("organizer", ""),
|
|
164
|
+
"ORGANIZER",
|
|
165
|
+
anonymize_email=anonymize_email,
|
|
166
|
+
)
|
|
167
|
+
add_attendees_to_event(
|
|
168
|
+
event,
|
|
169
|
+
json_event.get("requiredAttendees", ""),
|
|
170
|
+
"REQ-PARTICIPANT",
|
|
171
|
+
anonymize_email=anonymize_email,
|
|
172
|
+
)
|
|
173
|
+
add_attendees_to_event(
|
|
174
|
+
event,
|
|
175
|
+
json_event.get("optionalAttendees", ""),
|
|
176
|
+
"OPT-PARTICIPANT",
|
|
177
|
+
anonymize_email=anonymize_email,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Set classification based on private setting
|
|
181
|
+
if private:
|
|
182
|
+
event.add("class", "CONFIDENTIAL")
|
|
183
|
+
|
|
184
|
+
return event
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def connect_to_caldav(config: dict) -> CalDAVCalendar:
|
|
188
|
+
"""Connect to the CalDAV server and return the calendar object.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
config (dict): Configuration dictionary.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
caldav.objects.Calendar: CalDAV calendar object.
|
|
195
|
+
"""
|
|
196
|
+
client = DAVClient(
|
|
197
|
+
url=config.get("dav_url", ""),
|
|
198
|
+
username=config.get("dav_username", ""),
|
|
199
|
+
password=config.get("dav_password", ""),
|
|
200
|
+
)
|
|
201
|
+
principal = client.principal()
|
|
202
|
+
return principal.calendar(cal_id=config.get("dav_calendar", ""))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_existing_event_hashes(
|
|
206
|
+
calendar: CalDAVCalendar, past: datetime, future: datetime
|
|
207
|
+
) -> dict[str, str]:
|
|
208
|
+
"""Fetch and hash existing remote events.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
calendar (Calendar): CalDAV calendar object.
|
|
212
|
+
past (datetime): Start of time window.
|
|
213
|
+
future (datetime): End of time window.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
dict: Mapping of UID to hash for existing events.
|
|
217
|
+
"""
|
|
218
|
+
logging.info("Fetching events from CalDAV server...")
|
|
219
|
+
remote_events = cast(
|
|
220
|
+
"list[CalDAVEvent]", calendar.search(comp_class=CalDAVEvent, start=past, end=future)
|
|
221
|
+
)
|
|
222
|
+
hashes = {}
|
|
223
|
+
|
|
224
|
+
for e in remote_events:
|
|
225
|
+
try:
|
|
226
|
+
ical = e.icalendar_component
|
|
227
|
+
if ical.name == "VEVENT" and ical.get("UID"):
|
|
228
|
+
uid = str(ical.get("UID"))
|
|
229
|
+
hashes[uid] = hash_event(ical)
|
|
230
|
+
except Exception as err: # noqa: BLE001
|
|
231
|
+
logging.warning("Skipping broken event %s: %s", e, err)
|
|
232
|
+
|
|
233
|
+
logging.info("Found %d existing remote events", len(hashes))
|
|
234
|
+
return hashes
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def caldav_event_to_dict(event: Event) -> dict[str, str]:
|
|
238
|
+
"""Converts most important elements of a VEVENT to a plain dictionary with string values."""
|
|
239
|
+
return {
|
|
240
|
+
"SUMMARY": str(event.get("SUMMARY")),
|
|
241
|
+
"DTSTART": str(event.get("DTSTART").dt),
|
|
242
|
+
"DTEND": str(event.get("DTEND").dt),
|
|
243
|
+
"DTSTAMP": str(event.get("DTSTAMP").dt),
|
|
244
|
+
"UID": str(event.get("UID")),
|
|
245
|
+
"LOCATION": str(event.get("LOCATION")),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def delete_missing_events( # noqa: C901
|
|
250
|
+
calendar: CalDAVCalendar,
|
|
251
|
+
outlook_entries: list[dict[str, str]],
|
|
252
|
+
delete_enabled: bool = True,
|
|
253
|
+
nosync_uids: list[str] | None = None,
|
|
254
|
+
dry: bool = False,
|
|
255
|
+
) -> int:
|
|
256
|
+
"""Delete CalDAV events that are not in Outlook JSON but within the JSON date range.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
calendar (CalDAVCalendar): CalDAV calendar object.
|
|
260
|
+
outlook_entries (list): List of Outlook event dictionaries.
|
|
261
|
+
delete_enabled (bool): Whether deletion is enabled.
|
|
262
|
+
nosync_uids (list): List of UIDs that will be deleted if present remotely.
|
|
263
|
+
dry (bool): Dry run mode; if True, no deletions are performed.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
int: Number of deleted events.
|
|
267
|
+
"""
|
|
268
|
+
# Set default for nosync_uids
|
|
269
|
+
if nosync_uids is None:
|
|
270
|
+
nosync_uids = []
|
|
271
|
+
|
|
272
|
+
if not delete_enabled:
|
|
273
|
+
logging.info("Deletion of missing events is disabled by config.")
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
if not outlook_entries:
|
|
277
|
+
logging.warning("No Outlook entries loaded; skipping deletion check.")
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
# Determine JSON event date range
|
|
281
|
+
json_start_times = [
|
|
282
|
+
datetime.fromisoformat(e.get("startWithTimeZone", "")).astimezone(UTC)
|
|
283
|
+
for e in outlook_entries
|
|
284
|
+
if "startWithTimeZone" in e
|
|
285
|
+
]
|
|
286
|
+
json_start_min = min(json_start_times)
|
|
287
|
+
json_start_max = max(json_start_times)
|
|
288
|
+
|
|
289
|
+
# Collect UIDs from Outlook
|
|
290
|
+
json_uids = {e.get("iCalUId", "") for e in outlook_entries if "iCalUId" in e}
|
|
291
|
+
|
|
292
|
+
# Find CalDAV events in the same time window
|
|
293
|
+
remote_events = cast(
|
|
294
|
+
"list[CalDAVEvent]",
|
|
295
|
+
calendar.search(
|
|
296
|
+
comp_class=CalDAVEvent,
|
|
297
|
+
start=json_start_min,
|
|
298
|
+
end=json_start_max,
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
deleted = 0
|
|
303
|
+
for e in remote_events:
|
|
304
|
+
try:
|
|
305
|
+
ical: dict[str, str] = caldav_event_to_dict(e.icalendar_component)
|
|
306
|
+
except Exception as err: # noqa: BLE001
|
|
307
|
+
logging.warning("Skipping broken event %s: %s", e, err)
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
uid, description = get_short_info_from_event_dict(
|
|
311
|
+
event=ical, title_key="SUMMARY", start_key="DTSTART"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
if uid:
|
|
316
|
+
if uid not in json_uids:
|
|
317
|
+
if not dry:
|
|
318
|
+
e.delete()
|
|
319
|
+
logging.info("Deleted stale event: %s (UID: %s)", description, uid)
|
|
320
|
+
deleted += 1
|
|
321
|
+
elif uid in nosync_uids:
|
|
322
|
+
if not dry:
|
|
323
|
+
e.delete()
|
|
324
|
+
logging.info("Deleted no-sync event: %s (UID: %s)", description, uid)
|
|
325
|
+
deleted += 1
|
|
326
|
+
except Exception as err: # noqa: BLE001
|
|
327
|
+
logging.warning("Failed to delete event %s (UID: %s): %s", description, uid, err)
|
|
328
|
+
|
|
329
|
+
logging.debug("Deletion pass complete. Deleted %d events.", deleted)
|
|
330
|
+
|
|
331
|
+
return deleted
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def sync_events( # pylint: disable=too-many-locals
|
|
335
|
+
config: dict,
|
|
336
|
+
calendar: CalDAVCalendar,
|
|
337
|
+
existing_hashes: dict[str, str],
|
|
338
|
+
dry: bool = False,
|
|
339
|
+
force: bool = False,
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Sync Outlook JSON events to the CalDAV server.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
config (dict): Configuration dictionary.
|
|
345
|
+
calendar (Calendar): CalDAV calendar object.
|
|
346
|
+
existing_hashes (dict): Existing event hashes.
|
|
347
|
+
dry (bool): Dry run mode; if True, no changes are made.
|
|
348
|
+
force (bool): Force update of events even if unchanged.
|
|
349
|
+
"""
|
|
350
|
+
created, updated, skipped, deleted = 0, 0, 0, 0
|
|
351
|
+
nosync_uids: list[str] = []
|
|
352
|
+
|
|
353
|
+
# Load Outlook JSON data
|
|
354
|
+
with open(config.get("outlook_calendar_file", ""), encoding="utf-8") as f:
|
|
355
|
+
outlook_entries: list[dict] = json.load(f)
|
|
356
|
+
logging.info("Processing %d events from Outlook JSON", len(outlook_entries))
|
|
357
|
+
|
|
358
|
+
for item in outlook_entries:
|
|
359
|
+
uid, description = get_short_info_from_event_dict(item)
|
|
360
|
+
|
|
361
|
+
# Check for no-sync categories
|
|
362
|
+
categories: list[str] = item.get("categories", [])
|
|
363
|
+
if any(cat in config.get("nosync_categories", []) for cat in categories):
|
|
364
|
+
uid, description = get_short_info_from_event_dict(item)
|
|
365
|
+
logging.debug(
|
|
366
|
+
"Skipping event in categories that must not be synced: %s (UID: %s)",
|
|
367
|
+
description,
|
|
368
|
+
uid,
|
|
369
|
+
)
|
|
370
|
+
nosync_uids.append(uid)
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
# Check for no-sync showAs values
|
|
374
|
+
show_as: str = item.get("showAs", "")
|
|
375
|
+
if show_as in config.get("nosync_showas", []):
|
|
376
|
+
logging.debug(
|
|
377
|
+
"Skipping event with showAs '%s' that must not be synced: %s (UID: %s)",
|
|
378
|
+
show_as,
|
|
379
|
+
description,
|
|
380
|
+
uid,
|
|
381
|
+
)
|
|
382
|
+
nosync_uids.append(uid)
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
# Check for no-sync subject regex
|
|
386
|
+
subject: str = item.get("subject", "")
|
|
387
|
+
if any(
|
|
388
|
+
regex for regex in config.get("nosync_subject_regex", []) if re.search(regex, subject)
|
|
389
|
+
):
|
|
390
|
+
uid, description = get_short_info_from_event_dict(item)
|
|
391
|
+
logging.debug(
|
|
392
|
+
"Skipping event with subject that must not be synced: %s (UID: %s)",
|
|
393
|
+
description,
|
|
394
|
+
uid,
|
|
395
|
+
)
|
|
396
|
+
nosync_uids.append(uid)
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
# Skip events without UID
|
|
400
|
+
if not uid:
|
|
401
|
+
logging.warning("Skipping event without UID: %s", item)
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
new_event: Event = create_icalendar_event(
|
|
405
|
+
item,
|
|
406
|
+
anonymize_email=config.get("anonymize_email", False),
|
|
407
|
+
private=config.get("private", False),
|
|
408
|
+
)
|
|
409
|
+
new_hash = hash_event(new_event)
|
|
410
|
+
|
|
411
|
+
# Check if event is unchanged. If so, skip it unless force is True
|
|
412
|
+
if uid in existing_hashes and existing_hashes[uid] == new_hash and not force:
|
|
413
|
+
logging.debug("Skipping unchanged event: %s", description)
|
|
414
|
+
skipped += 1
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
if not dry:
|
|
419
|
+
calendar.add_event(new_event.to_ical().decode("utf-8"))
|
|
420
|
+
if uid in existing_hashes:
|
|
421
|
+
logging.info("Updated event: %s", description)
|
|
422
|
+
updated += 1
|
|
423
|
+
else:
|
|
424
|
+
logging.info("Created event: %s", description)
|
|
425
|
+
created += 1
|
|
426
|
+
except Exception:
|
|
427
|
+
logging.exception("Failed to upload event %s (UID: %s)", description, uid)
|
|
428
|
+
|
|
429
|
+
# Optional deletion step
|
|
430
|
+
deleted = delete_missing_events(
|
|
431
|
+
calendar=calendar,
|
|
432
|
+
outlook_entries=outlook_entries,
|
|
433
|
+
delete_enabled=config.get("delete_missing", True),
|
|
434
|
+
nosync_uids=nosync_uids,
|
|
435
|
+
dry=dry,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
logging.info(
|
|
439
|
+
"Done. Created: %d, Updated: %d, Skipped: %d, No-sync: %s, Deleted: %d",
|
|
440
|
+
created,
|
|
441
|
+
updated,
|
|
442
|
+
skipped,
|
|
443
|
+
len(nosync_uids),
|
|
444
|
+
deleted,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def main() -> None:
|
|
449
|
+
"""Main entry point."""
|
|
450
|
+
parser = argparse.ArgumentParser(description="Sync Outlook JSON calendar to CalDAV")
|
|
451
|
+
parser.add_argument(
|
|
452
|
+
"-c",
|
|
453
|
+
"--config",
|
|
454
|
+
type=str,
|
|
455
|
+
required=True,
|
|
456
|
+
help="Path to the configuration file (default: config.json)",
|
|
457
|
+
)
|
|
458
|
+
parser.add_argument(
|
|
459
|
+
"-i",
|
|
460
|
+
"--calendar-input",
|
|
461
|
+
type=str,
|
|
462
|
+
required=True,
|
|
463
|
+
help="Path to the Outlook JSON calendar export file",
|
|
464
|
+
)
|
|
465
|
+
parser.add_argument(
|
|
466
|
+
"-f",
|
|
467
|
+
"--force",
|
|
468
|
+
action="store_true",
|
|
469
|
+
help="Force update of remote events even if they seem to be equal",
|
|
470
|
+
)
|
|
471
|
+
parser.add_argument(
|
|
472
|
+
"--reset-remote",
|
|
473
|
+
action="store_true",
|
|
474
|
+
help="Force deletion of all remote events in the destination calendar (use with caution!)",
|
|
475
|
+
)
|
|
476
|
+
parser.add_argument("-vv", "--debug", action="store_true", help="Enable DEBUG logging")
|
|
477
|
+
parser.add_argument("--dry", action="store_true", help="Dry run mode (no changes made)")
|
|
478
|
+
args = parser.parse_args()
|
|
479
|
+
|
|
480
|
+
# --------------------------------------------------
|
|
481
|
+
# HANDLE CONFIGURATION AND ARGUMENTS
|
|
482
|
+
# --------------------------------------------------
|
|
483
|
+
# Load configuration file
|
|
484
|
+
with open(args.config, encoding="utf-8") as f:
|
|
485
|
+
config = json.load(f)
|
|
486
|
+
|
|
487
|
+
# Update config with command line arguments
|
|
488
|
+
config["outlook_calendar_file"] = args.calendar_input
|
|
489
|
+
|
|
490
|
+
# Configure logging (file + console)
|
|
491
|
+
configure_logger(config.get("log_file", ""), args.debug)
|
|
492
|
+
|
|
493
|
+
# --------------------------------------------------
|
|
494
|
+
# RESET REMOTE CALENDAR
|
|
495
|
+
# --------------------------------------------------
|
|
496
|
+
if args.reset_remote:
|
|
497
|
+
if ask_user_yes_no(
|
|
498
|
+
"Do you really want to DELETE ALL EVENTS in the remote calendar? "
|
|
499
|
+
"This action cannot be undone."
|
|
500
|
+
):
|
|
501
|
+
calendar = connect_to_caldav(config)
|
|
502
|
+
remote_events = cast("list[CalDAVEvent]", calendar.events())
|
|
503
|
+
for e in remote_events:
|
|
504
|
+
try:
|
|
505
|
+
uid, description = get_short_info_from_event_dict(
|
|
506
|
+
event=caldav_event_to_dict(e.icalendar_component),
|
|
507
|
+
title_key="SUMMARY",
|
|
508
|
+
start_key="DTSTART",
|
|
509
|
+
)
|
|
510
|
+
if not args.dry:
|
|
511
|
+
e.delete()
|
|
512
|
+
logging.info("Deleted event: %s (UID: %s)", description, uid)
|
|
513
|
+
except Exception as err: # noqa: BLE001
|
|
514
|
+
logging.warning("Skipping broken event %s: %s", e, err)
|
|
515
|
+
logging.info("Remote calendar reset complete.")
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
# --------------------------------------------------
|
|
519
|
+
# SYNC CALENDAR
|
|
520
|
+
# --------------------------------------------------
|
|
521
|
+
# Set time window, based on midnight UTC today
|
|
522
|
+
now = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
523
|
+
past = now - timedelta(days=config.get("dav_past_days", 3))
|
|
524
|
+
future = now + timedelta(days=config.get("dav_future_days", 365))
|
|
525
|
+
logging.info("Sync time window: %s to %s", past.isoformat(), future.isoformat())
|
|
526
|
+
|
|
527
|
+
calendar = connect_to_caldav(config)
|
|
528
|
+
existing_hashes = get_existing_event_hashes(calendar, past, future)
|
|
529
|
+
sync_events(config, calendar, existing_hashes, dry=args.dry, force=args.force)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
if __name__ == "__main__":
|
|
533
|
+
main()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "outlook-caldav-sync"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "A Python tool to sync calendar events from an Outlook JSON export to a CalDAV server"
|
|
5
|
+
authors = [{ name = "Max Mehl", email = "mail@mehl.mx" }]
|
|
6
|
+
requires-python = ">=3.12 ,<4.0"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Programming Language :: Python :: 3",
|
|
10
|
+
"Programming Language :: Python :: 3.12",
|
|
11
|
+
"Programming Language :: Python :: 3.13",
|
|
12
|
+
"Programming Language :: Python :: 3.14",
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"icalendar>=7.2.0,<7.3.0",
|
|
16
|
+
"caldav>=3.2.1,<3.3.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
outlook-caldav-sync = "outlook_caldav_sync.main:main"
|
|
21
|
+
|
|
22
|
+
[tool.uv.build-backend]
|
|
23
|
+
module-name = ["outlook_caldav_sync"]
|
|
24
|
+
module-root = ""
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["uv_build>=0.11.6,<0.12.0"]
|
|
28
|
+
build-backend = "uv_build"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"ruff>=0.15.10",
|
|
33
|
+
"ty>=0.0.29",
|
|
34
|
+
"types-requests>=2.32.4.20250913,<3",
|
|
35
|
+
"pytest>=9.1.1,<10",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.uv]
|
|
39
|
+
default-groups = "all"
|
|
40
|
+
|
|
41
|
+
# FORMATTING and LINTING settings
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["ALL"]
|
|
47
|
+
ignore = [
|
|
48
|
+
"T",
|
|
49
|
+
"FBT",
|
|
50
|
+
"LOG015",
|
|
51
|
+
"COM812",
|
|
52
|
+
"ERA001",
|
|
53
|
+
"S310",
|
|
54
|
+
"D205",
|
|
55
|
+
"D212",
|
|
56
|
+
"PTH123",
|
|
57
|
+
]
|
|
58
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
59
|
+
"tests/*" = ["S101", "PLR", "PT011", "ANN001", "PT019"]
|
|
60
|
+
[tool.ruff.lint.pydocstyle]
|
|
61
|
+
convention = "google"
|
|
62
|
+
|
|
63
|
+
# TY settings
|
|
64
|
+
[tool.ty.src]
|
|
65
|
+
include = ["outlook_caldav_sync"]
|