calque 0.1.0__py3-none-any.whl

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.
calque/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Mirror accepted calendar events into another local macOS calendar as anonymised busy blocks."""
2
+
3
+ __version__ = "0.1.0"
calque/cli.py ADDED
@@ -0,0 +1,200 @@
1
+ """Command-line entry point: parse options into a configuration and drive a synchronisation run."""
2
+
3
+ import logging
4
+ import re
5
+ import sys
6
+ from argparse import Action, ArgumentParser, BooleanOptionalAction, Namespace
7
+ from collections.abc import Sequence
8
+ from dataclasses import fields
9
+ from typing import Any, cast
10
+
11
+ from calque.config import DEFAULT_EXCLUDES, Config
12
+ from calque.errors import CalqueError
13
+ from calque.service import install, uninstall
14
+ from calque.store import CalendarStore
15
+ from calque.sync import synchronise
16
+
17
+
18
+ class CompilePatterns(Action):
19
+ """Compile the given regular expressions and store them as the immutable exclusion-pattern set."""
20
+
21
+ def __call__(
22
+ self,
23
+ parser: ArgumentParser, # noqa: ARG002
24
+ namespace: Namespace,
25
+ values: str | Sequence[Any] | None,
26
+ option_string: str | None = None,
27
+ ) -> None:
28
+ """Compile every pattern and store the resulting tuple on the namespace."""
29
+ setattr(namespace, self.dest, tuple(re.compile(pattern) for pattern in cast("Sequence[str]", values)))
30
+
31
+
32
+ class CollectMapping(Action):
33
+ """Collect one or more ``--option-for KEY VALUE`` pairs into a mapping.
34
+
35
+ Requires that the destination field is defaulted to a mutable mapping type, which it updates in-place with each pair.
36
+ """
37
+
38
+ def __call__(
39
+ self,
40
+ parser: ArgumentParser, # noqa: ARG002
41
+ namespace: Namespace,
42
+ values: str | Sequence[Any] | None,
43
+ option_string: str | None = None,
44
+ ) -> None:
45
+ """Store one parsed key/value pair into the mapping."""
46
+ key, value = cast("Sequence[str]", values)
47
+ getattr(namespace, self.dest)[key] = value
48
+
49
+
50
+ class CollectSet(Action):
51
+ """Collect the given values into an immutable set on the namespace."""
52
+
53
+ def __call__(
54
+ self,
55
+ parser: ArgumentParser, # noqa: ARG002
56
+ namespace: Namespace,
57
+ values: str | Sequence[Any] | None,
58
+ option_string: str | None = None,
59
+ ) -> None:
60
+ """Store the values as a frozenset on the namespace."""
61
+ setattr(namespace, self.dest, frozenset(cast("Sequence[str]", values)))
62
+
63
+
64
+ def parse_arguments(arguments: list[str] | None) -> Namespace:
65
+ """Build the parser and parse the given command-line arguments."""
66
+ parser = ArgumentParser(
67
+ prog="calque",
68
+ description="Mirror accepted events from one local calendar into another as anonymised busy blocks.",
69
+ )
70
+ parser.add_argument(
71
+ "--list-calendars",
72
+ action="store_true",
73
+ help="list all local calendar titles (qualified account.calendar names) and exit",
74
+ )
75
+ parser.add_argument(
76
+ "--title",
77
+ default="Busy ({account} calendar)",
78
+ help="title template used for every mirror block",
79
+ )
80
+ parser.add_argument(
81
+ "--title-to",
82
+ nargs=2,
83
+ action=CollectMapping,
84
+ default={},
85
+ metavar=("ACCOUNT", "TEMPLATE"),
86
+ help="title template to use when writing into ACCOUNT's calendar, overriding --title; repeatable",
87
+ )
88
+ parser.add_argument(
89
+ "--title-from",
90
+ nargs=2,
91
+ action=CollectMapping,
92
+ default={},
93
+ metavar=("ACCOUNT", "TEMPLATE"),
94
+ help="title template for events read from ACCOUNT's calendar, overriding both --title and --title-to; repeatable",
95
+ )
96
+ parser.add_argument(
97
+ "--lookback",
98
+ type=int,
99
+ default=1,
100
+ help="days before now to mirror; with --cleanup, the window within which finished events are removed",
101
+ )
102
+ parser.add_argument("--lookahead", type=int, default=60, help="days after now to mirror")
103
+ parser.add_argument(
104
+ "--cleanup",
105
+ action=BooleanOptionalAction,
106
+ default=False,
107
+ help="remove mirror blocks once their event is over, instead of keeping them through the lookback window",
108
+ )
109
+ parser.add_argument("--dry-run", action="store_true", help="report the plan without writing anything")
110
+ parser.add_argument(
111
+ "--install",
112
+ type=int,
113
+ metavar="SECONDS",
114
+ help="install a launchd agent that runs this same command every SECONDS seconds, then exit",
115
+ )
116
+ parser.add_argument("--uninstall", action="store_true", help="remove the installed launchd agent and exit")
117
+ parser.add_argument("--logging", default="info", help="set the logging level")
118
+ parser.add_argument(
119
+ "--exclude-pattern",
120
+ dest="exclude_patterns",
121
+ nargs="+",
122
+ action=CompilePatterns,
123
+ default=tuple(re.compile(pattern) for pattern in DEFAULT_EXCLUDES),
124
+ metavar="REGEX",
125
+ help="Exclude calendar events with titles that match any of these patterns",
126
+ )
127
+ parser.add_argument(
128
+ "--exclude-clashes",
129
+ action=BooleanOptionalAction,
130
+ default=True,
131
+ help="skip a source event when the target is already busy over any part of its slot",
132
+ )
133
+ parser.add_argument(
134
+ "--exclude-all-day",
135
+ action=BooleanOptionalAction,
136
+ default=True,
137
+ help="skip all-day events",
138
+ )
139
+ parser.add_argument(
140
+ "--exclude-out-of-hours",
141
+ action=BooleanOptionalAction,
142
+ default=True,
143
+ help="skip events that fall entirely outside working hours (Mon-Fri 08:00-18:00)",
144
+ )
145
+ parser.add_argument(
146
+ "--mute",
147
+ dest="muted",
148
+ nargs="+",
149
+ action=CollectSet,
150
+ default=frozenset(),
151
+ metavar="CALENDAR",
152
+ help="calendar names that should not be mirrored to; their viewers won't see the mirrored busy blocks",
153
+ )
154
+ parser.add_argument(
155
+ "calendars",
156
+ nargs="*",
157
+ help="calendars to mirror; the first is the primary calendar that every auxiliary is mirrored into and back out from",
158
+ )
159
+ options = parser.parse_args(arguments)
160
+ # --list-calendars and --uninstall stand alone; a sync or an install needs a primary and at least one auxiliary.
161
+ if len(options.calendars) <= 1 and not (options.list_calendars or options.uninstall):
162
+ parser.error("at least two calendars are required: a primary and some auxiliary calendars to mirror with")
163
+ return options
164
+
165
+
166
+ def to_config(options: Namespace) -> Config:
167
+ """Project the parsed options onto the configuration fields they share a name with.
168
+
169
+ The parser is responsible for giving every shared option its final type, so this mapping
170
+ stays uniform: a new option needs only to match on name.
171
+ """
172
+ return Config(
173
+ **{field.name: getattr(options, field.name) for field in fields(Config) if hasattr(options, field.name)}
174
+ )
175
+
176
+
177
+ def main(arguments: list[str] | None = None) -> int:
178
+ """Dispatch one calque invocation — list, install, uninstall, or sync — and return a process exit code."""
179
+ arguments = sys.argv[1:] if arguments is None else arguments
180
+ options = parse_arguments(arguments)
181
+ logging.basicConfig(level=options.logging.upper(), format="%(levelname)s: %(message)s")
182
+
183
+ try:
184
+ if options.list_calendars:
185
+ for name in CalendarStore().qualified_names():
186
+ print(name)
187
+ elif options.install is not None:
188
+ install(arguments, options.install)
189
+ elif options.uninstall:
190
+ uninstall()
191
+ else:
192
+ synchronise(to_config(options), options.calendars)
193
+ except CalqueError as exception:
194
+ logging.error("calque failed: %s", exception)
195
+ return 1
196
+ return 0
197
+
198
+
199
+ if __name__ == "__main__":
200
+ raise SystemExit(main())
calque/config.py ADDED
@@ -0,0 +1,69 @@
1
+ """Runtime configuration shared across every mirror direction."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from datetime import time
6
+
7
+ from calque.model import Participation
8
+ from calque.store import Calendar
9
+
10
+ # Titles that signal availability rather than a genuine commitment: a bare "Working"
11
+ # status block, and any annual-leave marker (the "A/L" shorthand).
12
+ DEFAULT_EXCLUDES = [r"^Working$", r"\bA/L\b"]
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Config:
17
+ """Settings governing how events are mirrored, independent of direction.
18
+
19
+ :param title: The default title template for mirror blocks; ``{field}`` placeholders are
20
+ filled from the source event (e.g. ``{account}``, ``{title}``).
21
+ :param title_to: Title templates keyed by the fully-qualified name of the
22
+ calendar being written into, overriding ``title`` for that target.
23
+ :param title_from: Title templates keyed by the fully-qualified name of the calendar an event was
24
+ read from, overriding ``title`` and any ``title_to`` entry for that source.
25
+ :param lookback: Days before now to keep mirrored, so recently-passed events stay tidy. When
26
+ ``cleanup`` is set, this instead bounds the window within which finished events are removed.
27
+ :param lookahead: Days after now to mirror.
28
+ :param statuses: The participation responses that count as "busy" and get mirrored.
29
+ :param exclude_patterns: Patterns whose match against a source title drops that event from the mirror.
30
+ :param exclude_clashes: Whether to drop a source event that overlaps an existing target event.
31
+ :param exclude_all_day: Whether to drop all-day events.
32
+ :param exclude_out_of_hours: Whether to drop events that fall entirely outside working hours.
33
+ :param work_days: Weekdays (Monday is 0) whose working hours are mirrored.
34
+ :param work_start: Start of the daily working-hours window, in local time.
35
+ :param work_end: End of the daily working-hours window, in local time.
36
+ :param muted: Names of calendars that should not be mirrored to, so their viewers never see the
37
+ mirrored busy blocks; the calendar is still read as a source and mirrored into the others.
38
+ :param cleanup: Whether to remove a mirror block once its event is over (end time has passed),
39
+ rather than keeping it for the ``lookback`` window.
40
+ :param dry_run: Whether to report the plan without writing any changes.
41
+ """
42
+
43
+ title: str = "Busy"
44
+ title_to: dict[str, str] = field(default_factory=dict)
45
+ title_from: dict[str, str] = field(default_factory=dict)
46
+ lookback: int = 1
47
+ lookahead: int = 60
48
+ statuses: frozenset[Participation] = field(default_factory=lambda: frozenset({Participation.ACCEPTED}))
49
+ exclude_patterns: tuple[re.Pattern[str], ...] = field(
50
+ default_factory=lambda: tuple(re.compile(pattern) for pattern in DEFAULT_EXCLUDES),
51
+ )
52
+ exclude_clashes: bool = True
53
+ exclude_all_day: bool = True
54
+ exclude_out_of_hours: bool = True
55
+ work_days: frozenset[int] = frozenset({0, 1, 2, 3, 4})
56
+ work_start: time = time(8)
57
+ work_end: time = time(18)
58
+ muted: frozenset[str] = frozenset()
59
+ cleanup: bool = False
60
+ dry_run: bool = False
61
+
62
+ def title_for_calendar(self, source: Calendar, target: Calendar) -> str:
63
+ """Return the title template for mirroring from ``source`` into ``target``, or the default.
64
+
65
+ A source override (``title_from``) wins over a target override (``title_to``), so a calendar
66
+ whose events should always be opaque can be pinned once wherever they land. Both are keyed on the
67
+ fully-qualified name (e.g. ``ClientCompany.Calendar``, as shown by ``--list-calendars``).
68
+ """
69
+ return self.title_from.get(source.qualified, self.title_to.get(target.qualified, self.title))
calque/errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Exception hierarchy for the mirror. Each composes its own message from the failing value."""
2
+
3
+
4
+ class CalqueError(Exception):
5
+ """Base for every error raised by calque."""
6
+
7
+
8
+ class AccessError(CalqueError):
9
+ """Calendar access was denied or could not be resolved in time."""
10
+
11
+ def __init__(self, status: object) -> None:
12
+ super().__init__(f"calendar access not granted (authorisation status {status})")
13
+
14
+
15
+ class CalendarError(CalqueError):
16
+ """No calendar with the requested title exists in the local store."""
17
+
18
+ def __init__(self, title: str) -> None:
19
+ super().__init__(f"no calendar titled {title!r} found in the local store")
20
+
21
+
22
+ class WriteError(CalqueError):
23
+ """EventKit refused to save or remove a mirror block."""
24
+
25
+ def __init__(self, detail: object) -> None:
26
+ super().__init__(f"EventKit write failed: {detail}")
27
+
28
+
29
+ class ServiceError(CalqueError):
30
+ """Installing or removing the launchd agent failed."""
31
+
32
+ def __init__(self, detail: object) -> None:
33
+ super().__init__(f"launchctl failed: {detail}")
calque/exclusions.py ADDED
@@ -0,0 +1,186 @@
1
+ """Composable exclusion rules that keep selected source events out of the mirror.
2
+
3
+ An :data:`Exclusion` is a predicate over an event, true when that event should be dropped.
4
+ Builders turn configuration and target-calendar context into rules, :func:`rules` assembles
5
+ the active ones, and :func:`excluded` runs them.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ from collections.abc import Callable, Container, Iterable, Iterator
11
+ from datetime import UTC, date, datetime, time, timedelta
12
+
13
+ from calque.config import Config
14
+ from calque.model import Event, Participation, untag
15
+
16
+ Exclusion = Callable[[Event], bool]
17
+
18
+
19
+ def dates_spanned(start: datetime, end: datetime) -> Iterator[date]:
20
+ """Yield each local calendar date an event touches, from its start through its end."""
21
+ day = start.date()
22
+ while day <= end.date():
23
+ yield day
24
+ day += timedelta(days=1)
25
+
26
+
27
+ def by_title(patterns: tuple[re.Pattern[str], ...]) -> Exclusion:
28
+ """Build a rule excluding events whose title matches any of the given patterns."""
29
+
30
+ def rule(event: Event) -> bool:
31
+ """Test the event's title against every configured pattern."""
32
+ for pattern in patterns:
33
+ if pattern.search(event.title):
34
+ logging.debug("excluding %r by title (%r)", event.title, pattern.pattern)
35
+ return True
36
+ return False
37
+
38
+ return rule
39
+
40
+
41
+ def by_clash(events: Iterable[Event]) -> Exclusion:
42
+ """Build a rule excluding events that overlap any interval already busy in the target."""
43
+ busy = tuple(event.window for event in events)
44
+
45
+ def rule(event: Event) -> bool:
46
+ """Half-open overlap test of the event against each busy interval."""
47
+ for interval in busy:
48
+ if event.start < interval.end and interval.start < event.end:
49
+ logging.debug("excluding %r due to clash with %s", event.title, interval)
50
+ return True
51
+ return False
52
+
53
+ return rule
54
+
55
+
56
+ def by_passed() -> Exclusion:
57
+ """Build a rule excluding events whose end time has already passed.
58
+
59
+ Enabled by cleanup: the cut-off is the moment the rule is built — cheap to read and captured by
60
+ the returned closure — so a finished event drops out of the desired set and reconciliation
61
+ deletes its now-stale mirror block instead of keeping it through the lookback window.
62
+ """
63
+ now = datetime.now(UTC)
64
+
65
+ def rule(event: Event) -> bool:
66
+ """True when the event has already ended."""
67
+ result = event.end < now
68
+ if result:
69
+ logging.debug("excluding %r as already ended", event.title)
70
+ return result
71
+
72
+ return rule
73
+
74
+
75
+ def by_origin(target: str) -> Exclusion:
76
+ """Build a rule excluding our own mirror blocks that we previously copied from ``target``.
77
+
78
+ A mirror is dropped only when it would return to the calendar it came from — which would become a
79
+ mirror of a mirror (this would otherwise be caused by a deleted event).
80
+ A mirror in the primary, sourced from one calendar, still propagates onward to the others.
81
+ """
82
+
83
+ def rule(event: Event) -> bool:
84
+ """True when the event is our mirror block originating from the target calendar."""
85
+ marker = untag(event.notes)
86
+ result = marker is not None and marker.origin == target
87
+ if result:
88
+ logging.debug("excluding %r as our own mirror returning to %s", event.title, target)
89
+ return result
90
+
91
+ return rule
92
+
93
+
94
+ def is_all_day(event: Event) -> bool:
95
+ """Whether the event is an all-day event, which would otherwise block the whole day."""
96
+ result = event.all_day
97
+ if result:
98
+ logging.debug("excluding %r by all-day", event.title)
99
+ return result
100
+
101
+
102
+ def by_participation(statuses: frozenset[Participation]) -> Exclusion:
103
+ """Build a rule excluding events whose participation is not among the mirrored statuses."""
104
+
105
+ def rule(event: Event) -> bool:
106
+ """True when the event's participation is not one we mirror."""
107
+ result = event.participation not in statuses
108
+ if result:
109
+ logging.debug("excluding %r by participation (%s)", event.title, event.participation.value)
110
+ return result
111
+
112
+ return rule
113
+
114
+
115
+ def by_hours(days: Container[int], opening: time, closing: time) -> Exclusion:
116
+ """Build a rule excluding events that fall entirely outside the working-hours window.
117
+
118
+ Hours are compared in local time. An event is kept if it overlaps the window on any
119
+ working day it touches, so an event straddling the edge (e.g. 17:00-19:00) still mirrors.
120
+ """
121
+
122
+ def rule(event: Event) -> bool:
123
+ """True when no working-hours interval on any day the event spans overlaps it."""
124
+ start = event.start.astimezone()
125
+ end = event.end.astimezone()
126
+ result = not any(
127
+ start < datetime.combine(day, closing, start.tzinfo) and datetime.combine(day, opening, start.tzinfo) < end
128
+ for day in dates_spanned(start, end)
129
+ if day.weekday() in days
130
+ )
131
+ if result:
132
+ logging.debug("excluding %r by hours (%s-%s)", event.title, opening, closing)
133
+ return result
134
+
135
+ return rule
136
+
137
+
138
+ def rules(config: Config, events: Iterable[Event], target: str) -> tuple[Exclusion, ...]:
139
+ """Return the active exclusion rule chain for mirroring into ``target`` given its busy ``events``.
140
+
141
+ The full exclusion chain:
142
+ - intrinsic rules:
143
+ - participation status
144
+ - title patterns
145
+ - all-day
146
+ - out-of-hours
147
+ - finished events, when cleanup is enabled
148
+ - our own mirror blocks returning to ``target``
149
+ - clash against target busy periods - which itself filters using the intrinsic rules
150
+
151
+ A mirror block is excluded only when its origin is ``target``, so a block written by a previous sync is never
152
+ returned to its source (which would chain into a mirror of a mirror) yet still propagates to the other
153
+ calendars. Genuinely busy periods are the target events the intrinsic rules don't themselves exclude, so a
154
+ focus block, all-day, out-of-hours or unaccepted event never blocks a source event.
155
+ """
156
+
157
+ def build() -> Iterable[Exclusion]:
158
+ yield by_participation(config.statuses)
159
+ if config.exclude_patterns:
160
+ yield by_title(config.exclude_patterns)
161
+ if config.exclude_all_day:
162
+ yield is_all_day
163
+ if config.exclude_out_of_hours:
164
+ yield by_hours(config.work_days, config.work_start, config.work_end)
165
+ if config.cleanup:
166
+ yield by_passed()
167
+
168
+ intrinsic = tuple(build())
169
+ exclusions = (*intrinsic, by_origin(target))
170
+ if not config.exclude_clashes:
171
+ return exclusions
172
+ # The clash rule takes the target events, which we filter to the genuinely busy periods using the intrinsic rules.
173
+ # Then, when it is used, it excludes any source event that overlaps the busy periods.
174
+ return (*exclusions, by_clash(included(events, intrinsic)))
175
+
176
+
177
+ def excluded(event: Event, exclusions: Iterable[Exclusion]) -> bool:
178
+ """Predicate: whether any rule in the chain excludes the event."""
179
+ return any(rule(event) for rule in exclusions)
180
+
181
+
182
+ def included(events: Iterable[Event], exclusions: Iterable[Exclusion]) -> Iterator[Event]:
183
+ """Filter: yield only the events that no exclusion rule rejects."""
184
+ for event in events:
185
+ if not excluded(event, exclusions):
186
+ yield event
calque/model.py ADDED
@@ -0,0 +1,158 @@
1
+ """Domain model for the mirror: events, anonymised busy blocks, and the reconciliation plan."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from typing import Protocol
8
+
9
+ MARKER = "⟦calque⟧"
10
+
11
+
12
+ class Participation(Enum):
13
+ """The current user's response to an event invitation."""
14
+
15
+ ACCEPTED = "accepted"
16
+ TENTATIVE = "tentative"
17
+ DECLINED = "declined"
18
+ PENDING = "pending"
19
+ UNKNOWN = "unknown"
20
+
21
+
22
+ class Source(Protocol):
23
+ """The slice of an EventKit ``EKSource`` we read: the account a calendar belongs to.
24
+
25
+ A narrowing of PyObjC's untyped objects; ``store.Calendar.source`` returns one.
26
+ """
27
+
28
+ def title(self) -> str:
29
+ """The account name (e.g. the Google or Exchange account behind the calendar)."""
30
+ ...
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class Window:
35
+ """A half-open time range in UTC — a query range or an interval occupied by an event."""
36
+
37
+ start: datetime
38
+ end: datetime
39
+
40
+ def __str__(self) -> str:
41
+ """A readable local-time rendering of the range, dropping seconds and the timezone."""
42
+ start = self.start.astimezone()
43
+ end = self.end.astimezone()
44
+ until = f"{end:%H:%M}" if start.date() == end.date() else f"{end:%a %Y-%m-%d %H:%M}"
45
+ return f"{start:%a %Y-%m-%d %H:%M} to {until}"
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class Event:
50
+ """A source-calendar event reduced to the fields the mirror cares about."""
51
+
52
+ identifier: str
53
+ title: str
54
+ account: str
55
+ window: Window
56
+ participation: Participation
57
+ all_day: bool
58
+ notes: str | None = None
59
+
60
+ @property
61
+ def start(self) -> datetime:
62
+ """The event's start, in UTC."""
63
+ return self.window.start
64
+
65
+ @property
66
+ def end(self) -> datetime:
67
+ """The event's end, in UTC."""
68
+ return self.window.end
69
+
70
+ def __str__(self) -> str:
71
+ """A human-readable representation of the event for logging and diagnostics."""
72
+ return f"title={self.title!r} {self.window}"
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class Mirror:
77
+ """An anonymised busy block in the target calendar, linked to its source by identifier."""
78
+
79
+ source: str
80
+ window: Window
81
+ title: str
82
+ # Excluded from equality:
83
+ original: str | None = field(default=None, compare=False)
84
+ # The calendar this block was copied from, stored in the tag so a mirror is never returned to its
85
+ # source. Excluded from equality: it is fixed per direction and absent on blocks read back for diffing.
86
+ origin: str = field(default="", compare=False)
87
+
88
+ @property
89
+ def start(self) -> datetime:
90
+ """The block's start, in UTC."""
91
+ return self.window.start
92
+
93
+ @property
94
+ def end(self) -> datetime:
95
+ """The block's end, in UTC."""
96
+ return self.window.end
97
+
98
+ def __str__(self) -> str:
99
+ """A human-readable representation of the mirror, in local time, for diagnostics."""
100
+ return f"title={self.title!r} original={self.original!r} window={self.window} source={self.source!r}"
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class Plan:
105
+ """The reconciliation outcome: blocks to create, retime, and remove in the target."""
106
+
107
+ create: tuple[Mirror, ...]
108
+ update: tuple[Mirror, ...]
109
+ delete: tuple[Mirror, ...]
110
+
111
+ @property
112
+ def empty(self) -> bool:
113
+ """Whether the plan would change nothing."""
114
+ return not (self.create or self.update or self.delete)
115
+
116
+ def __str__(self) -> str:
117
+ """A human-readable summary of the plan's contents for logging and diagnostics."""
118
+ return f"create={len(self.create)} update={len(self.update)} delete={len(self.delete)}"
119
+
120
+ def log(self) -> None:
121
+ """Log the plan's contents in a human-readable form."""
122
+ logging.info("create:")
123
+ for event in self.create:
124
+ logging.info(" %s", event)
125
+ logging.info("update:")
126
+ for event in self.update:
127
+ logging.info(" %s", event)
128
+ logging.info("delete:")
129
+ for event in self.delete:
130
+ logging.info(" %s", event)
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class Tag:
135
+ """The provenance encoded on a mirror block: the calendar it was copied from and that source's event id."""
136
+
137
+ origin: str
138
+ identifier: str
139
+
140
+
141
+ def tag(origin: str, identifier: str) -> str:
142
+ """Encode a mirror block's origin calendar and source event id into the marker stored on its notes.
143
+
144
+ The fields are space-separated: the identifier never contains a space, so it is the first token,
145
+ and the origin (which may) is the remainder. A space survives any normalisation that some
146
+ calendar backends apply, where a tab or newline does not.
147
+ """
148
+ return f"{MARKER} {identifier} {origin}"
149
+
150
+
151
+ def untag(notes: str | None) -> Tag | None:
152
+ """Recover the origin and source id from a mirror block's notes, or ``None`` if not one of ours."""
153
+ if not notes or not notes.startswith(MARKER):
154
+ return None
155
+ identifier, _, origin = notes.removeprefix(MARKER).strip().partition(" ")
156
+ if not identifier:
157
+ return None
158
+ return Tag(origin=origin, identifier=identifier)
calque/py.typed ADDED
File without changes