From 22bb47edba0215fa5455ef8cd7774e21dc3575d3 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 15 Jun 2025 10:40:43 +0200 Subject: [PATCH 01/11] brushing up examples, documentation, changelog, killing some deprecation warnings from the test code, and covering the basic_usage_examples by the test code --- CHANGELOG.md | 4 +++ docs/source/index.rst | 1 + docs/source/tutorial.rst | 4 ++- examples/basic_usage_examples.py | 30 +++++++++++------------ examples/get_events_example.py | 8 ++++-- examples/scheduling_examples.py | 4 +++ examples/sync_examples.py | 1 + tests/test_caldav.py | 42 ++++++++++++++++---------------- tests/test_examples.py | 5 ++++ 9 files changed, 59 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0397a95..f9a25021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0 * `event.component` is now an alias for `event.icalendar_component`. * `get_davclient` (earlier called `auto_conn`) is more complete now - it could already read from test config, now it can read from environment (including environment variable for reading from test config and for locating the config file). While the `auto_conn` itself is tested in the functional tests, the code for reading the config file (and all the corner cases) is not tested. It's allowable with a yaml config file, but the yaml module is not included in the dependencies yet ... so late imports as for now. - https://github.com/python-caldav/caldav/pull/502 - https://github.com/python-caldav/caldav/issues/485 +### Fixes + +* Support for Lark/Feishu got broken in the 1.6-release. Issue found and fixed by Hongbin Yang (github user @zealseeker) in https://github.com/python-caldav/caldav/issues/505 and https://github.com/python-caldav/caldav/pull/506 + ### Changed * The request library has been in a feature freeze for ages and may seem like a dead end. There exists a fork of the project niquests, we're migrating to that one. This means nothing except for one additional dependency. (httpx was also considered, but it's not a drop-in replacement for the requests library, and it's a risk that such a change will break compatibility with various other servers - see https://github.com/python-caldav/caldav/issues/457 for details). Work by @ArtemIsmagilov, https://github.com/python-caldav/caldav/pull/455. diff --git a/docs/source/index.rst b/docs/source/index.rst index 9877b3c1..c2342fea 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,6 +15,7 @@ Contents about tutorial reference + examples ==================== Indices and tables diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index cccaea8f..92ff74c0 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -5,7 +5,9 @@ Tutorial In this tutorial you should learn basic usage of the python CalDAV client library. You are encouraged to copy the code examples into a file and add a ``breakpoint()`` inside the with-block so you can -inspect the return objects you get from the library calls. +inspect the return objects you get from the library calls. Do not +name your file `caldav.py` or `calendar.py`, this may break some +imports. To follow this tutorial as intended, each code block should be run towards a clean-slate Radicale server. To do this, you need: diff --git a/examples/basic_usage_examples.py b/examples/basic_usage_examples.py index bf404e5e..d7f59465 100644 --- a/examples/basic_usage_examples.py +++ b/examples/basic_usage_examples.py @@ -8,6 +8,7 @@ sys.path.insert(0, ".") import caldav +from caldav.davclient import get_davclient ## DO NOT name your file calendar.py or caldav.py! We've had several ## issues filed, things break because the wrong files are imported. @@ -29,13 +30,10 @@ def run_examples(): ## The client object stores http session information, username, password, etc. ## As of 1.0, Initiating the client object will not cause any server communication, ## so the credentials aren't validated. + ## get_davclient will try to read credentials and url from environment variables + ## and config file. ## The client object can be used as a context manager, like this: - with caldav.DAVClient( - url=caldav_url, - username=username, - password=password, - headers=headers, # Optional parameter to set HTTP headers on each request if needed - ) as client: + with get_davclient() as client: ## Typically the next step is to fetch a principal object. ## This will cause communication with the server. my_principal = client.principal() @@ -132,20 +130,20 @@ def read_modify_event_demo(event): ## 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 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.icalendar_component["uid"] + ## 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.icalendar_component["summary"] = event.icalendar_component["summary"].replace( + event.component["summary"] = event.component["summary"].replace( "celebratiuns", "celebrations" ) ## timestamps (DTSTAMP, DTSTART, DTEND for events, DUE for tasks, ## etc) can be fetched using the icalendar library like this: - dtstart = event.icalendar_component.get("dtstart") + 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, @@ -156,13 +154,13 @@ def read_modify_event_demo(event): ## We can modify it: if dtstart: - event.icalendar_component["dtstart"].dt = dtstart.dt + timedelta(seconds=3600) + 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.icalendar_component + icalendar_component = event.component ## accessing the data (and setting it) will "disconnect" the ## icalendar_component from the event event.data = event.data @@ -184,7 +182,7 @@ def read_modify_event_demo(event): calendar = event.parent same_event = calendar.event_by_uid(uid) assert ( - same_event.icalendar_component["summary"] + same_event.component["summary"] == "Norwegian national day celebrations" ) diff --git a/examples/get_events_example.py b/examples/get_events_example.py index 5942fed0..97ec105f 100644 --- a/examples/get_events_example.py +++ b/examples/get_events_example.py @@ -3,8 +3,9 @@ from caldav.davclient import get_davclient -## Code contributed by Крылов Александр. Minor changes and quite some -## comments by Tobias Brox. +## Code contributed by Крылов Александр. +## Minor changes by Tobias Brox. +## All comments by Tobias Brox. ## Set CALDAV_USERNAME, CALDAV_URL and CALDAV_PASSWORD through ## environment variables before running this example @@ -37,6 +38,9 @@ def fill_event(component, calendar) -> dict[str, str]: cur["calendar"] = f"{calendar}" cur["summary"] = component.get("summary") cur["description"] = component.get("description") + ## month/day/year time? Never ever do that! + ## It's one of the most confusing date formats ever! + ## Use year-month-day time instead ... https://xkcd.com/1179/ cur["start"] = component.start.strftime("%m/%d/%Y %H:%M") endDate = component.end if endDate: diff --git a/examples/scheduling_examples.py b/examples/scheduling_examples.py index 58e68e9e..ddab881a 100644 --- a/examples/scheduling_examples.py +++ b/examples/scheduling_examples.py @@ -1,3 +1,7 @@ +## NOTE! This is currently NOT tested. It may and may not work. +## Please reach out if you need help with scheduling ... by https://xkcd.com/1179/ +## or scheduling-help@plann.no + import sys import uuid from datetime import datetime diff --git a/examples/sync_examples.py b/examples/sync_examples.py index 58c6274f..98783531 100644 --- a/examples/sync_examples.py +++ b/examples/sync_examples.py @@ -3,6 +3,7 @@ ## similar code in the tests/test_caldav.py file. Raise a github ## issue or reach out by email or write a pull request or send a patch ## if there are mistakes in this code) ... + ## USE CASE #1: we'll have a local copy of all calendar contents in a ## running python process, and later we'd like to synchronize the ## local contents. (In case of a reboot, all contents will be diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 98cb4b02..2a45a300 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -1412,15 +1412,15 @@ def testCopyEvent(self): ## what will happen with the event in c1 if we modify the event in c2, ## which shares the id with the event in c1? - e1_in_c2.instance.vevent.summary.value = "asdf" + e1_in_c2.vobject_instance.vevent.summary.value = "asdf" e1_in_c2.save() e1.load() ## should e1.summary be 'asdf' or 'Bastille Day Party'? I do ## not know, but all implementations I've tested will treat ## the copy in the other calendar as a distinct entity, even ## if the uid is the same. - assert e1.instance.vevent.summary.value == "Bastille Day Party" - assert c2.events()[0].instance.vevent.uid == e1.instance.vevent.uid + assert e1.vobject_instance.vevent.summary.value == "Bastille Day Party" + assert c2.events()[0].vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid ## Duplicate the event in the same calendar, with same uid - ## this makes no sense, there won't be any duplication @@ -1602,17 +1602,17 @@ def testSearchEvent(self): ## Even sorting should work out all_events = c.search(sort_keys=("summary", "dtstamp")) assert len(all_events) == 3 - assert all_events[0].instance.vevent.summary.value == "Bastille Day Jitsi Party" + assert all_events[0].vobject_instance.vevent.summary.value == "Bastille Day Jitsi Party" ## Sorting by upper case should also wor all_events = c.search(sort_keys=("SUMMARY", "DTSTAMP")) assert len(all_events) == 3 - assert all_events[0].instance.vevent.summary.value == "Bastille Day Jitsi Party" + assert all_events[0].vobject_instance.vevent.summary.value == "Bastille Day Jitsi Party" ## Sorting in reverse order should work also all_events = c.search(sort_keys=("SUMMARY", "DTSTAMP"), sort_reverse=True) assert len(all_events) == 3 - assert all_events[0].instance.vevent.summary.value == "Our Blissful Anniversary" + assert all_events[0].vobject_instance.vevent.summary.value == "Our Blissful Anniversary" ## A more robust check for the sort key all_events = c.search(sort_keys=("DTSTART",)) @@ -2146,7 +2146,7 @@ def testTodos(self): assert len(todos) == 3 def uids(lst): - return [x.instance.vtodo.uid for x in lst] + return [x.vobject_instance.vtodo.uid for x in lst] ## Default sort order is (due, priority). assert uids(todos) == uids([t2, t1, t4]) @@ -2158,9 +2158,9 @@ def uids(lst): def pri(lst): return [ - x.instance.vtodo.priority.value + x.vobject_instance.vtodo.priority.value for x in lst - if hasattr(x.instance.vtodo, "priority") + if hasattr(x.vobject_instance.vtodo, "priority") ] assert pri(todos) == pri([t4, t2]) @@ -2372,9 +2372,9 @@ def testTodoCompletion(self): assert len(todos) == 3 if not self.check_compatibility_flag("object_by_uid_is_broken"): t3_ = c.todo_by_uid(t3.id) - assert t3_.instance.vtodo.summary == t3.instance.vtodo.summary - assert t3_.instance.vtodo.uid == t3.instance.vtodo.uid - assert t3_.instance.vtodo.dtstart == t3.instance.vtodo.dtstart + assert t3_.vobject_instance.vtodo.summary == t3.vobject_instance.vtodo.summary + assert t3_.vobject_instance.vtodo.uid == t3.vobject_instance.vtodo.uid + assert t3_.vobject_instance.vtodo.dtstart == t3.vobject_instance.vtodo.dtstart t2.delete() @@ -2603,11 +2603,11 @@ def testLookupEvent(self): # Verify that we can look it up, both by URL and by ID if not self.check_compatibility_flag("event_by_url_is_broken"): e2 = c.event_by_url(e1.url) - assert e2.instance.vevent.uid == e1.instance.vevent.uid + assert e2.vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid assert e2.url == e1.url if not self.check_compatibility_flag("object_by_uid_is_broken"): e3 = c.event_by_uid("20010712T182145Z-123401@example.com") - assert e3.instance.vevent.uid == e1.instance.vevent.uid + assert e3.vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid assert e3.url == e1.url # Knowing the URL of an event, we should be able to get to it @@ -2615,7 +2615,7 @@ def testLookupEvent(self): if not self.check_compatibility_flag("event_by_url_is_broken"): e4 = Event(client=self.caldav, url=e1.url) e4.load() - assert e4.instance.vevent.uid == e1.instance.vevent.uid + assert e4.vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid with pytest.raises(error.NotFoundError): c.event_by_uid("0") @@ -2674,16 +2674,16 @@ def testCreateOverwriteDeleteEvent(self): t2 = c.save_todo(todo, no_create=no_create) ## this should also work. - e2.instance.vevent.summary.value = e2.instance.vevent.summary.value + "!" + e2.vobject_instance.vevent.summary.value = e2.vobject_instance.vevent.summary.value + "!" e2.save(no_create=no_create) if todo_ok: - t2.instance.vtodo.summary.value = t2.instance.vtodo.summary.value + "!" + t2.vobject_instance.vtodo.summary.value = t2.vobject_instance.vtodo.summary.value + "!" t2.save(no_create=no_create) if not self.check_compatibility_flag("event_by_url_is_broken"): e3 = c.event_by_url(e1.url) - assert e3.instance.vevent.summary.value == "Bastille Day Party!" + assert e3.vobject_instance.vevent.summary.value == "Bastille Day Party!" ## "no_overwrite" should throw a ConsistencyError. But it depends on object_by_uid. if not self.check_compatibility_flag("object_by_uid_is_broken"): @@ -2745,8 +2745,8 @@ def testDateSearchAndFreeBusy(self): expand=False, ) - assert e.instance.vevent.uid == r1[0].instance.vevent.uid - assert e.instance.vevent.uid == r2[0].instance.vevent.uid + assert e.vobject_instance.vevent.uid == r1[0].vobject_instance.vevent.uid + assert e.vobject_instance.vevent.uid == r2[0].vobject_instance.vevent.uid assert len(r1) == 1 assert len(r2) == 1 @@ -2794,7 +2794,7 @@ def testDateSearchAndFreeBusy(self): ) # TODO: assert something more complex on the return object assert isinstance(freebusy, FreeBusy) - assert freebusy.instance.vfreebusy + assert freebusy.vobject_instance.vfreebusy def testRecurringDateSearch(self): """ diff --git a/tests/test_examples.py b/tests/test_examples.py index 10f69197..4f196862 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -26,3 +26,8 @@ def test_get_events_example(self): from examples import get_events_example get_events_example.fetch_and_print() + + def test_basic_usage_examples(self): + from examples import basic_usage_examples + basic_usage_examples.run_examples() + From d90b75c0d868e1a564a598e2bd3fae5191993c3f Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 15 Jun 2025 14:08:19 +0200 Subject: [PATCH 02/11] work on config file documentation --- caldav/davclient.py | 9 +++++-- docs/source/configfile.rst | 48 ++++++++++++++++++++++++++++++++++++++ docs/source/reference.rst | 1 + 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 docs/source/configfile.rst diff --git a/caldav/davclient.py b/caldav/davclient.py index 9bd3cf8c..e12db46b 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -749,6 +749,7 @@ def request( ) try: + logging.error(f"doing {method} towards {str(url_obj)} through {self.session.request}") r = self.session.request( method, str(url_obj), @@ -760,6 +761,7 @@ def request( verify=self.ssl_verify_cert, cert=self.ssl_cert, ) + logging.error(f"done with {method} towards {str(url_obj)}") log.debug("server responded with %i %s" % (r.status_code, r.reason)) response = DAVResponse(r, self) except: @@ -977,11 +979,14 @@ def get_davclient( if environment: conf = {} - for conf_key in (x for x in os.environ if x.startswith("CALDAV_")): + 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) - config_file = os.environ.get("CALDAV_CONFIG_FILE") + if not config_file: + config_file = os.environ.get("CALDAV_CONFIG_FILE") + if not config_section: + config_section = os.enviorn.get("CALDAV_CONFIG_SECTION") if config_file: ## late import in 2.0, as the config stuff isn't properly tested diff --git a/docs/source/configfile.rst b/docs/source/configfile.rst new file mode 100644 index 00000000..6a420898 --- /dev/null +++ b/docs/source/configfile.rst @@ -0,0 +1,48 @@ +================== +Config file format +================== + + +The :class:`davclient.get_davclient` method (and perhaps in 2.1, also ``davclient.get_calendar``) can read from a config file. It will look for it in the following locations: + +* ``$HOME/.config/caldav/calendar.conf`` +* ``$HOME/.config/caldav/calendar.yaml`` +* ``$HOME/.config/caldav/calendar.json`` +* ``$HOME/.config/calendar.conf`` +* ``/etc/calendar.conf`` + +The config file has to be valid json or yaml (support for toml and Apple pkl may be considered). + +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:`davclient.get_davclient` or by enviornment variable ``CALDAV_CONFIG_SECTION``. If no section is given, the ``default`` section is used. + +Connection parameters +===================== + +The section should contain configuration keys and values. All configuration keys starting with ``caldav_`` is considered to be connection parameters and is passed to the DAVClient object. Typically, ``caldav_url``, ``caldav_username`` and ``caldav_password`` should be passed. + +Calendar parameters +=================== + +Not implemented yet. + +Probably in version 2.1 or version 2.2, ``calendar_name``, ``calendar_id`` and ``calendar_url`` can be used to specify a calendar. + +Inheritance and collections +=========================== + +A section may ``inherit`` another section. This may typically be used if having several sections in the config file corresponding to the same server/user but different calendars, or several sections corresponding to the same calendar server, but different users. + +If a section ``contains`` different other sections, it's efficiently a collection of calendars. This is not relevant for 2.0 though. + +Simple example +============== + +.. code-block: yaml + + --- + default: + caldav_url: http://caldav.example.com/dav/ + caldav_user: tor + caldav_pass: hunter2 diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 385828df..8669309c 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -10,6 +10,7 @@ Contents .. toctree:: :maxdepth: 1 + configfile.md caldav/davclient caldav/davobject caldav/collection From 8b7de7ab855fbb9250f9e0b85953bb1ed0d41608 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 15 Jun 2025 10:44:08 +0200 Subject: [PATCH 03/11] breaking some long lines (should rewrite this to use icalendar rather than vobject) --- examples/basic_usage_examples.py | 5 +---- examples/scheduling_examples.py | 1 - examples/sync_examples.py | 1 - tests/test_caldav.py | 36 +++++++++++++++++++++++++------- tests/test_examples.py | 2 +- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/examples/basic_usage_examples.py b/examples/basic_usage_examples.py index d7f59465..c95fa8a3 100644 --- a/examples/basic_usage_examples.py +++ b/examples/basic_usage_examples.py @@ -181,10 +181,7 @@ def read_modify_event_demo(event): ## Finally, let's verify that the correct data was saved calendar = event.parent same_event = calendar.event_by_uid(uid) - assert ( - same_event.component["summary"] - == "Norwegian national day celebrations" - ) + assert same_event.component["summary"] == "Norwegian national day celebrations" def search_calendar_demo(calendar): diff --git a/examples/scheduling_examples.py b/examples/scheduling_examples.py index ddab881a..7e728e5e 100644 --- a/examples/scheduling_examples.py +++ b/examples/scheduling_examples.py @@ -1,7 +1,6 @@ ## NOTE! This is currently NOT tested. It may and may not work. ## Please reach out if you need help with scheduling ... by https://xkcd.com/1179/ ## or scheduling-help@plann.no - import sys import uuid from datetime import datetime diff --git a/examples/sync_examples.py b/examples/sync_examples.py index 98783531..58c6274f 100644 --- a/examples/sync_examples.py +++ b/examples/sync_examples.py @@ -3,7 +3,6 @@ ## similar code in the tests/test_caldav.py file. Raise a github ## issue or reach out by email or write a pull request or send a patch ## if there are mistakes in this code) ... - ## USE CASE #1: we'll have a local copy of all calendar contents in a ## running python process, and later we'd like to synchronize the ## local contents. (In case of a reboot, all contents will be diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 2a45a300..6cce68cd 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -1420,7 +1420,10 @@ def testCopyEvent(self): ## the copy in the other calendar as a distinct entity, even ## if the uid is the same. assert e1.vobject_instance.vevent.summary.value == "Bastille Day Party" - assert c2.events()[0].vobject_instance.vevent.uid == e1.vobject_instance.vevent.uid + assert ( + c2.events()[0].vobject_instance.vevent.uid + == e1.vobject_instance.vevent.uid + ) ## Duplicate the event in the same calendar, with same uid - ## this makes no sense, there won't be any duplication @@ -1602,17 +1605,26 @@ def testSearchEvent(self): ## Even sorting should work out all_events = c.search(sort_keys=("summary", "dtstamp")) assert len(all_events) == 3 - assert all_events[0].vobject_instance.vevent.summary.value == "Bastille Day Jitsi Party" + assert ( + all_events[0].vobject_instance.vevent.summary.value + == "Bastille Day Jitsi Party" + ) ## Sorting by upper case should also wor all_events = c.search(sort_keys=("SUMMARY", "DTSTAMP")) assert len(all_events) == 3 - assert all_events[0].vobject_instance.vevent.summary.value == "Bastille Day Jitsi Party" + assert ( + all_events[0].vobject_instance.vevent.summary.value + == "Bastille Day Jitsi Party" + ) ## Sorting in reverse order should work also all_events = c.search(sort_keys=("SUMMARY", "DTSTAMP"), sort_reverse=True) assert len(all_events) == 3 - assert all_events[0].vobject_instance.vevent.summary.value == "Our Blissful Anniversary" + assert ( + all_events[0].vobject_instance.vevent.summary.value + == "Our Blissful Anniversary" + ) ## A more robust check for the sort key all_events = c.search(sort_keys=("DTSTART",)) @@ -2372,9 +2384,13 @@ def testTodoCompletion(self): assert len(todos) == 3 if not self.check_compatibility_flag("object_by_uid_is_broken"): t3_ = c.todo_by_uid(t3.id) - assert t3_.vobject_instance.vtodo.summary == t3.vobject_instance.vtodo.summary + assert ( + t3_.vobject_instance.vtodo.summary == t3.vobject_instance.vtodo.summary + ) assert t3_.vobject_instance.vtodo.uid == t3.vobject_instance.vtodo.uid - assert t3_.vobject_instance.vtodo.dtstart == t3.vobject_instance.vtodo.dtstart + assert ( + t3_.vobject_instance.vtodo.dtstart == t3.vobject_instance.vtodo.dtstart + ) t2.delete() @@ -2674,11 +2690,15 @@ def testCreateOverwriteDeleteEvent(self): t2 = c.save_todo(todo, no_create=no_create) ## this should also work. - e2.vobject_instance.vevent.summary.value = e2.vobject_instance.vevent.summary.value + "!" + e2.vobject_instance.vevent.summary.value = ( + e2.vobject_instance.vevent.summary.value + "!" + ) e2.save(no_create=no_create) if todo_ok: - t2.vobject_instance.vtodo.summary.value = t2.vobject_instance.vtodo.summary.value + "!" + t2.vobject_instance.vtodo.summary.value = ( + t2.vobject_instance.vtodo.summary.value + "!" + ) t2.save(no_create=no_create) if not self.check_compatibility_flag("event_by_url_is_broken"): diff --git a/tests/test_examples.py b/tests/test_examples.py index 4f196862..4c82ebf8 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -29,5 +29,5 @@ def test_get_events_example(self): def test_basic_usage_examples(self): from examples import basic_usage_examples - basic_usage_examples.run_examples() + basic_usage_examples.run_examples() From fbbb839462dee13a1ec2dbe29bd647fdb89fe1bd Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 15 Jun 2025 14:25:36 +0200 Subject: [PATCH 04/11] bugfixing --- caldav/davclient.py | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/caldav/davclient.py b/caldav/davclient.py index e12db46b..c4bc441a 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -749,7 +749,9 @@ def request( ) try: - logging.error(f"doing {method} towards {str(url_obj)} through {self.session.request}") + logging.error( + f"doing {method} towards {str(url_obj)} through {self.session.request}" + ) r = self.session.request( method, str(url_obj), @@ -930,9 +932,10 @@ def auto_conn(*largs, config_data: dict = None, **kwargs): def get_davclient( - config_file: str = f"{os.environ.get('HOME')}/.config/calendar.conf", - config_section="default", - testconfig=False, + check_config_file: bool = True, + config_file: str = None, + config_section: str = None, + testconfig: bool = False, environment: bool = True, name: str = None, **config_data, @@ -979,7 +982,11 @@ def get_davclient( if environment: conf = {} - for conf_key in (x for x in os.environ if x.startswith("CALDAV_") and not x.startswith("CALDAV_CONFIG"): + 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) @@ -988,11 +995,30 @@ def get_davclient( if not config_section: config_section = os.enviorn.get("CALDAV_CONFIG_SECTION") - if config_file: + if check_config_file: ## late import in 2.0, as the config stuff isn't properly tested from . import config - cfg = config.read_config(config_file) + if not config_section: + config_section = "default" + + if not config_file: + cfgdir = f"{os.environ.get('HOME', '/')}/.config/" + for config_file in ( + f"{cfgdir}/caldav/calendar.conf", + f"{cfgdir}/caldav/calendar.yaml" + f"{cfgdir}/caldav/calendar.json" + f"{cfgdir}/calendar.conf", + "/etc/calendar.conf", + "/etc/caldav/calendar.conf", + ): + try: + cfg = config.read_config(config_file) + break + except FileNotFoundError: + pass + else: + cfg = config.read_config(config_file) if cfg: section = config.config_section(cfg, config_section) conn_params = {} From e9d770cd6ecaf0f852b71fa46b87d11159eac839 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 15 Jun 2025 15:10:24 +0200 Subject: [PATCH 05/11] check_config --- tests/test_caldav.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 6cce68cd..b75bd802 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -448,27 +448,27 @@ class TestGetDAVClient: def testTestConfig(self): with get_davclient( - testconfig=True, environment=False, name=-1, config_file=False + testconfig=True, environment=False, name=-1, check_config_file=False ) as conn: assert conn.principal() def testEnvironment(self): os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] = "1" - with get_davclient(environment=True, config_file=False, name="-1") as conn: + with get_davclient(environment=True, check_config_file=False, name="-1") as conn: assert conn.principal() del os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] for key in ("url", "username", "password", "proxy"): if key in caldav_servers[-1]: os.environ[f"CALDAV_{key.upper()}"] = caldav_servers[-1][key] with get_davclient( - testconfig=False, environment=True, config_file=False + testconfig=False, environment=True, check_config_file=False ) as conn2: assert conn2.principal() def testConfigfile(self): ## start up a server with get_davclient( - testconfig=True, environment=False, name=-1, config_file=False + testconfig=True, environment=False, name=-1, check_config_file=False ) as conn: config = {} for key in ("url", "username", "password", "proxy"): From 961936a2724d4c375f16c53518f5a858a81fd253 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 15 Jun 2025 19:37:20 +0200 Subject: [PATCH 06/11] config files can be placed different places and have different names --- caldav/config.py | 17 +++++++++++++++++ caldav/davclient.py | 20 ++------------------ pyproject.toml | 1 + tests/test_caldav.py | 27 ++++++++++++++++++++++++++- 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/caldav/config.py b/caldav/config.py index 0a650eb9..11086cdb 100644 --- a/caldav/config.py +++ b/caldav/config.py @@ -1,4 +1,6 @@ import json +import logging +import os """ This configuration parsing code was just copied from my plann library (and will be removed from there at some point in the future). It's lacking tests, documentation and ... generally just lacking. @@ -71,6 +73,21 @@ def config_section(config, section="default"): def read_config(fn, interactive_error=False): + if not fn: + cfgdir = f"{os.environ.get('HOME', '/')}/.config/" + for config_file in ( + f"{cfgdir}/caldav/calendar.conf", + f"{cfgdir}/caldav/calendar.yaml" + f"{cfgdir}/caldav/calendar.json" + f"{cfgdir}/calendar.conf", + "/etc/calendar.conf", + "/etc/caldav/calendar.conf", + ): + cfg = read_config(config_file) + if cfg: + return cfg + return None + ## This can probably be refactored into fewer lines ... try: try: diff --git a/caldav/davclient.py b/caldav/davclient.py index c4bc441a..d4b26b5e 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -892,7 +892,7 @@ def request( def auto_calendars( - config_file: str = f"{os.environ.get('HOME')}/.config/calendar.conf", + config_file: str = None, config_section="default", testconfig=False, environment: bool = True, @@ -1002,23 +1002,7 @@ def get_davclient( if not config_section: config_section = "default" - if not config_file: - cfgdir = f"{os.environ.get('HOME', '/')}/.config/" - for config_file in ( - f"{cfgdir}/caldav/calendar.conf", - f"{cfgdir}/caldav/calendar.yaml" - f"{cfgdir}/caldav/calendar.json" - f"{cfgdir}/calendar.conf", - "/etc/calendar.conf", - "/etc/caldav/calendar.conf", - ): - try: - cfg = config.read_config(config_file) - break - except FileNotFoundError: - pass - else: - cfg = config.read_config(config_file) + cfg = config.read_config(config_file) if cfg: section = config.config_section(cfg, config_section) conn_params = {} diff --git a/pyproject.toml b/pyproject.toml index 6c2574c5..25e12a3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ test = [ "dulwich==0.20.50;python_version<'3.9'", "xandikos;python_version>='3.9'", "radicale", + "pyfakefs" ] [tool.setuptools_scm] diff --git a/tests/test_caldav.py b/tests/test_caldav.py index b75bd802..c2eae3bf 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -454,7 +454,9 @@ def testTestConfig(self): def testEnvironment(self): os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] = "1" - with get_davclient(environment=True, check_config_file=False, name="-1") as conn: + with get_davclient( + environment=True, check_config_file=False, name="-1" + ) as conn: assert conn.principal() del os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] for key in ("url", "username", "password", "proxy"): @@ -486,6 +488,29 @@ def testConfigfile(self): ) as conn2: assert conn2.principal() + def testNoConfigfile(self, fs): + """This is actually a unit test, not a functional test. + Should move it to another file probably, and make more unit + tests covering the config file parsing + """ + assert get_davclient(testconfig=False, environment=False) is None + HOME = os.environ["HOME"] + fs.create_dir(f"{HOME}/.config/caldav") + fs.create_file( + f"{HOME}/.config/caldav/calendar.conf", + contents=json.dumps( + { + "default": { + "caldav_url": "https://caldav.example.com/dav", + "caldav_username": "karl", + "caldav_password": "hunter2", + } + } + ), + ) + client = get_davclient(testconfig=False, environment=False) + assert client.url == "https://caldav.example.com/dav" + @pytest.mark.skipif( not rfc6638_users, reason="need rfc6638_users to be set in order to run this test" From 5fb39f0ea2c7c7393e91296d42766ed579a71203 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 15 Jun 2025 20:06:24 +0200 Subject: [PATCH 07/11] temp error-logging for debugging leaked through --- caldav/davclient.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/caldav/davclient.py b/caldav/davclient.py index d4b26b5e..8bd4d268 100644 --- a/caldav/davclient.py +++ b/caldav/davclient.py @@ -749,9 +749,6 @@ def request( ) try: - logging.error( - f"doing {method} towards {str(url_obj)} through {self.session.request}" - ) r = self.session.request( method, str(url_obj), @@ -763,7 +760,6 @@ def request( verify=self.ssl_verify_cert, cert=self.ssl_cert, ) - logging.error(f"done with {method} towards {str(url_obj)}") log.debug("server responded with %i %s" % (r.status_code, r.reason)) response = DAVResponse(r, self) except: From c2efd856f752ec70e3fd9670c5faf1bf275e4aef Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 15 Jun 2025 23:03:48 +0200 Subject: [PATCH 08/11] comment bugfix --- CHANGELOG.md | 9 ++++++++- caldav/config.py | 24 ++++++++++++++---------- examples/scheduling_examples.py | 3 ++- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a25021..2560fdb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,14 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0 ### Added * `event.component` is now an alias for `event.icalendar_component`. -* `get_davclient` (earlier called `auto_conn`) is more complete now - it could already read from test config, now it can read from environment (including environment variable for reading from test config and for locating the config file). While the `auto_conn` itself is tested in the functional tests, the code for reading the config file (and all the corner cases) is not tested. It's allowable with a yaml config file, but the yaml module is not included in the dependencies yet ... so late imports as for now. - https://github.com/python-caldav/caldav/pull/502 - https://github.com/python-caldav/caldav/issues/485 +* `get_davclient` (earlier called `auto_conn`) is more complete now - https://github.com/python-caldav/caldav/pull/502 - https://github.com/python-caldav/caldav/issues/485 - https://github.com/python-caldav/caldav/pull/507 + * It can read from environment (including environment variable for reading from test config and for locating the config file). + * It can read from a config file. New parameter `check_config_file`, defaults to true + * It will probe default locations for the config file (`~/.config/caldav/calendar.conf`, `~/.config/caldav/calendar.yaml`, `~/.config/caldav/calendar.json`, `~/.config/calendar.conf`, `/etc/calendar.conf`, `/etc/caldav/calendar.conf` as for now) + * Improved tests (but no test for inheritance yet). + * Documentation, linked up from the reference section of the doc. + * It's allowable with a yaml config file, but the yaml module is not included in the dependencies yet ... so late imports as for now, and the import is wrapped in a try/except-block +* Looked through and brushed up the examples, two of them are now executed by the unit tests. Added a doc section on the examples. ### Fixes diff --git a/caldav/config.py b/caldav/config.py index 11086cdb..a9fbce4d 100644 --- a/caldav/config.py +++ b/caldav/config.py @@ -94,18 +94,22 @@ def read_config(fn, interactive_error=False): with open(fn, "rb") as config_file: return json.load(config_file) except json.decoder.JSONDecodeError: - ## Late import. yaml is external module, + ## Late import, wrapped in try/except. yaml is external module, ## and not included in the requirements as for now. - ## TODO: should wrap it in try: ... except: log readable error - import yaml - try: - with open(fn, "rb") as config_file: - return yaml.load(config_file, yaml.Loader) - except yaml.scanner.ScannerError: - logging.error( - "config file exists but is neither valid json nor yaml. Check the syntax." - ) + import yaml + + try: + with open(fn, "rb") as config_file: + return yaml.load(config_file, yaml.Loader) + except yaml.scanner.ScannerError: + logging.error( + f"config file {fn} exists but is neither valid json nor yaml. Check the syntax." + ) + except ImportError:: + logging.error( + f"config file {fn} exists but is not valid json, and pyyaml is not installed." + ) except FileNotFoundError: ## File not found diff --git a/examples/scheduling_examples.py b/examples/scheduling_examples.py index 7e728e5e..2d8e1140 100644 --- a/examples/scheduling_examples.py +++ b/examples/scheduling_examples.py @@ -1,5 +1,6 @@ ## NOTE! This is currently NOT tested. It may and may not work. -## Please reach out if you need help with scheduling ... by https://xkcd.com/1179/ +## Please reach out if you need help with scheduling ... +## use https://github.com/python-caldav/caldav/issues ## or scheduling-help@plann.no import sys import uuid From 29b6577208c9791ab46b359d73902d4fb4b6a8f9 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 15 Jun 2025 23:05:44 +0200 Subject: [PATCH 09/11] changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2560fdb4..a8656dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0 ### Deprecated -* The `event.instance` property currently yields a vobject. For quite many years people have asked for the python vobject library to be replaced with the python icalendar objects, but I haven't been able to do that due to backward compatibility. In version 2.0 deprecation warnings will be given whenever someone uses the `event.instance` property. In 3.0, perhaps `event.instance` will yield a `icalendar` instance. +* The `event.instance` property currently yields a vobject. For quite many years people have asked for the python vobject library to be replaced with the python icalendar objects, but I haven't been able to do that due to backward compatibility. In version 2.0 deprecation warnings will be given whenever someone uses the `event.instance` property. In 3.0, perhaps `event.instance` will yield a `icalendar` instance. Old test code has been updated to use `.vobject_instance` instead of `.instance`. * `calendar.date_search` - use `calendar.search` instead. (this one has been deprecated for a while, but only with info-logging) * `davclient.auto_conn` that was introduced just some days ago has already been renamed to `davclient.get_davclient`. From eb1c2598398da631884619a643665d33127251df Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 15 Jun 2025 23:20:24 +0200 Subject: [PATCH 10/11] bugfix --- caldav/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caldav/config.py b/caldav/config.py index a9fbce4d..83c05e02 100644 --- a/caldav/config.py +++ b/caldav/config.py @@ -106,7 +106,7 @@ def read_config(fn, interactive_error=False): logging.error( f"config file {fn} exists but is neither valid json nor yaml. Check the syntax." ) - except ImportError:: + except ImportError: logging.error( f"config file {fn} exists but is not valid json, and pyyaml is not installed." ) From d93bdfa1fa85d09458f1ea7ab422a6c2f4f25fec Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sun, 15 Jun 2025 23:21:49 +0200 Subject: [PATCH 11/11] style --- caldav/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/caldav/config.py b/caldav/config.py index 83c05e02..0cdf1391 100644 --- a/caldav/config.py +++ b/caldav/config.py @@ -107,9 +107,9 @@ def read_config(fn, interactive_error=False): f"config file {fn} exists but is neither valid json nor yaml. Check the syntax." ) except ImportError: - logging.error( - f"config file {fn} exists but is not valid json, and pyyaml is not installed." - ) + logging.error( + f"config file {fn} exists but is not valid json, and pyyaml is not installed." + ) except FileNotFoundError: ## File not found