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
33 changes: 26 additions & 7 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,20 @@ name: Integration Tests
on:
workflow_dispatch:
push:
branches:
- main
tags:
- "v*"
# Changes that can't affect a sync; a tag runs whatever it holds.
paths-ignore:
- "news/**"
- "**/*.md"
- "docs/**/*.rst"
pull_request:
paths-ignore:
- "news/**"
- "**/*.md"
- "docs/**/*.rst"

concurrency:
group: integration-${{ github.ref }}
Expand Down Expand Up @@ -36,17 +48,24 @@ jobs:
- name: Install dependencies
run: pip install -e ".[dev]"

# Stalwart serves both JMAP and CalDAV, from this repo's own fixture.
- name: Start Stalwart
run: bash tests/docker/stalwart/start.sh
# Radicale and Xandikos run embedded; the rest from this repo's fixtures.
- name: Start Stalwart, Cyrus and Baïkal
run: bash tests/docker/start.sh stalwart cyrus baikal

# Radicale and Xandikos run embedded; Cyrus and Baïkal are skipped, as
# nothing starts them here.
- name: Run integration tests
run: |
pytest src/calendaring_sync/tests/test_caldav_integration.py \
src/calendaring_sync/tests/test_jmap_integration.py

- name: Stop Stalwart
# Each server's log, to read when a test fails; a server that never
# started has none, and that mustn't hide the others'.
- name: Show the server logs
if: ${{ failure() }}
run: |
docker exec calendaring-sync-stalwart sh -c 'cat /var/log/stalwart/*' || true
docker logs calendaring-sync-cyrus || true
docker logs calendaring-sync-baikal || true

- name: Stop the servers
if: ${{ always() }}
run: bash tests/docker/stalwart/stop.sh
run: bash tests/docker/stop.sh stalwart cyrus baikal
8 changes: 3 additions & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ jobs:
run: pip install -e ".[dev]"

- name: Type check
run: python -m mypy src/calendaring_sync docs/examples --ignore-missing-imports
# Its settings are in pyproject.toml.
run: python -m mypy src/calendaring_sync docs/examples tests/docker

test:
name: Test on Python ${{ matrix.python-version }}
Expand Down Expand Up @@ -173,10 +174,7 @@ jobs:
run: |
pytest --cov --cov-branch --cov-report=xml \
--junitxml=junit.xml -o junit_family=legacy \
src/calendaring_sync/tests/test_sync_unit.py \
src/calendaring_sync/tests/test_classify_unit.py \
src/calendaring_sync/tests/test_caldav_unit.py \
src/calendaring_sync/tests/test_jmap_unit.py
src/calendaring_sync/tests --ignore-glob="*_integration.py"

- name: Upload coverage to Codecov
if: ${{ !cancelled() }}
Expand Down
6 changes: 4 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@

"""Sphinx configuration for calendaring-sync documentation."""

import datetime
import datetime as dt
import os

project = "calendaring-sync"
this_year = datetime.date.today().year # noqa: DTZ011
this_year = dt.date.today().year # noqa: DTZ011
copyright = f"{this_year}, calendaring-sync contributors" # noqa: A001
author = "calendaring-sync contributors"

Expand Down Expand Up @@ -156,6 +156,8 @@

autodoc_default_options = {
"members": True,
# Each module's source is grouped by area, so its page reads the same way.
"member-order": "bysource",
"show-inheritance": True,
}

Expand Down
44 changes: 31 additions & 13 deletions docs/contribute.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Contribute
.. meta::
:description: Set up calendaring-sync for development, find your way around the code, run the checks CI runs, and send a pull request the way the project reviews it.

calendaring-sync takes bug reports, fixes, documentation, and new adapters. This page covers what you need to send one: the setup, how the code is laid out, the checks a pull request must pass, and the conventions reviewers hold it to.
calendaring-sync takes bug reports, fixes, documentation, and new adapters.

Before you start
================
Expand Down Expand Up @@ -65,22 +65,42 @@ A new adapter can live in its own package, built on :mod:`calendaring_sync.adapt
Run the tests
=============

Unit tests need no server, network, or Docker. CI runs these four files on every supported Python version:
Unit tests need no server, network, or Docker. CI runs every test file except the integration ones, on every supported Python version:

.. code-block:: bash

pytest src/calendaring_sync/tests/test_sync_unit.py src/calendaring_sync/tests/test_classify_unit.py src/calendaring_sync/tests/test_caldav_unit.py src/calendaring_sync/tests/test_jmap_unit.py
pytest src/calendaring_sync/tests --ignore-glob="*_integration.py"

``test_sync_unit.py`` covers the core, the stores, and every docs example that needs no server. ``test_classify_unit.py`` covers the classifier over every combination of its inputs. The two adapter files replay HTTP exchanges recorded from real servers, kept in ``src/calendaring_sync/tests/data/``.
``test_caldav.py`` and ``test_jmap.py`` replay HTTP exchanges recorded from real servers, kept in ``src/calendaring_sync/tests/data/``.

Integration tests run each adapter, and the docs examples that need a server, against real servers:

.. code-block:: bash

bash tests/docker/stalwart/start.sh
bash tests/docker/start.sh stalwart cyrus baikal
pytest src/calendaring_sync/tests/test_caldav_integration.py src/calendaring_sync/tests/test_jmap_integration.py

Radicale and Xandikos run embedded. Each other server is tested when it answers and skipped when it doesn't; the recorder each integration module's docstring names says how to start it. CI runs them only on a release tag or when a maintainer starts them, not on each pull request, so run them yourself when you change an adapter, against every server you can start.
Radicale and Xandikos run embedded, and ``tests/docker`` holds a fixture for each other server; one that doesn't answer is skipped. ``bash tests/docker/stop.sh stalwart cyrus baikal`` removes them, so the next start is clean. CI runs every server on each pull request that changes code.

Write a test
------------

Put a test in the file for its area:

- ``test_store.py``: the stores
- ``test_ingest.py``: change sets, sessions and resets
- ``test_replica.py``: scopes, lookups, the values the replica takes, and the change feed
- ``test_edits.py``: local edits and conflicts
- ``test_pushes.py``: pushes and resolutions
- ``test_classify.py``: the classifier, over every combination of its inputs
- ``test_adapters.py``, ``test_caldav.py`` and ``test_jmap.py``: the adapter base and each adapter
- ``test_examples.py``: the docs examples, each test named after its example's file
- ``test_package.py``: the package as a whole
- ``test_caldav_integration.py`` and ``test_jmap_integration.py``: each adapter against real servers, with what every protocol must show written once in ``EveryProtocol``, in ``_live.py``

In a file, each class covers one behavior, named in its docstring, and the classes follow the order the docs present things. In a class, the normal case comes first, then variations, then refusals, then threads, other processes and recovery. A test's name states what it checks, such as ``test_one_open_session_per_scope``, and variations of one behavior are rows of a ``pytest.mark.parametrize`` table beside it.

Fixtures are in ``conftest.py``: a test that takes ``store`` or ``replica`` runs on every store, and one that takes ``asynchronous`` runs with both kinds of adapter. Helpers more than one file uses are in ``_helpers.py``, and events in ``_content.py``. Test each refusal with ``_assert_refused``, which also checks that nothing changed.

Record server exchanges
-----------------------
Expand Down Expand Up @@ -108,7 +128,7 @@ It prints the best time of each scenario, each on a fresh store. ``--size`` and
Check your change
=================

CI runs every check below on each pull request. Run them first, and fix what they report before you ask for review.
CI runs every check below on each pull request; run them before you ask for review.

.. list-table::
:header-rows: 1
Expand All @@ -119,7 +139,7 @@ CI runs every check below on each pull request. Run them first, and fix what the
* - Lint and format
- ``ruff check .`` and ``ruff format --check .``
* - Types
- ``mypy src/calendaring_sync docs/examples --ignore-missing-imports``
- ``mypy src/calendaring_sync docs/examples tests/docker``, with its settings in ``pyproject.toml``
* - Unit tests
- The command in :ref:`running-tests`
* - Docs build
Expand All @@ -133,7 +153,7 @@ CI runs every check below on each pull request. Run them first, and fix what the
* - Change log
- ``towncrier check --compare-with origin/main``

``pre-commit`` runs the lint, format, and licensing checks on each commit. The docs build fails on any warning, including a cross-reference that doesn't resolve, so a renamed class or a mistyped ``:rfc:`` role fails the build instead of shipping a broken link.
``pre-commit`` runs the lint, format, and licensing checks on each commit. The docs build fails on any warning, including a cross-reference that doesn't resolve.

.. _code-style:

Expand Down Expand Up @@ -207,7 +227,7 @@ Cross-references
Link between pages with ``:doc:`` and a relative path, such as ``:doc:`../explanation/design```, and to a class or method with ``:class:`` or ``:meth:`` and a leading ``~`` for the short name. Link a sync term to :doc:`reference/glossary` with ``:term:`` the first time a section uses it, and define a new term there, not inline.

Code examples
Each Python example on a page is a file in ``docs/examples/``, included with ``literalinclude`` and its ``:start-after:`` and ``:end-before:`` markers, so the code a page shows is the code a test runs. An example that needs no server gets a test in ``TestExamples``, and starts from ``start.py`` when it needs a replica that synced. One that needs a server is listed in ``LIVE`` in ``_examples.py``, keeps its server details in top-level assignments, and gets an integration test, which replaces those details. Readers copy these files, so they pass the type check too. A shell command goes in a ``.. code-block:: bash``, and a tutorial ends with a "Next steps" section.
Each Python example on a page is a file in ``docs/examples/``, included with ``literalinclude`` and its ``:start-after:`` and ``:end-before:`` markers, so the code a page shows is the code a test runs. An example that needs no server gets a test in ``test_examples.py``, and starts from ``start.py`` when it needs a replica that synced. One that needs a server is listed in ``LIVE`` in ``_examples.py``, keeps its server details in top-level assignments, and gets an integration test, which replaces those details. Readers copy these files, so they pass the type check too. A shell command goes in a ``.. code-block:: bash``, and a tutorial ends with a "Next steps" section.

Diagrams and headings
Sphinx doesn't check the syntax inside a ``.. mermaid::`` diagram, so render a changed diagram before you open a pull request. Make each heading's underline exactly as long as the heading: Sphinx warns only about a page title's.
Expand All @@ -220,8 +240,6 @@ Preview the docs
pip install -e ".[docs]"
sphinx-autobuild docs docs/_build/html

This serves the docs locally and rebuilds them as you edit.

.. _vale-check:

Check prose with Vale
Expand All @@ -234,7 +252,7 @@ Check prose with Vale
vale sync
vale docs/

It runs the Microsoft style package and `signs-of-ai-writing <https://github.com/ammil-industries/vale-signs-of-ai-writing>`_, which catch many of the rules above. CI runs it as a check that warns without failing. The choice of Diátaxis category, and whether a fact is stated in one place, still need your own read of the diff.
It runs the Microsoft style package and `signs-of-ai-writing <https://github.com/ammil-industries/vale-signs-of-ai-writing>`_, which catch many of the rules above. CI runs it as a check that warns without failing.

.. _commits-and-prs:

Expand Down
36 changes: 19 additions & 17 deletions docs/examples/memory_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
Removed,
Replica,
Scope,
ScopeGoneError,
Session,
)
from calendaring_sync.adapters.base import (
Expand All @@ -40,19 +39,19 @@
Stored = tuple[str, bytes]


class VersionMismatch(Exception):
class VersionMismatchError(Exception):
"""The object doesn't have the version the write was based on."""


class NotFound(Exception):
class NotFoundError(Exception):
"""The server has no object with that id."""


class ExpiredToken(Exception):
class ExpiredTokenError(Exception):
"""The server no longer knows what changed since that token."""


class NoSuchScope(Exception):
class NoSuchScopeError(Exception):
"""The server no longer holds the objects at all."""


Expand All @@ -72,15 +71,15 @@ def token(self) -> str:

def listing(self) -> tuple[dict[str, Stored], str]:
if self.removed:
raise NoSuchScope()
raise NoSuchScopeError
return dict(self.objects), self.token()

def changes(self, since: str) -> tuple[dict[str, Stored | None], str]:
"""Every object written since the token, ``None`` for one deleted since."""
if self.removed:
raise NoSuchScope()
raise NoSuchScopeError
if int(since) < self.oldest:
raise ExpiredToken(since)
raise ExpiredTokenError(since)
written = set(self.log[int(since) :])
return {object_id: self.objects.get(object_id) for object_id in written}, self.token()

Expand All @@ -99,9 +98,9 @@ def delete(self, object_id: str, *, if_version: str | None) -> None:
def _check(self, object_id: str, if_version: str | None) -> None:
current = self.objects.get(object_id)
if current is None and if_version is not None:
raise NotFound(object_id)
raise NotFoundError(object_id)
if (None if current is None else current[0]) != if_version:
raise VersionMismatch(object_id)
raise VersionMismatchError(object_id)


class AsyncMemoryServer:
Expand Down Expand Up @@ -155,13 +154,11 @@ def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[
since = None if full_listing else scope.token
try:
if since is None:
objects, token = yield self._server.listing()
objects, token = yield from self._ask(self._server.listing)
else:
objects, token = yield self._server.changes(since)
except ExpiredToken:
objects, token = yield from self._ask(lambda: self._server.changes(since))
except ExpiredTokenError:
return ReadResult.INVALID_TOKEN
except NoSuchScope as exc:
raise ScopeGoneError(f"The server no longer holds {self.scope_id!r}") from exc
changes = {object_id: change(stored) for object_id, stored in objects.items()}
listing = since is None
change_set = ChangeSet(self.scope_id, scope.token, token, changes, full_listing=listing)
Expand All @@ -180,12 +177,17 @@ def _send(self, claimed: Record) -> Steps[PushOutcome]:
yield self._server.put(
object_id, cast(bytes, edit.content), if_version=claimed.version
)
except VersionMismatch:
except VersionMismatchError:
return Rejected()
except NotFound:
except NotFoundError:
return Gone()
return Applied()

def _gone(self, error: Exception) -> str | None:
if isinstance(error, NoSuchScopeError):
return f"The server no longer holds {self.scope_id!r}"
return None

def _waits(self, record: Record) -> bool:
return False

Expand Down
Loading
Loading