fix(python): derive DateTime "O" format from the value, not the host timezone - #4869
Merged
Merged
Conversation
…timezone
The round-trip specifier is meant to identify the same value on the way
back and to depend only on that value. On Python it depended on the
machine's timezone instead:
DateTime(2026, 8, 3, 14, 30, 15).ToString("O")
// .NET : "2026-08-03T14:30:15.0000000"
// Python : "2026-08-03T14:30:15.000+02:00"
`astimezone()` on the naive datetime backing Kind = Unspecified assumes
local time and attaches the local offset, so the same value printed
differently on a developer machine and on CI.
The DateTimeOffset path was worse: it appended a literal "Z" to the
offset-local wall clock, discarding the offset and naming a different
instant. A Local DateTime hit the same path.
Format the three kinds from the value alone — no designator for
Unspecified, "Z" for Utc, and the value's own offset for Local — and use
a constant 7-digit fraction on every path (it was 3 on one and 6 on the
other). A DateTimeOffset keeps a numeric offset even at +00:00, which is
what distinguishes it from Kind = Utc.
Also fixes a crash on year < 1000, where astimezone() could underflow
past year 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Python Type Checking Results (Pyright)
Excluded files with errors (4 files)These files have known type errors and are excluded from CI. Remove from
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
"O"is the round-trip specifier: its contract is that the string identifies the same value on the way back, and that it depends only on the value. On Python it depended on the machine's timezone.astimezone()on the naive datetime backingKind = Unspecifiedassumes local time and attaches the local offset, so the same value printed differently on a developer machine and on CI.Investigating turned up three more failures on the same path:
Unspecified…15.000+02:00…15.0000000Utc…15.000000Z…15.0000000ZLocal…15.000000Z…15.0000000+02:00DateTimeOffset(+02:00)…15.000000Z…15.0000000+02:00DateTimeOffset(-05:30)…15.000000Z…15.0000000-05:30ValueErrorcrash0001-01-01T00:00:00.0000000The
LocalandDateTimeOffsetrows are the serious ones: the old code appended a literalZto the offset-local wall clock, so the string named a different instant than the value — silent corruption rather than cosmetic drift. The fractional width was also inconsistent between the two paths (3 digits on one, 6 on the other; .NET always emits 7).Fix
A single
_to_roundtrip_stringindate.py, used by both thewith_kindandwith_offsetdispatchers. The zone designator comes from the value alone — none forUnspecified,ZforUtc, the value's own offset forLocal— and the fraction is always 7 digits, padding the seventh rather than truncating.Fields are formatted directly instead of via
strftime, which does not zero-pad years < 1000 on glibc.One wrinkle:
timezone(timedelta(0))isdatetime.UTCin CPython, so aDateTimeOffsetat+00:00cannot be told apart fromKind = Utcbytzinfoalone — yet .NET renders them differently (+00:00vsZ). Hence theis_offset_valuemarker onDateTimeOffset, checked by attribute rather thanisinstancebecausedate_offsetimportsdateand importing back would tripreportImportCycles: true.Verification
TZ=Europe/Oslo.dotnet fsirather than assumed; Python now matches literally.Europe/Oslo,UTCandAmerica/New_York.Kindsurvives only forUnspecified— confirmed correct, .NET's defaultDateTime.Parseconverts bothZand+hh:mmtoLocal.The existing Utc test worked around the 6-digit output with
str.Replace("0000000Z", "000000Z"); that is now an exact literal, so it actually pins the format.Notes
Found while adding
DateTimesupport to Fable.TypedJson, which guarantees a value serializes identically across Beam, Python, JS and .NET."O"was the one construct that broke the guarantee.Two related items deliberately left out of scope:
TimeSpanrenders as raw ticks under%A/%O/str()(72000000000instead of02:00:00), while.ToString()is correct —printflives in the Rust core and falls back to Python'sstr(), whichTimeSpan(int)does not override. The obvious fix (__str__/__format__) is blocked onTimeOnlybeing the same Python type, so one__str__cannot serve both (.NET wants02:00:00vs14:30). Needs a design call.format_roundtripemits no designator forKind = Local(fable_date.erl:435-444) where .NET emits+02:00. Beam'sDateTimeOffsetpath is already correct.🤖 Generated with Claude Code