Timezone conversion is mathematically trivial, but that doesn't mean
it's obvious or easy. Details can really bite.
tzinfo supplies .utcoffset(), which made converting _to_ UTC dead
obvious. But how to convert _from_ UTC remained clear as mud. The
default .fromutc() "gets it right" (as far as is possible without a
fold/is_dst flag), but _only_ handles DST transitions that strictly
alternate between "on" (although with a DST adjustment that may change
each time) and "off" (a DST adjustment of exactly 0). Nothing fancier
than that; e.g., no base offset changes. It was amazingly annoying to
craft an efficient, correct (so far as it goes) implementation of just
that much. Even then, hand-written tzinfo implementations had to
express DST transition points in "standard time" for it to always
work, instead of in natural local wall-clock times (end-of-DST is
where that makes a difference).
As Alex noted elsewhere, unlike the hand-written .utcoffset()
implementations shown in the Python docs, most timezone sources
(chiefly Olson - zoneinfo) effectively supply a .fromutc()
implementation instead. Which makes converting from UTC dead obvious,
but - surprise ;-) - leaves how to convert _to_ UTC (how to implement
.utcoffset()) clear as mud instead.
In a zoneinfo world, referring back to Guido's diagram a local
datetime is staring at a chart with _no_ visible diagonal lines when
looking right from the Y (local) axis; they're only visible when
looking up from the X (UTC) axis. The hand-written tzinfo classes in
the Python docs had the opposite problem, but implicitly left it to
the default .fromutc() to figure out the invisible part so "the
problem" isn't apparent in the docs.
Stewart noted before that always using fixed-offset classes in pytz
effectively supplies the missing is_dst bit, but it does more than
just that: it effectively stores the datetime's current UTC offset
too. The transition charts a pytz tzinfo sees always have a single,
continuous diagonal line, visible from both axes. Easy peasy. In
return, any operation on the datetime object that creates a new
datetime but just copies the original tzinfo into the result may end
up with a tzinfo that's no longer correct (lying about the UTC offset
that's _appropriate_ for the new date and time). Hence the need to
call .normalize() all over the place. If .normalize() were applied
magically instead by Python internals, that need would go away, but
then timeline arithmetic is "the natural" result - it's unclear to me
that classic arithmetic _could_ be implemented if the result of every
relevant operation added a "convert to UTC and back again, to get the
appropriate current UTC offset" step at the end (not to mention how
much slower much code would become).
So how can .utcoffset() be computed efficiently in a zoneinfo world
using "hybrid" tzinfo classes (tzinfos that are smart enough to figure
out the appropriate offset all on their own)? It's like re-inventing
the default .fromutc() all over again, but in the other direction in a
much lumpier world.
Of course there are many ideas. Rather than drone on about them, I'd
like to put the puzzle out there in case a correct "duh - it's
obvious, you moron" reply is just waiting for an invitation - but do
note the "correct" ;-)
BTW, after 15 minutes I wasn't able to convince myself I understood
what dateutil's zoneinfo-wrapping's .utcoffset() was doing; and if I
don't understand what it's doing, there's no way I can guess whether
it's always correct. One obvious idea for a zoneinfo
exhaustive-list-of-transitions-in-UTC world: precompute another
exhaustive list of transitions, but expressed in local time (including
"fold") mapping to the correct UTC offset at each point. That could
pretty obviously work, but is essentially a way of implementing "poke
and hope" in a simple, uniform way (via binary search).
There's also that exhaustive lists of transition points is a doomed
approach over time. zoneinfo supplies them through 2037 for the
benefit of legacy clients, but they expect modern clients to use a
POSIX TZ rule (stored in version 2 tzfiles) too. pytz and dateutil
both ship with version 2 (or maybe version 3) tzfiles, but neither
goes beyond using the version 1 exhaustive-list portion of tzfiles.
So more fun is waiting there ;-)