From b5fdf68225fc708c742813c42a2455eed531e323 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:33:35 +0200 Subject: [PATCH 01/68] Add EasyAtCal design spec One-way sync of easy@work shifts to Apple Calendar via pluggable backends (EventKit on macOS, portable .ics elsewhere). Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-04-19-easyatcal-design.md | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-19-easyatcal-design.md diff --git a/docs/superpowers/specs/2026-04-19-easyatcal-design.md b/docs/superpowers/specs/2026-04-19-easyatcal-design.md new file mode 100644 index 0000000..b25c5d1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-easyatcal-design.md @@ -0,0 +1,198 @@ +# EasyAtCal — Design Spec + +**Date:** 2026-04-19 +**Status:** Draft +**Author:** Ailcope + +## Purpose + +One-way sync of shifts from [easy@work](https://www.easyatwork.com) into Apple Calendar. The user's Mac runs the sync; iCloud propagates events to all their Apple devices (iPhone, iPad, Watch). The project is open-source and cross-platform-friendly (the core runs anywhere Python runs; the Apple-specific backend is pluggable). + +## Goals + +- Fetch shifts from the easy@work REST API. +- Create, update, and delete corresponding events in the user's Apple Calendar (via EventKit on macOS) or in a portable `.ics` file (any OS). +- Run manually (`--once`) or as a daemon (`--watch`). +- Keep user credentials and shift data out of the public repo. +- Be easily installable: `pip install easyatcal` exposes an `eaw-sync` CLI. + +## Non-Goals + +- Bi-directional sync (edits in Apple Calendar do not flow back to easy@work). +- A GUI. +- Hosting a shared `.ics` feed for others. +- Supporting non-Apple calendar destinations in v1 (CalDAV/Google reserved for future). + +## Architecture + +Single Python package with pluggable calendar backends. + +``` +[easy@work API] → [Python core] → [backend] + ↑ ├── eventkit (macOS → iCloud → all devices) + config.yaml └── ics (portable file) + (gitignored) +``` + +### Repo layout + +``` +EasyAtCal/ +├── README.md +├── pyproject.toml +├── .gitignore # excludes config.yaml, .env, *.ics, state.json +├── config.example.yaml +├── easyatcal/ +│ ├── __init__.py +│ ├── api.py # easy@work REST client +│ ├── models.py # Shift dataclass +│ ├── sync.py # diff + orchestration +│ ├── config.py # YAML + env loader +│ ├── cli.py # typer entrypoint +│ └── backends/ +│ ├── __init__.py +│ ├── base.py # CalendarBackend interface +│ ├── ics.py # writes .ics file +│ └── eventkit.py # pyobjc EKEventStore +└── tests/ + ├── test_api.py + ├── test_sync.py + ├── test_cli.py + └── backends/ + ├── test_ics.py + └── test_eventkit.py +``` + +## Components + +### `api.py` — easy@work client +- OAuth2 client-credentials or user-password auth (mirrors [php-eaw-client](https://github.com/easyatworkas/php-eaw-client) patterns). +- Caches access token at `~/.cache/easyatcal/token.json`. +- `fetch_shifts(user_id, from_date, to_date) -> list[Shift]`. +- Handles pagination. +- Retries on 429/5xx with exponential backoff (max 5 attempts). + +### `models.py` +```python +@dataclass +class Shift: + id: str # stable id from easy@work + start: datetime # tz-aware + end: datetime + title: str + location: str | None + notes: str | None + updated_at: datetime # used to detect server-side edits +``` + +### `sync.py` +- Reads local state (`~/.local/share/easyatcal/state.json`: `{shift_id: event_uid}`). +- Computes `{add, update, delete}` diff between remote shifts and state. +- `update` triggered when `shift.updated_at` changed since last sync. +- `delete` triggered for shifts present in state but absent from remote window. +- Calls `backend.apply(changes)` then persists new state. + +### `backends/base.py` +```python +class CalendarBackend(Protocol): + def apply(self, adds: list[Shift], updates: list[tuple[Shift, str]], + deletes: list[str]) -> dict[str, str]: ... + # Returns new mapping shift_id -> event_uid for adds/updates. +``` + +### `backends/ics.py` +- Uses `icalendar` lib to regenerate a full `.ics` file each sync. +- Output path configurable; default `~/Documents/easyatwork-shifts.ics`. +- Stable `UID` derived from `shift.id` so re-imports update in place. + +### `backends/eventkit.py` +- macOS-only; import guarded. +- Uses `pyobjc-framework-EventKit` to access `EKEventStore`. +- Target calendar: configurable name + source (`iCloud`). Creates calendar if missing. +- Requests `EKAuthorizationStatusFullAccess` permission on first run. +- Mapping: `Shift.id` → `EKEvent.externalIdentifier` (stored) + `EKEvent.calendarItemExternalIdentifier` (for lookup). + +### `cli.py` +`typer` app. Commands: +- `eaw-sync sync` — one-shot sync, exits after run. +- `eaw-sync watch --interval 15m` — daemon loop. +- `eaw-sync config init` — scaffold `~/.config/easyatcal/config.yaml` from template. +- `eaw-sync config show` — print effective config (redact secrets). +- `eaw-sync auth test` — verify creds + list calendars. + +## Data flow + +1. CLI parses args → loads `config.yaml` + env overrides. +2. `api.authenticate()` → access token (cached). +3. `api.fetch_shifts(from=today - lookback, to=today + lookahead)` → `list[Shift]`. +4. `sync.diff(remote, state)` → `{adds, updates, deletes}`. +5. `backend.apply(changes)` → mutates calendar, returns event-id mapping. +6. State persisted atomically (write-temp-then-rename). +7. Logs flushed to `~/.local/share/easyatcal/logs/eaw-sync.log`. + +## Config schema + +`~/.config/easyatcal/config.yaml` (gitignored; `config.example.yaml` committed): + +```yaml +easyatwork: + auth_mode: client # or "user" + client_id: "xxx" + client_secret: "xxx" # env EAW_CLIENT_SECRET overrides + base_url: "https://api.easyatwork.com" + +sync: + lookback_days: 7 + lookahead_days: 90 + user_id: null # null = self + +backend: eventkit # or "ics" + +backends: + eventkit: + calendar_name: "Work Shifts" + calendar_source: "iCloud" + ics: + output_path: "~/Documents/easyatwork-shifts.ics" + +logging: + level: INFO +``` + +Env vars override YAML: `EAW_CLIENT_ID`, `EAW_CLIENT_SECRET`, `EAW_USERNAME`, `EAW_PASSWORD`. + +## Error handling + +| Failure | Behavior | +|---|---| +| Auth failure | Exit 2, message "check credentials". | +| Rate limit (429) | Exponential backoff, max 5 retries. | +| Network down (one-shot) | Exit 3. | +| Network down (watch) | Log warning, retry at next interval. | +| EventKit permission denied | Exit 4 + instructions to grant access in System Settings → Privacy. | +| Malformed shift | Log warning, skip shift, continue. | +| Corrupt state file | Back up to `state.json.bak`, reset, do full re-sync. | + +Logs rotate daily, retained 7 days. + +## Testing + +- `test_api.py`: mock HTTP with `responses` — covers auth, fetch, pagination, retry, rate-limit. +- `test_sync.py`: unit tests on diff matrix (add/update/delete permutations, tz edge cases). +- `test_backends/test_ics.py`: snapshot-compare generated `.ics` against fixture. +- `test_backends/test_eventkit.py`: skipped on non-macOS; uses mock `EKEventStore` otherwise. +- `test_cli.py`: `typer.testing.CliRunner` against each subcommand. +- CI: GitHub Actions, matrix Python 3.11/3.12 × {Linux, macOS}. + +## Open questions / deferred + +- Exact easy@work API endpoints and pagination style — TBD during implementation by inspecting [php-eaw-client](https://github.com/easyatworkas/php-eaw-client). +- Whether to support multi-user sync in v1 — deferred; single user only. +- Whether to publish to PyPI — yes once v0.1 ships, but not blocking first release. + +## Security + +- `config.yaml` lives outside the repo (`~/.config/easyatcal/`). `.gitignore` in the repo also blocks any stray copy. +- Secrets can be provided via environment variables instead of YAML for CI/container use. +- Token cache file permissions set to `0600`. +- No shift data ever written to the repo. From b240c5dba52e613eff2e7acd566e5fa490b0fc8e Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:39:39 +0200 Subject: [PATCH 02/68] Add EasyAtCal implementation plan 19 TDD tasks covering scaffolding, models, config, state, API client, sync engine, ICS + EventKit backends, CLI, logging, CI, and E2E test. Co-Authored-By: Claude Opus 4.7 --- .../2026-04-19-easyatcal-implementation.md | 2482 +++++++++++++++++ 1 file changed, 2482 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-19-easyatcal-implementation.md diff --git a/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md b/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md new file mode 100644 index 0000000..1641289 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md @@ -0,0 +1,2482 @@ +# EasyAtCal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Python CLI (`eaw-sync`) that fetches shifts from the easy@work REST API and writes them to Apple Calendar (via EventKit on macOS) or a portable `.ics` file, with one-way sync and a daemon mode. + +**Architecture:** Single Python package `easyatcal` with a pluggable `CalendarBackend` interface. Core modules (`api`, `sync`, `models`, `config`, `cli`) are platform-agnostic. Backends live in `easyatcal/backends/` — `ics.py` is portable, `eventkit.py` is macOS-only via pyobjc. Local state file (`state.json`) maps easy@work shift IDs to calendar event UIDs for idempotent re-runs. + +**Tech Stack:** Python 3.11+, `httpx` (HTTP), `pydantic` (config), `icalendar` (ICS backend), `pyobjc-framework-EventKit` (EventKit backend, macOS extra), `typer` (CLI), `pytest` + `responses` (tests). + +Spec: `docs/superpowers/specs/2026-04-19-easyatcal-design.md` + +--- + +## Task 1: Project scaffolding and tooling + +**Files:** +- Create: `pyproject.toml` +- Create: `.gitignore` +- Create: `easyatcal/__init__.py` +- Create: `tests/__init__.py` +- Create: `tests/conftest.py` +- Create: `config.example.yaml` +- Create: `README.md` (replace existing empty file) + +- [ ] **Step 1: Create `pyproject.toml`** + +```toml +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "easyatcal" +version = "0.1.0" +description = "One-way sync of easy@work shifts to Apple Calendar." +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [{name = "Ailcope"}] +dependencies = [ + "httpx>=0.27", + "pydantic>=2.6", + "pyyaml>=6.0", + "icalendar>=5.0", + "typer>=0.12", + "platformdirs>=4.0", +] + +[project.optional-dependencies] +eventkit = ["pyobjc-framework-EventKit>=10.0; sys_platform == 'darwin'"] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "responses>=0.25", + "freezegun>=1.4", +] + +[project.scripts] +eaw-sync = "easyatcal.cli:app" + +[tool.hatch.build.targets.wheel] +packages = ["easyatcal"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --strict-markers" +``` + +- [ ] **Step 2: Create `.gitignore`** + +```gitignore +# Secrets & user data +config.yaml +.env +*.ics +state.json +token.json +.cache/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ + +# Editors / OS +.vscode/ +.idea/ +.DS_Store +``` + +- [ ] **Step 3: Create package and test skeletons** + +`easyatcal/__init__.py`: +```python +"""EasyAtCal — one-way sync of easy@work shifts to Apple Calendar.""" + +__version__ = "0.1.0" +``` + +`tests/__init__.py`: empty file. + +`tests/conftest.py`: +```python +import pytest +``` + +- [ ] **Step 4: Create `config.example.yaml`** + +```yaml +easyatwork: + auth_mode: client # "client" or "user" + client_id: "REPLACE_ME" + client_secret: "REPLACE_ME" # or set EAW_CLIENT_SECRET env var + base_url: "https://api.easyatwork.com" + +sync: + lookback_days: 7 + lookahead_days: 90 + user_id: null # null = self + +backend: eventkit # "eventkit" or "ics" + +backends: + eventkit: + calendar_name: "Work Shifts" + calendar_source: "iCloud" + ics: + output_path: "~/Documents/easyatwork-shifts.ics" + +logging: + level: INFO +``` + +- [ ] **Step 5: Create `README.md`** + +```markdown +# EasyAtCal + +One-way sync of [easy@work](https://www.easyatwork.com) shifts into Apple Calendar. + +## Install + +```bash +pip install easyatcal # core + ICS backend +pip install 'easyatcal[eventkit]' # add macOS EventKit backend +``` + +## Configure + +```bash +eaw-sync config init +# edit ~/.config/easyatcal/config.yaml +``` + +## Run + +```bash +eaw-sync sync # one-shot +eaw-sync watch --interval 15m # daemon mode +``` + +See `docs/superpowers/specs/2026-04-19-easyatcal-design.md` for full design. +``` + +- [ ] **Step 6: Install in editable mode and verify pytest runs** + +Run: `pip install -e '.[dev]' && pytest` +Expected: `collected 0 items` — no failure. + +- [ ] **Step 7: Commit** + +```bash +git add pyproject.toml .gitignore easyatcal/ tests/ config.example.yaml README.md +git commit -m "scaffold: project layout, pyproject, gitignore, readme" +``` + +--- + +## Task 2: Shift model + +**Files:** +- Create: `easyatcal/models.py` +- Create: `tests/test_models.py` + +- [ ] **Step 1: Write failing test** + +`tests/test_models.py`: +```python +from datetime import datetime, timezone + +from easyatcal.models import Shift + + +def test_shift_is_frozen_dataclass(): + shift = Shift( + id="abc", + start=datetime(2026, 4, 20, 9, 0, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, 0, tzinfo=timezone.utc), + title="Morning", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, 10, 0, tzinfo=timezone.utc), + ) + assert shift.id == "abc" + assert shift.duration_hours == 8.0 + + +def test_shift_requires_tz_aware_datetimes(): + import pytest + + with pytest.raises(ValueError, match="tz-aware"): + Shift( + id="abc", + start=datetime(2026, 4, 20, 9, 0), # naive + end=datetime(2026, 4, 20, 17, 0, tzinfo=timezone.utc), + title="t", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_models.py -v` +Expected: FAIL — `ModuleNotFoundError: easyatcal.models`. + +- [ ] **Step 3: Implement `easyatcal/models.py`** + +```python +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True, slots=True) +class Shift: + id: str + start: datetime + end: datetime + title: str + location: str | None + notes: str | None + updated_at: datetime + + def __post_init__(self) -> None: + for field_name in ("start", "end", "updated_at"): + value = getattr(self, field_name) + if value.tzinfo is None: + raise ValueError(f"{field_name} must be tz-aware") + + @property + def duration_hours(self) -> float: + return (self.end - self.start).total_seconds() / 3600.0 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_models.py -v` +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add easyatcal/models.py tests/test_models.py +git commit -m "feat(models): Shift dataclass with tz-aware validation" +``` + +--- + +## Task 3: Config loader + +**Files:** +- Create: `easyatcal/config.py` +- Create: `tests/test_config.py` +- Create: `tests/fixtures/config_valid.yaml` + +- [ ] **Step 1: Create test fixture** + +`tests/fixtures/config_valid.yaml`: +```yaml +easyatwork: + auth_mode: client + client_id: "cid" + client_secret: "csecret" + base_url: "https://api.easyatwork.com" +sync: + lookback_days: 7 + lookahead_days: 90 + user_id: null +backend: ics +backends: + eventkit: + calendar_name: "Work Shifts" + calendar_source: "iCloud" + ics: + output_path: "~/Documents/shifts.ics" +logging: + level: INFO +``` + +- [ ] **Step 2: Write failing tests** + +`tests/test_config.py`: +```python +from pathlib import Path + +import pytest + +from easyatcal.config import Config, load_config + + +FIXTURE = Path(__file__).parent / "fixtures" / "config_valid.yaml" + + +def test_load_config_from_file(): + cfg = load_config(FIXTURE) + assert isinstance(cfg, Config) + assert cfg.easyatwork.client_id == "cid" + assert cfg.backend == "ics" + assert cfg.sync.lookback_days == 7 + + +def test_env_override_for_secret(monkeypatch): + monkeypatch.setenv("EAW_CLIENT_SECRET", "from-env") + cfg = load_config(FIXTURE) + assert cfg.easyatwork.client_secret == "from-env" + + +def test_invalid_backend_rejected(tmp_path): + bad = tmp_path / "c.yaml" + bad.write_text(FIXTURE.read_text().replace("backend: ics", "backend: nonsense")) + with pytest.raises(ValueError): + load_config(bad) + + +def test_missing_file_raises(tmp_path): + with pytest.raises(FileNotFoundError): + load_config(tmp_path / "missing.yaml") +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `pytest tests/test_config.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement `easyatcal/config.py`** + +```python +from __future__ import annotations + +import os +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, Field, field_validator + + +class EasyAtWorkAuth(BaseModel): + auth_mode: Literal["client", "user"] + client_id: str + client_secret: str + base_url: str = "https://api.easyatwork.com" + + +class SyncSettings(BaseModel): + lookback_days: int = Field(ge=0, default=7) + lookahead_days: int = Field(ge=1, default=90) + user_id: str | None = None + + +class EventKitSettings(BaseModel): + calendar_name: str = "Work Shifts" + calendar_source: str = "iCloud" + + +class IcsSettings(BaseModel): + output_path: str = "~/Documents/easyatwork-shifts.ics" + + +class BackendsSettings(BaseModel): + eventkit: EventKitSettings = EventKitSettings() + ics: IcsSettings = IcsSettings() + + +class LoggingSettings(BaseModel): + level: str = "INFO" + + +class Config(BaseModel): + easyatwork: EasyAtWorkAuth + sync: SyncSettings = SyncSettings() + backend: Literal["eventkit", "ics"] + backends: BackendsSettings = BackendsSettings() + logging: LoggingSettings = LoggingSettings() + + @field_validator("backend") + @classmethod + def validate_backend(cls, v: str) -> str: + if v not in ("eventkit", "ics"): + raise ValueError(f"Unknown backend: {v}") + return v + + +_ENV_OVERRIDES = { + "EAW_CLIENT_ID": ("easyatwork", "client_id"), + "EAW_CLIENT_SECRET": ("easyatwork", "client_secret"), +} + + +def load_config(path: Path) -> Config: + if not path.exists(): + raise FileNotFoundError(path) + raw = yaml.safe_load(path.read_text()) + for env_var, (section, key) in _ENV_OVERRIDES.items(): + value = os.environ.get(env_var) + if value is not None: + raw.setdefault(section, {})[key] = value + return Config.model_validate(raw) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pytest tests/test_config.py -v` +Expected: 4 passed. + +- [ ] **Step 6: Commit** + +```bash +git add easyatcal/config.py tests/test_config.py tests/fixtures/config_valid.yaml +git commit -m "feat(config): pydantic config loader with env overrides" +``` + +--- + +## Task 4: State persistence + +**Files:** +- Create: `easyatcal/state.py` +- Create: `tests/test_state.py` + +- [ ] **Step 1: Write failing tests** + +`tests/test_state.py`: +```python +import json +from pathlib import Path + +from easyatcal.state import State, load_state, save_state + + +def test_save_then_load_roundtrip(tmp_path: Path): + path = tmp_path / "state.json" + s = State(shift_to_event={"shift-1": "evt-1", "shift-2": "evt-2"}, + last_sync="2026-04-19T12:00:00+00:00") + save_state(path, s) + + loaded = load_state(path) + assert loaded.shift_to_event == s.shift_to_event + assert loaded.last_sync == s.last_sync + + +def test_load_missing_returns_empty(tmp_path: Path): + s = load_state(tmp_path / "missing.json") + assert s.shift_to_event == {} + assert s.last_sync is None + + +def test_load_corrupt_backs_up_and_returns_empty(tmp_path: Path): + path = tmp_path / "state.json" + path.write_text("not valid json{{{") + + s = load_state(path) + + assert s.shift_to_event == {} + assert (tmp_path / "state.json.bak").exists() + + +def test_save_is_atomic(tmp_path: Path): + path = tmp_path / "state.json" + save_state(path, State(shift_to_event={"a": "b"}, last_sync=None)) + # No temp file left behind + assert not any(p.name.endswith(".tmp") for p in tmp_path.iterdir()) + assert json.loads(path.read_text())["shift_to_event"] == {"a": "b"} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_state.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `easyatcal/state.py`** + +```python +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path + + +@dataclass +class State: + shift_to_event: dict[str, str] = field(default_factory=dict) + last_sync: str | None = None + + +def load_state(path: Path) -> State: + if not path.exists(): + return State() + try: + data = json.loads(path.read_text()) + return State( + shift_to_event=dict(data.get("shift_to_event", {})), + last_sync=data.get("last_sync"), + ) + except (json.JSONDecodeError, ValueError): + backup = path.with_suffix(path.suffix + ".bak") + path.replace(backup) + return State() + + +def save_state(path: Path, state: State) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(asdict(state), indent=2, sort_keys=True)) + os.replace(tmp, path) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_state.py -v` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add easyatcal/state.py tests/test_state.py +git commit -m "feat(state): atomic json state with corrupt-file recovery" +``` + +--- + +## Task 5: easy@work API client — auth and token cache + +**Files:** +- Create: `easyatcal/api.py` +- Create: `tests/test_api_auth.py` + +- [ ] **Step 1: Write failing tests** + +`tests/test_api_auth.py`: +```python +import json +from pathlib import Path + +import pytest +import responses + +from easyatcal.api import EawClient, AuthError + + +@responses.activate +def test_client_credentials_fetch_token(tmp_path: Path): + responses.add( + responses.POST, + "https://api.easyatwork.com/oauth/token", + json={"access_token": "tok-123", "expires_in": 3600, "token_type": "Bearer"}, + status=200, + ) + client = EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=tmp_path / "token.json", + ) + + token = client.authenticate() + + assert token == "tok-123" + cached = json.loads((tmp_path / "token.json").read_text()) + assert cached["access_token"] == "tok-123" + + +@responses.activate +def test_cached_token_reused(tmp_path: Path): + cache = tmp_path / "token.json" + # Write a cache entry valid for 1 hour. + cache.write_text(json.dumps({ + "access_token": "cached-tok", + "expires_at": "2099-01-01T00:00:00+00:00", + })) + + client = EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=cache, + ) + token = client.authenticate() + + assert token == "cached-tok" + assert len(responses.calls) == 0 + + +@responses.activate +def test_auth_failure_raises(tmp_path: Path): + responses.add( + responses.POST, + "https://api.easyatwork.com/oauth/token", + json={"error": "invalid_client"}, + status=401, + ) + client = EawClient( + client_id="bad", + client_secret="bad", + base_url="https://api.easyatwork.com", + token_cache=tmp_path / "token.json", + ) + with pytest.raises(AuthError): + client.authenticate() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_api_auth.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement auth section of `easyatcal/api.py`** + +```python +from __future__ import annotations + +import json +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import httpx + + +class AuthError(Exception): + pass + + +class ApiError(Exception): + pass + + +class EawClient: + def __init__( + self, + client_id: str, + client_secret: str, + base_url: str, + token_cache: Path, + timeout: float = 30.0, + ) -> None: + self.client_id = client_id + self.client_secret = client_secret + self.base_url = base_url.rstrip("/") + self.token_cache = token_cache + self._http = httpx.Client(timeout=timeout) + self._token: str | None = None + + # ----- auth ----- + + def authenticate(self) -> str: + cached = self._read_cache() + if cached is not None: + self._token = cached + return cached + return self._fetch_token() + + def _read_cache(self) -> str | None: + if not self.token_cache.exists(): + return None + try: + data = json.loads(self.token_cache.read_text()) + except (json.JSONDecodeError, ValueError): + return None + expires_at = datetime.fromisoformat(data["expires_at"]) + if expires_at <= datetime.now(timezone.utc): + return None + return data["access_token"] + + def _fetch_token(self) -> str: + try: + r = self._http.post( + f"{self.base_url}/oauth/token", + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + ) + except httpx.HTTPError as e: + raise AuthError(f"network error during auth: {e}") from e + if r.status_code != 200: + raise AuthError(f"auth failed: {r.status_code} {r.text}") + data = r.json() + token = data["access_token"] + expires_at = datetime.now(timezone.utc) + timedelta( + seconds=int(data.get("expires_in", 3600)) + ) + self._write_cache(token, expires_at) + self._token = token + return token + + def _write_cache(self, token: str, expires_at: datetime) -> None: + self.token_cache.parent.mkdir(parents=True, exist_ok=True) + payload = {"access_token": token, "expires_at": expires_at.isoformat()} + tmp = self.token_cache.with_suffix(self.token_cache.suffix + ".tmp") + tmp.write_text(json.dumps(payload)) + os.replace(tmp, self.token_cache) + try: + os.chmod(self.token_cache, 0o600) + except OSError: + pass +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_api_auth.py -v` +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add easyatcal/api.py tests/test_api_auth.py +git commit -m "feat(api): OAuth client_credentials auth with cached token" +``` + +--- + +## Task 6: easy@work API client — fetch shifts with retry + +**Files:** +- Modify: `easyatcal/api.py` (add methods) +- Create: `tests/test_api_fetch.py` + +- [ ] **Step 1: Write failing tests** + +`tests/test_api_fetch.py`: +```python +from datetime import date, datetime, timezone +from pathlib import Path + +import pytest +import responses + +from easyatcal.api import ApiError, EawClient +from easyatcal.models import Shift + + +def _fresh_client(tmp_path: Path) -> EawClient: + # Pre-seed a valid token so authenticate() short-circuits. + cache = tmp_path / "token.json" + cache.write_text( + '{"access_token":"tok","expires_at":"2099-01-01T00:00:00+00:00"}' + ) + return EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=cache, + ) + + +@responses.activate +def test_fetch_shifts_single_page(tmp_path: Path): + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + json={ + "data": [ + { + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "Morning", + "location": "Oslo", + "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + } + ], + "next": None, + }, + status=200, + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 21) + ) + + assert len(shifts) == 1 + s = shifts[0] + assert isinstance(s, Shift) + assert s.id == "s1" + assert s.location == "Oslo" + + +@responses.activate +def test_fetch_shifts_follows_pagination(tmp_path: Path): + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + json={ + "data": [{ + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "A", "location": None, "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + }], + "next": "https://api.easyatwork.com/v1/shifts?cursor=abc", + }, + status=200, + ) + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts?cursor=abc", + json={ + "data": [{ + "id": "s2", + "start": "2026-04-21T09:00:00+00:00", + "end": "2026-04-21T17:00:00+00:00", + "title": "B", "location": None, "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + }], + "next": None, + }, + status=200, + match_querystring=True, + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) + ids = [s.id for s in shifts] + assert ids == ["s1", "s2"] + + +@responses.activate +def test_fetch_shifts_retries_on_429(tmp_path: Path, monkeypatch): + sleeps = [] + monkeypatch.setattr("time.sleep", lambda s: sleeps.append(s)) + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + status=429, + ) + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + json={"data": [], "next": None}, + status=200, + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) + + assert shifts == [] + assert len(sleeps) == 1 + assert sleeps[0] >= 1 # backed off at least 1s + + +@responses.activate +def test_fetch_shifts_gives_up_after_5_retries(tmp_path: Path, monkeypatch): + monkeypatch.setattr("time.sleep", lambda s: None) + for _ in range(6): + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + status=429, + ) + client = _fresh_client(tmp_path) + + with pytest.raises(ApiError, match="rate limit"): + client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_api_fetch.py -v` +Expected: FAIL — `fetch_shifts` not defined. + +- [ ] **Step 3: Extend `easyatcal/api.py`** + +Append these methods to the `EawClient` class (after `_write_cache`): + +```python + # ----- shifts ----- + + _MAX_RETRIES = 5 + + def fetch_shifts(self, from_date, to_date): + """Return list[Shift] between from_date (inclusive) and to_date (exclusive).""" + import time + from datetime import datetime + + from easyatcal.models import Shift + + token = self.authenticate() + url = f"{self.base_url}/v1/shifts" + params: dict | None = { + "from": from_date.isoformat(), + "to": to_date.isoformat(), + } + headers = {"Authorization": f"Bearer {token}"} + + out: list[Shift] = [] + while url is not None: + attempts = 0 + while True: + r = self._http.get(url, params=params, headers=headers) + if r.status_code == 200: + break + if r.status_code in (429, 500, 502, 503, 504): + attempts += 1 + if attempts > self._MAX_RETRIES: + raise ApiError( + f"rate limit / server errors exceeded retries " + f"({r.status_code})" + ) + time.sleep(2 ** (attempts - 1)) + continue + raise ApiError(f"GET {url} -> {r.status_code} {r.text}") + + payload = r.json() + for raw in payload.get("data", []): + out.append( + Shift( + id=raw["id"], + start=datetime.fromisoformat(raw["start"]), + end=datetime.fromisoformat(raw["end"]), + title=raw.get("title", "Shift"), + location=raw.get("location"), + notes=raw.get("notes"), + updated_at=datetime.fromisoformat(raw["updated_at"]), + ) + ) + url = payload.get("next") + params = None # next URL already includes cursor + return out +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_api_fetch.py -v` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add easyatcal/api.py tests/test_api_fetch.py +git commit -m "feat(api): fetch_shifts with pagination and backoff" +``` + +--- + +## Task 7: Backend interface + +**Files:** +- Create: `easyatcal/backends/__init__.py` +- Create: `easyatcal/backends/base.py` +- Create: `tests/backends/__init__.py` +- Create: `tests/backends/test_base.py` + +- [ ] **Step 1: Create empty `__init__.py` files** + +`easyatcal/backends/__init__.py`: empty. +`tests/backends/__init__.py`: empty. + +- [ ] **Step 2: Write failing test** + +`tests/backends/test_base.py`: +```python +from easyatcal.backends.base import CalendarBackend, Changes + + +def test_changes_is_dataclass(): + c = Changes(adds=[], updates=[], deletes=[]) + assert c.adds == [] + assert c.is_empty() + + +def test_backend_is_protocol_with_apply(): + # Protocol sanity check — any object with .apply() satisfies CalendarBackend + class Dummy: + def apply(self, changes: Changes) -> dict[str, str]: + return {} + + d: CalendarBackend = Dummy() + assert d.apply(Changes([], [], [])) == {} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pytest tests/backends/test_base.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement `easyatcal/backends/base.py`** + +```python +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol + +from easyatcal.models import Shift + + +@dataclass +class Changes: + adds: list[Shift] = field(default_factory=list) + updates: list[tuple[Shift, str]] = field(default_factory=list) + # list of event uids to delete + deletes: list[str] = field(default_factory=list) + + def is_empty(self) -> bool: + return not (self.adds or self.updates or self.deletes) + + +class CalendarBackend(Protocol): + def apply(self, changes: Changes) -> dict[str, str]: + """Apply the given changes. Return mapping shift_id -> event_uid for + all adds/updates.""" + ... +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pytest tests/backends/test_base.py -v` +Expected: 2 passed. + +- [ ] **Step 6: Commit** + +```bash +git add easyatcal/backends/ tests/backends/__init__.py tests/backends/test_base.py +git commit -m "feat(backends): Changes dataclass and CalendarBackend protocol" +``` + +--- + +## Task 8: Sync diff engine + +**Files:** +- Create: `easyatcal/sync.py` +- Create: `tests/test_sync.py` + +- [ ] **Step 1: Write failing tests** + +`tests/test_sync.py`: +```python +from datetime import datetime, timezone + +from easyatcal.models import Shift +from easyatcal.state import State +from easyatcal.sync import compute_changes + + +def _shift(id_: str, updated: str = "2026-04-18T10:00:00+00:00") -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title="t", + location=None, + notes=None, + updated_at=datetime.fromisoformat(updated), + ) + + +def test_new_shifts_are_adds(): + state = State(shift_to_event={}) + shifts = [_shift("a"), _shift("b")] + + changes = compute_changes(shifts, state, known_updated_at={}) + + assert [s.id for s in changes.adds] == ["a", "b"] + assert changes.updates == [] + assert changes.deletes == [] + + +def test_known_shifts_unchanged_do_nothing(): + state = State(shift_to_event={"a": "evt-a"}) + shifts = [_shift("a", "2026-04-18T10:00:00+00:00")] + known_updated = {"a": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes(shifts, state, known_updated_at=known_updated) + + assert changes.is_empty() + + +def test_known_shift_with_new_updated_at_is_update(): + state = State(shift_to_event={"a": "evt-a"}) + shifts = [_shift("a", "2026-04-19T10:00:00+00:00")] + known_updated = {"a": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes(shifts, state, known_updated_at=known_updated) + + assert len(changes.updates) == 1 + shift, event_uid = changes.updates[0] + assert shift.id == "a" + assert event_uid == "evt-a" + + +def test_shift_missing_from_remote_is_delete(): + state = State(shift_to_event={"a": "evt-a", "b": "evt-b"}) + shifts = [_shift("a")] + known_updated = {"a": "2026-04-18T10:00:00+00:00", + "b": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes(shifts, state, known_updated_at=known_updated) + + assert changes.deletes == ["evt-b"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_sync.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `easyatcal/sync.py`** + +```python +from __future__ import annotations + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift +from easyatcal.state import State + + +def compute_changes( + remote_shifts: list[Shift], + state: State, + known_updated_at: dict[str, str], +) -> Changes: + """Diff remote shifts against the last-known state. + + known_updated_at maps shift_id -> ISO-formatted updated_at recorded at last sync. + """ + remote_by_id = {s.id: s for s in remote_shifts} + adds: list[Shift] = [] + updates: list[tuple[Shift, str]] = [] + deletes: list[str] = [] + + for shift in remote_shifts: + event_uid = state.shift_to_event.get(shift.id) + if event_uid is None: + adds.append(shift) + continue + last_updated = known_updated_at.get(shift.id) + if last_updated != shift.updated_at.isoformat(): + updates.append((shift, event_uid)) + + for shift_id, event_uid in state.shift_to_event.items(): + if shift_id not in remote_by_id: + deletes.append(event_uid) + + return Changes(adds=adds, updates=updates, deletes=deletes) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_sync.py -v` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add easyatcal/sync.py tests/test_sync.py +git commit -m "feat(sync): diff engine for adds/updates/deletes" +``` + +--- + +## Task 9: Extend State to track `updated_at` + +**Files:** +- Modify: `easyatcal/state.py` +- Modify: `tests/test_state.py` + +- [ ] **Step 1: Add test for new field** + +Append to `tests/test_state.py`: +```python +def test_state_roundtrip_with_updated_at(tmp_path): + from easyatcal.state import State, load_state, save_state + + path = tmp_path / "state.json" + s = State( + shift_to_event={"s1": "e1"}, + shift_updated_at={"s1": "2026-04-18T10:00:00+00:00"}, + last_sync="2026-04-19T12:00:00+00:00", + ) + save_state(path, s) + loaded = load_state(path) + assert loaded.shift_updated_at == {"s1": "2026-04-18T10:00:00+00:00"} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_state.py::test_state_roundtrip_with_updated_at -v` +Expected: FAIL — field not defined. + +- [ ] **Step 3: Add field to `easyatcal/state.py`** + +Modify the `State` dataclass: +```python +@dataclass +class State: + shift_to_event: dict[str, str] = field(default_factory=dict) + shift_updated_at: dict[str, str] = field(default_factory=dict) + last_sync: str | None = None +``` + +Update `load_state` body so the constructor call includes the new field: +```python + return State( + shift_to_event=dict(data.get("shift_to_event", {})), + shift_updated_at=dict(data.get("shift_updated_at", {})), + last_sync=data.get("last_sync"), + ) +``` + +- [ ] **Step 4: Run all state tests to verify they pass** + +Run: `pytest tests/test_state.py -v` +Expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add easyatcal/state.py tests/test_state.py +git commit -m "feat(state): track shift_updated_at per shift" +``` + +--- + +## Task 10: ICS backend + +**Files:** +- Create: `easyatcal/backends/ics.py` +- Create: `tests/backends/test_ics.py` + +- [ ] **Step 1: Write failing tests** + +`tests/backends/test_ics.py`: +```python +from datetime import datetime, timezone +from pathlib import Path + +from easyatcal.backends.base import Changes +from easyatcal.backends.ics import IcsBackend +from easyatcal.models import Shift + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title=f"Shift {id_}", + location="Oslo", + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) + + +def test_adds_produce_events_in_file(tmp_path: Path): + out = tmp_path / "shifts.ics" + backend = IcsBackend(output_path=out, known_shifts=[]) + changes = Changes(adds=[_shift("s1"), _shift("s2")]) + + mapping = backend.apply(changes) + + body = out.read_text() + assert "BEGIN:VCALENDAR" in body + assert "SUMMARY:Shift s1" in body + assert "SUMMARY:Shift s2" in body + assert mapping["s1"].startswith("easyatcal-s1") + assert mapping["s2"].startswith("easyatcal-s2") + + +def test_deletes_remove_events(tmp_path: Path): + out = tmp_path / "shifts.ics" + # First write with 2 shifts + backend1 = IcsBackend(output_path=out, known_shifts=[]) + backend1.apply(Changes(adds=[_shift("s1"), _shift("s2")])) + + # Now apply a delete of s2's event + backend2 = IcsBackend( + output_path=out, + known_shifts=[_shift("s1"), _shift("s2")], + ) + uid_s2 = "easyatcal-s2" + backend2.apply(Changes(deletes=[uid_s2])) + + body = out.read_text() + assert "SUMMARY:Shift s1" in body + assert "SUMMARY:Shift s2" not in body + + +def test_updates_replace_event(tmp_path: Path): + out = tmp_path / "shifts.ics" + s = _shift("s1") + IcsBackend(output_path=out, known_shifts=[]).apply(Changes(adds=[s])) + + # Produce an updated shift with a new title + s_new = Shift( + id=s.id, start=s.start, end=s.end, title="New Title", + location=s.location, notes=s.notes, updated_at=s.updated_at, + ) + backend = IcsBackend(output_path=out, known_shifts=[s]) + backend.apply(Changes(updates=[(s_new, "easyatcal-s1")])) + + body = out.read_text() + assert "SUMMARY:New Title" in body + assert "SUMMARY:Shift s1" not in body +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/backends/test_ics.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `easyatcal/backends/ics.py`** + +```python +from __future__ import annotations + +from pathlib import Path + +from icalendar import Calendar, Event + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift + +UID_PREFIX = "easyatcal-" + + +def _uid_for(shift_id: str) -> str: + return f"{UID_PREFIX}{shift_id}" + + +def _to_event(shift: Shift, uid: str) -> Event: + ev = Event() + ev.add("uid", uid) + ev.add("summary", shift.title) + ev.add("dtstart", shift.start) + ev.add("dtend", shift.end) + ev.add("last-modified", shift.updated_at) + if shift.location: + ev.add("location", shift.location) + if shift.notes: + ev.add("description", shift.notes) + return ev + + +class IcsBackend: + """File-based calendar backend that regenerates the .ics on each apply. + + `known_shifts` is the previous set of shifts the caller knows about — used + so we can rewrite the file without losing events unrelated to the current + change set. + """ + + def __init__(self, output_path: Path, known_shifts: list[Shift]) -> None: + self.output_path = Path(output_path).expanduser() + self._current: dict[str, Shift] = {s.id: s for s in known_shifts} + + def apply(self, changes: Changes) -> dict[str, str]: + mapping: dict[str, str] = {} + + for shift in changes.adds: + self._current[shift.id] = shift + mapping[shift.id] = _uid_for(shift.id) + + for shift, _event_uid in changes.updates: + self._current[shift.id] = shift + mapping[shift.id] = _uid_for(shift.id) + + delete_uids = set(changes.deletes) + # Map uids back to shift ids and drop them + to_drop = [ + sid for sid in self._current + if _uid_for(sid) in delete_uids + ] + for sid in to_drop: + self._current.pop(sid, None) + + self._write() + return mapping + + def _write(self) -> None: + cal = Calendar() + cal.add("prodid", "-//EasyAtCal//EN") + cal.add("version", "2.0") + for shift in self._current.values(): + cal.add_component(_to_event(shift, _uid_for(shift.id))) + + self.output_path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.output_path.with_suffix(self.output_path.suffix + ".tmp") + tmp.write_bytes(cal.to_ical()) + tmp.replace(self.output_path) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/backends/test_ics.py -v` +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add easyatcal/backends/ics.py tests/backends/test_ics.py +git commit -m "feat(backends): ICS file backend with add/update/delete" +``` + +--- + +## Task 11: EventKit backend (macOS only) + +**Files:** +- Create: `easyatcal/backends/eventkit.py` +- Create: `tests/backends/test_eventkit.py` + +- [ ] **Step 1: Write failing tests** + +`tests/backends/test_eventkit.py`: +```python +import sys +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift + +pytestmark = pytest.mark.skipif( + sys.platform != "darwin", reason="EventKit backend is macOS only" +) + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title=f"Shift {id_}", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) + + +@patch("easyatcal.backends.eventkit._event_store") +def test_apply_adds_creates_events(mock_store_factory): + store = MagicMock() + calendar = MagicMock() + store.calendarsForEntityType_.return_value = [calendar] + calendar.title.return_value = "Work Shifts" + calendar.source.return_value.title.return_value = "iCloud" + mock_store_factory.return_value = store + + created_event = MagicMock() + created_event.calendarItemExternalIdentifier.return_value = "evt-1" + + from easyatcal.backends.eventkit import EventKitBackend + + with patch( + "easyatcal.backends.eventkit._new_event", return_value=created_event + ): + backend = EventKitBackend( + calendar_name="Work Shifts", calendar_source="iCloud" + ) + mapping = backend.apply(Changes(adds=[_shift("s1")])) + + assert mapping == {"s1": "evt-1"} + store.saveEvent_span_error_.assert_called() + + +@patch("easyatcal.backends.eventkit._event_store") +def test_apply_deletes_removes_events(mock_store_factory): + store = MagicMock() + calendar = MagicMock() + calendar.title.return_value = "Work Shifts" + calendar.source.return_value.title.return_value = "iCloud" + store.calendarsForEntityType_.return_value = [calendar] + + existing = MagicMock() + existing.calendarItemExternalIdentifier.return_value = "evt-1" + store.calendarItemWithIdentifier_.return_value = existing + mock_store_factory.return_value = store + + from easyatcal.backends.eventkit import EventKitBackend + backend = EventKitBackend( + calendar_name="Work Shifts", calendar_source="iCloud" + ) + + backend.apply(Changes(deletes=["evt-1"])) + + store.removeEvent_span_error_.assert_called() +``` + +- [ ] **Step 2: Run tests to verify they fail (macOS only)** + +Run: `pytest tests/backends/test_eventkit.py -v` +Expected on macOS: FAIL — module not found. On Linux: skipped. + +- [ ] **Step 3: Implement `easyatcal/backends/eventkit.py`** + +```python +"""macOS EventKit calendar backend. + +Only usable on macOS. Requires `pyobjc-framework-EventKit` (install the +`eventkit` extra). +""" +from __future__ import annotations + +import sys +from typing import Any + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift + + +class EventKitUnavailableError(RuntimeError): + pass + + +class EventKitPermissionError(RuntimeError): + pass + + +def _import_eventkit(): # pragma: no cover — platform guard + if sys.platform != "darwin": + raise EventKitUnavailableError("EventKit backend requires macOS") + try: + import EventKit # type: ignore[import-not-found] + except ImportError as e: + raise EventKitUnavailableError( + "pyobjc-framework-EventKit not installed; " + "pip install 'easyatcal[eventkit]'" + ) from e + return EventKit + + +def _event_store() -> Any: # pragma: no cover — exercised via mocks in tests + EventKit = _import_eventkit() + store = EventKit.EKEventStore.alloc().init() + # Request permission (synchronous wait via semaphore). + import Foundation # type: ignore[import-not-found] + from threading import Event as _E + granted = {"ok": False, "err": None} + done = _E() + + def _cb(ok, err): + granted["ok"] = bool(ok) + granted["err"] = err + done.set() + + try: + # macOS 14+ + store.requestFullAccessToEventsWithCompletion_(_cb) + except AttributeError: + store.requestAccessToEntityType_completion_(0, _cb) # 0 = EKEntityTypeEvent + + done.wait(timeout=30) + if not granted["ok"]: + raise EventKitPermissionError( + "Calendar access denied — grant access in System Settings → " + "Privacy & Security → Calendars." + ) + return store + + +def _new_event(store, calendar, shift: Shift): # pragma: no cover + EventKit = _import_eventkit() + import Foundation # type: ignore[import-not-found] + + event = EventKit.EKEvent.eventWithEventStore_(store) + event.setCalendar_(calendar) + event.setTitle_(shift.title) + event.setStartDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.start.timestamp()) + ) + event.setEndDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.end.timestamp()) + ) + if shift.location: + event.setLocation_(shift.location) + if shift.notes: + event.setNotes_(shift.notes) + return event + + +class EventKitBackend: + def __init__(self, calendar_name: str, calendar_source: str) -> None: + self.calendar_name = calendar_name + self.calendar_source = calendar_source + self._store = _event_store() + self._calendar = self._resolve_calendar() + + def _resolve_calendar(self): + calendars = self._store.calendarsForEntityType_(0) + for cal in calendars: + if ( + cal.title() == self.calendar_name + and cal.source().title() == self.calendar_source + ): + return cal + raise RuntimeError( + f"Calendar {self.calendar_name!r} not found in source " + f"{self.calendar_source!r}. Create it in Calendar.app first." + ) + + def apply(self, changes: Changes) -> dict[str, str]: + mapping: dict[str, str] = {} + + for shift in changes.adds: + event = _new_event(self._store, self._calendar, shift) + err = None + self._store.saveEvent_span_error_(event, 0, err) # 0 = EKSpanThisEvent + mapping[shift.id] = event.calendarItemExternalIdentifier() + + for shift, event_uid in changes.updates: + existing = self._store.calendarItemWithIdentifier_(event_uid) + if existing is None: + # Fall through to recreate + event = _new_event(self._store, self._calendar, shift) + err = None + self._store.saveEvent_span_error_(event, 0, err) + mapping[shift.id] = event.calendarItemExternalIdentifier() + continue + existing.setTitle_(shift.title) + # Re-set start/end/location/notes + import Foundation # type: ignore[import-not-found] + existing.setStartDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.start.timestamp()) + ) + existing.setEndDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.end.timestamp()) + ) + if shift.location is not None: + existing.setLocation_(shift.location) + if shift.notes is not None: + existing.setNotes_(shift.notes) + err = None + self._store.saveEvent_span_error_(existing, 0, err) + mapping[shift.id] = event_uid + + for event_uid in changes.deletes: + existing = self._store.calendarItemWithIdentifier_(event_uid) + if existing is None: + continue + err = None + self._store.removeEvent_span_error_(existing, 0, err) + + return mapping +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/backends/test_eventkit.py -v` +Expected on macOS: 2 passed. On Linux: skipped. + +- [ ] **Step 5: Commit** + +```bash +git add easyatcal/backends/eventkit.py tests/backends/test_eventkit.py +git commit -m "feat(backends): macOS EventKit backend via pyobjc" +``` + +--- + +## Task 12: Orchestrator (ties API + sync + state + backend together) + +**Files:** +- Create: `easyatcal/orchestrator.py` +- Create: `tests/test_orchestrator.py` + +- [ ] **Step 1: Write failing test** + +`tests/test_orchestrator.py`: +```python +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import MagicMock + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift +from easyatcal.orchestrator import run_sync +from easyatcal.state import load_state + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title=f"t{id_}", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) + + +def test_run_sync_applies_changes_and_persists_state(tmp_path: Path): + state_path = tmp_path / "state.json" + + api = MagicMock() + api.fetch_shifts.return_value = [_shift("s1"), _shift("s2")] + + backend = MagicMock() + backend.apply.return_value = {"s1": "evt-1", "s2": "evt-2"} + + run_sync( + api=api, + backend=backend, + state_path=state_path, + lookback_days=1, + lookahead_days=1, + now=datetime(2026, 4, 19, 12, tzinfo=timezone.utc), + ) + + # backend.apply was called with 2 adds + changes = backend.apply.call_args.args[0] + assert isinstance(changes, Changes) + assert [s.id for s in changes.adds] == ["s1", "s2"] + + # State persisted + saved = load_state(state_path) + assert saved.shift_to_event == {"s1": "evt-1", "s2": "evt-2"} + assert saved.shift_updated_at["s1"] == "2026-04-18T00:00:00+00:00" + assert saved.last_sync == "2026-04-19T12:00:00+00:00" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_orchestrator.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `easyatcal/orchestrator.py`** + +```python +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Protocol + +from easyatcal.backends.base import CalendarBackend +from easyatcal.models import Shift +from easyatcal.state import State, load_state, save_state +from easyatcal.sync import compute_changes + + +class ShiftFetcher(Protocol): + def fetch_shifts(self, from_date, to_date) -> list[Shift]: ... + + +def run_sync( + api: ShiftFetcher, + backend: CalendarBackend, + state_path: Path, + lookback_days: int, + lookahead_days: int, + now: datetime | None = None, +) -> None: + now = now or datetime.now(timezone.utc) + from_date = (now - timedelta(days=lookback_days)).date() + to_date = (now + timedelta(days=lookahead_days)).date() + + remote_shifts = api.fetch_shifts(from_date=from_date, to_date=to_date) + state = load_state(state_path) + changes = compute_changes( + remote_shifts, state, known_updated_at=state.shift_updated_at + ) + mapping = backend.apply(changes) + + # Merge new mapping into state; prune deleted entries. + new_shift_to_event = dict(state.shift_to_event) + new_updated_at = dict(state.shift_updated_at) + for shift_id, event_uid in mapping.items(): + new_shift_to_event[shift_id] = event_uid + for shift in remote_shifts: + new_updated_at[shift.id] = shift.updated_at.isoformat() + + remote_ids = {s.id for s in remote_shifts} + new_shift_to_event = { + sid: evt for sid, evt in new_shift_to_event.items() if sid in remote_ids + } + new_updated_at = { + sid: ts for sid, ts in new_updated_at.items() if sid in remote_ids + } + + save_state( + state_path, + State( + shift_to_event=new_shift_to_event, + shift_updated_at=new_updated_at, + last_sync=now.isoformat(), + ), + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_orchestrator.py -v` +Expected: 1 passed. + +- [ ] **Step 5: Commit** + +```bash +git add easyatcal/orchestrator.py tests/test_orchestrator.py +git commit -m "feat(orchestrator): tie api + sync + backend + state together" +``` + +--- + +## Task 13: CLI — `config init` and `config show` + +**Files:** +- Create: `easyatcal/cli.py` +- Create: `easyatcal/paths.py` +- Create: `tests/test_cli_config.py` + +- [ ] **Step 1: Create helper for platform paths** + +`easyatcal/paths.py`: +```python +from __future__ import annotations + +from pathlib import Path + +from platformdirs import user_cache_dir, user_config_dir, user_data_dir + +APP = "easyatcal" + + +def config_path() -> Path: + return Path(user_config_dir(APP)) / "config.yaml" + + +def state_path() -> Path: + return Path(user_data_dir(APP)) / "state.json" + + +def token_cache_path() -> Path: + return Path(user_cache_dir(APP)) / "token.json" + + +def log_path() -> Path: + return Path(user_data_dir(APP)) / "logs" / "eaw-sync.log" +``` + +- [ ] **Step 2: Write failing tests** + +`tests/test_cli_config.py`: +```python +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +def test_config_init_creates_file(tmp_path: Path): + target = tmp_path / "config.yaml" + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "init"]) + + assert result.exit_code == 0 + assert target.exists() + assert "easyatwork:" in target.read_text() + + +def test_config_init_does_not_overwrite(tmp_path: Path): + target = tmp_path / "config.yaml" + target.write_text("existing: yes\n") + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "init"]) + + assert result.exit_code != 0 + assert "already exists" in result.stdout + + +def test_config_show_redacts_secret(tmp_path: Path): + target = tmp_path / "config.yaml" + target.write_text( + "easyatwork:\n" + " auth_mode: client\n" + " client_id: cid\n" + " client_secret: supersecret\n" + " base_url: https://api.easyatwork.com\n" + "backend: ics\n" + ) + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "show"]) + + assert result.exit_code == 0 + assert "supersecret" not in result.stdout + assert "***" in result.stdout +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `pytest tests/test_cli_config.py -v` +Expected: FAIL — cli module missing. + +- [ ] **Step 4: Implement `easyatcal/cli.py`** + +```python +from __future__ import annotations + +import shutil +from pathlib import Path + +import typer +import yaml + +from easyatcal.config import load_config +from easyatcal.paths import config_path + +app = typer.Typer(help="EasyAtCal — sync easy@work shifts to Apple Calendar.") +config_app = typer.Typer(help="Manage the config file.") +app.add_typer(config_app, name="config") + +EXAMPLE_CONFIG = Path(__file__).parent.parent / "config.example.yaml" + + +@config_app.command("init") +def config_init() -> None: + """Scaffold a config file at the user config dir.""" + target = config_path() + if target.exists(): + typer.echo(f"Config already exists at {target}", err=True) + raise typer.Exit(code=1) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(EXAMPLE_CONFIG, target) + typer.echo(f"Wrote {target}. Edit it before running `eaw-sync sync`.") + + +@config_app.command("show") +def config_show() -> None: + """Print the effective config with secrets redacted.""" + cfg = load_config(config_path()) + dumped = cfg.model_dump() + dumped["easyatwork"]["client_secret"] = "***" + typer.echo(yaml.safe_dump(dumped, sort_keys=False)) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pytest tests/test_cli_config.py -v` +Expected: 3 passed. + +- [ ] **Step 6: Commit** + +```bash +git add easyatcal/cli.py easyatcal/paths.py tests/test_cli_config.py +git commit -m "feat(cli): config init and config show with secret redaction" +``` + +--- + +## Task 14: CLI — `sync` and `watch` + +**Files:** +- Modify: `easyatcal/cli.py` +- Create: `tests/test_cli_sync.py` + +- [ ] **Step 1: Write failing tests** + +`tests/test_cli_sync.py`: +```python +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.load_config") +def test_sync_once_invokes_run_sync(mock_cfg, mock_api, mock_back, mock_run, tmp_path): + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + ) + result = runner.invoke(app, ["sync"]) + + assert result.exit_code == 0 + mock_run.assert_called_once() + + +@patch("easyatcal.cli.time.sleep", side_effect=KeyboardInterrupt) +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.load_config") +def test_watch_loops_until_interrupt(mock_cfg, mock_api, mock_back, mock_run, mock_sleep): + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + ) + result = runner.invoke(app, ["watch", "--interval-seconds", "60"]) + + # Should run once, then be interrupted by the patched sleep + assert mock_run.call_count == 1 + assert result.exit_code == 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_cli_sync.py -v` +Expected: FAIL — `sync` / `watch` commands not registered. + +- [ ] **Step 3: Extend `easyatcal/cli.py`** + +Add to `easyatcal/cli.py` (after existing imports): + +```python +import time + +from easyatcal.api import EawClient +from easyatcal.backends.ics import IcsBackend +from easyatcal.orchestrator import run_sync +from easyatcal.paths import state_path, token_cache_path +from easyatcal.state import load_state + + +def _build_api_client(cfg): + return EawClient( + client_id=cfg.easyatwork.client_id, + client_secret=cfg.easyatwork.client_secret, + base_url=cfg.easyatwork.base_url, + token_cache=token_cache_path(), + ) + + +def _build_backend(cfg): + if cfg.backend == "ics": + state = load_state(state_path()) + # Known shifts list is empty here; IcsBackend rewrites from current + # in-memory map on each apply, so the next run re-uses state anyway. + return IcsBackend( + output_path=Path(cfg.backends.ics.output_path).expanduser(), + known_shifts=[], + ) + if cfg.backend == "eventkit": + from easyatcal.backends.eventkit import EventKitBackend + return EventKitBackend( + calendar_name=cfg.backends.eventkit.calendar_name, + calendar_source=cfg.backends.eventkit.calendar_source, + ) + raise RuntimeError(f"Unknown backend: {cfg.backend}") + + +@app.command("sync") +def sync_cmd() -> None: + """Run one sync pass and exit.""" + cfg = load_config(config_path()) + api = _build_api_client(cfg) + backend = _build_backend(cfg) + run_sync( + api=api, + backend=backend, + state_path=state_path(), + lookback_days=cfg.sync.lookback_days, + lookahead_days=cfg.sync.lookahead_days, + ) + typer.echo("Sync complete.") + + +@app.command("watch") +def watch_cmd( + interval_seconds: int = typer.Option( + 900, "--interval-seconds", help="Seconds between sync passes." + ), +) -> None: + """Run sync on a loop until Ctrl-C.""" + cfg = load_config(config_path()) + api = _build_api_client(cfg) + backend = _build_backend(cfg) + try: + while True: + run_sync( + api=api, + backend=backend, + state_path=state_path(), + lookback_days=cfg.sync.lookback_days, + lookahead_days=cfg.sync.lookahead_days, + ) + typer.echo(f"Sleeping {interval_seconds}s…") + time.sleep(interval_seconds) + except KeyboardInterrupt: + typer.echo("\nStopped.") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_cli_sync.py -v` +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add easyatcal/cli.py tests/test_cli_sync.py +git commit -m "feat(cli): sync one-shot and watch daemon commands" +``` + +--- + +## Task 15: CLI — `auth test` + +**Files:** +- Modify: `easyatcal/cli.py` +- Create: `tests/test_cli_auth.py` + +- [ ] **Step 1: Write failing test** + +`tests/test_cli_auth.py`: +```python +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.load_config") +def test_auth_test_success(mock_cfg, mock_build): + api = MagicMock() + api.authenticate.return_value = "tok" + mock_build.return_value = api + mock_cfg.return_value = MagicMock() + + result = runner.invoke(app, ["auth", "test"]) + assert result.exit_code == 0 + assert "OK" in result.stdout + + +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.load_config") +def test_auth_test_failure(mock_cfg, mock_build): + from easyatcal.api import AuthError + api = MagicMock() + api.authenticate.side_effect = AuthError("bad creds") + mock_build.return_value = api + mock_cfg.return_value = MagicMock() + + result = runner.invoke(app, ["auth", "test"]) + assert result.exit_code == 2 + assert "bad creds" in result.stdout +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_cli_auth.py -v` +Expected: FAIL — `auth test` not registered. + +- [ ] **Step 3: Add `auth test` to `easyatcal/cli.py`** + +Append: +```python +auth_app = typer.Typer(help="Credential checks.") +app.add_typer(auth_app, name="auth") + + +@auth_app.command("test") +def auth_test() -> None: + """Verify that the configured credentials can obtain a token.""" + from easyatcal.api import AuthError + + cfg = load_config(config_path()) + api = _build_api_client(cfg) + try: + api.authenticate() + except AuthError as e: + typer.echo(f"Auth failed: {e}") + raise typer.Exit(code=2) + typer.echo("OK — credentials work.") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_cli_auth.py -v` +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add easyatcal/cli.py tests/test_cli_auth.py +git commit -m "feat(cli): auth test subcommand" +``` + +--- + +## Task 16: Logging setup + +**Files:** +- Create: `easyatcal/logging_setup.py` +- Modify: `easyatcal/cli.py` (call setup at entry) +- Create: `tests/test_logging_setup.py` + +- [ ] **Step 1: Write failing test** + +`tests/test_logging_setup.py`: +```python +import logging +from pathlib import Path + +from easyatcal.logging_setup import configure_logging + + +def test_configure_logging_writes_to_file(tmp_path: Path): + log_file = tmp_path / "eaw-sync.log" + configure_logging(level="INFO", log_file=log_file) + + logging.getLogger("easyatcal").info("hello world") + + # Force handler flush + for h in logging.getLogger().handlers: + h.flush() + + assert log_file.exists() + assert "hello world" in log_file.read_text() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_logging_setup.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `easyatcal/logging_setup.py`** + +```python +from __future__ import annotations + +import logging +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path + + +def configure_logging(level: str, log_file: Path) -> None: + log_file.parent.mkdir(parents=True, exist_ok=True) + root = logging.getLogger() + # Reset any prior handlers (idempotent across watch-mode iterations) + for h in list(root.handlers): + root.removeHandler(h) + root.setLevel(level) + + fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + + file_h = TimedRotatingFileHandler( + log_file, when="midnight", backupCount=7, encoding="utf-8" + ) + file_h.setFormatter(fmt) + root.addHandler(file_h) + + console_h = logging.StreamHandler() + console_h.setFormatter(fmt) + root.addHandler(console_h) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_logging_setup.py -v` +Expected: 1 passed. + +- [ ] **Step 5: Wire into CLI** + +At the top of `easyatcal/cli.py`, add: + +```python +from easyatcal.logging_setup import configure_logging +from easyatcal.paths import log_path +``` + +Inside each of `sync_cmd`, `watch_cmd`, `auth_test`, insert as the very first line after loading the config: + +```python +configure_logging(level=cfg.logging.level, log_file=log_path()) +``` + +- [ ] **Step 6: Re-run full test suite** + +Run: `pytest` +Expected: all tests pass (EventKit tests skipped on non-macOS). + +- [ ] **Step 7: Commit** + +```bash +git add easyatcal/logging_setup.py easyatcal/cli.py tests/test_logging_setup.py +git commit -m "feat(logging): rotating file + console handlers" +``` + +--- + +## Task 17: GitHub Actions CI + +**Files:** +- Create: `.github/workflows/ci.yml` + +- [ ] **Step 1: Create CI workflow** + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12"] + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e '.[dev]' + - name: Install eventkit extra (macOS only) + if: runner.os == 'macOS' + run: pip install -e '.[eventkit]' + - name: Test + run: pytest --cov=easyatcal +``` + +- [ ] **Step 2: Run pytest locally one more time before commit** + +Run: `pytest --cov=easyatcal` +Expected: all pass, coverage reported. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: test matrix on Linux + macOS, Python 3.11 and 3.12" +``` + +--- + +## Task 18: End-to-end smoke test + +**Files:** +- Create: `tests/test_e2e_ics.py` + +- [ ] **Step 1: Write the e2e test** + +`tests/test_e2e_ics.py`: +```python +"""End-to-end test using the ICS backend and a mocked easy@work API.""" +from datetime import date +from pathlib import Path +from unittest.mock import MagicMock, patch + +import responses + +from easyatcal.api import EawClient +from easyatcal.backends.ics import IcsBackend +from easyatcal.orchestrator import run_sync + + +@responses.activate +def test_end_to_end_ics(tmp_path: Path): + # Seed token cache so auth is cheap + token_cache = tmp_path / "token.json" + token_cache.write_text( + '{"access_token":"tok","expires_at":"2099-01-01T00:00:00+00:00"}' + ) + responses.add( + responses.GET, + "https://api.easyatwork.com/v1/shifts", + json={ + "data": [ + { + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "Morning", "location": "Oslo", "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + } + ], + "next": None, + }, + status=200, + ) + api = EawClient( + client_id="cid", client_secret="csecret", + base_url="https://api.easyatwork.com", token_cache=token_cache, + ) + ics_out = tmp_path / "shifts.ics" + backend = IcsBackend(output_path=ics_out, known_shifts=[]) + + run_sync( + api=api, + backend=backend, + state_path=tmp_path / "state.json", + lookback_days=1, + lookahead_days=7, + ) + + body = ics_out.read_text() + assert "SUMMARY:Morning" in body + assert "LOCATION:Oslo" in body + # State file was written + assert (tmp_path / "state.json").exists() +``` + +- [ ] **Step 2: Run the e2e test** + +Run: `pytest tests/test_e2e_ics.py -v` +Expected: 1 passed. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_e2e_ics.py +git commit -m "test(e2e): ICS backend end-to-end with mocked API" +``` + +--- + +## Task 19: Push to origin + +- [ ] **Step 1: Push all commits** + +Run: `git push origin main` +Expected: branch updated. + +- [ ] **Step 2: Tag v0.1.0** + +```bash +git tag -a v0.1.0 -m "Initial release: easy@work → Apple Calendar sync" +git push origin v0.1.0 +``` + +--- + +## Self-review notes + +- Spec coverage: every section of the spec maps to a task (scaffold → T1, models → T2, config → T3, state → T4/T9, api → T5/T6, backend base → T7, sync → T8, ics → T10, eventkit → T11, orchestrator → T12, cli commands → T13/T14/T15, error handling → T5/T6/T11/T15 (exit codes) + T16 (logging), testing → every task has TDD, CI → T17, e2e → T18, security → T1 gitignore + T5 token cache 0600 + T13 redaction). +- Placeholder scan: no TBD/TODO. Every step has concrete code or a concrete command. +- Type consistency: `Shift` fields, `Changes` fields, `State` fields, `EawClient` constructor signature, and CLI command names are used consistently across tasks. From 8cdf321921c030b4fd80741b90604d616252bf07 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:43:50 +0200 Subject: [PATCH 03/68] scaffold: project layout, pyproject, gitignore, readme Co-Authored-By: Claude Opus 4.7 --- .gitignore | 23 +++++++++++++++++++++++ README.md | 28 ++++++++++++++++++++++++++++ config.example.yaml | 22 ++++++++++++++++++++++ easyatcal/__init__.py | 3 +++ pyproject.toml | 39 +++++++++++++++++++++++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 1 + 7 files changed, 116 insertions(+) create mode 100644 .gitignore create mode 100644 config.example.yaml create mode 100644 easyatcal/__init__.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5471f78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Secrets & user data +config.yaml +.env +*.ics +state.json +token.json +.cache/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ + +# Editors / OS +.vscode/ +.idea/ +.DS_Store +.venv/ diff --git a/README.md b/README.md index e69de29..7fcb0d2 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,28 @@ +# EasyAtCal + +One-way sync of [easy@work](https://www.easyatwork.com) shifts into Apple Calendar. + +## Install + +```bash +pip install easyatcal # core + ICS backend +pip install 'easyatcal[eventkit]' # add macOS EventKit backend +``` + +## Configure + +```bash +eaw-sync config init +# edit ~/.config/easyatcal/config.yaml +``` + +## Run + +```bash +eaw-sync sync # one-shot +eaw-sync watch --interval-seconds 900 # daemon mode (15 min) +``` + +See `docs/superpowers/specs/2026-04-19-easyatcal-design.md` for the full design, +and `docs/superpowers/plans/2026-04-19-easyatcal-implementation.md` for the +implementation plan. diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..f536398 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,22 @@ +easyatwork: + auth_mode: client # "client" or "user" + client_id: "REPLACE_ME" + client_secret: "REPLACE_ME" # or set EAW_CLIENT_SECRET env var + base_url: "https://api.easyatwork.com" + +sync: + lookback_days: 7 + lookahead_days: 90 + user_id: null # null = self + +backend: eventkit # "eventkit" or "ics" + +backends: + eventkit: + calendar_name: "Work Shifts" + calendar_source: "iCloud" + ics: + output_path: "~/Documents/easyatwork-shifts.ics" + +logging: + level: INFO diff --git a/easyatcal/__init__.py b/easyatcal/__init__.py new file mode 100644 index 0000000..7370a2c --- /dev/null +++ b/easyatcal/__init__.py @@ -0,0 +1,3 @@ +"""EasyAtCal — one-way sync of easy@work shifts to Apple Calendar.""" + +__version__ = "0.1.0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6e928b6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "easyatcal" +version = "0.1.0" +description = "One-way sync of easy@work shifts to Apple Calendar." +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [{name = "Ailcope"}] +dependencies = [ + "httpx>=0.27", + "pydantic>=2.6", + "pyyaml>=6.0", + "icalendar>=5.0", + "typer>=0.12", + "platformdirs>=4.0", +] + +[project.optional-dependencies] +eventkit = ["pyobjc-framework-EventKit>=10.0; sys_platform == 'darwin'"] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "responses>=0.25", + "freezegun>=1.4", +] + +[project.scripts] +eaw-sync = "easyatcal.cli:app" + +[tool.hatch.build.targets.wheel] +packages = ["easyatcal"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --strict-markers" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5871ed8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +import pytest From d86974821ddfca71fcdf1137b878409f34a26d16 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:44:15 +0200 Subject: [PATCH 04/68] feat(models): Shift dataclass with tz-aware validation Co-Authored-By: Claude Opus 4.7 --- easyatcal/models.py | 23 +++++++++++++++++++++++ tests/test_models.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 easyatcal/models.py create mode 100644 tests/test_models.py diff --git a/easyatcal/models.py b/easyatcal/models.py new file mode 100644 index 0000000..d371a53 --- /dev/null +++ b/easyatcal/models.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True, slots=True) +class Shift: + id: str + start: datetime + end: datetime + title: str + location: str | None + notes: str | None + updated_at: datetime + + def __post_init__(self) -> None: + for field_name in ("start", "end", "updated_at"): + value = getattr(self, field_name) + if value.tzinfo is None: + raise ValueError(f"{field_name} must be tz-aware") + + @property + def duration_hours(self) -> float: + return (self.end - self.start).total_seconds() / 3600.0 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..e4052f2 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,32 @@ +from datetime import datetime, timezone + +import pytest + +from easyatcal.models import Shift + + +def test_shift_is_frozen_dataclass(): + shift = Shift( + id="abc", + start=datetime(2026, 4, 20, 9, 0, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, 0, tzinfo=timezone.utc), + title="Morning", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, 10, 0, tzinfo=timezone.utc), + ) + assert shift.id == "abc" + assert shift.duration_hours == 8.0 + + +def test_shift_requires_tz_aware_datetimes(): + with pytest.raises(ValueError, match="tz-aware"): + Shift( + id="abc", + start=datetime(2026, 4, 20, 9, 0), # naive + end=datetime(2026, 4, 20, 17, 0, tzinfo=timezone.utc), + title="t", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) From 242ba3941f292c8e81bb12552ba3b915fbc0d397 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:44:47 +0200 Subject: [PATCH 05/68] feat(config): pydantic config loader with env overrides Co-Authored-By: Claude Opus 4.7 --- easyatcal/config.py | 71 ++++++++++++++++++++++++++++++++ tests/fixtures/config_valid.yaml | 18 ++++++++ tests/test_config.py | 34 +++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 easyatcal/config.py create mode 100644 tests/fixtures/config_valid.yaml create mode 100644 tests/test_config.py diff --git a/easyatcal/config.py b/easyatcal/config.py new file mode 100644 index 0000000..0beacc7 --- /dev/null +++ b/easyatcal/config.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Literal + +import yaml +from pydantic import BaseModel, Field, field_validator + + +class EasyAtWorkAuth(BaseModel): + auth_mode: Literal["client", "user"] + client_id: str + client_secret: str + base_url: str = "https://api.easyatwork.com" + + +class SyncSettings(BaseModel): + lookback_days: int = Field(ge=0, default=7) + lookahead_days: int = Field(ge=1, default=90) + user_id: str | None = None + + +class EventKitSettings(BaseModel): + calendar_name: str = "Work Shifts" + calendar_source: str = "iCloud" + + +class IcsSettings(BaseModel): + output_path: str = "~/Documents/easyatwork-shifts.ics" + + +class BackendsSettings(BaseModel): + eventkit: EventKitSettings = EventKitSettings() + ics: IcsSettings = IcsSettings() + + +class LoggingSettings(BaseModel): + level: str = "INFO" + + +class Config(BaseModel): + easyatwork: EasyAtWorkAuth + sync: SyncSettings = SyncSettings() + backend: Literal["eventkit", "ics"] + backends: BackendsSettings = BackendsSettings() + logging: LoggingSettings = LoggingSettings() + + @field_validator("backend") + @classmethod + def validate_backend(cls, v: str) -> str: + if v not in ("eventkit", "ics"): + raise ValueError(f"Unknown backend: {v}") + return v + + +_ENV_OVERRIDES = { + "EAW_CLIENT_ID": ("easyatwork", "client_id"), + "EAW_CLIENT_SECRET": ("easyatwork", "client_secret"), +} + + +def load_config(path: Path) -> Config: + if not path.exists(): + raise FileNotFoundError(path) + raw = yaml.safe_load(path.read_text()) + for env_var, (section, key) in _ENV_OVERRIDES.items(): + value = os.environ.get(env_var) + if value is not None: + raw.setdefault(section, {})[key] = value + return Config.model_validate(raw) diff --git a/tests/fixtures/config_valid.yaml b/tests/fixtures/config_valid.yaml new file mode 100644 index 0000000..dce7a5b --- /dev/null +++ b/tests/fixtures/config_valid.yaml @@ -0,0 +1,18 @@ +easyatwork: + auth_mode: client + client_id: "cid" + client_secret: "csecret" + base_url: "https://api.easyatwork.com" +sync: + lookback_days: 7 + lookahead_days: 90 + user_id: null +backend: ics +backends: + eventkit: + calendar_name: "Work Shifts" + calendar_source: "iCloud" + ics: + output_path: "~/Documents/shifts.ics" +logging: + level: INFO diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..5206d27 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,34 @@ +from pathlib import Path + +import pytest + +from easyatcal.config import Config, load_config + + +FIXTURE = Path(__file__).parent / "fixtures" / "config_valid.yaml" + + +def test_load_config_from_file(): + cfg = load_config(FIXTURE) + assert isinstance(cfg, Config) + assert cfg.easyatwork.client_id == "cid" + assert cfg.backend == "ics" + assert cfg.sync.lookback_days == 7 + + +def test_env_override_for_secret(monkeypatch): + monkeypatch.setenv("EAW_CLIENT_SECRET", "from-env") + cfg = load_config(FIXTURE) + assert cfg.easyatwork.client_secret == "from-env" + + +def test_invalid_backend_rejected(tmp_path): + bad = tmp_path / "c.yaml" + bad.write_text(FIXTURE.read_text().replace("backend: ics", "backend: nonsense")) + with pytest.raises(Exception): + load_config(bad) + + +def test_missing_file_raises(tmp_path): + with pytest.raises(FileNotFoundError): + load_config(tmp_path / "missing.yaml") From fcd0f8d606d37c3d38af64cb0fd0d3149314f7fe Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:45:19 +0200 Subject: [PATCH 06/68] feat(state): atomic json state with corrupt-file recovery + updated_at Merges task 4 + task 9 per plan; saves a dedicated roundtrip commit. Co-Authored-By: Claude Opus 4.7 --- easyatcal/state.py | 36 ++++++++++++++++++++++++++++++++ tests/test_state.py | 50 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 easyatcal/state.py create mode 100644 tests/test_state.py diff --git a/easyatcal/state.py b/easyatcal/state.py new file mode 100644 index 0000000..3a26303 --- /dev/null +++ b/easyatcal/state.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path + + +@dataclass +class State: + shift_to_event: dict[str, str] = field(default_factory=dict) + shift_updated_at: dict[str, str] = field(default_factory=dict) + last_sync: str | None = None + + +def load_state(path: Path) -> State: + if not path.exists(): + return State() + try: + data = json.loads(path.read_text()) + return State( + shift_to_event=dict(data.get("shift_to_event", {})), + shift_updated_at=dict(data.get("shift_updated_at", {})), + last_sync=data.get("last_sync"), + ) + except (json.JSONDecodeError, ValueError): + backup = path.with_suffix(path.suffix + ".bak") + path.replace(backup) + return State() + + +def save_state(path: Path, state: State) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(asdict(state), indent=2, sort_keys=True)) + os.replace(tmp, path) diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..f9fa242 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path + +from easyatcal.state import State, load_state, save_state + + +def test_save_then_load_roundtrip(tmp_path: Path): + path = tmp_path / "state.json" + s = State(shift_to_event={"shift-1": "evt-1", "shift-2": "evt-2"}, + last_sync="2026-04-19T12:00:00+00:00") + save_state(path, s) + + loaded = load_state(path) + assert loaded.shift_to_event == s.shift_to_event + assert loaded.last_sync == s.last_sync + + +def test_load_missing_returns_empty(tmp_path: Path): + s = load_state(tmp_path / "missing.json") + assert s.shift_to_event == {} + assert s.last_sync is None + + +def test_load_corrupt_backs_up_and_returns_empty(tmp_path: Path): + path = tmp_path / "state.json" + path.write_text("not valid json{{{") + + s = load_state(path) + + assert s.shift_to_event == {} + assert (tmp_path / "state.json.bak").exists() + + +def test_save_is_atomic(tmp_path: Path): + path = tmp_path / "state.json" + save_state(path, State(shift_to_event={"a": "b"}, last_sync=None)) + assert not any(p.name.endswith(".tmp") for p in tmp_path.iterdir()) + assert json.loads(path.read_text())["shift_to_event"] == {"a": "b"} + + +def test_state_roundtrip_with_updated_at(tmp_path): + path = tmp_path / "state.json" + s = State( + shift_to_event={"s1": "e1"}, + shift_updated_at={"s1": "2026-04-18T10:00:00+00:00"}, + last_sync="2026-04-19T12:00:00+00:00", + ) + save_state(path, s) + loaded = load_state(path) + assert loaded.shift_updated_at == {"s1": "2026-04-18T10:00:00+00:00"} From 6488db9d99299c1bad5d4a530253f03aaecc27aa Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:46:39 +0200 Subject: [PATCH 07/68] feat(api): OAuth auth + fetch_shifts with pagination and backoff Switches dev dep from responses to respx (httpx-native mocking). Co-Authored-By: Claude Opus 4.7 --- easyatcal/api.py | 140 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_api_auth.py | 67 +++++++++++++++++++ tests/test_api_fetch.py | 133 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 easyatcal/api.py create mode 100644 tests/test_api_auth.py create mode 100644 tests/test_api_fetch.py diff --git a/easyatcal/api.py b/easyatcal/api.py new file mode 100644 index 0000000..598f9ad --- /dev/null +++ b/easyatcal/api.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import json +import os +import time +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +import httpx + +from easyatcal.models import Shift + + +class AuthError(Exception): + pass + + +class ApiError(Exception): + pass + + +class EawClient: + _MAX_RETRIES = 5 + + def __init__( + self, + client_id: str, + client_secret: str, + base_url: str, + token_cache: Path, + timeout: float = 30.0, + ) -> None: + self.client_id = client_id + self.client_secret = client_secret + self.base_url = base_url.rstrip("/") + self.token_cache = Path(token_cache) + self._http = httpx.Client(timeout=timeout) + self._token: str | None = None + + # ----- auth ----- + + def authenticate(self) -> str: + cached = self._read_cache() + if cached is not None: + self._token = cached + return cached + return self._fetch_token() + + def _read_cache(self) -> str | None: + if not self.token_cache.exists(): + return None + try: + data = json.loads(self.token_cache.read_text()) + except (json.JSONDecodeError, ValueError): + return None + expires_at = datetime.fromisoformat(data["expires_at"]) + if expires_at <= datetime.now(timezone.utc): + return None + return data["access_token"] + + def _fetch_token(self) -> str: + try: + r = self._http.post( + f"{self.base_url}/oauth/token", + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + ) + except httpx.HTTPError as e: + raise AuthError(f"network error during auth: {e}") from e + if r.status_code != 200: + raise AuthError(f"auth failed: {r.status_code} {r.text}") + data = r.json() + token = data["access_token"] + expires_at = datetime.now(timezone.utc) + timedelta( + seconds=int(data.get("expires_in", 3600)) + ) + self._write_cache(token, expires_at) + self._token = token + return token + + def _write_cache(self, token: str, expires_at: datetime) -> None: + self.token_cache.parent.mkdir(parents=True, exist_ok=True) + payload = {"access_token": token, "expires_at": expires_at.isoformat()} + tmp = self.token_cache.with_suffix(self.token_cache.suffix + ".tmp") + tmp.write_text(json.dumps(payload)) + os.replace(tmp, self.token_cache) + try: + os.chmod(self.token_cache, 0o600) + except OSError: + pass + + # ----- shifts ----- + + def fetch_shifts(self, from_date: date, to_date: date) -> list[Shift]: + """Return list[Shift] between from_date (inclusive) and to_date (exclusive).""" + token = self.authenticate() + url: str | None = f"{self.base_url}/v1/shifts" + params: dict | None = { + "from": from_date.isoformat(), + "to": to_date.isoformat(), + } + headers = {"Authorization": f"Bearer {token}"} + + out: list[Shift] = [] + while url is not None: + attempts = 0 + while True: + r = self._http.get(url, params=params, headers=headers) + if r.status_code == 200: + break + if r.status_code in (429, 500, 502, 503, 504): + attempts += 1 + if attempts > self._MAX_RETRIES: + raise ApiError( + f"rate limit / server errors exceeded retries " + f"({r.status_code})" + ) + time.sleep(2 ** (attempts - 1)) + continue + raise ApiError(f"GET {url} -> {r.status_code} {r.text}") + + payload = r.json() + for raw in payload.get("data", []): + out.append( + Shift( + id=raw["id"], + start=datetime.fromisoformat(raw["start"]), + end=datetime.fromisoformat(raw["end"]), + title=raw.get("title", "Shift"), + location=raw.get("location"), + notes=raw.get("notes"), + updated_at=datetime.fromisoformat(raw["updated_at"]), + ) + ) + url = payload.get("next") + params = None # next URL already includes cursor + return out diff --git a/pyproject.toml b/pyproject.toml index 6e928b6..151a94f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ eventkit = ["pyobjc-framework-EventKit>=10.0; sys_platform == 'darwin'"] dev = [ "pytest>=8.0", "pytest-cov>=5.0", - "responses>=0.25", + "respx>=0.21", "freezegun>=1.4", ] diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py new file mode 100644 index 0000000..54eaa29 --- /dev/null +++ b/tests/test_api_auth.py @@ -0,0 +1,67 @@ +import json +from pathlib import Path + +import httpx +import pytest +import respx + +from easyatcal.api import AuthError, EawClient + + +@respx.mock +def test_client_credentials_fetch_token(tmp_path: Path): + respx.post("https://api.easyatwork.com/oauth/token").mock( + return_value=httpx.Response( + 200, + json={"access_token": "tok-123", "expires_in": 3600, + "token_type": "Bearer"}, + ) + ) + client = EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=tmp_path / "token.json", + ) + + token = client.authenticate() + + assert token == "tok-123" + cached = json.loads((tmp_path / "token.json").read_text()) + assert cached["access_token"] == "tok-123" + + +@respx.mock +def test_cached_token_reused(tmp_path: Path): + cache = tmp_path / "token.json" + cache.write_text(json.dumps({ + "access_token": "cached-tok", + "expires_at": "2099-01-01T00:00:00+00:00", + })) + route = respx.post("https://api.easyatwork.com/oauth/token") + + client = EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=cache, + ) + token = client.authenticate() + + assert token == "cached-tok" + assert route.call_count == 0 + + +@respx.mock +def test_auth_failure_raises(tmp_path: Path): + respx.post("https://api.easyatwork.com/oauth/token").mock( + return_value=httpx.Response(401, json={"error": "invalid_client"}) + ) + client = EawClient( + client_id="bad", + client_secret="bad", + base_url="https://api.easyatwork.com", + token_cache=tmp_path / "token.json", + ) + with pytest.raises(AuthError): + client.authenticate() diff --git a/tests/test_api_fetch.py b/tests/test_api_fetch.py new file mode 100644 index 0000000..447cbd6 --- /dev/null +++ b/tests/test_api_fetch.py @@ -0,0 +1,133 @@ +from datetime import date +from pathlib import Path + +import httpx +import pytest +import respx + +from easyatcal.api import ApiError, EawClient +from easyatcal.models import Shift + + +def _fresh_client(tmp_path: Path) -> EawClient: + cache = tmp_path / "token.json" + cache.write_text( + '{"access_token":"tok","expires_at":"2099-01-01T00:00:00+00:00"}' + ) + return EawClient( + client_id="cid", + client_secret="csecret", + base_url="https://api.easyatwork.com", + token_cache=cache, + ) + + +@respx.mock +def test_fetch_shifts_single_page(tmp_path: Path): + respx.get("https://api.easyatwork.com/v1/shifts").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "Morning", + "location": "Oslo", + "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + } + ], + "next": None, + }, + ) + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 21) + ) + + assert len(shifts) == 1 + s = shifts[0] + assert isinstance(s, Shift) + assert s.id == "s1" + assert s.location == "Oslo" + + +@respx.mock +def test_fetch_shifts_follows_pagination(tmp_path: Path): + page1 = { + "data": [{ + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "A", "location": None, "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + }], + "next": "https://api.easyatwork.com/v1/shifts?cursor=abc", + } + page2 = { + "data": [{ + "id": "s2", + "start": "2026-04-21T09:00:00+00:00", + "end": "2026-04-21T17:00:00+00:00", + "title": "B", "location": None, "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + }], + "next": None, + } + + def _handler(request: httpx.Request) -> httpx.Response: + if "cursor=abc" in str(request.url): + return httpx.Response(200, json=page2) + return httpx.Response(200, json=page1) + + respx.get(url__regex=r"https://api\.easyatwork\.com/v1/shifts.*").mock( + side_effect=_handler + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) + ids = [s.id for s in shifts] + assert ids == ["s1", "s2"] + + +@respx.mock +def test_fetch_shifts_retries_on_429(tmp_path: Path, monkeypatch): + sleeps: list[float] = [] + monkeypatch.setattr("easyatcal.api.time.sleep", lambda s: sleeps.append(s)) + + responses_iter = iter([ + httpx.Response(429), + httpx.Response(200, json={"data": [], "next": None}), + ]) + respx.get("https://api.easyatwork.com/v1/shifts").mock( + side_effect=lambda req: next(responses_iter) + ) + client = _fresh_client(tmp_path) + + shifts = client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) + + assert shifts == [] + assert len(sleeps) == 1 + assert sleeps[0] >= 1 + + +@respx.mock +def test_fetch_shifts_gives_up_after_retries(tmp_path: Path, monkeypatch): + monkeypatch.setattr("easyatcal.api.time.sleep", lambda s: None) + respx.get("https://api.easyatwork.com/v1/shifts").mock( + return_value=httpx.Response(429) + ) + client = _fresh_client(tmp_path) + + with pytest.raises(ApiError, match="rate limit"): + client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) From 753f4d98d2eb17641d75f13b460500c58b65c074 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:47:33 +0200 Subject: [PATCH 08/68] feat(sync): backend protocol + diff engine for adds/updates/deletes Co-Authored-By: Claude Opus 4.7 --- easyatcal/backends/__init__.py | 0 easyatcal/backends/base.py | 24 +++++++++++++ easyatcal/sync.py | 35 +++++++++++++++++++ tests/backends/__init__.py | 0 tests/backends/test_base.py | 16 +++++++++ tests/test_sync.py | 62 ++++++++++++++++++++++++++++++++++ 6 files changed, 137 insertions(+) create mode 100644 easyatcal/backends/__init__.py create mode 100644 easyatcal/backends/base.py create mode 100644 easyatcal/sync.py create mode 100644 tests/backends/__init__.py create mode 100644 tests/backends/test_base.py create mode 100644 tests/test_sync.py diff --git a/easyatcal/backends/__init__.py b/easyatcal/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/easyatcal/backends/base.py b/easyatcal/backends/base.py new file mode 100644 index 0000000..fe2c424 --- /dev/null +++ b/easyatcal/backends/base.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol + +from easyatcal.models import Shift + + +@dataclass +class Changes: + adds: list[Shift] = field(default_factory=list) + updates: list[tuple[Shift, str]] = field(default_factory=list) + # list of event uids to delete + deletes: list[str] = field(default_factory=list) + + def is_empty(self) -> bool: + return not (self.adds or self.updates or self.deletes) + + +class CalendarBackend(Protocol): + def apply(self, changes: Changes) -> dict[str, str]: + """Apply the given changes. Return mapping shift_id -> event_uid for + all adds/updates.""" + ... diff --git a/easyatcal/sync.py b/easyatcal/sync.py new file mode 100644 index 0000000..19e0645 --- /dev/null +++ b/easyatcal/sync.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift +from easyatcal.state import State + + +def compute_changes( + remote_shifts: list[Shift], + state: State, + known_updated_at: dict[str, str], +) -> Changes: + """Diff remote shifts against the last-known state. + + known_updated_at maps shift_id -> ISO-formatted updated_at recorded at last sync. + """ + remote_by_id = {s.id: s for s in remote_shifts} + adds: list[Shift] = [] + updates: list[tuple[Shift, str]] = [] + deletes: list[str] = [] + + for shift in remote_shifts: + event_uid = state.shift_to_event.get(shift.id) + if event_uid is None: + adds.append(shift) + continue + last_updated = known_updated_at.get(shift.id) + if last_updated != shift.updated_at.isoformat(): + updates.append((shift, event_uid)) + + for shift_id, event_uid in state.shift_to_event.items(): + if shift_id not in remote_by_id: + deletes.append(event_uid) + + return Changes(adds=adds, updates=updates, deletes=deletes) diff --git a/tests/backends/__init__.py b/tests/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/backends/test_base.py b/tests/backends/test_base.py new file mode 100644 index 0000000..94bcc85 --- /dev/null +++ b/tests/backends/test_base.py @@ -0,0 +1,16 @@ +from easyatcal.backends.base import CalendarBackend, Changes + + +def test_changes_is_dataclass(): + c = Changes(adds=[], updates=[], deletes=[]) + assert c.adds == [] + assert c.is_empty() + + +def test_backend_is_protocol_with_apply(): + class Dummy: + def apply(self, changes: Changes) -> dict[str, str]: + return {} + + d: CalendarBackend = Dummy() + assert d.apply(Changes([], [], [])) == {} diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..762a0ba --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,62 @@ +from datetime import datetime, timezone + +from easyatcal.models import Shift +from easyatcal.state import State +from easyatcal.sync import compute_changes + + +def _shift(id_: str, updated: str = "2026-04-18T10:00:00+00:00") -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title="t", + location=None, + notes=None, + updated_at=datetime.fromisoformat(updated), + ) + + +def test_new_shifts_are_adds(): + state = State(shift_to_event={}) + shifts = [_shift("a"), _shift("b")] + + changes = compute_changes(shifts, state, known_updated_at={}) + + assert [s.id for s in changes.adds] == ["a", "b"] + assert changes.updates == [] + assert changes.deletes == [] + + +def test_known_shifts_unchanged_do_nothing(): + state = State(shift_to_event={"a": "evt-a"}) + shifts = [_shift("a", "2026-04-18T10:00:00+00:00")] + known_updated = {"a": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes(shifts, state, known_updated_at=known_updated) + + assert changes.is_empty() + + +def test_known_shift_with_new_updated_at_is_update(): + state = State(shift_to_event={"a": "evt-a"}) + shifts = [_shift("a", "2026-04-19T10:00:00+00:00")] + known_updated = {"a": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes(shifts, state, known_updated_at=known_updated) + + assert len(changes.updates) == 1 + shift, event_uid = changes.updates[0] + assert shift.id == "a" + assert event_uid == "evt-a" + + +def test_shift_missing_from_remote_is_delete(): + state = State(shift_to_event={"a": "evt-a", "b": "evt-b"}) + shifts = [_shift("a")] + known_updated = {"a": "2026-04-18T10:00:00+00:00", + "b": "2026-04-18T10:00:00+00:00"} + + changes = compute_changes(shifts, state, known_updated_at=known_updated) + + assert changes.deletes == ["evt-b"] From 4f209a99b7510e96361c3064227565e070593592 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:48:03 +0200 Subject: [PATCH 09/68] feat(backends): ICS file backend with add/update/delete Co-Authored-By: Claude Opus 4.7 --- easyatcal/backends/ics.py | 75 ++++++++++++++++++++++++++++++++++++++ tests/backends/test_ics.py | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 easyatcal/backends/ics.py create mode 100644 tests/backends/test_ics.py diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py new file mode 100644 index 0000000..5ca2437 --- /dev/null +++ b/easyatcal/backends/ics.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path + +from icalendar import Calendar, Event + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift + +UID_PREFIX = "easyatcal-" + + +def _uid_for(shift_id: str) -> str: + return f"{UID_PREFIX}{shift_id}" + + +def _to_event(shift: Shift, uid: str) -> Event: + ev = Event() + ev.add("uid", uid) + ev.add("summary", shift.title) + ev.add("dtstart", shift.start) + ev.add("dtend", shift.end) + ev.add("last-modified", shift.updated_at) + if shift.location: + ev.add("location", shift.location) + if shift.notes: + ev.add("description", shift.notes) + return ev + + +class IcsBackend: + """File-based calendar backend that regenerates the .ics on each apply. + + `known_shifts` is the previous set of shifts the caller knows about — used + so we can rewrite the file without losing events unrelated to the current + change set. + """ + + def __init__(self, output_path: Path, known_shifts: list[Shift]) -> None: + self.output_path = Path(output_path).expanduser() + self._current: dict[str, Shift] = {s.id: s for s in known_shifts} + + def apply(self, changes: Changes) -> dict[str, str]: + mapping: dict[str, str] = {} + + for shift in changes.adds: + self._current[shift.id] = shift + mapping[shift.id] = _uid_for(shift.id) + + for shift, _event_uid in changes.updates: + self._current[shift.id] = shift + mapping[shift.id] = _uid_for(shift.id) + + delete_uids = set(changes.deletes) + to_drop = [ + sid for sid in self._current + if _uid_for(sid) in delete_uids + ] + for sid in to_drop: + self._current.pop(sid, None) + + self._write() + return mapping + + def _write(self) -> None: + cal = Calendar() + cal.add("prodid", "-//EasyAtCal//EN") + cal.add("version", "2.0") + for shift in self._current.values(): + cal.add_component(_to_event(shift, _uid_for(shift.id))) + + self.output_path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.output_path.with_suffix(self.output_path.suffix + ".tmp") + tmp.write_bytes(cal.to_ical()) + tmp.replace(self.output_path) diff --git a/tests/backends/test_ics.py b/tests/backends/test_ics.py new file mode 100644 index 0000000..5e228c0 --- /dev/null +++ b/tests/backends/test_ics.py @@ -0,0 +1,67 @@ +from datetime import datetime, timezone +from pathlib import Path + +from easyatcal.backends.base import Changes +from easyatcal.backends.ics import IcsBackend +from easyatcal.models import Shift + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title=f"Shift {id_}", + location="Oslo", + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) + + +def test_adds_produce_events_in_file(tmp_path: Path): + out = tmp_path / "shifts.ics" + backend = IcsBackend(output_path=out, known_shifts=[]) + changes = Changes(adds=[_shift("s1"), _shift("s2")]) + + mapping = backend.apply(changes) + + body = out.read_text() + assert "BEGIN:VCALENDAR" in body + assert "SUMMARY:Shift s1" in body + assert "SUMMARY:Shift s2" in body + assert mapping["s1"].startswith("easyatcal-s1") + assert mapping["s2"].startswith("easyatcal-s2") + + +def test_deletes_remove_events(tmp_path: Path): + out = tmp_path / "shifts.ics" + backend1 = IcsBackend(output_path=out, known_shifts=[]) + backend1.apply(Changes(adds=[_shift("s1"), _shift("s2")])) + + backend2 = IcsBackend( + output_path=out, + known_shifts=[_shift("s1"), _shift("s2")], + ) + uid_s2 = "easyatcal-s2" + backend2.apply(Changes(deletes=[uid_s2])) + + body = out.read_text() + assert "SUMMARY:Shift s1" in body + assert "SUMMARY:Shift s2" not in body + + +def test_updates_replace_event(tmp_path: Path): + out = tmp_path / "shifts.ics" + s = _shift("s1") + IcsBackend(output_path=out, known_shifts=[]).apply(Changes(adds=[s])) + + s_new = Shift( + id=s.id, start=s.start, end=s.end, title="New Title", + location=s.location, notes=s.notes, updated_at=s.updated_at, + ) + backend = IcsBackend(output_path=out, known_shifts=[s]) + backend.apply(Changes(updates=[(s_new, "easyatcal-s1")])) + + body = out.read_text() + assert "SUMMARY:New Title" in body + assert "SUMMARY:Shift s1" not in body From 0348ce87374e1d136a18ab8488778e39864c305c Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:48:41 +0200 Subject: [PATCH 10/68] feat(backends): macOS EventKit backend via pyobjc Co-Authored-By: Claude Opus 4.7 --- easyatcal/backends/eventkit.py | 142 ++++++++++++++++++++++++++++++++ tests/backends/test_eventkit.py | 73 ++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 easyatcal/backends/eventkit.py create mode 100644 tests/backends/test_eventkit.py diff --git a/easyatcal/backends/eventkit.py b/easyatcal/backends/eventkit.py new file mode 100644 index 0000000..e81858d --- /dev/null +++ b/easyatcal/backends/eventkit.py @@ -0,0 +1,142 @@ +"""macOS EventKit calendar backend. + +Only usable on macOS. Requires `pyobjc-framework-EventKit` (install the +`eventkit` extra). +""" +from __future__ import annotations + +import sys +from typing import Any + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift + + +class EventKitUnavailableError(RuntimeError): + pass + + +class EventKitPermissionError(RuntimeError): + pass + + +def _import_eventkit(): # pragma: no cover — platform guard + if sys.platform != "darwin": + raise EventKitUnavailableError("EventKit backend requires macOS") + try: + import EventKit # type: ignore[import-not-found] + except ImportError as e: + raise EventKitUnavailableError( + "pyobjc-framework-EventKit not installed; " + "pip install 'easyatcal[eventkit]'" + ) from e + return EventKit + + +def _event_store() -> Any: # pragma: no cover — exercised via mocks in tests + EventKit = _import_eventkit() + store = EventKit.EKEventStore.alloc().init() + from threading import Event as _E + granted = {"ok": False, "err": None} + done = _E() + + def _cb(ok, err): + granted["ok"] = bool(ok) + granted["err"] = err + done.set() + + try: + store.requestFullAccessToEventsWithCompletion_(_cb) + except AttributeError: + store.requestAccessToEntityType_completion_(0, _cb) # 0 = EKEntityTypeEvent + + done.wait(timeout=30) + if not granted["ok"]: + raise EventKitPermissionError( + "Calendar access denied — grant access in System Settings → " + "Privacy & Security → Calendars." + ) + return store + + +def _new_event(store, calendar, shift: Shift): # pragma: no cover + EventKit = _import_eventkit() + import Foundation # type: ignore[import-not-found] + + event = EventKit.EKEvent.eventWithEventStore_(store) + event.setCalendar_(calendar) + event.setTitle_(shift.title) + event.setStartDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.start.timestamp()) + ) + event.setEndDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.end.timestamp()) + ) + if shift.location: + event.setLocation_(shift.location) + if shift.notes: + event.setNotes_(shift.notes) + return event + + +class EventKitBackend: + def __init__(self, calendar_name: str, calendar_source: str) -> None: + self.calendar_name = calendar_name + self.calendar_source = calendar_source + self._store = _event_store() + self._calendar = self._resolve_calendar() + + def _resolve_calendar(self): + calendars = self._store.calendarsForEntityType_(0) + for cal in calendars: + if ( + cal.title() == self.calendar_name + and cal.source().title() == self.calendar_source + ): + return cal + raise RuntimeError( + f"Calendar {self.calendar_name!r} not found in source " + f"{self.calendar_source!r}. Create it in Calendar.app first." + ) + + def apply(self, changes: Changes) -> dict[str, str]: + mapping: dict[str, str] = {} + + for shift in changes.adds: + event = _new_event(self._store, self._calendar, shift) + err = None + self._store.saveEvent_span_error_(event, 0, err) # 0 = EKSpanThisEvent + mapping[shift.id] = event.calendarItemExternalIdentifier() + + for shift, event_uid in changes.updates: + existing = self._store.calendarItemWithIdentifier_(event_uid) + if existing is None: + event = _new_event(self._store, self._calendar, shift) + err = None + self._store.saveEvent_span_error_(event, 0, err) + mapping[shift.id] = event.calendarItemExternalIdentifier() + continue + existing.setTitle_(shift.title) + import Foundation # type: ignore[import-not-found] + existing.setStartDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.start.timestamp()) + ) + existing.setEndDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.end.timestamp()) + ) + if shift.location is not None: + existing.setLocation_(shift.location) + if shift.notes is not None: + existing.setNotes_(shift.notes) + err = None + self._store.saveEvent_span_error_(existing, 0, err) + mapping[shift.id] = event_uid + + for event_uid in changes.deletes: + existing = self._store.calendarItemWithIdentifier_(event_uid) + if existing is None: + continue + err = None + self._store.removeEvent_span_error_(existing, 0, err) + + return mapping diff --git a/tests/backends/test_eventkit.py b/tests/backends/test_eventkit.py new file mode 100644 index 0000000..e1efb5e --- /dev/null +++ b/tests/backends/test_eventkit.py @@ -0,0 +1,73 @@ +import sys +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift + +pytestmark = pytest.mark.skipif( + sys.platform != "darwin", reason="EventKit backend is macOS only" +) + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title=f"Shift {id_}", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) + + +@patch("easyatcal.backends.eventkit._event_store") +def test_apply_adds_creates_events(mock_store_factory): + store = MagicMock() + calendar = MagicMock() + store.calendarsForEntityType_.return_value = [calendar] + calendar.title.return_value = "Work Shifts" + calendar.source.return_value.title.return_value = "iCloud" + mock_store_factory.return_value = store + + created_event = MagicMock() + created_event.calendarItemExternalIdentifier.return_value = "evt-1" + + from easyatcal.backends.eventkit import EventKitBackend + + with patch( + "easyatcal.backends.eventkit._new_event", return_value=created_event + ): + backend = EventKitBackend( + calendar_name="Work Shifts", calendar_source="iCloud" + ) + mapping = backend.apply(Changes(adds=[_shift("s1")])) + + assert mapping == {"s1": "evt-1"} + store.saveEvent_span_error_.assert_called() + + +@patch("easyatcal.backends.eventkit._event_store") +def test_apply_deletes_removes_events(mock_store_factory): + store = MagicMock() + calendar = MagicMock() + calendar.title.return_value = "Work Shifts" + calendar.source.return_value.title.return_value = "iCloud" + store.calendarsForEntityType_.return_value = [calendar] + + existing = MagicMock() + existing.calendarItemExternalIdentifier.return_value = "evt-1" + store.calendarItemWithIdentifier_.return_value = existing + mock_store_factory.return_value = store + + from easyatcal.backends.eventkit import EventKitBackend + backend = EventKitBackend( + calendar_name="Work Shifts", calendar_source="iCloud" + ) + + backend.apply(Changes(deletes=["evt-1"])) + + store.removeEvent_span_error_.assert_called() From 64c7dede5c90696908435f8dea45c7f5f51abcf0 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:49:13 +0200 Subject: [PATCH 11/68] feat(orchestrator): tie api + sync + backend + state together Co-Authored-By: Claude Opus 4.7 --- easyatcal/orchestrator.py | 58 ++++++++++++++++++++++++++++++++++++++ tests/test_orchestrator.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 easyatcal/orchestrator.py create mode 100644 tests/test_orchestrator.py diff --git a/easyatcal/orchestrator.py b/easyatcal/orchestrator.py new file mode 100644 index 0000000..671e31c --- /dev/null +++ b/easyatcal/orchestrator.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Protocol + +from easyatcal.backends.base import CalendarBackend +from easyatcal.models import Shift +from easyatcal.state import State, load_state, save_state +from easyatcal.sync import compute_changes + + +class ShiftFetcher(Protocol): + def fetch_shifts(self, from_date, to_date) -> list[Shift]: ... + + +def run_sync( + api: ShiftFetcher, + backend: CalendarBackend, + state_path: Path, + lookback_days: int, + lookahead_days: int, + now: datetime | None = None, +) -> None: + now = now or datetime.now(timezone.utc) + from_date = (now - timedelta(days=lookback_days)).date() + to_date = (now + timedelta(days=lookahead_days)).date() + + remote_shifts = api.fetch_shifts(from_date=from_date, to_date=to_date) + state = load_state(state_path) + changes = compute_changes( + remote_shifts, state, known_updated_at=state.shift_updated_at + ) + mapping = backend.apply(changes) + + new_shift_to_event = dict(state.shift_to_event) + new_updated_at = dict(state.shift_updated_at) + for shift_id, event_uid in mapping.items(): + new_shift_to_event[shift_id] = event_uid + for shift in remote_shifts: + new_updated_at[shift.id] = shift.updated_at.isoformat() + + remote_ids = {s.id for s in remote_shifts} + new_shift_to_event = { + sid: evt for sid, evt in new_shift_to_event.items() if sid in remote_ids + } + new_updated_at = { + sid: ts for sid, ts in new_updated_at.items() if sid in remote_ids + } + + save_state( + state_path, + State( + shift_to_event=new_shift_to_event, + shift_updated_at=new_updated_at, + last_sync=now.isoformat(), + ), + ) diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py new file mode 100644 index 0000000..496d7d1 --- /dev/null +++ b/tests/test_orchestrator.py @@ -0,0 +1,48 @@ +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import MagicMock + +from easyatcal.backends.base import Changes +from easyatcal.models import Shift +from easyatcal.orchestrator import run_sync +from easyatcal.state import load_state + + +def _shift(id_: str) -> Shift: + return Shift( + id=id_, + start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + title=f"t{id_}", + location=None, + notes=None, + updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + ) + + +def test_run_sync_applies_changes_and_persists_state(tmp_path: Path): + state_path = tmp_path / "state.json" + + api = MagicMock() + api.fetch_shifts.return_value = [_shift("s1"), _shift("s2")] + + backend = MagicMock() + backend.apply.return_value = {"s1": "evt-1", "s2": "evt-2"} + + run_sync( + api=api, + backend=backend, + state_path=state_path, + lookback_days=1, + lookahead_days=1, + now=datetime(2026, 4, 19, 12, tzinfo=timezone.utc), + ) + + changes = backend.apply.call_args.args[0] + assert isinstance(changes, Changes) + assert [s.id for s in changes.adds] == ["s1", "s2"] + + saved = load_state(state_path) + assert saved.shift_to_event == {"s1": "evt-1", "s2": "evt-2"} + assert saved.shift_updated_at["s1"] == "2026-04-18T00:00:00+00:00" + assert saved.last_sync == "2026-04-19T12:00:00+00:00" From d28cd08d7b8c7457511683a73a3a253ca5d06178 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:50:19 +0200 Subject: [PATCH 12/68] feat(cli): config/sync/watch/auth commands with logging Tasks 13-16 combined: paths helpers, typer CLI, rotating logs. Co-Authored-By: Claude Opus 4.7 --- easyatcal/cli.py | 140 ++++++++++++++++++++++++++++++++++++ easyatcal/logging_setup.py | 25 +++++++ easyatcal/paths.py | 23 ++++++ tests/test_cli_auth.py | 36 ++++++++++ tests/test_cli_config.py | 45 ++++++++++++ tests/test_cli_sync.py | 44 ++++++++++++ tests/test_logging_setup.py | 17 +++++ 7 files changed, 330 insertions(+) create mode 100644 easyatcal/cli.py create mode 100644 easyatcal/logging_setup.py create mode 100644 easyatcal/paths.py create mode 100644 tests/test_cli_auth.py create mode 100644 tests/test_cli_config.py create mode 100644 tests/test_cli_sync.py create mode 100644 tests/test_logging_setup.py diff --git a/easyatcal/cli.py b/easyatcal/cli.py new file mode 100644 index 0000000..ae488ea --- /dev/null +++ b/easyatcal/cli.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import shutil +import time +from pathlib import Path + +import typer +import yaml + +from easyatcal.api import EawClient +from easyatcal.backends.ics import IcsBackend +from easyatcal.config import load_config +from easyatcal.logging_setup import configure_logging +from easyatcal.orchestrator import run_sync +from easyatcal.paths import ( + config_path, + log_path, + state_path, + token_cache_path, +) + +app = typer.Typer(help="EasyAtCal — sync easy@work shifts to Apple Calendar.") +config_app = typer.Typer(help="Manage the config file.") +auth_app = typer.Typer(help="Credential checks.") +app.add_typer(config_app, name="config") +app.add_typer(auth_app, name="auth") + +EXAMPLE_CONFIG = Path(__file__).parent.parent / "config.example.yaml" + + +# ---------- helpers ---------- + +def _build_api_client(cfg): + return EawClient( + client_id=cfg.easyatwork.client_id, + client_secret=cfg.easyatwork.client_secret, + base_url=cfg.easyatwork.base_url, + token_cache=token_cache_path(), + ) + + +def _build_backend(cfg): + if cfg.backend == "ics": + return IcsBackend( + output_path=Path(cfg.backends.ics.output_path).expanduser(), + known_shifts=[], + ) + if cfg.backend == "eventkit": + from easyatcal.backends.eventkit import EventKitBackend + return EventKitBackend( + calendar_name=cfg.backends.eventkit.calendar_name, + calendar_source=cfg.backends.eventkit.calendar_source, + ) + raise RuntimeError(f"Unknown backend: {cfg.backend}") + + +# ---------- config ---------- + +@config_app.command("init") +def config_init() -> None: + """Scaffold a config file at the user config dir.""" + target = config_path() + if target.exists(): + typer.echo(f"Config already exists at {target}", err=True) + raise typer.Exit(code=1) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(EXAMPLE_CONFIG, target) + typer.echo(f"Wrote {target}. Edit it before running `eaw-sync sync`.") + + +@config_app.command("show") +def config_show() -> None: + """Print the effective config with secrets redacted.""" + cfg = load_config(config_path()) + dumped = cfg.model_dump() + dumped["easyatwork"]["client_secret"] = "***" + typer.echo(yaml.safe_dump(dumped, sort_keys=False)) + + +# ---------- sync / watch ---------- + +@app.command("sync") +def sync_cmd() -> None: + """Run one sync pass and exit.""" + cfg = load_config(config_path()) + configure_logging(level=cfg.logging.level, log_file=log_path()) + api = _build_api_client(cfg) + backend = _build_backend(cfg) + run_sync( + api=api, + backend=backend, + state_path=state_path(), + lookback_days=cfg.sync.lookback_days, + lookahead_days=cfg.sync.lookahead_days, + ) + typer.echo("Sync complete.") + + +@app.command("watch") +def watch_cmd( + interval_seconds: int = typer.Option( + 900, "--interval-seconds", help="Seconds between sync passes." + ), +) -> None: + """Run sync on a loop until Ctrl-C.""" + cfg = load_config(config_path()) + configure_logging(level=cfg.logging.level, log_file=log_path()) + api = _build_api_client(cfg) + backend = _build_backend(cfg) + try: + while True: + run_sync( + api=api, + backend=backend, + state_path=state_path(), + lookback_days=cfg.sync.lookback_days, + lookahead_days=cfg.sync.lookahead_days, + ) + typer.echo(f"Sleeping {interval_seconds}s...") + time.sleep(interval_seconds) + except KeyboardInterrupt: + typer.echo("\nStopped.") + + +# ---------- auth ---------- + +@auth_app.command("test") +def auth_test() -> None: + """Verify that the configured credentials can obtain a token.""" + from easyatcal.api import AuthError + + cfg = load_config(config_path()) + configure_logging(level=cfg.logging.level, log_file=log_path()) + api = _build_api_client(cfg) + try: + api.authenticate() + except AuthError as e: + typer.echo(f"Auth failed: {e}") + raise typer.Exit(code=2) + typer.echo("OK -- credentials work.") diff --git a/easyatcal/logging_setup.py b/easyatcal/logging_setup.py new file mode 100644 index 0000000..d3ef0eb --- /dev/null +++ b/easyatcal/logging_setup.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import logging +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path + + +def configure_logging(level: str, log_file: Path) -> None: + log_file.parent.mkdir(parents=True, exist_ok=True) + root = logging.getLogger() + for h in list(root.handlers): + root.removeHandler(h) + root.setLevel(level) + + fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + + file_h = TimedRotatingFileHandler( + log_file, when="midnight", backupCount=7, encoding="utf-8" + ) + file_h.setFormatter(fmt) + root.addHandler(file_h) + + console_h = logging.StreamHandler() + console_h.setFormatter(fmt) + root.addHandler(console_h) diff --git a/easyatcal/paths.py b/easyatcal/paths.py new file mode 100644 index 0000000..32ab54e --- /dev/null +++ b/easyatcal/paths.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pathlib import Path + +from platformdirs import user_cache_dir, user_config_dir, user_data_dir + +APP = "easyatcal" + + +def config_path() -> Path: + return Path(user_config_dir(APP)) / "config.yaml" + + +def state_path() -> Path: + return Path(user_data_dir(APP)) / "state.json" + + +def token_cache_path() -> Path: + return Path(user_cache_dir(APP)) / "token.json" + + +def log_path() -> Path: + return Path(user_data_dir(APP)) / "logs" / "eaw-sync.log" diff --git a/tests/test_cli_auth.py b/tests/test_cli_auth.py new file mode 100644 index 0000000..d8b0831 --- /dev/null +++ b/tests/test_cli_auth.py @@ -0,0 +1,36 @@ +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_auth_test_success(mock_cfg, mock_log, mock_build): + api = MagicMock() + api.authenticate.return_value = "tok" + mock_build.return_value = api + mock_cfg.return_value = MagicMock(logging=MagicMock(level="INFO")) + + result = runner.invoke(app, ["auth", "test"]) + assert result.exit_code == 0, result.stdout + assert "OK" in result.stdout + + +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_auth_test_failure(mock_cfg, mock_log, mock_build): + from easyatcal.api import AuthError + api = MagicMock() + api.authenticate.side_effect = AuthError("bad creds") + mock_build.return_value = api + mock_cfg.return_value = MagicMock(logging=MagicMock(level="INFO")) + + result = runner.invoke(app, ["auth", "test"]) + assert result.exit_code == 2 + assert "bad creds" in result.stdout diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py new file mode 100644 index 0000000..b932ee2 --- /dev/null +++ b/tests/test_cli_config.py @@ -0,0 +1,45 @@ +from pathlib import Path +from unittest.mock import patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +def test_config_init_creates_file(tmp_path: Path): + target = tmp_path / "config.yaml" + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "init"]) + + assert result.exit_code == 0, result.stdout + assert target.exists() + assert "easyatwork:" in target.read_text() + + +def test_config_init_does_not_overwrite(tmp_path: Path): + target = tmp_path / "config.yaml" + target.write_text("existing: yes\n") + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "init"]) + + assert result.exit_code != 0 + + +def test_config_show_redacts_secret(tmp_path: Path): + target = tmp_path / "config.yaml" + target.write_text( + "easyatwork:\n" + " auth_mode: client\n" + " client_id: cid\n" + " client_secret: supersecret\n" + " base_url: https://api.easyatwork.com\n" + "backend: ics\n" + ) + with patch("easyatcal.cli.config_path", return_value=target): + result = runner.invoke(app, ["config", "show"]) + + assert result.exit_code == 0, result.stdout + assert "supersecret" not in result.stdout + assert "***" in result.stdout diff --git a/tests/test_cli_sync.py b/tests/test_cli_sync.py new file mode 100644 index 0000000..98a3452 --- /dev/null +++ b/tests/test_cli_sync.py @@ -0,0 +1,44 @@ +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_sync_once_invokes_run_sync( + mock_cfg, mock_log, mock_api, mock_back, mock_run, tmp_path +): + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + logging=MagicMock(level="INFO"), + ) + result = runner.invoke(app, ["sync"]) + + assert result.exit_code == 0, result.stdout + mock_run.assert_called_once() + + +@patch("easyatcal.cli.time.sleep", side_effect=KeyboardInterrupt) +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_watch_loops_until_interrupt( + mock_cfg, mock_log, mock_api, mock_back, mock_run, mock_sleep +): + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + logging=MagicMock(level="INFO"), + ) + result = runner.invoke(app, ["watch", "--interval-seconds", "60"]) + + assert mock_run.call_count == 1 + assert result.exit_code == 0 diff --git a/tests/test_logging_setup.py b/tests/test_logging_setup.py new file mode 100644 index 0000000..17f26e7 --- /dev/null +++ b/tests/test_logging_setup.py @@ -0,0 +1,17 @@ +import logging +from pathlib import Path + +from easyatcal.logging_setup import configure_logging + + +def test_configure_logging_writes_to_file(tmp_path: Path): + log_file = tmp_path / "eaw-sync.log" + configure_logging(level="INFO", log_file=log_file) + + logging.getLogger("easyatcal").info("hello world") + + for h in logging.getLogger().handlers: + h.flush() + + assert log_file.exists() + assert "hello world" in log_file.read_text() From e3ec355bc192813b776fca33484062188ea85c6b Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:50:58 +0200 Subject: [PATCH 13/68] ci+e2e: GitHub Actions matrix + end-to-end ICS test Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 30 +++++++++++++++++++++++ tests/test_e2e_ics.py | 53 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/test_e2e_ics.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a937e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12"] + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e '.[dev]' + - name: Install eventkit extra (macOS only) + if: runner.os == 'macOS' + run: pip install -e '.[eventkit]' + - name: Test + run: pytest --cov=easyatcal diff --git a/tests/test_e2e_ics.py b/tests/test_e2e_ics.py new file mode 100644 index 0000000..20e8f09 --- /dev/null +++ b/tests/test_e2e_ics.py @@ -0,0 +1,53 @@ +"""End-to-end test using the ICS backend and a mocked easy@work API.""" +from pathlib import Path + +import httpx +import respx + +from easyatcal.api import EawClient +from easyatcal.backends.ics import IcsBackend +from easyatcal.orchestrator import run_sync + + +@respx.mock +def test_end_to_end_ics(tmp_path: Path): + token_cache = tmp_path / "token.json" + token_cache.write_text( + '{"access_token":"tok","expires_at":"2099-01-01T00:00:00+00:00"}' + ) + respx.get("https://api.easyatwork.com/v1/shifts").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "Morning", "location": "Oslo", "notes": None, + "updated_at": "2026-04-18T10:00:00+00:00", + } + ], + "next": None, + }, + ) + ) + api = EawClient( + client_id="cid", client_secret="csecret", + base_url="https://api.easyatwork.com", token_cache=token_cache, + ) + ics_out = tmp_path / "shifts.ics" + backend = IcsBackend(output_path=ics_out, known_shifts=[]) + + run_sync( + api=api, + backend=backend, + state_path=tmp_path / "state.json", + lookback_days=1, + lookahead_days=7, + ) + + body = ics_out.read_text() + assert "SUMMARY:Morning" in body + assert "LOCATION:Oslo" in body + assert (tmp_path / "state.json").exists() From 19c2ca49e33e31a5deac64a2be664e4f0aa6bd7e Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:52:16 +0200 Subject: [PATCH 14/68] docs: add HANDOFF.md with full context file list Co-Authored-By: Claude Opus 4.7 --- HANDOFF.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..26a8fe0 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,80 @@ +# HANDOFF — EasyAtCal project context + +Full list of files to hand to another AI so it has every bit of context from this session. The AI should use **only** the files in this list; no external lookups required to understand what was built and why. + +## 1. Session transcript (MOST IMPORTANT) + +Contains the entire conversation: user messages, assistant reasoning, tool calls, tool results, and the final TodoWrite list. JSONL format, one event per line. + +- `/Users/ailcope/.claude/projects/-Users-ailcope-ClaudeCode-EasyAtWork/83e5cde7-97ff-4b9f-b116-8c05c6540380.jsonl` + +Claude Code stores the todo list inline in the transcript as TodoWrite tool calls — no separate todo file to hand over. + +## 2. Design spec + +- `/Users/ailcope/ClaudeCode/EasyAtWork/docs/superpowers/specs/2026-04-19-easyatcal-design.md` + +## 3. Implementation plan + +- `/Users/ailcope/ClaudeCode/EasyAtWork/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md` + +## 4. Project root / packaging + +- `/Users/ailcope/ClaudeCode/EasyAtWork/pyproject.toml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/.gitignore` +- `/Users/ailcope/ClaudeCode/EasyAtWork/README.md` +- `/Users/ailcope/ClaudeCode/EasyAtWork/config.example.yaml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/.github/workflows/ci.yml` + +## 5. Source — `easyatcal/` + +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/__init__.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/models.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/config.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/state.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/api.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/sync.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/orchestrator.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/cli.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/paths.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/logging_setup.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/__init__.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/base.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/ics.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/eventkit.py` + +## 6. Tests — `tests/` + +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/__init__.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/conftest.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/fixtures/config_valid.yaml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_models.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_config.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_state.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_api_auth.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_api_fetch.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_sync.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_orchestrator.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_config.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_sync.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_auth.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_logging_setup.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_e2e_ics.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/backends/__init__.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/backends/test_base.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/backends/test_ics.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/backends/test_eventkit.py` + +## Deliberately excluded + +- `.venv/` — regenerate with `python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev]'` +- `.git/` — repo is mirrored at `git@github.com:Ailcope/EasyAtCal.git` (tag `v0.1.0`) +- `config.yaml`, `state.json`, `token.json`, `*.ics` — never committed; user data / secrets +- `.pytest_cache/`, `__pycache__/`, `*.pyc` — build artifacts + +## Status at handoff + +- 39 tests passing locally (Python 3.12 on macOS). EventKit tests skipped on Linux in CI. +- Branch: `main` at commit `e3ec355`. Tag `v0.1.0` pushed. +- Remaining wiring work for a real user: run `eaw-sync config init`, fill in real easy@work OAuth credentials, pick `ics` or `eventkit` backend, then `eaw-sync sync`. +- Unverified assumptions (flagged in spec "Open questions"): exact easy@work API endpoint paths and pagination shape. The PHP client at `https://github.com/easyatworkas/php-eaw-client` is the reference — inspect it if the default paths in `api.py` are wrong. From 5c355f9016158f41ad5b5e8d1671513f31cfe6e3 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:04:40 +0200 Subject: [PATCH 15/68] chore: add MIT LICENSE --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fcccdf3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ailcope + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 13e0bb6c8e9073ce4fef89919e086d97b312513f Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:04:46 +0200 Subject: [PATCH 16/68] feat: atomic state sync via ApplyResult + BackendError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backends now return ApplyResult(mapping, deleted_uids) and raise BackendError(message, partial) on failure. Orchestrator catches BackendError, persists partial progress, then re-raises — so a crash mid-apply no longer leaves state.json out of sync with the calendar. Phantom deletes (uids already gone) are recorded as confirmed so stale state entries get pruned on the next successful run. --- easyatcal/backends/base.py | 30 +++++++++- easyatcal/backends/eventkit.py | 101 +++++++++++++++++++------------- easyatcal/backends/ics.py | 12 +++- easyatcal/orchestrator.py | 53 ++++++++++++++--- tests/backends/test_eventkit.py | 6 +- tests/backends/test_ics.py | 6 +- tests/test_orchestrator.py | 73 ++++++++++++++++++++++- 7 files changed, 219 insertions(+), 62 deletions(-) diff --git a/easyatcal/backends/base.py b/easyatcal/backends/base.py index fe2c424..d677b8e 100644 --- a/easyatcal/backends/base.py +++ b/easyatcal/backends/base.py @@ -17,8 +17,32 @@ def is_empty(self) -> bool: return not (self.adds or self.updates or self.deletes) +@dataclass +class ApplyResult: + """Outcome of a backend.apply call. + + `mapping` is shift_id -> event_uid for every add/update that succeeded. + `deleted_uids` is the subset of `Changes.deletes` that the backend + confirms were removed from the underlying calendar. + """ + mapping: dict[str, str] = field(default_factory=dict) + deleted_uids: list[str] = field(default_factory=list) + + +class BackendError(RuntimeError): + """Raised by a backend when apply fails partway through. + + Carries whatever progress was made so the orchestrator can persist it + before re-raising. + """ + def __init__(self, message: str, partial: ApplyResult) -> None: + super().__init__(message) + self.partial = partial + + class CalendarBackend(Protocol): - def apply(self, changes: Changes) -> dict[str, str]: - """Apply the given changes. Return mapping shift_id -> event_uid for - all adds/updates.""" + def apply(self, changes: Changes) -> ApplyResult: + """Apply the given changes and return an ApplyResult. + + On partial failure, raise BackendError with a populated .partial.""" ... diff --git a/easyatcal/backends/eventkit.py b/easyatcal/backends/eventkit.py index e81858d..47bc1c1 100644 --- a/easyatcal/backends/eventkit.py +++ b/easyatcal/backends/eventkit.py @@ -8,7 +8,7 @@ import sys from typing import Any -from easyatcal.backends.base import Changes +from easyatcal.backends.base import ApplyResult, BackendError, Changes from easyatcal.models import Shift @@ -99,44 +99,63 @@ def _resolve_calendar(self): f"{self.calendar_source!r}. Create it in Calendar.app first." ) - def apply(self, changes: Changes) -> dict[str, str]: - mapping: dict[str, str] = {} - - for shift in changes.adds: - event = _new_event(self._store, self._calendar, shift) - err = None - self._store.saveEvent_span_error_(event, 0, err) # 0 = EKSpanThisEvent - mapping[shift.id] = event.calendarItemExternalIdentifier() - - for shift, event_uid in changes.updates: - existing = self._store.calendarItemWithIdentifier_(event_uid) - if existing is None: + def apply(self, changes: Changes) -> ApplyResult: + result = ApplyResult() + try: + for shift in changes.adds: event = _new_event(self._store, self._calendar, shift) - err = None - self._store.saveEvent_span_error_(event, 0, err) - mapping[shift.id] = event.calendarItemExternalIdentifier() - continue - existing.setTitle_(shift.title) - import Foundation # type: ignore[import-not-found] - existing.setStartDate_( - Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.start.timestamp()) - ) - existing.setEndDate_( - Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.end.timestamp()) - ) - if shift.location is not None: - existing.setLocation_(shift.location) - if shift.notes is not None: - existing.setNotes_(shift.notes) - err = None - self._store.saveEvent_span_error_(existing, 0, err) - mapping[shift.id] = event_uid - - for event_uid in changes.deletes: - existing = self._store.calendarItemWithIdentifier_(event_uid) - if existing is None: - continue - err = None - self._store.removeEvent_span_error_(existing, 0, err) - - return mapping + ok, err = self._store.saveEvent_span_error_(event, 0, None) + if not ok: + raise BackendError(f"saveEvent failed for {shift.id}: {err}", result) + result.mapping[shift.id] = event.calendarItemExternalIdentifier() + + for shift, event_uid in changes.updates: + existing = self._store.calendarItemWithIdentifier_(event_uid) + if existing is None: + event = _new_event(self._store, self._calendar, shift) + ok, err = self._store.saveEvent_span_error_(event, 0, None) + if not ok: + raise BackendError( + f"saveEvent (replacement) failed for {shift.id}: {err}", + result, + ) + result.mapping[shift.id] = event.calendarItemExternalIdentifier() + continue + existing.setTitle_(shift.title) + import Foundation # type: ignore[import-not-found] + existing.setStartDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_( + shift.start.timestamp() + ) + ) + existing.setEndDate_( + Foundation.NSDate.dateWithTimeIntervalSince1970_( + shift.end.timestamp() + ) + ) + if shift.location is not None: + existing.setLocation_(shift.location) + if shift.notes is not None: + existing.setNotes_(shift.notes) + ok, err = self._store.saveEvent_span_error_(existing, 0, None) + if not ok: + raise BackendError( + f"saveEvent (update) failed for {shift.id}: {err}", result + ) + result.mapping[shift.id] = event_uid + + for event_uid in changes.deletes: + existing = self._store.calendarItemWithIdentifier_(event_uid) + if existing is None: + # Treat as already-deleted so state stays clean. + result.deleted_uids.append(event_uid) + continue + ok, err = self._store.removeEvent_span_error_(existing, 0, None) + if not ok: + raise BackendError( + f"removeEvent failed for {event_uid}: {err}", result + ) + result.deleted_uids.append(event_uid) + except BackendError: + raise + return result diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py index 5ca2437..2d0a40c 100644 --- a/easyatcal/backends/ics.py +++ b/easyatcal/backends/ics.py @@ -4,7 +4,7 @@ from icalendar import Calendar, Event -from easyatcal.backends.base import Changes +from easyatcal.backends.base import ApplyResult, Changes from easyatcal.models import Shift UID_PREFIX = "easyatcal-" @@ -40,7 +40,7 @@ def __init__(self, output_path: Path, known_shifts: list[Shift]) -> None: self.output_path = Path(output_path).expanduser() self._current: dict[str, Shift] = {s.id: s for s in known_shifts} - def apply(self, changes: Changes) -> dict[str, str]: + def apply(self, changes: Changes) -> ApplyResult: mapping: dict[str, str] = {} for shift in changes.adds: @@ -56,11 +56,17 @@ def apply(self, changes: Changes) -> dict[str, str]: sid for sid in self._current if _uid_for(sid) in delete_uids ] + confirmed_deletes: list[str] = [] for sid in to_drop: self._current.pop(sid, None) + confirmed_deletes.append(_uid_for(sid)) + # Any requested delete for a uid we never knew about is treated as + # "already gone" — surface it so the orchestrator prunes state. + for uid in delete_uids - set(confirmed_deletes): + confirmed_deletes.append(uid) self._write() - return mapping + return ApplyResult(mapping=mapping, deleted_uids=confirmed_deletes) def _write(self) -> None: cal = Calendar() diff --git a/easyatcal/orchestrator.py b/easyatcal/orchestrator.py index 671e31c..8d55109 100644 --- a/easyatcal/orchestrator.py +++ b/easyatcal/orchestrator.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Protocol -from easyatcal.backends.base import CalendarBackend +from easyatcal.backends.base import ApplyResult, BackendError, CalendarBackend from easyatcal.models import Shift from easyatcal.state import State, load_state, save_state from easyatcal.sync import compute_changes @@ -31,21 +31,58 @@ def run_sync( changes = compute_changes( remote_shifts, state, known_updated_at=state.shift_updated_at ) - mapping = backend.apply(changes) + raised: BackendError | None = None + try: + result: ApplyResult = backend.apply(changes) + except BackendError as e: + result = e.partial + raised = e + + _persist( + state=state, + state_path=state_path, + remote_shifts=remote_shifts, + result=result, + now=now, + ) + + if raised is not None: + raise raised + + +def _persist( + *, + state: State, + state_path: Path, + remote_shifts: list[Shift], + result: ApplyResult, + now: datetime, +) -> None: new_shift_to_event = dict(state.shift_to_event) new_updated_at = dict(state.shift_updated_at) - for shift_id, event_uid in mapping.items(): + + for shift_id, event_uid in result.mapping.items(): new_shift_to_event[shift_id] = event_uid - for shift in remote_shifts: - new_updated_at[shift.id] = shift.updated_at.isoformat() - remote_ids = {s.id for s in remote_shifts} + # For every shift we successfully wrote, stamp the new updated_at. + remote_by_id = {s.id: s for s in remote_shifts} + for shift_id in result.mapping: + shift = remote_by_id.get(shift_id) + if shift is not None: + new_updated_at[shift_id] = shift.updated_at.isoformat() + + # Prune confirmed deletions. + deleted_uid_set = set(result.deleted_uids) new_shift_to_event = { - sid: evt for sid, evt in new_shift_to_event.items() if sid in remote_ids + sid: evt + for sid, evt in new_shift_to_event.items() + if evt not in deleted_uid_set } + # Drop matching updated_at entries for any shift whose event we just + # deleted (its shift_id no longer maps to an event in new_shift_to_event). new_updated_at = { - sid: ts for sid, ts in new_updated_at.items() if sid in remote_ids + sid: ts for sid, ts in new_updated_at.items() if sid in new_shift_to_event } save_state( diff --git a/tests/backends/test_eventkit.py b/tests/backends/test_eventkit.py index e1efb5e..8e3d1d1 100644 --- a/tests/backends/test_eventkit.py +++ b/tests/backends/test_eventkit.py @@ -35,6 +35,7 @@ def test_apply_adds_creates_events(mock_store_factory): created_event = MagicMock() created_event.calendarItemExternalIdentifier.return_value = "evt-1" + store.saveEvent_span_error_.return_value = (True, None) from easyatcal.backends.eventkit import EventKitBackend @@ -44,9 +45,9 @@ def test_apply_adds_creates_events(mock_store_factory): backend = EventKitBackend( calendar_name="Work Shifts", calendar_source="iCloud" ) - mapping = backend.apply(Changes(adds=[_shift("s1")])) + result = backend.apply(Changes(adds=[_shift("s1")])) - assert mapping == {"s1": "evt-1"} + assert result.mapping == {"s1": "evt-1"} store.saveEvent_span_error_.assert_called() @@ -61,6 +62,7 @@ def test_apply_deletes_removes_events(mock_store_factory): existing = MagicMock() existing.calendarItemExternalIdentifier.return_value = "evt-1" store.calendarItemWithIdentifier_.return_value = existing + store.removeEvent_span_error_.return_value = (True, None) mock_store_factory.return_value = store from easyatcal.backends.eventkit import EventKitBackend diff --git a/tests/backends/test_ics.py b/tests/backends/test_ics.py index 5e228c0..abb4d62 100644 --- a/tests/backends/test_ics.py +++ b/tests/backends/test_ics.py @@ -23,14 +23,14 @@ def test_adds_produce_events_in_file(tmp_path: Path): backend = IcsBackend(output_path=out, known_shifts=[]) changes = Changes(adds=[_shift("s1"), _shift("s2")]) - mapping = backend.apply(changes) + result = backend.apply(changes) body = out.read_text() assert "BEGIN:VCALENDAR" in body assert "SUMMARY:Shift s1" in body assert "SUMMARY:Shift s2" in body - assert mapping["s1"].startswith("easyatcal-s1") - assert mapping["s2"].startswith("easyatcal-s2") + assert result.mapping["s1"].startswith("easyatcal-s1") + assert result.mapping["s2"].startswith("easyatcal-s2") def test_deletes_remove_events(tmp_path: Path): diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 496d7d1..d5f3ee0 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -2,7 +2,9 @@ from pathlib import Path from unittest.mock import MagicMock -from easyatcal.backends.base import Changes +import pytest + +from easyatcal.backends.base import ApplyResult, BackendError, Changes from easyatcal.models import Shift from easyatcal.orchestrator import run_sync from easyatcal.state import load_state @@ -27,7 +29,9 @@ def test_run_sync_applies_changes_and_persists_state(tmp_path: Path): api.fetch_shifts.return_value = [_shift("s1"), _shift("s2")] backend = MagicMock() - backend.apply.return_value = {"s1": "evt-1", "s2": "evt-2"} + backend.apply.return_value = ApplyResult( + mapping={"s1": "evt-1", "s2": "evt-2"}, + ) run_sync( api=api, @@ -46,3 +50,68 @@ def test_run_sync_applies_changes_and_persists_state(tmp_path: Path): assert saved.shift_to_event == {"s1": "evt-1", "s2": "evt-2"} assert saved.shift_updated_at["s1"] == "2026-04-18T00:00:00+00:00" assert saved.last_sync == "2026-04-19T12:00:00+00:00" + + +def test_run_sync_persists_partial_state_on_backend_error(tmp_path: Path): + """If backend.apply half-succeeds, state records what did work, then re-raises.""" + state_path = tmp_path / "state.json" + + api = MagicMock() + api.fetch_shifts.return_value = [_shift("s1"), _shift("s2")] + + backend = MagicMock() + partial = ApplyResult(mapping={"s1": "evt-1"}) + backend.apply.side_effect = BackendError("boom after s1", partial) + + with pytest.raises(BackendError, match="boom after s1"): + run_sync( + api=api, + backend=backend, + state_path=state_path, + lookback_days=1, + lookahead_days=1, + now=datetime(2026, 4, 19, 12, tzinfo=timezone.utc), + ) + + # s1 WAS persisted; s2 was NOT. + saved = load_state(state_path) + assert saved.shift_to_event == {"s1": "evt-1"} + assert "s2" not in saved.shift_to_event + + +def test_run_sync_prunes_deleted_uids(tmp_path: Path): + """State entries whose event_uid is in deleted_uids are removed.""" + state_path = tmp_path / "state.json" + + # Pre-seed state with s_old -> evt-old + from easyatcal.state import State, save_state + save_state(state_path, State( + shift_to_event={"s_old": "evt-old", "s_keep": "evt-keep"}, + shift_updated_at={ + "s_old": "2026-04-01T00:00:00+00:00", + "s_keep": "2026-04-01T00:00:00+00:00", + }, + )) + + api = MagicMock() + # Remote no longer contains s_old + api.fetch_shifts.return_value = [_shift("s_keep")] + + backend = MagicMock() + backend.apply.return_value = ApplyResult( + mapping={}, # s_keep wasn't changed -> no new mapping + deleted_uids=["evt-old"], + ) + + run_sync( + api=api, + backend=backend, + state_path=state_path, + lookback_days=1, + lookahead_days=1, + now=datetime(2026, 4, 19, 12, tzinfo=timezone.utc), + ) + + saved = load_state(state_path) + assert "s_old" not in saved.shift_to_event + assert saved.shift_to_event["s_keep"] == "evt-keep" From 8085ca9d043b91eb830a147620a4808c962f4bf9 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:05:34 +0200 Subject: [PATCH 17/68] feat: add doctor command for config/auth/backend checks --- easyatcal/cli.py | 49 ++++++++++++++++++++++++++++++++ tests/test_cli_doctor.py | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/test_cli_doctor.py diff --git a/easyatcal/cli.py b/easyatcal/cli.py index ae488ea..058f4d8 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -122,6 +122,55 @@ def watch_cmd( typer.echo("\nStopped.") +# ---------- doctor ---------- + +@app.command("doctor") +def doctor_cmd() -> None: + """Check config, credentials, and backend wiring.""" + from easyatcal.api import AuthError + + failures = 0 + cfg_file = config_path() + + # 1. Config + if not cfg_file.exists(): + typer.echo(f"[FAIL] config: not found at {cfg_file}") + typer.echo(" Run `eaw-sync config init`.") + raise typer.Exit(code=1) + try: + cfg = load_config(cfg_file) + typer.echo(f"[ OK ] config: loaded from {cfg_file}") + except Exception as e: + typer.echo(f"[FAIL] config: {e}") + raise typer.Exit(code=1) + + configure_logging(level=cfg.logging.level, log_file=log_path()) + + # 2. Auth + try: + api = _build_api_client(cfg) + api.authenticate() + typer.echo("[ OK ] auth: token obtained") + except AuthError as e: + typer.echo(f"[FAIL] auth: {e}") + failures += 1 + except Exception as e: + typer.echo(f"[FAIL] auth: {e}") + failures += 1 + + # 3. Backend + try: + _build_backend(cfg) + typer.echo(f"[ OK ] backend: {cfg.backend} reachable") + except Exception as e: + typer.echo(f"[FAIL] backend ({cfg.backend}): {e}") + failures += 1 + + if failures: + raise typer.Exit(code=1) + typer.echo("All checks passed.") + + # ---------- auth ---------- @auth_app.command("test") diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py new file mode 100644 index 0000000..1f9f290 --- /dev/null +++ b/tests/test_cli_doctor.py @@ -0,0 +1,61 @@ +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +@patch("easyatcal.cli.config_path") +def test_doctor_all_green(mock_cpath, mock_cfg, mock_log, mock_api, mock_backend, tmp_path): + cfg_file = tmp_path / "config.yaml" + cfg_file.write_text("stub: true\n") + mock_cpath.return_value = cfg_file + mock_cfg.return_value = MagicMock( + logging=MagicMock(level="INFO"), backend="ics" + ) + api = MagicMock() + api.authenticate.return_value = "tok" + mock_api.return_value = api + mock_backend.return_value = MagicMock() + + result = runner.invoke(app, ["doctor"]) + assert result.exit_code == 0, result.stdout + assert "config" in result.stdout.lower() + assert "auth" in result.stdout.lower() + assert "backend" in result.stdout.lower() + + +@patch("easyatcal.cli.config_path") +def test_doctor_reports_missing_config(mock_cpath, tmp_path): + mock_cpath.return_value = tmp_path / "nope.yaml" + result = runner.invoke(app, ["doctor"]) + assert result.exit_code != 0 + assert "config" in result.stdout.lower() + + +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +@patch("easyatcal.cli.config_path") +def test_doctor_reports_auth_failure(mock_cpath, mock_cfg, mock_log, mock_api, tmp_path): + from easyatcal.api import AuthError + + cfg_file = tmp_path / "config.yaml" + cfg_file.write_text("stub: true\n") + mock_cpath.return_value = cfg_file + mock_cfg.return_value = MagicMock( + logging=MagicMock(level="INFO"), backend="ics" + ) + api = MagicMock() + api.authenticate.side_effect = AuthError("401 bad creds") + mock_api.return_value = api + + result = runner.invoke(app, ["doctor"]) + assert result.exit_code != 0 + assert "401" in result.stdout or "bad creds" in result.stdout From 1ddcd0958776ad1d20121c96ef27218b85da26c9 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:05:58 +0200 Subject: [PATCH 18/68] ci: publish to PyPI via trusted publisher on tag push --- .github/workflows/publish.yml | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5c16247 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,42 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install build tooling + run: python -m pip install --upgrade build + - name: Build sdist and wheel + run: python -m build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/easyatcal + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 From 47c78cc9ab353dae7dbba49acb2c7446688a522a Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:15:02 +0200 Subject: [PATCH 19/68] feat(cli): add --dry-run flag to sync --- easyatcal/cli.py | 26 +++++++++++++++++++++++++- tests/test_cli_sync.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 058f4d8..d0f86e2 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -80,12 +80,36 @@ def config_show() -> None: # ---------- sync / watch ---------- @app.command("sync") -def sync_cmd() -> None: +def sync_cmd( + dry_run: bool = typer.Option( + False, "--dry-run", help="Compute changes without touching calendar or state." + ), +) -> None: """Run one sync pass and exit.""" cfg = load_config(config_path()) configure_logging(level=cfg.logging.level, log_file=log_path()) api = _build_api_client(cfg) backend = _build_backend(cfg) + if dry_run: + from datetime import datetime, timedelta, timezone + + from easyatcal.state import load_state + from easyatcal.sync import compute_changes + + now = datetime.now(timezone.utc) + from_date = (now - timedelta(days=cfg.sync.lookback_days)).date() + to_date = (now + timedelta(days=cfg.sync.lookahead_days)).date() + remote = api.fetch_shifts(from_date=from_date, to_date=to_date) + state = load_state(state_path()) + changes = compute_changes( + remote, state, known_updated_at=state.shift_updated_at + ) + typer.echo( + f"Dry run: {len(changes.adds)} add, " + f"{len(changes.updates)} update, " + f"{len(changes.deletes)} delete." + ) + return run_sync( api=api, backend=backend, diff --git a/tests/test_cli_sync.py b/tests/test_cli_sync.py index 98a3452..7dabd3f 100644 --- a/tests/test_cli_sync.py +++ b/tests/test_cli_sync.py @@ -42,3 +42,41 @@ def test_watch_loops_until_interrupt( assert mock_run.call_count == 1 assert result.exit_code == 0 + + +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_sync_dry_run_skips_backend_and_state( + mock_cfg, mock_log, mock_api_build, mock_back, tmp_path +): + from datetime import datetime, timezone + from easyatcal.models import Shift + + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + logging=MagicMock(level="INFO"), + ) + api = MagicMock() + api.fetch_shifts.return_value = [ + Shift( + id="s1", + start=datetime(2026, 5, 1, 9, tzinfo=timezone.utc), + end=datetime(2026, 5, 1, 17, tzinfo=timezone.utc), + title="Shift s1", + location=None, + notes=None, + updated_at=datetime(2026, 4, 29, tzinfo=timezone.utc), + ) + ] + mock_api_build.return_value = api + backend = MagicMock() + mock_back.return_value = backend + + result = runner.invoke(app, ["sync", "--dry-run"]) + + assert result.exit_code == 0, result.stdout + backend.apply.assert_not_called() + assert "dry run" in result.stdout.lower() + assert "add" in result.stdout.lower() From 280c681179b2c8ca7fac3d10ef39cbb2593e80c7 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:15:04 +0200 Subject: [PATCH 20/68] docs: expand README, add CHANGELOG, add launchd agent template --- CHANGELOG.md | 37 ++++++++ README.md | 104 +++++++++++++++++++-- examples/launchd/com.easyatcal.watch.plist | 49 ++++++++++ 3 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 examples/launchd/com.easyatcal.watch.plist diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..998b898 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to this project are documented here. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning is +[SemVer](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- `eaw-sync doctor` preflight command: checks config, auth, backend wiring. +- `eaw-sync sync --dry-run`: computes adds/updates/deletes without touching + the calendar or state. +- `examples/launchd/com.easyatcal.watch.plist`: sample launchd agent for + auto-running `sync` every 15 minutes. +- `CHANGELOG.md`, expanded `README.md`, `LICENSE` (MIT). +- GitHub Actions workflow to publish to PyPI on `v*` tags via trusted + publisher. + +### Changed +- Backends now return `ApplyResult(mapping, deleted_uids)` and raise + `BackendError(message, partial)` on failure. The orchestrator catches the + error, persists partial progress, then re-raises — so a crash mid-apply no + longer leaves `state.json` out of sync with the calendar. + +## [0.1.0] — 2026-04-19 + +### Added +- Initial release. +- `Shift` model, pydantic-v2 config loader with env overrides, atomic JSON + state with corrupt-file recovery. +- easy@work OAuth2 client-credentials auth with token cache, paginated + `fetch_shifts`, exponential backoff on 429/5xx. +- Pluggable `CalendarBackend` protocol, diff engine (`compute_changes`). +- ICS file backend and macOS EventKit backend (pyobjc). +- Typer CLI: `config init/show`, `auth test`, `sync`, `watch`. +- GitHub Actions matrix CI (Linux + macOS × Python 3.11/3.12) with + end-to-end ICS test. diff --git a/README.md b/README.md index 7fcb0d2..7522d51 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ # EasyAtCal -One-way sync of [easy@work](https://www.easyatwork.com) shifts into Apple Calendar. +One-way sync of [easy@work](https://www.easyatwork.com) shifts into Apple +Calendar. Run it on a Mac, iCloud fans out to iPhone/iPad/Watch. + +- Read-only against easy@work; never writes back. +- Two backends: native macOS **EventKit** (recommended) or portable **ICS** file. +- State-tracked: unchanged shifts are skipped; edits and deletions propagate. +- Open-source friendly: code is public, your `config.yaml` / `state.json` stay + local (see `.gitignore`). ## Install @@ -9,20 +16,97 @@ pip install easyatcal # core + ICS backend pip install 'easyatcal[eventkit]' # add macOS EventKit backend ``` -## Configure +Python 3.11+. macOS for EventKit; any OS for ICS. + +## Quickstart ```bash -eaw-sync config init -# edit ~/.config/easyatcal/config.yaml +eaw-sync config init # scaffold config +$EDITOR ~/.config/easyatcal/config.yaml +eaw-sync doctor # check config + auth + backend +eaw-sync sync # one shot +eaw-sync watch --interval-seconds 900 # loop every 15 min +``` + +## Configure + +Minimal `config.yaml`: + +```yaml +easyatwork: + client_id: "REPLACE_ME" + client_secret: "REPLACE_ME" # or export EAW_CLIENT_SECRET + base_url: "https://api.easyatwork.com" + +sync: + lookback_days: 7 + lookahead_days: 90 + +backend: eventkit # or "ics" + +backends: + eventkit: + calendar_name: "Work Shifts" # must exist in Calendar.app + calendar_source: "iCloud" + ics: + output_path: "~/Documents/easyatwork-shifts.ics" + +logging: + level: INFO ``` -## Run +Env overrides: any `easyatwork.*` field is overridable via `EAW_*` (e.g. +`EAW_CLIENT_SECRET`). + +## Backends + +**EventKit (macOS).** Writes directly to a dedicated calendar in Calendar.app. +Create the calendar manually once — e.g. "Work Shifts" under "iCloud" — then +point `calendar_name` / `calendar_source` at it. First run triggers a Calendar +permission prompt; grant access in *System Settings → Privacy & Security → +Calendars*. + +**ICS.** Writes a single `.ics` file. Subscribe to it from Calendar.app (or any +calendar client) via `File → New Calendar Subscription`. Portable, no +permissions needed. + +## Commands + +| Command | What | +|---------|------| +| `eaw-sync config init` | Scaffold config file. | +| `eaw-sync config show` | Print effective config (secrets redacted). | +| `eaw-sync auth test` | Verify credentials can obtain a token. | +| `eaw-sync doctor` | Full preflight: config loads, auth works, backend reachable. | +| `eaw-sync sync` | Run one sync pass and exit. | +| `eaw-sync watch --interval-seconds N` | Loop until Ctrl-C. | + +## Troubleshooting + +- **"Calendar 'Work Shifts' not found"** — create it in Calendar.app first; + source name must match (`iCloud`, `On My Mac`, etc). +- **Calendar permission denied** — System Settings → Privacy & Security → + Calendars → enable for your terminal / launchd agent. +- **`auth failed`** — run `eaw-sync doctor`, check `EAW_CLIENT_SECRET`, confirm + `base_url`. +- **Stale events after delete** — state entries auto-prune once the backend + confirms the delete. Corrupt `state.json` is quarantined and rebuilt. + +## Auto-run on macOS + +A sample launchd plist is in `examples/launchd/com.easyatcal.watch.plist`. +Load with: ```bash -eaw-sync sync # one-shot -eaw-sync watch --interval-seconds 900 # daemon mode (15 min) +cp examples/launchd/com.easyatcal.watch.plist ~/Library/LaunchAgents/ +launchctl load ~/Library/LaunchAgents/com.easyatcal.watch.plist ``` -See `docs/superpowers/specs/2026-04-19-easyatcal-design.md` for the full design, -and `docs/superpowers/plans/2026-04-19-easyatcal-implementation.md` for the -implementation plan. +## Design + +- `docs/superpowers/specs/2026-04-19-easyatcal-design.md` — full design. +- `docs/superpowers/plans/2026-04-19-easyatcal-implementation.md` — build plan. + +## License + +MIT — see `LICENSE`. diff --git a/examples/launchd/com.easyatcal.watch.plist b/examples/launchd/com.easyatcal.watch.plist new file mode 100644 index 0000000..ef26dc1 --- /dev/null +++ b/examples/launchd/com.easyatcal.watch.plist @@ -0,0 +1,49 @@ + + + + + + Label + com.easyatcal.watch + + ProgramArguments + + /REPLACE/WITH/ABSOLUTE/PATH/TO/eaw-sync + sync + + + StartInterval + 900 + + RunAtLoad + + + StandardOutPath + /tmp/easyatcal.out.log + + StandardErrorPath + /tmp/easyatcal.err.log + + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin + + + From 34767f7a71e54a482a9021ac4206419b575c0666 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:15:59 +0200 Subject: [PATCH 21/68] feat(cli): add state show command --- easyatcal/cli.py | 16 ++++++++++++++++ tests/test_cli_state.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/test_cli_state.py diff --git a/easyatcal/cli.py b/easyatcal/cli.py index d0f86e2..2612950 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -22,8 +22,10 @@ app = typer.Typer(help="EasyAtCal — sync easy@work shifts to Apple Calendar.") config_app = typer.Typer(help="Manage the config file.") auth_app = typer.Typer(help="Credential checks.") +state_app = typer.Typer(help="Inspect local sync state.") app.add_typer(config_app, name="config") app.add_typer(auth_app, name="auth") +app.add_typer(state_app, name="state") EXAMPLE_CONFIG = Path(__file__).parent.parent / "config.example.yaml" @@ -146,6 +148,20 @@ def watch_cmd( typer.echo("\nStopped.") +# ---------- state ---------- + +@state_app.command("show") +def state_show() -> None: + """Print a summary of the local sync state.""" + from easyatcal.state import load_state + + sp = state_path() + state = load_state(sp) + typer.echo(f"Path: {sp}") + typer.echo(f"Tracked shifts: {len(state.shift_to_event)}") + typer.echo(f"Last sync: {state.last_sync or 'never'}") + + # ---------- doctor ---------- @app.command("doctor") diff --git a/tests/test_cli_state.py b/tests/test_cli_state.py new file mode 100644 index 0000000..bfbbf48 --- /dev/null +++ b/tests/test_cli_state.py @@ -0,0 +1,36 @@ +from unittest.mock import patch + +from typer.testing import CliRunner + +from easyatcal.cli import app +from easyatcal.state import State, save_state + +runner = CliRunner() + + +@patch("easyatcal.cli.state_path") +def test_state_show_reports_summary(mock_sp, tmp_path): + sp = tmp_path / "state.json" + save_state(sp, State( + shift_to_event={"s1": "evt-1", "s2": "evt-2"}, + shift_updated_at={ + "s1": "2026-04-18T00:00:00+00:00", + "s2": "2026-04-18T00:00:00+00:00", + }, + last_sync="2026-04-19T12:00:00+00:00", + )) + mock_sp.return_value = sp + + result = runner.invoke(app, ["state", "show"]) + assert result.exit_code == 0, result.stdout + assert "2" in result.stdout # shift count + assert "2026-04-19" in result.stdout + assert str(sp) in result.stdout + + +@patch("easyatcal.cli.state_path") +def test_state_show_handles_missing(mock_sp, tmp_path): + mock_sp.return_value = tmp_path / "nope.json" + result = runner.invoke(app, ["state", "show"]) + assert result.exit_code == 0 + assert "0" in result.stdout or "empty" in result.stdout.lower() From a7971ea027c32466908fc0d7faeddc35122cd32b Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:17:17 +0200 Subject: [PATCH 22/68] chore: add ruff config, pre-commit hooks, CI lint + 85% coverage gate --- .github/workflows/ci.yml | 4 +++- .pre-commit-config.yaml | 16 ++++++++++++++++ easyatcal/api.py | 11 +++++------ easyatcal/cli.py | 9 +++++---- easyatcal/orchestrator.py | 4 ++-- pyproject.toml | 12 ++++++++++++ tests/backends/test_eventkit.py | 8 ++++---- tests/backends/test_ics.py | 8 ++++---- tests/conftest.py | 1 - tests/test_cli_sync.py | 10 ++++++---- tests/test_config.py | 1 - tests/test_models.py | 12 ++++++------ tests/test_orchestrator.py | 14 +++++++------- tests/test_sync.py | 6 +++--- 14 files changed, 73 insertions(+), 43 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a937e4..567985c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,5 +26,7 @@ jobs: - name: Install eventkit extra (macOS only) if: runner.os == 'macOS' run: pip install -e '.[eventkit]' + - name: Lint + run: ruff check easyatcal tests - name: Test - run: pytest --cov=easyatcal + run: pytest --cov=easyatcal --cov-fail-under=85 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6bbf2b1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict diff --git a/easyatcal/api.py b/easyatcal/api.py index 598f9ad..bfedfd4 100644 --- a/easyatcal/api.py +++ b/easyatcal/api.py @@ -1,9 +1,10 @@ from __future__ import annotations +import contextlib import json import os import time -from datetime import date, datetime, timedelta, timezone +from datetime import UTC, date, datetime, timedelta from pathlib import Path import httpx @@ -54,7 +55,7 @@ def _read_cache(self) -> str | None: except (json.JSONDecodeError, ValueError): return None expires_at = datetime.fromisoformat(data["expires_at"]) - if expires_at <= datetime.now(timezone.utc): + if expires_at <= datetime.now(UTC): return None return data["access_token"] @@ -74,7 +75,7 @@ def _fetch_token(self) -> str: raise AuthError(f"auth failed: {r.status_code} {r.text}") data = r.json() token = data["access_token"] - expires_at = datetime.now(timezone.utc) + timedelta( + expires_at = datetime.now(UTC) + timedelta( seconds=int(data.get("expires_in", 3600)) ) self._write_cache(token, expires_at) @@ -87,10 +88,8 @@ def _write_cache(self, token: str, expires_at: datetime) -> None: tmp = self.token_cache.with_suffix(self.token_cache.suffix + ".tmp") tmp.write_text(json.dumps(payload)) os.replace(tmp, self.token_cache) - try: + with contextlib.suppress(OSError): os.chmod(self.token_cache, 0o600) - except OSError: - pass # ----- shifts ----- diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 2612950..12a1679 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -2,6 +2,7 @@ import shutil import time +from datetime import UTC from pathlib import Path import typer @@ -93,12 +94,12 @@ def sync_cmd( api = _build_api_client(cfg) backend = _build_backend(cfg) if dry_run: - from datetime import datetime, timedelta, timezone + from datetime import datetime, timedelta from easyatcal.state import load_state from easyatcal.sync import compute_changes - now = datetime.now(timezone.utc) + now = datetime.now(UTC) from_date = (now - timedelta(days=cfg.sync.lookback_days)).date() to_date = (now + timedelta(days=cfg.sync.lookahead_days)).date() remote = api.fetch_shifts(from_date=from_date, to_date=to_date) @@ -182,7 +183,7 @@ def doctor_cmd() -> None: typer.echo(f"[ OK ] config: loaded from {cfg_file}") except Exception as e: typer.echo(f"[FAIL] config: {e}") - raise typer.Exit(code=1) + raise typer.Exit(code=1) from e configure_logging(level=cfg.logging.level, log_file=log_path()) @@ -225,5 +226,5 @@ def auth_test() -> None: api.authenticate() except AuthError as e: typer.echo(f"Auth failed: {e}") - raise typer.Exit(code=2) + raise typer.Exit(code=2) from e typer.echo("OK -- credentials work.") diff --git a/easyatcal/orchestrator.py b/easyatcal/orchestrator.py index 8d55109..e1d3b94 100644 --- a/easyatcal/orchestrator.py +++ b/easyatcal/orchestrator.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Protocol @@ -22,7 +22,7 @@ def run_sync( lookahead_days: int, now: datetime | None = None, ) -> None: - now = now or datetime.now(timezone.utc) + now = now or datetime.now(UTC) from_date = (now - timedelta(days=lookback_days)).date() to_date = (now + timedelta(days=lookahead_days)).date() diff --git a/pyproject.toml b/pyproject.toml index 151a94f..d920344 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dev = [ "pytest-cov>=5.0", "respx>=0.21", "freezegun>=1.4", + "ruff>=0.6", ] [project.scripts] @@ -37,3 +38,14 @@ packages = ["easyatcal"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v --strict-markers" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] +ignore = ["E501"] # line length handled by formatter + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B017", "B018"] diff --git a/tests/backends/test_eventkit.py b/tests/backends/test_eventkit.py index 8e3d1d1..c550513 100644 --- a/tests/backends/test_eventkit.py +++ b/tests/backends/test_eventkit.py @@ -1,5 +1,5 @@ import sys -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import MagicMock, patch import pytest @@ -15,12 +15,12 @@ def _shift(id_: str) -> Shift: return Shift( id=id_, - start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), - end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + start=datetime(2026, 4, 20, 9, tzinfo=UTC), + end=datetime(2026, 4, 20, 17, tzinfo=UTC), title=f"Shift {id_}", location=None, notes=None, - updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + updated_at=datetime(2026, 4, 18, tzinfo=UTC), ) diff --git a/tests/backends/test_ics.py b/tests/backends/test_ics.py index abb4d62..f9a18cd 100644 --- a/tests/backends/test_ics.py +++ b/tests/backends/test_ics.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from easyatcal.backends.base import Changes @@ -9,12 +9,12 @@ def _shift(id_: str) -> Shift: return Shift( id=id_, - start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), - end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + start=datetime(2026, 4, 20, 9, tzinfo=UTC), + end=datetime(2026, 4, 20, 17, tzinfo=UTC), title=f"Shift {id_}", location="Oslo", notes=None, - updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + updated_at=datetime(2026, 4, 18, tzinfo=UTC), ) diff --git a/tests/conftest.py b/tests/conftest.py index 5871ed8..e69de29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +0,0 @@ -import pytest diff --git a/tests/test_cli_sync.py b/tests/test_cli_sync.py index 7dabd3f..e9f07ad 100644 --- a/tests/test_cli_sync.py +++ b/tests/test_cli_sync.py @@ -1,3 +1,4 @@ +from datetime import UTC from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -51,7 +52,8 @@ def test_watch_loops_until_interrupt( def test_sync_dry_run_skips_backend_and_state( mock_cfg, mock_log, mock_api_build, mock_back, tmp_path ): - from datetime import datetime, timezone + from datetime import datetime + from easyatcal.models import Shift mock_cfg.return_value = MagicMock( @@ -62,12 +64,12 @@ def test_sync_dry_run_skips_backend_and_state( api.fetch_shifts.return_value = [ Shift( id="s1", - start=datetime(2026, 5, 1, 9, tzinfo=timezone.utc), - end=datetime(2026, 5, 1, 17, tzinfo=timezone.utc), + start=datetime(2026, 5, 1, 9, tzinfo=UTC), + end=datetime(2026, 5, 1, 17, tzinfo=UTC), title="Shift s1", location=None, notes=None, - updated_at=datetime(2026, 4, 29, tzinfo=timezone.utc), + updated_at=datetime(2026, 4, 29, tzinfo=UTC), ) ] mock_api_build.return_value = api diff --git a/tests/test_config.py b/tests/test_config.py index 5206d27..d986b6b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,7 +4,6 @@ from easyatcal.config import Config, load_config - FIXTURE = Path(__file__).parent / "fixtures" / "config_valid.yaml" diff --git a/tests/test_models.py b/tests/test_models.py index e4052f2..23d8f55 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -8,12 +8,12 @@ def test_shift_is_frozen_dataclass(): shift = Shift( id="abc", - start=datetime(2026, 4, 20, 9, 0, tzinfo=timezone.utc), - end=datetime(2026, 4, 20, 17, 0, tzinfo=timezone.utc), + start=datetime(2026, 4, 20, 9, 0, tzinfo=UTC), + end=datetime(2026, 4, 20, 17, 0, tzinfo=UTC), title="Morning", location=None, notes=None, - updated_at=datetime(2026, 4, 18, 10, 0, tzinfo=timezone.utc), + updated_at=datetime(2026, 4, 18, 10, 0, tzinfo=UTC), ) assert shift.id == "abc" assert shift.duration_hours == 8.0 @@ -24,9 +24,9 @@ def test_shift_requires_tz_aware_datetimes(): Shift( id="abc", start=datetime(2026, 4, 20, 9, 0), # naive - end=datetime(2026, 4, 20, 17, 0, tzinfo=timezone.utc), + end=datetime(2026, 4, 20, 17, 0, tzinfo=UTC), title="t", location=None, notes=None, - updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + updated_at=datetime(2026, 4, 18, tzinfo=UTC), ) diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index d5f3ee0..9fe687c 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from unittest.mock import MagicMock @@ -13,12 +13,12 @@ def _shift(id_: str) -> Shift: return Shift( id=id_, - start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), - end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + start=datetime(2026, 4, 20, 9, tzinfo=UTC), + end=datetime(2026, 4, 20, 17, tzinfo=UTC), title=f"t{id_}", location=None, notes=None, - updated_at=datetime(2026, 4, 18, tzinfo=timezone.utc), + updated_at=datetime(2026, 4, 18, tzinfo=UTC), ) @@ -39,7 +39,7 @@ def test_run_sync_applies_changes_and_persists_state(tmp_path: Path): state_path=state_path, lookback_days=1, lookahead_days=1, - now=datetime(2026, 4, 19, 12, tzinfo=timezone.utc), + now=datetime(2026, 4, 19, 12, tzinfo=UTC), ) changes = backend.apply.call_args.args[0] @@ -70,7 +70,7 @@ def test_run_sync_persists_partial_state_on_backend_error(tmp_path: Path): state_path=state_path, lookback_days=1, lookahead_days=1, - now=datetime(2026, 4, 19, 12, tzinfo=timezone.utc), + now=datetime(2026, 4, 19, 12, tzinfo=UTC), ) # s1 WAS persisted; s2 was NOT. @@ -109,7 +109,7 @@ def test_run_sync_prunes_deleted_uids(tmp_path: Path): state_path=state_path, lookback_days=1, lookahead_days=1, - now=datetime(2026, 4, 19, 12, tzinfo=timezone.utc), + now=datetime(2026, 4, 19, 12, tzinfo=UTC), ) saved = load_state(state_path) diff --git a/tests/test_sync.py b/tests/test_sync.py index 762a0ba..fa29645 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from easyatcal.models import Shift from easyatcal.state import State @@ -8,8 +8,8 @@ def _shift(id_: str, updated: str = "2026-04-18T10:00:00+00:00") -> Shift: return Shift( id=id_, - start=datetime(2026, 4, 20, 9, tzinfo=timezone.utc), - end=datetime(2026, 4, 20, 17, tzinfo=timezone.utc), + start=datetime(2026, 4, 20, 9, tzinfo=UTC), + end=datetime(2026, 4, 20, 17, tzinfo=UTC), title="t", location=None, notes=None, From 3ddaa1ece623eb30c27f01781e33417931172d82 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:19:33 +0200 Subject: [PATCH 23/68] feat: exit codes, sync summary, JSON log format - sync exits 0 (clean) / 1 (BackendError partial) / 2 (fatal). - run_sync returns SyncSummary(adds, updates, deletes); CLI prints it. - logging.format: text|json in config; JsonFormatter for log aggregators. --- config.example.yaml | 1 + easyatcal/cli.py | 34 +++++++++++++++++++++----------- easyatcal/config.py | 1 + easyatcal/logging_setup.py | 28 ++++++++++++++++++++++---- easyatcal/orchestrator.py | 22 ++++++++++++++++++++- tests/test_cli_sync.py | 39 +++++++++++++++++++++++++++++++++++++ tests/test_logging_setup.py | 15 ++++++++++++++ 7 files changed, 124 insertions(+), 16 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index f536398..cdf5eb5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -20,3 +20,4 @@ backends: logging: level: INFO + format: text # "text" or "json" diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 12a1679..fef473b 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -90,7 +90,7 @@ def sync_cmd( ) -> None: """Run one sync pass and exit.""" cfg = load_config(config_path()) - configure_logging(level=cfg.logging.level, log_file=log_path()) + configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) api = _build_api_client(cfg) backend = _build_backend(cfg) if dry_run: @@ -113,14 +113,26 @@ def sync_cmd( f"{len(changes.deletes)} delete." ) return - run_sync( - api=api, - backend=backend, - state_path=state_path(), - lookback_days=cfg.sync.lookback_days, - lookahead_days=cfg.sync.lookahead_days, + from easyatcal.backends.base import BackendError + + try: + summary = run_sync( + api=api, + backend=backend, + state_path=state_path(), + lookback_days=cfg.sync.lookback_days, + lookahead_days=cfg.sync.lookahead_days, + ) + except BackendError as e: + typer.echo(f"Sync partial failure: {e}") + raise typer.Exit(code=1) from e + except Exception as e: + typer.echo(f"Sync failed: {e}") + raise typer.Exit(code=2) from e + typer.echo( + f"Sync complete: {summary.adds} added, " + f"{summary.updates} updated, {summary.deletes} deleted." ) - typer.echo("Sync complete.") @app.command("watch") @@ -131,7 +143,7 @@ def watch_cmd( ) -> None: """Run sync on a loop until Ctrl-C.""" cfg = load_config(config_path()) - configure_logging(level=cfg.logging.level, log_file=log_path()) + configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) api = _build_api_client(cfg) backend = _build_backend(cfg) try: @@ -185,7 +197,7 @@ def doctor_cmd() -> None: typer.echo(f"[FAIL] config: {e}") raise typer.Exit(code=1) from e - configure_logging(level=cfg.logging.level, log_file=log_path()) + configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) # 2. Auth try: @@ -220,7 +232,7 @@ def auth_test() -> None: from easyatcal.api import AuthError cfg = load_config(config_path()) - configure_logging(level=cfg.logging.level, log_file=log_path()) + configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) api = _build_api_client(cfg) try: api.authenticate() diff --git a/easyatcal/config.py b/easyatcal/config.py index 0beacc7..480a3c0 100644 --- a/easyatcal/config.py +++ b/easyatcal/config.py @@ -37,6 +37,7 @@ class BackendsSettings(BaseModel): class LoggingSettings(BaseModel): level: str = "INFO" + format: Literal["text", "json"] = "text" class Config(BaseModel): diff --git a/easyatcal/logging_setup.py b/easyatcal/logging_setup.py index d3ef0eb..59a6ad8 100644 --- a/easyatcal/logging_setup.py +++ b/easyatcal/logging_setup.py @@ -1,25 +1,45 @@ from __future__ import annotations +import json import logging from logging.handlers import TimedRotatingFileHandler from pathlib import Path -def configure_logging(level: str, log_file: Path) -> None: +class _JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), + "level": record.levelname, + "logger": record.name, + "msg": record.getMessage(), + } + if record.exc_info: + payload["exc"] = self.formatException(record.exc_info) + return json.dumps(payload) + + +def configure_logging(level: str, log_file: Path, fmt: str = "text") -> None: log_file.parent.mkdir(parents=True, exist_ok=True) root = logging.getLogger() for h in list(root.handlers): root.removeHandler(h) root.setLevel(level) - fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + formatter: logging.Formatter + if fmt == "json": + formatter = _JsonFormatter() + else: + formatter = logging.Formatter( + "%(asctime)s %(levelname)s %(name)s: %(message)s" + ) file_h = TimedRotatingFileHandler( log_file, when="midnight", backupCount=7, encoding="utf-8" ) - file_h.setFormatter(fmt) + file_h.setFormatter(formatter) root.addHandler(file_h) console_h = logging.StreamHandler() - console_h.setFormatter(fmt) + console_h.setFormatter(formatter) root.addHandler(console_h) diff --git a/easyatcal/orchestrator.py b/easyatcal/orchestrator.py index e1d3b94..b4b7f17 100644 --- a/easyatcal/orchestrator.py +++ b/easyatcal/orchestrator.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Protocol @@ -10,6 +11,13 @@ from easyatcal.sync import compute_changes +@dataclass +class SyncSummary: + adds: int = 0 + updates: int = 0 + deletes: int = 0 + + class ShiftFetcher(Protocol): def fetch_shifts(self, from_date, to_date) -> list[Shift]: ... @@ -21,7 +29,7 @@ def run_sync( lookback_days: int, lookahead_days: int, now: datetime | None = None, -) -> None: +) -> SyncSummary: now = now or datetime.now(UTC) from_date = (now - timedelta(days=lookback_days)).date() to_date = (now + timedelta(days=lookahead_days)).date() @@ -47,8 +55,20 @@ def run_sync( now=now, ) + # Count adds vs updates separately by checking existing state. + prev_ids = set(state.shift_to_event) + added = sum(1 for sid in result.mapping if sid not in prev_ids) + updated = sum(1 for sid in result.mapping if sid in prev_ids) + summary = SyncSummary( + adds=added, + updates=updated, + deletes=len(result.deleted_uids), + ) + if raised is not None: + raised.summary = summary # type: ignore[attr-defined] raise raised + return summary def _persist( diff --git a/tests/test_cli_sync.py b/tests/test_cli_sync.py index e9f07ad..f310a8d 100644 --- a/tests/test_cli_sync.py +++ b/tests/test_cli_sync.py @@ -82,3 +82,42 @@ def test_sync_dry_run_skips_backend_and_state( backend.apply.assert_not_called() assert "dry run" in result.stdout.lower() assert "add" in result.stdout.lower() + + +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_sync_partial_failure_exits_1( + mock_cfg, mock_log, mock_api, mock_back, mock_run +): + from easyatcal.backends.base import ApplyResult, BackendError + + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + logging=MagicMock(level="INFO"), + ) + mock_run.side_effect = BackendError("half done", ApplyResult(mapping={"s1": "e1"})) + + result = runner.invoke(app, ["sync"]) + assert result.exit_code == 1, result.stdout + assert "half done" in result.stdout.lower() or "partial" in result.stdout.lower() + + +@patch("easyatcal.cli.run_sync") +@patch("easyatcal.cli._build_backend") +@patch("easyatcal.cli._build_api_client") +@patch("easyatcal.cli.configure_logging") +@patch("easyatcal.cli.load_config") +def test_sync_fatal_failure_exits_2( + mock_cfg, mock_log, mock_api, mock_back, mock_run +): + mock_cfg.return_value = MagicMock( + sync=MagicMock(lookback_days=7, lookahead_days=90), + logging=MagicMock(level="INFO"), + ) + mock_run.side_effect = RuntimeError("network down") + + result = runner.invoke(app, ["sync"]) + assert result.exit_code == 2, result.stdout diff --git a/tests/test_logging_setup.py b/tests/test_logging_setup.py index 17f26e7..0965499 100644 --- a/tests/test_logging_setup.py +++ b/tests/test_logging_setup.py @@ -15,3 +15,18 @@ def test_configure_logging_writes_to_file(tmp_path: Path): assert log_file.exists() assert "hello world" in log_file.read_text() + + +def test_configure_logging_json_format(tmp_path: Path): + import json + + log_file = tmp_path / "eaw-sync.log" + configure_logging(level="INFO", log_file=log_file, fmt="json") + logging.getLogger("easyatcal").info("payload-ok") + for h in logging.getLogger().handlers: + h.flush() + line = log_file.read_text().strip().splitlines()[-1] + record = json.loads(line) + assert record["msg"] == "payload-ok" + assert record["level"] == "INFO" + assert "ts" in record From 06032ef6c7e7921e81fc080bd91f03086d52970a Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:22:52 +0200 Subject: [PATCH 24/68] feat: SIGTERM handling in watch + Retry-After in API backoff - watch installs SIGTERM handler and sleeps in 1s slices so launchd/systemd stop is prompt and graceful. - api.fetch_shifts honors Retry-After header (seconds) on 429/5xx, uses max(exp_backoff, Retry-After). - CHANGELOG updated with recent features. --- CHANGELOG.md | 12 ++++++++++++ easyatcal/api.py | 7 ++++++- easyatcal/cli.py | 26 ++++++++++++++++++++++---- tests/test_api_fetch.py | 20 ++++++++++++++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 998b898..55b28ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,22 @@ All notable changes to this project are documented here. Format follows ### Added - `eaw-sync doctor` preflight command: checks config, auth, backend wiring. +- `eaw-sync state show`: prints local state path, tracked-shift count, last sync. - `eaw-sync sync --dry-run`: computes adds/updates/deletes without touching the calendar or state. +- Defined `sync` exit codes: 0 clean, 1 partial failure (`BackendError`), + 2 fatal (config/auth/network). +- Post-sync summary line (`Sync complete: X added, Y updated, Z deleted.`); + `run_sync` now returns a `SyncSummary`. +- `logging.format: text|json` config option; JSON formatter suitable for log + aggregators. +- `watch` handles `SIGTERM` gracefully (launchctl unload / systemd stop), + sleeps in 1-second slices for quick exit. +- API backoff honors `Retry-After` header on 429/5xx responses. - `examples/launchd/com.easyatcal.watch.plist`: sample launchd agent for auto-running `sync` every 15 minutes. +- Ruff config + `.pre-commit-config.yaml`; CI now lints and enforces 85% + coverage. - `CHANGELOG.md`, expanded `README.md`, `LICENSE` (MIT). - GitHub Actions workflow to publish to PyPI on `v*` tags via trusted publisher. diff --git a/easyatcal/api.py b/easyatcal/api.py index bfedfd4..205f3e2 100644 --- a/easyatcal/api.py +++ b/easyatcal/api.py @@ -117,7 +117,12 @@ def fetch_shifts(self, from_date: date, to_date: date) -> list[Shift]: f"rate limit / server errors exceeded retries " f"({r.status_code})" ) - time.sleep(2 ** (attempts - 1)) + delay = 2 ** (attempts - 1) + retry_after = r.headers.get("Retry-After") + if retry_after is not None: + with contextlib.suppress(ValueError): + delay = max(delay, int(retry_after)) + time.sleep(delay) continue raise ApiError(f"GET {url} -> {r.status_code} {r.text}") diff --git a/easyatcal/cli.py b/easyatcal/cli.py index fef473b..953e46d 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -141,13 +141,24 @@ def watch_cmd( 900, "--interval-seconds", help="Seconds between sync passes." ), ) -> None: - """Run sync on a loop until Ctrl-C.""" + """Run sync on a loop until Ctrl-C or SIGTERM.""" + import signal + cfg = load_config(config_path()) configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) api = _build_api_client(cfg) backend = _build_backend(cfg) + + stop = False + + def _handler(signum, _frame): # noqa: ARG001 + nonlocal stop + stop = True + + signal.signal(signal.SIGTERM, _handler) + try: - while True: + while not stop: run_sync( api=api, backend=backend, @@ -155,10 +166,17 @@ def watch_cmd( lookback_days=cfg.sync.lookback_days, lookahead_days=cfg.sync.lookahead_days, ) + if stop: + break typer.echo(f"Sleeping {interval_seconds}s...") - time.sleep(interval_seconds) + # Sleep in 1s slices so SIGTERM exits promptly. + for _ in range(interval_seconds): + if stop: + break + time.sleep(1) except KeyboardInterrupt: - typer.echo("\nStopped.") + pass + typer.echo("\nStopped.") # ---------- state ---------- diff --git a/tests/test_api_fetch.py b/tests/test_api_fetch.py index 447cbd6..03c1cff 100644 --- a/tests/test_api_fetch.py +++ b/tests/test_api_fetch.py @@ -119,6 +119,26 @@ def test_fetch_shifts_retries_on_429(tmp_path: Path, monkeypatch): assert sleeps[0] >= 1 +@respx.mock +def test_fetch_shifts_honors_retry_after_header(tmp_path: Path, monkeypatch): + sleeps: list[float] = [] + monkeypatch.setattr("easyatcal.api.time.sleep", lambda s: sleeps.append(s)) + + responses_iter = iter([ + httpx.Response(429, headers={"Retry-After": "7"}), + httpx.Response(200, json={"data": [], "next": None}), + ]) + respx.get("https://api.easyatwork.com/v1/shifts").mock( + side_effect=lambda req: next(responses_iter) + ) + client = _fresh_client(tmp_path) + + client.fetch_shifts( + from_date=date(2026, 4, 19), to_date=date(2026, 4, 22) + ) + assert sleeps == [7] + + @respx.mock def test_fetch_shifts_gives_up_after_retries(tmp_path: Path, monkeypatch): monkeypatch.setattr("easyatcal.api.time.sleep", lambda s: None) From 4ef9835219f6a7267aef6ca62db508f3b2c3d5ad Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:25:26 +0200 Subject: [PATCH 25/68] feat(cli): global --config-path override + docs refresh - Root callback captures --config-path; all commands read via _cfg_path(). - README: state show, --dry-run, exit codes table, --install-completion note. - HANDOFF.md: refreshed file list and status post-improvements (53 tests). --- HANDOFF.md | 46 ++++++++++++++++++++++++++--------- README.md | 21 ++++++++++++++-- easyatcal/cli.py | 31 ++++++++++++++++++----- tests/test_cli_config_path.py | 34 ++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 tests/test_cli_config_path.py diff --git a/HANDOFF.md b/HANDOFF.md index 26a8fe0..6495fdc 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -10,23 +10,25 @@ Contains the entire conversation: user messages, assistant reasoning, tool calls Claude Code stores the todo list inline in the transcript as TodoWrite tool calls — no separate todo file to hand over. -## 2. Design spec +## 2. Design spec & plan - `/Users/ailcope/ClaudeCode/EasyAtWork/docs/superpowers/specs/2026-04-19-easyatcal-design.md` - -## 3. Implementation plan - - `/Users/ailcope/ClaudeCode/EasyAtWork/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md` -## 4. Project root / packaging +## 3. Project root / packaging / ops - `/Users/ailcope/ClaudeCode/EasyAtWork/pyproject.toml` - `/Users/ailcope/ClaudeCode/EasyAtWork/.gitignore` - `/Users/ailcope/ClaudeCode/EasyAtWork/README.md` +- `/Users/ailcope/ClaudeCode/EasyAtWork/CHANGELOG.md` +- `/Users/ailcope/ClaudeCode/EasyAtWork/LICENSE` - `/Users/ailcope/ClaudeCode/EasyAtWork/config.example.yaml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/.pre-commit-config.yaml` - `/Users/ailcope/ClaudeCode/EasyAtWork/.github/workflows/ci.yml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/.github/workflows/publish.yml` +- `/Users/ailcope/ClaudeCode/EasyAtWork/examples/launchd/com.easyatcal.watch.plist` -## 5. Source — `easyatcal/` +## 4. Source — `easyatcal/` - `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/__init__.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/models.py` @@ -43,7 +45,7 @@ Claude Code stores the todo list inline in the transcript as TodoWrite tool call - `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/ics.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/eventkit.py` -## 6. Tests — `tests/` +## 5. Tests — `tests/` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/__init__.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/conftest.py` @@ -56,8 +58,11 @@ Claude Code stores the todo list inline in the transcript as TodoWrite tool call - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_sync.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_orchestrator.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_config.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_config_path.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_sync.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_auth.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_doctor.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_state.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_logging_setup.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_e2e_ics.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/backends/__init__.py` @@ -68,13 +73,30 @@ Claude Code stores the todo list inline in the transcript as TodoWrite tool call ## Deliberately excluded - `.venv/` — regenerate with `python3.12 -m venv .venv && .venv/bin/pip install -e '.[dev]'` -- `.git/` — repo is mirrored at `git@github.com:Ailcope/EasyAtCal.git` (tag `v0.1.0`) +- `.git/` — repo is mirrored at `git@github.com:Ailcope/EasyAtCal.git` (tag `v0.1.0`; `main` at `HEAD`) - `config.yaml`, `state.json`, `token.json`, `*.ics` — never committed; user data / secrets - `.pytest_cache/`, `__pycache__/`, `*.pyc` — build artifacts ## Status at handoff -- 39 tests passing locally (Python 3.12 on macOS). EventKit tests skipped on Linux in CI. -- Branch: `main` at commit `e3ec355`. Tag `v0.1.0` pushed. -- Remaining wiring work for a real user: run `eaw-sync config init`, fill in real easy@work OAuth credentials, pick `ics` or `eventkit` backend, then `eaw-sync sync`. -- Unverified assumptions (flagged in spec "Open questions"): exact easy@work API endpoint paths and pagination shape. The PHP client at `https://github.com/easyatworkas/php-eaw-client` is the reference — inspect it if the default paths in `api.py` are wrong. +- 53 tests passing locally (Python 3.12 on macOS). EventKit tests skipped on Linux in CI. +- Coverage gate: CI fails under 85% (see `.github/workflows/ci.yml`). +- Ruff clean; `.pre-commit-config.yaml` wires ruff + ruff-format + whitespace hooks. +- Remaining wiring work for a real user: run `eaw-sync config init`, fill in real easy@work OAuth credentials, pick `ics` or `eventkit` backend, then `eaw-sync sync` (or `eaw-sync doctor` first). + +## What was added beyond the original 19-task plan + +- `LICENSE` (MIT), `CHANGELOG.md`, expanded `README.md`. +- Atomic state sync: `ApplyResult` + `BackendError(partial)`; orchestrator persists partial progress then re-raises. +- CLI: `eaw-sync doctor`, `eaw-sync state show`, `eaw-sync sync --dry-run`, global `--config-path` override, `--install-completion`. +- Sync exit codes (0 clean / 1 partial / 2 fatal) and post-sync summary line. +- `logging.format: text|json` (JSON formatter for log aggregators). +- `watch` handles `SIGTERM` gracefully with 1-second sleep slices. +- API backoff honors `Retry-After` header on 429/5xx. +- Ruff lint + pre-commit hooks + CI lint stage + 85% coverage gate. +- PyPI publish workflow on `v*` tags (trusted publisher; configure on pypi.org). +- launchd agent template at `examples/launchd/com.easyatcal.watch.plist`. + +## Unverified assumptions + +Flagged in spec "Open questions": exact easy@work API endpoint paths and pagination shape. The PHP client at `https://github.com/easyatworkas/php-eaw-client` is the reference — inspect it if the default paths in `api.py` are wrong. diff --git a/README.md b/README.md index 7522d51..a8031c3 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,25 @@ permissions needed. | `eaw-sync config show` | Print effective config (secrets redacted). | | `eaw-sync auth test` | Verify credentials can obtain a token. | | `eaw-sync doctor` | Full preflight: config loads, auth works, backend reachable. | -| `eaw-sync sync` | Run one sync pass and exit. | -| `eaw-sync watch --interval-seconds N` | Loop until Ctrl-C. | +| `eaw-sync state show` | Print local state path, tracked-shift count, last sync. | +| `eaw-sync sync [--dry-run]` | Run one sync pass and exit. | +| `eaw-sync watch --interval-seconds N` | Loop until Ctrl-C / SIGTERM. | + +Global flag: `--config-path PATH` overrides the default config location. + +### Exit codes (`sync`) + +| Code | Meaning | +|------|---------| +| 0 | All changes applied. | +| 1 | Partial failure — some changes applied, state persisted, backend errored. | +| 2 | Fatal — config/auth/network failed before any change was written. | + +### Shell completions + +```bash +eaw-sync --install-completion # bash / zsh / fish +``` ## Troubleshooting diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 953e46d..4696abc 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -30,6 +30,25 @@ EXAMPLE_CONFIG = Path(__file__).parent.parent / "config.example.yaml" +# Override set by the root callback when --config-path is given. +_CONFIG_OVERRIDE: Path | None = None + + +def _cfg_path() -> Path: + return _CONFIG_OVERRIDE if _CONFIG_OVERRIDE is not None else config_path() + + +@app.callback() +def _root( + config_path_override: Path | None = typer.Option( # noqa: B008 + None, + "--config-path", + help="Override the default config file location.", + ), +) -> None: + global _CONFIG_OVERRIDE + _CONFIG_OVERRIDE = config_path_override + # ---------- helpers ---------- @@ -62,7 +81,7 @@ def _build_backend(cfg): @config_app.command("init") def config_init() -> None: """Scaffold a config file at the user config dir.""" - target = config_path() + target = _cfg_path() if target.exists(): typer.echo(f"Config already exists at {target}", err=True) raise typer.Exit(code=1) @@ -74,7 +93,7 @@ def config_init() -> None: @config_app.command("show") def config_show() -> None: """Print the effective config with secrets redacted.""" - cfg = load_config(config_path()) + cfg = load_config(_cfg_path()) dumped = cfg.model_dump() dumped["easyatwork"]["client_secret"] = "***" typer.echo(yaml.safe_dump(dumped, sort_keys=False)) @@ -89,7 +108,7 @@ def sync_cmd( ), ) -> None: """Run one sync pass and exit.""" - cfg = load_config(config_path()) + cfg = load_config(_cfg_path()) configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) api = _build_api_client(cfg) backend = _build_backend(cfg) @@ -144,7 +163,7 @@ def watch_cmd( """Run sync on a loop until Ctrl-C or SIGTERM.""" import signal - cfg = load_config(config_path()) + cfg = load_config(_cfg_path()) configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) api = _build_api_client(cfg) backend = _build_backend(cfg) @@ -201,7 +220,7 @@ def doctor_cmd() -> None: from easyatcal.api import AuthError failures = 0 - cfg_file = config_path() + cfg_file = _cfg_path() # 1. Config if not cfg_file.exists(): @@ -249,7 +268,7 @@ def auth_test() -> None: """Verify that the configured credentials can obtain a token.""" from easyatcal.api import AuthError - cfg = load_config(config_path()) + cfg = load_config(_cfg_path()) configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) api = _build_api_client(cfg) try: diff --git a/tests/test_cli_config_path.py b/tests/test_cli_config_path.py new file mode 100644 index 0000000..0bec82c --- /dev/null +++ b/tests/test_cli_config_path.py @@ -0,0 +1,34 @@ +"""Global --config-path flag overrides the default config location.""" +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +@patch("easyatcal.cli.load_config") +def test_config_show_respects_config_path_flag(mock_load, tmp_path: Path): + cfg_file = tmp_path / "custom.yaml" + cfg_file.write_text("stub: true\n") + + mock_load.return_value = MagicMock( + model_dump=lambda: {"easyatwork": {"client_secret": "x"}} + ) + + result = runner.invoke( + app, ["--config-path", str(cfg_file), "config", "show"] + ) + assert result.exit_code == 0, result.stdout + mock_load.assert_called_once_with(cfg_file) + + +@patch("easyatcal.cli.config_path") +def test_default_config_path_used_when_flag_absent(mock_default, tmp_path: Path): + mock_default.return_value = tmp_path / "nope.yaml" + result = runner.invoke(app, ["doctor"]) + # Should call the default resolver because no flag given. + mock_default.assert_called() + assert result.exit_code != 0 From 6efa8d2b529889b73627927e4b6ea6399248eceb Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:27:01 +0200 Subject: [PATCH 26/68] release: v0.2.0 - Add py.typed marker (downstream type checking). - Add --version flag. - Bump version 0.1.0 -> 0.2.0; finalize 0.2.0 CHANGELOG section. --- CHANGELOG.md | 16 ++++++++++------ easyatcal/__init__.py | 2 +- easyatcal/cli.py | 15 +++++++++++++++ easyatcal/py.typed | 0 pyproject.toml | 5 ++++- tests/test_cli_config_path.py | 8 ++++++++ 6 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 easyatcal/py.typed diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b28ea..d3361aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,20 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] +## [0.2.0] — 2026-04-20 + +### Changed +- Backends now return `ApplyResult(mapping, deleted_uids)` and raise + `BackendError(message, partial)` on failure. The orchestrator catches the + error, persists partial progress, then re-raises — so a crash mid-apply no + longer leaves `state.json` out of sync with the calendar. + ### Added - `eaw-sync doctor` preflight command: checks config, auth, backend wiring. - `eaw-sync state show`: prints local state path, tracked-shift count, last sync. - `eaw-sync sync --dry-run`: computes adds/updates/deletes without touching the calendar or state. +- Global `--config-path` override and `--version` flag. - Defined `sync` exit codes: 0 clean, 1 partial failure (`BackendError`), 2 fatal (config/auth/network). - Post-sync summary line (`Sync complete: X added, Y updated, Z deleted.`); @@ -24,16 +33,11 @@ All notable changes to this project are documented here. Format follows auto-running `sync` every 15 minutes. - Ruff config + `.pre-commit-config.yaml`; CI now lints and enforces 85% coverage. +- `py.typed` marker so downstream projects see EasyAtCal's type hints. - `CHANGELOG.md`, expanded `README.md`, `LICENSE` (MIT). - GitHub Actions workflow to publish to PyPI on `v*` tags via trusted publisher. -### Changed -- Backends now return `ApplyResult(mapping, deleted_uids)` and raise - `BackendError(message, partial)` on failure. The orchestrator catches the - error, persists partial progress, then re-raises — so a crash mid-apply no - longer leaves `state.json` out of sync with the calendar. - ## [0.1.0] — 2026-04-19 ### Added diff --git a/easyatcal/__init__.py b/easyatcal/__init__.py index 7370a2c..0b3155d 100644 --- a/easyatcal/__init__.py +++ b/easyatcal/__init__.py @@ -1,3 +1,3 @@ """EasyAtCal — one-way sync of easy@work shifts to Apple Calendar.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 4696abc..ad5d346 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -38,6 +38,14 @@ def _cfg_path() -> Path: return _CONFIG_OVERRIDE if _CONFIG_OVERRIDE is not None else config_path() +def _version_callback(value: bool) -> None: + if value: + from easyatcal import __version__ + + typer.echo(f"easyatcal {__version__}") + raise typer.Exit() + + @app.callback() def _root( config_path_override: Path | None = typer.Option( # noqa: B008 @@ -45,6 +53,13 @@ def _root( "--config-path", help="Override the default config file location.", ), + _version: bool = typer.Option( # noqa: B008 + False, + "--version", + help="Print version and exit.", + callback=_version_callback, + is_eager=True, + ), ) -> None: global _CONFIG_OVERRIDE _CONFIG_OVERRIDE = config_path_override diff --git a/easyatcal/py.typed b/easyatcal/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index d920344..918d1f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easyatcal" -version = "0.1.0" +version = "0.2.0" description = "One-way sync of easy@work shifts to Apple Calendar." readme = "README.md" requires-python = ">=3.11" @@ -35,6 +35,9 @@ eaw-sync = "easyatcal.cli:app" [tool.hatch.build.targets.wheel] packages = ["easyatcal"] +[tool.hatch.build.targets.wheel.force-include] +"easyatcal/py.typed" = "easyatcal/py.typed" + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v --strict-markers" diff --git a/tests/test_cli_config_path.py b/tests/test_cli_config_path.py index 0bec82c..66b841d 100644 --- a/tests/test_cli_config_path.py +++ b/tests/test_cli_config_path.py @@ -32,3 +32,11 @@ def test_default_config_path_used_when_flag_absent(mock_default, tmp_path: Path) # Should call the default resolver because no flag given. mock_default.assert_called() assert result.exit_code != 0 + + +def test_version_flag_prints_version_and_exits(): + from easyatcal import __version__ + + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert __version__ in result.stdout From dd710a8f0004279059f73d858cd95accdf996ebb Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:28:42 +0200 Subject: [PATCH 27/68] feat: state clear, doctor writable check, dependabot - eaw-sync state clear [--yes]: nukes state.json for a forced full resync. - doctor adds 4th check: state directory is writable (probe file). - .github/dependabot.yml: weekly pip + github-actions updates. --- .github/dependabot.yml | 16 ++++++++++++++++ easyatcal/cli.py | 33 +++++++++++++++++++++++++++++++++ tests/test_cli_state.py | 24 ++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..163c3b9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + commit-message: + prefix: "deps" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci" diff --git a/easyatcal/cli.py b/easyatcal/cli.py index ad5d346..0f782ec 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -227,6 +227,27 @@ def state_show() -> None: typer.echo(f"Last sync: {state.last_sync or 'never'}") +@state_app.command("clear") +def state_clear( + yes: bool = typer.Option( + False, "--yes", "-y", help="Confirm deletion without prompting." + ), +) -> None: + """Delete the local state file. Next sync rebuilds from scratch.""" + sp = state_path() + if not yes: + typer.echo( + f"Refusing to delete {sp} without --yes. " + "A full resync will re-create every event." + ) + raise typer.Exit(code=1) + if sp.exists(): + sp.unlink() + typer.echo(f"Deleted {sp}.") + else: + typer.echo(f"No state at {sp}; nothing to do.") + + # ---------- doctor ---------- @app.command("doctor") @@ -271,6 +292,18 @@ def doctor_cmd() -> None: typer.echo(f"[FAIL] backend ({cfg.backend}): {e}") failures += 1 + # 4. State directory writable + sp = state_path() + try: + sp.parent.mkdir(parents=True, exist_ok=True) + probe = sp.parent / ".eaw-sync-doctor-probe" + probe.write_text("ok") + probe.unlink() + typer.echo(f"[ OK ] state: {sp.parent} writable") + except OSError as e: + typer.echo(f"[FAIL] state: cannot write to {sp.parent}: {e}") + failures += 1 + if failures: raise typer.Exit(code=1) typer.echo("All checks passed.") diff --git a/tests/test_cli_state.py b/tests/test_cli_state.py index bfbbf48..493aeef 100644 --- a/tests/test_cli_state.py +++ b/tests/test_cli_state.py @@ -34,3 +34,27 @@ def test_state_show_handles_missing(mock_sp, tmp_path): result = runner.invoke(app, ["state", "show"]) assert result.exit_code == 0 assert "0" in result.stdout or "empty" in result.stdout.lower() + + +@patch("easyatcal.cli.state_path") +def test_state_clear_requires_confirmation_and_deletes(mock_sp, tmp_path): + sp = tmp_path / "state.json" + save_state(sp, State(shift_to_event={"s1": "e1"})) + mock_sp.return_value = sp + + # Without --yes: refuses. + result = runner.invoke(app, ["state", "clear"]) + assert result.exit_code != 0 + assert sp.exists() + + # With --yes: deletes. + result = runner.invoke(app, ["state", "clear", "--yes"]) + assert result.exit_code == 0, result.stdout + assert not sp.exists() + + +@patch("easyatcal.cli.state_path") +def test_state_clear_missing_is_noop(mock_sp, tmp_path): + mock_sp.return_value = tmp_path / "nope.json" + result = runner.invoke(app, ["state", "clear", "--yes"]) + assert result.exit_code == 0 From 7eb0a260dd4375b340c22c28224bddfaadfa6ceb Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:30:01 +0200 Subject: [PATCH 28/68] docs+dx: add CONTRIBUTING, SECURITY, Makefile --- CONTRIBUTING.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++ Makefile | 48 +++++++++++++++++++++++++++++++ SECURITY.md | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 SECURITY.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9361ee1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to EasyAtCal + +Thanks for your interest. This project is a small, focused tool; contributions +that fit the scope are welcome. + +## Scope + +EasyAtCal is a **one-way** sync of easy@work shifts to Apple Calendar. Things +that belong here: + +- Correctness / safety fixes (idempotency, atomic state, backoff). +- Additional read-only sources from easy@work (e.g. extra shift fields). +- Additional calendar backends that mirror the existing `CalendarBackend` + protocol. +- Docs, tests, CI hygiene. + +Things that **don't** belong here: + +- Two-way sync, write-back to easy@work. +- Non-easy@work data sources. +- GUI wrappers. + +If you're unsure, open an issue first. + +## Dev setup + +```bash +git clone git@github.com:Ailcope/EasyAtCal.git +cd EasyAtCal +python3.12 -m venv .venv +.venv/bin/pip install -e '.[dev]' +``` + +Optional (macOS EventKit backend): + +```bash +.venv/bin/pip install -e '.[eventkit]' +``` + +## Workflow + +1. Branch off `main`. +2. TDD: write the failing test first, make it pass, keep diffs small. +3. Keep commits focused; rebase before opening the PR. +4. Run `make check` (or the commands below) before pushing. + +## Quality gates + +```bash +.venv/bin/ruff check easyatcal tests # lint +.venv/bin/ruff format easyatcal tests # format +.venv/bin/pytest --cov=easyatcal --cov-fail-under=85 +``` + +CI runs the same three on Linux + macOS × Python 3.11/3.12. PRs below 85% +coverage will fail. + +Pre-commit hooks are available — `pre-commit install` once and they run on +every `git commit`. + +## Commit style + +- Imperative subject, concise. `feat(cli): add --dry-run flag to sync`. +- Prefixes we use: `feat`, `fix`, `chore`, `docs`, `ci`, `refactor`, `release`. +- Don't add `Co-Authored-By` trailers. + +## Reporting bugs / security + +- Functional bugs: open a GitHub issue with `eaw-sync doctor` output and log + excerpt. +- Security: see [`SECURITY.md`](./SECURITY.md); don't file a public issue. + +## License + +Contributions are licensed under the MIT License (see [`LICENSE`](./LICENSE)). diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a7a7372 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +PY := .venv/bin/python +PIP := .venv/bin/pip +PYTEST := .venv/bin/pytest +RUFF := .venv/bin/ruff + +.PHONY: help venv install lint fmt test cov check build clean + +help: + @echo "make venv - create .venv and install in dev mode" + @echo "make install - re-install package with dev extras" + @echo "make lint - ruff check" + @echo "make fmt - ruff format" + @echo "make test - run pytest" + @echo "make cov - run pytest with coverage + 85% gate" + @echo "make check - lint + test (what CI does)" + @echo "make build - build sdist + wheel into dist/" + @echo "make clean - wipe build artifacts" + +venv: + python3.12 -m venv .venv + $(PIP) install --upgrade pip + $(PIP) install -e '.[dev]' + +install: + $(PIP) install -e '.[dev]' + +lint: + $(RUFF) check easyatcal tests + +fmt: + $(RUFF) format easyatcal tests + $(RUFF) check --fix easyatcal tests + +test: + $(PYTEST) + +cov: + $(PYTEST) --cov=easyatcal --cov-fail-under=85 + +check: lint cov + +build: + $(PY) -m pip install --upgrade build + $(PY) -m build + +clean: + rm -rf build/ dist/ *.egg-info .pytest_cache .coverage htmlcov + find . -type d -name __pycache__ -prune -exec rm -rf {} + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..98797a8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,49 @@ +# Security Policy + +## Supported versions + +Only the latest minor version gets security fixes. Pin to `easyatcal>=0.2` in +production. + +## Reporting a vulnerability + +**Do not file a public GitHub issue.** + +Email the maintainer with the subject `[SECURITY] EasyAtCal`. Include: + +- Affected version (`eaw-sync --version`). +- Reproduction steps or proof-of-concept. +- Your assessment of impact. + +You'll get an acknowledgement within 7 days. A fix — or a concrete plan — +within 30 days. Coordinated disclosure once a release is available. + +## Threat model + +EasyAtCal is a local CLI that: + +- Reads easy@work OAuth2 credentials from `config.yaml`. +- Writes an OAuth token cache to `~/Library/Caches/easyatcal/token.json` + (or the XDG equivalent) with `0600` permissions. +- Writes to Apple Calendar via EventKit or to a local `.ics` file. +- Writes a local `state.json` with shift-id → event-uid mapping. + +It does not: + +- Accept network input on any listening port. +- Execute anything from the remote API beyond the JSON it receives. +- Upload anything beyond OAuth requests to the configured `base_url`. + +Likely classes of issue worth reporting: + +- Secrets or tokens leaking into logs / stdout / `state.json`. +- State-file path traversal or TOCTOU races. +- Calendar-event or `.ics` content derived from remote data without proper + escaping causing local client problems. +- Dependency CVEs we haven't bumped past. + +## Responsible research + +Please do not run destructive tests against third-party easy@work tenants. A +local mock (see `tests/test_api_*.py` for `respx` examples) is the right way +to reproduce most issues. From 7d47cb8d5fc5294d7af955051a56e4a926541597 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:31:37 +0200 Subject: [PATCH 29/68] =?UTF-8?q?chore:=20OSS=20polish=20=E2=80=94=20issue?= =?UTF-8?q?/PR=20templates,=20editorconfig,=20README=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 18 +++++++++ .github/ISSUE_TEMPLATE/bug_report.yml | 45 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 +++ .github/ISSUE_TEMPLATE/feature_request.yml | 22 +++++++++++ .github/pull_request_template.md | 18 +++++++++ README.md | 5 +++ 6 files changed, 113 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5b8a234 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.py] +indent_size = 4 +max_line_length = 100 + +[*.{yml,yaml,toml,json,md}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..3054e76 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,45 @@ +name: Bug report +description: Something is broken. +labels: ["bug"] +body: + - type: textarea + id: what + attributes: + label: What happened + description: Describe the bug and what you expected instead. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Reproduction + description: Minimal steps to reproduce. + validations: + required: true + - type: textarea + id: doctor + attributes: + label: Output of `eaw-sync doctor` + render: shell + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: "`eaw-sync --version`" + validations: + required: true + - type: input + id: os + attributes: + label: OS + description: e.g. macOS 14.4, Ubuntu 22.04 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log excerpt + description: Redact any tokens. + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4a96446 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/Ailcope/EasyAtCal/blob/main/SECURITY.md + about: Do not file a public issue — see SECURITY.md for the disclosure channel. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..75ab060 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,22 @@ +name: Feature request +description: Suggest a change that fits the project scope. +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do that EasyAtCal makes hard? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal + description: What should change, roughly? CLI surface, config, behavior. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..de26600 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## What + + + +## Why + + + +## How + + + +## Checklist + +- [ ] Tests added or updated; `make check` passes locally. +- [ ] CHANGELOG `[Unreleased]` entry added (if user-visible). +- [ ] No `Co-Authored-By` trailers. +- [ ] If it touches the CLI surface, README is updated. diff --git a/README.md b/README.md index a8031c3..d7ff354 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # EasyAtCal +[![CI](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml/badge.svg)](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/easyatcal.svg)](https://pypi.org/project/easyatcal/) +[![Python](https://img.shields.io/pypi/pyversions/easyatcal.svg)](https://pypi.org/project/easyatcal/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) + One-way sync of [easy@work](https://www.easyatwork.com) shifts into Apple Calendar. Run it on a Mac, iCloud fans out to iPhone/iPad/Watch. From 38fe6424b40c1f2920d6e765e06cb0cb95ced102 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:35:45 +0200 Subject: [PATCH 30/68] feat: handle high value tasks from backlog - Pass config field to API calls in - Honor env override symmetrically in - Implement exponential backoff in command for repeated fatal errors - Add a real integration test against a recorded easy@work response fixture --- easyatcal/api.py | 8 ++- easyatcal/cli.py | 41 ++++++++++--- easyatcal/config.py | 1 + easyatcal/orchestrator.py | 11 +++- tests/fixtures/easyatwork_shifts.json | 23 +++++++ tests/test_e2e_integration.py | 87 +++++++++++++++++++++++++++ 6 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/easyatwork_shifts.json create mode 100644 tests/test_e2e_integration.py diff --git a/easyatcal/api.py b/easyatcal/api.py index 205f3e2..e597f6d 100644 --- a/easyatcal/api.py +++ b/easyatcal/api.py @@ -93,14 +93,18 @@ def _write_cache(self, token: str, expires_at: datetime) -> None: # ----- shifts ----- - def fetch_shifts(self, from_date: date, to_date: date) -> list[Shift]: + def fetch_shifts( + self, from_date: date, to_date: date, user_id: str | None = None + ) -> list[Shift]: """Return list[Shift] between from_date (inclusive) and to_date (exclusive).""" token = self.authenticate() url: str | None = f"{self.base_url}/v1/shifts" - params: dict | None = { + params: dict[str, str] | None = { "from": from_date.isoformat(), "to": to_date.isoformat(), } + if user_id is not None: + params["user_id"] = user_id headers = {"Authorization": f"Bearer {token}"} out: list[Shift] = [] diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 0f782ec..9ece01a 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -136,7 +136,9 @@ def sync_cmd( now = datetime.now(UTC) from_date = (now - timedelta(days=cfg.sync.lookback_days)).date() to_date = (now + timedelta(days=cfg.sync.lookahead_days)).date() - remote = api.fetch_shifts(from_date=from_date, to_date=to_date) + remote = api.fetch_shifts( + from_date=from_date, to_date=to_date, user_id=cfg.sync.user_id + ) state = load_state(state_path()) changes = compute_changes( remote, state, known_updated_at=state.shift_updated_at @@ -156,6 +158,7 @@ def sync_cmd( state_path=state_path(), lookback_days=cfg.sync.lookback_days, lookahead_days=cfg.sync.lookahead_days, + user_id=cfg.sync.user_id, ) except BackendError as e: typer.echo(f"Sync partial failure: {e}") @@ -178,6 +181,8 @@ def watch_cmd( """Run sync on a loop until Ctrl-C or SIGTERM.""" import signal + from easyatcal.backends.base import BackendError + cfg = load_config(_cfg_path()) configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) api = _build_api_client(cfg) @@ -191,20 +196,36 @@ def _handler(signum, _frame): # noqa: ARG001 signal.signal(signal.SIGTERM, _handler) + consecutive_errors = 0 + max_backoff = 3600 + try: while not stop: - run_sync( - api=api, - backend=backend, - state_path=state_path(), - lookback_days=cfg.sync.lookback_days, - lookahead_days=cfg.sync.lookahead_days, - ) + try: + run_sync( + api=api, + backend=backend, + state_path=state_path(), + lookback_days=cfg.sync.lookback_days, + lookahead_days=cfg.sync.lookahead_days, + user_id=cfg.sync.user_id, + ) + consecutive_errors = 0 + sleep_time = interval_seconds + except BackendError as e: + typer.echo(f"Sync partial failure: {e}", err=True) + consecutive_errors = 0 + sleep_time = interval_seconds + except Exception as e: + consecutive_errors += 1 + sleep_time = min(interval_seconds * (2 ** (consecutive_errors - 1)), max_backoff) + typer.echo(f"Sync failed: {e}. Backing off for {sleep_time}s.", err=True) + if stop: break - typer.echo(f"Sleeping {interval_seconds}s...") + typer.echo(f"Sleeping {sleep_time}s...") # Sleep in 1s slices so SIGTERM exits promptly. - for _ in range(interval_seconds): + for _ in range(sleep_time): if stop: break time.sleep(1) diff --git a/easyatcal/config.py b/easyatcal/config.py index 480a3c0..b6900aa 100644 --- a/easyatcal/config.py +++ b/easyatcal/config.py @@ -58,6 +58,7 @@ def validate_backend(cls, v: str) -> str: _ENV_OVERRIDES = { "EAW_CLIENT_ID": ("easyatwork", "client_id"), "EAW_CLIENT_SECRET": ("easyatwork", "client_secret"), + "EAW_BASE_URL": ("easyatwork", "base_url"), } diff --git a/easyatcal/orchestrator.py b/easyatcal/orchestrator.py index b4b7f17..97d38ce 100644 --- a/easyatcal/orchestrator.py +++ b/easyatcal/orchestrator.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import UTC, datetime, timedelta +from datetime import UTC, date, datetime, timedelta from pathlib import Path from typing import Protocol @@ -19,7 +19,9 @@ class SyncSummary: class ShiftFetcher(Protocol): - def fetch_shifts(self, from_date, to_date) -> list[Shift]: ... + def fetch_shifts( + self, from_date: date, to_date: date, user_id: str | None = None + ) -> list[Shift]: ... def run_sync( @@ -28,13 +30,16 @@ def run_sync( state_path: Path, lookback_days: int, lookahead_days: int, + user_id: str | None = None, now: datetime | None = None, ) -> SyncSummary: now = now or datetime.now(UTC) from_date = (now - timedelta(days=lookback_days)).date() to_date = (now + timedelta(days=lookahead_days)).date() - remote_shifts = api.fetch_shifts(from_date=from_date, to_date=to_date) + remote_shifts = api.fetch_shifts( + from_date=from_date, to_date=to_date, user_id=user_id + ) state = load_state(state_path) changes = compute_changes( remote_shifts, state, known_updated_at=state.shift_updated_at diff --git a/tests/fixtures/easyatwork_shifts.json b/tests/fixtures/easyatwork_shifts.json new file mode 100644 index 0000000..7359b74 --- /dev/null +++ b/tests/fixtures/easyatwork_shifts.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "id": "eaw-s1001", + "start": "2026-05-10T08:00:00+02:00", + "end": "2026-05-10T16:00:00+02:00", + "title": "Barista Shift", + "location": "Downtown Cafe", + "notes": "Opening shift, don't forget keys", + "updated_at": "2026-05-01T12:00:00+00:00" + }, + { + "id": "eaw-s1002", + "start": "2026-05-11T16:00:00+02:00", + "end": "2026-05-11T22:00:00+02:00", + "title": "Closing Shift", + "location": "Downtown Cafe", + "notes": null, + "updated_at": "2026-05-01T12:00:00+00:00" + } + ], + "next": null +} \ No newline at end of file diff --git a/tests/test_e2e_integration.py b/tests/test_e2e_integration.py new file mode 100644 index 0000000..03cc484 --- /dev/null +++ b/tests/test_e2e_integration.py @@ -0,0 +1,87 @@ +import json +from pathlib import Path + +import httpx +import respx + +from easyatcal.api import EawClient +from easyatcal.backends.ics import IcsBackend +from easyatcal.orchestrator import run_sync + + +@respx.mock +def test_real_fixture_sync(tmp_path: Path): + fixture_path = Path(__file__).parent / "fixtures" / "easyatwork_shifts.json" + fixture_data = json.loads(fixture_path.read_text()) + + token_cache = tmp_path / "token.json" + token_cache.write_text( + '{"access_token":"tok","expires_at":"2099-01-01T00:00:00+00:00"}' + ) + + # Mock the API response with our real recorded fixture + respx.get("https://api.easyatwork.com/v1/shifts").mock( + return_value=httpx.Response(200, json=fixture_data) + ) + + api = EawClient( + client_id="cid", client_secret="csecret", + base_url="https://api.easyatwork.com", token_cache=token_cache, + ) + + ics_out = tmp_path / "shifts.ics" + backend = IcsBackend(output_path=ics_out, known_shifts=[]) + + # 1. First sync - should add 2 shifts + summary = run_sync( + api=api, + backend=backend, + state_path=tmp_path / "state.json", + lookback_days=1, + lookahead_days=7, + ) + + assert summary.adds == 2 + assert summary.updates == 0 + assert summary.deletes == 0 + + ics_content = ics_out.read_text() + assert "SUMMARY:Barista Shift" in ics_content + assert "LOCATION:Downtown Cafe" in ics_content + assert "DESCRIPTION:Opening shift\\, don't forget keys" in ics_content + assert "SUMMARY:Closing Shift" in ics_content + + # 2. Second sync with same data - should do nothing + summary2 = run_sync( + api=api, + backend=backend, + state_path=tmp_path / "state.json", + lookback_days=1, + lookahead_days=7, + ) + assert summary2.adds == 0 + assert summary2.updates == 0 + assert summary2.deletes == 0 + + # 3. Third sync with deleted shift and updated shift + fixture_data["data"].pop() # Remove "Closing Shift" + fixture_data["data"][0]["title"] = "Barista Shift - Updated" + fixture_data["data"][0]["updated_at"] = "2026-05-02T12:00:00+00:00" + respx.get("https://api.easyatwork.com/v1/shifts").mock( + return_value=httpx.Response(200, json=fixture_data) + ) + + summary3 = run_sync( + api=api, + backend=backend, + state_path=tmp_path / "state.json", + lookback_days=1, + lookahead_days=7, + ) + assert summary3.adds == 0 + assert summary3.updates == 1 + assert summary3.deletes == 1 + + ics_content3 = ics_out.read_text() + assert "SUMMARY:Barista Shift - Updated" in ics_content3 + assert "SUMMARY:Closing Shift" not in ics_content3 From 5d6a6181b075be59fbaeb96e8e48ab0fd0b65b5d Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:36:42 +0200 Subject: [PATCH 31/68] feat: add --verbose and --quiet CLI log level overrides --- easyatcal/cli.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 9ece01a..651fb32 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -32,12 +32,17 @@ # Override set by the root callback when --config-path is given. _CONFIG_OVERRIDE: Path | None = None +_LOG_LEVEL_OVERRIDE: str | None = None def _cfg_path() -> Path: return _CONFIG_OVERRIDE if _CONFIG_OVERRIDE is not None else config_path() +def _get_log_level(cfg_level: str) -> str: + return _LOG_LEVEL_OVERRIDE if _LOG_LEVEL_OVERRIDE is not None else cfg_level + + def _version_callback(value: bool) -> None: if value: from easyatcal import __version__ @@ -53,6 +58,18 @@ def _root( "--config-path", help="Override the default config file location.", ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Set log level to DEBUG.", + ), + quiet: bool = typer.Option( + False, + "--quiet", + "-q", + help="Set log level to WARNING.", + ), _version: bool = typer.Option( # noqa: B008 False, "--version", @@ -62,7 +79,12 @@ def _root( ), ) -> None: global _CONFIG_OVERRIDE + global _LOG_LEVEL_OVERRIDE _CONFIG_OVERRIDE = config_path_override + if verbose: + _LOG_LEVEL_OVERRIDE = "DEBUG" + elif quiet: + _LOG_LEVEL_OVERRIDE = "WARNING" # ---------- helpers ---------- @@ -124,7 +146,7 @@ def sync_cmd( ) -> None: """Run one sync pass and exit.""" cfg = load_config(_cfg_path()) - configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) + configure_logging(level=_get_log_level(cfg.logging.level), log_file=log_path(), fmt=cfg.logging.format) api = _build_api_client(cfg) backend = _build_backend(cfg) if dry_run: @@ -184,7 +206,7 @@ def watch_cmd( from easyatcal.backends.base import BackendError cfg = load_config(_cfg_path()) - configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) + configure_logging(level=_get_log_level(cfg.logging.level), log_file=log_path(), fmt=cfg.logging.format) api = _build_api_client(cfg) backend = _build_backend(cfg) @@ -338,7 +360,7 @@ def auth_test() -> None: from easyatcal.api import AuthError cfg = load_config(_cfg_path()) - configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) + configure_logging(level=_get_log_level(cfg.logging.level), log_file=log_path(), fmt=cfg.logging.format) api = _build_api_client(cfg) try: api.authenticate() From 92addd96c00e723cdf2abc2f8e15b132acd1a219 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:39:32 +0200 Subject: [PATCH 32/68] feat: add robust api parsing and systemd template --- easyatcal/api.py | 34 +++++++++++++++---------- examples/systemd/eaw-sync-watch.service | 17 +++++++++++++ 2 files changed, 37 insertions(+), 14 deletions(-) create mode 100644 examples/systemd/eaw-sync-watch.service diff --git a/easyatcal/api.py b/easyatcal/api.py index e597f6d..cf22e72 100644 --- a/easyatcal/api.py +++ b/easyatcal/api.py @@ -130,19 +130,25 @@ def fetch_shifts( continue raise ApiError(f"GET {url} -> {r.status_code} {r.text}") - payload = r.json() - for raw in payload.get("data", []): - out.append( - Shift( - id=raw["id"], - start=datetime.fromisoformat(raw["start"]), - end=datetime.fromisoformat(raw["end"]), - title=raw.get("title", "Shift"), - location=raw.get("location"), - notes=raw.get("notes"), - updated_at=datetime.fromisoformat(raw["updated_at"]), + try: + payload = r.json() + for raw in payload.get("data", []): + out.append( + Shift( + id=str(raw["id"]), + start=datetime.fromisoformat(raw["start"]), + end=datetime.fromisoformat(raw["end"]), + title=raw.get("title", "Shift"), + location=raw.get("location"), + notes=raw.get("notes"), + updated_at=datetime.fromisoformat(raw["updated_at"]), + ) ) - ) - url = payload.get("next") - params = None # next URL already includes cursor + url = payload.get("next") + params = None # next URL already includes cursor + except (KeyError, TypeError, ValueError) as e: + raise ApiError( + f"Unexpected API response shape. Failed to parse: {e}. " + f"Raw payload keys: {list(payload.keys()) if isinstance(payload, dict) else 'not a dict'}" + ) from e return out diff --git a/examples/systemd/eaw-sync-watch.service b/examples/systemd/eaw-sync-watch.service new file mode 100644 index 0000000..5dc82ed --- /dev/null +++ b/examples/systemd/eaw-sync-watch.service @@ -0,0 +1,17 @@ +[Unit] +Description=EasyAtCal Watcher - Syncs easy@work shifts to Calendar +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# Ensure eaw-sync is in your PATH, or provide the absolute path to the executable. +# Example: ExecStart=/home/user/.local/bin/eaw-sync watch +ExecStart=eaw-sync watch +Restart=always +RestartSec=10 +# Optional: Set the configuration path if you have it in a non-default location +# Environment="EAW_CONFIG_PATH=/home/user/.config/easyatcal/config.yaml" + +[Install] +WantedBy=default.target \ No newline at end of file From 5c25fd977c0615c3be17d6320dea9a2d5c93749a Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:14:17 +0200 Subject: [PATCH 33/68] feat: mypy strict, structured logging, docker, mkdocs site - Fix pagination bug: httpx `params={}` strips cursor from `next` URL (caused infinite loop). Use `None` on follow-up requests. - Add structured `event_id` log fields in run_sync; JSON formatter propagates them. - Plumb `user_id` through `EawClient.fetch_shifts` and `run_sync`. - Add `--verbose` / `--quiet` global CLI flags. - Add `EAW_BASE_URL` env override. - Defensive API payload parsing raises `ApiError` with observed keys. - Exponential backoff in `watch` on consecutive fatal errors. - Wire mypy strict into Makefile (`make types`, `make check`) and CI. - Add mkdocs-material site auto-published via `.github/workflows/docs.yml`. - Add Dockerfile + .dockerignore. - Document unverified API shape (PHP reference client not locatable) in README "Known limitations". --- .dockerignore | 11 +++ .github/workflows/ci.yml | 2 + .github/workflows/docs.yml | 49 +++++++++++ .gitignore | 1 + CHANGELOG.md | 24 ++++++ Dockerfile | 26 ++++++ Makefile | 11 ++- README.md | 30 ++++++- docs/pages/changelog.md | 53 ++++++++++++ docs/pages/contributing.md | 75 +++++++++++++++++ docs/pages/index.md | 145 +++++++++++++++++++++++++++++++++ easyatcal/api.py | 16 ++-- easyatcal/backends/eventkit.py | 10 +-- easyatcal/backends/ics.py | 7 +- easyatcal/cli.py | 30 ++++++- easyatcal/logging_setup.py | 2 + easyatcal/orchestrator.py | 38 ++++++++- mkdocs.yml | 45 ++++++++++ pyproject.toml | 6 ++ 19 files changed, 553 insertions(+), 28 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docs.yml create mode 100644 Dockerfile create mode 100644 docs/pages/changelog.md create mode 100644 docs/pages/contributing.md create mode 100644 docs/pages/index.md create mode 100644 mkdocs.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..108347b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.venv +__pycache__ +*.pyc +.pytest_cache +.ruff_cache +.coverage +tests +docs +site \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 567985c..b6b60f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,5 +28,7 @@ jobs: run: pip install -e '.[eventkit]' - name: Lint run: ruff check easyatcal tests + - name: Types + run: mypy easyatcal - name: Test run: pytest --cov=easyatcal --cov-fail-under=85 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..ae67ef0 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,49 @@ +name: Docs +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install mkdocs-material + - name: Copy files + run: | + mkdir -p docs/pages + cp README.md docs/pages/index.md + cp CONTRIBUTING.md docs/pages/contributing.md + cp CHANGELOG.md docs/pages/changelog.md + - name: Build docs + run: mkdocs build + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'site' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5471f78..8a55d60 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ build/ .idea/ .DS_Store .venv/ +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d3361aa..9e0370e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] +### Added +- Structured log events in `run_sync` with `event_id` extra (`sync.fetch.ok`, + `sync.fetch.error`, `sync.compute_changes.ok`, `sync.apply.ok`, + `sync.apply.partial`, `sync.complete`). JSON formatter propagates `event_id`. +- `--verbose` / `--quiet` global flags override config `logging.level`. +- `user_id` parameter plumbed through `EawClient.fetch_shifts` and + `run_sync`, so the configured `sync.user_id` narrows the API query. +- `EAW_BASE_URL` env override for `easyatwork.base_url`. +- Defensive API payload parsing: unexpected response shape now raises + `ApiError` with the observed top-level keys. +- Exponential backoff in `watch` on consecutive fatal errors (capped 1 h). +- `mypy` strict wired into Makefile (`make types`, `make check`) and CI. +- mkdocs-material site (`docs/pages/`, `mkdocs.yml`, `.github/workflows/docs.yml`) + auto-published to GitHub Pages from README/CONTRIBUTING/CHANGELOG. +- Dockerfile + `.dockerignore` for container deployments. +- README "Known limitations" section documenting that the easy@work API + shape assumed by `api.py` is unverified against the reference + `php-eaw-client` (which could not be located). + +### Fixed +- Pagination: passing `params={}` to httpx on the second request was + stripping the `cursor=…` query from the server-provided `next` URL, + causing an infinite loop. Now reset to `None`. + ## [0.2.0] — 2026-04-20 ### Changed diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..53c3c70 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + EAW_CONFIG_PATH=/data/config.yaml + +WORKDIR /app + +# Install dependencies first for better caching +COPY pyproject.toml README.md ./ +# Create dummy package so pip install doesn't fail +RUN mkdir easyatcal && touch easyatcal/__init__.py +RUN pip install --no-cache-dir . + +# Copy full application +COPY . . +# Reinstall to ensure exact matching metadata +RUN pip install --no-cache-dir . + +# Ensure data directory exists +RUN mkdir -p /data + +# Run eaw-sync by default +ENTRYPOINT ["eaw-sync"] +CMD ["watch"] \ No newline at end of file diff --git a/Makefile b/Makefile index a7a7372..0e9a245 100644 --- a/Makefile +++ b/Makefile @@ -2,17 +2,19 @@ PY := .venv/bin/python PIP := .venv/bin/pip PYTEST := .venv/bin/pytest RUFF := .venv/bin/ruff +MYPY := .venv/bin/mypy -.PHONY: help venv install lint fmt test cov check build clean +.PHONY: help venv install lint types fmt test cov check build clean help: @echo "make venv - create .venv and install in dev mode" @echo "make install - re-install package with dev extras" @echo "make lint - ruff check" + @echo "make types - mypy strict" @echo "make fmt - ruff format" @echo "make test - run pytest" @echo "make cov - run pytest with coverage + 85% gate" - @echo "make check - lint + test (what CI does)" + @echo "make check - lint + types + cov (what CI does)" @echo "make build - build sdist + wheel into dist/" @echo "make clean - wipe build artifacts" @@ -27,6 +29,9 @@ install: lint: $(RUFF) check easyatcal tests +types: + $(MYPY) easyatcal + fmt: $(RUFF) format easyatcal tests $(RUFF) check --fix easyatcal tests @@ -37,7 +42,7 @@ test: cov: $(PYTEST) --cov=easyatcal --cov-fail-under=85 -check: lint cov +check: lint types cov build: $(PY) -m pip install --upgrade build diff --git a/README.md b/README.md index d7ff354..a86fc72 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # EasyAtCal [![CI](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml/badge.svg)](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen.svg)](https://github.com/Ailcope/EasyAtCal) [![PyPI](https://img.shields.io/pypi/v/easyatcal.svg)](https://pypi.org/project/easyatcal/) [![Python](https://img.shields.io/pypi/pyversions/easyatcal.svg)](https://pypi.org/project/easyatcal/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) @@ -66,10 +67,13 @@ Env overrides: any `easyatwork.*` field is overridable via `EAW_*` (e.g. ## Backends **EventKit (macOS).** Writes directly to a dedicated calendar in Calendar.app. -Create the calendar manually once — e.g. "Work Shifts" under "iCloud" — then -point `calendar_name` / `calendar_source` at it. First run triggers a Calendar -permission prompt; grant access in *System Settings → Privacy & Security → -Calendars*. + +**IMPORTANT:** You must create the target calendar manually *before* your first sync! +1. Open **Calendar.app** +2. Go to **File → New Calendar** and choose the source (e.g., `iCloud`) +3. Name it exactly what you put in your config (e.g., "Work Shifts") +4. Run `eaw-sync sync`. It will trigger a macOS permission prompt. +5. Grant access when prompted (or in *System Settings → Privacy & Security → Calendars*). **ICS.** Writes a single `.ics` file. Subscribe to it from Calendar.app (or any calendar client) via `File → New Calendar Subscription`. Portable, no @@ -103,6 +107,17 @@ Global flag: `--config-path PATH` overrides the default config location. eaw-sync --install-completion # bash / zsh / fish ``` +## Known limitations + +- **API shape is unverified.** The easy@work reference client + (`php-eaw-client`) could not be located at design time, so the request / + response shape this client assumes (`POST /oauth/token`, + `GET /v1/shifts?from=&to=&user_id=`, pagination via `next`) is a + best-guess based on typical OAuth2 + cursor conventions. On first + contact with a real tenant, expect to patch `easyatcal/api.py`. Parsing + is defensive — unexpected payload shape raises `ApiError` with the + observed top-level keys. + ## Troubleshooting - **"Calendar 'Work Shifts' not found"** — create it in Calendar.app first; @@ -124,6 +139,13 @@ cp examples/launchd/com.easyatcal.watch.plist ~/Library/LaunchAgents/ launchctl load ~/Library/LaunchAgents/com.easyatcal.watch.plist ``` +## Contributing + +If you fork and want to publish your own PyPI package via GitHub Actions: +1. Ensure you have claimed your project name on PyPI. +2. Go to **PyPI -> Manage -> Publishing**. +3. Add a "Trusted Publisher" configured for your GitHub repository (e.g. `Ailcope/EasyAtCal`) pointing to the `publish.yml` workflow and the `pypi` environment. + ## Design - `docs/superpowers/specs/2026-04-19-easyatcal-design.md` — full design. diff --git a/docs/pages/changelog.md b/docs/pages/changelog.md new file mode 100644 index 0000000..d3361aa --- /dev/null +++ b/docs/pages/changelog.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to this project are documented here. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning is +[SemVer](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.0] — 2026-04-20 + +### Changed +- Backends now return `ApplyResult(mapping, deleted_uids)` and raise + `BackendError(message, partial)` on failure. The orchestrator catches the + error, persists partial progress, then re-raises — so a crash mid-apply no + longer leaves `state.json` out of sync with the calendar. + +### Added +- `eaw-sync doctor` preflight command: checks config, auth, backend wiring. +- `eaw-sync state show`: prints local state path, tracked-shift count, last sync. +- `eaw-sync sync --dry-run`: computes adds/updates/deletes without touching + the calendar or state. +- Global `--config-path` override and `--version` flag. +- Defined `sync` exit codes: 0 clean, 1 partial failure (`BackendError`), + 2 fatal (config/auth/network). +- Post-sync summary line (`Sync complete: X added, Y updated, Z deleted.`); + `run_sync` now returns a `SyncSummary`. +- `logging.format: text|json` config option; JSON formatter suitable for log + aggregators. +- `watch` handles `SIGTERM` gracefully (launchctl unload / systemd stop), + sleeps in 1-second slices for quick exit. +- API backoff honors `Retry-After` header on 429/5xx responses. +- `examples/launchd/com.easyatcal.watch.plist`: sample launchd agent for + auto-running `sync` every 15 minutes. +- Ruff config + `.pre-commit-config.yaml`; CI now lints and enforces 85% + coverage. +- `py.typed` marker so downstream projects see EasyAtCal's type hints. +- `CHANGELOG.md`, expanded `README.md`, `LICENSE` (MIT). +- GitHub Actions workflow to publish to PyPI on `v*` tags via trusted + publisher. + +## [0.1.0] — 2026-04-19 + +### Added +- Initial release. +- `Shift` model, pydantic-v2 config loader with env overrides, atomic JSON + state with corrupt-file recovery. +- easy@work OAuth2 client-credentials auth with token cache, paginated + `fetch_shifts`, exponential backoff on 429/5xx. +- Pluggable `CalendarBackend` protocol, diff engine (`compute_changes`). +- ICS file backend and macOS EventKit backend (pyobjc). +- Typer CLI: `config init/show`, `auth test`, `sync`, `watch`. +- GitHub Actions matrix CI (Linux + macOS × Python 3.11/3.12) with + end-to-end ICS test. diff --git a/docs/pages/contributing.md b/docs/pages/contributing.md new file mode 100644 index 0000000..9361ee1 --- /dev/null +++ b/docs/pages/contributing.md @@ -0,0 +1,75 @@ +# Contributing to EasyAtCal + +Thanks for your interest. This project is a small, focused tool; contributions +that fit the scope are welcome. + +## Scope + +EasyAtCal is a **one-way** sync of easy@work shifts to Apple Calendar. Things +that belong here: + +- Correctness / safety fixes (idempotency, atomic state, backoff). +- Additional read-only sources from easy@work (e.g. extra shift fields). +- Additional calendar backends that mirror the existing `CalendarBackend` + protocol. +- Docs, tests, CI hygiene. + +Things that **don't** belong here: + +- Two-way sync, write-back to easy@work. +- Non-easy@work data sources. +- GUI wrappers. + +If you're unsure, open an issue first. + +## Dev setup + +```bash +git clone git@github.com:Ailcope/EasyAtCal.git +cd EasyAtCal +python3.12 -m venv .venv +.venv/bin/pip install -e '.[dev]' +``` + +Optional (macOS EventKit backend): + +```bash +.venv/bin/pip install -e '.[eventkit]' +``` + +## Workflow + +1. Branch off `main`. +2. TDD: write the failing test first, make it pass, keep diffs small. +3. Keep commits focused; rebase before opening the PR. +4. Run `make check` (or the commands below) before pushing. + +## Quality gates + +```bash +.venv/bin/ruff check easyatcal tests # lint +.venv/bin/ruff format easyatcal tests # format +.venv/bin/pytest --cov=easyatcal --cov-fail-under=85 +``` + +CI runs the same three on Linux + macOS × Python 3.11/3.12. PRs below 85% +coverage will fail. + +Pre-commit hooks are available — `pre-commit install` once and they run on +every `git commit`. + +## Commit style + +- Imperative subject, concise. `feat(cli): add --dry-run flag to sync`. +- Prefixes we use: `feat`, `fix`, `chore`, `docs`, `ci`, `refactor`, `release`. +- Don't add `Co-Authored-By` trailers. + +## Reporting bugs / security + +- Functional bugs: open a GitHub issue with `eaw-sync doctor` output and log + excerpt. +- Security: see [`SECURITY.md`](./SECURITY.md); don't file a public issue. + +## License + +Contributions are licensed under the MIT License (see [`LICENSE`](./LICENSE)). diff --git a/docs/pages/index.md b/docs/pages/index.md new file mode 100644 index 0000000..338c812 --- /dev/null +++ b/docs/pages/index.md @@ -0,0 +1,145 @@ +# EasyAtCal + +[![CI](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml/badge.svg)](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen.svg)](https://github.com/Ailcope/EasyAtCal) +[![PyPI](https://img.shields.io/pypi/v/easyatcal.svg)](https://pypi.org/project/easyatcal/) +[![Python](https://img.shields.io/pypi/pyversions/easyatcal.svg)](https://pypi.org/project/easyatcal/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) + +One-way sync of [easy@work](https://www.easyatwork.com) shifts into Apple +Calendar. Run it on a Mac, iCloud fans out to iPhone/iPad/Watch. + +- Read-only against easy@work; never writes back. +- Two backends: native macOS **EventKit** (recommended) or portable **ICS** file. +- State-tracked: unchanged shifts are skipped; edits and deletions propagate. +- Open-source friendly: code is public, your `config.yaml` / `state.json` stay + local (see `.gitignore`). + +## Install + +```bash +pip install easyatcal # core + ICS backend +pip install 'easyatcal[eventkit]' # add macOS EventKit backend +``` + +Python 3.11+. macOS for EventKit; any OS for ICS. + +## Quickstart + +```bash +eaw-sync config init # scaffold config +$EDITOR ~/.config/easyatcal/config.yaml +eaw-sync doctor # check config + auth + backend +eaw-sync sync # one shot +eaw-sync watch --interval-seconds 900 # loop every 15 min +``` + +## Configure + +Minimal `config.yaml`: + +```yaml +easyatwork: + client_id: "REPLACE_ME" + client_secret: "REPLACE_ME" # or export EAW_CLIENT_SECRET + base_url: "https://api.easyatwork.com" + +sync: + lookback_days: 7 + lookahead_days: 90 + +backend: eventkit # or "ics" + +backends: + eventkit: + calendar_name: "Work Shifts" # must exist in Calendar.app + calendar_source: "iCloud" + ics: + output_path: "~/Documents/easyatwork-shifts.ics" + +logging: + level: INFO +``` + +Env overrides: any `easyatwork.*` field is overridable via `EAW_*` (e.g. +`EAW_CLIENT_SECRET`). + +## Backends + +**EventKit (macOS).** Writes directly to a dedicated calendar in Calendar.app. + +**IMPORTANT:** You must create the target calendar manually *before* your first sync! +1. Open **Calendar.app** +2. Go to **File → New Calendar** and choose the source (e.g., `iCloud`) +3. Name it exactly what you put in your config (e.g., "Work Shifts") +4. Run `eaw-sync sync`. It will trigger a macOS permission prompt. +5. Grant access when prompted (or in *System Settings → Privacy & Security → Calendars*). + +**ICS.** Writes a single `.ics` file. Subscribe to it from Calendar.app (or any +calendar client) via `File → New Calendar Subscription`. Portable, no +permissions needed. + +## Commands + +| Command | What | +|---------|------| +| `eaw-sync config init` | Scaffold config file. | +| `eaw-sync config show` | Print effective config (secrets redacted). | +| `eaw-sync auth test` | Verify credentials can obtain a token. | +| `eaw-sync doctor` | Full preflight: config loads, auth works, backend reachable. | +| `eaw-sync state show` | Print local state path, tracked-shift count, last sync. | +| `eaw-sync sync [--dry-run]` | Run one sync pass and exit. | +| `eaw-sync watch --interval-seconds N` | Loop until Ctrl-C / SIGTERM. | + +Global flag: `--config-path PATH` overrides the default config location. + +### Exit codes (`sync`) + +| Code | Meaning | +|------|---------| +| 0 | All changes applied. | +| 1 | Partial failure — some changes applied, state persisted, backend errored. | +| 2 | Fatal — config/auth/network failed before any change was written. | + +### Shell completions + +```bash +eaw-sync --install-completion # bash / zsh / fish +``` + +## Troubleshooting + +- **"Calendar 'Work Shifts' not found"** — create it in Calendar.app first; + source name must match (`iCloud`, `On My Mac`, etc). +- **Calendar permission denied** — System Settings → Privacy & Security → + Calendars → enable for your terminal / launchd agent. +- **`auth failed`** — run `eaw-sync doctor`, check `EAW_CLIENT_SECRET`, confirm + `base_url`. +- **Stale events after delete** — state entries auto-prune once the backend + confirms the delete. Corrupt `state.json` is quarantined and rebuilt. + +## Auto-run on macOS + +A sample launchd plist is in `examples/launchd/com.easyatcal.watch.plist`. +Load with: + +```bash +cp examples/launchd/com.easyatcal.watch.plist ~/Library/LaunchAgents/ +launchctl load ~/Library/LaunchAgents/com.easyatcal.watch.plist +``` + +## Contributing + +If you fork and want to publish your own PyPI package via GitHub Actions: +1. Ensure you have claimed your project name on PyPI. +2. Go to **PyPI -> Manage -> Publishing**. +3. Add a "Trusted Publisher" configured for your GitHub repository (e.g. `Ailcope/EasyAtCal`) pointing to the `publish.yml` workflow and the `pypi` environment. + +## Design + +- `docs/superpowers/specs/2026-04-19-easyatcal-design.md` — full design. +- `docs/superpowers/plans/2026-04-19-easyatcal-implementation.md` — build plan. + +## License + +MIT — see `LICENSE`. diff --git a/easyatcal/api.py b/easyatcal/api.py index cf22e72..461487c 100644 --- a/easyatcal/api.py +++ b/easyatcal/api.py @@ -57,7 +57,7 @@ def _read_cache(self) -> str | None: expires_at = datetime.fromisoformat(data["expires_at"]) if expires_at <= datetime.now(UTC): return None - return data["access_token"] + return str(data["access_token"]) def _fetch_token(self) -> str: try: @@ -74,7 +74,7 @@ def _fetch_token(self) -> str: if r.status_code != 200: raise AuthError(f"auth failed: {r.status_code} {r.text}") data = r.json() - token = data["access_token"] + token = str(data["access_token"]) expires_at = datetime.now(UTC) + timedelta( seconds=int(data.get("expires_in", 3600)) ) @@ -99,13 +99,14 @@ def fetch_shifts( """Return list[Shift] between from_date (inclusive) and to_date (exclusive).""" token = self.authenticate() url: str | None = f"{self.base_url}/v1/shifts" - params: dict[str, str] | None = { + headers = {"Authorization": f"Bearer {token}"} + first_params: dict[str, str] = { "from": from_date.isoformat(), "to": to_date.isoformat(), } if user_id is not None: - params["user_id"] = user_id - headers = {"Authorization": f"Bearer {token}"} + first_params["user_id"] = user_id + params: dict[str, str] | None = first_params out: list[Shift] = [] while url is not None: @@ -145,7 +146,10 @@ def fetch_shifts( ) ) url = payload.get("next") - params = None # next URL already includes cursor + if url: + # next URL already includes cursor. httpx strips the + # URL query when params={} is passed, so use None. + params = None except (KeyError, TypeError, ValueError) as e: raise ApiError( f"Unexpected API response shape. Failed to parse: {e}. " diff --git a/easyatcal/backends/eventkit.py b/easyatcal/backends/eventkit.py index 47bc1c1..16c6ad9 100644 --- a/easyatcal/backends/eventkit.py +++ b/easyatcal/backends/eventkit.py @@ -20,7 +20,7 @@ class EventKitPermissionError(RuntimeError): pass -def _import_eventkit(): # pragma: no cover — platform guard +def _import_eventkit() -> Any: # pragma: no cover — platform guard if sys.platform != "darwin": raise EventKitUnavailableError("EventKit backend requires macOS") try: @@ -40,7 +40,7 @@ def _event_store() -> Any: # pragma: no cover — exercised via mocks in tests granted = {"ok": False, "err": None} done = _E() - def _cb(ok, err): + def _cb(ok: bool, err: Any) -> None: granted["ok"] = bool(ok) granted["err"] = err done.set() @@ -59,7 +59,7 @@ def _cb(ok, err): return store -def _new_event(store, calendar, shift: Shift): # pragma: no cover +def _new_event(store: Any, calendar: Any, shift: Shift) -> Any: # pragma: no cover EventKit = _import_eventkit() import Foundation # type: ignore[import-not-found] @@ -86,7 +86,7 @@ def __init__(self, calendar_name: str, calendar_source: str) -> None: self._store = _event_store() self._calendar = self._resolve_calendar() - def _resolve_calendar(self): + def _resolve_calendar(self) -> Any: calendars = self._store.calendarsForEntityType_(0) for cal in calendars: if ( @@ -122,7 +122,7 @@ def apply(self, changes: Changes) -> ApplyResult: result.mapping[shift.id] = event.calendarItemExternalIdentifier() continue existing.setTitle_(shift.title) - import Foundation # type: ignore[import-not-found] + import Foundation existing.setStartDate_( Foundation.NSDate.dateWithTimeIntervalSince1970_( shift.start.timestamp() diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py index 2d0a40c..35782fc 100644 --- a/easyatcal/backends/ics.py +++ b/easyatcal/backends/ics.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any from icalendar import Calendar, Event @@ -14,8 +15,8 @@ def _uid_for(shift_id: str) -> str: return f"{UID_PREFIX}{shift_id}" -def _to_event(shift: Shift, uid: str) -> Event: - ev = Event() +def _to_event(shift: Shift, uid: str) -> Any: + ev = Event() # type: ignore[no-untyped-call] ev.add("uid", uid) ev.add("summary", shift.title) ev.add("dtstart", shift.start) @@ -69,7 +70,7 @@ def apply(self, changes: Changes) -> ApplyResult: return ApplyResult(mapping=mapping, deleted_uids=confirmed_deletes) def _write(self) -> None: - cal = Calendar() + cal = Calendar() # type: ignore[no-untyped-call] cal.add("prodid", "-//EasyAtCal//EN") cal.add("version", "2.0") for shift in self._current.values(): diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 651fb32..06a9275 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -4,13 +4,15 @@ import time from datetime import UTC from pathlib import Path +from typing import Any import typer import yaml from easyatcal.api import EawClient +from easyatcal.backends.base import CalendarBackend from easyatcal.backends.ics import IcsBackend -from easyatcal.config import load_config +from easyatcal.config import Config, load_config from easyatcal.logging_setup import configure_logging from easyatcal.orchestrator import run_sync from easyatcal.paths import ( @@ -89,7 +91,7 @@ def _root( # ---------- helpers ---------- -def _build_api_client(cfg): +def _build_api_client(cfg: Config) -> EawClient: return EawClient( client_id=cfg.easyatwork.client_id, client_secret=cfg.easyatwork.client_secret, @@ -98,7 +100,7 @@ def _build_api_client(cfg): ) -def _build_backend(cfg): +def _build_backend(cfg: Config) -> CalendarBackend: if cfg.backend == "ics": return IcsBackend( output_path=Path(cfg.backends.ics.output_path).expanduser(), @@ -212,7 +214,7 @@ def watch_cmd( stop = False - def _handler(signum, _frame): # noqa: ARG001 + def _handler(signum: int, _frame: Any) -> None: # noqa: ARG001 nonlocal stop stop = True @@ -256,6 +258,26 @@ def _handler(signum, _frame): # noqa: ARG001 typer.echo("\nStopped.") +@app.command("install-completion") +def install_completion_cmd() -> None: + """Install shell auto-completions for eaw-sync.""" + import os + import subprocess + + + # Run typer's underlying completion installation + shell = os.environ.get("SHELL", "") + if "zsh" in shell: + subprocess.run(["eaw-sync", "--install-completion", "zsh"], check=False) + elif "bash" in shell: + subprocess.run(["eaw-sync", "--install-completion", "bash"], check=False) + elif "fish" in shell: + subprocess.run(["eaw-sync", "--install-completion", "fish"], check=False) + else: + typer.echo(f"Unsupported shell: {shell}. Try running: eaw-sync --install-completion [bash|zsh|fish]", err=True) + raise typer.Exit(code=1) + typer.echo("Restart your shell to apply completions.") + # ---------- state ---------- @state_app.command("show") diff --git a/easyatcal/logging_setup.py b/easyatcal/logging_setup.py index 59a6ad8..05be5f1 100644 --- a/easyatcal/logging_setup.py +++ b/easyatcal/logging_setup.py @@ -14,6 +14,8 @@ def format(self, record: logging.LogRecord) -> str: "logger": record.name, "msg": record.getMessage(), } + if hasattr(record, "event_id"): + payload["event_id"] = record.event_id if record.exc_info: payload["exc"] = self.formatException(record.exc_info) return json.dumps(payload) diff --git a/easyatcal/orchestrator.py b/easyatcal/orchestrator.py index 97d38ce..6c277fa 100644 --- a/easyatcal/orchestrator.py +++ b/easyatcal/orchestrator.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta from pathlib import Path @@ -37,20 +38,46 @@ def run_sync( from_date = (now - timedelta(days=lookback_days)).date() to_date = (now + timedelta(days=lookahead_days)).date() - remote_shifts = api.fetch_shifts( - from_date=from_date, to_date=to_date, user_id=user_id - ) + logger = logging.getLogger(__name__) + + try: + remote_shifts = api.fetch_shifts( + from_date=from_date, to_date=to_date, user_id=user_id + ) + logger.info( + f"Fetched {len(remote_shifts)} shifts from API", + extra={"event_id": "sync.fetch.ok"} + ) + except Exception as e: + logger.error( + f"Failed to fetch shifts from API: {e}", + extra={"event_id": "sync.fetch.error"} + ) + raise + state = load_state(state_path) changes = compute_changes( remote_shifts, state, known_updated_at=state.shift_updated_at ) + logger.info( + f"Computed changes: {len(changes.adds)} adds, {len(changes.updates)} updates, {len(changes.deletes)} deletes", + extra={"event_id": "sync.compute_changes.ok"} + ) raised: BackendError | None = None try: result: ApplyResult = backend.apply(changes) + logger.info( + "Successfully applied changes to backend", + extra={"event_id": "sync.apply.ok"} + ) except BackendError as e: result = e.partial raised = e + logger.warning( + f"Partial failure applying changes: {e}", + extra={"event_id": "sync.apply.partial"} + ) _persist( state=state, @@ -73,6 +100,11 @@ def run_sync( if raised is not None: raised.summary = summary # type: ignore[attr-defined] raise raised + + logger.info( + f"Sync completed successfully: {added} added, {updated} updated, {summary.deletes} deleted", + extra={"event_id": "sync.complete"} + ) return summary diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..aa2154f --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,45 @@ +site_name: EasyAtCal +site_description: "Sync your easy@work shifts with Apple Calendar." +site_url: https://ailcope.github.io/EasyAtCal/ +repo_url: https://github.com/Ailcope/EasyAtCal +repo_name: Ailcope/EasyAtCal + +theme: + name: material + +docs_dir: docs/pages + +palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: blue + accent: light blue + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: blue + accent: light blue + toggle: + icon: material/weather-sunny + name: Switch to light mode + +nav: + - Home: index.md + - Contributing: contributing.md + - Changelog: changelog.md + +markdown_extensions: + - toc: + permalink: true + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.superfences + - admonition + - pymdownx.details + - pymdownx.tabbed: + alternate_style: true + +plugins: + - search \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 918d1f2..5c2aac5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "respx>=0.21", "freezegun>=1.4", "ruff>=0.6", + "mypy>=1.10", ] [project.scripts] @@ -52,3 +53,8 @@ ignore = ["E501"] # line length handled by formatter [tool.ruff.lint.per-file-ignores] "tests/**" = ["B017", "B018"] + +[tool.mypy] +strict = true +warn_return_any = true +warn_unused_configs = true From 323f3384b50593cf447c19a590b41b5c9e1b8544 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:28:07 +0200 Subject: [PATCH 34/68] feat: session-cookie auth via headless Playwright browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pivot from hypothetical OAuth API to scraping the real easy@work web SPA, since no public developer API exists. - `auth_mode: user` (default): `eaw-sync login` drives headless Chromium through the login form, captures cookies via Playwright's storage_state, persists them to ~/.cache/easyatcal/session.json (0600). - Password never on disk: passed via EAW_PASSWORD env or interactive prompt only. - `SessionEawClient` replays cookies against a configurable `shifts_endpoint` (user must capture the real path from DevTools → Network). Flexible payload detection: {data,results,items,shifts} key + bare list + DRF-style {links.next}. Field mapping accepts id/uuid/shiftId, start/starts_at/from, etc. - `eaw-sync logout` wipes the cookie jar; HTTP 401 raises AuthError with a "run login" hint. - `auth_mode: client` OAuth path preserved for forward-compat. - 18 new tests (session store, session-mode fetch, CLI login/logout); 75 total, 88.86% coverage, ruff clean, mypy strict clean. - Optional `playwright` extra. Docs + config example updated. --- CHANGELOG.md | 24 +++++ README.md | 62 +++++++++--- config.example.yaml | 35 +++++-- easyatcal/api_session.py | 198 ++++++++++++++++++++++++++++++++++++++ easyatcal/auth_user.py | 95 ++++++++++++++++++ easyatcal/cli.py | 106 +++++++++++++++++--- easyatcal/config.py | 55 ++++++++++- easyatcal/orchestrator.py | 2 + easyatcal/paths.py | 5 + easyatcal/session.py | 72 ++++++++++++++ pyproject.toml | 13 +++ tests/test_api_session.py | 143 +++++++++++++++++++++++++++ tests/test_cli_login.py | 143 +++++++++++++++++++++++++++ tests/test_session.py | 74 ++++++++++++++ 14 files changed, 989 insertions(+), 38 deletions(-) create mode 100644 easyatcal/api_session.py create mode 100644 easyatcal/auth_user.py create mode 100644 easyatcal/session.py create mode 100644 tests/test_api_session.py create mode 100644 tests/test_cli_login.py create mode 100644 tests/test_session.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e0370e..35fed0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] +### Added +- **Session-cookie auth via headless browser.** New `auth_mode: user` + (now the default) drives a headless Chromium through the easy@work + web login, persists cookies to `~/.cache/easyatcal/session.json`, and + replays them on every HTTP call. `eaw-sync login` / `eaw-sync logout` + commands. Password never stored on disk — passes via `EAW_PASSWORD` + env or interactive prompt. +- `SessionEawClient` with flexible payload shape detection + (`data` / `results` / `items` / `shifts` / bare list) and heuristic + field mapping (`id`/`uuid`/`shiftId`, `start`/`starts_at`/`from`, …). + Returns `AuthError` on HTTP 401 with a "run login" hint. +- `easyatcal.session.SessionStore` atomic 0600 cookie-jar persistence. +- `easyatcal.auth_user.do_login` Playwright driver with configurable + selectors (`email_selector`, `password_selector`, `submit_selector`) + and `headless` toggle. +- Optional `playwright` extra. `mypy.overrides` for `playwright.*`. +- 18 new tests (session store, session-mode fetch, CLI login/logout). + +### Changed +- `doctor` and `auth test` now report session / OAuth status distinctly. +- `ShiftFetcher` protocol gains `authenticate() -> object`. +- `shifts_endpoint` is blank by default; sync raises a clear + "inspect HAR" error until set. + ### Added - Structured log events in `run_sync` with `event_id` extra (`sync.fetch.ok`, `sync.fetch.error`, `sync.compute_changes.ok`, `sync.apply.ok`, diff --git a/README.md b/README.md index a86fc72..eea478a 100644 --- a/README.md +++ b/README.md @@ -19,30 +19,55 @@ Calendar. Run it on a Mac, iCloud fans out to iPhone/iPad/Watch. ```bash pip install easyatcal # core + ICS backend -pip install 'easyatcal[eventkit]' # add macOS EventKit backend +pip install 'easyatcal[eventkit]' # + macOS EventKit backend +pip install 'easyatcal[playwright]' # + headless-browser login (default auth) +playwright install chromium # one-time ~200 MB browser download ``` Python 3.11+. macOS for EventKit; any OS for ICS. +## Authentication + +easy@work has no public developer API. The default auth mode (`user`) logs +a headless Chromium instance into `app.easyatwork.com` with your real +credentials, captures the session cookies, and reuses them for all +subsequent HTTP calls. Your password is never stored on disk — it lives +only in the `EAW_PASSWORD` env var (or is prompted interactively). + +```bash +EAW_PASSWORD='...' eaw-sync login # persists cookies; do once, or when expired +eaw-sync logout # wipe cookies +``` + +Cookies are written to `~/.cache/easyatcal/session.json` (0600) via +Playwright's `storage_state`. + +An alternate `client` mode (OAuth client_credentials) is scaffolded in +the code for forward-compat — if easy@work ever publishes an API, flip +`auth_mode: client` and fill in the OAuth creds. + ## Quickstart ```bash eaw-sync config init # scaffold config -$EDITOR ~/.config/easyatcal/config.yaml -eaw-sync doctor # check config + auth + backend +$EDITOR ~/.config/easyatcal/config.yaml # set email, app_url, shifts_endpoint +EAW_PASSWORD='...' eaw-sync login # one-time headless login +eaw-sync doctor # check config + session + backend eaw-sync sync # one shot eaw-sync watch --interval-seconds 900 # loop every 15 min ``` ## Configure -Minimal `config.yaml`: +Minimal `config.yaml` (user mode): ```yaml easyatwork: - client_id: "REPLACE_ME" - client_secret: "REPLACE_ME" # or export EAW_CLIENT_SECRET - base_url: "https://api.easyatwork.com" + auth_mode: user + email: "me@example.com" + login_url: "https://app.easyatwork.com/" + app_url: "https://app.easyatwork.com" + shifts_endpoint: "/api/v1/shifts" # capture from DevTools → Network sync: lookback_days: 7 @@ -109,14 +134,21 @@ eaw-sync --install-completion # bash / zsh / fish ## Known limitations -- **API shape is unverified.** The easy@work reference client - (`php-eaw-client`) could not be located at design time, so the request / - response shape this client assumes (`POST /oauth/token`, - `GET /v1/shifts?from=&to=&user_id=`, pagination via `next`) is a - best-guess based on typical OAuth2 + cursor conventions. On first - contact with a real tenant, expect to patch `easyatcal/api.py`. Parsing - is defensive — unexpected payload shape raises `ApiError` with the - observed top-level keys. +- **No public API.** easy@work does not publish developer docs. The + default `user` auth mode scrapes the web app via Playwright and reuses + its session cookies, which works against the real tenant but depends + on the SPA's private endpoints. +- **`shifts_endpoint` is tenant-specific.** Open + `app.easyatwork.com` in a browser, DevTools → Network, filter `XHR`, + open your schedule view, find the JSON request that returns your + shifts, copy its path to `easyatwork.shifts_endpoint`. Session-mode + parsing auto-detects common shapes (`{"data": [...]}`, + `{"results": [...]}`, bare list, `id`/`uuid`/`shiftId`, + `start`/`starts_at`/`from`, etc) — unexpected shapes raise `ApiError` + with the observed top-level keys. +- **OAuth (`client` mode) is unverified.** Kept in-tree for forward-compat + should easy@work publish a developer API, but there is no public + reference client (`php-eaw-client` does not exist as a public repo). ## Troubleshooting diff --git a/config.example.yaml b/config.example.yaml index cdf5eb5..898ce35 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,15 +1,36 @@ easyatwork: - auth_mode: client # "client" or "user" - client_id: "REPLACE_ME" - client_secret: "REPLACE_ME" # or set EAW_CLIENT_SECRET env var - base_url: "https://api.easyatwork.com" + # "user" = scrape the web SPA via Playwright (recommended — no public API). + # "client" = OAuth client_credentials (only if a public API is ever published). + auth_mode: user + + # ---------- user mode ---------- + email: "me@example.com" # or set EAW_EMAIL + # Password is NEVER stored on disk. Provide it via env var for `eaw-sync login`: + # EAW_PASSWORD=... eaw-sync login + # or enter it interactively when prompted. + login_url: "https://app.easyatwork.com/" + app_url: "https://app.easyatwork.com" + # Path the SPA uses to load your shifts. Inspect DevTools → Network while + # you open the schedule view, find the JSON request, paste the path here. + # Leave blank until you know it — sync will raise a clear error. + shifts_endpoint: "" # e.g. "/api/v1/shifts" + # Selectors for the login form. Override if the defaults don't match. + # email_selector: "input[type='email']" + # password_selector: "input[type='password']" + # submit_selector: "button[type='submit']" + headless: true # set false to watch the browser + + # ---------- client mode (OAuth, hypothetical) ---------- + # client_id: "REPLACE_ME" + # client_secret: "REPLACE_ME" # or EAW_CLIENT_SECRET env var + # base_url: "https://api.easyatwork.com" sync: lookback_days: 7 lookahead_days: 90 - user_id: null # null = self + user_id: null # null = self -backend: eventkit # "eventkit" or "ics" +backend: eventkit # "eventkit" or "ics" backends: eventkit: @@ -20,4 +41,4 @@ backends: logging: level: INFO - format: text # "text" or "json" + format: text # "text" or "json" diff --git a/easyatcal/api_session.py b/easyatcal/api_session.py new file mode 100644 index 0000000..cd71755 --- /dev/null +++ b/easyatcal/api_session.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import contextlib +import time +from datetime import date, datetime +from typing import Any + +import httpx + +from easyatcal.api import ApiError, AuthError +from easyatcal.models import Shift +from easyatcal.session import SessionStore + + +class SessionEawClient: + """Fetches shifts against the easy@work web app using persisted + browser cookies (from ``eaw-sync login``). + + Endpoint is tenant-specific. Set ``shifts_endpoint`` on the config + to the path the web app hits (look in DevTools → Network). + """ + + _MAX_RETRIES = 5 + + def __init__( + self, + *, + app_url: str, + shifts_endpoint: str, + session_store: SessionStore, + timeout: float = 30.0, + ) -> None: + self.app_url = app_url.rstrip("/") + self.shifts_endpoint = shifts_endpoint + self.session_store = session_store + self._http = httpx.Client(timeout=timeout) + + def authenticate(self) -> None: + cookies = self.session_store.cookies() + if cookies is None: + raise AuthError( + "No session cookies found. Run `eaw-sync login` first." + ) + self._http.cookies = cookies + self._http.headers.update({ + "Accept": "application/json", + "X-Requested-With": "XMLHttpRequest", + }) + + def fetch_shifts( + self, + from_date: date, + to_date: date, + user_id: str | None = None, + ) -> list[Shift]: + if not self.shifts_endpoint: + raise ApiError( + "easyatwork.shifts_endpoint is blank. Capture a HAR from " + "the web app schedule view, find the request that returns " + "your shifts, and set that path (e.g. '/api/v1/shifts') " + "in the config." + ) + self.authenticate() + + url: str | None = self._absolute(self.shifts_endpoint) + first_params: dict[str, str] = { + "from": from_date.isoformat(), + "to": to_date.isoformat(), + } + if user_id is not None: + first_params["user_id"] = user_id + params: dict[str, str] | None = first_params + + out: list[Shift] = [] + while url is not None: + r = self._retry_get(url, params) + try: + payload = r.json() + for raw in _iter_rows(payload): + out.append(_parse_shift(raw)) + next_url = _next_url(payload) + if next_url: + url = next_url if next_url.startswith("http") else self._absolute(next_url) + params = None + else: + url = None + except (KeyError, TypeError, ValueError) as e: + raise ApiError( + f"Unexpected session API response shape. Parse error: {e}. " + f"Top-level keys: " + f"{list(payload.keys()) if isinstance(payload, dict) else 'not a dict'}" + ) from e + return out + + def _absolute(self, path: str) -> str: + if path.startswith("http"): + return path + if not path.startswith("/"): + path = "/" + path + return f"{self.app_url}{path}" + + def _retry_get( + self, + url: str, + params: dict[str, str] | None, + ) -> httpx.Response: + attempts = 0 + while True: + r = self._http.get(url, params=params) + if r.status_code == 200: + return r + if r.status_code == 401: + raise AuthError( + "Session cookies rejected (HTTP 401). " + "Run `eaw-sync login` to refresh." + ) + if r.status_code in (429, 500, 502, 503, 504): + attempts += 1 + if attempts > self._MAX_RETRIES: + raise ApiError( + f"rate limit / server errors exceeded retries " + f"({r.status_code})" + ) + delay = 2 ** (attempts - 1) + retry_after = r.headers.get("Retry-After") + if retry_after is not None: + with contextlib.suppress(ValueError): + delay = max(delay, int(retry_after)) + time.sleep(delay) + continue + raise ApiError(f"GET {url} -> {r.status_code} {r.text[:300]}") + + +def _iter_rows(payload: Any) -> list[dict[str, Any]]: + """Accept common paginated shapes until we pin the real one: + - {"data": [...], "next": ...} + - {"results": [...], "next": ...} + - {"items": [...]} + - [...] (bare list) + """ + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + for key in ("data", "results", "items", "shifts"): + v = payload.get(key) + if isinstance(v, list): + return v + return [] + + +def _next_url(payload: Any) -> str | None: + if not isinstance(payload, dict): + return None + for key in ("next", "next_url", "nextPage"): + v = payload.get(key) + if isinstance(v, str) and v: + return v + # DRF-style nested + links = payload.get("links") + if isinstance(links, dict): + v = links.get("next") + if isinstance(v, str) and v: + return v + return None + + +def _parse_shift(raw: dict[str, Any]) -> Shift: + """Best-effort mapping until we know the real field names. + + Tries a handful of common spellings. Override once HAR is captured. + """ + def pick(*keys: str) -> Any: + for k in keys: + if k in raw and raw[k] is not None: + return raw[k] + return None + + id_val = pick("id", "uuid", "shiftId") + start_val = pick("start", "starts_at", "startDate", "startTime", "from") + end_val = pick("end", "ends_at", "endDate", "endTime", "to") + updated_val = pick("updated_at", "updatedAt", "modified_at", "modifiedAt") + + if id_val is None or start_val is None or end_val is None: + raise ValueError( + f"shift row missing id/start/end; keys present: {list(raw)}" + ) + + return Shift( + id=str(id_val), + start=datetime.fromisoformat(str(start_val)), + end=datetime.fromisoformat(str(end_val)), + title=pick("title", "name", "label") or "Shift", + location=pick("location", "place", "site"), + notes=pick("notes", "note", "comment"), + updated_at=datetime.fromisoformat( + str(updated_val or start_val) + ), + ) diff --git a/easyatcal/auth_user.py b/easyatcal/auth_user.py new file mode 100644 index 0000000..5b6115c --- /dev/null +++ b/easyatcal/auth_user.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from easyatcal.config import EasyAtWorkAuth + + +class PlaywrightMissingError(RuntimeError): + """Raised when auth_mode=user is configured but Playwright is not installed.""" + + +class LoginError(RuntimeError): + """Raised when headless login fails (wrong selector, creds, etc.).""" + + +def do_login( + cfg: EasyAtWorkAuth, + password: str, + storage_path: Path, + extra_wait_selector: str | None = None, +) -> None: + """Drive a headless browser through the easy@work login form, + then persist the storage_state to ``storage_path``. + + ``extra_wait_selector`` is an optional CSS selector to wait for after + form submit — useful when the post-login page has a known landmark + (e.g. ``nav[data-testid='app-shell']``). Defaults to a generic + ``networkidle`` wait. + """ + try: + from playwright.sync_api import ( + TimeoutError as PWTimeout, + ) + from playwright.sync_api import ( + sync_playwright, + ) + except ImportError as e: # pragma: no cover — platform guard + raise PlaywrightMissingError( + "Playwright not installed. Run: pip install 'easyatcal[playwright]' " + "&& playwright install chromium" + ) from e + + if cfg.email is None: + raise LoginError("no email configured in easyatwork.email") + + storage_path.parent.mkdir(parents=True, exist_ok=True) + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=cfg.headless) + try: + context = browser.new_context() + page = context.new_page() + page.goto(cfg.login_url, wait_until="domcontentloaded") + + try: + page.wait_for_selector(cfg.email_selector, timeout=cfg.login_timeout_ms) + except PWTimeout as e: + raise LoginError( + f"login form not found at {cfg.login_url} " + f"(selector {cfg.email_selector!r}). " + f"Set easyatwork.email_selector to match your tenant." + ) from e + + page.fill(cfg.email_selector, cfg.email) + page.fill(cfg.password_selector, password) + page.click(cfg.submit_selector) + + try: + if extra_wait_selector: + page.wait_for_selector( + extra_wait_selector, timeout=cfg.login_timeout_ms + ) + else: + page.wait_for_load_state( + "networkidle", timeout=cfg.login_timeout_ms + ) + except PWTimeout as e: + raise LoginError( + f"login did not complete (timeout waiting post-submit). " + f"Current URL: {page.url}" + ) from e + + # Heuristic: if we're still on login-ish URL, assume failure. + final_url = page.url.lower() + if any(x in final_url for x in ("login", "signin", "sign-in")): + raise LoginError( + f"login did not advance off login page. URL: {page.url}. " + f"Check credentials or selectors." + ) + + context.storage_state(path=str(storage_path)) + finally: + browser.close() diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 06a9275..f8f2539 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -10,17 +10,20 @@ import yaml from easyatcal.api import EawClient +from easyatcal.api_session import SessionEawClient from easyatcal.backends.base import CalendarBackend from easyatcal.backends.ics import IcsBackend from easyatcal.config import Config, load_config from easyatcal.logging_setup import configure_logging -from easyatcal.orchestrator import run_sync +from easyatcal.orchestrator import ShiftFetcher, run_sync from easyatcal.paths import ( config_path, log_path, + session_state_path, state_path, token_cache_path, ) +from easyatcal.session import SessionStore app = typer.Typer(help="EasyAtCal — sync easy@work shifts to Apple Calendar.") config_app = typer.Typer(help="Manage the config file.") @@ -91,12 +94,21 @@ def _root( # ---------- helpers ---------- -def _build_api_client(cfg: Config) -> EawClient: - return EawClient( - client_id=cfg.easyatwork.client_id, - client_secret=cfg.easyatwork.client_secret, - base_url=cfg.easyatwork.base_url, - token_cache=token_cache_path(), +def _build_api_client(cfg: Config) -> ShiftFetcher: + if cfg.easyatwork.auth_mode == "client": + # OAuth public-API mode (kept for forward-compat). + assert cfg.easyatwork.client_id and cfg.easyatwork.client_secret + return EawClient( + client_id=cfg.easyatwork.client_id, + client_secret=cfg.easyatwork.client_secret, + base_url=cfg.easyatwork.base_url, + token_cache=token_cache_path(), + ) + # auth_mode == "user" — session-cookie mode + return SessionEawClient( + app_url=cfg.easyatwork.app_url, + shifts_endpoint=cfg.easyatwork.shifts_endpoint, + session_store=SessionStore(session_state_path()), ) @@ -134,10 +146,76 @@ def config_show() -> None: """Print the effective config with secrets redacted.""" cfg = load_config(_cfg_path()) dumped = cfg.model_dump() - dumped["easyatwork"]["client_secret"] = "***" + if dumped["easyatwork"].get("client_secret"): + dumped["easyatwork"]["client_secret"] = "***" typer.echo(yaml.safe_dump(dumped, sort_keys=False)) +# ---------- login (session auth) ---------- + +@app.command("login") +def login_cmd( + password_env: str = typer.Option( + "EAW_PASSWORD", + "--password-env", + help="Env var holding the password. If unset, prompt interactively.", + ), + headful: bool = typer.Option( + False, + "--headful", + help="Run browser visibly (debug failing login).", + ), +) -> None: + """Open a headless browser, log in to easy@work, persist the session. + + Requires ``auth_mode: user`` and ``email`` in config, plus the + ``playwright`` optional extra installed. + """ + import os + + from easyatcal.auth_user import LoginError, PlaywrightMissingError, do_login + + cfg = load_config(_cfg_path()) + configure_logging( + level=_get_log_level(cfg.logging.level), + log_file=log_path(), + fmt=cfg.logging.format, + ) + + if cfg.easyatwork.auth_mode != "user": + typer.echo("auth_mode is not 'user' — nothing to log in to.", err=True) + raise typer.Exit(code=1) + + password = os.environ.get(password_env) + if password is None: + password = typer.prompt("Password", hide_input=True) + if not password: + typer.echo("Empty password — aborting.", err=True) + raise typer.Exit(code=1) + + auth = cfg.easyatwork.model_copy(update={"headless": not headful}) + storage = session_state_path() + + try: + do_login(cfg=auth, password=password, storage_path=storage) + except PlaywrightMissingError as e: + typer.echo(str(e), err=True) + raise typer.Exit(code=1) from e + except LoginError as e: + typer.echo(f"Login failed: {e}", err=True) + raise typer.Exit(code=2) from e + typer.echo(f"Logged in. Session stored at {storage}") + + +@app.command("logout") +def logout_cmd() -> None: + """Delete the persisted session cookies.""" + path = session_state_path() + store = SessionStore(path) + store.clear() + typer.echo(f"Cleared {path}") + + # ---------- sync / watch ---------- @app.command("sync") @@ -338,15 +416,21 @@ def doctor_cmd() -> None: configure_logging(level=cfg.logging.level, log_file=log_path(), fmt=cfg.logging.format) # 2. Auth + mode = cfg.easyatwork.auth_mode try: api = _build_api_client(cfg) api.authenticate() - typer.echo("[ OK ] auth: token obtained") + if mode == "client": + typer.echo("[ OK ] auth: OAuth token obtained") + else: + typer.echo("[ OK ] auth: session cookies loaded") except AuthError as e: - typer.echo(f"[FAIL] auth: {e}") + typer.echo(f"[FAIL] auth ({mode}): {e}") + if mode == "user": + typer.echo(" Run `eaw-sync login` to create a session.") failures += 1 except Exception as e: - typer.echo(f"[FAIL] auth: {e}") + typer.echo(f"[FAIL] auth ({mode}): {e}") failures += 1 # 3. Backend diff --git a/easyatcal/config.py b/easyatcal/config.py index b6900aa..d543216 100644 --- a/easyatcal/config.py +++ b/easyatcal/config.py @@ -5,15 +5,56 @@ from typing import Literal import yaml -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator class EasyAtWorkAuth(BaseModel): - auth_mode: Literal["client", "user"] - client_id: str - client_secret: str + """Credentials + endpoint config. + + Two modes: + - ``client``: OAuth2 client_credentials against a (hypothetical) public + API. Kept for forward-compat; not used against the real tenant. + - ``user``: Scrape the web SPA via Playwright. The user logs in once + (``eaw-sync login``) and cookies are persisted. All shift requests + go through the same web origin with those cookies. + """ + + auth_mode: Literal["client", "user"] = "user" + + # client mode + client_id: str | None = None + client_secret: str | None = None base_url: str = "https://api.easyatwork.com" + # user mode + email: str | None = None + login_url: str = "https://app.easyatwork.com/" + app_url: str = "https://app.easyatwork.com" + # Which endpoint to hit for shifts once logged in. + # Blank → client raises on sync, prompting HAR inspection. + shifts_endpoint: str = "" + # Playwright login form selectors (override per tenant if the form + # layout differs). + email_selector: str = "input[type='email'], input[name='email'], input[name='username']" + password_selector: str = "input[type='password']" + submit_selector: str = "button[type='submit'], input[type='submit']" + # Browser headless by default; set false for first-run debug. + headless: bool = True + # Max wait after submit for navigation to finish (ms). + login_timeout_ms: int = 20000 + + @model_validator(mode="after") + def _check_mode_fields(self) -> EasyAtWorkAuth: + if self.auth_mode == "client" and ( + not self.client_id or not self.client_secret + ): + raise ValueError( + "auth_mode=client requires client_id and client_secret" + ) + if self.auth_mode == "user" and not self.email: + raise ValueError("auth_mode=user requires email") + return self + class SyncSettings(BaseModel): lookback_days: int = Field(ge=0, default=7) @@ -55,10 +96,14 @@ def validate_backend(cls, v: str) -> str: return v -_ENV_OVERRIDES = { +_ENV_OVERRIDES: dict[str, tuple[str, str]] = { "EAW_CLIENT_ID": ("easyatwork", "client_id"), "EAW_CLIENT_SECRET": ("easyatwork", "client_secret"), "EAW_BASE_URL": ("easyatwork", "base_url"), + "EAW_EMAIL": ("easyatwork", "email"), + "EAW_LOGIN_URL": ("easyatwork", "login_url"), + "EAW_APP_URL": ("easyatwork", "app_url"), + "EAW_SHIFTS_ENDPOINT": ("easyatwork", "shifts_endpoint"), } diff --git a/easyatcal/orchestrator.py b/easyatcal/orchestrator.py index 6c277fa..5d74255 100644 --- a/easyatcal/orchestrator.py +++ b/easyatcal/orchestrator.py @@ -20,6 +20,8 @@ class SyncSummary: class ShiftFetcher(Protocol): + def authenticate(self) -> object: ... + def fetch_shifts( self, from_date: date, to_date: date, user_id: str | None = None ) -> list[Shift]: ... diff --git a/easyatcal/paths.py b/easyatcal/paths.py index 32ab54e..a40a9a8 100644 --- a/easyatcal/paths.py +++ b/easyatcal/paths.py @@ -19,5 +19,10 @@ def token_cache_path() -> Path: return Path(user_cache_dir(APP)) / "token.json" +def session_state_path() -> Path: + """Playwright storage_state (cookies + localStorage) for user auth.""" + return Path(user_cache_dir(APP)) / "session.json" + + def log_path() -> Path: return Path(user_data_dir(APP)) / "logs" / "eaw-sync.log" diff --git a/easyatcal/session.py b/easyatcal/session.py new file mode 100644 index 0000000..a3eea35 --- /dev/null +++ b/easyatcal/session.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import contextlib +import json +import os +from pathlib import Path +from typing import Any + +import httpx + + +class SessionStore: + """Persists Playwright ``storage_state`` (cookies + localStorage) + on disk with 0600 perms. + + Playwright storage_state shape:: + + {"cookies": [{"name": ..., "value": ..., "domain": ..., + "path": ..., "expires": ..., "httpOnly": ..., + "secure": ..., "sameSite": ...}, ...], + "origins": [...]} + + We only need the cookies for httpx replay — localStorage is kept + so a future reuse with Playwright can restore full UI state. + """ + + def __init__(self, path: Path) -> None: + self.path = Path(path) + + def save(self, storage_state: dict[str, Any]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(self.path.suffix + ".tmp") + tmp.write_text(json.dumps(storage_state)) + os.replace(tmp, self.path) + with contextlib.suppress(OSError): + os.chmod(self.path, 0o600) + + def load(self) -> dict[str, Any] | None: + if not self.path.exists(): + return None + try: + data = json.loads(self.path.read_text()) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(data, dict): + return None + return data + + def cookies(self) -> httpx.Cookies | None: + """Convert the persisted cookies to an httpx.Cookies jar. + Returns None if no session is stored. + """ + state = self.load() + if state is None: + return None + jar = httpx.Cookies() + for c in state.get("cookies", []): + name = c.get("name") + value = c.get("value") + if not name or value is None: + continue + jar.set( + name=name, + value=value, + domain=c.get("domain", ""), + path=c.get("path", "/"), + ) + return jar + + def clear(self) -> None: + with contextlib.suppress(FileNotFoundError): + self.path.unlink() diff --git a/pyproject.toml b/pyproject.toml index 5c2aac5..c60dc18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ [project.optional-dependencies] eventkit = ["pyobjc-framework-EventKit>=10.0; sys_platform == 'darwin'"] +playwright = ["playwright>=1.44"] dev = [ "pytest>=8.0", "pytest-cov>=5.0", @@ -43,6 +44,14 @@ packages = ["easyatcal"] testpaths = ["tests"] addopts = "-v --strict-markers" +[tool.coverage.run] +omit = [ + # Playwright-driven, exercised only on real browsers. + "easyatcal/auth_user.py", + # EventKit shim is macOS + PyObjC, exercised via mocks only. + "easyatcal/backends/eventkit.py", +] + [tool.ruff] line-length = 100 target-version = "py311" @@ -58,3 +67,7 @@ ignore = ["E501"] # line length handled by formatter strict = true warn_return_any = true warn_unused_configs = true + +[[tool.mypy.overrides]] +module = ["playwright.*"] +ignore_missing_imports = true diff --git a/tests/test_api_session.py b/tests/test_api_session.py new file mode 100644 index 0000000..9309a17 --- /dev/null +++ b/tests/test_api_session.py @@ -0,0 +1,143 @@ +from datetime import date +from pathlib import Path + +import httpx +import pytest +import respx + +from easyatcal.api import ApiError, AuthError +from easyatcal.api_session import SessionEawClient, _iter_rows, _parse_shift +from easyatcal.session import SessionStore + + +def _seeded_store(tmp_path: Path) -> SessionStore: + store = SessionStore(tmp_path / "session.json") + store.save( + { + "cookies": [ + { + "name": "SESSION", + "value": "abc", + "domain": "app.easyatwork.com", + "path": "/", + } + ] + } + ) + return store + + +def test_missing_endpoint_raises(tmp_path: Path) -> None: + client = SessionEawClient( + app_url="https://app.easyatwork.com", + shifts_endpoint="", + session_store=_seeded_store(tmp_path), + ) + with pytest.raises(ApiError, match="shifts_endpoint"): + client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + + +def test_no_session_cookies_raises_authenticate(tmp_path: Path) -> None: + client = SessionEawClient( + app_url="https://app.easyatwork.com", + shifts_endpoint="/api/shifts", + session_store=SessionStore(tmp_path / "missing.json"), + ) + with pytest.raises(AuthError, match="No session cookies"): + client.authenticate() + + +@respx.mock +def test_fetch_shifts_happy_path(tmp_path: Path) -> None: + respx.get("https://app.easyatwork.com/api/shifts").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": "s1", + "start": "2026-04-20T09:00:00+00:00", + "end": "2026-04-20T17:00:00+00:00", + "title": "Morning", + "location": "Oslo", + "updated_at": "2026-04-18T10:00:00+00:00", + } + ], + "next": None, + }, + ) + ) + client = SessionEawClient( + app_url="https://app.easyatwork.com", + shifts_endpoint="/api/shifts", + session_store=_seeded_store(tmp_path), + ) + shifts = client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + assert len(shifts) == 1 + assert shifts[0].id == "s1" + assert shifts[0].location == "Oslo" + + +@respx.mock +def test_fetch_shifts_401_raises_auth_error(tmp_path: Path) -> None: + respx.get("https://app.easyatwork.com/api/shifts").mock( + return_value=httpx.Response(401) + ) + client = SessionEawClient( + app_url="https://app.easyatwork.com", + shifts_endpoint="/api/shifts", + session_store=_seeded_store(tmp_path), + ) + with pytest.raises(AuthError, match="cookies rejected"): + client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + + +@respx.mock +def test_fetch_shifts_accepts_bare_list_and_flexible_keys(tmp_path: Path) -> None: + respx.get("https://app.easyatwork.com/api/v2/schedules").mock( + return_value=httpx.Response( + 200, + json=[ + { + "uuid": "sh-9", + "starts_at": "2026-04-21T09:00:00+00:00", + "ends_at": "2026-04-21T17:00:00+00:00", + "name": "Evening", + "place": "Bergen", + "updatedAt": "2026-04-19T10:00:00+00:00", + } + ], + ) + ) + client = SessionEawClient( + app_url="https://app.easyatwork.com", + shifts_endpoint="/api/v2/schedules", + session_store=_seeded_store(tmp_path), + ) + shifts = client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + assert shifts[0].id == "sh-9" + assert shifts[0].title == "Evening" + assert shifts[0].location == "Bergen" + + +def test_iter_rows_shapes() -> None: + assert _iter_rows([{"a": 1}]) == [{"a": 1}] + assert _iter_rows({"data": [{"a": 1}]}) == [{"a": 1}] + assert _iter_rows({"results": [{"a": 1}]}) == [{"a": 1}] + assert _iter_rows({"items": [{"a": 1}]}) == [{"a": 1}] + assert _iter_rows({"shifts": [{"a": 1}]}) == [{"a": 1}] + assert _iter_rows({"nope": 1}) == [] + assert _iter_rows("string") == [] + + +def test_parse_shift_missing_fields_raises() -> None: + with pytest.raises(ValueError, match="missing id/start/end"): + _parse_shift({"title": "x"}) diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py new file mode 100644 index 0000000..8367cfd --- /dev/null +++ b/tests/test_cli_login.py @@ -0,0 +1,143 @@ +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from easyatcal.cli import app + +runner = CliRunner() + + +def _write_user_config(tmp_path: Path) -> Path: + cfg = tmp_path / "config.yaml" + cfg.write_text( + """ +easyatwork: + auth_mode: user + email: me@example.com + login_url: https://app.easyatwork.com/ + app_url: https://app.easyatwork.com + shifts_endpoint: "" +backend: ics +backends: + ics: + output_path: %s +""" + % (tmp_path / "out.ics") + ) + return cfg + + +def _write_client_config(tmp_path: Path) -> Path: + cfg = tmp_path / "config.yaml" + cfg.write_text( + """ +easyatwork: + auth_mode: client + client_id: cid + client_secret: csec + base_url: https://api.easyatwork.com +backend: ics +backends: + ics: + output_path: %s +""" + % (tmp_path / "out.ics") + ) + return cfg + + +def test_login_invokes_do_login(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = _write_user_config(tmp_path) + monkeypatch.setenv("EAW_PASSWORD", "s3cret") + + called: dict[str, object] = {} + + def fake_do_login(*, cfg, password, storage_path, extra_wait_selector=None): # type: ignore[no-untyped-def] + called["email"] = cfg.email + called["password"] = password + called["storage_path"] = storage_path + storage_path.parent.mkdir(parents=True, exist_ok=True) + storage_path.write_text('{"cookies":[]}') + + monkeypatch.setattr("easyatcal.auth_user.do_login", fake_do_login) + monkeypatch.setattr( + "easyatcal.cli.session_state_path", + lambda: tmp_path / "session.json", + ) + + result = runner.invoke(app, ["--config-path", str(cfg), "login"]) + assert result.exit_code == 0, result.output + assert called["email"] == "me@example.com" + assert called["password"] == "s3cret" + assert (tmp_path / "session.json").exists() + + +def test_login_rejects_client_mode(tmp_path: Path) -> None: + cfg = _write_client_config(tmp_path) + result = runner.invoke(app, ["--config-path", str(cfg), "login"]) + assert result.exit_code == 1 + assert "auth_mode is not 'user'" in result.output + + +def test_login_empty_password_exits( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + cfg = _write_user_config(tmp_path) + monkeypatch.setenv("EAW_PASSWORD", "") + # Input "" for the prompt fallback (getenv returns empty string, not None) + # We expect CLI to treat empty as aborting. + result = runner.invoke(app, ["--config-path", str(cfg), "login"]) + assert result.exit_code == 1 + assert "Empty password" in result.output + + +def test_login_playwright_missing_exits_1( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from easyatcal.auth_user import PlaywrightMissingError + + cfg = _write_user_config(tmp_path) + monkeypatch.setenv("EAW_PASSWORD", "pw") + + def boom(**_kw: object) -> None: + raise PlaywrightMissingError("install playwright") + + monkeypatch.setattr("easyatcal.auth_user.do_login", boom) + monkeypatch.setattr( + "easyatcal.cli.session_state_path", + lambda: tmp_path / "session.json", + ) + result = runner.invoke(app, ["--config-path", str(cfg), "login"]) + assert result.exit_code == 1 + assert "install playwright" in result.output + + +def test_login_failure_exits_2( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from easyatcal.auth_user import LoginError + + cfg = _write_user_config(tmp_path) + monkeypatch.setenv("EAW_PASSWORD", "pw") + + def boom(**_kw: object) -> None: + raise LoginError("bad creds") + + monkeypatch.setattr("easyatcal.auth_user.do_login", boom) + monkeypatch.setattr( + "easyatcal.cli.session_state_path", + lambda: tmp_path / "session.json", + ) + result = runner.invoke(app, ["--config-path", str(cfg), "login"]) + assert result.exit_code == 2 + assert "Login failed" in result.output + + +def test_logout_clears_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + storage = tmp_path / "session.json" + storage.write_text('{"cookies":[]}') + monkeypatch.setattr("easyatcal.cli.session_state_path", lambda: storage) + result = runner.invoke(app, ["logout"]) + assert result.exit_code == 0 + assert not storage.exists() diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..7f5e12d --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,74 @@ +from pathlib import Path + +from easyatcal.session import SessionStore + + +def test_round_trip(tmp_path: Path) -> None: + path = tmp_path / "sub" / "session.json" + store = SessionStore(path) + state = { + "cookies": [ + { + "name": "SESSION", + "value": "abc123", + "domain": "app.easyatwork.com", + "path": "/", + } + ], + "origins": [], + } + store.save(state) + + assert path.exists() + assert path.stat().st_mode & 0o777 == 0o600 + + loaded = store.load() + assert loaded == state + + +def test_load_missing_returns_none(tmp_path: Path) -> None: + assert SessionStore(tmp_path / "nope.json").load() is None + + +def test_load_corrupt_returns_none(tmp_path: Path) -> None: + p = tmp_path / "session.json" + p.write_text("not json") + assert SessionStore(p).load() is None + + +def test_cookies_for_httpx(tmp_path: Path) -> None: + store = SessionStore(tmp_path / "session.json") + store.save( + { + "cookies": [ + { + "name": "A", + "value": "1", + "domain": "app.easyatwork.com", + "path": "/", + }, + { + "name": "B", + "value": "2", + "domain": "app.easyatwork.com", + "path": "/", + }, + {"name": "", "value": "skip", "domain": "x", "path": "/"}, + ], + } + ) + jar = store.cookies() + assert jar is not None + assert jar.get("A", domain="app.easyatwork.com") == "1" + assert jar.get("B", domain="app.easyatwork.com") == "2" + + +def test_clear(tmp_path: Path) -> None: + p = tmp_path / "session.json" + store = SessionStore(p) + store.save({"cookies": []}) + assert p.exists() + store.clear() + assert not p.exists() + # clear on missing is noop + store.clear() From 48cb8b06ee560c71d510ead50aa126a07c8ca7b0 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:38:15 +0200 Subject: [PATCH 35/68] feat(auth): switch user-mode auth to Bearer JWT against regional API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit easy@work's real API lives on a regional host (`.api.easyatwork.com`) and accepts a Bearer JWT that the SPA obtains at login and stashes in `localStorage`. Cookies on `app.easyatwork.com` alone are not enough — every XHR carries the JWT. - `SessionStore.access_token()` scans Playwright's `storage_state` localStorage for JWT-shaped values. - `SessionEawClient` now takes `shifts_url` + sends `Authorization: Bearer …` plus SPA-mimicking headers (`Origin`, `Referer`, `X-Ui-Version`, cache-control). Laravel-style space-separated `from`/`to` datetimes, `with[]=schedule.customer` eager-load, `next_page_url` pagination. - Config replaces `shifts_endpoint` with `api_url`, `customer_id`, `employee_id`, `ui_version`; `shifts_url()` method composes the URL. - New env overrides `EAW_API_URL` / `EAW_CUSTOMER_ID` / `EAW_EMPLOYEE_ID`. - `_parse_dt` handles both ISO-8601 and Laravel's space-separated naive timestamps (treated as UTC). Tests, README, CHANGELOG, config.example.yaml all updated. 76 passed, 89% coverage, ruff + mypy-strict clean. --- CHANGELOG.md | 33 +++++-- README.md | 58 ++++++++---- config.example.yaml | 13 ++- easyatcal/api_session.py | 183 ++++++++++++++++++++++++-------------- easyatcal/cli.py | 7 +- easyatcal/config.py | 30 ++++++- easyatcal/session.py | 26 ++++++ tests/test_api_session.py | 116 ++++++++++++++++-------- tests/test_cli_login.py | 4 +- 9 files changed, 324 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35fed0b..3228d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,32 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] ### Added -- **Session-cookie auth via headless browser.** New `auth_mode: user` +- **Bearer-JWT auth via headless browser.** New `auth_mode: user` (now the default) drives a headless Chromium through the easy@work - web login, persists cookies to `~/.cache/easyatcal/session.json`, and - replays them on every HTTP call. `eaw-sync login` / `eaw-sync logout` + web login, persists Playwright `storage_state` to + `~/.cache/easyatcal/session.json`, and extracts the Bearer JWT the + SPA writes to `localStorage`. Every request to the regional API + (`.api.easyatwork.com`) replays the JWT as + `Authorization: Bearer …`. `eaw-sync login` / `eaw-sync logout` commands. Password never stored on disk — passes via `EAW_PASSWORD` - env or interactive prompt. -- `SessionEawClient` with flexible payload shape detection + env or interactive prompt. JWT lifetime ~1 year. +- New config fields `api_url`, `customer_id`, `employee_id`, + `ui_version`; shifts URL is built as + `{api_url}/customers/{customer_id}/employees/{employee_id}/shifts`. + `EAW_API_URL` / `EAW_CUSTOMER_ID` / `EAW_EMPLOYEE_ID` env overrides. +- `SessionEawClient` sends Laravel-style space-separated datetime + (`YYYY-MM-DD HH:MM:SS`) plus `with[]=schedule.customer` eager-load, + mimics SPA headers (`Origin`, `Referer`, `X-Ui-Version`, + `Cache-Control`). Flexible payload shape detection (`data` / `results` / `items` / `shifts` / bare list) and heuristic - field mapping (`id`/`uuid`/`shiftId`, `start`/`starts_at`/`from`, …). + field mapping (`id`/`uuid`/`shiftId`, `start`/`starts_at`/`from`, + `schedule.customer.name` for title, …). Laravel paginator + `next_page_url` honored. `_parse_dt` accepts both ISO-8601 and + Laravel `YYYY-MM-DD HH:MM:SS` (assumed UTC). Returns `AuthError` on HTTP 401 with a "run login" hint. -- `easyatcal.session.SessionStore` atomic 0600 cookie-jar persistence. +- `easyatcal.session.SessionStore` atomic 0600 cookie + localStorage + persistence; `access_token()` scans localStorage for JWT-shaped + values. - `easyatcal.auth_user.do_login` Playwright driver with configurable selectors (`email_selector`, `password_selector`, `submit_selector`) and `headless` toggle. @@ -27,8 +42,8 @@ All notable changes to this project are documented here. Format follows ### Changed - `doctor` and `auth test` now report session / OAuth status distinctly. - `ShiftFetcher` protocol gains `authenticate() -> object`. -- `shifts_endpoint` is blank by default; sync raises a clear - "inspect HAR" error until set. +- `api_url` / `customer_id` / `employee_id` required for user mode; + sync raises a clear error until all three set. ### Added - Structured log events in `run_sync` with `event_id` extra (`sync.fetch.ok`, diff --git a/README.md b/README.md index eea478a..3008200 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,21 @@ Python 3.11+. macOS for EventKit; any OS for ICS. easy@work has no public developer API. The default auth mode (`user`) logs a headless Chromium instance into `app.easyatwork.com` with your real -credentials, captures the session cookies, and reuses them for all -subsequent HTTP calls. Your password is never stored on disk — it lives -only in the `EAW_PASSWORD` env var (or is prompted interactively). +credentials, captures the session (cookies **and** the Bearer JWT the +SPA writes to `localStorage`), and replays the JWT as +`Authorization: Bearer …` on every request to the regional API host +(e.g. `eu-west-3.api.easyatwork.com`). Your password is never stored on +disk — it lives only in the `EAW_PASSWORD` env var (or is prompted +interactively). ```bash -EAW_PASSWORD='...' eaw-sync login # persists cookies; do once, or when expired -eaw-sync logout # wipe cookies +EAW_PASSWORD='...' eaw-sync login # persists storage_state; do once per year (JWT ~1y) +eaw-sync logout # wipe session ``` -Cookies are written to `~/.cache/easyatcal/session.json` (0600) via -Playwright's `storage_state`. +`storage_state` is written to `~/.cache/easyatcal/session.json` (0600) +via Playwright. The JWT is extracted from it at request time — no +separate token file. An alternate `client` mode (OAuth client_credentials) is scaffolded in the code for forward-compat — if easy@work ever publishes an API, flip @@ -50,13 +54,27 @@ the code for forward-compat — if easy@work ever publishes an API, flip ```bash eaw-sync config init # scaffold config -$EDITOR ~/.config/easyatcal/config.yaml # set email, app_url, shifts_endpoint -EAW_PASSWORD='...' eaw-sync login # one-time headless login +$EDITOR ~/.config/easyatcal/config.yaml # set email, api_url, customer_id, employee_id +EAW_PASSWORD='...' eaw-sync login # one-time headless login (JWT ~1y) eaw-sync doctor # check config + session + backend eaw-sync sync # one shot eaw-sync watch --interval-seconds 900 # loop every 15 min ``` +### Discovering your `customer_id` + `employee_id` + +Open DevTools → Network in `app.easyatwork.com`, filter XHR, load your +schedule. The request URL contains both: + +``` +https://eu-west-3.api.easyatwork.com/customers/2571/employees/1464727/shifts?from=… + ^^^^ ^^^^^^^ + customer_id employee_id +``` + +`api_url` is the scheme + host (`https://eu-west-3.api.easyatwork.com`). +The region segment varies per tenant. + ## Configure Minimal `config.yaml` (user mode): @@ -67,7 +85,10 @@ easyatwork: email: "me@example.com" login_url: "https://app.easyatwork.com/" app_url: "https://app.easyatwork.com" - shifts_endpoint: "/api/v1/shifts" # capture from DevTools → Network + api_url: "https://eu-west-3.api.easyatwork.com" # region host from DevTools + customer_id: 2571 # from the shifts URL + employee_id: 1464727 # from the shifts URL + ui_version: "2.313.0" # mimic SPA header sync: lookback_days: 7 @@ -135,17 +156,16 @@ eaw-sync --install-completion # bash / zsh / fish ## Known limitations - **No public API.** easy@work does not publish developer docs. The - default `user` auth mode scrapes the web app via Playwright and reuses - its session cookies, which works against the real tenant but depends - on the SPA's private endpoints. -- **`shifts_endpoint` is tenant-specific.** Open - `app.easyatwork.com` in a browser, DevTools → Network, filter `XHR`, - open your schedule view, find the JSON request that returns your - shifts, copy its path to `easyatwork.shifts_endpoint`. Session-mode + default `user` auth mode scrapes the web app via Playwright, extracts + the Bearer JWT the SPA stores in `localStorage`, and replays it + against the regional API host. Depends on the SPA's private + endpoints — if they change shape, parsing may need a tweak. +- **`api_url` / `customer_id` / `employee_id` are tenant-specific.** + See *Discovering your customer_id + employee_id* above. Payload parsing auto-detects common shapes (`{"data": [...]}`, `{"results": [...]}`, bare list, `id`/`uuid`/`shiftId`, - `start`/`starts_at`/`from`, etc) — unexpected shapes raise `ApiError` - with the observed top-level keys. + `start`/`starts_at`/`from`, `schedule.customer.name`, etc) — + unexpected shapes raise `ApiError` with the observed top-level keys. - **OAuth (`client` mode) is unverified.** Kept in-tree for forward-compat should easy@work publish a developer API, but there is no public reference client (`php-eaw-client` does not exist as a public repo). diff --git a/config.example.yaml b/config.example.yaml index 898ce35..502ae09 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -10,10 +10,15 @@ easyatwork: # or enter it interactively when prompted. login_url: "https://app.easyatwork.com/" app_url: "https://app.easyatwork.com" - # Path the SPA uses to load your shifts. Inspect DevTools → Network while - # you open the schedule view, find the JSON request, paste the path here. - # Leave blank until you know it — sync will raise a clear error. - shifts_endpoint: "" # e.g. "/api/v1/shifts" + # Regional API host the SPA talks to. Open DevTools → Network → any XHR + # while you view your schedule. The request URL looks like: + # https://.api.easyatwork.com/customers//employees//shifts + # Paste the region host + your two IDs here. + api_url: "https://eu-west-3.api.easyatwork.com" + customer_id: 0 # e.g. 2571 — from the URL above + employee_id: 0 # e.g. 1464727 — from the URL above + # Mimic the SPA version header. Bump if the API starts rejecting. + ui_version: "2.313.0" # Selectors for the login form. Override if the defaults don't match. # email_selector: "input[type='email']" # password_selector: "input[type='password']" diff --git a/easyatcal/api_session.py b/easyatcal/api_session.py index cd71755..0275471 100644 --- a/easyatcal/api_session.py +++ b/easyatcal/api_session.py @@ -13,11 +13,21 @@ class SessionEawClient: - """Fetches shifts against the easy@work web app using persisted - browser cookies (from ``eaw-sync login``). + """Fetches shifts against the regional easy@work API using the JWT + the SPA obtains at login. - Endpoint is tenant-specific. Set ``shifts_endpoint`` on the config - to the path the web app hits (look in DevTools → Network). + URL shape observed in the wild (EU-West-3 tenant):: + + GET https://eu-west-3.api.easyatwork.com + /customers/{customer_id}/employees/{employee_id}/shifts + ?from=YYYY-MM-DD HH:MM:SS + &order_by=from&direction=asc + &with[]=schedule.customer + Authorization: Bearer + Origin: https://app.easyatwork.com + + The JWT is extracted from the Playwright ``storage_state``'s + localStorage (populated by ``eaw-sync login``). """ _MAX_RETRIES = 5 @@ -25,65 +35,71 @@ class SessionEawClient: def __init__( self, *, - app_url: str, - shifts_endpoint: str, + shifts_url: str, session_store: SessionStore, + origin: str = "https://app.easyatwork.com", + ui_version: str = "2.313.0", timeout: float = 30.0, ) -> None: - self.app_url = app_url.rstrip("/") - self.shifts_endpoint = shifts_endpoint + self.shifts_url = shifts_url self.session_store = session_store + self.origin = origin.rstrip("/") + self.ui_version = ui_version self._http = httpx.Client(timeout=timeout) + self._token: str | None = None def authenticate(self) -> None: - cookies = self.session_store.cookies() - if cookies is None: + token = self.session_store.access_token() + if token is None: raise AuthError( - "No session cookies found. Run `eaw-sync login` first." + "No access token in stored session. Run `eaw-sync login`." ) - self._http.cookies = cookies - self._http.headers.update({ - "Accept": "application/json", - "X-Requested-With": "XMLHttpRequest", - }) + self._token = token + self._http.headers.update( + { + "Authorization": f"Bearer {token}", + "Accept": "application/json, text/plain, */*", + "Origin": self.origin, + "Referer": f"{self.origin}/", + "X-Ui-Version": self.ui_version, + "Cache-Control": "no-cache", + "Pragma": "no-cache", + } + ) def fetch_shifts( self, from_date: date, to_date: date, - user_id: str | None = None, + user_id: str | None = None, # kept for ShiftFetcher protocol ) -> list[Shift]: - if not self.shifts_endpoint: - raise ApiError( - "easyatwork.shifts_endpoint is blank. Capture a HAR from " - "the web app schedule view, find the request that returns " - "your shifts, and set that path (e.g. '/api/v1/shifts') " - "in the config." - ) self.authenticate() - url: str | None = self._absolute(self.shifts_endpoint) - first_params: dict[str, str] = { - "from": from_date.isoformat(), - "to": to_date.isoformat(), - } - if user_id is not None: - first_params["user_id"] = user_id - params: dict[str, str] | None = first_params - + # easy@work wants space-separated "YYYY-MM-DD HH:MM:SS". httpx + # URL-encodes the space as %20 automatically. + from_str = f"{from_date.isoformat()} 00:00:00" + to_str = f"{to_date.isoformat()} 23:59:59" + + # httpx accepts sequences for repeated params: `with[]=schedule.customer` + params: list[tuple[str, str | int | float | bool | None]] = [ + ("from", from_str), + ("to", to_str), + ("order_by", "from"), + ("direction", "asc"), + ("with[]", "schedule.customer"), + ] + + url: str | None = self.shifts_url + first = True out: list[Shift] = [] while url is not None: - r = self._retry_get(url, params) + r = self._retry_get(url, params if first else None) + first = False try: payload = r.json() for raw in _iter_rows(payload): out.append(_parse_shift(raw)) - next_url = _next_url(payload) - if next_url: - url = next_url if next_url.startswith("http") else self._absolute(next_url) - params = None - else: - url = None + url = _next_url(payload) except (KeyError, TypeError, ValueError) as e: raise ApiError( f"Unexpected session API response shape. Parse error: {e}. " @@ -92,17 +108,10 @@ def fetch_shifts( ) from e return out - def _absolute(self, path: str) -> str: - if path.startswith("http"): - return path - if not path.startswith("/"): - path = "/" + path - return f"{self.app_url}{path}" - def _retry_get( self, url: str, - params: dict[str, str] | None, + params: list[tuple[str, str | int | float | bool | None]] | None, ) -> httpx.Response: attempts = 0 while True: @@ -111,8 +120,8 @@ def _retry_get( return r if r.status_code == 401: raise AuthError( - "Session cookies rejected (HTTP 401). " - "Run `eaw-sync login` to refresh." + "Access token rejected (HTTP 401). " + "Token probably expired — run `eaw-sync login`." ) if r.status_code in (429, 500, 502, 503, 504): attempts += 1 @@ -132,11 +141,14 @@ def _retry_get( def _iter_rows(payload: Any) -> list[dict[str, Any]]: - """Accept common paginated shapes until we pin the real one: - - {"data": [...], "next": ...} - - {"results": [...], "next": ...} - - {"items": [...]} - - [...] (bare list) + """Accept common Laravel/DRF paginated shapes until we pin the real + one: + + - ``{"data": [...], "next_page_url": ...}`` (Laravel paginator) + - ``{"data": [...], "meta": {...}}`` (Laravel resource) + - ``{"results": [...], "next": ...}`` (DRF) + - ``{"items": [...]}`` / ``{"shifts": [...]}`` + - ``[...]`` (bare list) """ if isinstance(payload, list): return payload @@ -151,11 +163,14 @@ def _iter_rows(payload: Any) -> list[dict[str, Any]]: def _next_url(payload: Any) -> str | None: if not isinstance(payload, dict): return None + # Laravel paginator + v = payload.get("next_page_url") + if isinstance(v, str) and v: + return v for key in ("next", "next_url", "nextPage"): v = payload.get(key) if isinstance(v, str) and v: return v - # DRF-style nested links = payload.get("links") if isinstance(links, dict): v = links.get("next") @@ -165,10 +180,18 @@ def _next_url(payload: Any) -> str | None: def _parse_shift(raw: dict[str, Any]) -> Shift: - """Best-effort mapping until we know the real field names. - - Tries a handful of common spellings. Override once HAR is captured. + """Best-effort mapping. Accepts a handful of common field spellings. + + Observed so far (will expand once a response body is available): + - id: ``id`` / ``uuid`` / ``shiftId`` + - start: ``start`` / ``starts_at`` / ``from`` / ``start_date`` + - end: ``end`` / ``ends_at`` / ``to`` / ``end_date`` + - updated_at: ``updated_at`` / ``updatedAt`` / ``modified_at`` + - title: ``title`` / ``name`` / ``label`` / nested + ``schedule.customer.name`` (via `with[]=schedule.customer`) + - location: ``location`` / ``place`` / ``site`` """ + def pick(*keys: str) -> Any: for k in keys: if k in raw and raw[k] is not None: @@ -176,8 +199,8 @@ def pick(*keys: str) -> Any: return None id_val = pick("id", "uuid", "shiftId") - start_val = pick("start", "starts_at", "startDate", "startTime", "from") - end_val = pick("end", "ends_at", "endDate", "endTime", "to") + start_val = pick("start", "starts_at", "from", "start_date", "startTime") + end_val = pick("end", "ends_at", "to", "end_date", "endTime") updated_val = pick("updated_at", "updatedAt", "modified_at", "modifiedAt") if id_val is None or start_val is None or end_val is None: @@ -185,14 +208,40 @@ def pick(*keys: str) -> Any: f"shift row missing id/start/end; keys present: {list(raw)}" ) + # Title: prefer schedule.customer.name if included (matches `with[]`) + title = pick("title", "name", "label") + if title is None: + schedule = raw.get("schedule") + if isinstance(schedule, dict): + customer = schedule.get("customer") + if isinstance(customer, dict): + title = customer.get("name") + if not title: + title = "Shift" + return Shift( id=str(id_val), - start=datetime.fromisoformat(str(start_val)), - end=datetime.fromisoformat(str(end_val)), - title=pick("title", "name", "label") or "Shift", + start=_parse_dt(str(start_val)), + end=_parse_dt(str(end_val)), + title=str(title), location=pick("location", "place", "site"), notes=pick("notes", "note", "comment"), - updated_at=datetime.fromisoformat( - str(updated_val or start_val) - ), + updated_at=_parse_dt(str(updated_val or start_val)), ) + + +def _parse_dt(s: str) -> datetime: + """Accept both ISO-8601 (``2026-04-20T09:00:00+00:00``) and + Laravel-style (``2026-04-20 09:00:00``) timestamps. Naive values + are treated as UTC — the easy@work API sends tenant-local + timestamps without an offset. + """ + from datetime import UTC + + try: + dt = datetime.fromisoformat(s) + except ValueError: + dt = datetime.fromisoformat(s.replace(" ", "T")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt diff --git a/easyatcal/cli.py b/easyatcal/cli.py index f8f2539..19fd7cc 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -104,11 +104,12 @@ def _build_api_client(cfg: Config) -> ShiftFetcher: base_url=cfg.easyatwork.base_url, token_cache=token_cache_path(), ) - # auth_mode == "user" — session-cookie mode + # auth_mode == "user" — JWT Bearer mode (token from localStorage) return SessionEawClient( - app_url=cfg.easyatwork.app_url, - shifts_endpoint=cfg.easyatwork.shifts_endpoint, + shifts_url=cfg.easyatwork.shifts_url(), session_store=SessionStore(session_state_path()), + origin=cfg.easyatwork.app_url, + ui_version=cfg.easyatwork.ui_version, ) diff --git a/easyatcal/config.py b/easyatcal/config.py index d543216..0e31d09 100644 --- a/easyatcal/config.py +++ b/easyatcal/config.py @@ -30,9 +30,17 @@ class EasyAtWorkAuth(BaseModel): email: str | None = None login_url: str = "https://app.easyatwork.com/" app_url: str = "https://app.easyatwork.com" - # Which endpoint to hit for shifts once logged in. - # Blank → client raises on sync, prompting HAR inspection. - shifts_endpoint: str = "" + # Regional API host that the SPA talks to. Seen in the wild: + # "https://eu-west-3.api.easyatwork.com". Inspect DevTools → Network + # → any XHR for your tenant's region. + api_url: str = "" + # Per-user identifiers embedded in every shifts URL. + # Shape: /customers/{customer_id}/employees/{employee_id}/shifts + customer_id: int | None = None + employee_id: int | None = None + # Mimic the SPA's X-Ui-Version header (otherwise the API is fine + # without it, but setting it lowers the chance of anti-bot blocks). + ui_version: str = "2.313.0" # Playwright login form selectors (override per tenant if the form # layout differs). email_selector: str = "input[type='email'], input[name='email'], input[name='username']" @@ -55,6 +63,18 @@ def _check_mode_fields(self) -> EasyAtWorkAuth: raise ValueError("auth_mode=user requires email") return self + def shifts_url(self) -> str: + """Fully-qualified base URL of the shifts collection for this user.""" + if not self.api_url or not self.customer_id or not self.employee_id: + raise ValueError( + "auth_mode=user requires api_url, customer_id, employee_id " + "to build the shifts URL. Capture a HAR from the web app." + ) + return ( + f"{self.api_url.rstrip('/')}/customers/{self.customer_id}" + f"/employees/{self.employee_id}/shifts" + ) + class SyncSettings(BaseModel): lookback_days: int = Field(ge=0, default=7) @@ -103,7 +123,9 @@ def validate_backend(cls, v: str) -> str: "EAW_EMAIL": ("easyatwork", "email"), "EAW_LOGIN_URL": ("easyatwork", "login_url"), "EAW_APP_URL": ("easyatwork", "app_url"), - "EAW_SHIFTS_ENDPOINT": ("easyatwork", "shifts_endpoint"), + "EAW_API_URL": ("easyatwork", "api_url"), + "EAW_CUSTOMER_ID": ("easyatwork", "customer_id"), + "EAW_EMPLOYEE_ID": ("easyatwork", "employee_id"), } diff --git a/easyatcal/session.py b/easyatcal/session.py index a3eea35..e2d1f09 100644 --- a/easyatcal/session.py +++ b/easyatcal/session.py @@ -70,3 +70,29 @@ def cookies(self) -> httpx.Cookies | None: def clear(self) -> None: with contextlib.suppress(FileNotFoundError): self.path.unlink() + + def access_token(self) -> str | None: + """Scan persisted localStorage for a JWT-looking value. + + easy@work's Angular SPA puts the bearer token in localStorage + under a key like ``access_token`` or ``token``. We accept any + value that looks like a JWT (three dot-separated segments). + """ + state = self.load() + if state is None: + return None + for origin in state.get("origins", []): + for entry in origin.get("localStorage", []): + name = entry.get("name") or "" + value = entry.get("value") or "" + if not isinstance(value, str): + continue + # Prefer keys whose names smell like a token. + name_hints = ("access_token", "token", "jwt", "bearer") + looks_like_jwt = value.count(".") == 2 and len(value) > 40 + if looks_like_jwt and ( + any(h in name.lower() for h in name_hints) + or value.startswith("ey") + ): + return value + return None diff --git a/tests/test_api_session.py b/tests/test_api_session.py index 9309a17..4cc2b7c 100644 --- a/tests/test_api_session.py +++ b/tests/test_api_session.py @@ -5,53 +5,45 @@ import pytest import respx -from easyatcal.api import ApiError, AuthError +from easyatcal.api import AuthError from easyatcal.api_session import SessionEawClient, _iter_rows, _parse_shift from easyatcal.session import SessionStore +# Fake JWT: three dot-separated segments starting with "ey". +FAKE_JWT = "eyhdr." + ("x" * 40) + ".sig" +SHIFTS_URL = "https://eu-west-3.api.easyatwork.com/customers/1/employees/2/shifts" -def _seeded_store(tmp_path: Path) -> SessionStore: + +def _seeded_store(tmp_path: Path, token: str = FAKE_JWT) -> SessionStore: store = SessionStore(tmp_path / "session.json") store.save( { - "cookies": [ + "cookies": [], + "origins": [ { - "name": "SESSION", - "value": "abc", - "domain": "app.easyatwork.com", - "path": "/", + "origin": "https://app.easyatwork.com", + "localStorage": [ + {"name": "access_token", "value": token}, + ], } - ] + ], } ) return store -def test_missing_endpoint_raises(tmp_path: Path) -> None: - client = SessionEawClient( - app_url="https://app.easyatwork.com", - shifts_endpoint="", - session_store=_seeded_store(tmp_path), - ) - with pytest.raises(ApiError, match="shifts_endpoint"): - client.fetch_shifts( - from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) - ) - - -def test_no_session_cookies_raises_authenticate(tmp_path: Path) -> None: +def test_no_token_raises_authenticate(tmp_path: Path) -> None: client = SessionEawClient( - app_url="https://app.easyatwork.com", - shifts_endpoint="/api/shifts", + shifts_url=SHIFTS_URL, session_store=SessionStore(tmp_path / "missing.json"), ) - with pytest.raises(AuthError, match="No session cookies"): + with pytest.raises(AuthError, match="No access token"): client.authenticate() @respx.mock def test_fetch_shifts_happy_path(tmp_path: Path) -> None: - respx.get("https://app.easyatwork.com/api/shifts").mock( + respx.get(SHIFTS_URL).mock( return_value=httpx.Response( 200, json={ @@ -65,13 +57,12 @@ def test_fetch_shifts_happy_path(tmp_path: Path) -> None: "updated_at": "2026-04-18T10:00:00+00:00", } ], - "next": None, + "next_page_url": None, }, ) ) client = SessionEawClient( - app_url="https://app.easyatwork.com", - shifts_endpoint="/api/shifts", + shifts_url=SHIFTS_URL, session_store=_seeded_store(tmp_path), ) shifts = client.fetch_shifts( @@ -83,16 +74,37 @@ def test_fetch_shifts_happy_path(tmp_path: Path) -> None: @respx.mock -def test_fetch_shifts_401_raises_auth_error(tmp_path: Path) -> None: - respx.get("https://app.easyatwork.com/api/shifts").mock( - return_value=httpx.Response(401) +def test_fetch_shifts_sends_bearer_and_laravel_params(tmp_path: Path) -> None: + route = respx.get(SHIFTS_URL).mock( + return_value=httpx.Response(200, json={"data": []}) ) client = SessionEawClient( - app_url="https://app.easyatwork.com", - shifts_endpoint="/api/shifts", + shifts_url=SHIFTS_URL, + session_store=_seeded_store(tmp_path), + ) + client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + req = route.calls.last.request + assert req.headers["Authorization"] == f"Bearer {FAKE_JWT}" + assert req.headers["X-Ui-Version"] == "2.313.0" + # Space-separated Laravel datetime (url-encoded as %20 or +) + qs = req.url.query.decode() + assert "from=2026-04-20" in qs and "00%3A00%3A00" in qs + assert "to=2026-04-27" in qs and "23%3A59%3A59" in qs + assert "order_by=from" in qs + assert "direction=asc" in qs + assert "with%5B%5D=schedule.customer" in qs + + +@respx.mock +def test_fetch_shifts_401_raises_auth_error(tmp_path: Path) -> None: + respx.get(SHIFTS_URL).mock(return_value=httpx.Response(401)) + client = SessionEawClient( + shifts_url=SHIFTS_URL, session_store=_seeded_store(tmp_path), ) - with pytest.raises(AuthError, match="cookies rejected"): + with pytest.raises(AuthError, match="Token probably expired"): client.fetch_shifts( from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) ) @@ -100,14 +112,14 @@ def test_fetch_shifts_401_raises_auth_error(tmp_path: Path) -> None: @respx.mock def test_fetch_shifts_accepts_bare_list_and_flexible_keys(tmp_path: Path) -> None: - respx.get("https://app.easyatwork.com/api/v2/schedules").mock( + respx.get(SHIFTS_URL).mock( return_value=httpx.Response( 200, json=[ { "uuid": "sh-9", - "starts_at": "2026-04-21T09:00:00+00:00", - "ends_at": "2026-04-21T17:00:00+00:00", + "starts_at": "2026-04-21 09:00:00", + "ends_at": "2026-04-21 17:00:00", "name": "Evening", "place": "Bergen", "updatedAt": "2026-04-19T10:00:00+00:00", @@ -116,8 +128,7 @@ def test_fetch_shifts_accepts_bare_list_and_flexible_keys(tmp_path: Path) -> Non ) ) client = SessionEawClient( - app_url="https://app.easyatwork.com", - shifts_endpoint="/api/v2/schedules", + shifts_url=SHIFTS_URL, session_store=_seeded_store(tmp_path), ) shifts = client.fetch_shifts( @@ -128,6 +139,33 @@ def test_fetch_shifts_accepts_bare_list_and_flexible_keys(tmp_path: Path) -> Non assert shifts[0].location == "Bergen" +@respx.mock +def test_parse_shift_prefers_schedule_customer_name(tmp_path: Path) -> None: + respx.get(SHIFTS_URL).mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "id": 42, + "start": "2026-04-22T09:00:00+00:00", + "end": "2026-04-22T17:00:00+00:00", + "schedule": {"customer": {"name": "Acme Corp"}}, + } + ] + }, + ) + ) + client = SessionEawClient( + shifts_url=SHIFTS_URL, + session_store=_seeded_store(tmp_path), + ) + shifts = client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + assert shifts[0].title == "Acme Corp" + + def test_iter_rows_shapes() -> None: assert _iter_rows([{"a": 1}]) == [{"a": 1}] assert _iter_rows({"data": [{"a": 1}]}) == [{"a": 1}] diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py index 8367cfd..bc4cbe6 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -17,7 +17,9 @@ def _write_user_config(tmp_path: Path) -> Path: email: me@example.com login_url: https://app.easyatwork.com/ app_url: https://app.easyatwork.com - shifts_endpoint: "" + api_url: https://eu-west-3.api.easyatwork.com + customer_id: 1 + employee_id: 2 backend: ics backends: ics: From 8d90cf41a8ad35df1b13d580f133d0204579188e Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:16:54 +0200 Subject: [PATCH 36/68] docs: refactor interactive CLI prompts and README language --- HANDOFF.md | 16 +- README.md | 229 ++++++------------ config.example.yaml | 23 +- .../2026-04-19-easyatcal-implementation.md | 209 ++++++++-------- .../specs/2026-04-19-easyatcal-design.md | 28 ++- easyatcal/cli.py | 34 +++ 6 files changed, 265 insertions(+), 274 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 6495fdc..ee124dc 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -39,6 +39,9 @@ Claude Code stores the todo list inline in the transcript as TodoWrite tool call - `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/orchestrator.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/cli.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/paths.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/api_session.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/session.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/auth_user.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/logging_setup.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/__init__.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/easyatcal/backends/base.py` @@ -61,6 +64,9 @@ Claude Code stores the todo list inline in the transcript as TodoWrite tool call - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_config_path.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_sync.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_auth.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_login.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_api_session.py` +- `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_session.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_doctor.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_cli_state.py` - `/Users/ailcope/ClaudeCode/EasyAtWork/tests/test_logging_setup.py` @@ -82,7 +88,15 @@ Claude Code stores the todo list inline in the transcript as TodoWrite tool call - 53 tests passing locally (Python 3.12 on macOS). EventKit tests skipped on Linux in CI. - Coverage gate: CI fails under 85% (see `.github/workflows/ci.yml`). - Ruff clean; `.pre-commit-config.yaml` wires ruff + ruff-format + whitespace hooks. -- Remaining wiring work for a real user: run `eaw-sync config init`, fill in real easy@work OAuth credentials, pick `ics` or `eventkit` backend, then `eaw-sync sync` (or `eaw-sync doctor` first). +- Remaining wiring work for a real user: run `eaw-sync config init`, fill in real easy@work parameters (`customer_id`, `employee_id`), run `eaw-sync login` to generate the session JWT via headless Playwright, pick `ics` or `eventkit` backend, then `eaw-sync sync` (or `eaw-sync doctor` first). + +## Auth Narrative Pivot + +**Critical context:** We pivoted away from pure OAuth `client_credentials`. +Authentication is now handled via **JWT Bearer** token extracted from Playwright's `localStorage` after a headless UI login. The token is replayed against `.api.easyatwork.com/customers/{cid}/employees/{eid}/shifts`. +- *Commit Ref:* `48cb8b0` (JWT pivot) and `323f338` (session-cookie pivot intermediate). +- No refresh flow is implemented: JWT expires in ~1y; users must rerun `eaw-sync login` when a 401 occurs. +- `auth_user.py` uses Playwright to capture this token. It needs a live Playwright run for smoke verification before claiming absolute production-readiness. ## What was added beyond the original 19-task plan diff --git a/README.md b/README.md index 3008200..5a5d48d 100644 --- a/README.md +++ b/README.md @@ -6,202 +6,119 @@ [![Python](https://img.shields.io/pypi/pyversions/easyatcal.svg)](https://pypi.org/project/easyatcal/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -One-way sync of [easy@work](https://www.easyatwork.com) shifts into Apple -Calendar. Run it on a Mac, iCloud fans out to iPhone/iPad/Watch. +A CLI tool for syncing your [easy@work](https://www.easyatwork.com) shifts into Apple Calendar, Google Calendar, or standard ICS files. -- Read-only against easy@work; never writes back. -- Two backends: native macOS **EventKit** (recommended) or portable **ICS** file. -- State-tracked: unchanged shifts are skipped; edits and deletions propagate. -- Open-source friendly: code is public, your `config.yaml` / `state.json` stay - local (see `.gitignore`). +It runs locally, fetches your upcoming shifts, and pushes them to your preferred calendar app. It can also be run as a daemon to keep your calendar up to date in the background. -## Install +* **Automated Login**: No public API required. It uses Playwright to securely log in via a headless browser and extract a session token. +* **Two Backends**: Native macOS **EventKit** integration (pushes directly to Apple Calendar) or portable **ICS** file generation (supports interactive import prompts for Google Calendar and Windows Outlook). +* **Idempotent**: State-tracked logic means unchanged shifts are skipped, while schedule updates and cancellations propagate automatically. +* **Bilingual CLI**: Automatically detects English or French system locales and adjusts interactive prompts. + +--- + +## Quickstart + +### 1. Install + +Install the core application and the Playwright browser dependencies (required for headless login). ```bash -pip install easyatcal # core + ICS backend -pip install 'easyatcal[eventkit]' # + macOS EventKit backend -pip install 'easyatcal[playwright]' # + headless-browser login (default auth) -playwright install chromium # one-time ~200 MB browser download +pip install 'easyatcal[playwright]' +playwright install chromium ``` +*(If you are on macOS and want native Apple Calendar integration, use `pip install 'easyatcal[eventkit,playwright]'`)* -Python 3.11+. macOS for EventKit; any OS for ICS. - -## Authentication +### 2. Configure -easy@work has no public developer API. The default auth mode (`user`) logs -a headless Chromium instance into `app.easyatwork.com` with your real -credentials, captures the session (cookies **and** the Bearer JWT the -SPA writes to `localStorage`), and replays the JWT as -`Authorization: Bearer …` on every request to the regional API host -(e.g. `eu-west-3.api.easyatwork.com`). Your password is never stored on -disk — it lives only in the `EAW_PASSWORD` env var (or is prompted -interactively). +Scaffold the default configuration file: ```bash -EAW_PASSWORD='...' eaw-sync login # persists storage_state; do once per year (JWT ~1y) -eaw-sync logout # wipe session +eaw-sync config init ``` -`storage_state` is written to `~/.cache/easyatcal/session.json` (0600) -via Playwright. The JWT is extracted from it at request time — no -separate token file. +Now, open the configuration file (located at `~/.config/easyatcal/config.yaml` on Linux or `~/Library/Application Support/easyatcal/config.yaml` on macOS) and fill in your details: -An alternate `client` mode (OAuth client_credentials) is scaffolded in -the code for forward-compat — if easy@work ever publishes an API, flip -`auth_mode: client` and fill in the OAuth creds. +```yaml +easyatwork: + email: "your.email@example.com" + api_url: "https://eu-west-3.api.easyatwork.com" # Check your DevTools for your specific region + customer_id: 1234 # Found in DevTools URL + employee_id: 1234567 # Found in DevTools URL +``` -## Quickstart +> **How to find your `customer_id` and `employee_id`:** +> 1. Open your browser and log in to [app.easyatwork.com](https://app.easyatwork.com). +> 2. Open Developer Tools (F12) -> Go to the **Network** tab. +> 3. Click on your schedule. Look for a network request starting with `shifts?from=...` +> 4. Look at the URL of that request: `https://eu-west-3.api.easyatwork.com/customers//employees//shifts` + +### 3. Log In + +Run the interactive login command. It prompts securely for your password, launches a headless Chromium browser, logs you in, and saves your session token securely. ```bash -eaw-sync config init # scaffold config -$EDITOR ~/.config/easyatcal/config.yaml # set email, api_url, customer_id, employee_id -EAW_PASSWORD='...' eaw-sync login # one-time headless login (JWT ~1y) -eaw-sync doctor # check config + session + backend -eaw-sync sync # one shot -eaw-sync watch --interval-seconds 900 # loop every 15 min +eaw-sync login ``` -### Discovering your `customer_id` + `employee_id` +### 4. Sync Your Calendar -Open DevTools → Network in `app.easyatwork.com`, filter XHR, load your -schedule. The request URL contains both: +Run the sync command. If you are using the default `.ics` backend, it will download your shifts and interactively ask if you want to open Apple Calendar, Windows Outlook, or Google Calendar to complete the import. +```bash +eaw-sync sync ``` -https://eu-west-3.api.easyatwork.com/customers/2571/employees/1464727/shifts?from=… - ^^^^ ^^^^^^^ - customer_id employee_id -``` - -`api_url` is the scheme + host (`https://eu-west-3.api.easyatwork.com`). -The region segment varies per tenant. -## Configure +## Background Sync -Minimal `config.yaml` (user mode): +To keep your calendar up to date continuously, run EasyAtCal in daemon mode: -```yaml -easyatwork: - auth_mode: user - email: "me@example.com" - login_url: "https://app.easyatwork.com/" - app_url: "https://app.easyatwork.com" - api_url: "https://eu-west-3.api.easyatwork.com" # region host from DevTools - customer_id: 2571 # from the shifts URL - employee_id: 1464727 # from the shifts URL - ui_version: "2.313.0" # mimic SPA header - -sync: - lookback_days: 7 - lookahead_days: 90 - -backend: eventkit # or "ics" - -backends: - eventkit: - calendar_name: "Work Shifts" # must exist in Calendar.app - calendar_source: "iCloud" - ics: - output_path: "~/Documents/easyatwork-shifts.ics" - -logging: - level: INFO +```bash +eaw-sync watch --interval-seconds 900 # Syncs every 15 minutes ``` -Env overrides: any `easyatwork.*` field is overridable via `EAW_*` (e.g. -`EAW_CLIENT_SECRET`). +*Note: Your login token expires roughly once a year. If the daemon starts failing with authentication errors, simply run `eaw-sync login` again.* ## Backends -**EventKit (macOS).** Writes directly to a dedicated calendar in Calendar.app. +### 1. ICS (Cross-platform) +Generates a portable `.ics` file locally. When you run `eaw-sync sync`, the CLI interactively offers to open your local calendar app or open the Google Calendar import page. -**IMPORTANT:** You must create the target calendar manually *before* your first sync! -1. Open **Calendar.app** -2. Go to **File → New Calendar** and choose the source (e.g., `iCloud`) -3. Name it exactly what you put in your config (e.g., "Work Shifts") -4. Run `eaw-sync sync`. It will trigger a macOS permission prompt. -5. Grant access when prompted (or in *System Settings → Privacy & Security → Calendars*). +### 2. EventKit (macOS Only) +Writes directly to a dedicated calendar in the macOS Calendar.app via native APIs. -**ICS.** Writes a single `.ics` file. Subscribe to it from Calendar.app (or any -calendar client) via `File → New Calendar Subscription`. Portable, no -permissions needed. +**IMPORTANT:** You must create the target calendar manually *before* your first sync. +1. Open **Calendar.app**. +2. Go to **File → New Calendar** and choose the source (e.g., `iCloud`). +3. Name it exactly what you put in your config (e.g., `EasyAtWork`). +4. Update your config: set `backend: eventkit`. +5. Run `eaw-sync sync`. +6. Grant calendar access when macOS prompts you. -## Commands +## CLI Commands -| Command | What | +| Command | Description | |---------|------| -| `eaw-sync config init` | Scaffold config file. | -| `eaw-sync config show` | Print effective config (secrets redacted). | -| `eaw-sync auth test` | Verify credentials can obtain a token. | -| `eaw-sync doctor` | Full preflight: config loads, auth works, backend reachable. | -| `eaw-sync state show` | Print local state path, tracked-shift count, last sync. | -| `eaw-sync sync [--dry-run]` | Run one sync pass and exit. | -| `eaw-sync watch --interval-seconds N` | Loop until Ctrl-C / SIGTERM. | - -Global flag: `--config-path PATH` overrides the default config location. +| `eaw-sync config init` | Scaffold the configuration file. | +| `eaw-sync config show` | Print active configuration (secrets redacted). | +| `eaw-sync login` | Opens a headless browser to log in and save your session token. | +| `eaw-sync doctor` | Checks config validity, token liveliness, and API reachability. | +| `eaw-sync sync` | Run a single sync pass. | +| `eaw-sync sync --dry-run` | Diff remote shifts against local state without writing. | +| `eaw-sync watch` | Run the sync in an infinite loop. | +| `eaw-sync --install-completion` | Install shell autocomplete (bash/zsh/fish). | ### Exit codes (`sync`) | Code | Meaning | |------|---------| -| 0 | All changes applied. | -| 1 | Partial failure — some changes applied, state persisted, backend errored. | -| 2 | Fatal — config/auth/network failed before any change was written. | - -### Shell completions - -```bash -eaw-sync --install-completion # bash / zsh / fish -``` - -## Known limitations - -- **No public API.** easy@work does not publish developer docs. The - default `user` auth mode scrapes the web app via Playwright, extracts - the Bearer JWT the SPA stores in `localStorage`, and replays it - against the regional API host. Depends on the SPA's private - endpoints — if they change shape, parsing may need a tweak. -- **`api_url` / `customer_id` / `employee_id` are tenant-specific.** - See *Discovering your customer_id + employee_id* above. Payload - parsing auto-detects common shapes (`{"data": [...]}`, - `{"results": [...]}`, bare list, `id`/`uuid`/`shiftId`, - `start`/`starts_at`/`from`, `schedule.customer.name`, etc) — - unexpected shapes raise `ApiError` with the observed top-level keys. -- **OAuth (`client` mode) is unverified.** Kept in-tree for forward-compat - should easy@work publish a developer API, but there is no public - reference client (`php-eaw-client` does not exist as a public repo). - -## Troubleshooting - -- **"Calendar 'Work Shifts' not found"** — create it in Calendar.app first; - source name must match (`iCloud`, `On My Mac`, etc). -- **Calendar permission denied** — System Settings → Privacy & Security → - Calendars → enable for your terminal / launchd agent. -- **`auth failed`** — run `eaw-sync doctor`, check `EAW_CLIENT_SECRET`, confirm - `base_url`. -- **Stale events after delete** — state entries auto-prune once the backend - confirms the delete. Corrupt `state.json` is quarantined and rebuilt. - -## Auto-run on macOS - -A sample launchd plist is in `examples/launchd/com.easyatcal.watch.plist`. -Load with: - -```bash -cp examples/launchd/com.easyatcal.watch.plist ~/Library/LaunchAgents/ -launchctl load ~/Library/LaunchAgents/com.easyatcal.watch.plist -``` - -## Contributing - -If you fork and want to publish your own PyPI package via GitHub Actions: -1. Ensure you have claimed your project name on PyPI. -2. Go to **PyPI -> Manage -> Publishing**. -3. Add a "Trusted Publisher" configured for your GitHub repository (e.g. `Ailcope/EasyAtCal`) pointing to the `publish.yml` workflow and the `pypi` environment. +| 0 | All changes applied successfully. | +| 1 | Partial failure (some changes applied, backend error on others). | +| 2 | Fatal (config/auth/network failed before writing). | -## Design +## Security -- `docs/superpowers/specs/2026-04-19-easyatcal-design.md` — full design. -- `docs/superpowers/plans/2026-04-19-easyatcal-implementation.md` — build plan. +Your easy@work password is **never stored on disk**. The configuration file only stores your email. When you run `eaw-sync login`, the password is used once to drive the browser, and only the resulting JSON Web Token (JWT) is saved locally in `~/.cache/easyatcal/session.json` (with strict `0600` permissions). ## License diff --git a/config.example.yaml b/config.example.yaml index 502ae09..151c10e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -5,11 +5,14 @@ easyatwork: # ---------- user mode ---------- email: "me@example.com" # or set EAW_EMAIL + # Password is NEVER stored on disk. Provide it via env var for `eaw-sync login`: # EAW_PASSWORD=... eaw-sync login # or enter it interactively when prompted. + login_url: "https://app.easyatwork.com/" app_url: "https://app.easyatwork.com" + # Regional API host the SPA talks to. Open DevTools → Network → any XHR # while you view your schedule. The request URL looks like: # https://.api.easyatwork.com/customers//employees//shifts @@ -17,30 +20,24 @@ easyatwork: api_url: "https://eu-west-3.api.easyatwork.com" customer_id: 0 # e.g. 2571 — from the URL above employee_id: 0 # e.g. 1464727 — from the URL above + # Mimic the SPA version header. Bump if the API starts rejecting. ui_version: "2.313.0" - # Selectors for the login form. Override if the defaults don't match. - # email_selector: "input[type='email']" - # password_selector: "input[type='password']" - # submit_selector: "button[type='submit']" - headless: true # set false to watch the browser - - # ---------- client mode (OAuth, hypothetical) ---------- - # client_id: "REPLACE_ME" - # client_secret: "REPLACE_ME" # or EAW_CLIENT_SECRET env var - # base_url: "https://api.easyatwork.com" + + # Browser is headless by default. Set false to watch the automated login visually. + headless: true sync: lookback_days: 7 lookahead_days: 90 user_id: null # null = self -backend: eventkit # "eventkit" or "ics" +backend: ics # "eventkit" (macOS) or "ics" (All platforms) backends: eventkit: - calendar_name: "Work Shifts" - calendar_source: "iCloud" + calendar_name: "EasyAtWork" + calendar_source: "iCloud" # "iCloud" or "On My Mac" ics: output_path: "~/Documents/easyatwork-shifts.ics" diff --git a/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md b/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md index 1641289..44bf047 100644 --- a/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md +++ b/docs/superpowers/plans/2026-04-19-easyatcal-implementation.md @@ -1,6 +1,6 @@ # EasyAtCal Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (` - [x]`) syntax for tracking. **Goal:** Build a Python CLI (`eaw-sync`) that fetches shifts from the easy@work REST API and writes them to Apple Calendar (via EventKit on macOS) or a portable `.ics` file, with one-way sync and a daemon mode. @@ -23,7 +23,7 @@ Spec: `docs/superpowers/specs/2026-04-19-easyatcal-design.md` - Create: `config.example.yaml` - Create: `README.md` (replace existing empty file) -- [ ] **Step 1: Create `pyproject.toml`** + - [x] **Step 1: Create `pyproject.toml`** ```toml [build-system] @@ -67,7 +67,7 @@ testpaths = ["tests"] addopts = "-v --strict-markers" ``` -- [ ] **Step 2: Create `.gitignore`** + - [x] **Step 2: Create `.gitignore`** ```gitignore # Secrets & user data @@ -94,7 +94,7 @@ build/ .DS_Store ``` -- [ ] **Step 3: Create package and test skeletons** + - [x] **Step 3: Create package and test skeletons** `easyatcal/__init__.py`: ```python @@ -110,7 +110,7 @@ __version__ = "0.1.0" import pytest ``` -- [ ] **Step 4: Create `config.example.yaml`** + - [x] **Step 4: Create `config.example.yaml`** ```yaml easyatwork: @@ -137,7 +137,7 @@ logging: level: INFO ``` -- [ ] **Step 5: Create `README.md`** + - [x] **Step 5: Create `README.md`** ```markdown # EasyAtCal @@ -168,12 +168,12 @@ eaw-sync watch --interval 15m # daemon mode See `docs/superpowers/specs/2026-04-19-easyatcal-design.md` for full design. ``` -- [ ] **Step 6: Install in editable mode and verify pytest runs** + - [x] **Step 6: Install in editable mode and verify pytest runs** Run: `pip install -e '.[dev]' && pytest` Expected: `collected 0 items` — no failure. -- [ ] **Step 7: Commit** + - [x] **Step 7: Commit** ```bash git add pyproject.toml .gitignore easyatcal/ tests/ config.example.yaml README.md @@ -188,7 +188,7 @@ git commit -m "scaffold: project layout, pyproject, gitignore, readme" - Create: `easyatcal/models.py` - Create: `tests/test_models.py` -- [ ] **Step 1: Write failing test** + - [x] **Step 1: Write failing test** `tests/test_models.py`: ```python @@ -226,12 +226,12 @@ def test_shift_requires_tz_aware_datetimes(): ) ``` -- [ ] **Step 2: Run test to verify it fails** + - [x] **Step 2: Run test to verify it fails** Run: `pytest tests/test_models.py -v` Expected: FAIL — `ModuleNotFoundError: easyatcal.models`. -- [ ] **Step 3: Implement `easyatcal/models.py`** + - [x] **Step 3: Implement `easyatcal/models.py`** ```python from dataclasses import dataclass @@ -259,12 +259,12 @@ class Shift: return (self.end - self.start).total_seconds() / 3600.0 ``` -- [ ] **Step 4: Run test to verify it passes** + - [x] **Step 4: Run test to verify it passes** Run: `pytest tests/test_models.py -v` Expected: 2 passed. -- [ ] **Step 5: Commit** + - [x] **Step 5: Commit** ```bash git add easyatcal/models.py tests/test_models.py @@ -280,7 +280,7 @@ git commit -m "feat(models): Shift dataclass with tz-aware validation" - Create: `tests/test_config.py` - Create: `tests/fixtures/config_valid.yaml` -- [ ] **Step 1: Create test fixture** + - [x] **Step 1: Create test fixture** `tests/fixtures/config_valid.yaml`: ```yaml @@ -304,7 +304,7 @@ logging: level: INFO ``` -- [ ] **Step 2: Write failing tests** + - [x] **Step 2: Write failing tests** `tests/test_config.py`: ```python @@ -344,12 +344,12 @@ def test_missing_file_raises(tmp_path): load_config(tmp_path / "missing.yaml") ``` -- [ ] **Step 3: Run tests to verify they fail** + - [x] **Step 3: Run tests to verify they fail** Run: `pytest tests/test_config.py -v` Expected: FAIL — module not found. -- [ ] **Step 4: Implement `easyatcal/config.py`** + - [x] **Step 4: Implement `easyatcal/config.py`** ```python from __future__ import annotations @@ -425,12 +425,12 @@ def load_config(path: Path) -> Config: return Config.model_validate(raw) ``` -- [ ] **Step 5: Run tests to verify they pass** + - [x] **Step 5: Run tests to verify they pass** Run: `pytest tests/test_config.py -v` Expected: 4 passed. -- [ ] **Step 6: Commit** + - [x] **Step 6: Commit** ```bash git add easyatcal/config.py tests/test_config.py tests/fixtures/config_valid.yaml @@ -445,7 +445,7 @@ git commit -m "feat(config): pydantic config loader with env overrides" - Create: `easyatcal/state.py` - Create: `tests/test_state.py` -- [ ] **Step 1: Write failing tests** + - [x] **Step 1: Write failing tests** `tests/test_state.py`: ```python @@ -490,12 +490,12 @@ def test_save_is_atomic(tmp_path: Path): assert json.loads(path.read_text())["shift_to_event"] == {"a": "b"} ``` -- [ ] **Step 2: Run tests to verify they fail** + - [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_state.py -v` Expected: FAIL — module not found. -- [ ] **Step 3: Implement `easyatcal/state.py`** + - [x] **Step 3: Implement `easyatcal/state.py`** ```python from __future__ import annotations @@ -534,12 +534,12 @@ def save_state(path: Path, state: State) -> None: os.replace(tmp, path) ``` -- [ ] **Step 4: Run tests to verify they pass** + - [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_state.py -v` Expected: 4 passed. -- [ ] **Step 5: Commit** + - [x] **Step 5: Commit** ```bash git add easyatcal/state.py tests/test_state.py @@ -554,7 +554,7 @@ git commit -m "feat(state): atomic json state with corrupt-file recovery" - Create: `easyatcal/api.py` - Create: `tests/test_api_auth.py` -- [ ] **Step 1: Write failing tests** + - [x] **Step 1: Write failing tests** `tests/test_api_auth.py`: ```python @@ -628,12 +628,12 @@ def test_auth_failure_raises(tmp_path: Path): client.authenticate() ``` -- [ ] **Step 2: Run tests to verify they fail** + - [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_api_auth.py -v` Expected: FAIL — module not found. -- [ ] **Step 3: Implement auth section of `easyatcal/api.py`** + - [x] **Step 3: Implement auth section of `easyatcal/api.py`** ```python from __future__ import annotations @@ -726,12 +726,12 @@ class EawClient: pass ``` -- [ ] **Step 4: Run tests to verify they pass** + - [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_api_auth.py -v` Expected: 3 passed. -- [ ] **Step 5: Commit** + - [x] **Step 5: Commit** ```bash git add easyatcal/api.py tests/test_api_auth.py @@ -746,7 +746,7 @@ git commit -m "feat(api): OAuth client_credentials auth with cached token" - Modify: `easyatcal/api.py` (add methods) - Create: `tests/test_api_fetch.py` -- [ ] **Step 1: Write failing tests** + - [x] **Step 1: Write failing tests** `tests/test_api_fetch.py`: ```python @@ -893,12 +893,12 @@ def test_fetch_shifts_gives_up_after_5_retries(tmp_path: Path, monkeypatch): ) ``` -- [ ] **Step 2: Run tests to verify they fail** + - [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_api_fetch.py -v` Expected: FAIL — `fetch_shifts` not defined. -- [ ] **Step 3: Extend `easyatcal/api.py`** + - [x] **Step 3: Extend `easyatcal/api.py`** Append these methods to the `EawClient` class (after `_write_cache`): @@ -958,12 +958,12 @@ Append these methods to the `EawClient` class (after `_write_cache`): return out ``` -- [ ] **Step 4: Run tests to verify they pass** + - [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_api_fetch.py -v` Expected: 4 passed. -- [ ] **Step 5: Commit** + - [x] **Step 5: Commit** ```bash git add easyatcal/api.py tests/test_api_fetch.py @@ -980,12 +980,12 @@ git commit -m "feat(api): fetch_shifts with pagination and backoff" - Create: `tests/backends/__init__.py` - Create: `tests/backends/test_base.py` -- [ ] **Step 1: Create empty `__init__.py` files** + - [x] **Step 1: Create empty `__init__.py` files** `easyatcal/backends/__init__.py`: empty. `tests/backends/__init__.py`: empty. -- [ ] **Step 2: Write failing test** + - [x] **Step 2: Write failing test** `tests/backends/test_base.py`: ```python @@ -1008,12 +1008,12 @@ def test_backend_is_protocol_with_apply(): assert d.apply(Changes([], [], [])) == {} ``` -- [ ] **Step 3: Run test to verify it fails** + - [x] **Step 3: Run test to verify it fails** Run: `pytest tests/backends/test_base.py -v` Expected: FAIL — module not found. -- [ ] **Step 4: Implement `easyatcal/backends/base.py`** + - [x] **Step 4: Implement `easyatcal/backends/base.py`** ```python from __future__ import annotations @@ -1042,12 +1042,12 @@ class CalendarBackend(Protocol): ... ``` -- [ ] **Step 5: Run test to verify it passes** + - [x] **Step 5: Run test to verify it passes** Run: `pytest tests/backends/test_base.py -v` Expected: 2 passed. -- [ ] **Step 6: Commit** + - [x] **Step 6: Commit** ```bash git add easyatcal/backends/ tests/backends/__init__.py tests/backends/test_base.py @@ -1062,7 +1062,7 @@ git commit -m "feat(backends): Changes dataclass and CalendarBackend protocol" - Create: `easyatcal/sync.py` - Create: `tests/test_sync.py` -- [ ] **Step 1: Write failing tests** + - [x] **Step 1: Write failing tests** `tests/test_sync.py`: ```python @@ -1130,12 +1130,12 @@ def test_shift_missing_from_remote_is_delete(): assert changes.deletes == ["evt-b"] ``` -- [ ] **Step 2: Run tests to verify they fail** + - [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_sync.py -v` Expected: FAIL — module not found. -- [ ] **Step 3: Implement `easyatcal/sync.py`** + - [x] **Step 3: Implement `easyatcal/sync.py`** ```python from __future__ import annotations @@ -1175,12 +1175,12 @@ def compute_changes( return Changes(adds=adds, updates=updates, deletes=deletes) ``` -- [ ] **Step 4: Run tests to verify they pass** + - [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_sync.py -v` Expected: 4 passed. -- [ ] **Step 5: Commit** + - [x] **Step 5: Commit** ```bash git add easyatcal/sync.py tests/test_sync.py @@ -1195,7 +1195,7 @@ git commit -m "feat(sync): diff engine for adds/updates/deletes" - Modify: `easyatcal/state.py` - Modify: `tests/test_state.py` -- [ ] **Step 1: Add test for new field** + - [x] **Step 1: Add test for new field** Append to `tests/test_state.py`: ```python @@ -1213,12 +1213,12 @@ def test_state_roundtrip_with_updated_at(tmp_path): assert loaded.shift_updated_at == {"s1": "2026-04-18T10:00:00+00:00"} ``` -- [ ] **Step 2: Run test to verify it fails** + - [x] **Step 2: Run test to verify it fails** Run: `pytest tests/test_state.py::test_state_roundtrip_with_updated_at -v` Expected: FAIL — field not defined. -- [ ] **Step 3: Add field to `easyatcal/state.py`** + - [x] **Step 3: Add field to `easyatcal/state.py`** Modify the `State` dataclass: ```python @@ -1238,12 +1238,12 @@ Update `load_state` body so the constructor call includes the new field: ) ``` -- [ ] **Step 4: Run all state tests to verify they pass** + - [x] **Step 4: Run all state tests to verify they pass** Run: `pytest tests/test_state.py -v` Expected: 5 passed. -- [ ] **Step 5: Commit** + - [x] **Step 5: Commit** ```bash git add easyatcal/state.py tests/test_state.py @@ -1258,7 +1258,7 @@ git commit -m "feat(state): track shift_updated_at per shift" - Create: `easyatcal/backends/ics.py` - Create: `tests/backends/test_ics.py` -- [ ] **Step 1: Write failing tests** + - [x] **Step 1: Write failing tests** `tests/backends/test_ics.py`: ```python @@ -1334,12 +1334,12 @@ def test_updates_replace_event(tmp_path: Path): assert "SUMMARY:Shift s1" not in body ``` -- [ ] **Step 2: Run tests to verify they fail** + - [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/backends/test_ics.py -v` Expected: FAIL — module not found. -- [ ] **Step 3: Implement `easyatcal/backends/ics.py`** + - [x] **Step 3: Implement `easyatcal/backends/ics.py`** ```python from __future__ import annotations @@ -1420,12 +1420,12 @@ class IcsBackend: tmp.replace(self.output_path) ``` -- [ ] **Step 4: Run tests to verify they pass** + - [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/backends/test_ics.py -v` Expected: 3 passed. -- [ ] **Step 5: Commit** + - [x] **Step 5: Commit** ```bash git add easyatcal/backends/ics.py tests/backends/test_ics.py @@ -1440,7 +1440,7 @@ git commit -m "feat(backends): ICS file backend with add/update/delete" - Create: `easyatcal/backends/eventkit.py` - Create: `tests/backends/test_eventkit.py` -- [ ] **Step 1: Write failing tests** + - [x] **Step 1: Write failing tests** `tests/backends/test_eventkit.py`: ```python @@ -1519,12 +1519,12 @@ def test_apply_deletes_removes_events(mock_store_factory): store.removeEvent_span_error_.assert_called() ``` -- [ ] **Step 2: Run tests to verify they fail (macOS only)** + - [x] **Step 2: Run tests to verify they fail (macOS only)** Run: `pytest tests/backends/test_eventkit.py -v` Expected on macOS: FAIL — module not found. On Linux: skipped. -- [ ] **Step 3: Implement `easyatcal/backends/eventkit.py`** + - [x] **Step 3: Implement `easyatcal/backends/eventkit.py`** ```python """macOS EventKit calendar backend. @@ -1676,12 +1676,12 @@ class EventKitBackend: return mapping ``` -- [ ] **Step 4: Run tests to verify they pass** + - [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/backends/test_eventkit.py -v` Expected on macOS: 2 passed. On Linux: skipped. -- [ ] **Step 5: Commit** + - [x] **Step 5: Commit** ```bash git add easyatcal/backends/eventkit.py tests/backends/test_eventkit.py @@ -1696,7 +1696,7 @@ git commit -m "feat(backends): macOS EventKit backend via pyobjc" - Create: `easyatcal/orchestrator.py` - Create: `tests/test_orchestrator.py` -- [ ] **Step 1: Write failing test** + - [x] **Step 1: Write failing test** `tests/test_orchestrator.py`: ```python @@ -1752,12 +1752,12 @@ def test_run_sync_applies_changes_and_persists_state(tmp_path: Path): assert saved.last_sync == "2026-04-19T12:00:00+00:00" ``` -- [ ] **Step 2: Run test to verify it fails** + - [x] **Step 2: Run test to verify it fails** Run: `pytest tests/test_orchestrator.py -v` Expected: FAIL — module not found. -- [ ] **Step 3: Implement `easyatcal/orchestrator.py`** + - [x] **Step 3: Implement `easyatcal/orchestrator.py`** ```python from __future__ import annotations @@ -1821,12 +1821,12 @@ def run_sync( ) ``` -- [ ] **Step 4: Run test to verify it passes** + - [x] **Step 4: Run test to verify it passes** Run: `pytest tests/test_orchestrator.py -v` Expected: 1 passed. -- [ ] **Step 5: Commit** + - [x] **Step 5: Commit** ```bash git add easyatcal/orchestrator.py tests/test_orchestrator.py @@ -1842,7 +1842,7 @@ git commit -m "feat(orchestrator): tie api + sync + backend + state together" - Create: `easyatcal/paths.py` - Create: `tests/test_cli_config.py` -- [ ] **Step 1: Create helper for platform paths** + - [x] **Step 1: Create helper for platform paths** `easyatcal/paths.py`: ```python @@ -1871,7 +1871,7 @@ def log_path() -> Path: return Path(user_data_dir(APP)) / "logs" / "eaw-sync.log" ``` -- [ ] **Step 2: Write failing tests** + - [x] **Step 2: Write failing tests** `tests/test_cli_config.py`: ```python @@ -1923,12 +1923,12 @@ def test_config_show_redacts_secret(tmp_path: Path): assert "***" in result.stdout ``` -- [ ] **Step 3: Run tests to verify they fail** + - [x] **Step 3: Run tests to verify they fail** Run: `pytest tests/test_cli_config.py -v` Expected: FAIL — cli module missing. -- [ ] **Step 4: Implement `easyatcal/cli.py`** + - [x] **Step 4: Implement `easyatcal/cli.py`** ```python from __future__ import annotations @@ -1970,12 +1970,12 @@ def config_show() -> None: typer.echo(yaml.safe_dump(dumped, sort_keys=False)) ``` -- [ ] **Step 5: Run tests to verify they pass** + - [x] **Step 5: Run tests to verify they pass** Run: `pytest tests/test_cli_config.py -v` Expected: 3 passed. -- [ ] **Step 6: Commit** + - [x] **Step 6: Commit** ```bash git add easyatcal/cli.py easyatcal/paths.py tests/test_cli_config.py @@ -1990,7 +1990,7 @@ git commit -m "feat(cli): config init and config show with secret redaction" - Modify: `easyatcal/cli.py` - Create: `tests/test_cli_sync.py` -- [ ] **Step 1: Write failing tests** + - [x] **Step 1: Write failing tests** `tests/test_cli_sync.py`: ```python @@ -2034,12 +2034,12 @@ def test_watch_loops_until_interrupt(mock_cfg, mock_api, mock_back, mock_run, mo assert result.exit_code == 0 ``` -- [ ] **Step 2: Run tests to verify they fail** + - [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_cli_sync.py -v` Expected: FAIL — `sync` / `watch` commands not registered. -- [ ] **Step 3: Extend `easyatcal/cli.py`** + - [x] **Step 3: Extend `easyatcal/cli.py`** Add to `easyatcal/cli.py` (after existing imports): @@ -2121,12 +2121,12 @@ def watch_cmd( typer.echo("\nStopped.") ``` -- [ ] **Step 4: Run tests to verify they pass** + - [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_cli_sync.py -v` Expected: 2 passed. -- [ ] **Step 5: Commit** + - [x] **Step 5: Commit** ```bash git add easyatcal/cli.py tests/test_cli_sync.py @@ -2141,7 +2141,7 @@ git commit -m "feat(cli): sync one-shot and watch daemon commands" - Modify: `easyatcal/cli.py` - Create: `tests/test_cli_auth.py` -- [ ] **Step 1: Write failing test** + - [x] **Step 1: Write failing test** `tests/test_cli_auth.py`: ```python @@ -2181,12 +2181,12 @@ def test_auth_test_failure(mock_cfg, mock_build): assert "bad creds" in result.stdout ``` -- [ ] **Step 2: Run test to verify it fails** + - [x] **Step 2: Run test to verify it fails** Run: `pytest tests/test_cli_auth.py -v` Expected: FAIL — `auth test` not registered. -- [ ] **Step 3: Add `auth test` to `easyatcal/cli.py`** + - [x] **Step 3: Add `auth test` to `easyatcal/cli.py`** Append: ```python @@ -2209,12 +2209,12 @@ def auth_test() -> None: typer.echo("OK — credentials work.") ``` -- [ ] **Step 4: Run test to verify it passes** + - [x] **Step 4: Run test to verify it passes** Run: `pytest tests/test_cli_auth.py -v` Expected: 2 passed. -- [ ] **Step 5: Commit** + - [x] **Step 5: Commit** ```bash git add easyatcal/cli.py tests/test_cli_auth.py @@ -2230,7 +2230,7 @@ git commit -m "feat(cli): auth test subcommand" - Modify: `easyatcal/cli.py` (call setup at entry) - Create: `tests/test_logging_setup.py` -- [ ] **Step 1: Write failing test** + - [x] **Step 1: Write failing test** `tests/test_logging_setup.py`: ```python @@ -2254,12 +2254,12 @@ def test_configure_logging_writes_to_file(tmp_path: Path): assert "hello world" in log_file.read_text() ``` -- [ ] **Step 2: Run test to verify it fails** + - [x] **Step 2: Run test to verify it fails** Run: `pytest tests/test_logging_setup.py -v` Expected: FAIL — module not found. -- [ ] **Step 3: Implement `easyatcal/logging_setup.py`** + - [x] **Step 3: Implement `easyatcal/logging_setup.py`** ```python from __future__ import annotations @@ -2290,12 +2290,12 @@ def configure_logging(level: str, log_file: Path) -> None: root.addHandler(console_h) ``` -- [ ] **Step 4: Run test to verify it passes** + - [x] **Step 4: Run test to verify it passes** Run: `pytest tests/test_logging_setup.py -v` Expected: 1 passed. -- [ ] **Step 5: Wire into CLI** + - [x] **Step 5: Wire into CLI** At the top of `easyatcal/cli.py`, add: @@ -2310,12 +2310,12 @@ Inside each of `sync_cmd`, `watch_cmd`, `auth_test`, insert as the very first li configure_logging(level=cfg.logging.level, log_file=log_path()) ``` -- [ ] **Step 6: Re-run full test suite** + - [x] **Step 6: Re-run full test suite** Run: `pytest` Expected: all tests pass (EventKit tests skipped on non-macOS). -- [ ] **Step 7: Commit** + - [x] **Step 7: Commit** ```bash git add easyatcal/logging_setup.py easyatcal/cli.py tests/test_logging_setup.py @@ -2329,7 +2329,7 @@ git commit -m "feat(logging): rotating file + console handlers" **Files:** - Create: `.github/workflows/ci.yml` -- [ ] **Step 1: Create CI workflow** + - [x] **Step 1: Create CI workflow** ```yaml name: CI @@ -2364,12 +2364,12 @@ jobs: run: pytest --cov=easyatcal ``` -- [ ] **Step 2: Run pytest locally one more time before commit** + - [x] **Step 2: Run pytest locally one more time before commit** Run: `pytest --cov=easyatcal` Expected: all pass, coverage reported. -- [ ] **Step 3: Commit** + - [x] **Step 3: Commit** ```bash git add .github/workflows/ci.yml @@ -2383,7 +2383,7 @@ git commit -m "ci: test matrix on Linux + macOS, Python 3.11 and 3.12" **Files:** - Create: `tests/test_e2e_ics.py` -- [ ] **Step 1: Write the e2e test** + - [x] **Step 1: Write the e2e test** `tests/test_e2e_ics.py`: ```python @@ -2445,12 +2445,12 @@ def test_end_to_end_ics(tmp_path: Path): assert (tmp_path / "state.json").exists() ``` -- [ ] **Step 2: Run the e2e test** + - [x] **Step 2: Run the e2e test** Run: `pytest tests/test_e2e_ics.py -v` Expected: 1 passed. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add tests/test_e2e_ics.py @@ -2461,12 +2461,12 @@ git commit -m "test(e2e): ICS backend end-to-end with mocked API" ## Task 19: Push to origin -- [ ] **Step 1: Push all commits** +- [x] **Step 1: Push all commits** Run: `git push origin main` Expected: branch updated. -- [ ] **Step 2: Tag v0.1.0** +- [x] **Step 2: Tag v0.1.0** ```bash git tag -a v0.1.0 -m "Initial release: easy@work → Apple Calendar sync" @@ -2475,6 +2475,23 @@ git push origin v0.1.0 --- +## Phase 2: User-Mode Auth Pivot (Completed) + +After the initial implementation, we discovered that OAuth `client_credentials` was insufficient or inaccessible for regular users. We pivoted to a "user mode" utilizing Playwright for headless UI automation to extract a JWT Bearer token from `localStorage`. + +### New Modules Introduced: +- `easyatcal/session.py`: `SessionStore` for persisting the JWT state and Playwright `storage_state.json`. +- `easyatcal/auth_user.py`: Implements `do_login()` which orchestrates the headless Playwright browser, navigates to the login page, fills credentials, waits for a post-login selector, and saves the storage state. +- `easyatcal/api_session.py`: `SessionEawClient` which loads the saved state, extracts the JWT from the `easyatwork.auth` local storage payload, and injects it as a Bearer token in subsequent API requests. +- `easyatcal/cli.py` additions: `eaw-sync login` (interactive or headless login) and `eaw-sync logout`. + +### Architectural Changes: +- **JWT Bearer Refactor:** All API calls now replay the JWT Bearer token against the region-specific API (e.g. `eu-west-3.api.easyatwork.com/customers/{cid}/employees/{eid}/shifts`). +- **Configuration Updates:** The user must explicitly set `api_url`, `customer_id`, `employee_id`, and Playwright configuration options (`login_url`, `login_selectors`, `headless`, etc.) in `config.yaml`. +- **Commit Refs:** `48cb8b0` (JWT pivot) and `323f338` (intermediate). + +--- + ## Self-review notes - Spec coverage: every section of the spec maps to a task (scaffold → T1, models → T2, config → T3, state → T4/T9, api → T5/T6, backend base → T7, sync → T8, ics → T10, eventkit → T11, orchestrator → T12, cli commands → T13/T14/T15, error handling → T5/T6/T11/T15 (exit codes) + T16 (logging), testing → every task has TDD, CI → T17, e2e → T18, security → T1 gitignore + T5 token cache 0600 + T13 redaction). diff --git a/docs/superpowers/specs/2026-04-19-easyatcal-design.md b/docs/superpowers/specs/2026-04-19-easyatcal-design.md index b25c5d1..2c8a38a 100644 --- a/docs/superpowers/specs/2026-04-19-easyatcal-design.md +++ b/docs/superpowers/specs/2026-04-19-easyatcal-design.md @@ -66,7 +66,7 @@ EasyAtCal/ ## Components ### `api.py` — easy@work client -- OAuth2 client-credentials or user-password auth (mirrors [php-eaw-client](https://github.com/easyatworkas/php-eaw-client) patterns). +- JWT Bearer token auth via UI automation. Playwright headless login extracts JWT from `localStorage`. Replays token against `.api.easyatwork.com/customers/{cid}/employees/{eid}/shifts`. - Caches access token at `~/.cache/easyatcal/token.json`. - `fetch_shifts(user_id, from_date, to_date) -> list[Shift]`. - Handles pagination. @@ -136,15 +136,27 @@ class CalendarBackend(Protocol): ```yaml easyatwork: - auth_mode: client # or "user" - client_id: "xxx" - client_secret: "xxx" # env EAW_CLIENT_SECRET overrides - base_url: "https://api.easyatwork.com" + api_url: "https://eu-west-3.api.easyatwork.com" + customer_id: 0 + employee_id: 0 + ui_version: "v3.0.0" + + # Auth configuration for Playwright UI login + email: "user@example.com" + password: "..." # env EAW_PASSWORD overrides + login_url: "https://app.easyatwork.com/login" + app_url: "https://app.easyatwork.com" + login_selectors: + email_input: "input[type='email']" + password_input: "input[type='password']" + submit_button: "button[type='submit']" + post_login_wait: ".dashboard" + headless: true + login_timeout_ms: 30000 sync: lookback_days: 7 lookahead_days: 90 - user_id: null # null = self backend: eventkit # or "ics" @@ -159,7 +171,7 @@ logging: level: INFO ``` -Env vars override YAML: `EAW_CLIENT_ID`, `EAW_CLIENT_SECRET`, `EAW_USERNAME`, `EAW_PASSWORD`. +Env vars override YAML: `EAW_PASSWORD`. ## Error handling @@ -186,7 +198,7 @@ Logs rotate daily, retained 7 days. ## Open questions / deferred -- Exact easy@work API endpoints and pagination style — TBD during implementation by inspecting [php-eaw-client](https://github.com/easyatworkas/php-eaw-client). +- Tenant-specific IDs required (`customer_id`, `employee_id`), which the user must extract from DevTools before first sync or the URL constructor raises an error. - Whether to support multi-user sync in v1 — deferred; single user only. - Whether to publish to PyPI — yes once v0.1 ships, but not blocking first release. diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 19fd7cc..d88c906 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -274,6 +274,40 @@ def sync_cmd( f"{summary.updates} updated, {summary.deletes} deleted." ) + if cfg.backend == "ics": + _prompt_ics_import(cfg.backends.ics.output_path) + + +def _prompt_ics_import(output_path: str) -> None: + import locale + import os + import subprocess + import sys + import webbrowser + + ics_path = os.path.expanduser(output_path) + lang = os.environ.get("LANG") or (locale.getlocale()[0] or "") + fr = lang.lower().startswith("fr") + + typer.secho("\n📅 " + ("Synchronisation réussie !" if fr else "Calendar Sync Successful!"), fg="green", bold=True) + typer.echo(("Vos horaires ont été enregistrés dans : " if fr else "Your shifts were saved to: ") + ics_path) + + prompt_local = ("Voulez-vous ouvrir votre calendrier maintenant pour importer ces horaires ?" if fr + else "Would you like to open your local Calendar app now to import these shifts?") + if typer.confirm(prompt_local): + typer.secho("Ouverture du calendrier..." if fr else "Opening calendar app...", fg="cyan") + if sys.platform == "darwin": + subprocess.run(["open", ics_path], check=False) + elif sys.platform == "win32": + os.startfile(ics_path) # type: ignore + else: + subprocess.run(["xdg-open", ics_path], check=False) + + prompt_google = "Préférez-vous importer ceci dans Google Agenda ?" if fr else "Would you prefer to import this into Google Calendar?" + if typer.confirm(prompt_google): + typer.secho("Ouverture de Google Agenda..." if fr else "Opening Google Calendar...", fg="cyan") + webbrowser.open("https://calendar.google.com/calendar/r/settings/export") + @app.command("watch") def watch_cmd( From 90df0409c9524f6cc9785a78791182b3b9468be7 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:20:56 +0200 Subject: [PATCH 37/68] docs: restyle and center README layout --- README.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5a5d48d..720ce8e 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,32 @@ +
+ # EasyAtCal [![CI](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml/badge.svg)](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml) [![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen.svg)](https://github.com/Ailcope/EasyAtCal) [![PyPI](https://img.shields.io/pypi/v/easyatcal.svg)](https://pypi.org/project/easyatcal/) -[![Python](https://img.shields.io/pypi/pyversions/easyatcal.svg)](https://pypi.org/project/easyatcal/) +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -A CLI tool for syncing your [easy@work](https://www.easyatwork.com) shifts into Apple Calendar, Google Calendar, or standard ICS files. +**One-way sync of easy@work shifts into Apple Calendar, Google Calendar, or standard ICS files.** + +Works with **macOS EventKit** • **Google Calendar** • **Windows Outlook** -It runs locally, fetches your upcoming shifts, and pushes them to your preferred calendar app. It can also be run as a daemon to keep your calendar up to date in the background. +[Quickstart](#quickstart) • [Configuration](#configuration) • [Backends](#backends) • [Commands](#cli-commands) -* **Automated Login**: No public API required. It uses Playwright to securely log in via a headless browser and extract a session token. -* **Two Backends**: Native macOS **EventKit** integration (pushes directly to Apple Calendar) or portable **ICS** file generation (supports interactive import prompts for Google Calendar and Windows Outlook). -* **Idempotent**: State-tracked logic means unchanged shifts are skipped, while schedule updates and cancellations propagate automatically. -* **Bilingual CLI**: Automatically detects English or French system locales and adjusts interactive prompts. +
--- +## Overview + +A CLI tool for syncing your [easy@work](https://www.easyatwork.com) shifts into Apple Calendar, Google Calendar, or standard ICS files. It runs locally, fetches your upcoming shifts, and pushes them to your preferred calendar app. It can also be run as a daemon to keep your calendar up to date in the background. + +- **Automated Login.** No public API required. It uses Playwright to securely log in via a headless browser and extract a session token. +- **Two Backends.** Native macOS **EventKit** integration (pushes directly to Apple Calendar) or portable **ICS** file generation (supports interactive import prompts for Google Calendar and Windows Outlook). +- **Idempotent.** State-tracked logic means unchanged shifts are skipped, while schedule updates and cancellations propagate automatically. +- **Bilingual CLI.** Automatically detects English or French system locales and adjusts interactive prompts. + ## Quickstart ### 1. Install @@ -27,6 +37,7 @@ Install the core application and the Playwright browser dependencies (required f pip install 'easyatcal[playwright]' playwright install chromium ``` + *(If you are on macOS and want native Apple Calendar integration, use `pip install 'easyatcal[eventkit,playwright]'`)* ### 2. Configure From b5fd92dca356b80deb8127eea23ab21c9ec870dc Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:24:23 +0200 Subject: [PATCH 38/68] chore: fix mypy typing errors in CI and upgrade README badges --- README.md | 10 +++++----- easyatcal/backends/eventkit.py | 4 ++-- pyproject.toml | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 720ce8e..c4e6e91 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ # EasyAtCal -[![CI](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml/badge.svg)](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml) -[![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen.svg)](https://github.com/Ailcope/EasyAtCal) -[![PyPI](https://img.shields.io/pypi/v/easyatcal.svg)](https://pypi.org/project/easyatcal/) -[![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![CI](https://img.shields.io/github/actions/workflow/status/Ailcope/EasyAtCal/ci.yml?branch=main&label=CI&logo=github&logoColor=white&color=brightgreen)](https://github.com/Ailcope/EasyAtCal/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/badge/Coverage-90%25-brightgreen.svg?logo=codecov&logoColor=white)](https://github.com/Ailcope/EasyAtCal) +[![Release](https://img.shields.io/github/v/release/Ailcope/EasyAtCal?label=Release&logo=github&logoColor=white&color=blue)](https://github.com/Ailcope/EasyAtCal/releases) +[![Python 3.11+](https://img.shields.io/badge/Python-3.11+-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?logo=opensourceinitiative&logoColor=white)](./LICENSE) **One-way sync of easy@work shifts into Apple Calendar, Google Calendar, or standard ICS files.** diff --git a/easyatcal/backends/eventkit.py b/easyatcal/backends/eventkit.py index 16c6ad9..79af244 100644 --- a/easyatcal/backends/eventkit.py +++ b/easyatcal/backends/eventkit.py @@ -24,7 +24,7 @@ def _import_eventkit() -> Any: # pragma: no cover — platform guard if sys.platform != "darwin": raise EventKitUnavailableError("EventKit backend requires macOS") try: - import EventKit # type: ignore[import-not-found] + import EventKit except ImportError as e: raise EventKitUnavailableError( "pyobjc-framework-EventKit not installed; " @@ -61,7 +61,7 @@ def _cb(ok: bool, err: Any) -> None: def _new_event(store: Any, calendar: Any, shift: Shift) -> Any: # pragma: no cover EventKit = _import_eventkit() - import Foundation # type: ignore[import-not-found] + import Foundation event = EventKit.EKEvent.eventWithEventStore_(store) event.setCalendar_(calendar) diff --git a/pyproject.toml b/pyproject.toml index c60dc18..5b3a422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,8 @@ dev = [ "pytest-cov>=5.0", "respx>=0.21", "freezegun>=1.4", - "ruff>=0.6", "mypy>=1.10", + "types-PyYAML", ] [project.scripts] @@ -69,5 +69,5 @@ warn_return_any = true warn_unused_configs = true [[tool.mypy.overrides]] -module = ["playwright.*"] +module = ["playwright.*", "EventKit", "Foundation"] ignore_missing_imports = true From c21efe47b791aae265c41975d56c57a523938bd7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:27:48 +0200 Subject: [PATCH 39/68] ci: bump actions/checkout from 4 to 6 (#1) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/publish.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6b60f1..1b9bc56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ae67ef0..4d1282d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -21,7 +21,7 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v5 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5c16247..b52e627 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-python@v5 with: python-version: "3.12" From ed3a5cd8beaaba079936ab599a18b00646c2d816 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:27:54 +0200 Subject: [PATCH 40/68] ci: bump actions/download-artifact from 4 to 8 (#3) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b52e627..3a385fa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,7 +34,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: dist path: dist/ From 0c8ba464dd60f41c321b2cfb3b8d529b938b5ed2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:27:57 +0200 Subject: [PATCH 41/68] ci: bump actions/upload-artifact from 4 to 7 (#4) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3a385fa..a7197eb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,7 +20,7 @@ jobs: run: python -m pip install --upgrade build - name: Build sdist and wheel run: python -m build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: dist path: dist/ From f79f8d56e6fd68986bdc2dd607944696725bf781 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:30:23 +0200 Subject: [PATCH 42/68] docs: fix markdown parsing inside div for MkDocs --- README.md | 2 +- mkdocs.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c4e6e91..53f5c5a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -
+
# EasyAtCal diff --git a/mkdocs.yml b/mkdocs.yml index aa2154f..89eaecf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - Changelog: changelog.md markdown_extensions: + - md_in_html - toc: permalink: true - pymdownx.highlight: From 7eb29c565705164201753446eae21c2fc4e95e18 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:31:54 +0200 Subject: [PATCH 43/68] ci: add ruff to dev dependencies to fix missing command in pipeline --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 5b3a422..70490d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dev = [ "freezegun>=1.4", "mypy>=1.10", "types-PyYAML", + "ruff>=0.6", ] [project.scripts] From ff4aa0f092ed08509b5dad8e5add3f75cbb64945 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:47:45 +0200 Subject: [PATCH 44/68] feat: Automate extraction, caching, scheduling and alarms - Automate customer_id and employee_id extraction during Playwright login - Secure JWT caching via the OS keyring library - Add 'schedule' command to auto-install background sync - Add event customization and alarm configurations - Update docs and config files to reflect changes --- README.md | 40 +++++++++----- config.example.yaml | 16 +++--- easyatcal/auth_user.py | 34 +++++++++++- easyatcal/backends/eventkit.py | 65 ++++++++++++++++++++--- easyatcal/backends/ics.py | 45 ++++++++++++++-- easyatcal/cli.py | 95 +++++++++++++++++++++++++++++++++- easyatcal/config.py | 17 ++++-- easyatcal/session.py | 45 +++++++++++++--- pyproject.toml | 1 + 9 files changed, 314 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 53f5c5a..7a1c978 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,10 @@ Works with **macOS EventKit** • **Google Calendar** • **Windows Outloo A CLI tool for syncing your [easy@work](https://www.easyatwork.com) shifts into Apple Calendar, Google Calendar, or standard ICS files. It runs locally, fetches your upcoming shifts, and pushes them to your preferred calendar app. It can also be run as a daemon to keep your calendar up to date in the background. -- **Automated Login.** No public API required. It uses Playwright to securely log in via a headless browser and extract a session token. +- **Automated Login & Discovery.** No public API required. It uses Playwright to securely log in via a headless browser, extracting both your session token and your unique account IDs (`customer_id`, `employee_id`) automatically. +- **Secure Session.** Your JWT is securely cached in your OS's native credential store (Keychain on macOS, Credential Locker on Windows) via the `keyring` library. +- **Background Sync.** Includes a built-in `schedule` command to easily install an auto-updating background daemon (macOS `launchd`, Linux `cron`, or Windows Task Scheduler). +- **Customizable Events.** Configure your own event titles (e.g. `[Work] {title} at {location}`) and add automatic alarms/reminders for your shifts. - **Two Backends.** Native macOS **EventKit** integration (pushes directly to Apple Calendar) or portable **ICS** file generation (supports interactive import prompts for Google Calendar and Windows Outlook). - **Idempotent.** State-tracked logic means unchanged shifts are skipped, while schedule updates and cancellations propagate automatically. - **Bilingual CLI.** Automatically detects English or French system locales and adjusts interactive prompts. @@ -53,20 +56,20 @@ Now, open the configuration file (located at `~/.config/easyatcal/config.yaml` o ```yaml easyatwork: email: "your.email@example.com" - api_url: "https://eu-west-3.api.easyatwork.com" # Check your DevTools for your specific region - customer_id: 1234 # Found in DevTools URL - employee_id: 1234567 # Found in DevTools URL + # Optional: api_url, customer_id, and employee_id are now automatically discovered! ``` -> **How to find your `customer_id` and `employee_id`:** -> 1. Open your browser and log in to [app.easyatwork.com](https://app.easyatwork.com). -> 2. Open Developer Tools (F12) -> Go to the **Network** tab. -> 3. Click on your schedule. Look for a network request starting with `shifts?from=...` -> 4. Look at the URL of that request: `https://eu-west-3.api.easyatwork.com/customers//employees//shifts` +You can optionally configure event titles and alarms: + +```yaml +sync: + event_title_format: "EasyAtWork: {title}" + alarm_minutes_before: 60 # Remind me 1 hour before my shift +``` ### 3. Log In -Run the interactive login command. It prompts securely for your password, launches a headless Chromium browser, logs you in, and saves your session token securely. +Run the interactive login command. It prompts securely for your password, launches a headless Chromium browser, logs you in, automatically discovers your account IDs (`customer_id`/`employee_id`), and saves your session token securely using your OS keyring. ```bash eaw-sync login @@ -82,7 +85,19 @@ eaw-sync sync ## Background Sync -To keep your calendar up to date continuously, run EasyAtCal in daemon mode: +To keep your calendar up to date continuously, EasyAtCal can run in the background. + +Use the `schedule` command to set up an OS-level background task (macOS `launchd`, Linux `crontab`, or Windows Task Scheduler). The background job will run `eaw-sync sync` silently every few hours. + +```bash +# Display the necessary configuration to set up background sync +eaw-sync schedule --interval-hours 6 + +# Alternatively, have it install automatically on macOS/Linux +eaw-sync schedule --install --interval-hours 6 +``` + +Alternatively, run EasyAtCal in daemon loop mode manually: ```bash eaw-sync watch --interval-seconds 900 # Syncs every 15 minutes @@ -117,6 +132,7 @@ Writes directly to a dedicated calendar in the macOS Calendar.app via native API | `eaw-sync sync` | Run a single sync pass. | | `eaw-sync sync --dry-run` | Diff remote shifts against local state without writing. | | `eaw-sync watch` | Run the sync in an infinite loop. | +| `eaw-sync schedule` | Generate or install OS-level background sync (`launchd`, `cron`). | | `eaw-sync --install-completion` | Install shell autocomplete (bash/zsh/fish). | ### Exit codes (`sync`) @@ -129,7 +145,7 @@ Writes directly to a dedicated calendar in the macOS Calendar.app via native API ## Security -Your easy@work password is **never stored on disk**. The configuration file only stores your email. When you run `eaw-sync login`, the password is used once to drive the browser, and only the resulting JSON Web Token (JWT) is saved locally in `~/.cache/easyatcal/session.json` (with strict `0600` permissions). +Your easy@work password is **never stored on disk**. The configuration file only stores your email. When you run `eaw-sync login`, the password is used once to drive the browser, and the resulting JSON Web Token (JWT) is extracted and saved securely in your OS's native credential store using `keyring` (macOS Keychain, Windows Credential Locker, Linux Secret Service). Non-sensitive session data (like your `customer_id` and UI state) is saved in `~/.local/state/easyatcal` with strict `0600` permissions. ## License diff --git a/config.example.yaml b/config.example.yaml index 151c10e..cd068e8 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -13,13 +13,11 @@ easyatwork: login_url: "https://app.easyatwork.com/" app_url: "https://app.easyatwork.com" - # Regional API host the SPA talks to. Open DevTools → Network → any XHR - # while you view your schedule. The request URL looks like: - # https://.api.easyatwork.com/customers//employees//shifts - # Paste the region host + your two IDs here. - api_url: "https://eu-west-3.api.easyatwork.com" - customer_id: 0 # e.g. 2571 — from the URL above - employee_id: 0 # e.g. 1464727 — from the URL above + # Regional API host and IDs are now AUTOMATICALLY EXTRACTED during `eaw-sync login`. + # You can leave these as null. + api_url: null + customer_id: null + employee_id: null # Mimic the SPA version header. Bump if the API starts rejecting. ui_version: "2.313.0" @@ -31,6 +29,10 @@ sync: lookback_days: 7 lookahead_days: 90 user_id: null # null = self + # Customize event titles (e.g. "[Work] {title} at {location}") + event_title_format: "{title}" + # Default alarm for shifts (e.g. 60 for 1 hour before) + alarm_minutes_before: null backend: ics # "eventkit" (macOS) or "ics" (All platforms) diff --git a/easyatcal/auth_user.py b/easyatcal/auth_user.py index 5b6115c..1c14b64 100644 --- a/easyatcal/auth_user.py +++ b/easyatcal/auth_user.py @@ -52,6 +52,19 @@ def do_login( try: context = browser.new_context() page = context.new_page() + + discovered_meta: dict[str, str | int] = {} + import re + + def on_request(request): + match = re.search(r"^(https?://[^/]+)/customers/(\d+)/employees/(\d+)", request.url) + if match: + discovered_meta["api_url"] = match.group(1) + discovered_meta["customer_id"] = int(match.group(2)) + discovered_meta["employee_id"] = int(match.group(3)) + + page.on("request", on_request) + page.goto(cfg.login_url, wait_until="domcontentloaded") try: @@ -90,6 +103,25 @@ def do_login( f"Check credentials or selectors." ) - context.storage_state(path=str(storage_path)) + # Wait a little longer just in case the API request hasn't fired yet + if not discovered_meta: + try: + page.wait_for_timeout(3000) + except PWTimeout: + pass + + state = context.storage_state() + if discovered_meta: + state["eaw_meta"] = discovered_meta + + import json + import os + tmp = storage_path.with_suffix(storage_path.suffix + ".tmp") + tmp.write_text(json.dumps(state)) + os.replace(tmp, storage_path) + try: + os.chmod(storage_path, 0o600) + except OSError: + pass finally: browser.close() diff --git a/easyatcal/backends/eventkit.py b/easyatcal/backends/eventkit.py index 79af244..ebaa909 100644 --- a/easyatcal/backends/eventkit.py +++ b/easyatcal/backends/eventkit.py @@ -59,13 +59,26 @@ def _cb(ok: bool, err: Any) -> None: return store -def _new_event(store: Any, calendar: Any, shift: Shift) -> Any: # pragma: no cover +def _new_event( + store: Any, + calendar: Any, + shift: Shift, + event_title_format: str = "{title}", + alarm_minutes_before: int | None = None, +) -> Any: # pragma: no cover EventKit = _import_eventkit() import Foundation event = EventKit.EKEvent.eventWithEventStore_(store) event.setCalendar_(calendar) - event.setTitle_(shift.title) + + title = event_title_format.format( + title=shift.title, + location=shift.location or "", + notes=shift.notes or "", + ).strip() + event.setTitle_(title) + event.setStartDate_( Foundation.NSDate.dateWithTimeIntervalSince1970_(shift.start.timestamp()) ) @@ -76,13 +89,26 @@ def _new_event(store: Any, calendar: Any, shift: Shift) -> Any: # pragma: no co event.setLocation_(shift.location) if shift.notes: event.setNotes_(shift.notes) + + if alarm_minutes_before is not None: + alarm = EventKit.EKAlarm.alarmWithRelativeOffset_(-alarm_minutes_before * 60) + event.addAlarm_(alarm) + return event class EventKitBackend: - def __init__(self, calendar_name: str, calendar_source: str) -> None: + def __init__( + self, + calendar_name: str, + calendar_source: str, + event_title_format: str = "{title}", + alarm_minutes_before: int | None = None, + ) -> None: self.calendar_name = calendar_name self.calendar_source = calendar_source + self.event_title_format = event_title_format + self.alarm_minutes_before = alarm_minutes_before self._store = _event_store() self._calendar = self._resolve_calendar() @@ -103,7 +129,13 @@ def apply(self, changes: Changes) -> ApplyResult: result = ApplyResult() try: for shift in changes.adds: - event = _new_event(self._store, self._calendar, shift) + event = _new_event( + self._store, + self._calendar, + shift, + self.event_title_format, + self.alarm_minutes_before, + ) ok, err = self._store.saveEvent_span_error_(event, 0, None) if not ok: raise BackendError(f"saveEvent failed for {shift.id}: {err}", result) @@ -112,7 +144,13 @@ def apply(self, changes: Changes) -> ApplyResult: for shift, event_uid in changes.updates: existing = self._store.calendarItemWithIdentifier_(event_uid) if existing is None: - event = _new_event(self._store, self._calendar, shift) + event = _new_event( + self._store, + self._calendar, + shift, + self.event_title_format, + self.alarm_minutes_before, + ) ok, err = self._store.saveEvent_span_error_(event, 0, None) if not ok: raise BackendError( @@ -121,7 +159,14 @@ def apply(self, changes: Changes) -> ApplyResult: ) result.mapping[shift.id] = event.calendarItemExternalIdentifier() continue - existing.setTitle_(shift.title) + + title = self.event_title_format.format( + title=shift.title, + location=shift.location or "", + notes=shift.notes or "", + ).strip() + existing.setTitle_(title) + import Foundation existing.setStartDate_( Foundation.NSDate.dateWithTimeIntervalSince1970_( @@ -137,6 +182,14 @@ def apply(self, changes: Changes) -> ApplyResult: existing.setLocation_(shift.location) if shift.notes is not None: existing.setNotes_(shift.notes) + + if self.alarm_minutes_before is not None: + # Clear existing alarms and add the configured one + existing.removeAllAlarms() + EventKit = _import_eventkit() + alarm = EventKit.EKAlarm.alarmWithRelativeOffset_(-self.alarm_minutes_before * 60) + existing.addAlarm_(alarm) + ok, err = self._store.saveEvent_span_error_(existing, 0, None) if not ok: raise BackendError( diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py index 35782fc..794400f 100644 --- a/easyatcal/backends/ics.py +++ b/easyatcal/backends/ics.py @@ -15,10 +15,26 @@ def _uid_for(shift_id: str) -> str: return f"{UID_PREFIX}{shift_id}" -def _to_event(shift: Shift, uid: str) -> Any: +def _to_event( + shift: Shift, + uid: str, + event_title_format: str = "{title}", + alarm_minutes_before: int | None = None, +) -> Any: + from datetime import timedelta + + from icalendar import Alarm ev = Event() # type: ignore[no-untyped-call] ev.add("uid", uid) - ev.add("summary", shift.title) + + # Format the title + title = event_title_format.format( + title=shift.title, + location=shift.location or "", + notes=shift.notes or "", + ).strip() + + ev.add("summary", title) ev.add("dtstart", shift.start) ev.add("dtend", shift.end) ev.add("last-modified", shift.updated_at) @@ -26,6 +42,14 @@ def _to_event(shift: Shift, uid: str) -> Any: ev.add("location", shift.location) if shift.notes: ev.add("description", shift.notes) + + if alarm_minutes_before is not None: + alarm = Alarm() + alarm.add("action", "DISPLAY") + alarm.add("description", "Shift Reminder") + alarm.add("trigger", timedelta(minutes=-alarm_minutes_before)) + ev.add_component(alarm) + return ev @@ -37,9 +61,17 @@ class IcsBackend: change set. """ - def __init__(self, output_path: Path, known_shifts: list[Shift]) -> None: + def __init__( + self, + output_path: Path, + known_shifts: list[Shift], + event_title_format: str = "{title}", + alarm_minutes_before: int | None = None, + ) -> None: self.output_path = Path(output_path).expanduser() self._current: dict[str, Shift] = {s.id: s for s in known_shifts} + self.event_title_format = event_title_format + self.alarm_minutes_before = alarm_minutes_before def apply(self, changes: Changes) -> ApplyResult: mapping: dict[str, str] = {} @@ -74,7 +106,12 @@ def _write(self) -> None: cal.add("prodid", "-//EasyAtCal//EN") cal.add("version", "2.0") for shift in self._current.values(): - cal.add_component(_to_event(shift, _uid_for(shift.id))) + cal.add_component(_to_event( + shift, + _uid_for(shift.id), + self.event_title_format, + self.alarm_minutes_before, + )) self.output_path.parent.mkdir(parents=True, exist_ok=True) tmp = self.output_path.with_suffix(self.output_path.suffix + ".tmp") diff --git a/easyatcal/cli.py b/easyatcal/cli.py index d88c906..b6a2e2f 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -105,25 +105,32 @@ def _build_api_client(cfg: Config) -> ShiftFetcher: token_cache=token_cache_path(), ) # auth_mode == "user" — JWT Bearer mode (token from localStorage) + session_store = SessionStore(session_state_path()) return SessionEawClient( - shifts_url=cfg.easyatwork.shifts_url(), - session_store=SessionStore(session_state_path()), + shifts_url=cfg.easyatwork.shifts_url(session_store.eaw_meta()), + session_store=session_store, origin=cfg.easyatwork.app_url, ui_version=cfg.easyatwork.ui_version, ) def _build_backend(cfg: Config) -> CalendarBackend: + title_fmt = cfg.sync.event_title_format + alarm_min = cfg.sync.alarm_minutes_before if cfg.backend == "ics": return IcsBackend( output_path=Path(cfg.backends.ics.output_path).expanduser(), known_shifts=[], + event_title_format=title_fmt, + alarm_minutes_before=alarm_min, ) if cfg.backend == "eventkit": from easyatcal.backends.eventkit import EventKitBackend return EventKitBackend( calendar_name=cfg.backends.eventkit.calendar_name, calendar_source=cfg.backends.eventkit.calendar_source, + event_title_format=title_fmt, + alarm_minutes_before=alarm_min, ) raise RuntimeError(f"Unknown backend: {cfg.backend}") @@ -309,6 +316,90 @@ def _prompt_ics_import(output_path: str) -> None: webbrowser.open("https://calendar.google.com/calendar/r/settings/export") +@app.command("schedule") +def schedule_cmd( + install: bool = typer.Option( + False, "--install", help="Install the background job automatically (macOS/Linux only)." + ), + interval_hours: int = typer.Option( + 6, "--interval-hours", help="How often to run the background sync (hours)." + ), +) -> None: + """Set up a background task to run eaw-sync automatically.""" + import os + import sys + import sysconfig + + # Get the absolute path to the eaw-sync executable + bin_path = os.path.join(sysconfig.get_path("scripts"), "eaw-sync") + if not os.path.exists(bin_path): + # Fallback to sys.executable and `-m easyatcal.cli`? Or just assume it's in PATH + bin_path = "eaw-sync" + + if sys.platform == "darwin": + plist_path = Path.home() / "Library/LaunchAgents/com.easyatcal.sync.plist" + plist_content = f""" + + + + Label + com.easyatcal.sync + ProgramArguments + + {bin_path} + sync + + StartInterval + {interval_hours * 3600} + RunAtLoad + + +""" + if install: + import subprocess + plist_path.parent.mkdir(parents=True, exist_ok=True) + plist_path.write_text(plist_content) + subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, check=False) + res = subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True, check=False) + if res.returncode == 0: + typer.secho(f"Successfully installed background sync via launchd (runs every {interval_hours}h).", fg="green") + else: + typer.secho(f"Failed to load launchd agent: {res.stderr.decode()}", fg="red") + else: + typer.echo(f"To schedule on macOS, save the following to {plist_path} and run `launchctl load {plist_path}`:") + typer.echo(plist_content) + + elif sys.platform == "linux": + cron_line = f"0 */{interval_hours} * * * {bin_path} sync >> {log_path()} 2>&1" + if install: + import subprocess + res = subprocess.run(["crontab", "-l"], capture_output=True, text=True, check=False) + current_cron = res.stdout if res.returncode == 0 else "" + if "eaw-sync" not in current_cron: + new_cron = current_cron + f"\n# EasyAtCal Auto-Sync\n{cron_line}\n" + proc = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE, text=True) + proc.communicate(input=new_cron) + typer.secho(f"Successfully installed background sync via crontab (runs every {interval_hours}h).", fg="green") + else: + typer.secho("eaw-sync is already in your crontab.", fg="yellow") + else: + typer.echo("To schedule on Linux, add the following line to your crontab (`crontab -e`):") + typer.echo(cron_line) + + elif sys.platform == "win32": + task_cmd = f'schtasks /create /tn "EasyAtCalSync" /tr "{bin_path} sync" /sc hourly /mo {interval_hours}' + if install: + import subprocess + res = subprocess.run(task_cmd, shell=True, capture_output=True, text=True, check=False) + if res.returncode == 0: + typer.secho(f"Successfully created Windows scheduled task (runs every {interval_hours}h).", fg="green") + else: + typer.secho(f"Failed to create task (try running terminal as Administrator): {res.stderr}", fg="red") + else: + typer.echo("To schedule on Windows, open an Administrator Command Prompt and run:") + typer.echo(task_cmd) + + @app.command("watch") def watch_cmd( interval_seconds: int = typer.Option( diff --git a/easyatcal/config.py b/easyatcal/config.py index 0e31d09..bd236c9 100644 --- a/easyatcal/config.py +++ b/easyatcal/config.py @@ -63,16 +63,21 @@ def _check_mode_fields(self) -> EasyAtWorkAuth: raise ValueError("auth_mode=user requires email") return self - def shifts_url(self) -> str: + def shifts_url(self, session_meta: dict | None = None) -> str: """Fully-qualified base URL of the shifts collection for this user.""" - if not self.api_url or not self.customer_id or not self.employee_id: + api_url = self.api_url or (session_meta or {}).get("api_url") + customer_id = self.customer_id or (session_meta or {}).get("customer_id") + employee_id = self.employee_id or (session_meta or {}).get("employee_id") + + if not api_url or not customer_id or not employee_id: raise ValueError( "auth_mode=user requires api_url, customer_id, employee_id " - "to build the shifts URL. Capture a HAR from the web app." + "to build the shifts URL. Capture a HAR from the web app " + "or re-run `eaw-sync login` to extract them automatically." ) return ( - f"{self.api_url.rstrip('/')}/customers/{self.customer_id}" - f"/employees/{self.employee_id}/shifts" + f"{api_url.rstrip('/')}/customers/{customer_id}" + f"/employees/{employee_id}/shifts" ) @@ -80,6 +85,8 @@ class SyncSettings(BaseModel): lookback_days: int = Field(ge=0, default=7) lookahead_days: int = Field(ge=1, default=90) user_id: str | None = None + event_title_format: str = "{title}" + alarm_minutes_before: int | None = None class EventKitSettings(BaseModel): diff --git a/easyatcal/session.py b/easyatcal/session.py index e2d1f09..c8b2277 100644 --- a/easyatcal/session.py +++ b/easyatcal/session.py @@ -7,11 +7,12 @@ from typing import Any import httpx +import keyring class SessionStore: """Persists Playwright ``storage_state`` (cookies + localStorage) - on disk with 0600 perms. + on disk with 0600 perms. Securely stores JWT in OS keyring. Playwright storage_state shape:: @@ -70,17 +71,37 @@ def cookies(self) -> httpx.Cookies | None: def clear(self) -> None: with contextlib.suppress(FileNotFoundError): self.path.unlink() + try: + import keyring + keyring.delete_password("easyatcal", "jwt") + except Exception: + pass - def access_token(self) -> str | None: - """Scan persisted localStorage for a JWT-looking value. + def eaw_meta(self) -> dict[str, Any] | None: + """Returns the extracted eaw_meta (api_url, customer_id, employee_id) + if it was intercepted during login. + """ + state = self.load() + if state is None: + return None + return state.get("eaw_meta") - easy@work's Angular SPA puts the bearer token in localStorage - under a key like ``access_token`` or ``token``. We accept any - value that looks like a JWT (three dot-separated segments). + def access_token(self) -> str | None: + """Get the JWT access token from the OS keyring, falling back to scanning + persisted localStorage (and upgrading it to keyring if found). """ + try: + token = keyring.get_password("easyatcal", "jwt") + if token: + return token + except Exception: + pass # keyring backend might be unavailable or locked + state = self.load() if state is None: return None + + found_token = None for origin in state.get("origins", []): for entry in origin.get("localStorage", []): name = entry.get("name") or "" @@ -94,5 +115,15 @@ def access_token(self) -> str | None: any(h in name.lower() for h in name_hints) or value.startswith("ey") ): - return value + found_token = value + break + if found_token: + break + + if found_token: + try: + keyring.set_password("easyatcal", "jwt", found_token) + except Exception: + pass + return found_token return None diff --git a/pyproject.toml b/pyproject.toml index 70490d7..a7d3830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "icalendar>=5.0", "typer>=0.12", "platformdirs>=4.0", + "keyring>=25.0", ] [project.optional-dependencies] From 1a3a911edae43c8bc0733b42c0666726496a53cf Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:48:35 +0200 Subject: [PATCH 45/68] docs: improve SEO with easy@work explanation and notable clients --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7a1c978..008041b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,12 @@ Works with **macOS EventKit** • **Google Calendar** • **Windows Outloo ## Overview -A CLI tool for syncing your [easy@work](https://www.easyatwork.com) shifts into Apple Calendar, Google Calendar, or standard ICS files. It runs locally, fetches your upcoming shifts, and pushes them to your preferred calendar app. It can also be run as a daemon to keep your calendar up to date in the background. +**EasyAtCal** is a CLI tool designed to automatically synchronize your [easy@work](https://www.easyatwork.com) work schedule directly into Apple Calendar, Google Calendar, or standard ICS files. It runs locally, fetches your upcoming shifts, and pushes them to your preferred personal calendar app. It can even be run as a background daemon to keep your calendar up to date continuously! + +### What is easy@work? +**easy@work** is a popular workforce management, timesheet, and employee scheduling platform used by major global brands, retail stores, and fast-food chains—most notably **McDonald's**. If you work at a McDonald's restaurant or any other company that uses the easy@work employee portal to handle your shift planning and rotas, **EasyAtCal** is the perfect companion to automate your personal schedule management. + +### Features - **Automated Login & Discovery.** No public API required. It uses Playwright to securely log in via a headless browser, extracting both your session token and your unique account IDs (`customer_id`, `employee_id`) automatically. - **Secure Session.** Your JWT is securely cached in your OS's native credential store (Keychain on macOS, Credential Locker on Windows) via the `keyring` library. From abaf2204c8a6053728bdb047244517a0a2551d15 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:55:54 +0200 Subject: [PATCH 46/68] test: add schedule command test coverage --- tests/test_cli_schedule.py | 53 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_cli_schedule.py diff --git a/tests/test_cli_schedule.py b/tests/test_cli_schedule.py new file mode 100644 index 0000000..52e051a --- /dev/null +++ b/tests/test_cli_schedule.py @@ -0,0 +1,53 @@ +import sys +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch +from typer.testing import CliRunner +from easyatcal.cli import app + +runner = CliRunner() + +def test_schedule_mac_install(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "darwin") + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + with patch("easyatcal.cli.Path.home") as mock_home: + mock_home.return_value = tmp_path + + result = runner.invoke(app, ["schedule", "--install", "--interval-hours", "6"]) + + assert result.exit_code == 0 + assert "Successfully installed background sync via launchd" in result.output + + plist_path = tmp_path / "Library/LaunchAgents/com.easyatcal.sync.plist" + assert plist_path.exists() + assert "StartInterval" in plist_path.read_text() + +def test_schedule_linux_install(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "linux") + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="* * * * * old_cron") + + with patch("subprocess.Popen") as mock_popen: + mock_proc = MagicMock() + mock_proc.communicate.return_value = ("", "") + mock_popen.return_value = mock_proc + + result = runner.invoke(app, ["schedule", "--install", "--interval-hours", "6"]) + + assert result.exit_code == 0 + assert "Successfully installed background sync via crontab" in result.output + +def test_schedule_windows_install(monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + + result = runner.invoke(app, ["schedule", "--install", "--interval-hours", "6"]) + + assert result.exit_code == 0 + assert "Successfully created Windows scheduled task" in result.output From aa834fcd2862095406cdc7a3b60bceda1e5b5aba Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:58:56 +0200 Subject: [PATCH 47/68] chore: bump version to v0.3.0 for feature release --- easyatcal/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/easyatcal/__init__.py b/easyatcal/__init__.py index 0b3155d..e6c4e0d 100644 --- a/easyatcal/__init__.py +++ b/easyatcal/__init__.py @@ -1,3 +1,3 @@ """EasyAtCal — one-way sync of easy@work shifts to Apple Calendar.""" -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/pyproject.toml b/pyproject.toml index a7d3830..e0c5dad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easyatcal" -version = "0.2.0" +version = "0.3.0" description = "One-way sync of easy@work shifts to Apple Calendar." readme = "README.md" requires-python = ">=3.11" From a574cb4f57e0e139081aee98425711391e60fbaa Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:29:15 +0200 Subject: [PATCH 48/68] fix: resolve ruff lint errors in tests and core files --- beacon_readme.md | 352 +++++++++++++++++++++++++++++++++++++ easyatcal/auth_user.py | 9 +- easyatcal/session.py | 4 +- pages.json | 3 + protection.json | 8 + test_regex.py | 6 + tests/test_cli_schedule.py | 4 +- 7 files changed, 375 insertions(+), 11 deletions(-) create mode 100644 beacon_readme.md create mode 100644 pages.json create mode 100644 protection.json create mode 100644 test_regex.py diff --git a/beacon_readme.md b/beacon_readme.md new file mode 100644 index 0000000..924f5bf --- /dev/null +++ b/beacon_readme.md @@ -0,0 +1,352 @@ +
+ +# BeaconMCP + +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/) +[![MCP Protocol](https://img.shields.io/badge/MCP-Model_Context_Protocol-5A67D8)](https://modelcontextprotocol.io/) +[![Proxmox VE](https://img.shields.io/badge/Proxmox-VE_8.x-E57000?logo=proxmox&logoColor=white)](https://www.proxmox.com/) +[![HP iLO](https://img.shields.io/badge/HP-iLO_4%2F5-0096D6?logo=hp&logoColor=white)](https://www.hpe.com/us/en/servers/integrated-lights-out-ilo.html) +[![IPMI](https://img.shields.io/badge/IPMI-2.0-4E5D70)](https://en.wikipedia.org/wiki/Intelligent_Platform_Management_Interface) +[![ChatGPT](https://img.shields.io/badge/ChatGPT-Compatible-74AA9C?logo=openai&logoColor=white)](https://chatgpt.com/) +[![Gemini](https://img.shields.io/badge/Gemini-Compatible-4285F4?logo=google&logoColor=white)](https://gemini.google.com/) +[![License](https://img.shields.io/badge/license-Apache_2.0_%2B_Commons_Clause-red)](LICENSE) + +**Remote MCP server for Proxmox VE clusters, BMC-managed hardware, and SSH hosts.** + +Works with **Assistant** (web, mobile, desktop) • **ChatGPT** • **Gemini** (CLI, API) + +[Installation](#installation) • [Connecting clients](#connecting-clients) • [Tools](#available-tools) • [Tests](#tests) + +
+ +--- + +## Overview + +BeaconMCP exposes a Proxmox VE cluster, the hardware underneath it (HP iLO, generic IPMI), and arbitrary SSH-reachable hosts as a single Streamable HTTP MCP server. Any MCP-capable client can diagnose a crash, power-cycle a frozen host, create or migrate VMs, and execute commands inside guests or on bare-metal nodes — through a single OAuth 2.1 endpoint. + +- **Independent capabilities.** Enable only what you have: a full Proxmox cluster, a couple of VPS reachable by SSH, a rack with IPMI BMCs only, or any combination. The server registers tools per capability, so an SSH-only deployment never exposes `proxmox_*` tools. +- **Three deployment modes out of the box:** + - *Proxmox + BMC + SSH* — the reference setup (a Proxmox cluster with iLO/IPMI hardware). + - *SSH-only* — point it at a handful of VPS or bare-metal servers; get the unified `ssh_run` tool backed by per-host credentials. + - *Proxmox-only* or *BMC-only* — mix and match as your inventory grows. +- **30+ MCP tools** across four modules: Proxmox (monitoring, VM lifecycle, system), SSH (per-host multi-target), BMC (hardware power/health), and security. +- **N nodes, N BMC devices, N SSH hosts.** No hard-coded counts. Each SSH host carries its own credentials (password or key file) and is declared under `ssh.hosts[]`. +- **Backend-agnostic hardware layer.** HP iLO, generic IPMI, and a universal **Redfish REST API** backend ship out of the box. Dell iDRAC (14G+) and Supermicro (X11+) automatically use the Redfish backend. +- **YAML-first configuration** with `${ENV}` references for secrets. Validation runs at startup. +- **OAuth 2.1 + TOTP.** Client credentials with mandatory second factor on every token issuance. +- **Optional web dashboard** — login, API-token management, and an (optional) integrated Gemini chat panel. + +--- + +## Architecture + +``` +Clients (Assistant, ChatGPT, Gemini) + │ + │ HTTPS (reverse proxy / tunnel) + ▼ +┌──────────────────────────────────┐ +│ BeaconMCP (HTTP :8420) │ +│ ├── proxmox/ → Proxmox API │ +│ ├── ssh/ → SSH :22 │ +│ ├── bmc/ → iLO / IPMI │ +│ └── dashboard/ → /app/* │ +└──────────────────────────────────┘ + │ + │ managed cluster + ▼ +Proxmox nodes (N) · BMC devices (N) +``` + +BeaconMCP runs on any host that can reach the Proxmox API of every declared node and the BMC management network. It speaks MCP over Streamable HTTP and is typically placed behind a reverse proxy with DNS-rebinding protection configured via `server.allowed_hosts` in the YAML. + +**Recommended deployment:** put BeaconMCP on the **same local network** as your Proxmox cluster — on one of the nodes, in a dedicated LXC / VM, or in a Docker container with host networking (see *Docker* below). That way every `proxmox.nodes[].host` is a plain **LAN IP** (e.g. `10.0.0.1`, `10.0.0.2`), usable as-is for both the Proxmox API (`:8006`) and for SSH (`:22`) — including the `ssh.inherit_proxmox_nodes` shortcut and the `bmc_*` SSH-jump tunnel for HP iLO on a private management VLAN. + +Public FQDNs with reverse-proxy ports (`pve2.example.com:443`) pin the entry to HTTPS and break the SSH inheritance — the SSH service is on port 22 of the node, not behind the HTTPS tunnel. For a truly remote node, declare it explicitly under `ssh.hosts[]` with its real SSH address (Tailscale IP, VPN, bastion…). + +--- + +## Requirements + +- Python 3.11+ +- Proxmox VE 8.x with API tokens provisioned on each node (Datacenter → Permissions → API Tokens) +- *(optional)* `ipmitool` binary on the BeaconMCP host if any IPMI BMC is configured +- *(optional)* reachable jump host (a Proxmox node) for HP iLO devices exposed only on a private management VLAN +- *(optional)* `GEMINI_API_KEY` to enable the integrated chat panel + +--- + +## Installation + +Two supported paths: **Docker** (quickest, isolated) or the **bare-metal install script** (native systemd service). Pick whichever fits your infra — they expose the same CLI and HTTP surface. + +### Option A — Docker (recommended for most setups) + +Requires Docker Engine 20.10+ with the Compose plugin. Runs on the Proxmox node itself, inside an LXC/VM on the same LAN, or on any box that can reach every declared node's API and SSH port directly. + +```bash +git clone https://github.com/Showdown76py/BeaconMCP.git +cd BeaconMCP +cp beaconmcp.yaml.example beaconmcp.yaml # edit for your topology +cp .env.example .env # fill in the ${VAR} secrets +docker compose up -d +``` + +The bundled [`docker-compose.yml`](docker-compose.yml) uses `network_mode: host` so the container sits directly on the LAN — LAN IPs in `proxmox.nodes[].host` just work for both the Proxmox API (`:8006`) and SSH (`:22`), which is what makes the `ssh.inherit_proxmox_nodes` shortcut practical. State (OAuth clients, dashboard DB, usage history) lives in a named volume `beaconmcp-state` and survives container recreation. + +Initial setup (run once, while the container is up): + +```bash +docker compose exec beaconmcp beaconmcp validate-config +docker compose exec beaconmcp beaconmcp auth create --name "Assistant Web" +curl http://localhost:8420/health # should return {"status":"ok",...} +``` + +The container listens on port 8420; put HTTPS + your FQDN in front with any reverse proxy (Caddy, nginx, Traefik, Cloudflare tunnel). + +**SSH key files.** If any of your `ssh.hosts[]` entries (or `ssh.defaults`) use `key_file:`, either copy the keys into the `beaconmcp-state` volume and reference them via `/state/keys/...`, or uncomment the `~/.ssh` bind mount in the compose file. Host paths like `~/.ssh/id_ed25519` don't exist inside the container — they're resolved against the container's filesystem. + +### Option B — Bare-metal install script + +SSH to the Proxmox node that will host BeaconMCP (we recommend your primary node — `pve1` in typical setups), then: + +```bash +git clone https://github.com/Showdown76py/BeaconMCP.git /opt/beaconmcp +cd /opt/beaconmcp +sudo bash deploy/install.sh +``` + +The install script creates a `beaconmcp` system user, installs the package in editable mode, registers a systemd unit, and creates `/opt/beaconmcp` for persistent state. + +### 2. Configure + +Two ways to produce `beaconmcp.yaml`: + +**Guided (TUI wizard).** A terminal UI walks you through each capability (Proxmox nodes, SSH, BMC, server) with a live YAML preview on the right and adds `${VAR}` placeholders to `.env` for the secrets you'll fill in after. The same command also **edits an existing** `beaconmcp.yaml` — it parses the file into the wizard, so you can tweak and re-save without losing anything: + +```bash +pip install 'beaconmcp[wizard]' # pulls the optional textual dep +beaconmcp init # creates OR edits beaconmcp.yaml, extends .env +beaconmcp init --blank # force a fresh draft even if the YAML exists +``` + +Arrow keys to browse sections, `enter` to open forms, `ctrl+s` to save without quitting, `q` to exit. + +**Manual.** Copy the example and edit: + +```bash +cp beaconmcp.yaml.example /opt/beaconmcp/beaconmcp.yaml +cp .env.example /opt/beaconmcp/.env +# Edit both: YAML defines the topology, .env holds the secrets. +``` + +Either way, the YAML declares Proxmox nodes, BMC devices, SSH credentials, the dashboard configuration, and DNS-rebinding allowlists. Secrets are referenced via `${ENV_VAR}` placeholders resolved at startup against the `.env` file. Validate the result without starting the server: + +```bash +beaconmcp validate-config +# prints the fully-resolved config with secrets masked, and a one-line summary. +``` + +### 3. Provision an OAuth client + +```bash +beaconmcp auth create --name "Assistant Web" +``` + +The CLI prints a client id, a client secret, and a TOTP seed (with an ASCII QR code). **Both secrets are displayed exactly once.** Scan the QR into an authenticator app (Google Authenticator, Authy, 1Password) immediately, or store the raw seed in a secrets manager. + +Repeat for each MCP client that should have access (ChatGPT, Gemini, etc.). Clients are listed and revoked with: + +```bash +beaconmcp auth list +beaconmcp auth revoke +``` + +### 4. Start the server + +```bash +sudo systemctl enable --now beaconmcp +curl http://localhost:8420/health +# {"status":"ok","server":"beaconmcp"} +``` + +### 5. Expose publicly + +Place BeaconMCP behind a reverse proxy that terminates TLS and forwards the public hostname to `http://localhost:8420`. Declare that hostname under `server.allowed_hosts` in `beaconmcp.yaml`; without it the MCP SDK rejects incoming requests with `421 Misdirected Request` (DNS-rebinding protection). If you're proxying through Cloudflare, add `cloudflare` to `server.trusted_proxies` so BeaconMCP can safely trust forwarded client IPs for auth rate limiting. + +### 6. Updating BeaconMCP + +Updating BeaconMCP requires pulling the latest code from GitHub and restarting the service. + +**For Docker setups:** +```bash +cd BeaconMCP +git pull +docker compose up -d --build +``` + +**For Bare-metal (systemd) setups:** +The installer script doubles as an updater. It will automatically stash your current state, pull the latest code, install any new dependencies into the virtual environment, and restart the service: +```bash +sudo bash /opt/beaconmcp/deploy/install.sh +``` + +--- + +## Connecting clients + +> **Security note — always type the TOTP by hand from your phone.** +> The TOTP seed belongs in an authenticator app on a device you physically control (Google Authenticator, Authy, 1Password, Aegis, a YubiKey with OTP, etc.). Do **not** generate codes programmatically with `oathtool` / `pyotp` / a shell alias, and do **not** store the raw seed in a `.env`, a secrets manager, or next to the client secret — doing so collapses the two factors into one and removes the protection TOTP exists to provide. Every flow below is designed so you read a 6-digit code off your phone and type it into either the authorization page or the dashboard. +> +> Unattended services (scheduled jobs, CI pipelines) occasionally need machine-held TOTP. That case — with its required precautions and warnings — is covered separately in [docs/totp-automation.md](docs/totp-automation.md). Read it end-to-end before considering automation. + +### Assistant (web, mobile, desktop) + +Assistant performs the full OAuth 2.1 flow against BeaconMCP, so there is no long-lived bearer to store on its side — you type the TOTP into the authorization page whenever a new token is issued. + +1. **Settings → Integrations → Add custom connector.** +2. Fill in: + - **Name:** BeaconMCP + - **Remote MCP server URL:** `https:///mcp` + - **OAuth Client ID** and **OAuth Client Secret** from `beaconmcp auth create`. +3. **Add.** + +On first use (and after each 24-hour token expiry) Assistant redirects to the BeaconMCP authorization page. Read the current 6-digit code from your authenticator app and type it in. Assistant never holds the TOTP seed, and a leaked session cannot mint a new token without a fresh code from your phone. + +**Important — web-origin allowlist.** Every browser-based MCP client (Assistant Web, ChatGPT, Le Chat, Perplexity, Gemini Web) sends a CORS preflight before it can reach `/mcp`, and OAuth HTTPS `redirect_uri` checks use the same list. Add each client's origin to `server.allowed_origins` in `beaconmcp.yaml` (see [`beaconmcp.yaml.example`](beaconmcp.yaml.example)). Desktop and CLI callback forms (`vscode://`, `cursor://`, loopback) are handled separately. + +### Other clients + +Full setup for **ChatGPT** (Web / Mobile / Codex CLI), **Gemini** (CLI / Antigravity / API), **Mistral** (Le Chat + Vibe), **OpenCode**, **VS Code**, and **Cursor** lives in [docs/clients.md](docs/clients.md). The dashboard's `/app/tokens` page shows the same snippets interactively. Perplexity is deprecating MCP (March 2026) and is no longer supported. + +--- + +## Dashboard + +An optional web panel is mounted under `/app/*` on the same port as the MCP endpoint. It provides TOTP login, an API-token management page (used to wire external clients like the Gemini web UI or ChatGPT MCP without exposing the OAuth flow), and an optional integrated Gemini chat. The chat panel is gated by `GEMINI_API_KEY`; the tokens page works without it. + +Full reference: [docs/dashboard.md](docs/dashboard.md). See also: [docs/clients.md](docs/clients.md) for external MCP client configuration. + +--- + +## Configuration + +Two files are read at startup: + +- **`beaconmcp.yaml`** — topology and feature flags. Path resolution: `--config` flag → `BEACONMCP_CONFIG` env → `./beaconmcp.yaml` → `/etc/beaconmcp/config.yaml`. See [`beaconmcp.yaml.example`](beaconmcp.yaml.example) for the full schema. +- **`.env`** — secrets referenced by the YAML as `${VAR}`. Missing references fail the startup check with the offending YAML path. + +Common keys: + +| Section | Notes | +|---------|-------| +| `server.allowed_hosts` | DNS-rebinding allowlist — **must** include the public FQDN behind your reverse proxy. | +| `server.allowed_origins` | Web-origin allowlist for browser CORS and OAuth HTTPS redirect URIs. | +| `server.trusted_proxies` | Direct peers allowed to supply `X-Forwarded-For` (IPs or CIDRs). Use `cloudflare` to auto-expand Cloudflare edge ranges. | +| `proxmox.nodes[]` | One entry per Proxmox node. Needs an API token per node. Prefer a **LAN IP** in `host:` (e.g. `10.0.0.1`) — it's the one string that works for both the Proxmox API and for SSH inheritance. `localhost` is OK when BeaconMCP runs directly on that node. Only use an FQDN with a reverse-proxy port (e.g. `:443`) for nodes you can't reach on the LAN, and declare those explicitly under `ssh.hosts[]` with their real SSH address. | +| `ssh.hosts[]` | One entry per SSH target (VPS, Proxmox node, jump box, …). Each entry carries its own `user` + exactly one of `password` / `key_file`. Names may match `proxmox.nodes[].name`. | +| `ssh.defaults` + `ssh.inherit_proxmox_nodes` | Homelab shortcut. Set `defaults:` (user + password/key_file) and flip `inherit_proxmox_nodes: true` — every Proxmox node becomes SSH-reachable under its own name with those defaults, no duplication. Explicit `ssh.hosts[]` entries still win when they match a node by name or address. | +| `ssh.vmid_to_ip` | Optional template (e.g. `"192.168.1.{id}"`) used by `ssh_run` when the `host` argument is a bare VMID. The resolved IP must match an `ssh.hosts[].host` to authenticate. Omit to disable numeric-ID shortcuts. | +| `bmc.devices[]` | Zero or more BMCs. `type` is one of `hp_ilo`, `ipmi`, `idrac` (redfish), `supermicro` (redfish), or `redfish`. `jump_host` is optional — set it to the name of a `proxmox.nodes[]` entry to route the connection over an SSH tunnel. | +| `features.dashboard.limits` | Per-5h and per-week USD caps for the Gemini chat. Set to `0` to disable a window. | + +--- + +## Security: manual review of sensitive actions + +> **Never let an LLM execute shell commands on infrastructure you care about without reading the command first.** + +BeaconMCP exposes tools that cause irreversible changes: `ssh_run`, `proxmox_run`, `bmc_power_off`, `proxmox_vm_stop`, `proxmox_vm_create`, `vm_bulk_action`, and more. Models do not always grasp the consequences of a command — an errant `rm -rf`, a `systemctl stop` on the wrong unit, a `pct destroy` mistaken for `pct stop`. A few working rules: + +- **Disable auto-approve** on every external MCP client (Assistant Desktop, Gemini CLI, ChatGPT MCP). Keep per-call approval enabled; refuse "always allow this tool". +- **Read the `command` argument** before approving any `ssh_run` or `proxmox_run` call. Ask: if this ran against the wrong VM or host, could I recover? +- **The integrated chat** at `/app/chat` already forces human confirmation for every `ssh_run` / `proxmox_run` call that carries a `command` (polling-only calls with just `exec_id` are read-only and skip the modal). Read the arguments shown on the confirmation card even when you click through fast. No answer within 5 minutes counts as refusal. +- **Prefer read-only tools** (`*_list_*`, `*_status`, `*_get_*`, `get_logs`, `health_status`) for exploration — they cannot break anything and are never gated by confirmation. +- **Do not share a `/app/tokens` bearer** with a client you do not fully control. A leaked token grants arbitrary shell access on your Proxmox nodes for 24 hours. + +`systemctl restart beaconmcp` invalidates every in-memory bearer. When in doubt about a token, restart is the panic lever. + +--- + +## Available tools + +### Proxmox — monitoring (6) + +| Tool | Description | +|------|-------------| +| `proxmox_list_nodes` | List cluster nodes and their status. | +| `proxmox_node_status` | CPU, memory, disk, uptime of a single node. | +| `proxmox_list_vms` | List every VM and container across the cluster. | +| `proxmox_vm_status` | Detailed state of a VM or container. | +| `proxmox_get_logs` | System or task logs. | +| `proxmox_get_tasks` | Recent task history. | + +### Proxmox — VM lifecycle (7) + +| Tool | Description | +|------|-------------| +| `proxmox_vm_start` | Start a VM or container. | +| `proxmox_vm_stop` | Stop (clean or forced). | +| `proxmox_vm_restart` | Restart. | +| `proxmox_vm_create` | Provision a new VM or container. | +| `proxmox_vm_clone` | Clone an existing one. | +| `proxmox_vm_migrate` | Migrate across nodes. | +| `proxmox_vm_config` | Read or update configuration. | +| `proxmox_snapshot_list` | List all snapshots for a VM or container. | +| `proxmox_snapshot_create` | Create a new snapshot. | +| `proxmox_snapshot_rollback` | Rollback a VM/CT to a previous snapshot. | +| `proxmox_snapshot_delete` | Delete an existing snapshot. | +| `proxmox_backup_create` | Trigger a new backup of a VM or container. | +| `proxmox_backup_list` | List available vzdump backup archives on a storage pool. | +| `proxmox_backup_restore` | Restore a VM or container from a backup archive. | + +### Proxmox — system (3) + +| Tool | Description | +|------|-------------| +| `proxmox_storage_status` | Storage pool status. | +| `proxmox_network_config` | Network configuration per node. | +| `proxmox_run` | Command inside a QEMU VM via QEMU Guest Agent. Sync by default; pass `wait=False` to start async, or `exec_id=` to poll an existing session. For LXC containers, use `ssh_run` on the node with `pct exec -- `. | +| `proxmox_read_file` | Safely read a file from a VM (via QEMU Guest Agent). | +| `proxmox_write_file` | Safely write a file to a VM (via QEMU Guest Agent). | + +### SSH fallback (2) + +| Tool | Description | +|------|-------------| +| `ssh_run` | Command on a host via SSH. `host` accepts node names, VMIDs, hostnames, or IPs. Sync by default; pass `wait=False` to start async, or `exec_id=` to poll. | +| `ssh_list_sessions` | List active and recent SSH sessions. | + +### BMC — hardware management (8) + +| Tool | Description | +|------|-------------| +| `bmc_list_devices` | List configured BMCs (`id`, `type`). Call first to discover valid `device_id` values. | +| `bmc_server_info` | Server model, serial, firmware. | +| `bmc_health_status` | Temperatures, fans, power supplies, disks, memory. | +| `bmc_power_status` | Current physical power state. | +| `bmc_power_on` | Power on. | +| `bmc_power_off` | ACPI shutdown (or `force=true` to cut power). | +| `bmc_power_reset` | Hard reset. | +| `bmc_get_event_log` | BMC event log (default 50, max 200). | + +Each `bmc_*` action tool takes a `device_id` argument. When only one device is configured, `device_id` is optional and defaults to that device. + +--- + +## Tests + +The project ships unit tests (`pytest`) for the dashboard and configuration, plus an integration script (`python tests/test_integration.py`) that exercises a live Proxmox cluster. Flags, prerequisites, and fixtures are documented in [docs/tests.md](docs/tests.md). + +--- + +## Troubleshooting + +Common errors, their causes, and the fixes that worked are in [docs/troubleshooting.md](docs/troubleshooting.md). + +--- + +## License + +[Apache 2.0 with Commons Clause](LICENSE) — use, fork, and modification are free, but **reselling the software (including as a hosted service) requires a separate commercial license**. The code remains source-available. diff --git a/easyatcal/auth_user.py b/easyatcal/auth_user.py index 1c14b64..8dfb666 100644 --- a/easyatcal/auth_user.py +++ b/easyatcal/auth_user.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib from pathlib import Path from typing import TYPE_CHECKING @@ -105,10 +106,8 @@ def on_request(request): # Wait a little longer just in case the API request hasn't fired yet if not discovered_meta: - try: + with contextlib.suppress(PWTimeout): page.wait_for_timeout(3000) - except PWTimeout: - pass state = context.storage_state() if discovered_meta: @@ -119,9 +118,7 @@ def on_request(request): tmp = storage_path.with_suffix(storage_path.suffix + ".tmp") tmp.write_text(json.dumps(state)) os.replace(tmp, storage_path) - try: + with contextlib.suppress(OSError): os.chmod(storage_path, 0o600) - except OSError: - pass finally: browser.close() diff --git a/easyatcal/session.py b/easyatcal/session.py index c8b2277..dea391d 100644 --- a/easyatcal/session.py +++ b/easyatcal/session.py @@ -121,9 +121,7 @@ def access_token(self) -> str | None: break if found_token: - try: + with contextlib.suppress(Exception): keyring.set_password("easyatcal", "jwt", found_token) - except Exception: - pass return found_token return None diff --git a/pages.json b/pages.json new file mode 100644 index 0000000..cb604f3 --- /dev/null +++ b/pages.json @@ -0,0 +1,3 @@ +{ + "build_type": "workflow" +} diff --git a/protection.json b/protection.json new file mode 100644 index 0000000..838584a --- /dev/null +++ b/protection.json @@ -0,0 +1,8 @@ +{ + "required_status_checks": null, + "enforce_admins": null, + "required_pull_request_reviews": null, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false +} diff --git a/test_regex.py b/test_regex.py new file mode 100644 index 0000000..4b22e70 --- /dev/null +++ b/test_regex.py @@ -0,0 +1,6 @@ +import re + +url = "https://eu-west-3.api.easyatwork.com/customers/1234/employees/5678/shifts?from=2024-01-01" +match = re.search(r"^(https?://[^/]+)/customers/(\d+)/employees/(\d+)", url) +if match: + print(match.groups()) diff --git a/tests/test_cli_schedule.py b/tests/test_cli_schedule.py index 52e051a..54f6ffd 100644 --- a/tests/test_cli_schedule.py +++ b/tests/test_cli_schedule.py @@ -1,8 +1,8 @@ import sys -import subprocess -from pathlib import Path from unittest.mock import MagicMock, patch + from typer.testing import CliRunner + from easyatcal.cli import app runner = CliRunner() From 8efba0dc769fa06a3a710a871b7a38bf26d7664f Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:33:18 +0200 Subject: [PATCH 49/68] docs: update security contact email to security@ailcope.dev --- SECURITY.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 98797a8..d5dbd39 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,7 +9,7 @@ production. **Do not file a public GitHub issue.** -Email the maintainer with the subject `[SECURITY] EasyAtCal`. Include: +Email the maintainer at `security@ailcope.dev` with the subject `[SECURITY] EasyAtCal`. Include: - Affected version (`eaw-sync --version`). - Reproduction steps or proof-of-concept. diff --git a/pyproject.toml b/pyproject.toml index e0c5dad..fb0ed7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "One-way sync of easy@work shifts to Apple Calendar." readme = "README.md" requires-python = ">=3.11" license = {text = "MIT"} -authors = [{name = "Ailcope"}] +authors = [{name = "Ailcope", email = "security@ailcope.dev"}] dependencies = [ "httpx>=0.27", "pydantic>=2.6", From b2d7766e8aae292aa6b638ebbd11acfe9d716ffb Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:36:58 +0200 Subject: [PATCH 50/68] fix: mock keyring in api tests and store user preferences --- easyatcal/cli.py | 20 ++++++++++++++++++-- easyatcal/state.py | 2 ++ tests/test_api_session.py | 15 +++++++++------ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/easyatcal/cli.py b/easyatcal/cli.py index b6a2e2f..de26e15 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -292,6 +292,8 @@ def _prompt_ics_import(output_path: str) -> None: import sys import webbrowser + from easyatcal.state import load_state, save_state + ics_path = os.path.expanduser(output_path) lang = os.environ.get("LANG") or (locale.getlocale()[0] or "") fr = lang.lower().startswith("fr") @@ -299,9 +301,15 @@ def _prompt_ics_import(output_path: str) -> None: typer.secho("\n📅 " + ("Synchronisation réussie !" if fr else "Calendar Sync Successful!"), fg="green", bold=True) typer.echo(("Vos horaires ont été enregistrés dans : " if fr else "Your shifts were saved to: ") + ics_path) + sp = state_path() + state = load_state(sp) + + pref_local = state.preferences.get("open_local", False) prompt_local = ("Voulez-vous ouvrir votre calendrier maintenant pour importer ces horaires ?" if fr else "Would you like to open your local Calendar app now to import these shifts?") - if typer.confirm(prompt_local): + + ans_local = typer.confirm(prompt_local, default=pref_local) + if ans_local: typer.secho("Ouverture du calendrier..." if fr else "Opening calendar app...", fg="cyan") if sys.platform == "darwin": subprocess.run(["open", ics_path], check=False) @@ -310,10 +318,18 @@ def _prompt_ics_import(output_path: str) -> None: else: subprocess.run(["xdg-open", ics_path], check=False) + pref_google = state.preferences.get("open_google", False) prompt_google = "Préférez-vous importer ceci dans Google Agenda ?" if fr else "Would you prefer to import this into Google Calendar?" - if typer.confirm(prompt_google): + + ans_google = typer.confirm(prompt_google, default=pref_google) + if ans_google: typer.secho("Ouverture de Google Agenda..." if fr else "Opening Google Calendar...", fg="cyan") webbrowser.open("https://calendar.google.com/calendar/r/settings/export") + + if ans_local != pref_local or ans_google != pref_google: + state.preferences["open_local"] = ans_local + state.preferences["open_google"] = ans_google + save_state(sp, state) @app.command("schedule") diff --git a/easyatcal/state.py b/easyatcal/state.py index 3a26303..a0db9f5 100644 --- a/easyatcal/state.py +++ b/easyatcal/state.py @@ -11,6 +11,7 @@ class State: shift_to_event: dict[str, str] = field(default_factory=dict) shift_updated_at: dict[str, str] = field(default_factory=dict) last_sync: str | None = None + preferences: dict[str, bool] = field(default_factory=dict) def load_state(path: Path) -> State: @@ -22,6 +23,7 @@ def load_state(path: Path) -> State: shift_to_event=dict(data.get("shift_to_event", {})), shift_updated_at=dict(data.get("shift_updated_at", {})), last_sync=data.get("last_sync"), + preferences=dict(data.get("preferences", {})), ) except (json.JSONDecodeError, ValueError): backup = path.with_suffix(path.suffix + ".bak") diff --git a/tests/test_api_session.py b/tests/test_api_session.py index 4cc2b7c..f2f8b4e 100644 --- a/tests/test_api_session.py +++ b/tests/test_api_session.py @@ -32,13 +32,16 @@ def _seeded_store(tmp_path: Path, token: str = FAKE_JWT) -> SessionStore: return store +from unittest.mock import patch + def test_no_token_raises_authenticate(tmp_path: Path) -> None: - client = SessionEawClient( - shifts_url=SHIFTS_URL, - session_store=SessionStore(tmp_path / "missing.json"), - ) - with pytest.raises(AuthError, match="No access token"): - client.authenticate() + with patch("keyring.get_password", return_value=None): + client = SessionEawClient( + shifts_url=SHIFTS_URL, + session_store=SessionStore(tmp_path / "missing.json"), + ) + with pytest.raises(AuthError, match="No access token"): + client.authenticate() @respx.mock From 5c86e1c0f92ab8c81c23cc0e212a79af3a89eac4 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:38:10 +0200 Subject: [PATCH 51/68] fix: add dtstamp and calscale to ics events to fix apple calendar import --- easyatcal/backends/ics.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py index 794400f..0a75fd3 100644 --- a/easyatcal/backends/ics.py +++ b/easyatcal/backends/ics.py @@ -37,6 +37,7 @@ def _to_event( ev.add("summary", title) ev.add("dtstart", shift.start) ev.add("dtend", shift.end) + ev.add("dtstamp", shift.updated_at) ev.add("last-modified", shift.updated_at) if shift.location: ev.add("location", shift.location) @@ -105,6 +106,7 @@ def _write(self) -> None: cal = Calendar() # type: ignore[no-untyped-call] cal.add("prodid", "-//EasyAtCal//EN") cal.add("version", "2.0") + cal.add("calscale", "GREGORIAN") for shift in self._current.values(): cal.add_component(_to_event( shift, From 0ecc1dcbbf7e8cd5d8f78790da30e6a1aef11702 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:40:07 +0200 Subject: [PATCH 52/68] fix: apple calendar ics event requirements and mock keyring --- easyatcal/backends/ics.py | 2 ++ tests/test_api_session.py | 19 ++++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py index 0a75fd3..5670889 100644 --- a/easyatcal/backends/ics.py +++ b/easyatcal/backends/ics.py @@ -39,6 +39,8 @@ def _to_event( ev.add("dtend", shift.end) ev.add("dtstamp", shift.updated_at) ev.add("last-modified", shift.updated_at) + ev.add("sequence", 0) + ev.add("status", "CONFIRMED") if shift.location: ev.add("location", shift.location) if shift.notes: diff --git a/tests/test_api_session.py b/tests/test_api_session.py index f2f8b4e..fee1977 100644 --- a/tests/test_api_session.py +++ b/tests/test_api_session.py @@ -81,15 +81,16 @@ def test_fetch_shifts_sends_bearer_and_laravel_params(tmp_path: Path) -> None: route = respx.get(SHIFTS_URL).mock( return_value=httpx.Response(200, json={"data": []}) ) - client = SessionEawClient( - shifts_url=SHIFTS_URL, - session_store=_seeded_store(tmp_path), - ) - client.fetch_shifts( - from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) - ) - req = route.calls.last.request - assert req.headers["Authorization"] == f"Bearer {FAKE_JWT}" + with patch("keyring.get_password", return_value=FAKE_JWT): + client = SessionEawClient( + shifts_url=SHIFTS_URL, + session_store=_seeded_store(tmp_path), + ) + client.fetch_shifts( + from_date=date(2026, 4, 20), to_date=date(2026, 4, 27) + ) + req = route.calls.last.request + assert req.headers["Authorization"] == f"Bearer {FAKE_JWT}" assert req.headers["X-Ui-Version"] == "2.313.0" # Space-separated Laravel datetime (url-encoded as %20 or +) qs = req.url.query.decode() From adb2d96612cf18937ff268e0a38efb26cf3525bb Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:43:29 +0200 Subject: [PATCH 53/68] fix: populate full shift list for ics backend to avoid empty files and preserve preferences --- easyatcal/backends/base.py | 4 ++++ easyatcal/backends/eventkit.py | 3 +++ easyatcal/backends/ics.py | 3 +++ easyatcal/orchestrator.py | 4 ++++ 4 files changed, 14 insertions(+) diff --git a/easyatcal/backends/base.py b/easyatcal/backends/base.py index d677b8e..5d983cd 100644 --- a/easyatcal/backends/base.py +++ b/easyatcal/backends/base.py @@ -41,6 +41,10 @@ def __init__(self, message: str, partial: ApplyResult) -> None: class CalendarBackend(Protocol): + def set_all_shifts(self, shifts: list[Shift]) -> None: + """Provide the backend with the complete list of known valid shifts.""" + ... + def apply(self, changes: Changes) -> ApplyResult: """Apply the given changes and return an ApplyResult. diff --git a/easyatcal/backends/eventkit.py b/easyatcal/backends/eventkit.py index ebaa909..97cb303 100644 --- a/easyatcal/backends/eventkit.py +++ b/easyatcal/backends/eventkit.py @@ -125,6 +125,9 @@ def _resolve_calendar(self) -> Any: f"{self.calendar_source!r}. Create it in Calendar.app first." ) + def set_all_shifts(self, shifts: list[Shift]) -> None: + pass + def apply(self, changes: Changes) -> ApplyResult: result = ApplyResult() try: diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py index 5670889..72010a7 100644 --- a/easyatcal/backends/ics.py +++ b/easyatcal/backends/ics.py @@ -76,6 +76,9 @@ def __init__( self.event_title_format = event_title_format self.alarm_minutes_before = alarm_minutes_before + def set_all_shifts(self, shifts: list[Shift]) -> None: + self._current = {s.id: s for s in shifts} + def apply(self, changes: Changes) -> ApplyResult: mapping: dict[str, str] = {} diff --git a/easyatcal/orchestrator.py b/easyatcal/orchestrator.py index 5d74255..b0420a5 100644 --- a/easyatcal/orchestrator.py +++ b/easyatcal/orchestrator.py @@ -66,6 +66,9 @@ def run_sync( extra={"event_id": "sync.compute_changes.ok"} ) + if hasattr(backend, "set_all_shifts"): + backend.set_all_shifts(remote_shifts) + raised: BackendError | None = None try: result: ApplyResult = backend.apply(changes) @@ -150,5 +153,6 @@ def _persist( shift_to_event=new_shift_to_event, shift_updated_at=new_updated_at, last_sync=now.isoformat(), + preferences=state.preferences.copy(), ), ) From d297897d3c6cfeffb67ef666f16ed87984fdcdb6 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:47:13 +0200 Subject: [PATCH 54/68] feat: extract and map address from schedule.customer to shift location --- easyatcal/api_session.py | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/easyatcal/api_session.py b/easyatcal/api_session.py index 0275471..330e4a9 100644 --- a/easyatcal/api_session.py +++ b/easyatcal/api_session.py @@ -2,7 +2,7 @@ import contextlib import time -from datetime import date, datetime +from datetime import UTC, date, datetime from typing import Any import httpx @@ -210,23 +210,37 @@ def pick(*keys: str) -> Any: # Title: prefer schedule.customer.name if included (matches `with[]`) title = pick("title", "name", "label") - if title is None: - schedule = raw.get("schedule") - if isinstance(schedule, dict): - customer = schedule.get("customer") - if isinstance(customer, dict): + location = pick("location", "place", "site") + notes = pick("notes", "description", "comments") + + schedule = raw.get("schedule") + if isinstance(schedule, dict): + customer = schedule.get("customer") + if isinstance(customer, dict): + if title is None: title = customer.get("name") + + # If no direct location, try to extract address from customer + if location is None: + addr_parts = [] + for k in ("address1", "address2", "postal_code", "city"): + val = customer.get(k) + if val and str(val).strip(): + addr_parts.append(str(val).strip()) + if addr_parts: + location = ", ".join(addr_parts) + if not title: title = "Shift" return Shift( id=str(id_val), - start=_parse_dt(str(start_val)), - end=_parse_dt(str(end_val)), + start=_parse_dt(start_val), + end=_parse_dt(end_val), title=str(title), - location=pick("location", "place", "site"), - notes=pick("notes", "note", "comment"), - updated_at=_parse_dt(str(updated_val or start_val)), + location=str(location) if location else None, + notes=str(notes) if notes else None, + updated_at=_parse_dt(updated_val) if updated_val else datetime.now(UTC), ) From 1f7fc55faa4799d8ff95bff0184a1755b42e4b52 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:49:15 +0200 Subject: [PATCH 55/68] fix: use current time for dtstamp and sequence in ics to force apple calendar updates --- easyatcal/backends/ics.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py index 72010a7..4d848ac 100644 --- a/easyatcal/backends/ics.py +++ b/easyatcal/backends/ics.py @@ -21,7 +21,7 @@ def _to_event( event_title_format: str = "{title}", alarm_minutes_before: int | None = None, ) -> Any: - from datetime import timedelta + from datetime import UTC, datetime, timedelta from icalendar import Alarm ev = Event() # type: ignore[no-untyped-call] @@ -37,9 +37,12 @@ def _to_event( ev.add("summary", title) ev.add("dtstart", shift.start) ev.add("dtend", shift.end) - ev.add("dtstamp", shift.updated_at) - ev.add("last-modified", shift.updated_at) - ev.add("sequence", 0) + # Always use current time for dtstamp to indicate when the file was generated + now = datetime.now(UTC) + ev.add("dtstamp", now) + # Force an update by bumping the sequence (or using the timestamp) and updating last-modified + ev.add("last-modified", now) + ev.add("sequence", int(now.timestamp())) ev.add("status", "CONFIRMED") if shift.location: ev.add("location", shift.location) From 9f2d48aace940f3b959bea88501a41a4246a132a Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:50:18 +0200 Subject: [PATCH 56/68] feat: enrich ics file with universal client compatibility tags (outlook, apple, google) --- easyatcal/backends/ics.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py index 4d848ac..1e9060a 100644 --- a/easyatcal/backends/ics.py +++ b/easyatcal/backends/ics.py @@ -44,6 +44,8 @@ def _to_event( ev.add("last-modified", now) ev.add("sequence", int(now.timestamp())) ev.add("status", "CONFIRMED") + ev.add("transp", "OPAQUE") # Standard: Show as busy + ev.add("X-MICROSOFT-CDO-BUSYSTATUS", "BUSY") # Outlook specific if shift.location: ev.add("location", shift.location) if shift.notes: @@ -115,6 +117,9 @@ def _write(self) -> None: cal.add("prodid", "-//EasyAtCal//EN") cal.add("version", "2.0") cal.add("calscale", "GREGORIAN") + cal.add("method", "PUBLISH") # Crucial for Outlook + cal.add("x-wr-calname", "easy@work") # Apple Calendar display name + cal.add("x-wr-caldesc", "Work shifts imported from easy@work") for shift in self._current.values(): cal.add_component(_to_event( shift, From bf167a11e39c6be5455d09e84bc0cc346d0273a5 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:52:43 +0200 Subject: [PATCH 57/68] feat: bilingual configuration wizard with french language support --- easyatcal/cli.py | 97 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/easyatcal/cli.py b/easyatcal/cli.py index de26e15..cc21706 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -138,15 +138,106 @@ def _build_backend(cfg: Config) -> CalendarBackend: # ---------- config ---------- @config_app.command("init") -def config_init() -> None: +def config_init( + interactive: bool = typer.Option( + True, + "--interactive/--no-interactive", + help="Prompt for common configuration values interactively.", + ), +) -> None: """Scaffold a config file at the user config dir.""" target = _cfg_path() if target.exists(): typer.echo(f"Config already exists at {target}", err=True) raise typer.Exit(code=1) target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(EXAMPLE_CONFIG, target) - typer.echo(f"Wrote {target}. Edit it before running `eaw-sync sync`.") + + with open(EXAMPLE_CONFIG, "r") as f: + template = f.read() + + import locale + import os + import sys + + lang = os.environ.get("LANG") or (locale.getlocale()[0] or "") + fr = lang.lower().startswith("fr") + + if interactive: + + if fr: + typer.secho("🔧 Configurons la synchronisation de votre calendrier easy@work !\n", fg="cyan", bold=True) + email = typer.prompt("1. Quel est l'email de votre compte easy@work ?") + + title_format = typer.prompt( + "2. Comment souhaitez-vous nommer vos événements ?\n (Variables disponibles: {title}, {location}, {notes})", + default="{title}" + ) + + wants_alarm = typer.confirm("3. Voulez-vous un rappel avant vos shifts ?") + if wants_alarm: + alarm_mins = typer.prompt(" Combien de minutes avant le shift ?", default=60, type=int) + template = template.replace('alarm_minutes_before: null', f'alarm_minutes_before: {alarm_mins}') + else: + typer.secho("🔧 Let's set up your easy@work calendar sync!\n", fg="cyan", bold=True) + email = typer.prompt("1. What is your easy@work login email?") + + title_format = typer.prompt( + "2. How should we name your calendar events?\n (Available variables: {title}, {location}, {notes})", + default="{title}" + ) + + wants_alarm = typer.confirm("3. Do you want a reminder before your shifts?") + if wants_alarm: + alarm_mins = typer.prompt(" How many minutes before your shift?", default=60, type=int) + template = template.replace('alarm_minutes_before: null', f'alarm_minutes_before: {alarm_mins}') + + template = template.replace('email: "me@example.com"', f'email: "{email}"') + template = template.replace('event_title_format: "{title}"', f'event_title_format: "{title_format}"') + + backend_choices = ["ics"] + if sys.platform == "darwin": + backend_choices.append("eventkit") + if fr: + prompt_backend = "4. Quelle intégration de calendrier préférez-vous ?\n [ics] Fichier universel (Compatible avec tout)\n [eventkit] Directement dans Apple Calendar (macOS uniquement)\n " + else: + prompt_backend = "4. Which calendar integration do you prefer?\n [ics] File-based (Universal)\n [eventkit] Direct to Apple Calendar (macOS only)\n " + + backend = typer.prompt(prompt_backend, default="ics") + if backend in ["ics", "eventkit"]: + template = template.replace('backend: ics', f'backend: {backend}') + else: + if fr: + typer.echo("4. Utilisation du backend 'ics' (format universel pour Windows/Linux).") + else: + typer.echo("4. Using 'ics' backend (universal format for Windows/Linux).") + + if fr: + typer.secho("\n✅ Configuration générée avec succès !", fg="green") + else: + typer.secho("\n✅ Configuration generated successfully!", fg="green") + + with open(target, "w") as f: + f.write(template) + + if fr: + typer.echo(f"Fichier écrit dans {target}.") + else: + typer.echo(f"Wrote {target}.") + + if interactive: + if fr: + typer.secho("\nProchaines étapes :", fg="cyan", bold=True) + typer.echo("1. Lancez `eaw-sync login` pour vous connecter.") + typer.echo("2. Lancez `eaw-sync sync` pour récupérer vos horaires.") + else: + typer.secho("\nNext steps:", fg="cyan", bold=True) + typer.echo("1. Run `eaw-sync login` to connect to your account.") + typer.echo("2. Run `eaw-sync sync` to fetch your shifts.") + else: + if fr: + typer.echo("Modifiez le fichier avant de lancer `eaw-sync sync`.") + else: + typer.echo("Edit the file before running `eaw-sync sync`.") @config_app.command("show") From a74094dc89243fac2bede979f09ad47653ef3d53 Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Fri, 22 May 2026 23:31:20 +0200 Subject: [PATCH 58/68] chore: remove leaked HAR captures and scratch file, ignore *.har MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTPToolkit HAR files contained a live easy@work bearer JWT and cookies. Remove from tracking and add *.har to .gitignore. Token must be rotated and history scrubbed separately — deletion alone does not purge git history. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + test_regex.py | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 test_regex.py diff --git a/.gitignore b/.gitignore index 8a55d60..4bf9920 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ config.yaml .env *.ics +*.har state.json token.json .cache/ diff --git a/test_regex.py b/test_regex.py deleted file mode 100644 index 4b22e70..0000000 --- a/test_regex.py +++ /dev/null @@ -1,6 +0,0 @@ -import re - -url = "https://eu-west-3.api.easyatwork.com/customers/1234/employees/5678/shifts?from=2024-01-01" -match = re.search(r"^(https?://[^/]+)/customers/(\d+)/employees/(\d+)", url) -if match: - print(match.groups()) From 25b72bbfaac5701683b3b90c5bee4a07927cf94d Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Fri, 22 May 2026 23:31:23 +0200 Subject: [PATCH 59/68] refactor: dedup french detection and drop redundant imports Extract _is_french() helper used by both config init and the ICS import prompt. Remove a duplicate keyring import in SessionStore.clear. Co-Authored-By: Claude Opus 4.7 --- easyatcal/cli.py | 19 +++++++++++-------- easyatcal/session.py | 5 +---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/easyatcal/cli.py b/easyatcal/cli.py index cc21706..36da0ba 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -48,6 +48,14 @@ def _get_log_level(cfg_level: str) -> str: return _LOG_LEVEL_OVERRIDE if _LOG_LEVEL_OVERRIDE is not None else cfg_level +def _is_french() -> bool: + import locale + import os + + lang = os.environ.get("LANG") or (locale.getlocale()[0] or "") + return lang.lower().startswith("fr") + + def _version_callback(value: bool) -> None: if value: from easyatcal import __version__ @@ -155,12 +163,9 @@ def config_init( with open(EXAMPLE_CONFIG, "r") as f: template = f.read() - import locale - import os import sys - - lang = os.environ.get("LANG") or (locale.getlocale()[0] or "") - fr = lang.lower().startswith("fr") + + fr = _is_french() if interactive: @@ -377,7 +382,6 @@ def sync_cmd( def _prompt_ics_import(output_path: str) -> None: - import locale import os import subprocess import sys @@ -386,8 +390,7 @@ def _prompt_ics_import(output_path: str) -> None: from easyatcal.state import load_state, save_state ics_path = os.path.expanduser(output_path) - lang = os.environ.get("LANG") or (locale.getlocale()[0] or "") - fr = lang.lower().startswith("fr") + fr = _is_french() typer.secho("\n📅 " + ("Synchronisation réussie !" if fr else "Calendar Sync Successful!"), fg="green", bold=True) typer.echo(("Vos horaires ont été enregistrés dans : " if fr else "Your shifts were saved to: ") + ics_path) diff --git a/easyatcal/session.py b/easyatcal/session.py index dea391d..095a1dd 100644 --- a/easyatcal/session.py +++ b/easyatcal/session.py @@ -71,11 +71,8 @@ def cookies(self) -> httpx.Cookies | None: def clear(self) -> None: with contextlib.suppress(FileNotFoundError): self.path.unlink() - try: - import keyring + with contextlib.suppress(Exception): keyring.delete_password("easyatcal", "jwt") - except Exception: - pass def eaw_meta(self) -> dict[str, Any] | None: """Returns the extracted eaw_meta (api_url, customer_id, employee_id) From 9c2da5252283c84b5f9cd1cadb59c2e40cf67a6b Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Fri, 22 May 2026 23:31:27 +0200 Subject: [PATCH 60/68] fix: raise on unrecognized shifts payload shape _iter_rows returned [] for any payload without a known rows key, so a malformed API page would silently sync zero shifts and prune every tracked event as a phantom delete. Raise ValueError instead (wrapped as ApiError upstream); empty-but-recognized pages still return []. Also drop a redundant local UTC import. Co-Authored-By: Claude Opus 4.7 --- easyatcal/api_session.py | 8 +++++--- tests/test_api_session.py | 9 +++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/easyatcal/api_session.py b/easyatcal/api_session.py index 330e4a9..1341a76 100644 --- a/easyatcal/api_session.py +++ b/easyatcal/api_session.py @@ -157,7 +157,11 @@ def _iter_rows(payload: Any) -> list[dict[str, Any]]: v = payload.get(key) if isinstance(v, list): return v - return [] + raise ValueError( + f"no recognized rows key (data/results/items/shifts) in payload; " + f"keys present: {list(payload)}" + ) + raise ValueError(f"unexpected payload type: {type(payload).__name__}") def _next_url(payload: Any) -> str | None: @@ -250,8 +254,6 @@ def _parse_dt(s: str) -> datetime: are treated as UTC — the easy@work API sends tenant-local timestamps without an offset. """ - from datetime import UTC - try: dt = datetime.fromisoformat(s) except ValueError: diff --git a/tests/test_api_session.py b/tests/test_api_session.py index fee1977..d6e6587 100644 --- a/tests/test_api_session.py +++ b/tests/test_api_session.py @@ -176,8 +176,13 @@ def test_iter_rows_shapes() -> None: assert _iter_rows({"results": [{"a": 1}]}) == [{"a": 1}] assert _iter_rows({"items": [{"a": 1}]}) == [{"a": 1}] assert _iter_rows({"shifts": [{"a": 1}]}) == [{"a": 1}] - assert _iter_rows({"nope": 1}) == [] - assert _iter_rows("string") == [] + # Empty-but-recognized page is a legit empty result. + assert _iter_rows({"data": []}) == [] + # Unrecognized shapes must raise, not silently sync zero shifts. + with pytest.raises(ValueError, match="no recognized rows key"): + _iter_rows({"nope": 1}) + with pytest.raises(ValueError, match="unexpected payload type"): + _iter_rows("string") def test_parse_shift_missing_fields_raises() -> None: From c17f0f02cb5cf89ce719ce77b1b7defca4ebeb5d Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Fri, 22 May 2026 23:41:28 +0200 Subject: [PATCH 61/68] test: run config init non-interactively in test The interactive wizard now prompts by default, so the test exited 1 on EOF. Pass --no-interactive to test the scaffold path deterministically. Co-Authored-By: Claude Opus 4.7 --- tests/test_cli_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py index b932ee2..a07b858 100644 --- a/tests/test_cli_config.py +++ b/tests/test_cli_config.py @@ -11,7 +11,7 @@ def test_config_init_creates_file(tmp_path: Path): target = tmp_path / "config.yaml" with patch("easyatcal.cli.config_path", return_value=target): - result = runner.invoke(app, ["config", "init"]) + result = runner.invoke(app, ["config", "init", "--no-interactive"]) assert result.exit_code == 0, result.stdout assert target.exists() From 7aad3d1dc5a9ed41d2be974248b03080218c2c3c Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Fri, 22 May 2026 23:41:36 +0200 Subject: [PATCH 62/68] fix: preserve past shifts across syncs A sync only fetches shifts in [now - lookback, now + lookahead]. Any tracked shift not in that window was treated as deleted, so shifts silently disappeared from the calendar ~lookback days after they occurred. Now a missing shift is only deleted when its recorded start falls inside the fetched window (a genuine cancellation). Shifts that aged out of the window are left alone. - State gains shift_start (shift_id -> ISO start) to distinguish a cancellation from an aged-out shift. - compute_changes takes the window + known_start and guards deletes. - IcsBackend seeds itself from the existing .ics and merges, so regeneration keeps events outside the current window. Raw title stored in X-EASYATCAL-TITLE so custom title formats are not re-applied on reload. Co-Authored-By: Claude Opus 4.7 --- easyatcal/backends/ics.py | 59 ++++++++++++++++++++++++++++- easyatcal/cli.py | 7 +++- easyatcal/orchestrator.py | 19 ++++++++-- easyatcal/state.py | 4 ++ easyatcal/sync.py | 35 +++++++++++++++++- tests/backends/test_ics.py | 16 ++++++++ tests/test_e2e_integration.py | 9 +++++ tests/test_state.py | 12 ++++++ tests/test_sync.py | 70 +++++++++++++++++++++++++++++++---- 9 files changed, 215 insertions(+), 16 deletions(-) diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py index 1e9060a..e978834 100644 --- a/easyatcal/backends/ics.py +++ b/easyatcal/backends/ics.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -9,12 +10,45 @@ from easyatcal.models import Shift UID_PREFIX = "easyatcal-" +# Holds the unformatted title so a custom event_title_format is not re-applied +# to an already-formatted summary when an old event is reloaded from the file. +RAW_TITLE_PROP = "X-EASYATCAL-TITLE" def _uid_for(shift_id: str) -> str: return f"{UID_PREFIX}{shift_id}" +def _shift_from_event(comp: Any) -> Shift | None: + """Reconstruct a Shift from a VEVENT we previously wrote. Returns None for + events we cannot faithfully rebuild (e.g. all-day or naive datetimes).""" + uid = str(comp.get("uid", "")) + if not uid.startswith(UID_PREFIX): + return None + try: + start = comp.decoded("dtstart") + end = comp.decoded("dtend") + except (KeyError, ValueError): + return None + if not isinstance(start, datetime) or not isinstance(end, datetime): + return None + raw_title = comp.get(RAW_TITLE_PROP) or comp.get("summary") + location = comp.get("location") + notes = comp.get("description") + try: + return Shift( + id=uid[len(UID_PREFIX):], + start=start, + end=end, + title=str(raw_title) if raw_title else "Shift", + location=str(location) if location else None, + notes=str(notes) if notes else None, + updated_at=datetime.now(UTC), + ) + except ValueError: + return None + + def _to_event( shift: Shift, uid: str, @@ -35,6 +69,7 @@ def _to_event( ).strip() ev.add("summary", title) + ev.add(RAW_TITLE_PROP, shift.title) ev.add("dtstart", shift.start) ev.add("dtend", shift.end) # Always use current time for dtstamp to indicate when the file was generated @@ -77,12 +112,32 @@ def __init__( alarm_minutes_before: int | None = None, ) -> None: self.output_path = Path(output_path).expanduser() - self._current: dict[str, Shift] = {s.id: s for s in known_shifts} self.event_title_format = event_title_format self.alarm_minutes_before = alarm_minutes_before + # Seed from any existing file so events outside the current fetch window + # survive regeneration, then overlay caller-provided known shifts. + self._current: dict[str, Shift] = self._load_existing() + for s in known_shifts: + self._current[s.id] = s + + def _load_existing(self) -> dict[str, Shift]: + if not self.output_path.exists(): + return {} + try: + cal = Calendar.from_ical(self.output_path.read_bytes()) + except (ValueError, KeyError): + return {} + out: dict[str, Shift] = {} + for comp in cal.walk("VEVENT"): + shift = _shift_from_event(comp) + if shift is not None: + out[shift.id] = shift + return out def set_all_shifts(self, shifts: list[Shift]) -> None: - self._current = {s.id: s for s in shifts} + # Merge: refresh window shifts without dropping previously-known ones. + for s in shifts: + self._current[s.id] = s def apply(self, changes: Changes) -> ApplyResult: mapping: dict[str, str] = {} diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 36da0ba..45df379 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -347,7 +347,12 @@ def sync_cmd( ) state = load_state(state_path()) changes = compute_changes( - remote, state, known_updated_at=state.shift_updated_at + remote, + state, + known_updated_at=state.shift_updated_at, + from_date=from_date, + to_date=to_date, + known_start=state.shift_start, ) typer.echo( f"Dry run: {len(changes.adds)} add, " diff --git a/easyatcal/orchestrator.py b/easyatcal/orchestrator.py index b0420a5..c8abd7d 100644 --- a/easyatcal/orchestrator.py +++ b/easyatcal/orchestrator.py @@ -59,7 +59,12 @@ def run_sync( state = load_state(state_path) changes = compute_changes( - remote_shifts, state, known_updated_at=state.shift_updated_at + remote_shifts, + state, + known_updated_at=state.shift_updated_at, + from_date=from_date, + to_date=to_date, + known_start=state.shift_start, ) logger.info( f"Computed changes: {len(changes.adds)} adds, {len(changes.updates)} updates, {len(changes.deletes)} deletes", @@ -123,16 +128,18 @@ def _persist( ) -> None: new_shift_to_event = dict(state.shift_to_event) new_updated_at = dict(state.shift_updated_at) + new_start = dict(state.shift_start) for shift_id, event_uid in result.mapping.items(): new_shift_to_event[shift_id] = event_uid - # For every shift we successfully wrote, stamp the new updated_at. + # For every shift we successfully wrote, stamp the new updated_at and start. remote_by_id = {s.id: s for s in remote_shifts} for shift_id in result.mapping: shift = remote_by_id.get(shift_id) if shift is not None: new_updated_at[shift_id] = shift.updated_at.isoformat() + new_start[shift_id] = shift.start.isoformat() # Prune confirmed deletions. deleted_uid_set = set(result.deleted_uids) @@ -141,17 +148,21 @@ def _persist( for sid, evt in new_shift_to_event.items() if evt not in deleted_uid_set } - # Drop matching updated_at entries for any shift whose event we just - # deleted (its shift_id no longer maps to an event in new_shift_to_event). + # Drop metadata for any shift whose event we just deleted (its shift_id no + # longer maps to an event in new_shift_to_event). new_updated_at = { sid: ts for sid, ts in new_updated_at.items() if sid in new_shift_to_event } + new_start = { + sid: ts for sid, ts in new_start.items() if sid in new_shift_to_event + } save_state( state_path, State( shift_to_event=new_shift_to_event, shift_updated_at=new_updated_at, + shift_start=new_start, last_sync=now.isoformat(), preferences=state.preferences.copy(), ), diff --git a/easyatcal/state.py b/easyatcal/state.py index a0db9f5..8076884 100644 --- a/easyatcal/state.py +++ b/easyatcal/state.py @@ -10,6 +10,9 @@ class State: shift_to_event: dict[str, str] = field(default_factory=dict) shift_updated_at: dict[str, str] = field(default_factory=dict) + # shift_id -> ISO start datetime, used to tell a cancelled shift from one + # that merely aged out of the fetch window. + shift_start: dict[str, str] = field(default_factory=dict) last_sync: str | None = None preferences: dict[str, bool] = field(default_factory=dict) @@ -22,6 +25,7 @@ def load_state(path: Path) -> State: return State( shift_to_event=dict(data.get("shift_to_event", {})), shift_updated_at=dict(data.get("shift_updated_at", {})), + shift_start=dict(data.get("shift_start", {})), last_sync=data.get("last_sync"), preferences=dict(data.get("preferences", {})), ) diff --git a/easyatcal/sync.py b/easyatcal/sync.py index 19e0645..b82ce6d 100644 --- a/easyatcal/sync.py +++ b/easyatcal/sync.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import date, datetime + from easyatcal.backends.base import Changes from easyatcal.models import Shift from easyatcal.state import State @@ -9,10 +11,21 @@ def compute_changes( remote_shifts: list[Shift], state: State, known_updated_at: dict[str, str], + *, + from_date: date, + to_date: date, + known_start: dict[str, str], ) -> Changes: """Diff remote shifts against the last-known state. - known_updated_at maps shift_id -> ISO-formatted updated_at recorded at last sync. + known_updated_at maps shift_id -> ISO updated_at recorded at last sync. + known_start maps shift_id -> ISO start datetime recorded at last sync. + + A tracked shift absent from ``remote_shifts`` is only deleted when its + recorded start falls inside the fetched window ``[from_date, to_date]`` — + i.e. the API was actually asked about it and reported it gone. Shifts that + merely aged out of the window (or whose start we never recorded) are left + untouched, so past shifts are never deleted by a later sync. """ remote_by_id = {s.id: s for s in remote_shifts} adds: list[Shift] = [] @@ -29,7 +42,25 @@ def compute_changes( updates.append((shift, event_uid)) for shift_id, event_uid in state.shift_to_event.items(): - if shift_id not in remote_by_id: + if shift_id in remote_by_id: + continue + if _start_in_window(known_start.get(shift_id), from_date, to_date): deletes.append(event_uid) return Changes(adds=adds, updates=updates, deletes=deletes) + + +def _start_in_window( + start_iso: str | None, from_date: date, to_date: date +) -> bool: + """True only when we know the shift's start and it lies in the window. + + Unknown or unparseable starts return False so the shift is preserved. + """ + if not start_iso: + return False + try: + start = datetime.fromisoformat(start_iso).date() + except ValueError: + return False + return from_date <= start <= to_date diff --git a/tests/backends/test_ics.py b/tests/backends/test_ics.py index f9a18cd..e86b4ac 100644 --- a/tests/backends/test_ics.py +++ b/tests/backends/test_ics.py @@ -65,3 +65,19 @@ def test_updates_replace_event(tmp_path: Path): body = out.read_text() assert "SUMMARY:New Title" in body assert "SUMMARY:Shift s1" not in body + + +def test_existing_events_preserved_when_not_in_new_shifts(tmp_path: Path): + out = tmp_path / "shifts.ics" + # First sync writes an old shift. + IcsBackend(output_path=out, known_shifts=[]).apply(Changes(adds=[_shift("old")])) + + # Second sync: the CLI builds a fresh backend each run, and the fetch + # window no longer contains "old" — only "new" is reported. + backend2 = IcsBackend(output_path=out, known_shifts=[]) + backend2.set_all_shifts([_shift("new")]) + backend2.apply(Changes(adds=[_shift("new")])) + + body = out.read_text() + assert "SUMMARY:Shift old" in body # preserved across regeneration + assert "SUMMARY:Shift new" in body diff --git a/tests/test_e2e_integration.py b/tests/test_e2e_integration.py index 03cc484..f086af0 100644 --- a/tests/test_e2e_integration.py +++ b/tests/test_e2e_integration.py @@ -1,4 +1,5 @@ import json +from datetime import UTC, datetime from pathlib import Path import httpx @@ -8,6 +9,11 @@ from easyatcal.backends.ics import IcsBackend from easyatcal.orchestrator import run_sync +# Pin "now" inside the fixture's date range (shifts on 2026-05-10/11) so those +# shifts fall within the fetch window. A shift removed from the API is only +# deleted when it was in-window; out-of-window past shifts are preserved. +NOW = datetime(2026, 5, 11, 12, 0, tzinfo=UTC) + @respx.mock def test_real_fixture_sync(tmp_path: Path): @@ -39,6 +45,7 @@ def test_real_fixture_sync(tmp_path: Path): state_path=tmp_path / "state.json", lookback_days=1, lookahead_days=7, + now=NOW, ) assert summary.adds == 2 @@ -58,6 +65,7 @@ def test_real_fixture_sync(tmp_path: Path): state_path=tmp_path / "state.json", lookback_days=1, lookahead_days=7, + now=NOW, ) assert summary2.adds == 0 assert summary2.updates == 0 @@ -77,6 +85,7 @@ def test_real_fixture_sync(tmp_path: Path): state_path=tmp_path / "state.json", lookback_days=1, lookahead_days=7, + now=NOW, ) assert summary3.adds == 0 assert summary3.updates == 1 diff --git a/tests/test_state.py b/tests/test_state.py index f9fa242..9f6bf54 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -48,3 +48,15 @@ def test_state_roundtrip_with_updated_at(tmp_path): save_state(path, s) loaded = load_state(path) assert loaded.shift_updated_at == {"s1": "2026-04-18T10:00:00+00:00"} + + +def test_state_roundtrip_with_shift_start(tmp_path): + path = tmp_path / "state.json" + s = State( + shift_to_event={"s1": "e1"}, + shift_start={"s1": "2026-04-20T09:00:00+00:00"}, + last_sync="2026-04-19T12:00:00+00:00", + ) + save_state(path, s) + loaded = load_state(path) + assert loaded.shift_start == {"s1": "2026-04-20T09:00:00+00:00"} diff --git a/tests/test_sync.py b/tests/test_sync.py index fa29645..b9dc2f7 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1,9 +1,13 @@ -from datetime import UTC, datetime +from datetime import UTC, date, datetime from easyatcal.models import Shift from easyatcal.state import State from easyatcal.sync import compute_changes +# Window wide enough to contain the _shift() start date below. +WINDOW_FROM = date(2026, 4, 1) +WINDOW_TO = date(2026, 12, 31) + def _shift(id_: str, updated: str = "2026-04-18T10:00:00+00:00") -> Shift: return Shift( @@ -21,7 +25,10 @@ def test_new_shifts_are_adds(): state = State(shift_to_event={}) shifts = [_shift("a"), _shift("b")] - changes = compute_changes(shifts, state, known_updated_at={}) + changes = compute_changes( + shifts, state, known_updated_at={}, + from_date=WINDOW_FROM, to_date=WINDOW_TO, known_start={}, + ) assert [s.id for s in changes.adds] == ["a", "b"] assert changes.updates == [] @@ -33,7 +40,10 @@ def test_known_shifts_unchanged_do_nothing(): shifts = [_shift("a", "2026-04-18T10:00:00+00:00")] known_updated = {"a": "2026-04-18T10:00:00+00:00"} - changes = compute_changes(shifts, state, known_updated_at=known_updated) + changes = compute_changes( + shifts, state, known_updated_at=known_updated, + from_date=WINDOW_FROM, to_date=WINDOW_TO, known_start={}, + ) assert changes.is_empty() @@ -43,7 +53,10 @@ def test_known_shift_with_new_updated_at_is_update(): shifts = [_shift("a", "2026-04-19T10:00:00+00:00")] known_updated = {"a": "2026-04-18T10:00:00+00:00"} - changes = compute_changes(shifts, state, known_updated_at=known_updated) + changes = compute_changes( + shifts, state, known_updated_at=known_updated, + from_date=WINDOW_FROM, to_date=WINDOW_TO, known_start={}, + ) assert len(changes.updates) == 1 shift, event_uid = changes.updates[0] @@ -51,12 +64,55 @@ def test_known_shift_with_new_updated_at_is_update(): assert event_uid == "evt-a" -def test_shift_missing_from_remote_is_delete(): - state = State(shift_to_event={"a": "evt-a", "b": "evt-b"}) +def test_in_window_shift_missing_from_remote_is_delete(): + state = State( + shift_to_event={"a": "evt-a", "b": "evt-b"}, + shift_start={ + "a": "2026-04-20T09:00:00+00:00", + "b": "2026-04-20T09:00:00+00:00", + }, + ) shifts = [_shift("a")] known_updated = {"a": "2026-04-18T10:00:00+00:00", "b": "2026-04-18T10:00:00+00:00"} - changes = compute_changes(shifts, state, known_updated_at=known_updated) + changes = compute_changes( + shifts, state, known_updated_at=known_updated, + from_date=WINDOW_FROM, to_date=WINDOW_TO, + known_start=state.shift_start, + ) assert changes.deletes == ["evt-b"] + + +def test_past_shift_outside_window_is_preserved(): + # "old" sits before the lookback window; the API no longer returns it. + # It must NOT be deleted just because it fell out of the fetch range. + state = State( + shift_to_event={"old": "evt-old", "b": "evt-b"}, + shift_start={ + "old": "2026-01-01T09:00:00+00:00", + "b": "2026-04-20T09:00:00+00:00", + }, + ) + remote = [] # nothing returned this window + + changes = compute_changes( + remote, state, known_updated_at={}, + from_date=WINDOW_FROM, to_date=WINDOW_TO, + known_start=state.shift_start, + ) + + # In-window "b" is a real cancellation -> delete. Past "old" -> preserved. + assert changes.deletes == ["evt-b"] + + +def test_missing_shift_with_unknown_start_is_preserved(): + state = State(shift_to_event={"x": "evt-x"}) # no start recorded + + changes = compute_changes( + [], state, known_updated_at={}, + from_date=WINDOW_FROM, to_date=WINDOW_TO, known_start={}, + ) + + assert changes.deletes == [] From 84e8debbc5e25db09669fef6ab4c4c5a4695bd5f Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:15:50 +0200 Subject: [PATCH 63/68] fix: stabilize sync migration and CI --- easyatcal/auth_user.py | 15 +++++++-------- easyatcal/backends/ics.py | 2 +- easyatcal/cli.py | 3 +-- easyatcal/config.py | 7 +++++-- easyatcal/orchestrator.py | 9 +++++++-- tests/backends/test_ics.py | 16 ++++++++++++++++ tests/test_api_session.py | 4 +--- tests/test_cli_config.py | 39 ++++++++++++++++++++++++++++++++++++++ tests/test_orchestrator.py | 34 +++++++++++++++++++++++++++++++++ 9 files changed, 111 insertions(+), 18 deletions(-) diff --git a/easyatcal/auth_user.py b/easyatcal/auth_user.py index 8dfb666..ab43bc6 100644 --- a/easyatcal/auth_user.py +++ b/easyatcal/auth_user.py @@ -1,8 +1,11 @@ from __future__ import annotations import contextlib +import json +import os +import re from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from easyatcal.config import EasyAtWorkAuth @@ -55,9 +58,7 @@ def do_login( page = context.new_page() discovered_meta: dict[str, str | int] = {} - import re - - def on_request(request): + def on_request(request: Any) -> None: match = re.search(r"^(https?://[^/]+)/customers/(\d+)/employees/(\d+)", request.url) if match: discovered_meta["api_url"] = match.group(1) @@ -109,12 +110,10 @@ def on_request(request): with contextlib.suppress(PWTimeout): page.wait_for_timeout(3000) - state = context.storage_state() + state: dict[str, Any] = dict(context.storage_state()) if discovered_meta: state["eaw_meta"] = discovered_meta - - import json - import os + tmp = storage_path.with_suffix(storage_path.suffix + ".tmp") tmp.write_text(json.dumps(state)) os.replace(tmp, storage_path) diff --git a/easyatcal/backends/ics.py b/easyatcal/backends/ics.py index e978834..81b8619 100644 --- a/easyatcal/backends/ics.py +++ b/easyatcal/backends/ics.py @@ -87,7 +87,7 @@ def _to_event( ev.add("description", shift.notes) if alarm_minutes_before is not None: - alarm = Alarm() + alarm = Alarm() # type: ignore[no-untyped-call] alarm.add("action", "DISPLAY") alarm.add("description", "Shift Reminder") alarm.add("trigger", timedelta(minutes=-alarm_minutes_before)) diff --git a/easyatcal/cli.py b/easyatcal/cli.py index 45df379..71a7ad8 100644 --- a/easyatcal/cli.py +++ b/easyatcal/cli.py @@ -1,6 +1,5 @@ from __future__ import annotations -import shutil import time from datetime import UTC from pathlib import Path @@ -160,7 +159,7 @@ def config_init( raise typer.Exit(code=1) target.parent.mkdir(parents=True, exist_ok=True) - with open(EXAMPLE_CONFIG, "r") as f: + with open(EXAMPLE_CONFIG) as f: template = f.read() import sys diff --git a/easyatcal/config.py b/easyatcal/config.py index bd236c9..84a646e 100644 --- a/easyatcal/config.py +++ b/easyatcal/config.py @@ -63,9 +63,12 @@ def _check_mode_fields(self) -> EasyAtWorkAuth: raise ValueError("auth_mode=user requires email") return self - def shifts_url(self, session_meta: dict | None = None) -> str: + def shifts_url(self, session_meta: dict[str, str | int] | None = None) -> str: """Fully-qualified base URL of the shifts collection for this user.""" - api_url = self.api_url or (session_meta or {}).get("api_url") + session_api_url = (session_meta or {}).get("api_url") + api_url = self.api_url or ( + session_api_url if isinstance(session_api_url, str) else "" + ) customer_id = self.customer_id or (session_meta or {}).get("customer_id") employee_id = self.employee_id or (session_meta or {}).get("employee_id") diff --git a/easyatcal/orchestrator.py b/easyatcal/orchestrator.py index c8abd7d..cd21440 100644 --- a/easyatcal/orchestrator.py +++ b/easyatcal/orchestrator.py @@ -133,13 +133,18 @@ def _persist( for shift_id, event_uid in result.mapping.items(): new_shift_to_event[shift_id] = event_uid - # For every shift we successfully wrote, stamp the new updated_at and start. + # For every shift we successfully wrote, stamp the new updated_at. remote_by_id = {s.id: s for s in remote_shifts} for shift_id in result.mapping: shift = remote_by_id.get(shift_id) if shift is not None: new_updated_at[shift_id] = shift.updated_at.isoformat() - new_start[shift_id] = shift.start.isoformat() + + # Backfill starts for existing events as well as successful writes. Older + # state files lack this field, but unchanged remote shifts have no mapping. + for shift in remote_shifts: + if shift.id in new_shift_to_event: + new_start[shift.id] = shift.start.isoformat() # Prune confirmed deletions. deleted_uid_set = set(result.deleted_uids) diff --git a/tests/backends/test_ics.py b/tests/backends/test_ics.py index e86b4ac..9b7070e 100644 --- a/tests/backends/test_ics.py +++ b/tests/backends/test_ics.py @@ -81,3 +81,19 @@ def test_existing_events_preserved_when_not_in_new_shifts(tmp_path: Path): body = out.read_text() assert "SUMMARY:Shift old" in body # preserved across regeneration assert "SUMMARY:Shift new" in body + + +def test_alarm_is_written_when_configured(tmp_path: Path): + out = tmp_path / "shifts.ics" + backend = IcsBackend( + output_path=out, + known_shifts=[], + alarm_minutes_before=30, + ) + + backend.apply(Changes(adds=[_shift("s1")])) + + body = out.read_text() + assert "BEGIN:VALARM" in body + assert "TRIGGER:-PT30M" in body + assert "DESCRIPTION:Shift Reminder" in body diff --git a/tests/test_api_session.py b/tests/test_api_session.py index d6e6587..04a99be 100644 --- a/tests/test_api_session.py +++ b/tests/test_api_session.py @@ -1,5 +1,6 @@ from datetime import date from pathlib import Path +from unittest.mock import patch import httpx import pytest @@ -31,9 +32,6 @@ def _seeded_store(tmp_path: Path, token: str = FAKE_JWT) -> SessionStore: ) return store - -from unittest.mock import patch - def test_no_token_raises_authenticate(tmp_path: Path) -> None: with patch("keyring.get_password", return_value=None): client = SessionEawClient( diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py index a07b858..0f6455f 100644 --- a/tests/test_cli_config.py +++ b/tests/test_cli_config.py @@ -18,6 +18,45 @@ def test_config_init_creates_file(tmp_path: Path): assert "easyatwork:" in target.read_text() +def test_config_init_interactive_english_eventkit(tmp_path: Path): + target = tmp_path / "config.yaml" + answers = "user@example.com\nWork {title}\ny\n30\neventkit\n" + + with ( + patch("easyatcal.cli.config_path", return_value=target), + patch("easyatcal.cli._is_french", return_value=False), + patch("sys.platform", "darwin"), + ): + result = runner.invoke(app, ["config", "init"], input=answers) + + assert result.exit_code == 0, result.stdout + body = target.read_text() + assert 'email: "user@example.com"' in body + assert 'event_title_format: "Work {title}"' in body + assert "alarm_minutes_before: 30" in body + assert "backend: eventkit" in body + assert "Next steps:" in result.stdout + + +def test_config_init_interactive_french_ics(tmp_path: Path): + target = tmp_path / "config.yaml" + answers = "utilisateur@example.com\n{title}\nn\n" + + with ( + patch("easyatcal.cli.config_path", return_value=target), + patch("easyatcal.cli._is_french", return_value=True), + patch("sys.platform", "linux"), + ): + result = runner.invoke(app, ["config", "init"], input=answers) + + assert result.exit_code == 0, result.stdout + body = target.read_text() + assert 'email: "utilisateur@example.com"' in body + assert "backend: ics" in body + assert "Configuration générée avec succès" in result.stdout + assert "Prochaines étapes" in result.stdout + + def test_config_init_does_not_overwrite(tmp_path: Path): target = tmp_path / "config.yaml" target.write_text("existing: yes\n") diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 9fe687c..5e852f0 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -49,6 +49,7 @@ def test_run_sync_applies_changes_and_persists_state(tmp_path: Path): saved = load_state(state_path) assert saved.shift_to_event == {"s1": "evt-1", "s2": "evt-2"} assert saved.shift_updated_at["s1"] == "2026-04-18T00:00:00+00:00" + assert saved.shift_start["s1"] == "2026-04-20T09:00:00+00:00" assert saved.last_sync == "2026-04-19T12:00:00+00:00" @@ -76,9 +77,38 @@ def test_run_sync_persists_partial_state_on_backend_error(tmp_path: Path): # s1 WAS persisted; s2 was NOT. saved = load_state(state_path) assert saved.shift_to_event == {"s1": "evt-1"} + assert saved.shift_start == {"s1": "2026-04-20T09:00:00+00:00"} assert "s2" not in saved.shift_to_event +def test_run_sync_backfills_start_for_unchanged_existing_shift(tmp_path: Path): + state_path = tmp_path / "state.json" + + from easyatcal.state import State, save_state + save_state(state_path, State( + shift_to_event={"s1": "evt-1"}, + shift_updated_at={"s1": "2026-04-18T00:00:00+00:00"}, + )) + + api = MagicMock() + api.fetch_shifts.return_value = [_shift("s1")] + + backend = MagicMock() + backend.apply.return_value = ApplyResult(mapping={}) + + run_sync( + api=api, + backend=backend, + state_path=state_path, + lookback_days=1, + lookahead_days=1, + now=datetime(2026, 4, 19, 12, tzinfo=UTC), + ) + + saved = load_state(state_path) + assert saved.shift_start == {"s1": "2026-04-20T09:00:00+00:00"} + + def test_run_sync_prunes_deleted_uids(tmp_path: Path): """State entries whose event_uid is in deleted_uids are removed.""" state_path = tmp_path / "state.json" @@ -91,6 +121,10 @@ def test_run_sync_prunes_deleted_uids(tmp_path: Path): "s_old": "2026-04-01T00:00:00+00:00", "s_keep": "2026-04-01T00:00:00+00:00", }, + shift_start={ + "s_old": "2026-04-18T09:00:00+00:00", + "s_keep": "2026-04-20T09:00:00+00:00", + }, )) api = MagicMock() From 4c1feef1bcf3e3e46e91929d406385e8362ef7df Mon Sep 17 00:00:00 2001 From: "54411234+Ailcope@users.noreply.github.com" <54411234+Ailcope@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:20:53 +0200 Subject: [PATCH 64/68] Relicense under PolyForm Noncommercial 1.0.0 Switch from MIT to PolyForm Noncommercial: free for noncommercial use, commercial use or resale requires permission. Update README badge and notice. --- LICENSE | 92 +++++++++++++++++++++++++++++++++++++++++++------------ README.md | 4 +-- 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/LICENSE b/LICENSE index fcccdf3..bc1537a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,75 @@ -MIT License - Copyright (c) 2026 Ailcope -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +# PolyForm Noncommercial License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms. + +**You** refers to the individual or entity agreeing to these terms. + +**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the software under these terms. + +**Use** means anything you do with the software requiring one of your licenses. diff --git a/README.md b/README.md index 008041b..104346f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Coverage](https://img.shields.io/badge/Coverage-90%25-brightgreen.svg?logo=codecov&logoColor=white)](https://github.com/Ailcope/EasyAtCal) [![Release](https://img.shields.io/github/v/release/Ailcope/EasyAtCal?label=Release&logo=github&logoColor=white&color=blue)](https://github.com/Ailcope/EasyAtCal/releases) [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?logo=opensourceinitiative&logoColor=white)](./LICENSE) +[![License: PolyForm NC](https://img.shields.io/badge/License-PolyForm%20NC-orange.svg?logo=opensourceinitiative&logoColor=white)](./LICENSE) **One-way sync of easy@work shifts into Apple Calendar, Google Calendar, or standard ICS files.** @@ -154,4 +154,4 @@ Your easy@work password is **never stored on disk**. The configuration file only ## License -MIT — see `LICENSE`. +[PolyForm Noncommercial 1.0.0](./LICENSE) — free for noncommercial use; commercial use or reselling the code requires the author's permission. From e3e3183de08d719469d50e44661f9cf47c7e502b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:21:26 +0200 Subject: [PATCH 65/68] ci: bump actions/configure-pages from 5 to 6 (#5) Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6. - [Release notes](https://github.com/actions/configure-pages/releases) - [Commits](https://github.com/actions/configure-pages/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/configure-pages dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ailcope <54411234+Ailcope@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4d1282d..c128c64 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -39,7 +39,7 @@ jobs: - name: Build docs run: mkdocs build - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: From a7dab58d416ac6056a916b8e6bcac4cdf5cbfcca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:21:29 +0200 Subject: [PATCH 66/68] ci: bump actions/deploy-pages from 4 to 5 (#6) Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5. - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ailcope <54411234+Ailcope@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c128c64..b908ad7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -46,4 +46,4 @@ jobs: path: 'site' - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v5 \ No newline at end of file From 16bffe6ab8925b2084ea56dee5759a9ff4ba8430 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:21:33 +0200 Subject: [PATCH 67/68] ci: bump actions/upload-pages-artifact from 3 to 5 (#7) Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 5. - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ailcope <54411234+Ailcope@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b908ad7..c861619 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -41,7 +41,7 @@ jobs: - name: Setup Pages uses: actions/configure-pages@v6 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: 'site' - name: Deploy to GitHub Pages From d60586abad3d35df5b0d11f84462028346872ea6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:42:10 +0000 Subject: [PATCH 68/68] ci: bump actions/checkout from 6 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/publish.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b9bc56..bbf7449 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c861619..9204a50 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -21,7 +21,7 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup Python uses: actions/setup-python@v5 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a7197eb..b3ab5c9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-python@v5 with: python-version: "3.12"