Resolve globally unique TZIDs with a vendor prefix (#313) - #1479
Conversation
A "globally unique" TZID (RFC 5545, section 3.2.19) begins with a "/" and is emitted by clients such as libical/Evolution as "/<vendor>/<Olson/Name>", e.g. "/freeassociation.sourceforge.net/Europe/Berlin". TZP.timezone() did not recognise this form: clean_timezone_id only strips leading/trailing slashes, so the vendor-prefixed id matched no provider, no Windows alias and no cache entry, and the value was parsed as timezone-naive - silently dropping the offset. Add a fallback that, for an id beginning with "/", strips the vendor prefix and resolves the trailing Olson identifier, trying the longest suffix first so multi-part names like "America/Argentina/Buenos_Aires" still match. Unknown ids continue to return None. Adds a regression test (run under both the zoneinfo and pytz providers) and a news fragment.
c1ea67d to
781eeda
Compare
niccokunzmann
left a comment
There was a problem hiding this comment.
Thanks for the PR! This is really nice. I suggested some changes and you seem to touch code that has grown over time and can use a bit of clean up so that your fix is made best use of!
…ator
Replace the grown lookup chain and the separate _timezone_from_global_id
fallback in TZP.timezone() with _lookup_ids, a documented generator that
yields candidate ids in order (cleaned -> Windows/Olson -> globally-unique
suffixes -> original). Each candidate checks the cache before the provider,
and the first match is cached under the primary id for a fast next lookup.
This folds the globally-unique TZID resolution into the normal path instead
of bolting it on at the end.
Per review, resolve a vendor-prefixed zone without emitting a warning.
Tests: add a calendars/ .ics fixture with three events using different
globally-unique TZID formats (libical/Evolution, Mozilla Lightning, a
libical Tzfile multipart name), and merge the resolution cases into one
("tzid", "resolved_tzid") parametrised test covering a single-component
zone (UTC), a multi-part zone, a plain id, and an unknown id (-> None).
* Fix Mexico Mountain timezone mapping ♜ Use the current CLDR target America/Mazatlan for Mountain Standard Time (Mexico) and cover its July 2026 UTC-7 offset. AI disclosure: OpenAI GPT-5.6 Sol (openai/gpt-5.6-sol), via Rook, was used for the initial investigation and handoff and to research current CLDR data, identify overlap with PR #1479, and draft issue #1546, the implementation, regression test, news fragment, and PR text. I reviewed the source and output and validated the full test suite locally. Co-authored-by: Rook <rook@rook.is> * Use concise AI disclosure in changelog ♜ Keep the required news-fragment disclosure generic while preserving the user-facing timezone fix summary and attribution. AI disclosure: OpenAI GPT-5.6 Sol (openai/gpt-5.6-sol), via Rook, was used to make and verify this requested wording change. Co-authored-by: Rook <rook@rook.is> --------- Co-authored-by: Rob Hannay <609062+RobHannay@users.noreply.github.com> Co-authored-by: Rook <rook@rook.is> Co-authored-by: Steve Piercy <web@stevepiercy.com>
niccokunzmann
left a comment
There was a problem hiding this comment.
Yay! I was looking a while through the interaction of caching and this method. It is really nice. I love it. It seems to work and as far as I see, there is no issue.
|
@niccokunzmann auto-merge is blocked because you left unresolved conversations. OK to merge? |
stevepiercy
left a comment
There was a problem hiding this comment.
Some grammar and spelling tweaks. ID is capitalized, whereas id is a Latin term used in psychology.
| if tz_id in WINDOWS_TO_OLSON: | ||
| tz = self.__provider.timezone(WINDOWS_TO_OLSON[tz_id]) | ||
| return tz or self.__provider.timezone(_unclean_id) or self.__tz_cache.get(tz_id) | ||
| """Return a timezone with an id or None if we cannot find it. |
There was a problem hiding this comment.
| """Return a timezone with an id or None if we cannot find it. | |
| """Return a timezone with an ID or ``None`` if we can't find it. |
| timezone name, or a "globally unique" identifier | ||
| (:rfc:`5545#section-3.2.19`) such as | ||
| ``/freeassociation.sourceforge.net/Europe/Berlin``. We try the | ||
| candidate ids from :meth:`_lookup_ids` in order, checking the cache |
There was a problem hiding this comment.
| candidate ids from :meth:`_lookup_ids` in order, checking the cache | |
| candidate IDs from :meth:`_lookup_ids` in order, checking the cache |
| ``/freeassociation.sourceforge.net/Europe/Berlin``. We try the | ||
| candidate ids from :meth:`_lookup_ids` in order, checking the cache | ||
| before the provider for each one, and cache the first match under the | ||
| primary id so the next lookup is fast. |
There was a problem hiding this comment.
| primary id so the next lookup is fast. | |
| primary ID so the next lookup is fast. |
| return None | ||
|
|
||
| def _lookup_ids(self, tz_id: str) -> Iterator[str]: | ||
| """Yield the ids to try when resolving ``tz_id``, best match first. |
There was a problem hiding this comment.
| """Yield the ids to try when resolving ``tz_id``, best match first. | |
| """Yield the IDs to try when resolving ``tz_id``, best match first. |
| 1. the cleaned id, without any surrounding ``/`` | ||
| 2. the Olson name of a Windows timezone (e.g. | ||
| ``W. Europe Standard Time`` -> ``Europe/Berlin``) | ||
| 3. for a "globally unique" TZID (:rfc:`5545#section-3.2.19`) of the | ||
| form ``/<vendor>/<Olson/Name>`` -- emitted by clients such as | ||
| libical, Evolution and Mozilla Lightning -- the trailing Olson | ||
| identifier, dropping vendor path components from the front. The | ||
| longest suffix is tried first so multi-part names such as | ||
| ``America/Argentina/Buenos_Aires`` still match. | ||
| 4. the original, unmodified id |
There was a problem hiding this comment.
| 1. the cleaned id, without any surrounding ``/`` | |
| 2. the Olson name of a Windows timezone (e.g. | |
| ``W. Europe Standard Time`` -> ``Europe/Berlin``) | |
| 3. for a "globally unique" TZID (:rfc:`5545#section-3.2.19`) of the | |
| form ``/<vendor>/<Olson/Name>`` -- emitted by clients such as | |
| libical, Evolution and Mozilla Lightning -- the trailing Olson | |
| identifier, dropping vendor path components from the front. The | |
| longest suffix is tried first so multi-part names such as | |
| ``America/Argentina/Buenos_Aires`` still match. | |
| 4. the original, unmodified id | |
| 1. The cleaned ID, without any surrounding ``/``. | |
| 2. The Olson name of a Windows timezone (for example, | |
| ``W. Europe Standard Time`` -> ``Europe/Berlin``). | |
| 3. For a "globally unique" TZID (:rfc:`5545#section-3.2.19`) of the | |
| form ``/<vendor>/<Olson/Name>``—emitted by clients such as | |
| libical, Evolution and Mozilla Lightning—the trailing Olson | |
| identifier, dropping vendor path components from the front. The | |
| longest suffix is tried first, so multi-part names such as | |
| ``America/Argentina/Buenos_Aires`` still match. | |
| 4. The original, unmodified ID. |
| @@ -0,0 +1 @@ | |||
| Resolve the timezone of a "globally unique" :rfc:`5545#section-3.2.19` ``TZID`` such as ``/freeassociation.sourceforge.net/Europe/Berlin`` (emitted by clients like libical and Evolution). :meth:`TZP.timezone <icalendar.timezone.tzp.TZP.timezone>` now strips the leading vendor prefix and resolves the trailing Olson identifier, so the value is parsed as timezone-aware instead of being silently treated as naive. Prepared with AI assistance. @vjsai | |||
There was a problem hiding this comment.
| Resolve the timezone of a "globally unique" :rfc:`5545#section-3.2.19` ``TZID`` such as ``/freeassociation.sourceforge.net/Europe/Berlin`` (emitted by clients like libical and Evolution). :meth:`TZP.timezone <icalendar.timezone.tzp.TZP.timezone>` now strips the leading vendor prefix and resolves the trailing Olson identifier, so the value is parsed as timezone-aware instead of being silently treated as naive. Prepared with AI assistance. @vjsai | |
| Resolve the timezone of a "globally unique" :rfc:`5545#section-3.2.19` ``TZID`` such as ``/freeassociation.sourceforge.net/Europe/Berlin``, which is emitted by some clients, including libical and Evolution. :meth:`TZP.timezone <icalendar.timezone.tzp.TZP.timezone>` now strips the leading vendor prefix and resolves the trailing Olson identifier, so the value is parsed as timezone-aware instead of being silently treated as naive. Prepared with AI assistance. @vjsai |
|
@vjsai Thanks for your contribution! It is technically sound and we would like to merge it. @stevepiercy has suggested edits to the documentation. When they are merged, we will merge this. Could you have a look? |
|
@niccokunzmann feel free to apply the suggestions in a batch change, since it's been 10 days since I made the suggestions. Depending on your default GitHub UI choice, you can go to the |
Linked issue
Description
A globally unique
TZID(per RFC 5545 §3.2.19) begins with a/and is emitted by clients such as libical and Evolution in the form/<vendor>/<Olson/Name>— for example:TZP.timezone()did not recognise this form.clean_timezone_id()only strips leading/trailing slashes, so/freeassociation.sourceforge.net/Europe/Berlinbecamefreeassociation.sourceforge.net/Europe/Berlin, which matches no provider zone, noWINDOWS_TO_OLSONalias and no cached component.timezone()therefore returnedNone, and the datetime was parsed as timezone-naive — silently dropping the offset (the original bug report in #313).Fix
When the existing lookups fail and the id begins with
/, strip the vendor prefix and resolve the trailing Olson identifier, trying the longest suffix first so multi-part names likeAmerica/Argentina/Buenos_Airesstill match. Unknown ids continue to returnNone, so there are no false positives, and non-prefixed ids are untouched.Before / after for the reported value:
dt.tzinfodt.utcoffset()NoneNoneEurope/Berlin2:00:00(CEST)Tests
New regression test
test_issue_313_globally_unique_tzid.py, parametrised over both thezoneinfoandpytzproviders, covering: the reported value parses tz-aware toEurope/Berlin; an extra vendor path component (/citadel.org/20190914_1/Europe/Berlin); a multi-part Olson name behind a prefix; and an unknown suffix still resolving toNone. The full timezone/parsing suite passes (8172 passed), andruff check/ruff formatare clean.Checklist
/news(news/313.bugfix).Additional information
This change was prepared with AI assistance (Claude Code,
claude-opus-4-8); the news fragment notes this in line with the project's existing convention. I have reviewed and understand the change and howTZP.timezone()feeds the datetime/time/date parsing paths.