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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,29 @@ jobs:

- name: Run tests
run: pytest

suite-coverage:
name: Branch coverage of the conformance suite
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"

- name: Install dependencies
run: pip install -e ".[dev]"

# A branch of a capability-aware test that no memory profile reaches
# tests nothing; the profiles in tests/_async/test_memory_conformance.py
# are there to reach them.
- name: Measure
run: |
coverage run --branch --source=calendaring -m pytest -q tests/_async/test_memory_conformance.py
coverage report -m --include='*/_async/conformance.py' --fail-under=95
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ build/
*.egg-info/
.ruff_cache/
.lycheecache
.coverage
29 changes: 27 additions & 2 deletions docs/design/API_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1049,14 +1049,22 @@ initial set:
| `identity.client-uid`, `identity.foreign-id` | [§3.2 Identity](#32-identity) |
| `identity.client-native-id` | `add(native_id=...)`: the caller may name the stored object (CalDAV href, vdir file name) |
| `properties.passthrough` | unknown properties and components survive a round trip |
| `recurrence`, `recurrence.edit-this-and-future` | stores `RRULE`; [§3.7 Recurrence](#37-recurrence) |
| `recurrence`, `recurrence.edit-this-and-future` | stores a recurrence set: `RRULE`, `RDATE`, `EXDATE` and the overrides (`RECURRENCE-ID`); `UNSUPPORTED` drops all four, overrides included; [§3.7 Recurrence](#37-recurrence) |
| `recurrence.edit-occurrence` | `save(occurrence)` merges it into the stored object ([§3.7](#37-recurrence)); `UNSUPPORTED` where the merge is not available yet (Q3) |
| `task.complete-recurring` | `complete()` on a recurring task or one occurrence of it ([§3.7](#37-recurrence)) |
| `move` | [§2.3 Collection](#23-collection) |
| `task.status`, `task.priority`, `task.percent-complete` | `LOSSY` when values are mapped |
| `task.start`, `task.due`, `task.completed`, `task.planned`, `task.estimate` | |
| `task.relations.parent`, `task.relations.depends-on` | |
| `categories`, `attendees` | for every component; on a task, attendees are its assignees |
| `alarms` | `VALARM` components survive; `UNSUPPORTED` drops them |
| `timezones` | a time with a `TZID` keeps it (with its `VTIMEZONE`), a floating time stays floating; `LOSSY` stores both in UTC, a floating time read as local time ([§4](#4-search)) |
| `dates` | a `DATE` value stays a date; `LOSSY` stores midnight UTC |

Text is not a feature: non-ASCII, escaped and multi-line text, and every
value of a property with several, round-trip on every backend. The
conformance suite stores a corpus of real-world objects and lets `verify`
compare what comes back; only what the declaration says may differ.

Feature names are identifiers, not attribute paths: `task.due` means "stores a
task's `DUE`", which is read as `task.component.DUE`, and `task.estimate`
Expand Down Expand Up @@ -1165,6 +1173,7 @@ CalendaringError
├── UnsupportedError # carries .feature
│ └── LossyWriteError # carries .losses; raised before anything is sent
├── InvalidDataError # the backend rejected the data, or it does not parse; also a ValueError
│ └── PartialResultError # a read left out stored objects it could not use; .results, .broken
├── RateLimitError # carries .retry_after: float | None
├── VerificationError # verify=True: written, but stored differently; carries .differences
└── BackendError # anything else from the backend or transport
Expand Down Expand Up @@ -1197,7 +1206,23 @@ CalendaringWarning
reload the item. It is still a `ConflictError`, as `AlreadyExistsError`
is, because all three are a failed precondition; a handler that reloads
on `ConflictError` should catch the subclasses first.
- Fan-out ([§2.1 Workspace](#21-workspace)) does not raise; `MultiResult.raise_for_errors()` raises an `ExceptionGroup` of these.
- **One bad stored object does not hide the rest.** A read that lists
(`search()`, `iter_search()` once exhausted, the listing shorthands,
`changes()`, `relatives()`, and `get(uid)` where the UID is not found)
leaves out an
object that does not parse, or that the searcher cannot match (two
masters for one UID), and then raises `PartialResultError`. `.results`
is what the call would have returned from the rest (for `changes()` the
`ChangeSet`, whose token is good; for `iter_search()` nothing, since it
yielded them already), and `.broken` lists the objects left
out (`BrokenObject`: native id, etag, error, and the bytes as read where
the backend has them, to repair the object from). A broken object is
never reported as deleted. `get_by_native_id()` of one raises
`InvalidDataError`. Raising rather than warning means no caller misses
it; a caller that can live with the gap takes `.results`. The listing
primitives return a `BrokenObject` in place of a `StoredObject` rather
than raising, so the backend decides per object.
- Fan-out ([§2.1 Workspace](#21-workspace)) does not raise; `MultiResult.raise_for_errors()` raises an `ExceptionGroup` of these. `Workspace.search()` keeps a `PartialResultError`'s `.results` among the items, and the error among the errors.

caldav's errors map one to one where they overlap:
`NotFoundError` → `NotFoundError`, `ETagMismatchError` and
Expand Down
14 changes: 9 additions & 5 deletions docs/design/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,11 +278,15 @@ Notes from doing it:
the suite: `FULL`, `TRACKER` (tasks only, mapped status and priority,
assigned UIDs), `MINIMAL` (writable, most optional features left out, no
collections of its own, so the suite borrows one that already holds an
item) and `FEED` (read-only). Between them the capability-aware tests run
both branches, except the supported branches of
`recurrence.edit-occurrence` and `task.complete-recurring` (below). Every
skip says why: a write test on the read-only profile after the write was
checked to raise, or a recurrence test where nothing recurs.
item) and `FEED` (read-only). Three more in the test module (events
only, tasks without a status, and a feed of tasks) reach the branches
these four left out, which a review found
([issue 21](https://github.com/pycalendar/calendaring/issues/21)); CI
fails below 95 % branch coverage of the suite. The supported branches of
`recurrence.edit-occurrence`, `recurrence.edit-this-and-future` and
`task.complete-recurring` (below) are still not run. Every skip says why:
a write test on the read-only profile after the write was checked to
raise, or a recurrence test where nothing recurs.
- A backend implements a handful of primitives (`_query`, `_fetch`,
`_create`, `_update`, `_delete`); capability checks, loss policies,
client-side filtering, change emulation, `move` emulation, `relatives` and
Expand Down
1 change: 1 addition & 0 deletions news/15.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Three new capability features, ``alarms``, ``timezones`` and ``dates``, let a backend say honestly that it cannot keep alarms, time zones or all-day dates; what it would lose is then reported before writing, as for any other loss. The conformance suite now checks that real-world objects (time zones, alarms, recurrence exceptions, non-ASCII and multi-line text) come back as they were stored.
1 change: 1 addition & 0 deletions news/22.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
One stored item that cannot be read no longer makes a whole collection unreadable. A search, ``get()``, ``relatives()`` or ``changes()`` that has to leave items out raises ``PartialResultError``, which carries what could be read and the raw data of what could not, so it can be repaired.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dev = [
"mypy==2.4.0",
"pre-commit>=4.0",
"towncrier>=25.8",
"coverage>=7.6",
]

[project.urls]
Expand Down
5 changes: 4 additions & 1 deletion src/calendaring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
LossyWriteError,
LossyWriteWarning,
NotFoundError,
PartialResultError,
RateLimitError,
SyncTokenError,
TransportError,
Expand All @@ -47,7 +48,7 @@
)
from .items import Event, Item, Journal, Task, TaskStatus
from .loss import Loss, LossPolicy
from .types import ChangeSet, Component, MultiResult, Scope, StoredObject, SyncToken
from .types import BrokenObject, ChangeSet, Component, MultiResult, Scope, StoredObject, SyncToken

# Register the reference backend's kind ("memory") in both modes.
from . import memory as _memory # noqa: E402, F401 isort: skip
Expand Down Expand Up @@ -86,8 +87,10 @@
"LossyWriteWarning",
"MultiResult",
"NotFoundError",
"PartialResultError",
"RateLimitError",
"Scope",
"BrokenObject",
"StoredObject",
"Support",
"SyncEvent",
Expand Down
Loading
Loading