Skip to content

Commit 61b5ff7

Browse files
authored
New davclient.get_davclient-method
This method yields a davclient object based on ... * environment variables * config file * test config For tests, rigging up and tearing down test servers is now done through davclient context. This should make it easy to make doctests.
1 parent 848a82c commit 61b5ff7

5 files changed

Lines changed: 234 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,14 @@ This will be the last minor release before 2.0. The scheduling support has been
4141

4242
### Added
4343

44+
* `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.
4445
* 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
45-
* New methods `Event.set_dtend` and `CalendarObjectResource.set_end`. by @tobixen in https://github.com/python-caldav/caldav/pull/499
46+
* New methods `Event.set_dtend` and `CalendarObjectResource.set_end`. https://github.com/python-caldav/caldav/pull/499
4647

47-
### Refactoring
48+
### Refactoring and tests
4849

4950
* 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.
51+
* 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.
5052

5153
### Time spent and roadmap
5254

@@ -80,7 +82,7 @@ Python 3.7 is no longer tested (dependency problems) - but it should work. Plea
8082

8183
* Minor code cleanups by github user @ArtemIsmagilov in https://github.com/python-caldav/caldav/pull/456
8284
* The very much overgrown `objects.py`-file has been split into three - https://github.com/python-caldav/caldav/pull/483
83-
* Refactor compatibility issues by @tobixen in https://github.com/python-caldav/caldav/pull/484
85+
* Refactor compatibility issues https://github.com/python-caldav/caldav/pull/484
8486
* Refactoring of `multiget` in https://github.com/python-caldav/caldav/pull/492
8587

8688
### Documentation

caldav/config.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import json
2+
3+
## This is being moved from my plann library. The code itself will be introduced into caldav 2.0, but proper test code and documentation will come in a later release (2.1?)
4+
5+
6+
## TODO TODO TODO - write test code for all the corner cases
7+
## TODO TODO TODO - write documentation of config format
8+
def expand_config_section(config, section="default", blacklist=None):
9+
"""
10+
In the "normal" case, will return [ section ]
11+
12+
We allow:
13+
14+
* * includes all sections in config file
15+
* "Meta"-sections in the config file with the keyword "contains" followed by a list of section names
16+
* Recursive "meta"-sections
17+
* Glob patterns (work_* for all sections starting with work_)
18+
* Glob patterns in "meta"-sections
19+
"""
20+
## Optimizating for a special case. The results should be the same without this optimization.
21+
if section == "*":
22+
return [x for x in config if not config[x].get("disable", False)]
23+
24+
## If it's not a glob-pattern ...
25+
if set(section).isdisjoint(set("[*?")):
26+
## If it's referring to a "meta section" with the "contains" keyword
27+
if "contains" in config[section]:
28+
results = []
29+
if not blacklist:
30+
blacklist = set()
31+
blacklist.add(section)
32+
for subsection in config[section]["contains"]:
33+
if not subsection in results and not subsection in blacklist:
34+
for recursivesubsection in expand_config_section(
35+
config, subsection, blacklist
36+
):
37+
if not recursivesubsection in results:
38+
results.append(recursivesubsection)
39+
return results
40+
else:
41+
## Disabled sections should be ignored
42+
if config.get("section", {}).get("disable", False):
43+
return []
44+
45+
## NORMAL CASE - return [ section ]
46+
return [section]
47+
## section name is a glob pattern
48+
matching_sections = [x for x in config if fnmatch(x, section)]
49+
results = set()
50+
for s in matching_sections:
51+
if set(s).isdisjoint(set("[*?")):
52+
results.update(expand_config_section(config, s))
53+
else:
54+
## Section names shouldn't contain []?* ... but in case they do ... don't recurse
55+
results.add(s)
56+
return results
57+
58+
59+
def config_section(config, section="default"):
60+
if section in config and "inherits" in config[section]:
61+
ret = config_section(config, config[section]["inherits"])
62+
else:
63+
ret = {}
64+
if section in config:
65+
ret.update(config[section])
66+
return ret
67+
68+
69+
def read_config(fn, interactive_error=False):
70+
## This can probably be refactored into fewer lines ...
71+
try:
72+
try:
73+
with open(fn, "rb") as config_file:
74+
return json.load(config_file)
75+
except json.decoder.JSONDecodeError:
76+
## Late import. yaml is external module,
77+
## and not included in the requirements as for now.
78+
## TODO: should wrap it in try: ... except: log readable error
79+
import yaml
80+
81+
try:
82+
with open(fn, "rb") as config_file:
83+
return yaml.load(config_file, yaml.Loader)
84+
except yaml.scanner.ScannerError:
85+
logging.error(
86+
"config file exists but is neither valid json nor yaml. Check the syntax."
87+
)
88+
89+
except FileNotFoundError:
90+
## File not found
91+
logging.info("no config file found")
92+
except ValueError:
93+
if interactive_error:
94+
logging.error(
95+
"error in config file. Be aware that the interactive configuration will ignore and overwrite the current broken config file",
96+
exc_info=True,
97+
)
98+
else:
99+
logging.error("error in config file. It will be ignored", exc_info=True)
100+
return {}

caldav/davclient.py

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import os
44
import sys
5+
import warnings
56
from types import TracebackType
67
from typing import Any
78
from typing import cast
@@ -443,6 +444,8 @@ def __init__(
443444
"""
444445
headers = headers or {}
445446

447+
## Deprecation TODO: give a warning, user should use get_davclient or auto_calendar instead
448+
446449
self.session = niquests.Session(multiplexed=True)
447450

448451
log.debug("url: " + str(url))
@@ -495,6 +498,12 @@ def __init__(
495498
self._principal = None
496499

497500
def __enter__(self) -> Self:
501+
## Used for tests, to set up a temporarily test server
502+
if hasattr(self, "setup"):
503+
try:
504+
self.setup()
505+
except:
506+
self.setup(self)
498507
return self
499508

500509
def __exit__(
@@ -504,6 +513,12 @@ def __exit__(
504513
traceback: Optional[TracebackType],
505514
) -> None:
506515
self.close()
516+
## Used for tests, to tear down a temporarily test server
517+
if hasattr(self, "teardown"):
518+
try:
519+
self.teardown()
520+
except:
521+
self.teardown(self)
507522

508523
def close(self) -> None:
509524
"""
@@ -845,7 +860,8 @@ def request(
845860

846861

847862
def auto_calendars(
848-
configfile: str = f"{os.environ.get('HOME')}/.config/calendar.conf",
863+
config_file: str = f"{os.environ.get('HOME')}/.config/calendar.conf",
864+
config_section="default",
849865
testconfig=False,
850866
environment: bool = True,
851867
config_data: dict = None,
@@ -864,8 +880,26 @@ def auto_calendar(*largs, **kwargs) -> Iterable["Calendar"]:
864880
return next(auto_calendars(*largs, **kwargs), None)
865881

866882

867-
def auto_conn(
868-
configfile: str = f"{os.environ.get('HOME')}/.config/calendar.conf",
883+
def auto_conn(*largs, **kwargs):
884+
"""A quite stubbed verison of get_davclient was included in the
885+
v1.5-release as auto_conn, but renamed a few days later. Probably
886+
nobody except my caldav tester project uses auto_conn, but as a
887+
thumb of rule anything released should stay "deprecated" for at
888+
least one major release before being removed.
889+
890+
TODO: remove in version 3.0
891+
"""
892+
warnings.warn(
893+
"auto_conn was renamed get_davclient",
894+
DeprecationWarning,
895+
stacklevel=2,
896+
)
897+
return get_davclient(*largs, **kwargs)
898+
899+
900+
def get_davclient(
901+
config_file: str = f"{os.environ.get('HOME')}/.config/calendar.conf",
902+
config_section="default",
869903
testconfig=False,
870904
environment: bool = True,
871905
config_data: dict = None,
@@ -883,27 +917,29 @@ def auto_conn(
883917
884918
* Data from the given dict
885919
* Environment variables prepended with "CALDAV_"
920+
* Environment variables PYTHON_CALDAV_USE_TEST_SERVER and CALDAV_CONFIG_FILE will be honored if environment is set
886921
* Data from `./tests/conf.py` or `./conf.py` (this includes the possibility to spin up a test server)
887922
* Configuration file. Documented in the plann project as for now. (TODO - move it)
888-
889923
"""
890924
if config_data:
891925
return DAVClient(**config_data)
892926

893-
if testconfig:
927+
if testconfig or (environment and os.environ.get("PYTHON_CALDAV_USE_TEST_SERVER")):
894928
sys.path.insert(0, "tests")
895929
sys.path.insert(1, ".")
896930
## TODO: move the code from client into here
897931
try:
898932
from conf import client
899933

934+
idx = None
935+
if name:
936+
try:
937+
idx = int(name)
938+
name = None
939+
except ValueError:
940+
pass
900941
try:
901-
idx = int(name)
902-
name = None
903-
except ValueError:
904-
idx = None
905-
try:
906-
conn = client(idx, name, **config_data)
942+
conn = client(idx, name, **(config_data or {}))
907943
if conn:
908944
return conn
909945
except:
@@ -914,11 +950,28 @@ def auto_conn(
914950
sys.path = sys.path[2:]
915951

916952
if environment:
917-
raise NotImplementedError(
918-
"Not possible to configure the caldav server through environmental variables yet"
919-
)
920-
921-
if configfile:
922-
raise NotImplementedError(
923-
"Support for configuration file not made yet (TODO: copy the code from the plann tool)"
924-
)
953+
conf = {}
954+
for conf_key in (x for x in os.environ if x.startswith("CALDAV_")):
955+
conf[conf_key[7:]] = os.environ[conf_key].lower()
956+
if conf:
957+
return DAVClient(**conf)
958+
config_file = os.environ.get("CALDAV_CONFIG_FILE")
959+
960+
if config_file:
961+
## late import in 2.0, as the config stuff isn't properly tested
962+
from . import config
963+
964+
cfg = config.read_config(config_file)
965+
if cfg:
966+
section = config.config_section(cfg, config_section)
967+
conn_params = {}
968+
for k in section:
969+
if k.startswith("caldav_") and section[k]:
970+
key = k[7:]
971+
if key == "pass":
972+
key = "password"
973+
if key == "user":
974+
key = "username"
975+
conn_params[key] = section[k]
976+
if conn_params:
977+
return DAVClient(**conn_params)

tests/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ def client(
275275
)
276276
kwargs_.pop(kw)
277277
conn = DAVClient(**kwargs_)
278-
setup(conn)
278+
conn.setup = setup
279279
conn.teardown = teardown
280280
conn.incompatibilities = kwargs.get("incompatibilities")
281281
conn.server_name = name

tests/test_caldav.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
belong in test_caldav_unit.py
99
"""
1010
import codecs
11+
import json
1112
import logging
13+
import os
1214
import random
1315
import sys
16+
import tempfile
1417
import threading
1518
import time
1619
import uuid
@@ -41,6 +44,7 @@
4144
from caldav import compatibility_hints
4245
from caldav.davclient import DAVClient
4346
from caldav.davclient import DAVResponse
47+
from caldav.davclient import get_davclient
4448
from caldav.elements import cdav
4549
from caldav.elements import dav
4650
from caldav.elements import ical
@@ -432,6 +436,56 @@
432436
)
433437

434438

439+
@pytest.mark.skipif(
440+
not caldav_servers,
441+
reason="Requirement: at least one working server in conf.py. The tail object of the server list will be chosen, that is typically the LocalRadicale or LocalXandikos server.",
442+
)
443+
class TestGetDAVClient:
444+
"""
445+
Tests for get_davclient and auto_calendars.
446+
447+
"""
448+
449+
def testTestConfig(self):
450+
with get_davclient(
451+
testconfig=True, environment=False, name=-1, config_file=False
452+
) as conn:
453+
assert conn.principal()
454+
455+
def testEnvironment(self):
456+
os.environ["PYTHON_CALDAV_USE_TEST_SERVER"] = "1"
457+
with get_davclient(environment=True, config_file=False, name="-1") as conn:
458+
assert conn.principal()
459+
for key in ("url", "username", "password", "proxy"):
460+
if key in caldav_servers[-1]:
461+
os.environ[f"CALDAV_{key.upper()}"] = caldav_servers[-1][key]
462+
with get_davclient(
463+
testconfig=False, environment=True, config_file=False
464+
) as conn2:
465+
assert conn2.principal()
466+
467+
def testConfigfile(self):
468+
## start up a server
469+
with get_davclient(
470+
testconfig=True, environment=False, name=-1, config_file=False
471+
) as conn:
472+
config = {}
473+
for key in ("url", "username", "password", "proxy"):
474+
if key in caldav_servers[-1]:
475+
config[f"caldav_{key}"] = caldav_servers[-1][key]
476+
477+
with tempfile.NamedTemporaryFile(
478+
delete=True, encoding="utf-8", mode="w"
479+
) as tmp:
480+
json.dump({"default": config}, tmp)
481+
tmp.flush()
482+
os.fsync(tmp.fileno())
483+
with get_davclient(
484+
config_file=tmp.name, testconfig=False, environment=False
485+
) as conn2:
486+
assert conn2.principal()
487+
488+
435489
@pytest.mark.skipif(
436490
not rfc6638_users, reason="need rfc6638_users to be set in order to run this test"
437491
)
@@ -490,7 +544,7 @@ def teardown_method(self):
490544
except error.NotFoundError:
491545
pass
492546
for c in self.clients:
493-
c.teardown(c)
547+
c.__exit__()
494548

495549
## TODO
496550
# def testFreeBusy(self):
@@ -609,6 +663,7 @@ def setup_method(self):
609663
self.testcal_id2 = "pythoncaldav-test2"
610664

611665
self.caldav = client(**self.server_params)
666+
self.caldav.__enter__()
612667

613668
if self.check_compatibility_flag("rate_limited"):
614669
self.caldav.request = _delay_decorator(self.caldav.request)

0 commit comments

Comments
 (0)