From d75d3c1066875f13ab0427fd0de64081d9548dad Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 26 Mar 2026 16:50:45 +0100 Subject: [PATCH 1/2] fix: UnboundLocalError in _resolve_properties when PROPFIND response paths don't match In production mode, error.assert_() only logs rather than raising, so the else-branch of _resolve_properties() would fall through to self.props.update(rc) with rc unbound, causing UnboundLocalError. Adding return {} avoids the crash. Fixes https://github.com/pycalendar/calendar-cli/issues/114 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + caldav/davobject.py | 1 + tests/test_caldav_unit.py | 23 +++++++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd282426..39f62a8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0 ### Fixed * Reusing a `CalDAVSearcher` across multiple `search()` calls could yield inconsistent results: the first call would return only pending tasks (correct), but subsequent calls would change behaviour because `icalendar_searcher.Searcher.check_component()` mutated the `include_completed` field from `None` to `False` as a side-effect. Fixed by passing a copy with `include_completed` already resolved to `filter_search_results()`, leaving the original searcher object unchanged. Fixes https://github.com/python-caldav/caldav/issues/650 +* `_resolve_properties()` would crash with `UnboundLocalError` in production mode when a server returned an empty or unrecognisable PROPFIND response (the response paths did not match the request URI and there was more than one or zero paths returned). Fixed by returning `{}` instead of falling through to an unbound variable. Related: https://github.com/pycalendar/calendar-cli/issues/114 ## [3.1.0] - 2026-03-19 diff --git a/caldav/davobject.py b/caldav/davobject.py index 44dfcfd5..eb17b30a 100644 --- a/caldav/davobject.py +++ b/caldav/davobject.py @@ -356,6 +356,7 @@ def _resolve_properties(self, properties: dict) -> dict: f"paths found: {list(properties.keys())}" ) error.assert_(False) + return {} self.props.update(rc) return rc diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index 94aa4d38..b7d65b94 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -2713,3 +2713,26 @@ def test_meta_section_returns_multiple_dicts(self, tmp_path): "https://work.example.com/dav/", "https://personal.example.com/dav/", } + + +class TestResolveProperties: + """Tests for _resolve_properties unbound variable bug (issue #647 / calendar-cli #114).""" + + def _make_calendar(self, path="/calendar/"): + client = DAVClient(url="https://example.com") + return Calendar(client=client, url=f"https://example.com{path}") + + def test_resolve_properties_empty_dict_production_mode(self): + """In PRODUCTION mode, error.assert_ only logs; _resolve_properties must + not crash with UnboundLocalError when properties dict is empty.""" + cal = self._make_calendar() + with mock.patch.object(error, "debugmode", "PRODUCTION"): + result = cal._resolve_properties({}) + assert result == {} + + def test_resolve_properties_unmatched_paths_production_mode(self): + """Same but with a non-empty properties dict where path does not match.""" + cal = self._make_calendar("/calendar/") + with mock.patch.object(error, "debugmode", "PRODUCTION"): + result = cal._resolve_properties({"/other/path/": {"foo": "bar"}, "/yet/another/": {"baz": "qux"}}) + assert result == {} From a35a8e743dd50175880727163f991294d224de84 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 27 Mar 2026 00:37:25 +0100 Subject: [PATCH 2/2] style: apply ruff-format to test_caldav_unit.py Co-Authored-By: Claude Sonnet 4.6 --- tests/test_caldav_unit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index b7d65b94..128a8aad 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -2734,5 +2734,7 @@ def test_resolve_properties_unmatched_paths_production_mode(self): """Same but with a non-empty properties dict where path does not match.""" cal = self._make_calendar("/calendar/") with mock.patch.object(error, "debugmode", "PRODUCTION"): - result = cal._resolve_properties({"/other/path/": {"foo": "bar"}, "/yet/another/": {"baz": "qux"}}) + result = cal._resolve_properties( + {"/other/path/": {"foo": "bar"}, "/yet/another/": {"baz": "qux"}} + ) assert result == {}