diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml new file mode 100644 index 00000000..d20f94f2 --- /dev/null +++ b/.github/workflows/linkcheck.yml @@ -0,0 +1,18 @@ +name: Link check + +on: [push, pull_request] + +jobs: + linkcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check links with Lychee + uses: lycheeverse/lychee-action@v2 + with: + fail: true + args: >- + --timeout 10 + --max-retries 2 + '**/*.md' + '**/*.rst' diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 76b595a1..48555ffc 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python: ${{ github.event_name == 'pull_request' && fromJSON('["3.9", "3.14"]') || fromJSON('["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]') }} + python: ${{ github.event_name == 'pull_request' && fromJSON('["3.10", "3.14"]') || fromJSON('["3.10", "3.11", "3.12", "3.13", "3.14"]') }} services: baikal: image: ckulka/baikal:nginx @@ -144,7 +144,7 @@ jobs: docker exec ${{ job.services.nextcloud.id }} php occ app:disable password_policy || true # Create test user - docker exec -e OC_PASS="TestPassword123!" ${{ job.services.nextcloud.id }} php occ user:add --password-from-env --display-name="Test User" testuser || echo "User may already exist" + docker exec -e OC_PASS="testpass" ${{ job.services.nextcloud.id }} php occ user:add --password-from-env --display-name="Test User" testuser || echo "User may already exist" # Enable calendar and contacts apps docker exec ${{ job.services.nextcloud.id }} php occ app:enable calendar || true @@ -326,3 +326,50 @@ jobs: key: pip|${{ hashFiles('setup.py') }}|${{ hashFiles('tox.ini') }} - run: pip install tox - run: tox -e deptry + async-niquests: + # Test that async code works with niquests when httpx is not installed + name: async (niquests fallback) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies without httpx + run: | + pip install --editable .[test] + pip uninstall -y httpx + - name: Verify niquests is used + run: | + python -c " + from caldav.async_davclient import _USE_HTTPX, _USE_NIQUESTS + assert not _USE_HTTPX, 'httpx should not be available' + assert _USE_NIQUESTS, 'niquests should be used' + print('✓ Using niquests for async HTTP') + " + - name: Run async tests with niquests + run: pytest tests/test_async_davclient.py -v + sync-requests: + # Test that sync code works with requests when niquests is not installed + name: sync (requests fallback) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies with requests instead of niquests + run: | + pip install --editable .[test] + pip uninstall -y niquests + pip install requests + - name: Verify requests is used + run: | + python -c " + from caldav.davclient import _USE_REQUESTS, _USE_NIQUESTS + assert _USE_REQUESTS, 'requests should be available' + assert not _USE_NIQUESTS, 'niquests should not be available' + print('✓ Using requests for sync HTTP') + " + - name: Run sync tests with requests + run: pytest tests/test_caldav.py -v -k "Radicale" --ignore=tests/test_async_integration.py diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 00000000..7e7ebfdc --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,20 @@ +# Example domains that don't resolve +https?://your\.server\.example\.com/.* +https?://.*\.example\.com/.* + +# Localhost URLs for test servers (not accessible in CI) +http://localhost:\d+/.* + +# CalDAV endpoints that require authentication (401/403 expected) +https://caldav\.fastmail\.com/.* +https://caldav\.gmx\.net/.* +https://caldav\.icloud\.com/.* +https://p\d+-caldav\.icloud\.com/.* +https://posteo\.de:\d+/.* +https://purelymail\.com/.* +https://webmail\.all-inkl\.com/.* +https://www\.google\.com/calendar/dav/.* +https://caldav-jp\.larksuite\.com/.* + +# Apple namespace URL (returns 404 but is a valid XML namespace reference) +http://apple\.com/ns/ical/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0154e83a..c58f4034 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,3 +14,9 @@ repos: - id: check-byte-order-marker - id: trailing-whitespace - id: end-of-file-fixer + - repo: https://github.com/lycheeverse/lychee + rev: lychee-v0.22.0 + hooks: + - id: lychee-docker + args: ["--no-progress", "--timeout", "10"] + types: [markdown, rst] diff --git a/AI_POLICY.md b/AI-POLICY.md similarity index 92% rename from AI_POLICY.md rename to AI-POLICY.md index a525bd1d..975cc83a 100644 --- a/AI_POLICY.md +++ b/AI-POLICY.md @@ -63,11 +63,10 @@ experiences is that the AI performs best when being "supervised" and ` when it's doing commits, that's also OK. * **YOU** should be ready to follow up and respond to feedback and - questions on the contribution. If all you do is to relay it to the - AI and relaying the AI output back to the pull request, then - you're not adding value to the project and you're not transparent - and honest. You should at least do a quick QA on the AI-answer and - acknowledge that it was generated by the AI. + questions on the contribution. If you're letting the AI do this for + you, then you're neither honest nor adding value to the project. + You should at least do a quick QA on the AI-answer and acknowledge + that it was generated by the AI. * The Contributors Guidelines aren't strongly enforced on this project as of 2025-12, and I can hardly see cases where the AI would break diff --git a/CHANGELOG.md b/CHANGELOG.md index c50ffd8f..f1f01683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,27 +1,120 @@ # Changelog -## IMPORTANT - niquests vs requests - flapping changeset +## HTTP Library Dependencies -The requests library is stagnant, from 2.0.0 niquests has been in use. It's a very tiny changeset, which resolved three github issues (and created a new one - see https://github.com/python-caldav/caldav/issues/564), fixed support for HTTP/2 and may open the door for an upcoming async proejct. While I haven't looked much "under the bonnet", niquests seems to be a major upgrade of requests. However, the niquest author has apparently failed cooperating well with some significant parts of the python community, so niquests pulls in a lot of other forked libraries as for now. Shortly after releasing 2.0 I was requested to revert back to requests and release 2.0.1. After 2.0.1, the library has been fixed so that it will always use niquests if niquests is available, and requests if niquests is not available. +As of v3.x, **niquests** is the only required HTTP library dependency. It supports both sync and async operations, as well as HTTP/2 and HTTP/3. -You are encouraged to make an informed decision on weather you are most comfortable with the stable but stagnant requests, or the niquests fork. I hope to settle down on some final decision when I'm starting to work on 3.0 (which will support async requests). httpx may be an option. **Your opinion is valuable for me**. Feel free to comment on https://github.com/python-caldav/caldav/issues/457, https://github.com/python-caldav/caldav/issues/530 or https://github.com/jawah/niquests/issues/267 - if you have a github account, and if not you can reach out at python-http@plann.no. +Fallbacks are available: +* **Sync client**: Falls back to `requests` if niquests is not installed +* **Async client**: Uses `httpx` if installed, otherwise uses niquests -So far the most common recommendation seems to be to go for httpx. See also https://github.com/python-caldav/caldav/pull/565 +If you prefer not to use niquests, you can replace it with the original `requests` library for sync operations. See [HTTP Library Configuration](docs/source/http-libraries.rst) for details. + +Historical context: The transition from requests to niquests was discussed in https://github.com/python-caldav/caldav/issues/457 ## Meta This file should adhere to [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), but it's manually maintained, and I have some extra sections in it. Notably an executive summary at the top, "Breaking Changes" or "Potentially Breaking Changes", list of GitHub issues/pull requests closed/merged, information on changes in the test framework, credits to people assisting, an overview of how much time I've spent on each release, and an overview of calendar servers the release has been tested towards. -Changelogs prior to v1.2 is pruned, but available in the v1.2 release +Changelogs prior to v2.0 is pruned, but was available in the v2.x releases This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), though some earlier releases may be incompatible with the SemVer standard. -## [Unreleased] +## [Unreleased] - v3.0 + +### Highlights + +Version 3.0 introduces **full async support** using a Sans-I/O architecture. The same domain objects (Calendar, Event, Todo, etc.) now work with both synchronous and asynchronous clients. The async client uses niquests by default; httpx is also supported for projects that already have it as a dependency (`pip install caldav[async]`). + +### Breaking Changes + +(Be aware that the last minor-versions also tagged some Potentially Breaking Changes) + +* **Minimum Python version**: Python 3.10+ is now required (was 3.8+). +* **Test Server Configuration**: `tests/conf.py` has been removed and `conf_private.py` will be ignored. See the Test Framework section below. + +### Deprecated + +The following are deprecated and emit `DeprecationWarning`: +* `calendar.date_search()` - use `calendar.search()` instead +* `client.principals()` - use `client.search_principals()` instead +* `obj.split_expanded` - may be removed in a future version +* `obj.expand_rrule` - may be removed in a future version +* `.instance` property on calendar objects - use `.vobject_instance` or `.icalendar_instance` +* `response.find_objects_and_props()` - use `response.results` instead + +The following are deprecated but do not yet emit warnings (see https://github.com/python-caldav/caldav/issues/71): +* `calendar.save_event()` - use `calendar.add_event()` instead +* `calendar.save_todo()` - use `calendar.add_todo()` instead +* `calendar.save_journal()` - use `calendar.add_journal()` instead +* `calendar.save_object()` - use `calendar.add_object()` instead + +The following are deprecated but do not yet emit warnings: +* `calendar.event_by_uid()` - use `calendar.get_event_by_uid()` instead +* `calendar.todo_by_uid()` - use `calendar.get_todo_by_uid()` instead +* `calendar.journal_by_uid()` - use `calendar.get_journal_by_uid()` instead +* `calendar.object_by_uid()` - use `calendar.get_object_by_uid()` instead +* `principal.calendars()` - use `principal.get_calendars()` instead +* `calendar.events()` - use `calendar.get_events()` instead +* `calendar.todos()` - use `calendar.get_todos()` instead +* `calendar.journals()` - use `calendar.get_journals()` instead +* `calendar.objects_by_sync_token()` - use `calendar.get_objects_by_sync_token()` instead + +Additionally, direct `DAVClient()` instantiation should migrate to `get_davclient()` factory method (see `docs/design/API_NAMING_CONVENTIONS.md`) ### Added -* Added deptry for dependency verification in CI +* **Full async API** - New `AsyncDAVClient` and async-compatible domain objects: + ```python + from caldav.async_davclient import get_davclient + + async with await get_davclient(url="...", username="...", password="...") as client: + principal = await client.get_principal() + calendars = await client.get_calendars() + for cal in calendars: + events = await cal.get_events() + ``` + +* **Sans-I/O architecture** - Internal refactoring separates protocol logic from I/O: + - Protocol layer (`caldav/protocol/`): Pure functions for XML building/parsing + - Operations layer (`caldav/operations/`): High-level CalDAV operations + - This enables code reuse between sync and async implementations + * Added python-dateutil and PyYAML as explicit dependencies (were transitive) +* Added API consistency aliases: `client.supports_dav()`, `client.supports_caldav()`, `client.supports_scheduling()` as alternatives to the `check_*_support()` methods +* `Calendar` class now accepts a `name` parameter in its constructor, addressing a long-standing API inconsistency (https://github.com/python-caldav/caldav/issues/128) + +### Fixed + +* RFC 4791 compliance: Don't send Depth header for calendar-multiget REPORT (clients SHOULD NOT send it, but servers MUST ignore it per §7.9) +* Fixed HTTP/2 initialization when h2 package is not installed +* Fixed Python 3.9 compatibility in search.py (forward reference annotations) + +### Changed + +* Sync client (`DAVClient`) now shares common code with async client via `BaseDAVClient` +* Response handling unified in `BaseDAVResponse` class +* Test configuration migrated from legacy `tests/conf.py` to new `tests/test_servers/` framework + +### Test Framework + +* Fixed Nextcloud Docker test server tmpfs permissions race condition +* Added deptry for dependency verification in CI +* The test server framework has been refactored with a new `tests/test_servers/` module. It provides **YAML-based server configuration**: see `tests/test_servers/__init__.py` for usage +* Added pytest-asyncio for async test support + +### GitHub Pull Requests Merged + +* #607 - Add deptry for dependency verification + +### GitHub Issues Closed + +* #609 - How to get original RRULE when search expand=True? +* #128 - Calendar constructor should accept name parameter (long-standing issue) + +### Security + +Nothing to report. ## [2.2.3] - [2025-12-06] @@ -230,7 +323,7 @@ I'm working on a [caldav compatibility checker](https://github.com/tobixen/calda As always, the new release comes with quite some bugfixes, compatibility fixes and workarounds improving the support for various calendar servers observed in the wild. -### Breaking Changes +### Potentially Breaking Changes * As mentioned above, if you maintain a file `tests/conf_private.py`, chances are that your test runs will break. Does anyone except me maintain a `tests/conf_private.py`-file? Please reach out by email, GitHub issues or GitHub discussions. @@ -339,244 +432,3 @@ If you disagree with any of this, please raise an issue and I'll consider if it' ### Time Spent The maintainer has spent around 49 hours totally since 1.6. That is a bit above estimate. For one thing, the configuration file change was not in the original road map for 2.0. - -## [1.6.0] - 2025-05-30 - -This will be the last minor release before 2.0. The scheduling support has been fixed up a bit, and saving a single recurrence does what it should do, rather than messing up the whole series. - -### Fixed - -* Save single recurrence. I can't find any information in the RFCs on this, but all servers I've tested does the wrong thing - when saving a single recurrence (with RECURRENCE-ID set but without RRULE), then the original event (or task) will be overwritten (and the RRULE disappear), which is most likely not what one wants. New logic in place (with good test coverage) to ensure only the single instance is saved. Issue https://github.com/python-caldav/caldav/issues/379, pull request https://github.com/python-caldav/caldav/pull/500 -* Scheduling support. It was work in progress many years ago, but uncompleted work was eventually committed to the project. I managed to get a DAViCal test server up and running with three test accounts, ran through the tests, found quite some breakages, but managed to fix up. https://github.com/python-caldav/caldav/pull/497 - -### Added - -* New option `event.save(all_recurrences=True)` to edit the whole series when saving a modified recurrence. Part of https://github.com/python-caldav/caldav/pull/500 -* New methods `Event.set_dtend` and `CalendarObjectResource.set_end`. https://github.com/python-caldav/caldav/pull/499 - -### Refactoring and tests - -* Partially tossed out all internal usage of vobject, https://github.com/python-caldav/caldav/issues/476. Refactoring and removing unuseful code. Parts of this work was accidentally committed directly to master, 2f61dc7adbe044eaf43d0d2c78ba96df09201542, the rest was piggybaced in through https://github.com/python-caldav/caldav/pull/500. -* Server-specific setup- and teardown-methods (used for spinning up test servers in the tests) is now executed through the DAVClient context manager. This will allow doctests to run easily. -* Made exception for new `task.uncomplete`-check for GMX server - https://github.com/python-caldav/caldav/issues/525 - -### Time spent and roadmap - -Maintainer put down ten hours of effort for the 1.6-release. The estimate was 12 hours. - -## [1.5.0] - 2025-05-24 - -Version 1.5 comes with support for alarms (searching for alarms if the server permits and easy interface for adding alamrs when creating events), lots of workarounds and fixes ensuring compatibility with various servers, refactored some code, and done some preparations for the upcoming server compatibility hints project. - -### Deprecated - -Python 3.7 is no longer tested (dependency problems) - but it should work. Please file a bug report if it doesn't work. (Note that the caldav library pulls in many dependencies, and not all of them supports dead snakes). - -### Fixed - -* Servers that return a quoted URL in their path will now be parsed correctly by @edel-macias-cubix in https://github.com/python-caldav/caldav/pull/473 -* Compatibility workaround: If `event.load()` fails, it will retry the load by doing a multiget - https://github.com/python-caldav/caldav/pull/460 and https://github.com/python-caldav/caldav/pull/475 - https://github.com/python-caldav/caldav/issues/459 -* Compatibility workaround: A problem with a wiki calendar fixed by @soundstorm in https://github.com/python-caldav/caldav/pull/469 -* Blank passwords should be acceptable - https://github.com/python-caldav/caldav/pull/481 -* Compatibility workaround: Accept XML content from calendar server even if it's marked up with content-type text/plain by @niccokunzmann in https://github.com/python-caldav/caldav/pull/465 -* Bugfix for saving component failing on multi-component recurrence objects - https://github.com/python-caldav/caldav/pull/467 -* Some exotic servers may return object URLs on search, but it does not work out to fetch the calendar data. Now it will log an error instead of raising an error in such cases. -* Some workarounds and fixes for getting tests passing on all the test servers I had at hand in https://github.com/python-caldav/caldav/pull/492 -* Search for todo-items would ignore recurring tasks with COMPLETED recurrence instances, ref https://github.com/python-caldav/caldav/issues/495, fixed in https://github.com/python-caldav/caldav/pull/496 - -### Changed - -* The `tests/compatibility_issues.py` has been moved to `caldav/compatibility_hints.py`, this to make it available for a caldav-server-tester-tool that I'm splitting off to a separate project/repository, and also to make https://github.com/python-caldav/caldav/issues/402 possible. - -#### Refactoring - -* Minor code cleanups by github user @ArtemIsmagilov in https://github.com/python-caldav/caldav/pull/456 -* The very much overgrown `objects.py`-file has been split into three - https://github.com/python-caldav/caldav/pull/483 -* Refactor compatibility issues https://github.com/python-caldav/caldav/pull/484 -* Refactoring of `multiget` in https://github.com/python-caldav/caldav/pull/492 - -### Documentation - -* Add more project links to PyPI by @niccokunzmann in https://github.com/python-caldav/caldav/pull/464 -* Document how to use tox for testing by @niccokunzmann in https://github.com/python-caldav/caldav/pull/466 -* Readthedocs integration has been repaired (https://github.com/python-caldav/caldav/pull/453 - but eventually the fix was introduced directly in the master branch) - -#### Test framework - -* Radicale tests have been broken for a while, but now it's fixed ... and github will be running those tests as well. https://github.com/python-caldav/caldav/pull/480 plus commits directly to the main branch. -* Python 3.13 is officially supported by github user @ArtemIsmagilov in https://github.com/python-caldav/caldav/pull/454 -* Functional test framework has been refactored in https://github.com/python-caldav/caldav/pull/450 - * code for setting up and rigging down xandikos/radicale servers have been moved from `tests/test_caldav.py` to `tests/conf.py`. This allows for: - * Adding code (including system calls or remote API calls) for Setting up and tearing down calendar servers in `conf_private.py` - * Creating a local xandikos or radicale server in the `tests.client`-method, which is also used in the `examples`-section. - * Allows offline testing of my upcoming `check_server_compatibility`-script - * Also added the possibility to tag test servers with a name -* Many changes done to the compatibility flag list (due to work on the server-checker project) -* Functional tests for multiget in https://github.com/python-caldav/caldav/pull/489 - -### Added - -* Methods for verifying and adding reverse relations - https://github.com/python-caldav/caldav/pull/336 -* Easy creation of events and tasks with alarms, search for alarms - https://github.com/python-caldav/caldav/pull/221 -* Work in progress: `auto_conn`, `auto_calendar` and `auto_calendars` may read caldav connection and calendar configuration from a config file, environmental variables or other sources. Currently I've made the minimal possible work to be able to test the caldav-server-tester script. -* By now `calendar.search(..., sort_keys=("DTSTART")` will work. Sort keys expects a list or a tuple, but it's easy to send an attribute by mistake. https://github.com/python-caldav/caldav/issues/448 https://github.com/python-caldav/caldav/pull/449 -* The `class_`-parameter now works when sending data to `save_event()` etc. -* Search method now takes parameter `journal=True`. ref https://github.com/python-caldav/caldav/issues/237 and https://github.com/python-caldav/caldav/pull/486 - -### Time spent and roadmap - -A roadmap was made in May 2025: https://github.com/python-caldav/caldav/issues/474 - the roadmap includes time estimates. - -Since the roadmap was made, the maintainer has spent 39 hours working on the CalDAV project - this includes a bit of documentation, quite some communication, reading on the RFCs, code reviewing, but mostly just coding. This is above estimate due to new issues coming in. - - -## [1.4.0] - 2024-11-05 - -* Lots of work lifting the project up to more modern standards and improving code, thanks to Georges Toth (github @sim0nx), Matthias Urlichs (github @smurfix) and @ArtemIsmagilov. While this shouldn't matter for existing users, it will make the library more future-proof. -* Quite long lists of fixes, improvements and some few changes, nothing big, main focus is on ensuring compatibility with as many server implementations as possible. See below. - -### Fixed - -* Partial workaround for https://github.com/python-caldav/caldav/issues/401 - some servers require comp-type in the search query -* At least one bugfix, possibly fixing #399 - the `accept_invite`-method not working - https://github.com/python-caldav/caldav/pull/403 -* Fix/workaround for servers sending MAILTO in uppercase - https://github.com/python-caldav/caldav/issues/388, https://github.com/python-caldav/caldav/issues/399 and https://github.com/python-caldav/caldav/pull/403 -* `get_duration`: make sure the algorithm doesn't raise an exception comparing dates with timestamps - https://github.com/python-caldav/caldav/pull/381 -* `set_due`: make sure the algorithm doesn't raise an exception comparing naive timestamps with timezone timestamps - https://github.com/python-caldav/caldav/pull/381 -* Code formatting / style fixes. -* Jason Yau introduced the possibility to add arbitrary headers - but things like User-Agent would anyway always be overwritten. Now the custom logic takes precedence. Pull request https://github.com/python-caldav/caldav/pull/386, issue https://github.com/python-caldav/caldav/issues/385 -* Search method has some logic handling non-conformant servers (loading data from the server if the search response didn't include the icalendar data, ignoring trash from the Google server when it returns data without a VTODO/VEVENT/VJOURNAL component), but it was inside an if-statement and applied only if Expanded-flag was set to True. Moved the logic out of the if, so it always applies. -* Revisited a problem that Google sometimes delivers junk when doing searches - credits to github user @zhwei in https://github.com/python-caldav/caldav/pull/366 -* There were some compatibility-logic loading objects if the server does not deliver icalendar data (as it's suppsoed to do according to the RFC), but only if passing the `expand`-flag to the `search`-method. Fixed that it loads regardless of weather `expand` is set or not. Also in https://github.com/python-caldav/caldav/pull/366 -* Tests - lots of work getting as much test code as possible to pass on different servers, and now testing also for Python 3.12 - ref https://github.com/python-caldav/caldav/pull/368 https://github.com/python-caldav/caldav/issues/360 https://github.com/python-caldav/caldav/pull/447 https://github.com/python-caldav/caldav/pull/369 https://github.com/python-caldav/caldav/pull/370 https://github.com/python-caldav/caldav/pull/441 https://github.com/python-caldav/caldav/pull/443a -* The vcal fixup method was converting implicit dates into timestamps in the COMPLETED property, as it should be a timestamp according to the RFC - however, the regexp failed on explicit dates. Now it will take explicit dates too. https://github.com/python-caldav/caldav/pull/387 -* Code cleanups and modernizing the code - https://github.com/python-caldav/caldav/pull/404 https://github.com/python-caldav/caldav/pull/405 https://github.com/python-caldav/caldav/pull/406 https://github.com/python-caldav/caldav/pull/407 https://github.com/python-caldav/caldav/pull/408 https://github.com/python-caldav/caldav/pull/409 https://github.com/python-caldav/caldav/pull/412 https://github.com/python-caldav/caldav/pull/414 https://github.com/python-caldav/caldav/pull/415 https://github.com/python-caldav/caldav/pull/418 https://github.com/python-caldav/caldav/pull/419 https://github.com/python-caldav/caldav/pull/417 https://github.com/python-caldav/caldav/pull/421 https://github.com/python-caldav/caldav/pull/423 https://github.com/python-caldav/caldav/pull/430 https://github.com/python-caldav/caldav/pull/431 https://github.com/python-caldav/caldav/pull/440 https://github.com/python-caldav/caldav/pull/365 -* Doc - improved examples, https://github.com/python-caldav/caldav/pull/427 -* Purelymail sends absolute URLs, which is allowed by the RFC but was not supported by the library. Fixed in https://github.com/python-caldav/caldav/pull/442 - -### Changed - -* In https://github.com/python-caldav/caldav/pull/366, I optimized the logic in `search` a bit, now all data from the server not containing a VEVENT, VTODO or VJOURNAL will be thrown away. I believe this won't cause any problems for anyone, as the server should only deliver such components, but I may be wrong. -* Default User-Agent changed from `Mozilla/5` to `python-caldav/{__version__}` - https://github.com/python-caldav/caldav/pull/392 -* Change fixup log lvl to warning and merge diff log messages into related parent log by @MrEbbinghaus in https://github.com/python-caldav/caldav/pull/438 -* Mandatory fields are now added if trying to save incomplete icalendar data, https://github.com/python-caldav/caldav/pull/447 - -### Added - -* Allow to reverse the sorting order on search function by @twissell- in https://github.com/python-caldav/caldav/pull/433 -* Work on integrating typing information. Details in https://github.com/python-caldav/caldav/pull/358 -* Remove dependency on pytz. Details in https://github.com/python-caldav/caldav/issues/231 and https://github.com/python-caldav/caldav/pull/363 -* Use setuptools-scm / pyproject.toml (modern packaging). Details in https://github.com/python-caldav/caldav/pull/364 and https://github.com/python-caldav/caldav/pull/367 -* Debugging tool - an environment variable can be set, causing the library to spew out server communications into files under /tmp. Details in https://github.com/python-caldav/caldav/pull/249 and https://github.com/python-caldav/caldav/issues/248 -* Comaptibility matrix for posteo.de servers in `tests/compatibility_issues.py` -* Added sort_reverse option to the search function to reverse the sorting order of the found objects. -* It's now possible to specify if `expand` should be done on the server side or client side. Default is as before, expanding on server side, then on the client side if unexpanded data is returned. It was found that some servers does expanding, but does not add `RECURRENCE-ID`. https://github.com/python-caldav/caldav/pull/447 - -### Security - -The debug information gathering hook has been in the limbo for a long time, due to security concerns: - -* An attacker that has access to alter the environment the application is running under may cause a DoS-attack, filling up available disk space with debug logging. -* An attacker that has access to alter the environment the application is running under, and access to read files under /tmp (files being 0600 and owned by the uid the application is running under), will be able to read the communication between the server and the client, communication that may be private and confidential. - -Thinking it through three times, I'm not too concerned - if someone has access to alter the environment the process is running under and access to read files run by the uid of the application, then this someone should already be trusted and will probably have the possibility to DoS the system or gather this communication through other means. - -### Credits - -Georges Tooth, Крылов Александр, zhwei, Stefan Ollinger, Matthias Urlichs, ArtemIsmagilov, Tobias Brox has contributed directly with commits and pull requests included in this release. Many more has contributed through reporting issues and code snippets. - -### Test runs - -Prior to release (commit 92de2e29276d3da2dcc721cbaef8da5eb344bd11), tests have been run successfully towards: - -* radicale (internal tests) -* xandikos (internal tests) -* ecloud.global (NextCloud) - with flags `compatibility_issues.nextcloud + ['no_delete_calendar', 'unique_calendar_ids', 'rate_limited', 'broken_expand']` and with frequent manual "empty thrashcan"-operations in webui. -* Zimbra -* DAViCal -* Posteo -* Purelymail - -## [1.3.9] - 2023-12-12 - -Some bugfixes. - -### Fixed - -* Some parts of the library would throw OverflowError on very weird dates/timestamps. Now those are converted to the minimum or maximum accepted date/timestamp. Credits to github user @tamarinvs19 in https://github.com/python-caldav/caldav/pull/327 -* `DAVResponse.davclient` was always set to None, now it may be set to the `DAVClient` instance. Credits to github user @sobolevn in https://github.com/python-caldav/caldav/pull/323 -* `DAVResponse.davclient` was always set to None, now it may be set to the `DAVClient` instance. Credits to github user @sobolevn in https://github.com/python-caldav/caldav/pull/323 -* `examples/sync_examples.py`, the sync token needs to be saved to the database (credits to Savvas Giannoukas) -* Bugfixes in `set_relations`, credits to github user @Zocker1999NET in https://github.com/python-caldav/caldav/pull/335 and https://github.com/python-caldav/caldav/pull/333 -* Dates that are off the scale are converted to `min_date` and `max_date` (and logging en error) rather than throwing OverflowError, credits to github user @tamarinvs19 in https://github.com/python-caldav/caldav/pull/327 -* Completing a recurring task with a naïve or floating `DTSTART` would cause a runtime error -* Tests stopped working on python 3.7 and python 3.8 for a while. This was only an issue with libraries used for the testing, and has been mended. -* Bugfix that a 500 internal server error could cause an recursion loop, credits to github user @bchardin in https://github.com/python-caldav/caldav/pull/344 -* Compatibility-fix for Google calendar, credits to github user @e-katov in https://github.com/python-caldav/caldav/pull/344 -* Spelling, grammar and removing a useless regexp, credits to github user @scop in https://github.com/python-caldav/caldav/pull/337 -* Faulty icalendar code caused the code for fixing faulty icalendar code to break, credits to github user @yuwash in https://github.com/python-caldav/caldav/pull/347 and https://github.com/python-caldav/caldav/pull/350 -* Sorting on uppercase attributes didn't work, ref issue https://github.com/python-caldav/caldav/issues/352 - credits to github user @ArtemIsmagilov. -* The sorting algorithm was dependent on vobject library - refactored to use icalendar library instead -* Lots more test code on the sorting, and fixed some corner cases -* Creating a task with a status didn't work - -## [1.3.8] - 2023-12-10 [YANKED] - -Why do I never manage to do releases right .. - -## [1.3.7] - 2023-12-10 [YANKED] - -I managed to tag the wrong commit - -## [1.3.6] - 2023-07-20 - -Very minor test fix - -### Fixed - -One of the tests has been partially disabled, ref https://github.com/python-caldav/caldav/issues/300 , https://github.com/python-caldav/caldav/issues/320 and https://github.com/python-caldav/caldav/pull/321 - -## [1.3.5] - 2023-07-19 [YANKED] - -Seems like I've been using the wrong procedure all the time for doing pypi-releases - -## [1.3.4] - 2023-07-19 [YANKED] - -... GitHub has some features that it will merge pull requests only when all tests passes ... but somehow I can't get it to work, so 1.3.4 broke the style test again ... - -## [1.3.3] - 2023-07-19 - -Summary: Some few workarounds to support yet more different calendar servers and cloud providers, some few minor enhancements needed by various contributors, and some minor bugfixes. - -### Added -* Support for very big events, credits to github user @aaujon in https://github.com/python-caldav/caldav/pull/301 -* Custom HTTP headers was added in v1.2, but documentation and unit test is added in v1.3, credits to github user @JasonSanDiego in https://github.com/python-caldav/caldav/pull/306 -* More test code in https://github.com/python-caldav/caldav/pull/308 -* Add props parameter to search function, credits to github user @ge-lem in https://github.com/python-caldav/caldav/pull/315 -* Set an id field in calendar objects when populated through `CalendarSet.calendars()`, credits to github user @shikasta-net in https://github.com/python-caldav/caldav/pull/314 -* `get_relatives`-method, https://github.com/python-caldav/caldav/pull/294 -* `get_dtend`-method - -### Fixed -* Bugfix in error handling, credits to github user @aaujon in https://github.com/python-caldav/caldav/pull/299 -* Various minor bugfixes in https://github.com/python-caldav/caldav/pull/307 -* Compatibility workaround for unknown caldav server in https://github.com/python-caldav/caldav/pull/303 -* Google compatibility workaround, credits to github user @flozz in https://github.com/python-caldav/caldav/pull/312 -* Documentation typos, credits to github user @FluxxCode in https://github.com/python-caldav/caldav/pull/317 -* Improved support for cloud provider gmx.de in https://github.com/python-caldav/caldav/pull/318 -* Don't yield errors on (potentially invalid) XML-parameters that are included in the RFC examples - https://github.com/python-caldav/caldav/issues/209 - https://github.com/python-caldav/caldav/pull/508 - -### Changed - -* Refactored relation handling in `set_due` - -## [1.3.2] - 2023-07-19 [YANKED] - -One extra line in CHANGELOG.md caused style tests to break. Can't have a release with broken tests. Why is it so hard for me to do releases correctly? - -## [1.3.1] - 2023-07-19 [YANKED] - -I forgot bumping the version number from 1.3.0 to 1.3.1 prior to tagging - -## [1.3.0] - 2023-07-19 [YANKED] - -I accidentally tagged the wrong stuff in the git repo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a7f10be..fc0348d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Contributions are mostly welcome. If the length of this text scares you, then I ## Usage of AI and other tools -A separate [AI POLICY](AI_POLICY.md) has been made. The gist of it, be transparent and inform if your contribution was a result of clever tool usage and/or AI-usage, don't submit code if you don't understand the code yourself, and you are supposed to contribute value to the project. If you're too lazy to read the AI Policy, then at least have a chat with the AI to work out if your contribution is within the policy or not. +A separate [AI POLICY](AI-POLICY.md) has been made. The gist of it, be transparent and inform if your contribution was a result of clever tool usage and/or AI-usage, don't submit code if you don't understand the code yourself, and you are supposed to contribute value to the project. If you're too lazy to read the AI Policy, then at least have a chat with the AI to work out if your contribution is within the policy or not. ## GitHub diff --git a/README.md b/README.md index 2009943f..946efb28 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,47 @@ Features: * create, modify calendar * create, update and delete event * search events by dates + * async support via `caldav.aio` module * etc. -The documentation was freshed up a bit as of version 2.0, and is available at https://caldav.readthedocs.io/ +## Quick Start + +```python +from caldav import get_davclient + +with get_davclient() as client: + principal = client.principal() + calendars = principal.get_calendars() + for cal in calendars: + print(f"Calendar: {cal.name}") +``` + +## Async API + +For async/await support, use the `caldav.aio` module: + +```python +import asyncio +from caldav import aio + +async def main(): + async with aio.get_async_davclient() as client: + principal = await client.principal() + calendars = await principal.get_calendars() + for cal in calendars: + print(f"Calendar: {cal.name}") + +asyncio.run(main()) +``` + +The documentation was updated as of version 2.0, and is available at https://caldav.readthedocs.io/ The package is published at [Pypi](https://pypi.org/project/caldav) +## HTTP Libraries + +The sync client uses [niquests](https://github.com/jawah/niquests) by default (with fallback to [requests](https://requests.readthedocs.io/)). The async client uses [httpx](https://www.python-httpx.org/) if installed (`pip install caldav[async]`), otherwise falls back to niquests. See [HTTP Library Configuration](docs/source/http-libraries.rst) for details. + Licences: Caldav is dual-licensed under the [GNU GENERAL PUBLIC LICENSE Version 3](COPYING.GPL) or the [Apache License 2.0](COPYING.APACHE). diff --git a/caldav/__init__.py b/caldav/__init__.py index 319a6eaa..cef044c2 100644 --- a/caldav/__init__.py +++ b/caldav/__init__.py @@ -10,7 +10,7 @@ warnings.warn( "You need to install the `build` package and do a `python -m build` to get caldav.__version__ set correctly" ) -from .davclient import DAVClient +from .davclient import DAVClient, get_davclient, get_calendars, get_calendar from .search import CalDAVSearcher ## TODO: this should go away in some future version of the library. @@ -29,4 +29,4 @@ def emit(self, record) -> None: log.addHandler(NullHandler()) -__all__ = ["__version__", "DAVClient"] +__all__ = ["__version__", "DAVClient", "get_davclient", "get_calendars", "get_calendar"] diff --git a/caldav/aio.py b/caldav/aio.py new file mode 100644 index 00000000..c1bd9787 --- /dev/null +++ b/caldav/aio.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +""" +Async-first CalDAV API. + +This module provides async versions of the CalDAV client and objects. +Use this for new async code: + + from caldav import aio + + async with aio.AsyncDAVClient(url=..., username=..., password=...) as client: + principal = await client.get_principal() + calendars = await principal.get_calendars() + for cal in calendars: + events = await cal.get_events() + +For backward-compatible sync code, continue using: + + from caldav import DAVClient + +Note: As of the Sans-I/O refactoring (Phase 9), the domain objects (Calendar, +Principal, Event, etc.) are now dual-mode - they work with both sync and async +clients. When used with AsyncDAVClient, methods like calendars(), events(), etc. +return coroutines that must be awaited. + +The Async* aliases are kept for backward compatibility but now point to the +unified dual-mode classes. +""" +# Import the async client (this is truly async) +from caldav.async_davclient import AsyncDAVClient +from caldav.async_davclient import AsyncDAVResponse +from caldav.async_davclient import get_davclient as get_async_davclient +from caldav.calendarobjectresource import CalendarObjectResource +from caldav.calendarobjectresource import Event +from caldav.calendarobjectresource import FreeBusy +from caldav.calendarobjectresource import Journal +from caldav.calendarobjectresource import Todo +from caldav.collection import Calendar +from caldav.collection import CalendarSet +from caldav.collection import Principal +from caldav.collection import ScheduleInbox +from caldav.collection import ScheduleMailbox +from caldav.collection import ScheduleOutbox +from caldav.davobject import DAVObject + +# Import unified dual-mode domain classes + +# Create aliases for backward compatibility with code using Async* names +AsyncDAVObject = DAVObject +AsyncCalendarObjectResource = CalendarObjectResource +AsyncEvent = Event +AsyncTodo = Todo +AsyncJournal = Journal +AsyncFreeBusy = FreeBusy +AsyncCalendar = Calendar +AsyncCalendarSet = CalendarSet +AsyncPrincipal = Principal +AsyncScheduleMailbox = ScheduleMailbox +AsyncScheduleInbox = ScheduleInbox +AsyncScheduleOutbox = ScheduleOutbox + +__all__ = [ + # Client + "AsyncDAVClient", + "AsyncDAVResponse", + "get_async_davclient", + # Base objects (unified dual-mode) + "DAVObject", + "CalendarObjectResource", + # Calendar object types (unified dual-mode) + "Event", + "Todo", + "Journal", + "FreeBusy", + # Collections (unified dual-mode) + "Calendar", + "CalendarSet", + "Principal", + # Scheduling (RFC6638) + "ScheduleMailbox", + "ScheduleInbox", + "ScheduleOutbox", + # Legacy aliases for backward compatibility + "AsyncDAVObject", + "AsyncCalendarObjectResource", + "AsyncEvent", + "AsyncTodo", + "AsyncJournal", + "AsyncFreeBusy", + "AsyncCalendar", + "AsyncCalendarSet", + "AsyncPrincipal", + "AsyncScheduleMailbox", + "AsyncScheduleInbox", + "AsyncScheduleOutbox", +] diff --git a/caldav/async_davclient.py b/caldav/async_davclient.py new file mode 100644 index 00000000..a02006c6 --- /dev/null +++ b/caldav/async_davclient.py @@ -0,0 +1,1466 @@ +#!/usr/bin/env python +""" +Async-first DAVClient implementation for the caldav library. + +This module provides the core async CalDAV/WebDAV client functionality. +For sync usage, see the davclient.py wrapper. +""" +import sys +from collections.abc import Mapping +from types import TracebackType +from typing import Any +from typing import Optional +from typing import TYPE_CHECKING +from urllib.parse import unquote + +if TYPE_CHECKING: + from caldav.calendarobjectresource import CalendarObjectResource, Event, Todo + from caldav.collection import Calendar, Principal + +# Try httpx first (preferred), fall back to niquests +_USE_HTTPX = False +_USE_NIQUESTS = False +_H2_AVAILABLE = False + +try: + import httpx + + _USE_HTTPX = True + # Check if h2 is available for HTTP/2 support + try: + import h2 # noqa: F401 + + _H2_AVAILABLE = True + except ImportError: + pass +except ImportError: + pass + +if not _USE_HTTPX: + try: + import niquests + from niquests import AsyncSession + from niquests.structures import CaseInsensitiveDict + + _USE_NIQUESTS = True + except ImportError: + pass + +if not _USE_HTTPX and not _USE_NIQUESTS: + raise ImportError( + "Either httpx or niquests library is required for async_davclient. " + "Install with: pip install httpx (or: pip install niquests)" + ) + + +from caldav import __version__ +from caldav.base_client import BaseDAVClient +from caldav.base_client import get_davclient as _base_get_davclient +from caldav.compatibility_hints import FeatureSet +from caldav.lib import error +from caldav.lib.python_utilities import to_normal_str, to_wire +from caldav.lib.url import URL +from caldav.objects import log +from caldav.protocol.types import ( + CalendarQueryResult, + PropfindResult, +) +from caldav.protocol.xml_builders import ( + _build_calendar_multiget_body, + _build_calendar_query_body, + _build_propfind_body, + _build_sync_collection_body, +) +from caldav.protocol.xml_parsers import ( + _parse_calendar_query_response, + _parse_propfind_response, + _parse_sync_collection_response, +) +from caldav.requests import HTTPBearerAuth +from caldav.response import BaseDAVResponse + +if sys.version_info < (3, 11): + from typing_extensions import Self +else: + from typing import Self + + +class AsyncDAVResponse(BaseDAVResponse): + """ + Response from an async DAV request. + + This class handles the parsing of DAV responses, including XML parsing. + End users typically won't interact with this class directly. + + Response parsing methods are inherited from BaseDAVResponse. + + New protocol-based attributes: + results: Parsed results from protocol layer (List[PropfindResult], etc.) + sync_token: Sync token from sync-collection response + """ + + # Protocol-based parsed results (new interface) + results: list[PropfindResult | CalendarQueryResult] | None = None + sync_token: str | None = None + + def __init__( + self, response: Any, davclient: Optional["AsyncDAVClient"] = None + ) -> None: + """Initialize from httpx.Response or niquests.Response.""" + self._init_from_response(response, davclient) + + # Response parsing methods are inherited from BaseDAVResponse + + +class AsyncDAVClient(BaseDAVClient): + """ + Async WebDAV/CalDAV client. + + This is the core async implementation. For sync usage, see DAVClient + in davclient.py which provides a thin wrapper around this class. + + The recommended way to create a client is via get_davclient(): + async with await get_davclient(url="...", username="...", password="...") as client: + principal = await client.get_principal() + """ + + proxy: str | None = None + url: URL = None + huge_tree: bool = False + + def __init__( + self, + url: str | None = "", + proxy: str | None = None, + username: str | None = None, + password: str | None = None, + auth: Any | None = None, # httpx.Auth or niquests.auth.AuthBase + auth_type: str | None = None, + timeout: int | None = None, + ssl_verify_cert: bool | str = True, + ssl_cert: str | tuple[str, str] | None = None, + headers: Mapping[str, str] | None = None, + huge_tree: bool = False, + features: FeatureSet | dict | str | None = None, + enable_rfc6764: bool = True, + require_tls: bool = True, + ) -> None: + """ + Initialize an async DAV client. + + Args: + url: CalDAV server URL, domain, or email address. + proxy: Proxy server (scheme://hostname:port). + username: Username for authentication. + password: Password for authentication. + auth: Custom auth object (httpx.Auth or niquests AuthBase). + auth_type: Auth type ('bearer', 'digest', or 'basic'). + timeout: Request timeout in seconds. + ssl_verify_cert: SSL certificate verification (bool or CA bundle path). + ssl_cert: Client SSL certificate (path or (cert, key) tuple). + headers: Additional headers for all requests. + huge_tree: Enable XMLParser huge_tree for large events (security consideration). + features: FeatureSet for server compatibility workarounds. + enable_rfc6764: Enable RFC6764 DNS-based service discovery. + require_tls: Require TLS for discovered services (security consideration). + """ + headers = headers or {} + + if isinstance(features, str): + import caldav.compatibility_hints + + features = getattr(caldav.compatibility_hints, features) + if isinstance(features, FeatureSet): + self.features = features + else: + self.features = FeatureSet(features) + self.huge_tree = huge_tree + + # Store SSL and proxy settings for client creation + self._http2 = None + self._proxy = proxy + if self._proxy is not None and "://" not in self._proxy: + self._proxy = "http://" + self._proxy + self._ssl_verify_cert = ssl_verify_cert + self._ssl_cert = ssl_cert + self._timeout = timeout + + # Create async client with HTTP/2 if supported and h2 package is available + # Note: Client is created lazily or recreated when settings change + try: + # Only enable HTTP/2 if the server supports it AND h2 is installed + self._http2 = self.features.is_supported("http.multiplexing") and ( + _H2_AVAILABLE or _USE_NIQUESTS + ) + except (TypeError, AttributeError): + self._http2 = False + self._create_session() + + # Auto-construct URL if needed (RFC6764 discovery, etc.) + from caldav.davclient import _auto_url + + url_str, discovered_username = _auto_url( + url, + self.features, + timeout=timeout or 10, + ssl_verify_cert=ssl_verify_cert, + enable_rfc6764=enable_rfc6764, + username=username, + require_tls=require_tls, + ) + + # Use discovered username if available + if discovered_username and not username: + username = discovered_username + + # Parse and store URL + self.url = URL.objectify(url_str) + + # Extract auth from URL if present + url_username = None + url_password = None + if self.url.username: + url_username = unquote(self.url.username) + if self.url.password: + url_password = unquote(self.url.password) + + # Combine credentials (explicit params take precedence) + # Use explicit None check to preserve empty strings (needed for servers with no auth) + self.username = username if username is not None else url_username + self.password = password if password is not None else url_password + + # Setup authentication + self.auth = auth + self.auth_type = auth_type + if not self.auth and self.auth_type: + self.build_auth_object([self.auth_type]) + + # Setup proxy (stored in self._proxy above) + self.proxy = self._proxy + + # Setup other parameters (stored above for client creation) + self.timeout = self._timeout + self.ssl_verify_cert = self._ssl_verify_cert + self.ssl_cert = self._ssl_cert + + # Setup headers with User-Agent + self.headers: dict[str, str] = { + "User-Agent": f"caldav-async/{__version__}", + } + self.headers.update(headers) + + def _create_session(self) -> None: + """Create or recreate the async HTTP client with current settings.""" + if _USE_HTTPX: + self.session = httpx.AsyncClient( + http2=self._http2 or False, + proxy=self._proxy, + verify=self._ssl_verify_cert + if self._ssl_verify_cert is not None + else True, + cert=self._ssl_cert, + timeout=self._timeout, + ) + else: + # niquests - proxy/ssl/timeout are passed per-request + try: + self.session = AsyncSession(multiplexed=self._http2 or False) + except TypeError: + self.session = AsyncSession() + + async def __aenter__(self) -> Self: + """Async context manager entry.""" + return self + + async def __aexit__( + self, + exc_type: type | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Async context manager exit.""" + await self.close() + + async def close(self) -> None: + """Close the async client.""" + if hasattr(self, "session"): + if _USE_HTTPX: + await self.session.aclose() + else: + await self.session.close() + + @staticmethod + def _build_method_headers( + method: str, + depth: int | None = None, + extra_headers: Mapping[str, str] | None = None, + ) -> dict[str, str]: + """ + Build headers for WebDAV methods. + + Args: + method: HTTP method name. + depth: Depth header value (for PROPFIND/REPORT). + extra_headers: Additional headers to merge. + + Returns: + Dictionary of headers. + """ + headers: dict[str, str] = {} + + # Add Depth header for methods that support it + if depth is not None: + headers["Depth"] = str(depth) + + # Add Content-Type for methods that typically send XML bodies + if method in ("REPORT", "PROPFIND", "PROPPATCH", "MKCALENDAR", "MKCOL"): + headers["Content-Type"] = 'application/xml; charset="utf-8"' + + # Merge additional headers + if extra_headers: + headers.update(extra_headers) + + return headers + + async def request( + self, + url: str, + method: str = "GET", + body: str = "", + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Send an async HTTP request. + + Args: + url: Request URL. + method: HTTP method. + body: Request body. + headers: Additional headers. + + Returns: + AsyncDAVResponse object. + """ + headers = headers or {} + + combined_headers = self.headers.copy() + combined_headers.update(headers) + if (body is None or body == "") and "Content-Type" in combined_headers: + del combined_headers["Content-Type"] + + # Objectify the URL + url_obj = URL.objectify(url) + + log.debug( + f"sending request - method={method}, url={str(url_obj)}, headers={combined_headers}\nbody:\n{to_normal_str(body)}" + ) + + # Build request kwargs - different for httpx vs niquests + if _USE_HTTPX: + request_kwargs: dict[str, Any] = { + "method": method, + "url": str(url_obj), + "content": to_wire(body) if body else None, + "headers": combined_headers, + "auth": self.auth, + "timeout": self.timeout, + } + else: + # niquests uses different parameter names + proxies = None + if self.proxy is not None: + proxies = {url_obj.scheme: self.proxy} + request_kwargs: dict[str, Any] = { + "method": method, + "url": str(url_obj), + "data": to_wire(body) if body else None, + "headers": combined_headers, + "auth": self.auth, + "timeout": self.timeout, + "proxies": proxies, + "verify": self.ssl_verify_cert, + "cert": self.ssl_cert, + } + + try: + r = await self.session.request(**request_kwargs) + reason = r.reason_phrase if _USE_HTTPX else r.reason + log.debug(f"server responded with {r.status_code} {reason}") + if ( + r.status_code == 401 + and "text/html" in self.headers.get("Content-Type", "") + and not self.auth + ): + msg = ( + "No authentication object was provided. " + "HTML was returned when probing the server for supported authentication types. " + "To avoid logging errors, consider passing the auth_type connection parameter" + ) + if r.headers.get("WWW-Authenticate"): + auth_types = [ + t + for t in self.extract_auth_types(r.headers["WWW-Authenticate"]) + if t in ["basic", "digest", "bearer"] + ] + if auth_types: + msg += "\nSupported authentication types: {}".format( + ", ".join(auth_types) + ) + log.warning(msg) + response = AsyncDAVResponse(r, self) + except Exception: + # Workaround for servers that abort connection on unauthenticated requests + # ref https://github.com/python-caldav/caldav/issues/158 + if self.auth or not self.password: + raise + # Build minimal request for auth detection + if _USE_HTTPX: + r = await self.session.request( + method="GET", + url=str(url_obj), + headers=combined_headers, + timeout=self.timeout, + ) + else: + proxies = None + if self.proxy is not None: + proxies = {url_obj.scheme: self.proxy} + r = await self.session.request( + method="GET", + url=str(url_obj), + headers=combined_headers, + timeout=self.timeout, + proxies=proxies, + verify=self.ssl_verify_cert, + cert=self.ssl_cert, + ) + reason = r.reason_phrase if _USE_HTTPX else r.reason + log.debug( + f"auth type detection: server responded with {r.status_code} {reason}" + ) + if r.status_code == 401 and r.headers.get("WWW-Authenticate"): + auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"]) + self.build_auth_object(auth_types) + # Retry original request with auth + request_kwargs["auth"] = self.auth + r = await self.session.request(**request_kwargs) + response = AsyncDAVResponse(r, self) + + # Handle 401 responses for auth negotiation (after try/except) + # This matches the original sync client's auth negotiation logic + # httpx headers are already case-insensitive + if ( + r.status_code == 401 + and "WWW-Authenticate" in r.headers + and not self.auth + and self.username is not None + and self.password + is not None # Empty password OK, but None means not configured + ): + auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"]) + self.build_auth_object(auth_types) + + if not self.auth: + raise NotImplementedError( + "The server does not provide any of the currently " + "supported authentication methods: basic, digest, bearer" + ) + + # Retry request with authentication + return await self.request(url, method, body, headers) + + elif ( + r.status_code == 401 + and "WWW-Authenticate" in r.headers + and self.auth + and self.password + and isinstance(self.password, bytes) + ): + # Handle HTTP/2 issue (matches original sync client) + # Most likely wrong username/password combo, but could be an HTTP/2 problem + if ( + self.features.is_supported("http.multiplexing", return_defaults=False) + is None + ): + await self.close() # Uses correct close method for httpx/niquests + self._http2 = False + self._create_session() + # Set multiplexing to False BEFORE retry to prevent infinite loop + # If the retry succeeds, this was the right choice + # If it also fails with 401, it's not a multiplexing issue but an auth issue + self.features.set_feature("http.multiplexing", False) + # If this one also fails, we give up + ret = await self.request(str(url_obj), method, body, headers) + return ret + + # Most likely we're here due to wrong username/password combo, + # but it could also be charset problems. Some (ancient) servers + # don't like UTF-8 binary auth with Digest authentication. + # An example are old SabreDAV based servers. Not sure about UTF-8 + # and Basic Auth, but likely the same. So retry if password is + # a bytes sequence and not a string. + auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"]) + self.password = self.password.decode() + self.build_auth_object(auth_types) + + self.username = None + self.password = None + + return await self.request(str(url_obj), method, body, headers) + + # Raise AuthorizationError for 401/403 responses (matches original sync client) + if response.status in (401, 403): + try: + reason = response.reason + except AttributeError: + reason = "None given" + raise error.AuthorizationError(url=str(url_obj), reason=reason) + + return response + + # ==================== HTTP Method Wrappers ==================== + # Query methods (URL optional - defaults to self.url) + + async def propfind( + self, + url: str | None = None, + body: str = "", + depth: int = 0, + headers: Mapping[str, str] | None = None, + props: list[str] | None = None, + ) -> AsyncDAVResponse: + """ + Send a PROPFIND request. + + Args: + url: Target URL (defaults to self.url). + body: XML properties request (legacy, use props instead). + depth: Maximum recursion depth. + headers: Additional headers. + props: List of property names to request (uses protocol layer). + + Returns: + AsyncDAVResponse with results attribute containing parsed PropfindResult list. + """ + # Use protocol layer to build XML if props provided + if props is not None and not body: + body = _build_propfind_body(props).decode("utf-8") + + final_headers = self._build_method_headers("PROPFIND", depth, headers) + response = await self.request( + url or str(self.url), "PROPFIND", body, final_headers + ) + + # Parse response using protocol layer + if response.status in (200, 207) and response._raw: + raw_bytes = ( + response._raw + if isinstance(response._raw, bytes) + else response._raw.encode("utf-8") + ) + response.results = _parse_propfind_response( + raw_bytes, response.status, response.huge_tree + ) + + return response + + async def report( + self, + url: str | None = None, + body: str = "", + depth: int | None = 0, + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Send a REPORT request. + + Args: + url: Target URL (defaults to self.url). + body: XML report request. + depth: Maximum recursion depth. None means don't send Depth header + (required for calendar-multiget per RFC 4791 section 7.9). + headers: Additional headers. + + Returns: + AsyncDAVResponse + """ + final_headers = self._build_method_headers("REPORT", depth, headers) + return await self.request(url or str(self.url), "REPORT", body, final_headers) + + async def options( + self, + url: str | None = None, + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Send an OPTIONS request. + + Args: + url: Target URL (defaults to self.url). + headers: Additional headers. + + Returns: + AsyncDAVResponse + """ + return await self.request(url or str(self.url), "OPTIONS", "", headers) + + # ==================== Resource Methods (URL required) ==================== + + async def proppatch( + self, + url: str, + body: str = "", + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Send a PROPPATCH request. + + Args: + url: Target URL (required). + body: XML property update request. + headers: Additional headers. + + Returns: + AsyncDAVResponse + """ + final_headers = self._build_method_headers("PROPPATCH", extra_headers=headers) + return await self.request(url, "PROPPATCH", body, final_headers) + + async def mkcol( + self, + url: str, + body: str = "", + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Send a MKCOL request. + + MKCOL creates a WebDAV collection. For CalDAV, use mkcalendar instead. + + Args: + url: Target URL (required). + body: XML request (usually empty). + headers: Additional headers. + + Returns: + AsyncDAVResponse + """ + final_headers = self._build_method_headers("MKCOL", extra_headers=headers) + return await self.request(url, "MKCOL", body, final_headers) + + async def mkcalendar( + self, + url: str, + body: str = "", + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Send a MKCALENDAR request. + + Args: + url: Target URL (required). + body: XML request (usually contains calendar properties). + headers: Additional headers. + + Returns: + AsyncDAVResponse + """ + final_headers = self._build_method_headers("MKCALENDAR", extra_headers=headers) + return await self.request(url, "MKCALENDAR", body, final_headers) + + async def put( + self, + url: str, + body: str, + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Send a PUT request. + + Args: + url: Target URL (required). + body: Request body (e.g., iCalendar data). + headers: Additional headers. + + Returns: + AsyncDAVResponse + """ + return await self.request(url, "PUT", body, headers) + + async def post( + self, + url: str, + body: str, + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Send a POST request. + + Args: + url: Target URL (required). + body: Request body. + headers: Additional headers. + + Returns: + AsyncDAVResponse + """ + return await self.request(url, "POST", body, headers) + + async def delete( + self, + url: str, + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Send a DELETE request. + + Args: + url: Target URL (required). + headers: Additional headers. + + Returns: + AsyncDAVResponse + """ + return await self.request(url, "DELETE", "", headers) + + # ==================== High-Level CalDAV Methods ==================== + # These methods use the protocol layer for building XML and parsing responses + + async def calendar_query( + self, + url: str | None = None, + start: Any | None = None, + end: Any | None = None, + event: bool = False, + todo: bool = False, + journal: bool = False, + expand: bool = False, + depth: int = 1, + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Execute a calendar-query REPORT to search for calendar objects. + + Args: + url: Target calendar URL (defaults to self.url). + start: Start of time range filter. + end: End of time range filter. + event: Include events (VEVENT). + todo: Include todos (VTODO). + journal: Include journals (VJOURNAL). + expand: Expand recurring events. + depth: Search depth. + headers: Additional headers. + + Returns: + AsyncDAVResponse with results containing List[CalendarQueryResult]. + """ + + body, _ = _build_calendar_query_body( + start=start, + end=end, + event=event, + todo=todo, + journal=journal, + expand=expand, + ) + + final_headers = self._build_method_headers("REPORT", depth, headers) + response = await self.request( + url or str(self.url), "REPORT", body.decode("utf-8"), final_headers + ) + + # Parse response using protocol layer + if response.status in (200, 207) and response._raw: + raw_bytes = ( + response._raw + if isinstance(response._raw, bytes) + else response._raw.encode("utf-8") + ) + response.results = _parse_calendar_query_response( + raw_bytes, response.status, response.huge_tree + ) + + return response + + async def calendar_multiget( + self, + url: str | None = None, + hrefs: list[str] | None = None, + depth: int = 1, + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Execute a calendar-multiget REPORT to fetch specific calendar objects. + + Args: + url: Target calendar URL (defaults to self.url). + hrefs: List of object URLs to retrieve. + depth: Search depth. + headers: Additional headers. + + Returns: + AsyncDAVResponse with results containing List[CalendarQueryResult]. + """ + body = _build_calendar_multiget_body(hrefs or []) + + final_headers = self._build_method_headers("REPORT", depth, headers) + response = await self.request( + url or str(self.url), "REPORT", body.decode("utf-8"), final_headers + ) + + # Parse response using protocol layer + if response.status in (200, 207) and response._raw: + raw_bytes = ( + response._raw + if isinstance(response._raw, bytes) + else response._raw.encode("utf-8") + ) + response.results = _parse_calendar_query_response( + raw_bytes, response.status, response.huge_tree + ) + + return response + + async def sync_collection( + self, + url: str | None = None, + sync_token: str | None = None, + props: list[str] | None = None, + depth: int = 1, + headers: Mapping[str, str] | None = None, + ) -> AsyncDAVResponse: + """ + Execute a sync-collection REPORT for efficient synchronization. + + Args: + url: Target calendar URL (defaults to self.url). + sync_token: Previous sync token (None for initial sync). + props: Properties to include in response. + depth: Search depth. + headers: Additional headers. + + Returns: + AsyncDAVResponse with results containing SyncCollectionResult. + """ + body = _build_sync_collection_body(sync_token=sync_token, props=props) + + final_headers = self._build_method_headers("REPORT", depth, headers) + response = await self.request( + url or str(self.url), "REPORT", body.decode("utf-8"), final_headers + ) + + # Parse response using protocol layer + if response.status in (200, 207) and response._raw: + raw_bytes = ( + response._raw + if isinstance(response._raw, bytes) + else response._raw.encode("utf-8") + ) + sync_result = _parse_sync_collection_response( + raw_bytes, response.status, response.huge_tree + ) + response.results = sync_result.changed + response.sync_token = sync_result.sync_token + + return response + + # ==================== Authentication Helpers ==================== + + def build_auth_object(self, auth_types: list[str] | None = None) -> None: + """Build authentication object for the httpx/niquests library. + + Uses shared auth type selection logic from BaseDAVClient, then + creates the appropriate auth object for this HTTP library. + + Args: + auth_types: List of acceptable auth types from server. + """ + # Use shared selection logic + auth_type = self._select_auth_type(auth_types) + + # Build auth object - use appropriate classes for httpx or niquests + if auth_type == "bearer": + self.auth = HTTPBearerAuth(self.password) + elif auth_type == "digest": + if _USE_HTTPX: + self.auth = httpx.DigestAuth(self.username, self.password) + else: + from niquests.auth import HTTPDigestAuth + + self.auth = HTTPDigestAuth(self.username, self.password) + elif auth_type == "basic": + if _USE_HTTPX: + self.auth = httpx.BasicAuth(self.username, self.password) + else: + from niquests.auth import HTTPBasicAuth + + self.auth = HTTPBasicAuth(self.username, self.password) + elif auth_type: + raise error.AuthorizationError(f"Unsupported auth type: {auth_type}") + + # ==================== High-Level Methods ==================== + # These methods provide a clean, client-centric async API using the operations layer. + + async def get_principal(self) -> "Principal": + """Get the principal (user) for this CalDAV connection. + + This method fetches the current-user-principal from the server and returns + a Principal object that can be used to access calendars and other resources. + + Returns: + Principal object for the authenticated user. + + Example: + async with await get_davclient(url="...", username="...", password="...") as client: + principal = await client.get_principal() + calendars = await client.get_calendars(principal) + """ + from caldav.collection import Principal + + # Use operations layer for discovery logic + + # Fetch current-user-principal + response = await self.propfind( + str(self.url), + props=["{DAV:}current-user-principal"], + depth=0, + ) + + principal_url = None + if response.results: + for result in response.results: + cup = result.properties.get("{DAV:}current-user-principal") + if cup: + principal_url = cup + break + + if not principal_url: + # Fallback: use the base URL as principal URL + principal_url = str(self.url) + + # Create and return Principal object + principal = Principal(client=self, url=principal_url) + return principal + + async def get_calendars( + self, principal: Optional["Principal"] = None + ) -> list["Calendar"]: + """Get all calendars for the given principal. + + This method fetches calendars from the principal's calendar-home-set + and returns a list of Calendar objects. + + Args: + principal: Principal object (if None, fetches principal first) + + Returns: + List of Calendar objects. + + Example: + principal = await client.get_principal() + calendars = await client.get_calendars(principal) + for cal in calendars: + print(f"Calendar: {cal.name}") + """ + from caldav.collection import Calendar + from caldav.operations.calendarset_ops import ( + _extract_calendars_from_propfind_results as extract_calendars, + ) + + if principal is None: + principal = await self.get_principal() + + # Get calendar-home-set from principal + calendar_home_url = await self._get_calendar_home_set(principal) + if not calendar_home_url: + return [] + + # Make URL absolute if relative + calendar_home_url = self._make_absolute_url(calendar_home_url) + + # Fetch calendars via PROPFIND + response = await self.propfind( + calendar_home_url, + props=self.CALENDAR_LIST_PROPS, + depth=1, + ) + + # Process results using shared helper + calendar_infos = extract_calendars(response.results) + + # Convert CalendarInfo objects to Calendar objects + return [ + Calendar(client=self, url=info.url, name=info.name, id=info.cal_id) + for info in calendar_infos + ] + + async def _get_calendar_home_set(self, principal: "Principal") -> str | None: + """Get the calendar-home-set URL for a principal. + + Args: + principal: Principal object + + Returns: + Calendar home set URL or None + """ + from caldav.operations.principal_ops import ( + _extract_calendar_home_set_from_results as extract_home_set, + ) + + # Try to get from principal properties + response = await self.propfind( + str(principal.url), + props=self.CALENDAR_HOME_SET_PROPS, + depth=0, + ) + + return extract_home_set(response.results) + + async def get_events( + self, + calendar: "Calendar", + start: Any | None = None, + end: Any | None = None, + ) -> list["Event"]: + """Get events from a calendar. + + This is a convenience method that searches for VEVENT objects in the + calendar, optionally filtered by date range. + + Args: + calendar: Calendar to search + start: Start of date range (optional) + end: End of date range (optional) + + Returns: + List of Event objects. + + Example: + from datetime import datetime + events = await client.get_events( + calendar, + start=datetime(2024, 1, 1), + end=datetime(2024, 12, 31) + ) + """ + return await self.search_calendar(calendar, event=True, start=start, end=end) + + async def get_todos( + self, + calendar: "Calendar", + include_completed: bool = False, + ) -> list["Todo"]: + """Get todos from a calendar. + + Args: + calendar: Calendar to search + include_completed: Whether to include completed todos + + Returns: + List of Todo objects. + """ + return await self.search_calendar( + calendar, todo=True, include_completed=include_completed + ) + + async def search_calendar( + self, + calendar: "Calendar", + event: bool = False, + todo: bool = False, + journal: bool = False, + start: Any | None = None, + end: Any | None = None, + include_completed: bool | None = None, + expand: bool = False, + **kwargs: Any, + ) -> list["CalendarObjectResource"]: + """Search a calendar for events, todos, or journals. + + This method provides a clean interface to calendar search using the + operations layer for building queries and processing results. + + Args: + calendar: Calendar to search + event: Search for events (VEVENT) + todo: Search for todos (VTODO) + journal: Search for journals (VJOURNAL) + start: Start of date range + end: End of date range + include_completed: Include completed todos (default: False for todos) + expand: Expand recurring events + **kwargs: Additional search parameters + + Returns: + List of Event/Todo/Journal objects. + + Example: + # Get all events in January 2024 + events = await client.search_calendar( + calendar, + event=True, + start=datetime(2024, 1, 1), + end=datetime(2024, 1, 31), + ) + + # Get pending todos + todos = await client.search_calendar( + calendar, + todo=True, + include_completed=False, + ) + """ + from caldav.search import CalDAVSearcher + + # Build searcher with parameters + searcher = CalDAVSearcher( + event=event, + todo=todo, + journal=journal, + start=start, + end=end, + expand=expand, + ) + + if include_completed is not None: + searcher.include_completed = include_completed + + # Execute async search + results = await searcher.async_search(calendar, **kwargs) + return results + + async def search_principals(self, name: Optional[str] = None) -> list["Principal"]: + """ + Search for principals on the server. + + Instead of returning the current logged-in principal, this method + attempts to query for all principals (or principals matching a name). + This may or may not work depending on the permissions and + implementation of the calendar server. + + Args: + name: Optional name filter to search for specific principals + + Returns: + List of Principal objects found on the server + + Raises: + ReportError: If the server doesn't support principal search + """ + from caldav.collection import CalendarSet, Principal + from caldav.elements import cdav, dav + from lxml import etree + + if name: + name_filter = [ + dav.PropertySearch() + + [dav.Prop() + [dav.DisplayName()]] + + dav.Match(value=name) + ] + else: + name_filter = [] + + query = ( + dav.PrincipalPropertySearch() + + name_filter + + [dav.Prop(), cdav.CalendarHomeSet(), dav.DisplayName()] + ) + response = await self.report(str(self.url), etree.tostring(query.xmlelement())) + + if response.status >= 300: + raise error.ReportError( + f"{response.status} {response.reason} - {response.raw}" + ) + + principal_dict = response._find_objects_and_props() + ret = [] + for x in principal_dict: + p = principal_dict[x] + if dav.DisplayName.tag not in p: + continue + pname = p[dav.DisplayName.tag].text + error.assert_(not p[dav.DisplayName.tag].getchildren()) + error.assert_(not p[dav.DisplayName.tag].items()) + chs = p[cdav.CalendarHomeSet.tag] + error.assert_(not chs.items()) + error.assert_(not chs.text) + chs_href = chs.getchildren() + error.assert_(len(chs_href) == 1) + error.assert_(not chs_href[0].items()) + error.assert_(not chs_href[0].getchildren()) + chs_url = chs_href[0].text + calendar_home_set = CalendarSet(client=self, url=chs_url) + ret.append( + Principal( + client=self, url=x, name=pname, calendar_home_set=calendar_home_set + ) + ) + return ret + + async def principals(self, name: Optional[str] = None) -> list["Principal"]: + """ + Deprecated. Use :meth:`search_principals` instead. + + This method searches for principals on the server. + """ + import warnings + + warnings.warn( + "principals() is deprecated, use search_principals() instead", + DeprecationWarning, + stacklevel=2, + ) + return await self.search_principals(name=name) + + async def principal(self) -> "Principal": + """ + Legacy method. Use :meth:`get_principal` for new code. + + Returns the Principal object for the authenticated user. + """ + return await self.get_principal() + + def calendar(self, **kwargs: Any) -> "Calendar": + """Returns a calendar object. + + Typically, a URL should be given as a named parameter (url) + + No network traffic will be initiated by this method. + + If you don't know the URL of the calendar, use + ``await client.get_principal().get_calendars()`` instead, or + ``await client.get_calendars()`` + """ + from caldav.collection import Calendar + + return Calendar(client=self, **kwargs) + + async def check_dav_support(self) -> Optional[str]: + """ + Check if the server supports DAV. + + Returns the DAV header from an OPTIONS request, or None if not supported. + """ + response = await self.options(str(self.url)) + return response.headers.get("DAV") + + async def check_cdav_support(self) -> bool: + """ + Check if the server supports CalDAV. + + Returns True if the server indicates CalDAV support in DAV header. + """ + dav_header = await self.check_dav_support() + return dav_header is not None and "calendar-access" in dav_header + + async def check_scheduling_support(self) -> bool: + """ + Check if the server supports RFC6638 scheduling. + + Returns True if the server indicates scheduling support in DAV header. + """ + dav_header = await self.check_dav_support() + return dav_header is not None and "calendar-auto-schedule" in dav_header + + async def supports_dav(self) -> Optional[str]: + """ + Check if the server supports DAV. + + This is an alias for :meth:`check_dav_support`. + """ + return await self.check_dav_support() + + async def supports_caldav(self) -> bool: + """ + Check if the server supports CalDAV. + + This is an alias for :meth:`check_cdav_support`. + """ + return await self.check_cdav_support() + + async def supports_scheduling(self) -> bool: + """ + Check if the server supports RFC6638 scheduling. + + This is an alias for :meth:`check_scheduling_support`. + """ + return await self.check_scheduling_support() + + +# ==================== Factory Function ==================== + + +async def get_davclient(probe: bool = True, **kwargs: Any) -> AsyncDAVClient: + """ + Get an async DAV client instance with configuration from multiple sources. + + See :func:`caldav.base_client.get_davclient` for full documentation. + + Args: + probe: Verify connectivity with OPTIONS request (default: True). + **kwargs: All other arguments passed to base get_davclient. + + Returns: + AsyncDAVClient instance. + + Raises: + ValueError: If no configuration is found. + + Example:: + + async with await get_davclient(url="...", username="...", password="...") as client: + principal = await client.principal() + """ + client = _base_get_davclient(AsyncDAVClient, **kwargs) + + if client is None: + raise ValueError( + "No configuration found. Provide connection parameters, " + "set CALDAV_URL environment variable, or create a config file." + ) + + # Probe connection if requested + if probe: + try: + response = await client.options() + log.info(f"Connected to CalDAV server: {client.url}") + + # Check for DAV support + dav_header = response.headers.get("DAV", "") + if not dav_header: + log.warning( + "Server did not return DAV header - may not be a DAV server" + ) + else: + log.debug(f"Server DAV capabilities: {dav_header}") + + except Exception as e: + await client.close() + raise error.DAVError( + f"Failed to connect to CalDAV server at {client.url}: {e}" + ) from e + + return client + + +async def get_calendars( + calendar_url: Optional[Any] = None, + calendar_name: Optional[Any] = None, + raise_errors: bool = False, + **kwargs: Any, +) -> list["Calendar"]: + """ + Get calendars from a CalDAV server asynchronously. + + This is the async version of :func:`caldav.get_calendars`. + + Args: + calendar_url: URL(s) or ID(s) of specific calendars to fetch. + calendar_name: Name(s) of specific calendars to fetch by display name. + raise_errors: If True, raise exceptions on errors; if False, log and skip. + **kwargs: Connection parameters (url, username, password, etc.) + + Returns: + List of Calendar objects matching the criteria. + + Example:: + + from caldav.async_davclient import get_calendars + + calendars = await get_calendars(url="...", username="...", password="...") + """ + from caldav.base_client import _normalize_to_list + + def _try(coro_result, errmsg): + """Handle errors based on raise_errors flag.""" + if coro_result is None: + log.error(f"Problems fetching calendar information: {errmsg}") + if raise_errors: + raise ValueError(errmsg) + return coro_result + + try: + client = await get_davclient(probe=True, **kwargs) + except Exception as e: + if raise_errors: + raise + log.error(f"Failed to create async client: {e}") + return [] + + try: + principal = await client.get_principal() + if not principal: + _try(None, "getting principal") + return [] + + calendars = [] + calendar_urls = _normalize_to_list(calendar_url) + calendar_names = _normalize_to_list(calendar_name) + + # Fetch specific calendars by URL/ID + for cal_url in calendar_urls: + if "/" in str(cal_url): + calendar = principal.calendar(cal_url=cal_url) + else: + calendar = principal.calendar(cal_id=cal_url) + + try: + display_name = await calendar.get_display_name() + if display_name is not None: + calendars.append(calendar) + except Exception as e: + log.error(f"Problems fetching calendar {cal_url}: {e}") + if raise_errors: + raise + + # Fetch specific calendars by name + for cal_name in calendar_names: + try: + calendar = await principal.calendar(name=cal_name) + if calendar: + calendars.append(calendar) + except Exception as e: + log.error(f"Problems fetching calendar by name '{cal_name}': {e}") + if raise_errors: + raise + + # If no specific calendars requested, get all calendars + if not calendars and not calendar_urls and not calendar_names: + try: + all_cals = await principal.get_calendars() + if all_cals: + calendars = all_cals + except Exception as e: + log.error(f"Problems fetching all calendars: {e}") + if raise_errors: + raise + + return calendars + + finally: + # Don't close the client - let the caller manage its lifecycle + pass + + +async def get_calendar(**kwargs: Any) -> Optional["Calendar"]: + """ + Get a single calendar from a CalDAV server asynchronously. + + This is a convenience function for the common case where only one + calendar is needed. It returns the first matching calendar or None. + + Args: + Same as :func:`get_calendars`. + + Returns: + A single Calendar object, or None if no calendars found. + + Example:: + + from caldav.async_davclient import get_calendar + + calendar = await get_calendar(calendar_name="Work", url="...", ...) + """ + calendars = await get_calendars(**kwargs) + return calendars[0] if calendars else None diff --git a/caldav/base_client.py b/caldav/base_client.py new file mode 100644 index 00000000..7c1102f5 --- /dev/null +++ b/caldav/base_client.py @@ -0,0 +1,368 @@ +""" +Base class for DAV clients. + +This module contains the BaseDAVClient class which provides shared +functionality for both sync (DAVClient) and async (AsyncDAVClient) clients. +""" +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import Any +from typing import Mapping +from typing import Optional +from typing import TYPE_CHECKING + +from caldav.lib import error +from caldav.lib.auth import extract_auth_types +from caldav.lib.auth import select_auth_type + +if TYPE_CHECKING: + from caldav.compatibility_hints import FeatureSet + + +class BaseDAVClient(ABC): + """ + Base class for DAV clients providing shared authentication and configuration logic. + + This abstract base class contains common functionality used by both + DAVClient (sync) and AsyncDAVClient (async). Subclasses must implement + the abstract methods for their specific HTTP library. + + Shared functionality: + - Authentication type extraction and selection + - Feature set management + - Common properties (username, password, auth_type, etc.) + """ + + # Property lists for PROPFIND requests - shared between sync and async + CALENDAR_HOME_SET_PROPS = ["{urn:ietf:params:xml:ns:caldav}calendar-home-set"] + CALENDAR_LIST_PROPS = [ + "{DAV:}resourcetype", + "{DAV:}displayname", + "{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set", + "{http://apple.com/ns/ical/}calendar-color", + "{http://calendarserver.org/ns/}getctag", + ] + + # Common attributes that subclasses will set + username: Optional[str] = None + password: Optional[str] = None + auth: Optional[Any] = None + auth_type: Optional[str] = None + features: Optional["FeatureSet"] = None + url: Any = None # URL object, set by subclasses + + def _make_absolute_url(self, url: str) -> str: + """Make a URL absolute by joining with the client's base URL if needed. + + Args: + url: URL string, possibly relative (e.g., "/calendars/user/") + + Returns: + Absolute URL string. + """ + if url and not url.startswith("http"): + return str(self.url.join(url)) + return url + + def extract_auth_types(self, header: str) -> set[str]: + """Extract authentication types from WWW-Authenticate header. + + Parses the WWW-Authenticate header value and extracts the + authentication scheme names (e.g., "basic", "digest", "bearer"). + + Args: + header: WWW-Authenticate header value from server response. + + Returns: + Set of lowercase auth type strings. + + Example: + >>> client.extract_auth_types('Basic realm="test", Digest realm="test"') + {'basic', 'digest'} + """ + return extract_auth_types(header) + + def _select_auth_type( + self, auth_types: Optional[list[str]] = None + ) -> Optional[str]: + """ + Select the best authentication type from available options. + + This method implements the shared logic for choosing an auth type + based on configured credentials and server-supported types. + + Args: + auth_types: List of acceptable auth types from server. + + Returns: + Selected auth type string, or None if no suitable type found. + + Raises: + AuthorizationError: If configuration conflicts with server capabilities. + """ + auth_type = self.auth_type + + if not auth_type and not auth_types: + raise error.AuthorizationError( + "No auth-type given. This shouldn't happen. " + "Raise an issue at https://github.com/python-caldav/caldav/issues/" + ) + + if auth_types and auth_type and auth_type not in auth_types: + raise error.AuthorizationError( + reason=f"Configuration specifies to use {auth_type}, " + f"but server only accepts {auth_types}" + ) + + if not auth_type and auth_types: + # Use shared selection logic from lib/auth + auth_type = select_auth_type( + auth_types, + has_username=bool(self.username), + has_password=bool(self.password), + ) + + # Handle bearer token without password + if not auth_type and "bearer" in auth_types and not self.password: + raise error.AuthorizationError( + reason="Server provides bearer auth, but no password given. " + "The bearer token should be configured as password" + ) + + return auth_type + + @abstractmethod + def build_auth_object(self, auth_types: Optional[list[str]] = None) -> None: + """ + Build authentication object based on configured credentials. + + This method must be implemented by subclasses to create the + appropriate auth object for their HTTP library (requests, httpx, etc.). + + Args: + auth_types: List of acceptable auth types from server. + """ + pass + + +def _normalize_to_list(obj: Any) -> list: + """Convert a string or None to a list for uniform handling.""" + if not obj: + return [] + if isinstance(obj, (str, bytes)): + return [obj] + return list(obj) + + +def get_calendars( + client_class: type, + calendar_url: Optional[Any] = None, + calendar_name: Optional[Any] = None, + check_config_file: bool = True, + config_file: Optional[str] = None, + config_section: Optional[str] = None, + testconfig: bool = False, + environment: bool = True, + name: Optional[str] = None, + raise_errors: bool = False, + **config_data, +) -> list["Calendar"]: + """ + Get calendars from a CalDAV server with configuration from multiple sources. + + This function creates a client, connects to the server, and returns + calendar objects based on the specified criteria. Configuration is read + from various sources (explicit parameters, environment variables, config files). + + Args: + client_class: The client class to use (DAVClient or AsyncDAVClient). + calendar_url: URL(s) or ID(s) of specific calendars to fetch. + Can be a string or list of strings. If the value contains '/', + it's treated as a URL; otherwise as a calendar ID. + calendar_name: Name(s) of specific calendars to fetch by display name. + Can be a string or list of strings. + check_config_file: Whether to look for config files (default: True). + config_file: Explicit path to config file. + config_section: Section name in config file (default: "default"). + testconfig: Whether to use test server configuration. + environment: Whether to read from environment variables (default: True). + name: Name of test server to use (for testconfig). + raise_errors: If True, raise exceptions on errors; if False, log and skip. + **config_data: Connection parameters (url, username, password, etc.) + + Returns: + List of Calendar objects matching the criteria. + If no calendar_url or calendar_name specified, returns all calendars. + + Example:: + + from caldav import DAVClient + from caldav.base_client import get_calendars + + # Get all calendars + calendars = get_calendars(DAVClient, url="https://...", username="...", password="...") + + # Get specific calendars by name + calendars = get_calendars(DAVClient, calendar_name=["Work", "Personal"], ...) + + # Get specific calendar by URL or ID + calendars = get_calendars(DAVClient, calendar_url="/calendars/user/work/", ...) + """ + import logging + + log = logging.getLogger("caldav") + + def _try(meth, kwargs, errmsg): + """Try a method call, handling errors based on raise_errors flag.""" + try: + ret = meth(**kwargs) + if ret is None: + raise ValueError(f"Method returned None: {errmsg}") + return ret + except Exception as e: + log.error(f"Problems fetching calendar information: {errmsg} - {e}") + if raise_errors: + raise + return None + + # Get client using existing config infrastructure + client = get_davclient( + client_class=client_class, + check_config_file=check_config_file, + config_file=config_file, + config_section=config_section, + testconfig=testconfig, + environment=environment, + name=name, + **config_data, + ) + + if client is None: + if raise_errors: + raise ValueError("Could not create DAV client - no configuration found") + return [] + + # Get principal + principal = _try(client.principal, {}, "getting principal") + if not principal: + return [] + + calendars = [] + calendar_urls = _normalize_to_list(calendar_url) + calendar_names = _normalize_to_list(calendar_name) + + # Fetch specific calendars by URL/ID + for cal_url in calendar_urls: + if "/" in str(cal_url): + calendar = principal.calendar(cal_url=cal_url) + else: + calendar = principal.calendar(cal_id=cal_url) + + # Verify the calendar exists by trying to get its display name + if _try(calendar.get_display_name, {}, f"calendar {cal_url}"): + calendars.append(calendar) + + # Fetch specific calendars by name + for cal_name in calendar_names: + calendar = _try( + principal.calendar, + {"name": cal_name}, + f"calendar by name '{cal_name}'", + ) + if calendar: + calendars.append(calendar) + + # If no specific calendars requested, get all calendars + if not calendars and not calendar_urls and not calendar_names: + all_cals = _try(principal.get_calendars, {}, "getting all calendars") + if all_cals: + calendars = all_cals + + return calendars + + +def get_davclient( + client_class: type, + check_config_file: bool = True, + config_file: Optional[str] = None, + config_section: Optional[str] = None, + testconfig: bool = False, + environment: bool = True, + name: Optional[str] = None, + **config_data, +) -> Optional[Any]: + """ + Get a DAV client instance with configuration from multiple sources. + + This is the canonical implementation used by both sync and async clients. + Configuration is read from various sources in priority order: + + 1. Explicit parameters (url=, username=, password=, etc.) + 2. Test server config (if testconfig=True or PYTHON_CALDAV_USE_TEST_SERVER env var) + 3. Environment variables (CALDAV_URL, CALDAV_USERNAME, CALDAV_PASSWORD) + 4. Config file (CALDAV_CONFIG_FILE env var or ~/.config/caldav/) + + Args: + client_class: The client class to instantiate (DAVClient or AsyncDAVClient). + check_config_file: Whether to look for config files (default: True). + config_file: Explicit path to config file. + config_section: Section name in config file (default: "default"). + testconfig: Whether to use test server configuration. + environment: Whether to read from environment variables (default: True). + name: Name of test server to use (for testconfig). + **config_data: Explicit connection parameters passed to client constructor. + Common parameters include: + - url: CalDAV server URL, domain, or email address + - username: Username for authentication + - password: Password for authentication + - ssl_verify_cert: Whether to verify SSL certificates + - auth_type: Authentication type ("basic", "digest", "bearer") + + Returns: + Client instance, or None if no configuration is found. + + Example (sync):: + + from caldav import get_davclient + client = get_davclient(url="https://caldav.example.com", username="user", password="pass") + + Example (async):: + + from caldav.async_davclient import get_davclient + client = await get_davclient(url="https://caldav.example.com", username="user", password="pass") + """ + from caldav import config + + # Use unified config discovery + conn_params = config.get_connection_params( + check_config_file=check_config_file, + config_file=config_file, + config_section=config_section, + testconfig=testconfig, + environment=environment, + name=name, + **config_data, + ) + + if conn_params is None: + return None + + # Extract special keys that aren't connection params + setup_func = conn_params.pop("_setup", None) + teardown_func = conn_params.pop("_teardown", None) + server_name = conn_params.pop("_server_name", None) + + # Create client + client = client_class(**conn_params) + + # Attach test server metadata if present + if setup_func is not None: + client.setup = setup_func + if teardown_func is not None: + client.teardown = teardown_func + if server_name is not None: + client.server_name = server_name + + return client diff --git a/caldav/calendarobjectresource.py b/caldav/calendarobjectresource.py index f0b3ac0f..ad2bb8a6 100644 --- a/caldav/calendarobjectresource.py +++ b/caldav/calendarobjectresource.py @@ -7,7 +7,7 @@ Alarms and Time zone objects does not have any class as for now. Those are typically subcomponents of an event/task/journal component. -Users of the library should not need to construct any of those objects. To add new content to the calendar, use ``calendar.save_event``, ``calendar.save_todo`` or ``calendar.save_journal``. Those methods will return a CalendarObjectResource. +Users of the library should not need to construct any of those objects. To add new content to the calendar, use ``calendar.add_event``, ``calendar.add_todo`` or ``calendar.add_journal``. Those methods will return a CalendarObjectResource. To update an existing object, use ``event.save()``. """ import logging import re @@ -48,20 +48,21 @@ from .davclient import DAVClient -if sys.version_info < (3, 9): - from typing import Callable, Container, Iterable, Iterator, Sequence - - from typing_extensions import DefaultDict, Literal -else: - from collections import defaultdict as DefaultDict - from collections.abc import Callable, Container, Iterable, Iterator, Sequence - from typing import Literal +from collections.abc import Callable, Container, Iterable, Iterator, Sequence +from typing import DefaultDict, Literal if sys.version_info < (3, 11): from typing_extensions import Self else: from typing import Self +from contextlib import contextmanager + +from .datastate import DataState +from .datastate import IcalendarState +from .datastate import NoDataState +from .datastate import RawDataState +from .datastate import VobjectState from .davobject import DAVObject from .elements.cdav import CalendarData from .elements import cdav @@ -117,6 +118,37 @@ class CalendarObjectResource(DAVObject): _icalendar_instance = None _data = None + # New state management (issue #613) + _state: Optional[DataState] = None + _borrowed: bool = False + + @property + def id(self) -> Optional[str]: + """Returns the UID of the calendar object. + + Extracts the UID from the calendar data using cheap accessors + that avoid unnecessary parsing (issue #515, #613). + Falls back to direct icalendar parsing if the cheap accessor fails. + Does not trigger a load from the server. + """ + uid = self._get_uid_cheap() + if uid is None and self._icalendar_instance: + # Fallback: look in icalendar instance directly (without triggering load) + for comp in self._icalendar_instance.subcomponents: + if comp.name in ("VEVENT", "VTODO", "VJOURNAL") and "UID" in comp: + uid = str(comp["UID"]) + break + return uid + + @id.setter + def id(self, value: Optional[str]) -> None: + """Setter exists for compatibility with parent class __init__. + + The actual UID is stored in the calendar data, not separately. + Setting this is a no-op - modify the icalendar data directly. + """ + pass + def __init__( self, client: Optional["DAVClient"] = None, @@ -135,9 +167,12 @@ def __init__( ) if data is not None: self.data = data - if id: + if id and self._get_component_type_cheap(): old_id = self.icalendar_component.pop("UID", None) self.icalendar_component.add("UID", id) + # Clear raw data and update state to use the modified icalendar instance + self._data = None + self._state = IcalendarState(self._icalendar_instance) def set_end(self, end, move_dtstart=False): """The RFC specifies that a VEVENT/VTODO cannot have both @@ -146,7 +181,7 @@ def set_end(self, end, move_dtstart=False): WARNING: this method is likely to be deprecated and parts of it moved to the icalendar library. If you decide to use it, - please put caldav<3.0 in the requirements. + please put caldav<4.0 in the requirements. """ i = self.icalendar_component ## TODO: are those lines useful for anything? @@ -281,11 +316,12 @@ def set_relation( if other.id: uid = other.id else: - uid = other.icalendar_component["uid"] + # Use cheap accessor to avoid format conversion (issue #613) + uid = other._get_uid_cheap() or other.icalendar_component["uid"] else: uid = other if set_reverse: - other = self.parent.object_by_uid(uid) + other = self.parent.get_object_by_uid(uid) if set_reverse: ## TODO: special handling of NEXT/FIRST. ## STARTTOFINISH does not have any equivalent "reverse". @@ -370,7 +406,7 @@ def get_relatives( for obj in uids: try: - reltype_set.add(self.parent.object_by_uid(obj)) + reltype_set.add(self.parent.get_object_by_uid(obj)) except error.NotFoundError: if not ignore_missing: raise @@ -395,7 +431,9 @@ def _verify_reverse_relation(self, other, reltype) -> tuple: other_relations = other.get_relatives( fetch_objects=False, reltypes={revreltype} ) - if not str(self.icalendar_component["uid"]) in other_relations[revreltype]: + # Use cheap accessor to avoid format conversion (issue #613) + my_uid = self._get_uid_cheap() or str(self.icalendar_component["uid"]) + if my_uid not in other_relations[revreltype]: ## I don't remember why we need to return a tuple ## but it's propagated through the "public" methods, so we'll ## have to leave it like this. @@ -630,13 +668,13 @@ def tentatively_accept_invite(self, calendar: Optional[Any] = None) -> None: def _reply_to_invite_request(self, partstat, calendar) -> None: error.assert_(self.is_invite_request()) if not calendar: - calendar = self.client.principal().calendars()[0] + calendar = self.client.principal().get_calendars()[0] ## we need to modify the icalendar code, update our own participant status self.icalendar_instance.pop("METHOD") self.change_attendee_status(partstat=partstat) self.get_property(cdav.ScheduleTag(), use_cached=True) try: - calendar.save_event(self.data) + calendar.add_event(self.data) except Exception: ## TODO - TODO - TODO ## RFC6638 does not seem to be very clear (or @@ -673,13 +711,27 @@ def copy(self, keep_uid: bool = False, new_parent: Optional[Any] = None) -> Self def load(self, only_if_unloaded: bool = False) -> Self: """ (Re)load the object from the caldav server. + + For sync clients, loads and returns self. + For async clients, returns a coroutine that must be awaited. + + Example (sync): + obj.load() + + Example (async): + await obj.load() """ + # Check if already loaded BEFORE delegating to async + # This avoids returning a coroutine when no work is needed if only_if_unloaded and self.is_loaded(): return self + # Dual-mode support: async clients return a coroutine + if self.is_async_client: + return self._async_load(only_if_unloaded=only_if_unloaded) + if self.url is None: raise ValueError("Unexpected value None for self.url") - if self.client is None: raise ValueError("Unexpected value None for self.client") @@ -687,11 +739,39 @@ def load(self, only_if_unloaded: bool = False) -> Self: r = self.client.request(str(self.url)) if r.status and r.status == 404: raise error.NotFoundError(errmsg(r)) - self.data = r.raw + self.data = r.raw # type: ignore except error.NotFoundError: raise - except: + except Exception: return self.load_by_multiget() + + if "Etag" in r.headers: + self.props[dav.GetEtag.tag] = r.headers["Etag"] + if "Schedule-Tag" in r.headers: + self.props[cdav.ScheduleTag.tag] = r.headers["Schedule-Tag"] + return self + + async def _async_load(self, only_if_unloaded: bool = False) -> Self: + """Async implementation of load.""" + if only_if_unloaded and self.is_loaded(): + return self + + if self.url is None: + raise ValueError("Unexpected value None for self.url") + if self.client is None: + raise ValueError("Unexpected value None for self.client") + + try: + r = await self.client.request(str(self.url)) + if r.status and r.status == 404: + raise error.NotFoundError(errmsg(r)) + self.data = r.raw # type: ignore + except error.NotFoundError: + raise + except Exception: + # Note: load_by_multiget is sync-only, not supported in async mode yet + raise + if "Etag" in r.headers: self.props[dav.GetEtag.tag] = r.headers["Etag"] if "Schedule-Tag" in r.headers: @@ -753,8 +833,6 @@ def _find_id_path(self, id=None, path=None) -> None: i.pop("UID", None) i.add("UID", id) - self.id = id - for x in self.icalendar_instance.subcomponents: if not isinstance(x, icalendar.Timezone): error.assert_(x.get("UID", None) == self.id) @@ -787,17 +865,42 @@ def _put(self, retry_on_failure=True): else: raise error.PutError(errmsg(r)) + async def _async_put(self, retry_on_failure=True): + """Async version of _put for async clients.""" + r = await self.client.put( + str(self.url), + str(self.data), + {"Content-Type": 'text/calendar; charset="utf-8"'}, + ) + if r.status == 302: + path = [x[1] for x in r.headers if x[0] == "location"][0] + self.url = URL.objectify(path) + elif r.status not in (204, 201): + if retry_on_failure: + try: + import vobject + except ImportError: + retry_on_failure = False + if retry_on_failure: + self.vobject_instance + return await self._async_put(False) + else: + raise error.PutError(errmsg(r)) + def _create(self, id=None, path=None, retry_on_failure=True) -> None: ## TODO: Find a better method name self._find_id_path(id=id, path=path) self._put() + async def _async_create(self, id=None, path=None) -> None: + """Async version of _create for async clients.""" + self._find_id_path(id=id, path=path) + await self._async_put() + def _generate_url(self): ## See https://github.com/python-caldav/caldav/issues/143 for the rationale behind double-quoting slashes ## TODO: should try to wrap my head around issues that arises when id contains weird characters. maybe it's ## better to generate a new uuid here, particularly if id is in some unexpected format. - if not self.id: - self.id = self._get_icalendar_component(assert_one=False)["UID"] return self.parent.url.join(quote(self.id.replace("/", "%2F")) + ".ics") def change_attendee_status(self, attendee: Optional[Any] = None, **kwargs) -> None: @@ -887,79 +990,88 @@ def save( * self """ - ## Rather than passing the icalendar data verbatimely, we're - ## efficiently running the icalendar code through the icalendar - ## library. This may cause data modifications and may "unfix" - ## https://github.com/python-caldav/caldav/issues/43 - ## TODO: think more about this - if not obj_type: - obj_type = self.__class__.__name__.lower() + # Early return if there's no data (no-op case) if ( self._vobject_instance is None and self._data is None and self._icalendar_instance is None ): - ## TODO: This makes no sense. We should probably raise an error. - ## But the behaviour should be officially deprecated first. return self - path = self.url.path if self.url else None - + # Helper function to get the full object by UID def get_self(): - self.id = self.id or self.icalendar_component.get("uid") - if self.id: + from caldav.lib import error + + uid = self.id or self.icalendar_component.get("uid") + if uid and self.parent: try: - if obj_type: - return getattr(self.parent, "%s_by_uid" % obj_type)(self.id) + if not obj_type: + _obj_type = self.__class__.__name__.lower() else: - return self.parent.object_by_uid(self.id) + _obj_type = obj_type + if _obj_type: + method_name = f"get_{_obj_type}_by_uid" + if hasattr(self.parent, method_name): + return getattr(self.parent, method_name)(uid) + if hasattr(self.parent, "get_object_by_uid"): + return self.parent.get_object_by_uid(uid) except error.NotFoundError: return None return None + # Handle no_overwrite/no_create validation BEFORE async delegation + # This must be done here because it requires collection methods (get_event_by_uid, etc.) + # which are sync and can't be called from async context (nested event loop issue) if no_overwrite or no_create: - ## SECURITY TODO: path names on the server does not - ## necessarily map cleanly to UUIDs. We need to do quite - ## some refactoring here to ensure all corner cases are - ## covered. Doing a GET first to check if the resource is - ## found and then a PUT also gives a potential race - ## condition. (Possibly the API gives no safe way to ensure - ## a unique new calendar item is created to the server without - ## overwriting old stuff or vice versa - it seems silly to me - ## to do a PUT instead of POST when creating new data). - ## TODO: the "find id"-logic is duplicated in _create, - ## should be refactored + from caldav.lib import error + + if not obj_type: + obj_type = self.__class__.__name__.lower() + + # Determine the ID + uid = self.id or self.icalendar_component.get("uid") + + # Check if object exists using parent collection methods existing = get_self() - if not self.id and no_create: + + # Validate constraints + if not uid and no_create: raise error.ConsistencyError("no_create flag was set, but no ID given") if no_overwrite and existing: raise error.ConsistencyError( "no_overwrite flag was set, but object already exists" ) - if no_create and not existing: raise error.ConsistencyError( - "no_create flag was set, but object does not exists" + "no_create flag was set, but object does not exist" ) - ## Save a single recurrence-id and all calendars servers seems - ## to overwrite the full object, effectively deleting the - ## RRULE. I can't find this behaviour specified in the RFC. - ## That's probably not what the caller intended intended. + # Handle recurrence instances BEFORE async delegation + # When saving a single recurrence instance, we need to: + # - Get the full recurring event from the server + # - Add/update the recurrence instance in the event's subcomponents + # - Save the full event back + # This prevents overwriting the entire recurring event with just one instance if ( only_this_recurrence or all_recurrences ) and "RECURRENCE-ID" in self.icalendar_component: - obj = get_self() ## get the full object, not only the recurrence + import icalendar + from caldav.lib import error + + obj = get_self() # Get the full object, not only the recurrence + if obj is None: + raise error.NotFoundError("Could not find parent recurring event") + ici = obj.icalendar_instance # ical instance + if all_recurrences: - occ = obj.icalendar_component ## original calendar component - ncc = self.icalendar_component.copy() ## new calendar component + occ = obj.icalendar_component # original calendar component + ncc = self.icalendar_component.copy() # new calendar component for prop in ["exdate", "exrule", "rdate", "rrule"]: if prop in occ: ncc[prop] = occ[prop] - ## dtstart_diff = how much we've moved the time - ## TODO: we may easily have timezone problems here and events shifting some hours ... + # dtstart_diff = how much we've moved the time dtstart_diff = ( ncc.start.astimezone() - ncc["recurrence-id"].dt.astimezone() ) @@ -973,27 +1085,23 @@ def get_self(): ncc.pop("recurrence-id") s = ici.subcomponents - ## Replace the "root" subcomponent - comp_idxes = ( + # Replace the "root" subcomponent + comp_idxes = [ i for i in range(0, len(s)) if not isinstance(s[i], icalendar.Timezone) - ) - comp_idx = next(comp_idxes) + ] + comp_idx = comp_idxes[0] s[comp_idx] = ncc - ## The recurrence-ids of all objects has to be - ## recalculated (this is probably not quite right. If - ## we move the time of a daily meeting from 8 to 10, - ## then we need to do this. If we move the date of - ## the first instance, then probably we shouldn't - ## ... oh well ... so many complications) + # The recurrence-ids of all objects has to be recalculated if dtstart_diff: - for i in comp_idxes: + for i in comp_idxes[1:]: rid = s[i].pop("recurrence-id") s[i].add("recurrence-id", rid.dt + dtstart_diff) return obj.save(increase_seqno=increase_seqno) + if only_this_recurrence: existing_idx = [ i @@ -1008,47 +1116,49 @@ def get_self(): ici.add_component(self.icalendar_component) return obj.save(increase_seqno=increase_seqno) - if "SEQUENCE" in self.icalendar_component: + # Handle SEQUENCE increment + if increase_seqno and "SEQUENCE" in self.icalendar_component: seqno = self.icalendar_component.pop("SEQUENCE", None) if seqno is not None: self.icalendar_component.add("SEQUENCE", seqno + 1) + path = self.url.path if self.url else None + + # Dual-mode support: async clients return a coroutine + if self.is_async_client: + return self._async_save_final(path) + self._create(id=self.id, path=path) return self + async def _async_save_final(self, path) -> Self: + """Async helper for the final save operation.""" + await self._async_create(id=self.id, path=path) + return self + def is_loaded(self): """Returns True if there exists data in the object. An object is considered not to be loaded if it contains no data but just the URL. - TOOD: bad side effect, converts the data to a string, - potentially breaking couplings + Optimized to use cheap accessors (issue #613). """ - return ( - (self._data and self._data.count("BEGIN:") > 1) - or self._vobject_instance - or self._icalendar_instance - ) + # Use the state pattern to check for data without side effects + if not self._has_data(): + return False + # Check if there's an actual component (not just empty VCALENDAR) + return self._get_component_type_cheap() is not None - def has_component(self): + def has_component(self) -> bool: """ Returns True if there exists a VEVENT, VTODO or VJOURNAL in the data. Returns False if it's only a VFREEBUSY, VTIMEZONE or unknown components. - TODO: Bad side-effect: converts to data - any icalendar instances coupled to the object - will be decoupled. - Used internally after search to remove empty search results (sometimes Google return such) """ - return ( - self._data - or self._vobject_instance - or (self._icalendar_instance and self.icalendar_component) - ) and self.data.count("BEGIN:VEVENT") + self.data.count( - "BEGIN:VTODO" - ) + self.data.count( - "BEGIN:VJOURNAL" - ) > 0 + if not self._has_data(): + return False + return self._get_component_type_cheap() is not None def __str__(self) -> str: return "%s: %s" % (self.__class__.__name__, self.url) @@ -1107,6 +1217,8 @@ def _set_vobject_instance(self, inst: "vobject.base.Component"): self._vobject_instance = inst self._data = None self._icalendar_instance = None + # Keep _state in sync with _vobject_instance + self._state = VobjectState(inst) return self def _get_vobject_instance(self) -> Optional["vobject.base.Component"]: @@ -1178,6 +1290,8 @@ def _set_icalendar_instance(self, inst): self._icalendar_instance = inst self._data = None self._vobject_instance = None + # Keep _state in sync with _icalendar_instance + self._state = IcalendarState(inst) return self def _get_icalendar_instance(self): @@ -1195,6 +1309,172 @@ def _get_icalendar_instance(self): doc="icalendar instance of the object", ) + ## =================================================================== + ## New API for safe data access (issue #613) + ## =================================================================== + + def _ensure_state(self) -> DataState: + """Ensure we have a DataState object, migrating from legacy attributes if needed.""" + if self._state is not None: + return self._state + + # Migrate from legacy attributes + if self._icalendar_instance is not None: + self._state = IcalendarState(self._icalendar_instance) + elif self._vobject_instance is not None: + self._state = VobjectState(self._vobject_instance) + elif self._data is not None: + self._state = RawDataState(to_normal_str(self._data)) + else: + self._state = NoDataState() + + return self._state + + def get_data(self) -> str: + """Get raw iCalendar data as string. + + This is always safe to call and returns the current data without + side effects. If the current representation is a parsed object, + it will be serialized. + + Returns: + The iCalendar data as a string, or empty string if no data. + """ + return self._ensure_state().get_data() + + def get_icalendar_instance(self) -> icalendar.Calendar: + """Get a COPY of the icalendar object for read-only access. + + This is safe for inspection - modifications to the returned object + will NOT be saved. For editing, use edit_icalendar_instance(). + + Returns: + A copy of the icalendar.Calendar object. + """ + return self._ensure_state().get_icalendar_copy() + + def get_vobject_instance(self) -> "vobject.base.Component": + """Get a COPY of the vobject object for read-only access. + + This is safe for inspection - modifications to the returned object + will NOT be saved. For editing, use edit_vobject_instance(). + + Returns: + A copy of the vobject component. + """ + return self._ensure_state().get_vobject_copy() + + @contextmanager + def edit_icalendar_instance(self): + """Context manager to borrow the icalendar object for editing. + + Usage:: + + with event.edit_icalendar_instance() as cal: + cal.subcomponents[0]['SUMMARY'] = 'New Summary' + event.save() + + While inside the context, the icalendar object is the authoritative + source. Accessing other representations (vobject) while borrowed + will raise RuntimeError. + + Yields: + The authoritative icalendar.Calendar object. + + Raises: + RuntimeError: If another representation is currently borrowed. + """ + if self._borrowed: + raise RuntimeError( + "Cannot borrow icalendar - another representation is already borrowed. " + "Complete the current edit before starting another." + ) + + state = self._ensure_state() + + # Switch to icalendar state if not already + if not isinstance(state, IcalendarState): + cal = state.get_icalendar_copy() + self._state = IcalendarState(cal) + # Clear legacy attributes + self._data = None + self._vobject_instance = None + self._icalendar_instance = cal + + self._borrowed = True + try: + yield self._state.get_authoritative_icalendar() + finally: + self._borrowed = False + + @contextmanager + def edit_vobject_instance(self): + """Context manager to borrow the vobject object for editing. + + Usage:: + + with event.edit_vobject_instance() as vobj: + vobj.vevent.summary.value = 'New Summary' + event.save() + + While inside the context, the vobject object is the authoritative + source. Accessing other representations (icalendar) while borrowed + will raise RuntimeError. + + Yields: + The authoritative vobject component. + + Raises: + RuntimeError: If another representation is currently borrowed. + """ + if self._borrowed: + raise RuntimeError( + "Cannot borrow vobject - another representation is already borrowed. " + "Complete the current edit before starting another." + ) + + state = self._ensure_state() + + # Switch to vobject state if not already + if not isinstance(state, VobjectState): + vobj = state.get_vobject_copy() + self._state = VobjectState(vobj) + # Clear legacy attributes + self._data = None + self._icalendar_instance = None + self._vobject_instance = vobj + + self._borrowed = True + try: + yield self._state.get_authoritative_vobject() + finally: + self._borrowed = False + + # --- Internal cheap accessors (no state changes) --- + + def _get_uid_cheap(self) -> Optional[str]: + """Get UID without triggering format conversions. + + This is for internal use where we just need to peek at the UID + without needing to modify anything. + """ + return self._ensure_state().get_uid() + + def _get_component_type_cheap(self) -> Optional[str]: + """Get component type (VEVENT/VTODO/VJOURNAL) without parsing. + + This is for internal use to quickly determine the type. + """ + return self._ensure_state().get_component_type() + + def _has_data(self) -> bool: + """Check if we have any data without triggering conversions.""" + return self._ensure_state().has_data() + + ## =================================================================== + ## End of new API (issue #613) + ## =================================================================== + def get_duration(self) -> timedelta: """According to the RFC, either DURATION or DUE should be set for a task, but never both - implicitly meaning that DURATION diff --git a/caldav/collection.py b/caldav/collection.py index 530054cd..9b8e5522 100644 --- a/caldav/collection.py +++ b/caldav/collection.py @@ -10,10 +10,8 @@ A SynchronizableCalendarObjectCollection contains a local copy of objects from a calendar on the server. """ import logging -import sys import uuid import warnings -from dataclasses import dataclass from datetime import datetime from time import sleep from typing import Any @@ -29,10 +27,9 @@ from urllib.parse import unquote import icalendar -from icalendar.caselessdict import CaselessDict try: - from typing import ClassVar, Optional, Union, Type + from typing import Optional, Type, Union TimeStamp = Optional[Union[date, datetime]] except: @@ -42,32 +39,21 @@ from icalendar import vCalAddress from .davclient import DAVClient - -if sys.version_info < (3, 9): - from typing import Callable, Container, Iterable, Iterator, Sequence - - from typing_extensions import DefaultDict, Literal -else: - from collections import defaultdict as DefaultDict - from collections.abc import Callable, Container, Iterable, Iterator, Sequence - from typing import Literal - -if sys.version_info < (3, 11): - from typing_extensions import Self -else: - from typing import Self - -from .calendarobjectresource import CalendarObjectResource -from .calendarobjectresource import Event -from .calendarobjectresource import FreeBusy -from .calendarobjectresource import Journal -from .calendarobjectresource import Todo + from .search import CalDAVSearcher + +from collections.abc import Iterable, Iterator, Sequence +from typing import Literal + +from .calendarobjectresource import ( + CalendarObjectResource, + Event, + FreeBusy, + Journal, + Todo, +) from .davobject import DAVObject -from .elements.cdav import CalendarData -from .elements import cdav -from .elements import dav -from .lib import error -from .lib import vcal +from .elements import cdav, dav +from .lib import error, vcal from .lib.python_utilities import to_wire from .lib.url import URL @@ -80,22 +66,34 @@ class CalendarSet(DAVObject): A CalendarSet is a set of calendars. """ - def calendars(self) -> List["Calendar"]: + def get_calendars(self) -> List["Calendar"]: """ List all calendar collections in this set. + For sync clients, returns a list of Calendar objects directly. + For async clients, returns a coroutine that must be awaited. + Returns: * [Calendar(), ...] + + Example (sync): + calendars = calendar_set.get_calendars() + + Example (async): + calendars = await calendar_set.get_calendars() """ - cals = [] + # Delegate to client for dual-mode support + if self.is_async_client: + return self._async_calendars() + cals = [] data = self.children(cdav.Calendar.tag) for c_url, c_type, c_name in data: try: cal_id = c_url.split("/")[-2] if not cal_id: continue - except: + except Exception: log.error(f"Calendar {c_name} has unexpected url {c_url}") cal_id = None cals.append( @@ -104,12 +102,66 @@ def calendars(self) -> List["Calendar"]: return cals + async def _async_calendars(self) -> List["Calendar"]: + """Async implementation of calendars() using the client.""" + from caldav.operations.base import _is_calendar_resource as is_calendar_resource + from caldav.operations.calendarset_ops import ( + _extract_calendar_id_from_url as extract_calendar_id_from_url, + ) + + # Fetch calendars via PROPFIND + response = await self.client.propfind( + str(self.url), + props=[ + "{DAV:}resourcetype", + "{DAV:}displayname", + "{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set", + "{http://apple.com/ns/ical/}calendar-color", + "{http://calendarserver.org/ns/}getctag", + ], + depth=1, + ) + + # Process results to extract calendars + calendars = [] + for result in response.results or []: + # Check if this is a calendar resource + if not is_calendar_resource(result.properties): + continue + + # Extract calendar info + url = result.href + name = result.properties.get("{DAV:}displayname") + cal_id = extract_calendar_id_from_url(url) + + if not cal_id: + continue + + cal = Calendar( + client=self.client, + url=url, + name=name, + id=cal_id, + parent=self, + ) + calendars.append(cal) + + return calendars + + def calendars(self) -> List["Calendar"]: + """ + Deprecated: Use :meth:`get_calendars` instead. + + This method is an alias kept for backwards compatibility. + """ + return self.get_calendars() + def make_calendar( self, name: Optional[str] = None, cal_id: Optional[str] = None, supported_calendar_component_set: Optional[Any] = None, - method=None, + method: Optional[str] = None, ) -> "Calendar": """ Utility method for creating a new calendar. @@ -121,10 +173,18 @@ def make_calendar( (EVENT, VTODO, VFREEBUSY, VJOURNAL) the calendar should handle. Should be set to ['VTODO'] when creating a task list in Zimbra - in most other cases the default will be OK. + method: 'mkcalendar' or 'mkcol' - usually auto-detected + + For async clients, returns a coroutine that must be awaited. Returns: Calendar(...)-object """ + if self.is_async_client: + return self._async_make_calendar( + name, cal_id, supported_calendar_component_set, method + ) + return Calendar( self.client, name=name, @@ -133,6 +193,23 @@ def make_calendar( supported_calendar_component_set=supported_calendar_component_set, ).save(method=method) + async def _async_make_calendar( + self, + name: Optional[str] = None, + cal_id: Optional[str] = None, + supported_calendar_component_set: Optional[Any] = None, + method: Optional[str] = None, + ) -> "Calendar": + """Async implementation of make_calendar.""" + calendar = Calendar( + self.client, + name=name, + parent=self, + id=cal_id, + supported_calendar_component_set=supported_calendar_component_set, + ) + return await calendar._async_save(method=method) + def calendar( self, name: Optional[str] = None, cal_id: Optional[str] = None ) -> "Calendar": @@ -147,17 +224,18 @@ def calendar( Returns: Calendar(...)-object """ + # For name-based lookup, use calendars() which already uses async delegation if name and not cal_id: - for calendar in self.calendars(): + for calendar in self.get_calendars(): display_name = calendar.get_display_name() if display_name == name: return calendar if name and not cal_id: raise error.NotFoundError( - "No calendar with name %s found under %s" % (name, self.url) + f"No calendar with name {name} found under {self.url}" ) if not cal_id and not name: - cals = self.calendars() + cals = self.get_calendars() if not cals: raise error.NotFoundError("no calendars found") return cals[0] @@ -211,8 +289,8 @@ def __init__( self, client: Optional["DAVClient"] = None, url: Union[str, ParseResult, SplitResult, URL, None] = None, - calendar_home_set: URL = None, - **kwargs, ## to be passed to super.__init__ + calendar_home_set: Optional[URL] = None, + **kwargs: Any, ) -> None: """ Returns a Principal. @@ -244,6 +322,71 @@ def __init__( self.url = self.client.url.join(URL.objectify(cup)) + @classmethod + async def create( + cls, + client: "DAVClient", + url: Union[str, ParseResult, SplitResult, URL, None] = None, + calendar_home_set: Optional[URL] = None, + ) -> "Principal": + """ + Create a Principal, discovering URL if not provided. + + This is the recommended way to create a Principal with async clients + as it handles async URL discovery. + + For sync clients, you can use the regular constructor: Principal(client) + + Args: + client: A DAVClient or AsyncDAVClient instance + url: The principal URL (if known) + calendar_home_set: The calendar home set URL (if known) + + Returns: + Principal with URL discovered if not provided + + Example (async): + principal = await Principal.create(async_client) + """ + # Create principal without URL discovery (pass url even if None to skip sync discovery) + principal = cls( + client=client, + url=url or client.url, + calendar_home_set=calendar_home_set, + ) + + if url is None: + # Async URL discovery + cup = await principal._async_get_property(dav.CurrentUserPrincipal()) + if cup is None: + log.warning("calendar server lacking a feature:") + log.warning("current-user-principal property not found") + log.warning(f"assuming {client.url} is the principal URL") + else: + principal.url = client.url.join(URL.objectify(cup)) + + return principal + + async def _async_get_property(self, prop): + """Async version of get_property for use with async clients.""" + if self.url is None: + raise ValueError("Unexpected value None for self.url") + + response = await self.client.propfind( + str(self.url), + props=[prop.tag if hasattr(prop, "tag") else str(prop)], + depth=0, + ) + + if response.results: + for result in response.results: + value = result.properties.get( + prop.tag if hasattr(prop, "tag") else str(prop) + ) + if value is not None: + return value + return None + def make_calendar( self, name: Optional[str] = None, @@ -254,7 +397,14 @@ def make_calendar( """ Convenience method, bypasses the self.calendar_home_set object. See CalendarSet.make_calendar for details. + + For async clients, returns a coroutine that must be awaited. """ + if self.is_async_client: + return self._async_make_calendar( + name, cal_id, supported_calendar_component_set, method + ) + return self.calendar_home_set.make_calendar( name, cal_id, @@ -262,6 +412,37 @@ def make_calendar( method=method, ) + async def _async_make_calendar( + self, + name: Optional[str] = None, + cal_id: Optional[str] = None, + supported_calendar_component_set: Optional[Any] = None, + method=None, + ) -> "Calendar": + """Async implementation of make_calendar.""" + calendar_home_set = await self._async_get_calendar_home_set() + return await calendar_home_set._async_make_calendar( + name, + cal_id, + supported_calendar_component_set=supported_calendar_component_set, + method=method, + ) + + async def _async_get_calendar_home_set(self) -> "CalendarSet": + """Async helper to get the calendar home set.""" + if self._calendar_home_set: + return self._calendar_home_set + + calendar_home_set_url = await self._async_get_property(cdav.CalendarHomeSet()) + if ( + calendar_home_set_url is not None + and "@" in calendar_home_set_url + and "://" not in calendar_home_set_url + ): + calendar_home_set_url = quote(calendar_home_set_url) + self.calendar_home_set = calendar_home_set_url + return self._calendar_home_set + def calendar( self, name: Optional[str] = None, @@ -329,7 +510,7 @@ def calendar_home_set(self, url) -> None: ## TODO: ## Here be dragons. sanitized_url will be the root ## of all future objects derived from client. Changing - ## the client.url root by doing a principal.calendars() + ## the client.url root by doing a principal.get_calendars() ## is an unacceptable side effect and may be a cause of ## incompatibilities with icloud. Do more research! self.client.url = sanitized_url @@ -337,11 +518,29 @@ def calendar_home_set(self, url) -> None: self.client, self.client.url.join(sanitized_url) ) - def calendars(self) -> List["Calendar"]: + def get_calendars(self) -> List["Calendar"]: """ Return the principal's calendars. + + For sync clients, returns a list of Calendar objects directly. + For async clients, returns a coroutine that must be awaited. + + Example (sync): + calendars = principal.get_calendars() + + Example (async): + calendars = await principal.get_calendars() + """ + # Delegate to client for dual-mode support + return self.client.get_calendars(self) + + def calendars(self) -> List["Calendar"]: """ - return self.calendar_home_set.calendars() + Deprecated: Use :meth:`get_calendars` instead. + + This method is an alias kept for backwards compatibility. + """ + return self.get_calendars() def freebusy_request(self, dtstart, dtend, attendees): """Sends a freebusy-request for some attendee to the server @@ -370,7 +569,7 @@ def freebusy_request(self, dtstart, dtend, attendees): caldavobj.data, headers={"Content-Type": "text/calendar; charset=utf-8"}, ) - return response.find_objects_and_props() + return response._find_objects_and_props() def calendar_user_address_set(self) -> List[Optional[str]]: """ @@ -410,12 +609,45 @@ class Calendar(DAVObject): https://tools.ietf.org/html/rfc4791#section-5.3.1 """ + def __init__( + self, + client: Optional["DAVClient"] = None, + url: Union[str, ParseResult, SplitResult, URL, None] = None, + parent: Optional["DAVObject"] = None, + name: Optional[str] = None, + id: Optional[str] = None, + props=None, + **extra, + ) -> None: + """ + Initialize a Calendar object. + + Args: + client: A DAVClient instance + url: The url for this calendar. May be a full URL or a relative URL. + parent: The parent object (typically a CalendarSet or Principal) + name: The display name for the calendar + id: The calendar id (used when creating new calendars) + props: A dict with known properties for this calendar + """ + super().__init__( + client=client, url=url, parent=parent, id=id, props=props, **extra + ) + self.name = name + def _create( self, name=None, id=None, supported_calendar_component_set=None, method=None ) -> None: """ Create a new calendar with display name `name` in `parent`. + + For async clients, returns a coroutine that must be awaited. """ + if self.is_async_client: + return self._async_create( + name, id, supported_calendar_component_set, method + ) + if id is None: id = str(uuid.uuid1()) self.id = id @@ -458,8 +690,6 @@ def _create( sccs += cdav.Comp(scc) prop += sccs if method == "mkcol": - from caldav.lib.debug import printxml - prop += dav.ResourceType() + [dav.Collection(), cdav.Calendar()] set = dav.Set() + prop @@ -478,7 +708,7 @@ def _create( if name: try: self.set_properties([display_name]) - except Exception as e: + except Exception: ## TODO: investigate. Those asserts break. try: current_display_name = self.get_display_name() @@ -489,7 +719,82 @@ def _create( exc_info=True, ) + async def _async_create( + self, name=None, id=None, supported_calendar_component_set=None, method=None + ) -> None: + """Async implementation of _create.""" + if id is None: + id = str(uuid.uuid1()) + self.id = id + + if method is None: + if self.client: + supported = self.client.features.is_supported( + "create-calendar", return_type=dict + ) + if supported["support"] not in ("full", "fragile", "quirk"): + raise error.MkcalendarError( + "Creation of calendars (allegedly) not supported on this server" + ) + if ( + supported["support"] == "quirk" + and supported["behaviour"] == "mkcol-required" + ): + method = "mkcol" + else: + method = "mkcalendar" + else: + method = "mkcalendar" + + path = self.parent.url.join(id + "/") + self.url = path + + prop = dav.Prop() + if name: + display_name = dav.DisplayName(name) + prop += [ + display_name, + ] + if supported_calendar_component_set: + sccs = cdav.SupportedCalendarComponentSet() + for scc in supported_calendar_component_set: + sccs += cdav.Comp(scc) + prop += sccs + if method == "mkcol": + prop += dav.ResourceType() + [dav.Collection(), cdav.Calendar()] + + set = dav.Set() + prop + + mkcol = (dav.Mkcol() if method == "mkcol" else cdav.Mkcalendar()) + set + + await self._async_query( + root=mkcol, query_method=method, url=path, expected_return_value=201 + ) + + # COMPATIBILITY ISSUE - try to set display name explicitly + if name: + try: + await self._async_set_properties([display_name]) + except Exception: + try: + current_display_name = await self._async_get_property( + dav.DisplayName() + ) + error.assert_(current_display_name == name) + except: + log.warning( + "calendar server does not support display name on calendar? Ignoring", + exc_info=True, + ) + def delete(self): + """Delete the calendar. + + For async clients, returns a coroutine that must be awaited. + """ + if self.is_async_client: + return self._async_calendar_delete() + ## TODO: remove quirk handling from the functional tests ## TODO: this needs test code quirk_info = self.client.features.is_supported("delete-calendar", dict) @@ -502,7 +807,7 @@ def delete(self): except error.DeleteError: pass try: - x = self.events() + x = self.get_events() sleep(0.3) except error.NotFoundError: wipe = False @@ -514,6 +819,44 @@ def delete(self): else: super().delete() + async def _async_calendar_delete(self): + """Async implementation of Calendar.delete(). + + Note: Server quirk handling (fragile/wipe modes) is simplified for async. + Most modern servers support proper calendar deletion. + """ + quirk_info = self.client.features.is_supported("delete-calendar", dict) + + # For fragile servers, try simple delete first + if quirk_info["support"] == "fragile": + for _ in range(0, 5): + try: + await self._async_delete() + return + except error.DeleteError: + import asyncio + + await asyncio.sleep(0.3) + # If still failing after retries, fall through to wipe + + if quirk_info["support"] in ("unsupported", "fragile"): + # Need to delete all objects first + # Use the async client's get_events method + try: + events = await self.client.get_events(self) + for event in events: + await event._async_delete() + except Exception: + pass # Best effort + try: + todos = await self.client.get_todos(self) + for todo in todos: + await todo._async_delete() + except Exception: + pass # Best effort + + await self._async_delete() + def get_supported_components(self) -> List[Any]: """ returns a list of component types supported by the calendar, in @@ -524,7 +867,19 @@ def get_supported_components(self) -> List[Any]: props = [cdav.SupportedCalendarComponentSet()] response = self.get_properties(props, parse_response_xml=False) - response_list = response.find_objects_and_props() + + # Use protocol layer results if available + if response.results: + for result in response.results: + components = result.properties.get( + cdav.SupportedCalendarComponentSet().tag + ) + if components: + return components + return [] + + # Fallback for mocked responses without protocol parsing + response_list = response._find_objects_and_props() prop = response_list[unquote(self.url.path)][ cdav.SupportedCalendarComponentSet().tag ] @@ -532,7 +887,7 @@ def get_supported_components(self) -> List[Any]: def save_with_invites(self, ical: str, attendees, **attendeeoptions) -> None: """ - sends a schedule request to the server. Equivalent with save_event, save_todo, etc, + sends a schedule request to the server. Equivalent with add_event, add_todo, etc, but the attendees will be added to the ical object before sending it to the server. """ ## TODO: consolidate together with save_* @@ -556,7 +911,7 @@ def _use_or_create_ics(self, ical, objtype, **ical_data): return vcal.create_ical(objtype=objtype, **ical_data) return ical - def save_object( + def add_object( self, ## TODO: this should be made optional. The class may be given in the ical object. ## TODO: also, accept a string. @@ -567,22 +922,25 @@ def save_object( no_create: bool = False, **ical_data, ) -> "CalendarResourceObject": - """Add a new event to the calendar, with the given ical. + """Add a new calendar object (event, todo, journal) to the calendar. + + This method is for adding new content to the calendar. To update + an existing object, fetch it first and use ``object.save()``. Args: objclass: Event, Journal or Todo ical: ical object (text, icalendar or vobject instance) no_overwrite: existing calendar objects should not be overwritten no_create: don't create a new object, existing calendar objects should be updated - dt_start: properties to be inserted into the icalendar object - , dt_end: properties to be inserted into the icalendar object + dtstart: properties to be inserted into the icalendar object + dtend: properties to be inserted into the icalendar object summary: properties to be inserted into the icalendar object alarm_trigger: when given, one alarm will be added alarm_action: when given, one alarm will be added alarm_attach: when given, one alarm will be added Note that the list of parameters going into the icalendar - object and alamrs is not complete. Refer to the RFC or the + object and alarms is not complete. Refer to the RFC or the icalendar library for a full list of properties. """ o = objclass( @@ -600,50 +958,96 @@ def save_object( o._handle_reverse_relations(fix=True) return o - ## TODO: maybe we should deprecate those three + def add_event(self, *largs, **kwargs) -> "Event": + """ + Add an event to the calendar. + + Returns ``self.add_object(Event, ...)`` - see :meth:`add_object` + """ + return self.add_object(Event, *largs, **kwargs) + + def add_todo(self, *largs, **kwargs) -> "Todo": + """ + Add a todo/task to the calendar. + + Returns ``self.add_object(Todo, ...)`` - see :meth:`add_object` + """ + return self.add_object(Todo, *largs, **kwargs) + + def add_journal(self, *largs, **kwargs) -> "Journal": + """ + Add a journal entry to the calendar. + + Returns ``self.add_object(Journal, ...)`` - see :meth:`add_object` + """ + return self.add_object(Journal, *largs, **kwargs) + + ## Deprecated aliases - use add_* instead + ## These will be removed in a future version + + def save_object(self, *largs, **kwargs) -> "CalendarResourceObject": + """ + Deprecated: Use :meth:`add_object` instead. + + This method is an alias kept for backwards compatibility. + See https://github.com/python-caldav/caldav/issues/71 + """ + return self.add_object(*largs, **kwargs) + def save_event(self, *largs, **kwargs) -> "Event": """ - Returns ``self.save_object(Event, ...)`` - see :class:`save_object` + Deprecated: Use :meth:`add_event` instead. + + This method is an alias kept for backwards compatibility. + See https://github.com/python-caldav/caldav/issues/71 """ - return self.save_object(Event, *largs, **kwargs) + return self.add_event(*largs, **kwargs) def save_todo(self, *largs, **kwargs) -> "Todo": """ - Returns ``self.save_object(Todo, ...)`` - so see :class:`save_object` + Deprecated: Use :meth:`add_todo` instead. + + This method is an alias kept for backwards compatibility. + See https://github.com/python-caldav/caldav/issues/71 """ - return self.save_object(Todo, *largs, **kwargs) + return self.add_todo(*largs, **kwargs) def save_journal(self, *largs, **kwargs) -> "Journal": """ - Returns ``self.save_object(Journal, ...)`` - so see :class:`save_object` - """ - return self.save_object(Journal, *largs, **kwargs) - - ## legacy aliases - ## TODO: should be deprecated + Deprecated: Use :meth:`add_journal` instead. - ## TODO: think more through this - is `save_foo` better than `add_foo`? - ## `save_foo` should not be used for updating existing content on the - ## calendar! - add_object = save_object - add_event = save_event - add_todo = save_todo - add_journal = save_journal + This method is an alias kept for backwards compatibility. + See https://github.com/python-caldav/caldav/issues/71 + """ + return self.add_journal(*largs, **kwargs) def save(self, method=None): """ The save method for a calendar is only used to create it, for now. We know we have to create it when we don't have a url. + For async clients, returns a coroutine that must be awaited. + Returns: * self """ + if self.is_async_client: + return self._async_save(method) + if self.url is None: self._create( id=self.id, name=self.name, method=method, **self.extra_init_options ) return self + async def _async_save(self, method=None): + """Async implementation of save.""" + if self.url is None: + await self._async_create( + name=self.name, id=self.id, method=method, **self.extra_init_options + ) + return self + # def data2object_class def _multiget( @@ -663,7 +1067,9 @@ def _multiget( + prop + [dav.Href(value=u.path) for u in event_urls] ) - response = self._query(root, 1, "report") + # RFC 4791 section 7.9: "the 'Depth' header MUST be ignored by the + # server and SHOULD NOT be sent by the client" for calendar-multiget + response = self._query(root, None, "report") results = response.expand_simple_props([cdav.CalendarData()]) if raise_notfound: for href in response.statuses: @@ -709,30 +1115,33 @@ def date_search( verify_expand: bool = False, ) -> Sequence["CalendarObjectResource"]: # type (TimeStamp, TimeStamp, str, str) -> CalendarObjectResource - """Deprecated. Use self.search() instead. + """ + .. deprecated:: 3.0 + Use :meth:`search` instead. This method will be removed in 4.0. Search events by date in the calendar. - Args - start : defaults to datetime.today(). - end : same as above. - compfilter : defaults to events only. Set to None to fetch all calendar components. - expand : should recurrent events be expanded? (to preserve backward-compatibility the default "maybe" will be changed into True unless the date_search is open-ended) - verify_expand : not in use anymore, but kept for backward compatibility + Args: + start: Start of the date range to search. + end: End of the date range (optional for open-ended search). + compfilter: Component type to search for. Defaults to "VEVENT". + Set to None to fetch all calendar components. + expand: Should recurrent events be expanded? Default "maybe" + becomes True unless the search is open-ended. + verify_expand: Not in use anymore, kept for backward compatibility. Returns: - * [CalendarObjectResource(), ...] + List of CalendarObjectResource objects matching the search. + + Example (migrate to search):: - Recurring events are expanded if they are occurring during the - specified time frame and if an end timestamp is given. + # Legacy (deprecated): + events = calendar.date_search(start, end, expand=True) - Note that this is a deprecated method. The `search` method is - nearly equivalent. Differences: default for ``compfilter`` is - to search for all objects, default for ``expand`` is - ``False``, and it has a different default - ``split_expanded=True``. + # Recommended: + events = calendar.search(start=start, end=end, event=True, expand=True) """ - ## date_search will probably disappear in 3.0 + ## date_search will be removed in 4.0 warnings.warn( "use `calendar.search rather than `calendar.date_search`", DeprecationWarning, @@ -747,7 +1156,7 @@ def date_search( ## for backward compatibility - expand should be false ## in an open-ended date search, otherwise true if expand == "maybe": - expand = end + expand = start is not None and end is not None if compfilter == "VEVENT": comp_class = Event @@ -775,7 +1184,14 @@ def _request_report_build_resultlist( """ Takes some input XML, does a report query on a calendar object and returns the resource objects found. + + For async clients, returns a coroutine that must be awaited. """ + if self.is_async_client: + return self._async_request_report_build_resultlist( + xml, comp_class, props, no_calendardata + ) + matches = [] if props is None: props_ = [cdav.CalendarData()] @@ -815,6 +1231,98 @@ def _request_report_build_resultlist( ) return (response, matches) + async def _async_request_report_build_resultlist( + self, xml, comp_class=None, props=None, no_calendardata=False + ): + """Async implementation of _request_report_build_resultlist.""" + matches = [] + if props is None: + props_ = [cdav.CalendarData()] + else: + props_ = [cdav.CalendarData()] + props + response = await self._async_query(xml, 1, "report") + results = response.expand_simple_props(props_) + for r in results: + pdata = results[r] + if cdav.CalendarData.tag in pdata: + cdata = pdata.pop(cdav.CalendarData.tag) + comp_class_ = ( + self._calendar_comp_class_by_data(cdata) + if comp_class is None + else comp_class + ) + else: + cdata = None + if comp_class_ is None: + comp_class_ = CalendarObjectResource + url = URL(r) + if url.hostname is None: + url = quote(r) + if self.url.join(url) == self.url: + continue + matches.append( + comp_class_( + self.client, + url=self.url.join(url), + data=cdata, + parent=self, + props=pdata, + ) + ) + return (response, matches) + + def searcher(self, **searchargs) -> "CalDAVSearcher": + """Create a searcher object for building complex search queries. + + This is the recommended way to perform advanced searches. The + returned searcher can have filters added, and then be executed: + + .. code-block:: python + + searcher = calendar.searcher(event=True, start=..., end=...) + searcher.add_property_filter("SUMMARY", "meeting") + results = searcher.search() + + For simple searches, use :meth:`search` directly instead. + + :param searchargs: Search parameters (same as for :meth:`search`) + :return: A CalDAVSearcher bound to this calendar + + See :class:`caldav.search.CalDAVSearcher` for available filter methods. + """ + from .search import CalDAVSearcher + + my_searcher = CalDAVSearcher() + my_searcher._calendar = self + + for key in searchargs: + assert key[0] != "_" ## not allowed + alias = key + if key == "class_": ## because class is a reserved word + alias = "class" + if key == "no_category": + alias = "no_categories" + if key == "no_class_": + alias = "no_class" + if key == "sort_keys": + sort_reverse = searchargs.get("sort_reverse", False) + if isinstance(searchargs["sort_keys"], str): + searchargs["sort_keys"] = [searchargs["sort_keys"]] + for sortkey in searchargs["sort_keys"]: + my_searcher.add_sort_key(sortkey, sort_reverse) + elif key == "sort_reverse": + pass # handled with sort_keys + elif key == "comp_class" or key in my_searcher.__dataclass_fields__: + setattr(my_searcher, key, searchargs[key]) + elif alias.startswith("no_"): + my_searcher.add_property_filter( + alias[3:], searchargs[key], operator="undef" + ) + else: + my_searcher.add_property_filter(alias, searchargs[key]) + + return my_searcher + def search( self, xml: str = None, @@ -920,17 +1428,12 @@ def search( ## The logic below will massage the parameters in ``searchargs`` ## and put them into the CalDAVSearcher object. - if searchargs.get("expand", True) not in (True, False): - warnings.warn( - "in cal.search(), expand should be a bool", - DeprecationWarning, - stacklevel=2, - ) - if searchargs["expand"] == "client": - searchargs["expand"] = True - if searchargs["expand"] == "server": - server_expand = True - searchargs["expand"] = False + ## In caldav 1, expand could be set to True, False, "server" or "client". + ## in caldav 2, the extra argument `server_expand` was introduced + ## and usage of "server"/"client" was deprecated. + ## In caldav 3, the support for "server" or "client" will be shedded. + ## For server-side expansion, set `expand=True, server_expand=True` + assert isinstance(searchargs.get("expand", True), bool) ## Transfer all the arguments to CalDAVSearcher my_searcher = CalDAVSearcher() @@ -962,6 +1465,12 @@ def search( if not xml and filters: xml = filters + # For async clients, use async_search + if self.is_async_client: + return my_searcher.async_search( + self, server_expand, split_expanded, props, xml, post_filter, _hacks + ) + return my_searcher.search( self, server_expand, split_expanded, props, xml, post_filter, _hacks ) @@ -982,27 +1491,46 @@ def freebusy_request(self, start: datetime, end: datetime) -> "FreeBusy": response = self._query(root, 1, "report") return FreeBusy(self, response.raw) - def todos( + def get_todos( self, sort_keys: Sequence[str] = ("due", "priority"), include_completed: bool = False, sort_key: Optional[str] = None, ) -> List["Todo"]: """ - Fetches a list of todo events (this is a wrapper around search). + Fetches a list of todo items (this is a wrapper around search). + + For sync clients, returns a list of Todo objects directly. + For async clients, returns a coroutine that must be awaited. Args: sort_keys: use this field in the VTODO for sorting (iterable of lower case string, i.e. ('priority','due')). include_completed: boolean - by default, only pending tasks are listed sort_key: DEPRECATED, for backwards compatibility with version 0.4. + + Example (sync): + todos = calendar.get_todos() + + Example (async): + todos = await calendar.get_todos() """ if sort_key: sort_keys = (sort_key,) + # Use search() for both sync and async - this ensures any + # delay decorators applied to search() are respected return self.search( todo=True, include_completed=include_completed, sort_keys=sort_keys ) + def todos(self, *largs, **kwargs) -> List["Todo"]: + """ + Deprecated: Use :meth:`get_todos` instead. + + This method is an alias kept for backwards compatibility. + """ + return self.get_todos(*largs, **kwargs) + def _calendar_comp_class_by_data(self, data): """ takes some data, either as icalendar text or icalender object (TODO: @@ -1045,22 +1573,22 @@ def event_by_url(self, href, data: Optional[Any] = None) -> "Event": """ return Event(url=href, data=data, parent=self).load() - def object_by_uid( + def get_object_by_uid( self, uid: str, comp_filter: Optional[cdav.CompFilter] = None, comp_class: Optional["CalendarObjectResource"] = None, ) -> "Event": """ - Get one event from the calendar. + Get one calendar object from the calendar by UID. Args: - uid: the event uid + uid: the object uid comp_class: filter by component type (Event, Todo, Journal) comp_filter: for backward compatibility. Don't use! Returns: - Event() or None + CalendarObjectResource (Event, Todo, or Journal) """ ## late import to avoid cyclic dependencies from .search import CalDAVSearcher @@ -1085,36 +1613,98 @@ def object_by_uid( error.assert_(len(items_found) == 1) return items_found[0] - def todo_by_uid(self, uid: str) -> "CalendarObjectResource": + def get_todo_by_uid(self, uid: str) -> "CalendarObjectResource": + """ + Get a task/todo from the calendar by UID. + + Returns the task with the given uid. + See :meth:`get_object_by_uid` for more details. + """ + return self.get_object_by_uid(uid, comp_filter=cdav.CompFilter("VTODO")) + + def get_event_by_uid(self, uid: str) -> "CalendarObjectResource": + """ + Get an event from the calendar by UID. + + Returns the event with the given uid. + See :meth:`get_object_by_uid` for more details. + """ + return self.get_object_by_uid(uid, comp_filter=cdav.CompFilter("VEVENT")) + + def get_journal_by_uid(self, uid: str) -> "CalendarObjectResource": + """ + Get a journal entry from the calendar by UID. + + Returns the journal with the given uid. + See :meth:`get_object_by_uid` for more details. """ - Returns the task with the given uid (wraps around :class:`object_by_uid`) + return self.get_object_by_uid(uid, comp_filter=cdav.CompFilter("VJOURNAL")) + + ## Deprecated aliases - use get_*_by_uid instead + + def object_by_uid(self, *largs, **kwargs) -> "CalendarObjectResource": + """ + Deprecated: Use :meth:`get_object_by_uid` instead. + + This method is an alias kept for backwards compatibility. """ - return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VTODO")) + return self.get_object_by_uid(*largs, **kwargs) def event_by_uid(self, uid: str) -> "CalendarObjectResource": """ - Returns the event with the given uid (wraps around :class:`object_by_uid`) + Deprecated: Use :meth:`get_event_by_uid` instead. + + This method is an alias kept for backwards compatibility. """ - return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VEVENT")) + return self.get_event_by_uid(uid) + + def todo_by_uid(self, uid: str) -> "CalendarObjectResource": + """ + Deprecated: Use :meth:`get_todo_by_uid` instead. + + This method is an alias kept for backwards compatibility. + """ + return self.get_todo_by_uid(uid) def journal_by_uid(self, uid: str) -> "CalendarObjectResource": """ - Returns the journal with the given uid (wraps around :class:`object_by_uid`) + Deprecated: Use :meth:`get_journal_by_uid` instead. + + This method is an alias kept for backwards compatibility. """ - return self.object_by_uid(uid, comp_filter=cdav.CompFilter("VJOURNAL")) + return self.get_journal_by_uid(uid) # alias for backward compatibility event = event_by_uid - def events(self) -> List["Event"]: + def get_events(self) -> List["Event"]: """ List all events from the calendar. + For sync clients, returns a list of Event objects directly. + For async clients, returns a coroutine that must be awaited. + Returns: * [Event(), ...] + + Example (sync): + events = calendar.get_events() + + Example (async): + events = await calendar.get_events() """ + # Use search() for both sync and async - this ensures any + # delay decorators applied to search() are respected return self.search(comp_class=Event) + def events(self) -> List["Event"]: + """ + Deprecated: Use :meth:`get_events` instead. + + This method is an alias kept for backwards compatibility. + """ + return self.get_events() + def _generate_fake_sync_token(self, objects: List["CalendarObjectResource"]) -> str: """ Generate a fake sync token for servers without sync support. @@ -1140,13 +1730,13 @@ def _generate_fake_sync_token(self, objects: List["CalendarObjectResource"]) -> hash_value = hashlib.md5(combined.encode()).hexdigest() return f"fake-{hash_value}" - def objects_by_sync_token( + def get_objects_by_sync_token( self, sync_token: Optional[Any] = None, load_objects: bool = False, disable_fallback: bool = False, ) -> "SynchronizableCalendarObjectCollection": - """objects_by_sync_token aka objects + """get_objects_by_sync_token aka get_objects Do a sync-collection report, ref RFC 6578 and https://github.com/python-caldav/caldav/issues/87 @@ -1295,9 +1885,20 @@ def objects_by_sync_token( calendar=self, objects=all_objects, sync_token=fake_sync_token ) + def objects_by_sync_token( + self, *largs, **kwargs + ) -> "SynchronizableCalendarObjectCollection": + """ + Deprecated: Use :meth:`get_objects_by_sync_token` instead. + + This method is an alias kept for backwards compatibility. + """ + return self.get_objects_by_sync_token(*largs, **kwargs) + objects = objects_by_sync_token + get_objects = get_objects_by_sync_token - def journals(self) -> List["Journal"]: + def get_journals(self) -> List["Journal"]: """ List all journals from the calendar. @@ -1306,6 +1907,14 @@ def journals(self) -> List["Journal"]: """ return self.search(comp_class=Journal) + def journals(self) -> List["Journal"]: + """ + Deprecated: Use :meth:`get_journals` instead. + + This method is an alias kept for backwards compatibility. + """ + return self.get_journals() + class ScheduleMailbox(Calendar): """ @@ -1360,8 +1969,7 @@ def __init__( # we ignore the type here as this is defined in sub-classes only; require more changes to # properly fix in a future revision raise error.NotFoundError( - "principal has no %s. %s" - % (str(self.findprop()), error.ERR_FRAGMENT) # type: ignore + "principal has no %s. %s" % (str(self.findprop()), error.ERR_FRAGMENT) # type: ignore ) def get_items(self): @@ -1461,7 +2069,7 @@ def sync(self) -> Tuple[Any, Any]: if not is_fake_token: ## Try to use real sync tokens try: - updates = self.calendar.objects_by_sync_token( + updates = self.calendar.get_objects_by_sync_token( self.sync_token, load_objects=False ) diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index f02f3a1e..9257aab0 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -107,6 +107,9 @@ class FeatureSet: "save-load.todo.recurrences.count": {"description": "The server will receive and store a recurring task with a count set in the RRULE"}, "save-load.todo.mixed-calendar": {"description": "The same calendar may contain both events and tasks (Zimbra only allows tasks to be placed on special task lists)"}, "save-load.journal": {"description": "The server will even accept journals"}, + "save-load.reuse-deleted-uid": { + "description": "After deleting an event, the server allows creating a new event with the same UID. When 'broken', the server keeps deleted events in a trashbin with a soft-delete flag, causing unique constraint violations on UID reuse. See https://github.com/nextcloud/server/issues/30096" + }, "save-load.event.timezone": { "description": "The server accepts events with non-UTC timezone information. When unsupported or broken, the server may reject events with timezone data (e.g., return 403 Forbidden). Related to GitHub issue https://github.com/python-caldav/caldav/issues/372." }, @@ -153,7 +156,7 @@ class FeatureSet: "description": "Substring search for category should work according to the RFC. I.e., search for mil should match family,finance", }, "search.text.by-uid": { - "description": "The server supports searching for objects by UID property. When unsupported, calendar.object_by_uid(uid) will not work. This may be removed in the feature - the checker-script is not checking the right thing (check TODO-comments), probably search by uid is no special case for any server implementations" + "description": "The server supports searching for objects by UID property. When unsupported, calendar.get_object_by_uid(uid) will not work. This may be removed in the feature - the checker-script is not checking the right thing (check TODO-comments), probably search by uid is no special case for any server implementations" }, "search.recurrences": { "description": "Support for recurrences in search" @@ -214,6 +217,9 @@ class FeatureSet: "principal-search.list-all": { "description": "Server allows listing all principals without a name filter. Often blocked for privacy/security reasons" }, + "wrong-password-check": { + "description": "Server rejects requests with wrong password by returning an authorization error. Some servers may not properly reject wrong passwords in certain configurations." + }, "save": {}, "save.duplicate-uid": {}, "save.duplicate-uid.cross-calendar": { @@ -257,6 +263,9 @@ def __init__(self, feature_set_dict=None): """ if isinstance(feature_set_dict, FeatureSet): self._server_features = copy.deepcopy(feature_set_dict._server_features) + self.backward_compatibility_mode = feature_set_dict.backward_compatibility_mode + self._old_flags = copy.copy(feature_set_dict._old_flags) if hasattr(feature_set_dict, '_old_flags') else [] + return ## TODO: copy the FEATURES dict, or just the feature_set dict? ## (anyways, that is an internal design decision that may be @@ -407,9 +416,67 @@ def is_supported(self, feature, return_type=bool, return_defaults=True, accept_f if not '.' in feature_: if not return_defaults: return None + # Before returning default, check if we have subfeatures with explicit values + # If subfeatures exist and have mixed support levels, we should derive the parent status + derived = self._derive_from_subfeatures(feature_, feature_info, return_type, accept_fragile) + if derived is not None: + return derived return self._convert_node(self._default(feature_info), feature_info, return_type, accept_fragile) feature_ = feature_[:feature_.rfind('.')] + def _derive_from_subfeatures(self, feature, feature_info, return_type, accept_fragile=False): + """ + Derive parent feature status from explicitly set subfeatures. + + Logic: + - Only consider subfeatures WITHOUT explicit defaults (those are independent features) + - If all relevant subfeatures have the same status → use that status + - If subfeatures have mixed statuses → return "unknown" + (since we can't definitively determine the parent's status) + + Returns None if no relevant subfeatures are explicitly set. + """ + if 'subfeatures' not in feature_info or not feature_info['subfeatures']: + return None + + # Collect statuses from explicitly set subfeatures (excluding independent ones) + subfeature_statuses = [] + for sub in feature_info['subfeatures']: + subfeature_key = f"{feature}.{sub}" + if subfeature_key in self._server_features: + # Skip subfeatures with explicit defaults - they represent independent behaviors + # not hierarchical components of the parent feature + try: + subfeature_info = self.find_feature(subfeature_key) + if 'default' in subfeature_info: + # This subfeature has an explicit default, meaning it's independent + continue + except: + # If we can't find the feature info, include it conservatively + pass + + sub_dict = self._server_features[subfeature_key] + # Extract the support level (or enable/behaviour/observed) + status = sub_dict.get('support', sub_dict.get('enable', sub_dict.get('behaviour', sub_dict.get('observed')))) + if status: + subfeature_statuses.append(status) + + # If no relevant subfeatures are explicitly set, return None (use default) + if not subfeature_statuses: + return None + + # Check if all subfeatures have the same status + if all(status == subfeature_statuses[0] for status in subfeature_statuses): + # All same - use that status + derived_status = subfeature_statuses[0] + else: + # Mixed statuses - we don't have complete/consistent information + derived_status = 'unknown' + + # Create a node dict with the derived status + derived_node = {'support': derived_status} + return self._convert_node(derived_node, feature_info, return_type, accept_fragile) + def _convert_node(self, node, feature_info, return_type, accept_fragile=False): """ Return the information in a "node" given the wished return_type @@ -707,7 +774,10 @@ def dotted_feature_set_list(self, compact=False): ] } -xandikos=xandikos_v0_3 +xandikos_main = xandikos_v0_3.copy() +xandikos_main.pop('search.recurrences.expanded.todo') + +xandikos = xandikos_main ## This seems to work as of version 3.5.4 of Radicale. ## There is much development going on at Radicale as of summar 2025, @@ -715,7 +785,7 @@ def dotted_feature_set_list(self, compact=False): radicale = { "search.text.case-sensitive": {"support": "unsupported"}, "search.is-not-defined": {"support": "fragile", "behaviour": "seems to work for categories but not for dtend"}, - "search.recurrences.includes-implicit.todo.pending": {"support": "unsupported"}, + "search.recurrences.includes-implicit.todo.pending": {"support": "fragile", "behaviour": "inconsistent results between runs"}, "search.recurrences.expanded.todo": {"support": "unsupported"}, "search.recurrences.expanded.exception": {"support": "unsupported"}, 'principal-search': {'support': 'unknown', 'behaviour': 'No display name available - cannot test'}, @@ -837,6 +907,8 @@ def dotted_feature_set_list(self, compact=False): 'propfind_allprop_failure', 'duplicates_not_allowed', ], + # Ephemeral Docker container: wipe objects (delete-calendar not supported) + 'test-calendar': {'cleanup-regime': 'wipe-calendar'}, 'auto-connect.url': {'basepath': '/ucaldav/'}, "save-load.journal": { "support": "ungraceful" @@ -933,11 +1005,14 @@ def dotted_feature_set_list(self, compact=False): "search.recurrences.expanded.exception": {"support": "unsupported"}, 'search.time-range.alarm': {'support': 'unsupported'}, 'principal-search': {'support': 'ungraceful'}, - "test-calendar": {"cleanup-regime": "pre"}, + # Ephemeral Docker container: wipe objects but keep calendar (avoids UID conflicts) + "test-calendar": {"cleanup-regime": "wipe-calendar"}, 'delete-calendar': { 'support': 'fragile', 'behaviour': 'Deleting a recently created calendar fails'}, 'save.duplicate-uid.cross-calendar': {'support': 'ungraceful'}, + # Cyrus may not properly reject wrong passwords in some configurations + 'wrong-password-check': {'support': 'unsupported'}, 'old_flags': [] } @@ -951,11 +1026,13 @@ def dotted_feature_set_list(self, compact=False): # "no_freebusy_rfc4791", # 'no_recurring', # 'propfind_allprop_failure', -# 'object_by_uid_is_broken' +# 'get_object_by_uid_is_broken' #] davical = { - + # Disable HTTP/2 multiplexing - davical doesn't support it well and niquests + # lazy responses cause MultiplexingError when accessing status_code + "http.multiplexing": { "support": "unsupported" }, "search.comp-type-optional": { "support": "fragile" }, "search.recurrences.expanded.todo": { "support": "unsupported" }, "search.recurrences.expanded.exception": { "support": "unsupported" }, @@ -1021,6 +1098,8 @@ def dotted_feature_set_list(self, compact=False): "support": "ungraceful", "behaviour": "Search by name failed: ReportError at '501 Not Implemented - \n\n

An error occurred during object publishing

did not find the specified REPORT

\n\n', reason no reason", }, + # Ephemeral Docker container: wipe objects (delete-calendar fragile) + 'test-calendar': {'cleanup-regime': 'wipe-calendar'}, } ## Old notes for sogo (todo - incorporate them in the structure above) diff --git a/caldav/config.py b/caldav/config.py index bd1da620..186bdf0d 100644 --- a/caldav/config.py +++ b/caldav/config.py @@ -1,6 +1,13 @@ import json import logging import os +import re +import sys +from fnmatch import fnmatch +from typing import Any +from typing import Dict +from typing import Optional +from typing import Union """ This configuration parsing code was just copied from my plann library (and will be removed from there at some point in the future). Test coverage is poor as for now. @@ -123,3 +130,247 @@ def read_config(fn, interactive_error=False): else: logging.error("error in config file. It will be ignored", exc_info=True) return {} + + +def expand_env_vars(value: Any) -> Any: + """ + Expand environment variable references in configuration values. + + Supports two syntaxes: + - ${VAR} - expands to the value of VAR, or empty string if not set + - ${VAR:-default} - expands to the value of VAR, or 'default' if not set + + Works recursively on dicts and lists. + + Examples: + >>> os.environ['TEST_VAR'] = 'hello' + >>> expand_env_vars('${TEST_VAR}') + 'hello' + >>> expand_env_vars('${MISSING:-default_value}') + 'default_value' + >>> expand_env_vars({'key': '${TEST_VAR}'}) + {'key': 'hello'} + """ + if isinstance(value, str): + # Pattern matches ${VAR} or ${VAR:-default} + pattern = r"\$\{([^}:]+)(?::-([^}]*))?\}" + + def replacer(match: re.Match) -> str: + var_name = match.group(1) + default = match.group(2) if match.group(2) is not None else "" + return os.environ.get(var_name, default) + + return re.sub(pattern, replacer, value) + elif isinstance(value, dict): + return {k: expand_env_vars(v) for k, v in value.items()} + elif isinstance(value, list): + return [expand_env_vars(v) for v in value] + return value + + +# Valid connection parameter keys for DAVClient +CONNKEYS = frozenset( + [ + "url", + "proxy", + "username", + "password", + "timeout", + "headers", + "huge_tree", + "ssl_verify_cert", + "ssl_cert", + "auth", + "auth_type", + "features", + "enable_rfc6764", + "require_tls", + ] +) + + +def get_connection_params( + check_config_file: bool = True, + config_file: Optional[str] = None, + config_section: Optional[str] = None, + testconfig: bool = False, + environment: bool = True, + name: Optional[str] = None, + **explicit_params: Any, +) -> Optional[Dict[str, Any]]: + """ + Get connection parameters from multiple sources. + + This is THE single source of truth for configuration discovery. + Both sync and async get_davclient() functions should use this. + + Priority (first non-empty wins): + 1. Explicit parameters (url=, username=, password=, etc.) + 2. Test server config (if testconfig=True or PYTHON_CALDAV_USE_TEST_SERVER env var) + 3. Environment variables (CALDAV_URL, CALDAV_USERNAME, etc.) + 4. Config file (CALDAV_CONFIG_FILE env var or default locations) + + Test Server Mode: + When testconfig=True or PYTHON_CALDAV_USE_TEST_SERVER env var is set, + only config file sections with 'testing_allowed: true' will be used. + This prevents accidentally using personal/production servers for testing. + + If no test server is found, returns None (does NOT fall through to + regular config file or environment variables). + + Environment variable PYTHON_CALDAV_TEST_SERVER_NAME can specify which + config section to use for testing. + + Args: + check_config_file: Whether to look for config files + config_file: Explicit path to config file + config_section: Section name in config file (default: "default") + testconfig: Whether to use test server configuration + environment: Whether to read from environment variables + name: Name of test server/config section to use (for testconfig) + **explicit_params: Explicit connection parameters + + Returns: + Dict with connection parameters (url, username, password, etc.) + or None if no configuration found. + """ + # 1. Explicit parameters take highest priority + if explicit_params: + # Filter to valid connection keys + conn_params = {k: v for k, v in explicit_params.items() if k in CONNKEYS} + if conn_params.get("url"): + return conn_params + + # Check for config file path from environment early (needed for test server config too) + if environment: + if not config_file: + config_file = os.environ.get("CALDAV_CONFIG_FILE") + if not config_section: + config_section = os.environ.get("CALDAV_CONFIG_SECTION") + + # 2. Test server configuration + if testconfig or (environment and os.environ.get("PYTHON_CALDAV_USE_TEST_SERVER")): + conn = _get_test_server_config(name, environment, config_file) + if conn is not None: + return conn + # In test mode, don't fall through to regular config - return None + # This prevents accidentally using personal/production servers for testing + logging.info( + "Test server mode enabled but no server with testing_allowed=true found. " + "Add 'testing_allowed: true' to a config section to enable it for testing." + ) + return None + + # 3. Environment variables (CALDAV_*) + if environment: + conn_params = _get_env_config() + if conn_params: + return conn_params + + # 4. Config file + if check_config_file: + conn_params = _get_file_config(config_file, config_section) + if conn_params: + return conn_params + + return None + + +def _get_env_config() -> Optional[Dict[str, Any]]: + """Extract connection parameters from CALDAV_* environment variables.""" + conf: Dict[str, Any] = {} + for env_key in os.environ: + if env_key.startswith("CALDAV_") and not env_key.startswith("CALDAV_CONFIG"): + key = env_key[7:].lower() + # Map common aliases + if key == "pass": + key = "password" + elif key == "user": + key = "username" + if key in CONNKEYS: + conf[key] = os.environ[env_key] + return conf if conf else None + + +def _get_file_config( + file_path: Optional[str], section_name: Optional[str] +) -> Optional[Dict[str, Any]]: + """Extract connection parameters from config file.""" + if not section_name: + section_name = "default" + + cfg = read_config(file_path) + if not cfg: + return None + + section_data = config_section(cfg, section_name) + return _extract_conn_params_from_section(section_data) + + +def _get_test_server_config( + name: Optional[str], environment: bool, config_file: Optional[str] = None +) -> Optional[Dict[str, Any]]: + """ + Get connection parameters for test server. + + Priority: + 1. Config file sections with 'testing_allowed: true' + + Args: + name: Specific config section or test server name/index to use. + Can be a config section name, test server name, or numeric index. + environment: Whether to check environment variables for server selection. + config_file: Explicit config file path to check. + + Returns: + Connection parameters dict, or None if no test server configured. + """ + # Check environment for server name + if environment and name is None: + name = os.environ.get("PYTHON_CALDAV_TEST_SERVER_NAME") + + # 1. Try config file with testing_allowed flag + cfg = read_config(config_file) # Use explicit file or default locations + if cfg: + # If name is specified, check if it's a config section with testing_allowed + if name is not None and not isinstance(name, int): + section_data = config_section(cfg, str(name)) + if section_data.get("testing_allowed"): + return _extract_conn_params_from_section(section_data) + + # Find first section with testing_allowed=true (if no name specified) + if name is None: + for section_name in cfg: + section_data = config_section(cfg, section_name) + if section_data.get("testing_allowed"): + logging.info( + f"Using test server from config section: {section_name}" + ) + return _extract_conn_params_from_section(section_data) + + # No built-in test server fallback - use config files or environment variables + return None + + +def _extract_conn_params_from_section( + section_data: Dict[str, Any] +) -> Optional[Dict[str, Any]]: + """Extract connection parameters from a config section dict.""" + conn_params: Dict[str, Any] = {} + for k in section_data: + if k.startswith("caldav_"): + # Check for non-None value (empty string is valid for password) + value = section_data[k] + if value is not None: + key = k[7:] + # Map common aliases + if key == "pass": + key = "password" + elif key == "user": + key = "username" + if key in CONNKEYS: + conn_params[key] = expand_env_vars(value) + elif k == "features" and section_data[k]: + conn_params["features"] = section_data[k] + + return conn_params if conn_params.get("url") else None diff --git a/caldav/datastate.py b/caldav/datastate.py new file mode 100644 index 00000000..e2aaf5e9 --- /dev/null +++ b/caldav/datastate.py @@ -0,0 +1,248 @@ +""" +Data state management for CalendarObjectResource. + +This module implements the Strategy/State pattern for managing different +representations of calendar data (raw string, icalendar object, vobject object). + +See https://github.com/python-caldav/caldav/issues/613 for design discussion. +""" + +from __future__ import annotations + +import re +from abc import ABC +from abc import abstractmethod +from typing import TYPE_CHECKING +from typing import Optional + +import icalendar + +if TYPE_CHECKING: + import vobject + + +class DataState(ABC): + """Abstract base class for calendar data states. + + Each concrete state represents a different "source of truth" for the + calendar data. The state provides access to all representations, but + only one is authoritative at any time. + """ + + @abstractmethod + def get_data(self) -> str: + """Get raw iCalendar string representation. + + This may involve serialization if the current state holds a + parsed object. + """ + pass + + @abstractmethod + def get_icalendar_copy(self) -> icalendar.Calendar: + """Get a fresh copy of the icalendar object. + + This is safe for read-only access - modifications won't affect + the stored data. + """ + pass + + @abstractmethod + def get_vobject_copy(self) -> "vobject.base.Component": + """Get a fresh copy of the vobject object. + + This is safe for read-only access - modifications won't affect + the stored data. + """ + pass + + def get_uid(self) -> Optional[str]: + """Extract UID without full parsing if possible. + + Default implementation parses the data, but subclasses can optimize. + """ + cal = self.get_icalendar_copy() + for comp in cal.subcomponents: + if comp.name in ("VEVENT", "VTODO", "VJOURNAL") and "UID" in comp: + return str(comp["UID"]) + return None + + def get_component_type(self) -> Optional[str]: + """Get the component type (VEVENT, VTODO, VJOURNAL) without full parsing. + + Default implementation parses the data, but subclasses can optimize. + """ + cal = self.get_icalendar_copy() + for comp in cal.subcomponents: + if comp.name in ("VEVENT", "VTODO", "VJOURNAL"): + return comp.name + return None + + def has_data(self) -> bool: + """Check if this state has any data.""" + return True + + +class NoDataState(DataState): + """Null Object pattern - no data loaded yet. + + This state is used when a CalendarObjectResource is created without + any initial data. It provides empty/default values for all accessors. + """ + + def get_data(self) -> str: + return "" + + def get_icalendar_copy(self) -> icalendar.Calendar: + return icalendar.Calendar() + + def get_vobject_copy(self) -> "vobject.base.Component": + import vobject + + return vobject.iCalendar() + + def get_uid(self) -> Optional[str]: + return None + + def get_component_type(self) -> Optional[str]: + return None + + def has_data(self) -> bool: + return False + + +class RawDataState(DataState): + """State when raw string data is the source of truth. + + This is the most common initial state when data is loaded from + a CalDAV server. + """ + + def __init__(self, data: str): + self._data = data + + def get_data(self) -> str: + return self._data + + def get_icalendar_copy(self) -> icalendar.Calendar: + return icalendar.Calendar.from_ical(self._data) + + def get_vobject_copy(self) -> "vobject.base.Component": + import vobject + + return vobject.readOne(self._data) + + def get_uid(self) -> Optional[str]: + # Optimization: use regex instead of full parsing + match = re.search(r"^UID:(.+)$", self._data, re.MULTILINE) + if match: + return match.group(1).strip() + # Fall back to parsing if regex fails (e.g., folded lines) + return super().get_uid() + + def get_component_type(self) -> Optional[str]: + # Optimization: use simple string search + if "BEGIN:VEVENT" in self._data: + return "VEVENT" + elif "BEGIN:VTODO" in self._data: + return "VTODO" + elif "BEGIN:VJOURNAL" in self._data: + return "VJOURNAL" + return None + + +class IcalendarState(DataState): + """State when icalendar object is the source of truth. + + This state is entered when: + - User calls edit_icalendar_instance() + - User sets icalendar_instance property + - User modifies the icalendar object + """ + + def __init__(self, calendar: icalendar.Calendar): + self._calendar = calendar + + def get_data(self) -> str: + return self._calendar.to_ical().decode("utf-8") + + def get_icalendar_copy(self) -> icalendar.Calendar: + # Parse from serialized form to get a true copy + return icalendar.Calendar.from_ical(self.get_data()) + + def get_authoritative_icalendar(self) -> icalendar.Calendar: + """Returns THE icalendar object (not a copy). + + This is the authoritative object - modifications will be saved. + """ + return self._calendar + + def get_vobject_copy(self) -> "vobject.base.Component": + import vobject + + return vobject.readOne(self.get_data()) + + def get_uid(self) -> Optional[str]: + for comp in self._calendar.subcomponents: + if comp.name in ("VEVENT", "VTODO", "VJOURNAL") and "UID" in comp: + return str(comp["UID"]) + return None + + def get_component_type(self) -> Optional[str]: + for comp in self._calendar.subcomponents: + if comp.name in ("VEVENT", "VTODO", "VJOURNAL"): + return comp.name + return None + + +class VobjectState(DataState): + """State when vobject object is the source of truth. + + This state is entered when: + - User calls edit_vobject_instance() + - User sets vobject_instance property + - User modifies the vobject object + """ + + def __init__(self, vobj: "vobject.base.Component"): + self._vobject = vobj + + def get_data(self) -> str: + return self._vobject.serialize() + + def get_icalendar_copy(self) -> icalendar.Calendar: + return icalendar.Calendar.from_ical(self.get_data()) + + def get_vobject_copy(self) -> "vobject.base.Component": + import vobject + + return vobject.readOne(self.get_data()) + + def get_authoritative_vobject(self) -> "vobject.base.Component": + """Returns THE vobject object (not a copy). + + This is the authoritative object - modifications will be saved. + """ + return self._vobject + + def get_uid(self) -> Optional[str]: + # vobject uses different attribute access + try: + if hasattr(self._vobject, "vevent"): + return str(self._vobject.vevent.uid.value) + elif hasattr(self._vobject, "vtodo"): + return str(self._vobject.vtodo.uid.value) + elif hasattr(self._vobject, "vjournal"): + return str(self._vobject.vjournal.uid.value) + except AttributeError: + pass + return None + + def get_component_type(self) -> Optional[str]: + if hasattr(self._vobject, "vevent"): + return "VEVENT" + elif hasattr(self._vobject, "vtodo"): + return "VTODO" + elif hasattr(self._vobject, "vjournal"): + return "VJOURNAL" + return None diff --git a/caldav/davclient.py b/caldav/davclient.py index f719159c..17455e0b 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -1,12 +1,17 @@ #!/usr/bin/env python +""" +Sync CalDAV client using niquests or requests library. + +This module provides the traditional synchronous API with protocol layer +for XML building and response parsing. + +For async code, use: from caldav import aio +""" import logging -import os import sys import warnings from types import TracebackType from typing import Any -from typing import cast -from typing import Dict from typing import List from typing import Optional from typing import Tuple @@ -14,50 +19,54 @@ from typing import Union from urllib.parse import unquote +# Try niquests first (preferred), fall back to requests +_USE_NIQUESTS = False +_USE_REQUESTS = False try: import niquests as requests from niquests.auth import AuthBase from niquests.models import Response from niquests.structures import CaseInsensitiveDict + + _USE_NIQUESTS = True except ImportError: import requests from requests.auth import AuthBase from requests.models import Response from requests.structures import CaseInsensitiveDict + _USE_REQUESTS = True + from lxml import etree -from lxml.etree import _Element -from .elements.base import BaseElement -from caldav import __version__ -from caldav.collection import Calendar -from caldav.collection import CalendarSet -from caldav.collection import Principal import caldav.compatibility_hints +from caldav import __version__ + +from caldav.collection import Calendar, CalendarSet, Principal from caldav.compatibility_hints import FeatureSet -from caldav.elements import cdav -from caldav.elements import dav +from caldav.elements import cdav, dav +from caldav.base_client import BaseDAVClient +from caldav.base_client import get_calendars as _base_get_calendars +from caldav.base_client import get_davclient as _base_get_davclient from caldav.lib import error -from caldav.lib.python_utilities import to_normal_str -from caldav.lib.python_utilities import to_wire +from caldav.lib.python_utilities import to_normal_str, to_wire from caldav.lib.url import URL from caldav.objects import log from caldav.requests import HTTPBearerAuth +from caldav.response import BaseDAVResponse -if TYPE_CHECKING: - pass - -if sys.version_info < (3, 9): - from typing import Iterable, Mapping -else: - from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping if sys.version_info < (3, 11): from typing_extensions import Self else: from typing import Self +if TYPE_CHECKING: + from caldav.calendarobjectresource import CalendarObjectResource, Event, Todo + + """ The ``DAVClient`` class handles the basic communication with a CalDAV server. In 1.x the recommended usage of the library is to @@ -75,26 +84,8 @@ """ ## TODO: this is also declared in davclient.DAVClient.__init__(...) -## TODO: it should be consolidated, duplication is a bad thing -## TODO: and it's almost certain that we'll forget to update this list -CONNKEYS = set( - ( - "url", - "proxy", - "username", - "password", - "timeout", - "headers", - "huge_tree", - "ssl_verify_cert", - "ssl_cert", - "auth", - "auth_type", - "features", - "enable_rfc6764", - "require_tls", - ) -) +# Import CONNKEYS from config to avoid duplication +from caldav.config import CONNKEYS def _auto_url( @@ -136,7 +127,7 @@ def _auto_url( # Try RFC6764 discovery first if enabled and we have a bare domain/email if enable_rfc6764 and url: - from caldav.discovery import discover_caldav, DiscoveryError + from caldav.discovery import DiscoveryError, discover_caldav try: service_info = discover_caldav( @@ -171,7 +162,7 @@ def _auto_url( return (url, None) -class DAVResponse: +class DAVResponse(BaseDAVResponse): """ This class is a response from a DAV request. It is instantiated from the DAVClient class. End users of the library should not need to @@ -179,348 +170,21 @@ class DAVResponse: it tries to parse it into `self.tree` """ - raw = "" - reason: str = "" - tree: Optional[_Element] = None - headers: CaseInsensitiveDict = None - status: int = 0 - davclient = None - huge_tree: bool = False + # Protocol-layer parsed results (new interface, replaces find_objects_and_props()) + results: Optional[List] = None + sync_token: Optional[str] = None def __init__( - self, response: Response, davclient: Optional["DAVClient"] = None - ) -> None: - self.headers = response.headers - self.status = response.status_code - log.debug("response headers: " + str(self.headers)) - log.debug("response status: " + str(self.status)) - - self._raw = response.content - self.davclient = davclient - if davclient: - self.huge_tree = davclient.huge_tree - - content_type = self.headers.get("Content-Type", "") - xml = ["text/xml", "application/xml"] - no_xml = ["text/plain", "text/calendar", "application/octet-stream"] - expect_xml = any((content_type.startswith(x) for x in xml)) - expect_no_xml = any((content_type.startswith(x) for x in no_xml)) - if ( - content_type - and not expect_xml - and not expect_no_xml - and response.status_code < 400 - and response.text - ): - error.weirdness(f"Unexpected content type: {content_type}") - try: - content_length = int(self.headers["Content-Length"]) - except: - content_length = -1 - if content_length == 0 or not self._raw: - self._raw = "" - self.tree = None - log.debug("No content delivered") - else: - ## For really huge objects we should pass the object as a stream to the - ## XML parser, like this: - # self.tree = etree.parse(response.raw, parser=etree.XMLParser(remove_blank_text=True)) - ## However, we would also need to decompress on the fly. I won't bother now. - try: - ## https://github.com/python-caldav/caldav/issues/142 - ## We cannot trust the content=type (iCloud, OX and others). - ## We'll try to parse the content as XML no matter - ## the content type given. - self.tree = etree.XML( - self._raw, - parser=etree.XMLParser( - remove_blank_text=True, huge_tree=self.huge_tree - ), - ) - except: - ## Content wasn't XML. What does the content-type say? - ## expect_no_xml means text/plain or text/calendar - ## expect_no_xml -> ok, pass on, with debug logging - ## expect_xml means text/xml or application/xml - ## expect_xml -> raise an error - ## anything else (text/plain, text/html, ''), - ## log an info message and continue (some servers return HTML error pages) - if not expect_no_xml or log.level <= logging.DEBUG: - if not expect_no_xml: - _log = logging.info - else: - _log = logging.debug - ## The statement below may not be true. - ## We may be expecting something else - _log( - "Expected some valid XML from the server, but got this: \n" - + str(self._raw), - exc_info=True, - ) - if expect_xml: - raise - else: - if log.level <= logging.DEBUG: - log.debug(etree.tostring(self.tree, pretty_print=True)) - - ## this if will always be true as for now, see other comments on streaming. - if hasattr(self, "_raw"): - log.debug(self._raw) - # ref https://github.com/python-caldav/caldav/issues/112 stray CRs may cause problems - if isinstance(self._raw, bytes): - self._raw = self._raw.replace(b"\r\n", b"\n") - elif isinstance(self._raw, str): - self._raw = self._raw.replace("\r\n", "\n") - self.status = response.status_code - ## ref https://github.com/python-caldav/caldav/issues/81, - ## incidents with a response without a reason has been - ## observed - try: - self.reason = response.reason - except AttributeError: - self.reason = "" - - @property - def raw(self) -> str: - ## TODO: this should not really be needed? - if not hasattr(self, "_raw"): - self._raw = etree.tostring(cast(_Element, self.tree), pretty_print=True) - return to_normal_str(self._raw) - - def _strip_to_multistatus(self): - """ - The general format of inbound data is something like this: - - - (...) - (...) - (...) - - - but sometimes the multistatus and/or xml element is missing in - self.tree. We don't want to bother with the multistatus and - xml tags, we just want the response list. - - An "Element" in the lxml library is a list-like object, so we - should typically return the element right above the responses. - If there is nothing but a response, return it as a list with - one element. - - (The equivalent of this method could probably be found with a - simple XPath query, but I'm not much into XPath) - """ - tree = self.tree - if tree.tag == "xml" and tree[0].tag == dav.MultiStatus.tag: - return tree[0] - if tree.tag == dav.MultiStatus.tag: - return self.tree - return [self.tree] - - def validate_status(self, status: str) -> None: - """ - status is a string like "HTTP/1.1 404 Not Found". 200, 207 and - 404 are considered good statuses. The SOGo caldav server even - returns "201 created" when doing a sync-report, to indicate - that a resource was created after the last sync-token. This - makes sense to me, but I've only seen it from SOGo, and it's - not in accordance with the examples in rfc6578. - """ - if ( - " 200 " not in status - and " 201 " not in status - and " 207 " not in status - and " 404 " not in status - ): - raise error.ResponseError(status) - - def _parse_response(self, response) -> Tuple[str, List[_Element], Optional[Any]]: - """ - One response should contain one or zero status children, one - href tag and zero or more propstats. Find them, assert there - isn't more in the response and return those three fields - """ - status = None - href: Optional[str] = None - propstats: List[_Element] = [] - check_404 = False ## special for purelymail - error.assert_(response.tag == dav.Response.tag) - for elem in response: - if elem.tag == dav.Status.tag: - error.assert_(not status) - status = elem.text - error.assert_(status) - self.validate_status(status) - elif elem.tag == dav.Href.tag: - assert not href - # Fix for https://github.com/python-caldav/caldav/issues/471 - # Confluence server quotes the user email twice. We unquote it manually. - if "%2540" in elem.text: - elem.text = elem.text.replace("%2540", "%40") - href = unquote(elem.text) - elif elem.tag == dav.PropStat.tag: - propstats.append(elem) - elif elem.tag == "{DAV:}error": - ## This happens with purelymail on a 404. - ## This code is mostly moot, but in debug - ## mode I want to be sure we do not toss away any data - children = elem.getchildren() - error.assert_(len(children) == 1) - error.assert_( - children[0].tag == "{https://purelymail.com}does-not-exist" - ) - check_404 = True - else: - ## i.e. purelymail may contain one more tag, ... - ## This is probably not a breach of the standard. It may - ## probably be ignored. But it's something we may want to - ## know. - error.weirdness("unexpected element found in response", elem) - error.assert_(href) - if check_404: - error.assert_("404" in status) - ## TODO: is this safe/sane? - ## Ref https://github.com/python-caldav/caldav/issues/435 the paths returned may be absolute URLs, - ## but the caller expects them to be paths. Could we have issues when a server has same path - ## but different URLs for different elements? Perhaps href should always be made into an URL-object? - if ":" in href: - href = unquote(URL(href).path) - return (cast(str, href), propstats, status) - - def find_objects_and_props(self) -> Dict[str, Dict[str, _Element]]: - """Check the response from the server, check that it is on an expected format, - find hrefs and props from it and check statuses delivered. - - The parsed data will be put into self.objects, a dict {href: - {proptag: prop_element}}. Further parsing of the prop_element - has to be done by the caller. - - self.sync_token will be populated if found, self.objects will be populated. - """ - self.objects: Dict[str, Dict[str, _Element]] = {} - self.statuses: Dict[str, str] = {} - - if "Schedule-Tag" in self.headers: - self.schedule_tag = self.headers["Schedule-Tag"] - - responses = self._strip_to_multistatus() - for r in responses: - if r.tag == dav.SyncToken.tag: - self.sync_token = r.text - continue - error.assert_(r.tag == dav.Response.tag) - - (href, propstats, status) = self._parse_response(r) - ## I would like to do this assert here ... - # error.assert_(not href in self.objects) - ## but then there was https://github.com/python-caldav/caldav/issues/136 - if href not in self.objects: - self.objects[href] = {} - self.statuses[href] = status - - ## The properties may be delivered either in one - ## propstat with multiple props or in multiple - ## propstat - for propstat in propstats: - cnt = 0 - status = propstat.find(dav.Status.tag) - error.assert_(status is not None) - if status is not None and status.text is not None: - error.assert_(len(status) == 0) - cnt += 1 - self.validate_status(status.text) - ## if a prop was not found, ignore it - if " 404 " in status.text: - continue - for prop in propstat.iterfind(dav.Prop.tag): - cnt += 1 - for theprop in prop: - self.objects[href][theprop.tag] = theprop - - ## there shouldn't be any more elements except for status and prop - error.assert_(cnt == len(propstat)) - - return self.objects - - def _expand_simple_prop( - self, proptag, props_found, multi_value_allowed=False, xpath=None - ): - values = [] - if proptag in props_found: - prop_xml = props_found[proptag] - for item in prop_xml.items(): - if proptag == "{urn:ietf:params:xml:ns:caldav}calendar-data": - if ( - item[0].lower().endswith("content-type") - and item[1].lower() == "text/calendar" - ): - continue - if item[0].lower().endswith("version") and item[1] in ("2", "2.0"): - continue - log.error( - f"If you see this, please add a report at https://github.com/python-caldav/caldav/issues/209 - in _expand_simple_prop, dealing with {proptag}, extra item found: {'='.join(item)}." - ) - if not xpath and len(prop_xml) == 0: - if prop_xml.text: - values.append(prop_xml.text) - else: - _xpath = xpath if xpath else ".//*" - leafs = prop_xml.findall(_xpath) - values = [] - for leaf in leafs: - error.assert_(not leaf.items()) - if leaf.text: - values.append(leaf.text) - else: - values.append(leaf.tag) - if multi_value_allowed: - return values - else: - if not values: - return None - error.assert_(len(values) == 1) - return values[0] - - ## TODO: word "expand" does not feel quite right. - def expand_simple_props( self, - props: Iterable[BaseElement] = None, - multi_value_props: Iterable[Any] = None, - xpath: Optional[str] = None, - ) -> Dict[str, Dict[str, str]]: - """ - The find_objects_and_props() will stop at the xml element - below the prop tag. This method will expand those props into - text. - - Executes find_objects_and_props if not run already, then - modifies and returns self.objects. - """ - props = props or [] - multi_value_props = multi_value_props or [] - - if not hasattr(self, "objects"): - self.find_objects_and_props() - for href in self.objects: - props_found = self.objects[href] - for prop in props: - if prop.tag is None: - continue - - props_found[prop.tag] = self._expand_simple_prop( - prop.tag, props_found, xpath=xpath - ) - for prop in multi_value_props: - if prop.tag is None: - continue + response: Response, + davclient: Optional["DAVClient"] = None, + ) -> None: + self._init_from_response(response, davclient) - props_found[prop.tag] = self._expand_simple_prop( - prop.tag, props_found, xpath=xpath, multi_value_allowed=True - ) - # _Element objects in self.objects are parsed to str, thus the need to cast the return - return cast(Dict[str, Dict[str, str]], self.objects) + # Response parsing methods are inherited from BaseDAVResponse -class DAVClient: +class DAVClient(BaseDAVClient): """ Basic client for webdav, uses the niquests lib; gives access to low-level operations towards the caldav server. @@ -689,7 +353,7 @@ def __enter__(self) -> Self: if hasattr(self, "setup"): try: self.setup() - except: + except TypeError: self.setup(self) return self @@ -709,13 +373,27 @@ def __exit__( def close(self) -> None: """ - Closes the DAVClient's session object + Closes the DAVClient's session object. """ self.session.close() - def principals(self, name=None): + def search_principals(self, name=None): """ - Instead of returning the current logged-in principal, it attempts to query for all principals. This may or may not work dependent on the permissions and implementation of the calendar server. + Search for principals on the server. + + Instead of returning the current logged-in principal, this method + attempts to query for all principals (or principals matching a name). + This may or may not work depending on the permissions and + implementation of the calendar server. + + Args: + name: Optional name filter to search for specific principals + + Returns: + List of Principal objects found on the server + + Raises: + ReportError: If the server doesn't support principal search """ if name: name_filter = [ @@ -741,11 +419,11 @@ def principals(self, name=None): f"{response.status} {response.reason} - {response.raw}" ) - principal_dict = response.find_objects_and_props() + principal_dict = response._find_objects_and_props() ret = [] for x in principal_dict: p = principal_dict[x] - if not dav.DisplayName.tag in p: + if dav.DisplayName.tag not in p: continue name = p[dav.DisplayName.tag].text error.assert_(not p[dav.DisplayName.tag].getchildren()) @@ -766,8 +444,23 @@ def principals(self, name=None): ) return ret + def principals(self, name=None): + """ + Deprecated. Use :meth:`search_principals` instead. + + This method searches for principals on the server. + """ + warnings.warn( + "principals() is deprecated, use search_principals() instead", + DeprecationWarning, + stacklevel=2, + ) + return self.search_principals(name=name) + def principal(self, *largs, **kwargs): """ + Legacy method. Use :meth:`get_principal` for new code. + Convenience method, it gives a bit more object-oriented feel to write client.principal() than Principal(client). @@ -788,13 +481,203 @@ def calendar(self, **kwargs): If you don't know the URL of the calendar, use client.principal().calendar(...) instead, or - client.principal().calendars() + client.principal().get_calendars() """ return Calendar(client=self, **kwargs) + # ==================== High-Level Methods ==================== + # These methods mirror the async API for consistency. + + def get_principal(self) -> Principal: + """Get the principal (user) for this CalDAV connection. + + This is the recommended method for new code. It provides API + consistency between sync and async clients. + + Returns: + Principal object for the authenticated user. + + Example:: + + principal = client.get_principal() + calendars = principal.get_calendars() + """ + return self.principal() + + def get_calendars(self, principal: Optional[Principal] = None) -> List[Calendar]: + """Get all calendars for the given principal. + + This method fetches calendars from the principal's calendar-home-set + and returns a list of Calendar objects. + + Args: + principal: Principal object (if None, fetches principal first) + + Returns: + List of Calendar objects. + + Example: + principal = client.get_principal() + calendars = client.get_calendars(principal) + for cal in calendars: + print(f"Calendar: {cal.name}") + """ + from caldav.operations.calendarset_ops import ( + _extract_calendars_from_propfind_results as extract_calendars, + ) + + if principal is None: + principal = self.principal() + + # Get calendar-home-set from principal + calendar_home_url = self._get_calendar_home_set(principal) + if not calendar_home_url: + return [] + + # Make URL absolute if relative + calendar_home_url = self._make_absolute_url(calendar_home_url) + + # Fetch calendars via PROPFIND + response = self.propfind( + calendar_home_url, + props=self.CALENDAR_LIST_PROPS, + depth=1, + ) + + # Process results using shared helper + calendar_infos = extract_calendars(response.results) + + # Convert CalendarInfo objects to Calendar objects + return [ + Calendar(client=self, url=info.url, name=info.name, id=info.cal_id) + for info in calendar_infos + ] + + def _get_calendar_home_set(self, principal: Principal) -> Optional[str]: + """Get the calendar-home-set URL for a principal. + + Args: + principal: Principal object + + Returns: + Calendar home set URL or None + """ + from caldav.operations.principal_ops import ( + _extract_calendar_home_set_from_results as extract_home_set, + ) + + # Try to get from principal properties + response = self.propfind( + str(principal.url), + props=self.CALENDAR_HOME_SET_PROPS, + depth=0, + ) + + return extract_home_set(response.results) + + def get_events( + self, + calendar: Calendar, + start: Optional[Any] = None, + end: Optional[Any] = None, + ) -> List["Event"]: + """Get events from a calendar. + + This is a convenience method that searches for VEVENT objects in the + calendar, optionally filtered by date range. + + Args: + calendar: Calendar to search + start: Start of date range (optional) + end: End of date range (optional) + + Returns: + List of Event objects. + + Example: + from datetime import datetime + events = client.get_events( + calendar, + start=datetime(2024, 1, 1), + end=datetime(2024, 12, 31) + ) + """ + return self.search_calendar(calendar, event=True, start=start, end=end) + + def get_todos( + self, + calendar: Calendar, + include_completed: bool = False, + ) -> List["Todo"]: + """Get todos from a calendar. + + Args: + calendar: Calendar to search + include_completed: Whether to include completed todos + + Returns: + List of Todo objects. + """ + return self.search_calendar( + calendar, todo=True, include_completed=include_completed + ) + + def search_calendar( + self, + calendar: Calendar, + event: bool = False, + todo: bool = False, + journal: bool = False, + start: Optional[Any] = None, + end: Optional[Any] = None, + include_completed: Optional[bool] = None, + expand: bool = False, + **kwargs: Any, + ) -> List["CalendarObjectResource"]: + """Search a calendar for events, todos, or journals. + + This method provides a clean interface to calendar search. + + Args: + calendar: Calendar to search + event: Search for events (VEVENT) + todo: Search for todos (VTODO) + journal: Search for journals (VJOURNAL) + start: Start of date range + end: End of date range + include_completed: Include completed todos (default: False for todos) + expand: Expand recurring events + **kwargs: Additional search parameters + + Returns: + List of Event/Todo/Journal objects. + + Example: + # Get all events in January 2024 + events = client.search_calendar( + calendar, + event=True, + start=datetime(2024, 1, 1), + end=datetime(2024, 1, 31), + ) + """ + return calendar.search( + event=event, + todo=todo, + journal=journal, + start=start, + end=end, + include_completed=include_completed, + expand=expand, + **kwargs, + ) + def check_dav_support(self) -> Optional[str]: """ - Does a probe towards the server and returns True if it says it supports RFC4918 / DAV + Legacy method. Use :meth:`supports_dav` for new code. + + Does a probe towards the server and returns the DAV header if it + says it supports RFC4918 / DAV, or None otherwise. """ try: ## SOGo does not return the full capability list on the caldav @@ -809,20 +692,80 @@ def check_dav_support(self) -> Optional[str]: def check_cdav_support(self) -> bool: """ - Does a probe towards the server and returns True if it says it supports RFC4791 / CalDAV + Legacy method. Use :meth:`supports_caldav` for new code. + + Does a probe towards the server and returns True if it says it + supports RFC4791 / CalDAV. """ support_list = self.check_dav_support() return support_list is not None and "calendar-access" in support_list def check_scheduling_support(self) -> bool: """ - Does a probe towards the server and returns True if it says it supports RFC6833 / CalDAV Scheduling + Legacy method. Use :meth:`supports_scheduling` for new code. + + Does a probe towards the server and returns True if it says it + supports RFC6638 / CalDAV Scheduling. """ support_list = self.check_dav_support() return support_list is not None and "calendar-auto-schedule" in support_list + # Recommended methods for capability checks (API consistency with AsyncDAVClient) + + def supports_dav(self) -> Optional[str]: + """Check if the server supports WebDAV (RFC4918). + + This is the recommended method for new code. It provides API + consistency between sync and async clients. + + Returns: + The DAV header value if supported, None otherwise. + + Example:: + + if client.supports_dav(): + print("Server supports WebDAV") + """ + return self.check_dav_support() + + def supports_caldav(self) -> bool: + """Check if the server supports CalDAV (RFC4791). + + This is the recommended method for new code. It provides API + consistency between sync and async clients. + + Returns: + True if the server supports CalDAV, False otherwise. + + Example:: + + if client.supports_caldav(): + calendars = client.get_calendars() + """ + return self.check_cdav_support() + + def supports_scheduling(self) -> bool: + """Check if the server supports CalDAV Scheduling (RFC6638). + + This is the recommended method for new code. It provides API + consistency between sync and async clients. + + Returns: + True if the server supports CalDAV Scheduling, False otherwise. + + Example:: + + if client.supports_scheduling(): + # Server supports free-busy lookups and scheduling + pass + """ + return self.check_scheduling_support() + def propfind( - self, url: Optional[str] = None, props: str = "", depth: int = 0 + self, + url: Optional[str] = None, + props=None, + depth: int = 0, ) -> DAVResponse: """ Send a propfind request. @@ -831,8 +774,8 @@ def propfind( ---------- url : URL url for the root of the propfind. - props : xml - properties we want + props : str or List[str] + XML body string (old interface) or list of property names (new interface). depth : int maximum recursion depth @@ -840,9 +783,33 @@ def propfind( ------- DAVResponse """ - return self.request( - url or str(self.url), "PROPFIND", props, {"Depth": str(depth)} - ) + from caldav.protocol.xml_builders import _build_propfind_body + + # Handle both old interface (props=xml_string) and new interface (props=list) + body = "" + if props is not None: + if isinstance(props, list): + body = _build_propfind_body(props).decode("utf-8") + else: + body = props # Old interface: props is XML string + + # Use sync path with protocol layer parsing + headers = {"Depth": str(depth)} + response = self.request(url or str(self.url), "PROPFIND", body, headers) + + # Parse response using protocol layer + if response.status in (200, 207) and response._raw: + from caldav.protocol.xml_parsers import _parse_propfind_response + + raw_bytes = ( + response._raw + if isinstance(response._raw, bytes) + else response._raw.encode("utf-8") + ) + response.results = _parse_propfind_response( + raw_bytes, response.status, response.huge_tree + ) + return response def proppatch(self, url: str, body: str, dummy: None = None) -> DAVResponse: """ @@ -858,24 +825,23 @@ def proppatch(self, url: str, body: str, dummy: None = None) -> DAVResponse: """ return self.request(url, "PROPPATCH", body) - def report(self, url: str, query: str = "", depth: int = 0) -> DAVResponse: + def report( + self, url: str, query: str = "", depth: Optional[int] = 0 + ) -> DAVResponse: """ Send a report request. Args: url: url for the root of the propfind. query: XML request - depth: maximum recursion depth + depth: maximum recursion depth. None means don't send Depth header + (required for calendar-multiget per RFC 4791 section 7.9). Returns DAVResponse """ - return self.request( - url, - "REPORT", - query, - {"Depth": str(depth), "Content-Type": 'application/xml; charset="utf-8"'}, - ) + headers = {"Depth": str(depth)} if depth is not None else {} + return self.request(url, "REPORT", query, headers) def mkcol(self, url: str, body: str, dummy: None = None) -> DAVResponse: """ @@ -920,7 +886,7 @@ def put( """ Send a put request. """ - return self.request(url, "PUT", body, headers or {}) + return self.request(url, "PUT", body, headers) def post( self, url: str, body: str, headers: Mapping[str, str] = None @@ -928,65 +894,44 @@ def post( """ Send a POST request. """ - return self.request(url, "POST", body, headers or {}) + return self.request(url, "POST", body, headers) def delete(self, url: str) -> DAVResponse: """ Send a delete request. """ - return self.request(url, "DELETE") + return self.request(url, "DELETE", "") def options(self, url: str) -> DAVResponse: """ Send an options request. """ - return self.request(url, "OPTIONS") + return self.request(url, "OPTIONS", "") - def extract_auth_types(self, header: str): - """This is probably meant for internal usage. It takes the - headers it got from the server and figures out what - authentication types the server supports - """ - # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate#syntax - return {h.split()[0] for h in header.lower().split(",")} + def build_auth_object(self, auth_types: Optional[List[str]] = None) -> None: + """Build authentication object for the requests/niquests library. - def build_auth_object(self, auth_types: Optional[List[str]] = None): - """Fixes self.auth. If ``self.auth_type`` is given, then - insist on using this one. If not, then assume auth_types to - be a list of acceptable auth types and choose the most - appropriate one (prefer digest or basic if username is given, - and bearer if password is given). + Uses shared auth type selection logic from BaseDAVClient, then + creates the appropriate auth object for this HTTP library. Args: - auth_types - A list/tuple of acceptable auth_types + auth_types: List of acceptable auth types from server. """ - auth_type = self.auth_type - if not auth_type and not auth_types: - raise error.AuthorizationError( - "No auth-type given. This shouldn't happen. Raise an issue at https://github.com/python-caldav/caldav/issues/ or by email noauthtype@plann.no" - ) - if auth_types and auth_type and auth_type not in auth_types: - raise error.AuthorizationError( - reason=f"Configuration specifies to use {auth_type}, but server only accepts {auth_types}" - ) - if not auth_type and auth_types: - if self.username and "digest" in auth_types: - auth_type = "digest" - elif self.username and "basic" in auth_types: - auth_type = "basic" - elif self.password and "bearer" in auth_types: - auth_type = "bearer" - elif "bearer" in auth_types: - raise error.AuthorizationError( - reason="Server provides bearer auth, but no password given. The bearer token should be configured as password" - ) + # Use shared selection logic + auth_type = self._select_auth_type(auth_types) + + # Decode password if it's bytes (HTTPDigestAuth needs string) + password = self.password + if isinstance(password, bytes): + password = password.decode("utf-8") + # Create auth object for requests/niquests if auth_type == "digest": - self.auth = requests.auth.HTTPDigestAuth(self.username, self.password) + self.auth = requests.auth.HTTPDigestAuth(self.username, password) elif auth_type == "basic": - self.auth = requests.auth.HTTPBasicAuth(self.username, self.password) + self.auth = requests.auth.HTTPBasicAuth(self.username, password) elif auth_type == "bearer": - self.auth = HTTPBearerAuth(self.password) + self.auth = HTTPBearerAuth(password) def request( self, @@ -996,7 +941,30 @@ def request( headers: Mapping[str, str] = None, ) -> DAVResponse: """ - Actually sends the request, and does the authentication + Send a generic HTTP request. + + Uses the sync session directly for all operations. + + Args: + url: The URL to request + method: HTTP method (GET, PUT, DELETE, etc.) + body: Request body + headers: Optional headers dict + + Returns: + DAVResponse + """ + return self._sync_request(url, method, body, headers) + + def _sync_request( + self, + url: str, + method: str = "GET", + body: str = "", + headers: Mapping[str, str] = None, + ) -> DAVResponse: + """ + Sync HTTP request implementation with auth negotiation. """ headers = headers or {} @@ -1014,74 +982,30 @@ def request( log.debug("using proxy - %s" % (proxies)) log.debug( - "sending request - method={0}, url={1}, headers={2}\nbody:\n{3}".format( - method, str(url_obj), combined_headers, to_normal_str(body) - ) + f"sending request - method={method}, url={str(url_obj)}, headers={combined_headers}\nbody:\n{to_normal_str(body)}" ) - try: - r = self.session.request( - method, - str(url_obj), - data=to_wire(body), - headers=combined_headers, - proxies=proxies, - auth=self.auth, - timeout=self.timeout, - verify=self.ssl_verify_cert, - cert=self.ssl_cert, - ) - log.debug("server responded with %i %s" % (r.status_code, r.reason)) - if ( - r.status_code == 401 - and "text/html" in self.headers.get("Content-Type", "") - and not self.auth - ): - # The server can return HTML on 401 sometimes (ie. it's behind a proxy) - # The user can avoid logging errors by setting the authentication type by themselves. - msg = ( - "No authentication object was provided. " - "HTML was returned when probing the server for supported authentication types. " - "To avoid logging errors, consider passing the auth_type connection parameter" - ) - if r.headers.get("WWW-Authenticate"): - auth_types = [ - t - for t in self.extract_auth_types(r.headers["WWW-Authenticate"]) - if t in ["basic", "digest", "bearer"] - ] - if auth_types: - msg += "\nSupported authentication types: %s" % ( - ", ".join(auth_types) - ) - log.warning(msg) - response = DAVResponse(r, self) - except: - ## this is a workaround needed due to some weird server - ## that would just abort the connection rather than send a - ## 401 when an unauthenticated request with a body was - ## sent to the server - ref https://github.com/python-caldav/caldav/issues/158 - if self.auth or not self.password: - raise - r = self.session.request( - method="GET", - url=str(url_obj), - headers=combined_headers, - proxies=proxies, - timeout=self.timeout, - verify=self.ssl_verify_cert, - cert=self.ssl_cert, - ) - if not r.status_code == 401: - raise + r = self.session.request( + method, + str(url_obj), + data=to_wire(body), + headers=combined_headers, + proxies=proxies, + auth=self.auth, + timeout=self.timeout, + verify=self.ssl_verify_cert, + cert=self.ssl_cert, + ) - ## Returned headers + # Handle 401 responses for auth negotiation r_headers = CaseInsensitiveDict(r.headers) if ( r.status_code == 401 and "WWW-Authenticate" in r_headers and not self.auth - and (self.username or self.password) + and self.username is not None + and self.password + is not None # Empty password OK, but None means not configured ): auth_types = self.extract_auth_types(r_headers["WWW-Authenticate"]) self.build_auth_object(auth_types) @@ -1092,220 +1016,88 @@ def request( "supported authentication methods: basic, digest, bearer" ) - return self.request(url, method, body, headers) - - elif ( - r.status_code == 401 - and "WWW-Authenticate" in r_headers - and self.auth - and self.password - and isinstance(self.password, bytes) - ): - ## TODO: this has become a mess and should be refactored. - ## (Arguably, this logic doesn't belong here at all. - ## with niquests it's possible to just pass the username - ## and password, maybe we should try that?) - - ## Most likely we're here due to wrong username/password - ## combo, but it could also be a multiplexing problem. - if ( - self.features.is_supported("http.multiplexing", return_defaults=False) - is None - ): - self.session = requests.Session() - self.features.set_feature("http.multiplexing", "unknown") - ## If this one also fails, we give up - ret = self.request(str(url_obj), method, body, headers) - self.features.set_feature("http.multiplexing", False) - return ret - - ## Most likely we're here due to wrong username/password - ## combo, but it could also be charset problems. Some - ## (ancient) servers don't like UTF-8 binary auth with - ## Digest authentication. An example are old SabreDAV - ## based servers. Not sure about UTF-8 and Basic Auth, - ## but likely the same. so retry if password is a bytes - ## sequence and not a string (see commit 13a4714, which - ## introduced this regression) + # Retry request with authentication + return self._sync_request(url, method, body, headers) - auth_types = self.extract_auth_types(r_headers["WWW-Authenticate"]) - self.password = self.password.decode() - self.build_auth_object(auth_types) - - self.username = None - self.password = None - - return self.request(str(url_obj), method, body, headers) - - if error.debug_dump_communication: - import datetime - from tempfile import NamedTemporaryFile - - with NamedTemporaryFile(prefix="caldavcomm", delete=False) as commlog: - commlog.write(b"=" * 80 + b"\n") - commlog.write(f"{datetime.datetime.now():%FT%H:%M:%S}".encode("utf-8")) - commlog.write(b"\n====>\n") - commlog.write(f"{method} {url}\n".encode("utf-8")) - commlog.write( - b"\n".join(to_wire(f"{x}: {headers[x]}") for x in headers) - ) - commlog.write(b"\n\n") - commlog.write(to_wire(body)) - commlog.write(b"<====\n") - commlog.write(f"{response.status} {response.reason}".encode("utf-8")) - commlog.write( - b"\n".join( - to_wire(f"{x}: {response.headers[x]}") for x in response.headers - ) - ) - commlog.write(b"\n\n") - ct = response.headers.get("Content-Type", "") - if response.tree is not None: - commlog.write( - to_wire(etree.tostring(response.tree, pretty_print=True)) - ) - else: - commlog.write(to_wire(response._raw)) - commlog.write(b"\n") - - # this is an error condition that should be raised to the application - if ( - response.status == requests.codes.forbidden - or response.status == requests.codes.unauthorized - ): + # Raise AuthorizationError for 401/403 after auth attempt + if r.status_code in (401, 403): try: - reason = response.reason + reason = r.reason except AttributeError: reason = "None given" raise error.AuthorizationError(url=str(url_obj), reason=reason) + response = DAVResponse(r, self) return response -def auto_calendars( - config_file: str = None, - config_section: str = "default", - testconfig: bool = False, - environment: bool = True, - config_data: dict = None, - config_name: str = None, -) -> Iterable["Calendar"]: +def get_calendars(**kwargs) -> List["Calendar"]: """ - This will replace plann.lib.findcalendars() - """ - raise NotImplementedError("auto_calendars not implemented yet") + Get calendars from a CalDAV server with configuration from multiple sources. + This is a convenience wrapper around :func:`caldav.base_client.get_calendars` + that uses DAVClient. -def auto_calendar(*largs, **kwargs) -> Iterable["Calendar"]: + Args: + calendar_url: URL(s) or ID(s) of specific calendars to fetch. + calendar_name: Name(s) of specific calendars to fetch by display name. + check_config_file: Whether to look for config files (default: True). + config_file: Explicit path to config file. + config_section: Section name in config file. + testconfig: Whether to use test server configuration. + environment: Whether to read from environment variables (default: True). + name: Name of test server to use (for testconfig). + raise_errors: If True, raise exceptions on errors; if False, log and skip. + **config_data: Connection parameters (url, username, password, etc.) + + Returns: + List of Calendar objects matching the criteria. + + Example:: + + from caldav import get_calendars + + # Get all calendars + calendars = get_calendars(url="https://...", username="...", password="...") + + # Get specific calendar by name + calendars = get_calendars(calendar_name="Work", url="...", ...) """ - Alternative to auto_calendars - in most use cases, one calendar suffices + return _base_get_calendars(DAVClient, **kwargs) + + +def get_calendar(**kwargs) -> Optional["Calendar"]: """ - return next(auto_calendars(*largs, **kwargs), None) + Get a single calendar from a CalDAV server. + This is a convenience function for the common case where only one + calendar is needed. It returns the first matching calendar or None. -def auto_conn(*largs, config_data: dict = None, **kwargs): - """A quite stubbed verison of get_davclient was included in the - v1.5-release as auto_conn, but renamed a few days later. Probably - nobody except my caldav tester project uses auto_conn, but as a - thumb of rule anything released should stay "deprecated" for at - least one major release before being removed. + Args: + Same as :func:`get_calendars`. - TODO: remove in version 3.0 - """ - warnings.warn( - "auto_conn was renamed get_davclient", - DeprecationWarning, - stacklevel=2, - ) - if config_data: - kwargs.update(config_data) - return get_davclient(*largs, **kwargs) - - -def get_davclient( - check_config_file: bool = True, - config_file: str = None, - config_section: str = None, - testconfig: bool = False, - environment: bool = True, - name: str = None, - **config_data, -) -> "DAVClient": + Returns: + A single Calendar object, or None if no calendars found. + + Example:: + + from caldav import get_calendar + + calendar = get_calendar(calendar_name="Work", url="...", ...) + if calendar: + events = calendar.get_events() """ - This function will yield a DAVClient object. It will not try to - connect (see auto_calendars for that). It will read configuration - from various sources, dependent on the parameters given, in this - order: - - * Data from the parameters given - * Environment variables prepended with `CALDAV_`, like `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`. - * Environment variables `PYTHON_CALDAV_USE_TEST_SERVER` and `CALDAV_CONFIG_FILE` will be honored if environment is set - * Data from `./tests/conf.py` or `./conf.py` (this includes the possibility to spin up a test server) - * Configuration file. Documented in the plann project as for now. (TODO - move it) + calendars = _base_get_calendars(DAVClient, **kwargs) + return calendars[0] if calendars else None + + +def get_davclient(**kwargs) -> Optional["DAVClient"]: """ - if config_data: - return DAVClient(**config_data) + Get a DAVClient instance with configuration from multiple sources. - if testconfig or (environment and os.environ.get("PYTHON_CALDAV_USE_TEST_SERVER")): - sys.path.insert(0, "tests") - sys.path.insert(1, ".") - ## TODO: move the code from client into here - try: - from conf import client + See :func:`caldav.base_client.get_davclient` for full documentation. - idx = os.environ.get("PYTHON_CALDAV_TEST_SERVER_IDX") - try: - idx = int(idx) - except (ValueError, TypeError): - idx = None - name = name or os.environ.get("PYTHON_CALDAV_TEST_SERVER_NAME") - if name and not idx: - try: - idx = int(name) - name = None - except ValueError: - pass - conn = client(idx, name) - if conn: - return conn - except ImportError: - pass - finally: - sys.path = sys.path[2:] - - if environment: - conf = {} - for conf_key in ( - x - for x in os.environ - if x.startswith("CALDAV_") and not x.startswith("CALDAV_CONFIG") - ): - conf[conf_key[7:].lower()] = os.environ[conf_key] - if conf: - return DAVClient(**conf) - if not config_file: - config_file = os.environ.get("CALDAV_CONFIG_FILE") - if not config_section: - config_section = os.environ.get("CALDAV_CONFIG_SECTION") - - if check_config_file: - ## late import in 2.0, as the config stuff isn't properly tested - from . import config - - if not config_section: - config_section = "default" - - cfg = config.read_config(config_file) - if cfg: - section = config.config_section(cfg, config_section) - conn_params = {} - for k in section: - if k.startswith("caldav_") and section[k]: - key = k[7:] - if key == "pass": - key = "password" - if key == "user": - key = "username" - conn_params[key] = section[k] - if conn_params: - return DAVClient(**conn_params) + Returns: + DAVClient instance, or None if no configuration is found. + """ + return _base_get_davclient(DAVClient, **kwargs) diff --git a/caldav/davobject.py b/caldav/davobject.py index efa07d7c..f132ac68 100644 --- a/caldav/davobject.py +++ b/caldav/davobject.py @@ -26,14 +26,8 @@ from .davclient import DAVClient -if sys.version_info < (3, 9): - from typing import Callable, Container, Iterable, Iterator, Sequence - - from typing_extensions import DefaultDict, Literal -else: - from collections import defaultdict as DefaultDict - from collections.abc import Callable, Container, Iterable, Iterator, Sequence - from typing import Literal +from collections.abc import Callable, Container, Iterable, Iterator, Sequence +from typing import DefaultDict, Literal if sys.version_info < (3, 11): from typing_extensions import Self @@ -79,7 +73,6 @@ def __init__( client: Optional["DAVClient"] = None, url: Union[str, ParseResult, SplitResult, URL, None] = None, parent: Optional["DAVObject"] = None, - name: Optional[str] = None, id: Optional[str] = None, props=None, **extra, @@ -91,16 +84,14 @@ def __init__( client: A DAVClient instance url: The url for this object. May be a full URL or a relative URL. parent: The parent object - used when creating objects - name: A displayname - to be removed at some point, see https://github.com/python-caldav/caldav/issues/128 for details - props: a dict with known properties for this object id: The resource id (UID for an Event) + props: a dict with known properties for this object """ if client is None and parent is not None: client = parent.client self.client = client self.parent = parent - self.name = name self.id = id self.props = props or {} self.extra_init_options = extra @@ -118,6 +109,18 @@ def canonical_url(self) -> str: raise ValueError("Unexpected value None for self.url") return str(self.url.canonical()) + @property + def is_async_client(self) -> bool: + """Check if this object is connected to an async client. + + Returns: + True if the client is an AsyncDAVClient, False otherwise. + """ + if self.client is None: + return False + # Use string check to avoid circular imports + return type(self.client).__name__ == "AsyncDAVClient" + def children(self, type: Optional[str] = None) -> List[Tuple[URL, Any, Any]]: """List children, using a propfind (resourcetype) on the parent object, at depth = 1. @@ -181,7 +184,12 @@ def _query_properties( This is an internal method for doing a propfind query. It's a result of code-refactoring work, attempting to consolidate similar-looking code into a common method. + + For async clients, returns a coroutine that must be awaited. """ + if self.is_async_client: + return self._async_query_properties(props, depth) + root = None # build the propfind request if props is not None and len(props) > 0: @@ -190,6 +198,18 @@ def _query_properties( return self._query(root, depth) + async def _async_query_properties( + self, props: Optional[Sequence[BaseElement]] = None, depth: int = 0 + ): + """Async implementation of _query_properties.""" + root = None + # build the propfind request + if props is not None and len(props) > 0: + prop = dav.Prop() + props + root = dav.Propfind() + prop + + return await self._async_query(root, depth) + def _query( self, root=None, @@ -202,7 +222,14 @@ def _query( This is an internal method for doing a query. It's a result of code-refactoring work, attempting to consolidate similar-looking code into a common method. + + For async clients, returns a coroutine that must be awaited. """ + if self.is_async_client: + return self._async_query( + root, depth, query_method, url, expected_return_value + ) + body = "" if root: if hasattr(root, "xmlelement"): @@ -239,6 +266,50 @@ def _query( raise error.exception_by_method[query_method](errmsg(ret)) return ret + async def _async_query( + self, + root=None, + depth=0, + query_method="propfind", + url=None, + expected_return_value=None, + ): + """Async implementation of _query.""" + body = "" + if root: + if hasattr(root, "xmlelement"): + body = etree.tostring( + root.xmlelement(), + encoding="utf-8", + xml_declaration=True, + pretty_print=error.debug_dump_communication, + ) + else: + body = root + if url is None: + url = self.url + ret = await getattr(self.client, query_method)(url, body, depth) + if ret.status == 404: + raise error.NotFoundError(errmsg(ret)) + if ( + expected_return_value is not None and ret.status != expected_return_value + ) or ret.status >= 400: + ## COMPATIBILITY HACK - see https://github.com/python-caldav/caldav/issues/309 + body = to_wire(body) + if ( + ret.status == 500 + and b"D:getetag" not in body + and b" Optional[str]: @@ -251,7 +322,12 @@ def get_property( use_cached: don't send anything to the server if we've asked before Other parameters are sent directly to the :class:`get_properties` method + + For async clients, returns a coroutine that must be awaited. """ + if self.is_async_client: + return self._async_get_property(prop, use_cached, **passthrough) + ## TODO: use_cached should probably be true if use_cached: if prop.tag in self.props: @@ -259,6 +335,16 @@ def get_property( foo = self.get_properties([prop], **passthrough) return foo.get(prop.tag, None) + async def _async_get_property( + self, prop: BaseElement, use_cached: bool = False, **passthrough + ) -> Optional[str]: + """Async implementation of get_property.""" + if use_cached: + if prop.tag in self.props: + return self.props[prop.tag] + foo = await self._async_get_properties([prop], **passthrough) + return foo.get(prop.tag, None) + def get_properties( self, props: Optional[Sequence[BaseElement]] = None, @@ -283,17 +369,41 @@ def get_properties( Returns: ``{proptag: value, ...}`` + For async clients, returns a coroutine that must be awaited. """ - from .collection import Principal ## late import to avoid cyclic dependencies + if self.is_async_client: + return self._async_get_properties( + props, depth, parse_response_xml, parse_props + ) + + from .collection import ( + Principal, + ) ## late import to avoid cyclic dependencies rc = None response = self._query_properties(props, depth) if not parse_response_xml: return response - if not parse_props: - properties = response.find_objects_and_props() + # Use protocol layer results when available and parse_props=True + if parse_props and response.results: + # Convert results to the expected {href: {tag: value}} format + properties = {} + for result in response.results: + # Start with None for all requested props (for backward compat) + result_props = {} + if props: + for prop in props: + if prop.tag: + result_props[prop.tag] = None + # Then overlay with actual values from server + result_props.update(result.properties) + properties[result.href] = result_props + elif not parse_props: + # Caller wants raw XML elements - use internal method + properties = response._find_objects_and_props() else: + # Fallback to expand_simple_props for mocked responses properties = response.expand_simple_props(props) error.assert_(properties) @@ -311,58 +421,77 @@ def get_properties( rc = properties[path] elif exchange_path in properties: if not isinstance(self, Principal): - ## Some caldav servers reports the URL for the current - ## principal to end with / when doing a propfind for - ## current-user-principal - I believe that's a bug, - ## the principal is not a collection and should not - ## end with /. (example in rfc5397 does not end with /). - ## ... but it gets worse ... when doing a propfind on the - ## principal, the href returned may be without the slash. - ## Such inconsistency is clearly a bug. log.warning( - "potential path handling problem with ending slashes. Path given: %s, path found: %s. %s" - % (path, exchange_path, error.ERR_FRAGMENT) + f"The path {path} was not found in the properties, but {exchange_path} was. " + "This may indicate a server bug or a trailing slash issue." ) - error.assert_(False) rc = properties[exchange_path] - elif self.url in properties: - rc = properties[self.url] - elif "/principal/" in properties and path.endswith("/principal/"): - ## Workaround for a known iCloud bug. - ## The properties key is expected to be the same as the path. - ## path is on the format /123456/principal/ but properties key is /principal/ - ## tests apparently passed post bc589093a34f0ed0ef489ad5e9cba048750c9837 and 3ee4e42e2fa8f78b71e5ffd1ef322e4007df7a60, even without this workaround - ## TODO: should probably be investigated more. - ## (observed also by others, ref https://github.com/python-caldav/caldav/issues/168) - rc = properties["/principal/"] - elif "//" in path and path.replace("//", "/") in properties: - ## ref https://github.com/python-caldav/caldav/issues/302 - ## though, it would be nice to find the root cause, - ## self.url should not contain double slashes in the first place - rc = properties[path.replace("//", "/")] - elif len(properties) == 1: - ## Ref https://github.com/python-caldav/caldav/issues/191 ... - ## let's be pragmatic and just accept whatever the server is - ## throwing at us. But we'll log an error anyway. - log.warning( - "Possibly the server has a path handling problem, possibly the URL configured is wrong.\n" - "Path expected: %s, path found: %s %s.\n" - "Continuing, probably everything will be fine" - % (path, str(list(properties)), error.ERR_FRAGMENT) - ) - rc = list(properties.values())[0] else: - log.warning( - "Possibly the server has a path handling problem. Path expected: %s, paths found: %s %s" - % (path, str(list(properties)), error.ERR_FRAGMENT) - ) error.assert_(False) + self.props.update(rc) + return rc + + async def _async_get_properties( + self, + props: Optional[Sequence[BaseElement]] = None, + depth: int = 0, + parse_response_xml: bool = True, + parse_props: bool = True, + ): + """Async implementation of get_properties.""" + from .collection import ( + Principal, + ) ## late import to avoid cyclic dependencies + + rc = None + response = await self._async_query_properties(props, depth) + if not parse_response_xml: + return response - if parse_props: - if rc is None: - raise ValueError("Unexpected value None for rc") + # Use protocol layer results when available and parse_props=True + if parse_props and response.results: + # Convert results to the expected {href: {tag: value}} format + properties = {} + for result in response.results: + # Start with None for all requested props (for backward compat) + result_props = {} + if props: + for prop in props: + if prop.tag: + result_props[prop.tag] = None + # Then overlay with actual values from server + result_props.update(result.properties) + properties[result.href] = result_props + elif not parse_props: + # Caller wants raw XML elements - use internal method + properties = response._find_objects_and_props() + else: + # Fallback to expand_simple_props for mocked responses + properties = response.expand_simple_props(props) + + error.assert_(properties) + + if self.url is None: + raise ValueError("Unexpected value None for self.url") - self.props.update(rc) + path = unquote(self.url.path) + if path.endswith("/"): + exchange_path = path[:-1] + else: + exchange_path = path + "/" + + if path in properties: + rc = properties[path] + elif exchange_path in properties: + if not isinstance(self, Principal): + log.warning( + f"The path {path} was not found in the properties, but {exchange_path} was. " + "This may indicate a server bug or a trailing slash issue." + ) + rc = properties[exchange_path] + else: + error.assert_(False) + self.props.update(rc) return rc def set_properties(self, props: Optional[Any] = None) -> Self: @@ -371,20 +500,71 @@ def set_properties(self, props: Optional[Any] = None) -> Self: * props = [dav.DisplayName('name'), ...] + For async clients, returns a coroutine that must be awaited. + Returns: * self """ + if self.is_async_client: + return self._async_set_properties(props) + + props = [] if props is None else props + prop = dav.Prop() + props + set_elem = dav.Set() + prop + root = dav.PropertyUpdate() + set_elem + + body = "" + if root: + if hasattr(root, "xmlelement"): + body = etree.tostring( + root.xmlelement(), + encoding="utf-8", + xml_declaration=True, + pretty_print=error.debug_dump_communication, + ) + else: + body = root + + if self.url is None: + raise ValueError("Unexpected value None for self.url") + if self.client is None: + raise ValueError("Unexpected value None for self.client") + + r = self.client.proppatch(str(self.url), body) + + if r.status >= 400: + raise error.PropsetError(errmsg(r)) + + return self + + async def _async_set_properties(self, props: Optional[Any] = None) -> Self: + """Async implementation of set_properties.""" props = [] if props is None else props prop = dav.Prop() + props - set = dav.Set() + prop - root = dav.PropertyUpdate() + set + set_elem = dav.Set() + prop + root = dav.PropertyUpdate() + set_elem - r = self._query(root, query_method="proppatch") + body = "" + if root: + if hasattr(root, "xmlelement"): + body = etree.tostring( + root.xmlelement(), + encoding="utf-8", + xml_declaration=True, + pretty_print=error.debug_dump_communication, + ) + else: + body = root - statuses = r.tree.findall(".//" + dav.Status.tag) - for s in statuses: - if " 200 " not in s.text: - raise error.PropsetError(s.text) + if self.url is None: + raise ValueError("Unexpected value None for self.url") + if self.client is None: + raise ValueError("Unexpected value None for self.client") + + r = await self.client.proppatch(str(self.url), body) + + if r.status >= 400: + raise error.PropsetError(errmsg(r)) return self @@ -401,17 +581,37 @@ def save(self) -> Self: def delete(self) -> None: """ Delete the object. + + For sync clients, deletes and returns None. + For async clients, returns a coroutine that must be awaited. + + Example (sync): + obj.delete() + + Example (async): + await obj.delete() """ if self.url is not None: if self.client is None: raise ValueError("Unexpected value None for self.client") + # Delegate to client for dual-mode support + if self.is_async_client: + return self._async_delete() + r = self.client.delete(str(self.url)) # TODO: find out why we get 404 if r.status not in (200, 204, 404): raise error.DeleteError(errmsg(r)) + async def _async_delete(self) -> None: + """Async implementation of delete.""" + if self.url is not None and self.client is not None: + r = await self.client.delete(str(self.url)) + if r.status not in (200, 204, 404): + raise error.DeleteError(errmsg(r)) + def get_display_name(self): """ Get display name (calendar, principal, ...more?) diff --git a/caldav/elements/base.py b/caldav/elements/base.py index 8739199d..f95e68a6 100644 --- a/caldav/elements/base.py +++ b/caldav/elements/base.py @@ -1,5 +1,6 @@ #!/usr/bin/env python import sys +from collections.abc import Iterable from typing import ClassVar from typing import List from typing import Optional @@ -11,11 +12,6 @@ from caldav.lib.namespace import nsmap from caldav.lib.python_utilities import to_unicode -if sys.version_info < (3, 9): - from typing import Iterable -else: - from collections.abc import Iterable - if sys.version_info < (3, 11): from typing_extensions import Self else: diff --git a/caldav/lib/auth.py b/caldav/lib/auth.py new file mode 100644 index 00000000..05e32eb4 --- /dev/null +++ b/caldav/lib/auth.py @@ -0,0 +1,68 @@ +""" +Authentication utilities for CalDAV clients. + +This module contains shared authentication logic used by both +DAVClient (sync) and AsyncDAVClient (async). +""" +from __future__ import annotations + + +def extract_auth_types(header: str) -> set[str]: + """ + Extract authentication types from WWW-Authenticate header. + + Parses the WWW-Authenticate header value and extracts the + authentication scheme names (e.g., "basic", "digest", "bearer"). + + Args: + header: WWW-Authenticate header value from server response. + + Returns: + Set of lowercase auth type strings. + + Example: + >>> extract_auth_types('Basic realm="test", Digest realm="test"') + {'basic', 'digest'} + + Reference: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate#syntax + """ + return {h.split()[0] for h in header.lower().split(",")} + + +def select_auth_type( + auth_types: set[str] | list[str], + has_username: bool, + has_password: bool, + prefer_digest: bool = True, +) -> str | None: + """ + Select the best authentication type from available options. + + Args: + auth_types: Available authentication types from server. + has_username: Whether a username is configured. + has_password: Whether a password is configured. + prefer_digest: Whether to prefer Digest over Basic auth. + + Returns: + Selected auth type string, or None if no suitable type found. + + Selection logic: + - If username is set: prefer Digest (more secure) or Basic + - If only password is set: use Bearer token auth + - Otherwise: return None + """ + auth_types_set = set(auth_types) if not isinstance(auth_types, set) else auth_types + + if has_username: + if prefer_digest and "digest" in auth_types_set: + return "digest" + if "basic" in auth_types_set: + return "basic" + elif has_password: + # Password without username suggests bearer token + if "bearer" in auth_types_set: + return "bearer" + + return None diff --git a/caldav/lib/url.py b/caldav/lib/url.py index 9feff4ae..44728d1d 100644 --- a/caldav/lib/url.py +++ b/caldav/lib/url.py @@ -189,7 +189,7 @@ def join(self, path: Any) -> "URL": ): raise ValueError("%s can't be joined with %s" % (self, path)) - if path.path[0] == "/": + if path.path and path.path[0] == "/": ret_path = path.path else: sep = "/" diff --git a/caldav/operations/__init__.py b/caldav/operations/__init__.py new file mode 100644 index 00000000..81a5c0c4 --- /dev/null +++ b/caldav/operations/__init__.py @@ -0,0 +1,64 @@ +""" +Operations Layer - Sans-I/O Business Logic for CalDAV. + +This package contains pure functions that implement CalDAV business logic +without performing any network I/O. Both sync (DAVClient) and async +(AsyncDAVClient) clients use these same functions. + +Architecture: + ┌─────────────────────────────────────┐ + │ DAVClient / AsyncDAVClient │ + │ (handles I/O) │ + ├─────────────────────────────────────┤ + │ Operations Layer (this package) │ + │ - _build_*() -> QuerySpec │ + │ - _process_*() -> Result data │ + │ - Pure functions, no I/O │ + ├─────────────────────────────────────┤ + │ Protocol Layer (caldav.protocol) │ + │ - XML building and parsing │ + └─────────────────────────────────────┘ + +The functions in this layer are private (prefixed with _) and should be +imported directly from the submodules when needed. Only data types are +exported from this package. + +Modules: + base: Common utilities and base types + davobject_ops: DAVObject operations (properties, children, delete) + calendarobject_ops: CalendarObjectResource operations (load, save, ical manipulation) + principal_ops: Principal operations (discovery, calendar home set) + calendarset_ops: CalendarSet operations (list calendars, make calendar) + calendar_ops: Calendar operations (search, multiget, sync) + search_ops: Search operations (query building, filtering, strategy) +""" +from caldav.operations.base import PropertyData +from caldav.operations.base import QuerySpec +from caldav.operations.calendar_ops import CalendarObjectInfo +from caldav.operations.calendarobject_ops import CalendarObjectData +from caldav.operations.calendarset_ops import CalendarInfo +from caldav.operations.davobject_ops import ChildData +from caldav.operations.davobject_ops import ChildrenQuery +from caldav.operations.davobject_ops import PropertiesResult +from caldav.operations.principal_ops import PrincipalData +from caldav.operations.search_ops import SearchStrategy + +__all__ = [ + # Base types + "QuerySpec", + "PropertyData", + # DAVObject types + "ChildrenQuery", + "ChildData", + "PropertiesResult", + # CalendarObjectResource types + "CalendarObjectData", + # Principal types + "PrincipalData", + # CalendarSet types + "CalendarInfo", + # Calendar types + "CalendarObjectInfo", + # Search types + "SearchStrategy", +] diff --git a/caldav/operations/base.py b/caldav/operations/base.py new file mode 100644 index 00000000..c9aa1359 --- /dev/null +++ b/caldav/operations/base.py @@ -0,0 +1,193 @@ +""" +Base utilities for the operations layer. + +This module provides foundational types and utilities used by all +operations modules. The operations layer contains pure functions +(Sans-I/O) that handle business logic without performing any network I/O. + +Design principles: +- All functions are pure: same inputs always produce same outputs +- No network I/O - that's the client's responsibility +- Request specs describe WHAT to request, not HOW +- Response processors transform parsed data into domain-friendly formats +""" +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Sequence + +from caldav.lib.url import URL + + +@dataclass(frozen=True) +class QuerySpec: + """ + Base specification for a DAV query. + + This is an immutable description of what to request from the server. + The client uses this to construct and execute the actual HTTP request. + + Attributes: + url: The URL to query + method: HTTP method (PROPFIND, REPORT, etc.) + depth: DAV depth header (0, 1, or infinity) + props: Properties to request + body: Optional pre-built XML body (if complex) + """ + + url: str + method: str = "PROPFIND" + depth: int = 0 + props: tuple[str, ...] = () + body: Optional[bytes] = None + + def with_url(self, new_url: str) -> "QuerySpec": + """Return a copy with a different URL.""" + return QuerySpec( + url=new_url, + method=self.method, + depth=self.depth, + props=self.props, + body=self.body, + ) + + +@dataclass +class PropertyData: + """ + Generic property data extracted from a DAV response. + + Used when we need to pass around arbitrary properties + without knowing their specific structure. + """ + + href: str + properties: Dict[str, Any] = field(default_factory=dict) + status: int = 200 + + +def _normalize_href(href: str, base_url: Optional[str] = None) -> str: + """ + Normalize an href to a consistent format. + + Handles relative URLs, double slashes, and other common issues. + + Args: + href: The href from the server response + base_url: Optional base URL to resolve relative hrefs against + + Returns: + Normalized href string + """ + if not href: + return href + + # Handle double slashes + while "//" in href and not href.startswith("http"): + href = href.replace("//", "/") + + # Resolve relative URLs if base provided + if base_url and not href.startswith("http"): + try: + base = URL.objectify(base_url) + if base: + return str(base.join(href)) + except Exception: + pass + + return href + + +def _extract_resource_type(properties: Dict[str, Any]) -> List[str]: + """ + Extract resource types from properties dict. + + Args: + properties: Dict of property tag -> value + + Returns: + List of resource type tags (e.g., ['{DAV:}collection', '{urn:ietf:params:xml:ns:caldav}calendar']) + """ + resource_type_key = "{DAV:}resourcetype" + rt = properties.get(resource_type_key, []) + + if isinstance(rt, list): + return rt + elif rt is None: + return [] + else: + # Single value + return [rt] if rt else [] + + +def _is_calendar_resource(properties: Dict[str, Any]) -> bool: + """ + Check if properties indicate a calendar resource. + + Args: + properties: Dict of property tag -> value + + Returns: + True if this is a calendar collection + """ + resource_types = _extract_resource_type(properties) + calendar_tag = "{urn:ietf:params:xml:ns:caldav}calendar" + return calendar_tag in resource_types + + +def _is_collection_resource(properties: Dict[str, Any]) -> bool: + """ + Check if properties indicate a collection resource. + + Args: + properties: Dict of property tag -> value + + Returns: + True if this is a collection + """ + resource_types = _extract_resource_type(properties) + collection_tag = "{DAV:}collection" + return collection_tag in resource_types + + +def _get_property_value( + properties: Dict[str, Any], + prop_name: str, + default: Any = None, +) -> Any: + """ + Get a property value, handling both namespaced and simple keys. + + Tries the full namespaced key first, then common namespace prefixes. + + Args: + properties: Dict of property tag -> value + prop_name: Property name (e.g., 'displayname' or '{DAV:}displayname') + default: Default value if not found + + Returns: + Property value or default + """ + # Try exact key first + if prop_name in properties: + return properties[prop_name] + + # Try with common namespaces + namespaces = [ + "{DAV:}", + "{urn:ietf:params:xml:ns:caldav}", + "{http://calendarserver.org/ns/}", + "{http://apple.com/ns/ical/}", + ] + + for ns in namespaces: + full_key = f"{ns}{prop_name}" + if full_key in properties: + return properties[full_key] + + return default diff --git a/caldav/operations/calendar_ops.py b/caldav/operations/calendar_ops.py new file mode 100644 index 00000000..b272193d --- /dev/null +++ b/caldav/operations/calendar_ops.py @@ -0,0 +1,264 @@ +""" +Calendar operations - Sans-I/O business logic for Calendar objects. + +This module contains pure functions for Calendar operations like +component class detection, sync token generation, and result processing. +Both sync and async clients use these same functions. +""" +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Any +from typing import List +from typing import Optional +from typing import Tuple +from urllib.parse import quote + + +# Component type to class name mapping +COMPONENT_CLASS_MAP = { + "BEGIN:VEVENT": "Event", + "BEGIN:VTODO": "Todo", + "BEGIN:VJOURNAL": "Journal", + "BEGIN:VFREEBUSY": "FreeBusy", +} + + +@dataclass +class CalendarObjectInfo: + """Information about a calendar object extracted from server response.""" + + url: str + data: Optional[str] + etag: Optional[str] + component_type: Optional[str] # "Event", "Todo", "Journal", "FreeBusy" + extra_props: dict + + +def _detect_component_type_from_string(data: str) -> Optional[str]: + """ + Detect the component type (Event, Todo, etc.) from iCalendar string data. + + Args: + data: iCalendar data as string + + Returns: + Component type name ("Event", "Todo", "Journal", "FreeBusy") or None + """ + for line in data.split("\n"): + line = line.strip() + if line in COMPONENT_CLASS_MAP: + return COMPONENT_CLASS_MAP[line] + return None + + +def _detect_component_type_from_icalendar(ical_obj: Any) -> Optional[str]: + """ + Detect the component type from an icalendar object. + + Args: + ical_obj: icalendar.Calendar or similar object with subcomponents + + Returns: + Component type name ("Event", "Todo", "Journal", "FreeBusy") or None + """ + import icalendar + + ical2name = { + icalendar.Event: "Event", + icalendar.Todo: "Todo", + icalendar.Journal: "Journal", + icalendar.FreeBusy: "FreeBusy", + } + + if not hasattr(ical_obj, "subcomponents"): + return None + + if not len(ical_obj.subcomponents): + return None + + for sc in ical_obj.subcomponents: + if sc.__class__ in ical2name: + return ical2name[sc.__class__] + + return None + + +def _detect_component_type(data: Any) -> Optional[str]: + """ + Detect the component type from iCalendar data (string or object). + + Args: + data: iCalendar data as string, bytes, or icalendar object + + Returns: + Component type name ("Event", "Todo", "Journal", "FreeBusy") or None + """ + if data is None: + return None + + # Try string detection first + if hasattr(data, "split"): + return _detect_component_type_from_string(data) + + # Try icalendar object detection + if hasattr(data, "subcomponents"): + return _detect_component_type_from_icalendar(data) + + return None + + +def _generate_fake_sync_token(etags_and_urls: List[Tuple[Optional[str], str]]) -> str: + """ + Generate a fake sync token for servers without sync support. + + Uses a hash of all ETags/URLs to detect changes. This allows clients + to use the sync token API even when the server doesn't support it. + + Args: + etags_and_urls: List of (etag, url) tuples. ETag may be None. + + Returns: + A fake sync token string prefixed with "fake-" + """ + parts = [] + for etag, url in etags_and_urls: + if etag: + parts.append(str(etag)) + else: + # Use URL as fallback identifier + parts.append(str(url)) + + parts.sort() # Consistent ordering + combined = "|".join(parts) + hash_value = hashlib.md5(combined.encode()).hexdigest() + return f"fake-{hash_value}" + + +def _is_fake_sync_token(token: Optional[str]) -> bool: + """ + Check if a sync token is a fake one generated by the client. + + Args: + token: Sync token string + + Returns: + True if this is a fake sync token + """ + return token is not None and isinstance(token, str) and token.startswith("fake-") + + +def _normalize_result_url(result_url: str, parent_url: str) -> str: + """ + Normalize a URL from search/report results. + + Handles quoting for relative URLs and ensures proper joining with parent. + + Args: + result_url: URL from server response (may be relative or absolute) + parent_url: Parent calendar URL + + Returns: + Normalized URL string ready for joining with parent + """ + # If it's a full URL, return as-is + if "://" in result_url: + return result_url + + # Quote relative paths + return quote(result_url) + + +def _should_skip_calendar_self_reference(result_url: str, calendar_url: str) -> bool: + """ + Check if a result URL should be skipped because it's the calendar itself. + + iCloud and some other servers return the calendar URL along with + calendar item URLs. This function helps filter those out. + + Args: + result_url: URL from server response + calendar_url: The calendar's URL + + Returns: + True if this URL should be skipped (it's the calendar itself) + """ + # Normalize both URLs for comparison + result_normalized = result_url.rstrip("/") + calendar_normalized = calendar_url.rstrip("/") + + # Check if they're the same + return result_normalized == calendar_normalized + + +def _process_report_results( + results: dict, + calendar_url: str, + calendar_data_tag: str = "{urn:ietf:params:xml:ns:caldav}calendar-data", + etag_tag: str = "{DAV:}getetag", +) -> List[CalendarObjectInfo]: + """ + Process REPORT response results into CalendarObjectInfo objects. + + Args: + results: Dict mapping href -> properties dict + calendar_url: URL of the calendar (to filter out self-references) + calendar_data_tag: XML tag for calendar data property + etag_tag: XML tag for etag property + + Returns: + List of CalendarObjectInfo objects + """ + objects = [] + calendar_url_normalized = calendar_url.rstrip("/") + + for href, props in results.items(): + # Skip calendar self-reference + if _should_skip_calendar_self_reference(href, calendar_url_normalized): + continue + + # Extract calendar data + data = props.pop(calendar_data_tag, None) + + # Extract etag + etag = props.get(etag_tag) + + # Detect component type + component_type = _detect_component_type(data) + + # Normalize URL + normalized_url = _normalize_result_url(href, calendar_url) + + objects.append( + CalendarObjectInfo( + url=normalized_url, + data=data, + etag=etag, + component_type=component_type, + extra_props=props, + ) + ) + + return objects + + +def _build_calendar_object_url( + calendar_url: str, + object_id: str, +) -> str: + """ + Build a URL for a calendar object from calendar URL and object ID. + + Args: + calendar_url: URL of the parent calendar + object_id: ID of the calendar object (typically UID.ics) + + Returns: + Full URL for the calendar object + """ + calendar_url = str(calendar_url).rstrip("/") + object_id = quote(str(object_id)) + if not object_id.endswith(".ics"): + object_id += ".ics" + return f"{calendar_url}/{object_id}" diff --git a/caldav/operations/calendarobject_ops.py b/caldav/operations/calendarobject_ops.py new file mode 100644 index 00000000..b5dfe2f8 --- /dev/null +++ b/caldav/operations/calendarobject_ops.py @@ -0,0 +1,545 @@ +""" +CalendarObjectResource operations - Sans-I/O business logic. + +This module contains pure functions for working with calendar objects +(events, todos, journals) without performing any network I/O. +Both sync and async clients use these same functions. + +These functions work on icalendar component objects or raw data strings. +""" +from __future__ import annotations + +import re +import uuid +from dataclasses import dataclass +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from urllib.parse import quote + +import icalendar +from dateutil.rrule import rrulestr + + +# Relation type reverse mapping (RFC 9253) +RELTYPE_REVERSE_MAP = { + "PARENT": "CHILD", + "CHILD": "PARENT", + "SIBLING": "SIBLING", + "DEPENDS-ON": "FINISHTOSTART", + "FINISHTOSTART": "DEPENDENT", +} + + +@dataclass +class CalendarObjectData: + """Data extracted from a calendar object.""" + + uid: Optional[str] + url: Optional[str] + etag: Optional[str] + data: Optional[str] + + +def _generate_uid() -> str: + """Generate a new UID for a calendar object.""" + return str(uuid.uuid1()) + + +def _generate_url(parent_url: str, uid: str) -> str: + """ + Generate a URL for a calendar object based on its UID. + + Handles special characters in UID by proper quoting. + + Args: + parent_url: URL of the parent calendar (must end with /) + uid: The UID of the calendar object + + Returns: + Full URL for the calendar object + """ + # Double-quote slashes per https://github.com/python-caldav/caldav/issues/143 + quoted_uid = quote(uid.replace("/", "%2F")) + if not parent_url.endswith("/"): + parent_url += "/" + return f"{parent_url}{quoted_uid}.ics" + + +def _extract_uid_from_path(path: str) -> Optional[str]: + """ + Extract UID from a .ics file path. + + Args: + path: Path like "/calendars/user/calendar/event-uid.ics" + + Returns: + The UID portion, or None if not found + """ + if not path.endswith(".ics"): + return None + match = re.search(r"(/|^)([^/]*).ics$", path) + if match: + return match.group(2) + return None + + +def _find_id_and_path( + component: Any, # icalendar component + given_id: Optional[str] = None, + given_path: Optional[str] = None, + existing_id: Optional[str] = None, +) -> Tuple[str, str]: + """ + Determine the UID and path for a calendar object. + + This is Sans-I/O logic extracted from CalendarObjectResource._find_id_path(). + + Priority: + 1. given_id parameter + 2. existing_id (from object) + 3. UID from component + 4. UID extracted from path + 5. Generate new UID + + Args: + component: icalendar component (VEVENT, VTODO, etc.) + given_id: Explicitly provided ID + given_path: Explicitly provided path + existing_id: ID already set on the object + + Returns: + Tuple of (uid, relative_path) + """ + uid = given_id or existing_id + + if not uid: + # Try to get UID from component + uid_prop = component.get("UID") + if uid_prop: + uid = str(uid_prop) + + if not uid and given_path and given_path.endswith(".ics"): + # Extract from path + uid = _extract_uid_from_path(given_path) + + if not uid: + # Generate new UID + uid = _generate_uid() + + # Set UID in component (remove old one first) + if "UID" in component: + component.pop("UID") + component.add("UID", uid) + + # Determine path + if given_path: + path = given_path + else: + path = quote(uid.replace("/", "%2F")) + ".ics" + + return uid, path + + +def _get_duration( + component: Any, # icalendar component + end_param: str = "DTEND", +) -> timedelta: + """ + Get duration from a calendar component. + + According to the RFC, either DURATION or DTEND/DUE should be set, + but never both. This function calculates duration from whichever is present. + + Args: + component: icalendar component (VEVENT, VTODO, etc.) + end_param: The end parameter name ("DTEND" for events, "DUE" for todos) + + Returns: + Duration as timedelta + """ + if "DURATION" in component: + return component["DURATION"].dt + + if "DTSTART" in component and end_param in component: + end = component[end_param].dt + start = component["DTSTART"].dt + + # Handle date vs datetime mismatch + if isinstance(end, datetime) != isinstance(start, datetime): + # Convert both to datetime for comparison + if not isinstance(start, datetime): + start = datetime(start.year, start.month, start.day) + if not isinstance(end, datetime): + end = datetime(end.year, end.month, end.day) + + return end - start + + # Default: if only DTSTART and it's a date (not datetime), assume 1 day + if "DTSTART" in component: + dtstart = component["DTSTART"].dt + if not isinstance(dtstart, datetime): + return timedelta(days=1) + + return timedelta(0) + + +def _get_due(component: Any) -> Optional[datetime]: + """ + Get due date from a VTODO component. + + Handles DUE, DTEND, or DURATION+DTSTART. + + Args: + component: icalendar VTODO component + + Returns: + Due date/datetime, or None if not set + """ + if "DUE" in component: + return component["DUE"].dt + elif "DTEND" in component: + return component["DTEND"].dt + elif "DURATION" in component and "DTSTART" in component: + return component["DTSTART"].dt + component["DURATION"].dt + return None + + +def _set_duration( + component: Any, # icalendar component + duration: timedelta, + movable_attr: str = "DTSTART", +) -> None: + """ + Set duration on a component, adjusting other properties as needed. + + If both DTSTART and DUE/DTEND are set, one must be moved. + + Args: + component: icalendar component to modify + duration: New duration + movable_attr: Which attribute to move ("DTSTART" or "DUE") + """ + has_due = "DUE" in component or "DURATION" in component + has_start = "DTSTART" in component + + if has_due and has_start: + component.pop(movable_attr, None) + if movable_attr == "DUE": + component.pop("DURATION", None) + if movable_attr == "DTSTART": + component.add("DTSTART", component["DUE"].dt - duration) + elif movable_attr == "DUE": + component.add("DUE", component["DTSTART"].dt + duration) + elif "DUE" in component: + component.add("DTSTART", component["DUE"].dt - duration) + elif "DTSTART" in component: + component.add("DUE", component["DTSTART"].dt + duration) + else: + if "DURATION" in component: + component.pop("DURATION") + component.add("DURATION", duration) + + +def _is_task_pending(component: Any) -> bool: + """ + Check if a VTODO component is pending (not completed). + + Args: + component: icalendar VTODO component + + Returns: + True if task is pending, False if completed/cancelled + """ + if component.get("COMPLETED") is not None: + return False + + status = component.get("STATUS", "NEEDS-ACTION") + if status in ("NEEDS-ACTION", "IN-PROCESS"): + return True + if status in ("CANCELLED", "COMPLETED"): + return False + + # Unknown status - treat as pending + return True + + +def _mark_task_completed( + component: Any, # icalendar VTODO component + completion_timestamp: Optional[datetime] = None, +) -> None: + """ + Mark a VTODO component as completed. + + Modifies the component in place. + + Args: + component: icalendar VTODO component + completion_timestamp: When the task was completed (defaults to now) + """ + if completion_timestamp is None: + completion_timestamp = datetime.now(timezone.utc) + + component.pop("STATUS", None) + component.add("STATUS", "COMPLETED") + component.add("COMPLETED", completion_timestamp) + + +def _mark_task_uncompleted(component: Any) -> None: + """ + Mark a VTODO component as not completed. + + Args: + component: icalendar VTODO component + """ + component.pop("status", None) + component.pop("STATUS", None) + component.add("STATUS", "NEEDS-ACTION") + component.pop("completed", None) + component.pop("COMPLETED", None) + + +def _calculate_next_recurrence( + component: Any, # icalendar VTODO component + completion_timestamp: Optional[datetime] = None, + rrule: Optional[Any] = None, + dtstart: Optional[datetime] = None, + use_fixed_deadlines: Optional[bool] = None, + ignore_count: bool = True, +) -> Optional[datetime]: + """ + Calculate the next DTSTART for a recurring task after completion. + + This implements the logic from Todo._next(). + + Args: + component: icalendar VTODO component with RRULE + completion_timestamp: When the task was completed + rrule: Override RRULE (default: from component) + dtstart: Override DTSTART (default: calculated based on use_fixed_deadlines) + use_fixed_deadlines: If True, preserve DTSTART from component. + If False, use completion time minus duration. + If None, auto-detect from BY* parameters in rrule. + ignore_count: If True, ignore COUNT in RRULE + + Returns: + Next DTSTART datetime, or None if no more recurrences + """ + if rrule is None: + rrule = component.get("RRULE") + if rrule is None: + return None + + # Determine if we should use fixed deadlines + if use_fixed_deadlines is None: + use_fixed_deadlines = any(x for x in rrule if x.startswith("BY")) + + # Determine starting point for calculation + if dtstart is None: + if use_fixed_deadlines: + if "DTSTART" in component: + dtstart = component["DTSTART"].dt + else: + dtstart = completion_timestamp or datetime.now(timezone.utc) + else: + duration = _get_duration(component, "DUE") + dtstart = (completion_timestamp or datetime.now(timezone.utc)) - duration + + # Normalize to UTC for comparison + if hasattr(dtstart, "astimezone"): + dtstart = dtstart.astimezone(timezone.utc) + + ts = completion_timestamp or dtstart + + # Optionally ignore COUNT + if ignore_count and "COUNT" in rrule: + rrule = rrule.copy() + rrule.pop("COUNT") + + # Parse and calculate next occurrence + rrule_obj = rrulestr(rrule.to_ical().decode("utf-8"), dtstart=dtstart) + return rrule_obj.after(ts) + + +def _reduce_rrule_count(component: Any) -> bool: + """ + Reduce the COUNT in an RRULE by 1. + + Args: + component: icalendar component with RRULE + + Returns: + False if COUNT was 1 (task should end), True otherwise + """ + if "RRULE" not in component: + return True + + rrule = component["RRULE"] + count = rrule.get("COUNT", None) + if count is not None: + # COUNT is stored as a list in vRecur + count_val = count[0] if isinstance(count, list) else count + if count_val == 1: + return False + if isinstance(count, list): + count[0] = count_val - 1 + else: + rrule["COUNT"] = count_val - 1 + + return True + + +def _is_calendar_data_loaded( + data: Optional[str], + vobject_instance: Any, + icalendar_instance: Any, +) -> bool: + """ + Check if calendar object data is loaded. + + Args: + data: Raw iCalendar data string + vobject_instance: vobject instance (if any) + icalendar_instance: icalendar instance (if any) + + Returns: + True if data is loaded + """ + return bool( + (data and data.count("BEGIN:") > 1) or vobject_instance or icalendar_instance + ) + + +def _has_calendar_component(data: Optional[str]) -> bool: + """ + Check if data contains VEVENT, VTODO, or VJOURNAL. + + Args: + data: Raw iCalendar data string + + Returns: + True if a calendar component is present + """ + if not data: + return False + + return ( + data.count("BEGIN:VEVENT") + + data.count("BEGIN:VTODO") + + data.count("BEGIN:VJOURNAL") + ) > 0 + + +def _get_non_timezone_subcomponents( + icalendar_instance: Any, +) -> List[Any]: + """ + Get all subcomponents except VTIMEZONE. + + Args: + icalendar_instance: icalendar.Calendar instance + + Returns: + List of non-timezone subcomponents + """ + return [ + x + for x in icalendar_instance.subcomponents + if not isinstance(x, icalendar.Timezone) + ] + + +def _get_primary_component(icalendar_instance: Any) -> Optional[Any]: + """ + Get the primary (non-timezone) component from a calendar. + + For events/todos/journals, there should be exactly one. + For recurrence sets, returns the master component. + + Args: + icalendar_instance: icalendar.Calendar instance + + Returns: + The primary component (VEVENT, VTODO, VJOURNAL, or VFREEBUSY) + """ + components = _get_non_timezone_subcomponents(icalendar_instance) + if not components: + return None + + for comp in components: + if isinstance( + comp, + (icalendar.Event, icalendar.Todo, icalendar.Journal, icalendar.FreeBusy), + ): + return comp + + return None + + +def _copy_component_with_new_uid( + component: Any, + new_uid: Optional[str] = None, +) -> Any: + """ + Create a copy of a component with a new UID. + + Args: + component: icalendar component to copy + new_uid: New UID (generated if not provided) + + Returns: + Copy of the component with new UID + """ + new_comp = component.copy() + new_comp.pop("UID", None) + new_comp.add("UID", new_uid or _generate_uid()) + return new_comp + + +def _get_reverse_reltype(reltype: str) -> Optional[str]: + """ + Get the reverse relation type for a given relation type. + + Args: + reltype: Relation type (e.g., "PARENT", "CHILD") + + Returns: + Reverse relation type, or None if not defined + """ + return RELTYPE_REVERSE_MAP.get(reltype.upper()) + + +def _extract_relations( + component: Any, + reltypes: Optional[set] = None, +) -> Dict[str, set]: + """ + Extract RELATED-TO relations from a component. + + Args: + component: icalendar component + reltypes: Optional set of relation types to filter + + Returns: + Dict mapping reltype -> set of UIDs + """ + from collections import defaultdict + + result = defaultdict(set) + relations = component.get("RELATED-TO", []) + + if not isinstance(relations, list): + relations = [relations] + + for rel in relations: + reltype = rel.params.get("RELTYPE", "PARENT") + if reltypes and reltype not in reltypes: + continue + result[reltype].add(str(rel)) + + return dict(result) diff --git a/caldav/operations/calendarset_ops.py b/caldav/operations/calendarset_ops.py new file mode 100644 index 00000000..a66c7f2f --- /dev/null +++ b/caldav/operations/calendarset_ops.py @@ -0,0 +1,228 @@ +""" +CalendarSet operations - Sans-I/O business logic for CalendarSet objects. + +This module contains pure functions for CalendarSet operations like +extracting calendar IDs and building calendar URLs. Both sync and async +clients use these same functions. +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any +from typing import List +from typing import Optional +from typing import Tuple +from urllib.parse import quote + +log = logging.getLogger("caldav") + + +@dataclass +class CalendarInfo: + """Data for a calendar extracted from PROPFIND response.""" + + url: str + cal_id: Optional[str] + name: Optional[str] + resource_types: List[str] + + +def _extract_calendar_id_from_url(url: str) -> Optional[str]: + """ + Extract calendar ID from a calendar URL. + + Calendar URLs typically look like: /calendars/user/calendar-id/ + The calendar ID is the second-to-last path segment. + + Args: + url: Calendar URL + + Returns: + Calendar ID, or None if extraction fails + """ + try: + # Split and get second-to-last segment (last is empty due to trailing /) + parts = str(url).rstrip("/").split("/") + if len(parts) >= 1: + cal_id = parts[-1] + if cal_id: + return cal_id + except Exception: + log.error(f"Calendar has unexpected url {url}") + return None + + +def _process_calendar_list( + children_data: List[Tuple[str, List[str], Optional[str]]], +) -> List[CalendarInfo]: + """ + Process children data into CalendarInfo objects. + + Args: + children_data: List of (url, resource_types, display_name) tuples + from children() call + + Returns: + List of CalendarInfo objects with extracted calendar IDs + """ + calendars = [] + for c_url, c_types, c_name in children_data: + cal_id = _extract_calendar_id_from_url(c_url) + if not cal_id: + continue + calendars.append( + CalendarInfo( + url=c_url, + cal_id=cal_id, + name=c_name, + resource_types=c_types, + ) + ) + return calendars + + +def _resolve_calendar_url( + cal_id: str, + parent_url: str, + client_base_url: str, +) -> str: + """ + Resolve a calendar URL from a calendar ID. + + Handles different formats: + - Full URLs (https://...) + - Absolute paths (/calendars/...) + - Relative IDs (just the calendar name) + + Args: + cal_id: Calendar ID or URL + parent_url: URL of the calendar set + client_base_url: Base URL of the client + + Returns: + Resolved calendar URL + """ + # Normalize URLs for comparison + client_canonical = str(client_base_url).rstrip("/") + cal_id_str = str(cal_id) + + # Check if cal_id is already a full URL under the client base + if cal_id_str.startswith(client_canonical): + # It's a full URL, just join to handle any path adjustments + return _join_url(client_base_url, cal_id) + + # Check if it's a full URL (http:// or https://) + if cal_id_str.startswith("https://") or cal_id_str.startswith("http://"): + # Join with parent URL + return _join_url(parent_url, cal_id) + + # It's a relative ID - quote it and append trailing slash + quoted_id = quote(cal_id) + if not quoted_id.endswith("/"): + quoted_id += "/" + + return _join_url(parent_url, quoted_id) + + +def _join_url(base: str, path: str) -> str: + """ + Simple URL join - concatenates base and path. + + This is a placeholder that the actual URL class will handle. + Returns a string representation for the operations layer. + + Args: + base: Base URL + path: Path to join + + Returns: + Joined URL string + """ + # Basic implementation - real code uses URL.join() + base = str(base).rstrip("/") + path = str(path).lstrip("/") + return f"{base}/{path}" + + +def _find_calendar_by_name( + calendars: List[CalendarInfo], + name: str, +) -> Optional[CalendarInfo]: + """ + Find a calendar by display name. + + Args: + calendars: List of CalendarInfo objects + name: Display name to search for + + Returns: + CalendarInfo if found, None otherwise + """ + for cal in calendars: + if cal.name == name: + return cal + return None + + +def _find_calendar_by_id( + calendars: List[CalendarInfo], + cal_id: str, +) -> Optional[CalendarInfo]: + """ + Find a calendar by ID. + + Args: + calendars: List of CalendarInfo objects + cal_id: Calendar ID to search for + + Returns: + CalendarInfo if found, None otherwise + """ + for cal in calendars: + if cal.cal_id == cal_id: + return cal + return None + + +def _extract_calendars_from_propfind_results( + results: Optional[List[Any]], +) -> List[CalendarInfo]: + """ + Extract calendar information from PROPFIND results. + + This pure function processes propfind results to identify calendar + resources and extract their metadata. + + Args: + results: List of PropfindResult objects from parse_propfind_response + + Returns: + List of CalendarInfo objects for calendar resources found + """ + from caldav.operations.base import _is_calendar_resource as is_calendar_resource + + calendars = [] + for result in results or []: + # Check if this is a calendar resource + if not is_calendar_resource(result.properties): + continue + + # Extract calendar info + url = result.href + name = result.properties.get("{DAV:}displayname") + cal_id = _extract_calendar_id_from_url(url) + + if not cal_id: + continue + + calendars.append( + CalendarInfo( + url=url, + cal_id=cal_id, + name=name, + resource_types=result.properties.get("{DAV:}resourcetype", []), + ) + ) + + return calendars diff --git a/caldav/operations/davobject_ops.py b/caldav/operations/davobject_ops.py new file mode 100644 index 00000000..d1d60d7a --- /dev/null +++ b/caldav/operations/davobject_ops.py @@ -0,0 +1,313 @@ +""" +DAVObject operations - Sans-I/O business logic for DAV objects. + +This module contains pure functions for DAVObject operations like +getting/setting properties, listing children, and deleting resources. +Both sync and async clients use these same functions. +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from dataclasses import field +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from urllib.parse import quote +from urllib.parse import unquote + +from caldav.operations.base import _extract_resource_type as extract_resource_type +from caldav.operations.base import _is_calendar_resource as is_calendar_resource +from caldav.operations.base import _normalize_href as normalize_href +from caldav.operations.base import PropertyData +from caldav.operations.base import QuerySpec + +log = logging.getLogger("caldav") + + +# Property tags used in operations +DAV_DISPLAYNAME = "{DAV:}displayname" +DAV_RESOURCETYPE = "{DAV:}resourcetype" +CALDAV_CALENDAR = "{urn:ietf:params:xml:ns:caldav}calendar" + + +@dataclass(frozen=True) +class ChildrenQuery: + """Query specification for listing children.""" + + url: str + depth: int = 1 + props: Tuple[str, ...] = (DAV_DISPLAYNAME, DAV_RESOURCETYPE) + + +@dataclass +class ChildData: + """Data for a child resource.""" + + url: str + resource_types: List[str] + display_name: Optional[str] + + +@dataclass +class PropertiesResult: + """Result of extracting properties for a specific object.""" + + properties: Dict[str, Any] + matched_path: str + + +def _build_children_query(url: str) -> ChildrenQuery: + """ + Build query for listing children of a collection. + + Args: + url: URL of the parent collection + + Returns: + ChildrenQuery specification + """ + return ChildrenQuery(url=url) + + +def _process_children_response( + properties_by_href: Dict[str, Dict[str, Any]], + parent_url: str, + filter_type: Optional[str] = None, + is_calendar_set: bool = False, +) -> List[ChildData]: + """ + Process PROPFIND response into list of children. + + This is Sans-I/O - works on already-parsed response data. + + Args: + properties_by_href: Dict mapping href -> properties dict + parent_url: URL of the parent collection (to exclude from results) + filter_type: Optional resource type to filter by (e.g., CALDAV_CALENDAR) + is_calendar_set: True if parent is a CalendarSet (affects filtering logic) + + Returns: + List of ChildData for matching children + """ + children = [] + + # Normalize parent URL for comparison + parent_canonical = _canonical_path(parent_url) + + for path, props in properties_by_href.items(): + resource_types = props.get(DAV_RESOURCETYPE, []) + if isinstance(resource_types, str): + resource_types = [resource_types] + elif resource_types is None: + resource_types = [] + + display_name = props.get(DAV_DISPLAYNAME) + + # Filter by type if specified + if filter_type is not None and filter_type not in resource_types: + continue + + # Build URL, quoting if it's a relative path + url_obj_path = path + if not path.startswith("http"): + url_obj_path = quote(path) + + # Determine child's canonical path for comparison + child_canonical = _canonical_path(path) + + # Skip the parent itself + # Special case for CalendarSet filtering for calendars + if is_calendar_set and filter_type == CALDAV_CALENDAR: + # Include if it's a calendar (already filtered above) + children.append( + ChildData( + url=url_obj_path, + resource_types=resource_types, + display_name=display_name, + ) + ) + elif parent_canonical != child_canonical: + children.append( + ChildData( + url=url_obj_path, + resource_types=resource_types, + display_name=display_name, + ) + ) + + return children + + +def _canonical_path(url: str) -> str: + """Get canonical path for comparison, stripping trailing slashes.""" + # Extract path from URL + if "://" in url: + # Full URL - extract path + from urllib.parse import urlparse + + parsed = urlparse(url) + path = parsed.path + else: + path = url + + # Strip trailing slash for comparison + return path.rstrip("/") + + +def _find_object_properties( + properties_by_href: Dict[str, Dict[str, Any]], + object_url: str, + is_principal: bool = False, +) -> PropertiesResult: + """ + Find properties for a specific object from a PROPFIND response. + + Handles various server quirks like trailing slash mismatches, + iCloud path issues, and double slashes. + + Args: + properties_by_href: Dict mapping href -> properties dict + object_url: URL of the object we're looking for + is_principal: True if object is a Principal (affects warning behavior) + + Returns: + PropertiesResult with the found properties + + Raises: + ValueError: If no matching properties found + """ + path = ( + unquote(object_url) + if "://" not in object_url + else unquote(_extract_path(object_url)) + ) + + # Try with and without trailing slash + if path.endswith("/"): + exchange_path = path[:-1] + else: + exchange_path = path + "/" + + # Try exact path match + if path in properties_by_href: + return PropertiesResult(properties=properties_by_href[path], matched_path=path) + + # Try with/without trailing slash + if exchange_path in properties_by_href: + if not is_principal: + log.warning( + f"The path {path} was not found in the properties, but {exchange_path} was. " + "This may indicate a server bug or a trailing slash issue." + ) + return PropertiesResult( + properties=properties_by_href[exchange_path], matched_path=exchange_path + ) + + # Try full URL as key + if object_url in properties_by_href: + return PropertiesResult( + properties=properties_by_href[object_url], matched_path=object_url + ) + + # iCloud workaround - /principal/ path + if "/principal/" in properties_by_href and path.endswith("/principal/"): + log.warning("Applying iCloud workaround for /principal/ path mismatch") + return PropertiesResult( + properties=properties_by_href["/principal/"], matched_path="/principal/" + ) + + # Double slash workaround + if "//" in path: + normalized = path.replace("//", "/") + if normalized in properties_by_href: + log.warning(f"Path contained double slashes: {path} -> {normalized}") + return PropertiesResult( + properties=properties_by_href[normalized], matched_path=normalized + ) + + # Last resort: if only one result, use it + if len(properties_by_href) == 1: + only_path = list(properties_by_href.keys())[0] + log.warning( + f"Possibly the server has a path handling problem, possibly the URL configured is wrong. " + f"Path expected: {path}, path found: {only_path}. " + "Continuing, probably everything will be fine" + ) + return PropertiesResult( + properties=properties_by_href[only_path], matched_path=only_path + ) + + # No match found + raise ValueError( + f"Could not find properties for {path}. " + f"Available paths: {list(properties_by_href.keys())}" + ) + + +def _extract_path(url: str) -> str: + """Extract path component from a URL.""" + if "://" not in url: + return url + from urllib.parse import urlparse + + return urlparse(url).path + + +def _convert_protocol_results_to_properties( + results: List[Any], # List[PropfindResult] + requested_props: Optional[List[str]] = None, +) -> Dict[str, Dict[str, Any]]: + """ + Convert protocol layer results to the {href: {tag: value}} format. + + Args: + results: List of PropfindResult from protocol layer + requested_props: Optional list of property tags that were requested + (used to initialize missing props to None) + + Returns: + Dict mapping href -> properties dict + """ + properties = {} + for result in results: + result_props = {} + # Initialize requested props to None for backward compat + if requested_props: + for prop in requested_props: + result_props[prop] = None + # Overlay with actual values + result_props.update(result.properties) + properties[result.href] = result_props + return properties + + +def _validate_delete_response(status: int) -> None: + """ + Validate DELETE response status. + + Args: + status: HTTP status code + + Raises: + ValueError: If status indicates failure + """ + # 200 OK, 204 No Content, 404 Not Found (already deleted) are all acceptable + if status not in (200, 204, 404): + raise ValueError(f"Delete failed with status {status}") + + +def _validate_proppatch_response(status: int) -> None: + """ + Validate PROPPATCH response status. + + Args: + status: HTTP status code + + Raises: + ValueError: If status indicates failure + """ + if status >= 400: + raise ValueError(f"PROPPATCH failed with status {status}") diff --git a/caldav/operations/principal_ops.py b/caldav/operations/principal_ops.py new file mode 100644 index 00000000..833aa34b --- /dev/null +++ b/caldav/operations/principal_ops.py @@ -0,0 +1,165 @@ +""" +Principal operations - Sans-I/O business logic for Principal objects. + +This module contains pure functions for Principal operations like +URL sanitization and vCalAddress creation. Both sync and async clients +use these same functions. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from typing import List +from typing import Optional +from urllib.parse import quote + + +@dataclass +class PrincipalData: + """Data extracted from a principal.""" + + url: Optional[str] + display_name: Optional[str] + calendar_home_set_url: Optional[str] + calendar_user_addresses: List[str] + + +def _sanitize_calendar_home_set_url(url: Optional[str]) -> Optional[str]: + """ + Sanitize calendar home set URL, handling server quirks. + + OwnCloud returns URLs like /remote.php/dav/calendars/tobixen@e.email/ + where the @ should be quoted. Some servers return already-quoted URLs. + + Args: + url: Calendar home set URL from server + + Returns: + Sanitized URL with @ properly quoted (if not already) + """ + if url is None: + return None + + # Quote @ in URLs that aren't full URLs (owncloud quirk) + # Don't double-quote if already quoted + if "@" in url and "://" not in url and "%40" not in url: + return quote(url) + + return url + + +def _sort_calendar_user_addresses(addresses: List[Any]) -> List[Any]: + """ + Sort calendar user addresses by preference. + + The 'preferred' attribute is possibly iCloud-specific but we honor + it when present. + + Args: + addresses: List of address elements (lxml elements with text and attributes) + + Returns: + Sorted list (highest preference first) + """ + return sorted(addresses, key=lambda x: -int(x.get("preferred", 0))) + + +def _extract_calendar_user_addresses(addresses: List[Any]) -> List[Optional[str]]: + """ + Extract calendar user address strings from XML elements. + + Args: + addresses: List of DAV:href elements + + Returns: + List of address strings (sorted by preference) + """ + sorted_addresses = _sort_calendar_user_addresses(addresses) + return [x.text for x in sorted_addresses] + + +def _create_vcal_address( + display_name: Optional[str], + address: str, + calendar_user_type: Optional[str] = None, +) -> Any: + """ + Create an icalendar vCalAddress object from principal properties. + + Args: + display_name: The principal's display name (CN parameter) + address: The primary calendar user address + calendar_user_type: CalendarUserType (CUTYPE parameter) + + Returns: + icalendar.vCalAddress object + """ + from icalendar import vCalAddress, vText + + vcal_addr = vCalAddress(address) + if display_name: + vcal_addr.params["cn"] = vText(display_name) + if calendar_user_type: + vcal_addr.params["cutype"] = vText(calendar_user_type) + + return vcal_addr + + +def _extract_calendar_home_set_from_results( + results: Optional[List[Any]], +) -> Optional[str]: + """ + Extract calendar-home-set URL from PROPFIND results. + + This pure function processes propfind results to find the + calendar-home-set property, handling URL sanitization. + + Args: + results: List of PropfindResult objects from parse_propfind_response + + Returns: + Calendar home set URL, or None if not found + """ + if not results: + return None + + for result in results: + home_set = result.properties.get( + "{urn:ietf:params:xml:ns:caldav}calendar-home-set" + ) + if home_set: + return _sanitize_calendar_home_set_url(home_set) + + return None + + +def _should_update_client_base_url( + calendar_home_set_url: Optional[str], + client_hostname: Optional[str], +) -> bool: + """ + Check if client base URL should be updated for load-balanced systems. + + iCloud and others use load-balanced systems where each principal + resides on one named host. If the calendar home set URL has a different + hostname, we may need to update the client's base URL. + + Args: + calendar_home_set_url: The sanitized calendar home set URL + client_hostname: The current client hostname + + Returns: + True if client URL should be updated + """ + if calendar_home_set_url is None: + return False + + # Check if it's a full URL with a different host + if "://" in calendar_home_set_url: + from urllib.parse import urlparse + + parsed = urlparse(calendar_home_set_url) + if parsed.hostname and parsed.hostname != client_hostname: + return True + + return False diff --git a/caldav/operations/search_ops.py b/caldav/operations/search_ops.py new file mode 100644 index 00000000..b2034f58 --- /dev/null +++ b/caldav/operations/search_ops.py @@ -0,0 +1,463 @@ +""" +Search operations - Sans-I/O business logic for calendar search. + +This module contains pure functions that implement search logic +without performing any network I/O. Both sync (CalDAVSearcher.search) +and async (CalDAVSearcher.async_search) use these same functions. + +Key functions: +- build_search_xml_query(): Build CalDAV REPORT XML query +- filter_search_results(): Client-side filtering of search results +- determine_search_strategy(): Analyze server features and return search plan +- _collation_to_caldav(): Map collation enum to CalDAV identifier +""" +from copy import deepcopy +from dataclasses import dataclass +from dataclasses import field +from typing import Any +from typing import List +from typing import Optional +from typing import Set +from typing import Tuple +from typing import TYPE_CHECKING + +from icalendar import Timezone +from icalendar_searcher.collation import Collation + +from caldav.elements import cdav +from caldav.elements import dav +from caldav.lib import error + +if TYPE_CHECKING: + from caldav.calendarobjectresource import CalendarObjectResource + from caldav.calendarobjectresource import Event, Todo, Journal + from caldav.compatibility_hints import FeatureSet + from icalendar_searcher import Searcher + + +def _collation_to_caldav(collation: Collation, case_sensitive: bool = True) -> str: + """Map icalendar-searcher Collation enum to CalDAV collation identifier. + + CalDAV supports collation identifiers from RFC 4790. The default is "i;ascii-casemap" + and servers must support at least "i;ascii-casemap" and "i;octet". + + :param collation: icalendar-searcher Collation enum value + :param case_sensitive: Whether the collation should be case-sensitive + :return: CalDAV collation identifier string + """ + if collation == Collation.SIMPLE: + # SIMPLE collation maps to CalDAV's basic collations + if case_sensitive: + return "i;octet" + else: + return "i;ascii-casemap" + elif collation == Collation.UNICODE: + # Unicode Collation Algorithm - not all servers support this + # Note: "i;unicode-casemap" is case-insensitive by definition + # For case-sensitive Unicode, we fall back to i;octet (binary) + if case_sensitive: + return "i;octet" + else: + return "i;unicode-casemap" + elif collation == Collation.LOCALE: + # Locale-specific collation - not widely supported in CalDAV + # Fallback to i;ascii-casemap as most servers don't support locale-specific + return "i;ascii-casemap" + else: + # Default to binary/octet for unknown collations + return "i;octet" + + +@dataclass +class SearchStrategy: + """Encapsulates the search strategy decisions based on server capabilities. + + This dataclass holds all the decisions about how to execute a search, + allowing the same logic to be shared between sync and async implementations. + """ + + # Whether to apply client-side post-filtering + post_filter: Optional[bool] = None + + # Hack mode for server compatibility + hacks: Optional[str] = None + + # Whether to split expanded recurrences into separate objects + split_expanded: bool = True + + # Properties to remove from server query (for client-side filtering) + remove_properties: Set[str] = field(default_factory=set) + + # Whether category filters should be removed (server doesn't support them) + remove_category_filter: bool = False + + # Whether we need to do multiple searches for pending todos + pending_todo_multi_search: bool = False + + # Whether to retry with individual component types + retry_with_comptypes: bool = False + + +def _determine_post_filter_needed( + searcher: "Searcher", + features: "FeatureSet", + comp_type_support: Optional[str], + current_hacks: Optional[str], + current_post_filter: Optional[bool], +) -> Tuple[Optional[bool], Optional[str]]: + """Determine if post-filtering is needed based on searcher state and server features. + + Returns (post_filter, hacks) tuple with potentially updated values. + + This is a Sans-I/O function - it only examines data and makes decisions. + """ + post_filter = current_post_filter + hacks = current_hacks + + # Handle servers with broken component-type filtering (e.g., Bedework) + if ( + ( + searcher.comp_class + or getattr(searcher, "todo", False) + or getattr(searcher, "event", False) + or getattr(searcher, "journal", False) + ) + and comp_type_support == "broken" + and not hacks + and post_filter is not False + ): + hacks = "no_comp_filter" + post_filter = True + + # Setting default value for post_filter based on various conditions + if post_filter is None and ( + (getattr(searcher, "todo", False) and not searcher.include_completed) + or searcher.expand + or "categories" in searcher._property_filters + or "category" in searcher._property_filters + or not features.is_supported("search.text.case-sensitive") + or not features.is_supported("search.time-range.accurate") + ): + post_filter = True + + return post_filter, hacks + + +def _should_remove_category_filter( + searcher: "Searcher", + features: "FeatureSet", + post_filter: Optional[bool], +) -> bool: + """Check if category filters should be removed from server query. + + Returns True if categories/category are in property filters but server + doesn't support category search properly. + """ + return ( + not features.is_supported("search.text.category") + and ( + "categories" in searcher._property_filters + or "category" in searcher._property_filters + ) + and post_filter is not False + ) + + +def _get_explicit_contains_properties( + searcher: "Searcher", + features: "FeatureSet", + post_filter: Optional[bool], +) -> List[str]: + """Get list of properties with explicit 'contains' operator that server doesn't support. + + These properties should be removed from server query and applied client-side. + """ + if features.is_supported("search.text.substring") or post_filter is False: + return [] + + explicit_operators = getattr(searcher, "_explicit_operators", set()) + return [ + prop + for prop in searcher._property_operator + if prop in explicit_operators + and searcher._property_operator[prop] == "contains" + ] + + +def _should_remove_property_filters_for_combined( + searcher: "Searcher", + features: "FeatureSet", +) -> bool: + """Check if property filters should be removed due to combined search issues. + + Some servers don't handle combined time-range + property filters properly. + """ + if features.is_supported("search.combined-is-logical-and"): + return False + return bool((searcher.start or searcher.end) and searcher._property_filters) + + +def _needs_pending_todo_multi_search( + searcher: "Searcher", + features: "FeatureSet", +) -> bool: + """Check if we need multiple searches for pending todos. + + Returns True if searching for pending todos and server supports the + necessary features for multi-search approach. + """ + if not (getattr(searcher, "todo", False) and searcher.include_completed is False): + return False + + return ( + features.is_supported("search.text") + and features.is_supported("search.combined-is-logical-and") + and ( + not features.is_supported("search.recurrences.includes-implicit.todo") + or features.is_supported( + "search.recurrences.includes-implicit.todo.pending" + ) + ) + ) + + +def _filter_search_results( + objects: List["CalendarObjectResource"], + searcher: "Searcher", + post_filter: Optional[bool] = None, + split_expanded: bool = True, + server_expand: bool = False, +) -> List["CalendarObjectResource"]: + """Apply client-side filtering and handle recurrence expansion/splitting. + + This is a Sans-I/O function - it only processes data without network I/O. + + :param objects: List of Event/Todo/Journal objects to filter + :param searcher: The CalDAVSearcher with filter criteria + :param post_filter: Whether to apply the searcher's filter logic. + - True: Always apply filters (check_component) + - False: Never apply filters, only handle splitting + - None: Use default behavior (depends on searcher.expand and other flags) + :param split_expanded: Whether to split recurrence sets into multiple + separate CalendarObjectResource objects. If False, a recurrence set + will be contained in a single object with multiple subcomponents. + :param server_expand: Indicates that the server was supposed to expand + recurrences. If True and split_expanded is True, splitting will be + performed even without searcher.expand being set. + :return: Filtered and/or split list of CalendarObjectResource objects + """ + if not (post_filter or searcher.expand or (split_expanded and server_expand)): + return objects + + result = [] + for o in objects: + if searcher.expand or post_filter: + filtered = searcher.check_component(o, expand_only=not post_filter) + if not filtered: + continue + else: + filtered = [ + x + for x in o.icalendar_instance.subcomponents + if not isinstance(x, Timezone) + ] + + i = o.icalendar_instance + tz_ = [x for x in i.subcomponents if isinstance(x, Timezone)] + i.subcomponents = tz_ + + for comp in filtered: + if isinstance(comp, Timezone): + continue + if split_expanded: + new_obj = o.copy(keep_uid=True) + new_i = new_obj.icalendar_instance + new_i.subcomponents = [] + for tz in tz_: + new_i.add_component(tz) + result.append(new_obj) + else: + new_i = i + new_i.add_component(comp) + + if not split_expanded: + result.append(o) + + return result + + +def _build_search_xml_query( + searcher: "Searcher", + server_expand: bool = False, + props: Optional[List[Any]] = None, + filters: Any = None, + _hacks: Optional[str] = None, +) -> Tuple[Any, Optional[type]]: + """Build a CalDAV calendar-query XML request. + + This is a Sans-I/O function - it only builds XML without network I/O. + + :param searcher: CalDAVSearcher instance with search parameters + :param server_expand: Ask server to expand recurrences + :param props: Additional CalDAV properties to request + :param filters: Pre-built filter elements (or None to build from searcher) + :param _hacks: Compatibility hack mode + :return: Tuple of (xml_element, comp_class) + """ + # Import here to avoid circular imports at module level + from caldav.calendarobjectresource import Event, Todo, Journal + + # With dual-mode classes, Async* are now aliases to the sync classes + # Keep the aliases for backward compatibility in type checks + AsyncEvent = Event + AsyncTodo = Todo + AsyncJournal = Journal + + # Build the request + data = cdav.CalendarData() + if server_expand: + if not searcher.start or not searcher.end: + raise error.ReportError("can't expand without a date range") + data += cdav.Expand(searcher.start, searcher.end) + + if props is None: + props_ = [data] + else: + props_ = [data] + list(props) + prop = dav.Prop() + props_ + vcalendar = cdav.CompFilter("VCALENDAR") + + comp_filter = None + comp_class = searcher.comp_class + + if filters: + # Deep copy to avoid mutating the original + filters = deepcopy(filters) + if hasattr(filters, "tag") and filters.tag == cdav.CompFilter.tag: + comp_filter = filters + filters = [] + else: + filters = [] + + # Build status filters for pending todos + vNotCompleted = cdav.TextMatch("COMPLETED", negate=True) + vNotCancelled = cdav.TextMatch("CANCELLED", negate=True) + vNeedsAction = cdav.TextMatch("NEEDS-ACTION") + vStatusNotCompleted = cdav.PropFilter("STATUS") + vNotCompleted + vStatusNotCancelled = cdav.PropFilter("STATUS") + vNotCancelled + vStatusNeedsAction = cdav.PropFilter("STATUS") + vNeedsAction + vStatusNotDefined = cdav.PropFilter("STATUS") + cdav.NotDefined() + vNoCompleteDate = cdav.PropFilter("COMPLETED") + cdav.NotDefined() + + if _hacks == "ignore_completed1": + # Query in line with RFC 4791 section 7.8.9 + filters.extend([vNoCompleteDate, vStatusNotCompleted, vStatusNotCancelled]) + elif _hacks == "ignore_completed2": + # Handle servers that return false on negated TextMatch for undefined fields + filters.extend([vNoCompleteDate, vStatusNotDefined]) + elif _hacks == "ignore_completed3": + # Handle recurring tasks with NEEDS-ACTION status + filters.extend([vStatusNeedsAction]) + + if searcher.start or searcher.end: + filters.append(cdav.TimeRange(searcher.start, searcher.end)) + + if searcher.alarm_start or searcher.alarm_end: + filters.append( + cdav.CompFilter("VALARM") + + cdav.TimeRange(searcher.alarm_start, searcher.alarm_end) + ) + + # Map component flags/classes to comp_filter + comp_mappings = [ + ("event", "VEVENT", Event, AsyncEvent), + ("todo", "VTODO", Todo, AsyncTodo), + ("journal", "VJOURNAL", Journal, AsyncJournal), + ] + + for flag, comp_name, sync_class, async_class in comp_mappings: + comp_classes = ( + (sync_class,) if async_class is None else (sync_class, async_class) + ) + flagged = getattr(searcher, flag, False) + + if flagged: + if comp_class is not None and comp_class not in comp_classes: + raise error.ConsistencyError( + f"inconsistent search parameters - comp_class = {comp_class}, want {sync_class}" + ) + comp_class = sync_class + + if comp_filter and comp_filter.attributes.get("name") == comp_name: + comp_class = sync_class + if ( + flag == "todo" + and not getattr(searcher, "todo", False) + and searcher.include_completed is None + ): + searcher.include_completed = True + setattr(searcher, flag, True) + + if comp_class in comp_classes: + if comp_filter: + assert comp_filter.attributes.get("name") == comp_name + else: + comp_filter = cdav.CompFilter(comp_name) + setattr(searcher, flag, True) + + if comp_class and not comp_filter: + raise error.ConsistencyError(f"unsupported comp class {comp_class} for search") + + # Special hack for bedework - no comp_filter, do client-side filtering + if _hacks == "no_comp_filter": + comp_filter = None + comp_class = None + + # Add property filters + for property in searcher._property_operator: + if searcher._property_operator[property] == "undef": + match = cdav.NotDefined() + filters.append(cdav.PropFilter(property.upper()) + match) + else: + value = searcher._property_filters[property] + property_ = property.upper() + if property.lower() == "category": + property_ = "CATEGORIES" + if property.lower() == "categories": + values = value.cats + else: + values = [value] + + for value in values: + if hasattr(value, "to_ical"): + value = value.to_ical() + + # Get collation setting for this property if available + collation_str = "i;octet" # Default to binary + if ( + hasattr(searcher, "_property_collation") + and property in searcher._property_collation + ): + case_sensitive = searcher._property_case_sensitive.get( + property, True + ) + collation_str = _collation_to_caldav( + searcher._property_collation[property], case_sensitive + ) + + match = cdav.TextMatch(value, collation=collation_str) + filters.append(cdav.PropFilter(property_) + match) + + # Assemble the query + if comp_filter and filters: + comp_filter += filters + vcalendar += comp_filter + elif comp_filter: + vcalendar += comp_filter + elif filters: + vcalendar += filters + + filter_elem = cdav.Filter() + vcalendar + root = cdav.CalendarQuery() + [prop, filter_elem] + + return (root, comp_class) diff --git a/caldav/protocol/__init__.py b/caldav/protocol/__init__.py new file mode 100644 index 00000000..de271dc6 --- /dev/null +++ b/caldav/protocol/__init__.py @@ -0,0 +1,43 @@ +""" +Sans-I/O CalDAV protocol implementation. + +This module provides protocol-level operations without any I/O. +It builds requests and parses responses as pure data transformations. + +The protocol layer is organized into: +- types: Core data structures (DAVRequest, DAVResponse, result types) +- xml_builders: Internal functions to build XML request bodies +- xml_parsers: Internal functions to parse XML response bodies + +Both DAVClient (sync) and AsyncDAVClient (async) use these shared +functions for XML building and parsing, ensuring consistent behavior. + +Note: The xml_builders and xml_parsers functions are internal implementation +details and should not be used directly. Use the client methods instead. +""" +from .types import CalendarInfo +from .types import CalendarQueryResult +from .types import DAVMethod +from .types import DAVRequest +from .types import DAVResponse +from .types import MultiGetResult +from .types import MultistatusResponse +from .types import PrincipalInfo +from .types import PropfindResult +from .types import SyncCollectionResult + +__all__ = [ + # Enums + "DAVMethod", + # Request/Response + "DAVRequest", + "DAVResponse", + # Result types + "CalendarInfo", + "CalendarQueryResult", + "MultiGetResult", + "MultistatusResponse", + "PrincipalInfo", + "PropfindResult", + "SyncCollectionResult", +] diff --git a/caldav/protocol/types.py b/caldav/protocol/types.py new file mode 100644 index 00000000..5f39fbde --- /dev/null +++ b/caldav/protocol/types.py @@ -0,0 +1,246 @@ +""" +Core protocol types for Sans-I/O CalDAV implementation. + +These dataclasses represent HTTP requests and responses at the protocol level, +independent of any I/O implementation. +""" +from dataclasses import dataclass +from dataclasses import field +from enum import Enum +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + + +class DAVMethod(Enum): + """WebDAV/CalDAV HTTP methods.""" + + GET = "GET" + PUT = "PUT" + DELETE = "DELETE" + PROPFIND = "PROPFIND" + PROPPATCH = "PROPPATCH" + REPORT = "REPORT" + MKCALENDAR = "MKCALENDAR" + MKCOL = "MKCOL" + OPTIONS = "OPTIONS" + HEAD = "HEAD" + MOVE = "MOVE" + COPY = "COPY" + POST = "POST" + + +@dataclass(frozen=True) +class DAVRequest: + """ + Represents an HTTP request to be made. + + This is a pure data structure with no I/O. It describes what request + should be made, but does not make it. + + Attributes: + method: HTTP method (GET, PUT, PROPFIND, etc.) + url: Full URL for the request + headers: HTTP headers as dict + body: Request body as bytes (optional) + """ + + method: DAVMethod + url: str + headers: Dict[str, str] = field(default_factory=dict) + body: Optional[bytes] = None + + def with_header(self, name: str, value: str) -> "DAVRequest": + """Return new request with additional header.""" + new_headers = {**self.headers, name: value} + return DAVRequest( + method=self.method, + url=self.url, + headers=new_headers, + body=self.body, + ) + + def with_body(self, body: bytes) -> "DAVRequest": + """Return new request with body.""" + return DAVRequest( + method=self.method, + url=self.url, + headers=self.headers, + body=body, + ) + + +@dataclass(frozen=True) +class DAVResponse: + """ + Represents an HTTP response received. + + This is a pure data structure with no I/O. It contains the response + data but does not fetch it. + + Attributes: + status: HTTP status code + headers: HTTP headers as dict + body: Response body as bytes + """ + + status: int + headers: Dict[str, str] + body: bytes + + @property + def ok(self) -> bool: + """True if status indicates success (2xx).""" + return 200 <= self.status < 300 + + @property + def is_multistatus(self) -> bool: + """True if this is a 207 Multi-Status response.""" + return self.status == 207 + + @property + def reason(self) -> str: + """Return a reason phrase for the status code.""" + reasons = { + 200: "OK", + 201: "Created", + 204: "No Content", + 207: "Multi-Status", + 301: "Moved Permanently", + 302: "Found", + 304: "Not Modified", + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 409: "Conflict", + 412: "Precondition Failed", + 415: "Unsupported Media Type", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + } + return reasons.get(self.status, "Unknown") + + +@dataclass +class PropfindResult: + """ + Parsed result of a PROPFIND request for a single resource. + + Attributes: + href: URL/path of the resource + properties: Dict of property name -> value + status: HTTP status for this resource (default 200) + """ + + href: str + properties: Dict[str, Any] = field(default_factory=dict) + status: int = 200 + + +@dataclass +class CalendarQueryResult: + """ + Parsed result of a calendar-query REPORT for a single object. + + Attributes: + href: URL/path of the calendar object + etag: ETag of the object (for conditional updates) + calendar_data: iCalendar data as string + status: HTTP status for this resource (default 200) + """ + + href: str + etag: Optional[str] = None + calendar_data: Optional[str] = None + status: int = 200 + + +@dataclass +class MultiGetResult: + """ + Parsed result of a calendar-multiget REPORT for a single object. + + Same structure as CalendarQueryResult but semantically different operation. + """ + + href: str + etag: Optional[str] = None + calendar_data: Optional[str] = None + status: int = 200 + + +@dataclass +class SyncCollectionResult: + """ + Parsed result of a sync-collection REPORT. + + Attributes: + changed: List of changed/new resources + deleted: List of deleted resource hrefs + sync_token: New sync token for next sync + """ + + changed: List[CalendarQueryResult] = field(default_factory=list) + deleted: List[str] = field(default_factory=list) + sync_token: Optional[str] = None + + +@dataclass +class MultistatusResponse: + """ + Parsed multi-status response containing multiple results. + + This is the raw parsed form of a 207 Multi-Status response. + + Attributes: + responses: List of individual response results + sync_token: Sync token if present (for sync-collection) + """ + + responses: List[PropfindResult] = field(default_factory=list) + sync_token: Optional[str] = None + + +@dataclass +class PrincipalInfo: + """ + Information about a CalDAV principal. + + Attributes: + url: Principal URL + calendar_home_set: URL of calendar home + displayname: Display name of principal + calendar_user_address_set: Set of calendar user addresses (email-like) + """ + + url: str + calendar_home_set: Optional[str] = None + displayname: Optional[str] = None + calendar_user_address_set: List[str] = field(default_factory=list) + + +@dataclass +class CalendarInfo: + """ + Information about a calendar collection. + + Attributes: + url: Calendar URL + displayname: Display name + description: Calendar description + color: Calendar color (vendor extension) + supported_components: List of supported component types (VEVENT, VTODO, etc.) + ctag: Calendar CTag for change detection + """ + + url: str + displayname: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + supported_components: List[str] = field(default_factory=list) + ctag: Optional[str] = None diff --git a/caldav/protocol/xml_builders.py b/caldav/protocol/xml_builders.py new file mode 100644 index 00000000..109f9a6a --- /dev/null +++ b/caldav/protocol/xml_builders.py @@ -0,0 +1,440 @@ +""" +Pure functions for building CalDAV XML request bodies. + +All functions in this module are pure - they take data in and return XML out, +with no side effects or I/O. +""" +from datetime import datetime +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple + +from lxml import etree + +from caldav.elements import cdav +from caldav.elements import dav +from caldav.elements.base import BaseElement + + +def _build_propfind_body( + props: Optional[List[str]] = None, + allprop: bool = False, +) -> bytes: + """ + Build PROPFIND request body XML. + + Args: + props: List of property names to retrieve. If None and allprop=False, + returns minimal propfind. + allprop: If True, request all properties. + + Returns: + UTF-8 encoded XML bytes + """ + if allprop: + propfind = dav.Propfind() + dav.Allprop() + elif props: + prop_elements = [] + for prop_name in props: + prop_element = _prop_name_to_element(prop_name) + if prop_element is not None: + prop_elements.append(prop_element) + propfind = dav.Propfind() + (dav.Prop() + prop_elements) + else: + propfind = dav.Propfind() + dav.Prop() + + return etree.tostring(propfind.xmlelement(), encoding="utf-8", xml_declaration=True) + + +def _build_proppatch_body( + set_props: Optional[Dict[str, Any]] = None, +) -> bytes: + """ + Build PROPPATCH request body for setting properties. + + Args: + set_props: Properties to set (name -> value) + + Returns: + UTF-8 encoded XML bytes + """ + propertyupdate = dav.PropertyUpdate() + + if set_props: + set_elements = [] + for name, value in set_props.items(): + prop_element = _prop_name_to_element(name, value) + if prop_element is not None: + set_elements.append(prop_element) + if set_elements: + set_element = dav.Set() + (dav.Prop() + set_elements) + propertyupdate += set_element + + return etree.tostring( + propertyupdate.xmlelement(), encoding="utf-8", xml_declaration=True + ) + + +def _build_calendar_query_body( + start: Optional[datetime] = None, + end: Optional[datetime] = None, + expand: bool = False, + comp_filter: Optional[str] = None, + event: bool = False, + todo: bool = False, + journal: bool = False, + props: Optional[List[BaseElement]] = None, + filters: Optional[List[BaseElement]] = None, +) -> Tuple[bytes, Optional[str]]: + """ + Build calendar-query REPORT request body. + + This is the core CalDAV search operation for retrieving calendar objects + matching specified criteria. + + Args: + start: Start of time range filter + end: End of time range filter + expand: Whether to expand recurring events + comp_filter: Component type filter name (VEVENT, VTODO, VJOURNAL) + event: Include VEVENT components (sets comp_filter if not specified) + todo: Include VTODO components (sets comp_filter if not specified) + journal: Include VJOURNAL components (sets comp_filter if not specified) + props: Additional CalDAV properties to include + filters: Additional filters to apply + + Returns: + Tuple of (UTF-8 encoded XML bytes, component type name or None) + """ + # Build calendar-data element with optional expansion + data = cdav.CalendarData() + if expand: + if not start or not end: + from caldav.lib import error + + raise error.ReportError("can't expand without a date range") + data += cdav.Expand(start, end) + + # Build props + props_list: List[BaseElement] = [data] + if props: + props_list.extend(props) + prop = dav.Prop() + props_list + + # Build VCALENDAR filter + vcalendar = cdav.CompFilter("VCALENDAR") + + # Determine component filter from flags + comp_type = comp_filter + if not comp_type: + if event: + comp_type = "VEVENT" + elif todo: + comp_type = "VTODO" + elif journal: + comp_type = "VJOURNAL" + + # Build filter list + filter_list: List[BaseElement] = [] + if filters: + filter_list.extend(filters) + + # Add time range filter if specified + if start or end: + filter_list.append(cdav.TimeRange(start, end)) + + # Build component filter + if comp_type: + comp_filter_elem = cdav.CompFilter(comp_type) + if filter_list: + comp_filter_elem += filter_list + vcalendar += comp_filter_elem + elif filter_list: + vcalendar += filter_list + + # Build final query + filter_elem = cdav.Filter() + vcalendar + root = cdav.CalendarQuery() + [prop, filter_elem] + + return ( + etree.tostring(root.xmlelement(), encoding="utf-8", xml_declaration=True), + comp_type, + ) + + +def _build_calendar_multiget_body( + hrefs: List[str], + include_data: bool = True, +) -> bytes: + """ + Build calendar-multiget REPORT request body. + + Used to retrieve multiple calendar objects by their URLs in a single request. + + Args: + hrefs: List of calendar object URLs to retrieve + include_data: Include calendar-data in response + + Returns: + UTF-8 encoded XML bytes + """ + elements: List[BaseElement] = [] + + if include_data: + prop = dav.Prop() + cdav.CalendarData() + elements.append(prop) + + for href in hrefs: + elements.append(dav.Href(href)) + + multiget = cdav.CalendarMultiGet() + elements + + return etree.tostring(multiget.xmlelement(), encoding="utf-8", xml_declaration=True) + + +def _build_sync_collection_body( + sync_token: Optional[str] = None, + props: Optional[List[str]] = None, + sync_level: str = "1", +) -> bytes: + """ + Build sync-collection REPORT request body. + + Used for efficient synchronization - only returns changed items since + the given sync token. + + Args: + sync_token: Previous sync token (empty string for initial sync) + props: Property names to include in response + sync_level: Sync level (usually "1") + + Returns: + UTF-8 encoded XML bytes + """ + elements: List[BaseElement] = [] + + # Sync token (empty for initial sync) + token_elem = dav.SyncToken(sync_token or "") + elements.append(token_elem) + + # Sync level + level_elem = dav.SyncLevel(sync_level) + elements.append(level_elem) + + # Properties to return + if props: + prop_elements = [] + for prop_name in props: + prop_element = _prop_name_to_element(prop_name) + if prop_element is not None: + prop_elements.append(prop_element) + if prop_elements: + elements.append(dav.Prop() + prop_elements) + else: + # Default: return etag and calendar-data + elements.append(dav.Prop() + [dav.GetEtag(), cdav.CalendarData()]) + + sync_collection = dav.SyncCollection() + elements + + return etree.tostring( + sync_collection.xmlelement(), encoding="utf-8", xml_declaration=True + ) + + +def _build_freebusy_query_body( + start: datetime, + end: datetime, +) -> bytes: + """ + Build free-busy-query REPORT request body. + + Args: + start: Start of free-busy period + end: End of free-busy period + + Returns: + UTF-8 encoded XML bytes + """ + root = cdav.FreeBusyQuery() + [cdav.TimeRange(start, end)] + + return etree.tostring(root.xmlelement(), encoding="utf-8", xml_declaration=True) + + +def _build_mkcalendar_body( + displayname: Optional[str] = None, + description: Optional[str] = None, + timezone: Optional[str] = None, + supported_components: Optional[List[str]] = None, +) -> bytes: + """ + Build MKCALENDAR request body. + + Args: + displayname: Calendar display name + description: Calendar description + timezone: VTIMEZONE component data + supported_components: List of supported component types (VEVENT, VTODO, etc.) + + Returns: + UTF-8 encoded XML bytes + """ + prop = dav.Prop() + + if displayname: + prop += dav.DisplayName(displayname) + + if description: + prop += cdav.CalendarDescription(description) + + if timezone: + prop += cdav.CalendarTimeZone(timezone) + + if supported_components: + sccs = cdav.SupportedCalendarComponentSet() + for comp in supported_components: + sccs += cdav.Comp(comp) + prop += sccs + + # Add resource type + prop += dav.ResourceType() + [dav.Collection(), cdav.Calendar()] + + set_elem = dav.Set() + prop + mkcalendar = cdav.Mkcalendar() + set_elem + + return etree.tostring( + mkcalendar.xmlelement(), encoding="utf-8", xml_declaration=True + ) + + +def _build_mkcol_body( + displayname: Optional[str] = None, + resource_types: Optional[List[BaseElement]] = None, +) -> bytes: + """ + Build MKCOL (extended) request body. + + Args: + displayname: Collection display name + resource_types: List of resource type elements + + Returns: + UTF-8 encoded XML bytes + """ + prop = dav.Prop() + + if displayname: + prop += dav.DisplayName(displayname) + + if resource_types: + rt = dav.ResourceType() + for rt_elem in resource_types: + rt += rt_elem + prop += rt + else: + prop += dav.ResourceType() + dav.Collection() + + set_elem = dav.Set() + prop + mkcol = dav.Mkcol() + set_elem + + return etree.tostring(mkcol.xmlelement(), encoding="utf-8", xml_declaration=True) + + +# Property name to element mapping + + +def _prop_name_to_element( + name: str, value: Optional[Any] = None +) -> Optional[BaseElement]: + """ + Convert property name string to element object. + + Args: + name: Property name (case-insensitive) + value: Optional value for valued elements + + Returns: + BaseElement instance or None if unknown property + """ + # DAV properties (only those that exist in dav.py) + dav_props: Dict[str, Any] = { + "displayname": dav.DisplayName, + "resourcetype": dav.ResourceType, + "getetag": dav.GetEtag, + "current-user-principal": dav.CurrentUserPrincipal, + "owner": dav.Owner, + "sync-token": dav.SyncToken, + "supported-report-set": dav.SupportedReportSet, + } + + # CalDAV properties + caldav_props: Dict[str, Any] = { + "calendar-data": cdav.CalendarData, + "calendar-home-set": cdav.CalendarHomeSet, + "calendar-user-address-set": cdav.CalendarUserAddressSet, + "calendar-user-type": cdav.CalendarUserType, + "calendar-description": cdav.CalendarDescription, + "calendar-timezone": cdav.CalendarTimeZone, + "supported-calendar-component-set": cdav.SupportedCalendarComponentSet, + "schedule-inbox-url": cdav.ScheduleInboxURL, + "schedule-outbox-url": cdav.ScheduleOutboxURL, + } + + # Strip Clark notation namespace prefix if present (e.g., "{DAV:}displayname" -> "displayname") + if name.startswith("{") and "}" in name: + name = name.split("}", 1)[1] + + name_lower = name.lower().replace("_", "-") + + # Check DAV properties + if name_lower in dav_props: + cls = dav_props[name_lower] + if value is not None: + try: + return cls(value) + except TypeError: + return cls() + return cls() + + # Check CalDAV properties + if name_lower in caldav_props: + cls = caldav_props[name_lower] + if value is not None: + try: + return cls(value) + except TypeError: + return cls() + return cls() + + return None + + +def _to_utc_date_string(ts: datetime) -> str: + """ + Convert datetime to UTC date string for CalDAV. + + Args: + ts: datetime object (may or may not have timezone) + + Returns: + UTC date string in format YYYYMMDDTHHMMSSZ + """ + from datetime import timezone + + utc_tz = timezone.utc + + if ts.tzinfo is None: + # Assume local time, convert to UTC + try: + ts = ts.astimezone(utc_tz) + except Exception: + # For very old Python versions or edge cases + import tzlocal + + ts = ts.replace(tzinfo=tzlocal.get_localzone()) + ts = ts.astimezone(utc_tz) + else: + ts = ts.astimezone(utc_tz) + + return ts.strftime("%Y%m%dT%H%M%SZ") diff --git a/caldav/protocol/xml_parsers.py b/caldav/protocol/xml_parsers.py new file mode 100644 index 00000000..2a1451ea --- /dev/null +++ b/caldav/protocol/xml_parsers.py @@ -0,0 +1,466 @@ +""" +Pure functions for parsing CalDAV XML responses. + +All functions in this module are pure - they take XML bytes in and return +structured data out, with no side effects or I/O. +""" +import logging +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from typing import Union +from urllib.parse import unquote + +from lxml import etree +from lxml.etree import _Element + +from .types import CalendarQueryResult +from .types import MultistatusResponse +from .types import PropfindResult +from .types import SyncCollectionResult +from caldav.elements import cdav +from caldav.elements import dav +from caldav.lib import error +from caldav.lib.url import URL + +log = logging.getLogger(__name__) + + +def _parse_multistatus( + body: bytes, + huge_tree: bool = False, +) -> MultistatusResponse: + """ + Parse a 207 Multi-Status response body. + + Args: + body: Raw XML response bytes + huge_tree: Allow parsing very large XML documents + + Returns: + Structured MultistatusResponse with parsed results + + Raises: + XMLSyntaxError: If body is not valid XML + ResponseError: If response indicates an error + """ + parser = etree.XMLParser(huge_tree=huge_tree) + tree = etree.fromstring(body, parser) + + responses: List[PropfindResult] = [] + sync_token: Optional[str] = None + + # Strip to multistatus content + response_elements = _strip_to_multistatus(tree) + + for elem in response_elements: + if elem.tag == dav.SyncToken.tag: + sync_token = elem.text + continue + + if elem.tag != dav.Response.tag: + continue + + href, propstats, status = _parse_response_element(elem) + properties = _extract_properties(propstats) + status_code = _status_to_code(status) if status else 200 + + responses.append( + PropfindResult( + href=href, + properties=properties, + status=status_code, + ) + ) + + return MultistatusResponse(responses=responses, sync_token=sync_token) + + +def _parse_propfind_response( + body: bytes, + status_code: int = 207, + huge_tree: bool = False, +) -> List[PropfindResult]: + """ + Parse a PROPFIND response. + + Args: + body: Raw XML response bytes + status_code: HTTP status code of the response + huge_tree: Allow parsing very large XML documents + + Returns: + List of PropfindResult with properties for each resource + """ + if status_code == 404: + return [] + + if status_code not in (200, 207): + raise error.ResponseError(f"PROPFIND failed with status {status_code}") + + if not body: + return [] + + result = _parse_multistatus(body, huge_tree=huge_tree) + return result.responses + + +def _parse_calendar_query_response( + body: bytes, + status_code: int = 207, + huge_tree: bool = False, +) -> List[CalendarQueryResult]: + """ + Parse a calendar-query REPORT response. + + Args: + body: Raw XML response bytes + status_code: HTTP status code of the response + huge_tree: Allow parsing very large XML documents + + Returns: + List of CalendarQueryResult with calendar data + """ + if status_code not in (200, 207): + raise error.ResponseError(f"REPORT failed with status {status_code}") + + if not body: + return [] + + parser = etree.XMLParser(huge_tree=huge_tree) + tree = etree.fromstring(body, parser) + + results: List[CalendarQueryResult] = [] + response_elements = _strip_to_multistatus(tree) + + for elem in response_elements: + if elem.tag != dav.Response.tag: + continue + + href, propstats, status = _parse_response_element(elem) + status_code_elem = _status_to_code(status) if status else 200 + + calendar_data: Optional[str] = None + etag: Optional[str] = None + + # Extract properties from propstats + for propstat in propstats: + prop = propstat.find(dav.Prop.tag) + if prop is None: + continue + + for child in prop: + if child.tag == cdav.CalendarData.tag: + calendar_data = child.text + elif child.tag == dav.GetEtag.tag: + etag = child.text + + results.append( + CalendarQueryResult( + href=href, + etag=etag, + calendar_data=calendar_data, + status=status_code_elem, + ) + ) + + return results + + +def _parse_sync_collection_response( + body: bytes, + status_code: int = 207, + huge_tree: bool = False, +) -> SyncCollectionResult: + """ + Parse a sync-collection REPORT response. + + Args: + body: Raw XML response bytes + status_code: HTTP status code of the response + huge_tree: Allow parsing very large XML documents + + Returns: + SyncCollectionResult with changed items, deleted hrefs, and new sync token + """ + if status_code not in (200, 207): + raise error.ResponseError(f"sync-collection failed with status {status_code}") + + if not body: + return SyncCollectionResult() + + parser = etree.XMLParser(huge_tree=huge_tree) + tree = etree.fromstring(body, parser) + + changed: List[CalendarQueryResult] = [] + deleted: List[str] = [] + sync_token: Optional[str] = None + + response_elements = _strip_to_multistatus(tree) + + for elem in response_elements: + if elem.tag == dav.SyncToken.tag: + sync_token = elem.text + continue + + if elem.tag != dav.Response.tag: + continue + + href, propstats, status = _parse_response_element(elem) + status_code_elem = _status_to_code(status) if status else 200 + + # 404 means deleted + if status_code_elem == 404: + deleted.append(href) + continue + + calendar_data: Optional[str] = None + etag: Optional[str] = None + + for propstat in propstats: + prop = propstat.find(dav.Prop.tag) + if prop is None: + continue + + for child in prop: + if child.tag == cdav.CalendarData.tag: + calendar_data = child.text + elif child.tag == dav.GetEtag.tag: + etag = child.text + + changed.append( + CalendarQueryResult( + href=href, + etag=etag, + calendar_data=calendar_data, + status=status_code_elem, + ) + ) + + return SyncCollectionResult( + changed=changed, + deleted=deleted, + sync_token=sync_token, + ) + + +def _parse_calendar_multiget_response( + body: bytes, + status_code: int = 207, + huge_tree: bool = False, +) -> List[CalendarQueryResult]: + """ + Parse a calendar-multiget REPORT response. + + This is the same format as calendar-query, so we delegate to that parser. + + Args: + body: Raw XML response bytes + status_code: HTTP status code of the response + huge_tree: Allow parsing very large XML documents + + Returns: + List of CalendarQueryResult with calendar data + """ + return parse_calendar_query_response(body, status_code, huge_tree) + + +# Helper functions + + +def _strip_to_multistatus(tree: _Element) -> Union[_Element, List[_Element]]: + """ + Strip outer elements to get to the multistatus content. + + The general format is: + + ... + ... + + + But sometimes multistatus and/or xml element is missing. + Returns the element(s) containing responses. + """ + if tree.tag == "xml" and len(tree) > 0 and tree[0].tag == dav.MultiStatus.tag: + return tree[0] + if tree.tag == dav.MultiStatus.tag: + return tree + return [tree] + + +def _parse_response_element( + response: _Element, +) -> Tuple[str, List[_Element], Optional[str]]: + """ + Parse a single DAV:response element. + + Returns: + Tuple of (href, propstat elements list, status string) + """ + status: Optional[str] = None + href: Optional[str] = None + propstats: List[_Element] = [] + + for elem in response: + if elem.tag == dav.Status.tag: + status = elem.text + _validate_status(status) + elif elem.tag == dav.Href.tag: + # Fix for double-encoded URLs (e.g., Confluence) + text = elem.text or "" + if "%2540" in text: + text = text.replace("%2540", "%40") + href = unquote(text) + # Convert absolute URLs to paths + if ":" in href: + href = unquote(URL(href).path) + elif elem.tag == dav.PropStat.tag: + propstats.append(elem) + + return (href or "", propstats, status) + + +def _extract_properties(propstats: List[_Element]) -> Dict[str, Any]: + """ + Extract properties from propstat elements into a dict. + + Args: + propstats: List of propstat elements + + Returns: + Dict mapping property tag to value (text or element) + """ + properties: Dict[str, Any] = {} + + for propstat in propstats: + # Check status - skip 404 properties + status_elem = propstat.find(dav.Status.tag) + if status_elem is not None and status_elem.text: + if " 404 " in status_elem.text: + continue + + # Find prop element + prop = propstat.find(dav.Prop.tag) + if prop is None: + continue + + # Extract each property + for child in prop: + tag = child.tag + # Get simple text value or store element for complex values + if len(child) == 0: + properties[tag] = child.text + else: + # For complex elements, store the element itself + # or extract nested text values + properties[tag] = _element_to_value(child) + + return properties + + +def _element_to_value(elem: _Element) -> Any: + """ + Convert an XML element to a Python value. + + For simple elements, returns text content. + For complex elements with children, returns dict or list. + Handles special CalDAV elements like supported-calendar-component-set. + """ + if len(elem) == 0: + return elem.text + + # Special handling for known complex properties + tag = elem.tag + + # supported-calendar-component-set: extract comp names + if tag == cdav.SupportedCalendarComponentSet.tag: + return [child.get("name") for child in elem if child.get("name")] + + # calendar-user-address-set: extract href texts + if tag == cdav.CalendarUserAddressSet.tag: + return [ + child.text for child in elem if child.tag == dav.Href.tag and child.text + ] + + # calendar-home-set: extract href text (usually single) + if tag == cdav.CalendarHomeSet.tag: + hrefs = [ + child.text for child in elem if child.tag == dav.Href.tag and child.text + ] + return hrefs[0] if len(hrefs) == 1 else hrefs + + # resourcetype: extract child tag names (e.g., collection, calendar) + if tag == dav.ResourceType.tag: + return [child.tag for child in elem] + + # current-user-principal: extract href + if tag == dav.CurrentUserPrincipal.tag: + for child in elem: + if child.tag == dav.Href.tag and child.text: + return child.text + return None + + # Generic handling for elements with children + children_texts = [] + for child in elem: + if child.text: + children_texts.append(child.text) + elif child.get("name"): + # Elements with name attribute (like comp) + children_texts.append(child.get("name")) + elif len(child) == 0: + # Empty element - use tag name + children_texts.append(child.tag) + + if len(children_texts) == 1: + return children_texts[0] + elif children_texts: + return children_texts + + # Fallback: return the element for further processing + return elem + + +def _validate_status(status: Optional[str]) -> None: + """ + Validate a status string like "HTTP/1.1 404 Not Found". + + 200, 201, 207, and 404 are considered acceptable statuses. + + Args: + status: Status string from response + + Raises: + ResponseError: If status indicates an error + """ + if status is None: + return + + acceptable = (" 200 ", " 201 ", " 207 ", " 404 ") + if not any(code in status for code in acceptable): + raise error.ResponseError(status) + + +def _status_to_code(status: Optional[str]) -> int: + """ + Extract status code from status string like "HTTP/1.1 200 OK". + + Args: + status: Status string + + Returns: + Integer status code (defaults to 200 if parsing fails) + """ + if not status: + return 200 + + parts = status.split() + if len(parts) >= 2: + try: + return int(parts[1]) + except ValueError: + pass + + return 200 diff --git a/caldav/response.py b/caldav/response.py new file mode 100644 index 00000000..5ad98b0a --- /dev/null +++ b/caldav/response.py @@ -0,0 +1,404 @@ +""" +Base class for DAV response parsing. + +This module contains the shared logic between DAVResponse (sync) and +AsyncDAVResponse (async) to eliminate code duplication. +""" +import logging +import warnings +from collections.abc import Iterable +from typing import Any +from typing import cast +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from typing import TYPE_CHECKING +from typing import Union +from urllib.parse import unquote + +from lxml import etree +from lxml.etree import _Element + +from caldav.elements import dav +from caldav.elements.base import BaseElement +from caldav.lib import error +from caldav.lib.python_utilities import to_normal_str +from caldav.lib.url import URL + +if TYPE_CHECKING: + # Protocol for HTTP response objects (works with httpx, niquests, requests) + # Using Any as the type hint to avoid strict protocol matching + Response = Any + +log = logging.getLogger(__name__) + + +class BaseDAVResponse: + """ + Base class containing shared response parsing logic. + + This class provides the XML parsing and response extraction methods + that are common to both sync and async DAV responses. + """ + + # These attributes should be set by subclass __init__ + tree: Optional[_Element] = None + headers: Any = None + status: int = 0 + _raw: Any = "" + huge_tree: bool = False + reason: str = "" + davclient: Any = None + + def _init_from_response(self, response: "Response", davclient: Any = None) -> None: + """ + Initialize response from an HTTP response object. + + This shared method extracts headers, status, and parses XML content. + Both DAVResponse and AsyncDAVResponse should call this from their __init__. + + Args: + response: The HTTP response object from niquests + davclient: Optional reference to the DAVClient for huge_tree setting + """ + self.headers = response.headers + self.status = response.status_code + log.debug("response headers: " + str(self.headers)) + log.debug("response status: " + str(self.status)) + + self._raw = response.content + self.davclient = davclient + if davclient: + self.huge_tree = davclient.huge_tree + + content_type = self.headers.get("Content-Type", "") + xml_types = ["text/xml", "application/xml"] + no_xml_types = ["text/plain", "text/calendar", "application/octet-stream"] + expect_xml = any(content_type.startswith(x) for x in xml_types) + expect_no_xml = any(content_type.startswith(x) for x in no_xml_types) + if ( + content_type + and not expect_xml + and not expect_no_xml + and response.status_code < 400 + and response.text + ): + error.weirdness(f"Unexpected content type: {content_type}") + try: + content_length = int(self.headers["Content-Length"]) + except (KeyError, ValueError, TypeError): + content_length = -1 + if content_length == 0 or not self._raw: + self._raw = "" + self.tree = None + log.debug("No content delivered") + else: + # For really huge objects we should pass the object as a stream to the + # XML parser, but we would also need to decompress on the fly. + try: + # https://github.com/python-caldav/caldav/issues/142 + # We cannot trust the content-type (iCloud, OX and others). + # We'll try to parse the content as XML no matter the content type. + self.tree = etree.XML( + self._raw, + parser=etree.XMLParser( + remove_blank_text=True, huge_tree=self.huge_tree + ), + ) + except Exception: + # Content wasn't XML. What does the content-type say? + # expect_no_xml means text/plain or text/calendar -> ok, pass on + # expect_xml means text/xml or application/xml -> raise an error + # anything else -> log an info message and continue + if not expect_no_xml or log.level <= logging.DEBUG: + if not expect_no_xml: + _log = logging.info + else: + _log = logging.debug + _log( + "Expected some valid XML from the server, but got this: \n" + + str(self._raw), + exc_info=True, + ) + if expect_xml: + raise + else: + if log.level <= logging.DEBUG: + log.debug(etree.tostring(self.tree, pretty_print=True)) + + # this if will always be true as for now, see other comments on streaming. + if hasattr(self, "_raw"): + log.debug(self._raw) + # ref https://github.com/python-caldav/caldav/issues/112 stray CRs may cause problems + if isinstance(self._raw, bytes): + self._raw = self._raw.replace(b"\r\n", b"\n") + elif isinstance(self._raw, str): + self._raw = self._raw.replace("\r\n", "\n") + self.status = response.status_code + # ref https://github.com/python-caldav/caldav/issues/81, + # incidents with a response without a reason has been observed + # httpx uses reason_phrase, niquests/requests use reason + try: + self.reason = getattr(response, "reason_phrase", None) or response.reason + except AttributeError: + self.reason = "" + + @property + def raw(self) -> str: + """Return the raw response content as a string.""" + if not hasattr(self, "_raw"): + self._raw = etree.tostring(cast(_Element, self.tree), pretty_print=True) + return to_normal_str(self._raw) + + def _strip_to_multistatus(self) -> Union[_Element, List[_Element]]: + """ + The general format of inbound data is something like this: + + + (...) + (...) + (...) + + + but sometimes the multistatus and/or xml element is missing in + self.tree. We don't want to bother with the multistatus and + xml tags, we just want the response list. + + An "Element" in the lxml library is a list-like object, so we + should typically return the element right above the responses. + If there is nothing but a response, return it as a list with + one element. + + (The equivalent of this method could probably be found with a + simple XPath query, but I'm not much into XPath) + """ + tree = self.tree + if tree.tag == "xml" and tree[0].tag == dav.MultiStatus.tag: + return tree[0] + if tree.tag == dav.MultiStatus.tag: + return self.tree + return [self.tree] + + def validate_status(self, status: str) -> None: + """ + status is a string like "HTTP/1.1 404 Not Found". 200, 207 and + 404 are considered good statuses. The SOGo caldav server even + returns "201 created" when doing a sync-report, to indicate + that a resource was created after the last sync-token. This + makes sense to me, but I've only seen it from SOGo, and it's + not in accordance with the examples in rfc6578. + """ + if ( + " 200 " not in status + and " 201 " not in status + and " 207 " not in status + and " 404 " not in status + ): + raise error.ResponseError(status) + + def _parse_response( + self, response: _Element + ) -> Tuple[str, List[_Element], Optional[Any]]: + """ + One response should contain one or zero status children, one + href tag and zero or more propstats. Find them, assert there + isn't more in the response and return those three fields + """ + status = None + href: Optional[str] = None + propstats: List[_Element] = [] + check_404 = False ## special for purelymail + error.assert_(response.tag == dav.Response.tag) + for elem in response: + if elem.tag == dav.Status.tag: + error.assert_(not status) + status = elem.text + error.assert_(status) + self.validate_status(status) + elif elem.tag == dav.Href.tag: + assert not href + # Fix for https://github.com/python-caldav/caldav/issues/471 + # Confluence server quotes the user email twice. We unquote it manually. + if "%2540" in elem.text: + elem.text = elem.text.replace("%2540", "%40") + href = unquote(elem.text) + elif elem.tag == dav.PropStat.tag: + propstats.append(elem) + elif elem.tag == "{DAV:}error": + ## This happens with purelymail on a 404. + ## This code is mostly moot, but in debug + ## mode I want to be sure we do not toss away any data + children = elem.getchildren() + error.assert_(len(children) == 1) + error.assert_( + children[0].tag == "{https://purelymail.com}does-not-exist" + ) + check_404 = True + else: + ## i.e. purelymail may contain one more tag, ... + ## This is probably not a breach of the standard. It may + ## probably be ignored. But it's something we may want to + ## know. + error.weirdness("unexpected element found in response", elem) + error.assert_(href) + if check_404: + error.assert_("404" in status) + ## TODO: is this safe/sane? + ## Ref https://github.com/python-caldav/caldav/issues/435 the paths returned may be absolute URLs, + ## but the caller expects them to be paths. Could we have issues when a server has same path + ## but different URLs for different elements? Perhaps href should always be made into an URL-object? + if ":" in href: + href = unquote(URL(href).path) + return (cast(str, href), propstats, status) + + def _find_objects_and_props(self) -> Dict[str, Dict[str, _Element]]: + """Internal implementation of find_objects_and_props without deprecation warning.""" + self.objects: Dict[str, Dict[str, _Element]] = {} + self.statuses: Dict[str, str] = {} + + if "Schedule-Tag" in self.headers: + self.schedule_tag = self.headers["Schedule-Tag"] + + responses = self._strip_to_multistatus() + for r in responses: + if r.tag == dav.SyncToken.tag: + self.sync_token = r.text + continue + error.assert_(r.tag == dav.Response.tag) + + (href, propstats, status) = self._parse_response(r) + ## I would like to do this assert here ... + # error.assert_(not href in self.objects) + ## but then there was https://github.com/python-caldav/caldav/issues/136 + if href not in self.objects: + self.objects[href] = {} + self.statuses[href] = status + + ## The properties may be delivered either in one + ## propstat with multiple props or in multiple + ## propstat + for propstat in propstats: + cnt = 0 + status = propstat.find(dav.Status.tag) + error.assert_(status is not None) + if status is not None and status.text is not None: + error.assert_(len(status) == 0) + cnt += 1 + self.validate_status(status.text) + ## if a prop was not found, ignore it + if " 404 " in status.text: + continue + for prop in propstat.iterfind(dav.Prop.tag): + cnt += 1 + for theprop in prop: + self.objects[href][theprop.tag] = theprop + + ## there shouldn't be any more elements except for status and prop + error.assert_(cnt == len(propstat)) + + return self.objects + + def find_objects_and_props(self) -> Dict[str, Dict[str, _Element]]: + """Check the response from the server, check that it is on an expected format, + find hrefs and props from it and check statuses delivered. + + The parsed data will be put into self.objects, a dict {href: + {proptag: prop_element}}. Further parsing of the prop_element + has to be done by the caller. + + self.sync_token will be populated if found, self.objects will be populated. + + .. deprecated:: + Use ``response.results`` instead, which provides pre-parsed property values. + This method will be removed in a future version. + """ + warnings.warn( + "find_objects_and_props() is deprecated. Use response.results instead, " + "which provides pre-parsed property values from the protocol layer.", + DeprecationWarning, + stacklevel=2, + ) + return self._find_objects_and_props() + + def _expand_simple_prop( + self, + proptag: str, + props_found: Dict[str, _Element], + multi_value_allowed: bool = False, + xpath: Optional[str] = None, + ) -> Union[str, List[str], None]: + values: List[str] = [] + if proptag in props_found: + prop_xml = props_found[proptag] + for item in prop_xml.items(): + if proptag == "{urn:ietf:params:xml:ns:caldav}calendar-data": + if ( + item[0].lower().endswith("content-type") + and item[1].lower() == "text/calendar" + ): + continue + if item[0].lower().endswith("version") and item[1] in ("2", "2.0"): + continue + log.error( + f"If you see this, please add a report at https://github.com/python-caldav/caldav/issues/209 - in _expand_simple_prop, dealing with {proptag}, extra item found: {'='.join(item)}." + ) + if not xpath and len(prop_xml) == 0: + if prop_xml.text: + values.append(prop_xml.text) + else: + _xpath = xpath if xpath else ".//*" + leafs = prop_xml.findall(_xpath) + values = [] + for leaf in leafs: + error.assert_(not leaf.items()) + if leaf.text: + values.append(leaf.text) + else: + values.append(leaf.tag) + if multi_value_allowed: + return values + else: + if not values: + return None + error.assert_(len(values) == 1) + return values[0] + + ## TODO: word "expand" does not feel quite right. + def expand_simple_props( + self, + props: Optional[Iterable[BaseElement]] = None, + multi_value_props: Optional[Iterable[Any]] = None, + xpath: Optional[str] = None, + ) -> Dict[str, Dict[str, str]]: + """ + The find_objects_and_props() will stop at the xml element + below the prop tag. This method will expand those props into + text. + + Executes find_objects_and_props if not run already, then + modifies and returns self.objects. + """ + props = props or [] + multi_value_props = multi_value_props or [] + + if not hasattr(self, "objects"): + self._find_objects_and_props() + for href in self.objects: + props_found = self.objects[href] + for prop in props: + if prop.tag is None: + continue + + props_found[prop.tag] = self._expand_simple_prop( + prop.tag, props_found, xpath=xpath + ) + for prop in multi_value_props: + if prop.tag is None: + continue + + props_found[prop.tag] = self._expand_simple_prop( + prop.tag, props_found, xpath=xpath, multi_value_allowed=True + ) + # _Element objects in self.objects are parsed to str, thus the need to cast the return + return cast(Dict[str, Dict[str, str]], self.objects) diff --git a/caldav/search.py b/caldav/search.py index c8b4a942..d4fb819f 100644 --- a/caldav/search.py +++ b/caldav/search.py @@ -1,4 +1,6 @@ -from copy import deepcopy +from __future__ import annotations + +import logging from dataclasses import dataclass from dataclasses import field from dataclasses import replace @@ -6,57 +8,54 @@ from typing import Any from typing import List from typing import Optional +from typing import TYPE_CHECKING +from typing import Union from icalendar import Timezone from icalendar.prop import TypesFactory from icalendar_searcher import Searcher from icalendar_searcher.collation import Collation -from lxml import etree from .calendarobjectresource import CalendarObjectResource from .calendarobjectresource import Event from .calendarobjectresource import Journal from .calendarobjectresource import Todo from .collection import Calendar -from .elements import cdav -from .elements import dav -from .elements.base import BaseElement from .lib import error +from .operations.search_ops import _build_search_xml_query +from .operations.search_ops import _collation_to_caldav as collation_to_caldav +from .operations.search_ops import ( + _determine_post_filter_needed as determine_post_filter_needed, +) +from .operations.search_ops import _filter_search_results as filter_search_results +from .operations.search_ops import ( + _get_explicit_contains_properties as get_explicit_contains_properties, +) +from .operations.search_ops import ( + _needs_pending_todo_multi_search as needs_pending_todo_multi_search, +) +from .operations.search_ops import ( + _should_remove_category_filter as should_remove_category_filter, +) +from .operations.search_ops import ( + _should_remove_property_filters_for_combined as should_remove_property_filters_for_combined, +) + +if TYPE_CHECKING: + from .elements import cdav + from .collection import Calendar as AsyncCalendar + from .calendarobjectresource import ( + CalendarObjectResource as AsyncCalendarObjectResource, + Event as AsyncEvent, + Journal as AsyncJournal, + Todo as AsyncTodo, + ) TypesFactory = TypesFactory() -def _collation_to_caldav(collation: Collation, case_sensitive: bool = True) -> str: - """Map icalendar-searcher Collation enum to CalDAV collation identifier. - - CalDAV supports collation identifiers from RFC 4790. The default is "i;ascii-casemap" - and servers must support at least "i;ascii-casemap" and "i;octet". - - :param collation: icalendar-searcher Collation enum value - :param case_sensitive: Whether the collation should be case-sensitive - :return: CalDAV collation identifier string - """ - if collation == Collation.SIMPLE: - # SIMPLE collation maps to CalDAV's basic collations - if case_sensitive: - return "i;octet" - else: - return "i;ascii-casemap" - elif collation == Collation.UNICODE: - # Unicode Collation Algorithm - not all servers support this - # Note: "i;unicode-casemap" is case-insensitive by definition - # For case-sensitive Unicode, we fall back to i;octet (binary) - if case_sensitive: - return "i;octet" - else: - return "i;unicode-casemap" - elif collation == Collation.LOCALE: - # Locale-specific collation - not widely supported in CalDAV - # Fallback to i;ascii-casemap as most servers don't support locale-specific collations - return "i;ascii-casemap" - else: - # Default to binary/octet for unknown collations - return "i;octet" +# Re-export for backward compatibility +_collation_to_caldav = collation_to_caldav @dataclass @@ -73,16 +72,17 @@ class CalDAVSearcher(Searcher): search queries, as well as allowing for more complex searches. A search may be performed by first setting up a CalDAVSearcher, - populate it with filter options, and then initiate the search from - he CalDAVSearcher. Something like this (see the doc in the base - class): + populate it with filter options, and then initiate the search. + The recommended approach (as of 3.0) is to create the searcher + from a calendar: - ``ComponentSearchFilter(from=..., to=...).search(calendar)`` + ``searcher = calendar.searcher(event=True, start=..., end=...)`` + ``searcher.add_property_filter("SUMMARY", "meeting")`` + ``results = searcher.search()`` - However, for simple searches, the old way to - do it will always work: + For simple searches, the direct method call still works: - ``calendar.search(from=..., to=..., ...)`` + ``calendar.search(event=True, start=..., end=..., ...)`` The ``todo``, ``event`` and ``journal`` parameters are booleans for filtering the component type. It's currently recommended to @@ -103,6 +103,7 @@ class CalDAVSearcher(Searcher): comp_class: Optional["CalendarObjectResource"] = None _explicit_operators: set = field(default_factory=set) + _calendar: Optional["Calendar"] = field(default=None, repr=False) def add_property_filter( self, @@ -214,7 +215,7 @@ def _search_with_comptypes( ## TODO: refactor, split more logic out in smaller methods def search( self, - calendar: Calendar, + calendar: Calendar = None, server_expand: bool = False, split_expanded: bool = True, props: Optional[List[cdav.CalendarData]] = None, @@ -227,9 +228,10 @@ def search( Only CalDAV-specific parameters goes to this method. Those parameters are pretty obscure - mostly for power users and internal usage. Unless you have some very special needs, the - recommendation is to not pass anything but the calendar. + recommendation is to not pass anything. - :param calendar: Calendar to be searched + :param calendar: Calendar to be searched (optional if searcher was created + from a calendar via ``calendar.searcher()``) :param server_expand: Ask the CalDAV server to expand recurrences :param split_expanded: Don't collect a recurrence set in one ical calendar :param props: CalDAV properties to send in the query @@ -255,9 +257,14 @@ def search( objects. If you don't know what you're doing, then leave this flag on. - Use ``searcher.search(calendar)`` to apply the search on a caldav server. - """ + if calendar is None: + calendar = self._calendar + if calendar is None: + raise ValueError( + "No calendar provided. Either pass a calendar to search() or " + "create the searcher via calendar.searcher()" + ) ## Handle servers with broken component-type filtering (e.g., Bedework) ## Such servers may misclassify component types in responses comp_type_support = calendar.client.features.is_supported( @@ -365,7 +372,7 @@ def search( ) ## special compatibility-case when searching for pending todos - if self.todo and not self.include_completed: + if self.todo and self.include_completed is False: ## There are two ways to get the pending tasks - we can ## ask the server to filter them out, or we can do it ## client side. @@ -499,7 +506,7 @@ def search( ) raise - ## Some things, like `calendar.object_by_uid`, should always work, no matter if `davclient.compatibility_hints` is correctly configured or not + ## Some things, like `calendar.get_object_by_uid`, should always work, no matter if `davclient.compatibility_hints` is correctly configured or not if not objects and not self.comp_class and _hacks == "insist": return self._search_with_comptypes( calendar, @@ -540,243 +547,379 @@ def search( return self.sort(objects) - def filter( + async def _async_search_with_comptypes( self, - objects: List[CalendarObjectResource], - post_filter: Optional[bool] = None, - split_expanded: bool = True, + calendar: "AsyncCalendar", server_expand: bool = False, - ) -> List[CalendarObjectResource]: - """Apply client-side filtering and handle recurrence expansion/splitting. + split_expanded: bool = True, + props: Optional[List[cdav.CalendarData]] = None, + xml: str = None, + _hacks: str = None, + post_filter: bool = None, + ) -> List["AsyncCalendarObjectResource"]: + """ + Internal async method - does three searches, one for each comp class. + """ + # Import unified types at runtime to avoid circular imports + # These work with both sync and async clients + from .calendarobjectresource import ( + Event as AsyncEvent, + Journal as AsyncJournal, + Todo as AsyncTodo, + ) - This method performs client-side filtering of calendar objects, handles - recurrence expansion, and splits expanded recurrences into separate objects - when requested. + if xml and (isinstance(xml, str) or "calendar-query" in xml.tag): + raise NotImplementedError( + "full xml given, and it has to be patched to include comp_type" + ) + objects: List["AsyncCalendarObjectResource"] = [] - :param objects: List of Event/Todo/Journal objects to filter - :param post_filter: Whether to apply the searcher's filter logic. - - True: Always apply filters (check_component) - - False: Never apply filters, only handle splitting - - None: Use default behavior (depends on self.expand and other flags) - :param split_expanded: Whether to split recurrence sets into multiple - separate CalendarObjectResource objects. If False, a recurrence set - will be contained in a single object with multiple subcomponents. - :param server_expand: Indicates that the server was supposed to expand - recurrences. If True and split_expanded is True, splitting will be - performed even without self.expand being set. - :return: Filtered and/or split list of CalendarObjectResource objects + assert self.event is None and self.todo is None and self.journal is None - The method handles: - - Client-side filtering when server returns too many results - - Exact match filtering (== operator) - - Recurrence expansion via self.check_component - - Splitting expanded recurrences into separate objects - - Preserving VTIMEZONE components when splitting - """ - if post_filter or self.expand or (split_expanded and server_expand): - objects_ = objects - objects = [] - for o in objects_: - if self.expand or post_filter: - filtered = self.check_component(o, expand_only=not post_filter) - if not filtered: - continue - else: - filtered = [ - x - for x in o.icalendar_instance.subcomponents - if not isinstance(x, Timezone) - ] - i = o.icalendar_instance - tz_ = [x for x in i.subcomponents if isinstance(x, Timezone)] - i.subcomponents = tz_ - for comp in filtered: - if isinstance(comp, Timezone): - continue - if split_expanded: - new_obj = o.copy(keep_uid=True) - new_i = new_obj.icalendar_instance - new_i.subcomponents = [] - for tz in tz_: - new_i.add_component(tz) - objects.append(new_obj) - else: - new_i = i - new_i.add_component(comp) - if not (split_expanded): - objects.append(o) - return objects + for comp_class in (AsyncEvent, AsyncTodo, AsyncJournal): + clone = replace(self) + clone.comp_class = comp_class + results = await clone.async_search( + calendar, server_expand, split_expanded, props, xml, post_filter, _hacks + ) + objects.extend(results) + return self.sort(objects) - def build_search_xml_query( - self, server_expand=False, props=None, filters=None, _hacks=None - ): - """This method will produce a caldav search query as an etree object. + async def async_search( + self, + calendar: "AsyncCalendar" = None, + server_expand: bool = False, + split_expanded: bool = True, + props: Optional[List[cdav.CalendarData]] = None, + xml: str = None, + post_filter=None, + _hacks: str = None, + ) -> List["AsyncCalendarObjectResource"]: + """Async version of search() - does the search on an AsyncCalendar. - It is primarily to be used from the search method. See the - documentation for the search method for more information. + This method mirrors the sync search() method but uses async HTTP operations. + All the same compatibility logic is applied. + + See the sync search() method for full documentation. """ - # those xml elements are weird. (a+b)+c != a+(b+c). First makes b and c as list members of a, second makes c an element in b which is an element of a. - # First objective is to let this take over all xml search query building and see that the current tests pass. - # ref https://www.ietf.org/rfc/rfc4791.txt, section 7.8.9 for how to build a todo-query - # We'll play with it and don't mind it's getting ugly and don't mind that the test coverage is lacking. - # we'll refactor and create some unit tests later, as well as ftests for complicated queries. - - # build the request - data = cdav.CalendarData() - if server_expand: - if not self.start or not self.end: - raise error.ReportError("can't expand without a date range") - data += cdav.Expand(self.start, self.end) - if props is None: - props_ = [data] - else: - props_ = [data] + props - prop = dav.Prop() + props_ - vcalendar = cdav.CompFilter("VCALENDAR") + if calendar is None: + calendar = self._calendar + if calendar is None: + raise ValueError( + "No calendar provided. Either pass a calendar to async_search() or " + "create the searcher via calendar.searcher()" + ) - comp_filter = None + # Import unified types at runtime to avoid circular imports + # These work with both sync and async clients + from .calendarobjectresource import ( + Event as AsyncEvent, + Journal as AsyncJournal, + Todo as AsyncTodo, + ) - if filters: - ## It's disgraceful - `somexml = xml + [ more_elements ]` will alter xml, - ## and there exists no `xml.copy` - ## Hence, we need to import the deepcopy tool ... - filters = deepcopy(filters) - if filters.tag == cdav.CompFilter.tag: - comp_filter = filters - filters = [] + ## Handle servers with broken component-type filtering (e.g., Bedework) + comp_type_support = calendar.client.features.is_supported( + "search.comp-type", str + ) + if ( + (self.comp_class or self.todo or self.event or self.journal) + and comp_type_support == "broken" + and not _hacks + and post_filter is not False + ): + _hacks = "no_comp_filter" + post_filter = True - else: - filters = [] - - vNotCompleted = cdav.TextMatch("COMPLETED", negate=True) - vNotCancelled = cdav.TextMatch("CANCELLED", negate=True) - vNeedsAction = cdav.TextMatch("NEEDS-ACTION") - vStatusNotCompleted = cdav.PropFilter("STATUS") + vNotCompleted - vStatusNotCancelled = cdav.PropFilter("STATUS") + vNotCancelled - vStatusNeedsAction = cdav.PropFilter("STATUS") + vNeedsAction - vStatusNotDefined = cdav.PropFilter("STATUS") + cdav.NotDefined() - vNoCompleteDate = cdav.PropFilter("COMPLETED") + cdav.NotDefined() - if _hacks == "ignore_completed1": - ## This query is quite much in line with https://tools.ietf.org/html/rfc4791#section-7.8.9 - filters.extend([vNoCompleteDate, vStatusNotCompleted, vStatusNotCancelled]) - elif _hacks == "ignore_completed2": - ## some server implementations (i.e. NextCloud - ## and Baikal) will yield "false" on a negated TextMatch - ## if the field is not defined. Hence, for those - ## implementations we need to turn back and ask again - ## ... do you have any VTODOs for us where the STATUS - ## field is not defined? (ref - ## https://github.com/python-caldav/caldav/issues/14) - filters.extend([vNoCompleteDate, vStatusNotDefined]) - elif _hacks == "ignore_completed3": - ## ... and considering recurring tasks we really need to - ## look a third time as well, this time for any task with - ## the NEEDS-ACTION status set (do we need the first go? - ## NEEDS-ACTION or no status set should cover them all?) - filters.extend([vStatusNeedsAction]) - - if self.start or self.end: - filters.append(cdav.TimeRange(self.start, self.end)) - - if self.alarm_start or self.alarm_end: - filters.append( - cdav.CompFilter("VALARM") - + cdav.TimeRange(self.alarm_start, self.alarm_end) + ## Setting default value for post_filter + if post_filter is None and ( + (self.todo and not self.include_completed) + or self.expand + or "categories" in self._property_filters + or "category" in self._property_filters + or not calendar.client.features.is_supported("search.text.case-sensitive") + or not calendar.client.features.is_supported("search.time-range.accurate") + ): + post_filter = True + + ## split_expanded should only take effect on expanded data + if not self.expand and not server_expand: + split_expanded = False + + if self.expand or server_expand: + if not self.start or not self.end: + raise error.ReportError("can't expand without a date range") + + ## special compatibility-case for servers that does not + ## support category search properly + things = ("filters", "operator", "locale", "collation") + things = [f"_property_{thing}" for thing in things] + if ( + not calendar.client.features.is_supported("search.text.category") + and ( + "categories" in self._property_filters + or "category" in self._property_filters ) + and post_filter is not False + ): + replacements = {} + for thing in things: + replacements[thing] = getattr(self, thing).copy() + replacements[thing].pop("categories", None) + replacements[thing].pop("category", None) + clone = replace(self, **replacements) + objects = await clone.async_search( + calendar, server_expand, split_expanded, props, xml + ) + return self.filter(objects, post_filter, split_expanded, server_expand) - ## I've designed this badly, at different places the caller - ## may pass the component type either as boolean flags: - ## `search(event=True, ...)` - ## as a component class: - ## `search(comp_class=caldav.calendarobjectresource.Event)` - ## or as a component filter: - ## `search(filters=cdav.CompFilter('VEVENT'), ...)` - ## The only thing I don't support is the component name ('VEVENT'). - ## Anyway, this code section ensures both comp_filter and comp_class - ## is given. Or at least, it tries to ensure it. - for flag, comp_name, comp_class_ in ( - ("event", "VEVENT", Event), - ("todo", "VTODO", Todo), - ("journal", "VJOURNAL", Journal), + ## special compatibility-case for servers that do not support substring search + if ( + not calendar.client.features.is_supported("search.text.substring") + and post_filter is not False ): - flagged = getattr(self, flag) - if flagged: - ## event/journal/todo is set, we adjust comp_class accordingly - if self.comp_class is not None and self.comp_class is not comp_class_: - raise error.ConsistencyError( - f"inconsistent search parameters - comp_class = {self.comp_class}, want {comp_class_}" + explicit_contains = [ + prop + for prop in self._property_operator + if prop in self._explicit_operators + and self._property_operator[prop] == "contains" + ] + if explicit_contains: + replacements = {} + for thing in things: + replacements[thing] = getattr(self, thing).copy() + for prop in explicit_contains: + replacements[thing].pop(prop, None) + clone = replace(self, **replacements) + clone._explicit_operators = self._explicit_operators - set( + explicit_contains + ) + objects = await clone.async_search( + calendar, server_expand, split_expanded, props, xml + ) + return self.filter( + objects, + post_filter=True, + split_expanded=split_expanded, + server_expand=server_expand, + ) + + ## special compatibility-case for servers that do not support combined searches + if not calendar.client.features.is_supported("search.combined-is-logical-and"): + if self.start or self.end: + if self._property_filters: + replacements = {} + for thing in things: + replacements[thing] = {} + clone = replace(self, **replacements) + objects = await clone.async_search( + calendar, server_expand, split_expanded, props, xml + ) + return self.filter( + objects, post_filter, split_expanded, server_expand + ) + + ## special compatibility-case when searching for pending todos + if self.todo and self.include_completed is False: + clone = replace(self, include_completed=True) + clone.include_completed = True + clone.expand = False + if ( + calendar.client.features.is_supported("search.text") + and calendar.client.features.is_supported( + "search.combined-is-logical-and" + ) + and ( + not calendar.client.features.is_supported( + "search.recurrences.includes-implicit.todo" ) - self.comp_class = comp_class_ + or calendar.client.features.is_supported( + "search.recurrences.includes-implicit.todo.pending" + ) + ) + ): + matches: List["AsyncCalendarObjectResource"] = [] + for hacks in ( + "ignore_completed1", + "ignore_completed2", + "ignore_completed3", + ): + results = await clone.async_search( + calendar, + server_expand, + split_expanded=False, + props=props, + xml=xml, + _hacks=hacks, + ) + matches.extend(results) + else: + matches = await clone.async_search( + calendar, + server_expand, + split_expanded=False, + props=props, + xml=xml, + _hacks=_hacks, + ) + objects: List["AsyncCalendarObjectResource"] = [] + match_set = set() + for item in matches: + if item.url not in match_set: + match_set.add(item.url) + objects.append(item) + else: + orig_xml = xml + + if not xml or ( + not isinstance(xml, str) and not xml.tag.endswith("calendar-query") + ): + (xml, self.comp_class) = self.build_search_xml_query( + server_expand, props=props, filters=xml, _hacks=_hacks + ) - if comp_filter and comp_filter.attributes["name"] == comp_name: - self.comp_class = comp_class_ - if flag == "todo" and not self.todo and self.include_completed is None: + # Convert sync comp_class to async equivalent + sync_to_async = { + Event: AsyncEvent, + Todo: AsyncTodo, + Journal: AsyncJournal, + } + async_comp_class = sync_to_async.get(self.comp_class, self.comp_class) + + if not self.comp_class and not calendar.client.features.is_supported( + "search.comp-type-optional" + ): + if self.include_completed is None: self.include_completed = True - setattr(self, flag, True) - - if self.comp_class == comp_class_: - if comp_filter: - assert comp_filter.attributes["name"] == comp_name - else: - comp_filter = cdav.CompFilter(comp_name) - setattr(self, flag, True) - - if self.comp_class and not comp_filter: - raise error.ConsistencyError( - f"unsupported comp class {self.comp_class} for search" - ) - ## Special hack for bedework. - ## If asked for todos, we should NOT give any comp_filter to the server, - ## we should rather ask for everything, and then do client-side filtering - if _hacks == "no_comp_filter": - comp_filter = None - self.comp_class = None - - for property in self._property_operator: - if self._property_operator[property] == "undef": - match = cdav.NotDefined() - filters.append(cdav.PropFilter(property.upper()) + match) - else: - value = self._property_filters[property] - property_ = property.upper() - if property.lower() == "category": - property_ = "CATEGORIES" - if property.lower() == "categories": - values = value.cats - else: - values = [value] - - for value in values: - if hasattr(value, "to_ical"): - value = value.to_ical() - - # Get collation setting for this property if available - collation_str = "i;octet" # Default to binary - if ( - hasattr(self, "_property_collation") - and property in self._property_collation - ): - case_sensitive = self._property_case_sensitive.get( - property, True - ) - collation_str = _collation_to_caldav( - self._property_collation[property], case_sensitive - ) + return await self._async_search_with_comptypes( + calendar, + server_expand, + split_expanded, + props, + orig_xml, + _hacks, + post_filter, + ) - match = cdav.TextMatch(value, collation=collation_str) - filters.append(cdav.PropFilter(property_) + match) + try: + (response, objects) = await calendar._request_report_build_resultlist( + xml, async_comp_class, props=props + ) - if comp_filter and filters: - comp_filter += filters - vcalendar += comp_filter - elif comp_filter: - vcalendar += comp_filter - elif filters: - vcalendar += filters + except error.ReportError as err: + if ( + calendar.client.features.backward_compatibility_mode + and not self.comp_class + and "400" not in err.reason + ): + return await self._async_search_with_comptypes( + calendar, + server_expand, + split_expanded, + props, + orig_xml, + _hacks, + post_filter, + ) + raise - filter = cdav.Filter() + vcalendar + if not objects and not self.comp_class and _hacks == "insist": + return await self._async_search_with_comptypes( + calendar, + server_expand, + split_expanded, + props, + orig_xml, + _hacks, + post_filter, + ) - root = cdav.CalendarQuery() + [prop, filter] + obj2: List["AsyncCalendarObjectResource"] = [] + for o in objects: + try: + # load() may return self (sync) or coroutine (async) depending on state + result = o.load(only_if_unloaded=True) + import inspect + + if inspect.isawaitable(result): + await result + obj2.append(o) + except Exception: + import logging + + logging.error( + "Server does not want to reveal details about the calendar object", + exc_info=True, + ) + objects = obj2 - return (root, self.comp_class) + ## Google sometimes returns empty objects + objects = [o for o in objects if o.has_component()] + objects = self.filter(objects, post_filter, split_expanded, server_expand) + + ## partial workaround for https://github.com/python-caldav/caldav/issues/201 + for obj in objects: + try: + # load() may return self (sync) or coroutine (async) depending on state + result = obj.load(only_if_unloaded=True) + import inspect + + if inspect.isawaitable(result): + await result + except Exception: + pass + + return self.sort(objects) + + def filter( + self, + objects: List[CalendarObjectResource], + post_filter: Optional[bool] = None, + split_expanded: bool = True, + server_expand: bool = False, + ) -> List[CalendarObjectResource]: + """Apply client-side filtering and handle recurrence expansion/splitting. + + This method delegates to the operations layer filter_search_results(). + See that function for full documentation. + + :param objects: List of Event/Todo/Journal objects to filter + :param post_filter: Whether to apply the searcher's filter logic + :param split_expanded: Whether to split recurrence sets into separate objects + :param server_expand: Whether server was asked to expand recurrences + :return: Filtered and/or split list of CalendarObjectResource objects + """ + return filter_search_results( + objects=objects, + searcher=self, + post_filter=post_filter, + split_expanded=split_expanded, + server_expand=server_expand, + ) + + def build_search_xml_query( + self, server_expand=False, props=None, filters=None, _hacks=None + ): + """Build a CalDAV calendar-query XML request. + + Delegates to the operations layer for the actual XML building. + This method updates self.comp_class as a side effect based on + the search parameters. + + :param server_expand: Ask server to expand recurrences + :param props: Additional CalDAV properties to request + :param filters: Pre-built filter elements (or None to build from self) + :param _hacks: Compatibility hack mode + :return: Tuple of (xml_element, comp_class) + """ + xml, comp_class = _build_search_xml_query( + searcher=self, + server_expand=server_expand, + props=props, + filters=filters, + _hacks=_hacks, + ) + # Update self.comp_class from the result (side effect for compatibility) + self.comp_class = comp_class + return (xml, comp_class) diff --git a/docs/design/API_ANALYSIS.md b/docs/design/API_ANALYSIS.md new file mode 100644 index 00000000..3fd27adb --- /dev/null +++ b/docs/design/API_ANALYSIS.md @@ -0,0 +1,620 @@ +# DAVClient API Analysis and Improvement Suggestions + +## Current API Overview + +### DAVClient Public Methods (caldav/davclient.py) + +```python +class DAVClient: + # Constructor + __init__(url, proxy, username, password, auth, auth_type, timeout, + ssl_verify_cert, ssl_cert, headers, huge_tree, features, + enable_rfc6764, require_tls) + + # Context manager + __enter__() -> Self + __exit__(...) -> None + close() -> None + + # High-level API + principals(name=None) -> List[Principal] + principal(*args, **kwargs) -> Principal + calendar(**kwargs) -> Calendar + + # Capability checks + check_dav_support() -> Optional[str] + check_cdav_support() -> bool + check_scheduling_support() -> bool + + # HTTP methods (CalDAV/WebDAV) + propfind(url: Optional[str], props: str, depth: int) -> DAVResponse + proppatch(url: str, body: str, dummy: None) -> DAVResponse + report(url: str, query: str, depth: int) -> DAVResponse + mkcol(url: str, body: str, dummy: None) -> DAVResponse + mkcalendar(url: str, body: str, dummy: None) -> DAVResponse + put(url: str, body: str, headers: Mapping[str, str]) -> DAVResponse + post(url: str, body: str, headers: Mapping[str, str]) -> DAVResponse + delete(url: str) -> DAVResponse + options(url: str) -> DAVResponse + + # Low-level + request(url: str, method: str, body: str, headers: Mapping[str, str]) -> DAVResponse + extract_auth_types(header: str) -> Set[str] + build_auth_object(auth_types: Optional[List[str]]) -> None +``` + +--- + +## API Inconsistencies + +### 1. **Inconsistent URL Parameter Handling** + +**Issue:** Some methods accept `Optional[str]`, others require `str` + +```python +# Inconsistent: +propfind(url: Optional[str] = None, ...) # Can be None, defaults to self.url +proppatch(url: str, ...) # Required +delete(url: str) # Required +``` + +**Research Finding:** (See URL_AND_METHOD_RESEARCH.md for full analysis) + +The inconsistency exists for **good reasons**: +- `self.url` is the **base CalDAV URL** (e.g., `https://caldav.example.com/`) +- Query methods (`propfind`, `report`, `options`) often query the base URL ✓ +- Resource methods (`put`, `delete`, `post`, etc.) always target **specific resources** ✗ + +Making `delete(url=None)` would be **dangerous** - could accidentally try to delete the entire CalDAV server! + +**Recommendation:** +- **Query methods** (`propfind`, `report`, `options`): Optional URL, defaults to `self.url` ✓ +- **Resource methods** (`put`, `delete`, `post`, `proppatch`, `mkcol`, `mkcalendar`): **Required URL** ✓ + +```python +# Proposed (async API): +# Query methods - safe defaults +async def propfind(url: Optional[str] = None, ...) -> DAVResponse: +async def report(url: Optional[str] = None, ...) -> DAVResponse: +async def options(url: Optional[str] = None, ...) -> DAVResponse: + +# Resource methods - URL required for safety +async def put(url: str, ...) -> DAVResponse: +async def delete(url: str, ...) -> DAVResponse: # MUST be explicit! +async def post(url: str, ...) -> DAVResponse: +async def proppatch(url: str, ...) -> DAVResponse: +async def mkcol(url: str, ...) -> DAVResponse: +async def mkcalendar(url: str, ...) -> DAVResponse: +``` + +### 2. **Dummy Parameters** + +**Issue:** Several methods have `dummy: None = None` parameter + +```python +proppatch(url: str, body: str, dummy: None = None) +mkcol(url: str, body: str, dummy: None = None) +mkcalendar(url: str, body: str = "", dummy: None = None) +``` + +**Background:** Appears to be for backward compatibility + +**Recommendation:** +- **Remove in async API** - no need to maintain this backward compatibility +- Document as deprecated in current sync API + +```python +# Proposed (async): +async def proppatch(url: Optional[str] = None, body: str = "") -> DAVResponse: + ... +``` + +### 3. **Inconsistent Body Parameter Defaults** + +**Issue:** Some methods have default empty body, others don't + +```python +request(url: str, method: str = "GET", body: str = "", ...) # Default "" +propfind(url: Optional[str] = None, props: str = "", ...) # Default "" +mkcalendar(url: str, body: str = "", ...) # Default "" +proppatch(url: str, body: str, ...) # Required +mkcol(url: str, body: str, ...) # Required +``` + +**Recommendation:** +- Make body optional with default `""` for all methods +- This is more user-friendly + +```python +# Proposed: +async def proppatch(url: Optional[str] = None, body: str = "") -> DAVResponse: +async def mkcol(url: Optional[str] = None, body: str = "") -> DAVResponse: +``` + +### 4. **Inconsistent Headers Parameter** + +**Issue:** Only some methods accept headers parameter + +```python +request(url, method, body, headers: Mapping[str, str] = None) +put(url, body, headers: Mapping[str, str] = None) +post(url, body, headers: Mapping[str, str] = None) +propfind(...) # No headers parameter +report(...) # Hardcodes headers internally +``` + +**Recommendation:** +- Add optional `headers` parameter to ALL HTTP methods +- Merge with default headers in `request()` + +```python +# Proposed: +async def propfind( + url: Optional[str] = None, + props: str = "", + depth: int = 0, + headers: Optional[Mapping[str, str]] = None, +) -> DAVResponse: +``` + +### 5. **Method Naming Inconsistency** + +**Issue:** Mix of snake_case and noun-based names, unclear distinction between important/unimportant methods + +```python +# Good (verb-based, consistent): +propfind() +proppatch() +mkcol() + +# Inconsistent (check_ prefix vs methods): +check_dav_support() +check_cdav_support() +check_scheduling_support() + +# Getters without clear naming: +principal() # IMPORTANT: Works on all servers, gets current user's principal +principals(name=None) # UNIMPORTANT: Search/query, works on few servers +calendar() # Factory method, no server interaction +``` + +**Background on principals():** +- Uses `PrincipalPropertySearch` REPORT (RFC3744) +- Currently filters by `DisplayName` when `name` is provided +- Could be extended to filter by other properties (email, etc.) +- Only works on servers that support principal-property-search +- Less commonly used than `principal()` + +**Recommendation:** +- Keep existing names for backward compatibility in sync wrapper +- In async API, use clearer, more Pythonic names that indicate importance: + +```python +# Proposed (async API only): +async def get_principal() -> Principal: + """Get the current user's principal (works on all servers)""" + +async def search_principals( + name: Optional[str] = None, + email: Optional[str] = None, + # Future: other search filters +) -> List[Principal]: + """Search for principals using PrincipalPropertySearch (may not work on all servers)""" + +async def get_calendar(**kwargs) -> Calendar: + """Create a Calendar object (no server interaction)""" + +async def supports_dav() -> bool: +async def supports_caldav() -> bool: +async def supports_scheduling() -> bool: +``` + +### 6. **Return Type Inconsistencies** + +**Issue:** Some methods return DAVResponse, others return domain objects + +```python +propfind() -> DAVResponse # Low-level +principals() -> List[Principal] # High-level +principal() -> Principal # High-level +``` + +**This is actually OK** - Clear separation between low-level HTTP and high-level domain methods + +**Recommendation:** Keep this distinction, but document it clearly + +### 7. **Parameter Naming: `props` vs `query` vs `body`** + +**Issue:** XML content is named inconsistently + +```python +propfind(url, props: str = "", depth) # "props" +report(url, query: str = "", depth) # "query" +proppatch(url, body: str, dummy) # "body" +mkcol(url, body: str, dummy) # "body" +``` + +**Research Finding:** + +DAVObject._query() uses dynamic dispatch: +```python +ret = getattr(self.client, query_method)(url, body, depth) +``` + +This means all methods must have compatible signatures for when called via `_query(propfind/proppatch/mkcol/mkcalendar)`. + +**Recommendation:** +- Standardize on `body` for all methods to enable consistent dynamic dispatch +- More generic and works for all HTTP methods + +```python +# Proposed (async API): +async def propfind(url=None, body: str = "", depth: int = 0) -> DAVResponse: +async def report(url=None, body: str = "", depth: int = 0) -> DAVResponse: +async def proppatch(url, body: str = "") -> DAVResponse: +async def mkcol(url, body: str = "") -> DAVResponse: +async def mkcalendar(url, body: str = "") -> DAVResponse: + +# Sync wrapper maintains old names: +def propfind(self, url=None, props="", depth=0): # "props" for backward compat + return asyncio.run(self._async.propfind(url, props, depth)) +``` + +### 8. **Depth Parameter Inconsistency** + +**Issue:** Only some methods have depth parameter + +```python +propfind(url, props, depth: int = 0) +report(url, query, depth: int = 0) +# But put(), post(), delete(), etc. don't have depth +``` + +**This is actually correct** - only PROPFIND and REPORT use Depth header + +**Recommendation:** Keep as-is + +### 9. **Auth Methods Are Public But Internal** + +**Issue:** Methods that should be private are public + +```python +extract_auth_types(header: str) # Should be _extract_auth_types +build_auth_object(...) # Should be _build_auth_object +``` + +**Recommendation:** +- Prefix with `_` in async API +- Keep public in sync wrapper for backward compatibility + +### 10. **Type Hints Inconsistency** + +**Issue:** Some parameters have type hints, some don't + +```python +principals(self, name=None): # No type hints +principal(self, *largs, **kwargs): # No type hints +propfind(url: Optional[str] = None, ...) # Has type hints +``` + +**Recommendation:** +- Add complete type hints to async API +- Improves IDE support and catches bugs + +--- + +## Proposed Async API Design + +### Core Principles + +1. **Consistency first** - uniform parameter ordering and naming +2. **Pythonic** - follows Python naming conventions +3. **Type-safe** - complete type hints +4. **Clean** - no backward compatibility baggage +5. **Explicit** - clear parameter names + +### Proposed Method Signatures + +```python +class AsyncDAVClient: + """Modern async CalDAV/WebDAV client""" + + def __init__( + self, + url: str, + *, # Force keyword arguments + username: Optional[str] = None, + password: Optional[str] = None, + auth: Optional[AuthBase] = None, + auth_type: Optional[Literal["basic", "digest", "bearer"]] = None, + proxy: Optional[str] = None, + timeout: int = 90, + verify_ssl: bool = True, + ssl_cert: Optional[Union[str, Tuple[str, str]]] = None, + headers: Optional[Dict[str, str]] = None, + huge_tree: bool = False, + features: Optional[Union[FeatureSet, Dict, str]] = None, + enable_rfc6764: bool = True, + require_tls: bool = True, + ) -> None: + ... + + # Context manager + async def __aenter__(self) -> Self: + ... + + async def __aexit__(self, *args) -> None: + ... + + async def close(self) -> None: + """Close the session""" + ... + + # High-level API (Pythonic names) + async def get_principal(self) -> Principal: + """Get the current user's principal (works on all servers)""" + ... + + async def search_principals( + self, + name: Optional[str] = None, + email: Optional[str] = None, + **filters, + ) -> List[Principal]: + """ + Search for principals using PrincipalPropertySearch. + + May not work on all servers. Uses REPORT with principal-property-search. + + Args: + name: Filter by display name + email: Filter by email address (if supported) + **filters: Additional property filters for future extensibility + """ + ... + + async def get_calendar(self, **kwargs) -> Calendar: + """Create a Calendar object (no server interaction, factory method)""" + ... + + # Capability checks (renamed for clarity) + async def supports_dav(self) -> bool: + """Check if server supports WebDAV (RFC4918)""" + ... + + async def supports_caldav(self) -> bool: + """Check if server supports CalDAV (RFC4791)""" + ... + + async def supports_scheduling(self) -> bool: + """Check if server supports CalDAV Scheduling (RFC6833)""" + ... + + # HTTP methods - split by URL semantics (see URL_AND_METHOD_RESEARCH.md) + + # Query methods - URL optional (defaults to self.url) + async def propfind( + self, + url: Optional[str] = None, # Defaults to self.url + body: str = "", + depth: int = 0, + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """PROPFIND request. Defaults to querying the base CalDAV URL.""" + ... + + async def report( + self, + url: Optional[str] = None, # Defaults to self.url + body: str = "", + depth: int = 0, + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """REPORT request. Defaults to querying the base CalDAV URL.""" + ... + + async def options( + self, + url: Optional[str] = None, # Defaults to self.url + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """OPTIONS request. Defaults to querying the base CalDAV URL.""" + ... + + # Resource methods - URL required (safety!) + async def proppatch( + self, + url: str, # REQUIRED - targets specific resource + body: str = "", + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """PROPPATCH request to update properties of a specific resource.""" + ... + + async def mkcol( + self, + url: str, # REQUIRED - creates at specific path + body: str = "", + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """MKCOL request to create a collection at a specific path.""" + ... + + async def mkcalendar( + self, + url: str, # REQUIRED - creates at specific path + body: str = "", + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """MKCALENDAR request to create a calendar at a specific path.""" + ... + + async def put( + self, + url: str, # REQUIRED - targets specific resource + body: str = "", + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """PUT request to create/update a specific resource.""" + ... + + async def post( + self, + url: str, # REQUIRED - posts to specific endpoint + body: str = "", + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """POST request to a specific endpoint.""" + ... + + async def delete( + self, + url: str, # REQUIRED - safety critical! + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """DELETE request to remove a specific resource. URL must be explicit for safety.""" + ... + + # Low-level request method + async def request( + self, + url: Optional[str] = None, + method: str = "GET", + body: str = "", + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """Low-level HTTP request""" + ... + + # Internal methods (private) + def _extract_auth_types(self, header: str) -> Set[str]: + """Extract auth types from WWW-Authenticate header""" + ... + + async def _build_auth_object( + self, auth_types: Optional[List[str]] = None + ) -> None: + """Build auth object based on available auth types""" + ... +``` + +--- + +## Summary of Changes + +### High Priority (Consistency & Safety) + +1. ✅ **Split URL requirements** (see URL_AND_METHOD_RESEARCH.md): + - Optional for query methods: `propfind`, `report`, `options` + - **Required for resource methods**: `put`, `delete`, `post`, `proppatch`, `mkcol`, `mkcalendar` +2. ✅ Remove `dummy` parameters +3. ✅ Make `body` parameter optional everywhere (default to `""`) +4. ✅ Add `headers` parameter to all HTTP methods +5. ✅ Standardize parameter naming (`body` instead of `props`/`query`) for dynamic dispatch compatibility + +### Medium Priority (Pythonic) + +6. ⚠️ Rename methods for clarity (only in async API): + - `check_*` → `supports_*` + - `principals()` → `search_principals()` (better reflects it's a search/query operation) + - `principal()` → `get_principal()` (the important one that works everywhere) + - `calendar()` → `get_calendar()` (or keep as factory method?) + +7. ✅ Make internal methods private (`_extract_auth_types`, `_build_auth_object`) +8. ✅ Add complete type hints everywhere + +### Low Priority (Nice to Have) + +9. Add better defaults and validation +10. Improve docstrings with examples + +--- + +## Backward Compatibility Strategy + +The sync wrapper (`davclient.py`) will maintain 100% backward compatibility: + +```python +class DAVClient: + """Synchronous wrapper around AsyncDAVClient for backward compatibility""" + + def __init__(self, *args, **kwargs): + self._async_client = AsyncDAVClient(*args, **kwargs) + + def propfind(self, url: Optional[str] = None, props: str = "", depth: int = 0): + """Sync wrapper - maintains old signature with 'props' parameter name""" + return asyncio.run(self._async_client.propfind(url, props, depth)) + + def proppatch(self, url: str, body: str, dummy: None = None): + """Sync wrapper - maintains old signature with dummy parameter""" + return asyncio.run(self._async_client.proppatch(url, body)) + + # ... etc for all methods +``` + +--- + +## Testing Strategy + +### 1. New Async Tests + +Create `tests/test_async_davclient.py`: +- Test all async methods +- Test context manager behavior +- Test authentication flows +- Test error handling + +### 2. Existing Tests Must Pass + +All existing tests in `tests/test_caldav.py`, `tests/test_caldav_unit.py`, etc. must continue to pass with the sync wrapper. + +### 3. Integration Tests + +Test against real CalDAV servers (Radicale, Baikal, etc.) using both: +- Sync API (backward compatibility) +- Async API (new functionality) + +--- + +## Implementation Plan + +### Phase 1: Preparation +1. ✅ Analyze current API (this document) +2. Create backup branch +3. Ensure all tests pass on current code + +### Phase 2: Create Async Core +1. Copy `davclient.py` → `async_davclient.py` +2. Convert to async (add `async def`, use `AsyncSession`) +3. Clean up API inconsistencies +4. Add complete type hints +5. Write async tests + +### Phase 3: Create Sync Wrapper +1. Rewrite `davclient.py` as thin sync wrapper +2. Maintain 100% backward compatibility +3. Verify all old tests still pass + +### Phase 4: Documentation +1. Update README with async examples +2. Add migration guide +3. Document API improvements + +--- + +## Questions for Discussion + +1. **Method renaming**: Should we rename methods in async API (e.g., `check_dav_support` → `supports_dav`) or keep exact names? + - **Recommendation**: Rename for clarity, maintain old names in sync wrapper + +2. **URL parameter**: Should it be optional or required? + - **Recommendation**: Optional with default `self.url` for convenience + +3. **Type hints**: Should we use strict types (`str`) or flexible (`Union[str, URL]`)? + - **Recommendation**: Accept `Union[str, URL]` for flexibility, normalize internally + +4. **Auth handling**: Should auth retry logic stay in `request()` or be separate? + - **Recommendation**: Keep in `request()` for consistency + +5. **Error handling**: Should we create custom exception hierarchy? + - **Recommendation**: Keep existing error classes, they work well diff --git a/docs/design/API_NAMING_CONVENTIONS.md b/docs/design/API_NAMING_CONVENTIONS.md new file mode 100644 index 00000000..c159513f --- /dev/null +++ b/docs/design/API_NAMING_CONVENTIONS.md @@ -0,0 +1,184 @@ +# API Naming Conventions + +This document describes the API naming conventions for the caldav library, including guidance on legacy vs recommended method names. + +## Overview + +The caldav library maintains backward compatibility while introducing cleaner API names. Both sync (`DAVClient`) and async (`AsyncDAVClient`) clients support the recommended API names. + +## DAVClient Methods + +### Principal Access + +| Recommended | Legacy | Notes | +|-------------|--------|-------| +| `get_principal()` | `principal()` | Returns the Principal object for the authenticated user | +| `search_principals(name=None)` | `principals(name=None)` | Search for principals on the server | + +**Example:** +```python +# Recommended +principal = client.get_principal() + +# Legacy (still works, but not recommended for new code) +principal = client.principal() +``` + +### Capability Checks + +| Recommended | Legacy | Notes | +|-------------|--------|-------| +| `supports_dav()` | `check_dav_support()` | Returns DAV support string or None | +| `supports_caldav()` | `check_cdav_support()` | Returns True if CalDAV is supported | +| `supports_scheduling()` | `check_scheduling_support()` | Returns True if RFC6638 scheduling is supported | + +**Example:** +```python +# Recommended +if client.supports_caldav(): + calendars = client.get_calendars() + +# Legacy (still works, but not recommended for new code) +if client.check_cdav_support(): + calendars = client.get_calendars() +``` + +### Calendar and Event Access + +These methods use the recommended naming and are available in both sync and async clients: + +| Method | Description | +|--------|-------------| +| `get_calendars(principal=None)` | Get all calendars for a principal | +| `get_events(calendar_url, start, end)` | Get events in a date range | +| `get_todos(calendar_url, ...)` | Get todos with optional filters | +| `search_calendar(calendar_url, ...)` | Search calendar with flexible criteria | + +## Calendar Methods + +### Adding Objects + +| Recommended | Legacy | Notes | +|-------------|--------|-------| +| `add_event(...)` | `save_event(...)` | `save_event` is deprecated; use `add_event` for adding new events | +| `add_todo(...)` | `save_todo(...)` | `save_todo` is deprecated; use `add_todo` for adding new todos | +| `add_journal(...)` | `save_journal(...)` | `save_journal` is deprecated; use `add_journal` for adding new journals | +| `add_object(...)` | `save_object(...)` | `save_object` is deprecated; use `add_object` for adding new objects | + +**Note:** These methods are for *adding* new content to the calendar. To update an existing object, fetch it first and use `object.save()`. + +See https://github.com/python-caldav/caldav/issues/71 for rationale. + +### Getting Objects by UID + +| Recommended | Legacy | Notes | +|-------------|--------|-------| +| `get_event_by_uid(uid)` | `event_by_uid(uid)` | `event_by_uid` is deprecated | +| `get_todo_by_uid(uid)` | `todo_by_uid(uid)` | `todo_by_uid` is deprecated | +| `get_journal_by_uid(uid)` | `journal_by_uid(uid)` | `journal_by_uid` is deprecated | +| `get_object_by_uid(uid)` | `object_by_uid(uid)` | `object_by_uid` is deprecated | + +### Listing Objects + +| Recommended | Legacy | Notes | +|-------------|--------|-------| +| `principal.get_calendars()` | `principal.calendars()` | `calendars` is deprecated | +| `calendar.get_events()` | `calendar.events()` | `events` is deprecated | +| `calendar.get_todos()` | `calendar.todos()` | `todos` is deprecated | +| `calendar.get_journals()` | `calendar.journals()` | `journals` is deprecated | +| `calendar.get_objects_by_sync_token()` | `calendar.objects_by_sync_token()` | `objects_by_sync_token` is deprecated | + +### Search Methods + +| Recommended | Legacy | Notes | +|-------------|--------|-------| +| `search(...)` | `date_search(...)` | `date_search` is deprecated; use `search` instead | + +**Example:** +```python +# Recommended +events = calendar.search( + start=datetime(2024, 1, 1), + end=datetime(2024, 12, 31), + event=True, + expand=True +) + +# Legacy (deprecated, emits DeprecationWarning) +events = calendar.date_search( + start=datetime(2024, 1, 1), + end=datetime(2024, 12, 31), + expand=True +) +``` + +## Deprecation Timeline + +### Deprecated in 3.0 (will be removed in 4.0) + +- `Calendar.date_search()` - use `Calendar.search()` instead +- `DAVClient.principals()` - use `DAVClient.search_principals()` instead +- `CalendarObjectResource.expand_rrule()` - expansion is handled by `search(expand=True)` +- `CalendarObjectResource.split_expanded()` - expansion is handled by `search(expand=True)` + +### Legacy but Supported + +The following methods are considered "legacy" but will continue to work. New code should prefer the recommended alternatives: + +- `DAVClient.principal()` - use `get_principal()` instead +- `DAVClient.principals()` - use `search_principals()` instead (deprecated with warning) +- `DAVClient.check_dav_support()` - use `supports_dav()` instead +- `DAVClient.check_cdav_support()` - use `supports_caldav()` instead +- `DAVClient.check_scheduling_support()` - use `supports_scheduling()` instead +- `Calendar.save_event()` - use `add_event()` instead (see issue #71) +- `Calendar.save_todo()` - use `add_todo()` instead (see issue #71) +- `Calendar.save_journal()` - use `add_journal()` instead (see issue #71) +- `Calendar.save_object()` - use `add_object()` instead (see issue #71) +- `Calendar.event_by_uid()` - use `get_event_by_uid()` instead +- `Calendar.todo_by_uid()` - use `get_todo_by_uid()` instead +- `Calendar.journal_by_uid()` - use `get_journal_by_uid()` instead +- `Calendar.object_by_uid()` - use `get_object_by_uid()` instead +- `Principal.calendars()` - use `get_calendars()` instead +- `Calendar.events()` - use `get_events()` instead +- `Calendar.todos()` - use `get_todos()` instead +- `Calendar.journals()` - use `get_journals()` instead +- `Calendar.objects_by_sync_token()` - use `get_objects_by_sync_token()` instead + +## Rationale + +The new naming conventions follow these principles: + +1. **Consistency**: Same method names work in both sync and async clients +2. **Clarity**: `get_*` prefix for methods that retrieve data +3. **Readability**: `supports_*` is more natural than `check_*_support` +4. **Python conventions**: Method names follow PEP 8 style + +## Migration Guide + +### From caldav 2.x to 3.x + +1. Replace `date_search()` with `search()`: + ```python + # Before + events = calendar.date_search(start, end, expand=True) + + # After + events = calendar.search(start=start, end=end, event=True, expand=True) + ``` + +2. Optionally update to new naming conventions: + ```python + # Before + principal = client.principal() + if client.check_cdav_support(): + ... + + # After (recommended) + principal = client.get_principal() + if client.supports_caldav(): + ... + ``` + +3. Remove usage of deprecated methods: + - `expand_rrule()` - use `search(expand=True)` instead + - `split_expanded()` - use `search(expand=True)` instead diff --git a/docs/design/CODE_FLOW.md b/docs/design/CODE_FLOW.md new file mode 100644 index 00000000..3d9d7c0e --- /dev/null +++ b/docs/design/CODE_FLOW.md @@ -0,0 +1,337 @@ +# Code Flow for Common CalDAV Operations + +**Last Updated:** January 2026 + +This document explains how the caldav library processes common operations, showing the code flow through the layered architecture for both synchronous and asynchronous usage. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ User Application │ +└─────────────────────────┬───────────────────────────────────┘ + │ +┌─────────────────────────▼───────────────────────────────────┐ +│ Domain Objects (Dual-Mode) │ +│ Calendar, Principal, Event, Todo, Journal, FreeBusy │ +│ caldav/collection.py, caldav/objects/*.py │ +└─────────────────────────┬───────────────────────────────────┘ + │ +┌─────────────────────────▼───────────────────────────────────┐ +│ Operations Layer (Pure Python) │ +│ caldav/operations/*.py │ +│ - Builds requests using Protocol Layer │ +│ - Returns request descriptors (no I/O) │ +└─────────────────────────┬───────────────────────────────────┘ + │ +┌─────────────────────────▼───────────────────────────────────┐ +│ Protocol Layer (Sans-I/O) │ +│ caldav/protocol/ │ +│ - xml_builders.py: Build XML bodies │ +│ - xml_parsers.py: Parse XML responses │ +│ - types.py: DAVRequest, DAVResponse, result dataclasses │ +└─────────────────────────┬───────────────────────────────────┘ + │ +┌─────────────────────────▼───────────────────────────────────┐ +│ DAVClient (Sync) / AsyncDAVClient (Async) │ +│ caldav/davclient.py, caldav/async_davclient.py │ +│ - Executes HTTP requests via niquests/httpx │ +│ - Handles authentication │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Flow 1: Fetching Calendars (Sync) + +**User Code:** +```python +from caldav import DAVClient + +client = DAVClient(url="https://server/dav/", username="user", password="pass") +principal = client.principal() +calendars = principal.get_calendars() +``` + +**Internal Flow:** + +``` +1. client.principal() + └─► Principal(client=self, url=self.url) + +2. principal.get_calendars() + │ + ├─► _get_calendar_home_set() + │ ├─► Protocol: build_propfind_body(["{DAV:}current-user-principal"]) + │ ├─► Client: propfind(url, body, depth=0) + │ │ └─► HTTP PROPFIND → Response + │ └─► Protocol: parse_propfind_response(response.body) + │ + ├─► Protocol: build_propfind_body(["{DAV:}resourcetype", ...]) + │ + ├─► Client: propfind(calendar_home_url, body, depth=1) + │ └─► HTTP PROPFIND → Response + │ + ├─► Protocol: parse_propfind_response(response.body) + │ + └─► Returns: [Calendar(...), Calendar(...), ...] +``` + +**Key Files:** +- `caldav/davclient.py:DAVClient.principal()` (line ~470) +- `caldav/collection.py:Principal.get_calendars()` (line ~290) +- `caldav/protocol/xml_builders.py:_build_propfind_body()` +- `caldav/protocol/xml_parsers.py:_parse_propfind_response()` + +## Flow 2: Fetching Calendars (Async) + +**User Code:** +```python +from caldav.aio import AsyncDAVClient + +async with AsyncDAVClient(url="https://server/dav/", username="user", password="pass") as client: + principal = await client.principal() + calendars = await principal.get_calendars() +``` + +**Internal Flow:** + +``` +1. await client.principal() + └─► Principal(client=self, url=self.url) + (Principal detects async client, enables async mode) + +2. await principal.get_calendars() + │ + ├─► await _get_calendar_home_set() + │ ├─► Protocol: build_propfind_body(...) # Same as sync + │ ├─► await Client: propfind(...) + │ │ └─► async HTTP PROPFIND → Response + │ └─► Protocol: parse_propfind_response(...) # Same as sync + │ + ├─► await Client: propfind(calendar_home_url, ...) + │ + └─► Returns: [Calendar(...), Calendar(...), ...] +``` + +**Key Difference:** Domain objects (Calendar, Principal, etc.) are "dual-mode" - they detect whether they have a sync or async client and behave accordingly. The Protocol layer is identical for both. + +## Flow 3: Creating an Event + +**User Code (Sync):** +```python +calendar.add_event( + dtstart=datetime(2024, 6, 15, 10, 0), + dtend=datetime(2024, 6, 15, 11, 0), + summary="Meeting" +) +``` + +**User Code (Async):** +```python +await calendar.add_event( + dtstart=datetime(2024, 6, 15, 10, 0), + dtend=datetime(2024, 6, 15, 11, 0), + summary="Meeting" +) +``` + +**Internal Flow:** + +``` +1. calendar.add_event(dtstart, dtend, summary, ...) + │ + ├─► Build iCalendar data (icalendar library) + │ └─► VCALENDAR with VEVENT component + │ + ├─► Generate URL: calendar.url + uuid + ".ics" + │ + ├─► Client: put(url, data, headers={"Content-Type": "text/calendar"}) + │ └─► HTTP PUT → Response (201 Created) + │ + └─► Returns: Event(client, url, data, parent=calendar) +``` + +**Key Files:** +- `caldav/collection.py:Calendar.add_event()` (line ~880) +- `caldav/objects/base.py:CalendarObjectResource.save()` (line ~230) + +## Flow 4: Searching for Events + +**User Code:** +```python +events = calendar.search( + start=datetime(2024, 1, 1), + end=datetime(2024, 12, 31), + event=True +) +``` + +**Internal Flow:** + +``` +1. calendar.search(start, end, event=True) + │ + ├─► Protocol: build_calendar_query_body(start, end, event=True) + │ └─► Returns: (xml_body, "VEVENT") + │ + ├─► Client: report(calendar.url, body, depth=1) + │ └─► HTTP REPORT → Response (207 Multi-Status) + │ + ├─► Protocol: parse_calendar_query_response(response.body) + │ └─► Returns: [CalendarQueryResult(href, etag, calendar_data), ...] + │ + ├─► Wrap results in Event objects + │ + └─► Returns: [Event(...), Event(...), ...] +``` + +**Key Files:** +- `caldav/collection.py:Calendar.search()` (line ~670) +- `caldav/search.py:CalDAVSearcher` (handles complex search logic) +- `caldav/protocol/xml_builders.py:_build_calendar_query_body()` +- `caldav/protocol/xml_parsers.py:_parse_calendar_query_response()` + +## Flow 5: Sync Token Synchronization + +**User Code:** +```python +# Initial sync +sync_token, items = calendar.get_objects_by_sync_token() + +# Incremental sync +sync_token, changed, deleted = calendar.get_objects_by_sync_token(sync_token=sync_token) +``` + +**Internal Flow:** + +``` +1. calendar.get_objects_by_sync_token(sync_token=None) + │ + ├─► Protocol: build_sync_collection_body(sync_token="") + │ + ├─► Client: report(calendar.url, body) + │ └─► HTTP REPORT → Response (207) + │ + ├─► Protocol: parse_sync_collection_response(response.body) + │ └─► SyncCollectionResult(changed, deleted, sync_token) + │ + └─► Returns: (new_sync_token, [objects...]) + +2. calendar.get_objects_by_sync_token(sync_token="token-123") + │ + ├─► Protocol: build_sync_collection_body(sync_token="token-123") + │ + ├─► Client: report(...) + │ + ├─► Protocol: parse_sync_collection_response(...) + │ └─► Returns changed items and deleted hrefs + │ + └─► Returns: (new_sync_token, changed_objects, deleted_hrefs) +``` + +**Key Files:** +- `caldav/collection.py:Calendar.get_objects_by_sync_token()` (line ~560) +- `caldav/protocol/xml_builders.py:_build_sync_collection_body()` +- `caldav/protocol/xml_parsers.py:_parse_sync_collection_response()` + +## Flow 6: Creating a Calendar + +**User Code:** +```python +new_calendar = principal.make_calendar( + name="Work", + cal_id="work-calendar" +) +``` + +**Internal Flow:** + +``` +1. principal.make_calendar(name="Work", cal_id="work-calendar") + │ + ├─► Build URL: calendar_home_set + cal_id + "/" + │ + ├─► Protocol: build_mkcalendar_body(displayname="Work") + │ + ├─► Client: mkcalendar(url, body) + │ └─► HTTP MKCALENDAR → Response (201) + │ + └─► Returns: Calendar(client, url, props={displayname: "Work"}) +``` + +**Key Files:** +- `caldav/collection.py:Principal.make_calendar()` (line ~430) +- `caldav/protocol/xml_builders.py:_build_mkcalendar_body()` + +## HTTP Methods Used + +| CalDAV Operation | HTTP Method | When Used | +|-----------------|-------------|-----------| +| Get properties | PROPFIND | Discovery, getting calendar lists | +| Search events | REPORT | calendar-query, calendar-multiget, sync-collection | +| Create calendar | MKCALENDAR | Creating new calendars | +| Create/update item | PUT | Saving events, todos, journals | +| Delete item | DELETE | Removing calendars or items | +| Get item | GET | Fetching single item | + +## Dual-Mode Domain Objects + +Domain objects like `Calendar`, `Principal`, `Event` work with both sync and async clients: + +```python +class Calendar(DAVObject): + def calendars(self): + if self._is_async: + return self._calendars_async() + return self._calendars_sync() + + async def _calendars_async(self): + # Async implementation using await + response = await self.client.propfind(...) + ... + + def _calendars_sync(self): + # Sync implementation + response = self.client.propfind(...) + ... +``` + +The `_is_async` property checks if `self.client` is an `AsyncDAVClient` instance. + +## Protocol Layer Independence + +The Protocol layer functions are pure and work identically for sync/async: + +```python +# Same function used by both sync and async paths +body = _build_calendar_query_body(start=dt1, end=dt2, event=True) + +# Same parser used by both paths +results = _parse_calendar_query_response(response.body, status_code=207) +``` + +This separation means: +1. Protocol logic can be unit tested without HTTP mocking +2. Any bug fixes in parsing benefit both sync and async +3. Adding new CalDAV features only requires changes in one place + +## Error Handling Flow + +``` +1. Client makes HTTP request + │ + ├─► Success (2xx/207): Parse response, return result + │ + ├─► Auth required (401): Negotiate auth, retry + │ + ├─► Not found (404): Raise NotFoundError or return empty + │ + ├─► Server error (5xx): Raise DAVError with details + │ + └─► Malformed response: Log warning, attempt recovery or raise +``` + +Errors are defined in `caldav/lib/error.py` and include: +- `AuthorizationError` - Authentication failed +- `NotFoundError` - Resource doesn't exist +- `DAVError` - General WebDAV/CalDAV errors +- `ReportError` - REPORT request failed diff --git a/docs/design/DATA_PROPERTIES_USAGE.md b/docs/design/DATA_PROPERTIES_USAGE.md new file mode 100644 index 00000000..249a0ab5 --- /dev/null +++ b/docs/design/DATA_PROPERTIES_USAGE.md @@ -0,0 +1,239 @@ +# Data Properties Usage Overview + +This document provides an overview of where `obj.data`, `obj.icalendar_instance`, +`obj.icalendar_component`, `obj.vobject_instance`, and their aliases (`obj.component`, +`obj.instance`) are used throughout the codebase. + +Related: See [DATA_REPRESENTATION_DESIGN.md](DATA_REPRESENTATION_DESIGN.md) for the design +discussion around these properties (GitHub issue #613). + +## Property Definitions + +All properties are defined in `caldav/calendarobjectresource.py`: + +| Property | Line | Type | Notes | +|----------|------|------|-------| +| `data` | 1179 | `property()` | String representation of calendar data | +| `wire_data` | 1182 | `property()` | Raw wire format data | +| `vobject_instance` | 1235 | `property()` | vobject library object | +| `instance` | 1241 | `property()` | **Alias** for `vobject_instance` | +| `icalendar_instance` | 1274 | `property()` | icalendar library object (full calendar) | +| `icalendar_component` | 492 | `property()` | Inner component (VEVENT/VTODO/VJOURNAL) | +| `component` | 498 | N/A | **Alias** for `icalendar_component` | + +--- + +## Library Code Usage (`caldav/`) + +### `obj.data` + +| File | Line | Context | +|------|------|---------| +| `calendarobjectresource.py` | 131 | `self.data = data` - Setting data in constructor | +| `calendarobjectresource.py` | 633 | `calendar.add_event(self.data)` - Adding event to calendar | +| `calendarobjectresource.py` | 656 | `data=self.data` - Passing data to copy operation | +| `calendarobjectresource.py` | 698 | `self.data = r.raw` - Setting data from HTTP response | +| `calendarobjectresource.py` | 724 | `self.data = r.raw` - Setting data from HTTP response | +| `calendarobjectresource.py` | 745 | `url, self.data = next(mydata)` - Unpacking data | +| `calendarobjectresource.py` | 752 | `error.assert_(self.data)` - Asserting data exists | +| `calendarobjectresource.py` | 808 | `self.url, self.data, {...}` - PUT request with data | +| `calendarobjectresource.py` | 830 | `str(self.data)` - Converting data to string | +| `calendarobjectresource.py` | 1130-1132 | `self.data.count("BEGIN:VEVENT")` - Counting components in data | +| `calendarobjectresource.py` | 1267 | `if not self.data:` - Checking if data exists | +| `calendarobjectresource.py` | 1270 | `to_unicode(self.data)` - Converting data to unicode | +| `collection.py` | 568 | `caldavobj.data` - Accessing data from calendar object | +| `collection.py` | 2083 | `old_by_url[url].data` - Comparing old data | +| `collection.py` | 2087 | `obj.data if hasattr(obj, "data")` - Safely accessing data | + +### `obj.icalendar_instance` + +| File | Line | Context | +|------|------|---------| +| `calendarobjectresource.py` | 189 | `self.icalendar_instance.subcomponents` - Getting subcomponents | +| `calendarobjectresource.py` | 199 | `obj.icalendar_instance.subcomponents = []` - Clearing subcomponents | +| `calendarobjectresource.py` | 201-202 | Appending to subcomponents | +| `calendarobjectresource.py` | 236 | `self.icalendar_instance, components=[...]` - Passing to function | +| `calendarobjectresource.py` | 249 | `calendar = self.icalendar_instance` - Assignment | +| `calendarobjectresource.py` | 460 | `if not self.icalendar_instance:` - Checking existence | +| `calendarobjectresource.py` | 465 | Iterating over subcomponents | +| `calendarobjectresource.py` | 481-490 | Manipulating subcomponents and properties | +| `calendarobjectresource.py` | 593 | `self.icalendar_instance.get("method", None)` - Getting METHOD | +| `calendarobjectresource.py` | 601 | `self.icalendar_instance.get("method", None)` - Getting METHOD | +| `calendarobjectresource.py` | 629 | `self.icalendar_instance.pop("METHOD")` - Removing METHOD | +| `calendarobjectresource.py` | 794 | Iterating over subcomponents | +| `calendarobjectresource.py` | 1025 | `obj.icalendar_instance` - Getting instance | +| `calendarobjectresource.py` | 1269 | `self.icalendar_instance = icalendar.Calendar.from_ical(...)` - Setting | +| `calendarobjectresource.py` | 1549 | `self.icalendar_instance.subcomponents` - Getting recurrences | +| `calendarobjectresource.py` | 1614 | Appending to subcomponents | +| `collection.py` | 898 | `obj.icalendar_instance.walk("vevent")[0]["uid"]` - Getting UID | +| `operations/search_ops.py` | 261 | Iterating over subcomponents in search | +| `operations/search_ops.py` | 265 | `o.icalendar_instance` - Getting instance | +| `operations/search_ops.py` | 274 | `new_obj.icalendar_instance` - Getting instance | + +### `obj.icalendar_component` + +| File | Line | Context | +|------|------|---------| +| `calendarobjectresource.py` | 133-134 | Popping and adding UID | +| `calendarobjectresource.py` | 145 | `i = self.icalendar_component` - Assignment | +| `calendarobjectresource.py` | 170 | Adding organizer | +| `calendarobjectresource.py` | 278 | Getting UID from other object | +| `calendarobjectresource.py` | 289 | Getting RELATED-TO | +| `calendarobjectresource.py` | 305 | Adding RELATED-TO | +| `calendarobjectresource.py` | 341 | Getting RELATED-TO list | +| `calendarobjectresource.py` | 392 | Getting UID | +| `calendarobjectresource.py` | 508 | `i = self.icalendar_component` - Assignment | +| `calendarobjectresource.py` | 584 | `ievent = self.icalendar_component` - Assignment | +| `calendarobjectresource.py` | 896 | `ical_obj = self.icalendar_component` - Assignment | +| `calendarobjectresource.py` | 965 | Getting UID | +| `calendarobjectresource.py` | 992 | Getting UID | +| `calendarobjectresource.py` | 1017 | Checking for RECURRENCE-ID | +| `calendarobjectresource.py` | 1028-1029 | Getting component for modification | +| `calendarobjectresource.py` | 1070-1076 | Working with RECURRENCE-ID | +| `calendarobjectresource.py` | 1080-1083 | Working with SEQUENCE | +| `calendarobjectresource.py` | 1126 | Checking if component exists | +| `calendarobjectresource.py` | 1302 | `i = self.icalendar_component` - Assignment | +| `calendarobjectresource.py` | 1461 | `i = self.icalendar_component` - Assignment | +| `calendarobjectresource.py` | 1492 | `i = self.icalendar_component` - Assignment | +| `calendarobjectresource.py` | 1515 | Popping RRULE | +| `calendarobjectresource.py` | 1520 | `i = self.icalendar_component` - Assignment | +| `calendarobjectresource.py` | 1643 | Checking for RRULE | +| `calendarobjectresource.py` | 1652 | `i = self.icalendar_component` - Assignment | +| `calendarobjectresource.py` | 1660 | `i = self.icalendar_component` - Assignment | +| `calendarobjectresource.py` | 1674-1678 | Working with status and completed | +| `calendarobjectresource.py` | 1691 | `i = self.icalendar_component` - Assignment | +| `calendarobjectresource.py` | 1727 | `i = self.icalendar_component` - Assignment | + +### `obj.vobject_instance` + +| File | Line | Context | +|------|------|---------| +| `calendarobjectresource.py` | 821 | `self.vobject_instance` - Getting instance for ics() | +| `calendarobjectresource.py` | 843 | `self.vobject_instance` - Getting instance for wire_data | + +--- + +## Test Code Usage (`tests/`) + +### `obj.data` + +| File | Line | Context | +|------|------|---------| +| `test_caldav_unit.py` | 1135 | Docstring about data returning unicode | +| `test_caldav_unit.py` | 1143-1157 | Multiple assertions on `my_event.data` type | +| `test_caldav_unit.py` | 1165 | `"new summary" in my_event.data` | +| `test_caldav_unit.py` | 1170, 1186 | `my_event.data.strip().split("\n")` | +| `test_async_integration.py` | 272, 289 | Checking data content | +| `test_sync_token_fallback.py` | 40, 43, 109 | Setting and checking data | +| `test_caldav.py` | 1164 | `assert objects[0].data` | +| `test_caldav.py` | 1472, 1506, 1557 | Checking data is None or not None | +| `test_caldav.py` | 1637 | `"foobar" in ... .data` | +| `test_caldav.py` | 2477 | `j1_.data == journals[0].data` | +| `test_caldav.py` | 2744-2788 | Multiple checks for DTSTART in data | +| `test_caldav.py` | 3235, 3273 | Setting `e.data` | +| `test_caldav.py` | 3331-3405 | Multiple `.data.count()` assertions | +| `test_operations_calendar.py` | 336, 351 | Checking data value | + +### `obj.icalendar_instance` + +| File | Line | Context | +|------|------|---------| +| `test_caldav_unit.py` | 1146 | `my_event.icalendar_instance` - Accessing | +| `test_caldav_unit.py` | 1166 | `icalobj = my_event.icalendar_instance` | +| `test_caldav_unit.py` | 1208, 1212 | `target.icalendar_instance.subcomponents` | +| `test_caldav_unit.py` | 1236-1267 | Multiple subcomponent manipulations | +| `test_caldav.py` | 1096, 1104 | `object_by_id.icalendar_instance` | +| `test_caldav.py` | 1490, 1627 | Modifying subcomponents | +| `test_caldav.py` | 2475-2476, 2909 | Getting icalendar_instance | +| `test_search.py` | 338, 368 | Iterating over subcomponents | + +### `obj.icalendar_component` + +| File | Line | Context | +|------|------|---------| +| `test_caldav_unit.py` | 1182 | `my_event.icalendar_component` | +| `test_caldav_unit.py` | 1195 | Setting `my_event.icalendar_component` | +| `test_caldav_unit.py` | 1211 | Setting component from icalendar.Todo | +| `test_caldav.py` | 1274 | Getting UID from component | +| `test_caldav.py` | 1352 | Checking UID in events | +| `test_caldav.py` | 1960-1988 | Multiple DTSTART comparisons | +| `test_caldav.py` | 1996, 1998, 2037 | Getting UID from component | +| `test_caldav.py` | 2254-2298 | Working with RELATED-TO | +| `test_caldav.py` | 2356-2457 | Multiple DUE/DTSTART assertions | +| `test_caldav.py` | 3417-3529 | Working with RECURRENCE-ID and modifying | +| `test_search.py` | 239 | Getting SUMMARY | +| `test_search.py` | 288 | Getting STATUS | +| `test_search.py` | 487, 566, 575, 619-620 | Various component accesses | + +### `obj.vobject_instance` + +| File | Line | Context | +|------|------|---------| +| `test_caldav_unit.py` | 1150 | `my_event.vobject_instance` - Accessing | +| `test_caldav_unit.py` | 1164 | Modifying `vobject_instance.vevent.summary.value` | +| `test_caldav_unit.py` | 1168, 1184 | Asserting on vobject values | +| `test_caldav_unit.py` | 1197 | Accessing vtodo.summary.value | +| `test_caldav.py` | 1732-1742 | Multiple vobject manipulations | +| `test_caldav.py` | 1937, 1945, 1953 | Getting vevent.summary.value | +| `test_caldav.py` | 2564-2578 | Getting vtodo.uid and priority | +| `test_caldav.py` | 2835-2839 | Comparing vobject properties | +| `test_caldav.py` | 3072-3084 | Comparing vevent.uid | +| `test_caldav.py` | 3141-3154 | Modifying and comparing summary | +| `test_caldav.py` | 3220-3221 | Comparing vevent.uid | +| `test_caldav.py` | 3269 | Checking vfreebusy existence | + +### `obj.component` (alias for icalendar_component) + +| File | Line | Context | +|------|------|---------| +| `test_caldav_unit.py` | 1273-1285 | Working with component.start/end/duration | +| `test_caldav.py` | 1416 | `foo.component["summary"]` | +| `test_caldav.py` | 2156 | `t3.component.pop("COMPLETED")` | +| `test_caldav.py` | 2337-2338 | Getting UID from component | +| `test_caldav.py` | 2431, 2436 | Getting UID from component | +| `test_caldav.py` | 2631, 2635 | Getting summary from component | + +### `obj.instance` (alias for vobject_instance) + +| File | Line | Context | +|------|------|---------| +| `tests/_test_absolute.py` | 30 | `vobj = event.instance` | + +--- + +## Summary Statistics + +| Property | Library Uses | Test Uses | Total | +|----------|-------------|-----------|-------| +| `data` | ~15 | ~40 | ~55 | +| `icalendar_instance` | ~25 | ~20 | ~45 | +| `icalendar_component` | ~45 | ~50 | ~95 | +| `vobject_instance` | ~2 | ~25 | ~27 | +| `component` (alias) | 0 | ~12 | ~12 | +| `instance` (alias) | 0 | ~1 | ~1 | + +## Key Observations + +1. **`icalendar_component`** is the most heavily used property, especially for accessing + and modifying individual properties like UID, DTSTART, SUMMARY, etc. + +2. **`data`** is used for: + - Raw string manipulation and comparisons + - Passing to add/save operations + - Checking for specific content (e.g., `"BEGIN:VEVENT" in data`) + +3. **`icalendar_instance`** is used for: + - Accessing the full calendar object + - Working with subcomponents (timezones, multiple events) + - Getting/setting the METHOD property + +4. **`vobject_instance`** has limited use in library code (only in `ics()` and `wire_data`), + but is used extensively in tests for accessing nested properties like `vevent.summary.value`. + +5. **Aliases** (`component`, `instance`) are rarely used - mostly in tests. + +6. **Modification patterns**: + - Setting `data` directly: `obj.data = "..."` + - Modifying via icalendar: `obj.icalendar_component["SUMMARY"] = "..."` + - Modifying via vobject: `obj.vobject_instance.vevent.summary.value = "..."` + - These can conflict if not handled carefully (see issue #613) diff --git a/docs/design/DATA_REPRESENTATION_DESIGN.md b/docs/design/DATA_REPRESENTATION_DESIGN.md new file mode 100644 index 00000000..27ac3f29 --- /dev/null +++ b/docs/design/DATA_REPRESENTATION_DESIGN.md @@ -0,0 +1,743 @@ +# Data Representation Design for CalendarObjectResource + +**Issue**: https://github.com/python-caldav/caldav/issues/613 + +**Status**: Implemented in v3.0-dev + +## Implementation Summary + +The core API has been implemented in `caldav/calendarobjectresource.py` with supporting +state classes in `caldav/datastate.py`: + +**New Public API:** +- `get_data()` - Returns string, no side effects +- `get_icalendar_instance()` - Returns a COPY (safe for read-only) +- `get_vobject_instance()` - Returns a COPY (safe for read-only) +- `edit_icalendar_instance()` - Context manager for borrowing (exclusive editing) +- `edit_vobject_instance()` - Context manager for borrowing (exclusive editing) + +**Internal Optimizations:** +- `_get_uid_cheap()` - Get UID without format conversion +- `_get_component_type_cheap()` - Get VEVENT/VTODO/VJOURNAL without parsing +- `_has_data()` - Check data existence without conversion +- `has_component()` - Optimized to use cheap accessors + +**Legacy properties** (`data`, `icalendar_instance`, `vobject_instance`) continue to work +for backward compatibility. + +## Problem Statement + +The current `CalendarObjectResource` API has problematic side effects when accessing different representations of calendar data: + +```python +my_event.data # Raw string +my_event.icalendar_instance # Parsed icalendar object +my_event.vobject_instance # Parsed vobject object +my_event.data # Back to string +``` + +Each access can trigger conversions, and the code has surprising behavior: + +```python +my_event = calendar.search(...)[0] +icalendar_component = my_event.icalendar_component +my_event.data # NOW icalendar_component is disconnected! +icalendar_component['summary'] = "New Summary" +my_event.save() # Changes are NOT saved! +``` + +### Current Implementation + +The class has three internal fields where only ONE can be non-null at a time: + +- `_data` - raw iCalendar string +- `_icalendar_instance` - parsed icalendar.Calendar object +- `_vobject_instance` - parsed vobject object + +Accessing one clears the others, causing the disconnection problem. + +## The Fundamental Challenge + +The core issue is **mutable aliasing**. When you have multiple mutable representations: + +1. User gets `icalendar_instance` reference +2. User gets `vobject_instance` reference +3. User modifies one - the other is now stale +4. User calls `save()` - which representation should be used? + +**Only one mutable object can be the "source of truth" at any time.** + +## Proposed Solution: Strategy Pattern with Explicit Ownership + +### Key Insight: Ownership Transfer + +Accessing a mutable representation is an **ownership transfer**. Once you get an icalendar object and start modifying it, that object becomes the source of truth. + +### Proposed API + +```python +class CalendarObjectResource: + # === Read-only access (always safe, returns copies) === + + def get_data(self) -> str: + """Get raw iCalendar data as string. Always safe.""" + ... + + def get_icalendar(self) -> icalendar.Calendar: + """Get a COPY of the icalendar object. Safe for inspection.""" + ... + + def get_vobject(self) -> vobject.Component: + """Get a COPY of the vobject object. Safe for inspection.""" + ... + + # === Write access (explicit ownership transfer) === + + def set_data(self, data: str) -> None: + """Set raw data. This becomes the new source of truth.""" + ... + + def set_icalendar(self, cal: icalendar.Calendar) -> None: + """Set from icalendar object. This becomes the new source of truth.""" + ... + + def set_vobject(self, vobj: vobject.Component) -> None: + """Set from vobject object. This becomes the new source of truth.""" + ... + + # === Edit access (ownership transfer, returns authoritative object) === + + def edit_icalendar(self) -> icalendar.Calendar: + """Get THE icalendar object for editing. + + This transfers ownership - the icalendar object becomes the + source of truth. Previous vobject references become stale. + """ + ... + + def edit_vobject(self) -> vobject.Component: + """Get THE vobject object for editing. + + This transfers ownership - the vobject object becomes the + source of truth. Previous icalendar references become stale. + """ + ... + + # === Legacy properties (backward compatibility) === + + @property + def data(self) -> str: + """Get raw data. Does NOT invalidate parsed objects.""" + return self._strategy.get_data() + + @data.setter + def data(self, value: str) -> None: + self.set_data(value) + + @property + def icalendar_instance(self) -> icalendar.Calendar: + """Returns the authoritative icalendar object. + + WARNING: This transfers ownership. Previous vobject references + become stale. For read-only access, use get_icalendar(). + """ + return self.edit_icalendar() + + @property + def vobject_instance(self) -> vobject.Component: + """Returns the authoritative vobject object. + + WARNING: This transfers ownership. Previous icalendar references + become stale. For read-only access, use get_vobject(). + """ + return self.edit_vobject() +``` + +### Strategy Pattern Implementation + +```python +from abc import ABC, abstractmethod +from typing import Optional +import icalendar + + +class DataStrategy(ABC): + """Abstract strategy for calendar data representation.""" + + @abstractmethod + def get_data(self) -> str: + """Get raw iCalendar string.""" + pass + + @abstractmethod + def get_icalendar_copy(self) -> icalendar.Calendar: + """Get a fresh parsed copy (for read-only access).""" + pass + + @abstractmethod + def get_vobject_copy(self): + """Get a fresh parsed copy (for read-only access).""" + pass + + def get_uid(self) -> Optional[str]: + """Extract UID without full parsing if possible. + + Default implementation parses, but subclasses can optimize. + """ + cal = self.get_icalendar_copy() + for comp in cal.subcomponents: + if comp.name in ('VEVENT', 'VTODO', 'VJOURNAL') and 'UID' in comp: + return str(comp['UID']) + return None + + +class NoDataStrategy(DataStrategy): + """Null Object pattern - no data loaded yet.""" + + def get_data(self) -> str: + return "" + + def get_icalendar_copy(self) -> icalendar.Calendar: + return icalendar.Calendar() + + def get_vobject_copy(self): + import vobject + return vobject.iCalendar() + + def get_uid(self) -> Optional[str]: + return None + + +class RawDataStrategy(DataStrategy): + """Strategy when we have raw string data.""" + + def __init__(self, data: str): + self._data = data + + def get_data(self) -> str: + return self._data + + def get_icalendar_copy(self) -> icalendar.Calendar: + return icalendar.Calendar.from_ical(self._data) + + def get_vobject_copy(self): + import vobject + return vobject.readOne(self._data) + + def get_uid(self) -> Optional[str]: + # Optimization: use regex instead of full parsing + import re + match = re.search(r'^UID:(.+)$', self._data, re.MULTILINE) + return match.group(1).strip() if match else None + + +class IcalendarStrategy(DataStrategy): + """Strategy when icalendar object is the source of truth.""" + + def __init__(self, calendar: icalendar.Calendar): + self._calendar = calendar + + def get_data(self) -> str: + return self._calendar.to_ical().decode('utf-8') + + def get_icalendar_copy(self) -> icalendar.Calendar: + # Parse from serialized form to get a true copy + return icalendar.Calendar.from_ical(self.get_data()) + + def get_authoritative_icalendar(self) -> icalendar.Calendar: + """Returns THE icalendar object (not a copy).""" + return self._calendar + + def get_vobject_copy(self): + import vobject + return vobject.readOne(self.get_data()) + + +class VobjectStrategy(DataStrategy): + """Strategy when vobject object is the source of truth.""" + + def __init__(self, vobj): + self._vobject = vobj + + def get_data(self) -> str: + return self._vobject.serialize() + + def get_icalendar_copy(self) -> icalendar.Calendar: + return icalendar.Calendar.from_ical(self.get_data()) + + def get_vobject_copy(self): + import vobject + return vobject.readOne(self.get_data()) + + def get_authoritative_vobject(self): + """Returns THE vobject object (not a copy).""" + return self._vobject +``` + +### CalendarObjectResource Integration + +```python +class CalendarObjectResource: + _strategy: DataStrategy + + def __init__(self, data: Optional[str] = None, ...): + if data: + self._strategy = RawDataStrategy(data) + else: + self._strategy = NoDataStrategy() # Null Object pattern + ... + + def _switch_strategy(self, new_strategy: DataStrategy) -> None: + """Internal: switch to a new strategy.""" + self._strategy = new_strategy + + # Read-only access (Null Object pattern eliminates None checks) + def get_data(self) -> str: + return self._strategy.get_data() + + def get_icalendar(self) -> icalendar.Calendar: + return self._strategy.get_icalendar_copy() + + def get_vobject(self): + return self._strategy.get_vobject_copy() + + # Write access + def set_data(self, data: str) -> None: + self._strategy = RawDataStrategy(data) + + def set_icalendar(self, cal: icalendar.Calendar) -> None: + self._strategy = IcalendarStrategy(cal) + + def set_vobject(self, vobj) -> None: + self._strategy = VobjectStrategy(vobj) + + # Edit access (ownership transfer) + def edit_icalendar(self) -> icalendar.Calendar: + if not isinstance(self._strategy, IcalendarStrategy): + cal = self._strategy.get_icalendar_copy() + self._strategy = IcalendarStrategy(cal) + return self._strategy.get_authoritative_icalendar() + + def edit_vobject(self): + if not isinstance(self._strategy, VobjectStrategy): + vobj = self._strategy.get_vobject_copy() + self._strategy = VobjectStrategy(vobj) + return self._strategy.get_authoritative_vobject() + + # Legacy properties + @property + def data(self) -> Optional[str]: + return self.get_data() + + @data.setter + def data(self, value: str) -> None: + self.set_data(value) + + @property + def icalendar_instance(self) -> Optional[icalendar.Calendar]: + return self.edit_icalendar() + + @property + def vobject_instance(self): + return self.edit_vobject() + + @property + def icalendar_component(self): + """Get the VEVENT/VTODO/VJOURNAL component.""" + cal = self.edit_icalendar() + for comp in cal.subcomponents: + if comp.name in ('VEVENT', 'VTODO', 'VJOURNAL'): + return comp + return None +``` + +## State Transitions + +``` + ┌─────────────────┐ + set_data() │ RawDataStrategy │ + ─────────────────►│ (_data="...") │ + └────────┬────────┘ + │ + │ edit_icalendar() + ▼ + ┌─────────────────┐ + │IcalendarStrategy│ + │ (_calendar=...) │ + └────────┬────────┘ + │ + │ edit_vobject() + ▼ + ┌─────────────────┐ + │ VobjectStrategy │ + │ (_vobject=...) │ + └─────────────────┘ + +Note: get_data() works from ANY strategy without switching. + get_icalendar() / get_vobject() return COPIES without switching. + Only edit_*() methods cause strategy switches. +``` + +## Handling Internal Uses + +For internal operations that need to peek at the data without changing ownership: + +```python +def _find_uid(self) -> Optional[str]: + # Use strategy's optimized method - no ownership change + return self._strategy.get_uid() if self._strategy else None + +def _get_component_type(self) -> Optional[str]: + # Use a copy - don't transfer ownership + cal = self._strategy.get_icalendar_copy() + for comp in cal.subcomponents: + if comp.name in ('VEVENT', 'VTODO', 'VJOURNAL'): + return comp.name + return None +``` + +## Migration Path + +### Phase 1 (3.0) +- Add `get_*()`, `set_*()`, `edit_*()` methods +- Keep legacy properties working with current semantics +- Document the ownership transfer behavior clearly +- Add deprecation warnings for confusing usage patterns + +### Phase 2 (3.x) +- Add warnings when legacy properties cause ownership transfer +- Encourage migration to explicit methods + +### Phase 3 (4.0) +- Consider making legacy properties read-only +- Or remove implicit ownership transfer from properties + +## Usage Examples + +### Safe Read-Only Access +```python +event = calendar.search(...)[0] + +# Just inspecting - use get_*() methods +summary = event.get_icalendar().subcomponents[0]['summary'] +print(f"Event summary: {summary}") + +# Multiple formats at once - all are copies, no conflict +ical_copy = event.get_icalendar() +vobj_copy = event.get_vobject() +raw_data = event.get_data() +``` + +### Modifying with icalendar +```python +event = calendar.search(...)[0] + +# Get authoritative icalendar object for editing +cal = event.edit_icalendar() +cal.subcomponents[0]['summary'] = 'New Summary' + +# Save uses the icalendar object +event.save() +``` + +### Modifying with vobject +```python +event = calendar.search(...)[0] + +# Get authoritative vobject object for editing +vobj = event.edit_vobject() +vobj.vevent.summary.value = 'New Summary' + +# Save uses the vobject object +event.save() +``` + +### Setting from External Source +```python +# Set from string +event.set_data(ical_string) + +# Set from icalendar object created elsewhere +event.set_icalendar(my_calendar) + +# Set from vobject object created elsewhere +event.set_vobject(my_vobject) +``` + +## Open Questions + +1. **Should `get_data()` cache the serialized string?** This could avoid repeated serialization but adds complexity. + +2. **Should we support jcal (JSON) format?** The strategy pattern makes this easy to add. + +3. **Should `edit_*()` be renamed to `as_*()`?** e.g., `event.as_icalendar()` might be more intuitive. + +4. **What about component-level access?** Should we have `edit_icalendar_component()` that returns just the VEVENT/VTODO? + +5. **Thread safety?** The current design is not thread-safe. Should it be? + +## Alternative: Borrowing Pattern with Context Managers + +*Suggested by @niccokunzmann in issue #613* + +A cleaner approach inspired by Rust's borrowing semantics: use context managers +to explicitly "borrow" a representation for editing. + +### Concept + +```python +# Explicit borrowing with context managers +with my_event.icalendar_instance as calendar: + calendar.subcomponents[0]['summary'] = 'New Summary' + # Exclusive access - can't access vobject here + +# Changes committed, can now use other representations +with my_event.vobject_instance as vobj: + # verification, etc. +``` + +### Benefits + +1. **Clear ownership scope** - The `with` block clearly defines when you have edit access +2. **Prevents concurrent access** - Accessing another representation while one is borrowed raises an error +3. **Pythonic** - Context managers are idiomatic Python +4. **Explicit commit point** - Changes are committed when exiting the context + +### State Machine + +This is more of a **State pattern** than a Strategy pattern: + +``` + ┌──────────────────────────────────────────────────────┐ + │ │ + ▼ │ + ┌─────────────┐ │ + │ ReadOnly │◄──────────────────────────────────────────────┤ + │ State │ │ + └──────┬──────┘ │ + │ │ + ┌──────────────┼──────────────┐ │ + │ │ │ │ + │ with │ with │ with │ + │ .data │ .icalendar │ .vobject │ + ▼ ▼ ▼ │ +┌───────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ Editing │ │ Editing │ │ Editing │ │ +│ Data │ │ Icalendar │ │ Vobject │ │ +│ (noop) │ │ State │ │ State │ │ +└─────┬─────┘ └──────┬──────┘ └──────┬──────┘ │ + │ │ │ │ + │ exit │ exit │ exit │ + │ context │ context │ context │ + │ │ │ │ + └──────────────┴───────────────┴──────────────────────────────────────┘ +``` + +### Implementation Sketch + +```python +class CalendarObjectResource: + _state: 'DataState' + _borrowed: bool = False + + def __init__(self, data: Optional[str] = None): + self._state = RawDataState(data) if data else NoDataState() + self._borrowed = False + + @contextmanager + def icalendar_instance(self): + """Borrow the icalendar object for editing.""" + if self._borrowed: + raise RuntimeError("Already borrowed - cannot access another representation") + + # Switch to icalendar state if needed + if not isinstance(self._state, IcalendarState): + cal = self._state.get_icalendar_copy() + self._state = IcalendarState(cal) + + self._borrowed = True + try: + yield self._state.get_authoritative_icalendar() + finally: + self._borrowed = False + + @contextmanager + def vobject_instance(self): + """Borrow the vobject object for editing.""" + if self._borrowed: + raise RuntimeError("Already borrowed - cannot access another representation") + + # Switch to vobject state if needed + if not isinstance(self._state, VobjectState): + vobj = self._state.get_vobject_copy() + self._state = VobjectState(vobj) + + self._borrowed = True + try: + yield self._state.get_authoritative_vobject() + finally: + self._borrowed = False + + @contextmanager + def data(self): + """Borrow the data (read-only, strings are immutable).""" + if self._borrowed: + raise RuntimeError("Already borrowed - cannot access another representation") + + self._borrowed = True + try: + yield self._state.get_data() + finally: + self._borrowed = False + + # Read-only access (always safe, no borrowing needed) + def get_data(self) -> str: + return self._state.get_data() + + def get_icalendar(self) -> icalendar.Calendar: + return self._state.get_icalendar_copy() + + def get_vobject(self): + return self._state.get_vobject_copy() + + +class DataState(ABC): + """Abstract state for calendar data.""" + + @abstractmethod + def get_data(self) -> str: + pass + + @abstractmethod + def get_icalendar_copy(self) -> icalendar.Calendar: + pass + + @abstractmethod + def get_vobject_copy(self): + pass + + +class NoDataState(DataState): + """Null Object pattern - no data loaded yet.""" + + def get_data(self) -> str: + return "" + + def get_icalendar_copy(self) -> icalendar.Calendar: + return icalendar.Calendar() + + def get_vobject_copy(self): + import vobject + return vobject.iCalendar() + + +class RawDataState(DataState): + """State when raw string data is the source of truth.""" + + def __init__(self, data: str): + self._data = data + + def get_data(self) -> str: + return self._data + + def get_icalendar_copy(self) -> icalendar.Calendar: + return icalendar.Calendar.from_ical(self._data) + + def get_vobject_copy(self): + import vobject + return vobject.readOne(self._data) + + +class IcalendarState(DataState): + """State when icalendar object is the source of truth.""" + + def __init__(self, calendar: icalendar.Calendar): + self._calendar = calendar + + def get_data(self) -> str: + return self._calendar.to_ical().decode('utf-8') + + def get_icalendar_copy(self) -> icalendar.Calendar: + return icalendar.Calendar.from_ical(self.get_data()) + + def get_authoritative_icalendar(self) -> icalendar.Calendar: + return self._calendar + + def get_vobject_copy(self): + import vobject + return vobject.readOne(self.get_data()) + + +class VobjectState(DataState): + """State when vobject object is the source of truth.""" + + def __init__(self, vobj): + self._vobject = vobj + + def get_data(self) -> str: + return self._vobject.serialize() + + def get_icalendar_copy(self) -> icalendar.Calendar: + return icalendar.Calendar.from_ical(self.get_data()) + + def get_vobject_copy(self): + import vobject + return vobject.readOne(self.get_data()) + + def get_authoritative_vobject(self): + return self._vobject +``` + +### Usage Examples with Borrowing + +```python +# Read-only access (always safe, no borrowing) +summary = event.get_icalendar().subcomponents[0]['summary'] + +# Editing with explicit borrowing +with event.icalendar_instance as cal: + cal.subcomponents[0]['summary'] = 'New Summary' + # Can NOT access event.vobject_instance here - will raise RuntimeError + +event.save() + +# Now can use vobject +with event.vobject_instance as vobj: + print(vobj.vevent.summary.value) + +# Nested borrowing of same type works (with refcounting) +with event.icalendar_instance as cal: + # some function that also needs icalendar + def helper(evt): + with evt.icalendar_instance as inner_cal: # Works - same type + return inner_cal.subcomponents[0]['uid'] + uid = helper(event) +``` + +### Comparison: Edit Methods vs Borrowing + +| Aspect | edit_*() methods | with borrowing | +|--------|------------------|----------------| +| Ownership scope | Implicit (until next edit) | Explicit (with block) | +| Concurrent access | Silently replaces | Raises error | +| Pythonic | Less | More | +| Backward compatible | Easier | Harder | +| Thread safety | None | Could add locking | + +## Recommendation + +The **borrowing pattern with context managers** is the cleaner long-term solution, +but requires more breaking changes. For 3.0, consider: + +1. Add `get_*()` methods for safe read-only access (non-breaking) +2. Add context manager support for `icalendar_instance` / `vobject_instance` (additive) +3. Deprecate direct property access for editing +4. In 4.0, make context managers the only way to edit + +## Related Work + +- Python's `io.BytesIO` / `io.StringIO` - similar "view" concept +- Django's `QuerySet` - lazy evaluation with clear ownership +- SQLAlchemy's Unit of Work - tracks dirty objects +- Rust's borrowing and ownership - inspiration for the context manager approach +- Python's `threading.Lock` - context manager for exclusive access diff --git a/docs/design/ELIMINATE_METHOD_WRAPPERS_ANALYSIS.md b/docs/design/ELIMINATE_METHOD_WRAPPERS_ANALYSIS.md new file mode 100644 index 00000000..26f41415 --- /dev/null +++ b/docs/design/ELIMINATE_METHOD_WRAPPERS_ANALYSIS.md @@ -0,0 +1,363 @@ +# Analysis: Eliminating HTTP Method Wrappers by Refactoring _query() + +## Current Situation + +`DAVObject._query()` uses **dynamic dispatch** (line 219): +```python +ret = getattr(self.client, query_method)(url, body, depth) +``` + +This requires method wrappers like `propfind()`, `proppatch()`, `mkcol()`, etc. to exist on `DAVClient`. + +## Your Observation + +**The wrappers could be eliminated** by having `_query()` call `self.client.request()` directly instead! + +## Current Wrapper Implementation + +Each wrapper is **just a thin adapter** that adds method-specific headers: + +```python +def propfind(self, url=None, props="", depth=0): + return self.request( + url or str(self.url), + "PROPFIND", + props, + {"Depth": str(depth)} + ) + +def report(self, url, query="", depth=0): + return self.request( + url, + "REPORT", + query, + {"Depth": str(depth), "Content-Type": 'application/xml; charset="utf-8"'} + ) + +def proppatch(self, url, body, dummy=None): + return self.request(url, "PROPPATCH", body) + +def mkcol(self, url, body, dummy=None): + return self.request(url, "MKCOL", body) + +def mkcalendar(self, url, body="", dummy=None): + return self.request(url, "MKCALENDAR", body) +``` + +**Total code**: ~100 lines of mostly boilerplate + +## Proposed Refactoring + +### Option 1: Map Method Names to HTTP Methods + Headers + +```python +# In DAVClient: +_METHOD_HEADERS = { + "propfind": lambda depth: {"Depth": str(depth)}, + "report": lambda depth: { + "Depth": str(depth), + "Content-Type": 'application/xml; charset="utf-8"' + }, + "proppatch": lambda depth: {}, + "mkcol": lambda depth: {}, + "mkcalendar": lambda depth: {}, +} + +# In DAVObject._query(): +def _query( + self, + root=None, + depth=0, + query_method="propfind", + url=None, + expected_return_value=None, +): + body = "" + if root: + if hasattr(root, "xmlelement"): + body = etree.tostring( + root.xmlelement(), + encoding="utf-8", + xml_declaration=True, + pretty_print=error.debug_dump_communication, + ) + else: + body = root + + if url is None: + url = self.url + + # NEW: Build headers based on method + headers = {} + if query_method in DAVClient._METHOD_HEADERS: + headers = DAVClient._METHOD_HEADERS[query_method](depth) + + # NEW: Call request() directly + ret = self.client.request( + url, + query_method.upper(), # "propfind" -> "PROPFIND" + body, + headers + ) + + # ... rest of error handling stays the same ... +``` + +**Result**: No method wrappers needed! + +### Option 2: More Explicit Method Registry + +```python +# In DAVClient: +class MethodConfig: + def __init__(self, http_method, headers_fn=None): + self.http_method = http_method + self.headers_fn = headers_fn or (lambda depth: {}) + +_QUERY_METHODS = { + "propfind": MethodConfig( + "PROPFIND", + lambda depth: {"Depth": str(depth)} + ), + "report": MethodConfig( + "REPORT", + lambda depth: { + "Depth": str(depth), + "Content-Type": 'application/xml; charset="utf-8"' + } + ), + "proppatch": MethodConfig("PROPPATCH"), + "mkcol": MethodConfig("MKCOL"), + "mkcalendar": MethodConfig("MKCALENDAR"), +} + +# In DAVObject._query(): +def _query(self, root=None, depth=0, query_method="propfind", url=None, ...): + # ... body preparation same as before ... + + if url is None: + url = self.url + + # NEW: Look up method config + method_config = self.client._QUERY_METHODS.get(query_method) + if not method_config: + raise ValueError(f"Unknown query method: {query_method}") + + headers = method_config.headers_fn(depth) + + # NEW: Call request() directly + ret = self.client.request( + url, + method_config.http_method, + body, + headers + ) + + # ... error handling ... +``` + +### Option 3: Keep Wrappers but Make Them Optional + +Compromise: Keep wrappers for public API, but make `_query()` not depend on them: + +```python +# In DAVClient: +def _build_headers_for_method(self, method_name, depth=0): + """Internal: build headers for a WebDAV method""" + if method_name == "propfind": + return {"Depth": str(depth)} + elif method_name == "report": + return {"Depth": str(depth), "Content-Type": 'application/xml; charset="utf-8"'} + else: + return {} + +# Public wrappers still exist for direct use: +def propfind(self, url=None, body="", depth=0, headers=None): + """Public API for PROPFIND""" + merged_headers = self._build_headers_for_method("propfind", depth) + if headers: + merged_headers.update(headers) + return self.request(url or str(self.url), "PROPFIND", body, merged_headers) + +# In DAVObject._query(): +def _query(self, root=None, depth=0, query_method="propfind", url=None, ...): + # ... body preparation ... + + if url is None: + url = self.url + + # Call request() directly via internal helper + headers = self.client._build_headers_for_method(query_method, depth) + ret = self.client.request(url, query_method.upper(), body, headers) + + # ... error handling ... +``` + +## Pros and Cons + +### Pros of Eliminating Wrappers: + +1. **Less code** - ~100 lines eliminated +2. **Less duplication** - single place to define method behavior +3. **Easier to add new methods** - just update the registry +4. **More maintainable** - all logic in one place +5. **Cleaner architecture** - no artificial methods just for dispatch + +### Cons of Eliminating Wrappers: + +1. **Breaking change for mocking** - tests that mock `client.propfind` will break + ```python + # Currently works: + client.propfind = mock.MagicMock(return_value=response) + + # Would need to become: + client.request = mock.MagicMock(...) + ``` + +2. **Less discoverable API** - no auto-complete for `client.propfind()` + ```python + # Current (discoverable): + client.propfind(...) + client.report(...) + + # New (not discoverable): + client.request(..., method="PROPFIND", ...) # or hidden in _query() + ``` + +3. **Not part of public API anyway** - these methods are rarely called directly (only 6 times in entire codebase) + +4. **Could keep public wrappers** - eliminate the *dependency* in `_query()` but keep wrappers for convenience + +## Impact Analysis + +### Files that would need changes: + +1. **davobject.py** - Refactor `_query()` (1 method) +2. **davclient.py** - Add method registry/helper (10-30 lines) +3. **tests/** - Update any mocks (unknown number) + +### Files that would NOT need changes: + +- **collection.py** - calls `_query()`, doesn't care about implementation +- **calendarobjectresource.py** - calls `client.put()` directly (keep wrapper) +- **Most other code** - uses high-level API + +### Backward Compatibility + +**Option 1 & 2**: Breaking change +- Method wrappers removed +- Tests that mock them will break + +**Option 3**: Non-breaking +- Keep wrappers as public API +- `_query()` stops depending on them +- Tests continue to work + +## Recommendation + +### For Async Refactoring: **Option 3** (Keep wrappers, remove dependency) + +**Why:** + +1. **Non-breaking** - existing tests/mocks still work +2. **Better public API** - `client.propfind()` is more discoverable than `client.request(..., "PROPFIND", ...)` +3. **Best of both worlds**: + - `_query()` uses `request()` directly (clean architecture) + - Public wrappers exist for convenience and discoverability + - Wrappers can be thin (5-10 lines each) + +**Implementation:** + +```python +# In async_davclient.py: + +class AsyncDAVClient: + + @staticmethod + def _method_headers(method: str, depth: int = 0) -> Dict[str, str]: + """Build headers for a WebDAV method (internal helper)""" + if method.upper() == "PROPFIND": + return {"Depth": str(depth)} + elif method.upper() == "REPORT": + return { + "Depth": str(depth), + "Content-Type": 'application/xml; charset="utf-8"' + } + return {} + + async def request( + self, + url: Optional[str] = None, + method: str = "GET", + body: str = "", + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """Low-level HTTP request""" + # ... implementation ... + + # Public convenience wrappers (thin): + async def propfind( + self, + url: Optional[str] = None, + body: str = "", + depth: int = 0, + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """PROPFIND request""" + merged = {**self._method_headers("PROPFIND", depth), **(headers or {})} + return await self.request(url, "PROPFIND", body, merged) + + async def report( + self, + url: Optional[str] = None, + body: str = "", + depth: int = 0, + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """REPORT request""" + merged = {**self._method_headers("REPORT", depth), **(headers or {})} + return await self.request(url, "REPORT", body, merged) + + # ... other methods ... + +# In async_davobject.py: + +class AsyncDAVObject: + async def _query( + self, + root=None, + depth=0, + query_method="propfind", + url=None, + expected_return_value=None, + ): + """Internal query method - calls request() directly""" + # ... body preparation ... + + if url is None: + url = self.url + + # NEW: Call request() directly, not the method wrapper + headers = self.client._method_headers(query_method, depth) + ret = await self.client.request(url, query_method.upper(), body, headers) + + # ... error handling ... +``` + +## Summary + +**YES, we can eliminate the dependency on method wrappers in `_query()`**, and we should! + +**However**, we should **keep the wrappers as public convenience methods** because: +1. Better API discoverability +2. Maintains backward compatibility +3. Only ~50 lines of code each in async version +4. Makes testing easier (can mock specific methods) + +The key insight: **remove the _dependency_ in `_query()`, not the wrappers themselves.** + +This gives us: +- ✅ Clean internal architecture (`_query()` → `request()` directly) +- ✅ Nice public API (`client.propfind()` is clear and discoverable) +- ✅ No breaking changes +- ✅ Easy to test diff --git a/docs/design/GET_DAVCLIENT_ANALYSIS.md b/docs/design/GET_DAVCLIENT_ANALYSIS.md new file mode 100644 index 00000000..13fdd1ab --- /dev/null +++ b/docs/design/GET_DAVCLIENT_ANALYSIS.md @@ -0,0 +1,462 @@ +# Analysis: get_davclient() vs DAVClient() Direct Instantiation + +## Current State + +### What is get_davclient()? + +`get_davclient()` is a **factory function** that creates a `DAVClient` instance with configuration from multiple sources (davclient.py:1225-1311): + +```python +def get_davclient( + check_config_file: bool = True, + config_file: str = None, + config_section: str = None, + testconfig: bool = False, + environment: bool = True, + name: str = None, + **config_data, +) -> DAVClient: +``` + +### Configuration Sources (in priority order): + +1. **Direct parameters**: `get_davclient(url="...", username="...", password="...")` +2. **Environment variables**: `CALDAV_URL`, `CALDAV_USERNAME`, `CALDAV_PASSWORD`, etc. +3. **Test configuration**: `./tests/conf.py` or `./conf.py` (for development/testing) +4. **Config file**: INI-style config file (path from `CALDAV_CONFIG_FILE` or parameter) + +### Current Usage Patterns + +**Documentation (docs/source/tutorial.rst)**: +- ALL examples use `get_davclient()` ✓ +- **Recommended pattern**: `from caldav import get_davclient` + +```python +from caldav import get_davclient + +with get_davclient() as client: + principal = client.principal() + calendars = principal.get_calendars() +``` + +**Examples (examples/*.py)**: +- ALL examples use `DAVClient()` directly ✗ +- Pattern: `from caldav import DAVClient` + +**Tests (tests/*.py)**: +- Mostly use `DAVClient()` directly for mocking and unit tests +- Pattern: Direct instantiation for test control + +**Actual Code Comment (davclient.py:602)**: +```python +## Deprecation TODO: give a warning, user should use get_davclient or auto_calendar instead. Probably. +``` + +There's already a TODO suggesting `DAVClient()` direct usage should be discouraged! + +## Advantages of get_davclient() + +### 1. **12-Factor App Compliance** ✓ +Supports configuration via environment variables (config stored in env, not code): + +```bash +export CALDAV_URL=https://caldav.example.com +export CALDAV_USERNAME=alice +export CALDAV_PASSWORD=hunter2 +python my_script.py # No hardcoded credentials! +``` + +```python +# In code: +with get_davclient() as client: # Automatically reads env vars! + ... +``` + +### 2. **Testing Flexibility** ✓ +Can use test servers without code changes: + +```bash +export PYTHON_CALDAV_USE_TEST_SERVER=1 +python my_script.py # Uses test server from conf.py +``` + +### 3. **Configuration File Support** ✓ +Supports INI-style config files: + +```ini +# ~/.caldav.conf +[default] +caldav_url = https://caldav.example.com +caldav_user = alice +caldav_pass = hunter2 +``` + +```python +with get_davclient() as client: # Reads ~/.caldav.conf automatically + ... +``` + +### 4. **Consistency** ✓ +All official documentation uses it - this is the "blessed" way. + +### 5. **Future-Proofing** ✓ +- Can add discovery, retry logic, connection pooling, etc. without breaking user code +- Can add more config sources (keyring, cloud secrets, etc.) + +## Disadvantages of get_davclient() + +### 1. **Hidden Magic** ✗ +Config source priority isn't obvious: +```python +get_davclient(url="A") # Uses "A" +# But if CALDAV_URL=B is set, which wins? (Answer: parameter, but not obvious) +``` + +### 2. **Harder to Understand** ✗ +More indirection - need to understand config file format, env var names, etc. + +### 3. **Less Explicit** ✗ +Pythonic code prefers "explicit is better than implicit": +```python +# Explicit (clear what's happening): +client = DAVClient(url="...", username="...", password="...") + +# Implicit (where does config come from?): +client = get_davclient() +``` + +### 4. **Not in __init__.py** ✗ +Currently not exported: +```python +# Doesn't work: +from caldav import get_davclient # ImportError! + +# Must use: +from caldav import get_davclient +``` + +## Usage Statistics + +**Documentation**: 100% use `get_davclient()` ✓ +**Examples**: 0% use `get_davclient()` (all use `DAVClient` directly) ✗ +**Tests**: ~5% use `get_davclient()` (mostly direct instantiation) +**Real-world**: Unknown (but docs recommend `get_davclient`) + +**Interpretation**: There's a disconnect between what's documented (factory) and what's demonstrated (direct). + +## Recommendations + +### Option A: Make get_davclient() Primary (Your Suggestion) ✓✓✓ + +**Advantages:** +- Aligns with existing documentation +- Better for production use (env vars, config files) +- Future-proof (can add features without breaking API) +- Follows factory pattern (like urllib3, requests, etc.) + +**Implementation:** +1. Export from `__init__.py`: + ```python + from .davclient import get_davclient + __all__ = ["get_davclient", "DAVClient"] # Export both + ``` + +2. Update all examples to use `get_davclient()`: + ```python + from caldav import get_davclient + + with get_davclient(url="...", username="...", password="...") as client: + ... + ``` + +3. Add deprecation warning to `DAVClient.__init__()` (optional): + ```python + def __init__(self, ...): + warnings.warn( + "Direct DAVClient() instantiation is deprecated. " + "Use caldav.get_davclient() instead.", + DeprecationWarning, + stacklevel=2 + ) + ``` + +4. Keep `DAVClient` public for: + - Testing (mocking) + - Advanced use cases + - Type hints: `client: DAVClient` + +### Option B: Make Both Equal + +Keep both as first-class citizens: +- `DAVClient()` for simple/explicit use +- `get_davclient()` for config-based use + +Update docs to show both patterns. + +### Option C: Direct Only + +Deprecate `get_davclient()`, use only `DAVClient()`. + +**Problems:** +- Loses env var support +- Loses config file support +- Goes against current documentation +- Less future-proof + +## Async Implications + +For async API, we should be consistent: + +```python +# If we prefer factories: +from caldav import aio + +async with aio.get_client(url="...", username="...", password="...") as client: + ... + +# Or direct (current aio.py approach): +from caldav import aio + +async with aio.CalDAVClient(url="...", username="...", password="...") as client: + ... +``` + +## Verdict: Option A (Factory Primary) ✓ + +**YES, using `get_davclient()` as primary is a good idea** because: + +1. ✅ Already documented as recommended approach +2. ✅ Supports production use cases (env vars, config files) +3. ✅ Future-proof (can add connection pooling, retries, etc.) +4. ✅ Follows TODO comment in code (line 602) +5. ✅ Consistent with 12-factor app principles + +**Action Items:** + +1. Export `get_davclient` from `caldav.__init__` +2. Update all examples to use factory +3. Create async equivalent: `aio.get_client()` or `aio.get_davclient()` +4. Consider soft deprecation of direct `DAVClient()` (warning, not error) +5. Keep `DAVClient` class public for testing and type hints + +**Proposed Async API:** + +```python +# caldav/aio.py +async def get_client( + url: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + *, + check_config_file: bool = True, + config_file: Optional[str] = None, + environment: bool = True, + **config_data, +) -> CalDAVClient: + """ + Get an async CalDAV client with configuration from multiple sources. + + Configuration priority: + 1. Direct parameters + 2. Environment variables (CALDAV_*) + 3. Config file + + Example: + # From parameters: + async with await aio.get_client(url="...", username="...") as client: + ... + + # From environment: + async with await aio.get_client() as client: # Uses CALDAV_* env vars + ... + """ + # Read config from env, file, etc. (like sync get_davclient) + # Return CalDAVClient(**merged_config) +``` + +Usage: +```python +from caldav import aio + +# Simple: +async with await aio.get_client(url="...", username="...", password="...") as client: + calendars = await client.get_calendars() + +# From environment: +async with await aio.get_client() as client: # Reads CALDAV_* env vars + calendars = await client.get_calendars() +``` + +## Alternative Naming + +Since we're designing the async API from scratch, we could use cleaner names: + +```python +# Option 1: Parallel naming +caldav.get_davclient() # Sync +caldav.aio.get_davclient() # Async (or get_client) + +# Option 2: Simpler naming +caldav.get_client() # Sync +caldav.aio.get_client() # Async + +# Option 3: connect() - REJECTED +caldav.connect() # Sync +caldav.aio.connect() # Async +``` + +**Option 3 rejected**: `connect()` implies immediate connection attempt, but `DAVClient.__init__()` doesn't connect to the server. It only stores configuration. Actual network I/O happens on first method call. + +**Recommendation**: Stick with **Option 1** (`get_davclient`) for consistency. + +## Adding Connection Probe + +### The Problem + +Current behavior: +```python +# This succeeds even if server is unreachable: +client = get_davclient(url="https://invalid-server.com", username="x", password="y") + +# Error only happens on first actual call: +principal = client.principal() # <-- ConnectionError here +``` + +Users don't know if credentials/URL are correct until first use. + +### Proposal: Optional Connection Probe + +Add a `probe` parameter to verify connectivity: + +```python +def get_davclient( + check_config_file: bool = True, + config_file: str = None, + config_section: str = None, + testconfig: bool = False, + environment: bool = True, + name: str = None, + probe: bool = True, # NEW: verify connection + **config_data, +) -> DAVClient: + """ + Get a DAVClient with optional connection verification. + + Args: + probe: If True, performs a simple OPTIONS request to verify + the server is reachable and responds. Default: True. + Set to False to skip verification (useful for testing). + """ + client = DAVClient(**merged_config) + + if probe: + try: + # Simple probe - just check if server responds + client.options(str(client.url)) + except Exception as e: + raise ConnectionError( + f"Failed to connect to CalDAV server at {client.url}: {e}" + ) from e + + return client +``` + +### Usage + +```python +# Verify connection immediately: +with get_davclient(url="...", username="...", password="...") as client: + # If we get here, server is reachable + principal = client.principal() + +# Skip probe (for testing or when server might be down): +with get_davclient(url="...", probe=False) as client: + # No connection attempt yet + ... +``` + +### Async Version + +```python +async def get_davclient( + ..., + probe: bool = True, + **config_data, +) -> AsyncDAVClient: + """Async version with connection probe""" + client = AsyncDAVClient(**merged_config) + + if probe: + try: + await client.options(str(client.url)) + except Exception as e: + raise ConnectionError( + f"Failed to connect to CalDAV server at {client.url}: {e}" + ) from e + + return client + +# Usage: +async with await get_davclient(url="...") as client: + # Connection verified + ... +``` + +### Benefits + +1. **Fail fast** - errors caught immediately, not on first use +2. **Better UX** - clear error message about connectivity +3. **Opt-out available** - `probe=False` for testing or when needed +4. **Minimal overhead** - single OPTIONS request +5. **Validates config** - catches typos in URL, wrong credentials, etc. + +### Considerations + +**What should the probe do?** + +Option A (minimal): Just `OPTIONS` request +- ✅ Fast +- ✅ Doesn't require authentication (usually) +- ❌ Doesn't verify credentials + +Option B (thorough): Try to get principal +- ✅ Verifies credentials +- ✅ Verifies CalDAV support +- ❌ Slower +- ❌ Requires valid credentials + +**Recommendation**: Start with **Option A** (OPTIONS), consider Option B later or as separate parameter: + +```python +get_davclient( + ..., + probe: bool = True, # OPTIONS request + verify_auth: bool = False, # Also try to authenticate +) +``` + +### Default Value + +**Should probe default to True or False?** + +Arguments for `True`: +- ✅ Better UX - catches errors early +- ✅ Fail fast principle +- ✅ Most production use cases want this + +Arguments for `False`: +- ✅ Backward compatible (no behavior change) +- ✅ Faster (no extra request) +- ✅ Works when server is temporarily down + +**Recommendation**: Default to `True` for new async API, `False` for sync (backward compat). + +```python +# Sync (backward compatible): +def get_davclient(..., probe: bool = False) -> DAVClient: + ... + +# Async (new, opinionated): +async def get_davclient(..., probe: bool = True) -> AsyncDAVClient: + ... +``` diff --git a/docs/design/METHOD_GENERATION_ANALYSIS.md b/docs/design/METHOD_GENERATION_ANALYSIS.md new file mode 100644 index 00000000..13c3622e --- /dev/null +++ b/docs/design/METHOD_GENERATION_ANALYSIS.md @@ -0,0 +1,430 @@ +# Analysis: Generating HTTP Method Wrappers vs Manual Implementation + +## Your Insights + +1. **Option 3 loses mocking** - if `_query()` calls `request()` directly, we can't mock `client.propfind()` +2. **`_query()` could be eliminated** - callers could call methods directly instead +3. **Generate methods** - instead of writing them manually, generate them programmatically + +## Current Usage of _query() + +Let me trace where `_query()` is actually called: + +```python +# davobject.py:191 - in _query_properties() +return self._query(root, depth) + +# davobject.py:382 - in set_properties() +r = self._query(root, query_method="proppatch") + +# collection.py:469 - in save() for creating calendars +r = self._query(root=mkcol, query_method=method, url=path, expected_return_value=201) + +# collection.py:666, 784, 982 - in various search/report methods +response = self._query(root, 1, "report") +response = self._query(xml, 1, "report") +response = self._query(root, 1, "report") +``` + +### Key Observation + +`_query()` is called with different `query_method` values: +- `"propfind"` (default) +- `"proppatch"` +- `"mkcol"` or `"mkcalendar"` +- `"report"` + +**Your insight is correct**: These calls could be replaced with direct method calls! + +```python +# Instead of: +r = self._query(root, query_method="proppatch") + +# Could be: +r = self.client.proppatch(self.url, body) + +# Instead of: +r = self._query(root=mkcol, query_method="mkcol", url=path, ...) + +# Could be: +r = self.client.mkcol(path, body) +``` + +## Option Analysis + +### Option A: Remove _query(), Keep Manual Wrappers ✓ + +**Implementation:** +```python +# In DAVObject - eliminate _query() entirely +def _query_properties(self, props=None, depth=0): + """Query properties""" + root = None + if props is not None and len(props) > 0: + prop = dav.Prop() + props + root = dav.Propfind() + prop + + body = "" + if root: + body = etree.tostring(root.xmlelement(), encoding="utf-8", xml_declaration=True) + + # Direct call to method wrapper + ret = self.client.propfind(self.url, body, depth) + + if ret.status == 404: + raise error.NotFoundError(errmsg(ret)) + if ret.status >= 400: + raise error.exception_by_method["propfind"](errmsg(ret)) + return ret + +def set_properties(self, props=None): + """Set properties""" + prop = dav.Prop() + (props or []) + set_elem = dav.Set() + prop + root = dav.PropertyUpdate() + set_elem + body = etree.tostring(root.xmlelement(), encoding="utf-8", xml_declaration=True) + + # Direct call to method wrapper + r = self.client.proppatch(self.url, body) + + statuses = r.tree.findall(".//" + dav.Status.tag) + for s in statuses: + if " 200 " not in s.text: + raise error.PropsetError(s.text) + return self +``` + +**Pros:** +- ✅ Keeps mocking capability (`client.propfind = mock.Mock()`) +- ✅ Clear, explicit code +- ✅ Good discoverability +- ✅ Eliminates `_query()` complexity + +**Cons:** +- ❌ ~50 lines of boilerplate per wrapper (8 wrappers = ~400 lines) +- ❌ Duplicate parameter handling in each wrapper + +### Option B: Generate Wrappers Dynamically at Class Creation + +**Implementation:** + +```python +# In davclient.py + +class DAVClient: + """CalDAV client""" + + # Method specifications + _WEBDAV_METHODS = { + 'propfind': { + 'http_method': 'PROPFIND', + 'has_depth': True, + 'has_body': True, + 'default_headers': lambda depth: {'Depth': str(depth)}, + }, + 'report': { + 'http_method': 'REPORT', + 'has_depth': True, + 'has_body': True, + 'default_headers': lambda depth: { + 'Depth': str(depth), + 'Content-Type': 'application/xml; charset="utf-8"' + }, + }, + 'proppatch': { + 'http_method': 'PROPPATCH', + 'has_depth': False, + 'has_body': True, + 'url_required': True, + }, + 'mkcol': { + 'http_method': 'MKCOL', + 'has_depth': False, + 'has_body': True, + 'url_required': True, + }, + 'mkcalendar': { + 'http_method': 'MKCALENDAR', + 'has_depth': False, + 'has_body': True, + 'url_required': True, + }, + 'put': { + 'http_method': 'PUT', + 'has_depth': False, + 'has_body': True, + 'url_required': True, + 'has_headers': True, + }, + 'post': { + 'http_method': 'POST', + 'has_depth': False, + 'has_body': True, + 'url_required': True, + 'has_headers': True, + }, + 'delete': { + 'http_method': 'DELETE', + 'has_depth': False, + 'has_body': False, + 'url_required': True, + }, + 'options': { + 'http_method': 'OPTIONS', + 'has_depth': False, + 'has_body': False, + }, + } + + def __init__(self, ...): + # ... normal init ... + + async def request(self, url=None, method="GET", body="", headers=None): + """Low-level HTTP request""" + # ... implementation ... + + +# Generate wrapper methods dynamically +def _create_method_wrapper(method_name, method_spec): + """Factory function to create a method wrapper""" + + def wrapper(self, url=None, body="", depth=0, headers=None): + # Build the actual call + final_url = url if method_spec.get('url_required') else (url or str(self.url)) + final_headers = headers or {} + + # Add default headers + if method_spec.get('has_depth') and 'default_headers' in method_spec: + final_headers.update(method_spec['default_headers'](depth)) + + return self.request( + final_url, + method_spec['http_method'], + body if method_spec.get('has_body') else "", + final_headers + ) + + # Set proper metadata + wrapper.__name__ = method_name + wrapper.__doc__ = f"{method_spec['http_method']} request" + + return wrapper + +# Attach generated methods to the class +for method_name, method_spec in DAVClient._WEBDAV_METHODS.items(): + setattr(DAVClient, method_name, _create_method_wrapper(method_name, method_spec)) +``` + +**Usage is identical:** +```python +client.propfind(url, body, depth) # Works the same +client.proppatch(url, body) # Works the same +``` + +**Pros:** +- ✅ Keeps mocking capability +- ✅ DRY - single source of truth for method specs +- ✅ Easy to add new methods (just add to dict) +- ✅ ~100 lines instead of ~400 lines +- ✅ Still discoverable (methods exist on class) + +**Cons:** +- ❌ Harder to debug (generated code) +- ❌ IDE auto-complete might not work as well +- ❌ Type hints would need `__init_subclass__` or stub file +- ❌ Less explicit (magic) + +### Option C: Generate Wrappers with Explicit Signatures (Best of Both) + +Use a decorator to generate methods but keep signatures explicit: + +```python +# In davclient.py + +def webdav_method(http_method, has_depth=False, url_required=False, headers_fn=None): + """Decorator to create WebDAV method wrappers""" + def decorator(func): + @functools.wraps(func) + def wrapper(self, url=None, body="", depth=0, headers=None): + # Delegate to the decorated function for any custom logic + return func(self, url, body, depth, headers, http_method, headers_fn) + return wrapper + return decorator + +class DAVClient: + + @webdav_method("PROPFIND", has_depth=True, + headers_fn=lambda depth: {"Depth": str(depth)}) + def propfind(self, url, body, depth, headers, http_method, headers_fn): + """PROPFIND request""" + final_headers = {**headers_fn(depth), **(headers or {})} + return self.request(url or str(self.url), http_method, body, final_headers) + + @webdav_method("REPORT", has_depth=True, + headers_fn=lambda depth: { + "Depth": str(depth), + "Content-Type": 'application/xml; charset="utf-8"' + }) + def report(self, url, body, depth, headers, http_method, headers_fn): + """REPORT request""" + final_headers = {**headers_fn(depth), **(headers or {})} + return self.request(url or str(self.url), http_method, body, final_headers) + + @webdav_method("PROPPATCH", url_required=True) + def proppatch(self, url, body, depth, headers, http_method, headers_fn): + """PROPPATCH request""" + return self.request(url, http_method, body, headers or {}) +``` + +**Pros:** +- ✅ Explicit method signatures (good for IDE) +- ✅ Type hints work normally +- ✅ Can add docstrings +- ✅ DRY for common behavior +- ✅ Mocking works + +**Cons:** +- ❌ Still somewhat repetitive +- ❌ Decorator makes it less obvious what's happening + +## Recommendation: Option A (Manual + Helper) + +For the **async refactoring**, I recommend **Option A**: + +1. **Keep manual methods** - 8 methods × ~40 lines = ~320 lines +2. **Use helper for headers** - reduces duplication +3. **Eliminate `_query()`** - callers use methods directly +4. **Clear and explicit** - Pythonic, easy to understand + +Note: Option A achieves the same result as what I previously called "Option D" - they're the same approach. + +**Why not generated (Option B/C)?** +- Async/await makes generation more complex +- Type hints would be harder +- Debugging generated async code is painful +- Not that much code savings (~200 lines) + +**Implementation in async:** + +```python +class AsyncDAVClient: + + @staticmethod + def _method_headers(method: str, depth: int = 0) -> Dict[str, str]: + """Build headers for WebDAV methods (internal helper)""" + headers_map = { + "PROPFIND": {"Depth": str(depth)}, + "REPORT": { + "Depth": str(depth), + "Content-Type": 'application/xml; charset="utf-8"' + }, + } + return headers_map.get(method.upper(), {}) + + # Query methods (URL optional) + async def propfind( + self, + url: Optional[str] = None, + body: str = "", + depth: int = 0, + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """PROPFIND request - query properties""" + final_headers = { + **self._method_headers("PROPFIND", depth), + **(headers or {}) + } + return await self.request(url or str(self.url), "PROPFIND", body, final_headers) + + async def report( + self, + url: Optional[str] = None, + body: str = "", + depth: int = 0, + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """REPORT request - run reports""" + final_headers = { + **self._method_headers("REPORT", depth), + **(headers or {}) + } + return await self.request(url or str(self.url), "REPORT", body, final_headers) + + # Resource methods (URL required) + async def proppatch( + self, + url: str, + body: str = "", + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """PROPPATCH request - update properties""" + return await self.request(url, "PROPPATCH", body, headers or {}) + + async def mkcol( + self, + url: str, + body: str = "", + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """MKCOL request - create collection""" + return await self.request(url, "MKCOL", body, headers or {}) + + # ... etc +``` + +## What About _query()? + +**Eliminate it!** Callers should use the methods directly: + +```python +# In AsyncDAVObject: + +async def _query_properties(self, props=None, depth=0): + """Query properties via PROPFIND""" + root = None + if props: + prop = dav.Prop() + props + root = dav.Propfind() + prop + + body = "" + if root: + body = etree.tostring(root.xmlelement(), encoding="utf-8", xml_declaration=True) + + # Direct call - no _query() middleman + ret = await self.client.propfind(self.url, body, depth) + + if ret.status == 404: + raise error.NotFoundError(errmsg(ret)) + if ret.status >= 400: + raise error.exception_by_method["propfind"](errmsg(ret)) + return ret + +async def set_properties(self, props=None): + """Set properties via PROPPATCH""" + prop = dav.Prop() + (props or []) + set_elem = dav.Set() + prop + root = dav.PropertyUpdate() + set_elem + body = etree.tostring(root.xmlelement(), encoding="utf-8", xml_declaration=True) + + # Direct call - no _query() + r = await self.client.proppatch(self.url, body) + + statuses = r.tree.findall(".//" + dav.Status.tag) + for s in statuses: + if " 200 " not in s.text: + raise error.PropsetError(s.text) + return self +``` + +## Summary + +1. **Eliminate `_query()`** - it's unnecessary indirection ✅ +2. **Keep method wrappers** - for mocking and discoverability ✅ +3. **Use manual implementation** - clear, explicit, debuggable ✅ +4. **Add helper for headers** - reduce repetition ✅ + +**Code size**: ~320 lines for 8 methods (reasonable) +**Benefits**: Mocking works, clear code, easy to maintain +**Trade-off**: Some repetition, but Pythonic and explicit + +For async API, this is the sweet spot between DRY and explicit. diff --git a/docs/design/PROTOCOL_LAYER_USAGE.md b/docs/design/PROTOCOL_LAYER_USAGE.md new file mode 100644 index 00000000..b50bec58 --- /dev/null +++ b/docs/design/PROTOCOL_LAYER_USAGE.md @@ -0,0 +1,217 @@ +# Protocol Layer Usage Guide + +This guide explains how to use the Sans-I/O protocol layer for testing and advanced use cases. + +## Overview + +The protocol layer (`caldav/protocol/`) provides pure functions for: +- **XML Building**: Construct request bodies without I/O +- **XML Parsing**: Parse response bodies without I/O + +This separation enables: +- Easy testing without HTTP mocking +- Same code works for sync and async +- Clear separation of concerns + +## Module Structure + +``` +caldav/protocol/ +├── __init__.py # Public exports +├── types.py # DAVRequest, DAVResponse, result dataclasses +├── xml_builders.py # Pure functions to build XML +└── xml_parsers.py # Pure functions to parse XML +``` + +## Testing Without HTTP Mocking + +The main benefit of the protocol layer is testability: + +```python +from caldav.protocol import ( + build_propfind_body, + build_calendar_query_body, + parse_propfind_response, + parse_calendar_query_response, +) + +def test_propfind_body_building(): + """Test XML building - no HTTP needed.""" + body = build_propfind_body(["displayname", "resourcetype"]) + xml = body.decode("utf-8") + + assert "propfind" in xml.lower() + assert "displayname" in xml.lower() + assert "resourcetype" in xml.lower() + +def test_propfind_response_parsing(): + """Test XML parsing - no HTTP needed.""" + xml = b''' + + + /calendars/user/ + + + My Calendar + + HTTP/1.1 200 OK + + + ''' + + results = parse_propfind_response(xml, status_code=207) + + assert len(results) == 1 + assert results[0].href == "/calendars/user/" + assert results[0].properties["{DAV:}displayname"] == "My Calendar" +``` + +## Available Functions + +### XML Builders + +```python +from caldav.protocol import ( + build_propfind_body, + build_proppatch_body, + build_calendar_query_body, + build_calendar_multiget_body, + build_sync_collection_body, + build_mkcalendar_body, + build_mkcol_body, + build_freebusy_query_body, +) + +# PROPFIND +body = build_propfind_body(["displayname", "resourcetype"]) + +# Calendar query with time range +body, comp_type = build_calendar_query_body( + start=datetime(2024, 1, 1), + end=datetime(2024, 12, 31), + event=True, # or todo=True, journal=True +) + +# Multiget specific items +body = build_calendar_multiget_body([ + "/cal/event1.ics", + "/cal/event2.ics", +]) + +# MKCALENDAR +body = build_mkcalendar_body( + displayname="My Calendar", + description="A test calendar", +) +``` + +### XML Parsers + +```python +from caldav.protocol import ( + parse_multistatus, + parse_propfind_response, + parse_calendar_query_response, + parse_calendar_multiget_response, + parse_sync_collection_response, +) + +# Parse PROPFIND response +results = parse_propfind_response(xml_body, status_code=207) +for result in results: + print(f"href: {result.href}") + print(f"props: {result.properties}") + +# Parse calendar-query response +results = parse_calendar_query_response(xml_body, status_code=207) +for result in results: + print(f"href: {result.href}") + print(f"etag: {result.etag}") + print(f"data: {result.calendar_data}") + +# Parse sync-collection response +result = parse_sync_collection_response(xml_body, status_code=207) +print(f"changed: {result.changed}") +print(f"deleted: {result.deleted}") +print(f"sync_token: {result.sync_token}") +``` + +## Result Types + +The parsers return typed dataclasses: + +```python +from caldav.protocol import ( + PropfindResult, + CalendarQueryResult, + SyncCollectionResult, + MultistatusResponse, +) + +# PropfindResult +@dataclass +class PropfindResult: + href: str + properties: dict[str, Any] + status: int = 200 + +# CalendarQueryResult +@dataclass +class CalendarQueryResult: + href: str + etag: str | None + calendar_data: str | None + +# SyncCollectionResult +@dataclass +class SyncCollectionResult: + changed: list[CalendarQueryResult] + deleted: list[str] + sync_token: str | None +``` + +## Using with Custom HTTP + +If you want to use the protocol layer with a different HTTP library: + +```python +import httpx # or any HTTP library +from caldav.protocol import build_propfind_body, parse_propfind_response + +# Build request body +body = build_propfind_body(["displayname"]) + +# Make request with your HTTP library +response = httpx.request( + "PROPFIND", + "https://cal.example.com/calendars/", + content=body, + headers={ + "Content-Type": "application/xml", + "Depth": "1", + }, + auth=("user", "pass"), +) + +# Parse response +results = parse_propfind_response(response.content, response.status_code) +``` + +## Integration with DAVClient + +The protocol layer is used internally by `DAVClient` and `AsyncDAVClient`. +You can access parsed results via `response.results`: + +```python +from caldav import DAVClient + +client = DAVClient(url="https://cal.example.com", username="user", password="pass") +response = client.propfind(url, props=["displayname"], depth=1) + +# Access pre-parsed results +for result in response.results: + print(f"{result.href}: {result.properties}") + +# Legacy method (deprecated but still works) +objects = response.find_objects_and_props() +``` diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 00000000..1fa61ece --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,87 @@ +# CalDAV Design Documents + +## Current Status (January 2026) + +**Branch:** `v3.0-dev` + +### Architecture + +The caldav library uses a **Sans-I/O** approach where protocol logic (XML building/parsing) +is separated from I/O operations. This allows the same protocol code to be used by both +sync and async clients. + +``` +┌─────────────────────────────────────────────────────┐ +│ High-Level Objects (Calendar, Principal, etc.) │ +├─────────────────────────────────────────────────────┤ +│ Operations Layer (caldav/operations/) │ +│ - Pure functions for building queries │ +│ - Pure functions for processing responses │ +├─────────────────────────────────────────────────────┤ +│ DAVClient (sync) / AsyncDAVClient (async) │ +│ → Handle HTTP via niquests │ +├─────────────────────────────────────────────────────┤ +│ Protocol Layer (caldav/protocol/) │ +│ - xml_builders.py: Build XML request bodies │ +│ - xml_parsers.py: Parse XML responses │ +└─────────────────────────────────────────────────────┘ +``` + +## Design Documents + +### [SANS_IO_DESIGN.md](SANS_IO_DESIGN.md) +**Current architecture** - What Sans-I/O means for this project: +- Protocol layer separates XML logic from I/O +- Testing benefits +- Why we didn't implement a full I/O abstraction layer + +### [SANS_IO_IMPLEMENTATION_PLAN.md](SANS_IO_IMPLEMENTATION_PLAN.md) +**Implementation status** for reducing code duplication: +- Phase 1: Protocol layer ✅ Complete +- Phase 2: Extract shared utilities ✅ Complete +- Phase 3: Consolidate response handling ✅ Complete + +### [SANS_IO_IMPLEMENTATION_PLAN2.md](SANS_IO_IMPLEMENTATION_PLAN2.md) +**Detailed plan** for eliminating sync/async duplication through the operations layer. + +### [PROTOCOL_LAYER_USAGE.md](PROTOCOL_LAYER_USAGE.md) +How to use the protocol layer for testing and low-level access. + +### [GET_DAVCLIENT_ANALYSIS.md](GET_DAVCLIENT_ANALYSIS.md) +Analysis of `get_davclient()` factory function vs direct `DAVClient()` instantiation. + +### [TODO.md](TODO.md) +Known issues and remaining work items. + +## API Design + +### [API_NAMING_CONVENTIONS.md](API_NAMING_CONVENTIONS.md) +**API naming conventions** - Guide to recommended vs legacy method names: +- Which methods to use in new code +- Migration guide from legacy methods +- Deprecation timeline + +### Historical API Analysis + +These documents contain design rationale for API decisions: + +- [API_ANALYSIS.md](API_ANALYSIS.md) - API inconsistency analysis and improvement recommendations +- [URL_AND_METHOD_RESEARCH.md](URL_AND_METHOD_RESEARCH.md) - URL parameter semantics research +- [ELIMINATE_METHOD_WRAPPERS_ANALYSIS.md](ELIMINATE_METHOD_WRAPPERS_ANALYSIS.md) - Decision to keep method wrappers +- [METHOD_GENERATION_ANALYSIS.md](METHOD_GENERATION_ANALYSIS.md) - Decision to use manual implementation + +## Code Style + +### [RUFF_CONFIGURATION_PROPOSAL.md](RUFF_CONFIGURATION_PROPOSAL.md) +Proposed Ruff configuration for linting and formatting. + +### [RUFF_REMAINING_ISSUES.md](RUFF_REMAINING_ISSUES.md) +Remaining linting issues to address. + +## Historical Note + +Some design documents from the exploration phase were removed in January 2026 after +the Sans-I/O approach was chosen. Removed documents covered the abandoned async-first- +with-sync-wrapper approach (phase plans, sync wrapper demos, performance analysis of +event loop overhead, etc.). The API analysis documents were kept as they contain design +rationale that remains relevant regardless of the implementation approach. diff --git a/docs/design/RUFF_CONFIGURATION_PROPOSAL.md b/docs/design/RUFF_CONFIGURATION_PROPOSAL.md new file mode 100644 index 00000000..d1ad0c37 --- /dev/null +++ b/docs/design/RUFF_CONFIGURATION_PROPOSAL.md @@ -0,0 +1,249 @@ +# Ruff Configuration for Partial Codebase + +## Goal + +Apply Ruff formatting/linting only to new/rewritten async files while leaving existing code untouched. + +## icalendar-searcher Configuration (Reference) + +From `/home/tobias/icalendar-searcher/pyproject.toml`: + +```toml +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "ANN"] +ignore = ["E501", "ANN401"] + +[tool.ruff.lint.isort] +known-first-party = ["icalendar_searcher"] +``` + +## Option 1: Include/Exclude Patterns (RECOMMENDED) + +Use `include` or `extend-include` to specify which files Ruff should check: + +```toml +# pyproject.toml +[tool.ruff] +line-length = 100 +target-version = "py39" # caldav supports 3.9+ + +# Only apply Ruff to these files/directories +include = [ + "caldav/async_davclient.py", + "caldav/async_davobject.py", + "caldav/async_collection.py", + "caldav/aio/*.py", # If we use a submodule + "tests/test_async_*.py", +] + +# OR use extend-include to add to defaults +extend-include = ["*.pyi"] # Also check stub files + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "ANN"] +ignore = [ + "E501", # Line too long (handled by formatter) + "ANN401", # Any type annotation +] + +[tool.ruff.lint.isort] +known-first-party = ["caldav"] +``` + +## Option 2: Exclude Patterns (Alternative) + +Instead of listing files to include, exclude everything except new files: + +```toml +[tool.ruff] +line-length = 100 +target-version = "py39" + +# Exclude everything except async files +extend-exclude = [ + "caldav/davclient.py", # Exclude until rewritten + "caldav/davobject.py", # Exclude until rewritten + "caldav/collection.py", # Exclude until rewritten + "caldav/calendarobjectresource.py", + "caldav/search.py", + "caldav/objects.py", + "caldav/config.py", + "caldav/discovery.py", + "caldav/compatibility_hints.py", + "caldav/requests.py", + # Keep excluding old files... +] +``` + +**Problem with Option 2**: Harder to maintain - need to list every old file. + +## Option 3: Directory Structure (CLEANEST) + +Organize new async code in a separate directory: + +``` +caldav/ +├── __init__.py +├── aio/ # NEW: async module +│ ├── __init__.py +│ ├── client.py # AsyncDAVClient +│ ├── davobject.py # AsyncDAVObject +│ └── collection.py # Async collections +├── davclient.py # Old/sync code (no Ruff) +├── davobject.py # Old code (no Ruff) +└── collection.py # Old code (no Ruff) +``` + +Then configure Ruff: + +```toml +[tool.ruff] +line-length = 100 +target-version = "py39" + +# Only apply to aio/ directory +include = ["caldav/aio/**/*.py", "tests/test_aio_*.py"] +``` + +**Advantages**: +- Very clear separation +- Easy to configure +- Easy to understand what's "new" vs "old" + +**Disadvantages**: +- Different import structure +- May need to reorganize later + +## Option 4: Per-File Ruff Control (For Gradual Migration) + +Use `# ruff: noqa` at the top of files you don't want Ruff to check: + +```python +# caldav/davclient.py (old file) +# ruff: noqa +"""Old davclient - excluded from Ruff until rewrite""" +... +``` + +Then Ruff applies to everything by default, but old files opt out. + +## Recommended Approach for caldav + +**Use Option 1 (Include Patterns)** with explicit file list: + +### Phase 1: Initial Async Files + +```toml +[tool.ruff] +line-length = 100 +target-version = "py39" + +# Explicitly list new async files +include = [ + "caldav/async_davclient.py", + "caldav/async_davobject.py", + "caldav/async_collection.py", + "tests/test_async_davclient.py", + "tests/test_async_collection.py", +] + +[tool.ruff.lint] +# Based on icalendar-searcher config +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "UP", # pyupgrade (modernize code) + "B", # flake8-bugbear (find bugs) + "ANN", # type annotations +] +ignore = [ + "E501", # Line too long (formatter handles this) + "ANN401", # Any type (sometimes necessary) +] + +[tool.ruff.format] +# Use Ruff's formatter (Black-compatible) +quote-style = "double" +indent-style = "space" + +[tool.ruff.lint.isort] +known-first-party = ["caldav"] +``` + +### Phase 2: After Sync Wrapper Rewrite + +Add the rewritten sync files: + +```toml +include = [ + # Async files + "caldav/async_davclient.py", + "caldav/async_davobject.py", + "caldav/async_collection.py", + # Rewritten sync wrappers + "caldav/davclient.py", # Added after rewrite + # Tests + "tests/test_async_*.py", + "tests/test_davclient.py", # Added after rewrite +] +``` + +### Phase 3+: Gradually Expand + +As other files are refactored, add them to the `include` list. + +## Integration with pre-commit (Optional) + +From icalendar-searcher's `.pre-commit-config.yaml`: + +```yaml +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.4 # Use latest version + hooks: + - id: ruff + args: [--fix] + - id: ruff-format +``` + +This will: +1. Auto-fix issues Ruff can fix +2. Format code on commit +3. Only run on files in `include` list + +## Commands + +```bash +# Check files (no changes) +ruff check caldav/async_davclient.py + +# Fix issues automatically +ruff check --fix caldav/async_davclient.py + +# Format files +ruff format caldav/async_davclient.py + +# Check all included files +ruff check . + +# Format all included files +ruff format . +``` + +## Summary + +**Recommendation**: Use **Option 1 with explicit `include` list** in `pyproject.toml`: + +✅ Clear control over which files use Ruff +✅ Easy to expand as files are refactored +✅ No risk of accidentally formatting old code +✅ Works with pre-commit hooks +✅ Can run `ruff check .` safely (only checks included files) + +Start minimal (just async files) and expand as needed. diff --git a/docs/design/RUFF_REMAINING_ISSUES.md b/docs/design/RUFF_REMAINING_ISSUES.md new file mode 100644 index 00000000..aa6799f2 --- /dev/null +++ b/docs/design/RUFF_REMAINING_ISSUES.md @@ -0,0 +1,182 @@ +# Ruff Issues for Async Files - Resolution Log + +Generated after initial Ruff setup on new async files (v2.2.2+). + +## Summary + +- **Initial issues**: 33 +- **Auto-fixed (first pass)**: 13 +- **Auto-fixed (unsafe)**: 14 +- **Manually fixed**: 9 +- **Final status**: ✅ All issues resolved (0 remaining) + +## Resolution Summary + +All Ruff issues have been fixed! The async files now pass all linting checks. + +### What Was Fixed + +**Auto-fixed (Safe - First Pass)**: +- Sorted and organized imports +- Moved `Mapping` from `typing` to `collections.abc` +- Simplified generator expressions +- Converted some `.format()` calls to f-strings + +**Auto-fixed (Unsafe Fixes)**: +- Type annotation modernization: `Dict` → `dict`, `List` → `list`, `Tuple` → `tuple` +- Removed outdated Python version blocks +- Additional string formatting conversions + +**Manually Fixed**: +1. **Import error handling (B904)**: Added `from err` to raise statement +2. **Missing import (F821)**: Added `import niquests` module reference +3. **Variable redefinition (F811)**: Removed duplicate `raw = ""` class variable +4. **Bare except clauses (E722, 3 instances)**: + - Content-Length parsing: `except (KeyError, ValueError, TypeError)` + - XML parsing: `except Exception` + - Connection errors: `except Exception` +5. **String formatting (UP031, 2 instances)**: Converted `%` formatting to f-strings +6. **Type annotation (ANN003)**: Added `**kwargs: Any` annotation + +### Verification + +```bash +$ ruff check . +All checks passed! + +$ ruff format . +3 files left unchanged + +$ pytest tests/test_compatibility_hints.py tests/test_caldav.py::TestForServerLocalRadicale +57 passed, 13 skipped +``` + +--- + +## Original Issues by Category (For Reference) + +### 1. Type Annotation Modernization (UP006, UP035) +**Count**: 8 issues + +Replace deprecated `typing` types with builtin equivalents: +- `Dict` → `dict` +- `List` → `list` +- `Tuple` → `tuple` + +**Files**: `caldav/async_davclient.py` + +**Action**: Can be fixed with `--unsafe-fixes` flag, or manually replace throughout the file. + +### 2. Exception Handling (B904, E722) +**Count**: 4 issues + +- **B904**: Use `raise ... from err` or `raise ... from None` in except clauses +- **E722**: Replace bare `except:` with specific exception types + +**Files**: `caldav/async_davclient.py` + +**Action**: Requires manual review to determine appropriate exception types. + +### 3. String Formatting (UP031) +**Count**: 4 issues + +Replace old `%` formatting with f-strings: +```python +# Old +log.debug("server responded with %i %s" % (r.status_code, r.reason)) + +# New +log.debug(f"server responded with {r.status_code} {r.reason}") +``` + +**Files**: `caldav/async_davclient.py` + +**Action**: Can be auto-fixed with `--unsafe-fixes`. + +### 4. Version Block (UP036) +**Count**: 1 issue + +Remove outdated Python version check (since min version is 3.9): +```python +if sys.version_info < (3, 9): + from collections.abc import Mapping +else: + from collections.abc import Mapping +``` + +**Files**: `caldav/async_davclient.py` + +**Action**: Simplify to unconditional import since we require Python 3.9+. + +### 5. Missing Import (F821) +**Count**: 1 issue + +Undefined name `niquests` in exception handler: +```python +self.session = niquests.AsyncSession() +``` + +**Files**: `caldav/async_davclient.py` + +**Action**: Import `niquests` at module level (currently only imported in try/except). + +### 6. Variable Redefinition (F811) +**Count**: 1 issue + +`raw` defined as class variable and redefined as property: +```python +class AsyncDAVResponse: + raw = "" # Line 58 + + @property + def raw(self) -> str: # Line 139 - redefinition + ... +``` + +**Files**: `caldav/async_davclient.py` + +**Action**: Remove the class-level `raw = ""` line (property is sufficient). + +### 7. Missing Type Annotations (ANN003) +**Count**: 1 issue + +Function signature missing type annotation for `**kwargs`: +```python +def aio_client(..., **kwargs,) -> AsyncDAVClient: +``` + +**Files**: `caldav/async_davclient.py` + +**Action**: Add type annotation like `**kwargs: Any` or be more specific. + +## Commands to Fix + +### Auto-fix safe issues +```bash +ruff check --fix . +``` + +### Auto-fix with unsafe fixes (type replacements, formatting) +```bash +ruff check --fix --unsafe-fixes . +``` + +### Format code +```bash +ruff format . +``` + +## Recommendation + +1. **Now**: Commit the Ruff config and auto-fixes already applied +2. **Next**: Fix remaining issues gradually, or all at once with: + ```bash + ruff check --fix --unsafe-fixes . + ``` +3. **Review**: Manually review exception handling (E722, B904) changes + +## Notes + +- These issues only apply to files added after v2.2.2 +- Old/existing code is excluded from Ruff checks +- Can expand `include` list in `pyproject.toml` as more files are refactored diff --git a/docs/design/SANS_IO_DESIGN.md b/docs/design/SANS_IO_DESIGN.md new file mode 100644 index 00000000..f117cd05 --- /dev/null +++ b/docs/design/SANS_IO_DESIGN.md @@ -0,0 +1,195 @@ +# Sans-I/O Design for CalDAV Library + +**Last Updated:** January 2026 +**Status:** Implemented (Protocol Layer), Refactoring In Progress + +## What is Sans-I/O? + +Sans-I/O separates **protocol logic** from **I/O operations**. The core idea is that +protocol handling (XML building, parsing, state management) should be pure functions +that don't do any I/O themselves. + +## Current Implementation + +The caldav library uses a **partial Sans-I/O** approach: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Application Code │ +│ (Calendar, Principal, Event, Todo, etc.) │ +└─────────────────────────┬───────────────────────────────────┘ + │ +┌─────────────────────────▼───────────────────────────────────┐ +│ DAVClient / AsyncDAVClient │ +│ - HTTP requests via niquests (sync or async) │ +│ - Auth negotiation │ +│ - Uses protocol layer for XML │ +└─────────────────────────┬───────────────────────────────────┘ + │ +┌─────────────────────────▼───────────────────────────────────┐ +│ Protocol Layer (caldav/protocol/) │ +│ - xml_builders.py: Build XML request bodies (NO I/O) │ +│ - xml_parsers.py: Parse XML responses (NO I/O) │ +│ - types.py: DAVRequest, DAVResponse, result dataclasses │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Protocol Layer (`caldav/protocol/`) + +The protocol layer is **pure Python with no I/O**. It provides: + +#### Types (`types.py`) + +```python +@dataclass(frozen=True) +class DAVRequest: + """Immutable request descriptor - no I/O.""" + method: DAVMethod + url: str + headers: dict[str, str] + body: bytes | None = None + +@dataclass +class PropfindResult: + """Parsed PROPFIND response item.""" + href: str + properties: dict[str, Any] + status: int + +@dataclass +class CalendarQueryResult: + """Parsed calendar-query response item.""" + href: str + etag: str | None + calendar_data: str | None +``` + +#### XML Builders (`xml_builders.py`) + +Pure functions that return XML bytes: + +```python +def build_propfind_body(props: list[str] | None = None) -> bytes: + """Build PROPFIND request XML body.""" + +def build_calendar_query_body( + start: datetime | None = None, + end: datetime | None = None, + event: bool = False, + todo: bool = False, +) -> tuple[bytes, str]: + """Build calendar-query REPORT body. Returns (xml_body, component_type).""" + +def build_mkcalendar_body( + displayname: str | None = None, + description: str | None = None, +) -> bytes: + """Build MKCALENDAR request body.""" +``` + +#### XML Parsers (`xml_parsers.py`) + +Pure functions that parse XML bytes into typed results: + +```python +def parse_propfind_response( + xml_body: bytes, + status_code: int, +) -> list[PropfindResult]: + """Parse PROPFIND multistatus response.""" + +def parse_calendar_query_response( + xml_body: bytes, + status_code: int, +) -> list[CalendarQueryResult]: + """Parse calendar-query REPORT response.""" + +def parse_sync_collection_response( + xml_body: bytes, + status_code: int, +) -> SyncCollectionResult: + """Parse sync-collection REPORT response.""" +``` + +## Why Not Full Sans-I/O? + +The original plan proposed a separate "I/O Shell" abstraction layer. This was +**abandoned** for practical reasons: + +1. **niquests handles sync/async natively** - No need for a custom I/O abstraction +2. **Added complexity** - Extra layer without clear benefit +3. **Auth negotiation is I/O-dependent** - Hard to abstract cleanly + +The current approach achieves the main Sans-I/O benefits: +- Protocol logic (XML) is testable without mocking HTTP +- Same XML builders/parsers work for sync and async +- Clear separation of concerns + +## Remaining Work + +### The Duplication Problem + +`DAVClient` and `AsyncDAVClient` share ~65% identical code: + +| Component | Duplication | +|-----------|-------------| +| `extract_auth_types()` | 100% identical | +| HTTP method wrappers | ~95% | +| `build_auth_object()` | ~70% | +| Response init logic | ~80% | + +### Planned Refactoring + +See [SANS_IO_IMPLEMENTATION_PLAN.md](SANS_IO_IMPLEMENTATION_PLAN.md) for details. + +**Phase 2 (Current):** Extract shared utilities +- `caldav/lib/auth.py` - Auth helper functions +- `caldav/lib/constants.py` - Shared constants (CONNKEYS) + +**Phase 3:** Consolidate response handling +- Move common logic to `BaseDAVResponse` + +## Already Pure (No Changes Needed) + +These modules are already Sans-I/O compliant: + +| Module | Purpose | +|--------|---------| +| `caldav/elements/*.py` | XML element builders | +| `caldav/lib/url.py` | URL manipulation | +| `caldav/lib/namespace.py` | XML namespaces | +| `caldav/lib/vcal.py` | iCalendar handling | +| `caldav/lib/error.py` | Error classes | +| `caldav/protocol/*` | Protocol layer | + +## Testing Benefits + +The Sans-I/O protocol layer enables pure unit tests: + +```python +def test_build_propfind_body(): + """Test XML building without HTTP mocking.""" + body = build_propfind_body(["displayname", "resourcetype"]) + xml = body.decode("utf-8").lower() + assert "propfind" in xml + assert "displayname" in xml + +def test_parse_propfind_response(): + """Test XML parsing without HTTP mocking.""" + xml = b''' + + + /calendars/ + + My Cal + HTTP/1.1 200 OK + + + ''' + + results = parse_propfind_response(xml, status_code=207) + assert len(results) == 1 + assert results[0].properties["{DAV:}displayname"] == "My Cal" +``` + +These tests run fast, don't require network access, and don't need HTTP mocking. diff --git a/docs/design/SANS_IO_IMPLEMENTATION_PLAN.md b/docs/design/SANS_IO_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..e0d9c90e --- /dev/null +++ b/docs/design/SANS_IO_IMPLEMENTATION_PLAN.md @@ -0,0 +1,147 @@ +# Sans-I/O Implementation Plan + +**Last Updated:** January 2026 +**Status:** Phase 1-3 Complete + +## Current Architecture + +The Sans-I/O refactoring has been significantly completed. Here's the current state: + +``` +┌─────────────────────────────────────────────────────┐ +│ High-Level Objects (Calendar, Principal, etc.) │ +│ → Use DAVResponse.results (parsed protocol types) │ +├─────────────────────────────────────────────────────┤ +│ DAVClient (sync) / AsyncDAVClient (async) │ +│ → Handle HTTP via niquests │ +│ → Use protocol layer for XML building/parsing │ +│ → ~65% code duplication (problem!) │ +├─────────────────────────────────────────────────────┤ +│ Protocol Layer (caldav/protocol/) │ +│ → xml_builders.py: Pure functions for XML bodies │ +│ → xml_parsers.py: Pure functions for parsing │ +│ → types.py: DAVRequest, DAVResponse, result types │ +│ → NO I/O - just data transformations │ +└─────────────────────────────────────────────────────┘ +``` + +### What's Working + +1. **Protocol Layer** (`caldav/protocol/`): + - `xml_builders.py` - All XML request body building + - `xml_parsers.py` - All response parsing + - `types.py` - DAVRequest, DAVResponse, PropfindResult, etc. + - Used by both sync and async clients + +2. **Response Parsing**: + - `DAVResponse.results` provides parsed protocol types + - `find_objects_and_props()` deprecated but still works + +3. **Both Clients Work**: + - `DAVClient` - Full sync API with backward compatibility + - `AsyncDAVClient` - Async API (not yet released) + +### Remaining Duplication + +After Phase 2-3 refactoring, duplication has been significantly reduced: + +| Component | Status | +|-----------|--------| +| `extract_auth_types()` | ✅ Extracted to `caldav/lib/auth.py` | +| `select_auth_type()` | ✅ Extracted to `caldav/lib/auth.py` | +| `CONNKEYS` | ✅ Single source in `caldav/config.py` | +| Response initialization | ✅ Consolidated in `BaseDAVResponse._init_from_response()` | +| HTTP method wrappers | ~95% similar (acceptable - sync/async signatures differ) | +| Constructor logic | ~85% similar (acceptable - client setup differs) | + +## Refactoring Plan + +### Approach: Extract Shared Code (Not Abstract I/O) + +The original plan proposed an `io/` layer abstraction. This was **abandoned** because: +- Added complexity without clear benefit +- Both clients use niquests which handles sync/async natively +- The protocol layer already provides the "Sans-I/O" separation + +**New approach:** Extract identical/similar code to shared modules. + +### Phase 1: Protocol Layer ✅ COMPLETE + +The protocol layer is working: +- `caldav/protocol/xml_builders.py` - XML request body construction +- `caldav/protocol/xml_parsers.py` - Response parsing +- `caldav/protocol/types.py` - Type definitions + +### Phase 2: Extract Shared Utilities ✅ COMPLETE + +**Goal:** Reduce duplication without architectural changes. + +**Completed:** + +- `caldav/lib/auth.py` created with: + - `extract_auth_types()` - Parse WWW-Authenticate headers + - `select_auth_type()` - Choose best auth method from options +- `CONNKEYS` uses single source in `caldav/config.py` +- Both clients import and use these shared utilities + +### Phase 3: Consolidate Response Handling ✅ COMPLETE + +**Goal:** Move common response logic to `BaseDAVResponse`. + +**Completed:** + +- `BaseDAVResponse._init_from_response()` now contains all shared initialization: + - Headers and status extraction + - XML parsing with etree + - Content-type validation + - CRLF normalization + - Error handling +- `BaseDAVResponse.raw` property moved from subclasses +- `DAVResponse.__init__` reduced to single delegation call +- `AsyncDAVResponse.__init__` reduced to single delegation call +- Eliminated ~150 lines of duplicated code + +### Phase 4: Consider Base Client Class (Future) + +**Status:** Deferred - evaluate after Phase 2-3. + +A `BaseDAVClient` could reduce duplication further, but: +- Sync/async method signatures differ fundamentally +- May not be worth the complexity +- Evaluate after simpler refactoring is done + +## Files Modified + +| File | Changes | +|------|---------| +| `caldav/lib/auth.py` | ✅ NEW: Shared auth utilities | +| `caldav/config.py` | ✅ CONNKEYS single source | +| `caldav/davclient.py` | ✅ Uses shared utilities, simplified DAVResponse | +| `caldav/async_davclient.py` | ✅ Uses shared utilities, simplified AsyncDAVResponse | +| `caldav/response.py` | ✅ BaseDAVResponse with _init_from_response() and raw property | + +## Files Removed (Cleanup Done) + +These were from the abandoned io/ layer approach: + +| File | Reason Removed | +|------|----------------| +| `caldav/io/` | Never integrated, io/ abstraction abandoned | +| `caldav/protocol_client.py` | Redundant with protocol layer | +| `caldav/protocol/operations.py` | CalDAVProtocol class never used | + +## Success Criteria + +1. ✅ Protocol layer is single source of truth for XML +2. ✅ No duplicate utility functions between clients (auth.py) +3. ✅ Shared constants accessible to both clients (config.py) +4. ✅ Common response logic in BaseDAVResponse +5. ✅ All existing tests pass +6. ✅ Backward compatibility maintained for sync API + +## Timeline + +- **Phase 1:** ✅ Complete +- **Phase 2:** ✅ Complete +- **Phase 3:** ✅ Complete +- **Phase 4:** Evaluate if further refactoring is needed diff --git a/docs/design/SANS_IO_IMPLEMENTATION_PLAN2.md b/docs/design/SANS_IO_IMPLEMENTATION_PLAN2.md new file mode 100644 index 00000000..e9a59614 --- /dev/null +++ b/docs/design/SANS_IO_IMPLEMENTATION_PLAN2.md @@ -0,0 +1,485 @@ +# Sans-I/O Refactoring Plan: Eliminating Sync/Async Duplication + +## Status: 📋 PLANNING + +## Problem Statement + +Currently, the codebase has significant duplication between sync and async implementations: + +| File Pair | Lines (Sync) | Lines (Async) | Estimated Duplication | +|-----------|--------------|---------------|----------------------| +| `davobject.py` / `async_davobject.py` | 405 | 945 | ~300 lines | +| `collection.py` / `async_collection.py` | 1,473 | 1,128 | ~700 lines | +| `calendarobjectresource.py` / (in async_davobject) | 1,633 | ~500 | ~400 lines | +| **Total** | ~3,500 | ~2,500 | **~1,400 lines (40%)** | + +The previous refactoring made the protocol layer Sans-I/O for XML building/parsing, but the **business logic** in Calendar, Principal, DAVObject, and CalendarObjectResource is still duplicated. + +## Goal + +Extend the Sans-I/O pattern to high-level classes, resulting in: +1. **Single implementation** of all business logic +2. **Thin sync/async wrappers** for I/O only +3. **~40% code reduction** (~1,400 lines) +4. **Improved testability** - business logic tested without mocking HTTP + +## User Requirements + +1. **Sync API**: Full backward compatibility required +2. **Async API**: Can be changed freely (not yet released) +3. **No duplicated business logic**: Single source of truth +4. **Clean separation**: I/O vs business logic clearly separated + +## Target Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Public API │ +│ Sync: client.principal().get_calendars()[0].get_events() │ +│ Async: await client.get_principal() → get_calendars → ... │ +├─────────────────────────────────────────────────────────────┤ +│ Domain Objects (data containers) │ +│ Calendar, Principal, Event, Todo, Journal │ +│ - Hold data (url, name, properties, ical_data) │ +│ - Sync: convenience methods delegate to client │ +│ - Async: no methods, use client directly │ +│ - SAME classes for both sync and async │ +├─────────────────────────────────────────────────────────────┤ +│ DAVClient (sync) │ AsyncDAVClient (async) │ +│ ┌─────────────────────────┐│┌─────────────────────────────┐│ +│ │ get_events(calendar) │││ await get_events(calendar) ││ +│ │ 1. ops.build_query() │││ 1. ops.build_query() ││ +│ │ 2. self.report(...) │││ 2. await self.report(...) ││ +│ │ 3. ops.process_result() │││ 3. ops.process_result() ││ +│ │ 4. return [Event(...)] │││ 4. return [Event(...)] ││ +│ └─────────────────────────┘│└─────────────────────────────┘│ +│ ↓ SAME ops ↓ │ ↓ SAME ops ↓ │ +├─────────────────────────────────────────────────────────────┤ +│ Operations Layer (NEW - Sans-I/O) │ +│ caldav/operations/ │ +│ - Pure functions, NO I/O │ +│ - build_*_query() → QuerySpec (what to request) │ +│ - process_*_response() → List[DataClass] (parsed results) │ +│ - Server compatibility workarounds │ +│ - Used by BOTH sync and async clients │ +├─────────────────────────────────────────────────────────────┤ +│ Protocol Layer (existing) │ +│ caldav/protocol/ │ +│ - xml_builders.py: Build XML request bodies │ +│ - xml_parsers.py: Parse XML responses │ +│ - types.py: Result dataclasses │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key insight:** No async/sync bridging needed! Both clients: +1. Call the same operations layer (pure functions) +2. Do their own I/O (sync HTTP or async HTTP) +3. Return the same domain objects + +## Design Principles + +### 1. Operations as Pure Functions (Sans-I/O) + +```python +# caldav/operations/calendar_ops.py + +@dataclass(frozen=True) +class CalendarsQuery: + """Query specification for listing calendars.""" + url: str + props: List[str] + depth: int = 1 + +@dataclass +class CalendarData: + """Calendar metadata extracted from server response.""" + url: str + name: Optional[str] + color: Optional[str] + supported_components: List[str] + ctag: Optional[str] + +def build_calendars_query(calendar_home_url: str) -> CalendarsQuery: + """Build query params for listing calendars (Sans-I/O, no network).""" + return CalendarsQuery( + url=calendar_home_url, + props=['displayname', 'resourcetype', 'supported-calendar-component-set'], + depth=1, + ) + +def process_calendars_response( + results: List[PropfindResult], + base_url: str, +) -> List[CalendarData]: + """Process PROPFIND results into calendar list (Sans-I/O, no network).""" + calendars = [] + for result in results: + if _is_calendar_resource(result): + calendars.append(CalendarData( + url=result.href, + name=result.properties.get('{DAV:}displayname'), + # ... extract other properties + )) + return calendars +``` + +### 2. Both Clients Use Same Operations (No Bridging!) + +```python +# caldav/davclient.py - SYNC client + +class DAVClient: + def get_calendars(self, calendar_home_url: str) -> List[Calendar]: + """List calendars - sync implementation.""" + from caldav.operations import calendar_ops as ops + + # 1. Build query (Sans-I/O - same as async) + query = ops.build_calendars_query(calendar_home_url) + + # 2. Execute I/O (SYNC) + response = self.propfind(query.url, props=query.props, depth=query.depth) + + # 3. Process response (Sans-I/O - same as async) + calendar_data = ops.process_calendars_response(response.results, str(self.url)) + + # 4. Return domain objects + return [Calendar(client=self, **cd.__dict__) for cd in calendar_data] +``` + +```python +# caldav/async_davclient.py - ASYNC client + +class AsyncDAVClient: + async def get_calendars(self, calendar_home_url: str) -> List[Calendar]: + """List calendars - async implementation.""" + from caldav.operations import calendar_ops as ops + + # 1. Build query (Sans-I/O - SAME as sync) + query = ops.build_calendars_query(calendar_home_url) + + # 2. Execute I/O (ASYNC) + response = await self.propfind(query.url, props=query.props, depth=query.depth) + + # 3. Process response (Sans-I/O - SAME as sync) + calendar_data = ops.process_calendars_response(response.results, str(self.url)) + + # 4. Return domain objects (SAME Calendar class!) + return [Calendar(client=self, **cd.__dict__) for cd in calendar_data] +``` + +**Note:** Steps 1 and 3 are identical between sync and async - that's the Sans-I/O pattern! +Only step 2 (the actual I/O) differs. + +### 3. Domain Objects for Backward Compat (Sync Only) + +```python +# caldav/collection.py + +class Calendar: + """Calendar - data container with sync convenience methods.""" + + def __init__(self, client, url, name=None, **kwargs): + self.client = client + self.url = url + self.name = name + # ... store other data + + def events(self) -> List[Event]: + """Sync convenience method - delegates to client.""" + return self.client.get_events(self) + + def search(self, **kwargs) -> List[CalendarObjectResource]: + """Sync convenience method - delegates to client.""" + return self.client.search_calendar(self, **kwargs) +``` + +### 4. Async API is Client-Centric (Cleaner) + +```python +# Async users call client methods directly - no Calendar.get_events() + +async with AsyncDAVClient(url=...) as client: + principal = await client.get_principal() + calendars = await client.get_calendars(principal.calendar_home_set) + events = await client.get_events(calendars[0]) + + # Or with search: + events = await client.search_calendar(calendars[0], start=..., end=...) +``` + +## Implementation Phases + +### Phase 1: Create Operations Layer Foundation +**New files:** `caldav/operations/__init__.py`, `caldav/operations/base.py` + +1. Create `caldav/operations/` package +2. Define base patterns: + - Request dataclasses (frozen, immutable) + - Result dataclasses (for processed data) + - Pure function signatures +3. Add utility functions for common patterns + +**Estimated: 100-150 lines** + +### Phase 2: Extract DAVObject Operations +**New file:** `caldav/operations/davobject_ops.py` +**Modify:** `caldav/async_davobject.py`, `caldav/davobject.py` + +Extract from DAVObject: +- `get_properties()` → `build_propfind_request()` + `process_propfind_response()` +- `set_properties()` → `build_proppatch_request()` + `process_proppatch_response()` +- `children()` → `build_children_request()` + `process_children_response()` +- `delete()` → `build_delete_request()` + `validate_delete_response()` + +**Delete:** Most of `async_davobject.py` AsyncDAVObject (merge into operations) +**Result:** Single `DAVObject` class using operations, ~200 lines saved + +### Phase 3: Extract CalendarObjectResource Operations +**New file:** `caldav/operations/calendarobject_ops.py` +**Modify:** `caldav/calendarobjectresource.py`, `caldav/async_davobject.py` + +Extract pure logic (currently ~50% of CalendarObjectResource): +- `get_duration()`, `set_duration()` - duration calculations +- `add_attendee()`, `change_attendee_status()` - attendee management +- `expand_rrule()` - recurrence expansion +- `_find_id_path()`, `_generate_url()` - UID/URL handling +- `copy()` - object cloning +- Todo-specific: `complete()`, `is_pending()`, `_next()`, `_complete_recurring_*()` + +Keep in CalendarObjectResource: +- `load()`, `save()`, `_put()` - I/O methods (delegate to operations for logic) + +**Delete:** `AsyncCalendarObjectResource`, `AsyncEvent`, `AsyncTodo`, `AsyncJournal` from async_davobject.py +**Result:** Single implementation, ~400 lines saved + +### Phase 4: Extract Principal Operations +**New file:** `caldav/operations/principal_ops.py` +**Modify:** `caldav/collection.py`, `caldav/async_collection.py` + +Extract: +- `_discover_principal_url()` - current-user-principal discovery +- `_get_calendar_home_set()` - calendar-home-set resolution +- `calendar_user_address_set()` - address set extraction +- `get_vcal_address()` - vCalAddress creation + +**Delete:** `AsyncPrincipal` from async_collection.py +**Result:** Single `Principal` class, ~80 lines saved + +### Phase 5: Extract CalendarSet Operations +**New file:** `caldav/operations/calendarset_ops.py` +**Modify:** `caldav/collection.py`, `caldav/async_collection.py` + +Extract: +- `calendars()` - list calendars logic +- `calendar()` - find calendar by name/id +- `make_calendar()` - calendar creation logic + +**Delete:** `AsyncCalendarSet` from async_collection.py +**Result:** Single `CalendarSet` class, ~60 lines saved + +### Phase 6: Extract Calendar Operations +**New file:** `caldav/operations/calendar_ops.py` +**Modify:** `caldav/collection.py`, `caldav/async_collection.py` + +Extract: +- `_create()` - MKCALENDAR logic +- `get_supported_components()` - component type extraction +- `_calendar_comp_class_by_data()` - component class detection +- `_request_report_build_resultlist()` - report result processing +- `search()` integration - connect to CalDAVSearcher +- `multiget()`, `freebusy_request()` - specialized queries +- `objects_by_sync_token()` - sync logic + +**Delete:** `AsyncCalendar` from async_collection.py +**Result:** Single `Calendar` class, ~400 lines saved + +### Phase 7: Refactor CalDAVSearcher +**Modify:** `caldav/search.py` + +Current state: `search()` and `async_search()` are ~320 lines each (duplicated) + +Refactor: +1. Keep `build_search_xml_query()` (already Sans-I/O) +2. Extract `process_search_response()` as Sans-I/O function +3. Merge `search()` and `async_search()` into single implementation +4. Calendar.search() delegates to operations + +**Result:** ~300 lines saved + +### Phase 8: Add High-Level Methods to Both Clients +**Modify:** `caldav/davclient.py`, `caldav/async_davclient.py` + +Add high-level methods that use operations layer: + +**AsyncDAVClient:** +- `get_principal()` - returns Principal +- `get_calendars(principal)` - returns List[Calendar] +- `get_events(calendar)` - returns List[Event] +- `search_calendar(calendar, **kwargs)` - returns List[Event/Todo/Journal] + +**DAVClient:** +- Same methods, using same operations layer +- Sync I/O instead of async I/O +- Backward compat: keep existing `principal()` method delegating to `get_principal()` + +Both clients use **identical** operations layer calls - only the I/O differs. + +**Result:** ~200 lines saved (remove duplicate logic from both) + +### Phase 9: Delete Async Collection/Object Files +**Delete files:** +- `caldav/async_collection.py` (1,128 lines) +- `caldav/async_davobject.py` (945 lines - or most of it) + +All functionality now in: +- `caldav/operations/*.py` - business logic +- `caldav/collection.py` - domain objects +- `caldav/calendarobjectresource.py` - calendar objects +- `caldav/davobject.py` - base class + +### Phase 10: Update Public API (caldav/aio.py) +**Modify:** `caldav/aio.py`, `caldav/__init__.py` + +Ensure async users can: +```python +from caldav.aio import AsyncDAVClient + +async with AsyncDAVClient(url=...) as client: + principal = await client.get_principal() + calendars = await principal.get_calendars() # Works with same Calendar class + events = await calendars[0].get_events() # Async iteration +``` + +Domain objects (Calendar, Event, etc.) work with both sync and async clients. + +## Files Summary + +### New Files +| File | Purpose | Est. Lines | +|------|---------|------------| +| `caldav/operations/__init__.py` | Package exports | 20 | +| `caldav/operations/base.py` | Common utilities | 50 | +| `caldav/operations/davobject_ops.py` | DAVObject logic | 150 | +| `caldav/operations/calendarobject_ops.py` | CalendarObjectResource logic | 300 | +| `caldav/operations/principal_ops.py` | Principal logic | 100 | +| `caldav/operations/calendarset_ops.py` | CalendarSet logic | 80 | +| `caldav/operations/calendar_ops.py` | Calendar logic | 250 | +| **Total new** | | **~950** | + +### Files to Delete +| File | Lines Removed | +|------|---------------| +| `caldav/async_collection.py` | 1,128 | +| `caldav/async_davobject.py` | 945 | +| **Total deleted** | **~2,073** | + +### Files to Simplify +| File | Current | After | Savings | +|------|---------|-------|---------| +| `caldav/search.py` | ~1,100 | ~500 | ~600 | +| `caldav/collection.py` | 1,473 | ~800 | ~673 | +| `caldav/davobject.py` | 405 | ~200 | ~205 | +| `caldav/calendarobjectresource.py` | 1,633 | ~800 | ~833 | +| **Total** | | | **~2,311** | + +### Net Result +- **Lines added:** ~950 (operations layer) +- **Lines removed:** ~2,073 (async files) + ~2,311 (simplification) = ~4,384 +- **Net reduction:** ~3,434 lines (~45% of current ~7,600 lines) + +## Testing Strategy + +1. **Unit tests for operations layer** (new) + - Test each operation function in isolation + - No HTTP mocking needed - pure functions + - High coverage, fast execution + +2. **Integration tests** (existing) + - Run against Radicale, Xandikos, Docker servers + - Verify backward compatibility + - Test both sync and async APIs + +3. **Backward compatibility tests** (existing) + - All existing sync API tests must pass + - `find_objects_and_props()` still works (deprecated) + +## Migration Path for Users + +### Sync API Users (no changes required) +```python +# Before and after - identical +from caldav import DAVClient + +client = DAVClient(url=..., username=..., password=...) +principal = client.principal() +calendars = principal.get_calendars() +events = calendars[0].get_events() + +# All existing code continues to work unchanged +``` + +### Async API Users (cleaner client-centric API) +```python +# Before (old async API - mirrored sync with Async* classes) +from caldav.aio import AsyncDAVClient, AsyncPrincipal +async with AsyncDAVClient(...) as client: + principal = await AsyncPrincipal.create(client) + calendars = await principal.get_calendars() + events = await calendars[0].get_events() + +# After (new async API - client-centric, cleaner) +from caldav.aio import AsyncDAVClient + +async with AsyncDAVClient(...) as client: + principal = await client.get_principal() # Returns Principal (same class) + calendars = await client.get_calendars(principal) # Returns List[Calendar] + events = await client.get_events(calendars[0]) # Returns List[Event] + + # Search example + events = await client.search_calendar( + calendars[0], + start=datetime(2024, 1, 1), + end=datetime(2024, 12, 31), + event=True, + ) +``` + +**Benefits of new async API:** +- No more `AsyncPrincipal`, `AsyncCalendar`, `AsyncEvent` - just one set of classes +- Client methods are explicit about what I/O they do +- Easier to understand data flow +- Same domain objects work with both sync and async + +## Success Criteria + +1. ✅ All existing sync API tests pass (backward compat) +2. ✅ ~40% code reduction achieved +3. ✅ No business logic duplicated between sync/async +4. ✅ Operations layer has >90% test coverage +5. ✅ Async API is cleaner and well-documented +6. ✅ Integration tests pass on all supported servers + +## Risks and Mitigations + +| Risk | Mitigation | +|------|------------| +| Breaking sync API | Extensive backward compat tests, gradual migration | +| Complex merge conflicts | Small, focused commits per phase | +| Missing edge cases in operations | Port all existing test assertions to operations tests | +| Server compatibility workarounds lost | Migrate all workarounds to operations layer with tests | + +## Design Decisions (Resolved) + +1. **Domain object style:** Keep current class style for backward compat. Sync API has convenience methods (`calendar.get_events()`), async API uses client methods directly. + +2. **Sync/async bridging:** Not needed! True Sans-I/O means both clients use the same operations layer independently - no bridging required. + +3. **Operations return type:** Return data classes (e.g., `CalendarData`), clients wrap them into domain objects (e.g., `Calendar`). + +## Verification Plan + +1. **Unit tests:** Test each operation function with synthetic data (no HTTP) +2. **Integration tests:** Run existing test suite against Radicale, Xandikos, Docker servers +3. **Backward compat:** All existing sync API tests must pass unchanged +4. **Async tests:** Write new tests for client-centric async API +5. **Manual testing:** Test examples from `examples/` directory diff --git a/docs/design/TODO.md b/docs/design/TODO.md new file mode 100644 index 00000000..af517a70 --- /dev/null +++ b/docs/design/TODO.md @@ -0,0 +1,115 @@ +# Known Issues and TODO Items + +## Nextcloud UNIQUE Constraint Violations + +**Status**: Known issue, needs upstream investigation +**Priority**: Low (doesn't block caldav work) +**Estimated research time**: 6-12 hours + +### Problem +Nextcloud occasionally gets into an inconsistent internal state where it reports UNIQUE constraint violations when trying to save calendar objects: + +``` +SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: +oc_calendarobjects.calendarid, oc_calendarobjects.calendartype, oc_calendarobjects.uid +``` + +### Observations +- **Server-specific**: Only affects Nextcloud, not Radicale, Baikal, Xandikos, etc. +- **Intermittent**: Happens during `caldav_server_tester.ServerQuirkChecker.check_all()` +- **Workaround**: Taking down and restarting the ephemeral Docker container resolves it +- **Hypothesis**: Internal state corruption in Nextcloud, not a caldav library issue +- **Pre-existing**: Test was already failing before starting to work on the async support + +### Example Failure +``` +tests/test_caldav.py::TestForServerNextcloud::testCheckCompatibility +E caldav.lib.error.PutError: PutError at '500 Internal Server Error +E An exception occurred while executing a query: SQLSTATE[23000]: + Integrity constraint violation: 19 UNIQUE constraint failed: + oc_calendarobjects.calendarid, oc_calendarobjects.calendartype, + oc_calendarobjects.uid +``` + +### Test Results: Hypothesis CONFIRMED ✓ + +**Date**: 2025-12-17 +**Test script**: `/tmp/test_nextcloud_uid_reuse.py` + +**Finding**: Nextcloud does NOT allow reusing a UID after deletion. This is a **Nextcloud bug**. + +**Test steps**: +1. Created event with UID `test-uid-reuse-hypothesis-12345` ✓ +2. Deleted the event ✓ +3. Confirmed deletion with `get_event_by_uid()` (throws NotFoundError) ✓ +4. Attempted to create new event with same UID → **FAILED with UNIQUE constraint** ✗ + +**Error received**: +``` +500 Internal Server Error +SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: +oc_calendarobjects.calendarid, oc_calendarobjects.calendartype, oc_calendarobjects.uid +``` + +**Conclusion**: +- This violates CalDAV RFC expectations - UIDs should be reusable after deletion +- Nextcloud's internal database retains constraint even after CalDAV object is deleted +- This explains why `ServerQuirkChecker.check_all()` fails - it likely deletes and recreates test objects +- Container restart fixes it because it clears the internal state + +### Next Steps (when prioritized) +1. ✓ ~~Test the UID reuse hypothesis~~ - **CONFIRMED** +2. Search Nextcloud issue tracker for similar reports +3. Create minimal bug report with reproduction steps +4. File upstream bug report with Nextcloud +5. Consider adding server quirk detection in caldav_server_tester +6. Document workaround: avoid UID reuse with Nextcloud, or restart container between test runs + +### References +- Test: `tests/test_caldav.py::TestForServerNextcloud::testCheckCompatibility` +- Discussion: Session on 2025-12-17 + +--- + +## Phase 2 Remaining Work + +### Test Suite Status +- **Radicale**: 42 passed, 13 skipped ✓ +- **Baikal**: Some tests passing after path/auth fixes +- **Nextcloud**: testCheckCompatibility failing (see above) +- **Other servers**: Status unknown + +### Known Limitations (to be addressed in Phase 3) +- AsyncPrincipal not implemented → path matching warnings for Principal objects +- Async collection methods (get_event_by_uid, etc.) not implemented → no_create/no_overwrite validation done in sync wrapper +- Recurrence handling done in sync wrapper → will move to async in Phase 3 + +### Known Test Limitations + +#### MockedDAVClient doesn't work with async delegation +**Status**: Known limitation in Phase 2 +**Affected test**: `tests/test_caldav_unit.py::TestCalDAV::testPathWithEscapedCharacters` + +MockedDAVClient overrides `request()` to return mocked responses without network calls. +However, with async delegation, `_run_async()` creates a new async client that makes +real HTTP connections, bypassing the mock. + +**Options to fix**: +1. Make MockedDAVClient override `_get_async_client()` to return a mocked async client +2. Update tests to use `@mock.patch` on async client methods +3. Implement a fallback sync path for mocked clients + +**Current approach**: Raise clear NotImplementedError when mocked client tries to use +async delegation, documenting that mocking needs to be updated for async support. + +### Recently Fixed +- ✓ Infinite redirect loop in multiplexing retry +- ✓ Path matching assertion failures +- ✓ HTTPDigestAuth sync→async conversion +- ✓ UID generation issues +- ✓ Async class type mapping (Event→AsyncEvent, etc.) +- ✓ no_create/no_overwrite validation moved to sync wrapper +- ✓ Recurrence handling moved to sync wrapper +- ✓ Unit tests without client (load with only_if_unloaded) +- ✓ Mocked client detection for unit tests (testAbsoluteURL) +- ✓ Sync fallback in get_properties() for mocked clients diff --git a/docs/design/URL_AND_METHOD_RESEARCH.md b/docs/design/URL_AND_METHOD_RESEARCH.md new file mode 100644 index 00000000..cee762c7 --- /dev/null +++ b/docs/design/URL_AND_METHOD_RESEARCH.md @@ -0,0 +1,386 @@ +# Research: URL Parameters and HTTP Method Wrappers + +## Executive Summary + +After analyzing the codebase, I found that: + +1. **HTTP method wrappers are rarely called directly** - most calls go through `DAVObject._query()` using dynamic method dispatch +2. **URL parameters have different semantics** - `self.url` is a base URL that's inappropriate as default for some operations +3. **The wrappers serve important purposes** beyond convenience - they're used for mocking, dynamic dispatch, and API consistency + +## Detailed Findings + +### 1. HTTP Method Wrapper Usage Analysis + +#### Direct Calls in caldav/ (excluding aio.py): + +| Method | Direct Calls | Locations | +|--------|--------------|-----------| +| `propfind` | 0 | None (all via `_query()`) | +| `proppatch` | 0 | None (all via `_query()`) | +| `report` | 1 | `davclient.py:734` (in `principals()`) | +| `mkcol` | 0 | None (all via `_query()`) | +| `mkcalendar` | 0 | None (all via `_query()`) | +| `put` | 1 | `calendarobjectresource.py:771` (in `_put()`) | +| `post` | 1 | `collection.py:368` (in `get_freebusy()`) | +| `delete` | 1 | `davobject.py:409` (in `delete()`) | +| `options` | 2 | `davclient.py:805,807` (in `check_dav_support()`) | +| **TOTAL** | **6** | | + +#### Key Discovery: Dynamic Method Dispatch + +The most important finding: **`DAVObject._query()` uses `getattr()` for dynamic dispatch**: + +```python +# davobject.py:219 +ret = getattr(self.client, query_method)(url, body, depth) +``` + +This means methods like `propfind`, `proppatch`, `mkcol`, `mkcalendar` are invoked **by name as strings**: + +```python +# Usage examples: +self._query(root, query_method="propfind", ...) # Default +self._query(root, query_method="proppatch", ...) # davobject.py:382 +self._query(root, query_method="mkcol", ...) # collection.py:470 +self._query(root, query_method="mkcalendar", ...) # collection.py:470 +``` + +**Implication:** The method wrappers **cannot be removed** without breaking `_query()`'s dynamic dispatch. + +### 2. URL Parameter Semantics + +#### What is `self.url`? + +`self.url` is the **base CalDAV server URL** or **principal URL**, for example: +- `https://caldav.example.com/` +- `https://caldav.example.com/principals/user/` + +#### URL Usage Patterns by Method: + +**Category A: Methods that operate on `self.url` (base URL)** +- `propfind(url=None)` - Can query self.url for server capabilities ✓ +- `report(url=None)` - Used with self.url in `principals()` ✓ +- `options(url=None)` - Checks capabilities of self.url ✓ + +**Category B: Methods that operate on resource URLs (NOT self.url)** +- `put(url)` - Always targets a specific resource (event, calendar, etc.) +- `delete(url)` - Always deletes a specific resource +- `post(url)` - Always posts to a specific URL (e.g., outbox) +- `proppatch(url)` - Always patches a specific resource +- `mkcol(url)` - Creates a collection at a specific path +- `mkcalendar(url)` - Creates a calendar at a specific path + +#### Evidence from Actual Usage: + +```python +# davobject.py:409 - delete() always passes a specific URL +r = self.client.delete(str(self.url)) # self.url here is the OBJECT url, not base + +# calendarobjectresource.py:771 - put() always passes a specific URL +r = self.client.put(self.url, self.data, ...) # self.url is event URL + +# collection.py:368 - post() always to outbox +response = self.client.post(outbox.url, ...) # specific outbox URL + +# davclient.py:734 - report() with base URL for principal search +response = self.report(self.url, ...) # self.url is client base URL + +# davclient.py:805 - options() with principal URL +response = self.options(self.principal().url) # specific principal URL +``` + +### 3. Current Signature Analysis + +#### Methods with Optional URL (make sense with self.url): + +```python +propfind(url: Optional[str] = None, props: str = "", depth: int = 0) +# Usage: client.propfind() queries client.url - MAKES SENSE ✓ + +report(url: str, query: str = "", depth: int = 0) +# Currently REQUIRED but could be optional +# Usage: client.report(client.url, ...) - could default to self.url ✓ + +options(url: str) +# Currently REQUIRED but could be optional +# Usage: client.options(str(self.url)) - could default to self.url ✓ +``` + +#### Methods with Required URL (shouldn't default to self.url): + +```python +put(url: str, body: str, headers: Mapping[str, str] = None) +# Always targets specific resource - url SHOULD be required ✓ + +delete(url: str) +# Always targets specific resource - url SHOULD be required ✓ +# Deleting the base CalDAV URL would be catastrophic! + +post(url: str, body: str, headers: Mapping[str, str] = None) +# Always targets specific endpoint - url SHOULD be required ✓ + +proppatch(url: str, body: str, dummy: None = None) +# Always targets specific resource - url SHOULD be required ✓ + +mkcol(url: str, body: str, dummy: None = None) +# Creates at specific path - url SHOULD be required ✓ + +mkcalendar(url: str, body: str = "", dummy: None = None) +# Creates at specific path - url SHOULD be required ✓ +``` + +### 4. Why HTTP Method Wrappers Are Necessary + +#### Reason #1: Dynamic Dispatch in `_query()` + +```python +# davobject.py:219 +ret = getattr(self.client, query_method)(url, body, depth) +``` + +The wrappers are looked up **by name at runtime**. Removing them would break this pattern. + +#### Reason #2: Test Mocking + +```python +# tests/test_caldav_unit.py:542 +client.propfind = mock.MagicMock(return_value=mocked_davresponse) +``` + +Tests mock specific HTTP methods. Direct `request()` mocking would be harder to target specific operations. + +#### Reason #3: Consistent Parameter Transformation + +Each wrapper handles method-specific concerns: + +```python +def propfind(self, url=None, props="", depth=0): + return self.request( + url or str(self.url), + "PROPFIND", + props, + {"Depth": str(depth)} # Adds Depth header + ) + +def report(self, url, query="", depth=0): + return self.request( + url, + "REPORT", + query, + { + "Depth": str(depth), + "Content-Type": 'application/xml; charset="utf-8"' # Adds Content-Type + }, + ) +``` + +Without wrappers, callers would need to remember method-specific headers. + +#### Reason #4: Discoverability and Documentation + +```python +client.propfind(...) # Clear what operation is happening +client.mkcalendar(...) # Self-documenting +vs +client.request(..., method="PROPFIND", ...) # Less clear +``` + +### 5. Signature Consistency Issue + +Current signatures are **inconsistent** because they evolved organically: + +```python +# Inconsistent depths: +propfind(url, props, depth) # (depth as parameter) +report(url, query, depth) # (depth as parameter) + +# Inconsistent body names: +propfind(url, props, depth) # "props" +report(url, query, depth) # "query" +proppatch(url, body, dummy) # "body" +put(url, body, headers) # "body" +``` + +But **the depth issue is actually correct** - only PROPFIND and REPORT support the Depth header per RFC4918. + +## Recommendations + +### 1. Keep All HTTP Method Wrappers + +**Verdict:** ✅ **KEEP WRAPPERS** - they serve multiple essential purposes: +- Dynamic dispatch in `_query()` +- Test mocking +- Method-specific header handling +- API discoverability + +### 2. URL Parameter: Context-Specific Defaults + +**Proposal:** Different defaults based on method semantics: + +```python +class AsyncDAVClient: + # Category A: Query methods - self.url is a sensible default + async def propfind( + self, + url: Optional[str] = None, # Defaults to self.url ✓ + body: str = "", + depth: int = 0, + ) -> DAVResponse: + """PROPFIND request. Defaults to querying the base CalDAV URL.""" + ... + + async def report( + self, + url: Optional[str] = None, # Defaults to self.url ✓ + body: str = "", + depth: int = 0, + ) -> DAVResponse: + """REPORT request. Defaults to querying the base CalDAV URL.""" + ... + + async def options( + self, + url: Optional[str] = None, # Defaults to self.url ✓ + ) -> DAVResponse: + """OPTIONS request. Defaults to querying the base CalDAV URL.""" + ... + + # Category B: Resource methods - URL is REQUIRED + async def put( + self, + url: str, # REQUIRED - no sensible default ✓ + body: str = "", + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """PUT request to create/update a resource.""" + ... + + async def delete( + self, + url: str, # REQUIRED - no sensible default, dangerous if wrong! ✓ + ) -> DAVResponse: + """DELETE request to remove a resource.""" + ... + + async def post( + self, + url: str, # REQUIRED - always to specific endpoint ✓ + body: str = "", + headers: Optional[Dict[str, str]] = None, + ) -> DAVResponse: + """POST request.""" + ... + + async def proppatch( + self, + url: str, # REQUIRED - always patches specific resource ✓ + body: str = "", + ) -> DAVResponse: + """PROPPATCH request.""" + ... + + async def mkcol( + self, + url: str, # REQUIRED - always creates at specific path ✓ + body: str = "", + ) -> DAVResponse: + """MKCOL request.""" + ... + + async def mkcalendar( + self, + url: str, # REQUIRED - always creates at specific path ✓ + body: str = "", + ) -> DAVResponse: + """MKCALENDAR request.""" + ... +``` + +### 3. Standardize Parameter Names + +**Proposal:** Use `body` consistently, but keep depth only where it makes sense: + +```python +# Before (inconsistent): +propfind(url, props, depth) # "props" +report(url, query, depth) # "query" +proppatch(url, body, dummy) # "body" + dummy + +# After (consistent): +propfind(url, body, depth) # "body" +report(url, body, depth) # "body" +proppatch(url, body) # "body", no dummy +``` + +### 4. Add Headers Parameter to All + +**Proposal:** Allow custom headers on all methods: + +```python +async def propfind( + url: Optional[str] = None, + body: str = "", + depth: int = 0, + headers: Optional[Dict[str, str]] = None, # NEW +) -> DAVResponse: + ... +``` + +### 5. Alternative: Keep Low-Level, Add High-Level + +Instead of removing wrappers, we could **add high-level methods** while keeping low-level ones: + +```python +class AsyncDAVClient: + # Low-level HTTP wrappers (keep for backward compat & _query()) + async def propfind(url, body, depth) -> DAVResponse: ... + async def report(url, body, depth) -> DAVResponse: ... + + # High-level convenience methods + async def query_properties( + self, + url: Optional[str] = None, + properties: Optional[List[BaseElement]] = None, + depth: int = 0, + ) -> Dict: + """ + High-level property query that returns parsed properties. + Wraps propfind() with XML parsing. + """ + ... +``` + +**Verdict:** This adds complexity without much benefit. Skip for now. + +## Final Recommendations Summary + +1. ✅ **Keep all HTTP method wrappers** - essential for dynamic dispatch and testing +2. ✅ **Split URL requirements**: + - Optional (defaults to `self.url`): `propfind`, `report`, `options` + - Required: `put`, `delete`, `post`, `proppatch`, `mkcol`, `mkcalendar` +3. ✅ **Standardize parameter name to `body`** (not `props` or `query`) +4. ✅ **Remove `dummy` parameters** in async API +5. ✅ **Add `headers` parameter to all methods** +6. ✅ **Keep `depth` only on methods that support it** (propfind, report) + +## Impact on Backward Compatibility + +The sync wrapper can maintain old signatures: + +```python +class DAVClient: + def propfind(self, url=None, props="", depth=0): + """Sync wrapper - keeps 'props' parameter name""" + return asyncio.run(self._async_client.propfind(url, props, depth)) + + def proppatch(self, url, body, dummy=None): + """Sync wrapper - keeps dummy parameter""" + return asyncio.run(self._async_client.proppatch(url, body)) + + def delete(self, url): + """Sync wrapper - url required""" + return asyncio.run(self._async_client.delete(url)) +``` + +All existing code continues to work unchanged! diff --git a/docs/design/V3_CODE_REVIEW.md b/docs/design/V3_CODE_REVIEW.md new file mode 100644 index 00000000..6f03ac87 --- /dev/null +++ b/docs/design/V3_CODE_REVIEW.md @@ -0,0 +1,271 @@ +# v3.0 Code Review Findings + +**Date:** January 2026 +**Reviewer:** Claude Opus 4.5 (AI-assisted review) +**Branch:** v3.0-dev + +This document summarizes the code review findings for the v3.0.0 release candidate. + +## Executive Summary + +The codebase is in good shape for a v3.0 release. The Sans-I/O architecture is well-implemented with clear separation of concerns. There are some areas of technical debt (duplicated code, test coverage gaps) that are noted for future work but are not release blockers. + +--- + +## Duplicated Code + +### Addressed Duplications (January 2026) + +The following duplications have been consolidated: + +| Code Section | Status | Solution | +|--------------|--------|----------| +| `_get_calendar_home_set()` | ✅ Fixed | Extracted to `_extract_calendar_home_set_from_results()` in principal_ops.py | +| `get_calendars()` result processing | ✅ Fixed | Extracted to `_extract_calendars_from_propfind_results()` in calendarset_ops.py | +| Property lists for PROPFIND | ✅ Fixed | Moved to `BaseDAVClient.CALENDAR_LIST_PROPS` and `CALENDAR_HOME_SET_PROPS` | + +### Remaining Duplications + +| Code Section | Location (Sync) | Location (Async) | Duplication % | Lines | +|--------------|-----------------|------------------|---------------|-------| +| `propfind()` response parsing | davclient.py:280-320 | async_davclient.py:750-790 | 90% | ~40 | +| Auth type extraction | davclient.py:180-210 | async_davclient.py:420-450 | 100% | ~30 | + +**Remaining estimated duplicated lines:** ~70 lines (down from ~240) + +### Future Refactoring Opportunities + +The remaining duplication is in areas that are harder to consolidate due to sync/async differences: +1. HTTP response handling (different response object types) +2. Auth negotiation (requires I/O) + +These could potentially be addressed with a more sophisticated abstraction, but the current level is acceptable. + +--- + +## Dead Code + +### Functions That Should Be Removed + +| Function | Location | Reason | +|----------|----------|--------| +| `auto_calendars()` | davclient.py:1037-1048 | Raises `NotImplementedError` | +| `auto_calendar()` | davclient.py:1051-1055 | Raises `NotImplementedError` | + +### Unused Imports + +| Import | Location | Status | +|--------|----------|--------| +| `CONNKEYS` | davclient.py:87 | Imported but never used | + +### Recommendation + +Remove these in a cleanup commit before or after the v3.0 release. Low priority as they don't affect functionality. + +--- + +## Test Coverage Assessment + +### Coverage by Module + +| Module | Coverage | Rating | Notes | +|--------|----------|--------|-------| +| `caldav/protocol/` | Excellent | 9/10 | Pure unit tests, no mocking needed | +| `caldav/operations/` | Excellent | 9/10 | Well-tested request building | +| `caldav/async_davclient.py` | Good | 8/10 | Dedicated unit tests exist | +| `caldav/davclient.py` | Poor | 4/10 | Only integration tests | +| `caldav/collection.py` | Moderate | 6/10 | Integration tests cover most paths | +| `caldav/search.py` | Good | 7/10 | Complex search logic tested | +| `caldav/discovery.py` | None | 0/10 | No dedicated tests | + +### Coverage Gaps + +#### 1. Error Handling (Rating: 2/10) + +Missing tests for: +- Network timeout scenarios +- Malformed XML responses +- Authentication failures mid-session +- Server returning unexpected status codes +- Partial/truncated responses + +**Example missing test:** +```python +def test_propfind_malformed_xml(): + """Should handle malformed XML gracefully.""" + client = DAVClient(...) + # Mock response with invalid XML + with pytest.raises(DAVError): + client.propfind(url, body) +``` + +#### 2. Edge Cases (Rating: 3/10) + +Missing tests for: +- Empty calendar responses +- Calendars with thousands of events +- Unicode in calendar names/descriptions +- Very long URLs +- Concurrent modifications + +#### 3. Sync DAVClient Unit Tests + +The sync `DAVClient` lacks dedicated unit tests. All testing happens through integration tests in `tests/test_caldav.py`. This makes it harder to: +- Test error conditions +- Verify specific code paths +- Run tests without a server + +**Recommendation:** Add `tests/test_davclient.py` mirroring `tests/test_async_davclient.py` + +#### 4. Discovery Module + +`caldav/discovery.py` has zero test coverage. This module handles: +- RFC 6764 DNS-based service discovery +- Well-known URI probing +- Domain validation + +**Risk:** DNS discovery bugs could cause security issues or connection failures. + +--- + +## Architecture Assessment + +### Strengths + +1. **Clean Sans-I/O Protocol Layer** + - XML building/parsing is pure and testable + - Same code serves sync and async + - Well-documented with type hints + +2. **Dual-Mode Domain Objects** + - `Calendar`, `Principal`, `Event` work with both client types + - Automatic detection of sync vs async context + +3. **Good Separation of Concerns** + - Protocol layer: XML handling + - Operations layer: Request building + - Client layer: HTTP execution + - Domain layer: User-facing API + +### Weaknesses + +1. **Client Code Duplication** + - Significant overlap between sync and async clients + - Changes must be made in two places + +2. **Mixed Responsibilities in collection.py** + - 2000+ lines mixing domain logic with HTTP calls + - Could benefit from further extraction to operations layer + +3. **Inconsistent Error Handling** + - Some methods return `None` on error + - Others raise exceptions + - Logging levels inconsistent + +--- + +## API Consistency + +### Legacy vs Recommended Methods + +See [API_NAMING_CONVENTIONS.md](API_NAMING_CONVENTIONS.md) for the full naming convention guide. + +| Legacy Method | Recommended Method | Notes | +|---------------|-------------------|-------| +| `date_search()` | `search()` | Deprecated with warning | +| `event.instance` | `event.icalendar_component` | Deprecated in v2.0 | +| `client.auto_conn()` | `get_davclient()` | Renamed | + +### Capability Check Aliases + +Added for API consistency (v3.0): +- `client.supports_dav()` → alias for `client.check_dav_support()` +- `client.supports_caldav()` → alias for `client.check_caldav_support()` +- `client.supports_scheduling()` → alias for `client.check_scheduling_support()` + +--- + +## GitHub Issues Review + +### Issue #71: calendar.add_event can update as well + +**Status:** Open (since v0.7 milestone) +**Summary:** Suggests renaming `add_` to `save_` + +**Analysis:** +- Current API has both `add_event()` and `save_event()` +- `add_event()` is a convenience wrapper that creates and saves +- `save_event()` saves an existing or new event +- The naming reflects intent: "add" = create new, "save" = persist changes + +**Recommendation:** Document the distinction clearly. Not a v3.0 blocker. + +### Issue #613: Implicit data conversions + +**Status:** Open +**Summary:** Accessing `.data`, `.icalendar_instance`, `.vobject_instance` can cause implicit conversions with side effects + +**Analysis:** +```python +# This sequence looks like a no-op but converts data multiple times: +my_event.data +my_event.icalendar_instance +my_event.vobject_instance +my_event.data # Data may have changed! +``` + +**Risks:** +- Data representation changes +- CPU waste on conversions +- Potential data loss if reference held across conversion + +**Recommendation:** This is a significant API design issue but changing it in v3.0 would be disruptive. Consider for v4.0 with a migration path. + +--- + +## Recommendations + +### For v3.0 Release + +1. ✅ **Release as-is** - The codebase is stable and functional +2. 📝 **Update CHANGELOG** - Add missing entries for API aliases and issue #128 fix +3. 🧹 **Optional cleanup** - Remove dead code (`auto_calendars`, `auto_calendar`) + +### For v3.1 or Later + +1. **Reduce duplication** - Extract shared client logic to operations layer +2. **Add sync client unit tests** - Mirror async test structure +3. **Test discovery module** - Add tests for DNS-based discovery +4. **Error handling tests** - Add comprehensive error scenario tests +5. **Address issue #613** - Design solution for implicit conversions + +### For v4.0 + +1. **Consider issue #71** - Evaluate `add_*` vs `save_*` naming +2. **Fix implicit conversions** - Redesign data access to avoid side effects +3. **Further refactoring** - Consider splitting collection.py + +--- + +## Appendix: Test Files + +| Test File | Tests | Purpose | +|-----------|-------|---------| +| `tests/test_protocol.py` | 15+ | Protocol layer unit tests | +| `tests/test_operations_*.py` | 30+ | Operations layer unit tests | +| `tests/test_async_davclient.py` | 20+ | Async client unit tests | +| `tests/test_caldav.py` | 100+ | Integration tests | +| `tests/test_caldav_unit.py` | 10+ | Misc unit tests | + +### Running Tests + +```bash +# Quick unit tests (no server needed) +pytest tests/test_protocol.py tests/test_operations*.py -v + +# Full test suite with embedded servers +pytest -k "Radicale or Xandikos" + +# Style checks +tox -e style +``` diff --git a/docs/source/about.rst b/docs/source/about.rst index eddd8211..190e38ff 100644 --- a/docs/source/about.rst +++ b/docs/source/about.rst @@ -88,8 +88,8 @@ depend on another library for that. RFC 5545 describes the icalendar format. Constructing or parsing icalendar data was considered out of the scope of this library, but we do make exceptions - like, there is a method to complete a task - it -involves editing the icalendar data, and now the ``save_event``, -``save_todo`` and ``save_journal`` methods are able to construct icalendar +involves editing the icalendar data, and now the ``add_event``, +``add_todo`` and ``add_journal`` methods are able to construct icalendar data if needed. There exists two libraries supporting RFC 5545, vobject and icalendar. @@ -127,7 +127,7 @@ Notable classes and workflow * You'd always start by initiating a :class:`caldav.davclient.DAVClient` object, this object holds the authentication details for the - server. In 2.0 there is a function :class:`caldav.davclient.get_davclient` that can be used. + server. In 2.0 the function :func:`caldav.get_davclient` was added as the recommended way to get a client. * From the client object one can get hold of a :class:`caldav.collection.Principal` object representing the logged-in @@ -201,7 +201,7 @@ Here are some known issues: * Some problems observed with the propfind method - * object_by_uid does not work (and my object_by_uid follows the example in the RFC) + * get_object_by_uid does not work (and my get_object_by_uid follows the example in the RFC) * Google seems to be the new Microsoft, according to the issue tracker it seems like their CalDAV-support is rather lacking. At least they have a list ... https://developers.google.com/calendar/caldav/v2/guide diff --git a/docs/source/async.rst b/docs/source/async.rst new file mode 100644 index 00000000..0717ed24 --- /dev/null +++ b/docs/source/async.rst @@ -0,0 +1,236 @@ +==================== +Async API +==================== + +The caldav library provides an async-first API for use with Python's +``asyncio``. This is useful when you need to: + +* Make concurrent requests to the server +* Integrate with async web frameworks (FastAPI, aiohttp, etc.) +* Build responsive applications that don't block on I/O + +Quick Start +=========== + +The async API is available through the ``caldav.aio`` module: + +.. code-block:: python + + import asyncio + from caldav import aio + + async def main(): + async with aio.get_async_davclient() as client: + principal = await client.principal() + calendars = await principal.get_calendars() + for cal in calendars: + print(f"Calendar: {cal.name}") + events = await cal.get_events() + print(f" {len(events)} events") + + asyncio.run(main()) + +The async API mirrors the sync API, but all I/O operations are ``async`` +methods that must be awaited. + +Available Classes +================= + +The ``caldav.aio`` module exports: + +**Client:** + +* ``AsyncDAVClient`` - The main client class +* ``AsyncDAVResponse`` - Response wrapper +* ``get_async_davclient()`` - Factory function (recommended) + +**Calendar Objects:** + +* ``AsyncEvent`` - Calendar event +* ``AsyncTodo`` - Task/todo item +* ``AsyncJournal`` - Journal entry +* ``AsyncFreeBusy`` - Free/busy information + +**Collections:** + +* ``AsyncCalendar`` - A calendar +* ``AsyncCalendarSet`` - Collection of calendars +* ``AsyncPrincipal`` - User principal + +**Scheduling (RFC6638):** + +* ``AsyncScheduleInbox`` - Incoming invitations +* ``AsyncScheduleOutbox`` - Outgoing invitations + +Example: Working with Calendars +=============================== + +.. code-block:: python + + import asyncio + from caldav import aio + from datetime import datetime, date + + async def calendar_demo(): + async with aio.get_async_davclient() as client: + principal = await client.principal() + + # Create a new calendar + my_calendar = await principal.make_calendar( + name="My Async Calendar" + ) + + # Add an event + event = await my_calendar.add_event( + dtstart=datetime(2025, 6, 15, 10, 0), + dtend=datetime(2025, 6, 15, 11, 0), + summary="Team meeting" + ) + + # Search for events + events = await my_calendar.search( + event=True, + start=date(2025, 6, 1), + end=date(2025, 7, 1) + ) + print(f"Found {len(events)} events") + + # Clean up + await my_calendar.delete() + + asyncio.run(calendar_demo()) + +Example: Parallel Operations +============================ + +One of the main benefits of async is the ability to run operations +concurrently: + +.. code-block:: python + + import asyncio + from caldav import aio + + async def fetch_all_events(): + async with aio.get_async_davclient() as client: + principal = await client.principal() + calendars = await principal.get_calendars() + + # Fetch events from all calendars in parallel + tasks = [cal.get_events() for cal in calendars] + results = await asyncio.gather(*tasks) + + for cal, events in zip(calendars, results): + print(f"{cal.name}: {len(events)} events") + + asyncio.run(fetch_all_events()) + +Migration from Sync to Async +============================ + +The async API closely mirrors the sync API. Here are the key differences: + +1. **Import from ``caldav.aio``:** + + .. code-block:: python + + # Sync + from caldav import DAVClient, get_davclient + + # Async + from caldav import aio + # Use: aio.AsyncDAVClient, aio.get_async_davclient() + +2. **Use ``async with`` for context manager:** + + .. code-block:: python + + # Sync + with get_davclient() as client: + ... + + # Async + async with aio.get_async_davclient() as client: + ... + +3. **Await all I/O operations:** + + .. code-block:: python + + # Sync + principal = client.principal() + calendars = principal.get_calendars() + events = calendar.get_events() + + # Async + principal = await client.principal() + calendars = await principal.get_calendars() + events = await calendar.get_events() + +4. **Property access for cached data remains sync:** + + Properties that don't require I/O (like ``url``, ``name``, ``data``) + are still regular properties: + + .. code-block:: python + + # These work the same in both sync and async + print(calendar.url) + print(calendar.name) + print(event.data) + +Method Reference +================ + +The async classes have the same methods as their sync counterparts. +All methods that perform I/O are ``async`` and must be awaited: + +**AsyncDAVClient:** + +* ``await client.principal()`` - Get the principal +* ``client.calendar(url=...)`` - Get a calendar by URL (no await, no I/O) + +**AsyncPrincipal:** + +* ``await principal.get_calendars()`` - List all calendars +* ``await principal.make_calendar(name=...)`` - Create a calendar +* ``await principal.calendar(name=...)`` - Find a calendar + +**AsyncCalendar:** + +* ``await calendar.get_events()`` - Get all events +* ``await calendar.get_todos()`` - Get all todos +* ``await calendar.search(...)`` - Search for objects +* ``await calendar.add_event(...)`` - Create an event +* ``await calendar.add_todo(...)`` - Create a todo +* ``await calendar.get_event_by_uid(uid)`` - Find event by UID +* ``await calendar.delete()`` - Delete the calendar +* ``await calendar.get_supported_components()`` - Get supported types + +**AsyncEvent, AsyncTodo, AsyncJournal:** + +* ``await obj.load()`` - Load data from server +* ``await obj.save()`` - Save changes to server +* ``await obj.delete()`` - Delete the object +* ``await todo.complete()`` - Mark todo as complete + +Backward Compatibility +====================== + +The sync API (``caldav.DAVClient``, ``caldav.get_davclient()``) continues +to work exactly as before. The sync API now uses the async implementation +internally, with a thin sync wrapper. + +This means: + +* Existing sync code works without changes +* You can migrate to async gradually +* Both sync and async code can coexist in the same project + +Example Files +============= + +See the ``examples/`` directory for complete examples: + +* ``examples/async_usage_examples.py`` - Comprehensive async examples +* ``examples/basic_usage_examples.py`` - Sync examples (for comparison) diff --git a/docs/source/configfile.rst b/docs/source/configfile.rst index 3ec2fed7..5569870f 100644 --- a/docs/source/configfile.rst +++ b/docs/source/configfile.rst @@ -15,7 +15,7 @@ The config file has to be valid json or yaml (support for toml and Apple pkl may The config file is expected to be divided in sections, where each section can describe locations and credentials to a CalDAV server, a CalDAV calendar or a collection of calendars/servers. As of version 2.0, only the first is supported. -A config section can be given either through parameters to :class:`caldav.davclient.get_davclient` or by enviornment variable ``CALDAV_CONFIG_SECTION``. If no section is given, the ``default`` section is used. +A config section can be given either through parameters to :func:`caldav.get_davclient` or by enviornment variable ``CALDAV_CONFIG_SECTION``. If no section is given, the ``default`` section is used. Connection parameters ===================== diff --git a/docs/source/howtos.rst b/docs/source/howtos.rst index fbb2d47c..a725602a 100644 --- a/docs/source/howtos.rst +++ b/docs/source/howtos.rst @@ -2,7 +2,62 @@ How-To Guides ============= -Sorry, nothing here yet +Editing Calendar Data +--------------------- + +Calendar objects (events, todos, journals) can be accessed and modified +using the icalendar or vobject libraries. + +Reading Data +~~~~~~~~~~~~ + +For read-only access, use methods that return copies: + +.. code-block:: python + + # Get raw iCalendar string + data = event.get_data() + + # Get icalendar object (a copy - safe to inspect) + ical = event.get_icalendar_instance() + for comp in ical.subcomponents: + print(comp.get("SUMMARY")) + + # Get vobject object (a copy) + vobj = event.get_vobject_instance() + +Modifying Data +~~~~~~~~~~~~~~ + +To edit an object, use context managers that "borrow" the object: + +.. code-block:: python + + # Edit using icalendar + with event.edit_icalendar_instance() as cal: + for comp in cal.subcomponents: + if comp.name == "VEVENT": + comp["SUMMARY"] = "New summary" + event.save() + + # Edit using vobject + with event.edit_vobject_instance() as vobj: + vobj.vevent.summary.value = "New summary" + event.save() + +While inside the ``with`` block, the object is exclusively borrowed. +Attempting to borrow a different representation will raise ``RuntimeError``. + +Quick Access +~~~~~~~~~~~~ + +For simple read access, use the ``component`` property: + +.. code-block:: python + + # Read properties + summary = event.component["SUMMARY"] + start = event.component.start .. todo:: diff --git a/docs/source/http-libraries.rst b/docs/source/http-libraries.rst new file mode 100644 index 00000000..3449106f --- /dev/null +++ b/docs/source/http-libraries.rst @@ -0,0 +1,30 @@ +HTTP Library Configuration +========================== + +The caldav library supports multiple HTTP client libraries. This page explains +the default configuration and how to customize it if needed. + +As of v3.0, the caldav library uses **niquests** for both synchronous and +asynchronous HTTP requests. niquests is a modern HTTP library with support +for HTTP/2 and HTTP/3. + +The library also supports requests (for sync communication) and httpx +(for async communication). If you for some reason or another don't +want to drag in the niquests dependency, then you may simply edit the +pyproject.toml file and replace niquests with requests and httpx. + +HTTP/2 Support +-------------- + +HTTP/2 support is available with both niquests and httpx. For httpx, +you need to install the optional ``h2`` package:: + + pip install h2 + +The async client will automatically enable HTTP/2 when h2 is available +and the server supports it. + +Note: Some servers have compatibility issues with HTTP/2 multiplexing, +particularly when combined with digest authentication methods and +nginx server. (TODO: update the doc on this - I will most likely +remove the "do multiplexing by default"-logic before releasing v3.0) diff --git a/docs/source/index.rst b/docs/source/index.rst index 6a8ad45c..9b2aab26 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -4,6 +4,11 @@ This is the Python CalDAV client library, making communication with calendaring servers easy. +NOTE: version 3 introduces quite some new API. The documentation has been AI-maintained to reflect this, causing two potential problems: + +* The quality of the doc may be varying. I will get back to it and do proper QA on the documentation when I have my hands free. +* This is the v3-documentation. If you're stuck on v2, then quite some of the instructions in this documentation will not work for you. + Contents ======== @@ -12,8 +17,10 @@ Contents about tutorial + async howtos performance + http-libraries reference examples contact diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 6e183038..d02002d1 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -26,20 +26,66 @@ When you've run the tutorial as intended, I recommend going through the examples * You will need to revert all changes done. The code examples below does not do any cleanup. If your calendar server supports creating and deleting calendars, then it should be easy enough: ```my_new_calendar.delete()``` inside the with-block. Events also has a ``.delete()``-method. Beware that there is no ``undo``. You're adviced to have a local backup of your calendars. I'll probably write a HOWTO on that one day. * Usage of a context manager is considered best practice, but not really needed - you may skip the with-statement and write just ``client = get_davclient()``. This will make it easier to test code from the python shell. +Quick Start: Getting Calendars Directly +--------------------------------------- + +As of 3.0, there are convenience functions to get calendars directly +without manually creating a client and principal: + +.. code-block:: python + + from caldav import get_calendars, get_calendar + + # Get all calendars + calendars = get_calendars( + url="https://caldav.example.com/", + username="alice", + password="secret" + ) + for cal in calendars: + print(f"Found calendar: {cal.name}") + + # Get a specific calendar by name + work_calendar = get_calendar( + url="https://caldav.example.com/", + username="alice", + password="secret", + calendar_name="Work" + ) + + # Get calendars by URL or ID + calendars = get_calendars( + url="https://caldav.example.com/", + username="alice", + password="secret", + calendar_url="/calendars/alice/personal/" # or just "personal" + ) + +These functions also support reading configuration from environment +variables (``CALDAV_URL``, ``CALDAV_USERNAME``, ``CALDAV_PASSWORD``) +or config files, so you can simply call: + +.. code-block:: python + + from caldav import get_calendars + calendars = get_calendars() # Uses env vars or config file + +The Traditional Approach +------------------------ + As of 2.0, it's recommended to start initiating a :class:`caldav.davclient.DAVClient` object using the ``get_davclient`` function, go from there to get a :class:`caldav.collection.Principal`-object, and from there find a -:class:`caldav.collection.Calendar`-object. (I'm planning to add a -``get_calendar`` in version 3.0). This is how to do it: +:class:`caldav.collection.Calendar`-object. This is how to do it: .. code-block:: python - from caldav.davclient import get_davclient + from caldav import get_davclient from caldav.lib.error import NotFoundError with get_davclient() as client: - my_principal = client.principal() + my_principal = client.get_principal() try: my_calendar = my_principal.calendar() print(f"A calendar was found at URL {my_calendar.url}") @@ -57,11 +103,11 @@ be the correct one. To filter there are parameters ``name`` and .. code-block:: python - from caldav.davclient import get_davclient + from caldav import get_davclient from caldav.lib.error import NotFoundError with get_davclient() as client: - my_principal = client.principal() + my_principal = client.get_principal() try: my_calendar = my_principal.calendar(name="My Calendar") except NotFoundError: @@ -72,7 +118,7 @@ to go through the principal object. .. code-block:: python - from caldav.davclient import get_davclient + from caldav import get_davclient with get_davclient() as client: my_calendar = client.calendar(url="/dav/calendars/mycalendar") @@ -83,13 +129,13 @@ For servers that supports it, it may be useful to create a dedicated test calend .. code-block:: python - from caldav.davclient import get_davclient + from caldav import get_davclient import datetime with get_davclient() as client: - my_principal = client.principal() + my_principal = client.get_principal() my_new_calendar = my_principal.make_calendar(name="Test calendar") - may17 = my_new_calendar.save_event( + may17 = my_new_calendar.add_event( dtstart=datetime.datetime(2020,5,17,8), dtend=datetime.datetime(2020,5,18,1), uid="may17", @@ -100,12 +146,12 @@ You have icalendar code and want to put it into the calendar? Easy! .. code-block:: python - from caldav.davclient import get_davclient + from caldav import get_davclient with get_davclient() as client: - my_principal = client.principal() + my_principal = client.get_principal() my_new_calendar = my_principal.make_calendar(name="Test calendar") - may17 = my_new_calendar.save_event("""BEGIN:VCALENDAR + may17 = my_new_calendar.add_event("""BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN BEGIN:VEVENT @@ -123,13 +169,13 @@ The best way of getting information out from the calendar is to use the search. .. code-block:: python - from caldav.davclient import get_davclient + from caldav import get_davclient from datetime import date with get_davclient() as client: - my_principal = client.principal() + my_principal = client.get_principal() my_new_calendar = my_principal.make_calendar(name="Test calendar") - my_new_calendar.save_event( + my_new_calendar.add_event( dtstart=datetime.datetime(2023,5,17,8), dtend=datetime.datetime(2023,5,18,1), uid="may17", @@ -157,13 +203,13 @@ The ``data`` property delivers the icalendar data as a string. It can be modifi .. code-block:: python - from caldav.davclient import get_davclient + from caldav import get_davclient from datetime import date with get_davclient() as client: - my_principal = client.principal() + my_principal = client.get_principal() my_new_calendar = my_principal.make_calendar(name="Test calendar") - my_new_calendar.save_event( + my_new_calendar.add_event( dtstart=datetime.datetime(2023,5,17,8), dtend=datetime.datetime(2023,5,18,1), uid="may17", @@ -188,29 +234,21 @@ without expand set and with different years, print out you want to edit the full series! The code above is far from "best practice". You should not try to -parse or modify ``event.data``. Best current practice is to use the -icalendar library for that. You can access the data thorugh an -:class:`icalendar.cal.Calendar`-object at ``myevent.icalendar_instance``. -(in 3.0, probably ``myevent.instance`` will work out without yielding -a ``DeprecationWarning``). - -Most of the time every event one gets out from the search contains one -*component* - and it will always be like that when using -``expand=True``. To ease things out for users of the library that -wants easy access to the event data, the -``my_events[9].icalendar_component`` property will give a -:class:`icalendar.cal.Event`-object. From 2.0 also accessible simply as -``my_events[0].component``: +parse or modify ``event.data`` directly. Use the icalendar library instead. + +Most events contain one *component* (always true when using ``expand=True``). +The ``event.component`` property gives easy access to the +:class:`icalendar.cal.Event`-object. To edit, use ``edit_icalendar_instance()``: .. code-block:: python - from caldav.davclient import get_davclient + from caldav import get_davclient from datetime import date with get_davclient() as client: - my_principal = client.principal() + my_principal = client.get_principal() my_new_calendar = my_principal.make_calendar(name="Test calendar") - my_new_calendar.save_event( + my_new_calendar.add_event( dtstart=datetime.datetime(2023,5,17,8), dtend=datetime.datetime(2023,5,18,1), uid="may17", @@ -224,25 +262,24 @@ wants easy access to the event data, the assert len(my_events) == 1 print(f"Event starts at {my_events[0].component.start}") - my_events[0].component['summary'] = "Norwegian national day celebrations" + with my_events[0].edit_icalendar_instance() as cal: + cal.subcomponents[0]['summary'] = "Norwegian national day celebrations" my_events[0].save() -There is a danger to this - there is one (and only one) exception when an event contains more than one component. If you've been observant and followed all the steps in this tutorial very carefully, you should have spotted it. - -How to do operations on components and instances in the vobject and icalendar library is outside the scope of this tutorial. +How to do operations on components in the icalendar library is outside the scope of this tutorial. Usually tasks and journals can be applied directly to the same calendar as the events - but some implementations (notably Zimbra) has "task lists" and "calendars" as distinct entities. To create a task list, there is a parameter ``supported_calendar_component_set`` that can be set to ``['VTODO']``. Here is a quick example that features a task: .. code-block:: python - from caldav.davclient import get_davclient + from caldav import get_davclient from datetime import date with get_davclient() as client: - my_principal = client.principal() + my_principal = client.get_principal() my_new_calendar = my_principal.make_calendar( name="Test calendar", supported_calendar_component_set=['VTODO']) - my_new_calendar.save_todo( + my_new_calendar.add_todo( summary="prepare for the Norwegian national day", due=date(2025,5,16)) my_tasks = my_new_calendar.search( diff --git a/examples/async_usage_examples.py b/examples/async_usage_examples.py new file mode 100644 index 00000000..3a786acd --- /dev/null +++ b/examples/async_usage_examples.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python +""" +Async CalDAV Usage Examples + +This module demonstrates the async API for the caldav library. +For sync usage, see basic_usage_examples.py. + +The async API is available through the caldav.aio module: + + from caldav import aio + + async with aio.AsyncDAVClient(url=..., username=..., password=...) as client: + principal = await client.principal() + calendars = await principal.get_calendars() + for cal in calendars: + events = await cal.get_events() + +To run this example: + + env CALDAV_USERNAME=xxx@example.com \ + CALDAV_PASSWORD=xxx \ + CALDAV_URL=https://caldav.example.com/ \ + python ./examples/async_usage_examples.py +""" +import asyncio +import sys +from datetime import date +from datetime import datetime +from datetime import timedelta + +# Use local caldav library, not system-installed +sys.path.insert(0, "..") +sys.path.insert(0, ".") + +from caldav import aio, error + + +async def run_examples(): + """ + Run through all the async examples, one by one + """ + # The async client is available via caldav.aio module + # get_async_davclient() reads credentials from environment variables + # and config file, just like the sync version + async with aio.get_async_davclient() as client: + # Fetch the principal object - this triggers server communication + print("Connecting to the caldav server") + my_principal = await client.principal() + + # Fetch the principal's calendars + calendars = await my_principal.get_calendars() + + # Print calendar information + await print_calendars_demo(calendars) + + # Clean up from previous runs if needed + await find_delete_calendar_demo( + my_principal, "Test calendar from async examples" + ) + + # Create a new calendar to play with + my_new_calendar = await my_principal.make_calendar( + name="Test calendar from async examples" + ) + + # Add some events to our newly created calendar + await add_stuff_to_calendar_demo(my_new_calendar) + + # Find the stuff we just added + event = await search_calendar_demo(my_new_calendar) + + # Inspect and modify an event + await read_modify_event_demo(event) + + # Access a calendar by URL + await calendar_by_url_demo(client, my_new_calendar.url) + + # Clean up - delete the event and calendar + await event.delete() + await my_new_calendar.delete() + + +async def print_calendars_demo(calendars): + """ + Print the name and URL for every calendar on the list + """ + if calendars: + print(f"your principal has {len(calendars)} calendars:") + for c in calendars: + print(f" Name: {c.name:<36} URL: {c.url}") + else: + print("your principal has no calendars") + + +async def find_delete_calendar_demo(my_principal, calendar_name): + """ + Find a calendar by name and delete it if it exists. + This cleans up from previous runs. + """ + try: + # calendar() is async in the new API + demo_calendar = await my_principal.calendar(name=calendar_name) + print(f"Found existing calendar '{calendar_name}', now deleting it") + await demo_calendar.delete() + except error.NotFoundError: + # Calendar was not found - that's fine + pass + + +async def add_stuff_to_calendar_demo(calendar): + """ + Add some events and tasks to the calendar + """ + # Add an event with some attributes + print("Saving an event") + may_event = await calendar.add_event( + dtstart=datetime(2020, 5, 17, 6), + dtend=datetime(2020, 5, 18, 1), + summary="Do the needful", + rrule={"FREQ": "YEARLY"}, + ) + print("Saved an event") + + # Check if tasks are supported + acceptable_component_types = await calendar.get_supported_components() + if "VTODO" in acceptable_component_types: + print("Tasks are supported by your calendar, saving one") + dec_task = await calendar.add_todo( + ical_fragment="""DTSTART;VALUE=DATE:20201213 +DUE;VALUE=DATE:20201220 +SUMMARY:Chop down a tree and drag it into the living room +RRULE:FREQ=YEARLY +PRIORITY: 2 +CATEGORIES:outdoor""" + ) + print("Saved a task") + else: + print("Tasks are not supported by this calendar") + + +async def search_calendar_demo(calendar): + """ + Examples of fetching objects from the calendar + """ + # Date search for events with expand + print("Searching for expanded events") + events_fetched = await calendar.search( + start=datetime.now(), + end=datetime(date.today().year + 5, 1, 1), + event=True, + expand=True, + ) + + # The yearly event gives us one object per year when expanded + if len(events_fetched) > 1: + print(f"Found {len(events_fetched)} expanded events") + else: + print(f"Found {len(events_fetched)} event") + + print("Here is some ical data from the first one:") + print(events_fetched[0].data) + + # Same search without expand - gets the "master" event + print("Searching for unexpanded events") + events_fetched = await calendar.search( + start=datetime.now(), + end=datetime(date.today().year + 5, 1, 1), + event=True, + expand=False, + ) + print(f"Found {len(events_fetched)} event (master only)") + + # Search by category + print("Searching for tasks by category") + tasks_fetched = await calendar.search(todo=True, category="outdoor") + print(f"Found {len(tasks_fetched)} task(s)") + + # Get all objects from the calendar + print("Getting all events from the calendar") + events = await calendar.get_events() + + print("Getting all todos from the calendar") + tasks = await calendar.get_todos() + + print(f"Found {len(events)} events and {len(tasks)} tasks") + + # Mark tasks as complete + if tasks: + print("Marking a task completed") + await tasks[0].complete() + + # Completed tasks disappear from the regular list + remaining_tasks = await calendar.get_todos() + print(f"Remaining incomplete tasks: {len(remaining_tasks)}") + + # But they're not deleted - can still find with include_completed + all_tasks = await calendar.get_todos(include_completed=True) + print(f"All tasks (including completed): {len(all_tasks)}") + + # Delete the task completely + print("Deleting the task") + await tasks[0].delete() + + return events_fetched[0] + + +async def read_modify_event_demo(event): + """ + Demonstrate how to read and modify event properties + """ + # event.data is the raw ical data + print("Here comes some icalendar data:") + print(event.data) + + # Modify using vobject + event.vobject_instance.vevent.summary.value = "norwegian national day celebratiuns" + + # Get the UID using icalendar + uid = event.component["uid"] + + # Fix the typo using icalendar + event.component["summary"] = event.component["summary"].replace( + "celebratiuns", "celebrations" + ) + + # Modify timestamps + dtstart = event.component.get("dtstart") + if dtstart: + event.component["dtstart"].dt = dtstart.dt + timedelta(seconds=3600) + + # Fix casing + event.data = event.data.replace("norwegian", "Norwegian") + + # Save the modifications to the server + await event.save() + + # Verify the data was saved correctly + calendar = event.parent + same_event = await calendar.get_event_by_uid(uid) + print(f"Event summary after save: {same_event.component['summary']}") + + +async def calendar_by_url_demo(client, url): + """ + Access a calendar directly by URL without fetching the principal + """ + # No network traffic for this - just creates the object + calendar = client.calendar(url=url) + + # This will cause network activity + events = await calendar.get_events() + print(f"Calendar has {len(events)} event(s)") + + if events: + event_url = events[0].url + + # Construct an event object from URL (no network traffic) + same_event = aio.AsyncEvent(client=client, parent=calendar, url=event_url) + + # Load the data from the server + await same_event.load() + print(f"Loaded event: {same_event.component['summary']}") + + +async def parallel_operations_demo(): + """ + Demonstrate running multiple async operations in parallel. + This is one of the main benefits of async - concurrent I/O. + """ + async with aio.get_async_davclient() as client: + principal = await client.principal() + calendars = await principal.get_calendars() + + if len(calendars) >= 2: + # Fetch events from multiple calendars in parallel + print("Fetching events from multiple calendars in parallel...") + results = await asyncio.gather( + calendars[0].get_events(), + calendars[1].get_events(), + ) + for i, events in enumerate(results): + print(f"Calendar {i}: {len(events)} events") + + +if __name__ == "__main__": + asyncio.run(run_examples()) diff --git a/examples/basic_usage_examples.py b/examples/basic_usage_examples.py index 68d2a6bf..fb7f45e5 100644 --- a/examples/basic_usage_examples.py +++ b/examples/basic_usage_examples.py @@ -8,7 +8,7 @@ sys.path.insert(0, ".") import caldav -from caldav.davclient import get_davclient +from caldav import get_davclient ## Connection parameters can be set in a configuration file or passed ## as environmental variables. The format of the configuration file @@ -46,7 +46,7 @@ def run_examples(): my_principal = client.principal() ## The principals calendars can be fetched like this: - calendars = my_principal.calendars() + calendars = my_principal.get_calendars() ## print out some information print_calendars_demo(calendars) @@ -125,7 +125,7 @@ def add_stuff_to_calendar_demo(calendar): """ ## Add an event with some certain attributes print("Saving an event") - may_event = calendar.save_event( + may_event = calendar.add_event( dtstart=datetime(2020, 5, 17, 6), dtend=datetime(2020, 5, 18, 1), summary="Do the needful", @@ -143,7 +143,7 @@ def add_stuff_to_calendar_demo(calendar): ## Note that this may break on your server: ## * not all servers accepts tasks and events mixed on the same calendar. ## * not all servers accepts tasks at all - dec_task = calendar.save_todo( + dec_task = calendar.add_todo( ical_fragment="""DTSTART;VALUE=DATE:20201213 DUE;VALUE=DATE:20201220 SUMMARY:Chop down a tree and drag it into the living room @@ -205,15 +205,15 @@ def search_calendar_demo(calendar): ## This those should also work: print("Getting all objects from the calendar") all_objects = calendar.objects() - # updated_objects = calendar.objects_by_sync_token(some_sync_token) - # some_object = calendar.object_by_uid(some_uid) - # some_event = calendar.event_by_uid(some_uid) + # updated_objects = calendar.get_objects_by_sync_token(some_sync_token) + # some_object = calendar.get_object_by_uid(some_uid) + # some_event = calendar.get_event_by_uid(some_uid) print("Getting all children from the calendar") children = calendar.children() print("Getting all events from the calendar") - events = calendar.events() + events = calendar.get_events() print("Getting all todos from the calendar") - tasks = calendar.todos() + tasks = calendar.get_todos() assert len(events) + len(tasks) == len(all_objects) print( f"Found {len(events)} events and {len(tasks)} tasks which is {len(all_objects)}" @@ -229,11 +229,11 @@ def search_calendar_demo(calendar): ## They will then disappear from the task list print("Getting remaining todos") - assert not calendar.todos() + assert not calendar.get_todos() print("There are no todos") ## But they are not deleted - assert len(calendar.todos(include_completed=True)) == 1 + assert len(calendar.get_todos(include_completed=True)) == 1 ## Let's delete it completely print("Deleting it completely") @@ -249,80 +249,71 @@ def read_modify_event_demo(event): `search_calendar_demo`. The event needs some editing, which will be done below. Keep in mind that the differences between an Event, a Todo and a Journal is small, everything that is done to - he event here could as well be done towards a task. + the event here could as well be done towards a task. """ - ## The objects (events, journals and tasks) comes with some properties that - ## can be used for inspecting the data and modifying it. - - ## event.data is the raw data, as a string, with unix linebreaks - print("here comes some icalendar data:") - print(event.data) - - ## event.wire_data is the raw data as a byte string with CRLN linebreaks - assert len(event.wire_data) >= len(event.data) - - ## Two libraries exists to handle icalendar data - vobject and - ## icalendar. The caldav library traditionally supported the - ## first one, but icalendar is more popular. - - ## Here is an example - ## on how to modify the summary using vobject: - event.vobject_instance.vevent.summary.value = "norwegian national day celebratiuns" - - ## event.icalendar_instance gives an icalendar instance - which - ## normally would be one icalendar calendar object containing one - ## subcomponent. Quite often the fourth property, - ## icalendar_component (now available just as .component) is - ## preferable - it gives us the component - but be aware that if - ## the server returns a recurring events with exceptions, - ## event.icalendar_component will ignore all the exceptions. - uid = event.component["uid"] - - ## Let's correct that typo using the icalendar library. - event.component["summary"] = event.component["summary"].replace( - "celebratiuns", "celebrations" - ) + ## ========================================================= + ## RECOMMENDED: Safe data access API (3.0+) + ## ========================================================= + ## As of caldav 3.0, use context managers to "borrow" objects for editing. + ## This prevents confusing side effects where accessing one representation + ## can invalidate references to another. + + ## For READ-ONLY access, use get_* methods (returns copies): + print("here comes some icalendar data (using get_data):") + print(event.get_data()) + + ## For READ-ONLY inspection of icalendar object: + ical_copy = event.get_icalendar_instance() + for comp in ical_copy.subcomponents: + if comp.name == "VEVENT": + print(f"Event UID: {comp['UID']}") + uid = str(comp["UID"]) + + ## For EDITING, use context managers: + print("Editing the event using edit_icalendar_instance()...") + with event.edit_icalendar_instance() as cal: + for comp in cal.subcomponents: + if comp.name == "VEVENT": + comp["SUMMARY"] = "norwegian national day celebratiuns" + + ## Or edit using vobject: + print("Editing with vobject using edit_vobject_instance()...") + with event.edit_vobject_instance() as vobj: + vobj.vevent.summary.value = vobj.vevent.summary.value.replace( + "celebratiuns", "celebrations" + ) - ## timestamps (DTSTAMP, DTSTART, DTEND for events, DUE for tasks, - ## etc) can be fetched using the icalendar library like this: - dtstart = event.component.get("dtstart") - - ## but, dtstart is not a python datetime - it's a vDatetime from - ## the icalendar package. If you want it as a python datetime, - ## use the .dt property. (In this case dtstart is set - and it's - ## pretty much mandatory for events - but the code here is robust - ## enough to handle cases where it's undefined): - dtstart_dt = dtstart and dtstart.dt - - ## We can modify it: - if dtstart: - event.component["dtstart"].dt = dtstart.dt + timedelta(seconds=3600) - - ## And finally, get the casing correct - event.data = event.data.replace("norwegian", "Norwegian") - - ## Note that this is not quite thread-safe: - icalendar_component = event.component - ## accessing the data (and setting it) will "disconnect" the - ## icalendar_component from the event - event.data = event.data - ## So this will not affect the event anymore: - icalendar_component["summary"] = "do the needful" - assert not "do the needful" in event.data - - ## The mofifications are still only saved locally in memory - - ## let's save it to the server: + ## Modify the start time using icalendar + with event.edit_icalendar_instance() as cal: + for comp in cal.subcomponents: + if comp.name == "VEVENT": + dtstart = comp.get("dtstart") + if dtstart: + comp["dtstart"].dt = dtstart.dt + timedelta(seconds=3600) + ## Fix the casing + comp["SUMMARY"] = str(comp["SUMMARY"]).replace("norwegian", "Norwegian") + + ## Save to server event.save() - ## NOTE: always use event.save() for updating events and - ## calendar.save_event(data) for creating a new event. - ## This may break: - # event.save(event.data) - ## ref https://github.com/python-caldav/caldav/issues/153 - - ## Finally, let's verify that the correct data was saved + ## ========================================================= + ## LEGACY: Property-based access (still works, but be careful) + ## ========================================================= + ## The old property access still works for backward compatibility: + ## event.data, event.icalendar_instance, event.vobject_instance + ## + ## WARNING: These have confusing side effects! Accessing one + ## can disconnect your references to another: + ## + ## component = event.component + ## event.data = event.data # This disconnects 'component'! + ## component["summary"] = "new" # This won't be saved! + ## + ## Use the context managers above instead for safe editing. + + ## Verify the correct data was saved calendar = event.parent - same_event = calendar.event_by_uid(uid) + same_event = calendar.get_event_by_uid(uid) assert same_event.component["summary"] == "Norwegian national day celebrations" @@ -334,7 +325,7 @@ def calendar_by_url_demo(client, url): ## No network traffic will be initiated by this: calendar = client.calendar(url=url) ## At the other hand, this will cause network activity: - events = calendar.events() + events = calendar.get_events() ## We should still have only one event in the calendar assert len(events) == 1 diff --git a/examples/collation_usage.py b/examples/collation_usage.py index e00b41c4..f6b916eb 100644 --- a/examples/collation_usage.py +++ b/examples/collation_usage.py @@ -12,7 +12,7 @@ sys.path.insert(0, "..") sys.path.insert(0, ".") -from caldav.davclient import get_davclient +from caldav import get_davclient from caldav.search import CalDAVSearcher @@ -23,21 +23,21 @@ def run_examples(): print("=" * 80) with get_davclient() as client: - calendar = client.principal().calendars()[0] + calendar = client.principal().get_calendars()[0] # Create some test events with different cases print("\nCreating test events...") - calendar.save_event( + calendar.add_event( dtstart=datetime(2025, 6, 1, 10, 0), dtend=datetime(2025, 6, 1, 11, 0), summary="Team Meeting", ) - calendar.save_event( + calendar.add_event( dtstart=datetime(2025, 6, 2, 14, 0), dtend=datetime(2025, 6, 2, 15, 0), summary="team meeting", ) - calendar.save_event( + calendar.add_event( dtstart=datetime(2025, 6, 3, 9, 0), dtend=datetime(2025, 6, 3, 10, 0), summary="MEETING with clients", diff --git a/examples/example_rfc6764_usage.py b/examples/example_rfc6764_usage.py index 04ea8adf..3b1ed0db 100644 --- a/examples/example_rfc6764_usage.py +++ b/examples/example_rfc6764_usage.py @@ -4,7 +4,7 @@ This script demonstrates how the RFC6764 integration works. """ -from caldav.davclient import get_davclient +from caldav import get_davclient # Example 1: Automatic RFC6764 discovery with email address # Username is automatically extracted from the email address diff --git a/examples/get_calendars_example.py b/examples/get_calendars_example.py new file mode 100644 index 00000000..8a8c313e --- /dev/null +++ b/examples/get_calendars_example.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +""" +Example: Using get_calendars() and get_calendar() convenience functions. + +These functions provide a simple way to fetch calendars without +manually creating a client and principal object. + +Configuration can come from: +1. Explicit parameters (url, username, password) +2. Environment variables (CALDAV_URL, CALDAV_USERNAME, CALDAV_PASSWORD) +3. Config files (~/.config/caldav/config.yaml) +""" +from caldav import get_calendar +from caldav import get_calendars + + +def example_get_all_calendars(): + """Get all calendars from a CalDAV server.""" + print("=== Get All Calendars ===") + + # Option 1: Explicit credentials + calendars = get_calendars( + url="https://caldav.example.com/", + username="alice", + password="secret", + ) + + # Option 2: From environment variables (CALDAV_URL, CALDAV_USERNAME, CALDAV_PASSWORD) + # calendars = get_calendars() + + for cal in calendars: + print(f" - {cal.name} ({cal.url})") + + return calendars + + +def example_get_calendar_by_name(): + """Get a specific calendar by name.""" + print("\n=== Get Calendar by Name ===") + + calendar = get_calendar( + url="https://caldav.example.com/", + username="alice", + password="secret", + calendar_name="Work", + ) + + if calendar: + print(f"Found: {calendar.name}") + # Now you can work with events + events = calendar.get_events() + print(f" Contains {len(events)} events") + else: + print("Calendar 'Work' not found") + + return calendar + + +def example_get_multiple_calendars_by_name(): + """Get multiple specific calendars by name.""" + print("\n=== Get Multiple Calendars by Name ===") + + calendars = get_calendars( + url="https://caldav.example.com/", + username="alice", + password="secret", + calendar_name=["Work", "Personal", "Family"], # List of names + ) + + for cal in calendars: + print(f" - {cal.name}") + + return calendars + + +def example_get_calendar_by_url(): + """Get a calendar by URL or ID.""" + print("\n=== Get Calendar by URL/ID ===") + + # By full path + calendars = get_calendars( + url="https://caldav.example.com/", + username="alice", + password="secret", + calendar_url="/calendars/alice/work/", + ) + + # Or just by calendar ID (the last path segment) + calendars = get_calendars( + url="https://caldav.example.com/", + username="alice", + password="secret", + calendar_url="work", # No slash = treated as ID + ) + + for cal in calendars: + print(f" - {cal.name} at {cal.url}") + + return calendars + + +def example_error_handling(): + """Handle errors gracefully.""" + print("\n=== Error Handling ===") + + # With raise_errors=False (default), returns empty list on failure + calendars = get_calendars( + url="https://invalid.example.com/", + username="alice", + password="wrong", + raise_errors=False, + ) + print(f"Got {len(calendars)} calendars (errors suppressed)") + + # With raise_errors=True, raises exceptions + try: + calendars = get_calendars( + url="https://invalid.example.com/", + username="alice", + password="wrong", + raise_errors=True, + ) + except Exception as e: + print(f"Caught error: {type(e).__name__}: {e}") + + +def example_working_with_events(): + """Once you have a calendar, work with events.""" + print("\n=== Working with Events ===") + + import datetime + + calendar = get_calendar( + url="https://caldav.example.com/", + username="alice", + password="secret", + calendar_name="Work", + ) + + if not calendar: + print("No calendar found") + return + + # Create an event + event = calendar.add_event( + dtstart=datetime.datetime.now() + datetime.timedelta(days=1), + dtend=datetime.datetime.now() + datetime.timedelta(days=1, hours=1), + summary="Meeting created via get_calendar()", + ) + print(f"Created event: {event.vobject_instance.vevent.summary.value}") + + # Search for events + events = calendar.search( + start=datetime.datetime.now(), + end=datetime.datetime.now() + datetime.timedelta(days=7), + event=True, + ) + print(f"Found {len(events)} events in the next week") + + # Clean up + event.delete() + print("Deleted the test event") + + +if __name__ == "__main__": + print("CalDAV get_calendars() Examples") + print("================================") + print() + print("Note: These examples use placeholder URLs.") + print("Set CALDAV_URL, CALDAV_USERNAME, CALDAV_PASSWORD environment") + print("variables to test against a real server.") + print() + + # Uncomment the examples you want to run: + # example_get_all_calendars() + # example_get_calendar_by_name() + # example_get_multiple_calendars_by_name() + # example_get_calendar_by_url() + # example_error_handling() + # example_working_with_events() diff --git a/examples/get_events_example.py b/examples/get_events_example.py index 97ec105f..fe913951 100644 --- a/examples/get_events_example.py +++ b/examples/get_events_example.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import json -from caldav.davclient import get_davclient +from caldav import get_davclient ## Code contributed by Крылов Александр. ## Minor changes by Tobias Brox. @@ -12,7 +12,7 @@ def fetch_and_print(): with get_davclient() as client: - print_calendars_demo(client.principal().calendars()) + print_calendars_demo(client.principal().get_calendars()) def print_calendars_demo(calendars): @@ -20,7 +20,7 @@ def print_calendars_demo(calendars): return events = [] for calendar in calendars: - for event in calendar.events(): + for event in calendar.get_events(): ## Most calendar events will have only one component, ## and it can be accessed simply as event.component ## The exception is special recurrences, to handle those diff --git a/examples/google-django.py b/examples/google-django.py index 8e554f7d..5d18dc33 100644 --- a/examples/google-django.py +++ b/examples/google-django.py @@ -15,7 +15,7 @@ from allauth.socialaccount.models import SocialToken from google.oauth2.credentials import Credentials -from caldav.davclient import get_davclient +from caldav import get_davclient from caldav.requests import HTTPBearerAuth @@ -52,8 +52,8 @@ def sync_calendar(user, calendar_id): # Access calendar principal = client.principal() - calendar = principal.calendars()[0] + calendar = principal.get_calendars()[0] # Now you can work with events - events = calendar.events() + events = calendar.get_events() # ...etc diff --git a/examples/google-flask.py b/examples/google-flask.py index 7ffe1c04..ed338610 100644 --- a/examples/google-flask.py +++ b/examples/google-flask.py @@ -12,7 +12,7 @@ from flask import Response from google.oauth2.credentials import Credentials -from caldav.davclient import get_davclient +from caldav import get_davclient from caldav.requests import HTTPBearerAuth @@ -95,12 +95,12 @@ def serve_calendar_ics(calendar_name): # connect to the calendar using CalDAV client = get_davclient(url=calendar_url, auth=HTTPBearerAuth(access_token)) principal = client.principal() - calendars = principal.calendars() + calendars = principal.get_calendars() # fetch events from the first calendar (usually the only one) calendar = calendars[0] ics_data = "" - for event in calendar.events(): + for event in calendar.get_events(): ics_data += event.data # serve the calendar as an ICS file diff --git a/examples/google-service-account.py b/examples/google-service-account.py index f0f413d1..ab27cce4 100644 --- a/examples/google-service-account.py +++ b/examples/google-service-account.py @@ -10,7 +10,7 @@ from google_auth_oauthlib.flow import InstalledAppFlow from requests.auth import AuthBase -from caldav.davclient import get_davclient +from caldav import get_davclient SERVICE_ACCOUNT_FILE = "service.json" @@ -37,8 +37,8 @@ def __call__(self, r): client = get_davclient(url, auth=OAuth(creds)) -for calendar in client.principal().calendars(): - events = calendar.events() +for calendar in client.principal().get_calendars(): + events = calendar.get_events() for event in events: ## Comment from caldav maintainer: this usage of vobject works out as ## long as there are only events (and no tasks) on the calendar and diff --git a/examples/scheduling_examples.py b/examples/scheduling_examples.py index 56812d1b..e72e77ac 100644 --- a/examples/scheduling_examples.py +++ b/examples/scheduling_examples.py @@ -12,7 +12,7 @@ from icalendar import Event from caldav import error -from caldav.davclient import get_davclient +from caldav import get_davclient ############### @@ -112,7 +112,7 @@ def cleanup(self, calendar_name, calendar_id): ## There are two ways to send calendar invites: ## * Add Attendee-lines and an Organizer-line to the event data, and -## then use calendar.save_event(caldata) ... see RFC6638, appendix B.1 +## then use calendar.add_event(caldata) ... see RFC6638, appendix B.1 ## for an example. ## * Use convenience-method calendar.save_with_invites(caldata, attendees). diff --git a/examples/sync_examples.py b/examples/sync_examples.py index 58c6274f..21b81e79 100644 --- a/examples/sync_examples.py +++ b/examples/sync_examples.py @@ -12,7 +12,9 @@ # (... some time later ...) my_events.sync() for event in my_events: - print(event.icalendar.subcomponents[0]["SUMMARY"]) + # Use get_icalendar_instance() for read-only access (returns a copy) + ical = event.get_icalendar_instance() + print(ical.subcomponents[0]["SUMMARY"]) ## USE CASE #2, approach #1: We want to load all objects from the ## remote caldav server and insert them into a database. Later we @@ -26,7 +28,7 @@ # (... some time later ...) sync_token = load_sync_token_from_database() -my_updated_events = my_calendar.objects_by_sync_token(sync_token, load_objects=True) +my_updated_events = my_calendar.get_objects_by_sync_token(sync_token, load_objects=True) for event in my_updated_events: if event.data is None: delete_event_from_database(event) diff --git a/pyproject.toml b/pyproject.toml index 40a3b6e0..bea5562d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,12 +36,12 @@ license-files = ["COPYING.*"] description = "CalDAV (RFC4791) client library" keywords = [] readme = "README.md" +requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -74,6 +74,7 @@ Changelog = "https://github.com/python-caldav/caldav/blob/master/CHANGELOG.md" test = [ "vobject", "pytest", + "pytest-asyncio", "coverage", "manuel", "proxy.py", @@ -81,6 +82,7 @@ test = [ "xandikos>=0.2.12", "radicale", "pyfakefs", + "httpx", #"caldav_server_tester" "deptry>=0.24.0; python_version >= '3.10'", ] @@ -99,4 +101,56 @@ namespaces = false ignore = ["DEP002"] # Test dependencies (pytest, coverage, etc.) are not imported in main code [tool.deptry.per_rule_ignores] -DEP001 = ["conf"] # Local test configuration file, not a package +DEP001 = ["conf", "h2"] # conf: Local test config, h2: Optional HTTP/2 support + +[tool.ruff] +line-length = 100 +target-version = "py310" + +# Only apply Ruff to new async files added after v2.2.2 +# This allows gradual adoption without reformatting the entire codebase +include = [ + "caldav/aio.py", + "caldav/async_davclient.py", + "tests/test_async_davclient.py", +] + +[tool.ruff.lint] +# Based on icalendar-searcher config +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "UP", # pyupgrade (modernize code) + "B", # flake8-bugbear (find bugs) + "ANN", # type annotations +] +ignore = [ + "E501", # Line too long (formatter handles this) + "ANN401", # Any type (sometimes necessary) +] + +[tool.ruff.format] +# Use Ruff's formatter (Black-compatible) +quote-style = "double" +indent-style = "space" + +[tool.ruff.lint.isort] +known-first-party = ["caldav"] + +[tool.pytest.ini_options] +asyncio_mode = "strict" +filterwarnings = [ + # Treat all warnings as errors by default + "error", + + # Ignore deprecation warnings from external libraries we can't control + # https://github.com/jawah/niquests/pull/327 https://github.com/jawah/niquests/issues/326 + "ignore:.*asyncio.iscoroutinefunction.*:DeprecationWarning:niquests", + + # Radicale upstream bugs: unclosed resources during server shutdown + # https://github.com/Kozea/Radicale/issues/1972 + "ignore:unclosed.*:ResourceWarning", + "ignore:Exception ignored.*:pytest.PytestUnraisableExceptionWarning", +] diff --git a/tests/_test_absolute.py b/tests/_test_absolute.py index df237a74..6d4ba1c5 100644 --- a/tests/_test_absolute.py +++ b/tests/_test_absolute.py @@ -21,7 +21,7 @@ def setup(self): self.calendar = caldav.objects.Calendar(self.client, URL) def test_eventslist(self): - events = self.calendar.events() + events = self.calendar.get_events() assert len(events) == 2 summaries, dtstart = set(), set() @@ -42,5 +42,5 @@ def setup(self): self.calendar = caldav.objects.Calendar(self.client, URL) def test_eventslist(self): - events = self.calendar.events() + events = self.calendar.get_events() assert len(events) == 1 diff --git a/tests/conf.py b/tests/conf.py deleted file mode 100644 index 02c54652..00000000 --- a/tests/conf.py +++ /dev/null @@ -1,683 +0,0 @@ -#!/usr/bin/env python -# -*- encoding: utf-8 -*- -## YOU SHOULD MOST LIKELY NOT EDIT THIS FILE! -## Make a conf_private.py for personal configuration. -## Check conf_private.py.EXAMPLE -## TODO: Future refactoring suggestions (in priority order): -## -## 1. [DONE] Extract conf_private import logic into helper function -## -## 2. Create a DockerTestServer base class to eliminate duplication between -## Baikal, Nextcloud, and Cyrus setup/teardown logic. All three follow -## the same pattern: start.sh/stop.sh scripts, wait for HTTP response, -## similar accessibility checks. -## -## 3. Create a TestServer base class that also covers Radicale and Xandikos -## setup -## -## 4. Split into test_servers/ package structure: -## - test_servers/base.py: Base classes and utilities -## - test_servers/config_loader.py: Configuration import logic -## - test_servers/docker_servers.py: Baikal, Nextcloud, Cyrus -## - test_servers/embedded_servers.py: Radicale, Xandikos -## This would reduce conf.py from 550+ lines to <100 lines. -## -## 5. Consider creating server registry pattern for dynamic server registration -## instead of procedural if-blocks for each server type. -## -## 6. Extract magic numbers into named constants: -## DEFAULT_HTTP_TIMEOUT, MAX_STARTUP_WAIT_SECONDS, etc. -## -## 7. client() method should be removed, davclient.get_davclient should be used -## instead -import logging -import os -import subprocess -import tempfile -import threading -import time -from pathlib import Path -from typing import Any -from typing import List -from typing import Optional - -try: - import niquests as requests -except ImportError: - import requests - -from caldav import compatibility_hints -from caldav.compatibility_hints import FeatureSet -from caldav.davclient import CONNKEYS -from caldav.davclient import DAVClient - -#################################### -# Configuration import utilities -#################################### - - -def _import_from_private( - name: str, default: Any = None, variants: Optional[List[str]] = None -) -> Any: - """ - Import attribute from conf_private.py with fallback variants. - - Tries multiple import paths to handle different ways the test suite - might be invoked (pytest, direct execution, from parent directory, etc.). - - Args: - name: Attribute name to import from conf_private - default: Default value if attribute not found in any variant - variants: List of module paths to try. Defaults to common patterns. - - Returns: - The imported value or the default if not found anywhere. - - Examples: - >>> caldav_servers = _import_from_private('caldav_servers', default=[]) - >>> test_baikal = _import_from_private('test_baikal', default=True) - """ - if variants is None: - variants = ["conf_private", "tests.conf_private", ".conf_private"] - - for variant in variants: - try: - if variant.startswith("."): - # Relative import - use importlib for better compatibility - import importlib - - try: - module = importlib.import_module(variant, package=__package__) - return getattr(module, name) - except (ImportError, AttributeError, TypeError): - # TypeError can occur if __package__ is None - continue - else: - # Absolute import - module = __import__(variant, fromlist=[name]) - return getattr(module, name) - except (ImportError, AttributeError): - continue - - return default - - -#################################### -# Import personal test server config -#################################### - -# Legacy compatibility: only_private → test_public_test_servers -only_private = _import_from_private("only_private") -if only_private is not None: - test_public_test_servers = not only_private -else: - test_public_test_servers = _import_from_private( - "test_public_test_servers", default=False - ) - -# User-configured caldav servers -caldav_servers = _import_from_private("caldav_servers", default=[]) - -# Check if private test servers should be tested -test_private_test_servers = _import_from_private( - "test_private_test_servers", default=True -) -if not test_private_test_servers: - caldav_servers = [] - -# Xandikos configuration -xandikos_host = _import_from_private("xandikos_host", default="localhost") -xandikos_port = _import_from_private("xandikos_port", default=8993) -test_xandikos = _import_from_private("test_xandikos") -if test_xandikos is None: - # Auto-detect if xandikos is installed - try: - import xandikos - - test_xandikos = True - except ImportError: - test_xandikos = False - -# Radicale configuration -radicale_host = _import_from_private("radicale_host", default="localhost") -radicale_port = _import_from_private("radicale_port", default=5232) -test_radicale = _import_from_private("test_radicale") -if test_radicale is None: - # Auto-detect if radicale is installed - try: - import radicale - - test_radicale = True - except ImportError: - test_radicale = False - -# RFC6638 users for scheduling tests -rfc6638_users = _import_from_private("rfc6638_users", default=[]) - -############################# -# Docker-based test servers # -############################# - - -## This pattern is repeated quite often when trying to run docker -def _run_command(cmd_list, return_output=False, timeout=5): - try: - result = subprocess.run( - cmd_list, - capture_output=True, - check=True, - timeout=timeout, - ) - if return_output: - return result.stdout.strip() - return True - except ( - subprocess.CalledProcessError, - FileNotFoundError, - subprocess.TimeoutExpired, - ) as e: - return False - - -def _verify_docker(raise_err: bool = False): - has_docker = _run_command(["docker-compose", "--version"]) and _run_command( - ["docker", "ps"] - ) - if raise_err and not has_docker: - raise RuntimeError( - "docker-compose is not available. Baikal tests require Docker. " - "Please install Docker or skip Baikal tests by setting " - "test_baikal=False in tests/conf_private.py" - ) - return has_docker - - -## We may have different expectations to different servers on how they -## respond before they are ready to receive CalDAV requests and when -## they are still starting up, hence it's needed with different -## functions for each server. -_is_accessible_funcs = {} - - -def _start_or_stop_server(name, action, timeout=60): - lcname = name.lower() - - # Check if server is already accessible (e.g., in GitHub Actions) - if _is_accessible_funcs[lcname](): - print(f"✓ {name} is already running") - return - - ## TODO: generalize this, it doesn't need to be a docker - ## server. We simply run f"{action}.sh" and assume the server comes up/down. - ## If it's not a docker-server, we do not need to verify docker - _verify_docker(raise_err=True) - - # Get the docker-compose directory - dir = Path(__file__).parent / "docker-test-servers" / lcname - - # Check if start.sh/stop.sh exists - script = dir / f"{action}.sh" - if not script.exists(): - raise FileNotFoundError(f"{script} not found in {dir}") - - # Start the server - print(f"Let's {action} {name} from {dir}...") - - # Run start.sh/stop.sh script which handles docker-compose and setup - subprocess.run( - [str(script)], - cwd=dir, - check=True, - capture_output=True, - # env=env - ) - - if action == "stop": - print(f"✓ {name} server stopped and volumes removed") - ## Rest of the logic is irrelevant for stopping - return - - ## This is probably moot, typically already taken care of in start.sh, - ## but let's not rely on that - for attempt in range(0, 60): - if _is_accessible_funcs[lcname](): - print(f"✓ {name} is ready") - return - else: - print(f"... waiting for {name} to become ready") - time.sleep(1) - - raise RuntimeError( - f"{name} is still not accessible after {timeout}s, needs manual investigation. Tried to run {start_script} in directory {dir}" - ) - - -## wrapper -def _conf_method(name, action): - return lambda: _start_or_stop_server(name, action) - - -def _add_conf(name, url, username, password, extra_params={}): - lcname = name.lower() - conn_params = { - "name": name, - "features": lcname, - "url": url, - "username": username, - "password": password, - } - conn_params.update(extra_params) - if _is_accessible_funcs[lcname](): - caldav_servers.append(conn_params) - else: - # Not running, add with setup/teardown to auto-start - caldav_servers.append( - conn_params - | { - "setup": _conf_method(name, "start"), - "teardown": _conf_method(name, "stop"), - } - ) - - -# Baikal configuration -baikal_host = _import_from_private("baikal_host", default="localhost") -baikal_port = _import_from_private("baikal_port", default=8800) -test_baikal = _import_from_private("test_baikal") -if test_baikal is None: - # Auto-enable if BAIKAL_URL is set OR if docker-compose is available - if os.environ.get("BAIKAL_URL") is not None: - test_baikal = True - else: - test_baikal = _verify_docker() - -##################### -# Public test servers -##################### - -## Currently I'm not aware of any publically available test servers, and my -## own attempts on maintaining any has been canned. - -# if test_public_test_servers: -# caldav_servers.append( ... ) - -####################### -# Internal test servers -####################### - -if test_radicale: - import radicale.config - import radicale - import radicale.server - import socket - - def setup_radicale(self): - self.serverdir = tempfile.TemporaryDirectory() - self.serverdir.__enter__() - self.configuration = radicale.config.load("") - self.configuration.update( - { - "storage": {"filesystem_folder": self.serverdir.name}, - "auth": {"type": "none"}, - } - ) - self.server = radicale.server - self.shutdown_socket, self.shutdown_socket_out = socket.socketpair() - self.radicale_thread = threading.Thread( - target=self.server.serve, - args=(self.configuration, self.shutdown_socket_out), - ) - self.radicale_thread.start() - i = 0 - while True: - try: - requests.get(str(self.url)) - break - except: - time.sleep(0.05) - i += 1 - assert i < 100 - - def teardown_radicale(self): - self.shutdown_socket.close() - i = 0 - self.serverdir.__exit__(None, None, None) - - domain = f"{radicale_host}:{radicale_port}" - features = compatibility_hints.radicale.copy() - features["auto-connect.url"]["domain"] = domain - compatibility_hints.radicale_tmp_test = features - caldav_servers.append( - { - "name": "LocalRadicale", - "username": "user1", - "password": "", - "features": "radicale_tmp_test", - "backwards_compatibility_url": f"http://{domain}/user1", - "setup": setup_radicale, - "teardown": teardown_radicale, - } - ) - -## TODO: quite much duplicated code -if test_xandikos: - import asyncio - - import aiohttp - import aiohttp.web - from xandikos.web import XandikosApp, XandikosBackend - - def setup_xandikos(self): - ## TODO: https://github.com/jelmer/xandikos/issues/131#issuecomment-1054805270 suggests a simpler way to launch the xandikos server - - self.serverdir = tempfile.TemporaryDirectory() - self.serverdir.__enter__() - ## Most of the stuff below is cargo-cult-copied from xandikos.web.main - ## Later jelmer created some API that could be used for this - ## Threshold put high due to https://github.com/jelmer/xandikos/issues/235 - ## index_threshold not supported in latest release yet - # self.backend = XandikosBackend(path=self.serverdir.name, index_threshold=0, paranoid=True) - # self.backend = XandikosBackend(path=self.serverdir.name, index_threshold=9999, paranoid=True) - self.backend = XandikosBackend(path=self.serverdir.name) - self.backend._mark_as_principal("/sometestuser/") - self.backend.create_principal("/sometestuser/", create_defaults=True) - mainapp = XandikosApp( - self.backend, current_user_principal="sometestuser", strict=True - ) - - async def xandikos_handler(request): - return await mainapp.aiohttp_handler(request, "/") - - self.xapp = aiohttp.web.Application() - self.xapp.router.add_route("*", "/{path_info:.*}", xandikos_handler) - ## https://stackoverflow.com/questions/51610074/how-to-run-an-aiohttp-server-in-a-thread - self.xapp_loop = asyncio.new_event_loop() - self.xapp_runner = aiohttp.web.AppRunner(self.xapp) - asyncio.set_event_loop(self.xapp_loop) - self.xapp_loop.run_until_complete(self.xapp_runner.setup()) - self.xapp_site = aiohttp.web.TCPSite( - self.xapp_runner, host=xandikos_host, port=xandikos_port - ) - self.xapp_loop.run_until_complete(self.xapp_site.start()) - - def aiohttp_server(): - self.xapp_loop.run_forever() - - self.xandikos_thread = threading.Thread(target=aiohttp_server) - self.xandikos_thread.start() - - def teardown_xandikos(self): - if not test_xandikos: - return - self.xapp_loop.stop() - - ## ... but the thread may be stuck waiting for a request ... - def silly_request(): - try: - requests.get(str(self.url)) - except: - pass - - threading.Thread(target=silly_request).start() - i = 0 - while self.xapp_loop.is_running(): - time.sleep(0.05) - i += 1 - assert i < 100 - self.xapp_loop.run_until_complete(self.xapp_runner.cleanup()) - i = 0 - while self.xandikos_thread.is_alive(): - time.sleep(0.05) - i += 1 - assert i < 100 - - self.serverdir.__exit__(None, None, None) - - if xandikos.__version__ == (0, 2, 12): - features = compatibility_hints.xandikos_v0_2_12.copy() - else: - features = compatibility_hints.xandikos_v0_3.copy() - domain = f"{xandikos_host}:{xandikos_port}" - features["auto-connect.url"]["domain"] = domain - caldav_servers.append( - { - "name": "LocalXandikos", - "backwards_compatibility_url": f"http://{domain}/sometestuser", - "features": features, - "setup": setup_xandikos, - "teardown": teardown_xandikos, - } - ) - -## Baikal - Docker container with automated setup -if test_baikal: - baikal_base_url = os.environ.get( - "BAIKAL_URL", f"http://{baikal_host}:{baikal_port}" - ) - # Ensure the URL includes /dav.php/ for CalDAV endpoint - if not baikal_base_url.endswith("/dav.php") and not baikal_base_url.endswith( - "/dav.php/" - ): - baikal_url = f"{baikal_base_url}/dav.php" - else: - baikal_url = baikal_base_url.rstrip("/") - - baikal_username = os.environ.get("BAIKAL_USERNAME", "testuser") - baikal_password = os.environ.get("BAIKAL_PASSWORD", "testpass") - - def is_baikal_accessible() -> bool: - """Check if Baikal server is accessible.""" - try: - # Check the dav.php endpoint - response = requests.get(f"{baikal_url}/", timeout=5) - return response.status_code in (200, 401, 403, 404) - except Exception: - return False - - _is_accessible_funcs["baikal"] = is_baikal_accessible - _add_conf("Baikal", baikal_url, baikal_username, baikal_password) - -## Nextcloud - Docker container with automated setup -# Nextcloud configuration -nextcloud_host = _import_from_private("nextcloud_host", default="localhost") -nextcloud_port = _import_from_private("nextcloud_port", default=8801) -test_nextcloud = _import_from_private("test_nextcloud") -if test_nextcloud is None: - # Auto-enable if NEXTCLOUD_URL is set OR if docker-compose is available - if os.environ.get("NEXTCLOUD_URL") is not None: - test_nextcloud = True - else: - test_nextcloud = _verify_docker() - -if test_nextcloud: - nextcloud_base_url = os.environ.get( - "NEXTCLOUD_URL", f"http://{nextcloud_host}:{nextcloud_port}" - ) - # Ensure the URL includes /remote.php/dav/ for CalDAV endpoint - if not nextcloud_base_url.endswith( - "/remote.php/dav" - ) and not nextcloud_base_url.endswith("/remote.php/dav/"): - nextcloud_url = f"{nextcloud_base_url}/remote.php/dav" - else: - nextcloud_url = nextcloud_base_url.rstrip("/") - - nextcloud_username = os.environ.get("NEXTCLOUD_USERNAME", "testuser") - nextcloud_password = os.environ.get("NEXTCLOUD_PASSWORD", "TestPassword123!") - - def is_nextcloud_accessible() -> bool: - """Check if Nextcloud server is accessible.""" - try: - # Check the dav endpoint - response = requests.get(f"{nextcloud_url}/", timeout=5) - return response.status_code in (200, 401, 403, 404, 207) - except Exception: - return False - - _is_accessible_funcs["nextcloud"] = is_nextcloud_accessible - _add_conf("Nextcloud", nextcloud_url, nextcloud_username, nextcloud_password) - -## Cyrus IMAP - Docker container with CalDAV/CardDAV support -# Cyrus configuration -cyrus_host = _import_from_private("cyrus_host", default="localhost") -cyrus_port = _import_from_private("cyrus_port", default=8802) -test_cyrus = _import_from_private("test_cyrus") -if test_cyrus is None: - # Auto-enable if CYRUS_URL is set OR if docker-compose is available - if os.environ.get("CYRUS_URL") is not None: - test_cyrus = True - else: - test_cyrus = _verify_docker() - -if test_cyrus: - cyrus_base_url = os.environ.get("CYRUS_URL", f"http://{cyrus_host}:{cyrus_port}") - # Cyrus CalDAV path includes the username - # Use user1 (pre-created user in Cyrus docker test server) - cyrus_username = os.environ.get("CYRUS_USERNAME", "user1") - cyrus_password = os.environ.get("CYRUS_PASSWORD", "any-password-seems-to-work") - cyrus_url = f"{cyrus_base_url}/dav/calendars/user/{cyrus_username}" - - def is_cyrus_accessible() -> bool: - """Check if Cyrus CalDAV server is accessible and working.""" - try: - # Test actual CalDAV access, not just HTTP server - response = requests.request( - "PROPFIND", - f"{cyrus_url}/", - auth=(cyrus_username, cyrus_password), - headers={"Depth": "0"}, - timeout=5, - ) - # 207 Multi-Status means CalDAV is working - # 404 with multistatus also means server is responding but user might not exist yet - return response.status_code in (200, 207) - except Exception: - return False - - _is_accessible_funcs["cyrus"] = is_cyrus_accessible - - _add_conf("Cyrus", cyrus_url, cyrus_username, cyrus_password) - -## SOGo - Docker container with PostgreSQL backend -# SOGo configuration -sogo_host = _import_from_private("sogo_host", default="localhost") -sogo_port = _import_from_private("sogo_port", default=8803) -test_sogo = _import_from_private("test_sogo") -if test_sogo is None: - # Auto-enable if SOGO_URL is set OR if docker-compose is available - if os.environ.get("SOGO_URL") is not None: - test_sogo = True - else: - test_sogo = _verify_docker() - -if test_sogo: - sogo_base_url = os.environ.get("SOGO_URL", f"http://{sogo_host}:{sogo_port}") - # SOGo CalDAV path includes the username - sogo_username = os.environ.get("SOGO_USERNAME", "testuser") - sogo_password = os.environ.get("SOGO_PASSWORD", "testpass") - sogo_url = f"{sogo_base_url}/SOGo/dav/{sogo_username}/Calendar/" - - def is_sogo_accessible() -> bool: - """Check if SOGo CalDAV server is accessible and working.""" - try: - # Test actual CalDAV access, not just HTTP server - response = requests.request( - "PROPFIND", - f"{sogo_url}", - auth=(sogo_username, sogo_password), - headers={"Depth": "0"}, - timeout=5, - ) - # 207 Multi-Status means CalDAV is working - return response.status_code in (200, 207) - except Exception: - return False - - _is_accessible_funcs["sogo"] = is_sogo_accessible - - _add_conf("SOGo", sogo_url, sogo_username, sogo_password) - -## Bedework - Docker container with JBoss -# Bedework configuration -bedework_host = _import_from_private("bedework_host", default="localhost") -bedework_port = _import_from_private("bedework_port", default=8804) -test_bedework = _import_from_private("test_bedework") -if test_bedework is None: - # Auto-enable if BEDEWORK_URL is set OR if docker-compose is available - if os.environ.get("BEDEWORK_URL") is not None: - test_bedework = True - else: - test_bedework = _verify_docker() - -if test_bedework: - bedework_base_url = os.environ.get( - "BEDEWORK_URL", f"http://{bedework_host}:{bedework_port}" - ) - # Bedework CalDAV path includes the username - bedework_username = os.environ.get("BEDEWORK_USERNAME", "vbede") - bedework_password = os.environ.get("BEDEWORK_PASSWORD", "bedework") - bedework_url = f"{bedework_base_url}/ucaldav/user/{bedework_username}/" - - def is_bedework_accessible() -> bool: - """Check if Bedework CalDAV server is accessible and working.""" - try: - # Test actual CalDAV access, not just HTTP server - response = requests.request( - "PROPFIND", - f"{bedework_url}", - auth=(bedework_username, bedework_password), - headers={"Depth": "0"}, - timeout=5, - ) - # 207 Multi-Status means CalDAV is working - return response.status_code in (200, 207) - except Exception: - return False - - _is_accessible_funcs["bedework"] = is_bedework_accessible - - _add_conf("Bedework", bedework_url, bedework_username, bedework_password) - - -################################################################### -# Convenience - get a DAVClient object from the caldav_servers list -################################################################### -def client( - idx=None, name=None, setup=lambda conn: None, teardown=lambda conn: None, **kwargs -): - kwargs_ = kwargs.copy() - no_args = not any(x for x in kwargs if kwargs[x] is not None) - if idx is None and name is None and no_args and caldav_servers: - ## No parameters given - find the first server in caldav_servers list - return client(idx=0) - elif idx is not None and no_args and caldav_servers: - return client(**caldav_servers[idx]) - elif name is not None and no_args and caldav_servers: - for s in caldav_servers: - if s["name"] == name: - return client(**s) - return None - elif no_args: - return None - for bad_param in ( - "incompatibilities", - "backwards_compatibility_url", - "principal_url", - "enable", - ): - if bad_param in kwargs_: - kwargs_.pop(bad_param) - for kw in list(kwargs_.keys()): - if not kw in CONNKEYS: - logging.critical( - "unknown keyword %s in connection parameters. All compatibility flags should now be sent as a separate list, see conf_private.py.EXAMPLE. Ignoring." - % kw - ) - kwargs_.pop(kw) - conn = DAVClient(**kwargs_) - conn.setup = setup - conn.teardown = teardown - conn.server_name = name - return conn - - -caldav_servers = [x for x in caldav_servers if x.get("enable", True)] diff --git a/tests/conf_baikal.py b/tests/conf_baikal.py deleted file mode 100644 index 82f5adf0..00000000 --- a/tests/conf_baikal.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -Configuration for running tests against Baikal CalDAV server in Docker. - -This module provides configuration for testing against the Baikal CalDAV -server running in a Docker container. It can be used both locally (via -docker-compose) and in CI/CD pipelines (GitHub Actions). - -Usage: - Local testing: - docker-compose up -d - export BAIKAL_URL=http://localhost:8800 - pytest - - CI testing: - The GitHub Actions workflow automatically sets up the Baikal service - and exports the BAIKAL_URL environment variable. -""" -import os - -from caldav import compatibility_hints - -# Get Baikal URL from environment, default to local docker-compose setup -BAIKAL_URL = os.environ.get("BAIKAL_URL", "http://localhost:8800") - -# Baikal default credentials (these need to be configured after first start) -# Note: Baikal requires initial setup through the web interface -# For CI, you may need to pre-configure or use API/config file approach -BAIKAL_USERNAME = os.environ.get("BAIKAL_USERNAME", "testuser") -BAIKAL_PASSWORD = os.environ.get("BAIKAL_PASSWORD", "testpass") - -# Configuration for Baikal server -baikal_config = { - "name": "BaikalDocker", - "url": BAIKAL_URL, - "username": BAIKAL_USERNAME, - "password": BAIKAL_PASSWORD, - "features": compatibility_hints.baikal - if hasattr(compatibility_hints, "baikal") - else {}, -} - - -def is_baikal_available() -> bool: - """ - Check if Baikal server is available and configured. - - Returns: - bool: True if Baikal is running and accessible, False otherwise. - """ - try: - import requests - - response = requests.get(BAIKAL_URL, timeout=5) - return response.status_code in (200, 401, 403) # Server is responding - except Exception: - return False - - -def get_baikal_config(): - """ - Get Baikal configuration if the server is available. - - Returns: - dict or None: Configuration dict if available, None otherwise. - """ - if is_baikal_available(): - return baikal_config - return None diff --git a/tests/conf_private.py.EXAMPLE b/tests/conf_private.py.EXAMPLE deleted file mode 100644 index b6e774b3..00000000 --- a/tests/conf_private.py.EXAMPLE +++ /dev/null @@ -1,85 +0,0 @@ -from caldav import compatibility_hints - -## PRIVATE CALDAV SERVER(S) TO RUN TESTS TOWARDS -## Make a list of your own servers/accounts that you'd like to run the -## test towards. Running the test suite towards a personal account -## should generally be safe, it should not mess up with content there -## and it should clean up after itself, but don't sue me if anything -## goes wrong ... - -## Define your primary caldav server here -caldav_servers = [ - { - ## A friendly identifiter for the server. Should be a CamelCase name - ## Not needed, but may be nice if you have several servers to test towards. - ## Should not affect test runs in any other way than improved verbosity. - 'name': 'MyExampleServer', - - ## Set enable to False if you don't want to use a server - 'enable': True, - - ## This is all that is really needed - url, username and - ## password. (the URL may even include username and password) - 'url': 'https://some.server.example.com', - 'username': 'testuser', - 'password': 'hunter2', - ## skip ssl cert verification, for self-signed certificates - ## (sort of moot nowadays with letsencrypt freely available) - #'ssl_cert_verify': False - - ## incompatibilities is a list of flags that can be set for - ## skipping (parts) of certain tests. See - ## compatibility_hints.py for premade lists - #'features': compatibility_hints.nextcloud - 'features': [], - - ## You may even add setup and teardown methods to set up - ## and rig down the calendar server - #setup = lambda self: ... - #teardown = lambda self: ... - } -] - - -## SOGo virtual test server -## I did roughly those steps to set up a SOGo test server: -## 1) I download the ZEG - "Zero Effort Groupware" - from https://sourceforge.net/projects/sogo-zeg/ -## 2) I installed virtualbox on my laptop -## 3) "virtualbox ~/Downloads/ZEG-5.0.0.ova" (TODO: probably it's possible to launch it "headless"?) -## 4) I clicked on some buttons to get the file "imported" and started -## 5) I went to "tools" -> "preferences" -> "network" and created a NatNetwork -## 6) I think I went to ZEG -> Settings -> Network and chose "Host-only Adapter" -## 7) SOGo was then available at http://192.168.56.101/ from my laptop -## 8) I added the lines below to my conf_private.py -#caldav_servers.append({ -# 'url': 'http://192.168.56.101/SOGo/dav/', -# 'username': 'sogo1'. -# 'password': 'sogo' -#}) -#for i in (1, 2, 3): -# sogo = caldav_servers[-1].copy() -# sogo['username'] = 'sogo%i' % i -# rfc6638_users.append(sogo) - -## MASTER SWITCHES FOR TEST SERVER SETUP -## With those configuration switches, pre-configured test servers in conf.py -## can be turned on or off - -## test_public_test_servers - Use the list of common public test -## servers from conf.py. As of 2020-10 no public test servers exists, so this option -## is currently moot :-( -test_public_test_servers = False - -## test_private_test_servers - test using the list configured above in this file. -test_private_test_servers = True - -## test_xandikos and test_radicale ... since the xandikos and radicale caldav server implementation is -## written in python and can be instantiated quite easily, those will -## be the default caldav implementation to test towards. -test_xandikos = True -test_radicale = True - -## For usage by ../examples/scheduling_examples.py. Should typically -## be three different users on the same caldav server. -## (beware of dragons - there is some half-done work in the caldav_test that is likely to break if this is set) -#rfc6638_users = [ caldav_servers[0], caldav_servers[1], caldav_servers[2] ] diff --git a/tests/docker-test-servers/baikal/docker-compose.yml b/tests/docker-test-servers/baikal/docker-compose.yml index ec1774cc..26ef24c4 100644 --- a/tests/docker-test-servers/baikal/docker-compose.yml +++ b/tests/docker-test-servers/baikal/docker-compose.yml @@ -8,3 +8,7 @@ services: - "8800:80" environment: - BAIKAL_SERVERNAME=localhost + tmpfs: + # Make the container truly ephemeral - data is lost on restart + - /var/www/baikal/Specific:size=100m + - /var/www/baikal/config:size=10m diff --git a/tests/docker-test-servers/baikal/start.sh b/tests/docker-test-servers/baikal/start.sh index c2b6ddd7..9a7d019e 100755 --- a/tests/docker-test-servers/baikal/start.sh +++ b/tests/docker-test-servers/baikal/start.sh @@ -10,20 +10,25 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" -echo "Creating container (not started yet)..." -docker-compose up --no-start +echo "Creating and starting container..." +docker-compose up -d -echo "Copying pre-configured files into container..." -docker cp Specific/. baikal-test:/var/www/baikal/Specific/ -docker cp config/. baikal-test:/var/www/baikal/config/ +echo "Waiting for container to be fully started..." +sleep 2 -echo "Starting Baikal (entrypoint will fix permissions)..." -docker start baikal-test +echo "Copying pre-configured files into container (after tmpfs mounts are active)..." +# Use tar to preserve directory structure and permissions when copying to tmpfs +tar -C Specific -c . | docker exec -i baikal-test tar -C /var/www/baikal/Specific -x +tar -C config -c . | docker exec -i baikal-test tar -C /var/www/baikal/config -x + +echo "Fixing permissions..." +docker exec baikal-test chown -R nginx:nginx /var/www/baikal/Specific /var/www/baikal/config +docker exec baikal-test chmod -R 770 /var/www/baikal/Specific echo "" echo "Waiting for Baikal to be ready..." sleep 5 -timeout 60 bash -c 'until curl -f http://localhost:8800/dav.php/ 2>/dev/null; do echo -n "."; sleep 2; done' || { +timeout 60 bash -c 'until curl -s -o /dev/null -w "%{http_code}" http://localhost:8800/dav.php/ | grep -q "^[234]"; do echo -n "."; sleep 2; done' || { echo "" echo "Error: Baikal did not become ready in time" echo "Check logs with: docker-compose logs baikal" diff --git a/tests/docker-test-servers/davical/README.md b/tests/docker-test-servers/davical/README.md new file mode 100644 index 00000000..5be00f93 --- /dev/null +++ b/tests/docker-test-servers/davical/README.md @@ -0,0 +1,65 @@ +# DAViCal Test Server + +DAViCal is a CalDAV server that uses PostgreSQL as its backend. This Docker configuration provides a complete DAViCal server for testing. + +## Quick Start + +```bash +cd tests/docker-test-servers/davical +docker-compose up -d +``` + +Wait about 30 seconds for the database to initialize, then the server will be available. + +## Configuration + +- **URL**: http://localhost:8805/davical/caldav.php +- **Admin User**: admin +- **Admin Password**: testpass (set via DAVICAL_ADMIN_PASS) + +## Creating Test Users + +After the server starts, you can create test users via the admin interface: + +1. Navigate to http://localhost:8805/davical/admin.php +2. Login with admin / testpass +3. Create a new user (e.g., testuser / testpass) + +Alternatively, the container may pre-create a test user depending on the image configuration. + +## CalDAV Endpoints + +- **Principal URL**: `http://localhost:8805/davical/caldav.php/{username}/` +- **Calendar Home**: `http://localhost:8805/davical/caldav.php/{username}/calendar/` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DAVICAL_HOST` | localhost | Server hostname | +| `DAVICAL_PORT` | 8805 | HTTP port | +| `DAVICAL_USERNAME` | admin | Test username | +| `DAVICAL_PASSWORD` | testpass | Test password | + +## Docker Image + +This configuration uses the [tuxnvape/davical-standalone](https://hub.docker.com/r/tuxnvape/davical-standalone) Docker image, which provides a complete DAViCal installation with PostgreSQL. + +## Troubleshooting + +### Container won't start +Check if port 8805 is already in use: +```bash +lsof -i :8805 +``` + +### Database initialization +The first startup may take 30+ seconds while PostgreSQL initializes. Check logs: +```bash +docker-compose logs -f +``` + +### Testing connectivity +```bash +curl -u admin:testpass http://localhost:8805/davical/caldav.php/admin/ +``` diff --git a/tests/docker-test-servers/davical/docker-compose.yml b/tests/docker-test-servers/davical/docker-compose.yml new file mode 100644 index 00000000..50a009e6 --- /dev/null +++ b/tests/docker-test-servers/davical/docker-compose.yml @@ -0,0 +1,18 @@ +services: + davical: + image: tuxnvape/davical-standalone:latest + container_name: davical-test + ports: + - "8805:80" + environment: + - POSTGRES_PASSWORD=davical + - DAVICAL_ADMIN_PASS=testpass + tmpfs: + # Make the container ephemeral - data is lost on restart + - /var/lib/postgresql/data:size=500m + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/davical/"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s diff --git a/tests/docker-test-servers/nextcloud/README.md b/tests/docker-test-servers/nextcloud/README.md index aad2ed84..023a16aa 100644 --- a/tests/docker-test-servers/nextcloud/README.md +++ b/tests/docker-test-servers/nextcloud/README.md @@ -100,6 +100,24 @@ docker-compose down -v ./start.sh ``` +## Known Issues + +### Repeated Compatibility Tests Against Same Container + +**Issue:** Running the `testCheckCompatibility` test repeatedly against the same Nextcloud container will eventually fail with 500 errors due to database unique constraint violations. + +**Root Cause:** The compatibility tests create test objects with fixed UIDs (e.g., `csc_simple_event1`, `csc_alarm_test_event`). On the first run, these are created successfully. On subsequent runs against the same container, the test tries to create these objects again, violating SQLite unique constraints. + +**Workaround:** Restart the container between test runs to get a fresh database: +```bash +cd tests/docker-test-servers/nextcloud +./stop.sh && ./start.sh +``` + +**Note:** The tmpfs storage is ephemeral between container restarts (data is lost on stop/start), but persists during a single container's lifetime. This is the expected behavior for efficient testing - most tests work fine with a persistent container, and only the compatibility tests require a fresh container. + +**TODO:** This should be fixed in the caldav-server-tester project by having the PrepareCalendar check properly handle existing test objects or by cleaning up test data before creating new objects. + ## Docker Compose Commands ```bash @@ -135,7 +153,7 @@ The Nextcloud testing framework consists of: - First startup takes longer (~1 minute) as Nextcloud initializes - Uses SQLite for simplicity (production should use MySQL/PostgreSQL) -- Data is persisted in a Docker volume between restarts +- Data is stored in tmpfs (ephemeral storage) - lost on container restart but persists during container lifetime - Container runs on port 8801 (to avoid conflicts with Baikal on 8800) ## Version diff --git a/tests/docker-test-servers/nextcloud/docker-compose.yml b/tests/docker-test-servers/nextcloud/docker-compose.yml index 773912c4..b02b4bf2 100644 --- a/tests/docker-test-servers/nextcloud/docker-compose.yml +++ b/tests/docker-test-servers/nextcloud/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: nextcloud: image: nextcloud:latest @@ -11,3 +9,38 @@ services: - NEXTCLOUD_ADMIN_USER=admin - NEXTCLOUD_ADMIN_PASSWORD=admin - NEXTCLOUD_TRUSTED_DOMAINS=localhost + # Custom entrypoint: copy files with proper ownership, run installation, start apache + # The standard entrypoint copies as root causing permission issues on tmpfs + entrypoint: > + /bin/bash -c ' + set -e + + # Ensure base directory has correct ownership (for tmpfs mount) + chown www-data:www-data /var/www/html + chmod 755 /var/www/html + + # Copy nextcloud files with proper ownership + rsync -rlD --delete --chown=www-data:www-data /usr/src/nextcloud/ /var/www/html/ + + # Explicitly set config directory permissions after rsync + mkdir -p /var/www/html/config + chown -R www-data:www-data /var/www/html/config + chmod 770 /var/www/html/config + + # Run installation as www-data if not already installed + if [ ! -f /var/www/html/config/config.php ] || ! grep -q "installed.*=>.*true" /var/www/html/config/config.php 2>/dev/null; then + echo "Running Nextcloud installation..." + su -s /bin/bash www-data -c "php /var/www/html/occ maintenance:install \ + --database=sqlite \ + --admin-user=admin \ + --admin-pass=admin" + # Set trusted domains + su -s /bin/bash www-data -c "php /var/www/html/occ config:system:set trusted_domains 0 --value=localhost" + fi + + # Start apache + exec apache2-foreground + ' + tmpfs: + # Make the container truly ephemeral - data is lost on restart + - /var/www/html:size=2g diff --git a/tests/docker-test-servers/nextcloud/setup_nextcloud.sh b/tests/docker-test-servers/nextcloud/setup_nextcloud.sh index 335e755d..dc30579e 100755 --- a/tests/docker-test-servers/nextcloud/setup_nextcloud.sh +++ b/tests/docker-test-servers/nextcloud/setup_nextcloud.sh @@ -6,7 +6,7 @@ set -e CONTAINER_NAME="nextcloud-test" TEST_USER="testuser" -TEST_PASSWORD="TestPassword123!" +TEST_PASSWORD="testpass" echo "Waiting for Nextcloud to be ready..." max_attempts=60 diff --git a/tests/docker-test-servers/sogo/README.md b/tests/docker-test-servers/sogo/README.md index 2990fb94..0940b933 100644 --- a/tests/docker-test-servers/sogo/README.md +++ b/tests/docker-test-servers/sogo/README.md @@ -168,7 +168,7 @@ This setup uses the `pmietlicki/sogo:latest` Docker image. There is no official ## More Information - [SOGo Website](https://www.sogo.nu/) -- [SOGo Documentation](https://www.sogo.nu/support/documentation.html) +- [SOGo Documentation](https://www.sogo.nu/support.html) - [Docker Image Used](https://hub.docker.com/r/pmietlicki/sogo) - [Docker Image Source](https://github.com/pmietlicki/docker-sogo) - [SOGo GitHub (Official)](https://github.com/Alinto/sogo) diff --git a/tests/docker-test-servers/sogo/docker-compose.yml b/tests/docker-test-servers/sogo/docker-compose.yml index 05bfcd68..45f24b4d 100644 --- a/tests/docker-test-servers/sogo/docker-compose.yml +++ b/tests/docker-test-servers/sogo/docker-compose.yml @@ -13,8 +13,10 @@ services: db: condition: service_healthy volumes: - - sogo-data:/srv - ./sogo.conf:/etc/sogo/sogo.conf:ro + tmpfs: + # Make the container truly ephemeral - data is lost on restart + - /srv:size=500m healthcheck: test: ["CMD", "curl", "-f", "http://localhost/SOGo/"] interval: 10s @@ -32,12 +34,12 @@ services: - MYSQL_ROOT_PASSWORD=sogo volumes: - ./init-sogo-users.sql:/docker-entrypoint-initdb.d/init-sogo-users.sql:ro + tmpfs: + # Make the database truly ephemeral - data is lost on restart + - /var/lib/mysql:size=500m healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 5s timeout: 5s retries: 20 start_period: 10s - -volumes: - sogo-data: diff --git a/tests/fixture_helpers.py b/tests/fixture_helpers.py new file mode 100644 index 00000000..42a73c27 --- /dev/null +++ b/tests/fixture_helpers.py @@ -0,0 +1,107 @@ +""" +Shared test fixture helpers for both sync and async tests. + +This module provides common logic for setting up test calendars, +ensuring consistent behavior and safeguards across sync and async tests. +""" +import inspect +from typing import Any +from typing import Optional + + +async def _maybe_await(result: Any) -> Any: + """Await if result is awaitable, otherwise return as-is.""" + if inspect.isawaitable(result): + return await result + return result + + +async def get_or_create_test_calendar( + client: Any, + principal: Any, + calendar_name: str = "pythoncaldav-test", + cal_id: Optional[str] = None, +) -> tuple[Any, bool]: + """ + Get or create a test calendar, with fallback to existing calendars. + + This implements the same logic as the sync _fixCalendar_ method, + providing safeguards against accidentally overwriting user data. + + Args: + client: The DAV client (sync or async) + principal: The principal object (or None to skip principal-based creation) + calendar_name: Name for the test calendar + cal_id: Optional calendar ID + + Returns: + Tuple of (calendar, was_created) where was_created indicates if + we created the calendar (and should clean it up) or are using + an existing one. + """ + from caldav.lib import error + + calendar = None + created = False + + # Check if server supports calendar creation via features + supports_create = True + if hasattr(client, "features") and client.features: + supports_create = client.features.is_supported("create-calendar") + + if supports_create and principal is not None: + # Try to create a new calendar + try: + calendar = await _maybe_await( + principal.make_calendar(name=calendar_name, cal_id=cal_id) + ) + created = True + except (error.MkcalendarError, error.AuthorizationError, error.NotFoundError): + # Creation failed - fall back to finding existing calendar + pass + + if calendar is None: + # Fall back to finding an existing calendar + calendars = None + + if principal is not None: + try: + calendars = await _maybe_await(principal.get_calendars()) + except (error.NotFoundError, error.AuthorizationError): + pass + + if calendars: + # Look for a dedicated test calendar first + for c in calendars: + try: + props = await _maybe_await(c.get_properties([])) + display_name = props.get("{DAV:}displayname", "") + if "pythoncaldav-test" in str(display_name): + calendar = c + break + except Exception: + pass + + # Fall back to first calendar + if calendar is None: + calendar = calendars[0] + + return calendar, created + + +async def cleanup_calendar_objects(calendar: Any) -> None: + """ + Remove all objects from a calendar (for test isolation). + + Args: + calendar: The calendar to clean up + """ + try: + objects = await _maybe_await(calendar.search()) + for obj in objects: + try: + await _maybe_await(obj.delete()) + except Exception: + pass + except Exception: + pass diff --git a/tests/test_async_davclient.py b/tests/test_async_davclient.py new file mode 100644 index 00000000..6d7b0cb7 --- /dev/null +++ b/tests/test_async_davclient.py @@ -0,0 +1,839 @@ +#!/usr/bin/env python +""" +Unit tests for async_davclient module. + +Rule: None of the tests in this file should initiate any internet +communication. We use Mock/MagicMock to emulate server communication. +""" +import os +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from caldav.async_davclient import AsyncDAVClient +from caldav.async_davclient import AsyncDAVResponse +from caldav.async_davclient import get_davclient +from caldav.lib import error + +# Sample XML responses for testing +SAMPLE_MULTISTATUS_XML = b""" + + + /calendars/user/calendar/ + + + My Calendar + + HTTP/1.1 200 OK + + + +""" + +SAMPLE_PROPFIND_XML = b""" + + + /dav/ + + + + /dav/principals/user/ + + + HTTP/1.1 200 OK + + + +""" + +SAMPLE_OPTIONS_HEADERS = { + "DAV": "1, 2, calendar-access", + "Allow": "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, REPORT", +} + + +def create_mock_response( + content: bytes = b"", + status_code: int = 200, + reason: str = "OK", + headers: dict = None, +) -> MagicMock: + """Create a mock HTTP response.""" + resp = MagicMock() + resp.content = content + resp.status_code = status_code + resp.reason = reason + resp.reason_phrase = reason # httpx uses reason_phrase + resp.headers = headers or {} + resp.text = content.decode("utf-8") if content else "" + return resp + + +class TestAsyncDAVResponse: + """Tests for AsyncDAVResponse class.""" + + def test_response_with_xml_content(self) -> None: + """Test parsing XML response.""" + resp = create_mock_response( + content=SAMPLE_MULTISTATUS_XML, + status_code=207, + reason="Multi-Status", + headers={"Content-Type": "text/xml; charset=utf-8"}, + ) + + dav_response = AsyncDAVResponse(resp) + + assert dav_response.status == 207 + assert dav_response.reason == "Multi-Status" + assert dav_response.tree is not None + assert dav_response.tree.tag.endswith("multistatus") + + def test_response_with_empty_content(self) -> None: + """Test response with no content.""" + resp = create_mock_response( + content=b"", + status_code=204, + reason="No Content", + headers={"Content-Length": "0"}, + ) + + dav_response = AsyncDAVResponse(resp) + + assert dav_response.status == 204 + assert dav_response.tree is None + assert dav_response._raw == "" + + def test_response_with_non_xml_content(self) -> None: + """Test response with non-XML content.""" + resp = create_mock_response( + content=b"Plain text response", + status_code=200, + headers={"Content-Type": "text/plain"}, + ) + + dav_response = AsyncDAVResponse(resp) + + assert dav_response.status == 200 + assert dav_response.tree is None + assert b"Plain text response" in dav_response._raw + + def test_response_raw_property(self) -> None: + """Test raw property returns string.""" + resp = create_mock_response(content=b"test content") + + dav_response = AsyncDAVResponse(resp) + + assert isinstance(dav_response.raw, str) + assert "test content" in dav_response.raw + + def test_response_crlf_normalization(self) -> None: + """Test that CRLF is normalized to LF.""" + resp = create_mock_response(content=b"line1\r\nline2\r\nline3") + + dav_response = AsyncDAVResponse(resp) + + assert b"\r\n" not in dav_response._raw + assert b"\n" in dav_response._raw + + +class TestAsyncDAVClient: + """Tests for AsyncDAVClient class.""" + + def test_client_initialization(self) -> None: + """Test basic client initialization.""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + assert client.url.scheme == "https" + assert "caldav.example.com" in str(client.url) + assert "User-Agent" in client.headers + assert "caldav-async" in client.headers["User-Agent"] + + def test_client_with_credentials(self) -> None: + """Test client initialization with username/password.""" + client = AsyncDAVClient( + url="https://caldav.example.com/dav/", + username="testuser", + password="testpass", + ) + + assert client.username == "testuser" + assert client.password == "testpass" + + def test_client_with_auth_in_url(self) -> None: + """Test extracting credentials from URL.""" + client = AsyncDAVClient(url="https://user:pass@caldav.example.com/dav/") + + assert client.username == "user" + assert client.password == "pass" + + def test_client_with_proxy(self) -> None: + """Test client with proxy configuration.""" + client = AsyncDAVClient( + url="https://caldav.example.com/dav/", + proxy="proxy.example.com:8080", + ) + + assert client.proxy == "http://proxy.example.com:8080" + + def test_client_with_ssl_verify(self) -> None: + """Test SSL verification settings.""" + client = AsyncDAVClient( + url="https://caldav.example.com/dav/", + ssl_verify_cert=False, + ) + + assert client.ssl_verify_cert is False + + def test_client_with_custom_headers(self) -> None: + """Test client with custom headers.""" + custom_headers = {"X-Custom-Header": "test-value"} + client = AsyncDAVClient( + url="https://caldav.example.com/dav/", + headers=custom_headers, + ) + + assert "X-Custom-Header" in client.headers + assert client.headers["X-Custom-Header"] == "test-value" + assert "User-Agent" in client.headers # Default headers still present + + def test_build_method_headers(self) -> None: + """Test _build_method_headers helper.""" + # Test with depth + headers = AsyncDAVClient._build_method_headers("PROPFIND", depth=1) + assert headers["Depth"] == "1" + + # Test REPORT method adds Content-Type + headers = AsyncDAVClient._build_method_headers("REPORT", depth=0) + assert "Content-Type" in headers + assert "application/xml" in headers["Content-Type"] + + # Test with extra headers + extra = {"X-Test": "value"} + headers = AsyncDAVClient._build_method_headers( + "PROPFIND", depth=0, extra_headers=extra + ) + assert headers["X-Test"] == "value" + assert headers["Depth"] == "0" + + @pytest.mark.asyncio + async def test_context_manager(self) -> None: + """Test async context manager protocol.""" + async with AsyncDAVClient(url="https://caldav.example.com/dav/") as client: + assert client is not None + assert hasattr(client, "session") + + # After exit, session should be closed (we can't easily verify this without mocking) + + @pytest.mark.asyncio + async def test_close(self) -> None: + """Test close method.""" + from caldav.async_davclient import _USE_HTTPX + + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + client.session = AsyncMock() + # httpx uses aclose(), niquests uses close() + client.session.aclose = AsyncMock() + client.session.close = AsyncMock() + + await client.close() + + if _USE_HTTPX: + client.session.aclose.assert_called_once() + else: + client.session.close.assert_called_once() + + @pytest.mark.asyncio + async def test_request_method(self) -> None: + """Test request method.""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + # Mock the session.request method + mock_response = create_mock_response( + content=SAMPLE_MULTISTATUS_XML, + status_code=207, + headers={"Content-Type": "text/xml"}, + ) + + client.session.request = AsyncMock(return_value=mock_response) + + response = await client.request("/test/path", "GET") + + assert isinstance(response, AsyncDAVResponse) + assert response.status == 207 + client.session.request.assert_called_once() + + @pytest.mark.asyncio + async def test_propfind_method(self) -> None: + """Test propfind method.""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + mock_response = create_mock_response( + content=SAMPLE_PROPFIND_XML, + status_code=207, + headers={"Content-Type": "text/xml"}, + ) + + client.session.request = AsyncMock(return_value=mock_response) + + # Test with default URL + response = await client.propfind(body="", depth=1) + + assert response.status == 207 + call_args = client.session.request.call_args + # httpx uses kwargs for method and headers + assert call_args.kwargs["method"] == "PROPFIND" + assert "Depth" in call_args.kwargs["headers"] + assert call_args.kwargs["headers"]["Depth"] == "1" + + @pytest.mark.asyncio + async def test_propfind_with_custom_url(self) -> None: + """Test propfind with custom URL.""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + mock_response = create_mock_response( + content=SAMPLE_PROPFIND_XML, + status_code=207, + headers={"Content-Type": "text/xml"}, + ) + + client.session.request = AsyncMock(return_value=mock_response) + + response = await client.propfind( + url="https://caldav.example.com/dav/calendars/", + body="", + depth=0, + ) + + assert response.status == 207 + call_args = client.session.request.call_args + # httpx uses kwargs for url + assert "calendars" in call_args.kwargs["url"] + + @pytest.mark.asyncio + async def test_report_method(self) -> None: + """Test report method.""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + mock_response = create_mock_response( + content=SAMPLE_MULTISTATUS_XML, + status_code=207, + headers={"Content-Type": "text/xml"}, + ) + + client.session.request = AsyncMock(return_value=mock_response) + + response = await client.report(body="", depth=0) + + assert response.status == 207 + call_args = client.session.request.call_args + assert call_args.kwargs["method"] == "REPORT" + assert "Content-Type" in call_args.kwargs["headers"] + assert "application/xml" in call_args.kwargs["headers"]["Content-Type"] + + @pytest.mark.asyncio + async def test_options_method(self) -> None: + """Test options method.""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + mock_response = create_mock_response( + content=b"", + status_code=200, + headers=SAMPLE_OPTIONS_HEADERS, + ) + + client.session.request = AsyncMock(return_value=mock_response) + + response = await client.options() + + assert response.status == 200 + assert "DAV" in response.headers + call_args = client.session.request.call_args + assert call_args.kwargs["method"] == "OPTIONS" + + @pytest.mark.asyncio + async def test_proppatch_method(self) -> None: + """Test proppatch method (requires URL).""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + mock_response = create_mock_response(status_code=207) + client.session.request = AsyncMock(return_value=mock_response) + + response = await client.proppatch( + url="https://caldav.example.com/dav/calendar/", + body="", + ) + + assert response.status == 207 + call_args = client.session.request.call_args + assert call_args.kwargs["method"] == "PROPPATCH" + + @pytest.mark.asyncio + async def test_put_method(self) -> None: + """Test put method (requires URL).""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + mock_response = create_mock_response(status_code=201, reason="Created") + client.session.request = AsyncMock(return_value=mock_response) + + response = await client.put( + url="https://caldav.example.com/dav/calendar/event.ics", + body="BEGIN:VCALENDAR...", + ) + + assert response.status == 201 + call_args = client.session.request.call_args + assert call_args.kwargs["method"] == "PUT" + + @pytest.mark.asyncio + async def test_delete_method(self) -> None: + """Test delete method (requires URL).""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + mock_response = create_mock_response(status_code=204, reason="No Content") + client.session.request = AsyncMock(return_value=mock_response) + + response = await client.delete( + url="https://caldav.example.com/dav/calendar/event.ics" + ) + + assert response.status == 204 + call_args = client.session.request.call_args + assert call_args.kwargs["method"] == "DELETE" + + @pytest.mark.asyncio + async def test_post_method(self) -> None: + """Test post method (requires URL).""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + mock_response = create_mock_response(status_code=200) + client.session.request = AsyncMock(return_value=mock_response) + + response = await client.post( + url="https://caldav.example.com/dav/outbox/", + body="", + ) + + assert response.status == 200 + call_args = client.session.request.call_args + assert call_args.kwargs["method"] == "POST" + + @pytest.mark.asyncio + async def test_mkcol_method(self) -> None: + """Test mkcol method (requires URL).""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + mock_response = create_mock_response(status_code=201) + client.session.request = AsyncMock(return_value=mock_response) + + response = await client.mkcol( + url="https://caldav.example.com/dav/newcollection/" + ) + + assert response.status == 201 + call_args = client.session.request.call_args + assert call_args.kwargs["method"] == "MKCOL" + + @pytest.mark.asyncio + async def test_mkcalendar_method(self) -> None: + """Test mkcalendar method (requires URL).""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + mock_response = create_mock_response(status_code=201) + client.session.request = AsyncMock(return_value=mock_response) + + response = await client.mkcalendar( + url="https://caldav.example.com/dav/newcalendar/", + body="", + ) + + assert response.status == 201 + call_args = client.session.request.call_args + assert call_args.kwargs["method"] == "MKCALENDAR" + + def test_extract_auth_types(self) -> None: + """Test extracting auth types from WWW-Authenticate header.""" + client = AsyncDAVClient(url="https://caldav.example.com/dav/") + + # Single auth type + auth_types = client.extract_auth_types('Basic realm="Test"') + assert "basic" in auth_types + + # Multiple auth types + auth_types = client.extract_auth_types( + 'Basic realm="Test", Digest realm="Test"' + ) + assert "basic" in auth_types + assert "digest" in auth_types + + def test_build_auth_object_basic(self) -> None: + """Test building Basic auth object.""" + client = AsyncDAVClient( + url="https://caldav.example.com/dav/", + username="user", + password="pass", + ) + + client.build_auth_object(["basic"]) + + assert client.auth is not None + # Can't easily test the auth object type without importing HTTPBasicAuth + + def test_build_auth_object_digest(self) -> None: + """Test building Digest auth object.""" + client = AsyncDAVClient( + url="https://caldav.example.com/dav/", + username="user", + password="pass", + ) + + client.build_auth_object(["digest"]) + + assert client.auth is not None + + def test_build_auth_object_bearer(self) -> None: + """Test building Bearer auth object.""" + client = AsyncDAVClient( + url="https://caldav.example.com/dav/", + password="bearer-token", + ) + + client.build_auth_object(["bearer"]) + + assert client.auth is not None + + def test_build_auth_object_preference(self) -> None: + """Test auth type preference (digest > basic > bearer).""" + client = AsyncDAVClient( + url="https://caldav.example.com/dav/", + username="user", + password="pass", + ) + + # Should prefer digest + client.build_auth_object(["basic", "digest", "bearer"]) + # Can't easily verify which was chosen without inspecting auth object type + + def test_build_auth_object_with_explicit_type(self) -> None: + """Test building auth with explicit auth_type.""" + client = AsyncDAVClient( + url="https://caldav.example.com/dav/", + username="user", + password="pass", + auth_type="basic", + ) + + # build_auth_object should have been called in __init__ + assert client.auth is not None + + +class TestGetDAVClient: + """Tests for get_davclient factory function.""" + + @pytest.mark.asyncio + async def test_get_davclient_basic(self) -> None: + """Test basic get_davclient usage.""" + with patch.object(AsyncDAVClient, "options") as mock_options: + mock_response = create_mock_response( + status_code=200, + headers=SAMPLE_OPTIONS_HEADERS, + ) + mock_response_obj = AsyncDAVResponse(mock_response) + mock_options.return_value = mock_response_obj + + client = await get_davclient( + url="https://caldav.example.com/dav/", + username="user", + password="pass", + ) + + assert client is not None + assert isinstance(client, AsyncDAVClient) + mock_options.assert_called_once() + + @pytest.mark.asyncio + async def test_get_davclient_without_probe(self) -> None: + """Test get_davclient with probe disabled.""" + client = await get_davclient( + url="https://caldav.example.com/dav/", + username="user", + password="pass", + probe=False, + ) + + assert client is not None + assert isinstance(client, AsyncDAVClient) + + @pytest.mark.asyncio + async def test_get_davclient_env_vars(self) -> None: + """Test get_davclient with environment variables.""" + with patch.dict( + os.environ, + { + "CALDAV_URL": "https://env.example.com/dav/", + "CALDAV_USERNAME": "envuser", + "CALDAV_PASSWORD": "envpass", + }, + ): + client = await get_davclient(probe=False) + + assert "env.example.com" in str(client.url) + assert client.username == "envuser" + assert client.password == "envpass" + + @pytest.mark.asyncio + async def test_get_davclient_params_override_env(self) -> None: + """Test that explicit params override environment variables.""" + with patch.dict( + os.environ, + { + "CALDAV_URL": "https://env.example.com/dav/", + "CALDAV_USERNAME": "envuser", + "CALDAV_PASSWORD": "envpass", + }, + ): + client = await get_davclient( + url="https://param.example.com/dav/", + username="paramuser", + password="parampass", + probe=False, + ) + + assert "param.example.com" in str(client.url) + assert client.username == "paramuser" + assert client.password == "parampass" + + @pytest.mark.asyncio + async def test_get_davclient_missing_url(self) -> None: + """Test that get_davclient raises error without URL.""" + # Clear any env vars that might be set + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="No configuration found"): + await get_davclient(username="user", password="pass", probe=False) + + @pytest.mark.asyncio + async def test_get_davclient_probe_failure(self) -> None: + """Test get_davclient when probe fails.""" + with patch.object(AsyncDAVClient, "options") as mock_options: + mock_options.side_effect = Exception("Connection failed") + + with pytest.raises(error.DAVError, match="Failed to connect"): + await get_davclient( + url="https://caldav.example.com/dav/", + username="user", + password="pass", + probe=True, + ) + + @pytest.mark.asyncio + async def test_get_davclient_additional_kwargs(self) -> None: + """Test passing additional kwargs to AsyncDAVClient.""" + client = await get_davclient( + url="https://caldav.example.com/dav/", + username="user", + password="pass", + probe=False, + timeout=30, + ssl_verify_cert=False, + ) + + assert client.timeout == 30 + assert client.ssl_verify_cert is False + + +class TestAPIImprovements: + """Tests verifying that API improvements were applied.""" + + @pytest.mark.asyncio + async def test_no_dummy_parameters(self) -> None: + """Verify dummy parameters are not present in async API.""" + import inspect + + # Check proppatch signature + sig = inspect.signature(AsyncDAVClient.proppatch) + assert "dummy" not in sig.parameters + + # Check mkcol signature + sig = inspect.signature(AsyncDAVClient.mkcol) + assert "dummy" not in sig.parameters + + # Check mkcalendar signature + sig = inspect.signature(AsyncDAVClient.mkcalendar) + assert "dummy" not in sig.parameters + + @pytest.mark.asyncio + async def test_standardized_body_parameter(self) -> None: + """Verify methods have appropriate parameters. + + propfind has both 'body' (legacy) and 'props' (new protocol-based). + report uses 'body' for raw XML. + """ + import inspect + + # Check propfind has both body (legacy) and props (new) + sig = inspect.signature(AsyncDAVClient.propfind) + assert "body" in sig.parameters # Legacy parameter + assert "props" in sig.parameters # New protocol-based parameter + + # Check report uses 'body', not 'query' + sig = inspect.signature(AsyncDAVClient.report) + assert "body" in sig.parameters + assert "query" not in sig.parameters + + @pytest.mark.asyncio + async def test_all_methods_have_headers_parameter(self) -> None: + """Verify all HTTP methods accept headers parameter.""" + import inspect + + methods = [ + "propfind", + "report", + "options", + "proppatch", + "mkcol", + "mkcalendar", + "put", + "post", + "delete", + ] + + for method_name in methods: + method = getattr(AsyncDAVClient, method_name) + sig = inspect.signature(method) + assert ( + "headers" in sig.parameters + ), f"{method_name} missing headers parameter" + + @pytest.mark.asyncio + async def test_url_requirements_split(self) -> None: + """Verify URL parameter requirements are split correctly.""" + import inspect + + # Query methods - URL should be Optional + query_methods = ["propfind", "report", "options"] + for method_name in query_methods: + method = getattr(AsyncDAVClient, method_name) + sig = inspect.signature(method) + url_param = sig.parameters["url"] + # Check default is None or has default + assert ( + url_param.default is None + or url_param.default != inspect.Parameter.empty + ) + + # Resource methods - URL should be required (no default) + resource_methods = ["proppatch", "mkcol", "mkcalendar", "put", "post", "delete"] + for method_name in resource_methods: + method = getattr(AsyncDAVClient, method_name) + sig = inspect.signature(method) + url_param = sig.parameters["url"] + # URL should not have None as annotation type (should be str, not Optional[str]) + # This is a simplified check - in reality we'd need to inspect annotations more carefully + + +class TestTypeHints: + """Tests verifying type hints are present.""" + + def test_client_has_return_type_annotations(self) -> None: + """Verify methods have return type annotations.""" + import inspect + + methods = [ + "propfind", + "report", + "options", + "proppatch", + "put", + "delete", + ] + + for method_name in methods: + method = getattr(AsyncDAVClient, method_name) + sig = inspect.signature(method) + assert ( + sig.return_annotation != inspect.Signature.empty + ), f"{method_name} missing return type annotation" + + def test_get_davclient_has_return_type(self) -> None: + """Verify get_davclient has return type annotation.""" + import inspect + + sig = inspect.signature(get_davclient) + assert sig.return_annotation != inspect.Signature.empty + + +class TestAsyncCalendarObjectResource: + """Tests for AsyncCalendarObjectResource class.""" + + def test_has_component_method_exists(self) -> None: + """ + Test that AsyncCalendarObjectResource has the has_component() method. + + This test catches a bug where AsyncCalendarObjectResource was missing + the has_component() method that's used in AsyncCalendar.search() to + filter out empty search results (a Google quirk). + + See async_collection.py:779 which calls: + objects = [o for o in objects if o.has_component()] + """ + from caldav.aio import ( + AsyncCalendarObjectResource, + AsyncEvent, + AsyncJournal, + AsyncTodo, + ) + + # Verify has_component exists on all async calendar object classes + for cls in [AsyncCalendarObjectResource, AsyncEvent, AsyncTodo, AsyncJournal]: + assert hasattr( + cls, "has_component" + ), f"{cls.__name__} missing has_component method" + + def test_has_component_with_data(self) -> None: + """Test has_component returns True when object has VEVENT/VTODO/VJOURNAL.""" + from caldav.aio import AsyncEvent + + event_data = """BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:test@example.com +DTSTART:20200101T100000Z +DTEND:20200101T110000Z +SUMMARY:Test Event +END:VEVENT +END:VCALENDAR""" + + event = AsyncEvent(client=None, data=event_data) + assert event.has_component() is True + + def test_has_component_without_data(self) -> None: + """Test has_component returns False when object has no data.""" + from caldav.aio import AsyncCalendarObjectResource + + obj = AsyncCalendarObjectResource(client=None, data=None) + assert obj.has_component() is False + + def test_has_component_with_empty_data(self) -> None: + """Test has_component returns False when object has no data. + + Note: The sync CalendarObjectResource validates data on assignment, + so we use data=None instead of data="" to test the "no data" case. + """ + from caldav.aio import AsyncCalendarObjectResource + + obj = AsyncCalendarObjectResource(client=None, data=None) + assert obj.has_component() is False + + def test_has_component_with_only_vcalendar(self) -> None: + """Test has_component returns False when only VCALENDAR wrapper exists.""" + from caldav.aio import AsyncCalendarObjectResource + + # Only VCALENDAR wrapper, no actual component + data = """BEGIN:VCALENDAR +VERSION:2.0 +END:VCALENDAR""" + + obj = AsyncCalendarObjectResource(client=None, data=data) + # This should return False since there's no VEVENT/VTODO/VJOURNAL + assert obj.has_component() is False diff --git a/tests/test_async_integration.py b/tests/test_async_integration.py new file mode 100644 index 00000000..8168bffe --- /dev/null +++ b/tests/test_async_integration.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python +""" +Functional integration tests for the async API. + +These tests verify that the async API works correctly with real CalDAV servers. +They run against all available servers (Radicale, Xandikos, Docker servers) +using the same dynamic class generation pattern as the sync tests. +""" +import asyncio +from datetime import datetime +from functools import wraps +from typing import Any + +import pytest +import pytest_asyncio + +from .test_servers import get_available_servers +from .test_servers import TestServer + + +def _async_delay_decorator(f, t=20): + """ + Async decorator that adds a delay before calling the wrapped coroutine. + + This is needed for servers like Bedework that have a search cache that + isn't immediately updated when objects are created/modified. + """ + + @wraps(f) + async def wrapper(*args, **kwargs): + await asyncio.sleep(t) + return await f(*args, **kwargs) + + return wrapper + + +# Test data +ev1 = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:async-test-event-001@example.com +DTSTAMP:20060712T182145Z +DTSTART:20060714T170000Z +DTEND:20060715T040000Z +SUMMARY:Async Test Event +END:VEVENT +END:VCALENDAR""" + +ev2 = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:async-test-event-002@example.com +DTSTAMP:20060712T182145Z +DTSTART:20060715T170000Z +DTEND:20060716T040000Z +SUMMARY:Second Async Test Event +END:VEVENT +END:VCALENDAR""" + +todo1 = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:async-test-todo-001@example.com +DTSTAMP:20060712T182145Z +SUMMARY:Async Test Todo +STATUS:NEEDS-ACTION +END:VTODO +END:VCALENDAR""" + +todo2 = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:async-test-todo-002@example.com +DTSTAMP:20060712T182145Z +SUMMARY:Completed Async Todo +STATUS:COMPLETED +END:VTODO +END:VCALENDAR""" + + +async def add_event(calendar: Any, data: str) -> Any: + """Helper to add an event to a calendar.""" + from caldav.aio import AsyncEvent + + event = AsyncEvent(parent=calendar, data=data) + await event.save() + return event + + +async def add_todo(calendar: Any, data: str) -> Any: + """Helper to add a todo to a calendar.""" + from caldav.aio import AsyncTodo + + todo = AsyncTodo(parent=calendar, data=data) + await todo.save() + return todo + + +class AsyncFunctionalTestsBaseClass: + """ + Base class for async functional tests. + + This class contains test methods that will be run against each + configured test server. Subclasses are dynamically generated + for each server (similar to the sync test pattern). + """ + + # Server configuration - set by dynamic class generation + server: TestServer + + @pytest.fixture(scope="class") + def test_server(self) -> TestServer: + """Get the test server for this class.""" + server = self.server + server.start() + yield server + # Stop the server to free the port for other test modules + server.stop() + + @pytest_asyncio.fixture + async def async_client(self, test_server: TestServer, monkeypatch: Any) -> Any: + """Create an async client connected to the test server.""" + from caldav.aio import AsyncCalendar + + client = await test_server.get_async_client() + + # Apply search-cache delay if needed (similar to sync tests) + # Use monkeypatch so it's automatically reverted after the test + # (AsyncCalendar is an alias for Calendar, so we must restore it) + search_cache_config = client.features.is_supported("search-cache", dict) + if search_cache_config.get("behaviour") == "delay": + delay = search_cache_config.get("delay", 1.5) + monkeypatch.setattr( + AsyncCalendar, + "search", + _async_delay_decorator(AsyncCalendar.search, t=delay), + ) + + yield client + await client.close() + + @pytest_asyncio.fixture + async def async_principal(self, async_client: Any) -> Any: + """Get the principal for the async client.""" + from caldav.aio import AsyncPrincipal + from caldav.lib.error import NotFoundError + + try: + # Try standard principal discovery + principal = await AsyncPrincipal.create(async_client) + except NotFoundError: + # Some servers (like Radicale with no auth) don't support + # principal discovery. Fall back to using the client URL directly. + principal = AsyncPrincipal(client=async_client, url=async_client.url) + return principal + + @pytest_asyncio.fixture + async def async_calendar(self, async_client: Any) -> Any: + """Create a test calendar or use an existing one if creation not supported.""" + from caldav.aio import AsyncPrincipal + from caldav.lib.error import AuthorizationError, NotFoundError + + from .fixture_helpers import get_or_create_test_calendar + + calendar_name = f"async-test-{datetime.now().strftime('%Y%m%d%H%M%S%f')}" + + # Try to get principal for calendar operations + principal = None + try: + principal = await AsyncPrincipal.create(async_client) + except (NotFoundError, AuthorizationError): + pass + + # Use shared helper for calendar setup + calendar, created = await get_or_create_test_calendar( + async_client, principal, calendar_name=calendar_name + ) + + if calendar is None: + pytest.skip("Could not create or find a calendar for testing") + + yield calendar + + # Only cleanup if we created the calendar + if created: + try: + await calendar.delete() + except Exception: + pass + + # ==================== Test Methods ==================== + + @pytest.mark.asyncio + async def test_principal_calendars(self, async_client: Any) -> None: + """Test getting calendars from calendar home.""" + from caldav.aio import AsyncCalendarSet + + # Use calendar set at client URL to get calendars + # This bypasses principal discovery which some servers don't support + calendar_home = AsyncCalendarSet(client=async_client, url=async_client.url) + calendars = await calendar_home.get_calendars() + assert isinstance(calendars, list) + + @pytest.mark.asyncio + async def test_principal_make_calendar(self, async_client: Any) -> None: + """Test creating and deleting a calendar.""" + from caldav.aio import AsyncCalendarSet, AsyncPrincipal + from caldav.lib.error import AuthorizationError, MkcalendarError, NotFoundError + + calendar_name = ( + f"async-principal-test-{datetime.now().strftime('%Y%m%d%H%M%S%f')}" + ) + calendar = None + + # Try principal-based calendar creation first (works for Baikal, Xandikos) + try: + principal = await AsyncPrincipal.create(async_client) + calendar = await principal.make_calendar(name=calendar_name) + except (NotFoundError, AuthorizationError, MkcalendarError): + # Fall back to direct calendar creation (works for Radicale) + pass + + if calendar is None: + # Try creating calendar at client URL + try: + calendar_home = AsyncCalendarSet( + client=async_client, url=async_client.url + ) + calendar = await calendar_home.make_calendar(name=calendar_name) + except MkcalendarError: + pytest.skip("Server does not support MKCALENDAR") + + assert calendar is not None + assert calendar.url is not None + + # Clean up + await calendar.delete() + + @pytest.mark.asyncio + async def test_search_events(self, async_calendar: Any) -> None: + """Test searching for events.""" + from caldav.aio import AsyncEvent + + # Add test events + await add_event(async_calendar, ev1) + await add_event(async_calendar, ev2) + + # Search for all events + events = await async_calendar.search(event=True) + + assert len(events) >= 2 + assert all(isinstance(e, AsyncEvent) for e in events) + + @pytest.mark.asyncio + async def test_search_events_by_date_range(self, async_calendar: Any) -> None: + """Test searching for events in a date range.""" + # Add test event + await add_event(async_calendar, ev1) + + # Search for events in the date range + events = await async_calendar.search( + event=True, + start=datetime(2006, 7, 14), + end=datetime(2006, 7, 16), + ) + + assert len(events) >= 1 + assert "Async Test Event" in events[0].data + + @pytest.mark.asyncio + async def test_search_todos_pending(self, async_calendar: Any) -> None: + """Test searching for pending todos.""" + from caldav.aio import AsyncTodo + + # Add pending and completed todos + await add_todo(async_calendar, todo1) + await add_todo(async_calendar, todo2) + + # Search for pending todos only (default) + todos = await async_calendar.search(todo=True, include_completed=False) + + # Should only get the pending todo + assert len(todos) >= 1 + assert all(isinstance(t, AsyncTodo) for t in todos) + assert any("NEEDS-ACTION" in t.data for t in todos) + + @pytest.mark.asyncio + async def test_search_todos_all(self, async_calendar: Any) -> None: + """Test searching for all todos including completed.""" + # Add pending and completed todos + await add_todo(async_calendar, todo1) + await add_todo(async_calendar, todo2) + + # Search for all todos + todos = await async_calendar.search(todo=True, include_completed=True) + + # Should get both todos + assert len(todos) >= 2 + + @pytest.mark.asyncio + async def test_events_method(self, async_calendar: Any) -> None: + """Test the events() convenience method.""" + from caldav.aio import AsyncEvent + + # Add test events + await add_event(async_calendar, ev1) + await add_event(async_calendar, ev2) + + # Get all events + events = await async_calendar.get_events() + + assert len(events) >= 2 + assert all(isinstance(e, AsyncEvent) for e in events) + + @pytest.mark.asyncio + async def test_todos_method(self, async_calendar: Any) -> None: + """Test the todos() convenience method.""" + from caldav.aio import AsyncTodo + + # Add test todos + await add_todo(async_calendar, todo1) + + # Get all pending todos + todos = await async_calendar.get_todos() + + assert len(todos) >= 1 + assert all(isinstance(t, AsyncTodo) for t in todos) + + +# ==================== Dynamic Test Class Generation ==================== +# +# Create a test class for each available server, similar to how +# test_caldav.py works for sync tests. + +_generated_classes: dict[str, type] = {} + +for _server in get_available_servers(): + _classname = f"TestAsyncFor{_server.name.replace(' ', '')}" + + # Skip if we already have a class with this name + if _classname in _generated_classes: + continue + + # Create a new test class for this server + _test_class = type( + _classname, + (AsyncFunctionalTestsBaseClass,), + {"server": _server}, + ) + + # Add to module namespace so pytest discovers it + vars()[_classname] = _test_class + _generated_classes[_classname] = _test_class diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 2042329c..6bbb6fe8 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -32,22 +32,36 @@ import vobject from proxy.http.proxy import HttpProxyBasePlugin -from .conf import caldav_servers -from .conf import client -from .conf import radicale_host -from .conf import radicale_port -from .conf import rfc6638_users -from .conf import test_radicale -from .conf import test_xandikos -from .conf import xandikos_host -from .conf import xandikos_port +from .test_servers import get_registry +from .test_servers.config_loader import load_test_server_config +from caldav import get_davclient + +# Get server configuration from the test_servers framework +_registry = get_registry() +caldav_servers = _registry.get_caldav_servers_list() + +# Check which embedded servers are available +_radicale_server = _registry.get("Radicale") +_xandikos_server = _registry.get("Xandikos") + +test_radicale = _radicale_server is not None +test_xandikos = _xandikos_server is not None + +radicale_host = _radicale_server.host if _radicale_server else "localhost" +radicale_port = _radicale_server.port if _radicale_server else 5232 +xandikos_host = _xandikos_server.host if _xandikos_server else "localhost" +xandikos_port = _xandikos_server.port if _xandikos_server else 8993 + +# RFC6638 users for scheduling tests - loaded from config file +_config = load_test_server_config() +rfc6638_users = _config.get("rfc6638_users", []) +from caldav.davclient import CONNKEYS from caldav.compatibility_hints import FeatureSet from caldav.compatibility_hints import ( incompatibility_description, ) ## TEMP - should be removed in the future from caldav.davclient import DAVClient from caldav.davclient import DAVResponse -from caldav.davclient import get_davclient from caldav.elements import cdav from caldav.elements import dav from caldav.elements import ical @@ -68,6 +82,60 @@ log = logging.getLogger("caldav") +def _make_client( + idx=None, name=None, setup=lambda conn: None, teardown=lambda conn: None, **kwargs +): + """ + Create a DAVClient from server parameters. + + This is a test helper that creates a DAVClient instance from either: + - An index into caldav_servers list + - A server name to look up in caldav_servers + - Direct connection parameters + + It filters out non-connection parameters and attaches setup/teardown + callbacks to the client. + """ + kwargs_ = kwargs.copy() + no_args = not any(x for x in kwargs if kwargs[x] is not None) + + if idx is None and name is None and no_args and caldav_servers: + return _make_client(idx=0) + elif idx is not None and no_args and caldav_servers: + return _make_client(**caldav_servers[idx]) + elif name is not None and no_args and caldav_servers: + for s in caldav_servers: + if s["name"] == name: + return _make_client(**s) + return None + elif no_args: + return None + + # Filter out non-connection parameters + for bad_param in ( + "incompatibilities", + "backwards_compatibility_url", + "principal_url", + "enable", + ): + kwargs_.pop(bad_param, None) + + for kw in list(kwargs_.keys()): + if kw not in CONNKEYS: + log.debug(f"Ignoring non-connection parameter: {kw}") + kwargs_.pop(kw) + + conn = DAVClient(**kwargs_) + conn.setup = setup + conn.teardown = teardown + conn.server_name = name + return conn + + +# Alias for backward compatibility within tests +client = _make_client + + ev1 = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN @@ -470,36 +538,59 @@ class TestGetDAVClient: """ def testTestConfig(self): - with get_davclient( - testconfig=True, environment=False, name=-1, check_config_file=False - ) as conn: - assert conn.principal() + """Test that get_davclient(testconfig=True) finds config with testing_allowed.""" + # Start a test server using test_servers framework + server_params = caldav_servers[-1] + with client(**server_params) as conn: + # Create a config file with testing_allowed: true + config = {"testing_allowed": True} + for key in ("username", "password", "proxy"): + if key in server_params: + config[f"caldav_{key}"] = server_params[key] + config["caldav_url"] = str(conn.url) + + with tempfile.NamedTemporaryFile( + delete=True, encoding="utf-8", mode="w", suffix=".json" + ) as tmp: + json.dump({"test_server": config}, tmp) + tmp.flush() + os.fsync(tmp.fileno()) + # Test that get_davclient finds it with testconfig=True + with get_davclient( + config_file=tmp.name, testconfig=True, environment=False + ) as conn2: + assert conn2.principal() def testEnvironment(self): - os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] = "1" - with get_davclient( - environment=True, check_config_file=False, name="-1" - ) as conn: - assert conn.principal() - del os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] + """Test that get_davclient() reads from environment variables.""" + # Start a test server using test_servers framework + server_params = caldav_servers[-1] + with client(**server_params) as conn: + # Set environment variables (only if value is not None) for key in ("username", "password", "proxy"): - if key in caldav_servers[-1]: - os.environ[f"CALDAV_{key.upper()}"] = caldav_servers[-1][key] + if key in server_params and server_params[key] is not None: + os.environ[f"CALDAV_{key.upper()}"] = server_params[key] os.environ["CALDAV_URL"] = str(conn.url) - with get_davclient( - testconfig=False, environment=True, check_config_file=False - ) as conn2: - assert conn2.principal() + try: + # Test that get_davclient finds it via environment + with get_davclient( + testconfig=False, environment=True, check_config_file=False + ) as conn2: + assert conn2.principal() + finally: + # Clean up environment variables + for key in ("URL", "USERNAME", "PASSWORD", "PROXY"): + os.environ.pop(f"CALDAV_{key}", None) def testConfigfile(self): - ## start up a server - with get_davclient( - testconfig=True, environment=False, name=-1, check_config_file=False - ) as conn: + """Test that get_davclient() reads from config file.""" + # Start a test server using test_servers framework + server_params = caldav_servers[-1] + with client(**server_params) as conn: config = {} for key in ("username", "password", "proxy"): - if key in caldav_servers[-1]: - config[f"caldav_{key}"] = caldav_servers[-1][key] + if key in server_params: + config[f"caldav_{key}"] = server_params[key] config["caldav_url"] = str(conn.url) with tempfile.NamedTemporaryFile( @@ -619,7 +710,7 @@ def testInviteAndRespond(self): organizers_calendar.save_with_invites( sched, [self.principals[0], self.principals[1].get_vcal_address()] ) - assert len(organizers_calendar.events()) == 1 + assert len(organizers_calendar.get_events()) == 1 ## no new inbox items expected for principals[0] for item in self.principals[0].schedule_inbox().get_items(): @@ -781,9 +872,16 @@ def teardown_method(self): logging.debug("############################") self._cleanup("post") logging.debug("############## test teardown_method almost done") - self.caldav.teardown(self.caldav) + try: + self.caldav.teardown() + except TypeError: + self.caldav.teardown(self.caldav) + # Close the client to release resources (event loop, connections) + self.caldav.__exit__(None, None, None) def _cleanup(self, mode=None): + if self.cleanup_regime == "none": + return ## no cleanup for ephemeral servers if self.cleanup_regime in ("pre", "post") and self.cleanup_regime != mode: return if not self.is_supported("save-load"): @@ -803,7 +901,7 @@ def _cleanup(self, mode=None): for cal in self.calendars_used: for uid in uids_used: try: - obj = self._fixCalendar().object_by_uid(uid) + obj = self._fixCalendar().get_object_by_uid(uid) obj.delete() except error.NotFoundError: pass @@ -858,7 +956,7 @@ def _fixCalendar_(self, **kwargs): """ if not self.is_supported("create-calendar"): if not self._default_calendar: - calendars = self.principal.calendars() + calendars = self.principal.get_calendars() for c in calendars: if ( "pythoncaldav-test" @@ -901,7 +999,7 @@ def _fixCalendar_(self, **kwargs): def testCheckCompatibility(self, request) -> None: try: from caldav_server_tester import ServerQuirkChecker - except: + except ImportError: pytest.skip("caldav_server_tester is not installed") # Use pdb debug mode if pytest was run with --pdb, otherwise use logging @@ -968,7 +1066,7 @@ def testFindCalendarOwner(self): def testIssue397(self): self.skip_unless_support("search.text.by-uid") cal = self._fixCalendar() - cal.save_event( + cal.add_event( """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PeterB//caldav//en_DK @@ -994,7 +1092,7 @@ def testIssue397(self): """ ) - object_by_id = cal.object_by_uid("test1", comp_class=Event) + object_by_id = cal.get_object_by_uid("test1", comp_class=Event) instance = object_by_id.icalendar_instance events = [ event @@ -1002,7 +1100,7 @@ def testIssue397(self): if isinstance(event, icalendar.Event) ] assert len(events) == 2 - object_by_id = cal.object_by_uid("test1", comp_class=None) + object_by_id = cal.get_object_by_uid("test1", comp_class=None) instance = object_by_id.icalendar_instance events = [ event @@ -1046,7 +1144,7 @@ def testGetCalendarHomeSet(self): def testGetDefaultCalendar(self): self.skip_unless_support("get-current-user-principal.has-calendar") - assert len(self.principal.calendars()) != 0 + assert len(self.principal.get_calendars()) != 0 def testSearchShouldYieldData(self): """ @@ -1056,9 +1154,9 @@ def testSearchShouldYieldData(self): if self.is_supported("save-load.event"): ## populate the calendar with an event or two or three - c.save_event(ev1) - c.save_event(ev2) - c.save_event(ev3) + c.add_event(ev1) + c.add_event(ev2) + c.add_event(ev3) objects = c.search(event=True) ## This will break if served a read-only calendar without any events assert objects @@ -1069,7 +1167,7 @@ def testGetCalendar(self): # Create calendar c = self._fixCalendar() assert c.url is not None - assert len(self.principal.calendars()) != 0 + assert len(self.principal.get_calendars()) != 0 str_ = str(c) repr_ = repr(c) @@ -1091,7 +1189,7 @@ def _notFound(self): return error.NotFoundError def testPrincipal(self): - collections = self.principal.calendars() + collections = self.principal.get_calendars() if "principal_url" in self.server_params: assert self.principal.url == self.server_params["principal_url"] for c in collections: @@ -1115,28 +1213,31 @@ def testPrincipals(self): def testCreateDeleteCalendar(self): self.skip_unless_support("create-calendar") self.skip_unless_support("delete-calendar") - if not self.check_compatibility_flag( - "unique_calendar_ids" - ) and self.cleanup_regime in ("light", "pre"): - self._teardownCalendar(cal_id=self.testcal_id) + # Always try to delete the calendar first in case a previous test run + # was interrupted and left the calendar behind + self._teardownCalendar(cal_id=self.testcal_id) c = self.principal.make_calendar(name="Yep", cal_id=self.testcal_id) assert c.url is not None - events = c.events() + events = c.get_events() assert len(events) == 0 - events = self.principal.calendar(name="Yep", cal_id=self.testcal_id).events() + events = self.principal.calendar( + name="Yep", cal_id=self.testcal_id + ).get_events() assert len(events) == 0 c.delete() if self.is_supported("create-calendar.auto"): with pytest.raises(self._notFound()): - self.principal.calendar(name="Yapp", cal_id="shouldnotexist").events() + self.principal.calendar( + name="Yapp", cal_id="shouldnotexist" + ).get_events() def testChangeAttendeeStatusWithEmailGiven(self): self.skip_unless_support("save-load.event") c = self._fixCalendar() - event = c.save_event( + event = c.add_event( uid="test1", dtstart=datetime(2015, 10, 10, 8, 7, 6), dtend=datetime(2015, 10, 10, 9, 7, 6), @@ -1147,21 +1248,21 @@ def testChangeAttendeeStatusWithEmailGiven(self): ) event.save() self.skip_unless_support("search.text.by-uid") - event = c.event_by_uid("test1") + event = c.get_event_by_uid("test1") ## TODO: work in progress ... see https://github.com/python-caldav/caldav/issues/399 def testMultiGet(self): self.skip_unless_support("save-load.event") c = self._fixCalendar() - event1 = c.save_event( + event1 = c.add_event( uid="test1", dtstart=datetime(2015, 10, 10, 8, 7, 6), dtend=datetime(2015, 10, 10, 9, 7, 6), summary="test1", ) - event2 = c.save_event( + event2 = c.add_event( uid="test2", dtstart=datetime(2015, 10, 10, 8, 7, 6), dtend=datetime(2015, 10, 10, 9, 7, 6), @@ -1180,7 +1281,7 @@ def testCreateEvent(self): self.skip_unless_support("save-load.event") c = self._fixCalendar() - existing_events = c.events() + existing_events = c.get_events() existing_urls = {x.url for x in existing_events} cleanse = lambda events: [x for x in events if x.url not in existing_urls] @@ -1189,15 +1290,15 @@ def testCreateEvent(self): assert len(existing_events) == 0 # add event - c.save_event(broken_ev1) + c.add_event(broken_ev1) - # c.events() should give a full list of events - events = cleanse(c.events()) + # c.get_events() should give a full list of events + events = cleanse(c.get_events()) assert len(events) == 1 # We should be able to access the calender through the URL c2 = self.caldav.calendar(url=c.url) - events2 = cleanse(c2.events()) + events2 = cleanse(c2.get_events()) assert len(events2) == 1 assert events2[0].url == events[0].url @@ -1212,18 +1313,18 @@ def testCreateEvent(self): or self.is_supported("delete-calendar", str) == "fragile" ): assert c2.url == c.url - events2 = cleanse(c2.events()) + events2 = cleanse(c2.get_events()) assert len(events2) == 1 assert events2[0].url == events[0].url # add another event, it should be doable without having premade ICS - ev2 = c.save_event( + ev2 = c.add_event( dtstart=datetime(2015, 10, 10, 8, 7, 6), summary="This is a test event", dtend=datetime(2016, 10, 10, 9, 8, 7), uid="ctuid1", ) - events = c.events() + events = c.get_events() assert len(events) == len(existing_events) + 2 ev2.delete() @@ -1246,14 +1347,14 @@ def testCreateEventFromiCal(self, klass): ## Parametrized test - we should test both with the Calendar object and the Event object obj = {"Calendar": icalcal, "Event": icalevent}[klass] - event = c.save_event(obj) - events = c.events() + event = c.add_event(obj) + events = c.get_events() assert len([x for x in events if x.icalendar_component["uid"] == "ctuid1"]) == 1 def testAlarm(self): ## Ref https://github.com/python-caldav/caldav/issues/132 c = self._fixCalendar() - ev = c.save_event( + ev = c.add_event( dtstart=datetime(2015, 10, 10, 8, 0, 0), summary="This is a test event", uid="test1", @@ -1310,13 +1411,13 @@ def testObjectByUID(self): """ self.skip_unless_support("search.text.by-uid") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) - c.save_todo(summary="Some test task with a well-known uid", uid="well_known_1") - foo = c.object_by_uid("well_known_1") + c.add_todo(summary="Some test task with a well-known uid", uid="well_known_1") + foo = c.get_object_by_uid("well_known_1") assert foo.component["summary"] == "Some test task with a well-known uid" with pytest.raises(error.NotFoundError): - foo = c.object_by_uid("well_known") + foo = c.get_object_by_uid("well_known") with pytest.raises(error.NotFoundError): - foo = c.object_by_uid("well_known_10") + foo = c.get_object_by_uid("well_known_10") def testObjectBySyncToken(self): """ @@ -1330,17 +1431,17 @@ def testObjectBySyncToken(self): objcnt = 0 ## in case we need to reuse an existing calendar ... if self.is_supported("save-load.todo.mixed-calendar"): - objcnt += len(c.todos()) - objcnt += len(c.events()) - obj = c.save_event(ev1) + objcnt += len(c.get_todos()) + objcnt += len(c.get_events()) + obj = c.add_event(ev1) objcnt += 1 if self.is_supported("save-load.event.recurrences"): - c.save_event(evr) + c.add_event(evr) objcnt += 1 if self.is_supported("save-load.todo.mixed-calendar"): - c.save_todo(todo) - c.save_todo(todo2) - c.save_todo(todo3) + c.add_todo(todo) + c.add_todo(todo2) + c.add_todo(todo3) objcnt += 3 ## Check if sync tokens are time-based (need sleep(1) between operations) @@ -1374,7 +1475,9 @@ def testObjectBySyncToken(self): time.sleep(1) ## running sync_token again with the new token should return 0 hits - my_changed_objects = c.objects_by_sync_token(sync_token=my_objects.sync_token) + my_changed_objects = c.get_objects_by_sync_token( + sync_token=my_objects.sync_token + ) if not is_fragile: assert len(list(my_changed_objects)) == 0 @@ -1391,7 +1494,7 @@ def testObjectBySyncToken(self): time.sleep(1) ## The modified object should be returned by the server - my_changed_objects = c.objects_by_sync_token( + my_changed_objects = c.get_objects_by_sync_token( sync_token=my_changed_objects.sync_token, load_objects=True ) if is_fragile: @@ -1406,7 +1509,7 @@ def testObjectBySyncToken(self): time.sleep(1) ## Re-running objects_by_sync_token, and no objects should be returned - my_changed_objects = c.objects_by_sync_token( + my_changed_objects = c.get_objects_by_sync_token( sync_token=my_changed_objects.sync_token ) @@ -1416,10 +1519,10 @@ def testObjectBySyncToken(self): ## ADDING yet another object ... and it should also be reported if is_time_based: time.sleep(1) - obj3 = c.save_event(ev3) + obj3 = c.add_event(ev3) if is_time_based: time.sleep(1) - my_changed_objects = c.objects_by_sync_token( + my_changed_objects = c.get_objects_by_sync_token( sync_token=my_changed_objects.sync_token ) if not is_fragile: @@ -1429,7 +1532,7 @@ def testObjectBySyncToken(self): time.sleep(1) ## Re-running objects_by_sync_token, and no objects should be returned - my_changed_objects = c.objects_by_sync_token( + my_changed_objects = c.get_objects_by_sync_token( sync_token=my_changed_objects.sync_token ) if not is_fragile: @@ -1443,7 +1546,7 @@ def testObjectBySyncToken(self): self.skip_unless_support("sync-token.delete") if is_time_based: time.sleep(1) - my_changed_objects = c.objects_by_sync_token( + my_changed_objects = c.get_objects_by_sync_token( sync_token=my_changed_objects.sync_token, load_objects=True ) if not is_fragile: @@ -1454,7 +1557,7 @@ def testObjectBySyncToken(self): assert list(my_changed_objects)[0].data is None ## Re-running objects_by_sync_token, and no objects should be returned - my_changed_objects = c.objects_by_sync_token( + my_changed_objects = c.get_objects_by_sync_token( sync_token=my_changed_objects.sync_token ) if not is_fragile: @@ -1484,17 +1587,17 @@ def testSync(self): objcnt = 0 ## in case we need to reuse an existing calendar ... if self.is_supported("save-load.todo.mixed-calendar"): - objcnt += len(c.todos()) - objcnt += len(c.events()) - obj = c.save_event(ev1) + objcnt += len(c.get_todos()) + objcnt += len(c.get_events()) + obj = c.add_event(ev1) objcnt += 1 if self.is_supported("save-load.event.recurrences"): - c.save_event(evr) + c.add_event(evr) objcnt += 1 if self.is_supported("save-load.todo.mixed-calendar"): - c.save_todo(todo) - c.save_todo(todo2) - c.save_todo(todo3) + c.add_todo(todo) + c.add_todo(todo2) + c.add_todo(todo3) objcnt += 3 if is_time_based: @@ -1537,7 +1640,7 @@ def testSync(self): time.sleep(1) ## ADDING yet another object ... and it should also be reported - obj3 = c.save_event(ev3) + obj3 = c.add_event(ev3) if is_time_based: time.sleep(1) @@ -1583,9 +1686,9 @@ def testLoadEvent(self): c1 = self._fixCalendar(name="Yep", cal_id=self.testcal_id) c2 = self._fixCalendar(name="Yapp", cal_id=self.testcal_id2) - e1_ = c1.save_event(ev1) + e1_ = c1.add_event(ev1) e1_.load() - e1 = c1.events()[0] + e1 = c1.get_events()[0] assert e1.url == e1_.url e1.load() if ( @@ -1608,21 +1711,21 @@ def testCopyEvent(self): c1 = self._fixCalendar(name="Yep", cal_id=self.testcal_id) c2 = self._fixCalendar(name="Yapp", cal_id=self.testcal_id2) - assert not len(c1.events()) - assert not len(c2.events()) - e1_ = c1.save_event(ev1) - e1 = c1.events()[0] + assert not len(c1.get_events()) + assert not len(c2.get_events()) + e1_ = c1.add_event(ev1) + e1 = c1.get_events()[0] if not self.check_compatibility_flag("duplicates_not_allowed"): ## Duplicate the event in the same calendar, with new uid e1_dup = e1.copy() e1_dup.save() - assert len(c1.events()) == 2 + assert len(c1.get_events()) == 2 if self.is_supported("save.duplicate-uid.cross-calendar"): e1_in_c2 = e1.copy(new_parent=c2, keep_uid=True) e1_in_c2.save() - assert len(c2.events()) == 1 + assert len(c2.get_events()) == 1 ## what will happen with the event in c1 if we modify the event in c2, ## which shares the id with the event in c1? @@ -1635,7 +1738,7 @@ def testCopyEvent(self): ## if the uid is the same. assert e1.vobject_instance.vevent.summary.value == "Bastille Day Party" assert ( - c2.events()[0].vobject_instance.vevent.uid + c2.get_events()[0].vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid ) @@ -1644,9 +1747,9 @@ def testCopyEvent(self): e1_dup2 = e1.copy(keep_uid=True) e1_dup2.save() if self.check_compatibility_flag("duplicates_not_allowed"): - assert len(c1.events()) == 1 + assert len(c1.get_events()) == 1 else: - assert len(c1.events()) == 2 + assert len(c1.get_events()) == 2 if ( not self.check_compatibility_flag("unique_calendar_ids") @@ -1660,21 +1763,21 @@ def testCreateCalendarAndEventFromVobject(self): c = self._fixCalendar() ## in case the calendar is reused - cnt = len(c.events()) + cnt = len(c.get_events()) # add event from vobject data ve1 = vobject.readOne(ev1) - c.save_event(ve1) + c.add_event(ve1) cnt += 1 - # c.events() should give a full list of events - events = c.events() + # c.get_events() should give a full list of events + events = c.get_events() assert len(events) == cnt # This makes no sense, it's a noop. Perhaps an error # should be raised, but as for now, this is simply ignored. - c.save_event(None) - assert len(c.events()) == cnt + c.add_event(None) + assert len(c.get_events()) == cnt def testGetSupportedComponents(self): self.skip_on_compatibility_flag("no_supported_components_support") @@ -1689,13 +1792,13 @@ def testSearchEvent(self): self.skip_unless_support("search") c = self._fixCalendar() - num_existing = len(c.events()) - num_existing_t = len(c.todos()) - num_existing_j = len(c.journals()) + num_existing = len(c.get_events()) + num_existing_t = len(c.get_todos()) + num_existing_j = len(c.get_journals()) - c.save_event(ev1) - c.save_event(ev3) - c.save_event(evr) + c.add_event(ev1) + c.add_event(ev3) + c.add_event(evr) ## Search without any parameters should yield everything on calendar all_events = c.search() @@ -1889,42 +1992,42 @@ def testSearchSortTodo(self): self.skip_unless_support("save-load.todo") self.skip_unless_support("search") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) - pre_todos = c.todos() + pre_todos = c.get_todos() pre_todo_uid_map = {x.icalendar_component["uid"] for x in pre_todos} cleanse = lambda tasks: [ x for x in tasks if x.icalendar_component["uid"] not in pre_todo_uid_map ] - t1 = c.save_todo( + t1 = c.add_todo( summary="1 task overdue", due=date(2022, 12, 12), dtstart=date(2022, 10, 11), uid="test1", ) assert t1.is_pending() - t2 = c.save_todo( + t2 = c.add_todo( summary="2 task future", due=datetime.now() + timedelta(hours=15), dtstart=datetime.now() + timedelta(minutes=15), uid="test2", ) - t3 = c.save_todo( + t3 = c.add_todo( summary="3 task future due", due=datetime.now() + timedelta(hours=15), dtstart=datetime(2022, 12, 11, 10, 9, 8), uid="test3", ) - t4 = c.save_todo( + t4 = c.add_todo( summary="4 task priority is set to nine which is the lowest", priority=9, uid="test4", ) - t5 = c.save_todo( + t5 = c.add_todo( summary="5 task status is set to COMPLETED and this will disappear from the ordinary todo search", status="COMPLETED", uid="test5", ) assert not t5.is_pending() - t6 = c.save_todo( + t6 = c.add_todo( summary="6 task has categories", categories="home,garden,sunshine", uid="test6", @@ -1965,14 +2068,14 @@ def testSearchTodos(self): self.skip_unless_support("search") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) - pre_cnt = len(c.todos()) + pre_cnt = len(c.get_todos()) - t1 = c.save_todo(todo) - t2 = c.save_todo(todo2) - t3 = c.save_todo(todo3) - t4 = c.save_todo(todo4) - t5 = c.save_todo(todo5) - t6 = c.save_todo(todo6) + t1 = c.add_todo(todo) + t2 = c.add_todo(todo2) + t3 = c.add_todo(todo3) + t4 = c.add_todo(todo4) + t5 = c.add_todo(todo5) + t6 = c.add_todo(todo6) ## Search without any parameters should yield everything on calendar all_todos = c.search() @@ -2087,6 +2190,7 @@ def testWrongAuthType(self): client(**connect_params2).principal() def testWrongPassword(self): + self.skip_unless_support("wrong-password-check") if ( not "password" in self.server_params or not self.server_params["password"] @@ -2110,42 +2214,42 @@ def testCreateChildParent(self): self.skip_on_compatibility_flag("no_relships") self.skip_unless_support("search.text.by-uid") c = self._fixCalendar(supported_calendar_component_set=["VEVENT"]) - parent = c.save_event( + parent = c.add_event( dtstart=datetime(2022, 12, 26, 19, 15), dtend=datetime(2022, 12, 26, 20, 00), summary="this is a parent event test", uid="ctuid1", ) - child = c.save_event( + child = c.add_event( dtstart=datetime(2022, 12, 26, 19, 17), dtend=datetime(2022, 12, 26, 20, 00), summary="this is a child event test", parent=[parent.id], uid="ctuid2", ) - grandparent = c.save_event( + grandparent = c.add_event( dtstart=datetime(2022, 12, 26, 19, 00), dtend=datetime(2022, 12, 26, 20, 00), summary="this is a grandparent event test", child=[parent.id], uid="ctuid3", ) - another_child = c.save_event( + another_child = c.add_event( dtstart=datetime(2022, 12, 27, 19, 00), dtend=datetime(2022, 12, 27, 20, 00), summary="this is yet another child test event", uid="ctuid4", ) - another_parent = c.save_event( + another_parent = c.add_event( dtstart=datetime(2022, 12, 27, 19, 00), dtend=datetime(2022, 12, 27, 20, 00), summary="this is yet another parent test event", uid="ctuid5", ) - parent_ = c.event_by_uid(parent.id) - child_ = c.event_by_uid(child.id) - grandparent_ = c.event_by_uid(grandparent.id) + parent_ = c.get_event_by_uid(parent.id) + child_ = c.get_event_by_uid(child.id) + grandparent_ = c.get_event_by_uid(grandparent.id) rt = grandparent_.icalendar_component["RELATED-TO"] if isinstance(rt, list): @@ -2240,7 +2344,7 @@ def testSetDue(self): utc = timezone.utc - some_todo = c.save_todo( + some_todo = c.add_todo( dtstart=datetime(2022, 12, 26, 19, 15, tzinfo=utc), due=datetime(2022, 12, 26, 20, 00, tzinfo=utc), summary="Some task", @@ -2266,7 +2370,7 @@ def testSetDue(self): ) ## This task has duration set rather than due. Due should be implied to be 19:30. - some_other_todo = c.save_todo( + some_other_todo = c.add_todo( dtstart=datetime(2022, 12, 26, 19, 15, tzinfo=utc), duration=timedelta(minutes=15), summary="Some other task", @@ -2286,7 +2390,7 @@ def testSetDue(self): self.skip_on_compatibility_flag("no_relships") self.skip_unless_support("search.text.by-uid") - parent = c.save_todo( + parent = c.add_todo( dtstart=datetime(2022, 12, 26, 19, 00, tzinfo=utc), due=datetime(2022, 12, 26, 21, 00, tzinfo=utc), summary="this is a parent test task", @@ -2332,7 +2436,7 @@ def testSetDue(self): ).component["uid"] ) - child = c.save_todo( + child = c.add_todo( dtstart=datetime(2022, 12, 26, 19, 45), due=datetime(2022, 12, 26, 19, 55), summary="this is a test child task", @@ -2363,24 +2467,24 @@ def testCreateJournalListAndJournalEntry(self): """ self.skip_unless_support("save-load.journal") c = self._fixCalendar(supported_calendar_component_set=["VJOURNAL"]) - j1 = c.save_journal(journal) - journals = c.journals() + j1 = c.add_journal(journal) + journals = c.get_journals() assert len(journals) == 1 self.skip_unless_support("search.text.by-uid") - j1_ = c.journal_by_uid(j1.id) + j1_ = c.get_journal_by_uid(j1.id) j1_.icalendar_instance journals[0].icalendar_instance assert j1_.data == journals[0].data - j2 = c.save_journal( + j2 = c.add_journal( dtstart=date(2011, 11, 11), summary="A childbirth in a hospital in Kupchino", description="A quick birth, in the middle of the night", uid="ctuid1", ) - assert len(c.journals()) == 2 + assert len(c.get_journals()) == 2 assert len(c.search(journal=True)) == 2 - todos = c.todos() - events = c.events() + todos = c.get_todos() + events = c.get_events() assert todos + events == [] def testCreateTaskListAndTodo(self): @@ -2388,8 +2492,8 @@ def testCreateTaskListAndTodo(self): This test demonstrates the support for task lists. * It will create a "task list" * It will add a task to it - * Verify the cal.todos() method - * Verify that cal.events() method returns nothing + * Verify the cal.get_todos() method + * Verify that cal.get_events() method returns nothing """ self.skip_unless_support("save-load.todo") @@ -2405,29 +2509,29 @@ def testCreateTaskListAndTodo(self): # add todo-item logging.info("Adding todo item to calendar Yep") - t1 = c.save_todo(todo) + t1 = c.add_todo(todo) assert t1.id == "20070313T123432Z-456553@example.com" - # c.todos() should give a full list of todo items + # c.get_todos() should give a full list of todo items logging.info("Fetching the full list of todo items (should be one)") - todos = c.todos() - todos2 = c.todos(include_completed=True) + todos = c.get_todos() + todos2 = c.get_todos(include_completed=True) assert len(todos) == 1 assert len(todos2) == 1 - t3 = c.save_todo( + t3 = c.add_todo( summary="mop the floor", categories=["housework"], priority=4, uid="ctuid1" ) - assert len(c.todos()) == 2 + assert len(c.get_todos()) == 2 # adding a todo without a UID, it should also work (library will add the missing UID) - t7 = c.save_todo(todo7) + t7 = c.add_todo(todo7) logging.info("Fetching the todos (should be three)") - todos = c.todos() + todos = c.get_todos() logging.info("Fetching the events (should be none)") - # c.events() should NOT return todo-items - events = c.events() + # c.get_events() should NOT return todo-items + events = c.get_events() t7.delete() @@ -2436,11 +2540,11 @@ def testCreateTaskListAndTodo(self): ## in the test framework. assert len(todos) == 3 assert len(events) == 0 - assert len(c.todos()) == 2 + assert len(c.get_todos()) == 2 def testTodos(self): """ - This test will exercise the cal.todos() method, + This test will exercise the cal.get_todos() method, and in particular the sort_keys attribute. * It will list out all pending tasks, sorted by due date * It will list out all pending tasks, sorted by priority @@ -2449,11 +2553,11 @@ def testTodos(self): c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) # add todo-item - t1 = c.save_todo(todo) - t2 = c.save_todo(todo2) - t4 = c.save_todo(todo4) + t1 = c.add_todo(todo) + t2 = c.add_todo(todo2) + t4 = c.add_todo(todo4) - todos = c.todos() + todos = c.get_todos() assert len(todos) == 3 def uids(lst): @@ -2462,10 +2566,10 @@ def uids(lst): ## Default sort order is (due, priority). assert uids(todos) == uids([t2, t1, t4]) - todos = c.todos(sort_keys=("priority",)) + todos = c.get_todos(sort_keys=("priority",)) ## sort_key is considered to be a legacy parameter, ## but should work at least until 1.0 - todos2 = c.todos(sort_key="priority") + todos2 = c.get_todos(sort_key="priority") def pri(lst): return [ @@ -2477,7 +2581,7 @@ def pri(lst): assert pri(todos) == pri([t4, t2]) assert pri(todos2) == pri([t4, t2]) - todos = c.todos( + todos = c.get_todos( sort_keys=( "summary", "priority", @@ -2498,8 +2602,8 @@ def testSearchCompType(self) -> None: Test that component-type filtering works correctly, even on servers with broken comp-type support (like Bedework which misclassifies TODOs as events). - This test verifies that when calendar.events() is called, only events are returned, - and when calendar.todos() is called, only todos are returned, regardless of + This test verifies that when calendar.get_events() is called, only events are returned, + and when calendar.get_todos() is called, only todos are returned, regardless of server bugs. """ self.skip_unless_support("save-load.todo") @@ -2510,24 +2614,24 @@ def testSearchCompType(self) -> None: c = self._fixCalendar() ## Add an event - event = c.save_event( + event = c.add_event( summary="Test Event for Component-Type Filtering", dtstart=datetime(2025, 1, 1, 12, 0, 0), dtend=datetime(2025, 1, 1, 13, 0, 0), ) ## Add a todo - todo_obj = c.save_todo( + todo_obj = c.add_todo( summary="Test TODO for Component-Type Filtering", dtstart=date(2025, 1, 2), ) ## Get events - should only return the event, not the todo - events = c.events() + events = c.get_events() event_summaries = [e.component["summary"] for e in events] ## Get todos - should only return the todo, not the event - todos = c.todos(include_completed=True) + todos = c.get_todos(include_completed=True) todo_summaries = [t.component["summary"] for t in todos] ## Verify correct filtering @@ -2541,28 +2645,33 @@ def testSearchCompType(self) -> None: event.delete() todo_obj.delete() + @pytest.mark.filterwarnings("ignore:use `calendar.search:DeprecationWarning") def testTodoDatesearch(self): """ - Let's see how the date search method works for todo events + Let's see how the date search method works for todo events. + + Note: This test intentionally uses the deprecated date_search method + to ensure backward compatibility. """ self.skip_unless_support("save-load.todo") self.skip_unless_support("search.time-range.todo") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) # add todo-item - t1 = c.save_todo(todo) - t2 = c.save_todo(todo2) - t3 = c.save_todo(todo3) - t4 = c.save_todo(todo4) - t5 = c.save_todo(todo5) - t6 = c.save_todo(todo6) - todos = c.todos() + t1 = c.add_todo(todo) + t2 = c.add_todo(todo2) + t3 = c.add_todo(todo3) + t4 = c.add_todo(todo4) + t5 = c.add_todo(todo5) + t6 = c.add_todo(todo6) + todos = c.get_todos() assert len(todos) == 6 - notodos = c.date_search( # default compfilter is events - start=datetime(1997, 4, 14), end=datetime(2015, 5, 14), expand=False - ) - assert not notodos + with pytest.deprecated_call(): + notodos = c.date_search( # default compfilter is events + start=datetime(1997, 4, 14), end=datetime(2015, 5, 14), expand=False + ) + assert not notodos # Now, this is interesting. # t1 has due set but not dtstart set @@ -2571,12 +2680,13 @@ def testTodoDatesearch(self): # t5 has dtstart and due set prior to the search window # t6 has dtstart and due set prior to the search window, but is yearly recurring. # What will a date search yield? - todos1 = c.date_search( - start=datetime(1997, 4, 14), - end=datetime(2015, 5, 14), - compfilter="VTODO", - expand=True, - ) + with pytest.deprecated_call(): + todos1 = c.date_search( + start=datetime(1997, 4, 14), + end=datetime(2015, 5, 14), + compfilter="VTODO", + expand=True, + ) todos2 = c.search( start=datetime(1997, 4, 14), end=datetime(2015, 5, 14), @@ -2589,7 +2699,7 @@ def testTodoDatesearch(self): start=datetime(1997, 4, 14), end=datetime(2015, 5, 14), todo=True, - expand="client", + expand=True, split_expanded=False, include_completed=True, ) @@ -2597,7 +2707,7 @@ def testTodoDatesearch(self): start=datetime(1997, 4, 14), end=datetime(2015, 5, 14), todo=True, - expand="client", + expand=True, split_expanded=False, include_completed=True, ) @@ -2644,7 +2754,8 @@ def testTodoDatesearch(self): ## todo4 is server side expand, may work dependent on server ## exercise the default for expand (maybe -> False for open-ended search) - todos1 = c.date_search(start=datetime(2025, 4, 14), compfilter="VTODO") + with pytest.deprecated_call(): + todos1 = c.date_search(start=datetime(2025, 4, 14), compfilter="VTODO") todos2 = c.search( start=datetime(2025, 4, 14), todo=True, include_completed=True ) @@ -2688,8 +2799,8 @@ def testSearchWithoutCompType(self): """ self.skip_unless_support("save-load.todo.mixed-calendar") cal = self._fixCalendar() - cal.save_todo(todo) - cal.save_event(ev1) + cal.add_todo(todo) + cal.add_event(ev1) objects = cal.search() assert len(objects) == 2 assert set([type(x).__name__ for x in objects]) == {"Todo", "Event"} @@ -2703,26 +2814,26 @@ def testTodoCompletion(self): c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) # add todo-items - t1 = c.save_todo(todo) - t2 = c.save_todo(todo2) - t3 = c.save_todo(todo3, status="NEEDS-ACTION") + t1 = c.add_todo(todo) + t2 = c.add_todo(todo2) + t3 = c.add_todo(todo3, status="NEEDS-ACTION") # There are now three todo-items at the calendar - todos = c.todos() + todos = c.get_todos() assert len(todos) == 3 # Complete one of them t3.complete() # There are now two todo-items at the calendar - todos = c.todos() + todos = c.get_todos() assert len(todos) == 2 # The historic todo-item can still be accessed - todos = c.todos(include_completed=True) + todos = c.get_todos(include_completed=True) assert len(todos) == 3 if self.is_supported("search.text.by-uid"): - t3_ = c.todo_by_uid(t3.id) + t3_ = c.get_todo_by_uid(t3.id) assert ( t3_.vobject_instance.vtodo.summary == t3.vobject_instance.vtodo.summary ) @@ -2735,7 +2846,7 @@ def testTodoCompletion(self): # ... the deleted one is gone ... if not self.check_compatibility_flag("event_by_url_is_broken"): - todos = c.todos(include_completed=True) + todos = c.get_todos(include_completed=True) assert len(todos) == 2 # date search should not include completed events ... hum. @@ -2749,31 +2860,31 @@ def testTodoCompletion(self): def testTodoRecurringCompleteSafe(self): self.skip_unless_support("save-load.todo") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) - assert len(c.todos()) == 0 - t6 = c.save_todo(todo6, status="NEEDS-ACTION") - assert len(c.todos()) == 1 + assert len(c.get_todos()) == 0 + t6 = c.add_todo(todo6, status="NEEDS-ACTION") + assert len(c.get_todos()) == 1 if self.is_supported("save-load.todo.recurrences.count"): - assert len(c.todos()) == 1 - t8 = c.save_todo(todo8) - assert len(c.todos()) == 2 + assert len(c.get_todos()) == 1 + t8 = c.add_todo(todo8) + assert len(c.get_todos()) == 2 else: - assert len(c.todos()) == 1 + assert len(c.get_todos()) == 1 t6.complete(handle_rrule=True, rrule_mode="safe") if not self.is_supported("save-load.todo.recurrences.count"): - assert len(c.todos()) == 1 - assert len(c.todos(include_completed=True)) == 2 - c.todos()[0].delete() + assert len(c.get_todos()) == 1 + assert len(c.get_todos(include_completed=True)) == 2 + c.get_todos()[0].delete() self.skip_unless_support("save-load.todo.recurrences.count") - assert len(c.todos()) == 2 - assert len(c.todos(include_completed=True)) == 3 + assert len(c.get_todos()) == 2 + assert len(c.get_todos(include_completed=True)) == 3 t8.complete(handle_rrule=True, rrule_mode="safe") - todos = c.todos() + todos = c.get_todos() assert len(todos) == 2 t8.complete(handle_rrule=True, rrule_mode="safe") t8.complete(handle_rrule=True, rrule_mode="safe") - assert len(c.todos()) == 1 - assert len(c.todos(include_completed=True)) == 5 - [x.delete() for x in c.todos(include_completed=True)] + assert len(c.get_todos()) == 1 + assert len(c.get_todos(include_completed=True)) == 5 + [x.delete() for x in c.get_todos(include_completed=True)] def testTodoRecurringCompleteThisandfuture(self): self.skip_unless_support("save-load.todo") @@ -2783,27 +2894,27 @@ def testTodoRecurringCompleteThisandfuture(self): ## this ought to be researched better. self.skip_unless_support("search.text") c = self._fixCalendar(supported_calendar_component_set=["VTODO"]) - assert len(c.todos()) == 0 - t6 = c.save_todo(todo6, status="NEEDS-ACTION") + assert len(c.get_todos()) == 0 + t6 = c.add_todo(todo6, status="NEEDS-ACTION") if self.is_supported("save-load.todo.recurrences.count"): - t8 = c.save_todo(todo8) - assert len(c.todos()) == 2 + t8 = c.add_todo(todo8) + assert len(c.get_todos()) == 2 else: - assert len(c.todos()) == 1 + assert len(c.get_todos()) == 1 t6.complete(handle_rrule=True, rrule_mode="thisandfuture") - all_todos = c.todos(include_completed=True) + all_todos = c.get_todos(include_completed=True) if not self.is_supported("save-load.todo.recurrences.count"): - assert len(c.todos()) == 1 + assert len(c.get_todos()) == 1 assert len(all_todos) == 1 self.skip_unless_support("save-load.todo.recurrences.count") - assert len(c.todos()) == 2 + assert len(c.get_todos()) == 2 assert len(all_todos) == 2 # assert sum([len(x.icalendar_instance.subcomponents) for x in all_todos]) == 5 t8.complete(handle_rrule=True, rrule_mode="thisandfuture") - assert len(c.todos()) == 2 + assert len(c.get_todos()) == 2 t8.complete(handle_rrule=True, rrule_mode="thisandfuture") t8.complete(handle_rrule=True, rrule_mode="thisandfuture") - assert len(c.todos()) == 1 + assert len(c.get_todos()) == 1 def testUtf8Event(self): self.skip_unless_support("save-load.event") @@ -2819,16 +2930,14 @@ def testUtf8Event(self): c = self._fixCalendar(name="Yølp", cal_id=self.testcal_id) # add event - e1 = c.save_event( - ev1.replace("Bastille Day Party", "Bringebærsyltetøyfestival") - ) + e1 = c.add_event(ev1.replace("Bastille Day Party", "Bringebærsyltetøyfestival")) # fetch it back - events = c.events() + events = c.get_events() # no todos should be added if self.is_supported("save-load.todo"): - todos = c.todos() + todos = c.get_todos() assert len(todos) == 0 # COMPATIBILITY PROBLEM - todo, look more into it @@ -2851,12 +2960,12 @@ def testUnicodeEvent(self): c = self._fixCalendar(name="Yølp", cal_id=self.testcal_id) # add event - e1 = c.save_event( + e1 = c.add_event( to_str(ev1.replace("Bastille Day Party", "Bringebærsyltetøyfestival")) ) - # c.events() should give a full list of events - events = c.events() + # c.get_events() should give a full list of events + events = c.get_events() # COMPATIBILITY PROBLEM - todo, look more into it if "zimbra" not in str(c.url): @@ -2957,7 +3066,7 @@ def testLookupEvent(self): assert c.url is not None # add event - e1 = c.save_event(ev1) + e1 = c.add_event(ev1) assert e1.url is not None # Verify that we can look it up, both by URL and by ID @@ -2966,7 +3075,7 @@ def testLookupEvent(self): assert e2.vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid assert e2.url == e1.url if self.is_supported("search.text.by-uid"): - e3 = c.event_by_uid("20010712T182145Z-123401@example.com") + e3 = c.get_event_by_uid("20010712T182145Z-123401@example.com") assert e3.vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid assert e3.url == e1.url @@ -2978,10 +3087,10 @@ def testLookupEvent(self): assert e4.vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid with pytest.raises(error.NotFoundError): - c.event_by_uid("0") - c.save_event(evr) + c.get_event_by_uid("0") + c.add_event(evr) with pytest.raises(error.NotFoundError): - c.event_by_uid("0") + c.get_event_by_uid("0") def testCreateOverwriteDeleteEvent(self): """ @@ -2992,44 +3101,44 @@ def testCreateOverwriteDeleteEvent(self): c = self._fixCalendar() assert c.url is not None - # attempts on updating/overwriting a non-existing event should fail (unless object_by_uid_is_broken): + # attempts on updating/overwriting a non-existing event should fail (unless get_object_by_uid_is_broken): if self.is_supported("search.text.by-uid"): with pytest.raises(error.ConsistencyError): - c.save_event(ev1, no_create=True) + c.add_event(ev1, no_create=True) # no_create and no_overwrite is mutually exclusive, this will always # raise an error (unless the ical given is blank) with pytest.raises(error.ConsistencyError): - c.save_event(ev1, no_create=True, no_overwrite=True) + c.add_event(ev1, no_create=True, no_overwrite=True) # add event - e1 = c.save_event(ev1) + e1 = c.add_event(ev1) todo_ok = self.is_supported("save-load.todo.mixed-calendar") if todo_ok: - t1 = c.save_todo(todo) + t1 = c.add_todo(todo) assert e1.url is not None if todo_ok: assert t1.url is not None if not self.check_compatibility_flag("event_by_url_is_broken"): assert c.event_by_url(e1.url).url == e1.url if self.is_supported("search.text.by-uid"): - assert c.event_by_uid(e1.id).url == e1.url + assert c.get_event_by_uid(e1.id).url == e1.url - ## no_create will not work unless object_by_uid works + ## no_create will not work unless get_object_by_uid works no_create = self.is_supported("search.text.by-uid") ## add same event again. As it has same uid, it should be overwritten ## (but some calendars may throw a "409 Conflict") if not self.check_compatibility_flag("no_overwrite"): - e2 = c.save_event(ev1) + e2 = c.add_event(ev1) if todo_ok: - t2 = c.save_todo(todo) + t2 = c.add_todo(todo) ## add same event with "no_create". Should work like a charm. - e2 = c.save_event(ev1, no_create=no_create) + e2 = c.add_event(ev1, no_create=no_create) if todo_ok: - t2 = c.save_todo(todo, no_create=no_create) + t2 = c.add_todo(todo, no_create=no_create) ## this should also work. e2.vobject_instance.vevent.summary.value = ( @@ -3047,13 +3156,13 @@ def testCreateOverwriteDeleteEvent(self): e3 = c.event_by_url(e1.url) assert e3.vobject_instance.vevent.summary.value == "Bastille Day Party!" - ## "no_overwrite" should throw a ConsistencyError. But it depends on object_by_uid. + ## "no_overwrite" should throw a ConsistencyError. But it depends on get_object_by_uid. if self.is_supported("search.text.by-uid"): with pytest.raises(error.ConsistencyError): - c.save_event(ev1, no_overwrite=True) + c.add_event(ev1, no_overwrite=True) if todo_ok: with pytest.raises(error.ConsistencyError): - c.save_todo(todo, no_overwrite=True) + c.add_todo(todo, no_overwrite=True) # delete event e1.delete() @@ -3073,33 +3182,39 @@ def testCreateOverwriteDeleteEvent(self): c.event_by_url(e2.url) if not self.check_compatibility_flag("event_by_url_is_broken"): with pytest.raises(error.NotFoundError): - c.event_by_uid("20010712T182145Z-123401@example.com") + c.get_event_by_uid("20010712T182145Z-123401@example.com") + @pytest.mark.filterwarnings("ignore:use `calendar.search:DeprecationWarning") def testDateSearchAndFreeBusy(self): """ Verifies that date search works with a non-recurring event Also verifies that it's possible to change a date of a - non-recurring event + non-recurring event. + + Note: This test intentionally uses the deprecated date_search method + to ensure backward compatibility. """ self.skip_unless_support("save-load.event") self.skip_unless_support("search") # Create calendar, add event ... c = self._fixCalendar() assert c.url is not None - e = c.save_event(ev1) + e = c.add_event(ev1) ## just a sanity check to increase coverage (ref ## https://github.com/python-caldav/caldav/issues/93) - ## expand=False and no end date given is no-no - with pytest.raises(error.DAVError): - c.date_search(datetime(2006, 7, 13, 17, 00, 00), expand=True) + with pytest.deprecated_call(): + with pytest.raises(error.DAVError): + c.date_search(datetime(2006, 7, 13, 17, 00, 00), expand=True) # .. and search for it. - r1 = c.date_search( - datetime(2006, 7, 13, 17, 00, 00), - datetime(2006, 7, 15, 17, 00, 00), - expand=False, - ) + with pytest.deprecated_call(): + r1 = c.date_search( + datetime(2006, 7, 13, 17, 00, 00), + datetime(2006, 7, 15, 17, 00, 00), + expand=False, + ) r2 = c.search( event=True, start=datetime(2006, 7, 13, 17, 00, 00), @@ -3124,11 +3239,12 @@ def testDateSearchAndFreeBusy(self): # The timestamp should change. e.data = ev2 e.save() - r1 = c.date_search( - datetime(2006, 7, 13, 17, 00, 00), - datetime(2006, 7, 15, 17, 00, 00), - expand=False, - ) + with pytest.deprecated_call(): + r1 = c.date_search( + datetime(2006, 7, 13, 17, 00, 00), + datetime(2006, 7, 15, 17, 00, 00), + expand=False, + ) r2 = c.search( event=True, start=datetime(2006, 7, 13, 17, 00, 00), @@ -3137,16 +3253,18 @@ def testDateSearchAndFreeBusy(self): ) assert len(r1) == 0 assert len(r2) == 0 - r1 = c.date_search( - datetime(2007, 7, 13, 17, 00, 00), - datetime(2007, 7, 15, 17, 00, 00), - expand=False, - ) - assert len(r1) == 1 + with pytest.deprecated_call(): + r1 = c.date_search( + datetime(2007, 7, 13, 17, 00, 00), + datetime(2007, 7, 15, 17, 00, 00), + expand=False, + ) + assert len(r1) == 1 # date search without closing date should also find it - r = c.date_search(datetime(2007, 7, 13, 17, 00, 00), expand=False) - assert len(r) == 1 + with pytest.deprecated_call(): + r = c.date_search(datetime(2007, 7, 13, 17, 00, 00), expand=False) + assert len(r) == 1 # Lets try a freebusy request as well self.skip_unless_support("freebusy-query.rfc4791") @@ -3162,25 +3280,30 @@ def testDateSearchAndFreeBusy(self): ## (TODO: move it to some other test) e.data = icalendar.Calendar.from_ical(ev2) + @pytest.mark.filterwarnings("ignore:use `calendar.search:DeprecationWarning") def testRecurringDateSearch(self): """ This is more sanity testing of the server side than testing of the library per se. How will it behave if we serve it a recurring event? + + Note: This test intentionally uses the deprecated date_search method + to ensure backward compatibility. """ self.skip_unless_support("save-load.event") self.skip_unless_support("search.recurrences.includes-implicit.event") c = self._fixCalendar() # evr is a yearly event starting at 1997-11-02 - e = c.save_event(evr) + e = c.add_event(evr) ## Without "expand", we should still find it when searching over 2008 ... - r = c.date_search( - datetime(2008, 11, 1, 17, 00, 00), - datetime(2008, 11, 3, 17, 00, 00), - expand=False, - ) + with pytest.deprecated_call(): + r = c.date_search( + datetime(2008, 11, 1, 17, 00, 00), + datetime(2008, 11, 3, 17, 00, 00), + expand=False, + ) r2 = c.search( event=True, start=datetime(2008, 11, 1, 17, 00, 00), @@ -3192,11 +3315,12 @@ def testRecurringDateSearch(self): ## With expand=True, we should find one occurrence ## legacy method name - r1 = c.date_search( - datetime(2008, 11, 1, 17, 00, 00), - datetime(2008, 11, 3, 17, 00, 00), - expand=True, - ) + with pytest.deprecated_call(): + r1 = c.date_search( + datetime(2008, 11, 1, 17, 00, 00), + datetime(2008, 11, 3, 17, 00, 00), + expand=True, + ) ## server expansion, with client side fallback r2 = c.search( event=True, @@ -3210,7 +3334,7 @@ def testRecurringDateSearch(self): event=True, start=datetime(2008, 11, 1, 17, 00, 00), end=datetime(2008, 11, 3, 17, 00, 00), - expand="server", + server_expand=True, ) assert len(r1) == 1 assert len(r2) == 1 @@ -3223,11 +3347,12 @@ def testRecurringDateSearch(self): assert r4[0].data.count("DTSTART;VALUE=DATE:2008") == 1 ## With expand=True and searching over two recurrences ... - r1 = c.date_search( - datetime(2008, 11, 1, 17, 00, 00), - datetime(2009, 11, 3, 17, 00, 00), - expand=True, - ) + with pytest.deprecated_call(): + r1 = c.date_search( + datetime(2008, 11, 1, 17, 00, 00), + datetime(2009, 11, 3, 17, 00, 00), + expand=True, + ) r2 = c.search( event=True, start=datetime(2008, 11, 1, 17, 00, 00), @@ -3256,7 +3381,7 @@ def testRecurringDateSearch(self): # The recurring events should not be expanded when using the # events() method - r = c.events() + r = c.get_events() if not not self.is_supported("create-calendar"): assert len(r) == 1 assert r[0].data.count("END:VEVENT") == 1 @@ -3267,7 +3392,7 @@ def testRecurringDateWithExceptionSearch(self): # evr2 is a bi-weekly event starting 2024-04-11 ## It has an exception, edited summary for recurrence id 20240425T123000Z - e = c.save_event(evr2) + e = c.add_event(evr2) r = c.search( start=datetime(2024, 3, 31, 0, 0), @@ -3280,7 +3405,7 @@ def testRecurringDateWithExceptionSearch(self): start=datetime(2024, 3, 31, 0, 0), end=datetime(2024, 5, 4, 0, 0, 0), event=True, - expand="server", + server_expand=True, ) assert len(r) == 2 @@ -3332,7 +3457,7 @@ def testEditSingleRecurrence(self): cal = self._fixCalendar() ## Create a daily recurring event - cal.save_event( + cal.add_event( uid="test1", summary="daily test", dtstart=datetime(2015, 1, 1, 8, 7, 6), @@ -3429,7 +3554,7 @@ def testOffsetURL(self): for url in urls: conn = client(**connect_params, url=url) principal = conn.principal() - calendars = principal.calendars() + calendars = principal.get_calendars() def testObjects(self): # TODO: description ... what are we trying to test for here? diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index 3357a638..a2c36e15 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -306,73 +306,6 @@ def request(self, *largs, **kwargs): return MockedDAVResponse(self.xml_returned) -class TestExpandRRule: - """ - Tests the expand_rrule method - """ - - def setup_method(self): - cal_url = "http://me:hunter2@calendar.example:80/" - client = DAVClient(url=cal_url) - self.yearly = Event(client, data=evr) - self.todo = Todo(client, data=todo6) - - def testZero(self): - ## evr has rrule yearly and dtstart DTSTART 1997-11-02 - ## This should cause 0 recurrences: - self.yearly.expand_rrule(start=datetime(1998, 4, 4), end=datetime(1998, 10, 10)) - assert len(self.yearly.icalendar_instance.subcomponents) == 0 - - def testOne(self): - self.yearly.expand_rrule( - start=datetime(1998, 10, 10), end=datetime(1998, 12, 12) - ) - assert len(self.yearly.icalendar_instance.subcomponents) == 1 - assert not "RRULE" in self.yearly.icalendar_component - assert "UID" in self.yearly.icalendar_component - assert "RECURRENCE-ID" in self.yearly.icalendar_component - - def testThree(self): - self.yearly.expand_rrule( - start=datetime(1996, 10, 10), end=datetime(1999, 12, 12) - ) - assert len(self.yearly.icalendar_instance.subcomponents) == 3 - data1 = self.yearly.icalendar_instance.subcomponents[0].to_ical() - data2 = self.yearly.icalendar_instance.subcomponents[1].to_ical() - assert data1.replace(b"199711", b"199811") == data2 - - def testThreeTodo(self): - self.todo.expand_rrule(start=datetime(1996, 10, 10), end=datetime(1999, 12, 12)) - assert len(self.todo.icalendar_instance.subcomponents) == 3 - data1 = self.todo.icalendar_instance.subcomponents[0].to_ical() - data2 = self.todo.icalendar_instance.subcomponents[1].to_ical() - assert data1.replace(b"19970", b"19980") == data2 - - def testSplit(self): - self.yearly.expand_rrule( - start=datetime(1996, 10, 10), end=datetime(1999, 12, 12) - ) - events = self.yearly.split_expanded() - assert len(events) == 3 - assert len(events[0].icalendar_instance.subcomponents) == 1 - assert ( - events[1].icalendar_component["UID"] - == "19970901T130000Z-123403@example.com" - ) - - def test241(self): - """ - Ref https://github.com/python-caldav/caldav/issues/241 - - This seems like sort of a duplicate of testThreeTodo, but the ftests actually started failing - """ - assert len(self.todo.data) > 128 - self.todo.expand_rrule( - start=datetime(1997, 4, 14, 0, 0), end=datetime(2015, 5, 14, 0, 0) - ) - assert len(self.todo.data) > 128 - - class TestCalDAV: """ Test class for "pure" unit tests (small internal tests, testing that @@ -408,7 +341,7 @@ def testSearchForRecurringTask(self): assert len(mytasks) == 1 mytasks = calendar.search( todo=True, - expand="client", + expand=True, start=datetime(2025, 5, 5), end=datetime(2025, 6, 5), ) @@ -417,7 +350,7 @@ def testSearchForRecurringTask(self): ## It should not include the COMPLETED recurrences mytasks = calendar.search( todo=True, - expand="client", + expand=True, start=datetime(2025, 1, 1), end=datetime(2025, 6, 5), ## TODO - TEMP workaround for compatibility issues! post_filter should not be needed! @@ -602,10 +535,11 @@ def testDateSearch(self): calendar = Calendar( client, url="/principals/calendar/home@petroski.example.com/963/" ) - results = calendar.date_search( - datetime(2021, 2, 1), datetime(2021, 2, 7), expand=False - ) - assert len(results) == 3 + with pytest.deprecated_call(): + results = calendar.date_search( + datetime(2021, 2, 1), datetime(2021, 2, 7), expand=False + ) + assert len(results) == 3 def testCalendar(self): """ @@ -672,7 +606,7 @@ def test_get_events_icloud(self): client, url="/17149682/calendars/testcalendar-485d002e-31b9-4147-a334-1d71503a4e2c/", ) - assert len(calendar.events()) == 0 + assert len(calendar.get_events()) == 0 def test_get_calendars(self): xml = """ @@ -742,7 +676,7 @@ def test_get_calendars(self): """ client = MockedDAVClient(xml) calendar_home_set = CalendarSet(client, url="/dav/tobias%40redpill-linpro.com/") - assert len(calendar_home_set.calendars()) == 1 + assert len(calendar_home_set.get_calendars()) == 1 def test_supported_components(self): xml = """ @@ -1271,13 +1205,220 @@ def testComponentSet(self): target = Event(client, data=evr) ## Creating some dummy data such that the target has more than one subcomponent - target.expand_rrule(start=datetime(1996, 10, 10), end=datetime(1999, 12, 12)) - assert len(target.icalendar_instance.subcomponents) == 3 + with pytest.deprecated_call(): + target.expand_rrule(start=datetime(1996, 10, 10), end=datetime(1999, 12, 12)) + assert len(target.icalendar_instance.subcomponents) == 3 ## The following should not fail within _set_icalendar_component target.icalendar_component = icalendar.Todo.from_ical(todo).subcomponents[0] assert len(target.icalendar_instance.subcomponents) == 1 + def testNewDataAPI(self): + """Test the new safe data access API (issue #613). + + The new API provides: + - get_data() / get_icalendar_instance() / get_vobject_instance() for read-only access + - edit_icalendar_instance() / edit_vobject_instance() context managers for editing + """ + cal_url = "http://me:hunter2@calendar.example:80/" + client = DAVClient(url=cal_url) + event = Event(client, data=ev1) + + # Test get_data() returns string + data = event.get_data() + assert isinstance(data, str) + assert "Bastille Day Party" in data + + # Test get_icalendar_instance() returns a COPY + ical1 = event.get_icalendar_instance() + ical2 = event.get_icalendar_instance() + assert ical1 is not ical2 # Different objects (copies) + + # Modifying the copy should NOT affect the original + for comp in ical1.subcomponents: + if comp.name == "VEVENT": + comp["SUMMARY"] = "Modified in copy" + assert "Modified in copy" not in event.get_data() + + # Test get_vobject_instance() returns a COPY + vobj1 = event.get_vobject_instance() + vobj2 = event.get_vobject_instance() + assert vobj1 is not vobj2 # Different objects (copies) + + # Test edit_icalendar_instance() context manager + with event.edit_icalendar_instance() as cal: + for comp in cal.subcomponents: + if comp.name == "VEVENT": + comp["SUMMARY"] = "Edited Summary" + + # Changes should be reflected + assert "Edited Summary" in event.get_data() + + # Test edit_vobject_instance() context manager + with event.edit_vobject_instance() as vobj: + vobj.vevent.summary.value = "Vobject Edit" + + assert "Vobject Edit" in event.get_data() + + # Test that nested borrowing of different types raises error + with event.edit_icalendar_instance() as cal: + with pytest.raises(RuntimeError): + with event.edit_vobject_instance() as vobj: + pass + + def testDataAPICheapAccessors(self): + """Test the cheap internal accessors for issue #613. + + These accessors avoid unnecessary format conversions when we just + need to peek at basic properties like UID or component type. + """ + cal_url = "http://me:hunter2@calendar.example:80/" + client = DAVClient(url=cal_url) + + # Test with event + event = Event(client, data=ev1) + assert event._get_uid_cheap() == "20010712T182145Z-123401@example.com" + assert event._get_component_type_cheap() == "VEVENT" + assert event._has_data() is True + + # Test with todo + my_todo = Todo(client, data=todo) + assert my_todo._get_uid_cheap() == "20070313T123432Z-456553@example.com" + assert my_todo._get_component_type_cheap() == "VTODO" + assert my_todo._has_data() is True + + # Test with journal + my_journal = CalendarObjectResource(client, data=journal) + assert my_journal._get_uid_cheap() == "19970901T130000Z-123405@example.com" + assert my_journal._get_component_type_cheap() == "VJOURNAL" + assert my_journal._has_data() is True + + # Test with no data + empty_event = Event(client) + assert empty_event._get_uid_cheap() is None + assert empty_event._get_component_type_cheap() is None + assert empty_event._has_data() is False + + def testDataAPIStateTransitions(self): + """Test state transitions in the data API (issue #613). + + Verify that the internal state correctly transitions between + RawDataState, IcalendarState, and VobjectState. + """ + from caldav.datastate import ( + IcalendarState, + RawDataState, + VobjectState, + ) + + cal_url = "http://me:hunter2@calendar.example:80/" + client = DAVClient(url=cal_url) + event = Event(client, data=ev1) + + # Initial state should be RawDataState (or lazy init) + event._ensure_state() + assert isinstance(event._state, RawDataState) + + # get_data() should NOT change state + _ = event.get_data() + assert isinstance(event._state, RawDataState) + + # get_icalendar_instance() should NOT change state (returns copy) + _ = event.get_icalendar_instance() + assert isinstance(event._state, RawDataState) + + # edit_icalendar_instance() SHOULD change state to IcalendarState + with event.edit_icalendar_instance() as cal: + pass + assert isinstance(event._state, IcalendarState) + + # edit_vobject_instance() SHOULD change state to VobjectState + with event.edit_vobject_instance() as vobj: + pass + assert isinstance(event._state, VobjectState) + + # get_data() should still work from VobjectState + data = event.get_data() + assert "Bastille Day Party" in data + + def testDataAPINoDataState(self): + """Test NoDataState behavior (issue #613). + + When an object has no data, the NoDataState should provide + sensible defaults without raising errors. + """ + from caldav.datastate import NoDataState + + cal_url = "http://me:hunter2@calendar.example:80/" + client = DAVClient(url=cal_url) + event = Event(client) # No data + + # Ensure state is NoDataState + event._ensure_state() + assert isinstance(event._state, NoDataState) + + # get_data() should return empty string + assert event.get_data() == "" + + # get_icalendar_instance() should return empty Calendar + ical = event.get_icalendar_instance() + assert ical is not None + assert len(list(ical.subcomponents)) == 0 + + # Cheap accessors should return None + assert event._get_uid_cheap() is None + assert event._get_component_type_cheap() is None + assert event._has_data() is False + + def testDataAPIEdgeCases(self): + """Test edge cases in the data API (issue #613).""" + cal_url = "http://me:hunter2@calendar.example:80/" + client = DAVClient(url=cal_url) + + # Test with folded UID line (UID split across lines) + folded_uid_data = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:this-is-a-very-long-uid-that-might-be-folded-across-multiple-lines-in-r + eal-world-icalendar-files@example.com +DTSTAMP:20060712T182145Z +DTSTART:20060714T170000Z +SUMMARY:Folded UID Test +END:VEVENT +END:VCALENDAR +""" + event = Event(client, data=folded_uid_data) + # The cheap accessor uses regex which might not handle folded lines + # So we test that it falls back to full parsing when needed + uid = event._get_uid_cheap() + # Either the regex works or it falls back - either way we should get a UID + assert uid is not None + assert "this-is-a-very-long-uid" in uid + + # Test that nested borrowing (even same type) raises error + # This prevents confusing ownership semantics + event2 = Event(client, data=ev1) + with event2.edit_icalendar_instance() as cal1: + with pytest.raises(RuntimeError): + with event2.edit_icalendar_instance() as cal2: + pass + + # Test sequential edits work fine + event3 = Event(client, data=ev1) + with event3.edit_icalendar_instance() as cal: + for comp in cal.subcomponents: + if comp.name == "VEVENT": + comp["SUMMARY"] = "First Edit" + assert "First Edit" in event3.get_data() + + # Second edit after first is complete + with event3.edit_icalendar_instance() as cal: + for comp in cal.subcomponents: + if comp.name == "VEVENT": + comp["SUMMARY"] = "Second Edit" + assert "Second Edit" in event3.get_data() + def testTodoDuration(self): cal_url = "http://me:hunter2@calendar.example:80/" client = DAVClient(url=cal_url) @@ -1553,3 +1694,57 @@ def testAutoUrlEcloudWithEmailUsername(self) -> None: url == "https://ecloud.global/remote.php/dav" ), f"Expected 'https://ecloud.global/remote.php/dav', got '{url}'" assert discovered_username is None + + def testSearcherMethod(self): + """Test that calendar.searcher() returns a properly configured CalDAVSearcher. + + This tests issue #590 - the new API for creating search objects. + """ + from caldav.search import CalDAVSearcher + + client = MockedDAVClient(recurring_task_response) + calendar = Calendar(client, url="/calendar/issue491/") + + # Test basic searcher creation + searcher = calendar.searcher(event=True) + assert isinstance(searcher, CalDAVSearcher) + assert searcher._calendar is calendar + assert searcher.event is True + + # Test with multiple parameters + searcher = calendar.searcher( + todo=True, + start=datetime(2025, 1, 1), + end=datetime(2025, 12, 31), + expand=True, + ) + assert searcher.todo is True + assert searcher.start == datetime(2025, 1, 1) + assert searcher.end == datetime(2025, 12, 31) + assert searcher.expand is True + + # Test with sort keys + searcher = calendar.searcher(sort_keys=["due", "priority"], sort_reverse=True) + assert len(searcher._sort_keys) == 2 + + # Test with property filters + searcher = calendar.searcher(summary="meeting", location="office") + assert "summary" in searcher._property_filters + assert "location" in searcher._property_filters + + # Test with no_* filters (undef operator goes to _property_operator, not _property_filters) + searcher = calendar.searcher(no_summary=True) + assert searcher._property_operator.get("summary") == "undef" + + # Test that search() works without calendar argument + # Note: post_filter is a parameter to search(), not the searcher + mytasks = calendar.searcher(todo=True, expand=False).search(post_filter=True) + assert len(mytasks) == 1 + + def testSearcherWithoutCalendar(self): + """Test that CalDAVSearcher.search() raises ValueError without calendar.""" + from caldav.search import CalDAVSearcher + + searcher = CalDAVSearcher(event=True) + with pytest.raises(ValueError, match="No calendar provided"): + searcher.search() diff --git a/tests/test_compatibility_hints.py b/tests/test_compatibility_hints.py index 838cc37b..adbfbf01 100644 --- a/tests/test_compatibility_hints.py +++ b/tests/test_compatibility_hints.py @@ -241,3 +241,53 @@ def test_collapse_principal_search_real_scenario(self) -> None: # even if the behaviour message is different. assert "principal-search.list-all" not in fs._server_features assert "principal-search" in fs._server_features + + def test_independent_subfeature_not_derived(self) -> None: + """Test that independent subfeatures (with explicit defaults) don't affect parent derivation""" + fs = FeatureSet() + + # Scenario: create-calendar.auto is set to unsupported, but it's an independent + # feature (has explicit default) and should NOT cause create-calendar to be + # derived as unsupported + fs._server_features = { + "create-calendar.auto": {"support": "unsupported"}, + } + + # create-calendar should return its default (full), NOT derive from .auto + result = fs.is_supported("create-calendar", return_type=dict) + assert result == {"support": "full"}, ( + f"create-calendar should default to 'full' when only independent " + f"subfeature .auto is set, but got {result}" + ) + + # Verify that the independent subfeature itself is still accessible + auto_result = fs.is_supported("create-calendar.auto", return_type=dict) + assert auto_result == {"support": "unsupported"} + + def test_hierarchical_vs_independent_subfeatures(self) -> None: + """Test that hierarchical subfeatures derive parent, but independent ones don't""" + fs = FeatureSet() + + # Hierarchical subfeatures: principal-search.by-name and principal-search.list-all + # These should cause parent to derive to "unknown" when mixed + fs.set_feature("principal-search.by-name", {"support": "unknown"}) + fs.set_feature("principal-search.list-all", {"support": "unsupported"}) + + # Should derive to "unknown" due to mixed hierarchical subfeatures + result = fs.is_supported("principal-search", return_type=dict) + assert result == {"support": "unknown"}, ( + f"principal-search should derive to 'unknown' from mixed hierarchical " + f"subfeatures, but got {result}" + ) + + # Now test independent subfeature: create-calendar.auto + # This should NOT affect create-calendar parent + fs2 = FeatureSet() + fs2.set_feature("create-calendar.auto", {"support": "unsupported"}) + + # Should return default, NOT derive from independent subfeature + result2 = fs2.is_supported("create-calendar", return_type=dict) + assert result2 == {"support": "full"}, ( + f"create-calendar should default to 'full' ignoring independent " + f"subfeature .auto, but got {result2}" + ) diff --git a/tests/test_docs.py b/tests/test_docs.py index 571e752e..6c6dbfff 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,17 +1,26 @@ -import os import unittest import manuel.codeblock import manuel.doctest import manuel.testing +import pytest + +from .test_servers import client_context +from .test_servers import has_test_servers m = manuel.codeblock.Manuel() m += manuel.doctest.Manuel() manueltest = manuel.testing.TestFactory(m) +@pytest.mark.skipif(not has_test_servers(), reason="No test servers configured") class DocTests(unittest.TestCase): def setUp(self): - os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] = "1" + # Start a test server and configure environment for get_davclient() + self._test_context = client_context() + self._conn = self._test_context.__enter__() + + def tearDown(self): + self._test_context.__exit__(None, None, None) test_tutorial = manueltest("../docs/source/tutorial.rst") diff --git a/tests/test_examples.py b/tests/test_examples.py index 595d2545..30f4d169 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,24 +1,39 @@ -import os import sys from datetime import datetime +from pathlib import Path -from caldav.davclient import get_davclient +import pytest +from .test_servers import client_context +from .test_servers import has_test_servers +from caldav import get_davclient +# Get the project root directory (parent of tests/) +_PROJECT_ROOT = Path(__file__).parent.parent + + +@pytest.mark.skipif(not has_test_servers(), reason="No test servers configured") class TestExamples: - def setup_method(self): - os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] = "1" - sys.path.insert(0, ".") - sys.path.insert(1, "..") + @pytest.fixture(autouse=True) + def setup_test_server(self): + """Set up a test server config for get_davclient().""" + # Add project root to find examples/ + sys.path.insert(0, str(_PROJECT_ROOT)) + + # Start a test server and configure environment for get_davclient() + self._test_context = client_context() + self._conn = self._test_context.__enter__() + + yield - def teardown_method(self): - sys.path = sys.path[2:] - del os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] + # Cleanup + self._test_context.__exit__(None, None, None) + sys.path.remove(str(_PROJECT_ROOT)) def test_get_events_example(self): - with get_davclient() as client: - mycal = client.principal().make_calendar(name="Test calendar") - mycal.save_event( + with get_davclient() as dav_client: + mycal = dav_client.principal().make_calendar(name="Test calendar") + mycal.add_event( dtstart=datetime(2025, 5, 3, 10), dtend=datetime(2025, 5, 3, 11), summary="testevent", @@ -35,8 +50,8 @@ def test_basic_usage_examples(self): def test_collation(self): from examples import collation_usage - with get_davclient() as client: - mycal = client.principal().make_calendar(name="Test calendar") + with get_davclient() as dav_client: + mycal = dav_client.principal().make_calendar(name="Test calendar") collation_usage.run_examples() def test_rfc8764_test_conf(self): diff --git a/tests/test_operations_base.py b/tests/test_operations_base.py new file mode 100644 index 00000000..6de2aba4 --- /dev/null +++ b/tests/test_operations_base.py @@ -0,0 +1,192 @@ +""" +Tests for the operations layer base module. + +These tests verify the Sans-I/O utility functions work correctly +without any network I/O. +""" +import pytest + +from caldav.operations.base import _extract_resource_type as extract_resource_type +from caldav.operations.base import _get_property_value as get_property_value +from caldav.operations.base import _is_calendar_resource as is_calendar_resource +from caldav.operations.base import _is_collection_resource as is_collection_resource +from caldav.operations.base import _normalize_href as normalize_href +from caldav.operations.base import PropertyData +from caldav.operations.base import QuerySpec + + +class TestQuerySpec: + """Tests for QuerySpec dataclass.""" + + def test_query_spec_defaults(self): + """QuerySpec has sensible defaults.""" + spec = QuerySpec(url="/calendars/") + assert spec.url == "/calendars/" + assert spec.method == "PROPFIND" + assert spec.depth == 0 + assert spec.props == () + assert spec.body is None + + def test_query_spec_immutable(self): + """QuerySpec is immutable (frozen).""" + spec = QuerySpec(url="/test") + with pytest.raises(AttributeError): + spec.url = "/other" + + def test_query_spec_with_url(self): + """with_url() returns a new QuerySpec with different URL.""" + spec = QuerySpec(url="/old", method="REPORT", depth=1, props=("displayname",)) + new_spec = spec.with_url("/new") + + assert new_spec.url == "/new" + assert new_spec.method == "REPORT" + assert new_spec.depth == 1 + assert new_spec.props == ("displayname",) + # Original unchanged + assert spec.url == "/old" + + +class TestPropertyData: + """Tests for PropertyData dataclass.""" + + def test_property_data_defaults(self): + """PropertyData has sensible defaults.""" + data = PropertyData(href="/item") + assert data.href == "/item" + assert data.properties == {} + assert data.status == 200 + + def test_property_data_with_properties(self): + """PropertyData can store arbitrary properties.""" + data = PropertyData( + href="/cal/", + properties={ + "{DAV:}displayname": "My Calendar", + "{DAV:}resourcetype": ["collection"], + }, + status=200, + ) + assert data.properties["{DAV:}displayname"] == "My Calendar" + + +class TestNormalizeHref: + """Tests for normalize_href function.""" + + def test_normalize_empty(self): + """Empty href returns empty.""" + assert normalize_href("") == "" + + def test_normalize_double_slashes(self): + """Double slashes are normalized.""" + assert normalize_href("/path//to//resource") == "/path/to/resource" + + def test_normalize_preserves_http(self): + """HTTP URLs preserve double slashes in protocol.""" + result = normalize_href("https://example.com/path") + assert result == "https://example.com/path" + + def test_normalize_with_base_url(self): + """Relative URLs resolved against base.""" + result = normalize_href("/calendars/test/", "https://example.com/dav/") + # Should resolve to full URL + assert "calendars/test" in result + + +class TestExtractResourceType: + """Tests for extract_resource_type function.""" + + def test_extract_list(self): + """Extract list of resource types.""" + props = { + "{DAV:}resourcetype": [ + "{DAV:}collection", + "{urn:ietf:params:xml:ns:caldav}calendar", + ] + } + result = extract_resource_type(props) + assert "{DAV:}collection" in result + assert "{urn:ietf:params:xml:ns:caldav}calendar" in result + + def test_extract_single_value(self): + """Extract single resource type.""" + props = {"{DAV:}resourcetype": "{DAV:}collection"} + result = extract_resource_type(props) + assert result == ["{DAV:}collection"] + + def test_extract_none(self): + """Missing resourcetype returns empty list.""" + props = {"{DAV:}displayname": "Test"} + result = extract_resource_type(props) + assert result == [] + + def test_extract_explicit_none(self): + """Explicit None resourcetype returns empty list.""" + props = {"{DAV:}resourcetype": None} + result = extract_resource_type(props) + assert result == [] + + +class TestIsCalendarResource: + """Tests for is_calendar_resource function.""" + + def test_is_calendar(self): + """Detect calendar resource.""" + props = { + "{DAV:}resourcetype": [ + "{DAV:}collection", + "{urn:ietf:params:xml:ns:caldav}calendar", + ] + } + assert is_calendar_resource(props) is True + + def test_is_not_calendar(self): + """Non-calendar collection.""" + props = {"{DAV:}resourcetype": ["{DAV:}collection"]} + assert is_calendar_resource(props) is False + + def test_empty_props(self): + """Empty properties.""" + assert is_calendar_resource({}) is False + + +class TestIsCollectionResource: + """Tests for is_collection_resource function.""" + + def test_is_collection(self): + """Detect collection resource.""" + props = {"{DAV:}resourcetype": ["{DAV:}collection"]} + assert is_collection_resource(props) is True + + def test_is_not_collection(self): + """Non-collection resource.""" + props = {"{DAV:}resourcetype": []} + assert is_collection_resource(props) is False + + +class TestGetPropertyValue: + """Tests for get_property_value function.""" + + def test_get_exact_key(self): + """Get property with exact key.""" + props = {"{DAV:}displayname": "Test Calendar"} + assert get_property_value(props, "{DAV:}displayname") == "Test Calendar" + + def test_get_simple_key_dav_namespace(self): + """Get property with simple key, DAV namespace.""" + props = {"{DAV:}displayname": "Test Calendar"} + assert get_property_value(props, "displayname") == "Test Calendar" + + def test_get_simple_key_caldav_namespace(self): + """Get property with simple key, CalDAV namespace.""" + props = {"{urn:ietf:params:xml:ns:caldav}calendar-data": "BEGIN:VCALENDAR..."} + assert get_property_value(props, "calendar-data") == "BEGIN:VCALENDAR..." + + def test_get_missing_with_default(self): + """Missing property returns default.""" + props = {"{DAV:}displayname": "Test"} + assert get_property_value(props, "nonexistent", "default") == "default" + + def test_get_missing_no_default(self): + """Missing property returns None by default.""" + props = {} + assert get_property_value(props, "nonexistent") is None diff --git a/tests/test_operations_calendar.py b/tests/test_operations_calendar.py new file mode 100644 index 00000000..5c70bc2e --- /dev/null +++ b/tests/test_operations_calendar.py @@ -0,0 +1,353 @@ +""" +Tests for the Calendar operations module. + +These tests verify the Sans-I/O business logic for Calendar operations +like component detection, sync tokens, and result processing. +""" +import pytest + +from caldav.operations.calendar_ops import ( + _build_calendar_object_url as build_calendar_object_url, +) +from caldav.operations.calendar_ops import ( + _detect_component_type as detect_component_type, +) +from caldav.operations.calendar_ops import ( + _detect_component_type_from_icalendar as detect_component_type_from_icalendar, +) +from caldav.operations.calendar_ops import ( + _detect_component_type_from_string as detect_component_type_from_string, +) +from caldav.operations.calendar_ops import ( + _generate_fake_sync_token as generate_fake_sync_token, +) +from caldav.operations.calendar_ops import _is_fake_sync_token as is_fake_sync_token +from caldav.operations.calendar_ops import _normalize_result_url as normalize_result_url +from caldav.operations.calendar_ops import ( + _process_report_results as process_report_results, +) +from caldav.operations.calendar_ops import ( + _should_skip_calendar_self_reference as should_skip_calendar_self_reference, +) +from caldav.operations.calendar_ops import CalendarObjectInfo + + +class TestDetectComponentTypeFromString: + """Tests for detect_component_type_from_string function.""" + + def test_detects_vevent(self): + """Detects VEVENT component.""" + data = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Test\nEND:VEVENT\nEND:VCALENDAR" + assert detect_component_type_from_string(data) == "Event" + + def test_detects_vtodo(self): + """Detects VTODO component.""" + data = "BEGIN:VCALENDAR\nBEGIN:VTODO\nSUMMARY:Task\nEND:VTODO\nEND:VCALENDAR" + assert detect_component_type_from_string(data) == "Todo" + + def test_detects_vjournal(self): + """Detects VJOURNAL component.""" + data = ( + "BEGIN:VCALENDAR\nBEGIN:VJOURNAL\nSUMMARY:Note\nEND:VJOURNAL\nEND:VCALENDAR" + ) + assert detect_component_type_from_string(data) == "Journal" + + def test_detects_vfreebusy(self): + """Detects VFREEBUSY component.""" + data = "BEGIN:VCALENDAR\nBEGIN:VFREEBUSY\nEND:VFREEBUSY\nEND:VCALENDAR" + assert detect_component_type_from_string(data) == "FreeBusy" + + def test_returns_none_for_unknown(self): + """Returns None for unknown component types.""" + data = "BEGIN:VCALENDAR\nBEGIN:VTIMEZONE\nEND:VTIMEZONE\nEND:VCALENDAR" + assert detect_component_type_from_string(data) is None + + def test_handles_whitespace(self): + """Handles lines with extra whitespace.""" + data = ( + "BEGIN:VCALENDAR\n BEGIN:VEVENT \nSUMMARY:Test\nEND:VEVENT\nEND:VCALENDAR" + ) + assert detect_component_type_from_string(data) == "Event" + + +class TestDetectComponentTypeFromIcalendar: + """Tests for detect_component_type_from_icalendar function.""" + + def test_detects_event(self): + """Detects Event from icalendar object.""" + import icalendar + + cal = icalendar.Calendar() + event = icalendar.Event() + event.add("summary", "Test") + cal.add_component(event) + + assert detect_component_type_from_icalendar(cal) == "Event" + + def test_detects_todo(self): + """Detects Todo from icalendar object.""" + import icalendar + + cal = icalendar.Calendar() + todo = icalendar.Todo() + todo.add("summary", "Task") + cal.add_component(todo) + + assert detect_component_type_from_icalendar(cal) == "Todo" + + def test_returns_none_for_empty(self): + """Returns None for empty calendar.""" + import icalendar + + cal = icalendar.Calendar() + assert detect_component_type_from_icalendar(cal) is None + + def test_returns_none_for_no_subcomponents(self): + """Returns None when no subcomponents attribute.""" + obj = {"test": "value"} + assert detect_component_type_from_icalendar(obj) is None + + +class TestDetectComponentType: + """Tests for detect_component_type function.""" + + def test_detects_from_string(self): + """Detects from string data.""" + data = "BEGIN:VCALENDAR\nBEGIN:VTODO\nSUMMARY:Task\nEND:VTODO\nEND:VCALENDAR" + assert detect_component_type(data) == "Todo" + + def test_detects_from_icalendar(self): + """Detects from icalendar object.""" + import icalendar + + cal = icalendar.Calendar() + cal.add_component(icalendar.Journal()) + + assert detect_component_type(cal) == "Journal" + + def test_returns_none_for_none(self): + """Returns None for None input.""" + assert detect_component_type(None) is None + + +class TestGenerateFakeSyncToken: + """Tests for generate_fake_sync_token function.""" + + def test_generates_deterministic_token(self): + """Same input produces same token.""" + etags_urls = [("etag1", "/url1"), ("etag2", "/url2")] + + token1 = generate_fake_sync_token(etags_urls) + token2 = generate_fake_sync_token(etags_urls) + + assert token1 == token2 + + def test_prefix(self): + """Token starts with 'fake-' prefix.""" + token = generate_fake_sync_token([("etag", "/url")]) + assert token.startswith("fake-") + + def test_different_input_different_token(self): + """Different input produces different token.""" + token1 = generate_fake_sync_token([("etag1", "/url1")]) + token2 = generate_fake_sync_token([("etag2", "/url2")]) + + assert token1 != token2 + + def test_order_independent(self): + """Order of inputs doesn't affect token.""" + etags1 = [("a", "/a"), ("b", "/b")] + etags2 = [("b", "/b"), ("a", "/a")] + + assert generate_fake_sync_token(etags1) == generate_fake_sync_token(etags2) + + def test_uses_url_when_no_etag(self): + """Uses URL as fallback when etag is None.""" + token = generate_fake_sync_token([(None, "/url1"), (None, "/url2")]) + assert token.startswith("fake-") + + def test_empty_list(self): + """Handles empty list.""" + token = generate_fake_sync_token([]) + assert token.startswith("fake-") + + +class TestIsFakeSyncToken: + """Tests for is_fake_sync_token function.""" + + def test_detects_fake_token(self): + """Detects fake sync tokens.""" + assert is_fake_sync_token("fake-abc123") is True + + def test_rejects_real_token(self): + """Rejects tokens without fake- prefix.""" + assert is_fake_sync_token("http://example.com/sync/token123") is False + + def test_handles_none(self): + """Handles None input.""" + assert is_fake_sync_token(None) is False + + def test_handles_non_string(self): + """Handles non-string input.""" + assert is_fake_sync_token(12345) is False + + +class TestNormalizeResultUrl: + """Tests for normalize_result_url function.""" + + def test_quotes_relative_path(self): + """Quotes special characters in relative paths.""" + result = normalize_result_url("/calendars/event with spaces.ics", "/calendars/") + assert "%20" in result + + def test_preserves_full_url(self): + """Preserves full URLs as-is.""" + url = "https://example.com/calendars/event.ics" + result = normalize_result_url(url, "/calendars/") + assert result == url + + +class TestShouldSkipCalendarSelfReference: + """Tests for should_skip_calendar_self_reference function.""" + + def test_skips_exact_match(self): + """Skips when URLs match exactly.""" + assert ( + should_skip_calendar_self_reference("/calendars/work/", "/calendars/work/") + is True + ) + + def test_skips_trailing_slash_difference(self): + """Skips when URLs differ only by trailing slash.""" + assert ( + should_skip_calendar_self_reference("/calendars/work", "/calendars/work/") + is True + ) + assert ( + should_skip_calendar_self_reference("/calendars/work/", "/calendars/work") + is True + ) + + def test_does_not_skip_different_urls(self): + """Does not skip different URLs.""" + assert ( + should_skip_calendar_self_reference( + "/calendars/work/event.ics", "/calendars/work/" + ) + is False + ) + + +class TestProcessReportResults: + """Tests for process_report_results function.""" + + def test_processes_results(self): + """Processes results into CalendarObjectInfo objects.""" + results = { + "/cal/event1.ics": { + "{urn:ietf:params:xml:ns:caldav}calendar-data": "BEGIN:VCALENDAR\nBEGIN:VEVENT\nEND:VEVENT\nEND:VCALENDAR", + "{DAV:}getetag": '"etag1"', + }, + "/cal/todo1.ics": { + "{urn:ietf:params:xml:ns:caldav}calendar-data": "BEGIN:VCALENDAR\nBEGIN:VTODO\nEND:VTODO\nEND:VCALENDAR", + }, + } + + objects = process_report_results(results, "/cal/") + + assert len(objects) == 2 + + # Find event and todo + event = next(o for o in objects if o.component_type == "Event") + todo = next(o for o in objects if o.component_type == "Todo") + + assert event.etag == '"etag1"' + assert todo.etag is None + + def test_skips_calendar_self_reference(self): + """Filters out calendar self-reference.""" + results = { + "/cal/": { # Calendar itself - should be skipped + "{DAV:}resourcetype": "{DAV:}collection", + }, + "/cal/event.ics": { + "{urn:ietf:params:xml:ns:caldav}calendar-data": "BEGIN:VCALENDAR\nBEGIN:VEVENT\nEND:VEVENT\nEND:VCALENDAR", + }, + } + + objects = process_report_results(results, "/cal/") + + # Only the event should be returned + assert len(objects) == 1 + assert "event" in objects[0].url + + def test_handles_empty_results(self): + """Returns empty list for empty results.""" + assert process_report_results({}, "/cal/") == [] + + +class TestBuildCalendarObjectUrl: + """Tests for build_calendar_object_url function.""" + + def test_builds_url(self): + """Builds calendar object URL from calendar URL and ID.""" + result = build_calendar_object_url( + "https://example.com/calendars/work/", "event123" + ) + assert result == "https://example.com/calendars/work/event123.ics" + + def test_handles_trailing_slash(self): + """Handles calendar URL with or without trailing slash.""" + result = build_calendar_object_url( + "https://example.com/calendars/work", "event123" + ) + assert result == "https://example.com/calendars/work/event123.ics" + + def test_doesnt_double_ics(self): + """Doesn't add .ics if already present.""" + result = build_calendar_object_url( + "https://example.com/calendars/work/", "event123.ics" + ) + assert result == "https://example.com/calendars/work/event123.ics" + assert ".ics.ics" not in result + + def test_quotes_special_chars(self): + """Quotes special characters in object ID.""" + result = build_calendar_object_url( + "https://example.com/calendars/", "event with spaces" + ) + assert "%20" in result + + +class TestCalendarObjectInfo: + """Tests for CalendarObjectInfo dataclass.""" + + def test_creates_info(self): + """Creates CalendarObjectInfo with all fields.""" + info = CalendarObjectInfo( + url="/calendars/work/event.ics", + data="BEGIN:VCALENDAR...", + etag='"abc123"', + component_type="Event", + extra_props={"custom": "value"}, + ) + + assert info.url == "/calendars/work/event.ics" + assert info.data == "BEGIN:VCALENDAR..." + assert info.etag == '"abc123"' + assert info.component_type == "Event" + assert info.extra_props == {"custom": "value"} + + def test_allows_none_values(self): + """Allows None values for optional fields.""" + info = CalendarObjectInfo( + url="/calendars/work/event.ics", + data=None, + etag=None, + component_type=None, + extra_props={}, + ) + + assert info.data is None + assert info.etag is None + assert info.component_type is None diff --git a/tests/test_operations_calendarobject.py b/tests/test_operations_calendarobject.py new file mode 100644 index 00000000..74eb6c8d --- /dev/null +++ b/tests/test_operations_calendarobject.py @@ -0,0 +1,534 @@ +""" +Tests for CalendarObjectResource operations module. + +These tests verify the Sans-I/O business logic for calendar objects +without any network I/O. +""" +from datetime import datetime +from datetime import timedelta +from datetime import timezone + +import icalendar +import pytest + +from caldav.operations.calendarobject_ops import ( + _calculate_next_recurrence as calculate_next_recurrence, +) +from caldav.operations.calendarobject_ops import ( + _copy_component_with_new_uid as copy_component_with_new_uid, +) +from caldav.operations.calendarobject_ops import _extract_relations as extract_relations +from caldav.operations.calendarobject_ops import ( + _extract_uid_from_path as extract_uid_from_path, +) +from caldav.operations.calendarobject_ops import _find_id_and_path as find_id_and_path +from caldav.operations.calendarobject_ops import _generate_uid as generate_uid +from caldav.operations.calendarobject_ops import _generate_url as generate_url +from caldav.operations.calendarobject_ops import _get_due as get_due +from caldav.operations.calendarobject_ops import _get_duration as get_duration +from caldav.operations.calendarobject_ops import ( + _get_non_timezone_subcomponents as get_non_timezone_subcomponents, +) +from caldav.operations.calendarobject_ops import ( + _get_primary_component as get_primary_component, +) +from caldav.operations.calendarobject_ops import ( + _get_reverse_reltype as get_reverse_reltype, +) +from caldav.operations.calendarobject_ops import ( + _has_calendar_component as has_calendar_component, +) +from caldav.operations.calendarobject_ops import ( + _is_calendar_data_loaded as is_calendar_data_loaded, +) +from caldav.operations.calendarobject_ops import _is_task_pending as is_task_pending +from caldav.operations.calendarobject_ops import ( + _mark_task_completed as mark_task_completed, +) +from caldav.operations.calendarobject_ops import ( + _mark_task_uncompleted as mark_task_uncompleted, +) +from caldav.operations.calendarobject_ops import ( + _reduce_rrule_count as reduce_rrule_count, +) +from caldav.operations.calendarobject_ops import _set_duration as set_duration + + +class TestGenerateUid: + """Tests for generate_uid function.""" + + def test_generates_unique_uids(self): + """Each call generates a unique UID.""" + uids = {generate_uid() for _ in range(100)} + assert len(uids) == 100 + + def test_uid_is_string(self): + """UID is a string.""" + assert isinstance(generate_uid(), str) + + +class TestGenerateUrl: + """Tests for generate_url function.""" + + def test_basic_url(self): + """Generates correct URL from parent and UID.""" + url = generate_url("/calendars/user/cal/", "event-123") + assert url == "/calendars/user/cal/event-123.ics" + + def test_adds_trailing_slash(self): + """Adds trailing slash to parent if missing.""" + url = generate_url("/calendars/user/cal", "event-123") + assert url == "/calendars/user/cal/event-123.ics" + + def test_quotes_special_chars(self): + """Special characters in UID are quoted.""" + url = generate_url("/cal/", "event with spaces") + assert "event%20with%20spaces.ics" in url + + def test_double_quotes_slashes(self): + """Slashes in UID are double-quoted.""" + url = generate_url("/cal/", "event/with/slashes") + assert "%252F" in url # %2F is quoted again + + +class TestExtractUidFromPath: + """Tests for extract_uid_from_path function.""" + + def test_extracts_uid(self): + """Extracts UID from .ics path.""" + uid = extract_uid_from_path("/calendars/user/cal/event-123.ics") + assert uid == "event-123" + + def test_returns_none_for_non_ics(self): + """Returns None for non-.ics paths.""" + assert extract_uid_from_path("/calendars/user/cal/") is None + + def test_handles_simple_path(self): + """Handles simple filename.""" + uid = extract_uid_from_path("event.ics") + assert uid == "event" + + +class TestFindIdAndPath: + """Tests for find_id_and_path function.""" + + def test_uses_given_id(self): + """Given ID takes precedence.""" + comp = icalendar.Event() + comp.add("UID", "old-uid") + uid, path = find_id_and_path(comp, given_id="new-uid") + assert uid == "new-uid" + assert comp["UID"] == "new-uid" + + def test_uses_existing_id(self): + """Uses existing_id if no given_id.""" + comp = icalendar.Event() + uid, path = find_id_and_path(comp, existing_id="existing") + assert uid == "existing" + + def test_extracts_from_component(self): + """Extracts UID from component.""" + comp = icalendar.Event() + comp.add("UID", "comp-uid") + uid, path = find_id_and_path(comp) + assert uid == "comp-uid" + + def test_extracts_from_path(self): + """Extracts UID from path.""" + comp = icalendar.Event() + uid, path = find_id_and_path(comp, given_path="event-from-path.ics") + assert uid == "event-from-path" + + def test_generates_new_uid(self): + """Generates new UID if none available.""" + comp = icalendar.Event() + uid, path = find_id_and_path(comp) + assert uid is not None + assert len(uid) > 0 + + def test_generates_path(self): + """Generates path from UID.""" + comp = icalendar.Event() + uid, path = find_id_and_path(comp, given_id="test-uid") + assert path == "test-uid.ics" + + +class TestGetDuration: + """Tests for get_duration function.""" + + def test_from_duration_property(self): + """Gets duration from DURATION property.""" + comp = icalendar.Event() + comp.add("DURATION", timedelta(hours=2)) + assert get_duration(comp) == timedelta(hours=2) + + def test_from_dtstart_dtend(self): + """Calculates duration from DTSTART and DTEND.""" + comp = icalendar.Event() + comp.add("DTSTART", datetime(2024, 1, 1, 10, 0)) + comp.add("DTEND", datetime(2024, 1, 1, 12, 0)) + assert get_duration(comp, "DTEND") == timedelta(hours=2) + + def test_from_dtstart_due(self): + """Calculates duration from DTSTART and DUE (for todos).""" + comp = icalendar.Todo() + comp.add("DTSTART", datetime(2024, 1, 1, 10, 0)) + comp.add("DUE", datetime(2024, 1, 1, 11, 0)) + assert get_duration(comp, "DUE") == timedelta(hours=1) + + def test_date_only_default_one_day(self): + """Date-only DTSTART defaults to 1 day duration.""" + from datetime import date + + comp = icalendar.Event() + comp.add("DTSTART", date(2024, 1, 1)) + assert get_duration(comp) == timedelta(days=1) + + def test_no_duration_returns_zero(self): + """Returns zero if no duration info available.""" + comp = icalendar.Event() + assert get_duration(comp) == timedelta(0) + + +class TestGetDue: + """Tests for get_due function.""" + + def test_from_due_property(self): + """Gets due from DUE property.""" + comp = icalendar.Todo() + due = datetime(2024, 1, 15, 17, 0) + comp.add("DUE", due) + assert get_due(comp) == due + + def test_from_dtend(self): + """Falls back to DTEND.""" + comp = icalendar.Todo() + dtend = datetime(2024, 1, 15, 17, 0) + comp.add("DTEND", dtend) + assert get_due(comp) == dtend + + def test_calculated_from_duration(self): + """Calculates from DTSTART + DURATION.""" + comp = icalendar.Todo() + comp.add("DTSTART", datetime(2024, 1, 15, 10, 0)) + comp.add("DURATION", timedelta(hours=7)) + assert get_due(comp) == datetime(2024, 1, 15, 17, 0) + + def test_returns_none(self): + """Returns None if no due info.""" + comp = icalendar.Todo() + assert get_due(comp) is None + + +class TestSetDuration: + """Tests for set_duration function.""" + + def test_with_dtstart_and_due(self): + """Moves DUE when both set.""" + comp = icalendar.Todo() + comp.add("DTSTART", datetime(2024, 1, 1, 10, 0)) + comp.add("DUE", datetime(2024, 1, 1, 11, 0)) + + set_duration(comp, timedelta(hours=3), movable_attr="DUE") + + assert comp["DUE"].dt == datetime(2024, 1, 1, 13, 0) + + def test_move_dtstart(self): + """Moves DTSTART when specified.""" + comp = icalendar.Todo() + comp.add("DTSTART", datetime(2024, 1, 1, 10, 0)) + comp.add("DUE", datetime(2024, 1, 1, 12, 0)) + + set_duration(comp, timedelta(hours=1), movable_attr="DTSTART") + + assert comp["DTSTART"].dt == datetime(2024, 1, 1, 11, 0) + + def test_adds_duration_if_no_dates(self): + """Adds DURATION property if no dates set.""" + comp = icalendar.Todo() + set_duration(comp, timedelta(hours=2)) + assert comp["DURATION"].dt == timedelta(hours=2) + + +class TestIsTaskPending: + """Tests for is_task_pending function.""" + + def test_needs_action_is_pending(self): + """NEEDS-ACTION status is pending.""" + comp = icalendar.Todo() + comp.add("STATUS", "NEEDS-ACTION") + assert is_task_pending(comp) is True + + def test_in_process_is_pending(self): + """IN-PROCESS status is pending.""" + comp = icalendar.Todo() + comp.add("STATUS", "IN-PROCESS") + assert is_task_pending(comp) is True + + def test_completed_is_not_pending(self): + """COMPLETED status is not pending.""" + comp = icalendar.Todo() + comp.add("STATUS", "COMPLETED") + assert is_task_pending(comp) is False + + def test_cancelled_is_not_pending(self): + """CANCELLED status is not pending.""" + comp = icalendar.Todo() + comp.add("STATUS", "CANCELLED") + assert is_task_pending(comp) is False + + def test_completed_property_is_not_pending(self): + """COMPLETED property means not pending.""" + comp = icalendar.Todo() + comp.add("COMPLETED", datetime.now(timezone.utc)) + assert is_task_pending(comp) is False + + def test_no_status_is_pending(self): + """No status defaults to pending.""" + comp = icalendar.Todo() + assert is_task_pending(comp) is True + + +class TestMarkTaskCompleted: + """Tests for mark_task_completed function.""" + + def test_marks_completed(self): + """Sets STATUS to COMPLETED.""" + comp = icalendar.Todo() + comp.add("STATUS", "NEEDS-ACTION") + ts = datetime(2024, 1, 15, 12, 0, tzinfo=timezone.utc) + + mark_task_completed(comp, ts) + + assert comp["STATUS"] == "COMPLETED" + assert comp["COMPLETED"].dt == ts + + def test_uses_current_time(self): + """Uses current time if not specified.""" + comp = icalendar.Todo() + mark_task_completed(comp) + assert "COMPLETED" in comp + + +class TestMarkTaskUncompleted: + """Tests for mark_task_uncompleted function.""" + + def test_marks_uncompleted(self): + """Removes completion and sets NEEDS-ACTION.""" + comp = icalendar.Todo() + comp.add("STATUS", "COMPLETED") + comp.add("COMPLETED", datetime.now(timezone.utc)) + + mark_task_uncompleted(comp) + + assert comp["STATUS"] == "NEEDS-ACTION" + assert "COMPLETED" not in comp + + +class TestReduceRruleCount: + """Tests for reduce_rrule_count function.""" + + def test_reduces_count(self): + """Reduces COUNT by 1.""" + comp = icalendar.Todo() + comp.add("RRULE", {"FREQ": "WEEKLY", "COUNT": 5}) + + result = reduce_rrule_count(comp) + + assert result is True + # icalendar stores COUNT as list via .get() or int via [] + count = comp["RRULE"].get("COUNT") + count_val = count[0] if isinstance(count, list) else count + assert count_val == 4 + + def test_returns_false_at_one(self): + """Returns False when COUNT reaches 1.""" + comp = icalendar.Todo() + comp.add("RRULE", {"FREQ": "WEEKLY", "COUNT": 1}) + + result = reduce_rrule_count(comp) + + assert result is False + + def test_no_count_returns_true(self): + """Returns True if no COUNT in RRULE.""" + comp = icalendar.Todo() + comp.add("RRULE", {"FREQ": "WEEKLY"}) + + result = reduce_rrule_count(comp) + + assert result is True + + +class TestIsCalendarDataLoaded: + """Tests for is_calendar_data_loaded function.""" + + def test_loaded_with_data(self): + """Returns True with valid data.""" + data = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nEND:VEVENT\nEND:VCALENDAR" + assert is_calendar_data_loaded(data, None, None) is True + + def test_loaded_with_icalendar(self): + """Returns True with icalendar instance.""" + assert is_calendar_data_loaded(None, None, icalendar.Calendar()) is True + + def test_not_loaded_empty(self): + """Returns False with no data.""" + assert is_calendar_data_loaded(None, None, None) is False + + +class TestHasCalendarComponent: + """Tests for has_calendar_component function.""" + + def test_has_vevent(self): + """Returns True for VEVENT.""" + data = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nEND:VEVENT\nEND:VCALENDAR" + assert has_calendar_component(data) is True + + def test_has_vtodo(self): + """Returns True for VTODO.""" + data = "BEGIN:VCALENDAR\nBEGIN:VTODO\nEND:VTODO\nEND:VCALENDAR" + assert has_calendar_component(data) is True + + def test_has_vjournal(self): + """Returns True for VJOURNAL.""" + data = "BEGIN:VCALENDAR\nBEGIN:VJOURNAL\nEND:VJOURNAL\nEND:VCALENDAR" + assert has_calendar_component(data) is True + + def test_no_component(self): + """Returns False for no component.""" + data = "BEGIN:VCALENDAR\nEND:VCALENDAR" + assert has_calendar_component(data) is False + + def test_empty_data(self): + """Returns False for empty data.""" + assert has_calendar_component(None) is False + + +class TestGetNonTimezoneSubcomponents: + """Tests for get_non_timezone_subcomponents function.""" + + def test_filters_timezone(self): + """Filters out VTIMEZONE components.""" + cal = icalendar.Calendar() + cal.add_component(icalendar.Event()) + cal.add_component(icalendar.Timezone()) + cal.add_component(icalendar.Todo()) + + comps = get_non_timezone_subcomponents(cal) + + assert len(comps) == 2 + assert all(not isinstance(c, icalendar.Timezone) for c in comps) + + +class TestGetPrimaryComponent: + """Tests for get_primary_component function.""" + + def test_gets_event(self): + """Gets VEVENT component.""" + cal = icalendar.Calendar() + event = icalendar.Event() + cal.add_component(event) + + assert get_primary_component(cal) is event + + def test_gets_todo(self): + """Gets VTODO component.""" + cal = icalendar.Calendar() + todo = icalendar.Todo() + cal.add_component(todo) + + assert get_primary_component(cal) is todo + + def test_skips_timezone(self): + """Skips VTIMEZONE.""" + cal = icalendar.Calendar() + cal.add_component(icalendar.Timezone()) + event = icalendar.Event() + cal.add_component(event) + + assert get_primary_component(cal) is event + + +class TestCopyComponentWithNewUid: + """Tests for copy_component_with_new_uid function.""" + + def test_copies_with_new_uid(self): + """Creates copy with new UID.""" + comp = icalendar.Event() + comp.add("UID", "old-uid") + comp.add("SUMMARY", "Test Event") + + new_comp = copy_component_with_new_uid(comp, "new-uid") + + assert new_comp["UID"] == "new-uid" + assert new_comp["SUMMARY"] == "Test Event" + assert comp["UID"] == "old-uid" # Original unchanged + + def test_generates_uid(self): + """Generates UID if not provided.""" + comp = icalendar.Event() + comp.add("UID", "old-uid") + + new_comp = copy_component_with_new_uid(comp) + + assert new_comp["UID"] != "old-uid" + assert new_comp["UID"] is not None + + +class TestGetReverseReltype: + """Tests for get_reverse_reltype function.""" + + def test_parent_child(self): + """PARENT reverses to CHILD.""" + assert get_reverse_reltype("PARENT") == "CHILD" + + def test_child_parent(self): + """CHILD reverses to PARENT.""" + assert get_reverse_reltype("CHILD") == "PARENT" + + def test_sibling(self): + """SIBLING reverses to SIBLING.""" + assert get_reverse_reltype("SIBLING") == "SIBLING" + + def test_unknown(self): + """Unknown type returns None.""" + assert get_reverse_reltype("UNKNOWN") is None + + def test_case_insensitive(self): + """Case insensitive matching.""" + assert get_reverse_reltype("parent") == "CHILD" + + +class TestExtractRelations: + """Tests for extract_relations function.""" + + def test_extracts_relations(self): + """Extracts RELATED-TO properties.""" + comp = icalendar.Todo() + comp.add("RELATED-TO", "parent-uid", parameters={"RELTYPE": "PARENT"}) + + relations = extract_relations(comp) + + assert "PARENT" in relations + assert "parent-uid" in relations["PARENT"] + + def test_filters_by_reltype(self): + """Filters by relation type.""" + comp = icalendar.Todo() + comp.add("RELATED-TO", "parent-uid", parameters={"RELTYPE": "PARENT"}) + comp.add("RELATED-TO", "child-uid", parameters={"RELTYPE": "CHILD"}) + + relations = extract_relations(comp, reltypes={"PARENT"}) + + assert "PARENT" in relations + assert "CHILD" not in relations + + def test_default_parent(self): + """Defaults to PARENT if no RELTYPE.""" + comp = icalendar.Todo() + comp.add("RELATED-TO", "some-uid") + + relations = extract_relations(comp) + + assert "PARENT" in relations diff --git a/tests/test_operations_calendarset.py b/tests/test_operations_calendarset.py new file mode 100644 index 00000000..64cce633 --- /dev/null +++ b/tests/test_operations_calendarset.py @@ -0,0 +1,286 @@ +""" +Tests for the CalendarSet operations module. + +These tests verify the Sans-I/O business logic for CalendarSet operations +like extracting calendar IDs and resolving calendar URLs. +""" +import pytest + +from caldav.operations.calendarset_ops import ( + _extract_calendar_id_from_url as extract_calendar_id_from_url, +) +from caldav.operations.calendarset_ops import ( + _find_calendar_by_id as find_calendar_by_id, +) +from caldav.operations.calendarset_ops import ( + _find_calendar_by_name as find_calendar_by_name, +) +from caldav.operations.calendarset_ops import ( + _process_calendar_list as process_calendar_list, +) +from caldav.operations.calendarset_ops import ( + _resolve_calendar_url as resolve_calendar_url, +) +from caldav.operations.calendarset_ops import CalendarInfo + + +class TestExtractCalendarIdFromUrl: + """Tests for extract_calendar_id_from_url function.""" + + def test_extracts_id_from_path(self): + """Extracts calendar ID from standard path.""" + url = "/calendars/user/my-calendar/" + assert extract_calendar_id_from_url(url) == "my-calendar" + + def test_extracts_id_without_trailing_slash(self): + """Extracts calendar ID from path without trailing slash.""" + url = "/calendars/user/my-calendar" + assert extract_calendar_id_from_url(url) == "my-calendar" + + def test_extracts_id_from_full_url(self): + """Extracts calendar ID from full URL.""" + url = "https://example.com/calendars/user/work/" + assert extract_calendar_id_from_url(url) == "work" + + def test_returns_none_for_empty_id(self): + """Returns None when ID would be empty.""" + url = "/calendars/user//" + # After stripping trailing slashes and splitting, last part is empty + result = extract_calendar_id_from_url(url) + # Implementation should handle this gracefully + assert result is not None # Actually gets "user" + + def test_handles_root_url(self): + """Handles URLs with minimal path.""" + url = "/calendar/" + assert extract_calendar_id_from_url(url) == "calendar" + + +class TestProcessCalendarList: + """Tests for process_calendar_list function.""" + + def test_processes_children_data(self): + """Processes children data into CalendarInfo objects.""" + children_data = [ + ( + "/calendars/user/work/", + ["{DAV:}collection", "{urn:ietf:params:xml:ns:caldav}calendar"], + "Work", + ), + ( + "/calendars/user/personal/", + ["{DAV:}collection", "{urn:ietf:params:xml:ns:caldav}calendar"], + "Personal", + ), + ] + + result = process_calendar_list(children_data) + + assert len(result) == 2 + assert result[0].url == "/calendars/user/work/" + assert result[0].cal_id == "work" + assert result[0].name == "Work" + assert result[1].cal_id == "personal" + assert result[1].name == "Personal" + + def test_skips_entries_with_no_id(self): + """Skips entries where calendar ID cannot be extracted.""" + children_data = [ + ("/", ["{DAV:}collection"], None), # Root has no meaningful ID + ("/calendars/user/work/", ["{DAV:}collection"], "Work"), + ] + + result = process_calendar_list(children_data) + + # Only the work calendar should be included + assert len(result) == 1 + assert result[0].cal_id == "work" + + def test_handles_empty_list(self): + """Returns empty list for empty input.""" + assert process_calendar_list([]) == [] + + +class TestResolveCalendarUrl: + """Tests for resolve_calendar_url function.""" + + def test_resolves_relative_id(self): + """Resolves a simple calendar ID to full URL.""" + result = resolve_calendar_url( + cal_id="my-calendar", + parent_url="https://example.com/calendars/user/", + client_base_url="https://example.com", + ) + + assert result == "https://example.com/calendars/user/my-calendar/" + + def test_resolves_full_url_under_client(self): + """Handles full URLs that are under client base.""" + result = resolve_calendar_url( + cal_id="https://example.com/calendars/user/work/", + parent_url="https://example.com/calendars/user/", + client_base_url="https://example.com", + ) + + # Should join with client URL + assert "work" in result + + def test_resolves_full_url_different_host(self): + """Handles full URLs with different host.""" + result = resolve_calendar_url( + cal_id="https://other.example.com/calendars/work/", + parent_url="https://example.com/calendars/user/", + client_base_url="https://example.com", + ) + + # Should join with parent URL + assert "work" in result + + def test_quotes_special_characters(self): + """Quotes special characters in calendar ID.""" + result = resolve_calendar_url( + cal_id="calendar with spaces", + parent_url="https://example.com/calendars/", + client_base_url="https://example.com", + ) + + assert "calendar%20with%20spaces" in result + + def test_adds_trailing_slash(self): + """Adds trailing slash to calendar URL.""" + result = resolve_calendar_url( + cal_id="work", + parent_url="https://example.com/calendars/", + client_base_url="https://example.com", + ) + + assert result.endswith("/") + + +class TestFindCalendarByName: + """Tests for find_calendar_by_name function.""" + + def test_finds_calendar_by_name(self): + """Finds a calendar by its display name.""" + calendars = [ + CalendarInfo( + url="/cal/work/", cal_id="work", name="Work", resource_types=[] + ), + CalendarInfo( + url="/cal/personal/", + cal_id="personal", + name="Personal", + resource_types=[], + ), + ] + + result = find_calendar_by_name(calendars, "Personal") + + assert result is not None + assert result.cal_id == "personal" + + def test_returns_none_if_not_found(self): + """Returns None if no calendar matches.""" + calendars = [ + CalendarInfo( + url="/cal/work/", cal_id="work", name="Work", resource_types=[] + ), + ] + + result = find_calendar_by_name(calendars, "NonExistent") + + assert result is None + + def test_handles_empty_list(self): + """Returns None for empty list.""" + assert find_calendar_by_name([], "Any") is None + + def test_handles_none_name(self): + """Handles calendars with None name.""" + calendars = [ + CalendarInfo(url="/cal/work/", cal_id="work", name=None, resource_types=[]), + CalendarInfo( + url="/cal/personal/", + cal_id="personal", + name="Personal", + resource_types=[], + ), + ] + + result = find_calendar_by_name(calendars, "Personal") + + assert result is not None + assert result.cal_id == "personal" + + +class TestFindCalendarById: + """Tests for find_calendar_by_id function.""" + + def test_finds_calendar_by_id(self): + """Finds a calendar by its ID.""" + calendars = [ + CalendarInfo( + url="/cal/work/", cal_id="work", name="Work", resource_types=[] + ), + CalendarInfo( + url="/cal/personal/", + cal_id="personal", + name="Personal", + resource_types=[], + ), + ] + + result = find_calendar_by_id(calendars, "work") + + assert result is not None + assert result.name == "Work" + + def test_returns_none_if_not_found(self): + """Returns None if no calendar matches.""" + calendars = [ + CalendarInfo( + url="/cal/work/", cal_id="work", name="Work", resource_types=[] + ), + ] + + result = find_calendar_by_id(calendars, "nonexistent") + + assert result is None + + def test_handles_empty_list(self): + """Returns None for empty list.""" + assert find_calendar_by_id([], "any") is None + + +class TestCalendarInfo: + """Tests for CalendarInfo dataclass.""" + + def test_creates_calendar_info(self): + """Creates CalendarInfo with all fields.""" + info = CalendarInfo( + url="/calendars/user/work/", + cal_id="work", + name="Work Calendar", + resource_types=[ + "{DAV:}collection", + "{urn:ietf:params:xml:ns:caldav}calendar", + ], + ) + + assert info.url == "/calendars/user/work/" + assert info.cal_id == "work" + assert info.name == "Work Calendar" + assert "{urn:ietf:params:xml:ns:caldav}calendar" in info.resource_types + + def test_allows_none_values(self): + """Allows None values for optional fields.""" + info = CalendarInfo( + url="/calendars/user/work/", + cal_id=None, + name=None, + resource_types=[], + ) + + assert info.cal_id is None + assert info.name is None + assert info.resource_types == [] diff --git a/tests/test_operations_davobject.py b/tests/test_operations_davobject.py new file mode 100644 index 00000000..83a93428 --- /dev/null +++ b/tests/test_operations_davobject.py @@ -0,0 +1,290 @@ +""" +Tests for the DAVObject operations module. + +These tests verify the Sans-I/O business logic for DAVObject operations +like getting properties, listing children, and delete validation. +""" +import pytest + +from caldav.operations.davobject_ops import ( + _build_children_query as build_children_query, +) +from caldav.operations.davobject_ops import ( + _convert_protocol_results_to_properties as convert_protocol_results_to_properties, +) +from caldav.operations.davobject_ops import ( + _find_object_properties as find_object_properties, +) +from caldav.operations.davobject_ops import ( + _process_children_response as process_children_response, +) +from caldav.operations.davobject_ops import ( + _validate_delete_response as validate_delete_response, +) +from caldav.operations.davobject_ops import ( + _validate_proppatch_response as validate_proppatch_response, +) +from caldav.operations.davobject_ops import CALDAV_CALENDAR +from caldav.operations.davobject_ops import ChildData +from caldav.operations.davobject_ops import ChildrenQuery +from caldav.operations.davobject_ops import DAV_DISPLAYNAME +from caldav.operations.davobject_ops import DAV_RESOURCETYPE +from caldav.operations.davobject_ops import PropertiesResult + + +class TestBuildChildrenQuery: + """Tests for build_children_query function.""" + + def test_builds_query(self): + """Builds a ChildrenQuery with correct defaults.""" + query = build_children_query("/calendars/user/") + assert query.url == "/calendars/user/" + assert query.depth == 1 + assert DAV_DISPLAYNAME in query.props + assert DAV_RESOURCETYPE in query.props + + +class TestProcessChildrenResponse: + """Tests for process_children_response function.""" + + def test_excludes_parent(self): + """Parent URL is excluded from results.""" + props = { + "/calendars/": { + DAV_RESOURCETYPE: ["{DAV:}collection"], + DAV_DISPLAYNAME: "Calendars", + }, + "/calendars/work/": { + DAV_RESOURCETYPE: ["{DAV:}collection", CALDAV_CALENDAR], + DAV_DISPLAYNAME: "Work", + }, + } + children = process_children_response(props, "/calendars/") + assert len(children) == 1 + assert children[0].display_name == "Work" + + def test_filters_by_type(self): + """Filter by resource type works.""" + props = { + "/calendars/": { + DAV_RESOURCETYPE: ["{DAV:}collection"], + DAV_DISPLAYNAME: "Calendars", + }, + "/calendars/work/": { + DAV_RESOURCETYPE: ["{DAV:}collection", CALDAV_CALENDAR], + DAV_DISPLAYNAME: "Work Calendar", + }, + "/calendars/other/": { + DAV_RESOURCETYPE: ["{DAV:}collection"], + DAV_DISPLAYNAME: "Other Collection", + }, + } + children = process_children_response( + props, "/calendars/", filter_type=CALDAV_CALENDAR + ) + assert len(children) == 1 + assert children[0].display_name == "Work Calendar" + + def test_handles_trailing_slash_difference(self): + """Parent with/without trailing slash is handled.""" + props = { + "/calendars": { + DAV_RESOURCETYPE: ["{DAV:}collection"], + DAV_DISPLAYNAME: "Calendars", + }, + "/calendars/work/": { + DAV_RESOURCETYPE: ["{DAV:}collection", CALDAV_CALENDAR], + DAV_DISPLAYNAME: "Work", + }, + } + # Parent has trailing slash, response doesn't + children = process_children_response(props, "/calendars/") + assert len(children) == 1 + assert children[0].display_name == "Work" + + def test_handles_string_resource_type(self): + """Single string resource type is handled.""" + props = { + "/calendars/": { + DAV_RESOURCETYPE: "{DAV:}collection", + DAV_DISPLAYNAME: "Calendars", + }, + "/calendars/work/": { + DAV_RESOURCETYPE: CALDAV_CALENDAR, + DAV_DISPLAYNAME: "Work", + }, + } + children = process_children_response(props, "/calendars/") + assert len(children) == 1 + + def test_handles_none_resource_type(self): + """None resource type is handled.""" + props = { + "/calendars/": { + DAV_RESOURCETYPE: None, + DAV_DISPLAYNAME: "Calendars", + }, + "/calendars/work/": { + DAV_RESOURCETYPE: [CALDAV_CALENDAR], + DAV_DISPLAYNAME: "Work", + }, + } + children = process_children_response(props, "/calendars/") + # Parent excluded, work included + assert len(children) == 1 + + +class TestFindObjectProperties: + """Tests for find_object_properties function.""" + + def test_exact_match(self): + """Exact path match works.""" + props = { + "/calendars/user/": {"prop": "value"}, + } + result = find_object_properties(props, "/calendars/user/") + assert result.properties == {"prop": "value"} + assert result.matched_path == "/calendars/user/" + + def test_trailing_slash_mismatch(self): + """Trailing slash mismatch is handled.""" + props = { + "/calendars/user": {"prop": "value"}, + } + result = find_object_properties(props, "/calendars/user/") + assert result.properties == {"prop": "value"} + assert result.matched_path == "/calendars/user" + + def test_full_url_as_key(self): + """Full URL as properties key works.""" + props = { + "https://example.com/calendars/": {"prop": "value"}, + } + result = find_object_properties(props, "https://example.com/calendars/") + assert result.properties == {"prop": "value"} + + def test_double_slash_workaround(self): + """Double slash in path is normalized.""" + props = { + "/calendars/user/": {"prop": "value"}, + } + result = find_object_properties(props, "/calendars//user/") + assert result.properties == {"prop": "value"} + + def test_single_result_fallback(self): + """Single result is used as fallback.""" + props = { + "/some/other/path/": {"prop": "value"}, + } + result = find_object_properties(props, "/expected/path/") + assert result.properties == {"prop": "value"} + + def test_icloud_principal_workaround(self): + """iCloud /principal/ workaround works.""" + props = { + "/principal/": {"prop": "value"}, + } + result = find_object_properties(props, "/12345/principal/") + assert result.properties == {"prop": "value"} + + def test_no_match_raises(self): + """ValueError raised when no match found.""" + props = { + "/path/a/": {"prop": "a"}, + "/path/b/": {"prop": "b"}, + } + with pytest.raises(ValueError, match="Could not find properties"): + find_object_properties(props, "/path/c/") + + def test_principal_no_warning(self): + """Principal objects don't warn on trailing slash mismatch.""" + props = { + "/principal": {"prop": "value"}, + } + # Should not log warning for principals + result = find_object_properties(props, "/principal/", is_principal=True) + assert result.properties == {"prop": "value"} + + +class TestConvertProtocolResults: + """Tests for convert_protocol_results_to_properties function.""" + + def test_converts_results(self): + """Converts PropfindResult-like objects to dict.""" + + class FakeResult: + def __init__(self, href, properties): + self.href = href + self.properties = properties + + results = [ + FakeResult("/cal/", {DAV_DISPLAYNAME: "Calendar"}), + FakeResult("/cal/event.ics", {DAV_DISPLAYNAME: "Event"}), + ] + converted = convert_protocol_results_to_properties(results) + assert "/cal/" in converted + assert converted["/cal/"][DAV_DISPLAYNAME] == "Calendar" + assert "/cal/event.ics" in converted + + def test_initializes_requested_props(self): + """Requested props initialized to None.""" + + class FakeResult: + def __init__(self, href, properties): + self.href = href + self.properties = properties + + results = [FakeResult("/cal/", {DAV_DISPLAYNAME: "Calendar"})] + converted = convert_protocol_results_to_properties( + results, requested_props=[DAV_DISPLAYNAME, "{DAV:}getetag"] + ) + assert converted["/cal/"][DAV_DISPLAYNAME] == "Calendar" + assert converted["/cal/"]["{DAV:}getetag"] is None + + +class TestValidateDeleteResponse: + """Tests for validate_delete_response function.""" + + def test_accepts_200(self): + """200 OK is accepted.""" + validate_delete_response(200) # No exception + + def test_accepts_204(self): + """204 No Content is accepted.""" + validate_delete_response(204) # No exception + + def test_accepts_404(self): + """404 Not Found is accepted (already deleted).""" + validate_delete_response(404) # No exception + + def test_rejects_500(self): + """500 raises ValueError.""" + with pytest.raises(ValueError, match="Delete failed"): + validate_delete_response(500) + + def test_rejects_403(self): + """403 Forbidden raises ValueError.""" + with pytest.raises(ValueError, match="Delete failed"): + validate_delete_response(403) + + +class TestValidatePropatchResponse: + """Tests for validate_proppatch_response function.""" + + def test_accepts_200(self): + """200 OK is accepted.""" + validate_proppatch_response(200) # No exception + + def test_accepts_207(self): + """207 Multi-Status is accepted.""" + validate_proppatch_response(207) # No exception + + def test_rejects_400(self): + """400 raises ValueError.""" + with pytest.raises(ValueError, match="PROPPATCH failed"): + validate_proppatch_response(400) + + def test_rejects_403(self): + """403 Forbidden raises ValueError.""" + with pytest.raises(ValueError, match="PROPPATCH failed"): + validate_proppatch_response(403) diff --git a/tests/test_operations_principal.py b/tests/test_operations_principal.py new file mode 100644 index 00000000..bdf83e0a --- /dev/null +++ b/tests/test_operations_principal.py @@ -0,0 +1,243 @@ +""" +Tests for the Principal operations module. + +These tests verify the Sans-I/O business logic for Principal operations +like URL sanitization and vCalAddress creation. +""" +import pytest + +from caldav.operations.principal_ops import _create_vcal_address as create_vcal_address +from caldav.operations.principal_ops import ( + _extract_calendar_user_addresses as extract_calendar_user_addresses, +) +from caldav.operations.principal_ops import ( + _sanitize_calendar_home_set_url as sanitize_calendar_home_set_url, +) +from caldav.operations.principal_ops import ( + _should_update_client_base_url as should_update_client_base_url, +) +from caldav.operations.principal_ops import ( + _sort_calendar_user_addresses as sort_calendar_user_addresses, +) +from caldav.operations.principal_ops import PrincipalData + + +class TestSanitizeCalendarHomeSetUrl: + """Tests for sanitize_calendar_home_set_url function.""" + + def test_returns_none_for_none(self): + """Returns None if input is None.""" + assert sanitize_calendar_home_set_url(None) is None + + def test_quotes_at_in_path(self): + """Quotes @ character in path URLs (owncloud quirk).""" + url = "/remote.php/dav/calendars/user@example.com/" + result = sanitize_calendar_home_set_url(url) + assert "%40" in result + assert "@" not in result + + def test_preserves_full_urls(self): + """Does not quote @ in full URLs.""" + url = "https://example.com/dav/calendars/user@example.com/" + result = sanitize_calendar_home_set_url(url) + # Full URLs should be returned as-is + assert result == url + + def test_preserves_already_quoted(self): + """Does not double-quote already quoted URLs.""" + url = "/remote.php/dav/calendars/user%40example.com/" + result = sanitize_calendar_home_set_url(url) + assert result == url + # Should not have double-encoding like %2540 + assert "%2540" not in result + + def test_preserves_normal_path(self): + """Preserves paths without special characters.""" + url = "/calendars/default/" + result = sanitize_calendar_home_set_url(url) + assert result == url + + +class TestSortCalendarUserAddresses: + """Tests for sort_calendar_user_addresses function.""" + + def test_sorts_by_preference(self): + """Sorts addresses by preferred attribute (highest first).""" + + class FakeElement: + def __init__(self, text, preferred=0): + self.text = text + self._preferred = preferred + + def get(self, key, default=0): + if key == "preferred": + return self._preferred + return default + + addresses = [ + FakeElement("mailto:secondary@example.com", preferred=0), + FakeElement("mailto:primary@example.com", preferred=1), + FakeElement("mailto:tertiary@example.com", preferred=0), + ] + + result = sort_calendar_user_addresses(addresses) + + assert result[0].text == "mailto:primary@example.com" + # Other two maintain relative order (stable sort) + + def test_handles_missing_preferred(self): + """Handles elements without preferred attribute.""" + + class FakeElement: + def __init__(self, text): + self.text = text + + def get(self, key, default=0): + return default + + addresses = [ + FakeElement("mailto:a@example.com"), + FakeElement("mailto:b@example.com"), + ] + + # Should not raise, treats missing as 0 + result = sort_calendar_user_addresses(addresses) + assert len(result) == 2 + + +class TestExtractCalendarUserAddresses: + """Tests for extract_calendar_user_addresses function.""" + + def test_extracts_text(self): + """Extracts text from address elements.""" + + class FakeElement: + def __init__(self, text, preferred=0): + self.text = text + self._preferred = preferred + + def get(self, key, default=0): + if key == "preferred": + return self._preferred + return default + + addresses = [ + FakeElement("mailto:primary@example.com", preferred=1), + FakeElement("mailto:secondary@example.com", preferred=0), + ] + + result = extract_calendar_user_addresses(addresses) + + assert result == ["mailto:primary@example.com", "mailto:secondary@example.com"] + + def test_returns_empty_for_empty_list(self): + """Returns empty list for empty input.""" + assert extract_calendar_user_addresses([]) == [] + + +class TestCreateVcalAddress: + """Tests for create_vcal_address function.""" + + def test_creates_vcal_address(self): + """Creates vCalAddress with all parameters.""" + result = create_vcal_address( + display_name="John Doe", + address="mailto:john@example.com", + calendar_user_type="INDIVIDUAL", + ) + + assert str(result) == "mailto:john@example.com" + assert result.params["cn"] == "John Doe" + assert result.params["cutype"] == "INDIVIDUAL" + + def test_creates_without_display_name(self): + """Creates vCalAddress without display name.""" + result = create_vcal_address( + display_name=None, + address="mailto:john@example.com", + ) + + assert str(result) == "mailto:john@example.com" + assert "cn" not in result.params + + def test_creates_without_cutype(self): + """Creates vCalAddress without calendar user type.""" + result = create_vcal_address( + display_name="John", + address="mailto:john@example.com", + calendar_user_type=None, + ) + + assert str(result) == "mailto:john@example.com" + assert result.params["cn"] == "John" + assert "cutype" not in result.params + + +class TestShouldUpdateClientBaseUrl: + """Tests for should_update_client_base_url function.""" + + def test_returns_false_for_none(self): + """Returns False for None URL.""" + assert should_update_client_base_url(None, "example.com") is False + + def test_returns_false_for_same_host(self): + """Returns False when hostname matches.""" + assert ( + should_update_client_base_url( + "https://example.com/calendars/", + "example.com", + ) + is False + ) + + def test_returns_true_for_different_host(self): + """Returns True when hostname differs (iCloud load balancing).""" + assert ( + should_update_client_base_url( + "https://p123-caldav.icloud.com/calendars/", + "caldav.icloud.com", + ) + is True + ) + + def test_returns_false_for_relative_path(self): + """Returns False for relative paths (no host to compare).""" + assert ( + should_update_client_base_url( + "/calendars/user/", + "example.com", + ) + is False + ) + + +class TestPrincipalData: + """Tests for PrincipalData dataclass.""" + + def test_creates_principal_data(self): + """Creates PrincipalData with all fields.""" + data = PrincipalData( + url="/principals/user/", + display_name="John Doe", + calendar_home_set_url="/calendars/user/", + calendar_user_addresses=["mailto:john@example.com"], + ) + + assert data.url == "/principals/user/" + assert data.display_name == "John Doe" + assert data.calendar_home_set_url == "/calendars/user/" + assert data.calendar_user_addresses == ["mailto:john@example.com"] + + def test_allows_none_values(self): + """Allows None values for optional fields.""" + data = PrincipalData( + url=None, + display_name=None, + calendar_home_set_url=None, + calendar_user_addresses=[], + ) + + assert data.url is None + assert data.display_name is None + assert data.calendar_home_set_url is None + assert data.calendar_user_addresses == [] diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 00000000..b7208817 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,320 @@ +""" +Unit tests for Sans-I/O protocol layer. + +These tests verify protocol logic without any HTTP mocking required. +All tests are pure - they test data transformations only. +""" +from datetime import datetime + +import pytest + +from caldav.protocol import CalendarQueryResult +from caldav.protocol import DAVMethod +from caldav.protocol import DAVRequest +from caldav.protocol import DAVResponse +from caldav.protocol import MultistatusResponse +from caldav.protocol import PropfindResult +from caldav.protocol import SyncCollectionResult +from caldav.protocol.xml_builders import ( + _build_calendar_multiget_body as build_calendar_multiget_body, +) +from caldav.protocol.xml_builders import ( + _build_calendar_query_body as build_calendar_query_body, +) +from caldav.protocol.xml_builders import _build_mkcalendar_body as build_mkcalendar_body +from caldav.protocol.xml_builders import _build_propfind_body as build_propfind_body +from caldav.protocol.xml_builders import ( + _build_sync_collection_body as build_sync_collection_body, +) +from caldav.protocol.xml_parsers import ( + _parse_calendar_query_response as parse_calendar_query_response, +) +from caldav.protocol.xml_parsers import _parse_multistatus as parse_multistatus +from caldav.protocol.xml_parsers import ( + _parse_propfind_response as parse_propfind_response, +) +from caldav.protocol.xml_parsers import ( + _parse_sync_collection_response as parse_sync_collection_response, +) + + +class TestDAVTypes: + """Test core DAV types.""" + + def test_dav_request_immutable(self): + """DAVRequest should be immutable (frozen dataclass).""" + request = DAVRequest( + method=DAVMethod.GET, + url="https://example.com/", + headers={}, + ) + with pytest.raises(AttributeError): + request.url = "https://other.com/" + + def test_dav_request_with_header(self): + """with_header should return new request with added header.""" + request = DAVRequest( + method=DAVMethod.GET, + url="https://example.com/", + headers={"Accept": "text/html"}, + ) + new_request = request.with_header("Authorization", "Bearer token") + + # Original unchanged + assert "Authorization" not in request.headers + # New has both headers + assert new_request.headers["Accept"] == "text/html" + assert new_request.headers["Authorization"] == "Bearer token" + + def test_dav_response_ok(self): + """ok property should return True for 2xx status codes.""" + assert DAVResponse(status=200, headers={}, body=b"").ok + assert DAVResponse(status=201, headers={}, body=b"").ok + assert DAVResponse(status=207, headers={}, body=b"").ok + assert not DAVResponse(status=404, headers={}, body=b"").ok + assert not DAVResponse(status=500, headers={}, body=b"").ok + + def test_dav_response_is_multistatus(self): + """is_multistatus should return True only for 207.""" + assert DAVResponse(status=207, headers={}, body=b"").is_multistatus + assert not DAVResponse(status=200, headers={}, body=b"").is_multistatus + + +class TestXMLBuilders: + """Test XML building functions.""" + + def test_build_propfind_body_minimal(self): + """Minimal propfind should produce valid XML.""" + body = build_propfind_body() + assert b"propfind" in body.lower() + + def test_build_propfind_body_with_props(self): + """Propfind with properties should include them.""" + body = build_propfind_body(["displayname", "resourcetype"]) + xml = body.decode("utf-8").lower() + assert "displayname" in xml + assert "resourcetype" in xml + + def test_build_calendar_query_with_time_range(self): + """Calendar query with time range should include time-range element.""" + body, comp_type = build_calendar_query_body( + start=datetime(2024, 1, 1), + end=datetime(2024, 12, 31), + event=True, + ) + xml = body.decode("utf-8").lower() + assert "calendar-query" in xml + assert "time-range" in xml + assert comp_type == "VEVENT" + + def test_build_calendar_query_component_types(self): + """Calendar query should set correct component type.""" + _, comp = build_calendar_query_body(event=True) + assert comp == "VEVENT" + + _, comp = build_calendar_query_body(todo=True) + assert comp == "VTODO" + + _, comp = build_calendar_query_body(journal=True) + assert comp == "VJOURNAL" + + def test_build_calendar_multiget_body(self): + """Calendar multiget should include hrefs.""" + body = build_calendar_multiget_body(["/cal/event1.ics", "/cal/event2.ics"]) + xml = body.decode("utf-8") + assert "calendar-multiget" in xml.lower() + assert "/cal/event1.ics" in xml + assert "/cal/event2.ics" in xml + + def test_build_sync_collection_body(self): + """Sync collection should include sync-token.""" + body = build_sync_collection_body(sync_token="token-123") + xml = body.decode("utf-8") + assert "sync-collection" in xml.lower() + assert "token-123" in xml + + def test_build_mkcalendar_body(self): + """Mkcalendar should include properties.""" + body = build_mkcalendar_body( + displayname="My Calendar", + description="A test calendar", + ) + xml = body.decode("utf-8") + assert "mkcalendar" in xml.lower() + assert "My Calendar" in xml + assert "A test calendar" in xml + + +class TestXMLParsers: + """Test XML parsing functions.""" + + def test_parse_multistatus_simple(self): + """Parse simple multistatus response.""" + xml = b""" + + + /calendars/user/ + + + My Calendar + + HTTP/1.1 200 OK + + + """ + + result = parse_multistatus(xml) + + assert isinstance(result, MultistatusResponse) + assert len(result.responses) == 1 + assert result.responses[0].href == "/calendars/user/" + assert "{DAV:}displayname" in result.responses[0].properties + + def test_parse_multistatus_with_sync_token(self): + """Parse multistatus with sync-token.""" + xml = b""" + + + /cal/ + + Cal + HTTP/1.1 200 OK + + + token-456 + """ + + result = parse_multistatus(xml) + assert result.sync_token == "token-456" + + def test_parse_propfind_response(self): + """Parse PROPFIND response.""" + xml = b""" + + + /calendars/ + + + + + HTTP/1.1 200 OK + + + """ + + results = parse_propfind_response(xml, status_code=207) + + assert len(results) == 1 + assert results[0].href == "/calendars/" + + def test_parse_propfind_404_returns_empty(self): + """PROPFIND 404 should return empty list.""" + results = parse_propfind_response(b"", status_code=404) + assert results == [] + + def test_parse_calendar_query_response(self): + """Parse calendar-query response with calendar data.""" + xml = b""" + + + /cal/event.ics + + + "etag-123" + BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:test@example.com +END:VEVENT +END:VCALENDAR + + HTTP/1.1 200 OK + + + """ + + results = parse_calendar_query_response(xml, status_code=207) + + assert len(results) == 1 + assert results[0].href == "/cal/event.ics" + assert results[0].etag == '"etag-123"' + assert "VCALENDAR" in results[0].calendar_data + + def test_parse_sync_collection_response(self): + """Parse sync-collection response with changed and deleted items.""" + xml = b""" + + + /cal/new.ics + + + "new-etag" + + HTTP/1.1 200 OK + + + + /cal/deleted.ics + HTTP/1.1 404 Not Found + + new-token + """ + + result = parse_sync_collection_response(xml, status_code=207) + + assert isinstance(result, SyncCollectionResult) + assert len(result.changed) == 1 + assert result.changed[0].href == "/cal/new.ics" + assert len(result.deleted) == 1 + assert result.deleted[0] == "/cal/deleted.ics" + assert result.sync_token == "new-token" + + def test_parse_complex_properties(self): + """Parse complex properties like supported-calendar-component-set.""" + xml = b""" + + + /calendars/user/calendar/ + + + My Calendar + + + + + + + + + + + /calendars/user/ + + + HTTP/1.1 200 OK + + + """ + + results = parse_propfind_response(xml, status_code=207) + + assert len(results) == 1 + props = results[0].properties + + # Simple property + assert props["{DAV:}displayname"] == "My Calendar" + + # resourcetype - list of child tags + resourcetype = props["{DAV:}resourcetype"] + assert "{DAV:}collection" in resourcetype + assert "{urn:ietf:params:xml:ns:caldav}calendar" in resourcetype + + # supported-calendar-component-set - list of component names + components = props[ + "{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set" + ] + assert components == ["VEVENT", "VTODO", "VJOURNAL"] + + # calendar-home-set - extracted href + home_set = props["{urn:ietf:params:xml:ns:caldav}calendar-home-set"] + assert home_set == "/calendars/user/" diff --git a/tests/test_servers/__init__.py b/tests/test_servers/__init__.py new file mode 100644 index 00000000..38e84e07 --- /dev/null +++ b/tests/test_servers/__init__.py @@ -0,0 +1,60 @@ +""" +Test server framework for caldav tests. + +This package provides a unified framework for starting and managing +test servers (Radicale, Xandikos, Docker containers) for both sync +and async tests. + +Usage: + from tests.test_servers import client_context + + # Simple: get a running server with get_davclient() support + with client_context() as client: + principal = client.principal() + # get_davclient() also works within this context + + # Or use the lower-level APIs: + from tests.test_servers import get_available_servers, ServerRegistry + + for server in get_available_servers(): + server.start() + client = server.get_sync_client() + # ... run tests ... + server.stop() +""" +from .base import DEFAULT_HTTP_TIMEOUT +from .base import DockerTestServer +from .base import EmbeddedTestServer +from .base import ExternalTestServer +from .base import MAX_STARTUP_WAIT_SECONDS +from .base import STARTUP_POLL_INTERVAL +from .base import TestServer +from .config_loader import create_example_config +from .config_loader import load_test_server_config +from .helpers import client_context +from .helpers import has_test_servers +from .registry import get_available_servers +from .registry import get_registry +from .registry import ServerRegistry + +__all__ = [ + # High-level helpers + "client_context", + "has_test_servers", + # Base classes + "TestServer", + "EmbeddedTestServer", + "DockerTestServer", + "ExternalTestServer", + # Registry + "ServerRegistry", + "get_available_servers", + "get_registry", + # Config loading + "load_test_server_config", + "create_example_config", + # Constants + "DEFAULT_HTTP_TIMEOUT", + "MAX_STARTUP_WAIT_SECONDS", + "STARTUP_POLL_INTERVAL", +] diff --git a/tests/test_servers/base.py b/tests/test_servers/base.py new file mode 100644 index 00000000..c8104c85 --- /dev/null +++ b/tests/test_servers/base.py @@ -0,0 +1,422 @@ +""" +Base classes for test servers. + +This module provides abstract base classes for different types of test servers: +- TestServer: Abstract base for all test servers +- EmbeddedTestServer: For servers that run in-process (Radicale, Xandikos) +- DockerTestServer: For servers that run in Docker containers +""" +from abc import ABC +from abc import abstractmethod +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + +try: + import niquests as requests +except ImportError: + import requests # type: ignore + +# Constants - extracted from magic numbers in conf.py +DEFAULT_HTTP_TIMEOUT = 5 +MAX_STARTUP_WAIT_SECONDS = 60 +STARTUP_POLL_INTERVAL = 0.05 + + +class TestServer(ABC): + """ + Abstract base class for all test servers. + + A test server provides a CalDAV endpoint for running tests. It can be: + - An embedded server running in-process (Radicale, Xandikos) + - A Docker container (Baikal, Nextcloud, etc.) + - An external server (user-configured private servers) + + Attributes: + name: Human-readable name for the server (used in test class names) + server_type: Type of server ("embedded", "docker", "external") + config: Configuration dict for the server + """ + + name: str = "TestServer" + server_type: str = "abstract" + + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + """ + Initialize a test server. + + Args: + config: Configuration dict with server-specific options. + Common keys: host, port, username, password, features + """ + self.config = config or {} + self.name = self.config.get( + "name", self.__class__.__name__.replace("TestServer", "") + ) + self._started = False + self._started_by_us = ( + False # Track if we started the server or it was already running + ) + + @property + @abstractmethod + def url(self) -> str: + """Return the CalDAV endpoint URL.""" + pass + + @property + def username(self) -> Optional[str]: + """Return the username for authentication.""" + return self.config.get("username") + + @property + def password(self) -> Optional[str]: + """Return the password for authentication.""" + return self.config.get("password") + + @property + def features(self) -> Any: + """ + Return compatibility features for this server. + + This can be a dict of feature flags or a reference to a + compatibility hints object. + """ + return self.config.get("features", []) + + @abstractmethod + def start(self) -> None: + """ + Start the server if not already running. + + This method should be idempotent - calling it multiple times + should not cause issues. + + Raises: + RuntimeError: If the server fails to start + """ + pass + + @abstractmethod + def stop(self) -> None: + """ + Stop the server and cleanup resources. + + This method should be idempotent - calling it multiple times + should not cause issues. + """ + pass + + @abstractmethod + def is_accessible(self) -> bool: + """ + Check if the server is accessible and ready for requests. + + Returns: + True if the server is responding to HTTP requests + """ + pass + + def get_sync_client(self) -> "DAVClient": + """ + Get a synchronous DAVClient for this server. + + Returns: + DAVClient configured for this server + """ + from caldav.davclient import DAVClient + + client = DAVClient( + url=self.url, + username=self.username, + password=self.password, + ) + client.server_name = self.name + # Attach no-op setup/teardown by default + client.setup = lambda self_: None + client.teardown = lambda self_: None + return client + + async def get_async_client(self) -> "AsyncDAVClient": + """ + Get an async DAVClient for this server. + + Returns: + AsyncDAVClient configured for this server + """ + from caldav.aio import get_async_davclient + + return await get_async_davclient( + url=self.url, + username=self.username, + password=self.password, + features=self.features, + probe=False, # We already checked accessibility + ) + + def get_server_params(self) -> Dict[str, Any]: + """ + Get parameters dict compatible with current caldav_servers format. + + This allows the new test server framework to work with the + existing test infrastructure during migration. + + Returns: + Dict with keys: name, url, username, password, features, setup, teardown + """ + params: Dict[str, Any] = { + "name": self.name, + "url": self.url, + "username": self.username, + "password": self.password, + "features": self.features, + } + # Check if server is already running (either started by us or externally) + already_running = self._started or self.is_accessible() + if already_running: + # Server is already running - mark as started but don't add teardown + # to avoid stopping a server that was running before tests started + self._started = True + else: + # Server needs to be started - add setup/teardown callbacks + params["setup"] = lambda _: self.start() + params["teardown"] = lambda _: self.stop() + return params + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name={self.name!r}, url={self.url!r})" + + +class EmbeddedTestServer(TestServer): + """ + Base class for servers that run in-process. + + Embedded servers (like Radicale and Xandikos) run in a separate thread + within the test process. They use temporary directories for storage + and are automatically cleaned up when stopped. + + Attributes: + host: Host to bind to (default: "localhost") + port: Port to bind to (server-specific default) + """ + + server_type = "embedded" + + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + super().__init__(config) + self.host = self.config.get("host", "localhost") + self.port = self.config.get("port", self._default_port()) + + def _default_port(self) -> int: + """Return the default port for this server type.""" + return 5232 # Override in subclasses + + @property + def url(self) -> str: + """Return the CalDAV endpoint URL.""" + username = self.username or "" + return f"http://{self.host}:{self.port}/{username}" + + def _wait_for_startup(self) -> None: + """ + Wait for the server to become accessible. + + Raises: + RuntimeError: If server doesn't start within MAX_STARTUP_WAIT_SECONDS + """ + import time + + attempts = int(MAX_STARTUP_WAIT_SECONDS / STARTUP_POLL_INTERVAL) + for _ in range(attempts): + if self.is_accessible(): + return + time.sleep(STARTUP_POLL_INTERVAL) + + raise RuntimeError( + f"{self.name} failed to start after {MAX_STARTUP_WAIT_SECONDS} seconds" + ) + + +class DockerTestServer(TestServer): + """ + Base class for Docker-based test servers. + + Docker servers run in containers managed by docker-compose. + They expect a docker_dir with start.sh and stop.sh scripts. + + Attributes: + docker_dir: Path to the directory containing docker-compose.yml + host: Host where the container is accessible (default: "localhost") + port: Port where the container is accessible + """ + + server_type = "docker" + + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + super().__init__(config) + self.host = self.config.get("host", "localhost") + self.port = self.config.get("port", self._default_port()) + + # Docker directory - default to tests/docker-test-servers/ + from pathlib import Path + + default_docker_dir = ( + Path(__file__).parent.parent / "docker-test-servers" / self.name.lower() + ) + self.docker_dir = Path(self.config.get("docker_dir", default_docker_dir)) + + def _default_port(self) -> int: + """Return the default port for this server type.""" + return 8800 # Override in subclasses + + @staticmethod + def verify_docker() -> bool: + """ + Check if docker and docker-compose are available. + + Returns: + True if docker-compose is available and docker daemon is running + """ + import subprocess + + try: + subprocess.run( + ["docker-compose", "--version"], + capture_output=True, + check=True, + timeout=5, + ) + subprocess.run( + ["docker", "ps"], + capture_output=True, + check=True, + timeout=5, + ) + return True + except ( + subprocess.CalledProcessError, + FileNotFoundError, + subprocess.TimeoutExpired, + ): + return False + + def start(self) -> None: + """ + Start the Docker container if not already running. + + If the server is already running (either from a previous test run or + started externally), it will be reused without restarting. + + Raises: + RuntimeError: If Docker is not available or container fails to start + """ + import subprocess + import time + + if self._started or self.is_accessible(): + self._started = True # Mark as started even if already running + # Don't set _started_by_us - we didn't start it this time + print(f"[OK] {self.name} is already running") + return + + if not self.verify_docker(): + raise RuntimeError(f"Docker not available for {self.name}") + + start_script = self.docker_dir / "start.sh" + if not start_script.exists(): + raise FileNotFoundError(f"{start_script} not found") + + print(f"Starting {self.name} from {self.docker_dir}...") + subprocess.run( + [str(start_script)], + cwd=self.docker_dir, + check=True, + capture_output=True, + ) + + # Wait for server to become accessible + for _ in range(MAX_STARTUP_WAIT_SECONDS): + if self.is_accessible(): + print(f"[OK] {self.name} is ready") + self._started = True + self._started_by_us = True # We actually started this server + return + time.sleep(1) + + raise RuntimeError( + f"{self.name} failed to start after {MAX_STARTUP_WAIT_SECONDS}s" + ) + + def stop(self) -> None: + """Stop the Docker container and cleanup. + + Only stops the server if it was started by us (not externally). + This allows running servers to be reused across test runs. + """ + import subprocess + + if not self._started_by_us: + # Server was already running before we started - don't stop it + print(f"[OK] {self.name} was already running - leaving it running") + return + + stop_script = self.docker_dir / "stop.sh" + if stop_script.exists(): + print(f"Stopping {self.name}...") + subprocess.run( + [str(stop_script)], + cwd=self.docker_dir, + check=True, + capture_output=True, + ) + self._started = False + self._started_by_us = False + + def is_accessible(self) -> bool: + """Check if the Docker container is accessible.""" + try: + response = requests.get(f"{self.url}/", timeout=DEFAULT_HTTP_TIMEOUT) + return response.status_code in (200, 401, 403, 404) + except Exception: + return False + + +class ExternalTestServer(TestServer): + """ + Test server for external/user-configured servers. + + External servers are already running somewhere - we don't start or stop them. + This is used for testing against real CalDAV servers configured by the user. + """ + + server_type = "external" + + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + super().__init__(config) + self._url = self.config.get("url", "") + + @property + def url(self) -> str: + return self._url + + def start(self) -> None: + """External servers are already running - nothing to do.""" + if not self.is_accessible(): + raise RuntimeError( + f"External server {self.name} at {self.url} is not accessible" + ) + self._started = True + + def stop(self) -> None: + """External servers stay running - nothing to do.""" + self._started = False + self._started_by_us = False + + def is_accessible(self) -> bool: + """Check if the external server is accessible.""" + try: + response = requests.get(self.url, timeout=DEFAULT_HTTP_TIMEOUT) + return response.status_code in (200, 401, 403, 404) + except Exception: + return False diff --git a/tests/test_servers/config_loader.py b/tests/test_servers/config_loader.py new file mode 100644 index 00000000..96c3ce51 --- /dev/null +++ b/tests/test_servers/config_loader.py @@ -0,0 +1,253 @@ +""" +Configuration loader for test servers. + +This module provides functions for loading test server configuration +from YAML/JSON files, with fallback to the legacy conf_private.py. +""" +import os +import warnings +from pathlib import Path +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + +from caldav.config import expand_env_vars +from caldav.config import read_config + +# Default config file locations (in priority order) +DEFAULT_CONFIG_LOCATIONS = [ + "tests/test_servers.yaml", + "tests/test_servers.json", + "~/.config/caldav/test_servers.yaml", + "~/.config/caldav/test_servers.json", +] + + +def load_test_server_config( + config_file: Optional[str] = None, +) -> Dict[str, Dict[str, Any]]: + """ + Load test server configuration from file. + + Searches for config files in default locations and loads the first + one found. Falls back to conf_private.py with a deprecation warning. + + Args: + config_file: Optional explicit path to config file + + Returns: + Dict mapping server names to their configuration dicts. + Empty dict if no configuration found. + + Example config file (YAML): + test-servers: + radicale: + type: embedded + enabled: true + port: 5232 + baikal: + type: docker + enabled: ${TEST_BAIKAL:-auto} + url: http://localhost:8800/dav.php + """ + # Try explicit config file first + if config_file: + cfg = read_config(config_file) + if cfg: + servers = cfg.get("test-servers", cfg) + return expand_env_vars(servers) + + # Try default locations + for loc in DEFAULT_CONFIG_LOCATIONS: + path = Path(loc).expanduser() + if path.exists(): + cfg = read_config(str(path)) + if cfg: + servers = cfg.get("test-servers", cfg) + return expand_env_vars(servers) + + # Fallback to conf_private.py with deprecation warning + return _load_from_conf_private() + + +def _load_from_conf_private() -> Dict[str, Dict[str, Any]]: + """ + Load configuration from legacy conf_private.py. + + This provides backwards compatibility during migration to + the new YAML/JSON config format. + + Returns: + Dict mapping server names to their configuration dicts. + Empty dict if conf_private.py not found. + """ + import sys + + original_path = sys.path.copy() + try: + sys.path.insert(0, "tests") + sys.path.insert(1, ".") + + try: + import conf_private + + warnings.warn( + "conf_private.py is deprecated for test server configuration. " + "Please migrate to tests/test_servers.yaml. " + "See docs/testing.rst for the new format.", + DeprecationWarning, + stacklevel=3, + ) + return _convert_conf_private_to_config(conf_private) + except ImportError: + return {} + finally: + sys.path = original_path + + +def _convert_conf_private_to_config(conf_private: Any) -> Dict[str, Dict[str, Any]]: + """ + Convert conf_private.py format to new config format. + + Args: + conf_private: The imported conf_private module + + Returns: + Dict mapping server names to their configuration dicts + """ + result: Dict[str, Dict[str, Any]] = {} + + # Convert caldav_servers list + if hasattr(conf_private, "caldav_servers"): + for i, server in enumerate(conf_private.caldav_servers): + name = server.get("name", f"server_{i}") + config: Dict[str, Any] = { + "type": "external", + "enabled": server.get("enable", True), + } + # Copy all other keys + for key, value in server.items(): + if key not in ("enable", "name"): + config[key] = value + result[name.lower().replace(" ", "_")] = config + + # Handle boolean enable/disable switches + for attr in ( + "test_radicale", + "test_xandikos", + "test_baikal", + "test_nextcloud", + "test_cyrus", + "test_sogo", + "test_bedework", + ): + if hasattr(conf_private, attr): + server_name = attr.replace("test_", "") + if server_name not in result: + result[server_name] = {"type": server_name} + result[server_name]["enabled"] = getattr(conf_private, attr) + + # Handle host/port overrides + for server_name in ( + "radicale", + "xandikos", + "baikal", + "nextcloud", + "cyrus", + "sogo", + "bedework", + ): + host_attr = f"{server_name}_host" + port_attr = f"{server_name}_port" + + if hasattr(conf_private, host_attr): + if server_name not in result: + result[server_name] = {"type": server_name} + result[server_name]["host"] = getattr(conf_private, host_attr) + + if hasattr(conf_private, port_attr): + if server_name not in result: + result[server_name] = {"type": server_name} + result[server_name]["port"] = getattr(conf_private, port_attr) + + return result + + +def create_example_config() -> str: + """ + Generate an example config file content. + + Returns: + YAML-formatted example configuration + """ + return """# Test server configuration for caldav tests +# This file replaces the legacy conf_private.py + +test-servers: + # Embedded servers (run in-process) + radicale: + type: embedded + enabled: true + host: ${RADICALE_HOST:-localhost} + port: ${RADICALE_PORT:-5232} + username: user1 + password: "" + + xandikos: + type: embedded + enabled: true + host: ${XANDIKOS_HOST:-localhost} + port: ${XANDIKOS_PORT:-8993} + username: sometestuser + + # Docker servers (require docker-compose) + baikal: + type: docker + enabled: ${TEST_BAIKAL:-auto} # "auto" means check if docker available + host: ${BAIKAL_HOST:-localhost} + port: ${BAIKAL_PORT:-8800} + username: ${BAIKAL_USERNAME:-testuser} + password: ${BAIKAL_PASSWORD:-testpass} + + nextcloud: + type: docker + enabled: ${TEST_NEXTCLOUD:-auto} + host: ${NEXTCLOUD_HOST:-localhost} + port: ${NEXTCLOUD_PORT:-8801} + username: ${NEXTCLOUD_USERNAME:-testuser} + password: ${NEXTCLOUD_PASSWORD:-testpass} + + cyrus: + type: docker + enabled: ${TEST_CYRUS:-auto} + host: ${CYRUS_HOST:-localhost} + port: ${CYRUS_PORT:-8802} + username: ${CYRUS_USERNAME:-testuser@test.local} + password: ${CYRUS_PASSWORD:-testpassword} + + sogo: + type: docker + enabled: ${TEST_SOGO:-auto} + host: ${SOGO_HOST:-localhost} + port: ${SOGO_PORT:-8803} + username: ${SOGO_USERNAME:-testuser} + password: ${SOGO_PASSWORD:-testpassword} + + bedework: + type: docker + enabled: ${TEST_BEDEWORK:-auto} + host: ${BEDEWORK_HOST:-localhost} + port: ${BEDEWORK_PORT:-8804} + username: ${BEDEWORK_USERNAME:-admin} + password: ${BEDEWORK_PASSWORD:-bedework} + + # External/private servers (user-configured) + # Uncomment and configure for your own server: + # my-server: + # type: external + # enabled: true + # url: ${CALDAV_URL} + # username: ${CALDAV_USERNAME} + # password: ${CALDAV_PASSWORD} +""" diff --git a/tests/test_servers/docker.py b/tests/test_servers/docker.py new file mode 100644 index 00000000..4d8eed1d --- /dev/null +++ b/tests/test_servers/docker.py @@ -0,0 +1,251 @@ +""" +Docker-based test server implementations. + +This module provides test server implementations for servers that run +in Docker containers: Baikal, Nextcloud, Cyrus, SOGo, and Bedework. +""" +import os +from typing import Any +from typing import Dict +from typing import Optional + +try: + import niquests as requests +except ImportError: + import requests # type: ignore + +from caldav import compatibility_hints + +from .base import DEFAULT_HTTP_TIMEOUT, DockerTestServer +from .registry import register_server_class + + +class BaikalTestServer(DockerTestServer): + """ + Baikal CalDAV server in Docker. + + Baikal is a lightweight CalDAV/CardDAV server. + """ + + name = "Baikal" + + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + config = config or {} + config.setdefault("host", os.environ.get("BAIKAL_HOST", "localhost")) + config.setdefault("port", int(os.environ.get("BAIKAL_PORT", "8800"))) + config.setdefault("username", os.environ.get("BAIKAL_USERNAME", "testuser")) + config.setdefault("password", os.environ.get("BAIKAL_PASSWORD", "testpass")) + # Set up Baikal-specific compatibility hints + if "features" not in config: + config["features"] = compatibility_hints.baikal.copy() + super().__init__(config) + + def _default_port(self) -> int: + return 8800 + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}/dav.php" + + +class NextcloudTestServer(DockerTestServer): + """ + Nextcloud CalDAV server in Docker. + + Nextcloud is a self-hosted cloud platform with CalDAV support. + """ + + name = "Nextcloud" + + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + config = config or {} + config.setdefault("host", os.environ.get("NEXTCLOUD_HOST", "localhost")) + config.setdefault("port", int(os.environ.get("NEXTCLOUD_PORT", "8801"))) + config.setdefault("username", os.environ.get("NEXTCLOUD_USERNAME", "testuser")) + config.setdefault("password", os.environ.get("NEXTCLOUD_PASSWORD", "testpass")) + # Set up Nextcloud-specific compatibility hints + if "features" not in config: + config["features"] = compatibility_hints.nextcloud.copy() + super().__init__(config) + + def _default_port(self) -> int: + return 8801 + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}/remote.php/dav" + + def is_accessible(self) -> bool: + """Check if Nextcloud is accessible.""" + try: + response = requests.get(f"{self.url}/", timeout=DEFAULT_HTTP_TIMEOUT) + return response.status_code in (200, 207, 401, 403, 404) + except Exception: + return False + + +class CyrusTestServer(DockerTestServer): + """ + Cyrus IMAP server with CalDAV support in Docker. + + Cyrus is a mail server that also supports CalDAV/CardDAV. + """ + + name = "Cyrus" + + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + config = config or {} + config.setdefault("host", os.environ.get("CYRUS_HOST", "localhost")) + config.setdefault("port", int(os.environ.get("CYRUS_PORT", "8802"))) + config.setdefault("username", os.environ.get("CYRUS_USERNAME", "user1")) + config.setdefault("password", os.environ.get("CYRUS_PASSWORD", "x")) + # Set up Cyrus-specific compatibility hints + if "features" not in config: + config["features"] = compatibility_hints.cyrus.copy() + super().__init__(config) + + def _default_port(self) -> int: + return 8802 + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}/dav/calendars/user/{self.username}" + + def is_accessible(self) -> bool: + """Check if Cyrus is accessible using PROPFIND.""" + try: + response = requests.request( + "PROPFIND", + f"http://{self.host}:{self.port}/dav/", + timeout=DEFAULT_HTTP_TIMEOUT, + ) + return response.status_code in (200, 207, 401, 403, 404) + except Exception: + return False + + +class SOGoTestServer(DockerTestServer): + """ + SOGo groupware server in Docker. + + SOGo is an open-source groupware server with CalDAV support. + """ + + name = "SOGo" + + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + config = config or {} + config.setdefault("host", os.environ.get("SOGO_HOST", "localhost")) + config.setdefault("port", int(os.environ.get("SOGO_PORT", "8803"))) + config.setdefault("username", os.environ.get("SOGO_USERNAME", "testuser")) + config.setdefault("password", os.environ.get("SOGO_PASSWORD", "testpass")) + # Set up SOGo-specific compatibility hints + if "features" not in config: + config["features"] = compatibility_hints.sogo.copy() + super().__init__(config) + + def _default_port(self) -> int: + return 8803 + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}/SOGo/dav/{self.username}" + + def is_accessible(self) -> bool: + """Check if SOGo is accessible using PROPFIND.""" + try: + response = requests.request( + "PROPFIND", + f"http://{self.host}:{self.port}/SOGo/", + timeout=DEFAULT_HTTP_TIMEOUT, + ) + return response.status_code in (200, 207, 401, 403, 404) + except Exception: + return False + + +class BedeworkTestServer(DockerTestServer): + """ + Bedework calendar server in Docker. + + Bedework is an enterprise-class open-source calendar system. + """ + + name = "Bedework" + + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + config = config or {} + config.setdefault("host", os.environ.get("BEDEWORK_HOST", "localhost")) + config.setdefault("port", int(os.environ.get("BEDEWORK_PORT", "8804"))) + config.setdefault("username", os.environ.get("BEDEWORK_USERNAME", "vbede")) + config.setdefault("password", os.environ.get("BEDEWORK_PASSWORD", "bedework")) + # Set up Bedework-specific compatibility hints + if "features" not in config: + config["features"] = compatibility_hints.bedework.copy() + super().__init__(config) + + def _default_port(self) -> int: + return 8804 + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}/ucaldav/user/{self.username}" + + def is_accessible(self) -> bool: + """Check if Bedework is accessible using PROPFIND.""" + try: + response = requests.request( + "PROPFIND", + f"http://{self.host}:{self.port}/ucaldav/", + timeout=DEFAULT_HTTP_TIMEOUT, + ) + return response.status_code in (200, 207, 401, 403, 404) + except Exception: + return False + + +class DavicalTestServer(DockerTestServer): + """ + DAViCal CalDAV server in Docker. + + DAViCal is a CalDAV server using PostgreSQL as its backend. + It provides full CalDAV and CardDAV support. + """ + + name = "Davical" + + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + config = config or {} + config.setdefault("host", os.environ.get("DAVICAL_HOST", "localhost")) + config.setdefault("port", int(os.environ.get("DAVICAL_PORT", "8805"))) + config.setdefault("username", os.environ.get("DAVICAL_USERNAME", "admin")) + config.setdefault("password", os.environ.get("DAVICAL_PASSWORD", "testpass")) + super().__init__(config) + + def _default_port(self) -> int: + return 8805 + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}/davical/caldav.php/{self.username}/" + + def is_accessible(self) -> bool: + """Check if DAViCal is accessible.""" + try: + response = requests.request( + "PROPFIND", + f"http://{self.host}:{self.port}/davical/caldav.php/", + timeout=DEFAULT_HTTP_TIMEOUT, + ) + return response.status_code in (200, 207, 401, 403, 404) + except Exception: + return False + + +# Register server classes +register_server_class("baikal", BaikalTestServer) +register_server_class("nextcloud", NextcloudTestServer) +register_server_class("cyrus", CyrusTestServer) +register_server_class("sogo", SOGoTestServer) +register_server_class("bedework", BedeworkTestServer) +register_server_class("davical", DavicalTestServer) diff --git a/tests/test_servers/embedded.py b/tests/test_servers/embedded.py new file mode 100644 index 00000000..a9954628 --- /dev/null +++ b/tests/test_servers/embedded.py @@ -0,0 +1,308 @@ +""" +Embedded test server implementations. + +This module provides test server implementations for servers that run +in-process: Radicale and Xandikos. +""" +import socket +import tempfile +import threading +from typing import Any +from typing import Optional + +try: + import niquests as requests +except ImportError: + import requests # type: ignore + +from caldav import compatibility_hints + +from .base import EmbeddedTestServer +from .registry import register_server_class + + +class RadicaleTestServer(EmbeddedTestServer): + """ + Radicale CalDAV server running in a thread. + + Radicale is a lightweight CalDAV server that's easy to embed + for testing purposes. + """ + + name = "LocalRadicale" + + def __init__(self, config: Optional[dict[str, Any]] = None) -> None: + config = config or {} + config.setdefault("host", "localhost") + config.setdefault("port", 5232) + config.setdefault("username", "user1") + config.setdefault("password", "") + # Set up Radicale-specific compatibility hints + if "features" not in config: + features = compatibility_hints.radicale.copy() + host = config.get("host", "localhost") + port = config.get("port", 5232) + features["auto-connect.url"]["domain"] = f"{host}:{port}" + config["features"] = features + super().__init__(config) + + # Server state + self.serverdir: Optional[tempfile.TemporaryDirectory] = None + self.shutdown_socket: Optional[socket.socket] = None + self.shutdown_socket_out: Optional[socket.socket] = None + self.thread: Optional[threading.Thread] = None + + def _default_port(self) -> int: + return 5232 + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}/{self.username}" + + def is_accessible(self) -> bool: + try: + # Check the user URL to ensure the server is ready + # and to auto-create the user collection (Radicale does this on first access) + response = requests.get( + f"http://{self.host}:{self.port}/{self.username}", + timeout=2, + ) + return response.status_code in (200, 401, 403, 404) + except Exception: + return False + + def start(self) -> None: + """Start the Radicale server in a background thread.""" + # Only check is_accessible() if we haven't been started before. + # After stop() is called, the port might still respond briefly, + # so we can't trust is_accessible() in that case. + if self._started: + return + if not hasattr(self, "_was_stopped") and self.is_accessible(): + return + + try: + import radicale + import radicale.config + import radicale.server + except ImportError as e: + raise RuntimeError("Radicale is not installed") from e + + # Create temporary storage directory + self.serverdir = tempfile.TemporaryDirectory() + self.serverdir.__enter__() + + # Configure Radicale + configuration = radicale.config.load("") + configuration.update( + { + "storage": {"filesystem_folder": self.serverdir.name}, + "auth": {"type": "none"}, + } + ) + + # Create shutdown socket pair + self.shutdown_socket, self.shutdown_socket_out = socket.socketpair() + + # Start server thread + self.thread = threading.Thread( + target=radicale.server.serve, + args=(configuration, self.shutdown_socket_out), + ) + self.thread.start() + + # Wait for server to be ready + self._wait_for_startup() + + # Create the user collection with MKCOL + # Radicale requires the parent collection to exist before MKCALENDAR + user_url = f"http://{self.host}:{self.port}/{self.username}/" + try: + response = requests.request( + "MKCOL", + user_url, + timeout=5, + ) + # 201 = created, 405 = already exists (or method not allowed) + if response.status_code not in (200, 201, 204, 405): + # Some servers need a trailing slash, try without + response = requests.request( + "MKCOL", + user_url.rstrip("/"), + timeout=5, + ) + except Exception: + pass # Ignore errors, the collection might already exist + + self._started = True + + def stop(self) -> None: + """Stop the Radicale server and cleanup.""" + if self.shutdown_socket: + self.shutdown_socket.close() + self.shutdown_socket = None + self.shutdown_socket_out = None + + if self.thread: + self.thread.join(timeout=5) + self.thread = None + + if self.serverdir: + self.serverdir.__exit__(None, None, None) + self.serverdir = None + + self._started = False + self._was_stopped = True # Mark that we've been stopped at least once + + +class XandikosTestServer(EmbeddedTestServer): + """ + Xandikos CalDAV server running with aiohttp. + + Xandikos is an async CalDAV server that uses aiohttp. + We run it in a separate thread with its own event loop. + """ + + name = "LocalXandikos" + + def __init__(self, config: Optional[dict[str, Any]] = None) -> None: + config = config or {} + config.setdefault("host", "localhost") + config.setdefault("port", 8993) + config.setdefault("username", "sometestuser") + # Set up Xandikos-specific compatibility hints + if "features" not in config: + features = compatibility_hints.xandikos.copy() + host = config.get("host", "localhost") + port = config.get("port", 8993) + features["auto-connect.url"]["domain"] = f"{host}:{port}" + config["features"] = features + super().__init__(config) + + # Server state + self.serverdir: Optional[tempfile.TemporaryDirectory] = None + self.xapp_loop: Optional[Any] = None + self.xapp_runner: Optional[Any] = None + self.xapp: Optional[Any] = None + self.thread: Optional[threading.Thread] = None + + def _default_port(self) -> int: + return 8993 + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}/{self.username}" + + def is_accessible(self) -> bool: + try: + response = requests.request( + "PROPFIND", + f"http://{self.host}:{self.port}", + timeout=2, + ) + return response.status_code in (200, 207, 401, 403, 404) + except Exception: + return False + + def start(self) -> None: + """Start the Xandikos server.""" + # Only check is_accessible() if we haven't been started before. + # After stop() is called, the port might still respond briefly, + # so we can't trust is_accessible() in that case. + if self._started: + return + if not hasattr(self, "_was_stopped") and self.is_accessible(): + return + + try: + from xandikos.web import XandikosApp, XandikosBackend + except ImportError as e: + raise RuntimeError("Xandikos is not installed") from e + + import asyncio + + from aiohttp import web + + # Create temporary storage directory + self.serverdir = tempfile.TemporaryDirectory() + self.serverdir.__enter__() + + # Create backend and configure principal (following conf.py pattern) + backend = XandikosBackend(path=self.serverdir.name) + backend._mark_as_principal(f"/{self.username}/") + backend.create_principal(f"/{self.username}/", create_defaults=True) + + # Create the Xandikos app with the backend + mainapp = XandikosApp( + backend, current_user_principal=self.username, strict=True + ) + + # Create aiohttp handler + async def xandikos_handler(request: web.Request) -> web.Response: + return await mainapp.aiohttp_handler(request, "/") + + self.xapp = web.Application() + self.xapp.router.add_route("*", "/{path_info:.*}", xandikos_handler) + + def run_in_thread() -> None: + self.xapp_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.xapp_loop) + + async def start_app() -> None: + self.xapp_runner = web.AppRunner(self.xapp) + await self.xapp_runner.setup() + site = web.TCPSite(self.xapp_runner, self.host, self.port) + await site.start() + + self.xapp_loop.run_until_complete(start_app()) + self.xapp_loop.run_forever() + + # Start server in a background thread + self.thread = threading.Thread(target=run_in_thread) + self.thread.start() + + # Wait for server to be ready + self._wait_for_startup() + self._started = True + + def stop(self) -> None: + """Stop the Xandikos server and cleanup.""" + import asyncio + + if self.xapp_loop and self.xapp_runner: + # Clean shutdown: first cleanup the aiohttp runner (stops accepting + # connections and waits for in-flight requests), then stop the loop. + # This must be done from within the event loop thread. + async def cleanup_and_stop() -> None: + await self.xapp_runner.cleanup() + self.xapp_loop.stop() + + try: + asyncio.run_coroutine_threadsafe( + cleanup_and_stop(), self.xapp_loop + ).result(timeout=10) + except Exception: + # Fallback: force stop if cleanup fails + if self.xapp_loop: + self.xapp_loop.call_soon_threadsafe(self.xapp_loop.stop) + elif self.xapp_loop: + self.xapp_loop.call_soon_threadsafe(self.xapp_loop.stop) + + if self.thread: + self.thread.join(timeout=5) + self.thread = None + + if self.serverdir: + self.serverdir.__exit__(None, None, None) + self.serverdir = None + + self.xapp_loop = None + self.xapp_runner = None + self.xapp = None + self._started = False + self._was_stopped = True # Mark that we've been stopped at least once + + +# Register server classes +register_server_class("radicale", RadicaleTestServer) +register_server_class("xandikos", XandikosTestServer) diff --git a/tests/test_servers/helpers.py b/tests/test_servers/helpers.py new file mode 100644 index 00000000..927f0bdc --- /dev/null +++ b/tests/test_servers/helpers.py @@ -0,0 +1,115 @@ +""" +Helper functions for test server management. + +Provides convenient context managers for tests that need a running server +with get_davclient() support. +""" +import json +import os +import tempfile +from contextlib import contextmanager +from typing import Optional + +from .registry import get_registry +from caldav import DAVClient + + +@contextmanager +def client_context(server_index: int = 0, server_name: Optional[str] = None): + """ + Context manager that provides a running test server and configured environment. + + This is the recommended way to get a test client when you need: + - A running server + - Environment configured so get_davclient() works + - Automatic cleanup + + Usage: + from tests.test_servers import client_context + + with client_context() as client: + principal = client.principal() + # get_davclient() will also work within this context + + Args: + server_index: Index into the caldav_servers list (default: 0, first server) + server_name: Optional server name to use instead of index + + Yields: + DAVClient: Connected client to the test server + + Raises: + RuntimeError: If no test servers are configured + """ + registry = get_registry() + servers = registry.get_caldav_servers_list() + + if not servers: + raise RuntimeError("No test servers configured") + + # Find the server to use + if server_name: + server_params = None + for s in servers: + if s.get("name") == server_name: + server_params = s + break + if not server_params: + raise RuntimeError(f"Server '{server_name}' not found") + else: + server_params = servers[server_index] + + # Import here to avoid circular imports + from caldav.davclient import CONNKEYS + + # Create client and start server via setup callback + kwargs = {k: v for k, v in server_params.items() if k in CONNKEYS} + conn = DAVClient(**kwargs) + conn.setup = server_params.get("setup", lambda _: None) + conn.teardown = server_params.get("teardown", lambda _: None) + + # Create temporary config file for get_davclient() + config = {"testing_allowed": True} + for key in ("username", "password", "proxy"): + if key in server_params: + config[f"caldav_{key}"] = server_params[key] + config["caldav_url"] = server_params["url"] + + config_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) + json.dump({"default": config}, config_file) + config_file.close() + + # Set environment variables + old_config_file = os.environ.get("CALDAV_CONFIG_FILE") + old_test_server = os.environ.get("PYTHON_CALDAV_USE_TEST_SERVER") + + os.environ["CALDAV_CONFIG_FILE"] = config_file.name + os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] = "1" + + try: + # Enter client context (starts server) + conn.__enter__() + yield conn + finally: + # Exit client context (stops server) + conn.__exit__(None, None, None) + + # Clean up config file + os.unlink(config_file.name) + + # Restore environment + if old_config_file is not None: + os.environ["CALDAV_CONFIG_FILE"] = old_config_file + else: + os.environ.pop("CALDAV_CONFIG_FILE", None) + + if old_test_server is not None: + os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] = old_test_server + else: + os.environ.pop("PYTHON_CALDAV_USE_TEST_SERVER", None) + + +def has_test_servers() -> bool: + """Check if any test servers are configured.""" + registry = get_registry() + return len(registry.get_caldav_servers_list()) > 0 diff --git a/tests/test_servers/registry.py b/tests/test_servers/registry.py new file mode 100644 index 00000000..1bd4003d --- /dev/null +++ b/tests/test_servers/registry.py @@ -0,0 +1,254 @@ +""" +Server registry for test server discovery and management. + +This module provides a registry for discovering and managing test servers. +It supports automatic detection of available servers and lazy initialization. +""" +from typing import Dict +from typing import List +from typing import Optional +from typing import Type + +from .base import TestServer + + +# Server class registry - maps type names to server classes +_SERVER_CLASSES: Dict[str, Type[TestServer]] = {} + + +def register_server_class(type_name: str, server_class: Type[TestServer]) -> None: + """ + Register a server class for a given type name. + + Args: + type_name: The type identifier (e.g., "radicale", "baikal") + server_class: The TestServer subclass + """ + _SERVER_CLASSES[type_name] = server_class + + +def get_server_class(type_name: str) -> Optional[Type[TestServer]]: + """ + Get the server class for a given type name. + + Args: + type_name: The type identifier + + Returns: + The TestServer subclass, or None if not found + """ + return _SERVER_CLASSES.get(type_name) + + +class ServerRegistry: + """ + Registry for test server discovery and management. + + The registry maintains a collection of test servers and provides + methods for discovering, starting, and stopping them. + + Usage: + registry = ServerRegistry() + registry.auto_discover() # Detect available servers + + for server in registry.all_servers(): + server.start() + # ... run tests ... + server.stop() + """ + + def __init__(self) -> None: + self._servers: Dict[str, TestServer] = {} + + def register(self, server: TestServer) -> None: + """ + Register a test server. + + Args: + server: The test server instance to register + """ + self._servers[server.name] = server + + def unregister(self, name: str) -> Optional[TestServer]: + """ + Unregister a test server by name. + + Args: + name: The server name + + Returns: + The removed server, or None if not found + """ + return self._servers.pop(name, None) + + def get(self, name: str) -> Optional[TestServer]: + """ + Get a test server by name. + + Args: + name: The server name + + Returns: + The server instance, or None if not found + """ + return self._servers.get(name) + + def all_servers(self) -> List[TestServer]: + """ + Get all registered test servers. + + Returns: + List of all registered servers + """ + return list(self._servers.values()) + + def enabled_servers(self) -> List[TestServer]: + """ + Get all enabled test servers. + + Returns: + List of servers where config.get("enabled", True) is True + """ + return [s for s in self._servers.values() if s.config.get("enabled", True)] + + def load_from_config(self, config: Dict) -> None: + """ + Load servers from a configuration dict. + + The config should be a dict mapping server names to their configs: + { + "radicale": {"type": "embedded", "port": 5232, ...}, + "baikal": {"type": "docker", "port": 8800, ...}, + } + + Args: + config: Configuration dict + """ + for name, server_config in config.items(): + if not server_config.get("enabled", True): + continue + + server_type = server_config.get("type", name) + server_class = get_server_class(server_type) + + if server_class is None: + # Try to find by name if type not found + server_class = get_server_class(name) + + if server_class is not None: + server_config["name"] = name + server = server_class(server_config) + self.register(server) + + def auto_discover(self) -> None: + """ + Automatically discover and register available test servers. + + This checks for: + - Radicale (if radicale package is installed) + - Xandikos (if xandikos package is installed) + - Docker servers (if docker-compose is available) + """ + # Import server implementations to trigger registration + try: + from . import embedded + except ImportError: + pass + + try: + from . import docker + except ImportError: + pass + + # Discover embedded servers + self._discover_embedded_servers() + + # Discover Docker servers + self._discover_docker_servers() + + def _discover_embedded_servers(self) -> None: + """Discover available embedded servers.""" + # Check for Radicale + try: + import radicale # noqa: F401 + + radicale_class = get_server_class("radicale") + if radicale_class is not None: + self.register(radicale_class()) + except ImportError: + pass + + # Check for Xandikos + try: + import xandikos # noqa: F401 + + xandikos_class = get_server_class("xandikos") + if xandikos_class is not None: + self.register(xandikos_class()) + except ImportError: + pass + + def _discover_docker_servers(self) -> None: + """Discover available Docker servers.""" + from .base import DockerTestServer + from pathlib import Path + + if not DockerTestServer.verify_docker(): + return + + # Look for docker-test-servers directories + docker_servers_dir = Path(__file__).parent.parent / "docker-test-servers" + if not docker_servers_dir.exists(): + return + + # Check each subdirectory for a start.sh script + for server_dir in docker_servers_dir.iterdir(): + if server_dir.is_dir() and (server_dir / "start.sh").exists(): + server_name = server_dir.name + server_class = get_server_class(server_name) + + if server_class is not None and server_name not in self._servers: + self.register(server_class({"docker_dir": str(server_dir)})) + + def get_caldav_servers_list(self) -> List[Dict]: + """ + Return list compatible with current caldav_servers format. + + This is for backwards compatibility with the existing test infrastructure. + + Returns: + List of server parameter dicts + """ + return [s.get_server_params() for s in self.enabled_servers()] + + +# Global registry instance +_global_registry: Optional[ServerRegistry] = None + + +def get_registry() -> ServerRegistry: + """ + Get the global server registry instance. + + Creates the registry on first call and runs auto-discovery. + + Returns: + The global ServerRegistry instance + """ + global _global_registry + if _global_registry is None: + _global_registry = ServerRegistry() + _global_registry.auto_discover() + return _global_registry + + +def get_available_servers() -> List[TestServer]: + """ + Get all available test servers. + + Convenience function that returns enabled servers from the global registry. + + Returns: + List of available test servers + """ + return get_registry().enabled_servers() diff --git a/tests/test_sync_token_fallback.py b/tests/test_sync_token_fallback.py index ecdfce90..893ccb01 100644 --- a/tests/test_sync_token_fallback.py +++ b/tests/test_sync_token_fallback.py @@ -129,13 +129,13 @@ def test_fallback_returns_empty_when_nothing_changed(self, mock_search) -> None: self.mock_client.features.is_supported.return_value = {"support": "unsupported"} # First call: get initial state - result1 = self.calendar.objects_by_sync_token( + result1 = self.calendar.get_objects_by_sync_token( sync_token=None, load_objects=False ) initial_token = result1.sync_token # Second call: with same token, should return empty - result2 = self.calendar.objects_by_sync_token( + result2 = self.calendar.get_objects_by_sync_token( sync_token=initial_token, load_objects=False ) @@ -152,7 +152,7 @@ def test_fallback_returns_all_when_etag_changed(self, mock_search) -> None: self.mock_client.features.is_supported.return_value = {"support": "unsupported"} - result1 = self.calendar.objects_by_sync_token( + result1 = self.calendar.get_objects_by_sync_token( sync_token=None, load_objects=False ) initial_token = result1.sync_token @@ -165,7 +165,7 @@ def test_fallback_returns_all_when_etag_changed(self, mock_search) -> None: mock_search.return_value = [obj1_modified, obj2_same] # Second call: with old token, should detect change and return all objects - result2 = self.calendar.objects_by_sync_token( + result2 = self.calendar.get_objects_by_sync_token( sync_token=initial_token, load_objects=False ) @@ -174,6 +174,7 @@ def test_fallback_returns_all_when_etag_changed(self, mock_search) -> None: ), "Should return all objects when changes detected" assert result2.sync_token != initial_token + ## TODO @pytest.mark.xfail( reason="Mock objects don't preserve props updates properly - integration test needed" ) @@ -210,7 +211,7 @@ def test_fallback_fetches_etags_when_missing( self.mock_client.features.is_supported.return_value = {"support": "unsupported"} - result1 = self.calendar.objects_by_sync_token( + result1 = self.calendar.get_objects_by_sync_token( sync_token=None, load_objects=False ) initial_token = result1.sync_token @@ -245,7 +246,7 @@ def test_fallback_fetches_etags_when_missing( mock_query_props.return_value = mock_response2 # Second call: should detect change via ETags - result2 = self.calendar.objects_by_sync_token( + result2 = self.calendar.get_objects_by_sync_token( sync_token=initial_token, load_objects=False )