Skip to content

Resolve globally unique TZIDs with a vendor prefix (#313) - #1479

Open
vjsai wants to merge 3 commits into
collective:mainfrom
vjsai:fix/issue-313-globally-unique-tzid
Open

Resolve globally unique TZIDs with a vendor prefix (#313)#1479
vjsai wants to merge 3 commits into
collective:mainfrom
vjsai:fix/issue-313-globally-unique-tzid

Conversation

@vjsai

@vjsai vjsai commented Jun 18, 2026

Copy link
Copy Markdown

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:

DTSTART;TZID=/freeassociation.sourceforge.net/Europe/Berlin:20200426T140000

TZP.timezone() did not recognise this form. clean_timezone_id() only strips leading/trailing slashes, so /freeassociation.sourceforge.net/Europe/Berlin became freeassociation.sourceforge.net/Europe/Berlin, which matches no provider zone, no WINDOWS_TO_OLSON alias and no cached component. timezone() therefore returned None, 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 like America/Argentina/Buenos_Aires still match. Unknown ids continue to return None, so there are no false positives, and non-prefixed ids are untouched.

Before / after for the reported value:

dt.tzinfo dt.utcoffset()
before None None
after Europe/Berlin 2:00:00 (CEST)

Tests

New regression test test_issue_313_globally_unique_tzid.py, parametrised over both the zoneinfo and pytz providers, covering: the reported value parses tz-aware to Europe/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 to None. The full timezone/parsing suite passes (8172 passed), and ruff check / ruff format are clean.

Checklist

  • I've added a change log entry to /news (news/313.bugfix).
  • I've added or updated tests if applicable.
  • I've run and ensured all tests pass locally.
  • I've added or edited documentation, both as docstrings and narrative documentation, as necessary (the new resolution path is documented in the method docstring; no narrative-docs change needed for this behavioural fix).

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 how TZP.timezone() feeds the datetime/time/date parsing paths.

@read-the-docs-community

read-the-docs-community Bot commented Jun 18, 2026

Copy link
Copy Markdown

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.
@vjsai
vjsai force-pushed the fix/issue-313-globally-unique-tzid branch from c1ea67d to 781eeda Compare June 18, 2026 21:23
Comment thread src/icalendar/timezone/tzp.py Outdated

@niccokunzmann niccokunzmann left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment thread src/icalendar/tests/test_issue_313_globally_unique_tzid.py Outdated
Comment thread src/icalendar/tests/test_issue_313_globally_unique_tzid.py Outdated
Comment thread src/icalendar/tests/test_issue_313_globally_unique_tzid.py Outdated
Comment thread src/icalendar/timezone/tzp.py Outdated
…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).
@vjsai
vjsai requested review from angatha and niccokunzmann June 22, 2026 08:42
stevepiercy added a commit that referenced this pull request Jul 14, 2026
* 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 niccokunzmann left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
niccokunzmann enabled auto-merge July 15, 2026 19:06
@stevepiercy

Copy link
Copy Markdown
Member

@niccokunzmann auto-merge is blocked because you left unresolved conversations. OK to merge?

@stevepiercy stevepiercy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Yield the ids to try when resolving ``tz_id``, best match first.
"""Yield the IDs to try when resolving ``tz_id``, best match first.

Comment on lines +165 to +174
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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 Lightningthe 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.

Comment thread news/313.bugfix
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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

@niccokunzmann

Copy link
Copy Markdown
Member

@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?

@stevepiercy

Copy link
Copy Markdown
Member

@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 Files changed tab and click Add suggestion to batch for each item, then merge them all in one commit. See https://docs.github.com/en/pull-requests/how-tos/review-pull-requests/incorporating-feedback-in-your-pull-request#applying-suggested-changes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

parsing uncommon timezone formats

4 participants