diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 984121f..3263e7b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,49 +1,47 @@ --- name: Bug report -about: Create a bug report to help us improve calendaring-sync +about: Report something calendaring-sync does wrong title: '[Bug] ' labels: '' assignees: '' --- - + -## Describe the bug +## What happened - + -## To reproduce +## How to reproduce it - + ```python import calendaring_sync -### ... ``` Output: - + ```pycon ``` -## Expected behavior +## What you expected - + ## Environment - - -- [ ] Operating system: -- [ ] Python version: -- [ ] calendaring-sync version: -- [ ] Adapter and server, if one is involved: +- [ ] Operating system: +- [ ] Python version: +- [ ] calendaring-sync version: +- [ ] Store: +- [ ] Adapter, its client library's version, and the server, if one is involved: ## Additional context - + -- [ ] I tested it with the latest version. +- [ ] I reproduced it with the latest release. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index f36a90a..c2bd6a3 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,6 +3,9 @@ blank_issues_enabled: true contact_links: + - name: Documentation + url: https://calendaring-sync.readthedocs.io/en/stable/ + about: Guides for each task, the design and why it works that way, and the full API reference - name: Discussions - url: https://github.com/pycalendar/calendaring-sync/discussions - about: For questions, ideas, and community discussions + url: https://github.com/PyCalendar/calendaring-sync/discussions + about: Questions, ideas, and help with using calendaring-sync diff --git a/.github/ISSUE_TEMPLATE/empty-issue.md b/.github/ISSUE_TEMPLATE/empty-issue.md index 3c96a18..5075bef 100644 --- a/.github/ISSUE_TEMPLATE/empty-issue.md +++ b/.github/ISSUE_TEMPLATE/empty-issue.md @@ -1,19 +1,12 @@ --- name: Blank issue -about: Unstructured template for anything not covered by bug reports +about: Anything a bug report doesn't fit title: '' labels: '' assignees: '' --- diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 54f9733..f800058 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,64 +2,40 @@ ## Linked issue - Closes #ISSUE_NUMBER - - - ## Description ## Checklist -- [ ] I added a change log entry, following the [change log instructions](https://calendaring-sync.readthedocs.io/en/latest/contribute.html#change-log). -- [ ] I followed calendaring-sync's [Artificial intelligence policy](https://calendaring-sync.readthedocs.io/en/latest/contribute.html#artificial-intelligence-policy) and disclosed my AI use in my commit messages and change log entry, if applicable. -- [ ] I added or updated tests, if applicable. -- [ ] I ran and ensured all tests pass locally, following [Running tests](https://calendaring-sync.readthedocs.io/en/latest/contribute.html#running-tests). -- [ ] If I changed an adapter, I ran its integration tests against every server I could start. -- [ ] I added or edited documentation as necessary. +- [ ] I ran the [checks CI runs](https://calendaring-sync.readthedocs.io/en/stable/contribute.html#check-your-change), and they pass. +- [ ] I added or updated [tests](https://calendaring-sync.readthedocs.io/en/stable/contribute.html#running-tests) for what I changed. +- [ ] If I changed an adapter, I ran its [integration tests](https://calendaring-sync.readthedocs.io/en/stable/contribute.html#running-tests) against every server I could start. +- [ ] I updated the [documentation](https://calendaring-sync.readthedocs.io/en/stable/contribute.html#writing-documentation) that my change makes untrue. +- [ ] I added a [change log entry](https://calendaring-sync.readthedocs.io/en/stable/contribute.html#change-log), or the change doesn't need one. +- [ ] I followed the [artificial intelligence policy](https://calendaring-sync.readthedocs.io/en/stable/contribute.html#artificial-intelligence-policy) and disclosed any AI use. ## Additional information diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 5d55745..619c7f8 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -30,7 +30,7 @@ jobs: - name: Set up Python uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: - python-version: "3.14" + python-version: "3.15" pip-version: "26.2.2" - name: Install dependencies @@ -44,7 +44,7 @@ jobs: # nothing starts them here. - name: Run integration tests run: | - pytest src/calendaring_sync/tests/test_sync_integration.py \ + pytest src/calendaring_sync/tests/test_caldav_integration.py \ src/calendaring_sync/tests/test_jmap_integration.py - name: Stop Stalwart diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc62237..8e09194 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,17 +29,17 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: - python-version: "3.14" + python-version: "3.15" pip-version: "26.2.2" - - run: pip install build twine==7.0.0 + - run: pip install build==1.6.1 twine==7.0.0 - name: Build package run: python -m build @@ -47,7 +47,7 @@ jobs: - name: Check built distribution run: twine check dist/* - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: distribution path: dist/ @@ -62,7 +62,7 @@ jobs: permissions: id-token: write # required for PyPI trusted publishing (OIDC) steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: distribution path: dist/ @@ -77,7 +77,7 @@ jobs: permissions: contents: write # required to create the GitHub release steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml index b5a561f..40deaa9 100644 --- a/.github/workflows/reuse.yml +++ b/.github/workflows/reuse.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9ee6415..83a906a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -34,7 +34,7 @@ jobs: - name: Set up Python uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: - python-version: "3.14" + python-version: "3.15" pip-version: "26.2.2" - name: Install towncrier @@ -48,18 +48,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: - python-version: "3.14" + python-version: "3.15" pip-version: "26.2.2" - name: Install ruff - run: pip install ruff==0.16.10 + run: pip install ruff==0.17.2 - name: Lint code run: ruff check . @@ -72,22 +72,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: - python-version: "3.14" + python-version: "3.15" pip-version: "26.2.2" - name: Install docutils - run: pip install docutils==0.22.4 + run: pip install docutils==0.23 - name: Install Vale run: | - curl -sL https://github.com/errata-ai/vale/releases/download/v3.24.0/vale_3.24.0_Linux_64-bit.tar.gz | tar -xz -C /usr/local/bin vale + curl -sL https://github.com/errata-ai/vale/releases/download/v3.25.1/vale_3.25.1_Linux_64-bit.tar.gz | tar -xz -C /usr/local/bin vale - name: Sync Vale styles run: vale sync @@ -100,7 +100,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -108,6 +108,7 @@ jobs: - name: Set up Python uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: + # Read the Docs builds with 3.14, its newest Python. python-version: "3.14" pip-version: "26.2.2" @@ -117,26 +118,29 @@ jobs: - name: Build docs run: sphinx-build -W -b html docs docs/_build/html + - name: Check links + run: sphinx-build -b linkcheck docs docs/_build/linkcheck + mypy: name: Type check with mypy runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: - python-version: "3.14" + python-version: "3.15" pip-version: "26.2.2" - name: Install dependencies run: pip install -e ".[dev]" - name: Type check - run: python -m mypy src/calendaring_sync --ignore-missing-imports + run: python -m mypy src/calendaring_sync docs/examples --ignore-missing-imports test: name: Test on Python ${{ matrix.python-version }} @@ -147,11 +151,11 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index fa206cf..a0b5e12 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -24,19 +24,19 @@ jobs: security-events: write # required to upload the SARIF results steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: - python-version: "3.14" + python-version: "3.15" pip-version: "26.2.2" - name: Install zizmor - run: pip install zizmor==1.30.1 + run: pip install zizmor==1.31.1 - name: Run zizmor run: zizmor --format sarif .github/workflows/ > zizmor-results.sarif - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: zizmor-results.sarif diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7a68cd..1b072d9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.10 + rev: v0.17.2 hooks: - id: ruff-check args: [--fix] diff --git a/.python-version b/.python-version index 6324d40..93a848f 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14 +3.15 diff --git a/.readthedocs.yml b/.readthedocs.yml index 0db718f..8ca4437 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,7 +4,7 @@ version: 2 build: - os: ubuntu-24.04 + os: ubuntu-26.04 tools: python: "3.14" jobs: diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 2707203..622c67e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,8 +1,6 @@ -# Contributor Covenant 3.0 Code of Conduct +# Code of Conduct -calendaring-sync follows the Contributor Covenant 3.0 Code of Conduct, which is hosted on the Python Calendaring Ecosystem website at the following link. - -https://pycal.org/code-of-conduct/ +calendaring-sync follows the Python Calendaring Ecosystem's [Code of Conduct](https://pycal.org/code-of-conduct/), the Contributor Covenant 3.0. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e031bb9..7a6307d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,4 +3,4 @@ # Contributing -See our [contributing guide](https://calendaring-sync.readthedocs.io/en/latest/contribute.html) for complete information on how to contribute to calendaring-sync. +The [contributing guide](https://calendaring-sync.readthedocs.io/en/stable/contribute.html) covers setting up, how the code is laid out, the checks a pull request must pass, and the conventions reviewers hold it to. diff --git a/README.md b/README.md index 7d9ab80..1b648bd 100644 --- a/README.md +++ b/README.md @@ -7,26 +7,61 @@ Calendar sync state: sync tokens, object versions, change tracking, and conflict Part of the [Python Calendaring Ecosystem](https://pycal.org). -[![Documentation](https://readthedocs.org/projects/calendaring-sync/badge/?version=latest)](https://calendaring-sync.readthedocs.io/en/latest/) -[![Tests](https://github.com/pycalendar/calendaring-sync/actions/workflows/tests.yml/badge.svg)](https://github.com/pycalendar/calendaring-sync/actions/workflows/tests.yml) -[![codecov](https://codecov.io/gh/pycalendar/calendaring-sync/branch/main/graph/badge.svg)](https://codecov.io/gh/pycalendar/calendaring-sync) -[![REUSE status](https://api.reuse.software/badge/github.com/pycalendar/calendaring-sync)](https://api.reuse.software/info/github.com/pycalendar/calendaring-sync) -[![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL%203.0--or--later-blue.svg)](LICENSE) +[![Documentation](https://readthedocs.org/projects/calendaring-sync/badge/?version=stable)](https://calendaring-sync.readthedocs.io/en/stable/) +[![Tests](https://github.com/PyCalendar/calendaring-sync/actions/workflows/tests.yml/badge.svg)](https://github.com/PyCalendar/calendaring-sync/actions/workflows/tests.yml) +[![codecov](https://codecov.io/gh/PyCalendar/calendaring-sync/branch/main/graph/badge.svg)](https://codecov.io/gh/PyCalendar/calendaring-sync) +[![REUSE status](https://api.reuse.software/badge/github.com/PyCalendar/calendaring-sync)](https://api.reuse.software/info/github.com/PyCalendar/calendaring-sync) +[![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL%203.0--or--later-blue.svg)](https://github.com/PyCalendar/calendaring-sync/blob/main/LICENSE) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) -calendaring-sync keeps the state a calendar sync needs between runs: the sync token the server last issued, a version for each object, and a local copy of what was last synced. It tells you what changed on each side and where local and remote changes conflict; your code decides what to do with each conflict. It doesn't talk to servers or run a sync loop itself: adapters connect it to a protocol's client library. It's in early development and has no release yet. +calendaring-sync keeps the state a calendar sync needs between runs: the sync token the server last issued, a version for each object, and a local copy of what was last synced. It tells you what changed on each side and where local and remote changes conflict; your code decides what to do with each conflict. It doesn't talk to servers or run a sync loop itself: an adapter connects it to a server, whether one that ships with calendaring-sync or one you write. + +## Installation + +```bash +pip install calendaring-sync +``` + +The example below syncs a CalDAV calendar, so it needs the `caldav` extra: `pip install "calendaring-sync[caldav]"`. The [install guide](https://calendaring-sync.readthedocs.io/en/stable/how-to/install.html) lists the extra each adapter that ships with calendaring-sync needs. + +## Usage + +```python +from caldav.davclient import DAVClient + +from calendaring_sync import Replica, SQLiteStore +from calendaring_sync.adapters.caldav import CalDAVAdapter + +URL = "https://caldav.example.com/" +USERNAME = "alice" +PASSWORD = "secret" + +with ( + SQLiteStore("sync.db") as store, + DAVClient(url=URL, username=USERNAME, password=PASSWORD) as client, +): + calendar = client.get_calendars()[0] + replica = Replica(store) + adapter = CalDAVAdapter(replica, calendar) + adapter.sync() + + for entry in replica.read_feed(): + print(entry.kind, entry.object_id) +``` + +See the [quickstart](https://calendaring-sync.readthedocs.io/en/stable/tutorials/quickstart.html) for what this does, and the [how-to guides](https://calendaring-sync.readthedocs.io/en/stable/how-to/index.html) for local edits, conflicts, the change feed, and writing an adapter of your own. ## Documentation -Full documentation: https://calendaring-sync.readthedocs.io/en/latest/ +Full documentation: https://calendaring-sync.readthedocs.io/en/stable/ ## Changelog -See the [change log](https://calendaring-sync.readthedocs.io/en/latest/changelog.html). +See the [change log](https://calendaring-sync.readthedocs.io/en/stable/changelog.html). ## Contributing -See the [contributing guide](https://calendaring-sync.readthedocs.io/en/latest/contribute.html). +See the [contributing guide](https://calendaring-sync.readthedocs.io/en/stable/contribute.html). ## Funding @@ -37,4 +72,4 @@ This project is funded through [NGI0 Commons Fund](https://nlnet.nl/commonsfund) ## License -AGPL-3.0-or-later. See [LICENSE](LICENSE). +AGPL-3.0-or-later. See [LICENSE](https://github.com/PyCalendar/calendaring-sync/blob/main/LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 74a7eff..33368d1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,4 +3,4 @@ # Security policy -See our [security policy](https://calendaring-sync.readthedocs.io/en/latest/security.html) for how to report a vulnerability. +Report a vulnerability through [GitHub's private vulnerability reporting](https://github.com/PyCalendar/calendaring-sync/security/advisories/new), not a public issue. The [security policy](https://calendaring-sync.readthedocs.io/en/stable/security.html) says which versions get fixes and the risks to know. diff --git a/docs/.vale/styles/config/vocabularies/Base/accept.txt b/docs/.vale/styles/config/vocabularies/Base/accept.txt index 6901590..524ed3e 100644 --- a/docs/.vale/styles/config/vocabularies/Base/accept.txt +++ b/docs/.vale/styles/config/vocabularies/Base/accept.txt @@ -1,28 +1,41 @@ AGPL +asyncio autodoc autodoc'd autogenerated +Bedework calendaring-sync calendaring_sync charset chatbot +coroutines? Diataxis docstrings? +ecloud ETag filename hardcoding href iCalendar +Infomaniak inlined JSCalendar +lxml multiget multistatus +Nextcloud parens pimsync +Posteo +Purelymail pytest Radicale repo +Robur +SOGo +Synology towncrier vdirsyncer vstorage Xandikos +Zimbra diff --git a/docs/_static/js/custom-icons.js b/docs/_static/js/custom-icons.js new file mode 100644 index 0000000..1c5f596 --- /dev/null +++ b/docs/_static/js/custom-icons.js @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 calendaring-sync contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +FontAwesome.library.add( + /** + * Custom icon definitions + * + * see https://pydata-sphinx-theme.readthedocs.io/en/latest/user_guide/header-links.html#svg-image-icons + */ + { + prefix: "fa-custom", + iconName: "pypi", + icon: [ + 17.313, + 19.807, + [], + "e001", + // https://simpleicons.org/icons/pypi.svg + "m10.383 0.2-3.239 1.1769 3.1883 1.1614 3.239-1.1798zm-3.4152 1.2411-3.2362 1.1769 3.1855 1.1614 3.2369-1.1769zm6.7177 0.00281-3.2947 1.2009v3.8254l3.2947-1.1988zm-3.4145 1.2439-3.2926 1.1981v3.8254l0.17548-0.064132 3.1171-1.1347zm-6.6564 0.018325v3.8247l3.244 1.1805v-3.8254zm10.191 0.20931v2.3137l3.1777-1.1558zm3.2947 1.2425-3.2947 1.1988v3.8254l3.2947-1.1988zm-8.7058 0.45739c0.00929-1.931e-4 0.018327-2.977e-4 0.027485 0 0.25633 0.00851 0.4263 0.20713 0.42638 0.49826 1.953e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36226 0.13215-0.65608-0.073306-0.65613-0.4588-6.28e-5 -0.38556 0.2938-0.80504 0.65613-0.93662 0.068422-0.024919 0.13655-0.038114 0.20156-0.039466zm5.2913 0.78369-3.2947 1.1988v3.8247l3.2947-1.1981zm-10.132 1.239-3.2362 1.1769 3.1883 1.1614 3.2362-1.1769zm6.7177 0.00213-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2439-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.016195v3.8275l3.244 1.1805v-3.8254zm16.9 0.21143-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2432-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.019027v3.8247l3.244 1.1805v-3.8254zm13.485 1.4497-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm2.4018 0.38127c0.0093-1.83e-4 0.01833-3.16e-4 0.02749 0 0.25633 0.0085 0.4263 0.20713 0.42638 0.49826 1.97e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36188 0.1316-0.65525-0.07375-0.65542-0.4588-1.95e-4 -0.38532 0.29328-0.80469 0.65542-0.93662 0.06842-0.02494 0.13655-0.03819 0.20156-0.03947zm-5.8142 0.86403-3.244 1.1805v1.4201l3.244 1.1805z", + ], + }, +); diff --git a/docs/_templates/404.html b/docs/_templates/404.html index f1aed8a..84fb279 100644 --- a/docs/_templates/404.html +++ b/docs/_templates/404.html @@ -14,7 +14,7 @@

Page not found

Why is this happening?

The link you followed may be outdated, or the page may have moved. - If you would like to report an issue or request a redirect be set up, you can create a new issue in the GitHub issue tracker. + If you would like to report an issue or request a redirect be set up, you can create a new issue in the GitHub issue tracker.

diff --git a/docs/conf.py b/docs/conf.py index 7358dab..75683eb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,11 +21,16 @@ "sphinx_design", "sphinx_issues", "sphinx_last_updated_by_git", + "sphinx_llms_txt", "sphinxcontrib.mermaid", "sphinxext.opengraph", ] -html_baseurl = "https://calendaring-sync.readthedocs.io/" +# Read the Docs gives each build its address with the language and version, +# so the links built from it, in social cards and llms.txt, reach real pages. +html_baseurl = os.environ.get( + "READTHEDOCS_CANONICAL_URL", "https://calendaring-sync.readthedocs.io/en/stable/" +) ogp_site_url = html_baseurl ogp_site_name = "calendaring-sync" @@ -39,18 +44,25 @@ } # sphinx_issues configuration: enables :issue:`N`, :pr:`N`, :user:`name` roles -issues_github_path = "pycalendar/calendaring-sync" +issues_github_path = "PyCalendar/calendaring-sync" html_theme = "pydata_sphinx_theme" html_theme_options = { "icon_links": [ { "name": "GitHub", - "url": "https://github.com/pycalendar/calendaring-sync", + "url": "https://github.com/PyCalendar/calendaring-sync", "icon": "fa-brands fa-square-github", "type": "fontawesome", "attributes": {"target": "_blank", "rel": "noopener me"}, }, + { + "name": "PyPI", + "url": "https://pypi.org/project/calendaring-sync", + "icon": "fa-custom fa-pypi", + "type": "fontawesome", + "attributes": {"target": "_blank", "rel": "noopener me"}, + }, ], "footer_start": ["copyright"], "footer_end": ["nlnet"], @@ -61,13 +73,18 @@ "navigation_with_keys": True, "collapse_navigation": False, "search_bar_text": "Search documentation", - "secondary_sidebar_items": ["page-toc"], + "secondary_sidebar_items": ["page-toc", "edit-this-page", "sourcelink"], + "use_edit_page_button": True, "article_footer_items": ["last-updated"], } html_last_updated_fmt = "%Y-%m-%d" +# sphinx-last-updated-by-git hides the source of a page git doesn't track yet, +# and sphinx-llms-txt needs every page's source, so a new page would fail the +# build until it's committed. +git_untracked_show_sourcelink = True html_context = { - "github_user": "pycalendar", + "github_user": "PyCalendar", "github_repo": "calendaring-sync", "github_version": "main", "doc_path": "docs", @@ -75,10 +92,11 @@ templates_path = ["_templates"] html_static_path = ["_static"] -# sync-bootstrap-theme mirrors data-theme onto data-bs-theme so Bootstrap's own -# dark-mode variables (dropdown menus, tooltips) activate instead of staying -# stuck in light mode. +# Custom fa-custom/fa-pypi icon used in icon_links above; sync-bootstrap-theme +# mirrors data-theme onto data-bs-theme so Bootstrap's own dark-mode variables +# (dropdown menus, tooltips) activate instead of staying stuck in light mode. html_js_files = [ + ("js/custom-icons.js", {"defer": "defer"}), ("js/sync-bootstrap-theme.js", {"defer": "defer"}), ] # Loaded as a separate rather than @import inside pycal-theme.css: @@ -108,20 +126,35 @@ # Measure labels in the site's own font, so they fit the boxes drawn for them. mermaid_init_config = {"startOnLoad": False, "fontFamily": "var(--pst-font-family-base)"} +# The link check fails on a broken link. GitHub sends anyone signed out of +# the private vulnerability form to its login page, which isn't broken. +linkcheck_ignore = [r"https://github\.com/PyCalendar/calendaring-sync/security/advisories/new"] +linkcheck_timeout = 10 +linkcheck_retries = 2 + intersphinx_mapping = { "python": ("https://docs.python.org/3", None), + "caldav": ("https://caldav.readthedocs.io/stable/", None), + "calendaring_jmap": ("https://calendaring-jmap.readthedocs.io/en/stable/", None), } +# Every reference must resolve, so a renamed class or method can't leave a +# link that quietly renders as plain text. +nitpicky = True +nitpick_ignore = [ + # The type variable inside the Steps alias, which has no page of its own. + ("py:obj", "calendaring_sync.adapters.base._T"), +] + napoleon_google_docstring = True napoleon_numpy_docstring = False napoleon_include_init_with_doc = True -# Render "Attributes:" as :ivar: field-list entries instead of standalone -# attribute directives, which collide with autodoc's own dataclass field -# introspection and produce "duplicate object description" warnings. -napoleon_use_ivar = True +# Each "Attributes:" entry becomes the field's own entry, with its type and a +# link target, and autodoc leaves out members without a docstring, so a +# dataclass field is listed once. +napoleon_use_ivar = False autodoc_default_options = { "members": True, - "undoc-members": True, "show-inheritance": True, } diff --git a/docs/contribute.rst b/docs/contribute.rst index ab43e86..fa6cefd 100644 --- a/docs/contribute.rst +++ b/docs/contribute.rst @@ -5,198 +5,274 @@ Contribute ========== -This guide describes how to contribute to calendaring-sync. +.. 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 follows the Python Calendaring Ecosystem's `Code of Conduct `_. +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. -Development setup -================= +Before you start +================ + +Report a bug or propose a change in an `issue `_, and ask questions in `Discussions `_. For anything beyond a small fix, open an issue first and agree on the approach before you write the code. Report a vulnerability privately, as :doc:`security` describes. + +Everyone taking part follows the Python Calendaring Ecosystem's `Code of Conduct `_. + +.. _artificial-intelligence-policy: + +Artificial intelligence policy +------------------------------ + +calendaring-sync follows the Python Calendaring Ecosystem's `AI policy `_. Read it before you use AI to help write a pull request. + +Set up +====== .. code-block:: bash - git clone https://github.com/pycalendar/calendaring-sync + git clone https://github.com/PyCalendar/calendaring-sync cd calendaring-sync pip install -e ".[dev]" pre-commit install -Running tests +The ``dev`` extra installs every adapter's client library and the test servers that run embedded. To build the docs, also install the ``docs`` extra, which needs Python 3.13 or later. + +How the code is laid out +======================== + +.. list-table:: + :header-rows: 1 + :widths: 35 65 + + * - Where + - What it holds + * - ``replica.py``, ``session.py``, ``objects/``, ``_classify.py`` + - The core: the replica, sync sessions, the classifier, and the values they take and return + * - ``store/`` + - The store interface, and the stores that ship + * - ``adapters/base.py`` + - The adapter base classes + * - ``adapters/.py`` + - Each adapter that ships, behind an extra of the same name + * - ``tests/`` + - The unit and integration tests, the recorded server exchanges, and the benchmark + +The core imports no protocol's client library and names no protocol, a store decides nothing about sync, and an adapter persists nothing itself. A change that crosses one of those lines gets redesigned in review, so raise it in the issue first. :doc:`explanation/design` explains why the layers are split this way. + +A new adapter can live in its own package, built on :mod:`calendaring_sync.adapters.base` as :doc:`how-to/write-an-adapter` shows. To propose one for this repository, open an issue with the protocol, its client library, and the servers you can test it against. + +.. _running-tests: + +Run the tests ============= -Unit tests, no server required: +Unit tests need no server, network, or Docker. CI runs these four files 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 -Tests live inside the package, in ``src/calendaring_sync/tests/``. ``test_classify_unit.py`` holds the classifier's tests, over every combination of its inputs, ``test_caldav_unit.py`` and ``test_jmap_unit.py`` the adapters', and ``test_sync_unit.py`` the rest. Unit tests make no network calls and need no Docker, so they run anywhere. - -The adapters' unit tests replay HTTP exchanges recorded from real servers, kept in ``src/calendaring_sync/tests/data/caldav/`` and ``src/calendaring_sync/tests/data/jmap/``. Each recording comes from a scenario in ``src/calendaring_sync/tests/_caldav_scenarios.py`` or ``src/calendaring_sync/tests/_jmap_scenarios.py``, which checks what it records. A rule no tested server shows on its own is tested by editing one exchange of a recording in the test itself. To record again, after a client library upgrade or a new scenario, run ``python -m calendaring_sync.tests.record_caldav SERVER [SCENARIO...]`` or ``python -m calendaring_sync.tests.record_jmap SERVER [SCENARIO...]``; each docstring says which servers it starts and which you start first. The JMAP scenarios delete every event in the account they use, so record them with a test account. +``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/``. -Integration tests run the adapter against real servers: +Integration tests run each adapter, and the docs examples that need a server, against real servers: .. code-block:: bash - pytest src/calendaring_sync/tests/test_sync_integration.py src/calendaring_sync/tests/test_jmap_integration.py + bash tests/docker/stalwart/start.sh + pytest src/calendaring_sync/tests/test_caldav_integration.py src/calendaring_sync/tests/test_jmap_integration.py -Radicale and Xandikos run embedded, and Stalwart, which serves both CalDAV and JMAP, starts from this repo's fixture with ``tests/docker/stalwart/start.sh``, so CI runs all three on every release. Cyrus and Baïkal are tested when their Docker fixtures answer and skipped otherwise; two Stalwart tests change the whole server, as their skip messages say, so they run only when asked. Run the integration tests when you change an adapter, with every server you can start. +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. -Checking performance -==================== +Record server exchanges +----------------------- -If your change touches ingesting, local edits, pushes, a store or a fingerprint, run the benchmarks on ``main`` and on your branch, on the same machine, and compare: +After a client library upgrade, or for a new scenario, record the exchanges again: .. code-block:: bash - python -m calendaring_sync.tests.benchmark + python -m calendaring_sync.tests.record_caldav SERVER [SCENARIO...] + python -m calendaring_sync.tests.record_jmap SERVER [SCENARIO...] -It times a full listing of a large scope, a second listing over local edits and conflicts, a run of single edits, pushes of those edits, and the longest read in another thread while a listing runs, each on a fresh SQLite store, and the CalDAV and JMAP adapters' fingerprints of as many events. Times differ between machines, so only the comparison means anything. Explain any slowdown beyond run-to-run noise in your pull request. ``--size`` and ``--runs`` change the scope's size and the number of runs. +Each scenario checks what it records, so a recording that doesn't show its behavior fails instead of being saved. The JMAP scenarios delete every event in the account they use, so record them with a test account. A rule no tested server shows on its own is tested by editing one exchange of a recording inside the test. -Previewing docs -=============== +Measure performance +------------------- + +If your change touches ingesting, local edits, pushes, a store, or a fingerprint, run the benchmark on ``main`` and on your branch, on the same machine, and compare: .. code-block:: bash - pip install -e ".[docs]" - sphinx-autobuild docs docs/_build/html + python -m calendaring_sync.tests.benchmark -Opens a local server that rebuilds and reloads the browser tab automatically as you edit files under ``docs/``. The ``docs`` extra needs Python 3.12 or newer, because Sphinx itself does. +It prints the best time of each scenario, each on a fresh store. ``--size`` and ``--runs`` change the scope's size and the number of runs. Explain any slowdown beyond run-to-run noise in your pull request. -.. _vale-check: +Check your change +================= -Checking docs prose with Vale -============================= +CI runs every check below on each pull request. Run them first, and fix what they report before you ask for review. + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Check + - Command + * - Lint and format + - ``ruff check .`` and ``ruff format --check .`` + * - Types + - ``mypy src/calendaring_sync docs/examples --ignore-missing-imports`` + * - Unit tests + - The command in :ref:`running-tests` + * - Docs build + - ``sphinx-build -W -b html docs docs/_build/html`` + * - Links + - ``sphinx-build -b linkcheck docs docs/_build/linkcheck`` + * - Docs prose + - ``vale sync`` then ``vale docs/``, as :ref:`vale-check` describes + * - Licensing + - ``reuse lint`` + * - 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. -`Install Vale `_ first; it's a standalone binary, not a Python package. Then: +.. _code-style: -.. code-block:: bash +Write code +========== - vale sync - vale docs/ +``ruff`` and ``mypy`` don't catch the rules below, so review your own diff against them. -CI runs this the same way, as a warn-only check (a failure doesn't block a PR yet): it catches a real subset of :ref:`writing-documentation`'s rules mechanically, but it isn't a substitute for reading your own diff. +No duplication + Before you write a function, test, or fixture, look for one that already does the job, and reuse or extend it. A test that checks what an existing test checks extends that test instead. -.. _artificial-intelligence-policy: +No hardcoding + A literal with a meaning, such as a status value or a default path, is a named constant defined once. A literal used once with no meaning beyond it, such as a test's id, isn't. -Artificial intelligence policy -============================== +Argument types + The public methods of the replica, the session, the stores and the adapters, and the values an adapter builds, check their argument types at run time from their annotations, so annotate every parameter of a new public method. A built-in type such as ``str`` or ``int`` must match exactly, so ``True`` isn't taken as ``1``. The unit tests build every check, and fail on a parameter with no annotation or one the checks can't read. -calendaring-sync follows the Python Calendaring Ecosystem's `AI policy `_. Read it before using AI to help draft a pull request. +Errors + Every failure a caller can act on raises a subclass of :class:`~calendaring_sync.error.CalendaringSyncError`, and failures a caller handles differently are different classes. A public method documents what it raises, and every error path has a test that checks the store is left as it was. -.. _commits-and-prs: +Stored data + A store file outlives the release that wrote it. A change to what :class:`~calendaring_sync.store.sqlite.SQLiteStore` writes, such as a table, a column, or what a value means, bumps :data:`~calendaring_sync.constants.SCHEMA_VERSION` and migrates a file of the old version when it's opened, with a test that opens one. -Commits and pull requests -========================= +Public names + Renaming or removing a public name follows the deprecation rule in :doc:`release`. -Commit messages follow Conventional Commits: ``type: subject``, lowercase type, imperative mood, no period, for example ``fix: keep the previous sync token when a sync fails``. Common types in this repo: ``feat``, ``fix``, ``test``, ``docs``, ``chore``, ``refactor``. A scope in parens is fine when it adds real information, skip it otherwise. One line is enough; this project doesn't use commit bodies or trailers. +RFC citations + The core cites no RFC, as :ref:`write-about-sync` says. In an adapter's docstrings, cite a published RFC with the ``:rfc:`` role and the section inside the role, such as ``:rfc:`5545#section-3.6.1```. Written as ``:rfc:`5545` section 3.6.1``, it links only to the RFC's first page. Check the section number against the RFC's text before you cite it. -Keep each commit to one concern. A PR that both fixes a bug and refactors an unrelated helper should be two commits, not one with an unrelated diff mixed in; split it into two PRs instead if the two concerns don't obviously belong together (a broad cleanup PR touching many small, related things is fine as one PR with several commits inside it). + An Internet-Draft has no RFC number, so cite it as plain text with its name and section, such as "draft-ietf-jmap-calendars-33 section 5.9". Comments, tests, and private modules aren't rendered, so cite RFCs there as plain text too, such as "RFC 6578 section 3.2". -Write the PR description in your own words, as a short account of what you found and did, not a restatement of the linked issue's own text back at its author, and not a bullet-by-bullet checklist. If the work turned up real bugs along the way, describe the fix; don't frame the PR as "here's how many bugs I found," even when true, that framing reads as padding a body's length rather than reporting the work. +.. _prose-docstrings-comments: -Before opening a PR: run the full local test suite, ``ruff check`` and ``ruff format --check``, ``mypy``, ``reuse lint``, and :ref:`vale-check` and ``sphinx-build -W -b html docs docs/_build/html`` if you touched any ``.rst`` page. Fix everything that comes back before asking for review, rather than leaving a known-red check for the reviewer to raise. Vale runs warn-only in CI for now, so it won't block the PR on its own, but treat its findings the same as the others. CI also builds the docs with Sphinx's warnings-as-errors flag, so a broken ``:rfc:`` role, cross-reference, or ``automodule``/``autoclass`` directive fails the PR there rather than silently deploying a broken page to Read the Docs. +Docstrings and comments + Say what's true about this code, specifically. A comment explains what the code can't show, such as a spec rule or a server's behavior, and never restates the code next to it. Use the punctuation a sentence needs: no em dash or spaced hyphen in place of a colon, comma, or period, and "produces" or "maps to" instead of an arrow. Leave out filler such as delve, leverage, utilize, robust, seamless, and pivotal. -.. _change-log: +Dead code + If your change makes a function unreachable, remove it in the same pull request. -Change log -========== +.. _writing-documentation: -If your PR changes behavior, add a news fragment. CI-only and internal-refactor PRs don't need one. +Write documentation +=================== -.. code-block:: bash +This section covers the pages in ``docs/``. Docstrings follow :ref:`code-style`. - touch news/..rst +Choose a Diátaxis category + The docs follow the `Diátaxis framework `_. Each category answers one question, so put your page where its reader's question is: -Where ```` is one of: ``breaking``, ``removal``, ``feature``, ``bugfix``, ``documentation``, ``deps``, ``internal``, ``chore``, ``security``. + - **Tutorial** (``tutorials/``): one guided path a newcomer follows from start to finish. New content rarely belongs here. + - **How-to guide** (``how-to/``): the steps for one task, for a reader who knows the basics. It links to an explanation page for the why. + - **Reference** (``reference/``): the API, generated from docstrings. Each page renders its module's docstring as its introduction, so write the introduction there. + - **Explanation** (``explanation/``): why calendaring-sync works the way it does, under "Why" headings, as :doc:`explanation/design` does. -Write a short, user-facing description of the change inside the file: state what changed and, if it's a fix, what happened before. The issue link is generated automatically from the filename's number, so don't add one yourself. If your PR isn't tied to an issue, prefix the filename with ``+`` instead of a number (for example ``+resync-after-invalid-token.bugfix.rst``); towncrier accepts orphan fragments this way and just omits the issue link. Fragments are collected into ``docs/changelog.rst`` at release time. Don't edit that file directly. See :doc:`release` for the maintainer-only steps that actually cut a release. +.. _one-place: -Keep each fragment to one or two sentences. State the change; don't explain how it works internally, why it was needed, or how it was found, that belongs in the PR description, not the changelog. If your PR touches more than one distinct behavior (two separate bugs, or a feature plus an unrelated parameter addition), write one fragment per behavior (``11.feature.1.rst``, ``11.feature.2.rst``, and further numbered fragments as needed) instead of one fragment covering all of them. See any ``bugfix`` fragment in ``news/`` for the length and tone to aim for. +Say each thing in one place + Each fact, rule, and reason lives on one page or in one docstring, and every other place links to it with ``:doc:``, ``:ref:``, ``:term:``, ``:class:``, or ``:meth:``. What a class or method does belongs in its docstring, why it does it on an explanation page, and how to do a task on a how-to page. -If you used AI to help write the change, briefly disclose it in the fragment, per the :ref:`artificial-intelligence-policy`. +.. _write-about-sync: -To preview what the change log will look like: +Write about sync, not about protocols + The core is protocol-agnostic, and so are its docs. Core pages and core docstrings describe sync in its own terms: sync tokens, versions, changes, and conflicts. Don't explain a core behavior through a protocol or a client library, even in an aside, and don't count or list the adapters in shared text: adding an adapter must never mean editing a core page. Protocol names, RFC numbers, and protocol fields belong in that adapter's own pages and docstrings. -.. code-block:: bash +Prose style + Pages follow the `Microsoft Writing Style Guide `_: plain language, active voice, second person, present tense, sentence-case headings, and no exclamation marks. Contractions are fine. Write "for example" and "that is", not "e.g." and "i.e.", and "such as", not "like", to introduce an example. Leave out "simple", "just", "very", and "easy": to a reader who can't complete the task as described, they read as condescending. :ref:`prose-docstrings-comments` applies here too. - towncrier build --draft --version 0.0.0 + Write each paragraph as one line. reStructuredText ignores line breaks inside a paragraph, and every page in this repository is written this way. -If you're unsure whether your PR needs a fragment, ask a maintainer for the ``skip-changelog`` label rather than skipping silently. +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-style: +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. -Code style -========== +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. -``ruff`` and ``mypy`` catch syntax and typing issues; they don't catch everything below, so review your own diff for it before opening a PR. +Preview the docs +---------------- -No duplication - If you're about to write a function, block, or test fixture that's the same shape as one that already exists elsewhere (even with different variable names), extract a shared helper instead. This applies to tests as much as source. +.. code-block:: bash -No hardcoding - A literal that means something (a status value, a format string, a default path) belongs in a named constant defined once, not inlined at every call site. A one-off literal with no reused meaning (a test fixture's arbitrary ID) doesn't need this. + pip install -e ".[docs]" + sphinx-autobuild docs docs/_build/html -Argument types - The public methods of ``Replica``, ``Session``, the stores and the adapters, and the value classes an adapter builds, check their argument types at runtime, from their annotations, so give every parameter of a new public method an annotation. A built-in type such as ``str`` or ``int`` has to match exactly, so ``True`` isn't taken as ``1``. The unit tests build every check and fail on a parameter without an annotation, or on one the checks can't read. +This serves the docs locally and rebuilds them as you edit. -RFC citations - The core doesn't cite RFCs; see :ref:`write-about-sync`. In adapter docstrings (not comments, not test files, see below), cite a real, finalized RFC with Sphinx's ``:rfc:`` role, and always merge the section number into the role itself: ``:rfc:`5545#section-3.6.1```, not ``:rfc:`5545` section 3.6.1``. The latter renders as two disconnected pieces, a link to the RFC's front page and plain text next to it, so a reader has to manually find the section after clicking through. Verify the section number against the actual RFC text before citing it; don't guess from a related section or trust an existing comment's number without checking. +.. _vale-check: - A spec that has no RFC number yet (an Internet-Draft) can't use ``:rfc:``, since the role links to ``datatracker.ietf.org/doc/html/rfcNNNN.html`` and there's no such page yet. Cite it as plain text with its draft name and section until it's assigned a number, then convert it. +Check prose with Vale +--------------------- - ``:rfc:`` only renders correctly in docstrings that Sphinx's ``automodule``/autodoc actually pulls in (check ``docs/reference/*.rst`` for which modules that is; private, underscore-prefixed modules aren't autodoc'd, so a role there is only for source readers, not the built docs). Plain ``#``-comments are never RST-parsed, and neither are docstrings inside ``tests/``, so use plain "RFC NNNN section N.N" text in both, not the role. +`Install Vale `_, a standalone program rather than a Python package, then run: -.. _prose-docstrings-comments: +.. code-block:: bash -Prose in docstrings and comments - Write plainly and specifically: say what's actually true about this code, not a general description that could apply to any project. Pick the punctuation a sentence actually needs instead of leaning on an em dash or a spaced hyphen (``word - word``) as a stand-in for a colon, comma, or period; the same goes for the Unicode arrow ``→`` used as shorthand for "produces" or "maps to," which reads clearer written out, or as ASCII ``->`` inside a code example where alignment matters. + vale sync + vale docs/ - A few words read as filler more often than not and are usually worth cutting: delve, leverage, utilize (say "use"), robust, seamless, pivotal, cutting-edge, game-changer. If a sentence is true and specific without one of these, it almost never needs it back in. +It runs the Microsoft style package and `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. -Dead code - Before adding a new helper, check whether an existing one already does the job. If your change makes a function unreachable, remove it in the same PR rather than leaving it for a future cleanup pass; check the milestone tracker first in case a near-term feature already claims it. +.. _commits-and-prs: -.. _writing-documentation: +Commits and pull requests +========================= -Writing documentation -===================== +Commit messages follow Conventional Commits: ``type: subject``, with a lowercase type, the imperative mood, and no period, such as ``fix: keep the previous sync token when a sync fails``. The usual types are ``feat``, ``fix``, ``test``, ``docs``, ``chore``, and ``refactor``. Add a scope in parentheses only when it adds information. A commit message is one line, with no body or trailers. -This section covers ``docs/*.rst`` page prose: how-to guides, tutorials, reference pages, and explanation pages. For docstring and comment conventions, see :ref:`code-style` above; this section cross-references those rules rather than restating them. +Every change reaches ``main`` through a pull request, except the change log a maintainer builds for a release. Each commit makes one change. A bug fix and an unrelated refactor are two commits, or two pull requests when they don't belong together. -Choosing a Diataxis category - calendaring-sync's docs are organized into four categories: tutorials, how-to guides, reference, and explanation. This split comes from the `Diátaxis framework `_. Django's docs use the same four-way split, under different section names; pytest's docs use nearly these same names. Each category answers a different question, so pick the one matching what the reader actually needs: +Write the pull request description in your own words: what you found and what you changed. Don't restate the linked issue, and don't write a checklist; the template has the one that counts. - - **Tutorial** (``tutorials/``): a single guided path a newcomer follows start to finish, learning by doing. New content almost never belongs here: a tutorial teaches someone who knows nothing yet, which is a narrow, rarely-needed case, and most new content is really a how-to. - - **How-to guide** (``how-to/``): a terse recipe for someone who already knows the basics and wants to do one specific thing. No "why" content inline: cross-reference :doc:`explanation/design` instead. - - **Reference** (``reference/``): generated, factual lookup material. This is almost entirely autodoc output; hand-written prose here should be limited to a short page intro, not an explanation of design choices. - - **Explanation** (``explanation/``): the why, background, and design rationale, kept separate so how-to pages stay scannable. ``explanation/design.rst``'s existing "Why X" section headings are the pattern to match. +.. _change-log: -.. _write-about-sync: +Change log +========== -Write about sync, not about protocols - calendaring-sync's core is protocol-agnostic, and its docs are too. Core pages and core docstrings describe sync on its own terms: sync tokens, object versions, changes, and conflicts. Don't explain a core behavior by contrasting it against a specific protocol or library, even as a single clarifying aside. Protocol names, RFC numbers, and protocol-specific fields belong only in an adapter's own pages and docstrings. +A pull request that changes behavior adds a news fragment. Changes to CI or internal code that users can't see don't need one. -Prose style for pages - Page prose follows the `Microsoft Writing Style Guide `_: plain language, active voice, second person ("you," not "the user"), sentence-case headings, no exclamation points. Contractions are fine. Avoid Latin abbreviations in prose ("for example," not "e.g."; "that is," not "i.e."). This is a different, more conversational register than :ref:`prose-docstrings-comments`'s rule, which governs terse, factual docstring/comment text; that rule's bans apply here too, unchanged. +.. code-block:: bash - Write each paragraph as one line, not hard-wrapped at a fixed column width. RST collapses whitespace, so wrapping serves no rendering purpose; every existing page in this repo is written this way. + touch news/..rst - Avoid "simple," "just," "very," and "easy": a reader who can't complete the task as described finds these words condescending rather than reassuring. Prefer "such as" over "like" when introducing an example, "select" over "check"/"tick" for a UI action, and "verify" over "be sure" when asking the reader to confirm something. +```` is one of ``breaking``, ``removal``, ``feature``, ``bugfix``, ``documentation``, ``deps``, ``internal``, ``chore``, or ``security``. - Spell these terms consistently: GitHub, add-on (not addon), plug-in (not plugin), reST or reStructuredText (not RST, except as the file extension or in a code span like ``.rst``). +In the file, say in one or two sentences what changed for users and, for a fix, what happened before. Name a class, method or error with a role, such as ``:class:`~calendaring_sync.replica.Replica```, so the entry links to its reference. How it works and how you found it belong in the pull request. The file name's number links the issue. A change with no issue uses ``+`` instead of a number, such as ``+resync-after-invalid-token.bugfix.rst``. Write one fragment per change, numbered when an issue has several (``11.feature.1.rst``, ``11.feature.2.rst``). Don't edit ``docs/changelog.rst``: :doc:`release` builds it from the fragments. - :ref:`vale-check` runs the Microsoft Vale style package plus `signs-of-ai-writing `_ (patterns from Wikipedia's `Signs of AI writing `_ essay) in CI, catching a real subset of these rules mechanically: contractions, passive voice, dash spacing, sentence-case headings, hedging clusters, chatbot phrases, and the AI-typical vocabulary and symbolic-language patterns the essay documents. It doesn't catch everything: the Diataxis category choice and whether a how-to page pushed "why" content where it belongs still need a reviewer's judgment. +If you used AI to help write the change, disclose it briefly in the fragment, as the :ref:`artificial-intelligence-policy` asks. -Cross-referencing and code examples - Link between pages with ``:doc:`` and an explicit relative path (``:doc:`../explanation/design```). Reference a class or method with ``:class:``/``:meth:`` and a leading ``~`` for a short display name. Cite an RFC the same way :ref:`code-style`'s "RFC citations" rule describes for docstrings, merging the section into the role. +To preview the change log: - Link a sync term to :doc:`reference/glossary` with ``:term:`` the first time a section uses it, and define a new term there rather than inline. Sphinx doesn't check the syntax inside a ``.. mermaid::`` diagram, so render a changed diagram before opening a PR. +.. code-block:: bash - Use ``.. code-block:: python`` or ``.. code-block:: bash``, with a blank line before and after the directive and the indented block. A tutorial ends with an explicit "Next steps" section linking forward to relevant how-to and explanation pages. + towncrier build --draft --version 0.0.0 -Title underlines - RST title underlines must match the title's exact character width. Sphinx only warns about this for a page title's overline/underline pair, not for an ordinary section heading's underline alone, so a mismatched section underline builds silently; count it yourself rather than assume a clean build means it's correct. +If you're unsure whether your pull request needs a fragment, ask a maintainer for the ``skip-changelog`` label. License ======= -By contributing, you agree your contributions are licensed under AGPL-3.0-or-later, same as the rest of the project. +By contributing, you agree that your contributions are licensed under AGPL-3.0-or-later, the same as the rest of the project. diff --git a/docs/examples/async_sync.py b/docs/examples/async_sync.py new file mode 100644 index 0000000..9f96a1d --- /dev/null +++ b/docs/examples/async_sync.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from calendaring_sync import MemoryStore, Replica + +replica = Replica(MemoryStore()) +URL = "https://caldav.example.com/" +USERNAME = "alice" +PASSWORD = "secret" + +# start: sync +import asyncio + +from caldav.aio import AsyncDAVClient + +from calendaring_sync.adapters.caldav import AsyncCalDAVAdapter + + +async def sync() -> None: + async with AsyncDAVClient(url=URL, username=USERNAME, password=PASSWORD) as client: + calendar = (await client.get_calendars())[0] + adapter = AsyncCalDAVAdapter(replica, calendar) + await adapter.sync() + + +asyncio.run(sync()) +# end: sync diff --git a/docs/examples/caldav_sync.py b/docs/examples/caldav_sync.py new file mode 100644 index 0000000..d92c3a7 --- /dev/null +++ b/docs/examples/caldav_sync.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from calendaring_sync import MemoryStore, Replica + +replica = Replica(MemoryStore()) +URL = "https://caldav.example.com/" +CALENDAR_URL = "https://caldav.example.com/calendars/alice/work/" +USERNAME = "alice" +PASSWORD = "secret" +SERVER = "radicale" + +# start: client +from caldav.davclient import DAVClient + +from calendaring_sync.adapters.caldav import CalDAVAdapter + +client = DAVClient(url=URL, username=USERNAME, password=PASSWORD, features=SERVER) +calendar = client.calendar(url=CALENDAR_URL) +adapter = CalDAVAdapter(replica, calendar) +# end: client + +# start: listings +from datetime import UTC, datetime, timedelta + +adapter.sync() +listed = replica.scope(adapter.scope_id).last_full_listing_at +stale = listed is None or datetime.now(UTC) - listed > timedelta(days=1) +if adapter.needs_full_listings and stale: + adapter.sync(full_listing=True) +# end: listings + +client.close() diff --git a/docs/examples/change_feed.py b/docs/examples/change_feed.py new file mode 100644 index 0000000..1e840a3 --- /dev/null +++ b/docs/examples/change_feed.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from start import adapter, edit_on_server, replica + +handled: list[object] = [] + + +def handle(entry): + handled.append((entry.kind, entry.object_id)) + + +def reload_everything(): + handled.clear() + + +# start: read +from pathlib import Path + +from calendaring_sync import TrimmedFeedError + +CURSOR = Path("feed-cursor.txt") + +cursor = int(CURSOR.read_text()) if CURSOR.exists() else 0 +try: + while entries := replica.read_feed(after=cursor): + for entry in entries: + handle(entry) + cursor = entries[-1].number + CURSOR.write_text(str(cursor)) +except TrimmedFeedError: + reload_everything() + CURSOR.write_text(str(replica.feed_head())) +# end: read + +edit_on_server(None) +adapter.sync() +cursor = replica.feed_head() + +# start: trim +replica.trim_feed(through=cursor) +replica.purge(through=cursor) +# end: trim diff --git a/docs/examples/conflicts.py b/docs/examples/conflicts.py new file mode 100644 index 0000000..b1e9c29 --- /dev/null +++ b/docs/examples/conflicts.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from start import adapter, edit_on_server, meeting, replica + +# Another client moves the meeting while the application renames it. +edit_on_server(b"Team meeting, moved") +adapter.change(meeting.local_key, b"Team sync") +adapter.sync() + +# start: one +from calendaring_sync import KeepLocal, RecordState + +for record in replica.records("work", states=frozenset({RecordState.CONFLICT})): + replica.resolve(record, KeepLocal()) +adapter.sync() +# end: one + +edit_on_server(b"Team sync, room 3") +adapter.change(meeting.local_key, b"Team sync, at 10") +adapter.sync() + + +# start: merge +from calendaring_sync import ConflictKind, KeepServer, Merge + + +def resolver(record): + conflict = record.conflict + if conflict.kind is ConflictKind.BOTH_CHANGED: + merged = conflict.remote_content + b" / " + record.edit.content + return Merge(merged, adapter.edit_fields(merged)) + if conflict.kind is ConflictKind.REMOVED_REMOTELY: + return KeepServer() + return None + + +replica.resolve_conflicts(resolver) +adapter.sync() +# end: merge diff --git a/docs/examples/errors.py b/docs/examples/errors.py new file mode 100644 index 0000000..6148092 --- /dev/null +++ b/docs/examples/errors.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from start import adapter, meeting, replica, server + +adapter.change(meeting.local_key, b"Team meeting, room 3") +# Someone deletes the whole calendar on the server. +server.removed = True + +# start: sync +from calendaring_sync import ScopeGoneError, StaleChangeSetError, StoreBusyError + +unsent = [] +try: + adapter.sync() +except (StoreBusyError, StaleChangeSetError): + pass # Nothing changed; sync again later. +except ScopeGoneError: + dropped = replica.drop_scope(adapter.scope_id) + unsent = [record.edit.content for record in dropped.pending + dropped.conflicts if record.edit] +# end: sync diff --git a/docs/examples/every_calendar.py b/docs/examples/every_calendar.py new file mode 100644 index 0000000..8f72a99 --- /dev/null +++ b/docs/examples/every_calendar.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +# start: sync +from caldav.davclient import DAVClient + +from calendaring_sync import Replica, SQLiteStore +from calendaring_sync.adapters.caldav import CalDAVAdapter + +URL = "https://caldav.example.com/" +USERNAME = "alice" +PASSWORD = "secret" + +with ( + SQLiteStore("alice.db") as store, + DAVClient(url=URL, username=USERNAME, password=PASSWORD) as client, +): + replica = Replica(store) + adapters = [CalDAVAdapter(replica, c) for c in client.get_calendars()] + for adapter in adapters: + adapter.sync() + + still_there = {adapter.scope_id for adapter in adapters} + dropped = [replica.drop_scope(s.id) for s in replica.scopes() if s.id not in still_there] +# end: sync diff --git a/docs/examples/inspect_scope.py b/docs/examples/inspect_scope.py new file mode 100644 index 0000000..f45d259 --- /dev/null +++ b/docs/examples/inspect_scope.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from start import adapter, meeting, replica + +adapter.change(meeting.local_key, b"Team meeting, room 3") + +# start: inspect +from calendaring_sync import RecordState + +scope = replica.scope(adapter.scope_id) +token, last_sync, last_listing = scope.token, scope.last_sync_at, scope.last_full_listing_at + +unsent = replica.records(adapter.scope_id, states=frozenset({RecordState.PENDING})) +conflicts = replica.records(adapter.scope_id, states=frozenset({RecordState.CONFLICT})) +blocked = adapter.waiting() +# end: inspect + +# start: repair +adapter.sync(full_listing=True) + +replica.reset(adapter.scope_id) +adapter.sync() +# end: repair diff --git a/docs/examples/jmap_sync.py b/docs/examples/jmap_sync.py new file mode 100644 index 0000000..eca4d33 --- /dev/null +++ b/docs/examples/jmap_sync.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from calendaring_sync import MemoryStore, Replica + +replica = Replica(MemoryStore()) +URL = "https://jmap.example.com/.well-known/jmap" +USERNAME = "alice" +PASSWORD = "secret" + +# start: sync +from calendaring_jmap import JMAPClient + +from calendaring_sync.adapters.jmap import JMAPAdapter + +with JMAPClient(url=URL, username=USERNAME, password=PASSWORD) as client: + adapter = JMAPAdapter(replica, client, account_id=client.get_account_id()) + adapter.sync() +# end: sync diff --git a/docs/examples/local_edits.py b/docs/examples/local_edits.py new file mode 100644 index 0000000..fafb66b --- /dev/null +++ b/docs/examples/local_edits.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from start import adapter, replica + +# start: record +lunch = adapter.create(b"Lunch with Ana") +meeting = replica.find_record("work", "meeting") +if meeting is not None: + adapter.change(meeting.local_key, b"Team meeting, room 3") +adapter.sync() +# end: record + +# start: undo +adapter.change(lunch, b"Lunch with Ana and Ben") +replica.discard(lunch) +# end: undo + +# start: delete +replica.delete(lunch) +adapter.sync() +# end: delete diff --git a/docs/examples/memory_adapter.py b/docs/examples/memory_adapter.py new file mode 100644 index 0000000..38b0f6c --- /dev/null +++ b/docs/examples/memory_adapter.py @@ -0,0 +1,204 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""An adapter for a server that keeps its objects in memory.""" + +from __future__ import annotations + +import hashlib +import uuid +from typing import cast + +from calendaring_sync import ( + Applied, + Change, + Changed, + ChangeSet, + EditFields, + EditKind, + Gone, + LocalEdit, + PushOutcome, + Record, + Rejected, + Removed, + Replica, + Scope, + ScopeGoneError, + Session, +) +from calendaring_sync.adapters.base import ( + Adapter, + AsyncAdapter, + InStore, + ReadResult, + Steps, + SyncAdapter, +) + +# An object as the server holds it: its version and its content. +Stored = tuple[str, bytes] + + +class VersionMismatch(Exception): + """The object doesn't have the version the write was based on.""" + + +class NotFound(Exception): + """The server has no object with that id.""" + + +class ExpiredToken(Exception): + """The server no longer knows what changed since that token.""" + + +class NoSuchScope(Exception): + """The server no longer holds the objects at all.""" + + +class MemoryServer: + """A server that numbers its writes: a write's number is the token and the version.""" + + def __init__(self) -> None: + self.objects: dict[str, Stored] = {} + # The id of the object each write touched, in order. + self.log: list[str] = [] + # The oldest token the server still answers changes since. + self.oldest = 0 + self.removed = False + + def token(self) -> str: + return str(len(self.log)) + + def listing(self) -> tuple[dict[str, Stored], str]: + if self.removed: + raise NoSuchScope() + 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() + if int(since) < self.oldest: + raise ExpiredToken(since) + written = set(self.log[int(since) :]) + return {object_id: self.objects.get(object_id) for object_id in written}, self.token() + + def put(self, object_id: str, content: bytes, *, if_version: str | None) -> None: + """Write an object, only if it has ``if_version``; ``None`` creates one.""" + self._check(object_id, if_version) + self.log.append(object_id) + self.objects[object_id] = (self.token(), content) + + def delete(self, object_id: str, *, if_version: str | None) -> None: + """Delete an object, only if it has ``if_version``.""" + self._check(object_id, if_version) + self.log.append(object_id) + del self.objects[object_id] + + 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) + if (None if current is None else current[0]) != if_version: + raise VersionMismatch(object_id) + + +class AsyncMemoryServer: + """The same server, for an asynchronous client.""" + + def __init__(self, server: MemoryServer) -> None: + self.server = server + + async def listing(self) -> tuple[dict[str, Stored], str]: + return self.server.listing() + + async def changes(self, since: str) -> tuple[dict[str, Stored | None], str]: + return self.server.changes(since) + + async def put(self, object_id: str, content: bytes, *, if_version: str | None) -> None: + self.server.put(object_id, content, if_version=if_version) + + async def delete(self, object_id: str, *, if_version: str | None) -> None: + self.server.delete(object_id, if_version=if_version) + + +def fingerprint(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def change(stored: Stored | None) -> Change: + """What the core needs to know about an object the server reported.""" + if stored is None: + return Removed() + version, content = stored + return Changed(version, fingerprint(content), content) + + +class _MemoryAdapter(Adapter): + """Everything the synchronous and asynchronous adapters share.""" + + _fingerprint_scheme = "sha256" + _media_type = "text/plain" + # Every write names the version it was based on. + _guarded_by_version = True + _unique_logical_key = False + _guard_covers_scope = False + + def __init__( + self, replica: Replica, server: MemoryServer | AsyncMemoryServer, scope_id: str + ) -> None: + super().__init__(replica, scope_id) + self._server = server + + def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[ReadResult]: + since = None if full_listing else scope.token + try: + if since is None: + objects, token = yield self._server.listing() + else: + objects, token = yield self._server.changes(since) + except ExpiredToken: + 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) + yield InStore(session.ingest, (change_set,)) + return ReadResult.COMPLETE + + def _send(self, claimed: Record) -> Steps[PushOutcome]: + # A claimed record holds its edit and the id to send it under. + edit, object_id = cast(LocalEdit, claimed.edit), cast(str, claimed.object_id) + try: + if edit.kind is EditKind.DELETE: + yield self._server.delete(object_id, if_version=claimed.version) + elif edit.kind is EditKind.CREATE: + yield self._server.put(object_id, cast(bytes, edit.content), if_version=None) + else: + yield self._server.put( + object_id, cast(bytes, edit.content), if_version=claimed.version + ) + except VersionMismatch: + return Rejected() + except NotFound: + return Gone() + return Applied() + + def _waits(self, record: Record) -> bool: + return False + + def _new_object_id(self) -> str: + return uuid.uuid4().hex + + def _edit_fields(self, content: bytes, *, create: bool) -> EditFields: + return EditFields(fingerprint(content)) + + +class MemoryAdapter(_MemoryAdapter, SyncAdapter): + """Syncs a :class:`MemoryServer` with a replica.""" + + +class AsyncMemoryAdapter(_MemoryAdapter, AsyncAdapter): + """Syncs an :class:`AsyncMemoryServer` with a replica.""" diff --git a/docs/examples/mirror.py b/docs/examples/mirror.py new file mode 100644 index 0000000..5b19dc6 --- /dev/null +++ b/docs/examples/mirror.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from start import adapter, edit_on_server, replica + +# isort: split +# start: mirror +import sqlite3 + +from calendaring_sync import FeedEntryKind, RecordState + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS events ( + local_key INTEGER PRIMARY KEY, + scope TEXT NOT NULL, + content BLOB NOT NULL +); +CREATE TABLE IF NOT EXISTS feed_cursor ( + id INTEGER PRIMARY KEY CHECK (id = 0), + cursor INTEGER NOT NULL +); +""" + + +def mirror(replica, db): + """Bring the events table up to date with the replica's change feed.""" + row = db.execute("SELECT cursor FROM feed_cursor").fetchone() + cursor = row[0] if row else 0 + while entries := replica.read_feed(after=cursor): + with db: + for entry in entries: + apply(replica, db, entry) + cursor = entries[-1].number + db.execute( + "INSERT INTO feed_cursor VALUES (0, ?) ON CONFLICT(id) DO UPDATE SET cursor = ?", + (cursor, cursor), + ) + + +def apply(replica, db, entry): + """Bring the row of one feed entry's record up to date.""" + if entry.kind is FeedEntryKind.SCOPE_DROPPED: + db.execute("DELETE FROM events WHERE scope = ?", (entry.scope,)) + return + record = replica.record(entry.local_key) + if record is None or record.state is RecordState.REMOVED or record.content is None: + db.execute("DELETE FROM events WHERE local_key = ?", (entry.local_key,)) + else: + db.execute( + "INSERT INTO events VALUES (?, ?, ?) ON CONFLICT(local_key) DO UPDATE SET content = ?", + (record.local_key, record.scope, record.content, record.content), + ) + + +db = sqlite3.connect("app.db") +db.executescript(SCHEMA) +adapter.sync() +mirror(replica, db) +# end: mirror + +# The server moves the meeting, then deletes it; each sync's mirror follows. +edit_on_server(b"Team meeting, moved") +adapter.sync() +mirror(replica, db) +moved = db.execute("SELECT content FROM events").fetchall() +edit_on_server(None) +adapter.sync() +mirror(replica, db) +after_delete = db.execute("SELECT content FROM events").fetchall() +db.close() diff --git a/docs/examples/parallel.py b/docs/examples/parallel.py new file mode 100644 index 0000000..33669be --- /dev/null +++ b/docs/examples/parallel.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +from calendaring_sync import MemoryStore, Replica + +from memory_adapter import MemoryAdapter, MemoryServer + +replica = Replica(MemoryStore()) +servers = {name: MemoryServer() for name in ("work", "home", "team")} +for name, server in servers.items(): + server.put("meeting", f"{name} meeting".encode(), if_version=None) +adapters = [MemoryAdapter(replica, server, name) for name, server in servers.items()] + +# start: threads +from concurrent.futures import ThreadPoolExecutor + +with ThreadPoolExecutor() as pool: + for done in [pool.submit(adapter.sync) for adapter in adapters]: + done.result() +# end: threads + +# start: retry +from calendaring_sync import StaleChangeSetError + + +def sync(adapter, attempts=3): + """Sync once, again if another process ingested the scope first.""" + for _ in range(attempts - 1): + try: + return adapter.sync() + except StaleChangeSetError: + pass + return adapter.sync() + + +for adapter in adapters: + sync(adapter) +# end: retry diff --git a/docs/examples/quickstart.py b/docs/examples/quickstart.py new file mode 100644 index 0000000..ebb15af --- /dev/null +++ b/docs/examples/quickstart.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +# start: sync +from caldav.davclient import DAVClient + +from calendaring_sync import Replica, SQLiteStore +from calendaring_sync.adapters.caldav import CalDAVAdapter + +URL = "https://caldav.example.com/" +USERNAME = "alice" +PASSWORD = "secret" + +with ( + SQLiteStore("sync.db") as store, + DAVClient(url=URL, username=USERNAME, password=PASSWORD) as client, +): + calendar = client.get_calendars()[0] + replica = Replica(store) + adapter = CalDAVAdapter(replica, calendar) + adapter.sync() + + for entry in replica.read_feed(): + print(entry.kind, entry.object_id) +# end: sync diff --git a/docs/examples/snapshots.py b/docs/examples/snapshots.py new file mode 100644 index 0000000..01c9722 --- /dev/null +++ b/docs/examples/snapshots.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +# What the source holds on each run: the same twice, then one object moved +# and the other gone. +_snapshots = iter( + [ + {"meeting": b"Team meeting", "lunch": b"Lunch with Ana"}, + {"meeting": b"Team meeting", "lunch": b"Lunch with Ana"}, + {"meeting": b"Team meeting, moved"}, + ] +) + + +def read_source() -> dict[str, bytes]: + return next(_snapshots) + + +# start: track +import hashlib +import uuid + +from calendaring_sync import Change, Changed, ChangeSet, MemoryStore, Replica, Unchanged + +SCOPE = "team-calendar" +replica = Replica(MemoryStore()) + + +def track(objects: dict[str, bytes]) -> None: + """Hand the core everything the source holds now, by id.""" + with replica.session(SCOPE, fingerprint_scheme="sha256", media_type="text/plain") as session: + since = replica.scope(SCOPE).token + known = replica.known_versions(SCOPE) + changes: dict[str, Change] = {} + for object_id, content in objects.items(): + digest = hashlib.sha256(content).hexdigest() + if known.get(object_id) == digest: + changes[object_id] = Unchanged() + else: + changes[object_id] = Changed(digest, digest, content) + session.ingest(ChangeSet(SCOPE, since, uuid.uuid4().hex, changes, full_listing=True)) + + +# end: track + +# start: run +for _ in range(3): + track(read_source()) + +for entry in replica.read_feed(): + print(entry.kind, entry.object_id) +# end: run diff --git a/docs/examples/sqlite.py b/docs/examples/sqlite.py new file mode 100644 index 0000000..1895e25 --- /dev/null +++ b/docs/examples/sqlite.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +# start: open +import sqlite3 + +from calendaring_sync import Replica, SQLiteStore + +connection = sqlite3.connect("sync.db") +connection.execute("PRAGMA journal_mode = WAL") +connection.close() + +store = SQLiteStore("sync.db", timeout=10.0) +replica = Replica(store) +# end: open + +from memory_adapter import MemoryAdapter, MemoryServer + +adapter = MemoryAdapter(replica, MemoryServer(), "work") + +# start: busy +import time + +from calendaring_sync import StoreBusyError + +for _ in range(3): + try: + adapter.sync() + break + except StoreBusyError: + time.sleep(1) +# end: busy + +store.close() diff --git a/docs/examples/start.py b/docs/examples/start.py new file mode 100644 index 0000000..ca94374 --- /dev/null +++ b/docs/examples/start.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Where the how-to examples start: a replica synced with a server that holds one event.""" + +from calendaring_sync import MemoryStore, Replica + +from memory_adapter import MemoryAdapter, MemoryServer + +server = MemoryServer() +server.put("meeting", b"Team meeting", if_version=None) +replica = Replica(MemoryStore()) +adapter = MemoryAdapter(replica, server, "work") +adapter.sync() +(meeting,) = replica.records("work") + + +def edit_on_server(content: bytes | None) -> None: + """Another client gives the meeting this content on the server, or deletes it for ``None``.""" + version = server.objects["meeting"][0] + if content is None: + server.delete("meeting", if_version=version) + else: + server.put("meeting", content, if_version=version) diff --git a/docs/examples/testing.py b/docs/examples/testing.py new file mode 100644 index 0000000..8257ce3 --- /dev/null +++ b/docs/examples/testing.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +# start: server +from calendaring_sync import Changed, ChangeSet, MemoryStore, Removed, Replica + + +def server_sends(replica, since, token, changes, *, full_listing=False): + """Ingest what a server would have sent for the "work" scope.""" + with replica.session("work", fingerprint_scheme="test", media_type="text/plain") as session: + session.ingest(ChangeSet("work", since, token, changes, full_listing=full_listing)) + + +replica = Replica(MemoryStore()) +server_sends(replica, None, "t1", {"a": Changed("v1", "a1", b"Team meeting")}, full_listing=True) +server_sends(replica, "t1", "t2", {"a": Changed("v2", "a2", b"Team meeting, moved")}) +server_sends(replica, "t2", "t3", {"a": Removed()}) +# end: server + +# start: check +from calendaring_sync import FeedEntryKind + +kinds = [entry.kind for entry in replica.read_feed()] +assert kinds == [FeedEntryKind.CHANGED, FeedEntryKind.CHANGED, FeedEntryKind.REMOVED] +# end: check diff --git a/docs/explanation/adapters.rst b/docs/explanation/adapters.rst index 8eb140e..5d29295 100644 --- a/docs/explanation/adapters.rst +++ b/docs/explanation/adapters.rst @@ -13,22 +13,12 @@ An adapter maps one protocol onto the model in :doc:`design`. Adding an adapter Why every adapter provides the same things ========================================== -The core never branches on protocol, so it asks every adapter for the same things: - -- what a :term:`scope` is, and an :term:`object id` unique within it -- a :term:`version` per object, if the protocol has one; without it, every reported object is fetched and compared by :term:`fingerprint` -- changes since a :term:`sync token`, if the protocol has them; without them, every sync is a :term:`full listing` -- the content's media type, and its own fingerprint function and :term:`fingerprint scheme` name if the exact text isn't stable -- each object's :term:`containers ` and :term:`logical key`, where the protocol has them, and whether that key is unique -- :term:`change sets ` built from the protocol's answers: incremental or a full listing, complete or one :term:`truncated page` -- each :term:`push`'s outcome (applied, rejected, gone, refused for its logical key, or unknown), and which errors mean the scope must be :term:`reset` - -An adapter's synchronous and asynchronous variants share all of its translation and parsing, and differ only in the client library calls they make. +The core never branches on protocol, so every adapter answers the same questions, which :class:`~calendaring_sync.adapters.base.Adapter` lists: what a :term:`scope` and an :term:`object id` are, how to read changes and send an edit, and what the server checks before it applies one. A protocol without a :term:`version` per object has every reported object fetched and compared by :term:`fingerprint`, and one without changes since a :term:`sync token` gets a :term:`full listing` on every sync. An adapter's synchronous and asynchronous variants share all of its translation and parsing, and differ only in the client they're given. CalDAV ====== -CalDAV keeps calendars on a web server, using the WebDAV extensions to HTTP. Each calendar is a calendar collection, a folder at its own web address. Each event or task in a calendar is a member: one iCalendar file, the standard text format for calendar data, at its own address, called its href. The server gives every member an ETag, a short string that changes when the member changes. To learn what changed, a client sends a ``sync-collection`` report with the sync token from its last report, and the server answers with the members changed or removed since then, plus a new token. A ``PROPFIND`` request lists every member with its ETag, and a ``calendar-multiget`` report fetches several members' content in one request. The adapter sends these requests through python-caldav, a Python CalDAV client library. +CalDAV keeps calendars on a web server, using the WebDAV extensions to HTTP. Each calendar is a calendar collection, a folder at its own web address. Each event or task in a calendar is a member: one iCalendar file, the standard text format for calendar data, at its own address, called its href. The server gives every member an ETag, a short string that changes when the member changes. To learn what changed, a client sends a ``sync-collection`` report with the sync token from its last report, and the server answers with the members changed or removed since then, plus a new token. A ``PROPFIND`` request lists every member with its ETag, and a ``calendar-multiget`` report fetches several members' content in one request. The adapter sends these requests through python-caldav, a Python CalDAV client library, at the version its ``caldav`` extra requires. Why a CalDAV scope is one calendar collection --------------------------------------------- @@ -50,7 +40,7 @@ Each collection has its own sync token (:rfc:`6578`), so one collection is one s * - Sync token - ``DAV:sync-token``, an opaque URI (:rfc:`6578#section-3.2`) * - Content and fingerprint - - The iCalendar data (``text/calendar``) as the server sent it, and SHA-256 of a canonical form: lines unfolded, properties and parameters sorted within each component, components sorted, the parts of each recurrence rule sorted, and ``DTSTAMP``, ``LAST-MODIFIED`` and ``PRODID`` left out + - The iCalendar data (``text/calendar``) as the server sent it, and SHA-256 of a canonical form of it, as :ref:`caldav-fingerprint` describes * - Containers - The collection the member is in * - Logical key @@ -66,12 +56,12 @@ Why a full listing reads the token first A :term:`full listing` reads the collection's ``DAV:sync-token`` property, which holds the token a report would return at that moment (:rfc:`6578#section-4`), and then lists each member and its ETag with a ``PROPFIND`` (:rfc:`4918#section-9.1`) of ``Depth: 1``, which covers the collection and the members directly inside it (:rfc:`4918#section-10.2`). Members whose ETag matches the stored one are reported unchanged, and the rest are fetched. Anything that changes during the listing appears in the next report from that token. -The adapter never lists with a report sent with an empty token, which asks for every member. A server may truncate any report (:rfc:`6578#section-3.6`), and a tested server truncates an empty-token report at a :term:`checkpoint` that skips the remaining members, so a listing would infer that objects were removed when they weren't. A server can cap a ``PROPFIND`` too: a tested server lists at most its configured number of results and marks the cut with a 507 for the collection. That cut is visible and never comes with a token that skips members, but a listing cut short still can't tell a missing member from a removed one. So when a ``PROPFIND`` comes back with a 507, the adapter raises an error instead of ingesting it, and the server's limit has to be raised. That leaves a known gap: a calendar larger than the limit can't be synced from such a server. Listing with ``calendar-query`` split into time ranges, each under the limit, could close it later. +The adapter never lists with a report sent with an empty token, which asks for every member. A server may truncate any report (:rfc:`6578#section-3.6`), and a tested server truncates an empty-token report at a :term:`checkpoint` that skips the remaining members, so a listing would infer that objects were removed when they weren't. A server can cap a ``PROPFIND`` too: a tested server lists at most its configured number of results and marks the cut with a 507 for the collection. That cut is visible and never comes with a token that skips members, but a listing cut short still can't tell a missing member from a removed one. So when a ``PROPFIND`` comes back with a 507, the adapter raises an error instead of ingesting it, and the server's limit has to be raised. That leaves a known gap: a calendar larger than the limit can't be synced from such a server. Why changes come from python-caldav's sync report ------------------------------------------------- -Changes since a token come from python-caldav's ``get_objects_by_sync_token()``. Up to 3.6.0, python-caldav sends ``sync-collection`` with ``Depth: 1``, although the report is only defined for ``Depth: 0`` (:rfc:`6578#section-3.2`); every tested server answers it all the same, and later releases send ``Depth: 0``. When a server truncates a report with a 507 status and a :term:`checkpoint` token (:rfc:`6578#section-3.6`), python-caldav follows the checkpoints, up to 100 reports, and the adapter hands the core the whole result as one change set. If the result is still cut after that, the change set is a :term:`truncated page` that ends at the last checkpoint, nothing is pushed, and the next sync continues from there. The adapter turns python-caldav's fallback off: it would answer a failed report with a full listing under a client-side ``fake-`` token. +Changes since a token come from python-caldav's ``get_objects_by_sync_token()``. Up to 3.6.0, python-caldav sends ``sync-collection`` with ``Depth: 1``, although the report is only defined for ``Depth: 0`` (:rfc:`6578#section-3.2`); every tested server answers it all the same, and later releases send ``Depth: 0``. When a server truncates a report with a 507 status and a :term:`checkpoint` token (:rfc:`6578#section-3.6`), python-caldav follows the checkpoints, up to :attr:`~caldav.collection.Calendar.sync_max_pages` reports, and the adapter hands the core the whole result as one change set. If the result is still cut after that, the change set is a :term:`truncated page` that ends at the last checkpoint, nothing is pushed, and the next sync continues from there. The adapter turns python-caldav's fallback off: it would answer a failed report with a full listing under a client-side ``fake-`` token. A removed member has a 404 status and no ``DAV:propstat``, the element that would carry its properties (:rfc:`6578#section-3.2`), and python-caldav lists it in the result's ``deleted_urls``, which the adapter reads removals from. Some servers report a member deleted and created again at one href only as removed, so the adapter fetches removed members too, in multigets of their own: one that comes back with data is reported changed, the rest removed. A changed member whose reported ETag is the one already stored hasn't changed since the adapter fetched it, so it isn't fetched again. A server can leave the new token out of its answer; the adapter then keeps the scope's token, so nothing in that answer is lost, and the next sync asks again from the same point. @@ -93,15 +83,17 @@ Changed members are fetched with ``calendar-multiget`` (:rfc:`4791#section-7.9`) A member that comes back without data, as python-caldav returns a 404 entry, or is missing from the response although :rfc:`4791#section-7.9` requires an entry for every href, was removed after the report. It's left out of the change set, and the next report carries its removal. A server can also answer the whole ``calendar-multiget`` with 404 when one member is gone; the adapter then checks that the calendar is still there and fetches each member on its own. A member that comes back with data but no ETag is kept with no version, so the next listing fetches it again. +.. _caldav-fingerprint: + Why the fingerprint leaves out three properties ----------------------------------------------- -Tested servers rewrite what they store: they add ``DTSTAMP``, reorder properties and parameters, fold lines at other points, and reorder the parts of a recurrence rule, which may come in any order (:rfc:`5545#section-3.3.10`). A server sets ``DTSTAMP`` and ``LAST-MODIFIED`` itself (:rfc:`5545#section-3.8.7.2`, :rfc:`5545#section-3.8.7.3`), and ``PRODID`` names the product that created the object (:rfc:`5545#section-3.7.3`). Leaving those three out and sorting the rest makes a server's copy of your own write fingerprint the same as what you sent, on every tested server. ``UID`` is kept, since it's the logical key, and so is everything else, because any other difference can be a real edit. Calendar data is UTF-8 unless a charset says otherwise (:rfc:`5545#section-3.1.4`): python-caldav hands the adapter what a server sent as UTF-8, and the adapter refuses an edit that isn't. +Tested servers rewrite what they store: they add ``DTSTAMP``, reorder properties and parameters, fold lines at other points, and reorder the parts of a recurrence rule, which may come in any order (:rfc:`5545#section-3.3.10`). A server sets ``DTSTAMP`` and ``LAST-MODIFIED`` itself (:rfc:`5545#section-3.8.7.2`, :rfc:`5545#section-3.8.7.3`), and ``PRODID`` names the product that created the object (:rfc:`5545#section-3.7.3`). So the canonical form unfolds the lines, sorts the components, the properties and parameters within each, and the parts of each recurrence rule, and leaves those three properties out. A server's copy of your own write then fingerprints the same as what you sent, on every tested server. ``UID`` is kept, since it's the logical key, and so is everything else, because any other difference can be a real edit. Calendar data is UTF-8 unless a charset says otherwise (:rfc:`5545#section-3.1.4`): python-caldav hands the adapter what a server sent as UTF-8, and the adapter refuses an edit that isn't. Why writes are conditional -------------------------- -Updates and deletes send ``If-Match`` with the base ETag (:rfc:`9110#section-13.1.1`), so the server applies them only if the member still has that ETag, and creates send ``If-None-Match: *`` (:rfc:`9110#section-13.1.2`), so a create never replaces an existing member. A mismatch gets status 412, and the edit stays pending for the next :term:`ingest`. ``If-Match`` compares ETags strongly, so a weak one (``W/``) never matches; an edit to a member whose stored ETag is weak waits, without being sent, until a sync stores a strong one, and the adapter's ``waiting()`` lists such edits. The ETag comes from the ``DAV:getetag`` property of the server's answer, not from an HTTP header a proxy can weaken, so edits wait only on a server that reports a weak ETag itself. +Updates and deletes send ``If-Match`` with the base ETag (:rfc:`9110#section-13.1.1`), so the server applies them only if the member still has that ETag, and creates send ``If-None-Match: *`` (:rfc:`9110#section-13.1.2`), so a create never replaces an existing member. A mismatch gets status 412, and the edit stays pending for the next :term:`ingest`. ``If-Match`` compares ETags strongly, so a weak one (``W/``) never matches; an edit to a member whose stored ETag is weak waits, without being sent, until a sync stores a strong one, and :meth:`~calendaring_sync.adapters.base.SyncAdapter.waiting` lists such edits. The ETag comes from the ``DAV:getetag`` property of the server's answer, not from an HTTP header a proxy can weaken, so edits wait only on a server that reports a weak ETag itself. Creates and updates use python-caldav's ``save()`` with ``raw_data``, which sends the bytes you recorded unchanged, and ``precondition="etag"``, which sends the stored ETag. Updates also pass ``increase_seqno=False``: ``save()`` increments an event's ``SEQUENCE`` revision number by default, and though it leaves the bytes passed as ``raw_data`` alone, the flag says outright that the server receives the edit you recorded. Creates pass ``no_overwrite=True``, which sends ``If-None-Match: *``. Deletes use ``delete(if_match=True, precondition="etag")``. A 404 on an update or a delete means the member is gone; a tested server answers a delete of a gone member with 412 instead, which leaves the delete pending until the next ingest reports the removal. @@ -111,11 +103,6 @@ Unless the client is configured with the server's profile, python-caldav searche A server MUST NOT return a strong ETag for data it stored differently (:rfc:`4791#section-5.3.4`), but tested servers do, so an applied push doesn't keep the ETag it got back. -Why the adapter needs python-caldav 3.6.0 or later --------------------------------------------------- - -The adapter needs python-caldav 3.6.0 or later. It relies on following truncated sync reports, listing removed members in ``deleted_urls``, keeping preconditions on every error, ``UidConflictError``, ``raw_data`` holding calendar data as bytes, ``save(raw_data=...)`` sending bytes unchanged, ``precondition="etag"``, and ``If-None-Match: *`` on ``save(no_overwrite=True)``. - JMAP ==== @@ -126,7 +113,7 @@ JMAP is a protocol for reading and changing data on a server with JSON requests - ``/query`` lists the ids that match a filter - ``/set`` creates, updates, and destroys records -Events are JSCalendar objects, a JSON format for calendar data. The adapter makes these calls through calendaring-jmap, a Python JMAP client library. +Events are JSCalendar objects, a JSON format for calendar data. The adapter makes these calls through calendaring-jmap, a Python JMAP client library, at the version its ``jmap`` extra requires, which writes the JSCalendar you recorded without converting it and keeps an event's own ``calendarIds``. Why a JMAP scope is one account and one record type --------------------------------------------------- @@ -148,7 +135,7 @@ A record type is the kind of data a JMAP record holds, such as ``CalendarEvent`` * - Sync token - The type's ``state`` string * - Content and fingerprint - - The event as the server returned it, as compact UTF-8 JSON, and SHA-256 of a canonical form of it, as the next section describes + - The event as the server returned it, as compact UTF-8 JSON, and SHA-256 of a canonical form of it, as :ref:`jmap-fingerprint` describes * - Containers - The calendars set to true in the event's ``calendarIds`` * - Logical key @@ -158,10 +145,12 @@ Content stays JSCalendar, since converting to iCalendar would lose data on each With no per-record version, every object ``/changes`` reports is fetched and compared by :term:`fingerprint`, and a :term:`full listing` fetches everything. +.. _jmap-fingerprint: + Why the fingerprint leaves out what the server sets or fills in --------------------------------------------------------------- -The server sets ``id``, ``baseEventId`` and ``isOrigin``, and works out ``utcStart`` and ``utcEnd`` when asked (draft-ietf-jmap-calendars-33 section 5). It also sets ``created``, ``updated`` and ``sequence`` itself (draft-ietf-jmap-calendars-33 section 5.9), and ``prodId`` names the product that last changed the event (:rfc:`8984#section-4.1.4`). Tested servers rewrite more than that when they store an event, without changing what it means: one leaves out every member that holds its default value, such as an alert's ``action`` of ``display``, and the other leaves out a ``@type`` its place already implies, adds ``updated`` inside each recurrence override, adds conversion metadata (``iCalendar``) to a location, and leaves out ``version``. +The server sets ``id``, ``baseEventId`` and ``isOrigin``, and works out ``utcStart`` and ``utcEnd`` when asked (draft-ietf-jmap-calendars-33 section 5). It also sets ``created``, ``updated`` and ``sequence`` itself (draft-ietf-jmap-calendars-33 section 5.9), and ``prodId`` names the product that last changed the event (:rfc:`8984#section-4.1.4`). Tested servers rewrite more than that when they store an event, without changing what it means. One leaves out every member that holds its default value, such as an alert's ``action`` of ``display``. The other leaves out a ``@type`` its place already implies, adds ``updated`` inside each recurrence override, adds conversion metadata (``iCalendar``) to a location, and leaves out ``version``. So the fingerprint is SHA-256 of the event serialized with sorted keys, leaving out: @@ -179,19 +168,19 @@ A server does add real data sometimes: an event created with participants and no Why some errors reset the scope ------------------------------- -On ``cannotCalculateChanges``, the error a server returns when it can't work out the changes since the state given, the client MUST invalidate its cache for the type (:rfc:`8620#section-5.2`), so the adapter :term:`resets ` the scope. It also resets on two answers seen on a tested server: an ``oldState`` that doesn't repeat the ``sinceState`` the adapter sent, and ``invalidArguments`` from ``/changes``. The adapter only sends states the server issued and a valid ``maxChanges``, so the state is the only argument that can be wrong. One answer looks like a real one: Cyrus answers a state newer than any it issued as if nothing changed, which happens after the server is restored from a backup. Nothing tells the adapter to reset then, so list each scope in full after a restore. An account the server doesn't have (``accountNotFound``, :rfc:`8620#section-3.6.2`) raises an error, and the store stays as it was. +On ``cannotCalculateChanges``, the error a server returns when it can't work out the changes since the state given, the client MUST invalidate its cache for the type (:rfc:`8620#section-5.2`), so the adapter :term:`resets ` the scope. It also resets on two answers seen on a tested server: an ``oldState`` that doesn't repeat the ``sinceState`` the adapter sent, and ``invalidArguments`` from ``/changes``. The adapter only sends states the server issued and a valid ``maxChanges``, so the state is the only argument that can be wrong. One answer looks like a real one: Cyrus answers a state newer than any it issued as if nothing changed, which happens after the server is restored from a backup. Nothing tells the adapter to reset then, so list each scope in full after a restore. An account the server doesn't have (``accountNotFound``, :rfc:`8620#section-3.6.2`) raises :class:`~calendaring_sync.error.ScopeGoneError`, as any scope a server lost does. Why pages follow the new state ------------------------------ -When a ``/changes`` answer leaves out some changes, it sets ``hasMoreChanges`` to true, and the client may call ``/changes`` again with the ``newState`` it received (:rfc:`8620#section-5.2`). The adapter asks for at most 100 changes a page, and each answer is a :term:`truncated page` that advances the scope to its ``newState``. +When a ``/changes`` answer leaves out some changes, it sets ``hasMoreChanges`` to true, and the client may call ``/changes`` again with the ``newState`` it received (:rfc:`8620#section-5.2`). The adapter asks for at most :data:`~calendaring_sync.adapters.jmap.PAGE_SIZE` changes a page, and each answer is a :term:`truncated page` that advances the scope to its ``newState``. Objects are fetched with ``/get`` after ``/changes``, so one can be newer than the ``newState`` it came with. The next ``/changes`` reports it again, and the matching fingerprint makes that a no-op. One that's gone by the ``/get`` is left out, and the next ``/changes`` reports its removal. Why a full listing checks the state before its last page -------------------------------------------------------- -A full listing reads the type's ``state`` first, then pages through ``/query`` and ``/get`` (:rfc:`8620#section-5.5`), 100 events a page, counting the position itself, since a tested server answers every page with position 0. That first state becomes the token, so anything changed during the listing appears in the next ``/changes``. +A full listing reads the type's ``state`` first, then pages through ``/query`` and ``/get`` (:rfc:`8620#section-5.5`), :data:`~calendaring_sync.adapters.jmap.PAGE_SIZE` events a page, counting the position itself, since a tested server answers every page with position 0. That first state becomes the token, so anything changed during the listing appears in the next ``/changes``. Pages are counted by position, so an event destroyed while the listing runs shifts every later page by one, and an unchanged event can fall between two pages. The last page removes every object the listing never mentioned, so before it, the adapter reads the state again. If it moved, the adapter reads ``/changes`` from the first state: events created or changed meanwhile join the last page, but if one was destroyed, the listing ends there, removes nothing and pushes nothing. The scope's token hasn't moved, so the next sync reads the changes since it, or lists again if the scope has no token yet. @@ -207,12 +196,7 @@ A ``/set`` with ``ifInState``, the state the client expects, fails with ``stateM That state covers the whole account, so a rejection means something changed, not necessarily this object. The rest of the sync's pushes would be rejected too, so they aren't sent: every edit stays pending for the next sync, which ingests the change first. Another client's delete moves the state as well, so a delete of an event someone else deleted is rejected before the server would say it's gone; the next ingest reports the removal. When a server does answer ``notFound``, the push ended gone: a change becomes a :term:`conflict` and a delete is done. -Why events come before tasks ----------------------------- - -Tasks fit the model as their own scope, but no tested JMAP server advertises JMAP Tasks (``urn:ietf:params:jmap:tasks``). The JMAP adapter starts with ``CalendarEvent`` and adds tasks once a server supports them. - -Why the adapter needs calendaring-jmap 2.0.0 or later ------------------------------------------------------ +Why the adapter syncs events only +--------------------------------- -The adapter pushes the JSCalendar you recorded unchanged, so it needs calls that write JSCalendar without converting it, and that keep an event's own ``calendarIds``. Guarded writes need ``ifInState`` on every write and the state each write ends at, deletes included; paging needs each ``/changes`` page's ``newState``; and telling a taken ``uid`` from other refusals needs the ``properties`` a refusal names. calendaring-jmap 2.0.0 has all of them. +The JMAP adapter syncs ``CalendarEvent`` records. Tasks would fit the model as a scope of their own, but no tested JMAP server advertises JMAP Tasks (``urn:ietf:params:jmap:tasks``), so there's nothing to test a task adapter against. diff --git a/docs/explanation/design.rst b/docs/explanation/design.rst index a6bb5e3..1a6e7f0 100644 --- a/docs/explanation/design.rst +++ b/docs/explanation/design.rst @@ -10,11 +10,22 @@ Design calendaring-sync keeps a local copy of the calendar data your application syncs, and remembers how far each sync got. Your application reads and edits that copy, and calendaring-sync works out what to take from the server, what to send back, and where both sides changed the same object. An :term:`adapter` does the talking to the server. +.. mermaid:: + + flowchart LR + accTitle: How the parts fit + accDescr: Your application edits and reads through the replica, which keeps its state in a store; an adapter syncs the replica with a server through a client library. + app[Your application] -- "edits, reads, resolves" --> replica[Replica] + replica --> store[(Store)] + adapter[Adapter] -- "change sets, push outcomes" --> replica + adapter -- requests --> client[Client library] + client --> server[(Server)] + One sync of one :term:`scope`, such as one calendar, goes like this: #. As you edit objects, you record each change in calendaring-sync as a :term:`pending edit`. #. When your application decides to sync, the adapter asks the server what changed since the scope's :term:`sync token`, and hands the answer to calendaring-sync as a :term:`change set`. -#. calendaring-sync :term:`ingests ` the change set in one step: it stores each changed :term:`object`, removes the ones the server deleted, records a :term:`conflict` where the server changed something you also edited, and saves the new sync token, writing each change to the :term:`change feed`. +#. calendaring-sync :term:`ingests ` the change set in one step: it stores each changed :term:`object`, removes the ones the server deleted, records a :term:`conflict` where the server changed something you also edited, saves the new sync token, and writes each change to the :term:`change feed`. #. The adapter then :term:`pushes ` your pending edits to the server, and calendaring-sync records how each push ended. #. Your application reads the change feed to learn what changed, resolves any conflicts when it's ready, and the next sync pushes the result. @@ -30,17 +41,20 @@ Why each protocol is an adapter The :term:`core` describes sync in its own terms, such as a :term:`sync token`, a :term:`version`, and an :term:`object id`. Each :term:`adapter` maps one protocol's equivalents onto those terms, and turns what the server sends into :term:`change sets `. Supporting a new protocol means writing a new adapter, not changing the core. +Why the parts around the core can be swapped +============================================ + +The :term:`core` holds every sync rule and meets the rest through narrow interfaces, so each part around it can change without the core or the other parts changing: + +- where the state lives: a SQLite file, memory, or storage of your own, behind one store interface, as :doc:`../how-to/store` shows +- where changes come from: an adapter that ships with calendaring-sync, one you write, or your own code handing the core :term:`change sets `, as :doc:`../how-to/write-an-adapter` and :doc:`../how-to/without-adapter` show +- what reads and edits the data: the core never reads inside :term:`content`, so your application uses whichever library suits the format +- how a sync runs: with a synchronous or an asynchronous client, for one :term:`scope` or many across servers and accounts, from one thread or several, as :doc:`../how-to/async` and :doc:`../how-to/parallel` show + Why sync state is kept per scope ================================ -A :term:`sync token` only describes the objects it covers, so the core keeps one token per :term:`scope`. The adapter decides what a scope is: one calendar, or all events in one account. For each object in a scope, the core keeps one :term:`record`, holding: - -- its :term:`object id` -- its :term:`version`, if the server gives one -- a :term:`fingerprint` of its content -- its :term:`content` -- its :term:`containers `, so a removal still tells you which calendars it affected -- its :term:`logical key`, if it has one +A :term:`sync token` only describes the objects it covers, so the core keeps one token per :term:`scope`. The adapter decides what a scope is: one calendar, or all events in one account. For each object in a scope, the core keeps one :class:`~calendaring_sync.objects.record.Record`, which holds the object's :term:`containers ` too, so a removal still tells you which calendars it affected. Each record also gets a :term:`local key` from the core, so an object you create has an identity before the server assigns one, and keeps it if the server renames the object. @@ -49,6 +63,8 @@ Why identity isn't the calendar UID A calendar :term:`UID` names a recurring event with all its occurrences, and a server can hold several objects with one UID, for example one for each occurrence that someone got a separate invitation to. Some servers also accept unrelated objects with the same UID. So the :term:`object id` is the identity. The :term:`logical key` has two uses only: finding objects, where a search returns every match, and recognizing your own create when the server's answer to it never arrived, which works only where the adapter says the key is unique. +.. _design-fingerprints: + Why changes are compared by fingerprint ======================================= @@ -60,18 +76,14 @@ The core never reads inside :term:`content`. It stores what the server sent and .. _lone-surrogates: -Content is bytes, kept exactly as the server sent them, so nothing is decoded and any media type works. Every other value the core stores, such as an id, a token or a logical key, is a string, kept exactly as it's given, empty strings and whitespace included. The one exception is a string holding a lone surrogate, such as ``"\ud800"``: half of the pair UTF-16 uses for characters such as emoji. Python allows one in a string, but it isn't a character and UTF-8 can't encode it, so a store that writes UTF-8 would fail on it where one that keeps Python objects wouldn't. The core refuses such a string with ``ValueError`` wherever it takes one, so every store behaves the same. +Content is bytes, kept exactly as the server sent them, so nothing is decoded and any media type works. Every other value the core stores, such as an id, a token or a logical key, is a string, kept exactly as it's given, empty strings and whitespace included. The one exception is a string holding a lone surrogate, such as ``"\ud800"``: half of the pair UTF-16 uses for characters such as emoji. Python allows one in a string, but it isn't a character and UTF-8 can't encode it, so a store that writes UTF-8 would fail on it where one that keeps Python objects wouldn't. The core refuses such a string with :class:`~calendaring_sync.error.LoneSurrogateError` wherever it takes one, so every store behaves the same. + +.. _design-change-sets: Why a change set is applied all at once ======================================= -An :term:`adapter` hands the core one :term:`change set` at a time. Each one is either an :term:`incremental change set` or a :term:`full listing`, and either complete or one :term:`truncated page`. Each object appears once, in one of three ways: - -- changed, with its version, fingerprint, content, containers, and logical key -- removed -- unchanged since your version - -An object created and removed between two syncs counts as removed. +An :term:`adapter` hands the core one :term:`change set` at a time. Each one is either an :term:`incremental change set` or a :term:`full listing`, and either complete or one :term:`truncated page`. Each object in it is :class:`~calendaring_sync.objects.change.Changed`, :class:`~calendaring_sync.objects.change.Removed`, or :class:`~calendaring_sync.objects.change.Unchanged` since your version. An object created and removed between two syncs counts as removed. The core :term:`ingests ` a change set in one transaction: it applies each change, records a conflict for it, or ignores it, and moves the :term:`sync token` forward in the same transaction. If anything fails, nothing changes, so the token is never ahead of the data. @@ -84,6 +96,8 @@ Truncated pages differ by kind: Removing an object the scope doesn't know does nothing. +.. _design-invalid-tokens: + Why an invalid token is routine =============================== @@ -98,6 +112,8 @@ You tell calendaring-sync about each :term:`local edit` as you make it: a change Comparing every object at every sync instead would cost a full comparison each time, and a difference can't tell whether your application edited an object or never loaded its latest version. +.. _design-reads-first: + Why a sync reads before it writes ================================= @@ -122,30 +138,28 @@ A session is ``ingesting`` while pages remain and ``ingested`` once the token is note right of ingesting: no pushes while pages remain note right of pushing: no ingests until it ends -Before a push goes out, the adapter :term:`claims ` it: the core marks it :term:`in flight` in the store, in one step that fails if another process got there first, so two processes can't send the same edit. Each push has one of five outcomes: - -- applied: the edit becomes the synced content -- rejected: the server refused the write because something changed since your last sync, and the edit stays pending -- gone: the object no longer exists on the server -- refused because another object holds its :term:`logical key`, which becomes a conflict -- unknown: the request may or may not have reached the server +Before a push goes out, the adapter :term:`claims ` it: the core marks it :term:`in flight` in the store, in one step that fails if another process got there first, so two processes can't send the same edit. The adapter then reports how the push ended as a :data:`~calendaring_sync.objects.push.PushOutcome`. -An unknown push stays in flight, and so does one whose process stopped before the answer arrived. The next complete :term:`ingest` of its scope, in any process, settles it: +An :class:`~calendaring_sync.objects.push.Unknown` push stays in flight, and so does one whose process stopped before the answer arrived. The next complete :term:`ingest` of its scope, in any process, settles it: -- a create that comes back with what you sent, under the id it chose, reached the server; where the server picks the id, a new object with the same logical key and what you sent counts the same, but only if the adapter says that key is unique. A different object at the id the create chose is a conflict, and one that only shares its logical key is another object, so the create goes out again +- a create that comes back with what you sent, under the id it chose, reached the server. Where the server picks the id, a new object with the same logical key and what you sent counts the same, but only if the adapter says that key is unique. A different object at the id the create chose is a conflict, and one that only shares its logical key is another object, so the create goes out again - an update that comes back with your fingerprint reached the server, and one that comes back with another fingerprint is a conflict, since someone else changed the object too - a delete that comes back removed reached the server - a push the change set doesn't mention didn't reach the server, and its edit is pending again Treating an unknown push as failed could create the same object twice. An outcome that arrives after an ingest settled its push still counts while the record holds the same edit and no one has claimed it again, and changes nothing otherwise. Two processes syncing one scope at the same moment can therefore send one edit twice. A second change or delete is rejected where the adapter guards it with the object's version. A create whose id the server picks has no such guard, so the server can end up with the object twice, unless the adapter says logical keys are unique: then the server refuses the second create for its key, which becomes a conflict. Nothing you edited is lost either way. +.. _design-server-answer: + Why the server's answer to a write isn't trusted ================================================ -Some servers rewrite what they store and still answer the write with a version. So an applied push clears the record's :term:`version` instead of keeping the one the server returned. The next incremental change set includes your write, so the core fetches the object once and stores it as the server holds it. Because the fingerprint ignores what the server rewrites, that copy counts as your own write coming back, not as a remote change. Where the adapter guards each push with the object's version, a new edit to a record whose version was cleared waits until an ingest gives it one again, so it never goes out unguarded. +Some servers rewrite what they store and still answer the write with a version. So an applied push clears the record's :term:`version` instead of keeping the one the server returned. The next incremental change set includes your write, so the adapter fetches the object once and the core stores it as the server holds it. Because the fingerprint ignores what the server rewrites, that copy counts as your own write coming back, not as a remote change. Where the adapter guards each push with the object's version, a new edit to a record whose version was cleared waits until an ingest gives it one again, so it never goes out unguarded. A rejected push isn't a :term:`conflict` by itself, because some protocols only check whether anything in the scope changed since the last sync, not whether this object did. The next ingest decides: if this object changed, it's a conflict, and if not, the adapter pushes the edit again. Where the check covers the whole scope, one rejection means every later push in that sync would be rejected too, so they wait, unsent, for the next sync. An edit stays pending until the server applies it, so your content isn't lost. +.. _design-states: + Why an object has five states ============================= @@ -174,7 +188,13 @@ Every :term:`record` is in one of five states at a time: synced, pending, in fli conflict --> removed: resolved: removal removed --> [*]: purged -The table lists every transition with its conditions, including the ones the diagram leaves out: an unknown push, a create recorded at a removed record's object id, a conflict the server settles on its own, both sides reaching the same content or both deleting, and a scope :term:`reset`, which leaves every record's state as it is. +The table lists every transition with its conditions, including these, which the diagram leaves out: + +- an unknown push +- a create recorded at a removed record's object id +- a conflict the server settles on its own +- both sides reaching the same content, or both deleting +- a scope :term:`reset`, which leaves every record's state as it is .. list-table:: :header-rows: 1 @@ -216,6 +236,9 @@ The table lists every transition with its conditions, including the ones the dia * - conflict - Remote change ingested - Conflict with the new remote side; synced if it now equals your edit, pending again if the server is back at the synced content, or removed if both sides deleted the object + * - conflict + - The object holding the logical key an edit was refused for stops holding it, removed or changed on the server or by your own push + - Pending again, unless another object in the scope still holds the key * - conflict - Resolved - Synced, pending, or removed, depending on the resolution; a create the server refused is deleted from the store when you discard it or push it as an update to the object holding its logical key @@ -232,10 +255,12 @@ The table lists every transition with its conditions, including the ones the dia - Scope reset - Unchanged; pending edits and conflicts survive the listing that follows +.. _design-conflicts: + Why conflicts are reported, not resolved ======================================== -When a :term:`conflict` comes up, calendaring-sync reports it and leaves the decision to your code. Only your application knows whether the local edit, the server's version, or a merge is right. +When a :term:`conflict` comes up, calendaring-sync reports it and leaves the decision to your code. Only your application knows whether the local edit, the server's version, or a merge is right. A removal on the server against a local edit is always a conflict, never resolved automatically, because that would delete your edit without asking. The core classifies each change by looking only at the :term:`synced content`, the server's change, and your edit. The result is one of six classes: @@ -256,37 +281,31 @@ The core classifies each change by looking only at the :term:`synced content`, t * - ``DELETED_LOCAL`` - You deleted the object and the server didn't change it; the delete is pushed * - ``CONFLICT`` - - Both sides acted; a conflict record is stored - -Each :term:`conflict record` has one of five kinds: - -- both sides changed the object -- the server removed the object, and you edited it -- you deleted the object, and the server edited it -- the server has a new object at the id your create chose, and your create hasn't been sent yet -- the server refused your create or change because another object holds its UID + - Both sides acted; a conflict record is stored, of one :class:`~calendaring_sync.objects.conflict.ConflictKind` -Later remote changes update the record's remote side. The conflict resolves itself when the server reaches your edit, goes back to the synced content, or removes an object you deleted too. +Later remote changes update the record's remote side. The conflict resolves itself when the server reaches your edit, goes back to the synced content, or removes an object you deleted too. An edit refused for its logical key goes back to pending once the object holding that key no longer holds it. -The core stores conflicts instead of raising them: an exception for one object would abort every other change in the sync, and a :term:`resolver` called during a sync would run again on every retry. You resolve a record with your edit (``KeepEdit``), the server's version (``TakeServer``), merged content (``Merge``), or a delete (``Delete``), and the core pushes the result as a change to the server's current version. You can't push an edit the server refused for its UID again unchanged. You can drop it (``TakeServer``), push it as an update to the object that holds the UID (``UpdateHolder``), or replace it with merged content, possibly with a new UID (``Merge``). ``Delete`` discards a refused create, and deletes the object a refused change was for. +The core stores conflicts instead of raising them: an exception for one object would abort every other change in the sync, and a :term:`resolver` called during a sync would run again on every retry. So you resolve them when you choose, outside any sync, with a :data:`~calendaring_sync.objects.resolution.Resolution`. Anything but the server's side becomes a pending edit over the server's current copy, so its push is checked against the server's current version. :meth:`~calendaring_sync.replica.Replica.resolve` refuses a record whose conflict changed since you read it, so a decision is never applied to news you haven't seen. -``resolve(record, resolution)`` resolves one record you read, and refuses if its conflict changed since, so a decision is never applied to news you haven't seen. ``resolve_conflicts(resolver)`` passes each open record to your resolver, never during an ingest, and resolves each answer the same way. Each resolution is its own transaction: ``None`` leaves a record open, a record that changed while your resolver ran waits for the next call, and an exception stops the loop and keeps the resolutions before it. - -A removal on the server against a local edit is always a conflict, never resolved automatically, because that would delete your edit without asking. +.. _design-feed: Why changes are read from a feed ================================ -Every change the core makes to the store also goes into a :term:`change feed`, in the same transaction: a remote change or removal applied, a conflict stored, updated or resolved, a push's outcome, and a scope dropped. Each entry has a number that only grows. Your application keeps the number of the last entry it handled, its :term:`cursor`, and asks for the entries after it, so it learns what changed even if it stopped right after a sync. Several parts of an application can each keep their own cursor. +Everything a sync, a resolution or a dropped scope changes in the store also goes into a :term:`change feed`, in the same transaction: a remote change or removal applied, a conflict stored, updated or resolved, a push's outcome, and a scope dropped. Recording an edit writes no entry; the edit reaches the feed when its push ends. Each entry has a number that only grows. Your application keeps the number of the last entry it handled, its :term:`cursor`, and asks for the entries after it, so it learns what changed even if it stopped right after a sync. Several parts of an application can each keep their own cursor. A removed record stays in the store, marked removed, so a removal can always be read. It stays until your application :term:`purges ` it, or until the object comes back, on the server or as a create at its id. You trim the feed and purge removed records up to a point you choose. A cursor older than the oldest entry kept gets an error, and the application reads its records again from the store instead. +.. _design-dropped-scope: + Why a removed scope is yours to drop ==================================== If a calendar or account disappears from the server, the adapter raises an error and the :term:`store` stays as it was. Treating it as an empty listing would turn every pending edit into a conflict after one transient error. You :term:`drop` the scope yourself, and the call returns the pending edits and open conflicts it discards. +.. _design-storage: + Why storage is synchronous ========================== -Storage is a typed interface, ``StateStore``, with an in-memory implementation for tests and a SQLite one for real use. One :term:`store` holds many scopes. A key-value interface couldn't apply a change set and advance a token in one transaction, and an asynchronous store would push synchronous callers through an event loop. Adapters come in synchronous and asynchronous variants that share their logic. The asynchronous ones run each store call in a worker thread, since ingesting a large full listing is one long transaction and would otherwise block the event loop. Reads from other threads don't wait for that transaction, at most for its commit: until then they see what the store held before it began, so your application can show its records while a sync runs. +Storage is a typed interface, :class:`~calendaring_sync.store.base.StateStore`, with an in-memory implementation for tests and a SQLite one for real use. One :term:`store` holds many scopes. A key-value interface couldn't apply a change set and advance a token in one transaction, and an asynchronous store would push synchronous callers through an event loop. Adapters come in synchronous and asynchronous variants that share their logic. The asynchronous ones run each store call in a worker thread, since ingesting a large full listing is one long transaction and would otherwise block the event loop. Reads from other threads don't wait for that transaction, at most for its commit: until then they see what the store held before it began, so your application can show its records while a sync runs. diff --git a/docs/explanation/index.rst b/docs/explanation/index.rst index bf1e884..49938e3 100644 --- a/docs/explanation/index.rst +++ b/docs/explanation/index.rst @@ -6,12 +6,13 @@ Explanation =========== .. meta:: - :description: Background and design rationale for why calendaring-sync is built the way it is. + :description: What calendaring-sync is for, and why it works the way it does. -Background and design rationale: why calendaring-sync is built the way it is, not how to use it. See the how-to guides for task-focused instructions instead. +What calendaring-sync is for, and why it works the way it does. The how-to guides cover what to do. .. toctree:: :maxdepth: 1 + when-to-use design adapters diff --git a/docs/explanation/when-to-use.rst b/docs/explanation/when-to-use.rst new file mode 100644 index 0000000..2d82977 --- /dev/null +++ b/docs/explanation/when-to-use.rst @@ -0,0 +1,25 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +============== +When to use it +============== + +.. meta:: + :description: What calendaring-sync is for, what it leaves to your application, and how it compares with vdirsyncer and pimsync. + +calendaring-sync is for a Python application that keeps and edits its own copy of calendar data from a server. Examples are a calendar app that works offline, a service that mirrors events into its database, and a tool that changes events in bulk. It remembers how far each sync got, tells your application what changed, keeps your application's edits until a sync sends them, and stores a :term:`conflict` where both sides changed the same event. + +What it leaves to you +===================== + +calendaring-sync is a library, not a sync tool. Your application decides when to sync, how to show and convert events, and which side of a conflict wins; :doc:`design` gives the reason for each. :doc:`../reference/guarantees` lists what it promises in return, and its limits. + +Compared with vdirsyncer and pimsync +==================================== + +vdirsyncer is a command line tool, written in Python, that keeps two places in step, such as a calendar on a server and a folder of files. It compares both sides in full on every run: asking a server only for what changed since a :term:`sync token` has been an open issue since 2014 (`pimutils/vdirsyncer#26 `_). + +pimsync does the same job in Rust. Its library, vstorage, reads and writes items wherever they're kept, and a planner compares the two sides to decide what to copy. + +calendaring-sync works inside your application instead. One side is a server, and the other is your application, which reads and edits the synced data through it. It reads only what changed since the last sync, where the server supports that, and keeps everything a sync needs to continue after a stop. It doesn't talk to the server itself; an :term:`adapter` does. The core keeps the same state, with the same rules, whichever adapter does the talking, including one you write. diff --git a/docs/how-to/async.rst b/docs/how-to/async.rst new file mode 100644 index 0000000..067b4fc --- /dev/null +++ b/docs/how-to/async.rst @@ -0,0 +1,18 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +====================== +Sync from asyncio code +====================== + +.. meta:: + :description: Sync with an asynchronous client from asyncio code, with the async variant of each adapter. + +Each adapter that ships with calendaring-sync has an asynchronous variant for an asynchronous client, such as :class:`~calendaring_sync.adapters.caldav.AsyncCalDAVAdapter`. Its methods are the same, as coroutines. An adapter you write gets its variant from :class:`~calendaring_sync.adapters.base.AsyncAdapter`, as :doc:`write-an-adapter` shows. + +.. literalinclude:: ../examples/async_sync.py + :language: python + :start-after: # start: sync + :end-before: # end: sync + +The adapter keeps the event loop free during a sync, as :class:`~calendaring_sync.adapters.base.AsyncAdapter` describes. The replica itself is synchronous: call its methods, such as :meth:`~calendaring_sync.replica.Replica.read_feed`, directly, or through :func:`asyncio.to_thread` when they read a lot. diff --git a/docs/how-to/caldav.rst b/docs/how-to/caldav.rst new file mode 100644 index 0000000..762c4f9 --- /dev/null +++ b/docs/how-to/caldav.rst @@ -0,0 +1,36 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +====================== +Sync a CalDAV calendar +====================== + +.. meta:: + :description: Connect CalDAVAdapter to a CalDAV calendar with the server's profile, and schedule full listings where the server needs them. + +:class:`~calendaring_sync.adapters.caldav.CalDAVAdapter` syncs one calendar through a python-caldav client: every object in it, tasks and journal entries as well as events. :doc:`../tutorials/quickstart` shows a whole sync. This page covers what's particular to CalDAV. + +Connect with your server's profile +================================== + +.. literalinclude:: ../examples/caldav_sync.py + :language: python + :start-after: # start: client + :end-before: # end: client + +Name your server in ``features`` when python-caldav has a profile for it, such as ``"radicale"`` or ``"stalwart"``. The profile saves a request for each event you create, and tells the adapter whether the server needs :term:`full listings `. ``client.calendar(url=...)`` opens a calendar without a request; ``client.get_calendars()`` lists the account's calendars instead. + +Schedule full listings +====================== + +.. literalinclude:: ../examples/caldav_sync.py + :language: python + :start-after: # start: listings + :end-before: # end: listings + +Some servers answer a :term:`sync token` they no longer accept with fewer changes, instead of refusing it. :attr:`~calendaring_sync.adapters.base.Adapter.needs_full_listings` is ``True`` for those, and a regular :term:`full listing` repairs what an incremental sync missed, at little cost, as :doc:`../explanation/adapters` explains. :doc:`../reference/server-compatibility` lists which tested servers need it. + +Move from python-caldav's own sync +================================== + +python-caldav's ``sync()`` keeps the :term:`sync token` only on the Python object, so a new process starts over unless you save the token yourself, and it stores nothing it returns. The adapter keeps the token, a copy of every object, and the :term:`change feed` in the store, and sends your edits too. Replace the ``sync()`` call with :meth:`adapter.sync() `, and read what changed from the feed instead of the two lists ``sync()`` returned, as :doc:`change-feed` shows. diff --git a/docs/how-to/change-feed.rst b/docs/how-to/change-feed.rst new file mode 100644 index 0000000..07f22ff --- /dev/null +++ b/docs/how-to/change-feed.rst @@ -0,0 +1,33 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +==================== +Read the change feed +==================== + +.. meta:: + :description: Read the change feed from a cursor your application keeps, so it handles every change once, even after a crash. + +The :term:`change feed` lists every change syncs made to the store, every conflict resolved and every scope dropped. Keep a :term:`cursor`, the number of the last entry you handled, and read the entries after it. + +Read from a cursor +================== + +.. literalinclude:: ../examples/change_feed.py + :language: python + :start-after: # start: read + :end-before: # end: read + +``handle`` and ``reload_everything`` are your application's. The example reads the cursor before a pass and saves it after each page. A crash between the two replays that page instead of skipping it, so make ``handle`` safe to call twice for one entry. Each part of your application that reads the feed keeps a cursor of its own. + +The ``except`` branch handles a cursor older than what the feed keeps, as :class:`~calendaring_sync.error.TrimmedFeedError` describes. + +Trim and purge +============== + +.. literalinclude:: ../examples/change_feed.py + :language: python + :start-after: # start: trim + :end-before: # end: trim + +The feed and the removed records grow until you trim and purge them. Pass the lowest cursor any part of your application still reads from. :meth:`~calendaring_sync.replica.Replica.trim_feed` deletes the entries up to it, and :meth:`~calendaring_sync.replica.Replica.purge` deletes the records removed by then. diff --git a/docs/how-to/conflicts.rst b/docs/how-to/conflicts.rst new file mode 100644 index 0000000..63b7368 --- /dev/null +++ b/docs/how-to/conflicts.rst @@ -0,0 +1,31 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +================= +Resolve conflicts +================= + +.. meta:: + :description: Resolve the conflicts a sync stored, one at a time or with a function that decides for each. + +A sync stores a :term:`conflict` where the server changed an event you edited too, and carries on with the others. The event waits until you resolve it, and the next sync sends the result. In the examples below, another client moved the meeting while your application renamed it. + +Resolve one conflict +==================== + +.. literalinclude:: ../examples/conflicts.py + :language: python + :start-after: # start: one + :end-before: # end: one + +:meth:`~calendaring_sync.replica.Replica.resolve` takes the record you read and a :data:`~calendaring_sync.objects.resolution.Resolution`. If the conflict changed since you read it, it raises :class:`~calendaring_sync.error.StaleConflictError`, so you never decide on news you haven't seen. Each class in :mod:`calendaring_sync.objects.resolution` says what it does. + +Resolve every conflict with a function +====================================== + +.. literalinclude:: ../examples/conflicts.py + :language: python + :start-after: # start: merge + :end-before: # end: merge + +:meth:`~calendaring_sync.replica.Replica.resolve_conflicts` calls your function with each open conflict, and ``None`` leaves one open. A :class:`~calendaring_sync.objects.resolution.Merge` needs the fields the adapter reads from content, which :meth:`~calendaring_sync.adapters.base.Adapter.edit_fields` returns. :attr:`Record.conflict ` holds the server's side, and :class:`~calendaring_sync.objects.conflict.ConflictKind` says what each side did. diff --git a/docs/how-to/errors.rst b/docs/how-to/errors.rst new file mode 100644 index 0000000..37bd457 --- /dev/null +++ b/docs/how-to/errors.rst @@ -0,0 +1,18 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +============= +Handle errors +============= + +.. meta:: + :description: Catch the errors a sync raises, retry the ones that change nothing, and drop a scope the server no longer has. + +Every error calendaring-sync raises on purpose derives from :class:`~calendaring_sync.error.CalendaringSyncError`, and :doc:`the error reference <../reference/errors>` says what each one means. A step of a sync that fails changes nothing, so you can always sync again, as :doc:`../reference/guarantees` lists. + +.. literalinclude:: ../examples/errors.py + :language: python + :start-after: # start: sync + :end-before: # end: sync + +The client library's own errors, such as a refused login, reach you unchanged. Each adapter's class description names them. diff --git a/docs/how-to/every-calendar.rst b/docs/how-to/every-calendar.rst new file mode 100644 index 0000000..de50120 --- /dev/null +++ b/docs/how-to/every-calendar.rst @@ -0,0 +1,20 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +================================= +Sync every calendar in an account +================================= + +.. meta:: + :description: Sync many scopes into one store, such as every calendar of an account, and drop the scopes of calendars the account no longer has. + +One store holds as many :term:`scopes ` as you sync. Where each calendar is a scope of its own, as with :class:`~calendaring_sync.adapters.caldav.CalDAVAdapter`, make an adapter for each calendar, sync each one, and drop the scopes of calendars that are gone. + +.. literalinclude:: ../examples/every_calendar.py + :language: python + :start-after: # start: sync + :end-before: # end: sync + +Each calendar keeps its scope from run to run, through the adapter's default ``scope_id``. A calendar deleted on the server no longer appears in the account's list, so the last line drops its scope: :meth:`~calendaring_sync.replica.Replica.drop_scope` returns the edits it discards, which you can show or save. Keep one store for each account, so the scopes it drops are only that account's. + +What a scope covers is each adapter's choice: :doc:`../explanation/adapters` gives it for each adapter that ships with calendaring-sync. Where one scope already covers the whole account, one adapter syncs it all. diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst index d0e9e5b..ee97b4c 100644 --- a/docs/how-to/index.rst +++ b/docs/how-to/index.rst @@ -6,11 +6,42 @@ How-to guides ============= .. meta:: - :description: Task-focused guides for one specific thing at a time, starting with installing calendaring-sync. + :description: Guides for the tasks of an application that syncs calendar data with calendaring-sync, from connecting to a server to running in production. -Task-focused guides for one specific thing at a time. +Guides for one task each. :doc:`../tutorials/quickstart` comes first if you haven't synced a calendar yet. Many examples that need no server start from a ``replica`` that synced one event, ``meeting``, through the ``adapter`` that :doc:`write-an-adapter` builds; the others set up what they need themselves. .. toctree:: :maxdepth: 1 + :caption: Connect to a server install + caldav + jmap + every-calendar + async + +.. toctree:: + :maxdepth: 1 + :caption: Work with synced data + + local-edits + change-feed + mirror + conflicts + +.. toctree:: + :maxdepth: 1 + :caption: Run in production + + store + parallel + errors + inspect + +.. toctree:: + :maxdepth: 1 + :caption: Build on calendaring-sync + + without-adapter + write-an-adapter + testing diff --git a/docs/how-to/inspect.rst b/docs/how-to/inspect.rst new file mode 100644 index 0000000..b64814f --- /dev/null +++ b/docs/how-to/inspect.rst @@ -0,0 +1,35 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +========================== +Inspect and repair a scope +========================== + +.. meta:: + :description: See what a scope's sync state holds, from its token to its unsent edits, and repair a scope that looks out of step with the server. + +calendaring-sync keeps everything it knows about a :term:`scope` in the store, so you can read where a sync stands at any time, from any thread. + +Inspect a scope +=============== + +.. literalinclude:: ../examples/inspect_scope.py + :language: python + :start-after: # start: inspect + :end-before: # end: inspect + +The :class:`~calendaring_sync.objects.scope.Scope` says how far the last sync got and when the last full listing finished. The records in each state show what's waiting on whom: :term:`pending edits ` wait for the next sync, :term:`conflicts ` wait for you, and :meth:`~calendaring_sync.adapters.base.SyncAdapter.waiting` lists the edits the adapter can't send yet. The :term:`change feed` shows what each sync did, as :doc:`change-feed` shows. + +Repair a scope +============== + +.. literalinclude:: ../examples/inspect_scope.py + :language: python + :start-after: # start: repair + :end-before: # end: repair + +A :term:`full listing` compares every object with the server and repairs whatever an incremental sync missed. :meth:`~calendaring_sync.replica.Replica.reset` forgets the scope's :term:`sync token`, so its next sync is a full listing too. Neither touches your pending edits or open conflicts. To start a scope over entirely, :meth:`~calendaring_sync.replica.Replica.drop_scope` it, keep the edits it returns, and sync again. + +After a server is restored from a backup, sync every scope once with a full listing. A token from after the backup is newer than anything the restored server issued, and some servers answer it as if nothing changed, as :doc:`../reference/server-compatibility` records. + +To see the requests themselves, turn on your client library's logging. diff --git a/docs/how-to/install.rst b/docs/how-to/install.rst index 3ce5e38..0189e2e 100644 --- a/docs/how-to/install.rst +++ b/docs/how-to/install.rst @@ -5,26 +5,50 @@ Install ======= -calendaring-sync has no release on PyPI yet, so install it from GitHub: +.. meta:: + :description: Install calendaring-sync, and the extra for each adapter you use that ships with it. + +calendaring-sync needs Python 3.11 or later: .. code-block:: bash - pip install git+https://github.com/pycalendar/calendaring-sync + pip install calendaring-sync -The CalDAV adapter needs python-caldav and icalendar, which the ``caldav`` extra installs: +The :term:`core` uses only the Python standard library. That's all you need to keep sync state with an :term:`adapter` of your own, as :doc:`write-an-adapter` shows. -.. code-block:: bash +The package ships its type hints (:pep:`561`), so a type checker reads them without a stub package. + +Adapters that ship with calendaring-sync +======================================== + +Each adapter that ships with calendaring-sync needs its protocol's client library, which an extra of the same name installs: - pip install "calendaring-sync[caldav] @ git+https://github.com/pycalendar/calendaring-sync" +.. list-table:: + :header-rows: 1 -The JMAP adapter needs calendaring-jmap, which the ``jmap`` extra installs: + * - Extra + - Adapter + - Installs + * - ``caldav`` + - :class:`~calendaring_sync.adapters.caldav.CalDAVAdapter` + - python-caldav, icalendar, lxml + * - ``jmap`` + - :class:`~calendaring_sync.adapters.jmap.JMAPAdapter` + - calendaring-jmap + +Name the extras you use when you install: .. code-block:: bash - pip install "calendaring-sync[jmap] @ git+https://github.com/pycalendar/calendaring-sync" + pip install "calendaring-sync[caldav]" + +From source +=========== -To work on calendaring-sync itself, see :doc:`../contribute` for the development setup instead. +To try a change that isn't released yet, install from GitHub: + +.. code-block:: bash -Without an extra, it has no runtime dependencies beyond the Python standard library. It needs Python 3.11 or newer. + pip install "calendaring-sync @ git+https://github.com/PyCalendar/calendaring-sync" -The package ships inline type hints and a ``py.typed`` marker (:pep:`561`), so a type checker reads them directly without a separate stub package. +To work on calendaring-sync itself, set up a development environment as :doc:`../contribute` describes. diff --git a/docs/how-to/jmap.rst b/docs/how-to/jmap.rst new file mode 100644 index 0000000..35fe443 --- /dev/null +++ b/docs/how-to/jmap.rst @@ -0,0 +1,18 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +=================== +Sync a JMAP account +=================== + +.. meta:: + :description: Connect JMAPAdapter to the calendar events of a JMAP account. + +:class:`~calendaring_sync.adapters.jmap.JMAPAdapter` syncs the calendar events of one JMAP account through a calendaring-jmap client. + +.. literalinclude:: ../examples/jmap_sync.py + :language: python + :start-after: # start: sync + :end-before: # end: sync + +``client.get_account_id()`` returns the session's default account. To sync an account someone shared with you, pass its id instead. Each account is a :term:`scope` of its own. diff --git a/docs/how-to/local-edits.rst b/docs/how-to/local-edits.rst new file mode 100644 index 0000000..35c35ed --- /dev/null +++ b/docs/how-to/local-edits.rst @@ -0,0 +1,41 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +================== +Record local edits +================== + +.. meta:: + :description: Record each change your application makes, so the next sync sends it to the server. + +Record each change your application makes as you make it, and the next sync sends it. + +Create and change +================= + +.. literalinclude:: ../examples/local_edits.py + :language: python + :start-after: # start: record + :end-before: # end: record + +:meth:`~calendaring_sync.adapters.base.SyncAdapter.create` returns the new :term:`record`'s :term:`local key`, which stays the same once the server gives the object an id. Edits go through the adapter, which reads their fingerprint the same way it reads the server's copy. + +Undo an edit +============ + +.. literalinclude:: ../examples/local_edits.py + :language: python + :start-after: # start: undo + :end-before: # end: undo + +:meth:`~calendaring_sync.replica.Replica.discard` drops an edit the server hasn't received. + +Delete +====== + +.. literalinclude:: ../examples/local_edits.py + :language: python + :start-after: # start: delete + :end-before: # end: delete + +An event with an open :term:`conflict` takes no edit until you resolve it, as :doc:`conflicts` shows. diff --git a/docs/how-to/mirror.rst b/docs/how-to/mirror.rst new file mode 100644 index 0000000..53bcbe4 --- /dev/null +++ b/docs/how-to/mirror.rst @@ -0,0 +1,22 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +=================================== +Mirror synced data in your database +=================================== + +.. meta:: + :description: A complete script that keeps a table in your application's own database up to date with every sync, from the change feed. + +Your application often keeps its own table of events, in the shape its screens and queries need. This script keeps such a table up to date after every sync, from the :term:`change feed`. It works the same with any adapter. + +.. literalinclude:: ../examples/mirror.py + :language: python + :start-after: # start: mirror + :end-before: # end: mirror + +The table and the :term:`cursor` live in one database, and each page of the feed updates both in one transaction. So a crash leaves the table matching the cursor, and the next run continues from there: no entry is handled twice, and none is skipped. + +``apply`` reads the record each entry is about, since the record holds its latest synced content, however many entries there were since the last run. A record that's removed, or that was :term:`purged ` meanwhile, loses its row, and a dropped scope loses every row. Your own edits write no feed entries until the server has them, so the table holds each record's synced content, the copy both sides last agreed on. Read :attr:`Record.edit ` as well where your application shows edits that aren't sent yet. + +When the feed was trimmed past your cursor, :meth:`~calendaring_sync.replica.Replica.read_feed` raises :class:`~calendaring_sync.error.TrimmedFeedError`: rebuild the table from :meth:`~calendaring_sync.replica.Replica.records`, as :doc:`change-feed` shows. diff --git a/docs/how-to/parallel.rst b/docs/how-to/parallel.rst new file mode 100644 index 0000000..f6ad67f --- /dev/null +++ b/docs/how-to/parallel.rst @@ -0,0 +1,31 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +====================================== +Sync from several threads or processes +====================================== + +.. meta:: + :description: Sync several scopes at once from threads, share a store between processes, and retry when another process ingested first. + +One :class:`~calendaring_sync.replica.Replica` serves every thread of a process, and a SQLite store serves every process on the machine. + +Sync scopes at once +=================== + +.. literalinclude:: ../examples/parallel.py + :language: python + :start-after: # start: threads + :end-before: # end: threads + +Each adapter syncs its own scope, so they run side by side. The store runs one write at a time, with reads beside it. Sync a scope from one thread at a time: a second sync of the same scope in the same process raises :class:`~calendaring_sync.error.SessionError`. Your application's reads don't wait for a sync, as :class:`~calendaring_sync.store.base.StateStore` describes. + +Share a store between processes +=============================== + +.. literalinclude:: ../examples/parallel.py + :language: python + :start-after: # start: retry + :end-before: # end: retry + +Each process opens the store file itself and makes its own replica. When two processes sync one scope at the same moment, the one that ingests second gets :class:`~calendaring_sync.error.StaleChangeSetError` and nothing changes, so syncing again is enough. :doc:`../explanation/design` explains why that's safe, and the rare case where one edit is sent twice. diff --git a/docs/how-to/store.rst b/docs/how-to/store.rst new file mode 100644 index 0000000..bc21355 --- /dev/null +++ b/docs/how-to/store.rst @@ -0,0 +1,52 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +========================= +Choose and set up a store +========================= + +.. meta:: + :description: Keep sync state in a SQLite file with WAL, back it up and restore it, keep it in memory for tests, or in storage of your own. + +A :term:`store` keeps every scope your application syncs. Use a SQLite file to keep sync state between runs, memory in tests, or your own storage where your application keeps everything else. + +Keep sync state in SQLite +========================= + +.. literalinclude:: ../examples/sqlite.py + :language: python + :start-after: # start: open + :end-before: # end: open + +Set the file's journal mode to WAL once, before you open it. With WAL, each edit and each push writes several times faster, which counts when your application records edits one at a time. :class:`~calendaring_sync.store.sqlite.SQLiteStore` says what else WAL changes, and why it doesn't suit a network filesystem. Several processes can share the file, as :doc:`parallel` shows. + +Retry a busy store +================== + +.. literalinclude:: ../examples/sqlite.py + :language: python + :start-after: # start: busy + :end-before: # end: busy + +``timeout`` sets how long :class:`~calendaring_sync.store.sqlite.SQLiteStore` waits for another process before it gives up. + +.. _store-backup: + +Back up and restore the store +============================= + +Back up the store file together with everything your application derived from the :term:`change feed`, such as your :term:`cursor` and a mirrored table, as of the same moment. Copy the file while no process has it open, or use :meth:`sqlite3.Connection.backup`, which copies a consistent snapshot of a file in use. A plain copy of a file in use can miss the changes WAL keeps in a file beside it. + +Restoring an older copy loses nothing on the server. The next sync asks the server for every change since the copy's :term:`sync token`, or lists the scope in full where the server no longer accepts it. An edit the copy still holds as pending, which reached the server after the copy was made, is matched to the server's copy before anything is sent. A create whose id the server picks has nothing to match by, so it goes out again. Where :term:`logical keys ` are unique, the server refuses it, and it becomes a :term:`conflict` for you to resolve; elsewhere, the server can end up holding it twice. + +The restored feed continues from the copy, so it numbers new entries again from there, and a cursor saved after the copy was made skips entries without an error. If you restore the store alone, rebuild what you derived from the feed from :meth:`~calendaring_sync.replica.Replica.records`, and set your cursor to :meth:`~calendaring_sync.replica.Replica.feed_head`, as :doc:`change-feed` does after a trim. + +Keep sync state in memory +========================= + +:class:`~calendaring_sync.store.memory.MemoryStore` suits tests, as :doc:`testing` shows. + +Keep sync state in your own storage +=================================== + +To keep sync state where your application keeps its other data, subclass :class:`~calendaring_sync.store.memory.MemoryStore` and override how it loads and saves its data, as its description shows with :mod:`pickle`. For a database that should look up records itself, implement :class:`~calendaring_sync.store.base.StateStore`: its description lists the methods a store writes, and the base class adds every check, transaction rule and error a store needs. diff --git a/docs/how-to/testing.rst b/docs/how-to/testing.rst new file mode 100644 index 0000000..3e3a498 --- /dev/null +++ b/docs/how-to/testing.rst @@ -0,0 +1,25 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +==================================== +Test code that uses calendaring-sync +==================================== + +.. meta:: + :description: Test the parts of your application that read the change feed and records, with no server, by ingesting the changes a server would send. + +The parts of your application that read records and the :term:`change feed` don't need a server in their tests. Ingest what a server would send, and the replica reaches the state your code then reads. + +.. literalinclude:: ../examples/testing.py + :language: python + :start-after: # start: server + :end-before: # end: server + +Each :class:`~calendaring_sync.objects.change_set.ChangeSet` is the :term:`change set` an adapter would hand the core, so the replica ends up exactly as a real sync would leave it. A new :class:`~calendaring_sync.store.memory.MemoryStore` starts empty, so each test begins from nothing. Your test then checks what your code made of it: + +.. literalinclude:: ../examples/testing.py + :language: python + :start-after: # start: check + :end-before: # end: check + +To test your application's edits, and a server's answers to them, write a small adapter instead, as :doc:`write-an-adapter` shows. The examples on these pages run on one. diff --git a/docs/how-to/without-adapter.rst b/docs/how-to/without-adapter.rst new file mode 100644 index 0000000..fe223d5 --- /dev/null +++ b/docs/how-to/without-adapter.rst @@ -0,0 +1,27 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +================================ +Track changes without an adapter +================================ + +.. meta:: + :description: Use the core on its own: hand it everything a source holds, and it reports what's new, what changed, and what's gone since the last run. + +The :term:`core` needs no :term:`adapter` and no protocol. Hand it everything a source holds, by id, and it works out what changed since the last run: what's new, what changed, and what's gone. The source can be anything you can read in full, such as a published calendar file, an export, or a table in another system. + +.. literalinclude:: ../examples/snapshots.py + :language: python + :start-after: # start: track + :end-before: # end: track + +Each run is a :term:`full listing`. The source has no :term:`version` for each object, so the example uses the content's hash as one. An object whose hash the store already holds is reported :class:`~calendaring_sync.objects.change.Unchanged`, as :meth:`~calendaring_sync.replica.Replica.known_versions` describes, and isn't written again. An object the listing doesn't mention is removed. The source has no :term:`sync token` either, so each run gets a new one. + +.. literalinclude:: ../examples/snapshots.py + :language: python + :start-after: # start: run + :end-before: # end: run + +The :term:`change feed` reports each change once: both objects arriving, then the meeting moving and the lunch going. The second run found the source as it was and added nothing. + +Keep the state between runs in a SQLite file, as :doc:`store` shows, and read the feed from a :term:`cursor`, as :doc:`change-feed` shows. To send edits back to the source too, write an adapter, as :doc:`write-an-adapter` shows: the base classes hold the order of a two-way sync, so you write only how your source reads and writes. diff --git a/docs/how-to/write-an-adapter.rst b/docs/how-to/write-an-adapter.rst new file mode 100644 index 0000000..ffb54f8 --- /dev/null +++ b/docs/how-to/write-an-adapter.rst @@ -0,0 +1,37 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +================ +Write an adapter +================ + +.. meta:: + :description: Connect calendaring-sync to a new kind of server by subclassing the adapter base classes. + +An :term:`adapter` connects calendaring-sync to one kind of server. You write how your protocol reads changes and sends one edit. The base classes in :mod:`calendaring_sync.adapters.base` hold everything else: the session, the order of a sync, claiming and settling each push, and resetting a scope whose token the server refused. The adapter below syncs a server that keeps its objects in memory, and the other how-to pages use it. + +.. literalinclude:: ../examples/memory_adapter.py + :language: python + :lines: 4- + +Write the protocol once +======================= + +``_MemoryAdapter`` subclasses :class:`~calendaring_sync.adapters.base.Adapter`, sets the class attributes that describe what the server checks, and writes the methods its docstring lists. ``MemoryAdapter`` and ``AsyncMemoryAdapter`` add :class:`~calendaring_sync.adapters.base.SyncAdapter` or :class:`~calendaring_sync.adapters.base.AsyncAdapter`, which supply the public methods. The two differ only in the client they're given. + +Each method that talks to the server yields every client call, as :mod:`calendaring_sync.adapters.base` describes, so ``objects, token = yield self._server.listing()`` works with either client. + +Read changes +============ + +:meth:`~calendaring_sync.adapters.base.Adapter._read` turns each answer into a :class:`~calendaring_sync.objects.change_set.ChangeSet`. Here an expired token ends the read with ``ReadResult.INVALID_TOKEN``, and a server that lost the scope raises :class:`~calendaring_sync.error.ScopeGoneError`. Compute the fingerprint so it ignores what your server rewrites on its own, as :doc:`../explanation/design` explains. + +Send edits +========== + +:meth:`~calendaring_sync.adapters.base.Adapter._send` maps each answer your protocol gives to a :data:`~calendaring_sync.objects.push.PushOutcome`: here a version mismatch is :class:`~calendaring_sync.objects.push.Rejected`, and an object the server doesn't have is :class:`~calendaring_sync.objects.push.Gone`. Any other error propagates. + +Test it +======= + +Run the same steps through both variants, and compare the stores they leave. This project's tests do that for every adapter. :doc:`../explanation/adapters` describes how each adapter that ships with calendaring-sync maps its protocol, with the reason for each rule. diff --git a/docs/index.rst b/docs/index.rst index 257e9e0..7f30497 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,23 +13,29 @@ A Python library for calendar sync state: sync tokens, object versions, change t .. grid:: 2 :gutter: 2 + .. grid-item-card:: 🚀 Quickstart + :link: tutorials/quickstart + :link-type: doc + + Sync a calendar into a SQLite file. + .. grid-item-card:: 📋 How-to guides - :link: how-to/install + :link: how-to/index :link-type: doc - Install calendaring-sync. + Connect a server, record edits, read changes, resolve conflicts, run in production. .. grid-item-card:: 📖 Reference :link: reference/index :link-type: doc - Sync terms, server behavior, and every class and method. + Every class and method, guarantees and limits, sync terms, and server behavior. - .. grid-item-card:: 💡 Design - :link: explanation/design + .. grid-item-card:: 💡 Explanation + :link: explanation/index :link-type: doc - Why calendaring-sync is built the way it is. + When to use calendaring-sync, and why it works the way it does. .. grid-item-card:: 🤝 Contribute :link: contribute @@ -41,11 +47,12 @@ A Python library for calendar sync state: sync tokens, object versions, change t :link: security :link-type: doc - Report a vulnerability. + Report a vulnerability, and the risks to know. .. toctree:: :hidden: + tutorials/quickstart how-to/index Reference explanation/index diff --git a/docs/reference/adapters.rst b/docs/reference/adapters.rst index 9a7fd45..7d9146a 100644 --- a/docs/reference/adapters.rst +++ b/docs/reference/adapters.rst @@ -1,19 +1,25 @@ .. SPDX-FileCopyrightText: 2026 calendaring-sync contributors .. SPDX-License-Identifier: AGPL-3.0-or-later -:mod:`adapters`: Syncing a CalDAV calendar or a JMAP account -============================================================ +:mod:`~calendaring_sync.adapters`: Connecting to servers +======================================================== .. meta:: - :description: The CalDAV adapters, which sync one calendar collection through python-caldav, and the JMAP adapters, which sync one account's events through calendaring-jmap. + :description: The adapter base classes, the CalDAV adapters built on python-caldav, and the JMAP adapters built on calendaring-jmap. -An :term:`adapter` connects a :class:`~calendaring_sync.replica.Replica` to one kind of server. Each comes in a synchronous and an asynchronous variant, takes the matching client, and is installed with an extra of its own. :doc:`../explanation/adapters` explains every rule they follow, and :doc:`server-compatibility` how each tested server behaves. +.. automodule:: calendaring_sync.adapters + :no-members: + +Base classes +------------ + +.. automodule:: calendaring_sync.adapters.base + :members: + :private-members: _read, _send, _waits, _new_object_id, _edit_fields CalDAV ------ -:class:`~calendaring_sync.adapters.caldav.CalDAVAdapter` syncs one CalDAV calendar collection through a python-caldav client, and :class:`~calendaring_sync.adapters.caldav.AsyncCalDAVAdapter` does the same through an asynchronous one. Install them with the ``caldav`` extra. - .. automodule:: calendaring_sync.adapters.caldav :members: :inherited-members: @@ -22,8 +28,6 @@ CalDAV JMAP ---- -:class:`~calendaring_sync.adapters.jmap.JMAPAdapter` syncs one JMAP account's calendar events through a calendaring-jmap client, and :class:`~calendaring_sync.adapters.jmap.AsyncJMAPAdapter` does the same through an asynchronous one. Install them with the ``jmap`` extra. - .. automodule:: calendaring_sync.adapters.jmap :members: :inherited-members: diff --git a/docs/reference/constants.rst b/docs/reference/constants.rst index 2c66944..e32ba4b 100644 --- a/docs/reference/constants.rst +++ b/docs/reference/constants.rst @@ -1,11 +1,11 @@ .. SPDX-FileCopyrightText: 2026 calendaring-sync contributors .. SPDX-License-Identifier: AGPL-3.0-or-later -:mod:`constants`: Shared values -=============================== +:mod:`~calendaring_sync.constants`: Shared values +================================================= .. meta:: - :description: The schema version, feed page size, and store timeout calendaring-sync uses. + :description: The schema version, change feed page size, and store timeout calendaring-sync uses. .. automodule:: calendaring_sync.constants :members: diff --git a/docs/reference/errors.rst b/docs/reference/errors.rst index b1bc555..26bf19b 100644 --- a/docs/reference/errors.rst +++ b/docs/reference/errors.rst @@ -1,9 +1,8 @@ .. SPDX-FileCopyrightText: 2026 calendaring-sync contributors .. SPDX-License-Identifier: AGPL-3.0-or-later -====== -Errors -====== +:mod:`~calendaring_sync.error`: Errors +====================================== .. meta:: :description: The errors calendaring-sync raises, all derived from CalendaringSyncError. diff --git a/docs/reference/glossary.rst b/docs/reference/glossary.rst index 83deb47..6e4e0b7 100644 --- a/docs/reference/glossary.rst +++ b/docs/reference/glossary.rst @@ -14,22 +14,22 @@ These are the sync terms calendaring-sync's pages use. :doc:`../explanation/desi :sorted: adapter - The part that connects calendaring-sync to one kind of server. It asks the server what changed, hands the answer to the :term:`core` as a :term:`change set`, and sends your :term:`pushes `. + The part that connects calendaring-sync to one kind of server. It asks the server what changed, hands the answer to the :term:`core` as a :term:`change set`, and sends your :term:`pushes `. Some ship with calendaring-sync, and you can write your own. change set What an adapter hands the core after asking the server what changed in one :term:`scope`: the objects that changed or were removed, the :term:`sync token` it was fetched from, and the token it leads to. change feed - The numbered list of every change the core made to the store, which your application reads from its :term:`cursor`. + The numbered list of what syncs, resolved conflicts and dropped scopes changed in the store, which your application reads from its :term:`cursor`. checkpoint The sync token a :term:`truncated page` ends at. The next request continues from it. claim - To mark a :term:`pending edit` :term:`in flight` just before pushing it, in one step that fails if another process got there first. + To mark a :term:`pending edit` :term:`in flight` before it's pushed, in one step that fails if another process got there first. :ref:`design-states` shows a record's states and what moves it between them. conflict - An object where your :term:`pending edit` and the server disagree: both sides changed it, one side deleted it while the other edited it, or the server refused your create or change because another object holds its :term:`UID`. calendaring-sync reports it and leaves the decision to you. + An object where your :term:`pending edit` and the server disagree: both sides changed it, one side deleted it while the other edited it, or the server refused your create or change because another object holds its :term:`logical key`. calendaring-sync reports it and leaves the decision to you. conflict record What the core stores for one :term:`conflict`: the :term:`synced content`, your edit, the server's side, and the kind of conflict. It stays open until you resolve it or it resolves itself. @@ -88,7 +88,7 @@ These are the sync terms calendaring-sync's pages use. :doc:`../explanation/desi To delete removed records from the store for good, up to a point your application chooses. push - To send one :term:`pending edit` to the server. :doc:`../explanation/design` describes how a push can end. + To send one :term:`pending edit` to the server. :data:`~calendaring_sync.objects.push.PushOutcome` lists how a push can end. record Everything the core stores about one :term:`object`, including its current state. @@ -97,7 +97,7 @@ These are the sync terms calendaring-sync's pages use. :doc:`../explanation/desi To forget a scope's :term:`sync token`, so that the next sync of that scope is a :term:`full listing`. resolver - A function you write and pass to ``resolve_conflicts()``. It receives each open :term:`conflict record` and says how to resolve it. + A function you write and pass to :meth:`~calendaring_sync.replica.Replica.resolve_conflicts`. It receives each open :term:`conflict record` and says how to resolve it. scope The set of objects one :term:`sync token` covers, such as one calendar. diff --git a/docs/reference/guarantees.rst b/docs/reference/guarantees.rst new file mode 100644 index 0000000..551d2eb --- /dev/null +++ b/docs/reference/guarantees.rst @@ -0,0 +1,75 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +===================== +Guarantees and limits +===================== + +.. meta:: + :description: What calendaring-sync guarantees about your data, edits and changes, and the limits it has, each linked to the reason behind it. + +What the :term:`core` guarantees, whichever :term:`adapter` and :term:`store` you use, and the limits it has. Each row links to the reason behind it. + +Guarantees +========== + +.. list-table:: + :header-rows: 1 + :widths: 75 25 + + * - Guarantee + - Why + * - A change set is applied whole or not at all, and the :term:`sync token` moves only with it. + - :ref:`design-change-sets` + * - A sync that stops at any point loses nothing: the next one starts from the last :term:`sync token` the store saved. + - :ref:`design-change-sets` + * - A change set fetched from an outdated token is refused, so two processes never apply the same changes twice. + - :ref:`design-change-sets` + * - Nothing is pushed until every page of a sync is ingested. + - :ref:`design-reads-first` + * - Two processes never :term:`claim` the same push at once. + - :ref:`design-reads-first` + * - A :term:`pending edit` stays until the server applies it. A rejected push, or one whose answer never arrived, doesn't discard it. + - :ref:`design-server-answer` + * - An object the server removed while you edited it is always a :term:`conflict`, never resolved for you. + - :ref:`design-conflicts` + * - A conflict that changed since you read it can't be resolved, so a decision never applies to changes you haven't seen. + - :ref:`design-conflicts` + * - A server's copy of your own write isn't reported as a change, however the server rewrote it. + - :ref:`design-fingerprints` + * - Everything a sync changes, every conflict you resolve and every scope you drop reaches the :term:`change feed` in the same transaction, numbered in order, and a removal stays readable until you :term:`purge` it. Your own edit reaches it when its push ends. + - :ref:`design-feed` + * - A scope the server no longer has is never emptied for you. Dropping it returns the edits and conflicts it discards. + - :ref:`design-dropped-scope` + * - In the stores that ship, a read in another thread doesn't wait for a sync's write, at most for its commit, and sees the store as it was before the write began. A store of your own does the same where it sets ``_reads_beside_writes``. + - :ref:`design-storage` + * - Every store checks arguments and raises errors the same way, since the checks live in the base class. + - :class:`~calendaring_sync.store.base.StateStore` + +Limits +====== + +.. list-table:: + :header-rows: 1 + :widths: 75 25 + + * - Limit + - Why + * - An edit that changes only values a server stamps on its own, such as when an object was last saved, isn't pushed. + - :ref:`design-fingerprints` + * - If two processes sync a scope at once, both can send the same edit. A guarded change or delete is then rejected, but a create whose id the server picks can exist twice where the adapter doesn't treat logical keys as unique. + - :ref:`design-reads-first` + * - After a push is applied, the next sync fetches that object from the server once more. + - :ref:`design-server-answer` + * - A full listing compares every object, and an adapter whose protocol has no :term:`version` for each object fetches every one. + - :ref:`design-invalid-tokens` + * - A replica runs one sync of a scope at a time, and a second raises :class:`~calendaring_sync.error.SessionError`. + - :ref:`design-reads-first` + * - Store calls are synchronous. The asynchronous adapters run them in a worker thread. + - :ref:`design-storage` + * - A store restored from an older copy numbers new feed entries again from the copy's point, so a cursor saved after the copy skips entries. + - :ref:`store-backup` + * - calendaring-sync logs nothing. Your client library's logging shows the requests an adapter sends. + - :doc:`../how-to/inspect` + +Each adapter's own limits, and how each tested server behaves, are on :doc:`../explanation/adapters` and :doc:`server-compatibility`. diff --git a/docs/reference/index.rst b/docs/reference/index.rst index f27792f..ff9e320 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -5,19 +5,22 @@ ================================ .. meta:: - :description: The calendaring_sync package, generated from its source. + :description: Every public class and method of calendaring-sync, the sync terms its pages use, and how tested servers behave. -``calendaring_sync`` exports every public name, so ``from calendaring_sync import Replica, SQLiteStore`` works for every one. Each module's page below documents its names. New to sync? :doc:`../explanation/design` walks through one sync first, and the :doc:`glossary` defines each term the pages link. +.. automodule:: calendaring_sync + :no-members: .. toctree:: :maxdepth: 1 replica + session store objects errors constants adapters + guarantees glossary server-compatibility ../changelog diff --git a/docs/reference/objects.rst b/docs/reference/objects.rst index 1c911ab..8144848 100644 --- a/docs/reference/objects.rst +++ b/docs/reference/objects.rst @@ -1,13 +1,14 @@ .. SPDX-FileCopyrightText: 2026 calendaring-sync contributors .. SPDX-License-Identifier: AGPL-3.0-or-later -:mod:`objects`: Change sets, records, edits, conflicts, push outcomes, resolutions, scopes, and feed entries -============================================================================================================ +:mod:`~calendaring_sync.objects`: Values the core takes and returns +=================================================================== .. meta:: - :description: The data objects calendaring-sync takes and returns: change sets and their changes, records with their local edits and conflicts, push outcomes, resolutions, scopes, feed entries, and dropped scopes. + :description: The values calendaring-sync takes and returns: change sets, records, edits, conflicts, push outcomes, resolutions, scopes, and feed entries. -The data an :term:`adapter` hands the core, such as change sets and how each :term:`push` ended, the resolutions your application chooses for :term:`conflicts `, and what the core returns. +.. automodule:: calendaring_sync.objects + :no-members: .. automodule:: calendaring_sync.objects.change_set :members: @@ -38,5 +39,3 @@ The data an :term:`adapter` hands the core, such as change sets and how each :te .. automodule:: calendaring_sync.objects.dropped_scope :members: - -See :doc:`errors` for the exception hierarchy. diff --git a/docs/reference/replica.rst b/docs/reference/replica.rst index 194b2d9..91ed1b4 100644 --- a/docs/reference/replica.rst +++ b/docs/reference/replica.rst @@ -1,13 +1,11 @@ .. SPDX-FileCopyrightText: 2026 calendaring-sync contributors .. SPDX-License-Identifier: AGPL-3.0-or-later -:mod:`replica`: Sessions, ingesting, local edits, pushing, resolving conflicts, and the change feed -=================================================================================================== +:mod:`~calendaring_sync.replica`: The sync state +================================================ .. meta:: - :description: Replica and Session, where calendaring-sync applies change sets, records local edits, pushes them, stores and resolves conflicts, and serves the change feed. - -:class:`~calendaring_sync.replica.Replica` is the entry point. Record your :term:`local edits ` as you make them. To sync, open a :term:`session` for a :term:`scope` and :term:`ingest` the :term:`change sets ` an :term:`adapter` fetched. After the last page, the adapter pushes your edits in the same session: it :term:`claims ` each one, sends it, and settles it with how the :term:`push` ended. Then read the :term:`change feed` to learn what changed and which :term:`conflicts ` came up, and resolve them with :meth:`~.Replica.resolve` or :meth:`~.Replica.resolve_conflicts`. + :description: Replica, the class your application and adapters use to sync, record edits, resolve conflicts, and read the change feed. .. automodule:: calendaring_sync.replica :members: diff --git a/docs/reference/server-compatibility.rst b/docs/reference/server-compatibility.rst index be96f57..8d10cd9 100644 --- a/docs/reference/server-compatibility.rst +++ b/docs/reference/server-compatibility.rst @@ -6,13 +6,15 @@ Server compatibility ==================== .. meta:: - :description: How each tested server behaves where sync depends on it, and what calendaring-sync's adapters do about each difference. + :description: How each tested server behaves where sync depends on it, and what each adapter that ships with calendaring-sync does about it. -Each table lists what the tested servers did where sync depends on it, and what the adapter does about it. The CalDAV adapter was checked against Radicale 3.8.3, Xandikos 0.4.8, Cyrus 3.13.7, Stalwart 0.16.21 and Baïkal 0.10.1, and the JMAP adapter against Cyrus 3.13.7 and Stalwart 0.16.21, between 2026-10-10 and 2026-10-12. The CalDAV adapter's integration tests ran against all five on 2026-10-23, with python-caldav 3.6.0 and Xandikos 0.4.10, and the rows they added are marked with that date. The JMAP adapter's integration tests ran against Cyrus 3.13.7 and Stalwart 0.16.27 on 2026-10-25, with calendaring-jmap's main before its 2.0.0 release, and the rows they added are marked with that date. Entries marked (tester) come from `caldav-server-tester `_, which ran these checks against 22 servers in October 2026; python-caldav publishes its results as compatibility hints (`python-caldav/caldav#822 `_). A later release may behave differently. The probes and logs behind every row are on the `evidence branch `_, with the steps to reproduce them. :doc:`../explanation/adapters` explains each rule, and the :doc:`glossary` defines the sync terms in the Adapter column. +Each table lists how the tested servers behave where sync depends on it, and what the adapter does about it. Each section names the versions it was tested with; a later release may behave differently. The probes and logs behind every row are on the `evidence branch `_, with the steps to reproduce them. :doc:`../explanation/adapters` explains each rule, and the :doc:`glossary` defines the sync terms in the Adapter column. CalDAV ====== +The CalDAV adapter was checked against Radicale 3.8.3, Xandikos 0.4.8, Cyrus 3.13.7, Stalwart 0.16.21 and Baïkal 0.10.1 between 2026-10-10 and 2026-10-12. Its integration tests ran against the same five on 2026-10-23, with python-caldav 3.6.0 and Xandikos 0.4.10, and the rows they added are marked with that date. Entries marked (tester) come from `caldav-server-tester `_, which ran these checks against 22 servers in October 2026; python-caldav publishes its results as compatibility hints (`python-caldav/caldav#822 `_). + .. list-table:: :header-rows: 1 :widths: 34 36 30 @@ -34,7 +36,7 @@ CalDAV - Lists with ``PROPFIND``, never an empty token * - Member created and deleted between two reports (:rfc:`6578#section-3.5.2`: reported as removed) - - Xandikos: not reported - - Stalwart 0.16.11: reported only when a member that existed at the earlier token also changed in between (tester) + - Stalwart 0.16.11: reported only when a member that existed at the earlier token also changed since (tester) - Bedework 5: not reported (tester) - Others: reported as removed - A create of yours that's never mentioned stays :term:`pending ` @@ -114,6 +116,8 @@ Conditional writes behaved as :rfc:`9110#section-13.1.1` and :rfc:`9110#section- JMAP ==== +The JMAP adapter was checked against Cyrus 3.13.7 and Stalwart 0.16.21 between 2026-10-10 and 2026-10-12. Its integration tests ran against Cyrus 3.13.7 and Stalwart 0.16.27 on 2026-10-25, with calendaring-jmap before its 2.0.0 release, and the rows they added are marked with that date. + .. list-table:: :header-rows: 1 :widths: 34 36 30 diff --git a/docs/reference/session.rst b/docs/reference/session.rst new file mode 100644 index 0000000..f6992ef --- /dev/null +++ b/docs/reference/session.rst @@ -0,0 +1,11 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +:mod:`~calendaring_sync.session`: One sync of one scope +======================================================= + +.. meta:: + :description: Session, through which an adapter ingests what changed on the server and pushes your edits. + +.. automodule:: calendaring_sync.session + :members: diff --git a/docs/reference/store.rst b/docs/reference/store.rst index 51a675b..8ba4938 100644 --- a/docs/reference/store.rst +++ b/docs/reference/store.rst @@ -1,16 +1,18 @@ .. SPDX-FileCopyrightText: 2026 calendaring-sync contributors .. SPDX-License-Identifier: AGPL-3.0-or-later -:mod:`store`: Where sync state lives -==================================== +:mod:`~calendaring_sync.store`: Where sync state lives +====================================================== .. meta:: - :description: StateStore, the storage interface, and its two implementations: MemoryStore, for tests or as the base of a store that saves its objects elsewhere, and SQLiteStore for real use. + :description: StateStore, the storage interface, and its implementations: SQLiteStore for real use and MemoryStore for tests. -A :term:`store` keeps every :term:`scope`, its records, and the :term:`change feed`. Use :class:`~calendaring_sync.store.sqlite.SQLiteStore` to keep state between runs, and :class:`~calendaring_sync.store.memory.MemoryStore` in tests. In both, a read in one thread runs while another thread writes. To save the objects themselves somewhere else, such as with :mod:`pickle` or in an object database, subclass ``MemoryStore`` as its documentation shows. +.. automodule:: calendaring_sync.store + :no-members: .. automodule:: calendaring_sync.store.base :members: + :private-members: _begin, _commit, _rollback, _scope_records_in, _store_error, _writing, _reads_beside_writes .. automodule:: calendaring_sync.store.memory :members: diff --git a/docs/release.rst b/docs/release.rst index 82d9340..5494294 100644 --- a/docs/release.rst +++ b/docs/release.rst @@ -5,11 +5,16 @@ Release ======= -This page is for maintainers cutting a new release. It isn't something a contributor opening a PR needs to read. +.. meta:: + :description: How calendaring-sync is versioned, how a maintainer cuts a release, and the access a release needs. + +This page is for maintainers cutting a release. Contributors opening a pull request don't need it. Versioning ========== +Versions follow `Semantic Versioning 2.0.0 `_. While the major version is 0, a release with new features or with changes that break the public API bumps the minor version, and a release with only fixes bumps the patch version. From 1.0.0 on, a breaking change bumps the major version. A public name that's renamed or removed keeps working, with a :exc:`DeprecationWarning`, for at least one release first. + calendaring-sync has no release branches: every release ships from ``main``. The version number comes entirely from the git tag, read at build time by ``hatch-vcs``; nothing in the source tree stores it. ``src/calendaring_sync/_version.py`` is generated during the build and isn't tracked in git. Cut a release @@ -28,9 +33,9 @@ Cut a release towncrier build --yes --version 1.2.0 - This writes the new section into ``docs/changelog.rst`` and deletes the fragments it consumed. Review the diff; towncrier sorts entries alphabetically within each category, not in the order the fragments were added. + This writes the new section into ``docs/changelog.rst`` and deletes the fragments it consumed. Review the diff: towncrier orders the entries in each category by issue number, not in the order the fragments were added. -#. Commit directly to ``main``. This one commit is the exception to :ref:`commits-and-prs`'s one-PR-per-change rule: it's a mechanical, maintainer-only step with nothing for a reviewer to meaningfully review. +#. Commit directly to ``main``, the one exception :ref:`commits-and-prs` makes to going through a pull request: the commit is mechanical, with nothing for a reviewer to review. .. code-block:: bash @@ -38,14 +43,14 @@ Cut a release git commit -m "chore: build 1.2.0 changelog" git push -#. Tag the commit you just pushed, and push the tag: +#. Tag the commit you pushed, and push the tag: .. code-block:: bash git tag v1.2.0 git push origin v1.2.0 -Pushing the tag triggers the ``release`` GitHub Actions workflow: it runs the full test suite, builds the package, publishes to PyPI via trusted publishing (no stored token), and creates a GitHub release with autogenerated notes. Nothing past the tag push is manual. +Pushing the tag triggers the ``release`` GitHub Actions workflow: it runs the full test suite, builds the package, publishes to PyPI through trusted publishing, with no stored token, and creates a GitHub release with autogenerated notes. Nothing past the tag push is manual. Read the Docs builds the new version automatically from the same tag push; no separate step is needed there. @@ -54,7 +59,7 @@ Maintainer access Releasing calendaring-sync requires: -``Admin`` access to the `GitHub repository `_ +``Admin`` access to the `GitHub repository `_ Needed to push directly to ``main`` and push tags. ``Owner`` or ``Maintainer`` access to the `PyPI project `_ PyPI publishing uses `trusted publishing `_ (OIDC), configured against this repository's ``release`` workflow and its ``pypi`` environment; no PyPI API token is stored anywhere. A PyPI account with access can revoke or reconfigure this. diff --git a/docs/security.rst b/docs/security.rst index 797ee3e..1840409 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -5,7 +5,10 @@ Security policy =============== -Report a vulnerability using `GitHub's private vulnerability reporting `_ (the "Report a vulnerability" button on the Security tab), rather than a public issue. +.. meta:: + :description: How to report a vulnerability in calendaring-sync, which versions get fixes, and the risks to know: what the store keeps, server data, connections, and stores you write. + +Report a vulnerability using `GitHub's private vulnerability reporting `_ (the "Report a vulnerability" button on the Security tab), rather than a public issue. Supported versions ================== @@ -15,4 +18,27 @@ Security fixes target the latest release. There's no long-term support branch. Dependencies ============ -calendaring-sync has no runtime dependencies: it uses only the Python standard library. See ``pyproject.toml`` for the development and documentation tooling. +:doc:`how-to/install` lists what calendaring-sync and each of its extras depend on, and ``pyproject.toml`` gives the versions. + +Known risks and design notes +============================ + +What the store keeps +-------------------- + +A :term:`store` keeps the content of every synced object, and your edits to it, as they are. Calendar data holds personal information, such as names, places and what a meeting is about. Each scope's id is in the store too, and an adapter's default scope id holds the server's address, without a login. The library never stores a password or another credential: those stay with the client you give the adapter. :class:`~calendaring_sync.store.sqlite.SQLiteStore` doesn't encrypt its file, so give it the permissions you give the calendar data itself, such as ``chmod 600``. + +Data from the server +-------------------- + +The core never parses content: it stores and compares the bytes a server sent. The adapters that ship with calendaring-sync read what they need from it with their protocol's parsers. The CalDAV adapter reads iCalendar content lines with icalendar, and the JMAP adapter reads JSON with the standard library. The CalDAV adapter parses a server's XML error body with lxml, with entity expansion and network access turned off, so a hostile server can't make it read local files or fetch other addresses. + +Connections and credentials +--------------------------- + +calendaring-sync opens no connection of its own: the client library you give an adapter makes every request, with its own certificate checks and login. Point it at a server you trust, over HTTPS, as the client library's documentation describes. + +Stores you write yourself +------------------------- + +:class:`~calendaring_sync.store.memory.MemoryStore`'s description shows a store that saves its data with :mod:`pickle`. Loading a pickle runs code, so load such a file only if your application wrote it and no one else can change it. diff --git a/docs/tutorials/quickstart.rst b/docs/tutorials/quickstart.rst new file mode 100644 index 0000000..38e4866 --- /dev/null +++ b/docs/tutorials/quickstart.rst @@ -0,0 +1,39 @@ +.. SPDX-FileCopyrightText: 2026 calendaring-sync contributors +.. SPDX-License-Identifier: AGPL-3.0-or-later + +========== +Quickstart +========== + +.. meta:: + :description: Sync one calendar into a SQLite file and list what the sync brought in. + +This tutorial syncs one calendar from a CalDAV server into a SQLite file, then lists what the sync brought in. You need an account on a CalDAV server with at least one calendar, and calendaring-sync installed with the ``caldav`` extra, as :doc:`../how-to/install` shows. + +Sync a calendar +=============== + +.. literalinclude:: ../examples/quickstart.py + :language: python + :start-after: # start: sync + :end-before: # end: sync + +Replace the URL, username, and password with your own, then run the script. It prints a line for each event in your first calendar, such as ``changed /calendars/alice/work/meeting.ics``. + +Change an event in your calendar app and run the script again. This sync fetches only that event, and the feed gains one entry for it. + +What happened +============= + +:class:`~calendaring_sync.store.sqlite.SQLiteStore` keeps the sync state in ``sync.db``, so each run continues where the last one stopped. On the first run, :class:`~calendaring_sync.adapters.caldav.CalDAVAdapter` listed the calendar in full and stored each event as a :term:`record`. Later runs ask the server only for what changed since the :term:`sync token` it stored. Every change also went into the :term:`change feed`, which :meth:`~calendaring_sync.replica.Replica.read_feed` returns from its first entry. + +Only the lines that open the client and create the adapter are about CalDAV. With any other :term:`adapter`, including one you write, the rest of the script stays the same. + +Next steps +========== + +- :doc:`../how-to/every-calendar` to sync every calendar in an account. +- :doc:`../how-to/change-feed` to read only the entries you haven't handled yet, and :doc:`../how-to/mirror` to keep your own table up to date with them. +- :doc:`../how-to/local-edits` to change events and send the changes to the server. +- :doc:`../how-to/conflicts` for an event both sides changed. +- :doc:`../explanation/when-to-use` to see what calendaring-sync is for, and what it isn't. diff --git a/news/10.feature.1.rst b/news/10.feature.1.rst deleted file mode 100644 index b951444..0000000 --- a/news/10.feature.1.rst +++ /dev/null @@ -1 +0,0 @@ -Added the CalDAV adapter: ``CalDAVAdapter`` and ``AsyncCalDAVAdapter`` sync one calendar collection through python-caldav 3.6.0 or later, installed with the ``caldav`` extra, and their ``create`` and ``change`` record your edits with the fingerprint and UID a sync would give them, while ``waiting()`` lists the edits they can't send yet. A calendar the server no longer has raises the new ``ScopeGoneError``, and a full listing the server cuts short raises ``IncompleteListingError``. diff --git a/news/10.feature.2.rst b/news/10.feature.2.rst deleted file mode 100644 index bf4c91f..0000000 --- a/news/10.feature.2.rst +++ /dev/null @@ -1 +0,0 @@ -A change the server refuses because another object holds its logical key becomes a ``LOGICAL_KEY_TAKEN`` conflict, resolved as a refused create is, except that dropping it keeps the object as it was synced. diff --git a/news/10.feature.3.rst b/news/10.feature.3.rst deleted file mode 100644 index c0b6bea..0000000 --- a/news/10.feature.3.rst +++ /dev/null @@ -1 +0,0 @@ -``Replica.records()`` takes ``states``, to read only the records in those states. diff --git a/news/10.feature.rst b/news/10.feature.rst new file mode 100644 index 0000000..a09820b --- /dev/null +++ b/news/10.feature.rst @@ -0,0 +1 @@ +Added the CalDAV adapter, :class:`~calendaring_sync.adapters.caldav.CalDAVAdapter` and :class:`~calendaring_sync.adapters.caldav.AsyncCalDAVAdapter`, which syncs one calendar through python-caldav 3.6.0 or later; install it with the ``caldav`` extra. A calendar the server no longer has raises :exc:`~calendaring_sync.error.ScopeGoneError`, and a full listing the server cuts short raises :exc:`~calendaring_sync.error.IncompleteListingError`. diff --git a/news/11.feature.rst b/news/11.feature.rst index c6d3e09..2a6354e 100644 --- a/news/11.feature.rst +++ b/news/11.feature.rst @@ -1 +1 @@ -Added the JMAP adapter: ``JMAPAdapter`` and ``AsyncJMAPAdapter`` sync one account's calendar events through calendaring-jmap 2.0.0 or later, installed with the ``jmap`` extra, with every write guarded by ``ifInState``. An event's fingerprint ignores what servers fill in or leave out on their own, so a server's copy of your own write isn't reported as a change. +Added the JMAP adapter, :class:`~calendaring_sync.adapters.jmap.JMAPAdapter` and :class:`~calendaring_sync.adapters.jmap.AsyncJMAPAdapter`, which syncs one account's calendar events through calendaring-jmap 2.0.0 or later; install it with the ``jmap`` extra. diff --git a/news/12.documentation.rst b/news/12.documentation.rst new file mode 100644 index 0000000..e6c7154 --- /dev/null +++ b/news/12.documentation.rst @@ -0,0 +1 @@ +Added a quickstart, how-to guides for each task from connecting to a server to writing an adapter or using the core without one, pages on when to use calendaring-sync and on what it guarantees, and the risks to know on the security page. Every code example on these pages runs as a test. diff --git a/news/12.feature.1.rst b/news/12.feature.1.rst new file mode 100644 index 0000000..fccbd4c --- /dev/null +++ b/news/12.feature.1.rst @@ -0,0 +1 @@ +Added the adapter base classes :class:`~calendaring_sync.adapters.base.Adapter`, :class:`~calendaring_sync.adapters.base.SyncAdapter` and :class:`~calendaring_sync.adapters.base.AsyncAdapter` in :mod:`calendaring_sync.adapters.base`: an adapter for another protocol writes how it reads changes and sends one edit, and gets the rest of a sync, and an asynchronous variant, from them. diff --git a/news/12.feature.2.rst b/news/12.feature.2.rst new file mode 100644 index 0000000..c3bb610 --- /dev/null +++ b/news/12.feature.2.rst @@ -0,0 +1 @@ +:class:`~calendaring_sync.objects.resolution.Merge` takes the :class:`~calendaring_sync.objects.edit.EditFields` an adapter's :meth:`~calendaring_sync.adapters.base.Adapter.edit_fields` reads from the merged content, so a merge is fingerprinted the way a sync fingerprints the server's copy. diff --git a/news/24.feature.rst b/news/24.feature.rst index 087de2e..3bebd22 100644 --- a/news/24.feature.rst +++ b/news/24.feature.rst @@ -1 +1 @@ -A read no longer waits for another thread's write: in ``MemoryStore`` and ``SQLiteStore``, a transaction that only reads runs while a write is open in another thread. In SQLite's default journal mode the commit still waits for such reads, and a write keeps its changes in memory until it commits, which for a large listing is about as much again as its change set. +In :class:`~calendaring_sync.store.memory.MemoryStore` and :class:`~calendaring_sync.store.sqlite.SQLiteStore`, a read runs while another thread writes. In SQLite's default journal mode, a commit waits for those reads, and a write holds its changes in memory until it commits: for a large listing, about as much again as its change set. diff --git a/news/27.feature.rst b/news/27.feature.rst index 1bc5fb5..64107e4 100644 --- a/news/27.feature.rst +++ b/news/27.feature.rst @@ -1 +1 @@ -Every public method of ``Replica``, ``Session`` and the stores, and every value an adapter or application builds, now checks its argument types and raises ``TypeError`` for a wrong one the same way on every store; a subclass of ``str`` or ``int``, such as an enum, counts as wrong. Before, a wrong type could be stored as it was by one store and converted by another, such as ``delete_record("1")``, which deleted record 1 only on SQLite. +The public methods of :class:`~calendaring_sync.replica.Replica`, :class:`~calendaring_sync.session.Session`, the stores and the adapters, and the values an adapter or application builds, check their argument types and raise :exc:`TypeError` for a wrong one; a subclass of ``str`` or ``int``, such as an enum, counts as wrong. diff --git a/news/28.feature.rst b/news/28.feature.rst index 421ec0a..dfe5cc4 100644 --- a/news/28.feature.rst +++ b/news/28.feature.rst @@ -1 +1 @@ -``MemoryStore`` reads and saves its data through two methods a subclass can override, ``_load()`` and ``_save()``, so a store that keeps the same objects in a file with ``pickle`` or in an object database is a small subclass. +A :class:`~calendaring_sync.store.memory.MemoryStore` subclass can keep its data elsewhere, such as in a file with ``pickle``, by overriding ``_load()`` and ``_save()``. diff --git a/news/6.documentation.rst b/news/6.documentation.rst index 0b3a8be..59e1682 100644 --- a/news/6.documentation.rst +++ b/news/6.documentation.rst @@ -1 +1 @@ -Added the sync state design with a walk-through of one sync and state diagrams, a glossary of sync terms, an explanation of how each adapter maps its protocol onto the design, and a server compatibility reference. +Added a design page that walks through one sync with state diagrams, a page on how each adapter maps its protocol onto that design, a glossary of sync terms, and a server compatibility reference. diff --git a/news/7.feature.rst b/news/7.feature.rst index 6a01e4d..47d88db 100644 --- a/news/7.feature.rst +++ b/news/7.feature.rst @@ -1 +1 @@ -Added the sync state store. ``Replica`` ingests change sets into a ``MemoryStore`` or ``SQLiteStore`` and keeps each scope's token and records. Every change goes into a change feed your application reads from its own cursor. +Added :class:`~calendaring_sync.replica.Replica`, which keeps each scope's sync token and records in a :class:`~calendaring_sync.store.memory.MemoryStore` or :class:`~calendaring_sync.store.sqlite.SQLiteStore` and applies each change set in one transaction. Every change a sync makes goes into a change feed that your application reads from its own cursor. diff --git a/news/8.feature.rst b/news/8.feature.rst index 417d1ce..80c96a8 100644 --- a/news/8.feature.rst +++ b/news/8.feature.rst @@ -1 +1 @@ -Added local edits and conflict detection. ``Replica`` records a change, delete or create, finds records by object id or logical key, and stores a conflict where the server changed an object you also edited. An object's content is bytes, kept exactly as the server sent it. +Added local edits and conflict detection: :class:`~calendaring_sync.replica.Replica` records your changes, deletes and creates, and stores a conflict wherever the server changed an object you also edited. Content is bytes, kept exactly as the server sent it. diff --git a/news/9.feature.rst b/news/9.feature.rst index cdcefc3..7576bf4 100644 --- a/news/9.feature.rst +++ b/news/9.feature.rst @@ -1 +1 @@ -Added pushes and conflict resolution. A ``Session`` lists the edits ready to push, claims each one so no other process sends it, and records how the push ended; the next complete ingest settles a push whose answer never arrived. ``Replica.resolve`` and ``Replica.resolve_conflicts`` resolve conflicts with your edit, the server's side, merged content, or a delete. +Added pushes and conflict resolution: a :class:`~calendaring_sync.session.Session` claims each pending edit before it's sent, so no other process sends it at the same time, and records how the push ended. :meth:`Replica.resolve() ` and :meth:`Replica.resolve_conflicts() ` resolve a conflict with your edit, the server's side, merged content, or a delete. diff --git a/pyproject.toml b/pyproject.toml index 20fa831..2863474 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ authors = [{ name = "Sashank Bhamidi" }] dynamic = ["version"] keywords = ["calendar", "calendaring", "sync", "synchronization", "conflict-detection"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", "Operating System :: OS Independent", @@ -25,6 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Topic :: Office/Business :: Scheduling", "Topic :: Software Development :: Libraries :: Python Modules", ] @@ -34,19 +35,19 @@ dependencies = [] [project.optional-dependencies] dev = [ "pytest==9.1.1", - "ruff==0.16.10", + "ruff==0.17.2", "mypy==2.4.0", - "coverage==7.16.2", + "coverage==7.17.0", "pytest-cov==7.1.0", "reuse==6.2.0", "towncrier==26.9.0", - "pre-commit==4.6.2", + "pre-commit==4.7.0", "caldav==3.6.0", "icalendar==7.3.0", "lxml==6.1.3", "radicale==3.8.3", "xandikos==0.4.10", - "calendaring-jmap @ git+https://github.com/pycalendar/calendaring-jmap@21a3f25d55b393b821a4b74c6e93a4c6c1d701c2", + "calendaring-jmap @ git+https://github.com/PyCalendar/calendaring-jmap@21a3f25d55b393b821a4b74c6e93a4c6c1d701c2", "niquests==3.21.2", ] caldav = [ @@ -58,7 +59,7 @@ jmap = [ "calendaring-jmap>=2.0.0", ] docs = [ - "sphinx==9.1.0", + "sphinx==9.2.0", "pydata-sphinx-theme==0.23.0", "matplotlib==3.11.2", "sphinx-autobuild==2025.8.25", @@ -66,18 +67,19 @@ docs = [ "sphinx-design==0.7.0", "sphinx-issues==6.0.0", "sphinx-last-updated-by-git==0.3.8", + "sphinx-llms-txt==0.7.1", "sphinx-notfound-page==1.1.0", "sphinxcontrib-mermaid==2.1.1", "sphinxext-opengraph==0.13.0", "caldav==3.6.0", - "calendaring-jmap @ git+https://github.com/pycalendar/calendaring-jmap@21a3f25d55b393b821a4b74c6e93a4c6c1d701c2", + "calendaring-jmap @ git+https://github.com/PyCalendar/calendaring-jmap@21a3f25d55b393b821a4b74c6e93a4c6c1d701c2", ] [project.urls] -Repository = "https://github.com/pycalendar/calendaring-sync" -Issues = "https://github.com/pycalendar/calendaring-sync/issues" -Documentation = "https://calendaring-sync.readthedocs.io/en/latest/" -Changelog = "https://calendaring-sync.readthedocs.io/en/latest/changelog.html" +Repository = "https://github.com/PyCalendar/calendaring-sync" +Issues = "https://github.com/PyCalendar/calendaring-sync/issues" +Documentation = "https://calendaring-sync.readthedocs.io/en/stable/" +Changelog = "https://calendaring-sync.readthedocs.io/en/stable/changelog.html" [tool.hatch.version] source = "vcs" @@ -109,8 +111,14 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B"] +[tool.ruff.lint.per-file-ignores] +# Each block a docs page shows brings its own imports, after the setup it doesn't show. +"docs/examples/*.py" = ["E402"] + [tool.ruff.lint.isort] known-first-party = ["calendaring_sync"] +# The modules the docs examples share. +known-local-folder = ["memory_adapter", "start"] [tool.pytest.ini_options] testpaths = ["src/calendaring_sync/tests"] @@ -118,7 +126,7 @@ testpaths = ["src/calendaring_sync/tests"] [tool.towncrier] directory = "news/" filename = "docs/changelog.rst" -issue_format = "`#{issue} `_" +issue_format = "`#{issue} `_" start_string = ".. towncrier release notes start\n" title_format = "{version} ({project_date})" underlines = ["-", "~", "`"] diff --git a/src/calendaring_sync/__init__.py b/src/calendaring_sync/__init__.py index 3caee53..20efcf1 100644 --- a/src/calendaring_sync/__init__.py +++ b/src/calendaring_sync/__init__.py @@ -1,12 +1,14 @@ # SPDX-FileCopyrightText: 2026 calendaring-sync contributors # SPDX-License-Identifier: AGPL-3.0-or-later -""" -Calendar sync state. +"""Calendar sync state: sync tokens, object versions, change tracking, and conflict detection. -Tracks sync tokens and per-object versions across sync cycles, keeps a local -copy of synced calendar objects, and detects conflicts between local and remote -changes. +An application starts from :class:`~calendaring_sync.replica.Replica` +and a :term:`store`; an :term:`adapter`, or code of your own, tells them +what changed. +Import every public name from ``calendaring_sync`` itself, such as +``from calendaring_sync import Replica, SQLiteStore``, except the +adapters, which each come from their own module. """ from calendaring_sync._version import __version__ @@ -14,8 +16,13 @@ CalendaringSyncError, DuplicateObjectError, EditRefusedError, + FeedRangeError, IncompleteListingError, + ListingRequiredError, + LoneSurrogateError, + ObjectIdAssignedError, OpenConflictError, + OtherScopeError, PendingEditError, PushInFlightError, RecordValueError, @@ -36,12 +43,13 @@ UnknownHolderError, UnknownRecordError, UnknownScopeError, + UnknownVersionError, ) from calendaring_sync.objects.change import Change, Changed, Removed, Unchanged from calendaring_sync.objects.change_set import ChangeSet from calendaring_sync.objects.conflict import Conflict, ConflictKind from calendaring_sync.objects.dropped_scope import DroppedScope -from calendaring_sync.objects.edit import EditKind, LocalEdit +from calendaring_sync.objects.edit import EditFields, EditKind, LocalEdit from calendaring_sync.objects.feed import FeedEntry, FeedEntryKind from calendaring_sync.objects.push import ( Applied, @@ -54,14 +62,15 @@ from calendaring_sync.objects.record import Record, RecordState from calendaring_sync.objects.resolution import ( Delete, - KeepEdit, + KeepLocal, + KeepServer, Merge, Resolution, - TakeServer, UpdateHolder, ) from calendaring_sync.objects.scope import Scope -from calendaring_sync.replica import Replica, Session, SessionState +from calendaring_sync.replica import Replica +from calendaring_sync.session import Session, SessionState from calendaring_sync.store.base import StateStore from calendaring_sync.store.memory import MemoryStore from calendaring_sync.store.sqlite import SQLiteStore @@ -77,18 +86,25 @@ "Delete", "DroppedScope", "DuplicateObjectError", + "EditFields", "EditKind", "EditRefusedError", "FeedEntry", "FeedEntryKind", + "FeedRangeError", "Gone", "IncompleteListingError", - "KeepEdit", + "KeepLocal", + "KeepServer", + "ListingRequiredError", + "LoneSurrogateError", "LocalEdit", "LogicalKeyTaken", "MemoryStore", "Merge", + "ObjectIdAssignedError", "OpenConflictError", + "OtherScopeError", "PendingEditError", "PushInFlightError", "PushOutcome", @@ -113,7 +129,6 @@ "StoreBusyError", "StoreError", "StoreVersionError", - "TakeServer", "TransactionError", "TransactionPermissionError", "TransactionRequiredError", @@ -124,6 +139,7 @@ "UnknownHolderError", "UnknownRecordError", "UnknownScopeError", + "UnknownVersionError", "UpdateHolder", "__version__", ] diff --git a/src/calendaring_sync/_checks.py b/src/calendaring_sync/_checks.py index db29c9d..ab73636 100644 --- a/src/calendaring_sync/_checks.py +++ b/src/calendaring_sync/_checks.py @@ -20,6 +20,8 @@ from functools import wraps from typing import Any, TypeVar +from calendaring_sync.error import LoneSurrogateError + _T = TypeVar("_T") #: Built-in types a value must be exactly, not a subclass of: ``True`` is @@ -189,15 +191,25 @@ def check_types(obj: object) -> None: def checked_when_built(cls: type[_T]) -> type[_T]: """Check a value dataclass's field types every time one is built, before its own checks. - The class needs a ``__post_init__`` of its own, which runs after the check. + The class's own ``__post_init__``, if it has one, runs after the check. """ - original = cls.__dict__["__post_init__"] + original = cls.__dict__.get("__post_init__") + if original is None: + # A dataclass calls __post_init__ only if it had one when built. + init: Callable[..., None] = cls.__init__ + + def __init__(self: object, *args: Any, **kwargs: Any) -> None: + init(self, *args, **kwargs) + check_types(self) + + cls.__init__ = wraps(init)(__init__) # type: ignore[method-assign] + else: - def __post_init__(self: object) -> None: - check_types(self) - original(self) + def __post_init__(self: object) -> None: + check_types(self) + original(self) - cls.__post_init__ = __post_init__ # type: ignore[attr-defined] + cls.__post_init__ = __post_init__ # type: ignore[attr-defined] _CHECKED_WHEN_BUILT.add(cls) return cls @@ -261,15 +273,10 @@ def wrapper(*args: Any, **kwargs: Any) -> _T: def check_text(**values: str | Iterable[str] | None) -> None: - """Raise ``ValueError`` if a string holds a lone surrogate. + """Raise :class:`~calendaring_sync.error.LoneSurrogateError` if a string holds a lone surrogate. - Every store keeps any string exactly, empty strings and whitespace - included, except one kind. Python allows a ``str`` to hold a lone - surrogate, half of the pair UTF-16 uses for characters such as emoji, - for example ``"\\ud800"``. It's not a character, and UTF-8 can't encode - it, so a store that writes UTF-8 fails on it while one that keeps - Python objects doesn't. Refusing it where the string comes in keeps - every store the same. + The design page's lone surrogates section says why no store can keep + one; refusing it where the string comes in keeps every store the same. Args: values: Each value by its argument's name: a string, strings, or @@ -282,7 +289,7 @@ def check_text(**values: str | Iterable[str] | None) -> None: try: text.encode() except UnicodeEncodeError as exc: - raise ValueError( + raise LoneSurrogateError( f"{name} holds a lone surrogate at {exc.start}, which can't be stored" ) from exc @@ -296,9 +303,9 @@ def check_data( """Check the strings of a copy of an object with :func:`check_text`. Args: - fingerprint: Its fingerprint. - containers: Its containers. - logical_key: Its logical key. - values: Any other strings that come with it, by name. + fingerprint: The copy's fingerprint. + containers: The copy's containers. + logical_key: The copy's logical key. + values: Any other strings that come with the copy, by name. """ check_text(fingerprint=fingerprint, containers=containers, logical_key=logical_key, **values) diff --git a/src/calendaring_sync/_classify.py b/src/calendaring_sync/_classify.py index 9720ab7..1cace3e 100644 --- a/src/calendaring_sync/_classify.py +++ b/src/calendaring_sync/_classify.py @@ -6,6 +6,7 @@ from __future__ import annotations from enum import StrEnum +from typing import TypeAlias from calendaring_sync.objects._data import REMOTE, data from calendaring_sync.objects.change import Changed, Removed @@ -14,7 +15,7 @@ from calendaring_sync.objects.record import Record #: A copy of an object: the synced copy, a server's change, or a local edit. -Copy = Record | Changed | LocalEdit +Copy: TypeAlias = Record | Changed | LocalEdit class ChangeClass(StrEnum): diff --git a/src/calendaring_sync/adapters/__init__.py b/src/calendaring_sync/adapters/__init__.py index 7d73fe5..dd601c7 100644 --- a/src/calendaring_sync/adapters/__init__.py +++ b/src/calendaring_sync/adapters/__init__.py @@ -1,8 +1,10 @@ # SPDX-FileCopyrightText: 2026 calendaring-sync contributors # SPDX-License-Identifier: AGPL-3.0-or-later -"""Adapters, one per protocol, each in its own module behind its own extra. +"""The adapter base classes, and the adapters that ship with calendaring-sync. -Importing this package imports no protocol library; import an adapter's -module, such as :mod:`calendaring_sync.adapters.caldav`, to use it. +Each shipped adapter is a module of its own that needs the extra of the +same name. Importing this package imports no protocol's client library; +import the adapter's module, such as :mod:`calendaring_sync.adapters.caldav`, +to use it. """ diff --git a/src/calendaring_sync/adapters/_flow.py b/src/calendaring_sync/adapters/_flow.py deleted file mode 100644 index bf9352e..0000000 --- a/src/calendaring_sync/adapters/_flow.py +++ /dev/null @@ -1,340 +0,0 @@ -# SPDX-FileCopyrightText: 2026 calendaring-sync contributors -# SPDX-License-Identifier: AGPL-3.0-or-later - -"""One sync of one scope, written once for every protocol and for sync and async callers. - -A sync is a generator. Each step yields what a client call returned, which -is the answer itself for a synchronous client and a coroutine for an -asynchronous one, or an :class:`_InStore` for a call into the -:term:`store`. :func:`_run` drives the generator for synchronous callers; -:func:`_arun` awaits the coroutines and runs each store call in a worker -thread, so a long ingest doesn't block the event loop. An adapter -subclasses :class:`_Flow` and writes its protocol's steps as generators -too; the order of a sync, the session and the pushes live here. -""" - -from __future__ import annotations - -import asyncio -import concurrent.futures -import contextvars -import enum -import inspect -from abc import ABC, abstractmethod -from collections.abc import Callable, Generator, Mapping -from dataclasses import dataclass, field -from typing import Any, TypedDict, TypeVar -from urllib.parse import urlsplit, urlunsplit - -from calendaring_sync.objects.push import PushOutcome, Rejected -from calendaring_sync.objects.record import Record, RecordState -from calendaring_sync.objects.scope import Scope -from calendaring_sync.replica import Replica, Session - -_T = TypeVar("_T") - -# The records whose edit is waiting to be sent. -_PENDING = frozenset({RecordState.PENDING}) - -#: The steps of a sync: they yield client answers or :class:`_InStore` -#: calls, and are sent back what each one returned. -Steps = Generator[Any, Any, _T] - - -@dataclass(frozen=True) -class _InStore: - """A call into the store, which an asynchronous driver runs in a worker thread.""" - - call: Callable[..., Any] - args: tuple[Any, ...] = () - kwargs: Mapping[str, Any] = field(default_factory=dict) - - def run(self) -> Any: - return self.call(*self.args, **self.kwargs) - - -def _run(steps: Steps[_T]) -> _T: - """Drive the steps of a sync for a synchronous client.""" - sent: Any = None - failure: BaseException | None = None - while True: - try: - step = steps.send(sent) if failure is None else steps.throw(failure) - except StopIteration as done: - return done.value # type: ignore[no-any-return] - sent, failure = None, None - try: - sent = step.run() if isinstance(step, _InStore) else step - except BaseException as exc: # noqa: BLE001, thrown back into the steps - failure = exc - - -async def _arun(steps: Steps[_T]) -> _T: - """Drive the steps of a sync for an asynchronous client. - - A store call runs to its end in its worker thread before the steps hear - of a cancellation or of the driver being closed, so a session never ends - while one of its calls is still running. - """ - sent: Any = None - failure: BaseException | None = None - while True: - try: - step = steps.send(sent) if failure is None else steps.throw(failure) - except StopIteration as done: - return done.value # type: ignore[no-any-return] - sent, failure = None, None - call: concurrent.futures.Future[Any] | None = None - try: - if isinstance(step, _InStore): - call = _in_thread(step) - running = asyncio.wrap_future(call) - try: - sent = await asyncio.shield(running) - except asyncio.CancelledError: - await asyncio.wait([running]) - raise - else: - sent = await step - except GeneratorExit: - # Nothing can be awaited while closing, so the call is waited for here. - if call is not None: - _hand_over(steps, call) - steps.close() - raise - except BaseException as exc: # noqa: BLE001, thrown back into the steps - failure = exc - - -def _in_thread(step: _InStore) -> concurrent.futures.Future[Any]: - """Start a store call in a worker thread of the running loop.""" - result: concurrent.futures.Future[Any] = concurrent.futures.Future() - context = contextvars.copy_context() - - def run() -> None: - try: - result.set_result(context.run(step.run)) - except BaseException as exc: # noqa: BLE001, kept for the driver - result.set_exception(exc) - - asyncio.get_running_loop().run_in_executor(None, run) - return result - - -def _hand_over(steps: Steps[Any], call: concurrent.futures.Future[Any]) -> None: - """Give the steps what a store call they were waiting for ended with, when closing. - - The steps then know whether, for example, a session was entered and has - to end. Whatever they do next goes nowhere, since the driver is closing. - """ - try: - try: - outcome = call.result() - except BaseException as exc: # noqa: BLE001, thrown into the steps - following = steps.throw(exc) - else: - following = steps.send(outcome) - except BaseException: # noqa: BLE001, nobody is left to receive it - return - if inspect.iscoroutine(following): - following.close() - - -def _end(session_manager: Any, exc_info: tuple[Any, Any, Any]) -> Steps[None]: - """End a session as a store step, or right away when the driver closes first. - - A closing driver drops the step that follows the call it waited for, and - that step can be this one. - """ - try: - yield _InStore(session_manager.__exit__, exc_info) - except GeneratorExit: - session_manager.__exit__(*exc_info) - raise - - -def _url_id(url: str) -> str: - """A server URL in one spelling, for a default scope id. - - Scheme and host are case-insensitive (RFC 3986 section 6.2.2.1), so - they're lowercased, and a login is left out, so it never reaches a - store. An explicit port is kept as a number, even the scheme's default. - """ - parts = urlsplit(url) - host = parts.hostname or "" - if ":" in host: - host = f"[{host}]" - netloc = host if parts.port is None else f"{host}:{parts.port}" - return urlunsplit(parts._replace(scheme=parts.scheme.lower(), netloc=netloc)) - - -class _EditFields(TypedDict): - """What an edit records beside its content, read from the content as a sync reads it.""" - - fingerprint: str - containers: frozenset[str] - logical_key: str | None - - -class _Read(enum.Enum): - """How the read part of a sync ended.""" - - #: Every change was ingested, so the pushes can go out. - COMPLETE = "complete" - #: The server cut the changes short; the next sync continues from the checkpoint. - TRUNCATED = "truncated" - #: The server refused the scope's token, so the scope needs a full listing. - INVALID_TOKEN = "invalid token" - - -class _Flow(ABC): - """The order of one sync, the same for every protocol. - - A sync opens a :term:`session`, reads what changed and ingests it, then - pushes the pending edits. When the server refuses the scope's token, - the scope is :term:`reset` and listed in a new session. Recording an - edit reads its fields from the content as a sync would. A subclass - sets the session options and :attr:`_guard_covers_scope`, and writes - :meth:`_read`, :meth:`_send`, :meth:`_waits`, :meth:`_new_object_id` - and :meth:`_edit_fields` for its protocol. - """ - - #: The name of the adapter's fingerprint scheme. - _fingerprint_scheme: str - #: The media type of the scope's content. - _media_type: str - #: Whether a push only applies if the object still has the version it was based on. - _guarded_by_version: bool - #: Whether no two objects in the scope can share a logical key. - _unique_logical_key: bool - #: Whether the server checks a push against the whole scope rather than - #: the object, so one rejection means every later push would be rejected - #: too, and they wait for the next sync instead. - _guard_covers_scope: bool - - def __init__(self, replica: Replica, scope_id: str) -> None: - self._replica = replica - self._scope_id = scope_id - - @property - def scope_id(self) -> str: - """The :term:`scope` this adapter syncs.""" - return self._scope_id - - def _create(self, content: bytes) -> Steps[int]: - """Record a create of an object with this content, and return its local key.""" - fields = self._edit_fields(content, create=True) - key: int = yield _InStore( - self._replica.create, - (self._scope_id, content), - {**fields, "object_id": self._new_object_id()}, - ) - return key - - def _change(self, local_key: int, content: bytes) -> Steps[None]: - """Record a change of a record in the scope to this content. - - A change to a create that wasn't sent stays a create, so its content - must pass what a create's does. - """ - fields = self._edit_fields(content, create=False) - # A record never changes scope or stops being a create before it's - # sent, so these hold when the change runs. - record: Record | None = yield _InStore(self._replica.record, (local_key,)) - if record is not None and record.scope != self._scope_id: - raise ValueError(f"Record {local_key} is in scope {record.scope!r}, not this one") - if record is not None and record.is_create: - fields = self._edit_fields(content, create=True) - yield _InStore(self._replica.change, (local_key, content), fields) - - def _waiting(self) -> Steps[list[Record]]: - """The pending edits the adapter won't send yet.""" - pending: list[Record] = yield _InStore( - self._replica.records, (self._scope_id,), {"states": _PENDING} - ) - return [r for r in pending if self._waits(r)] - - def _sync(self, *, full_listing: bool) -> Steps[None]: - """One sync: read and ingest, then push; a refused token resets and lists.""" - read = yield from self._in_session( - lambda session: self._sync_session(session, full_listing) - ) - if read is _Read.INVALID_TOKEN: - yield _InStore(self._replica.reset, (self._scope_id,)) - yield from self._in_session(lambda session: self._sync_session(session, True)) - - def _sync_session(self, session: Session, full_listing: bool) -> Steps[_Read]: - scope: Scope = yield _InStore(self._replica.scope, (self._scope_id,)) - read = yield from self._read(session, scope, full_listing=full_listing) - if read is _Read.COMPLETE: - yield from self._push(session) - return read - - def _in_session(self, steps: Callable[[Session], Steps[_T]]) -> Steps[_T]: - """Run ``steps`` inside a session of the scope, which ends whatever happens.""" - session_manager = self._replica.session( - self._scope_id, - fingerprint_scheme=self._fingerprint_scheme, - media_type=self._media_type, - guarded_by_version=self._guarded_by_version, - unique_logical_key=self._unique_logical_key, - ) - session: Session = yield _InStore(session_manager.__enter__) - try: - result = yield from steps(session) - except GeneratorExit: - # A closing driver runs no more steps, so the session ends here. - session_manager.__exit__(None, None, None) - raise - except BaseException as exc: - yield from _end(session_manager, (type(exc), exc, exc.__traceback__)) - raise - yield from _end(session_manager, (None, None, None)) - return result - - def _push(self, session: Session) -> Steps[None]: - """Push every pending edit, each claimed, sent and settled on its own. - - Where the server checks a push against the whole scope, the pushes - stop at the first rejection, and the rest stay pending, unclaimed. - """ - records: list[Record] = yield _InStore(session.to_push) - for record in records: - if self._waits(record): - continue - object_id = self._new_object_id() if record.object_id is None else None - claimed: Record | None = yield _InStore( - session.claim, (record.local_key,), {"object_id": object_id} - ) - if claimed is None: - continue - # A send that raises leaves the push in flight, since the request - # may have reached the server; the next complete ingest settles it. - outcome = yield from self._send(claimed) - yield _InStore(session.settle, (claimed, outcome)) - if self._guard_covers_scope and isinstance(outcome, Rejected): - return - - @abstractmethod - def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[_Read]: - """Fetch what changed in the scope and ingest it, as one change set or listing pages.""" - - @abstractmethod - def _send(self, claimed: Record) -> Steps[PushOutcome]: - """Send one claimed edit and return how it ended.""" - - @abstractmethod - def _waits(self, record: Record) -> bool: - """Whether a pending edit can't be sent yet and waits for a later sync.""" - - @abstractmethod - def _new_object_id(self) -> str | None: - """An object id for a create that has none, or ``None`` where the server picks it.""" - - @abstractmethod - def _edit_fields(self, content: bytes, *, create: bool) -> _EditFields: - """The fields of an edit to this content, a create or a change. - - Raises: - ValueError: If the content can't be an object of the scope. - """ diff --git a/src/calendaring_sync/adapters/base.py b/src/calendaring_sync/adapters/base.py new file mode 100644 index 0000000..9f95e11 --- /dev/null +++ b/src/calendaring_sync/adapters/base.py @@ -0,0 +1,533 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""The base classes every :term:`adapter` is built on. + +:class:`Adapter` holds the order of a sync, written once for every +protocol: open a :term:`session`, read and :term:`ingest` what changed, +then :term:`push` the pending edits. An adapter subclasses it, fills in +how its protocol reads and sends, and adds :class:`SyncAdapter` or +:class:`AsyncAdapter` for the public methods. + +Both variants run the same steps. A step is a generator that yields what +a client call returned, the answer itself from a synchronous client or a +coroutine from an asynchronous one, or an :class:`InStore` for a call into +the :term:`store`, and gets each result sent back. :class:`SyncAdapter` +runs the steps as they come; :class:`AsyncAdapter` awaits the coroutines +and runs the store calls as its description says. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import contextvars +import enum +import inspect +from abc import ABC, abstractmethod +from collections.abc import Callable, Generator, Mapping +from dataclasses import dataclass, field +from typing import Any, TypeVar +from urllib.parse import urlsplit, urlunsplit + +from calendaring_sync._checks import check_argument_types +from calendaring_sync.error import OtherScopeError +from calendaring_sync.objects.edit import EditFields +from calendaring_sync.objects.push import PushOutcome, Rejected +from calendaring_sync.objects.record import Record, RecordState +from calendaring_sync.objects.scope import Scope +from calendaring_sync.replica import Replica +from calendaring_sync.session import Session + +_T = TypeVar("_T") + +# The records whose edit is waiting to be sent. +_PENDING = frozenset({RecordState.PENDING}) + +#: The steps of a sync, as a generator that yields client answers or +#: :class:`InStore` calls, is sent back what each one returned, and returns +#: its result. +Steps = Generator[Any, Any, _T] + + +@dataclass(frozen=True) +class InStore: + """A call into the store, which an asynchronous adapter runs in a worker thread. + + Attributes: + call: The function to call, such as ``session.ingest``. + args: Its positional arguments. + kwargs: Its keyword arguments. + + Examples: + A step that ingests a change set:: + + yield InStore(session.ingest, (change_set,)) + """ + + call: Callable[..., Any] + args: tuple[Any, ...] = () + kwargs: Mapping[str, Any] = field(default_factory=dict) + + def run(self) -> Any: + """Make the call and return what it returned.""" + return self.call(*self.args, **self.kwargs) + + +class ReadResult(enum.Enum): + """How the read part of a sync ended, which decides what the sync does next.""" + + #: Every change was ingested, so the pushes go out. + COMPLETE = "complete" + #: The server cut the changes short. The pushes wait, and the next sync + #: continues from the :term:`checkpoint`. + TRUNCATED = "truncated" + #: The server refused the scope's :term:`sync token`, so the scope is + #: :term:`reset` and listed in full. + INVALID_TOKEN = "invalid token" + + +def _run(steps: Steps[_T]) -> _T: + """Drive the steps of a sync for a synchronous client.""" + sent: Any = None + failure: BaseException | None = None + while True: + try: + step = steps.send(sent) if failure is None else steps.throw(failure) + except StopIteration as done: + return done.value # type: ignore[no-any-return] + sent, failure = None, None + try: + sent = step.run() if isinstance(step, InStore) else step + except BaseException as exc: # noqa: BLE001, thrown back into the steps + failure = exc + + +async def _arun(steps: Steps[_T]) -> _T: + """Drive the steps of a sync for an asynchronous client. + + A store call runs to its end in its worker thread before the steps hear + of a cancellation or of the driver being closed, so a session never ends + while one of its calls is still running. + """ + sent: Any = None + failure: BaseException | None = None + while True: + try: + step = steps.send(sent) if failure is None else steps.throw(failure) + except StopIteration as done: + return done.value # type: ignore[no-any-return] + sent, failure = None, None + call: concurrent.futures.Future[Any] | None = None + try: + if isinstance(step, InStore): + call = _in_thread(step) + running = asyncio.wrap_future(call) + try: + sent = await asyncio.shield(running) + except asyncio.CancelledError: + await asyncio.wait([running]) + raise + else: + sent = await step + except GeneratorExit: + # Nothing can be awaited while closing, so the call is waited for here. + if call is not None: + _hand_over(steps, call) + steps.close() + raise + except BaseException as exc: # noqa: BLE001, thrown back into the steps + failure = exc + + +def _in_thread(step: InStore) -> concurrent.futures.Future[Any]: + """Start a store call in a worker thread of the running loop.""" + result: concurrent.futures.Future[Any] = concurrent.futures.Future() + context = contextvars.copy_context() + + def run() -> None: + try: + result.set_result(context.run(step.run)) + except BaseException as exc: # noqa: BLE001, kept for the driver + result.set_exception(exc) + + asyncio.get_running_loop().run_in_executor(None, run) + return result + + +def _hand_over(steps: Steps[Any], call: concurrent.futures.Future[Any]) -> None: + """Give the steps what a store call they were waiting for ended with, when closing. + + The steps then know whether, for example, a session was entered and has + to end. Whatever they do next goes nowhere, since the driver is closing. + """ + try: + try: + outcome = call.result() + except BaseException as exc: # noqa: BLE001, thrown into the steps + following = steps.throw(exc) + else: + following = steps.send(outcome) + except BaseException: # noqa: BLE001, nobody is left to receive it + return + if inspect.iscoroutine(following): + following.close() + + +def _end(session_manager: Any, exc_info: tuple[Any, Any, Any]) -> Steps[None]: + """End a session as a store step, or right away when the driver closes first. + + A closing driver drops the step that follows the call it waited for, and + that step can be this one. + """ + try: + yield InStore(session_manager.__exit__, exc_info) + except GeneratorExit: + session_manager.__exit__(*exc_info) + raise + + +def _url_id(url: str) -> str: + """A server URL in one spelling, for a default scope id. + + Scheme and host are case-insensitive (RFC 3986 section 6.2.2.1), so + they're lowercased, and a login is left out, so it never reaches a + store. An explicit port is kept as a number, even the scheme's default. + """ + parts = urlsplit(url) + host = parts.hostname or "" + if ":" in host: + host = f"[{host}]" + netloc = host if parts.port is None else f"{host}:{parts.port}" + return urlunsplit(parts._replace(scheme=parts.scheme.lower(), netloc=netloc)) + + +@check_argument_types +class Adapter(ABC): + """Syncs one :term:`scope` with a :class:`~calendaring_sync.replica.Replica`, for any protocol. + + A sync opens a :term:`session`, reads what changed on the server and + ingests it, then pushes the pending edits one at a time: claimed, sent, + settled. When the server refuses the scope's :term:`sync token`, the + scope is :term:`reset` and listed in full in a new session. An adapter + subclasses it together with :class:`SyncAdapter` or + :class:`AsyncAdapter`, which add the public methods. + + A subclass sets these class attributes, which open each session: + + - ``_fingerprint_scheme``: the name of the adapter's :term:`fingerprint scheme`. + - ``_media_type``: the media type of the scope's :term:`content`. + - ``_guarded_by_version``: ``True`` if a push applies only while the + object still has the :term:`version` the edit was based on. + - ``_unique_logical_key``: ``True`` if no two objects in the scope can + share a :term:`logical key`. + - ``_guard_covers_scope``: ``True`` if the server checks a push against + the whole scope rather than the object, so after one rejection every + later push would be rejected too, and they wait for the next sync. + + It writes these methods for its protocol, each a generator of + :data:`Steps` except :meth:`_waits`, :meth:`_new_object_id` and + :meth:`_edit_fields`: + + - :meth:`_read` fetches what changed and ingests it. + - :meth:`_send` sends one claimed edit. + - :meth:`_waits` says whether a pending edit can't be sent yet. + - :meth:`_new_object_id` picks the :term:`object id` of a create. + - :meth:`_edit_fields` reads an edit's fields from its content. + + Args: + replica: Where the sync state is kept. + scope_id: The scope's id. + """ + + _fingerprint_scheme: str + _media_type: str + _guarded_by_version: bool + _unique_logical_key: bool + _guard_covers_scope: bool + + def __init__(self, replica: Replica, scope_id: str) -> None: + self._replica = replica + self._scope_id = scope_id + + @property + def scope_id(self) -> str: + """The :term:`scope` this adapter syncs.""" + return self._scope_id + + @property + def needs_full_listings(self) -> bool: + """Whether the server can lose changes without refusing the scope's token. + + If it's ``True``, an incremental sync may miss a change or a + removal, so schedule a sync with ``full_listing=True`` now and then, + such as once a day. ``False`` by default; an adapter whose server + can lose changes says so. + """ + return False + + def edit_fields(self, content: bytes) -> EditFields: + """Read an object's fields from its content, the same way a sync reads the server's copy. + + Use it for content your application made itself, such as a + :class:`~calendaring_sync.objects.resolution.Merge`. Reads nothing + from the store or the server. + + Raises: + ValueError: If the content can't be an object of the scope, as + for a create. + """ + return self._edit_fields(content, create=True) + + def _create(self, content: bytes) -> Steps[int]: + """Record a create of an object with this content, and return its local key.""" + fields = self._edit_fields(content, create=True) + key: int = yield InStore( + self._replica.create, + (self._scope_id, content), + {**vars(fields), "object_id": self._new_object_id()}, + ) + return key + + def _change(self, local_key: int, content: bytes) -> Steps[None]: + """Record a change of a record in the scope to this content. + + A change to a create that wasn't sent stays a create, so its content + must pass what a create's does. + """ + fields = self._edit_fields(content, create=False) + # A record never changes scope or stops being a create before it's + # sent, so these hold when the change runs. + record: Record | None = yield InStore(self._replica.record, (local_key,)) + if record is not None and record.scope != self._scope_id: + raise OtherScopeError(f"Record {local_key} is in scope {record.scope!r}, not this one") + if record is not None and record.is_create: + fields = self._edit_fields(content, create=True) + yield InStore(self._replica.change, (local_key, content), vars(fields)) + + def _waiting(self) -> Steps[list[Record]]: + """The pending edits the adapter won't send yet.""" + pending: list[Record] = yield InStore( + self._replica.records, (self._scope_id,), {"states": _PENDING} + ) + return [r for r in pending if self._waits(r)] + + def _sync(self, *, full_listing: bool) -> Steps[None]: + """One sync: read and ingest, then push; a refused token resets and lists.""" + read = yield from self._in_session( + lambda session: self._sync_session(session, full_listing) + ) + if read is ReadResult.INVALID_TOKEN: + yield InStore(self._replica.reset, (self._scope_id,)) + yield from self._in_session(lambda session: self._sync_session(session, True)) + + def _sync_session(self, session: Session, full_listing: bool) -> Steps[ReadResult]: + scope: Scope = yield InStore(self._replica.scope, (self._scope_id,)) + read = yield from self._read(session, scope, full_listing=full_listing) + if read is ReadResult.COMPLETE: + yield from self._push(session) + return read + + def _in_session(self, steps: Callable[[Session], Steps[_T]]) -> Steps[_T]: + """Run ``steps`` inside a session of the scope, which ends whatever happens.""" + session_manager = self._replica.session( + self._scope_id, + fingerprint_scheme=self._fingerprint_scheme, + media_type=self._media_type, + guarded_by_version=self._guarded_by_version, + unique_logical_key=self._unique_logical_key, + ) + session: Session = yield InStore(session_manager.__enter__) + try: + result = yield from steps(session) + except GeneratorExit: + # A closing driver runs no more steps, so the session ends here. + session_manager.__exit__(None, None, None) + raise + except BaseException as exc: + yield from _end(session_manager, (type(exc), exc, exc.__traceback__)) + raise + yield from _end(session_manager, (None, None, None)) + return result + + def _push(self, session: Session) -> Steps[None]: + """Push every pending edit, each claimed, sent and settled on its own. + + Where the server checks a push against the whole scope, the pushes + stop at the first rejection, and the rest stay pending, unclaimed. + """ + records: list[Record] = yield InStore(session.to_push) + for record in records: + if self._waits(record): + continue + object_id = self._new_object_id() if record.object_id is None else None + claimed: Record | None = yield InStore( + session.claim, (record.local_key,), {"object_id": object_id} + ) + if claimed is None: + continue + outcome = yield from self._send(claimed) + yield InStore(session.settle, (claimed, outcome)) + if self._guard_covers_scope and isinstance(outcome, Rejected): + return + + @abstractmethod + def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[ReadResult]: + """Fetch what changed in the scope, ingest it, and say how the read ended. + + Without a token in ``scope.token``, or with ``full_listing``, list + every object; otherwise ask for the changes since the token. Ingest + each :class:`~calendaring_sync.objects.change_set.ChangeSet` with + ``yield InStore(session.ingest, (change_set,))``. + + Args: + session: The open session to ingest into. + scope: The scope as the store holds it. + full_listing: Whether the caller asked for a full listing. + + Returns: + :attr:`ReadResult.INVALID_TOKEN` if the server refused the + token, before anything was ingested; :attr:`ReadResult.TRUNCATED` + if the server cut the changes short; :attr:`ReadResult.COMPLETE` + otherwise. + """ + + @abstractmethod + def _send(self, claimed: Record) -> Steps[PushOutcome]: + """Send one claimed edit and return how it ended. + + An error raised here reaches the caller and leaves the push in + flight, since the request may have reached the server; the next + complete ingest settles it. + + Args: + claimed: The record :meth:`~calendaring_sync.session.Session.claim` + returned, holding the edit to send. + """ + + @abstractmethod + def _waits(self, record: Record) -> bool: + """Whether a pending edit can't be sent yet, so it waits for a later sync. + + Args: + record: A record with a pending edit. + """ + + @abstractmethod + def _new_object_id(self) -> str | None: + """Return an object id for a create, or ``None`` if the server picks it.""" + + @abstractmethod + def _edit_fields(self, content: bytes, *, create: bool) -> EditFields: + """Read an edit's fields from its content, the same way a sync reads the server's copy. + + Args: + content: The edit's content. + create: Whether the edit is a create, which may need more of + the content, such as a logical key. + + Raises: + ValueError: If the content can't be an object of the scope. + """ + + +@check_argument_types +class SyncAdapter(Adapter): + """The public methods of an adapter for a synchronous client. + + Your application calls :meth:`sync` when it decides to sync, and + records its edits with :meth:`create` and :meth:`change`. Deletes and + discards need nothing from the content, so they go to + :meth:`~calendaring_sync.replica.Replica.delete` and + :meth:`~calendaring_sync.replica.Replica.discard`. The adapter's class + says what content it takes and which of its client's errors reach you; + the next sync settles a push such an error interrupted. Every method + also raises the store's errors, as + :class:`~calendaring_sync.replica.Replica` describes. + """ + + def sync(self, *, full_listing: bool = False) -> None: + """Sync the scope: ingest what changed on the server, then push your edits. + + A scope with no token is listed in full. When the server refuses the + token, the scope is :term:`reset` and listed in full. Your edits are + pushed only once everything the server reported was ingested. Each + step runs in a store transaction of its own. + + Args: + full_listing: List every object instead of asking for changes. + + Raises: + ScopeGoneError: If the server no longer has the scope. + IncompleteListingError: If the server cut a full listing short. + StaleChangeSetError: If another process ingested the scope first. + SessionError: If this replica already has a session open for the scope. + ScopeMismatchError: If the scope's content has another media type. + TransactionError: If a store transaction is open in this thread. + """ + _run(self._sync(full_listing=full_listing)) + + def create(self, content: bytes) -> int: + """Record a create of an object with this content, and return its local key. + + The next :meth:`sync` sends it. Runs in a write transaction. + + Raises: + ValueError: If the content can't be an object of the scope. + UnknownScopeError: If the scope was never synced. + TransactionError: If a store transaction is open in this thread. + """ + return _run(self._create(content)) + + def change(self, local_key: int, content: bytes) -> None: + """Record a change of an object to this content; the next :meth:`sync` sends it. + + Reads the record, then writes the change, each in a transaction of + its own. A change to a create that wasn't sent yet stays a create, + so the content must pass what a create's does. + + Raises: + ValueError: If the content can't be an object of the scope. + OtherScopeError: If the record is in another scope. + UnknownRecordError: If no record has this local key. + EditRefusedError: If the record can't take an edit, such as one + with an open conflict. + TransactionError: If a store transaction is open in this thread. + """ + _run(self._change(local_key, content)) + + def waiting(self) -> list[Record]: + """Return the pending edits the next :meth:`sync` won't send, because it can't yet. + + Runs in a read transaction. + + Raises: + UnknownScopeError: If the scope was never synced. + TransactionError: If a store transaction is open in this thread. + """ + return _run(self._waiting()) + + +@check_argument_types +class AsyncAdapter(Adapter): + """The public methods of an adapter for an asynchronous client. + + The same as :class:`SyncAdapter`'s, as coroutines. Each store call runs + in a worker thread, so a long ingest doesn't block the event loop. + """ + + async def sync(self, *, full_listing: bool = False) -> None: + """See :meth:`~calendaring_sync.adapters.base.SyncAdapter.sync`.""" + await _arun(self._sync(full_listing=full_listing)) + + async def create(self, content: bytes) -> int: + """See :meth:`~calendaring_sync.adapters.base.SyncAdapter.create`.""" + return await _arun(self._create(content)) + + async def change(self, local_key: int, content: bytes) -> None: + """See :meth:`~calendaring_sync.adapters.base.SyncAdapter.change`.""" + await _arun(self._change(local_key, content)) + + async def waiting(self) -> list[Record]: + """See :meth:`~calendaring_sync.adapters.base.SyncAdapter.waiting`.""" + return await _arun(self._waiting()) diff --git a/src/calendaring_sync/adapters/caldav.py b/src/calendaring_sync/adapters/caldav.py index 34b615f..2dc5c8b 100644 --- a/src/calendaring_sync/adapters/caldav.py +++ b/src/calendaring_sync/adapters/caldav.py @@ -3,7 +3,7 @@ """The CalDAV :term:`adapter`, which syncs one calendar collection through python-caldav. -Install it with the ``caldav`` extra: ``pip install 'calendaring-sync[caldav]'``. +It needs the ``caldav`` extra, as :doc:`/how-to/install` shows. :doc:`/explanation/adapters` explains every rule it follows, and :doc:`/reference/server-compatibility` how each tested server behaves. """ @@ -32,29 +32,29 @@ ) from exc from calendaring_sync._checks import check_argument_types -from calendaring_sync.adapters._flow import ( +from calendaring_sync.adapters.base import ( + Adapter, + AsyncAdapter, + InStore, + ReadResult, Steps, - _arun, - _EditFields, - _Flow, - _InStore, - _Read, - _run, + SyncAdapter, _url_id, ) from calendaring_sync.error import IncompleteListingError, ScopeGoneError from calendaring_sync.objects.change import Change, Changed, Removed, Unchanged from calendaring_sync.objects.change_set import ChangeSet -from calendaring_sync.objects.edit import EditKind, LocalEdit +from calendaring_sync.objects.edit import EditFields, EditKind, LocalEdit from calendaring_sync.objects.push import Applied, Gone, LogicalKeyTaken, PushOutcome, Rejected from calendaring_sync.objects.record import Record from calendaring_sync.objects.scope import Scope -from calendaring_sync.replica import Replica, Session +from calendaring_sync.replica import Replica +from calendaring_sync.session import Session #: The name of the canonical fingerprint this adapter computes. A change to #: how it's computed gets a new name, so every scope is listed again. FINGERPRINT_SCHEME = "caldav-canonical-1" -#: The media type of a CalDAV scope's content: iCalendar data. +#: The media type of a CalDAV scope's content, iCalendar data. MEDIA_TYPE = "text/calendar" #: How many objects one ``calendar-multiget`` fetches, and one page of a #: :term:`full listing` holds. @@ -166,7 +166,7 @@ def _chunks(items: Sequence[str], size: int) -> Iterator[Sequence[str]]: yield items[start : start + size] -class _CalDAVFlow(_Flow): +class _CalDAVFlow(Adapter): """Everything the sync and async CalDAV adapters share.""" _fingerprint_scheme = FINGERPRINT_SCHEME @@ -200,9 +200,10 @@ def needs_full_listings(self) -> bool: """Whether the server may leave changes out without refusing the scope's token. Read from the server profile configured on the client, python-caldav's - ``sync-token.invalid-token`` feature. When it's true, schedule regular - full listings with ``sync(full_listing=True)``: a listing only fetches - objects whose ETag changed, so it costs little more than a sync. + ``sync-token.invalid-token`` feature; + :attr:`Adapter.needs_full_listings + ` says + what to do when it's ``True``. """ features = self._client.features if features is None: @@ -223,15 +224,11 @@ def _new_object_id(self) -> str: # back spelled another way. return self._object_id(f"{uuid.uuid4()}.ics") - def _edit_fields(self, content: bytes, *, create: bool) -> _EditFields: + def _edit_fields(self, content: bytes, *, create: bool) -> EditFields: fields = _fields(content) if create and fields.logical_key is None: raise ValueError("Calendar data must hold an object with a UID") - return { - "fingerprint": fields.fingerprint, - "containers": self._containers, - "logical_key": fields.logical_key, - } + return EditFields(fields.fingerprint, self._containers, fields.logical_key) def _ask(self, request: Any) -> Steps[Any]: """Send one request about the collection; a collection that's gone raises. @@ -244,22 +241,22 @@ def _ask(self, request: Any) -> Steps[Any]: except dav_error.NotFoundError as exc: raise ScopeGoneError(f"The server has no calendar at {self._url}") from exc - def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[_Read]: + def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[ReadResult]: # A collection that names no token (one that supports the report # must, RFC 6578 section 4) is listed every time too. if full_listing or not scope.token or not (yield from self._supports_changes()): yield from self._list(session, scope) - return _Read.COMPLETE + return ReadResult.COMPLETE try: found = yield from self._ask( lambda: self._calendar.get_objects_by_sync_token(scope.token, disable_fallback=True) ) except dav_error.DAVError as exc: if _VALID_SYNC_TOKEN in exc.preconditions: - return _Read.INVALID_TOKEN + return ReadResult.INVALID_TOKEN raise removed = {self._object_id(url) for url in found.deleted_urls} - known: dict[str, str] = yield _InStore(self._replica.known_versions, (self.scope_id,)) + known: dict[str, str] = yield InStore(self._replica.known_versions, (self.scope_id,)) # A member reported with the ETag already stored hasn't changed since, # such as one this sync already fetched or that came back twice. reported = ((self._object_id(obj.url), obj.etag) for obj in found.objects) @@ -282,8 +279,8 @@ def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[ change_set = ChangeSet( self.scope_id, scope.token, token, changes, truncated=found.truncated ) - yield _InStore(session.ingest, (change_set,)) - return _Read.TRUNCATED if found.truncated else _Read.COMPLETE + yield InStore(session.ingest, (change_set,)) + return ReadResult.TRUNCATED if found.truncated else ReadResult.COMPLETE def _supports_changes(self) -> Steps[bool]: """Whether the collection lists ``sync-collection`` in its supported reports.""" @@ -318,7 +315,7 @@ def _list(self, session: Session, scope: Scope) -> Steps[None]: self._object_id(entry.href): entry.properties.get(dav.GetEtag.tag) for entry in response.parse_propfind() } - known: dict[str, str] = yield _InStore(self._replica.known_versions, (self.scope_id,)) + known: dict[str, str] = yield InStore(self._replica.known_versions, (self.scope_id,)) pages = list(_chunks(list(versions), MULTIGET_SIZE)) or [()] for number, page in enumerate(pages, start=1): changes: dict[str, Change] = { @@ -335,7 +332,7 @@ def _list(self, session: Session, scope: Scope) -> Steps[None]: full_listing=True, truncated=number < len(pages), ) - yield _InStore(session.ingest, (change_set,)) + yield InStore(session.ingest, (change_set,)) def _fetch(self, object_ids: Iterable[str]) -> Steps[dict[str, Changed]]: """Fetch objects' content and ETags together, a multiget at a time. @@ -452,32 +449,18 @@ def _holder(self, refusal: Any) -> str | None: @check_argument_types -class CalDAVAdapter(_CalDAVFlow): - """Syncs one calendar collection with a :class:`~calendaring_sync.replica.Replica`. - - The calendar is a python-caldav ``Calendar`` on a synchronous client, - and it's one :term:`scope`. Your application calls :meth:`sync` when it - decides to sync, and records its edits with :meth:`create` and - :meth:`change`, which build an object's fingerprint and logical key the - same way a sync does. Deletes and discards need nothing from the - content, so they go to :meth:`~calendaring_sync.replica.Replica.delete` - and :meth:`~calendaring_sync.replica.Replica.discard`. - - With no server profile on the client, python-caldav searches the - calendar for a create's ``UID`` before sending it. Configuring the - client's ``features`` (``features="radicale"``, for example) lets the - server check instead, in the same request. +class CalDAVAdapter(_CalDAVFlow, SyncAdapter): + """Syncs one calendar collection through a synchronous python-caldav client. - A change or delete of an object whose stored ETag is weak (``W/``) - waits, unsent, until a sync stores a strong one, since ``If-Match`` - never matches a weak ETag; :meth:`waiting` lists such edits. - - Every method also raises the store's errors, as - :class:`~calendaring_sync.replica.Replica` describes. + The calendar is one :term:`scope`. The content :meth:`create` and + :meth:`change` take is iCalendar data in UTF-8, and a create's must + hold an object with a ``UID``; anything else raises + :class:`ValueError`. A failure python-caldav reports, such as a refused + login, reaches you as its ``caldav.lib.error.DAVError``, unchanged. Args: replica: Where the sync state is kept. - calendar: The calendar collection to sync. + calendar: The calendar to sync. scope_id: The scope's id. Defaults to the calendar's URL, without a login and spelled one way, so calendars on two servers never share a scope and one calendar always gets the same one. @@ -503,82 +486,13 @@ def __init__( ) -> None: super().__init__(replica, calendar, scope_id) - def sync(self, *, full_listing: bool = False) -> None: - """Sync the calendar: ingest what changed on the server, then push your edits. - - Changes since the scope's token come from a ``sync-collection`` - report, and a calendar that has none, or a scope that has no token, - is listed in full instead. When the server refuses the token, the - scope is :term:`reset` and listed in full. Your edits are pushed only - once everything the server reported was ingested. Each step runs in - a store transaction of its own. - - Args: - full_listing: List every object instead of asking for changes. - Members whose ETag didn't change aren't fetched. - - Raises: - ScopeGoneError: If the server has no calendar at its URL. - IncompleteListingError: If the server cut a full listing short. - StaleChangeSetError: If another process ingested the scope first. - SessionError: If this replica already has a session open for the scope. - ScopeMismatchError: If the scope's content has another media type. - TransactionError: If a store transaction is open in this thread. - caldav.lib.error.DAVError: For any other failure python-caldav - reports, such as a refused login, unchanged. A push it - interrupted is settled by the next sync. - """ - _run(self._sync(full_listing=full_listing)) - - def create(self, content: bytes) -> int: - """Record a create of an object with this iCalendar data, and return its local key. - - The object gets an href of its own; the next :meth:`sync` sends it. - Runs in a write transaction. - - Raises: - ValueError: If the content isn't UTF-8 iCalendar data with a ``UID``. - UnknownScopeError: If the calendar was never synced. - TransactionError: If a store transaction is open in this thread. - """ - return _run(self._create(content)) - - def change(self, local_key: int, content: bytes) -> None: - """Record a change of an object to this iCalendar data; the next :meth:`sync` sends it. - - Reads the record, then writes the change, each in a transaction of its own. - - Raises: - ValueError: If the content isn't UTF-8 iCalendar data, the - record is in another scope, or the record is a create not - sent yet and the content holds no ``UID``. - UnknownRecordError: If no record has this local key. - EditRefusedError: If the record can't take an edit, such as one - with an open conflict. - TransactionError: If a store transaction is open in this thread. - """ - _run(self._change(local_key, content)) - - def waiting(self) -> list[Record]: - """Return the pending edits the next :meth:`sync` won't send, and can't yet. - - That's a change or delete of an object whose stored ETag is weak. - Runs in a read transaction. - - Raises: - UnknownScopeError: If the calendar was never synced. - TransactionError: If a store transaction is open in this thread. - """ - return _run(self._waiting()) - @check_argument_types -class AsyncCalDAVAdapter(_CalDAVFlow): +class AsyncCalDAVAdapter(_CalDAVFlow, AsyncAdapter): """:class:`CalDAVAdapter` for a calendar on an asynchronous client. - :meth:`sync`, :meth:`create` and :meth:`change` are coroutines; each - store call runs in a worker thread, so a long ingest doesn't block the - event loop. Everything else is as :class:`CalDAVAdapter` describes. + Its methods are coroutines, as :class:`~calendaring_sync.adapters.base.AsyncAdapter` + describes; everything else is as :class:`CalDAVAdapter` describes. Raises: TypeError: If the calendar is on a synchronous client, or an @@ -592,19 +506,3 @@ def __init__( self, replica: Replica, calendar: Calendar, *, scope_id: str | None = None ) -> None: super().__init__(replica, calendar, scope_id) - - async def sync(self, *, full_listing: bool = False) -> None: - """See :meth:`CalDAVAdapter.sync`.""" - await _arun(self._sync(full_listing=full_listing)) - - async def create(self, content: bytes) -> int: - """See :meth:`CalDAVAdapter.create`.""" - return await _arun(self._create(content)) - - async def change(self, local_key: int, content: bytes) -> None: - """See :meth:`CalDAVAdapter.change`.""" - await _arun(self._change(local_key, content)) - - async def waiting(self) -> list[Record]: - """See :meth:`CalDAVAdapter.waiting`.""" - return await _arun(self._waiting()) diff --git a/src/calendaring_sync/adapters/jmap.py b/src/calendaring_sync/adapters/jmap.py index 93ce8c9..2c7cc2e 100644 --- a/src/calendaring_sync/adapters/jmap.py +++ b/src/calendaring_sync/adapters/jmap.py @@ -3,7 +3,7 @@ """The JMAP :term:`adapter`, which syncs one account's calendar events through calendaring-jmap. -Install it with the ``jmap`` extra: ``pip install 'calendaring-sync[jmap]'``. +It needs the ``jmap`` extra, as :doc:`/how-to/install` shows. :doc:`/explanation/adapters` explains every rule it follows, and :doc:`/reference/server-compatibility` how each tested server behaves. """ @@ -25,29 +25,29 @@ ) from exc from calendaring_sync._checks import check_argument_types -from calendaring_sync.adapters._flow import ( +from calendaring_sync.adapters.base import ( + Adapter, + AsyncAdapter, + InStore, + ReadResult, Steps, - _arun, - _EditFields, - _Flow, - _InStore, - _Read, - _run, + SyncAdapter, _url_id, ) from calendaring_sync.error import ScopeGoneError from calendaring_sync.objects.change import Change, Changed, Removed from calendaring_sync.objects.change_set import ChangeSet -from calendaring_sync.objects.edit import EditKind, LocalEdit +from calendaring_sync.objects.edit import EditFields, EditKind, LocalEdit from calendaring_sync.objects.push import Applied, Gone, LogicalKeyTaken, PushOutcome, Rejected from calendaring_sync.objects.record import Record from calendaring_sync.objects.scope import Scope -from calendaring_sync.replica import Replica, Session +from calendaring_sync.replica import Replica +from calendaring_sync.session import Session #: The name of the canonical fingerprint this adapter computes. A change to #: how it's computed gets a new name, so every scope is listed again. FINGERPRINT_SCHEME = "jmap-canonical-1" -#: The media type of a JMAP scope's content: JSCalendar (RFC 8984). +#: The media type of a JMAP scope's content, JSCalendar (RFC 8984). MEDIA_TYPE = "application/jscalendar+json" #: How many events one ``/changes`` page reports, and one page of a #: :term:`full listing` holds. Below every tested server's ``maxObjectsInGet``. @@ -138,15 +138,6 @@ _REFUSED_STATE = frozenset({"cannotCalculateChanges", "invalidArguments"}) -@dataclass(frozen=True) -class _Fields: - """What the adapter reads from an event for the core.""" - - fingerprint: str - containers: frozenset[str] - logical_key: str | None - - @dataclass(frozen=True) class _ChangesPage: """One page of ``/changes`` from a state, with the events it names fetched.""" @@ -220,7 +211,7 @@ def _canonical(event: dict[str, Any]) -> dict[str, Any]: return out -def _event_fields(event: dict[str, Any]) -> _Fields: +def _event_fields(event: dict[str, Any]) -> EditFields: """Read the fingerprint, the calendars and the logical key from an event.""" canonical = json.dumps( _canonical(event), sort_keys=True, separators=(",", ":"), ensure_ascii=False @@ -239,7 +230,8 @@ def _event_fields(event: dict[str, Any]) -> _Fields: logical_key = f"{uid} {recurrence_id}" else: logical_key = uid - return _Fields(hashlib.sha256(canonical.encode("utf-8")).hexdigest(), containers, logical_key) + fingerprint = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + return EditFields(fingerprint, containers, logical_key) def _event(content: bytes) -> dict[str, Any]: @@ -274,7 +266,7 @@ def _refusal(kind: EditKind, error: JMAPMethodError) -> PushOutcome | None: return None -class _JMAPFlow(_Flow): +class _JMAPFlow(Adapter): """Everything the sync and async JMAP adapters share.""" _fingerprint_scheme = FINGERPRINT_SCHEME @@ -295,18 +287,6 @@ def __init__( # The account's state as this sync last saw it, which each write must match. self._state = "" - @property - def needs_full_listings(self) -> bool: - """Whether the server may leave changes out without refusing the scope's state; never. - - Every tested server refuses a state it can't calculate changes from, - and the scope is listed again. The one answer that looks like a real - one: Cyrus answers a state newer than any it issued as if nothing - changed. So after restoring a server from a backup, list each scope - once with ``sync(full_listing=True)``. - """ - return False - def _new_object_id(self) -> None: # The server picks a created event's id. return None @@ -314,18 +294,14 @@ def _new_object_id(self) -> None: def _waits(self, record: Record) -> bool: return False - def _edit_fields(self, content: bytes, *, create: bool) -> _EditFields: + def _edit_fields(self, content: bytes, *, create: bool) -> EditFields: event = _event(content) fields = _event_fields(event) if create and not (isinstance(event.get(_UID), str) and event[_UID]): raise ValueError("A created event must have a uid") if create and not fields.containers: raise ValueError("A created event must be in a calendar") - return { - "fingerprint": fields.fingerprint, - "containers": fields.containers, - "logical_key": fields.logical_key, - } + return fields def _ask(self, request: Callable[[], Any]) -> Steps[Any]: """Make one call about the account; an account that's gone raises. @@ -369,14 +345,14 @@ def _changes_page(self, state: str) -> Steps[_ChangesPage | None]: return None return _ChangesPage(self._changed([*added, *modified]), destroyed, new_state, more) - def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[_Read]: + def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[ReadResult]: if full_listing or not scope.token: return (yield from self._list(session, scope)) state = scope.token while True: page = yield from self._changes_page(state) if page is None: - return _Read.INVALID_TOKEN + return ReadResult.INVALID_TOKEN changes: dict[str, Change] = { **dict.fromkeys(page.destroyed, Removed()), **page.changed, @@ -384,13 +360,13 @@ def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[ change_set = ChangeSet( self.scope_id, state, page.new_state, changes, truncated=page.more ) - yield _InStore(session.ingest, (change_set,)) + yield InStore(session.ingest, (change_set,)) state = page.new_state if not page.more: self._state = state - return _Read.COMPLETE + return ReadResult.COMPLETE - def _list(self, session: Session, scope: Scope) -> Steps[_Read]: + def _list(self, session: Session, scope: Scope) -> Steps[ReadResult]: """A full listing: the state first, then every event, a page at a time. The position is counted here, since a tested server answers every @@ -419,7 +395,7 @@ def _list(self, session: Session, scope: Scope) -> Steps[_Read]: change_set = ChangeSet( self.scope_id, scope.token, token, changes, full_listing=True, truncated=True ) - yield _InStore(session.ingest, (change_set,)) + yield InStore(session.ingest, (change_set,)) continue break state = yield from self._ask( @@ -428,13 +404,13 @@ def _list(self, session: Session, scope: Scope) -> Steps[_Read]: if state != token: moved = yield from self._changed_since(token) if moved is None: - return _Read.TRUNCATED + return ReadResult.TRUNCATED changes.update(moved[0]) state = moved[1] change_set = ChangeSet(self.scope_id, scope.token, token, changes, full_listing=True) - yield _InStore(session.ingest, (change_set,)) + yield InStore(session.ingest, (change_set,)) self._state = state - return _Read.COMPLETE + return ReadResult.COMPLETE def _changed_since(self, state: str) -> Steps[tuple[dict[str, Changed], str] | None]: """The events created or changed since ``state``, and the state they lead to. @@ -485,29 +461,23 @@ def _send(self, claimed: Record) -> Steps[PushOutcome]: @check_argument_types -class JMAPAdapter(_JMAPFlow): - """Syncs one JMAP account's calendar events with a :class:`~calendaring_sync.replica.Replica`. - - The client is a calendaring-jmap ``JMAPClient``, and the account's - ``CalendarEvent`` records are one :term:`scope`. Your application calls - :meth:`sync` when it decides to sync, and records its edits with - :meth:`create` and :meth:`change`, which build an event's fingerprint, - calendars and logical key the same way a sync does. Deletes and - discards need nothing from the content, so they go to - :meth:`~calendaring_sync.replica.Replica.delete` and - :meth:`~calendaring_sync.replica.Replica.discard`. - - Every write carries ``ifInState`` with the state the sync last saw, and - that state covers every event in the account. When the server refuses a - write because the state moved, the remaining edits wait, unsent, for - the next sync. - - Every method also raises the store's errors, as - :class:`~calendaring_sync.replica.Replica` describes. +class JMAPAdapter(_JMAPFlow, SyncAdapter): + """Syncs one account's calendar events through a synchronous calendaring-jmap client. + + The account's events are one :term:`scope`. The content + :meth:`create` and :meth:`change` take is a JSCalendar event as UTF-8 + JSON. A create's must have a ``uid`` and at least one calendar in + ``calendarIds``, and so must a change to a create not sent yet; + anything else raises :class:`ValueError`. The server picks a created + event's id, and :meth:`waiting` always returns an empty list. A + failure calendaring-jmap reports, such as a refused login, reaches you + as its + :external+calendaring_jmap:py:class:`~calendaring_jmap.error.JMAPError`, + unchanged. Args: replica: Where the sync state is kept. - client: The client of the JMAP server. + client: The server's client. account_id: The account whose events to sync, such as the one the client's ``get_account_id()`` returns, or a shared account's id. scope_id: The scope's id. Defaults to the client's session URL, @@ -538,84 +508,13 @@ def __init__( ) -> None: super().__init__(replica, client, account_id, scope_id) - def sync(self, *, full_listing: bool = False) -> None: - """Sync the account's events: ingest what changed on the server, then push your edits. - - Changes since the scope's token come from ``CalendarEvent/changes``, - a page at a time, and a scope that has no token is listed in full - instead. When the server refuses the token, the scope is - :term:`reset` and listed in full. Your edits are pushed only once - everything the server reported was ingested. Each step runs in a - store transaction of its own. - - Args: - full_listing: List every event instead of asking for changes. - JMAP has no version per event, so every event is fetched. - - Raises: - ScopeGoneError: If the server has no such account. - StaleChangeSetError: If another process ingested the scope first. - SessionError: If this replica already has a session open for the scope. - ScopeMismatchError: If the scope's content has another media type. - TransactionError: If a store transaction is open in this thread. - calendaring_jmap.JMAPError: For any other failure calendaring-jmap - reports, such as a refused login, unchanged. A push it - interrupted is settled by the next sync. - """ - _run(self._sync(full_listing=full_listing)) - - def create(self, content: bytes) -> int: - """Record a create of an event with this JSCalendar content, and return its local key. - - The server picks the event's id when the next :meth:`sync` sends it. - Runs in a write transaction. - - Raises: - ValueError: If the content isn't UTF-8 JSON holding an event - with a ``uid`` and at least one calendar in ``calendarIds``. - UnknownScopeError: If the account was never synced. - TransactionError: If a store transaction is open in this thread. - """ - return _run(self._create(content)) - - def change(self, local_key: int, content: bytes) -> None: - """Record a change of an event to this JSCalendar content; the next :meth:`sync` sends it. - - Reads the record, then writes the change, each in a transaction of its own. - - Raises: - ValueError: If the content isn't UTF-8 JSON holding an object, - the record is in another scope, or the record is a create - not sent yet and the content has no ``uid`` or no calendar. - UnknownRecordError: If no record has this local key. - EditRefusedError: If the record can't take an edit, such as one - with an open conflict. - TransactionError: If a store transaction is open in this thread. - """ - _run(self._change(local_key, content)) - - def waiting(self) -> list[Record]: - """Return the pending edits the next :meth:`sync` won't send; none for JMAP. - - Every pending edit goes out with the next sync, unless the server - rejects one and the rest wait for the sync after. Runs in a read - transaction. - - Raises: - UnknownScopeError: If the account was never synced. - TransactionError: If a store transaction is open in this thread. - """ - return _run(self._waiting()) - @check_argument_types -class AsyncJMAPAdapter(_JMAPFlow): +class AsyncJMAPAdapter(_JMAPFlow, AsyncAdapter): """:class:`JMAPAdapter` for an asynchronous client. - :meth:`sync`, :meth:`create`, :meth:`change` and :meth:`waiting` are - coroutines; each store call runs in a worker thread, so a long ingest - doesn't block the event loop. Everything else is as - :class:`JMAPAdapter` describes. + Its methods are coroutines, as :class:`~calendaring_sync.adapters.base.AsyncAdapter` + describes; everything else is as :class:`JMAPAdapter` describes. Raises: TypeError: If an argument has the wrong type, such as a synchronous client. @@ -630,19 +529,3 @@ def __init__( scope_id: str | None = None, ) -> None: super().__init__(replica, client, account_id, scope_id) - - async def sync(self, *, full_listing: bool = False) -> None: - """See :meth:`JMAPAdapter.sync`.""" - await _arun(self._sync(full_listing=full_listing)) - - async def create(self, content: bytes) -> int: - """See :meth:`JMAPAdapter.create`.""" - return await _arun(self._create(content)) - - async def change(self, local_key: int, content: bytes) -> None: - """See :meth:`JMAPAdapter.change`.""" - await _arun(self._change(local_key, content)) - - async def waiting(self) -> list[Record]: - """See :meth:`JMAPAdapter.waiting`.""" - return await _arun(self._waiting()) diff --git a/src/calendaring_sync/constants.py b/src/calendaring_sync/constants.py index 705b67a..4fb4dda 100644 --- a/src/calendaring_sync/constants.py +++ b/src/calendaring_sync/constants.py @@ -1,14 +1,9 @@ # SPDX-FileCopyrightText: 2026 calendaring-sync contributors # SPDX-License-Identifier: AGPL-3.0-or-later -"""Constants shared across calendaring-sync. - -Every value with a meaning beyond one line of code is defined here, so it is -never repeated. Other modules import from this file. -""" +"""Values shared across calendaring-sync.""" #: Version of the SQLite store's schema, kept in its ``store_meta`` table. -#: Version 1 is edited in place until the first release. SCHEMA_VERSION = 1 #: Default and largest number of entries one @@ -23,4 +18,4 @@ #: Where to report a bug, named in the message of #: :class:`~calendaring_sync.error.UnexpectedStoreError`. -ISSUES_URL = "https://github.com/pycalendar/calendaring-sync/issues" +ISSUES_URL = "https://github.com/PyCalendar/calendaring-sync/issues" diff --git a/src/calendaring_sync/error.py b/src/calendaring_sync/error.py index b6bfdf9..ce446c7 100644 --- a/src/calendaring_sync/error.py +++ b/src/calendaring_sync/error.py @@ -5,10 +5,12 @@ Every error the library raises on purpose derives from :class:`CalendaringSyncError`, except for arguments that can never be -valid: those raise :class:`ValueError`, such as a negative cursor, or -:class:`TypeError`, such as content that isn't bytes. A record a store -can't hold raises :class:`RecordValueError`, which is both a -:class:`CalendaringSyncError` and a :class:`ValueError`. +valid: those raise :class:`ValueError`, such as a feed page size of 0, or +:class:`TypeError`, such as content that isn't bytes. An argument refused +for what the store holds, such as a record of another scope, raises an +error that is both a :class:`CalendaringSyncError` and a +:class:`ValueError`, as a record a store can't hold does with +:class:`RecordValueError`. """ @@ -20,8 +22,26 @@ class StaleChangeSetError(CalendaringSyncError): """A :term:`change set` was fetched from a token the :term:`scope` no longer has. Another process ingested first, or started another listing of the same - scope. The changes it fetched are already outdated, so the adapter - fetches again from the scope's current token. + scope, so the changes fetched are outdated. Nothing was ingested; the + next sync fetches from the scope's current token. + """ + + +class ListingRequiredError(CalendaringSyncError, ValueError): + """An incremental :term:`change set` arrived for a :term:`scope` with no :term:`sync token`. + + A scope gets its token from its first :term:`full listing`, and again + after a :term:`reset`, so only a listing can be ingested until then. + """ + + +class UnknownVersionError(CalendaringSyncError, ValueError): + """A :term:`full listing` reported an object unchanged, but the store holds no version for it. + + An adapter reports an object unchanged only when its version equals + one :meth:`~calendaring_sync.replica.Replica.known_versions` returned, + so the listing was built from versions the store no longer has. Nothing + was ingested; build the listing again. """ @@ -34,6 +54,14 @@ class TrimmedFeedError(CalendaringSyncError): """ +class FeedRangeError(CalendaringSyncError, ValueError): + """A :term:`change feed` number is below 0 or past the last entry. + + No entry has that number yet, so the number didn't come from this + store's feed, or the store was replaced since. + """ + + class UnknownScopeError(CalendaringSyncError): """The :term:`store` has no :term:`scope` with this id.""" @@ -46,6 +74,29 @@ class DuplicateObjectError(CalendaringSyncError): """Another :term:`record` in the :term:`scope` already has this :term:`object id`.""" +class OtherScopeError(CalendaringSyncError, ValueError): + """A :term:`record` or :term:`change set` belongs to another :term:`scope`. + + A session or adapter works on one scope, and refuses what belongs to + another before anything is written. + """ + + +class ObjectIdAssignedError(CalendaringSyncError, ValueError): + """The :term:`record` already has an :term:`object id`, so it can't be given another. + + Only a create that has none takes an object id when its push is + claimed; claim any other record without one. + """ + + +class LoneSurrogateError(CalendaringSyncError, ValueError): + """A string holds a :ref:`lone surrogate `, which no store can keep. + + It's refused where the string comes in, before anything is written. + """ + + class RecordValueError(CalendaringSyncError, ValueError): """A :term:`record` holds what its state can't, so a :term:`store` refuses to write it. diff --git a/src/calendaring_sync/objects/__init__.py b/src/calendaring_sync/objects/__init__.py index abe09da..e7638b3 100644 --- a/src/calendaring_sync/objects/__init__.py +++ b/src/calendaring_sync/objects/__init__.py @@ -1,2 +1,4 @@ # SPDX-FileCopyrightText: 2026 calendaring-sync contributors # SPDX-License-Identifier: AGPL-3.0-or-later + +"""The values the core takes and returns.""" diff --git a/src/calendaring_sync/objects/_data.py b/src/calendaring_sync/objects/_data.py index b3b278d..96e119b 100644 --- a/src/calendaring_sync/objects/_data.py +++ b/src/calendaring_sync/objects/_data.py @@ -5,6 +5,8 @@ from __future__ import annotations +from typing import TypeAlias + #: The fields that hold an object's data, in every copy of it: a record's #: synced side, a change from the server, a local edit, and a conflict's #: server side. Two copies hold the same data when these match, and a record @@ -15,7 +17,7 @@ REMOTE = "remote_" #: A copy's data: its fingerprint, content, containers and logical key. -Data = tuple[str | None, bytes | None, frozenset[str], str | None] +Data: TypeAlias = tuple[str | None, bytes | None, frozenset[str], str | None] #: The data of a copy that isn't there, such as an object the server removed. NO_DATA: Data = (None, None, frozenset(), None) diff --git a/src/calendaring_sync/objects/change.py b/src/calendaring_sync/objects/change.py index 89721ae..309863a 100644 --- a/src/calendaring_sync/objects/change.py +++ b/src/calendaring_sync/objects/change.py @@ -6,6 +6,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TypeAlias from calendaring_sync._checks import check_data, checked_when_built @@ -27,7 +28,7 @@ class Changed: Raises: TypeError: If a field has the wrong type. - ValueError: If a string holds a :ref:`lone surrogate `. + LoneSurrogateError: If a string holds a lone surrogate. Examples: An :term:`adapter` reports an object the server changed, and one it @@ -64,4 +65,4 @@ class Unchanged: #: Any one of the three. -Change = Changed | Removed | Unchanged +Change: TypeAlias = Changed | Removed | Unchanged diff --git a/src/calendaring_sync/objects/change_set.py b/src/calendaring_sync/objects/change_set.py index 4e9803d..e164f76 100644 --- a/src/calendaring_sync/objects/change_set.py +++ b/src/calendaring_sync/objects/change_set.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2026 calendaring-sync contributors # SPDX-License-Identifier: AGPL-3.0-or-later -"""What an :term:`adapter` hands the core after asking the server what changed.""" +"""A :term:`change set`, whole or one page of it.""" from __future__ import annotations @@ -35,8 +35,7 @@ class ChangeSet: Raises: TypeError: If a field has the wrong type. - ValueError: If the scope id, a token or an object id holds a - :ref:`lone surrogate `. + LoneSurrogateError: If the scope id, a token or an object id holds a lone surrogate. """ scope: str diff --git a/src/calendaring_sync/objects/conflict.py b/src/calendaring_sync/objects/conflict.py index 1045ba2..4cc067f 100644 --- a/src/calendaring_sync/objects/conflict.py +++ b/src/calendaring_sync/objects/conflict.py @@ -26,7 +26,8 @@ class ConflictKind(StrEnum): #: The server has a new object at the id your unsent create chose. BOTH_CREATED = "both_created" #: The server refused your create, or your change, because another - #: object holds its :term:`logical key`. + #: object holds its :term:`logical key`. Once that object stops holding + #: it, the edit is pending again. LOGICAL_KEY_TAKEN = "logical_key_taken" diff --git a/src/calendaring_sync/objects/edit.py b/src/calendaring_sync/objects/edit.py index b169584..08f696c 100644 --- a/src/calendaring_sync/objects/edit.py +++ b/src/calendaring_sync/objects/edit.py @@ -9,6 +9,8 @@ from datetime import datetime from enum import StrEnum +from calendaring_sync._checks import checked_when_built + class EditKind(StrEnum): """What a local edit does to its object.""" @@ -45,3 +47,35 @@ class LocalEdit: containers: frozenset[str] logical_key: str | None recorded_at: datetime + + +@checked_when_built +@dataclass(frozen=True) +class EditFields: + """What an edit records about an object beside its content. + + An :term:`adapter` reads these from content the same way for the + server's copy and for an edit, so an edit back to the synced content + cancels, and the server's copy of your own edit isn't a conflict. To + build them for content you made, such as a :class:`Merge + `, call the adapter's + :meth:`~calendaring_sync.adapters.base.Adapter.edit_fields`. + + Attributes: + fingerprint: The content's :term:`fingerprint`. + containers: The :term:`containers ` the object belongs to. + logical_key: The object's :term:`logical key`, if it has one. + + Raises: + TypeError: If a field has the wrong type. + + Examples: + >>> from calendaring_sync import EditFields + >>> fields = EditFields("fp-a2", frozenset({"work"}), "meeting-1") + >>> fields.logical_key + 'meeting-1' + """ + + fingerprint: str + containers: frozenset[str] = frozenset() + logical_key: str | None = None diff --git a/src/calendaring_sync/objects/feed.py b/src/calendaring_sync/objects/feed.py index e3104c0..cad1b97 100644 --- a/src/calendaring_sync/objects/feed.py +++ b/src/calendaring_sync/objects/feed.py @@ -13,8 +13,8 @@ class FeedEntryKind(StrEnum): """What a feed entry records.""" - #: A remote change was applied: the object is new, changed, or back on - #: the server. + #: A remote change was applied, to an object that's new, changed, or + #: back on the server. CHANGED = "changed" #: A remote removal was applied. REMOVED = "removed" diff --git a/src/calendaring_sync/objects/push.py b/src/calendaring_sync/objects/push.py index ef56dec..2d1bd10 100644 --- a/src/calendaring_sync/objects/push.py +++ b/src/calendaring_sync/objects/push.py @@ -6,6 +6,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TypeAlias from calendaring_sync._checks import check_text, checked_when_built @@ -22,7 +23,7 @@ class Applied: Raises: TypeError: If the id isn't a string or ``None``. - ValueError: If the id holds a :ref:`lone surrogate `. + LoneSurrogateError: If the id holds a lone surrogate. Examples: An :term:`adapter` reports a create the server stored under an id it @@ -44,8 +45,8 @@ class Rejected: """The server refused the write because something changed since your last sync. The edit stays pending. The next :term:`ingest` makes it a - :term:`conflict` only if this object changed, and otherwise it's pushed - again. + :term:`conflict` only if the object the edit is for changed, and + otherwise it's pushed again. """ @@ -68,7 +69,7 @@ class LogicalKeyTaken: Raises: TypeError: If the id isn't a string or ``None``. - ValueError: If the id holds a :ref:`lone surrogate `. + LoneSurrogateError: If the id holds a lone surrogate. """ holder: str | None = None @@ -87,4 +88,4 @@ class Unknown: #: Any one of the five. -PushOutcome = Applied | Rejected | Gone | LogicalKeyTaken | Unknown +PushOutcome: TypeAlias = Applied | Rejected | Gone | LogicalKeyTaken | Unknown diff --git a/src/calendaring_sync/objects/record.py b/src/calendaring_sync/objects/record.py index ae08297..b952482 100644 --- a/src/calendaring_sync/objects/record.py +++ b/src/calendaring_sync/objects/record.py @@ -5,13 +5,19 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime from enum import StrEnum +from typing import Self, TypeAlias, cast +from calendaring_sync.objects._data import REMOTE, data +from calendaring_sync.objects.change import Changed from calendaring_sync.objects.conflict import Conflict, ConflictKind from calendaring_sync.objects.edit import EditKind, LocalEdit +#: A copy of an object that a record can be synced to. +SyncedCopy: TypeAlias = Changed | LocalEdit | Conflict + class RecordState(StrEnum): """The five states a record can be in.""" @@ -87,11 +93,105 @@ class Record: conflict: Conflict | None = None claims: int = 0 + @classmethod + def new(cls, scope_id: str, object_id: str | None = None) -> Self: + """Return a record the store doesn't hold yet, with nothing synced. + + Its :attr:`local_key` is ``0`` until a store's + :meth:`~calendaring_sync.store.base.StateStore.insert_record` gives + it one; fill in the rest with :meth:`synced_to`, or with an edit. + + Examples: + >>> from calendaring_sync import Record + >>> record = Record.new("work", "a") + >>> record.local_key, record.object_id, record.content + (0, 'a', None) + """ + return cls( + local_key=0, + scope=scope_id, + object_id=object_id, + state=RecordState.SYNCED, + version=None, + fingerprint=None, + content=None, + containers=frozenset(), + logical_key=None, + synced_at=None, + last_entry=0, + ) + + def with_copy(self, copy: SyncedCopy, at: datetime) -> Self: + """Return the record whose synced copy is ``copy``, synced at ``at``. + + Its state, edit and conflict stay as they are. ``copy`` is one of: + + - a change from the server, at its version + - your edit, once the server applied it, with no version: the server + may store it rewritten, so the next ingest brings its version + - a conflict's server side, at its version + + Examples: + >>> from datetime import UTC, datetime + >>> from calendaring_sync import Changed, Record + >>> change = Changed("v2", "fp-a2", b"Team meeting, moved") + >>> moved = Record.new("work", "a").with_copy(change, datetime(2026, 11, 2, tzinfo=UTC)) + >>> moved.version, moved.content + ('v2', b'Team meeting, moved') + """ + if isinstance(copy, Conflict): + version, prefix = copy.remote_version, REMOTE + else: + version, prefix = (copy.version if isinstance(copy, Changed) else None), "" + fingerprint, content, containers, logical_key = data(copy, prefix) + return replace( + self, + version=version, + fingerprint=fingerprint, + content=content, + containers=containers, + logical_key=logical_key, + synced_at=at, + ) + + def synced_to(self, copy: SyncedCopy, at: datetime) -> Self: + """Return the record synced to ``copy``, with no edit or conflict left. + + ``copy`` is what :meth:`with_copy` takes. + + Examples: + >>> from datetime import UTC, datetime + >>> from calendaring_sync import Changed, Record + >>> change = Changed("v1", "fp-a", b"Team meeting") + >>> Record.new("work", "a").synced_to(change, datetime(2026, 11, 2, tzinfo=UTC)).state + + """ + return replace(self.with_copy(copy, at), state=RecordState.SYNCED, edit=None, conflict=None) + + def removed_at(self, at: datetime) -> Self: + """Return the record once its object is gone from the server, with nothing left to push. + + It keeps its synced copy, so a reader of the :term:`change feed` + can still see what was removed. + + Examples: + >>> from datetime import UTC, datetime + >>> from calendaring_sync import Record + >>> Record.new("work", "a").removed_at(datetime(2026, 11, 2, tzinfo=UTC)).state + + """ + return replace(self, state=RecordState.REMOVED, edit=None, conflict=None, synced_at=at) + @property def is_create(self) -> bool: """Whether the record is an object you created that the server doesn't have yet.""" return self.edit is not None and self.edit.kind is EditKind.CREATE + @property + def is_refused(self) -> bool: + """Whether the server refused this record's create or change for its :term:`logical key`.""" + return self.conflict is not None and self.conflict.kind is ConflictKind.LOGICAL_KEY_TAKEN + @property def is_refused_change(self) -> bool: """Whether the server refused this record's change for its :term:`logical key`. @@ -99,11 +199,7 @@ def is_refused_change(self) -> bool: The server then still holds the synced copy, and the conflict holds no copy of its own. """ - return ( - self.conflict is not None - and self.conflict.kind is ConflictKind.LOGICAL_KEY_TAKEN - and not self.is_create - ) + return self.is_refused and not self.is_create @property def server_version(self) -> str | None: @@ -115,6 +211,25 @@ def server_version(self) -> str | None: """ if self.state is RecordState.REMOVED: return None - if self.conflict is not None and not self.is_refused_change: - return self.conflict.remote_version + if self._conflict_holds_server_copy: + return cast(Conflict, self.conflict).remote_version return self.version + + @property + def server_logical_key(self) -> str | None: + """The :term:`logical key` of the server's copy, ``None`` if the server holds none. + + As with :attr:`server_version`, that's the conflict's while the + record is in conflict, and the synced one for a change the server + refused. A create the server doesn't have has none. + """ + if self.state is RecordState.REMOVED: + return None + if self._conflict_holds_server_copy: + return cast(Conflict, self.conflict).remote_logical_key + return self.logical_key + + @property + def _conflict_holds_server_copy(self) -> bool: + """Whether the conflict, not the synced copy, holds what the server has.""" + return self.conflict is not None and not self.is_refused_change diff --git a/src/calendaring_sync/objects/resolution.py b/src/calendaring_sync/objects/resolution.py index c0b0bd8..2699071 100644 --- a/src/calendaring_sync/objects/resolution.py +++ b/src/calendaring_sync/objects/resolution.py @@ -5,70 +5,86 @@ from __future__ import annotations -from dataclasses import KW_ONLY, dataclass +from dataclasses import dataclass +from typing import TypeAlias from calendaring_sync._checks import check_data, checked_when_built +from calendaring_sync.objects.edit import EditFields @dataclass(frozen=True) -class TakeServer: - """Keep the server's side and drop your edit. +class KeepServer: + """Keep the server's side, and drop your :term:`local edit`. - For a create the server refused because another object holds its - :term:`logical key`, the server has nothing of yours, so the create is - discarded; for a refused change, the object stays as it was synced. + Where the server has the object, the record takes the server's copy. + Where it doesn't, because the server removed the object or refused your + create, the record is removed, or deleted if the server never had it. """ @dataclass(frozen=True) -class KeepEdit: - """Push your edit as it is, over the server's current copy. +class KeepLocal: + """Push your :term:`local edit` as it is, over the server's current copy. - Where the server removed the object, a change is pushed as a create. + Where the server removed the object, a change goes out as a create. An + edit the server refused because another object holds its + :term:`logical key` can't go out unchanged, so this refuses it. """ @checked_when_built @dataclass(frozen=True) class Merge: - """Push new content you made from both sides, over the server's current copy. - - Build ``fingerprint``, ``containers`` and ``logical_key`` with the - :term:`adapter`'s function for them, as for - :meth:`~calendaring_sync.replica.Replica.change`. + """Push content you merged from the conflict's versions, over the server's current copy. + + A record in conflict holds three versions of its object: the + :term:`synced content` both sides last agreed on, in + :attr:`~calendaring_sync.objects.record.Record.content`; your + :term:`local edit`, in :attr:`~calendaring_sync.objects.record.Record.edit`; + and the server's side, in + :attr:`~calendaring_sync.objects.record.Record.conflict`. Your application + merges them into new content, and reads its fields with the + :term:`adapter`'s :meth:`~calendaring_sync.adapters.base.Adapter.edit_fields`, + so they match what a sync reads from the server's copy. Where the server + removed the object, the merge goes out as a create. Attributes: content: The merged :term:`content`. - fingerprint: Its :term:`fingerprint`. - containers: The :term:`containers ` it belongs to. - logical_key: Its :term:`logical key`, if it has one. + fields: Its fingerprint, containers and logical key. Raises: TypeError: If a field has the wrong type. - ValueError: If a string holds a :ref:`lone surrogate `. + LoneSurrogateError: If a string holds a lone surrogate. Examples: - Your application keeps what both sides changed in one copy: - - >>> from calendaring_sync import Merge - >>> merged = Merge(b"Team meeting, room 3, moved", fingerprint="fp-merged") - >>> merged.containers, merged.logical_key - (frozenset(), None) + Your application keeps the room your edit set and the time the + server's side moved to, in one copy. An adapter would read its + fields; these are made up: + + >>> from calendaring_sync import EditFields, Merge + >>> content = b"Team meeting, room 3, moved" + >>> merged = Merge(content, EditFields("fp-merged")) + >>> merged.fields.fingerprint + 'fp-merged' """ content: bytes - _: KW_ONLY - fingerprint: str - containers: frozenset[str] = frozenset() - logical_key: str | None = None + fields: EditFields def __post_init__(self) -> None: - check_data(self.fingerprint, self.containers, self.logical_key) + # Every other way in checks these strings where the core takes them. + check_data(self.fields.fingerprint, self.fields.containers, self.fields.logical_key) @dataclass(frozen=True) class Delete: - """Delete the object, whatever the server's side holds.""" + """Delete the object, whatever the server's side holds. + + Where the server has the object, the delete goes out over its current + copy, and for a change the server refused for its :term:`logical key`, + that's the object the change was for. Where the server doesn't have it, + the record is removed, or deleted if the server never had it. + """ @dataclass(frozen=True) @@ -82,4 +98,4 @@ class UpdateHolder: #: Any one of the five. -Resolution = TakeServer | KeepEdit | Merge | Delete | UpdateHolder +Resolution: TypeAlias = KeepServer | KeepLocal | Merge | Delete | UpdateHolder diff --git a/src/calendaring_sync/replica.py b/src/calendaring_sync/replica.py index 2f6fe95..4d97b4b 100644 --- a/src/calendaring_sync/replica.py +++ b/src/calendaring_sync/replica.py @@ -1,11 +1,7 @@ # SPDX-FileCopyrightText: 2026 calendaring-sync contributors # SPDX-License-Identifier: AGPL-3.0-or-later -"""The core's entry point: sessions, pushes, local edits, conflicts, and the :term:`change feed`. - -Every sync rule lives here, on top of any -:class:`~calendaring_sync.store.base.StateStore`. -""" +"""The core's entry point, which holds every sync rule on top of any :term:`store`.""" from __future__ import annotations @@ -13,7 +9,6 @@ from contextlib import contextmanager from dataclasses import replace from datetime import UTC, datetime -from enum import StrEnum from threading import Lock from typing import TypeVar, cast @@ -28,7 +23,11 @@ from calendaring_sync.constants import FEED_PAGE_SIZE from calendaring_sync.error import ( DuplicateObjectError, + FeedRangeError, + ListingRequiredError, + ObjectIdAssignedError, OpenConflictError, + OtherScopeError, PendingEditError, PushInFlightError, RemovedRecordError, @@ -40,8 +39,8 @@ UnknownHolderError, UnknownRecordError, UnknownScopeError, + UnknownVersionError, ) -from calendaring_sync.objects._data import REMOTE, data from calendaring_sync.objects.change import Change, Changed, Removed, Unchanged from calendaring_sync.objects.change_set import ChangeSet from calendaring_sync.objects.conflict import Conflict, ConflictKind @@ -51,7 +50,6 @@ from calendaring_sync.objects.push import ( Applied, Gone, - LogicalKeyTaken, PushOutcome, Rejected, Unknown, @@ -59,10 +57,10 @@ from calendaring_sync.objects.record import Record, RecordState from calendaring_sync.objects.resolution import ( Delete, - KeepEdit, + KeepLocal, + KeepServer, Merge, Resolution, - TakeServer, UpdateHolder, ) from calendaring_sync.objects.scope import Scope @@ -94,68 +92,13 @@ def _without_fingerprints(record: Record) -> Record: ) -def _new_record(scope_id: str, object_id: str | None) -> Record: - """A record the store doesn't hold yet, with nothing synced; the caller fills it in.""" - return Record( - local_key=0, - scope=scope_id, - object_id=object_id, - state=RecordState.SYNCED, - version=None, - fingerprint=None, - content=None, - containers=frozenset(), - logical_key=None, - synced_at=None, - last_entry=0, - ) - - -def _with_copy( - record: Record, copy: object, version: str | None, now: datetime, *, prefix: str = "" -) -> Record: - """The record whose synced side is a copy of its object, at ``version``. - - ``copy`` is a change from the server, your edit once it's applied, or a - conflict's server side, whose fields carry the :data:`REMOTE` prefix. - """ - fingerprint, content, containers, logical_key = data(copy, prefix) - return replace( - record, - version=version, - fingerprint=fingerprint, - content=content, - containers=containers, - logical_key=logical_key, - synced_at=now, - ) - - -def _synced( - record: Record, copy: object, version: str | None, now: datetime, *, prefix: str = "" -) -> Record: - """The record synced to a copy of its object, with no edit or conflict left.""" - return replace( - _with_copy(record, copy, version, now, prefix=prefix), - state=RecordState.SYNCED, - edit=None, - conflict=None, - ) - - -def _removed(record: Record, now: datetime) -> Record: - """The record once its object is gone from the server, with nothing left to push.""" - return replace(record, state=RecordState.REMOVED, edit=None, conflict=None, synced_at=now) - - def _check_resolution(record: Record, resolution: Resolution) -> None: """Raise ``ValueError`` unless ``record`` is in conflict and ``resolution`` fits its kind.""" if record.state is not RecordState.CONFLICT or record.conflict is None: raise ValueError("resolve() takes a record in conflict") - refused = record.conflict.kind is ConflictKind.LOGICAL_KEY_TAKEN - if isinstance(resolution, KeepEdit) and refused: + if isinstance(resolution, KeepLocal) and record.is_refused: raise ValueError("An edit refused for its logical key can't be pushed again unchanged") - if isinstance(resolution, UpdateHolder) and not refused: + if isinstance(resolution, UpdateHolder) and not record.is_refused: raise ValueError("Only an edit refused for its logical key goes to the key's holder") @@ -178,28 +121,28 @@ def _resolved(record: Record, resolution: Resolution, now: datetime) -> Record | """ conflict, edit = cast(Conflict, record.conflict), cast(LocalEdit, record.edit) on_server = conflict.remote_content is not None or record.is_refused_change - if not on_server and isinstance(resolution, (TakeServer, Delete)): + if not on_server and isinstance(resolution, (KeepServer, Delete)): # The server doesn't have the object: a create it never had leaves nothing. - return None if record.is_create else _removed(record, now) + return None if record.is_create else record.removed_at(now) if record.is_refused_change: # The server kept the synced copy. base = replace(record, state=RecordState.SYNCED, edit=None, conflict=None) else: - base = _synced(record, conflict, conflict.remote_version, now, prefix=REMOTE) + base = record.synced_to(conflict, now) # Over a copy the server has, an edit is a change; with none, a create. kind = EditKind.CHANGE if on_server else EditKind.CREATE match resolution: - case TakeServer(): + case KeepServer(): return base - case KeepEdit(): + case KeepLocal(): return _edited(base, edit if edit.kind is EditKind.DELETE else replace(edit, kind=kind)) case Merge(): merged = LocalEdit( kind, resolution.content, - resolution.fingerprint, - resolution.containers, - resolution.logical_key, + resolution.fields.fingerprint, + resolution.fields.containers, + resolution.fields.logical_key, now, ) return _edited(base, merged) @@ -225,292 +168,6 @@ def _waits_for_version(record: Record, *, guarded: bool) -> bool: return guarded and record.version is None and not record.is_create -def _check_outcome(claimed_keys: set[int], claimed: Record, outcome: PushOutcome) -> None: - """Raise ``ValueError`` unless the outcome is one a push of the claimed edit can have.""" - if ( - claimed.local_key not in claimed_keys - or claimed.state is not RecordState.IN_FLIGHT - or claimed.edit is None - ): - raise ValueError("settle() takes a record this session's claim() returned") - kind = claimed.edit.kind - match outcome: - case Gone() if kind is EditKind.CREATE: - raise ValueError("A create can't be gone: the server never had the object") - case LogicalKeyTaken() if kind is EditKind.DELETE: - raise ValueError("A delete can't be refused for its logical key") - case Applied(object_id=str()) if kind is EditKind.DELETE: - raise ValueError("An applied delete has no object id to report") - case Applied(object_id=None) if kind is EditKind.CREATE and claimed.object_id is None: - raise ValueError("An applied create needs the object id the server gave it") - - -class SessionState(StrEnum): - """Where a :term:`session` is.""" - - #: Nothing ingested yet. - IDLE = "idle" - #: A :term:`truncated page` was ingested, and more pages follow. - INGESTING = "ingesting" - #: The last page was ingested, and the token is final. - INGESTED = "ingested" - #: A :term:`push` was claimed; no more pages can be ingested. - PUSHING = "pushing" - - -@check_argument_types -class Session: - """One sync of one :term:`scope`, opened with :meth:`Replica.session`. - - A session :term:`ingests ` every page first, then the - :term:`adapter` pushes your :term:`pending edits `: for each - record :meth:`to_push` lists, it :meth:`claims ` it, sends it, and - :meth:`settles ` it with how the push ended. - - Every method raises :class:`TypeError` for an argument of the wrong - type, before it changes anything. - - Examples: - The adapter pushes an edit after ingesting what changed on the server, - here nothing: - - >>> from calendaring_sync import Applied - >>> key = replica.find_record("work", "a").local_key - >>> replica.change(key, b"Team meeting, moved", fingerprint="fp-a2") - >>> with replica.session( - ... "work", fingerprint_scheme="sha256", media_type="text/plain" - ... ) as session: - ... session.ingest(ChangeSet("work", "t1", "t2", {})) - ... for record in session.to_push(): - ... claimed = session.claim(record.local_key) - ... # The adapter sends claimed.edit to the server here. - ... session.settle(claimed, Applied()) - True - >>> replica.record(key).state, replica.record(key).content - (, b'Team meeting, moved') - """ - - def __init__( - self, replica: Replica, scope_id: str, *, guarded_by_version: bool, unique_logical_key: bool - ) -> None: - self._replica = replica - self._scope_id = scope_id - self._guarded_by_version = guarded_by_version - self._unique_logical_key = unique_logical_key - self._state = SessionState.IDLE - self._listing: bool | None = None - self._mentioned: set[str] = set() - self._closed = False - # The local keys this session claimed, the only ones it settles. - self._claimed: set[int] = set() - - @property - def scope_id(self) -> str: - """The id of the session's scope.""" - return self._scope_id - - @property - def state(self) -> SessionState: - """Where the session is.""" - return self._state - - def ingest(self, change_set: ChangeSet) -> None: - """:term:`Ingest` one change set, or one page of it, in one transaction. - - The core applies each object in it: a new object gets a record, a - changed one gets the new data, and a removed one is marked removed. - An object whose :term:`fingerprint`, containers and logical key - didn't change only gets its version and the server's content, and isn't - reported. A :term:`truncated page` of an :term:`incremental change set` - moves the token to its :term:`checkpoint`. A :term:`full listing` - moves the token only on its last page, which also removes every - record the listing never mentioned. A complete change set, or the - last page, also settles every :term:`push` still :term:`in flight` - from what the server reported, and one it doesn't mention is - pending again. If anything fails, nothing changes. - - Raises: - TransactionError: If a store transaction is already open in this thread. - StaleChangeSetError: If the change set was fetched from a token - the scope no longer has, or is a later page of a listing with - another token, such as one another session has replaced. The - session goes back to idle, so the adapter can fetch again and - ingest from the start in the same session. - SessionError: If the session has ended, already ingested its last - page, has started pushing, or the page is a listing page in an - incremental session or the other way round. - ValueError: If the change set is for another scope, an - incremental change set arrives for a scope with no token, or - an ``Unchanged`` entry is outside a listing or names a record - with no version. - """ - if change_set.scope != self._scope_id: - raise ValueError( - f"Change set for {change_set.scope!r} in a session for {self._scope_id!r}" - ) - if self._closed: - raise SessionError("The session has ended") - if self._state is SessionState.INGESTED: - raise SessionError("The session already ingested its last page") - if self._state is SessionState.PUSHING: - raise SessionError("The session already pushed, and a change set can't come after that") - if self._listing is not None and self._listing != change_set.full_listing: - raise SessionError("One session's pages are all listing pages or all incremental") - try: - self._mentioned = self._replica._ingest( - change_set, - first_page=self._state is SessionState.IDLE, - mentioned=self._mentioned, - unique_logical_key=self._unique_logical_key, - ) - except StaleChangeSetError: - self._restart() - raise - self._listing = change_set.full_listing - self._state = SessionState.INGESTING if change_set.truncated else SessionState.INGESTED - - def to_push(self) -> list[Record]: - """Return the :term:`pending edits ` ready to :term:`push`, by local key. - - With ``guarded_by_version``, a change or delete of a record with no - :term:`version` waits for an ingest that gives it one, and isn't - listed. Runs in a read transaction of its own. - - Raises: - TransactionError: If a store transaction is already open in this thread. - SessionError: If the session hasn't ingested its last page, or has ended. - - Examples: - >>> key = replica.find_record("work", "a").local_key - >>> replica.change(key, b"Team meeting, moved", fingerprint="fp-a2") - >>> with replica.session( - ... "work", fingerprint_scheme="sha256", media_type="text/plain" - ... ) as session: - ... session.ingest(ChangeSet("work", "t1", "t2", {})) - ... [record.local_key for record in session.to_push()] == [key] - True - """ - self._check_ready() - return self._replica._to_push(self._scope_id, guarded=self._guarded_by_version) - - def claim(self, local_key: int, *, object_id: str | None = None) -> Record | None: - """Mark a :term:`pending edit` :term:`in flight`, so no other process pushes it. - - Send the record this returns, then report how the push ended with - :meth:`settle`. Runs in a write transaction of its own; when two - processes claim one record, only one gets it. - - Args: - local_key: The record to push. - object_id: For a create the server takes ids for, the id it's - about to be sent under. It's stored with the claim, so the - create is recognized by it even if the answer never arrives. - An id a removed record holds passes to this one. - - Returns: - The claimed record, or ``None`` when it no longer holds a - pending edit to push: another process claimed it, the edit was - discarded or resolved, or it waits for a version. - - Raises: - TransactionError: If a store transaction is already open in this thread. - SessionError: If the session hasn't ingested its last page, or has ended. - DuplicateObjectError: If a record that isn't removed holds ``object_id``. - ValueError: If the record belongs to another scope, ``object_id`` - is given for a record that has one, or a string holds a - :ref:`lone surrogate `. - - Examples: - A second claim of the same record gets nothing: - - >>> key = replica.find_record("work", "a").local_key - >>> replica.change(key, b"Team meeting, moved", fingerprint="fp-a2") - >>> with replica.session( - ... "work", fingerprint_scheme="sha256", media_type="text/plain" - ... ) as session: - ... session.ingest(ChangeSet("work", "t1", "t2", {})) - ... claimed = session.claim(key) - ... claimed.state, session.claim(key) - (, None) - """ - check_text(object_id=object_id) - self._check_ready() - claimed = self._replica._claim( - self._scope_id, local_key, object_id, guarded=self._guarded_by_version - ) - if claimed is not None: - self._state = SessionState.PUSHING - self._claimed.add(local_key) - return claimed - - def settle(self, claimed: Record, outcome: PushOutcome) -> bool: - """Record how the :term:`push` of a claimed record ended. - - Runs in a write transaction of its own, and writes a :term:`change - feed` entry for every outcome but ``Unknown``. If another process - already settled this push, for example by ingesting while it was - in flight, the outcome only counts while the record still holds the - same edit and no one has claimed it again. - - Args: - claimed: The record :meth:`claim` returned. - outcome: How the push ended. - - Returns: - ``True`` if the store now agrees with the outcome, ``False`` if - the push had already been settled another way and nothing was - written. - - Raises: - TransactionError: If a store transaction is already open in this thread. - SessionError: If the session has ended. - DuplicateObjectError: If ``Applied`` names an id a record that - isn't removed holds. - ValueError: If ``claimed`` isn't a record this session's - :meth:`claim` returned, or the outcome is one no push of its - edit can have: ``Gone`` for a create, ``LogicalKeyTaken`` for - a delete, ``Applied`` with an id for a delete, or - ``Applied`` without one for a create that has none. - - Examples: - The server refused the write, so the edit is pending again: - - >>> from calendaring_sync import Rejected - >>> key = replica.find_record("work", "a").local_key - >>> replica.change(key, b"Team meeting, moved", fingerprint="fp-a2") - >>> with replica.session( - ... "work", fingerprint_scheme="sha256", media_type="text/plain" - ... ) as session: - ... session.ingest(ChangeSet("work", "t1", "t2", {})) - ... session.settle(session.claim(key), Rejected()) - True - >>> replica.record(key).state - - """ - _check_outcome(self._claimed, claimed, outcome) - if self._closed: - raise SessionError("The session has ended") - return self._replica._settle(claimed, outcome) - - def _check_ready(self) -> None: - """Raise unless the session may push: its last page is ingested and it hasn't ended.""" - if self._closed: - raise SessionError("The session has ended") - if self._state not in (SessionState.INGESTED, SessionState.PUSHING): - raise SessionError("A session pushes only after it ingested its last page") - - def _restart(self) -> None: - """Go back to idle, dropping a half-done listing.""" - self._state = SessionState.IDLE - self._listing = None - self._mentioned = set() - - def _end(self) -> None: - """Close the session when its ``with`` block ends.""" - self._closed = True - self._restart() - - @check_argument_types class Replica: """The sync state of every :term:`scope` in one :term:`store`. @@ -530,12 +187,10 @@ class Replica: Every method raises :class:`TypeError` for an argument of the wrong type, before it changes anything. Any method can also raise the - store's errors: - :class:`~calendaring_sync.error.StoreBusyError` when another process - kept the store busy, which is safe to retry, - :class:`~calendaring_sync.error.StoreError` when the store can't read or - write its data, and :class:`~calendaring_sync.error.UnexpectedStoreError` - for a bug in the store. None of them changes anything. + store's errors, :class:`~calendaring_sync.error.StoreBusyError`, + :class:`~calendaring_sync.error.StoreError` and + :class:`~calendaring_sync.error.UnexpectedStoreError`, none of which + changes anything. Args: store: Where the state is kept. @@ -587,7 +242,8 @@ def session( :term:`full listing` fetches every object again. Objects whose content is the same as before aren't reported as changed. - Opening the session and each :meth:`Session.ingest` run in a write + Opening the session and each + :meth:`~calendaring_sync.session.Session.ingest` run in a write transaction of their own. Args: @@ -607,7 +263,7 @@ def session( TransactionError: If a store transaction is already open in this thread. SessionError: If this replica already has a session open for the scope. ScopeMismatchError: If the scope's content has another media type. - ValueError: If a string holds a :ref:`lone surrogate `. + LoneSurrogateError: If a string holds a lone surrogate. Examples: >>> changes = {"a": Changed("v2", "fp-a2", b"Team meeting, moved")} @@ -675,23 +331,27 @@ def _ingest( f"scope {scope.id!r} is at {scope.token!r}" ) if scope.token is None and not change_set.full_listing: - raise ValueError(f"Scope {scope.id!r} has no token, so it needs a full listing") + raise ListingRequiredError( + f"Scope {scope.id!r} has no token, so it needs a full listing" + ) if change_set.full_listing: scope = self._check_listing_page(scope, change_set, first_page) claimed = self._claimed_creates(scope.id) if unique_logical_key else {} + freed: set[str] = set() for object_id, change in change_set.changes.items(): if not change_set.full_listing and isinstance(change, Unchanged): raise ValueError(f"Unchanged object {object_id!r} outside a full listing") - self._apply(scope.id, object_id, change, now, claimed) + self._apply(scope.id, object_id, change, now, claimed, freed) if change_set.full_listing: mentioned.add(object_id) if change_set.full_listing and not change_set.truncated: - self._remove_unmentioned(scope.id, mentioned, now) + self._remove_unmentioned(scope.id, mentioned, now, freed) scope = replace( scope, token=change_set.token, listing_token=None, last_full_listing_at=now ) elif not change_set.full_listing: scope = replace(scope, token=change_set.token) + self._lift_refusals(scope.id, freed, now) if not change_set.truncated: # The change set is complete: a push it didn't settle didn't land. for record in self._store.scope_records(scope.id, states=_IN_FLIGHT): @@ -720,17 +380,25 @@ def _apply( change: Change, now: datetime, claimed: dict[str, Record], + freed: set[str], ) -> None: """Apply one object's change to the store, classified against its local edit. ``claimed`` maps a logical key to the one create in flight without an object id that holds it; a new object with that key is taken as - that create, and leaves the map. + that create, and leaves the map. A logical key the server's copy held + before the change and doesn't after it goes into ``freed``. """ record = self._store.find_record(scope_id, object_id) + held = None if record is None else record.server_logical_key + if held is not None and ( + isinstance(change, Removed) + or (isinstance(change, Changed) and change.logical_key != held) + ): + freed.add(held) if isinstance(change, Unchanged): if record is None or record.server_version is None: - raise ValueError( + raise UnknownVersionError( f"Object {object_id!r} is reported unchanged, " "but the store has no version for it" ) @@ -787,7 +455,7 @@ def _apply( # Both sides arrived at the same content, so the edit is done. self._store_server_copy(record, scope_id, object_id, change, now, settled) case ChangeClass.DELETED_REMOTE if live is not None: - self._write(_removed(live, now), settled or FeedEntryKind.REMOVED, now) + self._write(live.removed_at(now), settled or FeedEntryKind.REMOVED, now) case ChangeClass.UNCHANGED | ChangeClass.LOCAL_ONLY | ChangeClass.DELETED_LOCAL if ( live is not None and (isinstance(change, Changed) or was_conflict or in_flight) ): @@ -795,7 +463,7 @@ def _apply( # content move; a conflict this settles leaves the edit to be pushed, # and so does a push in flight that hasn't landed. if isinstance(change, Changed): - live = _with_copy(live, change, change.version, now) + live = live.with_copy(change, now) if was_conflict or in_flight: self._write( replace(live, state=RecordState.PENDING, conflict=None), @@ -817,8 +485,8 @@ def _store_server_copy( settled: FeedEntryKind | None, ) -> None: """Store the server's copy as the record's synced copy, with the feed entry about it.""" - known = _new_record(scope_id, object_id) if record is None else record - synced = replace(_synced(known, change, change.version, now), object_id=object_id) + known = Record.new(scope_id, object_id) if record is None else record + synced = replace(known.synced_to(change, now), object_id=object_id) if record is None: synced = replace(synced, local_key=self._store.insert_record(synced)) self._write(synced, settled or FeedEntryKind.CHANGED, now) @@ -836,11 +504,38 @@ def _claimed_creates(self, scope_id: str) -> dict[str, Record]: found.setdefault(key, []).append(record) return {key: records[0] for key, records in found.items() if len(records) == 1} - def _remove_unmentioned(self, scope_id: str, mentioned: set[str], now: datetime) -> None: + def _remove_unmentioned( + self, scope_id: str, mentioned: set[str], now: datetime, freed: set[str] + ) -> None: """Remove every record with an object id the finished listing never mentioned.""" for record in self._store.scope_records(scope_id): if record.object_id is not None and record.object_id not in mentioned: - self._apply(scope_id, record.object_id, Removed(), now, {}) + self._apply(scope_id, record.object_id, Removed(), now, {}, freed) + + def _lift_refusals(self, scope_id: str, freed: set[str], now: datetime) -> None: + """Make each edit refused for a key in ``freed`` pending, unless a server copy holds it. + + Only a key the server stopped holding here lifts a refusal: a key + held by an object outside the scope never shows up as freed, so its + edit isn't sent again to be refused again. + """ + if not freed: + return + for record in self._store.scope_records(scope_id, states=_CONFLICT): + key = record.edit.logical_key if record.edit else None + if not record.is_refused or key is None or key not in freed: + continue + holders = [ + other + for other in self._store.find_by_logical_key(scope_id, key) + if other.local_key != record.local_key and other.server_logical_key == key + ] + if not holders: + self._write( + replace(record, state=RecordState.PENDING, conflict=None), + FeedEntryKind.CONFLICT_RESOLVED, + now, + ) def _write(self, record: Record, kind: FeedEntryKind, at: datetime) -> None: """Store a record together with the feed entry about it.""" @@ -972,10 +667,11 @@ def create( the server. A create at the object id of a record the server removed takes that record over, as the server does when it has an object at that id again: it keeps its local key and drops the removed copy. - Build ``fingerprint``, ``containers`` and ``logical_key`` with the - :term:`adapter`'s function for them, the same one it uses for the - server's changes; built any other way, an identical copy on the - server looks different. + Pass the ``fingerprint``, ``containers`` and ``logical_key`` the + :term:`adapter`'s :meth:`~calendaring_sync.adapters.base.Adapter.edit_fields` + reads from the content; built any other way, an identical copy on + the server looks different. An adapter's own ``create`` does this + for you. Runs in a write transaction of its own. @@ -994,7 +690,7 @@ def create( UnknownScopeError: If there's no scope with this id. DuplicateObjectError: If a record that isn't removed holds the object id. - ValueError: If a string holds a :ref:`lone surrogate `. + LoneSurrogateError: If a string holds a lone surrogate. Examples: >>> key = replica.create("work", b"Lunch", fingerprint="fp-l", object_id="b") @@ -1009,9 +705,7 @@ def create( edit = LocalEdit( EditKind.CREATE, content, fingerprint, containers, logical_key, self._clock() ) - created = replace( - _new_record(scope_id, object_id), state=RecordState.PENDING, edit=edit - ) + created = replace(Record.new(scope_id, object_id), state=RecordState.PENDING, edit=edit) removed = None if object_id is None else self._removed_holder(scope_id, object_id) if removed is None: return self._store.insert_record(created) @@ -1049,7 +743,7 @@ def change( OpenConflictError: If the record holds an open :term:`conflict`. RemovedRecordError: If the record was removed from the server. PushInFlightError: If the record's :term:`push` is :term:`in flight`. - ValueError: If a string holds a :ref:`lone surrogate `. + LoneSurrogateError: If a string holds a lone surrogate. Examples: >>> key = replica.find_record("work", "a").local_key @@ -1137,9 +831,11 @@ def _claim( if record is None: return None if record.scope != scope_id: - raise ValueError(f"Record {local_key} belongs to scope {record.scope!r}") + raise OtherScopeError(f"Record {local_key} belongs to scope {record.scope!r}") if object_id is not None and record.object_id is not None: - raise ValueError(f"Record {local_key} already has object id {record.object_id!r}") + raise ObjectIdAssignedError( + f"Record {local_key} already has object id {record.object_id!r}" + ) if record.state is not RecordState.PENDING or _waits_for_version( record, guarded=guarded ): @@ -1169,6 +865,7 @@ def _settle(self, claimed: Record, outcome: PushOutcome) -> bool: ): return False pending_again = record.state is RecordState.PENDING + held = record.server_logical_key # A record in flight or pending always holds an edit; the store checks it. edit = cast(LocalEdit, record.edit) match outcome: @@ -1181,13 +878,13 @@ def _settle(self, claimed: Record, outcome: PushOutcome) -> bool: case Unknown(): pass case Applied() | Gone() if edit.kind is EditKind.DELETE: - self._write(_removed(record, now), FeedEntryKind.PUSHED, now) + self._write(record.removed_at(now), FeedEntryKind.PUSHED, now) case Applied(object_id=renamed): if renamed is not None and renamed != record.object_id: self._free_object_id(record.scope, renamed, record.local_key) # The server may store the edit rewritten, so no version is kept. pushed = replace( - _synced(record, edit, None, now), object_id=renamed or record.object_id + record.synced_to(edit, now), object_id=renamed or record.object_id ) self._write(pushed, FeedEntryKind.PUSHED, now) case Gone(): @@ -1195,6 +892,9 @@ def _settle(self, claimed: Record, outcome: PushOutcome) -> bool: case _: # LogicalKeyTaken, the one outcome left. self._open_conflict(record, ConflictKind.LOGICAL_KEY_TAKEN, outcome.holder, now) + settled = self._store.get_record(record.local_key) + if held is not None and (settled is None or settled.server_logical_key != held): + self._lift_refusals(record.scope, {held}, now) return True def _open_conflict( @@ -1396,11 +1096,11 @@ def resolve(self, record: Record, resolution: Resolution) -> None: PendingEditError: For ``UpdateHolder``, if the holder holds a pending edit of its own, which this would replace. ValueError: If ``record`` isn't in conflict, or the resolution - doesn't fit its kind: ``KeepEdit`` for an edit the server + doesn't fit its kind: ``KeepLocal`` for an edit the server refused for its logical key, or ``UpdateHolder`` for any other. Examples: - >>> from calendaring_sync import KeepEdit + >>> from calendaring_sync import KeepLocal >>> key = replica.find_record("work", "a").local_key >>> replica.change(key, b"Team meeting, room 2", fingerprint="fp-mine") >>> theirs = {"a": Changed("v2", "fp-theirs", b"Team meeting, room 3")} @@ -1411,7 +1111,7 @@ def resolve(self, record: Record, resolution: Resolution) -> None: >>> record = replica.record(key) >>> record.conflict.kind - >>> replica.resolve(record, KeepEdit()) + >>> replica.resolve(record, KeepLocal()) >>> replica.record(key).state, replica.record(key).version (, 'v2') """ @@ -1431,7 +1131,7 @@ def resolve(self, record: Record, resolution: Resolution) -> None: edit = cast(LocalEdit, stored.edit) self._store.update_record(_changed_to(holder, replace(edit, recorded_at=now))) # A refused create leaves nothing; a refused change, the synced copy. - resolved = None if stored.is_create else _resolved(stored, TakeServer(), now) + resolved = None if stored.is_create else _resolved(stored, KeepServer(), now) else: resolved = _resolved(stored, resolution, now) if resolved is None: @@ -1468,7 +1168,10 @@ def resolve_conflicts(self, resolver: Callable[[Record], Resolution | None]) -> TransactionError: If a store transaction is already open in this thread. Examples: - >>> from calendaring_sync import TakeServer + An application that takes the server's side of every conflict, + without asking anyone, resolves them all in one call: + + >>> from calendaring_sync import KeepServer >>> key = replica.find_record("work", "a").local_key >>> replica.change(key, b"Team meeting, room 2", fingerprint="fp-mine") >>> theirs = {"a": Changed("v2", "fp-theirs", b"Team meeting, room 3")} @@ -1476,7 +1179,7 @@ def resolve_conflicts(self, resolver: Callable[[Record], Resolution | None]) -> ... "work", fingerprint_scheme="sha256", media_type="text/plain" ... ) as session: ... session.ingest(ChangeSet("work", "t1", "t2", theirs)) - >>> replica.resolve_conflicts(lambda record: TakeServer()) + >>> replica.resolve_conflicts(lambda record: KeepServer()) 1 >>> replica.record(key).content b'Team meeting, room 3' @@ -1551,8 +1254,8 @@ def feed_head(self) -> int: def read_feed(self, after: int = 0, limit: int = FEED_PAGE_SIZE) -> list[FeedEntry]: """Return the :term:`change feed` entries after a :term:`cursor`, oldest first. - Save the new cursor only after handling the entries, so a crash in - between reads them again instead of skipping them. + :doc:`/how-to/change-feed` shows how to keep a cursor so a crash + never skips an entry. Runs in a read transaction of its own. @@ -1564,8 +1267,8 @@ def read_feed(self, after: int = 0, limit: int = FEED_PAGE_SIZE) -> list[FeedEnt Raises: TransactionError: If a store transaction is already open in this thread. TrimmedFeedError: If entries after ``after`` were trimmed. - ValueError: If ``after`` is negative or past the last entry, - or ``limit`` is outside 1 to ``FEED_PAGE_SIZE``. + FeedRangeError: If ``after`` is negative or past the last entry. + ValueError: If ``limit`` is outside 1 to ``FEED_PAGE_SIZE``. Examples: >>> [(entry.number, entry.kind) for entry in replica.read_feed(after=0)] @@ -1590,7 +1293,7 @@ def trim_feed(self, through: int) -> None: Raises: TransactionError: If a store transaction is already open in this thread. - ValueError: If ``through`` is negative or past the last entry. + FeedRangeError: If ``through`` is negative or past the last entry. Examples: >>> replica.trim_feed(replica.feed_head()) @@ -1618,7 +1321,7 @@ def purge(self, through: int) -> int: Raises: TransactionError: If a store transaction is already open in this thread. - ValueError: If ``through`` is negative or past the last entry. + FeedRangeError: If ``through`` is negative or past the last entry. Examples: >>> from calendaring_sync import Removed @@ -1642,5 +1345,11 @@ def _checked_feed_bounds(self, number: int, name: str) -> int: """Check a feed number against the last entry, and return where the feed is trimmed.""" trimmed, head = self._store.feed_bounds() if not 0 <= number <= head: - raise ValueError(f"{name} must be between 0 and the last entry, {head}, not {number}") + raise FeedRangeError( + f"{name} must be between 0 and the last entry, {head}, not {number}" + ) return trimmed + + +# Last, since session.py imports Replica from this module. +from calendaring_sync.session import Session # noqa: E402 diff --git a/src/calendaring_sync/session.py b/src/calendaring_sync/session.py new file mode 100644 index 0000000..3dd1802 --- /dev/null +++ b/src/calendaring_sync/session.py @@ -0,0 +1,305 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""A :term:`session`, one sync of one :term:`scope`: it ingests what changed, then pushes.""" + +from __future__ import annotations + +from enum import StrEnum + +from calendaring_sync._checks import check_argument_types, check_text +from calendaring_sync.error import OtherScopeError, SessionError, StaleChangeSetError +from calendaring_sync.objects.change_set import ChangeSet +from calendaring_sync.objects.edit import EditKind +from calendaring_sync.objects.push import Applied, Gone, LogicalKeyTaken, PushOutcome +from calendaring_sync.objects.record import Record, RecordState +from calendaring_sync.replica import Replica + + +def _check_outcome(claimed_keys: set[int], claimed: Record, outcome: PushOutcome) -> None: + """Raise ``ValueError`` unless the outcome is one a push of the claimed edit can have.""" + if ( + claimed.local_key not in claimed_keys + or claimed.state is not RecordState.IN_FLIGHT + or claimed.edit is None + ): + raise ValueError("settle() takes a record this session's claim() returned") + kind = claimed.edit.kind + match outcome: + case Gone() if kind is EditKind.CREATE: + raise ValueError("A create can't be gone: the server never had the object") + case LogicalKeyTaken() if kind is EditKind.DELETE: + raise ValueError("A delete can't be refused for its logical key") + case Applied(object_id=str()) if kind is EditKind.DELETE: + raise ValueError("An applied delete has no object id to report") + case Applied(object_id=None) if kind is EditKind.CREATE and claimed.object_id is None: + raise ValueError("An applied create needs the object id the server gave it") + + +class SessionState(StrEnum): + """Where a :term:`session` is.""" + + #: Nothing ingested yet. + IDLE = "idle" + #: A :term:`truncated page` was ingested, and more pages follow. + INGESTING = "ingesting" + #: The last page was ingested, and the token is final. + INGESTED = "ingested" + #: A :term:`push` was claimed; no more pages can be ingested. + PUSHING = "pushing" + + +@check_argument_types +class Session: + """One sync of one :term:`scope`, opened with :meth:`~calendaring_sync.replica.Replica.session`. + + A session :term:`ingests ` every page first, then the + :term:`adapter` pushes your :term:`pending edits `: for each + record :meth:`to_push` lists, it :meth:`claims ` it, sends it, and + :meth:`settles ` it with how the push ended. + + Every method raises :class:`TypeError` for an argument of the wrong + type, before it changes anything. + + Examples: + The adapter pushes an edit after ingesting what changed on the server, + here nothing: + + >>> from calendaring_sync import Applied + >>> key = replica.find_record("work", "a").local_key + >>> replica.change(key, b"Team meeting, moved", fingerprint="fp-a2") + >>> with replica.session( + ... "work", fingerprint_scheme="sha256", media_type="text/plain" + ... ) as session: + ... session.ingest(ChangeSet("work", "t1", "t2", {})) + ... for record in session.to_push(): + ... claimed = session.claim(record.local_key) + ... # The adapter sends claimed.edit to the server here. + ... session.settle(claimed, Applied()) + True + >>> replica.record(key).state, replica.record(key).content + (, b'Team meeting, moved') + """ + + def __init__( + self, replica: Replica, scope_id: str, *, guarded_by_version: bool, unique_logical_key: bool + ) -> None: + self._replica = replica + self._scope_id = scope_id + self._guarded_by_version = guarded_by_version + self._unique_logical_key = unique_logical_key + self._state = SessionState.IDLE + self._listing: bool | None = None + self._mentioned: set[str] = set() + self._closed = False + # The local keys this session claimed, the only ones it settles. + self._claimed: set[int] = set() + + @property + def scope_id(self) -> str: + """The id of the session's scope.""" + return self._scope_id + + @property + def state(self) -> SessionState: + """Where the session is.""" + return self._state + + def ingest(self, change_set: ChangeSet) -> None: + """:term:`Ingest` one change set, or one page of it, in one transaction. + + The core applies each object in it: a new object gets a record, a + changed one gets the new data, and a removed one is marked removed. + An object whose :term:`fingerprint`, containers and logical key + didn't change only gets its version and the server's content, and isn't + reported. A :term:`truncated page` of an :term:`incremental change set` + moves the token to its :term:`checkpoint`. A :term:`full listing` + moves the token only on its last page, which also removes every + record the listing never mentioned. A complete change set, or the + last page, also settles every :term:`push` still :term:`in flight` + from what the server reported, and one it doesn't mention is + pending again. If anything fails, nothing changes. + + Raises: + TransactionError: If a store transaction is already open in this thread. + StaleChangeSetError: If the change set was fetched from a token + the scope no longer has, or is a later page of a listing with + another token, such as one another session has replaced. The + session goes back to idle, so the adapter can fetch again and + ingest from the start in the same session. + SessionError: If the session has ended, already ingested its last + page, has started pushing, or the page is a listing page in an + incremental session or the other way round. + OtherScopeError: If the change set is for another scope. + ListingRequiredError: If an incremental change set arrives for a + scope with no token. + UnknownVersionError: If an ``Unchanged`` entry names a record + with no version. + ValueError: If an ``Unchanged`` entry is outside a listing. + """ + if change_set.scope != self._scope_id: + raise OtherScopeError( + f"Change set for {change_set.scope!r} in a session for {self._scope_id!r}" + ) + if self._closed: + raise SessionError("The session has ended") + if self._state is SessionState.INGESTED: + raise SessionError("The session already ingested its last page") + if self._state is SessionState.PUSHING: + raise SessionError("The session already pushed, and a change set can't come after that") + if self._listing is not None and self._listing != change_set.full_listing: + raise SessionError("One session's pages are all listing pages or all incremental") + try: + self._mentioned = self._replica._ingest( + change_set, + first_page=self._state is SessionState.IDLE, + mentioned=self._mentioned, + unique_logical_key=self._unique_logical_key, + ) + except StaleChangeSetError: + self._restart() + raise + self._listing = change_set.full_listing + self._state = SessionState.INGESTING if change_set.truncated else SessionState.INGESTED + + def to_push(self) -> list[Record]: + """Return the :term:`pending edits ` ready to :term:`push`, by local key. + + With ``guarded_by_version``, a change or delete of a record with no + :term:`version` waits for an ingest that gives it one, and isn't + listed. Runs in a read transaction of its own. + + Raises: + TransactionError: If a store transaction is already open in this thread. + SessionError: If the session hasn't ingested its last page, or has ended. + + Examples: + >>> key = replica.find_record("work", "a").local_key + >>> replica.change(key, b"Team meeting, moved", fingerprint="fp-a2") + >>> with replica.session( + ... "work", fingerprint_scheme="sha256", media_type="text/plain" + ... ) as session: + ... session.ingest(ChangeSet("work", "t1", "t2", {})) + ... [record.local_key for record in session.to_push()] == [key] + True + """ + self._check_ready() + return self._replica._to_push(self._scope_id, guarded=self._guarded_by_version) + + def claim(self, local_key: int, *, object_id: str | None = None) -> Record | None: + """Mark a :term:`pending edit` :term:`in flight`, so no other process pushes it. + + Send the record this returns, then report how the push ended with + :meth:`settle`. Runs in a write transaction of its own; when two + processes claim one record, only one gets it. + + Args: + local_key: The record to push. + object_id: For a create the server takes ids for, the id it's + about to be sent under. It's stored with the claim, so the + create is recognized by it even if the answer never arrives. + An id a removed record holds passes to this one. + + Returns: + The claimed record, or ``None`` when it no longer holds a + pending edit to push: another process claimed it, the edit was + discarded or resolved, or it waits for a version. + + Raises: + TransactionError: If a store transaction is already open in this thread. + SessionError: If the session hasn't ingested its last page, or has ended. + DuplicateObjectError: If a record that isn't removed holds ``object_id``. + OtherScopeError: If the record belongs to another scope. + ObjectIdAssignedError: If ``object_id`` is given for a record + that has one. + LoneSurrogateError: If a string holds a lone surrogate. + + Examples: + A second claim of the same record gets nothing: + + >>> key = replica.find_record("work", "a").local_key + >>> replica.change(key, b"Team meeting, moved", fingerprint="fp-a2") + >>> with replica.session( + ... "work", fingerprint_scheme="sha256", media_type="text/plain" + ... ) as session: + ... session.ingest(ChangeSet("work", "t1", "t2", {})) + ... claimed = session.claim(key) + ... claimed.state, session.claim(key) + (, None) + """ + check_text(object_id=object_id) + self._check_ready() + claimed = self._replica._claim( + self._scope_id, local_key, object_id, guarded=self._guarded_by_version + ) + if claimed is not None: + self._state = SessionState.PUSHING + self._claimed.add(local_key) + return claimed + + def settle(self, claimed: Record, outcome: PushOutcome) -> bool: + """Record how the :term:`push` of a claimed record ended. + + Runs in a write transaction of its own, and writes a :term:`change + feed` entry for every outcome but ``Unknown``. If another process + already settled this push, for example by ingesting while it was + in flight, the outcome only counts while the record still holds the + same edit and no one has claimed it again. + + Args: + claimed: The record :meth:`claim` returned. + outcome: How the push ended. + + Returns: + ``True`` if the store now agrees with the outcome, ``False`` if + the push had already been settled another way and nothing was + written. + + Raises: + TransactionError: If a store transaction is already open in this thread. + SessionError: If the session has ended. + DuplicateObjectError: If ``Applied`` names an id a record that + isn't removed holds. + ValueError: If ``claimed`` isn't a record this session's + :meth:`claim` returned, or the outcome is one no push of its + edit can have: ``Gone`` for a create, ``LogicalKeyTaken`` for + a delete, ``Applied`` with an id for a delete, or + ``Applied`` without one for a create that has none. + + Examples: + The server refused the write, so the edit is pending again: + + >>> from calendaring_sync import Rejected + >>> key = replica.find_record("work", "a").local_key + >>> replica.change(key, b"Team meeting, moved", fingerprint="fp-a2") + >>> with replica.session( + ... "work", fingerprint_scheme="sha256", media_type="text/plain" + ... ) as session: + ... session.ingest(ChangeSet("work", "t1", "t2", {})) + ... session.settle(session.claim(key), Rejected()) + True + >>> replica.record(key).state + + """ + _check_outcome(self._claimed, claimed, outcome) + if self._closed: + raise SessionError("The session has ended") + return self._replica._settle(claimed, outcome) + + def _check_ready(self) -> None: + """Raise unless the session may push: its last page is ingested and it hasn't ended.""" + if self._closed: + raise SessionError("The session has ended") + if self._state not in (SessionState.INGESTED, SessionState.PUSHING): + raise SessionError("A session pushes only after it ingested its last page") + + def _restart(self) -> None: + """Go back to idle, dropping a half-done listing.""" + self._state = SessionState.IDLE + self._listing = None + self._mentioned = set() + + def _end(self) -> None: + """Close the session when its ``with`` block ends.""" + self._closed = True + self._restart() diff --git a/src/calendaring_sync/store/__init__.py b/src/calendaring_sync/store/__init__.py index abe09da..08fb1b0 100644 --- a/src/calendaring_sync/store/__init__.py +++ b/src/calendaring_sync/store/__init__.py @@ -1,2 +1,7 @@ # SPDX-FileCopyrightText: 2026 calendaring-sync contributors # SPDX-License-Identifier: AGPL-3.0-or-later + +"""Where the core keeps sync state. + +The interface every :term:`store` implements, and the stores that ship. +""" diff --git a/src/calendaring_sync/store/base.py b/src/calendaring_sync/store/base.py index 97b58c7..6fe354f 100644 --- a/src/calendaring_sync/store/base.py +++ b/src/calendaring_sync/store/base.py @@ -54,6 +54,13 @@ } +class _Transaction(local): + """This thread's open transaction.""" + + #: Whether it writes, or ``None`` when the thread has none open. + write: bool | None = None + + def _check_record(record: Record) -> None: """Raise :class:`RecordValueError` if the record holds what its state can't.""" state, edit, conflict = record.state, record.edit, record.conflict @@ -120,8 +127,10 @@ class StateStore(ABC): The public methods check that they run inside a transaction of the right kind, then call a method of the same name with a leading underscore. A store implements those, along with :meth:`_begin`, :meth:`_commit` and - :meth:`_rollback`, and needs no checks of its own. It turns the errors of - the storage it uses into store errors by overriding :meth:`_store_error`. + :meth:`_rollback`, and needs no checks of its own; it may also override + :meth:`_scope_records_in` to filter records by state itself. It turns + the errors of the storage it uses into store errors by overriding + :meth:`_store_error`. Its ``__init__`` calls ``super().__init__()`` first. A hook runs in the thread of the transaction it's for, and :meth:`_writing` says whether that transaction writes. @@ -138,8 +147,7 @@ def __init__(self) -> None: # Held by the write transaction, or by every transaction of a store # that doesn't read beside a write. self._turn = Lock() - # This thread's open transaction: whether it writes, or None. - self._thread = local() + self._thread = _Transaction() self._open = 0 self._closed = False @@ -167,8 +175,8 @@ def transaction(self, *, write: bool = True) -> Iterator[None]: if self._in_transaction(): raise TransactionError("A store transaction is already open in this thread") self._open += 1 - waits = write or not self._reads_beside_writes try: + waits = write or not self._reads_beside_writes if waits: self._turn.acquire() try: @@ -193,11 +201,11 @@ def transaction(self, *, write: bool = True) -> Iterator[None]: def _in_transaction(self) -> bool: """Whether this thread has a transaction open.""" - return getattr(self._thread, "write", None) is not None + return self._thread.write is not None def _writing(self) -> bool: """Whether this thread's open transaction writes.""" - return getattr(self._thread, "write", None) is True + return self._thread.write is True def _operation(self, *, write: bool) -> AbstractContextManager[None]: """Check that this thread's transaction allows the operation, then guard its errors.""" @@ -404,16 +412,15 @@ def scope_records( Args: scope_id: The scope to read. states: Only the records in one of these states; ``None`` for - every record. An empty set matches none, without asking the - store, since not every database takes an empty list. + every record. An empty set always returns an empty list. Raises: TransactionRequiredError: If no transaction is open in this thread. """ with self._operation(write=False): - if states is not None and not states: - return [] - return self._scope_records(scope_id, states) + if states is None: + return self._scope_records(scope_id) + return self._scope_records_in(scope_id, states) if states else [] def removed_records(self, through: int) -> list[Record]: """Return the removed records no feed entry after ``through`` is about. @@ -563,9 +570,16 @@ def _find_record(self, scope_id: str, object_id: str) -> Record | None: ... def _find_by_logical_key(self, scope_id: str, logical_key: str) -> list[Record]: ... @abstractmethod - def _scope_records( - self, scope_id: str, states: frozenset[RecordState] | None - ) -> list[Record]: ... + def _scope_records(self, scope_id: str) -> list[Record]: ... + + def _scope_records_in(self, scope_id: str, states: frozenset[RecordState]) -> list[Record]: + """The scope's records in one of ``states``, ordered by local key. + + It filters what ``_scope_records`` returns. A store that can + filter where it keeps its records, such as with an index, overrides + it. It never gets an empty set. + """ + return [record for record in self._scope_records(scope_id) if record.state in states] @abstractmethod def _removed_records(self, through: int) -> list[Record]: ... diff --git a/src/calendaring_sync/store/memory.py b/src/calendaring_sync/store/memory.py index ae9a7c5..db1e49d 100644 --- a/src/calendaring_sync/store/memory.py +++ b/src/calendaring_sync/store/memory.py @@ -36,6 +36,13 @@ def copy(self) -> _Data: ) +class _View(local): + """This thread's open transaction's view of the data.""" + + #: The view, or ``None`` when the thread has no transaction open. + data: _Data | None = None + + @check_argument_types class MemoryStore(StateStore): """A store held in memory and lost when the process ends. @@ -105,14 +112,13 @@ class MemoryStore(StateStore): def __init__(self) -> None: super().__init__() self._committed = _Data() - # Each thread's open transaction's view of the data. - self._views = local() + self._view = _View() @property def _data(self) -> _Data: """This thread's transaction's view of the data.""" - data: _Data = self._views.data - return data + # Every hook runs inside a transaction, after _begin set the view. + return cast(_Data, self._view.data) def _load(self) -> _Data: """Return the data the last committed write saved.""" @@ -124,7 +130,7 @@ def _save(self, data: _Data) -> None: def _begin(self, *, write: bool) -> None: data = self._load() - self._views.data = data.copy() if write else data + self._view.data = data.copy() if write else data def _commit(self) -> None: data = self._end_view() @@ -138,7 +144,7 @@ def _rollback(self) -> None: def _end_view(self) -> _Data | None: """Drop and return this thread's view, so an idle thread keeps no old copy of the data.""" - data: _Data | None = vars(self._views).pop("data", None) + data, self._view.data = self._view.data, None return data def _get_scope(self, scope_id: str) -> Scope | None: @@ -182,16 +188,12 @@ def _find_record(self, scope_id: str, object_id: str) -> Record | None: def _find_by_logical_key(self, scope_id: str, logical_key: str) -> list[Record]: return [ r - for r in self._scope_records(scope_id, None) + for r in self._scope_records(scope_id) if logical_key in (r.logical_key, r.edit and r.edit.logical_key) ] - def _scope_records(self, scope_id: str, states: frozenset[RecordState] | None) -> list[Record]: - return [ - r - for r in self._ordered_records() - if r.scope == scope_id and (states is None or r.state in states) - ] + def _scope_records(self, scope_id: str) -> list[Record]: + return [r for r in self._ordered_records() if r.scope == scope_id] def _removed_records(self, through: int) -> list[Record]: return [ diff --git a/src/calendaring_sync/store/sqlite.py b/src/calendaring_sync/store/sqlite.py index de64f7d..9da3975 100644 --- a/src/calendaring_sync/store/sqlite.py +++ b/src/calendaring_sync/store/sqlite.py @@ -314,6 +314,12 @@ def _load(reader: _Reader, row: sqlite3.Row) -> Any: _PRIVATE = (":memory:", "") +class _ReadTurn(local): + """Whether this thread's transaction holds the read turn.""" + + taken: bool = False + + def _connect(path: str | os.PathLike[str], timeout: float) -> sqlite3.Connection: """Open a connection to the store's file the way every one of its connections is opened.""" conn = sqlite3.connect(path, timeout=timeout, isolation_level=None, check_same_thread=False) @@ -368,7 +374,7 @@ def __init__(self, path: str | os.PathLike[str], *, timeout: float = DEFAULT_TIM # process is open while it runs. Each thread notes whether it has the # turn, so only the thread that took it gives it back. self._read_turn = Lock() - self._turn_taken = local() + self._turn_taken = _ReadTurn() with self._errors(): self._writer = _connect(path, timeout) try: @@ -478,7 +484,7 @@ def _take_read_turn(self) -> None: def _end_read(self) -> None: """Give the read turn back, if this thread's transaction took it.""" - if getattr(self._turn_taken, "taken", False): + if self._turn_taken.taken: self._turn_taken.taken = False self._read_turn.release() @@ -539,9 +545,10 @@ def _find_by_logical_key(self, scope_id: str, logical_key: str) -> list[Record]: (scope_id, logical_key, scope_id, logical_key), ) - def _scope_records(self, scope_id: str, states: frozenset[RecordState] | None) -> list[Record]: - if states is None: - return self._select(Record, _RECORDS, "WHERE scope = ? ORDER BY local_key", (scope_id,)) + def _scope_records(self, scope_id: str) -> list[Record]: + return self._select(Record, _RECORDS, "WHERE scope = ? ORDER BY local_key", (scope_id,)) + + def _scope_records_in(self, scope_id: str, states: frozenset[RecordState]) -> list[Record]: marks = ", ".join("?" for _ in states) return self._select( Record, diff --git a/src/calendaring_sync/tests/_examples.py b/src/calendaring_sync/tests/_examples.py new file mode 100644 index 0000000..ad814eb --- /dev/null +++ b/src/calendaring_sync/tests/_examples.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2026 calendaring-sync contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Running the documentation's examples as tests. + +Every Python block on a docs page comes from a file in ``docs/examples``. +The unit tests run the examples that need no server; the integration tests +run the others against a running server, each with the server's details in +place of the example's placeholder ones. +""" + +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +#: Where the examples are. +EXAMPLES = Path(__file__).resolve().parents[3] / "docs" / "examples" +#: The examples that talk to a server, run by the integration tests. +LIVE = frozenset({"quickstart", "caldav_sync", "async_sync", "jmap_sync", "every_calendar"}) +#: The modules other examples import, not run on their own. +SHARED = frozenset({"memory_adapter", "start"}) + + +def names() -> list[str]: + """Every example's name.""" + return sorted(path.stem for path in EXAMPLES.glob("*.py")) + + +def load(name: str) -> ModuleType: + """Import one of the shared example modules, as the examples do.""" + if name not in sys.modules: + spec = importlib.util.spec_from_file_location(name, EXAMPLES / f"{name}.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + # No bytecode is left in the docs folder. + writes, sys.dont_write_bytecode = sys.dont_write_bytecode, True + try: + spec.loader.exec_module(module) + finally: + sys.dont_write_bytecode = writes + return sys.modules[name] + + +def run( + name: str, monkeypatch: pytest.MonkeyPatch, folder: Path, **settings: object +) -> dict[str, Any]: + """Run an example in ``folder`` and return the names it left. + + Each keyword replaces the example's top-level assignment of that name, + such as a server's URL. + """ + source = (EXAMPLES / f"{name}.py").read_text(encoding="utf-8") + for setting, value in settings.items(): + source, found = re.subn(rf"^{setting} = .*$", f"{setting} = {value!r}", source, flags=re.M) + assert found == 1, f"{name} has no {setting} to replace" + monkeypatch.chdir(folder) + monkeypatch.syspath_prepend(str(EXAMPLES)) + monkeypatch.setattr(sys, "dont_write_bytecode", True) + load("memory_adapter") + # The starting state is new for every example. + monkeypatch.delitem(sys.modules, "start", raising=False) + namespace: dict[str, Any] = {"__name__": "__main__"} + exec(compile(source, str(EXAMPLES / f"{name}.py"), "exec"), namespace) + return namespace diff --git a/src/calendaring_sync/tests/_live.py b/src/calendaring_sync/tests/_live.py index adddff1..2d962a6 100644 --- a/src/calendaring_sync/tests/_live.py +++ b/src/calendaring_sync/tests/_live.py @@ -9,9 +9,11 @@ from __future__ import annotations +import socket from collections.abc import Callable, Iterator from contextlib import contextmanager from typing import Any +from urllib.parse import urlsplit import pytest @@ -20,6 +22,16 @@ from calendaring_sync.tests._tape import Tape +def answers(url: str) -> bool: + """Whether a server listens at the host and port of ``url``.""" + parts = urlsplit(url) + try: + socket.create_connection((parts.hostname, parts.port), timeout=1).close() + except OSError: + return False + return True + + def identical_write(env: Env) -> None: """An object written again as it was changes nothing the feed reports.""" env.synced("a") diff --git a/src/calendaring_sync/tests/_scenarios.py b/src/calendaring_sync/tests/_scenarios.py index c4260d5..4f9bd92 100644 --- a/src/calendaring_sync/tests/_scenarios.py +++ b/src/calendaring_sync/tests/_scenarios.py @@ -347,6 +347,11 @@ def done(step: Any) -> Any: return asyncio.run(step) if asyncio.iscoroutine(step) else step +def fields_of(held: Any) -> tuple[Any, Any, Any]: + """The fingerprint, containers and logical key an edit or a read of content holds.""" + return held.fingerprint, held.containers, held.logical_key + + def record(scenarios: Any, library: tuple[str, str]) -> None: """The recorder's command line: record one server's scenarios, or all its recordings. diff --git a/src/calendaring_sync/tests/test_sync_integration.py b/src/calendaring_sync/tests/test_caldav_integration.py similarity index 69% rename from src/calendaring_sync/tests/test_sync_integration.py rename to src/calendaring_sync/tests/test_caldav_integration.py index 6ba922c..7c2cd55 100644 --- a/src/calendaring_sync/tests/test_sync_integration.py +++ b/src/calendaring_sync/tests/test_caldav_integration.py @@ -14,14 +14,12 @@ from __future__ import annotations import os -import socket import sys import time from collections.abc import Iterator from functools import partial from typing import Any from unittest.mock import patch -from urllib.parse import urlsplit import pytest import radicale.config @@ -31,6 +29,7 @@ from calendaring_sync import RecordState from calendaring_sync.tests import _caldav_scenarios as scenarios +from calendaring_sync.tests import _examples from calendaring_sync.tests._caldav_scenarios import ( SCENARIOS, SERVERS, @@ -39,10 +38,13 @@ Server, adapter_class, caldav_client, + calendar_path, + ics, started, ) from calendaring_sync.tests._live import ( CHECKS, + answers, live_environment, refused_login, removed_and_created, @@ -52,17 +54,6 @@ put_a_and_b, ) - -def answers(url: str) -> bool: - """Whether a server listens at the host and port of ``url``.""" - parts = urlsplit(url) - try: - socket.create_connection((parts.hostname, parts.port), timeout=1).close() - except OSError: - return False - return True - - # Set to run the scenario that needs Stalwart's result limit lowered to 2, # as record_caldav's docstring says; the other Stalwart tests need the default. _LIMITED_STALWART = "CALENDARING_SYNC_STALWART_LIMITED" @@ -70,6 +61,8 @@ def answers(url: str) -> bool: _TOKEN_AGE = 1 # Where that Radicale listens, next to the one every other test uses. _EXPIRY_PORT = 5233 +# Where the Radicale of the docs' examples listens, with nothing else on it. +_EXAMPLES_PORT = 5234 @pytest.fixture(scope="session", params=sorted(SERVERS)) @@ -164,3 +157,49 @@ def short_lived_tokens(*args: Any) -> Any: refused = [e for e in env.tape.exchanges if e.method == "REPORT" and e.status == 403] assert len(refused) == 1 and "valid-sync-token" in refused[0].body assert env.at(key).state is RecordState.SYNCED + + +@pytest.mark.parametrize("example", sorted(_examples.LIVE - {"jmap_sync", "every_calendar"})) +def test_the_docs_examples(example, tmp_path, monkeypatch, capsys): + server = SERVERS["radicale"] + with started(server, {"port": _EXAMPLES_PORT}) as (url, username, password): + calendar_url = f"{url}{calendar_path(server, 'docs')}" + client = caldav_client(False, url=url, username=username, password=password) + client.request(calendar_url, "MKCALENDAR") + client.request( + f"{calendar_url}meeting.ics", + "PUT", + ics("meeting", "Team meeting"), + {"Content-Type": "text/calendar"}, + ) + client.close() + settings = {"URL": url, "USERNAME": username, "PASSWORD": password} + if example == "caldav_sync": + settings.update(CALENDAR_URL=calendar_url, SERVER=server.name) + ran = _examples.run(example, monkeypatch, tmp_path, **settings) + if example == "quickstart": + # The store is closed by then; the example printed the feed. + assert capsys.readouterr().out == "changed /user1/calendaring-sync-docs/meeting.ics\n" + else: + replica = ran["replica"] + held = [r.logical_key for scope in replica.scopes() for r in replica.records(scope.id)] + assert held == ["meeting"] + + +def test_the_docs_example_of_every_calendar(tmp_path, monkeypatch): + server = SERVERS["radicale"] + with started(server, {"port": _EXAMPLES_PORT}) as (url, username, password): + client = caldav_client(False, url=url, username=username, password=password) + calendars = [f"{url}{calendar_path(server, name)}" for name in ("work", "home")] + for calendar_url in calendars: + client.request(calendar_url, "MKCALENDAR") + login = {"URL": url, "USERNAME": username, "PASSWORD": password} + ran = _examples.run("every_calendar", monkeypatch, tmp_path, **login) + assert len(ran["adapters"]) == 2 and ran["dropped"] == [] + # A calendar deleted on the server loses its scope at the next run. + client.request(calendars[1], "DELETE") + client.close() + ran = _examples.run("every_calendar", monkeypatch, tmp_path, **login) + assert [d.scope for d in ran["dropped"]] == [ + ran["adapters"][0].scope_id.replace("work", "home") + ] diff --git a/src/calendaring_sync/tests/test_caldav_unit.py b/src/calendaring_sync/tests/test_caldav_unit.py index 4a577c2..d60875d 100644 --- a/src/calendaring_sync/tests/test_caldav_unit.py +++ b/src/calendaring_sync/tests/test_caldav_unit.py @@ -31,6 +31,7 @@ from calendaring_sync import ( ConflictKind, + OtherScopeError, RecordState, ScopeGoneError, StateStore, @@ -38,7 +39,7 @@ UnknownScopeError, ) from calendaring_sync.adapters import caldav as caldav_adapter -from calendaring_sync.adapters._flow import Steps, _arun, _InStore +from calendaring_sync.adapters.base import InStore, Steps, _arun from calendaring_sync.adapters.caldav import ( FINGERPRINT_SCHEME, MEDIA_TYPE, @@ -47,7 +48,8 @@ _fields, _server_fields, ) -from calendaring_sync.replica import Replica, Session +from calendaring_sync.replica import Replica +from calendaring_sync.session import Session from calendaring_sync.tests import _caldav_scenarios as scenarios from calendaring_sync.tests import record_caldav from calendaring_sync.tests._caldav_scenarios import ( @@ -66,6 +68,7 @@ done, edit_a, edit_answer, + fields_of, forgotten_edit, put_a_and_b, recorded, @@ -260,16 +263,16 @@ def fails() -> None: raise RuntimeError("the store call failed") def ends() -> Steps[None]: - seen.append((yield _InStore(returns, ("done",)))) + seen.append((yield InStore(returns, ("done",)))) def is_told() -> Steps[None]: try: - yield _InStore(fails) + yield InStore(fails) except RuntimeError as exc: seen.append(str(exc)) def goes_on() -> Steps[None]: - seen.append((yield _InStore(returns, ("on",)))) + seen.append((yield InStore(returns, ("on",)))) yield later for steps in (ends(), is_told(), goes_on()): @@ -511,6 +514,14 @@ def test_a_client_without_features_needs_no_full_listings(self, store): True, id="create, no UID", ), + pytest.param( + lambda a: a.edit_fields(_ical(_with("UID:event-1"))), + _SCOPE, + ValueError, + "with a UID", + True, + id="fields, no UID", + ), pytest.param( lambda a: a.change(1, _NOT_UTF8), _SCOPE, @@ -546,7 +557,7 @@ def test_a_client_without_features_needs_no_full_listings(self, store): pytest.param( lambda a: a.change(1, _ical(_EVENT)), "nope", - ValueError, + OtherScopeError, "^Record 1 is in scope 'cal', not this one$", False, id="other scope", @@ -575,6 +586,7 @@ def test_an_edit_gets_the_fields_a_sync_gives(self, store, asynchronous): fields.logical_key, frozenset({_SCOPE}), ) + assert fields_of(adapter.edit_fields(_ical(_EVENT))) == fields_of(edit) assert re.fullmatch(r"/cal/[0-9a-f-]{36}\.ics", record.object_id or "") diff --git a/src/calendaring_sync/tests/test_jmap_integration.py b/src/calendaring_sync/tests/test_jmap_integration.py index 1de5cff..0d25eeb 100644 --- a/src/calendaring_sync/tests/test_jmap_integration.py +++ b/src/calendaring_sync/tests/test_jmap_integration.py @@ -32,6 +32,7 @@ from calendaring_sync import RecordState from calendaring_sync.adapters import jmap as adapter_module from calendaring_sync.adapters.jmap import PAGE_SIZE +from calendaring_sync.tests import _examples from calendaring_sync.tests import _jmap_scenarios as scenarios from calendaring_sync.tests._jmap_scenarios import ( SCENARIOS, @@ -43,6 +44,7 @@ ) from calendaring_sync.tests._live import ( CHECKS, + answers, created_and_removed, live_environment, refused_login, @@ -53,7 +55,6 @@ edit_a, ) from calendaring_sync.tests._tape import Exchange -from calendaring_sync.tests.test_sync_integration import answers # Set to run the test that makes Stalwart forget its change history, which # turns every later incremental sync on that server into a full listing @@ -276,3 +277,11 @@ def test_an_expired_state_lists_the_account_again(live, asynchronous): ) assert env.at(key).state is RecordState.SYNCED assert env.record("b").state is RecordState.SYNCED + + +def test_the_docs_example(live, tmp_path, monkeypatch): + _, (url, username, password) = live + ran = _examples.run( + "jmap_sync", monkeypatch, tmp_path, URL=url, USERNAME=username, PASSWORD=password + ) + assert ran["replica"].scope(ran["adapter"].scope_id).last_sync_at is not None diff --git a/src/calendaring_sync/tests/test_jmap_unit.py b/src/calendaring_sync/tests/test_jmap_unit.py index 477718b..b10732a 100644 --- a/src/calendaring_sync/tests/test_jmap_unit.py +++ b/src/calendaring_sync/tests/test_jmap_unit.py @@ -29,6 +29,7 @@ EditKind, Gone, LogicalKeyTaken, + OtherScopeError, RecordState, Rejected, Replica, @@ -61,6 +62,7 @@ create_c, done, edit_answer, + fields_of, forgotten_edit, recorded, replaying, @@ -394,6 +396,14 @@ def test_a_client_of_the_other_kind_is_refused(self, store): True, id="no calendar", ), + pytest.param( + lambda a: a.edit_fields(_content(calendarIds={})), + _SCOPE, + ValueError, + "in a calendar", + True, + id="fields, no calendar", + ), pytest.param( lambda a: a.change(1, _content(uid="")), _SCOPE, @@ -424,7 +434,7 @@ def test_a_client_of_the_other_kind_is_refused(self, store): pytest.param( lambda a: a.change(1, _content()), "nope", - ValueError, + OtherScopeError, f"^Record 1 is in scope '{_SCOPE}', not this one$", False, id="other scope", @@ -453,6 +463,7 @@ def test_an_edit_gets_the_fields_a_sync_gives(self, store, asynchronous): "event-1", frozenset({_CALENDAR}), ) + assert fields_of(adapter.edit_fields(_content())) == fields_of(edit) # The server picks a created event's id, and no JMAP edit waits. assert record.object_id is None assert done(adapter.waiting()) == [] diff --git a/src/calendaring_sync/tests/test_sync_unit.py b/src/calendaring_sync/tests/test_sync_unit.py index 7a4a683..e8a3aed 100644 --- a/src/calendaring_sync/tests/test_sync_unit.py +++ b/src/calendaring_sync/tests/test_sync_unit.py @@ -24,12 +24,13 @@ from collections import Counter from collections.abc import Callable, Iterator, Mapping from concurrent.futures import ThreadPoolExecutor -from contextlib import AbstractContextManager, ExitStack, contextmanager, suppress +from contextlib import AbstractContextManager, ExitStack, closing, contextmanager, suppress from dataclasses import dataclass, replace from datetime import UTC, datetime, timedelta from functools import partial from pathlib import Path from threading import Event +from types import SimpleNamespace from typing import Any, Literal from unittest.mock import patch @@ -45,15 +46,23 @@ ConflictKind, Delete, DuplicateObjectError, + EditFields, EditKind, + FeedEntry, FeedEntryKind, + FeedRangeError, Gone, - KeepEdit, + KeepLocal, + KeepServer, + ListingRequiredError, LocalEdit, LogicalKeyTaken, + LoneSurrogateError, MemoryStore, Merge, + ObjectIdAssignedError, OpenConflictError, + OtherScopeError, PendingEditError, PushInFlightError, PushOutcome, @@ -65,6 +74,7 @@ RemovedRecordError, Replica, Scope, + ScopeGoneError, ScopeMismatchError, Session, SessionError, @@ -76,7 +86,6 @@ StoreBusyError, StoreError, StoreVersionError, - TakeServer, TransactionError, TransactionPermissionError, TransactionRequiredError, @@ -87,16 +96,17 @@ UnknownHolderError, UnknownRecordError, UnknownScopeError, + UnknownVersionError, UpdateHolder, ) from calendaring_sync._checks import _CHECKED_WHEN_BUILT, _fields, argument_check -from calendaring_sync.adapters._flow import Steps, _EditFields, _Flow, _InStore, _Read, _run +from calendaring_sync.adapters.base import Adapter, AsyncAdapter, Steps, SyncAdapter from calendaring_sync.adapters.caldav import AsyncCalDAVAdapter, CalDAVAdapter, _CalDAVFlow from calendaring_sync.adapters.jmap import AsyncJMAPAdapter, JMAPAdapter, _JMAPFlow from calendaring_sync.constants import FEED_PAGE_SIZE, ISSUES_URL, SCHEMA_VERSION from calendaring_sync.store.memory import _Data from calendaring_sync.store.sqlite import _FEED, _META, _RECORDS, _reader -from calendaring_sync.tests import _caldav_scenarios, _jmap_scenarios, benchmark +from calendaring_sync.tests import _caldav_scenarios, _examples, _jmap_scenarios, benchmark from calendaring_sync.tests._scenarios import done, run from calendaring_sync.tests._tape import Tape @@ -110,6 +120,13 @@ _SHORT_TIMEOUT = 0.1 # How long a test waits for another thread before it counts as stuck. _WAIT = 5.0 +# The scope and event the docs' examples start from, in their start.py. +_EXAMPLE_SCOPE = "work" +_EXAMPLE_EVENT = "meeting" +# The tests that run the docs' examples, which an installed wheel doesn't hold. +_needs_examples = pytest.mark.skipif( + not _examples.EXAMPLES.is_dir(), reason="The docs examples aren't installed" +) class _Clock: @@ -467,6 +484,20 @@ def _summary(record: Record | None) -> tuple[object, ...] | None: ) +def _assert_ended( + replica: Replica, + key: int, + head: int, + expected: tuple[object, ...] | None, + feed: list[FeedEntryKind], +) -> Record | None: + """Check the summary a record ended with and the feed kinds since ``head``, and return it.""" + record = replica.record(key) + assert _summary(record) == expected + assert [kind for kind, _ in _feed(replica, head)] == feed + return record + + def _edited(replica: Replica) -> Replica: """A seeded scope at token T2 where a holds a pending change and b an open conflict.""" _seeded(replica) @@ -534,6 +565,19 @@ def _refused_create( return key +def _refused_edit(replica: Replica, refused: EditKind) -> int: + """Have the server refuse an edit for b's logical key u, at T2 to T3; return its key. + + A create gets the id n, and a change goes to a. + """ + if refused is EditKind.CREATE: + return _refused_create(replica, "b", "T2", "T3") + key = _key(replica) + _change(replica, key, "fl", logical_key="u") + _push(replica, key, LogicalKeyTaken("b"), "T2", "T3") + return key + + # The local edit and the server's report that open each kind of conflict an # ingest stores. _OPENED_BY = { @@ -623,7 +667,7 @@ def _assert_refused( # Examples that continue from the replica the Replica class example builds. -_CONTINUING = ("calendaring_sync.replica.Replica.", "calendaring_sync.replica.Session") +_CONTINUING = ("calendaring_sync.replica.Replica.", "calendaring_sync.session.Session") def _docstring_examples() -> list[doctest.DocTest]: @@ -643,6 +687,239 @@ def _docstring_examples() -> list[doctest.DocTest]: return tests +def _sync_fails( + monkeypatch: pytest.MonkeyPatch, error: Exception, failing: frozenset[int] = frozenset({1}) +) -> list[int]: + """Make every adapter's sync raise ``error`` on the calls numbered in ``failing``. + + Returns the list of calls, which grows by one with each. + """ + sync = SyncAdapter.sync + calls: list[int] = [] + + def fails(adapter: SyncAdapter, **kwargs: bool) -> None: + calls.append(1) + if len(calls) in failing: + raise error + sync(adapter, **kwargs) + + monkeypatch.setattr(SyncAdapter, "sync", fails) + return calls + + +def _memory_adapter(asynchronous: bool) -> tuple[Any, Replica, Any]: + """The example adapter, synced with a server holding a; return the server, replica, adapter.""" + memory = _examples.load("memory_adapter") + server = memory.MemoryServer() + server.put("a", b"theirs", if_version=None) + replica = Replica(MemoryStore()) + adapter = ( + memory.AsyncMemoryAdapter(replica, memory.AsyncMemoryServer(server), _SCOPE) + if asynchronous + else memory.MemoryAdapter(replica, server, _SCOPE) + ) + done(adapter.sync()) + return server, replica, adapter + + +@_needs_examples +class TestExamples: + """Each example a docs page shows does what the page says.""" + + def test_every_example_is_run_and_shown(self): + tested = {name.removeprefix("test_") for name in vars(TestExamples)} + assert set(_examples.names()) - _examples.LIVE <= tested + pages = "".join( + p.read_text(encoding="utf-8") for p in _examples.EXAMPLES.parent.rglob("*.rst") + ) + shown = {name for name in _examples.names() if f"examples/{name}.py" in pages} + assert set(_examples.names()) - _examples.SHARED <= shown + + def test_memory_adapter(self): + def synced(asynchronous: bool) -> object: + server, replica, adapter = _memory_adapter(asynchronous) + created = done(adapter.create(b"new")) + done(adapter.change(_key(replica), b"ours")) + done(adapter.sync()) + replica.delete(created) + # The token expires, so the sync that sends the delete lists the scope again. + server.oldest = int(server.token()) + 1 + done(adapter.sync()) + records = [(r.local_key, r.state, r.content) for r in replica.records(_SCOPE)] + return records, sorted(content for _, content in server.objects.values()) + + assert synced(asynchronous=True) == synced(asynchronous=False) + assert synced(asynchronous=False) == ( + [(1, RecordState.SYNCED, b"ours"), (2, RecordState.REMOVED, b"new")], + [b"ours"], + ) + + @pytest.mark.parametrize("asynchronous", [False, True]) + def test_memory_adapter_refusals(self, asynchronous): + server, replica, adapter = _memory_adapter(asynchronous) + key = _key(replica) + # Written past the server's log, so no sync reports it and only the push meets it. + server.objects["a"] = ("99", b"theirs, again") + done(adapter.change(key, b"ours")) + done(adapter.sync()) + assert _summary(replica.record(key)) == _pending(EditKind.CHANGE) + del server.objects["a"] + done(adapter.sync()) + assert _summary(replica.record(key)) == _conflicted( + EditKind.CHANGE, ConflictKind.REMOVED_REMOTELY + ) + server.removed = True + with pytest.raises(ScopeGoneError): + done(adapter.sync(full_listing=True)) + + def test_run_replaces_a_setting(self, tmp_path, monkeypatch): + (tmp_path / "setting.py").write_text("answer = 1\ndoubled = answer * 2\n") + monkeypatch.setattr(_examples, "EXAMPLES", tmp_path) + monkeypatch.setattr(_examples, "load", lambda name: None) + assert _examples.run("setting", monkeypatch, tmp_path, answer=21)["doubled"] == 42 + with pytest.raises(AssertionError, match="^setting has no missing to replace$"): + _examples.run("setting", monkeypatch, tmp_path, missing=1) + + def test_readme_shows_the_quickstart(self): + quickstart = (_examples.EXAMPLES / "quickstart.py").read_text(encoding="utf-8") + shown = quickstart.split("# start: sync\n")[1].split("# end: sync\n")[0] + readme = (_examples.EXAMPLES.parents[1] / "README.md").read_text(encoding="utf-8") + assert f"```python\n{shown}```\n" in readme + + def test_start(self, tmp_path, monkeypatch): + ran = _examples.run("start", monkeypatch, tmp_path) + assert [r.content for r in ran["replica"].records(_EXAMPLE_SCOPE)] == [b"Team meeting"] + + @pytest.mark.parametrize( + "found, content", [(True, b"Team meeting, room 3"), (False, b"Team meeting")] + ) + def test_local_edits(self, tmp_path, monkeypatch, found, content): + if not found: + monkeypatch.setattr(Replica, "find_record", lambda *args: None) + ran = _examples.run("local_edits", monkeypatch, tmp_path) + replica = ran["replica"] + meeting = replica.record(_examples.load("start").meeting.local_key) + assert (meeting.state, meeting.content) == (RecordState.SYNCED, content) + assert replica.record(ran["lunch"]).state is RecordState.REMOVED + assert list(_examples.load("start").server.objects) == [_EXAMPLE_EVENT] + + def test_change_feed(self, tmp_path, monkeypatch): + ran = _examples.run("change_feed", monkeypatch, tmp_path) + assert ran["handled"] == [(FeedEntryKind.CHANGED, _EXAMPLE_EVENT)] + assert (tmp_path / "feed-cursor.txt").read_text() == "1" + replica = ran["replica"] + # The meeting the server deleted is purged, and the feed trimmed. + assert replica.records(_EXAMPLE_SCOPE) == [] + with pytest.raises(TrimmedFeedError): + replica.read_feed(0) + + def test_change_feed_after_a_trim(self, tmp_path, monkeypatch): + def trimmed(*args: object, **kwargs: object) -> list[FeedEntry]: + raise TrimmedFeedError("trimmed") + + monkeypatch.setattr(Replica, "read_feed", trimmed) + ran = _examples.run("change_feed", monkeypatch, tmp_path) + assert ran["handled"] == [] + assert (tmp_path / "feed-cursor.txt").read_text() == "1" + + def test_conflicts(self, tmp_path, monkeypatch): + ran = _examples.run("conflicts", monkeypatch, tmp_path) + merged = b"Team sync, room 3 / Team sync, at 10" + meeting = ran["replica"].find_record(_EXAMPLE_SCOPE, _EXAMPLE_EVENT) + assert (meeting.state, meeting.content) == (RecordState.SYNCED, merged) + assert _examples.load("start").server.objects[_EXAMPLE_EVENT][1] == merged + + @pytest.mark.parametrize( + "kind, resolution", + [(ConflictKind.REMOVED_REMOTELY, KeepServer()), (ConflictKind.DELETED_LOCALLY, None)], + ) + def test_conflicts_resolver(self, tmp_path, monkeypatch, kind, resolution): + ran = _examples.run("conflicts", monkeypatch, tmp_path) + record = SimpleNamespace(conflict=SimpleNamespace(kind=kind)) + assert ran["resolver"](record) == resolution + + @pytest.mark.parametrize( + "busy, unsent, scopes", + [(False, [b"Team meeting, room 3"], []), (True, [], [_EXAMPLE_SCOPE])], + ) + def test_errors(self, tmp_path, monkeypatch, busy, unsent, scopes): + if busy: + # The first sync is the starting state's, the second the example's. + _sync_fails(monkeypatch, StoreBusyError("busy"), frozenset({2})) + ran = _examples.run("errors", monkeypatch, tmp_path) + assert ran["unsent"] == unsent + assert [scope.id for scope in ran["replica"].scopes()] == scopes + + def test_mirror(self, tmp_path, monkeypatch): + ran = _examples.run("mirror", monkeypatch, tmp_path) + assert ran["moved"] == [(b"Team meeting, moved",)] + assert ran["after_delete"] == [] + + def test_mirror_after_a_drop(self, tmp_path, monkeypatch): + ran = _examples.run("mirror", monkeypatch, tmp_path) + replica = ran["replica"] + db = sqlite3.connect(tmp_path / "app.db") + _examples.load("start").server.put("lunch", b"Lunch", if_version=None) + ran["adapter"].sync() + ran["mirror"](replica, db) + assert db.execute("SELECT count(*) FROM events").fetchone() == (1,) + replica.drop_scope(_EXAMPLE_SCOPE) + ran["mirror"](replica, db) + assert db.execute("SELECT count(*) FROM events").fetchone() == (0,) + db.close() + + def test_parallel(self, tmp_path, monkeypatch): + ran = _examples.run("parallel", monkeypatch, tmp_path) + replica = ran["replica"] + assert [scope.id for scope in replica.scopes()] == ["home", "team", "work"] + + @pytest.mark.parametrize("failing", [frozenset({1}), frozenset({1, 2})]) + def test_parallel_retry(self, tmp_path, monkeypatch, failing): + ran = _examples.run("parallel", monkeypatch, tmp_path) + calls = _sync_fails(monkeypatch, StaleChangeSetError("stale"), failing) + ran["sync"](ran["adapters"][0]) + assert len(calls) == len(failing) + 1 + + def test_inspect_scope(self, tmp_path, monkeypatch): + ran = _examples.run("inspect_scope", monkeypatch, tmp_path) + assert [r.edit.content for r in ran["unsent"]] == [b"Team meeting, room 3"] + assert (ran["conflicts"], ran["blocked"]) == ([], []) + assert ran["token"] is not None + # The repairs sent the edit and left the scope listed again. + meeting = ran["replica"].find_record(_EXAMPLE_SCOPE, _EXAMPLE_EVENT) + assert (meeting.state, meeting.content) == (RecordState.SYNCED, b"Team meeting, room 3") + + def test_testing(self, tmp_path, monkeypatch): + ran = _examples.run("testing", monkeypatch, tmp_path) + assert ran["replica"].scope(_EXAMPLE_SCOPE).token == "t3" + + def test_snapshots(self, tmp_path, monkeypatch): + ran = _examples.run("snapshots", monkeypatch, tmp_path) + feed = [(entry.kind, entry.object_id) for entry in ran["replica"].read_feed()] + # The second, unchanged snapshot adds nothing. + assert feed == [ + (FeedEntryKind.CHANGED, _EXAMPLE_EVENT), + (FeedEntryKind.CHANGED, "lunch"), + (FeedEntryKind.CHANGED, _EXAMPLE_EVENT), + (FeedEntryKind.REMOVED, "lunch"), + ] + + # Busy once, the sync goes through on the retry; busy every time, it gives up after three. + @pytest.mark.parametrize( + "failing, calls, scopes", + [(frozenset({1}), 2, [_EXAMPLE_SCOPE]), (frozenset({1, 2, 3}), 3, [])], + ) + def test_sqlite(self, tmp_path, monkeypatch, failing, calls, scopes): + made = _sync_fails(monkeypatch, StoreBusyError("busy"), failing) + monkeypatch.setattr("time.sleep", lambda seconds: None) + _examples.run("sqlite", monkeypatch, tmp_path) + assert len(made) == calls + with closing(sqlite3.connect(tmp_path / "sync.db")) as connection: + assert connection.execute("PRAGMA journal_mode").fetchone() == ("wal",) + with SQLiteStore(tmp_path / "sync.db") as store: + assert [scope.id for scope in Replica(store).scopes()] == scopes + + class TestPackage: @pytest.mark.parametrize("test", _docstring_examples(), ids=lambda test: test.name) def test_docstring_examples(self, test): @@ -702,6 +979,9 @@ def test_every_entry_point_checks_its_types(self): StateStore, MemoryStore, SQLiteStore, + Adapter, + SyncAdapter, + AsyncAdapter, CalDAVAdapter, AsyncCalDAVAdapter, JMAPAdapter, @@ -723,7 +1003,14 @@ def test_every_entry_point_checks_its_types(self): if isinstance(cls, type) and dataclasses.is_dataclass(cls): _fields(cls) # The values an adapter or application builds check themselves. - assert _CHECKED_WHEN_BUILT == {Changed, ChangeSet, Applied, LogicalKeyTaken, Merge} + assert _CHECKED_WHEN_BUILT == { + Changed, + ChangeSet, + Applied, + LogicalKeyTaken, + Merge, + EditFields, + } def test_a_check_refuses_what_it_cant_read(self): def takes_many(*values: str) -> None: ... @@ -861,7 +1148,11 @@ def test_incremental_without_token_is_refused(self, replica, store): with _open_session(replica) as session: page = _page(None, "T1", {"a": _changed("fa")}) _assert_refused( - replica, store, lambda: session.ingest(page), ValueError, "needs a full listing" + replica, + store, + lambda: session.ingest(page), + ListingRequiredError, + "needs a full listing", ) def test_unchanged_outside_listing_is_refused(self, replica, store): @@ -873,7 +1164,9 @@ def test_unchanged_outside_listing_is_refused(self, replica, store): def test_unchanged_needs_a_live_record_with_a_version(self, replica, store, object_id): _a_removed(replica) page = _page("T2", "L", {object_id: Unchanged()}, full=True) - _assert_refused(replica, store, lambda: _sync(replica, page), ValueError, "no version") + _assert_refused( + replica, store, lambda: _sync(replica, page), UnknownVersionError, "no version" + ) def test_change_set_for_another_scope_is_refused(self, replica, store): with _open_session(replica) as session: @@ -882,7 +1175,7 @@ def test_change_set_for_another_scope_is_refused(self, replica, store): replica, store, lambda: session.ingest(page), - ValueError, + OtherScopeError, "in a session for", before_a_transaction=True, ) @@ -1130,7 +1423,11 @@ def test_fingerprint_scheme_change_resets_and_clears_versions(self, replica, sto assert replica.known_versions(_SCOPE) == {} page = _page(None, "T2", {}) _assert_refused( - replica, store, lambda: session.ingest(page), ValueError, "needs a full listing" + replica, + store, + lambda: session.ingest(page), + ListingRequiredError, + "needs a full listing", ) def test_listing_after_a_scheme_change_reports_only_new_content(self, replica): @@ -1376,33 +1673,33 @@ def _conflicted(edit: EditKind, kind: ConflictKind) -> tuple[object, ...]: # The rows of _SETTLED_LATE where the server reported nothing new. _NOTHING_NEW = (None, "edited", "claimed again") -_MERGED = Merge(_content("fm"), fingerprint="fm") +_MERGED = Merge(_content("fm"), EditFields("fm")) # Each conflict resolved each way: the conflict's kind, the kind of edit a # refused one was, the resolution, then what the record ends up as, or None # once it's gone. _RESOLVED = [ - (ConflictKind.BOTH_CHANGED, "create", TakeServer(), _SYNCED), - (ConflictKind.BOTH_CHANGED, "create", KeepEdit(), _pending(EditKind.CHANGE)), + (ConflictKind.BOTH_CHANGED, "create", KeepServer(), _SYNCED), + (ConflictKind.BOTH_CHANGED, "create", KeepLocal(), _pending(EditKind.CHANGE)), (ConflictKind.BOTH_CHANGED, "create", _MERGED, _pending(EditKind.CHANGE)), (ConflictKind.BOTH_CHANGED, "create", Delete(), _pending(EditKind.DELETE)), - (ConflictKind.REMOVED_REMOTELY, "create", TakeServer(), _REMOVED), - (ConflictKind.REMOVED_REMOTELY, "create", KeepEdit(), _pending(EditKind.CREATE)), + (ConflictKind.REMOVED_REMOTELY, "create", KeepServer(), _REMOVED), + (ConflictKind.REMOVED_REMOTELY, "create", KeepLocal(), _pending(EditKind.CREATE)), (ConflictKind.REMOVED_REMOTELY, "create", _MERGED, _pending(EditKind.CREATE)), (ConflictKind.REMOVED_REMOTELY, "create", Delete(), _REMOVED), - (ConflictKind.DELETED_LOCALLY, "create", TakeServer(), _SYNCED), - (ConflictKind.DELETED_LOCALLY, "create", KeepEdit(), _pending(EditKind.DELETE)), + (ConflictKind.DELETED_LOCALLY, "create", KeepServer(), _SYNCED), + (ConflictKind.DELETED_LOCALLY, "create", KeepLocal(), _pending(EditKind.DELETE)), (ConflictKind.DELETED_LOCALLY, "create", _MERGED, _pending(EditKind.CHANGE)), (ConflictKind.DELETED_LOCALLY, "create", Delete(), _pending(EditKind.DELETE)), - (ConflictKind.BOTH_CREATED, "create", TakeServer(), _SYNCED), - (ConflictKind.BOTH_CREATED, "create", KeepEdit(), _pending(EditKind.CHANGE)), + (ConflictKind.BOTH_CREATED, "create", KeepServer(), _SYNCED), + (ConflictKind.BOTH_CREATED, "create", KeepLocal(), _pending(EditKind.CHANGE)), (ConflictKind.BOTH_CREATED, "create", _MERGED, _pending(EditKind.CHANGE)), (ConflictKind.BOTH_CREATED, "create", Delete(), _pending(EditKind.DELETE)), - (ConflictKind.LOGICAL_KEY_TAKEN, "create", TakeServer(), None), + (ConflictKind.LOGICAL_KEY_TAKEN, "create", KeepServer(), None), (ConflictKind.LOGICAL_KEY_TAKEN, "create", _MERGED, _pending(EditKind.CREATE)), (ConflictKind.LOGICAL_KEY_TAKEN, "create", Delete(), None), (ConflictKind.LOGICAL_KEY_TAKEN, "create", UpdateHolder(), None), - (ConflictKind.LOGICAL_KEY_TAKEN, "change", TakeServer(), _SYNCED), + (ConflictKind.LOGICAL_KEY_TAKEN, "change", KeepServer(), _SYNCED), (ConflictKind.LOGICAL_KEY_TAKEN, "change", _MERGED, _pending(EditKind.CHANGE)), (ConflictKind.LOGICAL_KEY_TAKEN, "change", Delete(), _pending(EditKind.DELETE)), (ConflictKind.LOGICAL_KEY_TAKEN, "change", UpdateHolder(), _SYNCED), @@ -1536,8 +1833,7 @@ def test_ingest_classifies_against_the_local_edit(self, replica, local, remote, key, object_id = _seeded_edit(replica, local) head = replica.feed_head() _sync(replica, _page("T1", "T2", {} if remote is None else {object_id: _remote(remote)})) - assert _summary(replica.record(key)) == expected - assert [kind for kind, _ in _feed(replica, head)] == feed + _assert_ended(replica, key, head, expected, feed) @pytest.mark.parametrize("local, opening, following, expected, feed", _SETTLED) def test_an_open_conflict_meets_the_next_report( @@ -1548,10 +1844,8 @@ def test_an_open_conflict_meets_the_next_report( opened_at = replica.record(key).conflict.opened_at head = replica.feed_head() _sync(replica, _page("T2", "T3", {object_id: _remote(following, "v3")})) - record = replica.record(key) - assert _summary(record) == expected - assert [kind for kind, _ in _feed(replica, head)] == feed - if record.conflict is not None: + record = _assert_ended(replica, key, head, expected, feed) + if record is not None and record.conflict is not None: assert record.conflict.opened_at == opened_at assert record.conflict.remote_version == (None if following == "removed" else "v3") @@ -1578,8 +1872,110 @@ def test_a_refused_change_meets_the_next_report(self, replica, following, expect assert replica.known_versions(_SCOPE)["a"] == replica.record(key).version head = replica.feed_head() _sync(replica, _page("T2", "T3", {"a": _remote(following, "v3")})) - assert _summary(replica.record(key)) == expected - assert [kind for kind, _ in _feed(replica, head)] == feed + _assert_ended(replica, key, head, expected, feed) + + @pytest.mark.parametrize("refused", [EditKind.CREATE, EditKind.CHANGE]) + @pytest.mark.parametrize( + "held, report, lifted, feed", + [ + ( + True, + {"b": Removed()}, + True, + [FeedEntryKind.REMOVED, FeedEntryKind.CONFLICT_RESOLVED], + ), + ( + True, + {"b": _changed("fb", "v3", logical_key="w")}, + True, + [FeedEntryKind.CHANGED, FeedEntryKind.CONFLICT_RESOLVED], + ), + # A listing that leaves the holder out removes it. + ( + True, + {"a": Unchanged()}, + True, + [FeedEntryKind.REMOVED, FeedEntryKind.CONFLICT_RESOLVED], + ), + (True, {"b": _changed("fx", "v3", logical_key="u")}, False, [FeedEntryKind.CHANGED]), + ( + True, + {"b": Removed(), "c": _changed("fc", logical_key="u")}, + False, + [FeedEntryKind.REMOVED, FeedEntryKind.CHANGED], + ), + # An object outside the scope holds the key, so nothing here frees it. + (False, {"b": Removed()}, False, [FeedEntryKind.REMOVED]), + ], + ids=[ + "holder-removed", + "holder-rekeyed", + "holder-unlisted", + "holder-kept", + "key-taken-again", + "holder-elsewhere", + ], + ) + def test_a_refusal_ends_when_the_server_frees_its_key( + self, replica, refused, held, report, lifted, feed + ): + holding = {"b": _changed("fb", "v2", logical_key="u")} if held else {} + _sync(_seeded(replica), _page("T1", "T2", holding)) + key = _refused_edit(replica, refused) + head = replica.feed_head() + listing = any(isinstance(change, Unchanged) for change in report.values()) + _sync(replica, _page("T3", "T4", report, full=listing)) + expected = ( + _pending(refused) if lifted else _conflicted(refused, ConflictKind.LOGICAL_KEY_TAKEN) + ) + _assert_ended(replica, key, head, expected, feed) + + @pytest.mark.parametrize("refused", [EditKind.CREATE, EditKind.CHANGE]) + def test_a_refusal_ends_when_your_push_frees_its_key(self, replica, refused): + _sync(_seeded(replica), _page("T1", "T2", {"b": _changed("fb", "v2", logical_key="u")})) + key = _refused_edit(replica, refused) + holder = _key(replica, "b") + replica.delete(holder) + head = replica.feed_head() + _push(replica, holder, Applied(), "T3", "T4") + _assert_ended( + replica, + key, + head, + _pending(refused), + [FeedEntryKind.PUSHED, FeedEntryKind.CONFLICT_RESOLVED], + ) + + def test_a_refused_change_is_not_the_holder_of_its_own_key(self, replica): + # a's synced copy already has u, so the server keeps it under u; only b blocks it. + holding = {k: _changed(f"f{k}", "v2", logical_key="u") for k in "ab"} + _sync(_seeded(replica), _page("T1", "T2", holding)) + key = _refused_edit(replica, EditKind.CHANGE) + head = replica.feed_head() + _sync(replica, _page("T3", "T4", {"b": Removed()})) + _assert_ended( + replica, + key, + head, + _pending(EditKind.CHANGE), + [FeedEntryKind.REMOVED, FeedEntryKind.CONFLICT_RESOLVED], + ) + + @pytest.mark.parametrize("refused", [EditKind.CREATE, EditKind.CHANGE]) + def test_a_holder_in_conflict_holds_the_key_of_the_server_side(self, replica, refused): + _sync(_seeded(replica), _page("T1", "T2", {"b": _changed("fb", "v2", logical_key="u")})) + key = _refused_edit(replica, refused) + # b's synced copy keeps u, but the server's side of its conflict moved to w. + _change(replica, _key(replica, "b"), "fl") + head = replica.feed_head() + _sync(replica, _page("T3", "T4", {"b": _changed("fx", "v3", logical_key="w")})) + _assert_ended( + replica, + key, + head, + _pending(refused), + [FeedEntryKind.CONFLICT_OPENED, FeedEntryKind.CONFLICT_RESOLVED], + ) def test_a_version_only_change_moves_the_version_under_an_edit(self, replica): key, _ = _seeded_edit(replica, "change") @@ -1638,36 +2034,28 @@ def test_a_scheme_change_makes_edits_compare_by_content(self, replica): assert _feed(replica, head) == [] -class _FirstPushRejected(_Flow): - """A flow whose server rejects the first push and applies every later one.""" +def _first_push_rejected(replica: Replica, guard_covers_scope: bool) -> Any: + """The docs' example adapter, its first push rejected and every later one applied.""" + memory = _examples.load("memory_adapter") + # Loaded from its file, so a type checker can't see its classes. + example: Any = memory.MemoryAdapter - _fingerprint_scheme = _SCHEME - _media_type = _MEDIA - _guarded_by_version = False - _unique_logical_key = False + class FirstPushRejected(example): + def __init__(self) -> None: + server = memory.MemoryServer() + server.put("a", b"a", if_version=None) + super().__init__(replica, server, _SCOPE) + self._guard_covers_scope = guard_covers_scope + self.sent: list[int] = [] - def __init__(self, replica: Replica, guard_covers_scope: bool) -> None: - super().__init__(replica, _SCOPE) - self._guard_covers_scope = guard_covers_scope - self.sent: list[int] = [] + def _send(self, claimed: Record) -> Steps[PushOutcome]: + self.sent.append(claimed.local_key) + if len(self.sent) == 1: + yield from () + return Rejected() + return (yield from super()._send(claimed)) - def _read(self, session: Session, scope: Scope, *, full_listing: bool) -> Steps[_Read]: - yield _InStore(session.ingest, (_page(scope.token, "T2", {}),)) - return _Read.COMPLETE - - def _send(self, claimed: Record) -> Steps[PushOutcome]: - self.sent.append(claimed.local_key) - yield from () - return Rejected() if len(self.sent) == 1 else Applied() - - def _waits(self, record: Record) -> bool: - return False - - def _new_object_id(self) -> str | None: - return "n" - - def _edit_fields(self, content: bytes, *, create: bool) -> _EditFields: - return {"fingerprint": content.decode(), "containers": frozenset(), "logical_key": None} + return FirstPushRejected() # Each protocol's scenario module, and each recording of each. @@ -1736,17 +2124,19 @@ def test_each_adapter_declares_what_its_server_checks( declared = (flow._guarded_by_version, flow._unique_logical_key, flow._guard_covers_scope) assert declared == (guarded_by_version, unique_logical_key, guard_covers_scope) + @_needs_examples @pytest.mark.parametrize("covers_scope", [True, False], ids=["scope", "object"]) def test_a_rejected_push_stops_the_rest_where_the_check_covers_the_scope( self, replica, covers_scope ): - _seeded(replica) - flow = _FirstPushRejected(replica, covers_scope) - _run(flow._change(_key(replica), b"fl")) - created = _run(flow._create(b"fn")) - _run(flow._sync(full_listing=False)) + flow = _first_push_rejected(replica, covers_scope) + flow.sync() + changed = _key(replica) + flow.change(changed, b"fl") + created = flow.create(b"fn") + flow.sync() # Where the check covers the scope, the create would be rejected too, so it waits unsent. - assert flow.sent == ([_key(replica)] if covers_scope else [_key(replica), created]) + assert flow.sent == ([changed] if covers_scope else [changed, created]) later = replica.record(created) assert later is not None assert later.state is (RecordState.PENDING if covers_scope else RecordState.SYNCED) @@ -1781,9 +2171,8 @@ def test_settle_records_how_a_push_ended(self, replica, local, outcome, expected key, _ = _seeded_edit(replica, local) head = replica.feed_head() _push(replica, key, outcome) - record = replica.record(key) - assert _summary(record) == expected - assert [kind for kind, _ in _feed(replica, head)] == feed + record = _assert_ended(replica, key, head, expected, feed) + assert record is not None if expected == _SYNCED: # The server may store the edit rewritten, so it gives no version to keep. assert (record.version, record.fingerprint, record.content) == ( @@ -1792,7 +2181,7 @@ def test_settle_records_how_a_push_ended(self, replica, local, outcome, expected _content("fl"), ) if isinstance(outcome, LogicalKeyTaken): - assert record.conflict.holder == outcome.holder + assert record.conflict is not None and record.conflict.holder == outcome.holder @pytest.mark.parametrize( "local, outcome, message", @@ -1858,16 +2247,20 @@ def test_claim_refuses_what_it_cant_push(self, replica, store): _sync(replica, _page(None, "O1", {}, full=True, scope=_OTHER)) elsewhere = _create(replica, "fn", scope=_OTHER) with _pushing(replica) as session: - for call, match in [ - (lambda: session.claim(elsewhere), "belongs to scope"), - (lambda: session.claim(key, object_id="x"), "already has object id"), + for call, error, match in [ + (lambda: session.claim(elsewhere), OtherScopeError, "belongs to scope"), + ( + lambda: session.claim(key, object_id="x"), + ObjectIdAssignedError, + "already has object id", + ), ]: - _assert_refused(replica, store, call, ValueError, match) + _assert_refused(replica, store, call, error, match) _assert_refused( replica, store, lambda: session.claim(key, object_id=_BAD_TEXT), - ValueError, + LoneSurrogateError, "^object_id ", before_a_transaction=True, ) @@ -1961,8 +2354,7 @@ def test_the_next_complete_ingest_settles_a_push_left_in_flight( session.ingest(_page("T2", "C", {}, truncated=True)) assert _summary(replica.record(key)) == (RecordState.IN_FLIGHT, EditKind(local), None) session.ingest(_page("C", "T3", reported)) - assert _summary(replica.record(key)) == expected - assert [kind for kind, _ in _feed(replica, head)] == feed + _assert_ended(replica, key, head, expected, feed) def test_a_listing_settles_pushes_it_never_mentions(self, replica): _seeded(replica) @@ -2060,9 +2452,7 @@ def test_each_resolution_of_each_conflict(self, replica, kind, refused, resoluti ) head = replica.feed_head() replica.resolve(replica.record(key), resolution) - record = replica.record(key) - assert _summary(record) == expected - assert [kind for kind, _ in _feed(replica, head)] == [FeedEntryKind.CONFLICT_RESOLVED] + record = _assert_ended(replica, key, head, expected, [FeedEntryKind.CONFLICT_RESOLVED]) if record is not None and record.state is not RecordState.REMOVED: # Whatever is pushed next goes out against the server's current copy. assert (record.version, record.content) == server @@ -2078,8 +2468,8 @@ def test_each_resolution_of_each_conflict(self, replica, kind, refused, resoluti "kind, resolution, message", [ *((kind, UpdateHolder(), "^Only an edit refused") for kind in _OPENED_BY), - (ConflictKind.LOGICAL_KEY_TAKEN, KeepEdit(), "can't be pushed again unchanged$"), - (None, TakeServer(), "takes a record in conflict$"), + (ConflictKind.LOGICAL_KEY_TAKEN, KeepLocal(), "can't be pushed again unchanged$"), + (None, KeepServer(), "takes a record in conflict$"), ], ) def test_a_resolution_that_does_not_fit_is_refused( @@ -2099,7 +2489,7 @@ def test_a_resolution_that_does_not_fit_is_refused( def test_a_merge_the_server_already_holds_is_synced(self, replica): key = _in_conflict(_seeded(replica)) - replica.resolve(replica.record(key), Merge(_content("fx"), fingerprint="fx")) + replica.resolve(replica.record(key), Merge(_content("fx"), EditFields("fx"))) assert _summary(replica.record(key)) == _SYNCED @pytest.mark.parametrize("following", ["fx", "fy", "removed", "fl"]) @@ -2109,12 +2499,12 @@ def test_a_conflict_that_moved_on_is_stale(self, replica, store, following): _sync(replica, _page("T2", "T3", {"a": _remote(following, "v3")})) if following == "fx": # Only the version moved, and the edit goes out against the new one. - replica.resolve(read, KeepEdit()) + replica.resolve(read, KeepLocal()) record = replica.record(key) assert (record.state, record.version) == (RecordState.PENDING, "v3") else: _assert_refused( - replica, store, lambda: replica.resolve(read, KeepEdit()), StaleConflictError + replica, store, lambda: replica.resolve(read, KeepLocal()), StaleConflictError ) @pytest.mark.parametrize( @@ -2187,9 +2577,9 @@ def test_resolve_conflicts_resolves_each_answer(self, replica): _sync(replica, _page("O1", "O2", {"a": _changed("fx", "v2")}, scope=_OTHER)) seen = [] - def resolver(record: Record) -> TakeServer | None: + def resolver(record: Record) -> KeepServer | None: seen.append(record.local_key) - return None if record.local_key == a else TakeServer() + return None if record.local_key == a else KeepServer() assert replica.resolve_conflicts(resolver) == 2 assert seen == [a, b, other] @@ -2203,24 +2593,24 @@ def resolver(record: Record) -> TakeServer | None: def test_resolve_conflicts_skips_a_conflict_that_moved_on(self, replica): a, b = _two_conflicts(replica) - def resolver(record: Record) -> TakeServer: + def resolver(record: Record) -> KeepServer: if record.local_key == a: # A sync brings news about b while the application decides about a. _sync(replica, _page("T3", "T4", {"b": _changed("fy", "v4")})) - return TakeServer() + return KeepServer() assert replica.resolve_conflicts(resolver) == 1 assert replica.record(b).state is RecordState.CONFLICT - assert replica.resolve_conflicts(lambda record: TakeServer()) == 1 + assert replica.resolve_conflicts(lambda record: KeepServer()) == 1 assert replica.record(b).content == _content("fy") def test_an_error_from_the_resolver_keeps_earlier_resolutions(self, replica): a, b = _two_conflicts(replica) - def resolver(record: Record) -> TakeServer: + def resolver(record: Record) -> KeepServer: if record.local_key == b: raise RuntimeError("the application failed") - return TakeServer() + return KeepServer() with pytest.raises(RuntimeError, match="the application failed"): replica.resolve_conflicts(resolver) @@ -2261,7 +2651,7 @@ def test_wrong_types_and_text_are_refused(self, replica, store, db_path): built: list[tuple[Callable[[], object], type[Exception], str]] = [ ( lambda: Changed("v1", _BAD_TEXT, b"c"), - ValueError, + LoneSurrogateError, "^fingerprint holds a lone surrogate at 1", ), (lambda: Changed("v1", "fa", _NOT_BYTES), TypeError, "^content must be bytes, not str"), @@ -2295,17 +2685,18 @@ def test_wrong_types_and_text_are_refused(self, replica, store, db_path): TypeError, "^changes must be mapping of str to Changed, Removed or Unchanged, not list", ), - (lambda: _page(None, "T1", {_BAD_TEXT: Removed()}), ValueError, "^object_ids "), + (lambda: _page(None, "T1", {_BAD_TEXT: Removed()}), LoneSurrogateError, "^object_ids "), ( lambda: _page(None, "T1", {"a": _wrong("removed")}), TypeError, r"^changes\['a'\] must be Changed, Removed or Unchanged, not str", ), - (lambda: Applied(_BAD_TEXT), ValueError, "^object_id "), + (lambda: Applied(_BAD_TEXT), LoneSurrogateError, "^object_id "), (lambda: Applied(_wrong(1)), TypeError, "^object_id must be str or None, not int"), - (lambda: LogicalKeyTaken(_BAD_TEXT), ValueError, "^holder "), - (lambda: Merge(b"c", fingerprint=_BAD_TEXT), ValueError, "^fingerprint "), - (lambda: Merge(_NOT_BYTES, fingerprint="f"), TypeError, "^content "), + (lambda: LogicalKeyTaken(_BAD_TEXT), LoneSurrogateError, "^holder "), + (lambda: Merge(b"c", EditFields(_BAD_TEXT)), LoneSurrogateError, "^fingerprint "), + (lambda: EditFields(_wrong(1)), TypeError, "^fingerprint must be str, not int"), + (lambda: Merge(_NOT_BYTES, EditFields("f")), TypeError, "^content "), ( lambda: SQLiteStore(db_path, timeout=_wrong("5")), TypeError, @@ -2321,18 +2712,18 @@ def test_wrong_types_and_text_are_refused(self, replica, store, db_path): key = _key(replica) synced = replica.record(key) refused: list[tuple[Callable[[], object], type[Exception], str]] = [ - (lambda: _enter(_open_session(replica, _BAD_TEXT)), ValueError, "^scope_id "), + (lambda: _enter(_open_session(replica, _BAD_TEXT)), LoneSurrogateError, "^scope_id "), (lambda: _enter(_open_session(replica, _wrong(1))), TypeError, "^scope_id must be str"), ( lambda: replica.create( _SCOPE, b"c", fingerprint="f", containers=frozenset({_BAD_TEXT}) ), - ValueError, + LoneSurrogateError, "^containers ", ), ( lambda: _change(replica, key, "fl", logical_key=_BAD_TEXT), - ValueError, + LoneSurrogateError, "^logical_key ", ), (lambda: replica.create(_SCOPE, _NOT_BYTES, fingerprint="f"), TypeError, "^content "), @@ -2391,17 +2782,22 @@ def test_paging(self, replica): # A limit out of range is refused before the feed is read; a cursor needs the feed. @pytest.mark.parametrize( - "after, limit, unread", - [(-1, 10, False), (3, 10, False), (0, 0, True), (0, FEED_PAGE_SIZE + 1, True)], + "after, limit, error", + [ + (-1, 10, FeedRangeError), + (3, 10, FeedRangeError), + (0, 0, ValueError), + (0, FEED_PAGE_SIZE + 1, ValueError), + ], ) - def test_bad_read_arguments(self, replica, store, after, limit, unread): + def test_bad_read_arguments(self, replica, store, after, limit, error): _seeded(replica) _assert_refused( replica, store, lambda: replica.read_feed(after, limit), - ValueError, - before_a_transaction=unread, + error, + before_a_transaction=error is ValueError, ) def test_trim_and_trimmed_cursor(self, replica, store): @@ -2417,7 +2813,7 @@ def test_trim_and_trimmed_cursor(self, replica, store): def test_bad_through(self, replica, store, method, through): _seeded(replica) call = getattr(replica, method) - _assert_refused(replica, store, lambda: call(through), ValueError) + _assert_refused(replica, store, lambda: call(through), FeedRangeError) def test_purge(self, replica): _a_removed(replica) @@ -2602,13 +2998,15 @@ def test_scope_records_by_state(self, replica, store, states, expected): _edited(replica) with ( store.transaction(write=False), - patch.object(store, "_scope_records", wraps=store._scope_records) as hook, + patch.object(store, "_scope_records", wraps=store._scope_records) as every, + patch.object(store, "_scope_records_in", wraps=store._scope_records_in) as some, ): records = store.scope_records(_SCOPE, states=states) assert [r.object_id for r in records] == expected assert replica.records(_SCOPE, states=states) == records # An empty set is answered by the base class, so no store sees one. - assert hook.called == (states != frozenset()) + assert (every.called or some.called) == (states != frozenset()) + assert some.called == bool(states) @pytest.mark.parametrize("record", _BAD_RECORDS.values(), ids=_BAD_RECORDS) def test_records_holding_what_their_state_cant_are_refused(self, store, record):