Harden portable Redmine CLI runtime and cache writes

This commit is contained in:
Jason Thistlethwaite
2026-10-18 03:18:36 -04:00
parent f6915face2
commit 2e686215ed
19 changed files with 1353 additions and 224 deletions
+42 -24
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import argparse
import json
from functools import wraps
from redmine_cache_io import CacheError, cache_lock, atomic_write_json
import os
import re
import subprocess
@@ -497,28 +499,29 @@ def main() -> int:
try:
if args.command == "fetch":
client = client_from_args(args)
contacts = client.fetch_contacts(limit=args.limit)
filtered_contacts = filter_contacts_from_args(contacts, args)
cache_path = resolve_cache_path(args)
effective_cache_mode = resolve_fetch_cache_mode(args)
with cache_lock(resolve_cache_path(args)):
client = client_from_args(args)
contacts = client.fetch_contacts(limit=args.limit)
filtered_contacts = filter_contacts_from_args(contacts, args)
cache_path = resolve_cache_path(args)
effective_cache_mode = resolve_fetch_cache_mode(args)
cache_contacts = filtered_contacts if effective_cache_mode == "filtered" else contacts
write_cache(cache_path, args, cache_contacts)
cache_contacts = filtered_contacts if effective_cache_mode == "filtered" else contacts
write_cache(cache_path, args, cache_contacts)
if effective_cache_mode == "filtered":
print(
f"Cached {len(filtered_contacts)} filtered contacts "
f"(from {len(contacts)} fetched) in {cache_path}"
)
elif has_contact_filters(args):
print(
f"Cached {len(contacts)} contacts in {cache_path}. "
f"{len(filtered_contacts)} contacts currently match the supplied filters."
)
else:
print(f"Cached {len(contacts)} contacts in {cache_path}")
return 0
if effective_cache_mode == "filtered":
print(
f"Cached {len(filtered_contacts)} filtered contacts "
f"(from {len(contacts)} fetched) in {cache_path}"
)
elif has_contact_filters(args):
print(
f"Cached {len(contacts)} contacts in {cache_path}. "
f"{len(filtered_contacts)} contacts currently match the supplied filters."
)
else:
print(f"Cached {len(contacts)} contacts in {cache_path}")
return 0
if args.command == "search":
contacts = load_or_refresh(args)
@@ -627,7 +630,7 @@ def main() -> int:
client = client_from_args(args)
print_json(client.helpdesk_timeline(args.contact_id, args.limit))
return 0
except RedmineError as exc:
except (RedmineError, CacheError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
@@ -683,6 +686,21 @@ def add_contact_filter_args(parser: argparse.ArgumentParser) -> None:
)
def snapshot_transaction(fn):
@wraps(fn)
def wrapped(args, *a, **kw):
path = resolve_cache_path(args)
refresh = bool(getattr(args, "refresh", False) or getattr(args, "refresh_cache", False))
if refresh or not path.exists():
# Includes fetch and replacement; serializing replacement alone lets
# a slower old fetch clobber the newer snapshot.
with cache_lock(path):
return fn(args, *a, **kw)
return fn(args, *a, **kw)
return wrapped
@snapshot_transaction
def load_or_refresh(args: argparse.Namespace) -> list[dict[str, Any]]:
cache_path = resolve_cache_path(args)
if args.refresh or not cache_path.exists():
@@ -986,6 +1004,7 @@ def read_cache_payload(cache_path: Path, args: argparse.Namespace) -> dict[str,
return data
@snapshot_transaction
def resolve_contact_from_args(args: argparse.Namespace) -> dict[str, Any]:
source = args.source
cache_path = resolve_cache_path(args)
@@ -1026,6 +1045,7 @@ def has_update_changes(args: argparse.Namespace) -> bool:
)
@snapshot_transaction
def load_contacts_for_batch(args: argparse.Namespace) -> list[dict[str, Any]]:
cache_path = resolve_cache_path(args)
@@ -1304,9 +1324,7 @@ def write_cache(cache_path: Path, args: argparse.Namespace, contacts: list[dict[
"cached_at": int(time.time()),
"contacts": contacts,
}
with cache_path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")
atomic_write_json(cache_path, payload)
def resolve_cache_path(args: argparse.Namespace) -> Path: