From 5553b7c1c454651372158238649cd04574931361 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 20 Oct 2026 03:17:24 +0200 Subject: [PATCH 1/2] feat: password_command in config, caldav 3.6 caldav 3.6 reads the config file through calendaring-config, so a plann section can use kind: caldav with plain keys, password_command and password_keyring. caldav runs a password_command only after checking the file it came from, so plann now passes its config file along to extract_conn_params_from_section(), with ~ expanded. A failing password_command skips the section with an error message, unless --raise-errors. caldav 3.6 is required for that parameter. https://github.com/python-caldav/caldav/issues/786 Prompt: fix (the issue found in) plann, lift the cladav (sic) floor to 3.6, open a PR Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- CHANGELOG.md | 1 + README.md | 11 ++++++++++- plann/cli.py | 12 ++++++++---- plann/lib.py | 22 ++++++++++++++++------ pyproject.toml | 2 +- tests/test_lib.py | 30 ++++++++++++++++++++++++++++++ tests/test_plann_cli.py | 18 ++++++++++++++++++ 7 files changed, 84 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efb4360..1395be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and I do try to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.h * `completed!` in `set-task-attribs` moves a recurring task on to its next recurrence, like `edit --complete` does, instead of completing the whole series. * Shell tab completion scripts (bash, zsh, fish) now ship with the package. They work without shell configuration for system-wide installs (and for bash user/venv installs with bash-completion 2.12+); see the README for other setups. The `make install-completion` targets are gone. * icalendar 6.2.0 or newer is required. +* caldav 3.6 or newer is required. The config file is now read as caldav 3.6 reads it, so it may also use `kind: caldav` sections with plain keys, `password_command` (a program printing the password) and `password_keyring`, and `~/.config/pycal/calendar.yaml`. ### Fixed diff --git a/README.md b/README.md index de4abc2..e0a5b2e 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,16 @@ The connection settings (`caldav_url`, `caldav_user`, `caldav_pass`, `caldav_pro } ``` -Only the connection settings are expanded; other keys, such as `calendar_url` and `calendar_name`, are used as written. +Instead of a password in the file or the environment, `caldav_password_command` may name a program (as a list of the program and its arguments) whose output is the password, and `caldav_password_keyring` a service name to look up in the system keyring: + +```yaml +default: + caldav_url: http://foo.bar.example.com/caldav/ + caldav_user: luser + caldav_password_command: [pass, show, dav/work] +``` + +The command is only run when the config file is writable by nobody but its owner (`chmod 600`); `plann configure` writes the file with your umask, so check it after configuring. A section may also be written as `kind: caldav` with plain keys (`url`, `username`, `password_command`, ...). The file format is shared with the caldav library and other tools, and is specified in [calendaring-config](https://github.com/pycalendar/calendaring-config/blob/main/docs/format.md). A `features` key may be given to enable server-specific compatibility workarounds, referring to a server profile in the caldav library's `compatibility_hints` module (e.g. `"features": "ecloud"`). For servers with a known profile, `caldav_url` may even be omitted - the caldav library will derive the URL from the profile. diff --git a/plann/cli.py b/plann/cli.py index b5296d6..2df9785 100755 --- a/plann/cli.py +++ b/plann/cli.py @@ -135,6 +135,10 @@ def cli(ctx, **kwargs): ## caldav_client, principal and calendar ctx.ensure_object(dict) + ## caldav checks the config file before running a password_command + ## from it, and needs a real path for that, not `~/...` + if kwargs['config_file']: + kwargs['config_file'] = os.path.expanduser(kwargs['config_file']) ## TODO: add all relevant connection parameters for the DAVClient as options ## TODO: logic to read the config file and edit kwargs from config file ## TODO: catch errors, present nice error messages @@ -161,13 +165,13 @@ def _discover_calendars(kwargs): config = read_config(kwargs['config_file']) if config: for meta_section in kwargs['config_section']: - calendars.extend(_config_calendars(config, meta_section, kwargs['raise_errors'])) + calendars.extend(_config_calendars(config, meta_section, kwargs['raise_errors'], kwargs['config_file'])) return calendars -def _config_calendars(config, meta_section, raise_errors): +def _config_calendars(config, meta_section, raise_errors, config_file): calendars = [] for section in expand_config_section(config, meta_section): - calendars.extend(find_calendars(config_section(config, section), raise_errors=raise_errors)) + calendars.extend(find_calendars(config_section(config, section), raise_errors=raise_errors, config_file=config_file)) return calendars def _section_calendars(ctx, meta_section): @@ -176,7 +180,7 @@ def _section_calendars(ctx, meta_section): config = read_config(ctx.obj['config_file']) if not config: return [] - return _config_calendars(config, meta_section, raise_errors=True) + return _config_calendars(config, meta_section, raise_errors=True, config_file=ctx.obj['config_file']) @cli.command() @click.pass_context diff --git a/plann/lib.py b/plann/lib.py index e78ca50..272cea9 100644 --- a/plann/lib.py +++ b/plann/lib.py @@ -175,18 +175,28 @@ def _warn_orphaned_overrides(ical): "master, otherwise they are stored as detached occurrence(s).", uid, [str(x.get('SUMMARY', '(no summary)')) for x in components]) -def find_calendars(args, raise_errors): +def find_calendars(args, raise_errors, config_file=None): """ Find calendars from a dict of connection parameters - typically a config file section or the command line arguments. The connection keys are - caldav_-prefixed (caldav_url, caldav_user, caldav_pass, ...), optionally - accompanied by `features` and the calendar_url/calendar_name filters. + caldav_-prefixed (caldav_url, caldav_user, caldav_pass, ...), or plain + in a `kind: caldav` section, optionally accompanied by `features` and the + calendar_url/calendar_name filters. Connection parameter extraction (including resolving the URL from a - `features` server profile when no caldav_url is given) and the calendar - lookup itself are delegated to the caldav library. + `features` server profile when no caldav_url is given, and running a + password_command) and the calendar lookup itself are delegated to the + caldav library. `config_file` is the file a section came from; caldav + checks it before running a password_command from it. """ - conn_params = extract_conn_params_from_section(args) + try: + conn_params = extract_conn_params_from_section(args, config_file) + except ValueError as e: + ## a password_command that failed, an unsafe config file, ... + if raise_errors: + raise + logging.error(f"Skipping a config section: {e}") + return [] if not conn_params: return [] calendars = caldav.get_calendars( diff --git a/pyproject.toml b/pyproject.toml index ea8effd..0b7e14d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] keywords = ["caldav", "calendar", "cli", "todo", "planning"] dependencies = [ - "caldav>=3.3.0", + "caldav>=3.6.0", "Click", "PyYAML", "sortedcontainers", diff --git a/tests/test_lib.py b/tests/test_lib.py index e027777..059207e 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -74,6 +74,36 @@ def test_features_without_url(self): 'features': self.features}) assert calendars == [cal] + def test_password_command(self, tmp_path): + """caldav runs a password_command only with the config file it came + from, so plann has to pass that file along.""" + import sys + config_file = tmp_path / "calendar.conf" + config_file.write_text("") + config_file.chmod(0o600) + section = {'caldav_url': 'https://calendar.example.com/dav', + 'caldav_password_command': [sys.executable, '-c', "print('s3cr3t')"]} + from plann.lib import find_calendars + with patch('caldav.get_calendars', return_value=[]) as get_calendars: + find_calendars(section, raise_errors=True, config_file=str(config_file)) + assert get_calendars.call_args.kwargs['password'] == 's3cr3t' + + def test_failing_password_command(self, tmp_path, caplog): + """A failing command is reported, and only stops discovery under + --raise-errors.""" + import sys + import pytest + config_file = tmp_path / "calendar.conf" + config_file.write_text("") + config_file.chmod(0o600) + section = {'caldav_url': 'https://calendar.example.com/dav', + 'caldav_password_command': [sys.executable, '-c', 'raise SystemExit(3)']} + from plann.lib import find_calendars + assert find_calendars(section, raise_errors=False, config_file=str(config_file)) == [] + assert 'exited with status 3' in caplog.text + with pytest.raises(ValueError, match='status 3'): + find_calendars(section, raise_errors=True, config_file=str(config_file)) + def test_no_connection_params(self): calendars, cal = self._find_calendars({}) assert calendars == [] diff --git a/tests/test_plann_cli.py b/tests/test_plann_cli.py index 981724b..d226a5c 100644 --- a/tests/test_plann_cli.py +++ b/tests/test_plann_cli.py @@ -569,3 +569,21 @@ def test_mass_interactive_edit_works_on_tasks_fetched_after_editing(): assert len(saved) == 1 assert saved[0] is not task assert saved[0].icalendar_component['PRIORITY'] == 3 + + +def test_config_file_reaches_caldav(tmp_path, monkeypatch): + """The config file goes to caldav with each section, also as `~/...`, + so a password_command in it can run.""" + import sys + monkeypatch.setenv("HOME", str(tmp_path)) + config_file = tmp_path / "calendar.conf" + config_file.write_text( + "default:\n" + " caldav_url: https://calendar.example.com/dav\n" + f" caldav_password_command: ['{sys.executable}', '-c', \"print('s3cr3t')\"]\n") + config_file.chmod(0o600) + with patch('caldav.get_calendars', return_value=[]) as get_calendars: + res = CliRunner().invoke(cli, ['-c', '~/calendar.conf', 'list-calendars']) + assert 'Traceback' not in res.output, res.output + passwords = [c.kwargs.get('password') for c in get_calendars.call_args_list] + assert 's3cr3t' in passwords, res.output From 6a2ebed65e958feba2bd3c756fbcae8d5b44abef Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 20 Oct 2026 03:51:22 +0200 Subject: [PATCH 2/2] feat: find the config file on the shared path plann no longer hard-codes ~/.config/calendar.conf: without -c it uses the first file on the search path shared with caldav and other tools (calendaring-config decides it, ~/.config/pycal/calendar.yaml first), and configure saves at the head of that path when the file it read is not writable (/etc), creating it readable by its owner only. The file is looked up only when it is read, so --help and --skip-config work whatever CALDAV_CONFIG_FILE says. Config errors name their section. https://github.com/python-caldav/caldav/issues/786 Prompt: The shared search path defined in calendaring-config should be used, we should have no hard-coded search path in the plann library. Open the PR. Followup-Prompt: (...) I think it should be safe to go ahead and fix calendaring-config [the section names in errors were added by a parallel session, from a review finding] Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- CHANGELOG.md | 3 +- README.md | 4 +-- USER_GUIDE.md | 2 +- examples/copy-calendar.md | 2 +- plann/cli.py | 41 +++++++++++++++------- plann/config.py | 42 ++++++++++++++++++++--- plann/lib.py | 7 ++-- pyproject.toml | 1 + tests/conftest.py | 16 +++++++++ tests/test_lib.py | 5 +-- tests/test_plann_cli.py | 71 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 167 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1395be4..6d3c53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,8 @@ and I do try to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.h * `completed!` in `set-task-attribs` moves a recurring task on to its next recurrence, like `edit --complete` does, instead of completing the whole series. * Shell tab completion scripts (bash, zsh, fish) now ship with the package. They work without shell configuration for system-wide installs (and for bash user/venv installs with bash-completion 2.12+); see the README for other setups. The `make install-completion` targets are gone. * icalendar 6.2.0 or newer is required. -* caldav 3.6 or newer is required. The config file is now read as caldav 3.6 reads it, so it may also use `kind: caldav` sections with plain keys, `password_command` (a program printing the password) and `password_keyring`, and `~/.config/pycal/calendar.yaml`. +* caldav 3.6 or newer is required. The config file is now read as caldav 3.6 reads it, so it may also use `kind: caldav` sections with plain keys, `password_command` (a program printing the password) and `password_keyring`. +* The config file is found on the search path shared with the caldav library and other tools: `~/.config/pycal/calendar.yaml` first, then caldav's paths, `~/.config/calendar.conf` and `/etc`, or the file `CALENDARING_CONFIG_FILE` / `CALDAV_CONFIG_FILE` names. Before, plann read `~/.config/calendar.conf` only, so with several of these files a different one may be used now. `plann configure` creates `~/.config/pycal/calendar.yaml` when there is none, or when the file it found cannot be written (one under `/etc`), and makes the file readable by its owner only. ### Fixed diff --git a/README.md b/README.md index e0a5b2e..1c1617a 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ not be up-to-date and may contain features not implemented yet. * --caldav-url, --caldav-user, --caldav-pass: how to connect to the CalDAV server. (Fits better into a configuration file) * --calendar-url: url to the calendar one wants to use. A relative URL (path) or a calendar-id is also accepted. -* --config-file: use a specific configuration file (default: $HOME/.config/calendar.conf) +* --config-file: use a specific configuration file (default: the first that exists on the search path shared with the caldav library, see below) * --config-section: use a specific section from the config file (i.e. to select a different caldav-server to connect to) * --icalendar: Write or read icalendar to/from stdout/stdin * --nocaldav: don't connect to a caldav server @@ -137,7 +137,7 @@ The file TASK_MANAGEMENT.md contains some thoughts on how to organize tasks. ## Configuration file -Configuration file is by default located in $HOME/.config/calendar.conf. `plann` supports both json and yaml. +`plann` shares its configuration file with the caldav library and other calendar tools. Unless `--config-file` names one, it uses the first that exists of `$XDG_CONFIG_HOME/pycal/calendar.yaml` (by default `~/.config/pycal/calendar.yaml`), `~/.config/caldav/calendar.conf`, `~/.config/caldav/calendar.yaml`, `~/.config/caldav/calendar.json`, `~/.config/calendar.conf`, `/etc/calendar.conf` and `/etc/caldav/calendar.conf`; `CALENDARING_CONFIG_FILE` or `CALDAV_CONFIG_FILE` may name another. `plann configure` creates `~/.config/pycal/calendar.yaml` when none exists. `plann` supports both json and yaml. (I considered .ini, but I was told that it's actually not a standard. I'd like any calendar application to be able to access the file, hence calendar.conf and not plann.conf) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index ad73e82..ef3e51f 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -48,7 +48,7 @@ It's recommended to rather use a config file. Those options can be used for spe * `--config-file` * `--config-section` -The default is to utilize the `default` section under `$HOME/.config/calendar.conf` Here is an example configuration file: +The default is to utilize the `default` section of the configuration file, found on the search path shared with the caldav library (`~/.config/pycal/calendar.yaml` first; see the README). Here is an example configuration file: ```yaml --- diff --git a/examples/copy-calendar.md b/examples/copy-calendar.md index 3316d1d..220461d 100644 --- a/examples/copy-calendar.md +++ b/examples/copy-calendar.md @@ -28,7 +28,7 @@ Take the events from the work calendar and mark up in your default calendar that ## Sub-task: take out all data in raw ical from the calendar -`plann` without any options will select the default calendar from `.config/calendar.conf`. +`plann` without any options will select the default calendar from the configuration file (`~/.config/pycal/calendar.yaml` or another file on the shared search path, see the README). `plann select` without any options will select everything. diff --git a/plann/cli.py b/plann/cli.py index 2df9785..c38131f 100755 --- a/plann/cli.py +++ b/plann/cli.py @@ -39,7 +39,14 @@ _split_high_pri_tasks, _split_huge_tasks, ) -from plann.config import config_section, expand_config_section, interactive_config, read_config +from plann.config import ( + config_section, + expand_config_section, + interactive_config, + read_config, + resolve_config_file, + writable_config_file, +) from plann.interactive import _abort from plann.lib import ( COMMA_LIST_ATTRS, @@ -115,7 +122,7 @@ def extend(self, more): @click.option('--show-native-timezone/--show-local-timezone', help="Show timestamps as they are in the calendar (default is to convert to local timezone)") @click.option('--implicit-timezone', help="Timestamps entered without timezone is assumed to be in this timezone (default: local/system tz)") @click.option('--store-timezone', help="Timestamps saved to the calendar should be converted to this timezone (default: same as --implicit-timezone)") -@click.option('-c', '--config-file', default=f"{os.environ.get('HOME')}/.config/calendar.conf") +@click.option('-c', '--config-file', help="Configuration file (default: the first that exists of ~/.config/pycal/calendar.yaml, ~/.config/caldav/calendar.conf and the other paths shared with the caldav library)") @click.option('--skip-config/--read-config', help="Skip reading the config file") @click.option('--config-section', default=["default"], multiple=True) @click.option('--caldav-url', help="Full URL to the caldav server", metavar='URL') @@ -135,10 +142,6 @@ def cli(ctx, **kwargs): ## caldav_client, principal and calendar ctx.ensure_object(dict) - ## caldav checks the config file before running a password_command - ## from it, and needs a real path for that, not `~/...` - if kwargs['config_file']: - kwargs['config_file'] = os.path.expanduser(kwargs['config_file']) ## TODO: add all relevant connection parameters for the DAVClient as options ## TODO: logic to read the config file and edit kwargs from config file ## TODO: catch errors, present nice error messages @@ -162,25 +165,37 @@ def _discover_calendars(kwargs): --skip-config is given, from every selected config-file section.""" calendars = find_calendars(kwargs, kwargs['raise_errors']) if not kwargs['skip_config']: - config = read_config(kwargs['config_file']) + config_file = _config_file(kwargs['config_file']) + config = read_config(config_file) if config: for meta_section in kwargs['config_section']: - calendars.extend(_config_calendars(config, meta_section, kwargs['raise_errors'], kwargs['config_file'])) + calendars.extend(_config_calendars(config, meta_section, kwargs['raise_errors'], config_file)) return calendars +def _config_file(given): + """The config file to read, looked up only when it is needed, so that + --help and --skip-config work whatever CALDAV_CONFIG_FILE says. caldav + checks this file before running a password_command from it.""" + try: + return resolve_config_file(given) + except ValueError as e: ## CALDAV_CONFIG_FILE naming a file that is not there + raise click.UsageError(str(e)) from e + def _config_calendars(config, meta_section, raise_errors, config_file): calendars = [] for section in expand_config_section(config, meta_section): - calendars.extend(find_calendars(config_section(config, section), raise_errors=raise_errors, config_file=config_file)) + calendars.extend(find_calendars(config_section(config, section), raise_errors=raise_errors, + config_file=config_file, section_name=section)) return calendars def _section_calendars(ctx, meta_section): """Find the calendars of one config-file section, independent of the sections selected with --config-section. Errors are raised.""" - config = read_config(ctx.obj['config_file']) + config_file = _config_file(ctx.obj['config_file']) + config = read_config(config_file) if not config: return [] - return _config_calendars(config, meta_section, raise_errors=True, config_file=ctx.obj['config_file']) + return _config_calendars(config, meta_section, raise_errors=True, config_file=config_file) @cli.command() @click.pass_context @@ -205,11 +220,11 @@ def configure(ctx): Prompts for connection parameters and writes them to the config file. """ - config_file = ctx.obj['config_file'] + config_file = _config_file(ctx.obj['config_file']) sections = ctx.obj['config_section'] section = sections[0] if sections else 'default' config = read_config(config_file, interactive_error=True) or {} - interactive_config(config, config_file, config_section=section) + interactive_config(config, writable_config_file(ctx.obj['config_file'], config_file), config_section=section) def _set_attr_options_(func, verb, desc=""): """ diff --git a/plann/config.py b/plann/config.py index a376cd8..9e2488d 100644 --- a/plann/config.py +++ b/plann/config.py @@ -12,6 +12,8 @@ from caldav.config import expand_config_section as expand_config_section from caldav.config import read_config as _read_config +import calendaring_config + ## Connection parameters are written caldav_-prefixed; caldav's reader ## (extract_conn_params_from_section) maps caldav_user/caldav_pass to ## username/password. The list is hand-written, and tests/test_config.py @@ -132,10 +134,7 @@ def interactive_config(config, config_file, config_section='default', allow_use= del config[section] section = new_section try: - if os.path.isfile(config_file): - os.rename(config_file, f"{config_file}.{int(time.time())}.bak") - with open(config_file, 'w') as outfile: - json.dump(config, outfile, indent=4) + write_config_file(config, config_file) except Exception as e: print(e) else: @@ -146,6 +145,41 @@ def interactive_config(config, config_file, config_section='default', allow_use= config['default'] = config[section] return config +def write_config_file(config, config_file): + """Write `config` to `config_file`, keeping a backup of the old one. The + file may hold a password, so it is created readable by its owner only - + and caldav will only run a password_command from a file nobody else can + write to.""" + if os.path.isfile(config_file): + os.rename(config_file, f"{config_file}.{int(time.time())}.bak") + os.makedirs(os.path.dirname(config_file) or '.', mode=0o700, exist_ok=True) + fd = os.open(config_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, 'w') as outfile: + json.dump(config, outfile, indent=4) + +def writable_config_file(given, found): + """Where `plann configure` saves: the file it read (`found`), unless + that was picked from the search path and cannot be written - such as + /etc/calendar.conf - in which case a new file goes first on the path.""" + if given: + return found + ## saving renames the old file to a backup, so the directory must be writable + directory = os.path.dirname(found) or '.' + if not os.path.exists(directory) or os.access(directory, os.W_OK): + return found + return str(calendaring_config.search_path()[0]) + +def resolve_config_file(config_file): + """The config file to use: the one given (with ~ expanded), else the + first that exists on the search path shared with the caldav library and + other tools (calendaring-config decides it, also from + CALENDARING_CONFIG_FILE / CALDAV_CONFIG_FILE), else the first path on + it, where `plann configure` creates a new one.""" + if config_file: + return os.path.expanduser(config_file) + found = calendaring_config.find_config_file() + return str(found if found else calendaring_config.search_path()[0]) + def read_config(fn, interactive_error=False): """ Thin wrapper around the caldav library's read_config. The caldav diff --git a/plann/lib.py b/plann/lib.py index 272cea9..65428c0 100644 --- a/plann/lib.py +++ b/plann/lib.py @@ -175,7 +175,7 @@ def _warn_orphaned_overrides(ical): "master, otherwise they are stored as detached occurrence(s).", uid, [str(x.get('SUMMARY', '(no summary)')) for x in components]) -def find_calendars(args, raise_errors, config_file=None): +def find_calendars(args, raise_errors, config_file=None, section_name="section"): """ Find calendars from a dict of connection parameters - typically a config file section or the command line arguments. The connection keys are @@ -187,10 +187,11 @@ def find_calendars(args, raise_errors, config_file=None): `features` server profile when no caldav_url is given, and running a password_command) and the calendar lookup itself are delegated to the caldav library. `config_file` is the file a section came from; caldav - checks it before running a password_command from it. + checks it before running a password_command from it, and + `section_name` names the section in error messages. """ try: - conn_params = extract_conn_params_from_section(args, config_file) + conn_params = extract_conn_params_from_section(args, config_file, section_name) except ValueError as e: ## a password_command that failed, an unsafe config file, ... if raise_errors: diff --git a/pyproject.toml b/pyproject.toml index 0b7e14d..d2b359a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ keywords = ["caldav", "calendar", "cli", "todo", "planning"] dependencies = [ "caldav>=3.6.0", + "calendaring-config>=0.4", "Click", "PyYAML", "sortedcontainers", diff --git a/tests/conftest.py b/tests/conftest.py index 9e2ad63..ce9b181 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,3 +12,19 @@ def _reset_tz(): tz.__dict__.clear() yield tz.__dict__.clear() + + +@pytest.fixture(autouse=True) +def _no_real_config(tmp_path, monkeypatch): + """Keep the developer's own config files and CALDAV_*/CALENDARING_* + variables out of every test: plann looks the config file up on the + shared search path when no -c is given.""" + import os + + import calendaring_config.discovery + for name in list(os.environ): + if name.startswith(("CALDAV_", "CALENDARING_")): + monkeypatch.delenv(name) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setattr(calendaring_config.discovery, "SYSTEM_PATHS", (tmp_path / "etc.conf",)) diff --git a/tests/test_lib.py b/tests/test_lib.py index 059207e..3ad4b5a 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -99,8 +99,9 @@ def test_failing_password_command(self, tmp_path, caplog): section = {'caldav_url': 'https://calendar.example.com/dav', 'caldav_password_command': [sys.executable, '-c', 'raise SystemExit(3)']} from plann.lib import find_calendars - assert find_calendars(section, raise_errors=False, config_file=str(config_file)) == [] - assert 'exited with status 3' in caplog.text + assert find_calendars(section, raise_errors=False, config_file=str(config_file), + section_name='work') == [] + assert 'section work exited with status 3' in caplog.text with pytest.raises(ValueError, match='status 3'): find_calendars(section, raise_errors=True, config_file=str(config_file)) diff --git a/tests/test_plann_cli.py b/tests/test_plann_cli.py index d226a5c..3d1cd21 100644 --- a/tests/test_plann_cli.py +++ b/tests/test_plann_cli.py @@ -587,3 +587,74 @@ def test_config_file_reaches_caldav(tmp_path, monkeypatch): assert 'Traceback' not in res.output, res.output passwords = [c.kwargs.get('password') for c in get_calendars.call_args_list] assert 's3cr3t' in passwords, res.output + + +def test_shared_search_path(tmp_path, monkeypatch): + """Without -c, plann reads the file the shared search path finds, here + ~/.config/pycal/calendar.yaml.""" + import sys + config_file = tmp_path / ".config" / "pycal" / "calendar.yaml" + config_file.parent.mkdir(parents=True) + config_file.write_text( + "default:\n" + " kind: caldav\n" + " url: https://calendar.example.com/dav\n" + f" password_command: ['{sys.executable}', '-c', \"print('s3cr3t')\"]\n") + config_file.chmod(0o600) + with patch('caldav.get_calendars', return_value=[]) as get_calendars: + res = CliRunner().invoke(cli, ['list-calendars']) + passwords = [c.kwargs.get('password') for c in get_calendars.call_args_list] + assert 's3cr3t' in passwords, res.output + + +def test_new_config_file_goes_first_on_the_search_path(tmp_path, monkeypatch): + from plann.config import resolve_config_file + assert resolve_config_file(None) == str(tmp_path / ".config" / "pycal" / "calendar.yaml") + assert resolve_config_file("~/my.conf") == str(tmp_path / "my.conf") + + +def test_missing_env_config_file_only_matters_when_read(monkeypatch): + """A CALDAV_CONFIG_FILE naming a missing file does not block --help or + --skip-config.""" + monkeypatch.setenv("CALDAV_CONFIG_FILE", "/nonexistent.invalid/calendar.conf") + res = CliRunner().invoke(cli, ['list-calendars', '--help']) + assert res.exit_code == 0, res.output + with patch('caldav.get_calendars', return_value=[]): + res = CliRunner().invoke(cli, ['--skip-config', '--caldav-url', 'http://example.invalid/', 'list-calendars']) + assert 'does not exist' not in res.output, res.output + res = CliRunner().invoke(cli, ['list-calendars']) + assert 'does not exist' in res.output + + +def test_configure_writes_beside_a_read_only_system_file(tmp_path, monkeypatch): + import calendaring_config.discovery + + from plann.config import resolve_config_file, writable_config_file + etc = tmp_path / "etc" + etc.mkdir() + system_file = etc / "calendar.conf" + system_file.write_text("{}") + etc.chmod(0o555) + try: + monkeypatch.setattr(calendaring_config.discovery, "SYSTEM_PATHS", (system_file,)) + found = resolve_config_file(None) + assert found == str(system_file) + assert writable_config_file(None, found) == str(tmp_path / ".config" / "pycal" / "calendar.yaml") + assert writable_config_file("~/my.conf", str(tmp_path / "my.conf")) == str(tmp_path / "my.conf") + finally: + etc.chmod(0o755) + + +def test_new_config_file_is_private(tmp_path, monkeypatch): + import os + import stat + + from plann.config import write_config_file + old = os.umask(0o002) + try: + path = tmp_path / ".config" / "pycal" / "calendar.yaml" + write_config_file({"default": {"caldav_pass": "x"}}, str(path)) + finally: + os.umask(old) + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700