Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ By design, this is **not** a cloud service. Because calendars are already subscr

- Reads events from the calendars you specify, in a configurable time window around the current time. The **first** calendar
you list is the **primary**; the rest are mirrored against it.
- Mirrors only the events you've **accepted** (tentative, declined, and unanswered are skipped by default).
The set of statuses that count as busy is configurable.
- Mirrors only the events you've **accepted** or that have no response (`unknown`); tentative, declined, and
pending are skipped by default. The set of statuses that count as busy is configurable with `--statuses`.
- Drops any event an **exclusion rule** rejects: by title pattern, time, or because the target
calendar is already busy over that slot (see [Exclusions](#exclusions)).
- Writes one block per mirrored event with a **templated title**
Expand Down Expand Up @@ -177,6 +177,8 @@ calque --uninstall
- **`--lookahead DAYS`** — days after now to mirror (default `60`).
- **`--cleanup`** / **`--no-cleanup`** — remove a mirror block once its event is over, instead of
keeping it for the lookback window (default off).
- **`--statuses STATUS …`** — participation responses that count as busy and get mirrored, from
`accepted`, `tentative`, `declined`, `pending`, `unknown` (default `accepted unknown`).
- **`--exclude-pattern REGEX …`** — replace the default title-exclusion patterns (see
[Exclusions](#exclusions)).
- **`--exclude-clashes`** / **`--no-exclude-clashes`** — skip a source event that overlaps a genuine
Expand All @@ -191,8 +193,8 @@ calque --uninstall
- **`--uninstall`** — remove the installed `launchd` agent and exit.
- **`--logging LEVEL`** — logging level (default `info`; `debug` shows why each event was kept or
excluded).
Be careful not to place variadic options (`--mute`, `--exclude-pattern`) immediately before the calendar arguments,
or they will swallow them.
Be careful not to place variadic options (`--mute`, `--exclude-pattern`, `--statuses`) immediately before the
calendar arguments, or they will swallow them.

## Titles

Expand Down Expand Up @@ -275,5 +277,7 @@ the hub so it can't act as a hub for mirroring.
## Status filtering

Your response is read from the event's attendee list.
Only events you've accepted are mirrored by default (the busy statuses are configurable on
`Config`). Events with no attendees (blocks you created yourself) are treated as implicitly accepted.
By default, events you've **accepted** and events with no clear response (`unknown`) are mirrored; tentative,
declined, and pending invitations are skipped. Choose the responses that count as busy with `--statuses`,
e.g. `--statuses accepted tentative`. Events with no attendees (blocks you created yourself) are treated as
implicitly accepted.
72 changes: 55 additions & 17 deletions src/calque/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
import logging
import re
import sys
from argparse import Action, ArgumentParser, BooleanOptionalAction, Namespace
from collections.abc import Sequence
from argparse import Action, ArgumentDefaultsHelpFormatter, ArgumentParser, BooleanOptionalAction, Namespace
from collections.abc import Iterable, Sequence
from dataclasses import fields
from typing import Any, cast

from calque.config import DEFAULT_EXCLUDES, Config
from calque.config import Config
from calque.errors import CalqueError
from calque.model import Participation
from calque.service import install, uninstall
from calque.store import CalendarStore
from calque.sync import synchronise
Expand Down Expand Up @@ -61,11 +62,26 @@ def __call__(
setattr(namespace, self.dest, frozenset(cast("Sequence[str]", values)))


def values_string(items: Iterable[Any]) -> str:
"""Return a list of the string values of a group of items."""
return ", ".join(sorted(item.value for item in items))


class DefaultsHelpFormatter(ArgumentDefaultsHelpFormatter):
"""Append each option's default to its help unless the help already mentions it."""

def _get_help_string(self, action: Action) -> str | None:
"""Return the help string for an action, appending the default if not already mentioned."""
return action.help if action.help and "default: " in action.help else super()._get_help_string(action)


def parse_arguments(arguments: list[str] | None) -> Namespace:
"""Build the parser and parse the given command-line arguments."""
defaults = Config()
parser = ArgumentParser(
prog="calque",
description="Mirror accepted events from one local calendar into another as anonymised busy blocks.",
formatter_class=DefaultsHelpFormatter,
)
parser.add_argument(
"--list-calendars",
Expand All @@ -74,39 +90,49 @@ def parse_arguments(arguments: list[str] | None) -> Namespace:
)
parser.add_argument(
"--title",
default="Busy ({account} calendar)",
default=defaults.title,
help="title template used for every mirror block",
)
parser.add_argument(
"--title-to",
nargs=2,
action=CollectMapping,
default={},
default=defaults.title_to,
metavar=("ACCOUNT", "TEMPLATE"),
help="title template to use when writing into ACCOUNT's calendar, overriding --title; repeatable",
)
parser.add_argument(
"--title-from",
nargs=2,
action=CollectMapping,
default={},
default=defaults.title_from,
metavar=("ACCOUNT", "TEMPLATE"),
help="title template for events read from ACCOUNT's calendar, overriding both --title and --title-to; repeatable",
)
parser.add_argument(
"--lookback",
type=int,
default=1,
default=defaults.lookback,
help="days before now to mirror; with --cleanup, the window within which finished events are removed",
)
parser.add_argument("--lookahead", type=int, default=60, help="days after now to mirror")
parser.add_argument(
"--lookahead",
type=int,
default=defaults.lookahead,
help="days after now to mirror events",
)
parser.add_argument(
"--cleanup",
action=BooleanOptionalAction,
default=False,
default=defaults.cleanup,
help="remove mirror blocks once their event is over, instead of keeping them through the lookback window",
)
parser.add_argument("--dry-run", action="store_true", help="report the plan without writing anything")
parser.add_argument(
"--dry-run",
action="store_true",
default=defaults.dry_run,
help="report the plan without writing anything",
)
parser.add_argument(
"--install",
type=int,
Expand All @@ -120,36 +146,48 @@ def parse_arguments(arguments: list[str] | None) -> Namespace:
dest="exclude_patterns",
nargs="+",
action=CompilePatterns,
default=tuple(re.compile(pattern) for pattern in DEFAULT_EXCLUDES),
default=defaults.exclude_patterns,
metavar="REGEX",
help="Exclude calendar events with titles that match any of these patterns",
help="exclude calendar events with titles that match any of these patterns",
)
parser.add_argument(
"--statuses",
nargs="+",
type=Participation,
action=CollectSet,
default=defaults.statuses,
metavar="STATUS",
help=(
f"participation responses that count as busy and get mirrored, from {{{values_string(Participation)}}} "
f"(default: {values_string(defaults.statuses)})"
),
)
parser.add_argument(
"--exclude-clashes",
action=BooleanOptionalAction,
default=True,
default=defaults.exclude_clashes,
help="skip a source event when the target is already busy over any part of its slot",
)
parser.add_argument(
"--exclude-all-day",
action=BooleanOptionalAction,
default=True,
default=defaults.exclude_all_day,
help="skip all-day events",
)
parser.add_argument(
"--exclude-out-of-hours",
action=BooleanOptionalAction,
default=True,
default=defaults.exclude_out_of_hours,
help="skip events that fall entirely outside working hours (Mon-Fri 08:00-18:00)",
)
parser.add_argument(
"--mute",
dest="muted",
nargs="+",
action=CollectSet,
default=frozenset(),
default=defaults.muted,
metavar="CALENDAR",
help="calendar names that should not be mirrored to; their viewers won't see the mirrored busy blocks",
help="calendar names that should not be mirrored to; their viewers won't see the mirrored busy blocks (default: None)",
)
parser.add_argument(
"calendars",
Expand Down
6 changes: 4 additions & 2 deletions src/calque/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ class Config:
:param dry_run: Whether to report the plan without writing any changes.
"""

title: str = "Busy"
title: str = "Busy ({account} calendar)"
title_to: dict[str, str] = field(default_factory=dict)
title_from: dict[str, str] = field(default_factory=dict)
lookback: int = 1
lookahead: int = 60
statuses: frozenset[Participation] = field(default_factory=lambda: frozenset({Participation.ACCEPTED}))
statuses: frozenset[Participation] = field(
default_factory=lambda: frozenset({Participation.ACCEPTED, Participation.UNKNOWN}),
)
exclude_patterns: tuple[re.Pattern[str], ...] = field(
default_factory=lambda: tuple(re.compile(pattern) for pattern in DEFAULT_EXCLUDES),
)
Expand Down
25 changes: 24 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from calque import cli
from calque.config import Config
from calque.errors import CalendarError, ServiceError
from calque.model import Participation


def test_parse_arguments_collects_the_calendars_in_order() -> None:
Expand Down Expand Up @@ -65,6 +66,28 @@ def test_parse_arguments_defaults_muted_to_empty() -> None:
assert cli.parse_arguments(["Personal", "Work"]).muted == frozenset()


def test_parse_arguments_defaults_statuses_to_accepted_and_unknown() -> None:
assert cli.parse_arguments(["Personal", "Work"]).statuses == frozenset(
{Participation.ACCEPTED, Participation.UNKNOWN},
)


def test_parse_arguments_collects_statuses_into_participation_members() -> None:
# The variadic --statuses must follow the positionals, or it swallows them.
parsed = cli.parse_arguments(["Personal", "Work", "--statuses", "accepted", "tentative"])
assert parsed.statuses == frozenset({Participation.ACCEPTED, Participation.TENTATIVE})


def test_parse_arguments_rejects_an_unknown_status() -> None:
with pytest.raises(SystemExit):
cli.parse_arguments(["Personal", "Work", "--statuses", "maybe"])


def test_to_config_projects_statuses() -> None:
config = cli.to_config(cli.parse_arguments(["Personal", "Work", "--statuses", "declined"]))
assert config.statuses == frozenset({Participation.DECLINED})


def test_to_config_projects_shared_options_and_keeps_defaults_for_the_rest() -> None:
options = cli.parse_arguments(
[
Expand All @@ -85,7 +108,7 @@ def test_to_config_projects_shared_options_and_keeps_defaults_for_the_rest() ->
assert config.title_to == {"Work": "{title}"}
assert config.lookback == 3
assert config.exclude_all_day is False
assert config.statuses == Config().statuses # a field with no CLI option falls back to its default
assert config.work_start == Config().work_start # a field with no CLI option falls back to its default


def test_main_lists_calendars_and_exits(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
Expand Down
5 changes: 3 additions & 2 deletions tests/test_exclusions.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,15 +209,16 @@ def test_by_participation_excludes_unmirrored_statuses(start: datetime) -> None:


def test_rules_excludes_source_events_by_participation(start: datetime) -> None:
exclusions = rules(Config(), (), TARGET) # default statuses mirror ACCEPTED only
exclusions = rules(Config(statuses=frozenset({Participation.ACCEPTED, Participation.UNKNOWN})), (), TARGET)
assert excluded(event("tentative", start, participation=Participation.TENTATIVE), exclusions)
assert not excluded(event("accepted", start), exclusions)
assert not excluded(event("unknown", start, participation=Participation.UNKNOWN), exclusions)


def test_rules_ignores_busy_periods_with_unmirrored_participation(start: datetime) -> None:
# A declined block in the target is not genuine busy, so it does not block a source event.
declined = event("declined block", start, participation=Participation.DECLINED)
exclusions = rules(Config(), (declined,), TARGET)
exclusions = rules(Config(statuses=frozenset({Participation.ACCEPTED, Participation.UNKNOWN})), (declined,), TARGET)
assert not excluded(event("Standup", start), exclusions)


Expand Down
Loading