From 326fc326a895aabcbd1e18f49c06e30f7e909a20 Mon Sep 17 00:00:00 2001 From: Brett Porter <49108+brettporter@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:39:58 +1000 Subject: [PATCH] fix(hybrid): resolve system timezone through /usr/share/zoneinfo.default _get_local_tz() derived the IANA timezone name by resolving /etc/localtime and splitting on the literal substring "zoneinfo/". On a clean macOS install, the symlink chain can resolve to /usr/share/zoneinfo.default instead of /usr/share/zoneinfo, so the split silently no-ops and the full absolute path gets passed to ZoneInfo(), which rejects it with "ZoneInfo keys may not be absolute paths". Since this runs at import time, it broke the server (and every test importing HybridRepository) on affected machines. Use a regex that strips either zoneinfo variant so the IANA name resolves correctly regardless of which directory the symlink chain lands on. Co-Authored-By: Claude Sonnet 5 --- .../repository/hybrid/hybrid.py | 9 ++++-- tests/test_hybrid_repository.py | 31 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/omnifocus_operator/repository/hybrid/hybrid.py b/src/omnifocus_operator/repository/hybrid/hybrid.py index 10b1a2e6..4f241bc0 100644 --- a/src/omnifocus_operator/repository/hybrid/hybrid.py +++ b/src/omnifocus_operator/repository/hybrid/hybrid.py @@ -104,9 +104,14 @@ def _get_local_tz() -> ZoneInfo: - """Get system timezone as ZoneInfo from /etc/localtime symlink (macOS).""" + """Get system timezone as ZoneInfo from /etc/localtime symlink (macOS). + + /etc/localtime resolves through /var/db/timezone/zoneinfo, but fully + resolving symlinks can land on /usr/share/zoneinfo.default instead of + /usr/share/zoneinfo depending on macOS build, so match either variant. + """ tz_path = pathlib.Path("/etc/localtime").resolve() - tz_name = str(tz_path).split("zoneinfo/")[-1] + tz_name = re.sub(r".*/zoneinfo(?:\.default)?/", "", str(tz_path)) return ZoneInfo(tz_name) diff --git a/tests/test_hybrid_repository.py b/tests/test_hybrid_repository.py index 8bd66c7d..49ca102c 100644 --- a/tests/test_hybrid_repository.py +++ b/tests/test_hybrid_repository.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import pathlib import plistlib import sqlite3 import time @@ -32,7 +33,11 @@ from omnifocus_operator.contracts.use_cases.list.tasks import ListTasksRepoQuery from omnifocus_operator.models.enums import Availability, FolderAvailability, TagAvailability from omnifocus_operator.models.snapshot import AllEntities -from omnifocus_operator.repository.hybrid.hybrid import _FRESHNESS_TIMEOUT, HybridRepository +from omnifocus_operator.repository.hybrid.hybrid import ( + _FRESHNESS_TIMEOUT, + HybridRepository, + _get_local_tz, +) from tests.doubles import InMemoryBridge, StubBridge # Core Foundation epoch: Jan 1, 2001 00:00:00 UTC @@ -1924,6 +1929,30 @@ async def test_project_reads_plain_text_note(self, hybrid_repo: HybridRepository # ============================================================================ +class TestGetLocalTz: + """_get_local_tz() derives an IANA ZoneInfo name from the /etc/localtime symlink.""" + + @pytest.mark.parametrize( + "resolved_path", + [ + "/usr/share/zoneinfo/Australia/Sydney", + "/usr/share/zoneinfo.default/Australia/Sydney", + "/var/db/timezone/zoneinfo/Australia/Sydney", + ], + ) + def test_resolves_tz_name_across_symlink_variants(self, resolved_path: str) -> None: + """Regression: some macOS builds resolve /etc/localtime through + /usr/share/zoneinfo.default rather than /usr/share/zoneinfo, which used to + break naive `.split("zoneinfo/")` parsing and raise + `ZoneInfo keys may not be absolute paths`. + """ + with patch( + "pathlib.Path.resolve", + return_value=pathlib.PosixPath(resolved_path), + ): + assert _get_local_tz() == ZoneInfo("Australia/Sydney") + + class TestLocalDatetimeParsing: """dateDue, dateToStart, datePlanned should be parsed as local time with DST."""