diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml new file mode 100644 index 0000000..ee4cffd --- /dev/null +++ b/.github/workflows/linkcheck.yml @@ -0,0 +1,64 @@ +name: Link check + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + schedule: + - cron: "03 22 * * *" + +concurrency: + group: linkcheck-${{ github.ref }} + cancel-in-progress: false + +jobs: + linkcheck: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/checkout@v5 + - name: Restore lychee cache + uses: actions/cache@v4 + with: + path: .lycheecache + key: cache-lychee-${{ github.run_id }} + restore-keys: cache-lychee- + - name: Check links with Lychee + id: lychee + uses: lycheeverse/lychee-action@v2 + with: + fail: false + args: >- + --timeout 30 + --max-retries 6 + --retry-wait-time 2 + --cache + --max-cache-age 14d + . + - name: Create or update Link Checker issue + if: steps.lychee.outputs.exit_code != 0 && github.ref == 'refs/heads/master' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Keep the oldest open report issue as canonical, fold duplicates in. + ISSUES=$(gh issue list --label "report" --state open --json number --jq '.[].number' | sort -n) + CANON=$(printf '%s\n' "$ISSUES" | head -1) + for n in $(printf '%s\n' "$ISSUES" | tail -n +2); do + gh issue close "$n" --comment "Duplicate of #${CANON} - auto-closed by the link checker." + done + if [ -n "$CANON" ]; then + gh issue edit "$CANON" --body-file ./lychee/out.md + else + gh issue create --title "Link Checker Report" --body-file ./lychee/out.md --label "report" + fi + - name: Close Link Checker issue if all links are healthy + if: steps.lychee.outputs.exit_code == 0 && github.ref == 'refs/heads/master' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for n in $(gh issue list --label "report" --state open --json number --jq '.[].number'); do + gh issue close "$n" --comment "All links are now healthy." + done diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..96e1e6d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,37 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/calendar-cli + permissions: + id-token: write + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install build tools + run: pip install build + + - name: Build package + run: python -m build + + - name: Verify built version matches the tag + run: ls -lh dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6e4172e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run ruff + run: ruff check . + + - name: Run tests + run: pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1963db2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Version file generated at build time by hatch-vcs +calendar_cli/_version.py + +# Editor backups / autosave files +*~ +\#*\# +.\#* +*.orig +*.bak + +# Python build artifacts +build/ +dist/ +*.egg-info/ +__pycache__/ +*.py[cod] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fd0b8e5..43baabe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,5 @@ repos: + # === ai-prompt-auto-commit: record Claude Code prompts in commit messages === - repo: https://github.com/pycalendar/ai-prompt-auto-commit rev: v0.0.5 hooks: @@ -9,3 +10,59 @@ repos: stages: [post-commit] - id: prepare-ai-repository stages: [manual] + + # === PRE-COMMIT STAGE (quick checks on every commit) === + # NOTE: ruff-format is intentionally omitted. calendar_cli is a legacy/LTS + # codebase and we do not want a wholesale reformat; ruff lint (scoped to real + # errors in pyproject.toml) is enough. + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.19 + hooks: + - id: ruff-check + args: [--fix] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + # === COMMIT-MSG: Enforce Conventional Commits === + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v4.4.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + + # === PRE-PUSH: full test suite before pushing === + - repo: local + hooks: + - id: pytest + name: pytest + entry: pytest + language: system + pass_filenames: false + always_run: true + stages: [pre-push] + + # === PRE-PUSH: prevent direct pushes to master (mature, v1+ project) === + # Bypass with: git push --no-verify + - repo: local + hooks: + - id: no-push-to-main + name: Prevent direct push to master branch + entry: bash -c 'branch=$(git rev-parse --abbrev-ref HEAD); if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then echo "Direct pushes to $branch are not allowed. Use --no-verify to bypass."; exit 1; fi' + language: system + pass_filenames: false + stages: [pre-push] + + # === PRE-PUSH: link checker (cached for speed; config in lychee.toml) === + - repo: https://github.com/lycheeverse/lychee + rev: lychee-v0.24.2 + hooks: + - id: lychee + args: ["--no-progress", "--timeout", "10", "--cache", "--max-cache-age", "1d", "."] + pass_filenames: false + stages: [pre-push] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ae2bebc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed +- Packaging migrated from `setup.py` to a PEP 621 `pyproject.toml` using the + Hatch build backend. The version is now derived automatically from git tags + via hatch-vcs instead of being hard-coded in `calendar_cli/metadata.py`, so + the released version can no longer drift from the tags + (https://github.com/pycalendar/calendar-cli/issues/117). + +### Fixed +- `--interactive-config` no longer crashes on Python 3: it relied on the + removed Python 2 `raw_input` builtin and used `os`, `time` and `getpass` + without importing them. +- The package license metadata now uses the valid SPDX identifier + `GPL-3.0-or-later` + (https://github.com/pycalendar/calendar-cli/issues/115). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6044e85 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# Contributing to calendar-cli + +Contributions are mostly welcome (but do inform about it if you've used AI or +other tools). If the length of this text scares you, then I'd rather want you +to skip reading and just produce a pull-request in GitHub. If you find it too +difficult to write test code, etc, then you may skip it and hope the maintainer +will fix it. + +## What to include + +Every submission should ideally include: + +- **Test code** covering the new behaviour or bug fix +- **Documentation** updates where relevant +- **A changelog entry** in `CHANGELOG.md` under `[Unreleased]` + +## Development setup + +``` +make dev # editable install with dev dependencies (pytest, ruff, pre-commit) +make test # run the test suite +make lint # run ruff +``` + +To enable the pre-commit hooks (linting on commit, tests/link-check on push, +conventional-commit message checks): + +``` +pre-commit install +pre-commit install --hook-type pre-push +pre-commit install --hook-type commit-msg +``` + +## Versioning and releases + +The version is derived automatically from git tags via hatch-vcs — it is **not** +stored in the source. A release is made by tagging (`vX.Y.Z`) and pushing the +tag; a GitHub Actions workflow then builds and publishes to PyPI via trusted +publishing. There is nothing to bump by hand. + +## Commit messages + +Please follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) +and write messages in the imperative mood: + +- `fix: correct time-range search handling for recurring events` +- `feat: add new command` +- `docs: update README` + +Rather than: + +- `This commit fixes the time-range search` +- `Added new command` + +Note: older commits in this repository predate this convention and do not +follow it. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..53f0cf7 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +.PHONY: help install dev lint test clean venv-install + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' + +install: ## Install the package (auto-detects root, uv, pipx, or pip) + @if [ "$$(id -u)" = "0" ]; then \ + echo "Running as root, installing system-wide..."; \ + pip install .; \ + elif command -v uv >/dev/null 2>&1; then \ + echo "Installing with uv..."; \ + uv tool install .; \ + elif command -v pipx >/dev/null 2>&1; then \ + echo "Installing with pipx..."; \ + pipx install .; \ + else \ + echo "Tip: Install uv or pipx for isolated installs (pacman -S uv, apt install pipx, brew install uv)"; \ + echo "Falling back to pip install --user ..."; \ + PIP_BREAK_SYSTEM_PACKAGES=1 pip install --user .; \ + fi + +dev: ## Install with dev dependencies (editable) + PIP_BREAK_SYSTEM_PACKAGES=1 pip install -e ".[dev]" + +lint: ## Run ruff linter + python -m ruff check calendar_cli/ tests/ + +test: ## Run tests + python -m pytest + +clean: ## Remove build artifacts + rm -rf dist/ build/ *.egg-info calendar_cli/_version.py .pytest_cache .ruff_cache + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + +VENV := .venv +WRAPPER_DIR := $(HOME)/bin + +venv-install: ## Install into a venv and create a ~/bin/calendar-cli wrapper + python3 -m venv $(VENV) + $(VENV)/bin/pip install --upgrade pip + $(VENV)/bin/pip install . + mkdir -p $(WRAPPER_DIR) + @printf '#!/bin/sh\nexec %s/bin/calendar-cli "$$@"\n' "$$(pwd)/$(VENV)" > $(WRAPPER_DIR)/calendar-cli + chmod +x $(WRAPPER_DIR)/calendar-cli + @echo "Installed: $(WRAPPER_DIR)/calendar-cli -> $$(pwd)/$(VENV)/bin/calendar-cli" diff --git a/README.md b/README.md index cdb59bf..e1c8ab5 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,9 @@ Installation ------------ `calendar-cli` depends on quite some python libraries, i.e. pytz, caldav, etc. -"sudo ./setup.py install" should take care of all those eventually, and will -also make an executable under /usr/bin +Run `make install` — it auto-detects `uv`, `pipx` or `pip` and installs the +package together with all its dependencies, exposing a `calendar-cli` +executable. Dependencies are declared in `pyproject.toml`. Support ------- diff --git a/calendar_cli/config.py b/calendar_cli/config.py index 37d6dd2..650a564 100644 --- a/calendar_cli/config.py +++ b/calendar_cli/config.py @@ -1,10 +1,13 @@ import logging import json +import os +import time import yaml from fnmatch import fnmatch +from getpass import getpass def interactive_config(args, config, remaining_argv): - import readline + import readline # noqa: F401 (importing enables line editing in input()) new_config = False section = 'default' @@ -25,7 +28,7 @@ def interactive_config(args, config, remaining_argv): section = args.config_section else: ## TODO: tab completion - section = raw_input("Chose one of those, or a new name / no name for a new configuration section: ") + section = input("Chose one of those, or a new name / no name for a new configuration section: ") if section in config: backup = config[section].copy() print("Using section " + section) @@ -42,7 +45,7 @@ def interactive_config(args, config, remaining_argv): value = getpass(prompt="Enter new value (or just enter to keep the old): ") else: print("Config option %s - old value: %s" % (config_key, config[section].get(config_key, '(None)'))) - value = raw_input("Enter new value (or just enter to keep the old): ") + value = input("Enter new value (or just enter to keep the old): ") if value: config[section][config_key] = value @@ -64,12 +67,12 @@ def interactive_config(args, config, remaining_argv): print("CONFIGURATION DONE ...") for o in options: print("Type %s if you want to %s" % o) - cmd = raw_input("Enter a command: ") + cmd = input("Enter a command: ") if cmd in ('use', 'abort'): state = 'done' if cmd in ('save', 'save_other'): if cmd == 'save_other': - new_section = raw_input("New config section name: ") + new_section = input("New config section name: ") config[new_section] = config[section] if backup: config[section] = backup diff --git a/calendar_cli/legacy.py b/calendar_cli/legacy.py index 3bd2069..3ffcd88 100755 --- a/calendar_cli/legacy.py +++ b/calendar_cli/legacy.py @@ -21,7 +21,6 @@ #except: # from backports import zoneinfo import pytz -import time from datetime import datetime, timedelta, date from datetime import time as time_ import dateutil.parser @@ -31,16 +30,29 @@ import vobject import caldav import uuid -import json import os import logging import sys import re -from getpass import getpass from six import PY3 from calendar_cli.metadata import metadata -__version__ = metadata["version"] + +## The version is derived from git tags by hatch-vcs. Prefer the installed +## package metadata; fall back to the build-generated _version.py (present in +## built/installed trees) and finally to "unknown" when running from a bare +## source checkout that has neither. +try: + from importlib.metadata import version as _pkg_version, PackageNotFoundError + try: + __version__ = _pkg_version("calendar-cli") + except PackageNotFoundError: + try: + from calendar_cli._version import __version__ + except ImportError: + __version__ = "unknown" +except ImportError: + __version__ = "unknown" UTC = pytz.utc #UTC = zoneinfo.ZoneInfo('UTC') @@ -667,9 +679,9 @@ def todo_complete(caldav_conn, args): remaining_task.instance.vtodo.dtstart.value = next ## TODO: should be same type as dtstart (date or datetime) remaining_task.instance.vtodo.recurrence_id.params['RANGE'] = [ 'THISANDFUTURE' ] remaining_task.instance.vtodo.rrule - count_search = re.search('COUNT=(\d+)', completed_task.instance.vtodo.rrule.value) + count_search = re.search(r'COUNT=(\d+)', completed_task.instance.vtodo.rrule.value) if count_search: - remaining_task.instance.vtodo.rrule.value = re.replace('COUNT=(\d+)', 'COUNT=%d' % int(count_search.group(1))-1) + remaining_task.instance.vtodo.rrule.value = re.replace(r'COUNT=(\d+)', 'COUNT=%d' % int(count_search.group(1))-1) remaining_task.save() ## the completed task should have recurrence id set to current time @@ -724,7 +736,7 @@ def main(): conf_parser.add_argument("--interactive-config", help="Interactively ask for configuration", action="store_true") args, remaining_argv = conf_parser.parse_known_args() - conf_parser.add_argument("--version", action='version', version='%%(prog)s %s' % metadata["version"]) + conf_parser.add_argument("--version", action='version', version='%%(prog)s %s' % __version__) config = read_config(args.config_file) diff --git a/calendar_cli/metadata.py b/calendar_cli/metadata.py index 9b80be7..e8def4d 100644 --- a/calendar_cli/metadata.py +++ b/calendar_cli/metadata.py @@ -1,5 +1,7 @@ +## NOTE: the package version is no longer stored here. It is derived from +## git tags at build time by hatch-vcs and read at runtime from the installed +## package metadata (see calendar_cli/legacy.py). metadata = { - "version": "1.0.1", "author": "Tobias Brox", "author_short": "tobixen", "copyright": "Copyright 2013-2022, Tobias Brox and contributors", diff --git a/calendar_cli/template.py b/calendar_cli/template.py index 0a5e000..86a088a 100644 --- a/calendar_cli/template.py +++ b/calendar_cli/template.py @@ -5,7 +5,6 @@ googling a bit, and didn't find anything like this out there ... but I'm sure there must exist something like this?""" -import datetime import string import re diff --git a/lychee.toml b/lychee.toml new file mode 100644 index 0000000..93e567a --- /dev/null +++ b/lychee.toml @@ -0,0 +1,16 @@ +# Configuration for the lychee link checker (auto-discovered by both the +# pre-commit hook and the GitHub Action). + +# Don't try to "resolve" mailto: links. +exclude_mail = true + +# Treat these HTTP status codes as success. +accept = ["200", "206", "429"] + +# Example/placeholder hosts and other URLs that are not meant to resolve. +exclude = [ + "example\\.com", + "example\\.org", + "localhost", + "127\\.0\\.0\\.1", +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8fffba3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,80 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "calendar-cli" +dynamic = ["version"] +description = "Simple command-line CalDav client, for adding and browsing calendar items, todo list items, etc." +readme = "README.md" +requires-python = ">=3.9" +license = { text = "GPL-3.0-or-later" } +authors = [{ name = "Tobias Brox", email = "t-calendar-cli@tobixen.no" }] +maintainers = [{ name = "Tobias Brox", email = "t-calendar-cli@tobixen.no" }] +classifiers = [ + "Environment :: Web Environment", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: POSIX", + "Programming Language :: Python", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries", +] +dependencies = [ + "icalendar", + "caldav>=0.12-dev0", + "pytz", # pytz is supposed to be obsoleted, but see https://github.com/collective/icalendar/issues/333 + "pyyaml", + "tzlocal", + "Click", + "six", + "vobject", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "ruff>=0.15", "pre-commit>=4.0"] + +[project.scripts] +calendar-cli = "calendar_cli.legacy:main" + +[project.urls] +Repository = "https://github.com/tobixen/calendar-cli" +Issues = "https://github.com/tobixen/calendar-cli/issues" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "calendar_cli/_version.py" + +[tool.hatch.build] +# Never ship editor backups / autosave files even if they linger in the tree. +exclude = [ + "*~", + "#*#", + ".#*", + "*.orig", + "*.bak", +] + +[tool.hatch.build.targets.wheel] +packages = ["calendar_cli"] + +[tool.hatch.build.targets.sdist] +include = ["calendar_cli", "bin", "README.md", "LICENSE.TXT"] + +[tool.ruff] +# calendar-cli is a deliberately "legacy / long term support" codebase, so we +# do not enforce stylistic rules (line length, etc.) that would force a large +# reformat. Ruff is scoped to catching genuine errors. +line-length = 120 +target-version = "py39" + +[tool.ruff.lint] +select = ["F", "E9", "W605"] +# F841 (assigned-but-never-used) flags some legacy local variables we don't +# want to rewrite blindly; revisit when the surrounding code is refactored. +ignore = ["F841"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] diff --git a/setup.py b/setup.py deleted file mode 100644 index 64986c0..0000000 --- a/setup.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python - -import sys -import os - -from setuptools import setup, find_packages - -from calendar_cli.metadata import metadata -metadata_ = metadata.copy() - -for x in metadata: - if not x in ('author', 'version', 'license', 'maintainer', 'author_email', - 'status', 'name', 'description', 'url', 'description'): - metadata_.pop(x) - -setup( - packages=['calendar_cli', - ], - classifiers=[ - #"Development Status :: ..." - "Environment :: Web Environment", - #"Intended Audience :: Developers", - "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", - "Operating System :: POSIX", - "Programming Language :: Python", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries", - ], - scripts=['bin/calendar-cli.py', 'bin/calendar-cli'], - install_requires=[ - 'icalendar', - 'caldav>=0.12-dev0', -# 'isodate', - 'pytz', ## pytz is supposed to be obsoleted, but see https://github.com/collective/icalendar/issues/333 - 'pyyaml', - 'tzlocal', - 'Click', - 'six', - 'vobject' - ], - **metadata_ -) diff --git a/tests/_setup_alias b/tests/_setup_alias index ab47954..50b26b9 100644 --- a/tests/_setup_alias +++ b/tests/_setup_alias @@ -12,20 +12,29 @@ error() { } find_executable_to_test() { - local candidate + local candidate root + ## Prefer the installed console-script entry point (created by + ## `pip install` / `make install`); it needs no PYTHONPATH. + if command -v calendar-cli >/dev/null 2>&1; then + echo "calendar-cli" + return 0 + fi + + ## Otherwise run from a source checkout via bin/calendar-cli.py, wrapping it + ## with PYTHONPATH pointing at the repo root so `import calendar_cli` works. for candidate in \ - './calendar-cli.py' \ - '../calendar-cli.py' \ - './bin/calendar-cli.py' + './bin/calendar-cli.py' \ + '../bin/calendar-cli.py' do if [ -x "$candidate" ]; then - echo "$candidate" + root=$(dirname "$(dirname "$candidate")") + echo "env PYTHONPATH=$root:$PYTHONPATH $candidate" return 0 fi done - error "couldn't find ./calendar_cli.py nor ../calendar_cli.py" + error "couldn't find an installed 'calendar-cli' nor bin/calendar-cli.py" } set_test_command() { diff --git a/tests/test_cal.py b/tests/test_cal.py index 200abec..0f989a0 100644 --- a/tests/test_cal.py +++ b/tests/test_cal.py @@ -1,8 +1,7 @@ -import pytest import sys sys.path.insert(0,'.') sys.path.insert(1,'..') -from datetime import datetime, date +from datetime import date from calendar_cli.template import Template """calendar-cli is a command line utility, and it's an explicit design diff --git a/tests/test_calendar-cli.sh b/tests/test_calendar-cli.sh index 8a34d51..d9615c8 100755 --- a/tests/test_calendar-cli.sh +++ b/tests/test_calendar-cli.sh @@ -4,6 +4,22 @@ storage=$(mktemp -d) +## Radicale (like most real servers) requires authentication; set up an +## htpasswd file so the hard-coded testuser/password1 used below is accepted. +htpasswd_file="$storage/htpasswd" +printf 'testuser:password1\n' > "$htpasswd_file" + +## Always tear the test servers down, even if the script is interrupted - +## otherwise an orphaned server keeps holding its port and breaks the next run. +radicale_pid="" +xandikos_pid="" +cleanup() { + [ -n "$radicale_pid" ] && kill "$radicale_pid" 2>/dev/null + [ -n "$xandikos_pid" ] && kill "$xandikos_pid" 2>/dev/null + rm -rf "$storage" +} +trap cleanup EXIT + echo "This script will attempt to set up a Radicale server and a Xandikos server and run the test code towards those two servers" echo "The test code itself is found in tests.sh" @@ -12,11 +28,15 @@ export RUNTESTSNOPAUSE="foo" echo "########################################################################" echo "## RADICALE" echo "########################################################################" -sh -c "$PYTHON3 -m radicale --storage-filesystem-folder='$storage' \ - ${DAEMONS_OUTPUT_TO_FILES:+>$storage/radicale.stdout} \ - ${DAEMONS_OUTPUT_TO_FILES:+2>$storage/radicale.stderr}" & +## Launch radicale directly in the background (not via "sh -c ... &") so that +## $! is radicale's own pid and the kill below actually stops it. +rad_out=/dev/stdout; rad_err=/dev/stderr +[ -n "$DAEMONS_OUTPUT_TO_FILES" ] && { rad_out="$storage/radicale.stdout"; rad_err="$storage/radicale.stderr"; } +"$PYTHON3" -m radicale --storage-filesystem-folder="$storage" \ + --auth-type htpasswd --auth-htpasswd-filename "$htpasswd_file" --auth-htpasswd-encryption plain \ + >"$rad_out" 2>"$rad_err" & radicale_pid=$! -sleep 0.3 +sleep 0.5 if [ -n "$radicale_pid" ]; then echo "## Radicale now running on pid $radicale_pid" calendar_cli="env PYTHONPATH=..:$PYTHONPATH $( printf "%s%s%s%s" '../bin/calendar-cli.py ' \ @@ -38,7 +58,8 @@ if [ -n "$radicale_pid" ]; then echo "press enter to take down test server" read -r fi - kill "$radicale_pid" + kill "$radicale_pid" 2>/dev/null + radicale_pid="" sleep 0.3 else echo "## Could not start up radicale (is it installed?). Will skip running tests towards radicale" @@ -50,19 +71,21 @@ echo "## XANDIKOS" echo "########################################################################" xandikos_bin=$(which xandikos 2> /dev/null) if [ -n "$xandikos_bin" ]; then - sh -c "$xandikos_bin --defaults -d '$storage' \ - ${DAEMONS_OUTPUT_TO_FILES:+>$storage/xandikos.stdout} \ - ${DAEMONS_OUTPUT_TO_FILES:+2>$storage/xandikos.stderr}" & + ## Launch directly in the background so $! is xandikos' own pid. + xan_out=/dev/stdout; xan_err=/dev/stderr + [ -n "$DAEMONS_OUTPUT_TO_FILES" ] && { xan_out="$storage/xandikos.stdout"; xan_err="$storage/xandikos.stderr"; } + "$xandikos_bin" --defaults -d "$storage" >"$xan_out" 2>"$xan_err" & xandikos_pid=$! sleep 0.5 fi if [ -n "$xandikos_pid" ]; then echo "## Xandikos now running on pid $xandikos_pid" - calendar_cli="../bin/calendar-cli --caldav-url=http://localhost:8080/ --caldav-user=user" + calendar_cli="env PYTHONPATH=..:$PYTHONPATH ../bin/calendar-cli.py --caldav-url=http://localhost:8080/ --caldav-user=user" ./tests.sh "$calendar_cli" - kill "$xandikos_pid" + kill "$xandikos_pid" 2>/dev/null + xandikos_pid="" else echo "## Could not start up xandikos (is it installed?). Will skip running tests towards xandikos" fi diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..eeef5a7 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,48 @@ +"""Tests for calendar_cli.config. + +interactive_config() historically carried a "untested code ahead" warning and +was in fact broken on Python 3 (it referenced the Python 2 raw_input builtin +and used os/time/getpass without importing them). These tests exercise the +save path so that breakage cannot silently return. +""" +from types import SimpleNamespace +from unittest import mock + +from calendar_cli.config import config_section, interactive_config, read_config + + +def _fake_input(prompt=""): + """Answer the command prompt with 'save', every other prompt with a value.""" + if "command" in prompt.lower(): + return "save" + return "somevalue" + + +class TestInteractiveConfig: + def test_saves_config_to_disk(self, tmp_path): + cfg_file = tmp_path / "calendar.conf" + args = SimpleNamespace(config_section="default", config_file=str(cfg_file)) + + with mock.patch("builtins.input", side_effect=_fake_input), \ + mock.patch("calendar_cli.config.getpass", return_value="secretpass"): + result = interactive_config(args, {}, []) + + assert cfg_file.exists() + written = read_config(str(cfg_file)) + assert written["default"]["caldav_url"] == "somevalue" + # the password comes via getpass(), not input() + assert written["default"]["caldav_pass"] == "secretpass" + # the returned config matches what was persisted + assert result["default"]["caldav_url"] == "somevalue" + + +class TestConfigSection: + def test_inheritance(self): + config = { + "base": {"caldav_url": "https://example.com", "caldav_user": "base"}, + "child": {"inherits": "base", "caldav_user": "child"}, + } + section = config_section(config, "child") + # inherited value is present, own value overrides the inherited one + assert section["caldav_url"] == "https://example.com" + assert section["caldav_user"] == "child"