From 28ce59ce805fdab7a5017076f2a32ee0727b9aba Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Wed, 24 Jun 2026 15:08:10 +0200 Subject: [PATCH 1/5] build: derive version from git tags via hatch-vcs Migrate packaging from setup.py to a PEP 621 pyproject.toml using the hatchling build backend with hatch-vcs, so the version is derived from git tags instead of being hardcoded. Addresses one of the root cause behind https://github.com/pycalendar/calendar-cli/issues/117: the version in calendar_cli/metadata.py had drifted from the real releases (stuck at 1.0.1 while PyPI shipped 1.0.2). - Add pyproject.toml (hatchling + hatch-vcs); version-file calendar_cli/_version.py, console-script entry point 'calendar-cli'. - Drop the hardcoded 'version' key from calendar_cli/metadata.py. - legacy.py: resolve __version__ from importlib.metadata, falling back to _version.py and then 'unknown' for bare source checkouts. - Remove setup.py; gitignore the generated _version.py and editor backups. - Exclude editor backup files (*~, #*#, ...) from built artifacts. Prompt: Have a look into https://github.com/pycalendar/calendar-cli/issues/117 (...) we should use autoversioning and hatch Co-Authored-By: Claude Opus 4.8 --- .gitignore | 16 +++++++++++ calendar_cli/legacy.py | 19 +++++++++++-- calendar_cli/metadata.py | 4 ++- pyproject.toml | 60 ++++++++++++++++++++++++++++++++++++++++ setup.py | 42 ---------------------------- 5 files changed, 96 insertions(+), 45 deletions(-) create mode 100644 .gitignore create mode 100644 pyproject.toml delete mode 100644 setup.py 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/calendar_cli/legacy.py b/calendar_cli/legacy.py index 3bd2069..61b1bb4 100755 --- a/calendar_cli/legacy.py +++ b/calendar_cli/legacy.py @@ -40,7 +40,22 @@ 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') @@ -724,7 +739,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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b39d229 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[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.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"] 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_ -) From 4b500023c53ad467fafb02c760c5f6b54d0b1182 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 25 Jun 2026 10:50:08 +0200 Subject: [PATCH 2/5] ci: add ruff/pytest config, CI, PyPI publish, link-check and dev tooling Round out the project modernization on top of the hatch-vcs migration: - pyproject.toml: scope ruff to real-error lint rules (legacy/LTS code, so no wholesale reformat), add pytest config and a 'dev' optional-deps extra. - GitHub Actions: tests.yml (ruff + pytest across Python 3.9-3.14), publish.yml (PyPI trusted publishing on v* tags), linkcheck.yml (lychee). - Extend .pre-commit-config.yaml (keeping ai-prompt-auto-commit): ruff lint, standard hygiene hooks, conventional-commit check, and pre-push pytest / link-check / no-push-to-master. lychee.toml for link-check config. - Add Makefile (make install/dev/lint/test) and CONTRIBUTING.md; point the README install instructions at 'make install' instead of the removed setup.py. - Add CHANGELOG.md (Keep a Changelog). Co-Authored-By: Claude Opus 4.8 AI Prompts: claude-sonnet-4-6: Continue with the other stuff from my python modernization skill - CHANGELOG, auto-publish, etc. --- .github/workflows/linkcheck.yml | 64 +++++++++++++++++++++++++++++++++ .github/workflows/publish.yml | 37 +++++++++++++++++++ .github/workflows/tests.yml | 33 +++++++++++++++++ .pre-commit-config.yaml | 57 +++++++++++++++++++++++++++++ CHANGELOG.md | 23 ++++++++++++ CONTRIBUTING.md | 56 +++++++++++++++++++++++++++++ Makefile | 46 ++++++++++++++++++++++++ README.md | 5 +-- lychee.toml | 16 +++++++++ pyproject.toml | 20 +++++++++++ 10 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/linkcheck.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/tests.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 lychee.toml 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/.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/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 index b39d229..8fffba3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ dependencies = [ "vobject", ] +[project.optional-dependencies] +dev = ["pytest>=8.0", "ruff>=0.15", "pre-commit>=4.0"] + [project.scripts] calendar-cli = "calendar_cli.legacy:main" @@ -58,3 +61,20 @@ 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"] From 8d822564d007dbc920091cc11201b1ce6b2dd528 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 25 Jun 2026 10:51:07 +0200 Subject: [PATCH 3/5] fix: repair Python 3 breakage in interactive_config The --interactive-config code path was broken on Python 3: it called the removed Python 2 'raw_input' builtin and referenced 'os', 'time' and 'getpass' without importing them, so it crashed immediately. Import os/time/getpass, switch raw_input -> input, and add a regression test exercising the save path (the function previously had none and carried an 'untested code ahead' warning). Co-Authored-By: Claude Opus 4.8 --- calendar_cli/config.py | 13 +++++++----- tests/test_config.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 tests/test_config.py 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/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" From 284550133689defa6bd87014a2d3e164fae7921e Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 25 Jun 2026 10:54:12 +0200 Subject: [PATCH 4/5] style: drop unused imports and fix invalid regex escapes (ruff) Apply ruff's safe fixes to the legacy modules: remove unused imports (time, json, getpass in legacy.py; datetime in template.py; pytest and datetime in the test) and mark the COUNT regex literals as raw strings to silence invalid-escape-sequence warnings. Co-Authored-By: Claude Opus 4.8 --- calendar_cli/legacy.py | 7 ++----- calendar_cli/template.py | 1 - tests/test_cal.py | 3 +-- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/calendar_cli/legacy.py b/calendar_cli/legacy.py index 61b1bb4..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,12 +30,10 @@ 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 @@ -682,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 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/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 From 798f41cc04b6ceab77ecee1d871b17ae86089129 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Fri, 26 Jun 2026 13:32:18 +0200 Subject: [PATCH 5/5] test: make the integration harness work with the current packaging The shell integration harness (test_calendar-cli.sh) no longer worked out of the box: - Radicale rejected the hard-coded testuser/password1 because it was started without an auth backend; configure htpasswd auth so the tests authenticate (radicale's defaults deny anonymous access). - Servers were launched via "sh -c '... &'", so $! was the wrapper shell and the later kill orphaned the actual server, leaving it holding its port and breaking the next run. Launch them directly and add an EXIT trap that always tears them down. - The xandikos branch invoked ../bin/calendar-cli without PYTHONPATH, which now fails with ModuleNotFoundError (no setup.py installs the package into the source tree anymore); set PYTHONPATH like the radicale branch. - _setup_alias: prefer the installed 'calendar-cli' console script, and make the source-tree fallback self-contained by setting PYTHONPATH. The radicale path now runs the full tests.sh suite to completion. Xandikos remains functionally incompatible (already noted in the tests README). Co-Authored-By: Claude Opus 4.8 AI Prompts: claude-sonnet-4-6: Create an issue for the "out of scope"-task claude-sonnet-4-6: post it against pycalendar/calendar-cli claude-sonnet-4-6: Please look through the test script for integration testing, does it still work? claude-sonnet-4-6: please fix things claude-sonnet-4-6: retry --- tests/_setup_alias | 21 +++++++++++++------ tests/test_calendar-cli.sh | 43 +++++++++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 16 deletions(-) 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_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