calque 0.1.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,21 @@
1
+ name: Build
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ # Cancel superseded runs on the same ref to save runner time.
9
+ concurrency:
10
+ group: ci-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ gate:
18
+ uses: ./.github/workflows/gate.yml
19
+ with:
20
+ sonar: true
21
+ secrets: inherit
@@ -0,0 +1,50 @@
1
+ name: Gate
2
+
3
+ # Reusable quality gate: lint, format, types, tests. Called by build.yml on
4
+ # every push/PR and by publish.yml before a release to PyPI.
5
+ on:
6
+ workflow_call:
7
+ inputs:
8
+ sonar:
9
+ description: Run the SonarQube Cloud scan (requires the SONAR_TOKEN secret).
10
+ type: boolean
11
+ default: false
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+ gate:
18
+ runs-on: macos-latest
19
+ steps:
20
+ - name: Checkout
21
+ uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
22
+ with:
23
+ fetch-depth: 0
24
+
25
+ - name: Set up uv
26
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
27
+ with:
28
+ python-version: "3.14"
29
+ enable-cache: true
30
+
31
+ - name: Install dependencies
32
+ run: uv sync --locked
33
+
34
+ - name: Lint
35
+ run: uv run ruff check .
36
+
37
+ - name: Format
38
+ run: uv run ruff format --check .
39
+
40
+ - name: Types
41
+ run: uv run mypy .
42
+
43
+ - name: Test with coverage
44
+ run: uv run pytest --cov=calque --cov-report=xml --cov-report=term-missing
45
+
46
+ - name: SonarQube scan
47
+ if: inputs.sonar
48
+ uses: SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e # v8.2.0
49
+ env:
50
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
@@ -0,0 +1,37 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ gate:
12
+ uses: ./.github/workflows/gate.yml
13
+
14
+ publish:
15
+ needs: gate
16
+ runs-on: ubuntu-latest
17
+ # The environment lets you require a manual approval before publishing;
18
+ # configure reviewers under Settings -> Environments -> pypi.
19
+ environment:
20
+ name: pypi
21
+ url: https://pypi.org/project/calque/
22
+ permissions:
23
+ id-token: write
24
+ steps:
25
+ - name: Checkout
26
+ uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
27
+
28
+ - name: Set up uv
29
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
30
+ with:
31
+ python-version: "3.14"
32
+
33
+ - name: Build
34
+ run: uv build
35
+
36
+ - name: Publish to PyPI
37
+ run: uv publish --trusted-publishing always
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+
8
+ # Coverge
9
+ .coverage
10
+ htmlcov/
11
+
12
+ # Editor and tooling
13
+ .vscode/
14
+ .claude/
15
+
16
+ # Virtual environment
17
+ .venv/
18
+
19
+ # macOS
20
+ .DS_Store
@@ -0,0 +1 @@
1
+ 3.14
calque-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The calque authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
calque-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,275 @@
1
+ Metadata-Version: 2.4
2
+ Name: calque
3
+ Version: 0.1.0
4
+ Summary: Mirrors events between calendars as anonymized busy blocks, using your local macOS calendar store (EventKit).
5
+ Project-URL: Homepage, https://github.com/agilezebra/calque
6
+ Project-URL: Repository, https://github.com/agilezebra/calque
7
+ Project-URL: Issues, https://github.com/agilezebra/calque/issues
8
+ Author-email: Graham Jones <graham@agilezebra.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: availability,busy,calendar,eventkit,macos
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Environment :: MacOS X
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: Operating System :: MacOS :: MacOS X
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Office/Business :: Scheduling
22
+ Requires-Python: >=3.13
23
+ Requires-Dist: pyobjc-framework-eventkit>=12.2
24
+ Description-Content-Type: text/markdown
25
+
26
+ # calque
27
+
28
+ [![Build](https://github.com/agilezebra/calque/actions/workflows/build.yml/badge.svg)](https://github.com/agilezebra/calque/actions/workflows/build.yml)
29
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=agilezebra_calque&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=agilezebra_calque)
30
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=agilezebra_calque&metric=coverage)](https://sonarcloud.io/summary/new_code?id=agilezebra_calque)
31
+
32
+ Mirrors events between calendars as **anonymized busy blocks**, using your **local macOS calendar store**.
33
+ Built for the case where your availability has to show up on calendars in different tenants (e.g. keeping a client Exchange calendar and a your company Google calendar in sync) without exposing undesirable event detail.
34
+
35
+ By design, this is **not** a cloud service. Because calendars are already subscribed in macOS, calque talks only to the local EventKit store. There is **no authentication** required against any of the calendar providers, and no use of their APIs. This makes it suitable for use with **government clients** and other sensitive tenants that **forbid third-party tool** access.
36
+
37
+ ## What it does
38
+
39
+ - Reads events from the calendars you specify, in a configurable time window around the current time. The **first** calendar
40
+ you list is the **primary**; the rest are mirrored against it.
41
+ - Mirrors only the events you've **accepted** (tentative, declined, and unanswered are skipped by default).
42
+ The set of statuses that count as busy is configurable.
43
+ - Drops any event an **exclusion rule** rejects: by title pattern, time, or because the target
44
+ calendar is already busy over that slot (see [Exclusions](#exclusions)).
45
+ - Writes one block per mirrored event with a **templated title**
46
+ (default `Busy ({account} calendar)`); no attendees, no joining into, , no location, no notes beyond a hidden opaque marker.
47
+ - **Collects then fans out**: each calendar's events are mirrored into the primary, so the primary
48
+ acts as a hub and holds the full picture, and the primary is then mirrored back out to each of the others, so time
49
+ you've committed with one client shows as busy (anonymized) to the rest. Pass `--mute <CALENDAR>` to
50
+ keep a calendar from *receiving* blocks while still reading it as a source.
51
+ - Re-runs are **idempotent**: each block carries a hidden marker linking it to its source event
52
+ and origin calendar, so calque updates times that moved, removes blocks whose source disappeared,
53
+ and never mirrors a block back to the calendar it came from.
54
+
55
+ ## Getting started
56
+
57
+ ### Prerequisites
58
+
59
+ - macOS
60
+ - every calendar you want to sync already subscribed in Calendar.app.
61
+ - Python 3.13 or later.
62
+ - Something to install Python packages: calque is a standard Python package — `uv`, `pipx` or `pip` all install it.
63
+
64
+ [`pipx`](https://pipx.pypa.io/) is recommended for a command-line tool, as it puts calque in its
65
+ own isolated environment and on your `PATH`:
66
+
67
+ ```sh
68
+ pipx install calque
69
+ ```
70
+
71
+ To install from a checkout of this repository, point any of them at the directory, e.g. `uv tool install .`
72
+
73
+ ### Grant Access and View Calendars
74
+
75
+ EventKit is gated by macOS privacy controls. Run calque with `--list-calendars` once interactively: the first
76
+ run triggers a permission prompt; you grant access via a Ui dialog which is thereafter managed under
77
+ **System Settings → Privacy & Security → Calendars** for the app running calque.
78
+ In the first instance, this is your terminal. If you install calque as a `launchd` agent, the agent will not run under
79
+ the the terminal and will ask separately for permission.
80
+ Once granted, you can list every calendar's **account-qualified name** with:
81
+
82
+ ```sh
83
+ calque --list-calendars
84
+ ```
85
+
86
+ The output is every calendar's **account-qualified name** (`Account.Calendar`). This is an example of the output you might see:
87
+
88
+ ```text
89
+ Acme Consulting.Acme Consulting
90
+ Acme Consulting.Holidays in the United Kingdom
91
+ MoM.Calendar
92
+ MoSW.Birthdays
93
+ MoSW.Calendar
94
+ MoSW.United Kingdom holidays
95
+ Other.Birthdays
96
+ Subscribed Calendars.UK Holidays
97
+ iCloud.Home
98
+ iCloud.Personal
99
+ ```
100
+
101
+ These qualified names are the form calque accepts: use them exactly as printed wherever a
102
+ calendar is named, quoting any that contain spaces.
103
+
104
+ ### Dry Run
105
+
106
+ Always dry-run first: calque logs the exact plan (what it would create, update, and delete in each
107
+ calendar) and writes nothing. The first calendar is the primary; the rest are mirrored
108
+ against it.
109
+
110
+ ```sh
111
+ calque "Acme Consulting.Acme Consulting" "MoSW.Calendar" "MoM.Calendar" "iCloud.Home" \
112
+ --title-to "Acme Consulting.Acme Consulting" "{account}: {title}" \
113
+ --title-from "iCloud.Home" "Busy" \
114
+ --mute "iCloud.Home" \
115
+ --dry-run
116
+ ```
117
+
118
+ This is a typical consultant setup. `Acme Consulting.Acme Consulting` is your company calendar (the
119
+ hub), `MoSW.Calendar` and `MoM.Calendar` are two client tenants, and `iCloud.Home` is your personal
120
+ calendar. It mirrors all three into your company calendar, then fans your company calendar's combined availability back out to the two clients:
121
+
122
+ - `--title-to "Acme Consulting.Acme Consulting" "{account}: {title}"`: in your *own* company
123
+ diary, show the real source account and subject, so you and your colleagues can see what each block actually is and where you are busy.
124
+ - `--title-from "iCloud.Home" "Busy"`: anything originating from your **personal** calendar is
125
+ labelled just `Busy` wherever it lands, so home detail never leaks — not even into your company
126
+ diary, which the `--title-to` rule would otherwise make detailed (a source override wins over a
127
+ target one).
128
+ - Client calendars (`MoSW.Calendar`, `MoM.Calendar`) see only the default opaque `Busy (Acme Consulting calendar)` blocks, so neither sees the other's events, your home detail, or anything beyond the fact that you're busy.
129
+ - `--mute "iCloud.Home"` — read your personal calendar as a source but never write blocks into it;
130
+ it stays untouched.
131
+
132
+ The two clients (`MoSW.Calendar`, `MoM.Calendar`) receive only the default opaque `Busy (…)` blocks,
133
+ so neither sees the other's events, your home detail, or anything beyond the fact that you're busy.
134
+
135
+ When the plan looks right, drop `--dry-run` to apply it once, or install it as an agent to keep both
136
+ sides in sync (next step).
137
+
138
+ ### Install Agent
139
+
140
+ Schedule calque to run automatically with `launchd`. calque installs its own lanunchd agent:
141
+ pass `--install SECONDS` with the same arguments you previously (dry-)ran, and it writes and loads the
142
+ agent for you (no plist to edit):
143
+
144
+ When the agent first runs from launchd, it will prompt for calendar access via a system dialog.
145
+ This will show up as `python3.x` this time, instead of terminal.
146
+ You will also get a notification of Managed Login Items Added to indicate that the agent has been installed; this will show as `calque` in System Settings → General → Login Items.
147
+
148
+ ```sh
149
+ calque "Acme Consulting.Acme Consulting" "MoSW.Calendar" "MoM.Calendar" iCloud.Home \
150
+ --title-to "Acme Consulting.Acme Consulting" "{account}: {title}" \
151
+ --title-from "iCloud.Home" "Busy" \
152
+ --mute "iCloud.Home" \
153
+ --install 180 # run every 3 minutes
154
+ ```
155
+
156
+ Remove it with:
157
+
158
+ ```sh
159
+ calque --uninstall
160
+ ```
161
+
162
+ ## Options
163
+
164
+ - **`calendars`** (positional, two or more) — the calendars to sync, each a qualified name from
165
+ `--list-calendars`. The **first** is your primary calendar: every other is mirrored into it,
166
+ and it is then mirrored back out to each.
167
+ - **`--title TEMPLATE`** — default title template for every mirror block (default
168
+ `Busy ({account} calendar)`). `{field}` placeholders are filled from the source event; see
169
+ [Titles](#titles).
170
+ - **`--title-to NAME TEMPLATE`** — title template used when writing **into** NAME's calendar.
171
+ Repeatable.
172
+ - **`--title-from NAME TEMPLATE`** — title template for events read **from** NAME's calendar,
173
+ wherever they land; **wins over** `--title-to`. Repeatable.
174
+ - **`--mute NAME …`** — calendars to read as a source but never write blocks into.
175
+ - **`--lookback DAYS`** — days before now to keep mirrored (default `1`). With `--cleanup`, this
176
+ instead bounds the window within which finished blocks are removed.
177
+ - **`--lookahead DAYS`** — days after now to mirror (default `60`).
178
+ - **`--cleanup`** / **`--no-cleanup`** — remove a mirror block once its event is over, instead of
179
+ keeping it for the lookback window (default off).
180
+ - **`--exclude-pattern REGEX …`** — replace the default title-exclusion patterns (see
181
+ [Exclusions](#exclusions)).
182
+ - **`--exclude-clashes`** / **`--no-exclude-clashes`** — skip a source event that overlaps a genuine
183
+ event already on the target (default on).
184
+ - **`--exclude-all-day`** / **`--no-exclude-all-day`** — skip all-day events (default on).
185
+ - **`--exclude-out-of-hours`** / **`--no-exclude-out-of-hours`** — skip events that fall entirely
186
+ outside working hours (default on).
187
+ - **`--dry-run`** — log the plan without writing any changes.
188
+ - **`--list-calendars`** — print every calendar's account-qualified name and exit.
189
+ - **`--install SECONDS`** — install a `launchd` agent that runs this same command every SECONDS,
190
+ then exit.
191
+ - **`--uninstall`** — remove the installed `launchd` agent and exit.
192
+ - **`--logging LEVEL`** — logging level (default `info`; `debug` shows why each event was kept or
193
+ excluded).
194
+ Be careful not to place variadic options (`--mute`, `--exclude-pattern`) immediately before the calendar arguments,
195
+ or they will swallow them.
196
+
197
+ ## Titles
198
+
199
+ The mirror-block title is a template. Placeholders are filled from the source event,
200
+ so `{account}` is the source account and `{title}` is the event's real subject. The default,
201
+ `Busy ({account} calendar)`, doesn't include `{title}` and so keeps the details opaque.
202
+ {account} is always the source of the current event, so when mirroring events from the primary calendar
203
+ into a client calendar, {account} is the primary calendar's account (even if the busy block originated
204
+ from a different client calendar). This ensures that, if using `{account}`, auxiliary calendars never see the
205
+ names of calendars other than the primary (which we assume they already know).
206
+
207
+ In addition to `--title`, two overrides control the title used for a calendar, both repeatable and
208
+ both keyed on a calendar's qualified name:
209
+
210
+ - `--title-to NAME TEMPLATE` — the title used when writing *into* NAME's calendar ("when putting
211
+ events into *this* diary, use this format").
212
+ - `--title-from NAME TEMPLATE` — the title used for events read *from* NAME's calendar, wherever
213
+ they land. A title-from override wins over a title-to one.
214
+
215
+ Common use-cases:
216
+
217
+ `--title-to` lets your *own* diary show real subjects while every client calendar still sees only
218
+ opaque busy blocks (and so never sees detail of your own or other clients' events):
219
+
220
+ `--title-from` pins how one source always appears, regardless of the target. For example, feed a
221
+ personal calendar into the company diary to block out home commitments, but keep those entries
222
+ fully opaque even if a `--title-to` on that calendar would otherwise make them detailed:
223
+
224
+ ```sh
225
+ calque "Acme Consulting.Acme Consulting" "MoSW.Calendar" "MoM.Calendar" "iCloud.Home" \
226
+ --title-to "Acme Consulting.Acme Consulting" "{account}: {title}" \
227
+ --title-from "iCloud.Home" "Busy"
228
+ ```
229
+
230
+ ## Exclusions
231
+
232
+ Before mirroring, every source event runs through a set of **exclusion rules**; if any rule
233
+ rejects it, no busy block is written. The rules are assembled per run in
234
+ [`exclusions.py`](src/calque/exclusions.py), so adding a new kind is a matter of writing one
235
+ builder and wiring it into `rules`.
236
+
237
+ Current rules:
238
+
239
+ - **By status** — only events with a busy status are mirrored. The default busy status is only
240
+ `accepted`, but you can override them on `Config`.
241
+ - **By title** — source events whose title matches any `--exclude-pattern` regular expression
242
+ are skipped, so availability markers don't get mirrored as meetings. The default excludes a
243
+ bare `Working` status block (`^Working$`) and any annual-leave marker (`\bA/L\b`). Pass one
244
+ or more of your own patterns to replace the defaults.
245
+ - **All-day** — all-day events (out-of-office banners, leave, public holidays) are skipped so
246
+ they don't blank out the whole day on the other calendar. On by default; `--no-exclude-all-day`.
247
+ - **Out of hours** — events that fall entirely outside working hours are skipped, so only your
248
+ working day is mirrored. The window defaults to **Monday–Friday 08:00–18:00 local time**; an
249
+ event that overlaps it at all (e.g. a 17:00–19:00 overrun) is still mirrored. On by default;
250
+ `--no-exclude-out-of-hours`. The window itself (`work_days`, `work_start`, `work_end`) is set
251
+ on `Config`.
252
+ - **By clash** — a source event is skipped when the target calendar already has a genuine event
253
+ (not one of calque's own mirror blocks) overlapping any part of its slot, so calque never
254
+ stacks a busy block on time you're already committed elsewhere. On by default; turn it off
255
+ with `--no-exclude-clashes`.
256
+
257
+ Example: skip any event whose title is exactly `Working`, contains `A/L` as a whole word, or is `Lunch`, and don't skip events that clash with existing events on the target:
258
+ ```sh
259
+ calque "Acme Consulting.Acme Consulting" "MoSW.Calendar" \
260
+ --exclude-pattern "^Working$" "\bA/L\b" "\bLunch " --no-exclude-clashes
261
+ ```
262
+
263
+ ## Writing into every calendar
264
+
265
+ Availability is mirrored in both directions by default for every calendar you list.
266
+ If you want to read a calendar but not write into it, pass `--mute NAME` for that calendar.
267
+ Note that if you mute the primary calendar, it will mirror its events into the other calendars,
268
+ but will not not be able to mirror busy blocked between the other calendars - i.e. you have muted
269
+ the hub so it can't act as a hub for mirroring.
270
+
271
+ ## Status filtering
272
+
273
+ Your response is read from the event's attendee list.
274
+ Only events you've accepted are mirrored by default (the busy statuses are configurable on
275
+ `Config`). Events with no attendees (blocks you created yourself) are treated as implicitly accepted.