diff --git a/caldav/async_davclient.py b/caldav/async_davclient.py index aadcc109..445594ea 100644 --- a/caldav/async_davclient.py +++ b/caldav/async_davclient.py @@ -1271,20 +1271,16 @@ async def get_calendars( ## Reading the config file and RFC 6764 discovery are blocking, so the ## clients are resolved in a thread, ref ## https://github.com/python-caldav/caldav/issues/794 - try: - targets = await asyncio.to_thread( - _get_calendars_targets, - AsyncDAVClient, - calendar_url=calendar_url, - calendar_name=calendar_name, - raise_errors=raise_errors, - **kwargs, - ) - except Exception as e: - if raise_errors: - raise - log.error(f"Failed to create async client: {e}") - return CalendarCollection() + ## Errors here are mistakes in the configuration, raised as in sync mode: + ## raise_errors=False only skips servers that fail below + targets = await asyncio.to_thread( + _get_calendars_targets, + AsyncDAVClient, + calendar_url=calendar_url, + calendar_name=calendar_name, + raise_errors=raise_errors, + **kwargs, + ) clients = [t[0] for t in targets] calendars: list = [] diff --git a/caldav/base_client.py b/caldav/base_client.py index a887b1ad..ac51c26b 100644 --- a/caldav/base_client.py +++ b/caldav/base_client.py @@ -1213,20 +1213,11 @@ def _get_calendars_targets( raise ValueError("Could not create DAV client - no configuration found") return [] - # Resolve config_file path from env if not given (mirrors get_connection_params) - resolved_config_file = config_file - if environment and not resolved_config_file: - import os - - resolved_config_file = os.environ.get("CALDAV_CONFIG_FILE") - - resolved_section = config_section - if environment and not resolved_section: - import os - - resolved_section = os.environ.get("CALDAV_CONFIG_SECTION") - - all_params = _config.get_all_file_connection_params(resolved_config_file, resolved_section) + # The file and section variables (CALENDARING_CONFIG_*, CALDAV_CONFIG_*) + # are read by calendaring-config when environment is set + all_params = _config.get_all_file_connection_params( + config_file, config_section, environment=environment + ) if not all_params: if raise_errors: diff --git a/caldav/config.py b/caldav/config.py index c8de7c6f..0bed39eb 100644 --- a/caldav/config.py +++ b/caldav/config.py @@ -2,10 +2,15 @@ import json import logging import os -import re -from fnmatch import fnmatch +import warnings from typing import Any +import calendaring_config +from calendaring_config import Connection +from calendaring_config import expand_env_vars as expand_env_vars + +log = logging.getLogger("caldav") + """ 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. """ @@ -17,53 +22,24 @@ ## TODO TODO TODO - write documentation of config format def expand_config_section(config, section="default", blacklist=None): """ - In the "normal" case, will return [ section ] + The leaf sections that ``section`` expands to, as a list. We allow: - * * includes all sections in config file + * * includes all sections in config file that are not meta-sections * "Meta"-sections in the config file with the keyword "contains" followed by a list of section names * Recursive "meta"-sections * Glob patterns (work_* for all sections starting with work_) * Glob patterns in "meta"-sections + + The rules are calendaring-config's ``expand_section``; ``blacklist`` is + no longer used. """ - ## Optimizating for a special case. The results should be the same without this optimization. - if section == "*": - return [x for x in config if not config[x].get("disable", False)] - - ## If it's not a glob-pattern ... - if set(section).isdisjoint(set("[*?")): - if section not in config: - return [] - ## If it's referring to a "meta section" with the "contains" keyword - if "contains" in config[section]: - results = [] - if not blacklist: - blacklist = set() - blacklist.add(section) - for subsection in config[section]["contains"]: - if subsection not in results and subsection not in blacklist: - for recursivesubsection in expand_config_section(config, subsection, blacklist): - if recursivesubsection not in results: - results.append(recursivesubsection) - return results - else: - ## Disabled sections should be ignored - if config.get(section, {}).get("disable", False): - return [] - - ## NORMAL CASE - return [ section ] - return [section] - ## section name is a glob pattern - matching_sections = [x for x in config if fnmatch(x, section)] - results = set() - for s in matching_sections: - if set(s).isdisjoint(set("[*?")): - results.update(expand_config_section(config, s)) - else: - ## Section names shouldn't contain []?* ... but in case they do ... don't recurse - results.add(s) - return results + if section == "default" and (section not in config or config[section].get("disable", False)): + ## As for get_davclient(): when no section was named, a file + ## without a usable "default" means no configuration + return [] + return calendaring_config.expand_section(config, section) def config_section(config, section="default"): @@ -77,92 +53,19 @@ 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 {} + """The sections of config file ``fn``, or of the first file found when it is not given. - ## This can probably be refactored into fewer lines ... + A missing file gives an empty dict; a file that cannot be parsed raises + ``ValueError``. Environment variables are not consulted. + """ try: - try: - with open(fn, "rb") as config_file: - return json.load(config_file) - except json.decoder.JSONDecodeError: - ## Late import, wrapped in try/except. yaml is external module, - ## and not included in the requirements as for now. - try: - import yaml - - try: - with open(fn, "rb") as config_file: - ## safe_load: a config file, possibly in /etc/, must not build Python objects - return yaml.safe_load(config_file) - except yaml.YAMLError as e: - # Re-raise YAML errors so they can be handled by caller - raise ValueError(f"config file {fn} is neither valid JSON nor YAML: {e}") from e - except ImportError: - raise ValueError( - f"config file {fn} is not valid JSON, and pyyaml is not installed" - ) from None - - except OSError as e: - ## Optional config file is unusable (missing, a directory, no - ## permission, ...). Treat any of these as "no config here" rather - ## than letting e.g. IsADirectoryError propagate out of an optional - ## config-file probe (happens when HOME is unset and the path expands - ## to something like '//.config//caldav/calendar.conf'). + path = calendaring_config.find_config_file(fn or None, environment=False) + except calendaring_config.ConfigError as e: logging.debug(f"config file {fn} not usable: {e}") return {} - except ValueError: - # Re-raise ValueError so caller can handle config errors - raise - 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 + if path is None: + return {} + return calendaring_config.read_config(path) ## Profiles that have been renamed. The names are user-facing - they turn up @@ -248,6 +151,30 @@ def resolve_features(features): ] ) +# Keys starting with ``test_`` in a config section are reserved for test +# tooling; other users of the config file (like the calendaring library) +# drop them. The old names are still read, with a deprecation warning. +_DEPRECATED_TEST_KEYS = { + "test_allowed": "testing_allowed", + "test_priority": "priority", +} + + +def get_test_setting(section_data: dict[str, Any], key: str, default: Any = None) -> Any: + """Read the test-tooling setting ``key`` (e.g. ``test_priority``) from a + config section, falling back to its deprecated name.""" + if key in section_data: + return section_data[key] + old_key = _DEPRECATED_TEST_KEYS.get(key) + if old_key and old_key in section_data: + warnings.warn( + f"The config key '{old_key}' is deprecated, use '{key}' instead", + DeprecationWarning, + stacklevel=2, + ) + return section_data[old_key] + return default + def get_connection_params( check_config_file: bool = True, @@ -272,7 +199,7 @@ def get_connection_params( 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. + only config file sections with 'test_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 @@ -313,13 +240,6 @@ def get_connection_params( # files when the caller explicitly provided connection info. return explicit_conn - # 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) @@ -331,24 +251,20 @@ def get_connection_params( # 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." + "Test server mode enabled but no server with test_allowed=true found. " + "Add 'test_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: - conn_params.update(explicit_conn) - return conn_params - - # 4. Config file - if check_config_file: - conn_params = _get_file_config(config_file, config_section) - if conn_params: - conn_params.update(explicit_conn) - return conn_params + # 3./4. Environment variables (CALDAV_*), then the config file, through + # calendaring-config: a CALDAV_URL replaces the file, other CALDAV_* + # variables override the section's keys one by one. + for conn_params in _caldav_connections( + config_file, config_section, environment=environment, file=check_config_file + ): + conn_params = {k: v for k, v in conn_params.items() if k in CONNKEYS} + conn_params.update(explicit_conn) + return conn_params # No env/config source matched. At this point explicit_conn has neither # 'url' nor 'features' (those return early above), so it cannot produce a @@ -356,33 +272,88 @@ def get_connection_params( return None -def _get_env_config() -> dict[str, Any] | None: - """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: str | None, section_name: str | None) -> dict[str, Any] | None: - """Extract connection parameters from config file.""" - if not section_name: - section_name = "default" - - cfg = read_config(file_path) - if not cfg: - return None +# Section keys that are not client parameters. calendar_name and +# calendar_url select calendars; test_*, x_* and extra_config belong to test +# tooling and applications (calendaring-config's docs/format.md). +CALENDAR_KEYS = ("calendar_name", "calendar_url") +# The kinds of section caldav connects to; a JMAP_URL must not hide them +KINDS = ("caldav",) +PASSED_KEYS = frozenset(["extra_config", *_DEPRECATED_TEST_KEYS.values()]) +PASSED_PREFIXES = ("test_", "x_") +BOOLEAN_KEYS = frozenset(["ssl_verify_cert", "huge_tree", "enable_rfc6764", "require_tls"]) +TRUE_WORDS = ("true", "yes", "on", "1") +FALSE_WORDS = ("false", "no", "off", "0") + + +def _is_caldav(conn: Connection) -> bool: + """A section without a kind is caldav's when its URL is http(s).""" + if conn.kind is not None: + return conn.kind == "caldav" + return str(conn.params.get("url", "")).startswith(("http://", "https://")) + + +def _convert(key: str, value: Any) -> Any: + """Environment variables are strings; turn them into what the client takes.""" + if key == "features": + return resolve_features(value) + if not isinstance(value, str): + return value + if key in BOOLEAN_KEYS: + # ssl_verify_cert may also be the path of a CA bundle + if value.lower() in TRUE_WORDS: + return True + if value.lower() in FALSE_WORDS: + return False + elif key == "headers": + try: + headers = json.loads(value) + except json.JSONDecodeError: + headers = None + if not isinstance(headers, dict): + ## the value is left out: headers often carry a token + raise ValueError("headers must be a JSON object") + return headers + return value + - section_data = config_section(cfg, section_name) - return extract_conn_params_from_section(section_data) +def _caldav_params(conn: Connection, warn: bool = True) -> dict[str, Any] | None: + """The client parameters and calendar filters of a caldav connection. + + Keys caldav does not know are left out, and logged when ``warn``. + ``None`` when there is neither a URL nor features to find one from. + """ + params: dict[str, Any] = {} + for key, value in conn.params.items(): + if key in CONNKEYS: + params[key] = _convert(key, value) + elif key in CALENDAR_KEYS: + params[key] = value + elif warn and key not in PASSED_KEYS and not key.startswith(PASSED_PREFIXES): + log.warning(f"config section {conn.name}: unknown key '{key}'") + return params if (params.get("url") or params.get("features")) else None + + +def _caldav_connections( + config_file: str | None, + section_name: str | None, + environment: bool = True, + file: bool = True, +) -> list[dict[str, Any]]: + """The caldav connections ``section_name`` stands for, from the + environment and the config file; other kinds of sections are skipped.""" + if file: + conns = calendaring_config.load( + config_file, section_name, environment=environment, kinds=KINDS + ) + elif environment: + conns = [ + Connection("environment", kind, params) + for kind, params in calendaring_config.environment_connections(KINDS) + ] + else: + return [] + found = (_caldav_params(conn) for conn in conns if _is_caldav(conn)) + return [params for params in found if params] def _get_test_server_config( @@ -393,24 +364,31 @@ def _get_test_server_config( Uses the priority-ordered server list as the single source of truth. Embedded servers (xandikos priority 10, radicale priority 10) beat docker - (20) and configured / testing_allowed servers (30) by default. Any server - can override its priority via ``priority: `` in its config. + (20) and configured / test_allowed servers (30) by default. Any server + can override its priority via ``test_priority: `` in its config. When running from the source tree the full registry is used (embedded + docker + external). When caldav is pip-installed without the test infrastructure, only embedded servers from ``caldav.testing`` plus any - ``testing_allowed`` config-file sections are available. + ``test_allowed`` config-file sections are available. Args: name: Optional server name or server_type to restrict selection. environment: Whether to read PYTHON_CALDAV_TEST_SERVER_NAME. - config_file: Explicit config file path (for testing_allowed fallback). + config_file: Explicit config file path (for test_allowed fallback). Returns: Connection parameters dict, or None if no server could be started. """ if environment and name is None: name = os.environ.get("PYTHON_CALDAV_TEST_SERVER_NAME") + if environment and config_file is None: + ## CALENDARING_CONFIG_FILE / CALDAV_CONFIG_FILE, else the default search + try: + found = calendaring_config.find_config_file(None) + except calendaring_config.ConfigError: + found = None + config_file = str(found) if found else None for server in _collect_test_servers(name, config_file): was_already_started = server._started @@ -431,7 +409,7 @@ def _get_test_server_config( logging.info( "PYTHON_CALDAV_USE_TEST_SERVER is set but no test server is available. " - "Install xandikos or radicale, or add 'testing_allowed: true' to a config section." + "Install xandikos or radicale, or add 'test_allowed: true' to a config section." ) return None @@ -441,7 +419,7 @@ def _collect_test_servers(name: str | None, config_file: str | None) -> list[Any Return a priority-ordered list of available test servers. Tries the full registry first (source tree); falls back to embedded - servers from ``caldav.testing`` plus ``testing_allowed`` config sections + servers from ``caldav.testing`` plus ``test_allowed`` config sections (pip-installed users). """ try: @@ -460,7 +438,7 @@ def _get_pip_test_servers(config_file: str | None) -> list[Any]: """ Build a server list for pip-installed users (no tests/ available). - Includes embedded servers from caldav.testing and any testing_allowed + Includes embedded servers from caldav.testing and any test_allowed sections from config files, all sorted by priority. """ from caldav.testing import RadicaleServer, XandikosServer @@ -489,7 +467,7 @@ def _get_pip_test_servers(config_file: str | None) -> list[Any]: class _ConfiguredServer: """ - Thin wrapper around a ``testing_allowed`` config-file section. + Thin wrapper around a ``test_allowed`` config-file section. Used only in pip-installed mode; in the source tree these sections are loaded into the registry as ``ExternalTestServer`` instances instead. @@ -504,7 +482,7 @@ def __init__(self, name: str, params: dict[str, Any]) -> None: @property def priority(self) -> int: - return int(self._params.get("priority", 30)) + return int(self._params.get("test_priority", 30)) @property def url(self) -> str: @@ -551,79 +529,61 @@ def _test_server_to_params(server: Any, was_already_started: bool) -> dict[str, return params -def extract_conn_params_from_section(section_data: dict[str, Any]) -> dict[str, Any] | None: +def extract_conn_params_from_section( + section_data: dict[str, Any], config_file: str | None = None +) -> dict[str, Any] | None: """Extract connection parameters from a config section dict. - Keys prefixed with ``caldav_`` are mapped to client constructor parameters - (with ``caldav_user``/``caldav_pass`` accepted as aliases for - username/password), environment variable references are expanded, and a - ``features`` key is resolved through :func:`resolve_features`. Public so - that downstream tools (e.g. plann) can reuse it on plann-style config - sections. - - Returns a dict containing only CONNKEYS entries. Returns ``None`` if - neither a server URL nor features are present (with features, the client - constructor can resolve the URL from auto-connect.url hints). Calendar - filter keys (``calendar_name``, ``calendar_url``) are intentionally - excluded — callers that need them (e.g. - :func:`get_all_file_connection_params`) read ``section_data`` directly. + The section is read as calendaring-config reads it: a ``kind: caldav`` + section with plain keys, or ``caldav_``-prefixed keys (with + ``caldav_user``/``caldav_pass`` accepted as aliases for + username/password); environment variable references are expanded, + ``password_command`` / ``password_keyring`` are resolved, and a + ``features`` key is resolved through :func:`resolve_features`. A + ``password_command`` is only run when ``config_file`` names the file the + section came from. Public so that downstream tools (e.g. plann) can + reuse it on plann-style config sections, or on command line arguments: + keys it does not know are ignored without a warning. + + Returns a dict containing only CONNKEYS entries. Returns ``None`` for a + section of another kind, or one with neither a server URL nor features + (with features, the client constructor can resolve the URL from + auto-connect.url hints). Calendar filter keys (``calendar_name``, + ``calendar_url``) are intentionally excluded — callers that need them + read ``section_data`` directly. """ - 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"] = resolve_features(section_data[k]) - - return conn_params if (conn_params.get("url") or conn_params.get("features")) else None + conn = calendaring_config.section_connection("section", section_data, config_file) + if conn.kind not in (None, *KINDS): + return None + params = _caldav_params(conn, warn=False) + if not params: + return None + return {k: v for k, v in params.items() if k in CONNKEYS} def get_all_file_connection_params( config_file: str | None, section_name: str | None = None, + environment: bool = False, ) -> list[dict[str, Any]]: - """Return connection-params dicts for every leaf section that ``section_name`` expands to. + """Return connection-params dicts for every caldav leaf section that ``section_name`` expands to. ``section_name`` follows the same rules as everywhere else in the config system: a plain name returns ``[section_name]``; a meta-section with ``contains: [...]`` expands recursively; ``*`` means all non-disabled - sections; glob patterns (``work_*``) are also supported. + leaf sections; glob patterns (``work_*``) are also supported. Sections + of another kind (``kind: jmap``, a directory of ``.ics`` files) are + skipped. Each dict contains CONNKEYS entries plus optional ``calendar_name`` / ``calendar_url`` calendar-filter keys read from the config section. + With ``environment``, ``CALDAV_*`` variables apply as in + :func:`get_connection_params`. Returns an empty list when the config file is absent or the section has neither a usable URL nor features to derive one from. """ - if not section_name: - section_name = "default" - - cfg = read_config(config_file) - if not cfg: - return [] - - sections = expand_config_section(cfg, section_name) - result: list[dict[str, Any]] = [] - for s in sections: - section_data = config_section(cfg, s) - params = extract_conn_params_from_section(section_data) - if params: - # Add calendar filter keys — these must NOT flow into DAVClient() - for k in ("calendar_name", "calendar_url"): - if section_data.get(k): - params[k] = section_data[k] - result.append(params) - return result + return _caldav_connections(config_file, section_name, environment=environment) def get_all_test_servers( @@ -632,7 +592,7 @@ def get_all_test_servers( """ Get all test servers from config file. - Finds all sections with 'testing_allowed: true' and returns their + Finds all sections with 'test_allowed: true' and returns their connection parameters. Args: @@ -643,18 +603,25 @@ def get_all_test_servers( Dict mapping section names to connection parameter dicts. Each dict contains: url, username, password, features, etc. """ - cfg = read_config(config_file) + try: + path = calendaring_config.find_config_file(config_file or None, environment=False) + except calendaring_config.ConfigError: + return {} + cfg = calendaring_config.read_config(path) if path else {} if not cfg: return {} result: dict[str, dict[str, Any]] = {} for section_name in cfg: section_data = config_section(cfg, section_name) - if section_data.get("testing_allowed"): - conn_params = extract_conn_params_from_section(section_data) + if get_test_setting(section_data, "test_allowed"): + conn_params = extract_conn_params_from_section(section_data, str(path)) if conn_params: + priority = get_test_setting(section_data, "test_priority") + if priority is not None: + conn_params["test_priority"] = priority # Also copy the raw section data for keys not in CONNKEYS - # (e.g., testing_allowed itself, or custom keys) + # (e.g., test_allowed itself, or custom keys) conn_params["_raw_config"] = section_data result[section_name] = conn_params diff --git a/caldav/testing.py b/caldav/testing.py index 527b8012..790cb2cf 100644 --- a/caldav/testing.py +++ b/caldav/testing.py @@ -16,6 +16,7 @@ import time from typing import Any +from caldav.config import get_test_setting from caldav.lib.http_sync import requests # ── Constants ──────────────────────────────────────────────────────────────── @@ -36,7 +37,7 @@ class EmbeddedServer: Priority -------- Both embedded servers default to priority **10**, which beats docker (20) - and external / configured servers (30). Override via ``priority: `` + and external / configured servers (30). Override via ``test_priority: `` in the config dict or by setting ``_default_priority`` on the subclass. """ @@ -58,7 +59,7 @@ def _default_port(self) -> int: @property def priority(self) -> int: - return int(self.config.get("priority", self._default_priority)) + return int(get_test_setting(self.config, "test_priority", self._default_priority)) @property def username(self) -> str | None: diff --git a/changes/+calendaring-config.changed.md b/changes/+calendaring-config.changed.md new file mode 100644 index 00000000..6859005b --- /dev/null +++ b/changes/+calendaring-config.changed.md @@ -0,0 +1,10 @@ +The config file is now read through the [calendaring-config](https://github.com/pycalendar/calendaring-config) package, which the file's other users (plann, calendaring) share; see https://github.com/python-caldav/caldav/issues/786. New in the format: + +- `~/.config/pycal/calendar.yaml` is searched first, before the old paths. +- A section may say `kind: caldav` and drop the `caldav_` prefix. Sections of other kinds (`kind: jmap`, a directory of `.ics` files) are skipped. +- `password_command` runs a program for the password, and `password_keyring` looks it up in the system keyring. +- `CALDAV_*` environment variables override the section's keys one by one: a `CALDAV_PASSWORD` alone no longer makes caldav ignore the config file (https://github.com/python-caldav/caldav/issues/780). `true`/`false` in a variable is a boolean now, and `CALDAV_HEADERS` is read as JSON. +- `extract_conn_params_from_section()`, which plann uses, reads the new format too; pass it the config file's path as `config_file` to let a `password_command` run. +- An unknown key in a section is logged as a warning. `test_*`, `x_*` and `extra_config` are reserved for test tooling and applications. + +Some files are read differently: an empty file found first is used, rather than skipped for the next one; a section named directly that is missing or disabled, or a glob that matches nothing, is an error; a name in `contains` that matches nothing gives a warning; a disabled meta-section leaves out its members too; `*` is only the sections that are not meta-sections; and a config file named explicitly must exist. diff --git a/changes/786.deprecated.md b/changes/786.deprecated.md new file mode 100644 index 00000000..b68245a2 --- /dev/null +++ b/changes/786.deprecated.md @@ -0,0 +1 @@ +Config file keys starting with `test_` are now reserved for test tooling, so other programs sharing the config file can drop them. A section is marked for testing with `test_allowed: true` rather than `testing_allowed: true`, and the order test servers are tried in is set with `test_priority` rather than `priority`. The old names still work, but emit a `DeprecationWarning`. `test_priority` (or `priority`) in a section of the main config file was ignored before; it is now honoured. diff --git a/docs/source/configfile.rst b/docs/source/configfile.rst index b671a644..665094e5 100644 --- a/docs/source/configfile.rst +++ b/docs/source/configfile.rst @@ -4,17 +4,23 @@ Config file format The :func:`caldav.davclient.get_davclient`, :func:`caldav.davclient.get_calendar`, and :func:`caldav.davclient.get_calendars` functions read connection parameters from a config -file. The file is searched in the following locations (first match wins): +file. The file is shared with other programs, such as plann and +calendaring, and is read through the `calendaring-config +`_ package, whose +``docs/format.md`` is the full specification. The file is searched in the +following locations (the first that exists is used, even if it is empty): +* ``$XDG_CONFIG_HOME/pycal/calendar.yaml`` (``$HOME/.config/pycal/calendar.yaml``) * ``$HOME/.config/caldav/calendar.conf`` * ``$HOME/.config/caldav/calendar.yaml`` * ``$HOME/.config/caldav/calendar.json`` * ``$HOME/.config/calendar.conf`` -* ``/etc/caldav/calendar.conf`` * ``/etc/calendar.conf`` +* ``/etc/caldav/calendar.conf`` The config file must be valid JSON or YAML. The path can also be given -explicitly via the ``CALDAV_CONFIG_FILE`` environment variable. +explicitly via the ``CALENDARING_CONFIG_FILE`` or ``CALDAV_CONFIG_FILE`` +environment variable; a file named this way must exist. Sections ======== @@ -26,9 +32,13 @@ wins): 1. The ``config_section`` parameter passed to :func:`~caldav.davclient.get_davclient` / :func:`~caldav.davclient.get_calendars`. -2. The ``CALDAV_CONFIG_SECTION`` environment variable. +2. The ``CALENDARING_CONFIG_SECTION`` or ``CALDAV_CONFIG_SECTION`` + environment variable. 3. The ``default`` section. +A section named this way that is missing or disabled is an error, except for +an implicit ``default``. + Connection parameters ===================== @@ -75,6 +85,23 @@ constructor can be given the same way, as long as it is listed in authentication object and can therefore only be passed from code, not from a config file.) +A section may instead say ``kind: caldav`` and leave out the prefix. Such a +section can also describe the servers of other programs sharing the file +(``kind: jmap``, a directory of ``.ics`` files); caldav skips those. A +section without ``kind`` and without ``caldav_`` keys is caldav's when its +``url`` is ``http://`` or ``https://``. + +.. code-block:: yaml + + work: + kind: caldav + url: https://caldav.example.com/ + username: alice + password_command: [pass, show, dav/work] + +A key caldav does not know is logged as a warning and left out; see +`Reserved keys`_ for the keys other programs may put in a section. + The special ``features`` key (not prefixed with ``caldav_``) names a server-compatibility profile — e.g. ``xandikos``, ``radicale``, ``baikal``. See :mod:`caldav.compatibility_hints` for the full list of known profiles. @@ -94,6 +121,23 @@ Values may reference environment variables using ``${VAR}`` or caldav_username: ${CALDAV_USER:-alice} caldav_password: ${CALDAV_PASSWORD} +Passwords from a command or the keyring +--------------------------------------- + +Instead of ``password`` (or ``caldav_password``), a section may give +``password_command``, a list of a program and its arguments whose output is +the password, or ``password_keyring``, a service name to look up in the +system keyring together with the username (this needs the ``keyring`` +package). The command is run without a shell, and only when the file is not +writable by group or others. + +.. code-block:: yaml + + default: + caldav_url: https://caldav.example.com/ + caldav_username: alice + caldav_password_command: [pass, show, dav/work] + Calendar parameters =================== @@ -171,8 +215,10 @@ aggregate calendars from all listed sections, including across multiple servers: Calling ``get_calendars(config_section="all")`` returns calendars from both servers. Meta-sections are resolved recursively, so a meta-section may contain other meta-sections. Circular references are detected and ignored. +A listed name that matches no section is skipped with a warning. -A section can also be disabled so it is skipped during expansion: +A section can also be disabled so it is skipped during expansion; a disabled +meta-section is skipped together with all its members: .. code-block:: yaml @@ -208,39 +254,64 @@ be a glob or ``*``: ``get_calendars(config_section="work_*")`` and ``get_calendars(config_section="all_work")`` are therefore equivalent. ``get_calendars(config_section="*")`` returns calendars from every -non-disabled section in the file. +non-disabled section in the file that is not a meta-section. A glob that +matches no section is an error. -Environment variables -===================== +Reserved keys +============= -Connection parameters can also be passed via environment variables without a -config file. The variables are mapped as follows: +Some keys in a section are not caldav's, and caldav passes them by without +a warning: -.. list-table:: - :header-rows: 1 +* ``test_*`` is for test tooling; +* ``x_*`` is for an application's own settings; +* ``extra_config`` is plann's. - * - Environment variable - - Parameter - * - ``CALDAV_URL`` - - ``url`` - * - ``CALDAV_USERNAME`` or ``CALDAV_USER`` - - ``username`` - * - ``CALDAV_PASSWORD`` or ``CALDAV_PASS`` - - ``password`` - * - ``CALDAV_AUTH_TYPE`` - - ``auth_type`` - * - ``CALDAV_CONFIG_FILE`` - - Path to config file - * - ``CALDAV_CONFIG_SECTION`` - - Section name (may be a glob) - -``CALDAV_`` followed by the upper-cased name of any other connection -parameter works as well. Beware that environment variables are always -strings and are not converted, so the boolean-ish parameters -(``ssl_verify_cert``, ``huge_tree``, ``enable_rfc6764``, ``require_tls``) -cannot be turned off this way — ``CALDAV_REQUIRE_TLS=false`` sets the -parameter to the non-empty, hence true, string ``"false"``. Use the config -file or a keyword argument for those. +With ``testconfig=True`` (or the ``PYTHON_CALDAV_USE_TEST_SERVER`` +environment variable), ``get_davclient()`` only uses sections marked with +``test_allowed: true``, so a personal server is never used for testing by +accident. ``test_priority`` sets the order servers are tried in, lower +first; the default is 30, while embedded servers come at 10 and docker +servers at 20. + +.. code-block:: yaml + + scratch_server: + caldav_url: https://test.example.com/dav/ + caldav_username: tester + caldav_password: secret + test_allowed: true + test_priority: 5 + +The older names ``testing_allowed`` and ``priority`` still work, but are +deprecated. + +Environment variables +===================== + +Every ``CALDAV_`` environment variable is the key ```` of the +section, as if written in the config file: ``CALDAV_PASSWORD`` is +``password``, ``CALDAV_SSL_VERIFY_CERT`` is ``ssl_verify_cert``. +``CALDAV_USER`` and ``CALDAV_PASS`` are short for ``CALDAV_USERNAME`` and +``CALDAV_PASSWORD``. Not keys are ``CALDAV_CONFIG_FILE`` and +``CALDAV_CONFIG_SECTION``, which choose the file and the section, and +``CALDAV_AUTH``, since ``auth`` takes an object. + +* With ``CALDAV_URL`` set, the config file is not read at all: the + connection is the one the variables describe. +* Otherwise, when the section is a single section, the variables override + its keys one by one: ``CALDAV_PASSWORD`` alone keeps the URL from the + file, and replaces a ``password_command`` there, which is then not run. +* When the section expands to several sections, the variables without a URL + are not applied. + +Environment variables are strings, and caldav converts them: +``true``/``yes``/``on``/``1`` and ``false``/``no``/``off``/``0`` become +booleans for ``ssl_verify_cert``, ``huge_tree``, ``enable_rfc6764`` and +``require_tls`` (``ssl_verify_cert`` may also be the path of a CA bundle), +``CALDAV_HEADERS`` is a JSON object, ``CALDAV_TIMEOUT`` a number, and +``CALDAV_FEATURES`` names a compatibility profile. As in the file, a key +caldav does not know is logged as a warning. Examples ======== diff --git a/pyproject.toml b/pyproject.toml index fb897dfb..642852a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ dependencies = [ "dnspython", "python-dateutil", "PyYAML", + "calendaring-config>=0.3", ## reads the config file, see issue 786 ] dynamic = ["version"] @@ -197,6 +198,9 @@ filterwarnings = [ # Allow missing _version.py warning during development/testing (generated by hatch-vcs on build/install) "ignore:You need to install the `build` package:UserWarning", + # An old key in the developer's own config file should not break the test run + "default:The config key '.*' is deprecated:DeprecationWarning", + # 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", diff --git a/tests/conftest.py b/tests/conftest.py index 25f0eddf..abfe5890 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,3 +28,22 @@ def process_tz(request, monkeypatch): monkeypatch.undo() time.tzset() tzlocal.reload_localzone() + + +@pytest.fixture +def no_config_files(monkeypatch, tmp_path): + """Keep the developer's own config files, CALDAV_*/CALENDARING_* + variables and test-server mode out of the test: HOME is an empty directory, and the system + paths point into it too.""" + import calendaring_config.discovery + + for name in list(os.environ): + if name.startswith(("CALDAV_", "JMAP_", "CALENDARING_")): + monkeypatch.delenv(name) + monkeypatch.delenv("PYTHON_CALDAV_USE_TEST_SERVER", raising=False) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setattr( + calendaring_config.discovery, "SYSTEM_PATHS", (tmp_path / "etc" / "calendar.conf",) + ) + return tmp_path diff --git a/tests/test_async_davclient.py b/tests/test_async_davclient.py index 94b44faa..e3a12c53 100644 --- a/tests/test_async_davclient.py +++ b/tests/test_async_davclient.py @@ -698,12 +698,11 @@ async def test_get_davclient_params_override_env(self) -> None: assert client.password == "parampass" @pytest.mark.asyncio + @pytest.mark.usefixtures("no_config_files") 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) + 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: diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 49897e55..5534252f 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -514,12 +514,12 @@ class TestGetDAVClient: """ def testTestConfig(self): - """Test that get_davclient(testconfig=True) finds config with testing_allowed.""" + """Test that get_davclient(testconfig=True) finds config with test_allowed.""" # Start a test server using test_servers framework server_params = caldav_servers[0] with client(**server_params) as conn: - # Create a config file with testing_allowed: true - config = {"testing_allowed": True} + # Create a config file with test_allowed: true + config = {"test_allowed": True} for key in ("username", "password", "proxy"): if key in server_params: config[f"caldav_{key}"] = server_params[key] diff --git a/tests/test_caldav_unit.py b/tests/test_caldav_unit.py index 08c282ac..f35e75ef 100755 --- a/tests/test_caldav_unit.py +++ b/tests/test_caldav_unit.py @@ -4450,14 +4450,20 @@ def test_recursive_meta_section(self): } assert set(expand_config_section(config, "all")) == {"a", "b", "c"} - def test_missing_section_returns_empty(self): - """expand_config_section(config, "default") when "default" is absent must - return [] rather than raising KeyError.""" + def test_missing_section_is_an_error(self): + """A section named directly that is not there is a ValueError, not a KeyError.""" from caldav.config import expand_config_section config = {"work": {"caldav_url": "https://work.example.com/"}} - # Requesting a section that doesn't exist should return [] (no match), not crash - assert expand_config_section(config, "default") == [] + with pytest.raises(ValueError, match="home"): + expand_config_section(config, "home") + + def test_missing_default_is_no_configuration(self): + """plann asks for "default" when none was named; a file without one is not an error.""" + from caldav.config import expand_config_section + + assert expand_config_section({"work": {"caldav_url": "https://w.example.com/"}}) == [] + assert expand_config_section({"default": {"caldav_url": "x", "disable": True}}) == [] def test_disable_respected_for_named_sections(self): """disable:true must suppress named sections, not just glob '*' results. @@ -4467,8 +4473,14 @@ def test_disable_respected_for_named_sections(self): """ from caldav.config import expand_config_section - config = {"work": {"caldav_url": "https://work.example.com/", "disable": True}} - assert expand_config_section(config, "work") == [] + config = { + "work": {"caldav_url": "https://work.example.com/", "disable": True}, + "home": {"caldav_url": "https://home.example.com/"}, + "all": {"contains": ["work", "home"]}, + } + assert expand_config_section(config, "all") == ["home"] + with pytest.raises(ValueError, match="work"): + expand_config_section(config, "work") class TestConfigSectionInheritance: @@ -4587,6 +4599,60 @@ def test_plain_yaml_still_works(self, tmp_path): } +class TestTestKeyPrefix: + """Keys starting with ``test_`` are reserved for test tooling (issue 786).""" + + def _write(self, tmp_path, section): + import json + + config_file = tmp_path / "calendar.conf" + config_file.write_text( + json.dumps({"srv": {"caldav_url": "https://dav.example.com/", **section}}) + ) + return str(config_file) + + def test_test_allowed(self, tmp_path): + from caldav.config import get_all_test_servers + + assert "srv" in get_all_test_servers(self._write(tmp_path, {"test_allowed": True})) + + def test_testing_allowed_is_deprecated(self, tmp_path): + from caldav.config import get_all_test_servers + + config_file = self._write(tmp_path, {"testing_allowed": True}) + with pytest.warns(DeprecationWarning, match="test_allowed"): + assert "srv" in get_all_test_servers(config_file) + + def test_test_priority(self, tmp_path): + from caldav.config import _ConfiguredServer, get_all_test_servers + + config_file = self._write(tmp_path, {"test_allowed": True, "test_priority": 5}) + params = get_all_test_servers(config_file)["srv"] + assert _ConfiguredServer("srv", params).priority == 5 + assert "test_priority" not in extract_conn_keys(params) + + def test_priority_is_deprecated(self, tmp_path): + from caldav.config import _ConfiguredServer, get_all_test_servers + + config_file = self._write(tmp_path, {"test_allowed": True, "priority": 5}) + with pytest.warns(DeprecationWarning, match="test_priority"): + params = get_all_test_servers(config_file)["srv"] + assert _ConfiguredServer("srv", params).priority == 5 + + def test_embedded_server_priority(self): + from caldav.testing import RadicaleServer + + assert RadicaleServer({"test_priority": 3}).priority == 3 + with pytest.warns(DeprecationWarning, match="test_priority"): + assert RadicaleServer({"priority": 4}).priority == 4 + + +def extract_conn_keys(params): + from caldav.config import CONNKEYS + + return {k for k in params if k in CONNKEYS} + + class TestGetAllFileConnectionParams: """Unit tests for caldav.config.get_all_file_connection_params.""" @@ -4786,6 +4852,223 @@ def test_explicit_params_merged_with_test_server_config(self, monkeypatch): assert result.get("password") == "secret" +class TestSharedConfigFormat: + """The config file is read through calendaring-config (issue 786).""" + + @pytest.fixture(autouse=True) + def _clean_env(self, no_config_files): + pass + + def _write(self, tmp_path, config, name="calendar.conf"): + import json + + config_file = tmp_path / name + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(json.dumps(config)) + config_file.chmod(0o600) + return str(config_file) + + def test_kind_section(self, tmp_path): + from caldav.config import get_connection_params + + config_file = self._write( + tmp_path, {"default": {"kind": "caldav", "url": "https://dav.example.com/"}} + ) + assert get_connection_params(config_file=config_file) == {"url": "https://dav.example.com/"} + + def test_password_command(self, tmp_path): + import sys + + from caldav.config import get_connection_params + + config_file = self._write( + tmp_path, + { + "default": { + "caldav_url": "https://dav.example.com/", + "caldav_password_command": [sys.executable, "-c", "print('s3cr3t')"], + } + }, + ) + assert get_connection_params(config_file=config_file)["password"] == "s3cr3t" + + def test_environment_overrides_key_by_key(self, tmp_path, monkeypatch): + """https://github.com/python-caldav/caldav/issues/780""" + from caldav.config import get_connection_params + + config_file = self._write(tmp_path, {"default": {"caldav_url": "https://dav.example.com/"}}) + monkeypatch.setenv("CALDAV_PASSWORD", "from-env") + assert get_connection_params(config_file=config_file) == { + "url": "https://dav.example.com/", + "password": "from-env", + } + assert "password" not in get_connection_params(config_file=config_file, environment=False) + + def test_environment_values_are_converted(self, monkeypatch): + from caldav.config import get_connection_params + + monkeypatch.setenv("CALDAV_URL", "https://env.example.com/") + monkeypatch.setenv("CALDAV_REQUIRE_TLS", "false") + monkeypatch.setenv("CALDAV_HUGE_TREE", "1") + monkeypatch.setenv("CALDAV_SSL_VERIFY_CERT", "/etc/ssl/ca.pem") + monkeypatch.setenv("CALDAV_HEADERS", '{"X-Test": "1"}') + monkeypatch.setenv("CALDAV_FEATURES", "radicale") + params = get_connection_params(check_config_file=False) + assert params["require_tls"] is False + assert params["huge_tree"] is True + assert params["ssl_verify_cert"] == "/etc/ssl/ca.pem" + assert params["headers"] == {"X-Test": "1"} + assert isinstance(params["features"], dict) + + def test_bad_headers_is_an_error(self, monkeypatch): + from caldav.config import get_connection_params + + monkeypatch.setenv("CALDAV_URL", "https://env.example.com/") + monkeypatch.setenv("CALDAV_HEADERS", "X-Test: 1") + with pytest.raises(ValueError, match="headers"): + get_connection_params(check_config_file=False) + + def test_unknown_key_is_warned_about(self, tmp_path, caplog): + from caldav.config import get_connection_params + + config_file = self._write( + tmp_path, + { + "default": { + "caldav_url": "https://dav.example.com/", + "caldav_pasword": "typo", + "calendar_name": "Work", + "test_allowed": True, + "testing_allowed": True, + "x_colour": "blue", + "extra_config": {}, + } + }, + ) + with caplog.at_level("WARNING", logger="caldav"): + get_connection_params(config_file=config_file) + assert [r.getMessage() for r in caplog.records if r.levelname == "WARNING"] == [ + "config section default: unknown key 'pasword'" + ] + + def test_other_kinds_are_skipped(self, tmp_path): + from caldav.config import get_all_file_connection_params + + config_file = self._write( + tmp_path, + { + "dav": {"kind": "caldav", "url": "https://dav.example.com/"}, + "mail": {"kind": "jmap", "url": "https://jmap.example.com/"}, + "files": {"url": "~/calendars"}, + "plain": {"url": "https://plain.example.com/"}, + "all": {"contains": ["*"]}, + }, + ) + urls = [p["url"] for p in get_all_file_connection_params(config_file, "all")] + assert urls == ["https://dav.example.com/", "https://plain.example.com/"] + + def test_pycal_path_is_searched_first(self, tmp_path): + from caldav.config import get_connection_params + + home = tmp_path / "home" / ".config" + self._write( + home, {"default": {"caldav_url": "https://old.example.com/"}}, "caldav/calendar.conf" + ) + self._write( + home, {"default": {"caldav_url": "https://new.example.com/"}}, "pycal/calendar.yaml" + ) + assert get_connection_params()["url"] == "https://new.example.com/" + + def test_jmap_url_does_not_hide_the_file(self, tmp_path, monkeypatch): + from caldav.config import get_all_file_connection_params, get_connection_params + + config_file = self._write(tmp_path, {"default": {"caldav_url": "https://dav.example.com/"}}) + monkeypatch.setenv("JMAP_URL", "https://jmap.example.com/") + assert get_connection_params(config_file=config_file)["url"] == "https://dav.example.com/" + assert get_all_file_connection_params(config_file, environment=True)[0]["url"] == ( + "https://dav.example.com/" + ) + + def test_environment_secrets_without_the_file(self, monkeypatch): + from caldav.config import get_connection_params + + monkeypatch.setenv("CALDAV_URL", "https://env.example.com/") + monkeypatch.setenv("CALDAV_PASSWORD_COMMAND", "pass show dav") + with pytest.raises(ValueError, match="must be a list"): + get_connection_params(check_config_file=False) + + def test_headers_error_hides_the_value(self, monkeypatch): + from caldav.config import get_connection_params + + monkeypatch.setenv("CALDAV_URL", "https://env.example.com/") + monkeypatch.setenv("CALDAV_HEADERS", "Authorization: Bearer s3cr3t") + with pytest.raises(ValueError) as e: + get_connection_params(check_config_file=False) + assert "s3cr3t" not in str(e.value) + + def test_calendaring_variables_come_first(self, tmp_path, monkeypatch): + from caldav.config import get_connection_params + + config_file = self._write( + tmp_path, + { + "a": {"caldav_url": "https://a.example.com/"}, + "b": {"caldav_url": "https://b.example.com/"}, + }, + ) + monkeypatch.setenv("CALDAV_CONFIG_FILE", config_file) + monkeypatch.setenv("CALDAV_CONFIG_SECTION", "a") + monkeypatch.setenv("CALENDARING_CONFIG_SECTION", "b") + assert get_connection_params()["url"] == "https://b.example.com/" + + def test_extract_reads_the_new_format(self, tmp_path, caplog): + import sys + + from caldav.config import extract_conn_params_from_section + + config_file = self._write(tmp_path, {}) + command = [sys.executable, "-c", "print('s3cr3t')"] + new = {"kind": "caldav", "url": "https://dav.example.com/", "password_command": command} + assert extract_conn_params_from_section(new, config_file) == { + "url": "https://dav.example.com/", + "password": "s3cr3t", + } + with pytest.raises(ValueError, match="file"): + extract_conn_params_from_section(new) + assert extract_conn_params_from_section({"kind": "jmap", "url": "x"}) is None + # plann passes its command line arguments too: no warnings about those + cli = {"caldav_url": "https://dav.example.com/", "caldav_proxy": None, "calendar_name": "W"} + with caplog.at_level("WARNING", logger="caldav"): + assert extract_conn_params_from_section({**cli, "debug": True}) == { + "url": "https://dav.example.com/" + } + assert not caplog.records + assert "features" in extract_conn_params_from_section({"features": "radicale"}) + + def test_test_servers_in_the_new_format(self, tmp_path): + import sys + + from caldav.config import get_all_test_servers + + command = [sys.executable, "-c", "print('s3cr3t')"] + config_file = self._write( + tmp_path, + { + "scratch": { + "kind": "caldav", + "url": "https://test.example.com/", + "password_command": command, + "test_allowed": True, + "test_priority": 5, + } + }, + ) + params = get_all_test_servers(config_file)["scratch"] + assert params["url"] == "https://test.example.com/" + assert params["password"] == "s3cr3t" + assert params["test_priority"] == 5 + + class TestResolveFeaturesMutation: """resolve_features and testing.py server classes must deepcopy hint dicts. diff --git a/tests/test_get_calendars_sections.py b/tests/test_get_calendars_sections.py index ccdda3e4..b63a9dfa 100644 --- a/tests/test_get_calendars_sections.py +++ b/tests/test_get_calendars_sections.py @@ -34,6 +34,7 @@ def config_file(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Path]: f"work:\n{section} calendar_name: Work\n" f"home:\n{section} calendar_name: Home\n" "both:\n contains: [work, home]\n" + f"badheaders:\n{section} caldav_headers: notjson\n" ) yield path finally: @@ -70,3 +71,11 @@ def test_argument_overrides_section_filter(self, mode: str, config_file: Path) - mode, config_file=str(config_file), config_section="work", calendar_name="Home" ) assert names == ["Home"] + + @pytest.mark.parametrize("section", ["typo", "x*", "badheaders"]) + def test_config_error_raises_whatever_raise_errors( + self, mode: str, config_file: Path, section: str + ) -> None: + """A mistake in the configuration is not a server failure to skip.""" + with pytest.raises(ValueError, match="section|glob|match|headers"): + _names(mode, config_file=str(config_file), config_section=section, raise_errors=False) diff --git a/tests/test_servers/base.py b/tests/test_servers/base.py index 53e1637f..c461e47c 100644 --- a/tests/test_servers/base.py +++ b/tests/test_servers/base.py @@ -15,6 +15,8 @@ from pathlib import Path from typing import IO, Any +from caldav.config import get_test_setting + try: import niquests as requests except ImportError: @@ -108,9 +110,9 @@ def priority(self) -> int: """Return the sort priority for this server. Lower values are returned first by the registry. Can be overridden - per-server via ``priority: `` in the server config. + per-server via ``test_priority: `` in the server config. """ - return int(self.config.get("priority", self._default_priority)) + return int(get_test_setting(self.config, "test_priority", self._default_priority)) @property def features(self) -> Any: diff --git a/tests/test_servers/config_loader.py b/tests/test_servers/config_loader.py index c7467e46..f46fba8c 100644 --- a/tests/test_servers/config_loader.py +++ b/tests/test_servers/config_loader.py @@ -3,15 +3,18 @@ This module loads test server configuration from: 1. Test-specific config files (tests/caldav_test_servers.yaml) -2. Main caldav config files with 'testing_allowed: true' sections +2. Main caldav config files with 'test_allowed: true' sections """ +import json from pathlib import Path from typing import Any -from caldav.config import expand_env_vars, get_all_test_servers, read_config +import yaml -# Test-specific config file locations (don't need testing_allowed) +from caldav.config import expand_env_vars, get_all_test_servers + +# Test-specific config file locations (don't need test_allowed) TEST_CONFIG_LOCATIONS = [ "tests/caldav_test_servers.yaml", "tests/caldav_test_servers.json", @@ -35,7 +38,7 @@ def load_test_server_config( Priority: 1. Explicit config_file argument 2. Test-specific config files (tests/caldav_test_servers.yaml, etc.) - 3. Main caldav config sections with 'testing_allowed: true' + 3. Main caldav config sections with 'test_allowed: true' Args: config_file: Optional explicit path to config file @@ -62,7 +65,7 @@ def load_test_server_config( if path.exists(): return _load_config_file(str(path)) - # Try main caldav config (sections with testing_allowed) + # Try main caldav config (sections with test_allowed) servers = get_all_test_servers() if servers: # Add type: external for registry to use ExternalTestServer @@ -75,9 +78,18 @@ def load_test_server_config( def _load_config_file(path: str) -> dict[str, dict[str, Any]]: - """Load and parse a config file.""" + """Load and parse a config file. + + Not the shared config format (it may hold top-level lists such as + rfc6638_users), so it is parsed here: as JSON, else as YAML. + """ try: - cfg = read_config(path) + with open(path, "rb") as f: + raw = f.read() + try: + cfg = json.loads(raw) + except json.JSONDecodeError: + cfg = yaml.safe_load(raw) except Exception as e: raise ConfigParseError(f"Config file '{path}' exists but could not be parsed: {e}") from e