Programming with Python › Data Formats and Pipelines › Day 95
Hands-on lab — Day 95: Dates, Times, and Time Zones
- ← Back to the Day 95 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-095-dates-times-and-time-zones/
Commands
Setup
cd labs/sections/programming-with-python/day-095-dates-times-and-time-zones
python3 --version
python3 -c "import zoneinfo; print(len(zoneinfo.available_timezones()))"
python3 -c "from zoneinfo import ZoneInfo; print(ZoneInfo('Europe/London'))" Run
bash tests/run_tests.sh
bash starter/02_check.sh
python3 examples/01_zone_database.py
python3 examples/02_odd_days.py
python3 examples/03_fold.py
python3 examples/04_sorting.py
python3 examples/05_clocks.py
python3 examples/06_resolver.py
bash starter/02_check.sh examples/07_solution.py Test
bash tests/run_tests.sh File tree
examples/01_zone_database.py examples/02_odd_days.py examples/03_fold.py examples/04_sorting.py examples/05_clocks.py examples/06_resolver.py examples/07_solution.py expected-output/clocks.txt expected-output/FIELDS.md expected-output/fold.txt expected-output/odd-days.txt expected-output/resolver.txt expected-output/sorting.txt expected-output/starter-progress.txt expected-output/test-run.txt expected-output/zone-database.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/00_brief.md starter/01_timezones.py starter/02_check.sh starter/check_exercises.py tests/run_tests.sh troubleshooting.md
Lab README
Day 095 lab — The Hour That Happened Twice
Lesson
- Lesson title: Dates, Times, and Time Zones
- Day number: 95 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-095-dates-times-and-time-zones
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-095-dates-times-and-time-zoneswhen the site is running.
Purpose
Day 95 of 365, and the day the course stops trusting a timestamp.
A nightly job charged every customer twice on 25 October and did not run at all on 29 March. The schedule entry was correct both times. Its author had written 01:30, Europe/London, and on one of those nights the local clock read 01:30 twice, an hour apart, while on the other it never read 01:30 at all.
By the end of this lab you will have reproduced every trap in that story with
real output on your own machine, and then written the thirty lines that
zoneinfo is running when it answers the question.
The sentence the whole day hangs on:
A timestamp without a time zone is not a time. It is a rumour.
The spine, in order:
- Prove your machine has an IANA zone database at all, and count what is in it — because the rules are data on your disk, not code in Python.
- Build the 23-hour day and the 25-hour day and assert their lengths.
- Find the wall-clock time that never happened and watch a round trip fail to come home.
- Produce the two distinct instants that both render as
01:30and separate them withfold. - Sort a list of ISO UTC strings as text and prove it matches chronology — then do the same with local text and watch it come out reversed.
- Measure elapsed time with
time.time()andtime.monotonic()and say which one you would trust, with the reason. - Implement the offset resolver from scratch and assert it agrees with
zoneinfoon twenty-six comparisons.
Learning objectives
By the end of this lab you will be able to:
- Show where
zoneinfogets its data, name the database, read its version, and say what happens when it is out of date. - Explain what a naive
datetimeis, why the standard library lets you make one, and what it costs. - Measure the real length of a local calendar day, and explain why subtracting two local midnights cannot do it.
- Detect a nonexistent wall-clock reading with a round trip, and an ambiguous one by the order of its two folds.
- State exactly what
foldselects in each of those two cases. - Explain why two aware datetimes an hour apart can compare equal, and what to do about it.
- Argue, rather than assert, why UTC is the storage format and local time a display concern — with a sort that proves it.
- Distinguish ISO 8601 from RFC 3339 and say which one to write.
- Name the two traps in
strptime:%Zparsing almost nothing and throwing the zone away, and the day-first/month-first ambiguity that raises no error. - Choose between wall-clock and monotonic time for a given question, and say what a wall clock does to a duration when the clocks change.
- Explain why "add one month" is not a
timedelta, and why the standard library refuses to choose the policy for you. - Implement a zone-offset resolver over a rule table, in both directions, and classify a wall reading as normal, ambiguous or nonexistent.
Prerequisites
- Day 43 — a working
python3on yourPATH. - Day 70 — floating point, which is why
timestamp()has a precision limit worth knowing. - Day 81 — scheduling and background jobs. The drift that day measured with a wall clock is the bug this day fixes properly.
- Day 91 — storing timestamps as ISO 8601 text in UTC in a database with no date type. That day used the sorting property; this day proves it and shows what breaks it.
- Comfort with the shell, and with reading a Python traceback.
Supported operating systems
| System | Status |
|---|---|
| macOS (Apple Silicon or Intel) | Captured here — macOS 26.5.2, arm64, zone database 2026c |
| Linux (any current distribution) | Expected identical, given Python 3.11+ and a system tzdata package |
| Windows | Windows ships no IANA database; zoneinfo needs the tzdata PyPI package. Use WSL and follow the Linux path. The two scripts are bash and were not run on native Windows here, so no output is claimed for it |
Hardware requirements
Anything. Every script finishes in well under a second, nothing is written to disk, and the largest data structure is a list of 598 zone names.
Required software
| Tool | Minimum | Used here | Why |
|---|---|---|---|
python3 |
3.11 | 3.14.0 | zoneinfo needs 3.9; fromisoformat accepting a trailing Z needs 3.11; fold has existed since 3.6 |
| IANA time zone database | any current release | 2026c | zoneinfo reads it; it is not part of Python |
bash |
3.2 | 3.2.57 | The two harness scripts |
Check all three in one line:
python3 --version
python3 -c "import zoneinfo; print(zoneinfo.TZPATH, len(zoneinfo.available_timezones()))"
That printed 598 zones here. A different number is fine and is itself part
of the lesson.
Free and open-source options
Everything here is free, and two of the three are unusually so.
- Python is under the PSF licence, and this lab uses only its standard
library —
datetime,zoneinfo,time,calendar. Nothing to install. - The IANA time zone database is in the public domain, maintained collaboratively, and distributed with essentially every operating system. It is one of the quietest pieces of shared infrastructure in computing.
tzdata(the PyPI package) is the same data packaged for Python, needed only on systems without a system database. Also free.- The alternatives —
dateutil,arrow,pendulum— are all free and open source, and the lesson covers what each buys. None of them is installed here and no output is reproduced for any of them.pytzis covered as history, because its unusual API exists for a reason worth understanding.
No account, no key, no paid tier, and nothing in this lab is degraded without one.
Installation
None. Change into this directory and start.
cd labs/sections/programming-with-python/day-095-dates-times-and-time-zones
python3 --version
python3 -c "from zoneinfo import ZoneInfo; print(ZoneInfo('Europe/London'))"
If the second line raises ZoneInfoNotFoundError, your machine has no zone
database where zoneinfo looks — see troubleshooting.md, which has the fix
for each platform. If python3 lives somewhere unusual, both scripts take an
override rather than guessing:
PYTHON=/path/to/python3 bash tests/run_tests.sh
File structure
day-095-dates-times-and-time-zones/
├── README.md this file
├── metadata.yml lab metadata and the recorded run
├── security.md what this lab does to your machine, and why a
│ timestamp is more often personal data than
│ people expect
├── troubleshooting.md grouped by the message you see — and by the
│ wrong answer you get with no message at all
├── requirements/
│ ├── README.md versions, the dependency that is not a package,
│ │ and what is deliberately absent
│ └── requirements.txt empty of packages, on purpose
├── starter/ YOUR work happens here
│ ├── 00_brief.md the incident, and the ten exercises
│ ├── 01_timezones.py ten numbered exercises, each with its method
│ ├── 02_check.sh "N of 10 exercises complete."
│ └── check_exercises.py the marker. Do not edit
├── examples/ the reference. Read AFTER you have tried
│ ├── 01_zone_database.py where the rules live, and that they are data
│ ├── 02_odd_days.py the 23-hour and 25-hour days, measured
│ ├── 03_fold.py the repeated hour, the skipped hour, and fold
│ ├── 04_sorting.py UTC text sorts; local text does not; formats
│ ├── 05_clocks.py wall clock against monotonic, epoch, leap seconds
│ ├── 06_resolver.py the from-scratch resolver, checked against zoneinfo
│ └── 07_solution.py the ten reference answers
├── tests/
│ └── run_tests.sh 75 checks of real values
└── expected-output/ captured from a real run on 2026-08-16
├── FIELDS.md what must match and what may differ
├── zone-database.txt the search path, version and zone count here
├── odd-days.txt 23.0h, 25.0h, and 24.0h on the wall every time
├── fold.txt two instants, one wall clock, and a job firing twice
├── sorting.txt the sorts, the formats, the strptime traps
├── clocks.txt clock properties, the epoch, leap seconds
├── resolver.txt 26 comparisons, 0 disagreements
├── starter-progress.txt 0 of 10 before, 10 of 10 with the answers
└── test-run.txt the full harness run
How to run
## 1. The whole thing. It should be green before you change anything, and
## green again when you have finished.
bash tests/run_tests.sh
echo "exit code: $?"
## 2. Read the brief. It is the incident this lab reproduces.
## starter/00_brief.md
## 3. Find out where you stand. It will say 0 of 10, and name each exercise.
bash starter/02_check.sh
## 4. Now do the work in starter/01_timezones.py, re-running step 3 as you go.
## --- everything below is the reference. Look after you have tried. ---
## 5. Does this machine have a zone database, and what is in it?
python3 examples/01_zone_database.py
## 6. The 23-hour day and the 25-hour day, measured rather than asserted.
python3 examples/02_odd_days.py
## 7. The hour that happened twice and the hour that never happened.
python3 examples/03_fold.py
## 8. Why UTC ISO text sorts correctly, and the three ways local text does not.
python3 examples/04_sorting.py
## 9. Which clock measures a duration, and what the other one does when the
## clocks change.
python3 examples/05_clocks.py
## 10. The resolver from scratch, checked against the real database.
python3 examples/06_resolver.py
## 11. The reference answers, marked by the same checker.
bash starter/02_check.sh examples/07_solution.py
What the commands do
bash tests/run_tests.sh runs 75 checks of real values: that the zone
database is present and loadable, that the two odd days measure 23 and 25
hours, that the ambiguous reading yields two offsets and two instants an hour
apart, that the nonexistent one does not survive a round trip, that a
local-01:30 schedule fires twice on one night and zero times on another, that
UTC text sorts chronologically and local text does not, that %Z rejects
BST, that a leap second is not representable, that monotonic time never goes
backwards across 20,000 samples, and that the hand-written resolver agrees
with zoneinfo on all 26 comparisons. Everything happens in a temporary
directory removed on exit.
bash starter/02_check.sh loads your starter file by path, calls each of
the ten functions with pinned inputs, and compares against values that do not
depend on when you run it. An unfinished exercise is reported as not
started, a wrong one as WRONG with what it wanted and what it got. It never
looks at how you wrote anything, with one exception: exercise 8 checks that
the file mentions time.monotonic, because the entire content of that
exercise is which clock you chose.
python3 examples/01_zone_database.py prints zoneinfo.TZPATH and which
entries exist, reads the database version from +VERSION, counts the zones,
renders one instant in five places to show that offsets are not whole hours,
and then bisects the database to find the two instants bounding a real
historical oddity — the years London spent at +01:00 through the winter.
python3 examples/02_odd_days.py measures six calendar days in three
zones, in both real elapsed time and wall-clock time, and shows the wall
column reading 24.0 on every row including the two that are not.
python3 examples/03_fold.py is the heart of it: the same wall reading at
fold=0 and fold=1, the two instants and two epoch seconds it names, the
comparison trap, the round trip that does not come home, a scheduler walked
minute by minute and firing twice, and the difference between adding a
timedelta and adding elapsed time.
python3 examples/04_sorting.py sorts the same four events three ways —
UTC text, local text, local text with offsets — and prints all three orders.
Then it renders one instant in five ISO 8601 forms, shows what
fromisoformat accepts on your Python, and demonstrates both strptime
traps.
python3 examples/05_clocks.py prints time.get_clock_info for four
clocks, measures real work with both, computes what a wall-clock stopwatch
would have reported across four real transitions, and covers epoch seconds,
the 2038 limit, float precision, and leap seconds.
python3 examples/06_resolver.py implements the resolver over a
three-line rule table and prints a 26-row comparison against zoneinfo,
ending with the two counts that must both be zero.
Expected output
The harness ends with a real captured line:
75 checks, 0 failure(s).
and exits 0. The starter reports 0 of 10 exercises complete. with exit 1
before you begin, and 10 of 10 exercises complete. with exit 0 once the
answers are in place.
The measurement the day is named after:
fold=0
local 2026-10-25T01:30:00+01:00
offset +01:00 (BST)
the UTC instant it means 2026-10-25T00:30:00+00:00
epoch seconds 1792888200
fold=1
local 2026-10-25T01:30:00+00:00
offset +00:00 (GMT)
the UTC instant it means 2026-10-25T01:30:00+00:00
epoch seconds 1792891800
They are 1:00:00 apart. Same string, same zone, different moments.
and the consequence:
the local clock read 01:30 2 times:
2026-10-25T00:30:00+00:00 UTC = 01:30 BST (fold=0)
2026-10-25T01:30:00+00:00 UTC = 01:30 GMT (fold=1)
with the mirror image on the other transition:
firings on 2026-03-29: 0
The day lengths:
zone local date real wall what happened
----------------------------------------------------------------------
Europe/London 2026-03-29 23.0h 24.0h spring forward
Europe/London 2026-10-25 25.0h 24.0h autumn back
Europe/London 2026-06-15 24.0h 24.0h an ordinary day
And the resolver, which is the exercise the day is built around:
cases: 13 wall readings x 2 folds = 26 comparisons
disagreements with zoneinfo: 0
cases classified wrongly: 0
The full captures are in expected-output/, and
expected-output/FIELDS.md says which values must match on any machine and
which are allowed to differ on yours — the zone count and the database version
being the two that legitimately will.
Validation steps
bash tests/run_tests.shends with75 checks, 0 failure(s).and exits 0.python3 -c "import zoneinfo; print(len(zoneinfo.available_timezones()))"prints a number over 100. It printed 598 here.- Europe/London on 2026-03-29 measures 23.0 hours and on 2026-10-25 25.0 hours — and subtracting the two local midnights says 24.0 on both.
2026-10-25 01:30in Europe/London gives offset +01:00 atfold=0and +00:00 atfold=1, naming two instants exactly one hour apart.- Those two aware datetimes compare equal with
==, and their UTC conversions do not. If your code sorts or deduplicates aware datetimes without converting first, this is why it is wrong. 2026-03-29 01:30in Europe/London does not survive a round trip through UTC: it comes back as02:30+01:00.- A schedule matching local
01:30fires twice on 25 October 2026 and zero times on 29 March 2026. - Four events sorted by UTC ISO text come out in true chronological order; sorted by local text they come out in the exact reverse; and sorted by local text with the offset attached they are still wrong.
%ZraisesValueErroronBST, and onUTCit succeeds and leavestzinfoasNone.datetime(2016, 12, 31, 23, 59, 60)raisesValueError.examples/06_resolver.pyexits 0 with 0 disagreements across 26 comparisons — and the suite proves that check is real by corrupting one transition date and confirming the run then fails.- After the harness finishes,
find . -type d -name __pycache__finds nothing and no file has been added to this directory.
Tests
bash tests/run_tests.sh
echo "exit code: $?"
75 checks, exit 0 when they all pass and non-zero otherwise. They are value checks: exact offsets, exact instants, exact epoch seconds, exact sort orders and exact classifications.
Two of them exist to prove the suite is not vacuous, and they are worth pointing at:
- The suite copies
examples/06_resolver.py, moves one transition date a week earlier, runs it, and requires that it fail. A resolver that agreed withzoneinfono matter what its rule table said would be checking nothing. - The suite copies
examples/07_solution.py, replacesfirst < secondwithfirst != secondinis_ambiguous— the single most likely mistake in the whole lab — and requires the marker to catch it and report9 of 10.
No check anywhere in this suite asserts a timing. Durations are asserted by shape: positive, and never decreasing across 20,000 samples. A test that asserted a millisecond figure would be flaky on somebody else's machine and would be measuring their processor rather than your code.
Overrides, if python3 is somewhere unusual:
PYTHON=/path/to/python3 bash tests/run_tests.sh
Cleanup
find . -type d -name __pycache__ -prune -exec rm -rf -- {} +
Both scripts export PYTHONDONTWRITEBYTECODE=1 and do their work inside
mktemp -d, removed in a trap, so if you only ran those there is nothing to
clean up — and the suite asserts as much before it finishes. Nothing was
installed, no database or file was created, and nothing outside this directory
was touched.
To reset your own work and start the exercises again:
git checkout -- starter/
Troubleshooting
troubleshooting.md has the full list, grouped by the message you see and —
longer, because this subject fails quietly — by the wrong answer you get with
no message at all. The ones you are most likely to meet:
ZoneInfoNotFoundError— your machine, or more likely your container image, has no IANA database. Installtzdata.ValueError: Invalid isoformat string: '...Z'— Python 3.10 or older;fromisoformatdid not accept the trailingZuntil 3.11.- Every day measures 24 hours — you subtracted two local datetimes. Convert both to UTC first.
- Two datetimes an hour apart compare equal — expected and documented:
same
tzinfomeans comparison by wall clock, andfoldis ignored. Compare in UTC. is_ambiguousis also true for the skipped hour —!=cannot separate them; the fold order can. Testfirst < second.- A duration comes out negative — you measured with a wall clock.
- The test suite passes today and fails in October — it reads the clock somewhere.
Security notes
security.md has the full account. In short: nothing here opens a socket,
runs sudo, needs a credential, or installs anything — and the suite checks
each of those rather than promising them, including that no URL appears
anywhere in the lab's scripts. In particular nothing changes your system
clock or your system time zone; every "what if the clock jumped"
demonstration computes the answer from real transition data instead.
The point worth repeating is the one that does not feel like security at all:
a precise timestamp plus a local zone is a location signal, a sequence of them
is a behavioural profile, and microsecond precision is a strong fingerprint
for correlating one person across two systems. Precision is a decision you
make at collection time, and you cannot un-collect it later. security.md
also covers the three genuine vulnerability classes that follow directly from
this lab: expiry checks on a wall clock, certificate validity windows compared
against naive datetimes, and lockout windows a clock jump can reset.
Extension exercises
- Extend the rule table backwards and find something strange. Add
Europe/London's transitions for 1968 to 1972 to
LONDON_2026and re-run your resolver againstzoneinfofor wall readings in those years. The three winters at+01:00are inexamples/01_zone_database.py; find their boundary instants by bisection, add them, and see whether your resolver still agrees. Then answer this: how many segments does your table have now, and does yourresolve_wallstill terminate correctly on the first one? - Write
add_months(instant, n, policy)and defend the policy. Implement at least two — clamp to the month end, and overflow into the next month — usingcalendar.monthrange. Then find a case where they differ by more than three days, and write a paragraph on which one a billing system should use and why. There is a defensible answer and it is not the same for every business. - Break the sort deliberately. Take the four events in
examples/04_sorting.py, store them as local text, and write the query you would need to sort them correctly anyway. Then count how many rows you have to parse to do it, and compare that with an index on a UTC text column. Write down what storing local time actually cost. - Find a zone whose offset is not a whole number of minutes. They exist,
historically. Search
zoneinfo.available_timezones()for a zone and a year whereutcoffset().total_seconds() % 60 != 0, print the instant it changed, and then work out what that does to a system storing offsets as an integer number of minutes — which many do. - Rewrite Day 81's drift measurement. Take the scheduled job from Day 81,
find every place it subtracts two clock readings, and convert each to
time.monotonic()— except the ones that are recording when something happened, which must stay on a UTC instant. Write down the rule you used to decide which was which; that rule is the whole day in one sentence.
Navigation
- Previous day: Day 94 — Data Validation with pydantic
(
labs/sections/programming-with-python/day-094-data-validation-with-pydantic/). - Next day: Day 96 — Concurrency and async Basics
(
labs/sections/programming-with-python/day-096-concurrency-and-async-basics/). - Week 14 — Data Formats and Pipelines: this day supplies the timestamp handling every pipeline in the week depends on.
Expected output
FIELDS.md
# What must match, and what may differ
Every file in this directory was captured from a real run on the authoring
machine on 2026-08-16: macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0,
bash 3.2.57, and the IANA time zone database version **2026c** found at
`/usr/share/zoneinfo`, offering **598** zones.
That last line is the one to read carefully, because this lab's subject is a
database that is updated several times a year. Zone rules are data. If your
`+VERSION` file says something other than 2026c, your zone count will differ
from 598 and one or two historical answers may differ too. Every value in the
"must match" table below is a 2026 transition that has been published for
years, so it should hold on any database from the last several releases — and
if one of them does not hold on yours, your database is the authority and this
capture is the stale one. Run `python3 examples/01_zone_database.py` and see
what you have.
## Must match exactly, on any machine
These are values, not formatting, and a difference means something is wrong —
either in your code or in your zone database.
| Value | Where | Must be |
| --- | --- | --- |
| Europe/London 2026-03-29 | `odd-days.txt` | 23.0 hours |
| Europe/London 2026-10-25 | `odd-days.txt` | 25.0 hours |
| Europe/London 2026-06-15 | `odd-days.txt` | 24.0 hours |
| America/New_York 2026-03-08 / 2026-11-01 | `odd-days.txt` | 23.0 and 25.0 |
| Australia/Lord_Howe 2026-10-04 | `test-run.txt` | 23.5 — the half-hour step |
| Wall-clock length of every day | `odd-days.txt` | 24.0, including the two that are not |
| 2026-10-25 01:30 London, fold=0 | `fold.txt` | offset +01:00, BST, `2026-10-25T00:30:00+00:00`, epoch 1792888200 |
| 2026-10-25 01:30 London, fold=1 | `fold.txt` | offset +00:00, GMT, `2026-10-25T01:30:00+00:00`, epoch 1792891800 |
| `fold=0 == fold=1` on the ambiguous reading | `fold.txt` | **True** — the comparison trap |
| Their UTC instants compared | `fold.txt` | **False** — an hour apart |
| 2026-03-29 01:30 London round trip | `fold.txt` | comes back as `2026-03-29T02:30:00+01:00` |
| Firings of a local-01:30 schedule | `fold.txt` | **2** on 25 October, **0** on 29 March |
| UTC ISO text sort vs instant sort | `sorting.txt` | identical |
| Local text sort of the four events | `sorting.txt` | `checkout, dispatch, ordered, packed` — the exact reverse of the true order |
| Local text *with* offsets | `sorting.txt` | also wrong: `same order? False` |
| The two 01:30 local strings | `sorting.txt` | identical, from two instants an hour apart |
| `%Z` parsing "BST" | `sorting.txt` | ValueError |
| `%Z` parsing "UTC" | `sorting.txt` | succeeds, and `tzinfo` is `None` |
| `05/03/2026` two ways | `sorting.txt` | 2026-03-05 and 2026-05-03 |
| 31 Jan + `timedelta(days=30)` | `sorting.txt` | 2026-03-02 |
| `datetime(..., second=60)` | `clocks.txt` | ValueError: second must be in 0..59 |
| `2**31 - 1` as an instant | `clocks.txt` | `2038-01-19T03:14:07+00:00` |
| Wall stopwatch across the repeated hour | `clocks.txt` | reports 0:15:00 for 1:15:00 of work |
| Wall stopwatch ending after the change | `clocks.txt` | reports `-1 day, 23:20:00` — a negative duration |
| Resolver agreement | `resolver.txt` | 26 comparisons, **0 disagreements, 0 misclassified** |
| Starter before | `starter-progress.txt` | `0 of 10 exercises complete.`, exit 1 |
| Reference answers | `starter-progress.txt` | `10 of 10 exercises complete.`, exit 0 |
| Harness total | `test-run.txt` | `75 checks, 0 failure(s).`, exit 0 |
## Expected to differ on your machine
- **The zone count and the database version** in `zone-database.txt` and in
the `test-run.txt` banner. 598 zones and version 2026c is what this machine
had. The suite only requires more than 100, because a database with fewer
than that is not a real one.
- **The `TZPATH` listing.** On this macOS machine only `/usr/share/zoneinfo`
exists; on Linux you may see `/usr/share/zoneinfo` and `/etc/zoneinfo` both
present. On Windows there is usually no system database at all and
`zoneinfo` falls back to the `tzdata` package — see the platform notes.
- **The two measured timings in `clocks.txt`.** `time.time() measured ...` and
`time.monotonic() measured ...` are a real measurement of real work on this
processor and will differ on yours. Nothing asserts on them. What the suite
asserts is that both are positive and that monotonic never decreases across
20,000 samples — a shape, not a duration.
- **The clock resolutions and implementations in `clocks.txt`.**
`mach_absolute_time()` is macOS; Linux reports `clock_gettime(CLOCK_MONOTONIC)`.
The `monotonic`/`adjustable` columns are what matters, and those are the
same everywhere.
- **The `%c` and `%x` lines in `sorting.txt`**, which follow the C locale. On
this machine `LC_TIME` was `C`. A machine with a different locale prints
different text, and that is the entire point being made there.
- **The 1968 and 1971 boundary instants** printed by `zone-database.txt`. They
came out of the database by bisection, so a database that records them
differently will print differently. On this one they were
`1968-02-18T02:00:00+00:00` and `1971-10-31T02:00:00+00:00`.
## Deliberately stable, and why
Not one value in this lab depends on when you run it. Every instant is written
out in the source: `datetime(2026, 10, 25, 1, 30)`, `datetime(2026, 3, 29, 1,
0, tzinfo=UTC)`, and so on. That is not a testing convenience — it is the
subject. A suite that used `datetime.now()` would pass on 363 days of the year
and fail on the two the material is about, which is precisely the failure
schedule that makes these bugs survive to production.
The one place a clock is read at all is `measure_elapsed` and the timing
demonstration in `05_clocks.py`, and there the assertions are on shape
(positive, non-decreasing) rather than on any number of seconds.
## Platform notes
- **Linux** — identical output expected, given Python 3.11+ and a system
`tzdata` package. The clock implementation strings differ as noted above.
- **Windows** — Windows ships no IANA database, so `zoneinfo` needs the
`tzdata` package from PyPI before `ZoneInfo("Europe/London")` will load. Use
WSL and follow the Linux path; `tests/run_tests.sh` and `starter/02_check.sh`
are bash scripts and were not run on native Windows here, so no output is
claimed for it.
clocks.txt
====================================================================
What the standard library says about each clock
====================================================================
clock monotonic adjustable implementation
time False True clock_gettime(CLOCK_REALTIME)
monotonic True False mach_absolute_time()
perf_counter True False mach_absolute_time()
process_time True False clock_gettime(CLOCK_PROCESS_CPUTIME_ID)
Read the `adjustable` column. `time.time()` is adjustable, which
is a polite way of saying something else may move it while your
code is between two readings of it. `time.monotonic()` is not.
time resolution 0.000001000 s
monotonic resolution 0.000000042 s
perf_counter resolution 0.000000042 s
====================================================================
Measuring the same piece of work with both
====================================================================
the work summed to 1999999000000
time.time() measured 0.034609 s
time.monotonic() measured 0.034610 s
both positive: True
On an undisturbed machine they agree to within their resolution,
and that agreement is exactly what makes the bug invisible in
testing. The difference only appears on the day the clock moves.
====================================================================
What a wall-clock measurement does when the clock moves
====================================================================
Nothing below changes your system clock — that would need root and
would be a rude thing for a lab to do. Instead it computes what a
wall-clock stopwatch WOULD have reported, using real transitions.
autumn back in London: a task starting at 01:30 BST and
ending 20 real minutes later
real elapsed time 0:20:00
local clock at start / end 01:30:00 / 01:50:00
a naive local stopwatch reports 0:20:00
wrong? False
a task spanning the whole repeated hour
(starts 01:00 BST, ends 01:15 GMT)
real elapsed time 1:15:00
local clock at start / end 01:00:00 / 01:15:00
a naive local stopwatch reports 0:15:00
wrong? True
a task ending after the clocks go back
(starts 01:50 BST, ends 20 real minutes later at 01:10 GMT)
real elapsed time 0:20:00
local clock at start / end 01:50:00 / 01:10:00
a naive local stopwatch reports -1 day, 23:20:00
wrong? True
spring forward in London: a task over the gap
(starts 00:45 GMT, ends 25 real minutes later)
real elapsed time 0:25:00
local clock at start / end 00:45:00 / 02:10:00
a naive local stopwatch reports 1:25:00
wrong? True
Three failures, three different shapes. The second under-reports
by a full hour: a job that ran for 75 minutes is logged as 15. The
third reports a NEGATIVE duration — minus forty minutes for work
that took twenty — and a retry loop written as `while elapsed <
timeout` never terminates on a negative elapsed. The fourth
over-reports by an hour, which is how a healthy job ends up paged
as a timeout. All three are one line of monotonic away from
correct, and all three pass every test you will ever run in June.
====================================================================
Epoch seconds: what they are, and where they stop
====================================================================
2026-10-25T01:30:00+00:00
epoch seconds 1792891800
back again 2026-10-25T01:30:00+00:00
the epoch itself 1970-01-01T00:00:00+00:00
signed 32-bit limit 2038-01-19T03:14:07+00:00
An epoch count is unambiguous by construction — it names an
instant with no zone, no offset and no wall clock anywhere in it.
It is also unreadable, which is a real cost: nobody spots that
1792891800 is wrong by a month while reading a log.
Two limits worth knowing. The first is that famous one above: a
signed 32-bit count of seconds runs out in January 2038, and code
storing seconds in an int32 anywhere still exists. The second is
quieter — Python's timestamp() returns a float, and a float has
53 bits of mantissa:
2026-08-16T09:00:00.123456+00:00
-> 1786870800.123456
-> 2026-08-16T09:00:00.123456+00:00
smallest representable step near now: 3.975e-07 s
Microseconds survive today. Nanoseconds do not, which is why
time.time_ns() exists and returns an integer.
time.time_ns() returns an integer: int
====================================================================
Leap seconds: why Python does not model them
====================================================================
A leap second is an extra second inserted into UTC to keep it in
step with the Earth's rotation, which is neither constant nor
predictable. When one is inserted, that UTC minute really does
contain 61 seconds, labelled 23:59:60.
Ask Python for one:
datetime(2016, 12, 31, 23, 59, 60) -> ValueError: second must be in 0..59, not 60
Python's datetime implements POSIX time, in which every day has
exactly 86400 seconds by definition. A leap second is therefore
not representable, and epoch counts silently repeat or stretch a
second when one occurs. Most large operators now smear the extra
second across a whole day instead, so no clock ever shows :60.
The practical position: if you are timing rocket launches or
reconciling financial trades to the microsecond you need TAI and a
specialist library. For everything else, treat 'a day has 86400
seconds' as true, know that it is an approximation, and use a
monotonic clock for any duration you actually care about.
====================================================================
Which clock, for which question
====================================================================
question use why
How long did this take? time.monotonic() never adjusted
Has the timeout expired? time.monotonic() never goes backwards
How fast is this function? time.perf_counter() highest resolution
How much CPU did it use? time.process_time() excludes sleep
When did this happen? datetime.now(timezone.utc) a calendar instant
What should the log say? datetime.now(timezone.utc) comparable across hosts
And the one to stop using: datetime.utcnow() returns a NAIVE
datetime holding UTC fields, which is the worst of both worlds —
it looks like a local time and is not one. It is deprecated.
Write datetime.now(timezone.utc) and get an aware one.
fold.txt
====================================================================
AMBIGUOUS — the same wall clock, two different instants
====================================================================
wall reading: 2026-10-25T01:30:00 zone: Europe/London
fold=0
local 2026-10-25T01:30:00+01:00
offset +01:00 (BST)
the UTC instant it means 2026-10-25T00:30:00+00:00
epoch seconds 1792888200
fold=1
local 2026-10-25T01:30:00+00:00
offset +00:00 (GMT)
the UTC instant it means 2026-10-25T01:30:00+00:00
epoch seconds 1792891800
They are 1:00:00 apart. Same string, same zone, different moments.
The trap, and it is a real one:
first == second -> True
same instant? -> False
first.timestamp() equal? -> False
Comparing two aware datetimes that carry the SAME tzinfo object
compares their wall readings and ignores fold, so two moments an
hour apart test equal. Convert to UTC before you compare, sort or
deduplicate anything.
====================================================================
NONEXISTENT — a wall clock reading nobody ever saw
====================================================================
wall reading: 2026-03-29T01:30:00 zone: Europe/London
fold=0
local 2026-03-29T01:30:00+00:00 offset +00:00
as UTC 2026-03-29T01:30:00+00:00
back to London 2026-03-29T02:30:00+01:00 <- NOT what you started with
fold=1
local 2026-03-29T01:30:00+01:00 offset +01:00
as UTC 2026-03-29T00:30:00+00:00
back to London 2026-03-29T00:30:00+00:00 <- NOT what you started with
Python constructs the object without complaining, because a naive
wall reading plus a zone is a request, not a fact. The round trip
is where it shows: local -> UTC -> local does not come home.
fold=0 uses the offset in force BEFORE the gap, fold=1 the offset
after it, and neither answer is the time you asked for, because
the time you asked for did not occur.
====================================================================
THE CONSEQUENCE — a job scheduled at 01:30 local
====================================================================
A scheduler that wakes every minute and fires when the local clock
reads 01:30 will fire once on an ordinary day. Walk 25 October 2026
minute by minute in real elapsed time and count:
the local clock read 01:30 2 times:
2026-10-25T00:30:00+00:00 UTC = 01:30 BST (fold=0)
2026-10-25T01:30:00+00:00 UTC = 01:30 GMT (fold=1)
Two firings, one hour apart, from one schedule entry. If that job
charges a card, sends a statement or writes a daily partition, it
has now done it twice. On 29 March 2026 the same schedule fires
zero times, because 01:30 never arrives:
firings on 2026-03-29: 0
Schedule in UTC and you get exactly one firing on both days. That
is not a workaround; it is what 'daily at 01:30' actually meant.
====================================================================
ARITHMETIC — 'one hour later' has two different meanings
====================================================================
start 2026-10-25T00:30:00+01:00
+ timedelta(hours=1) 2026-10-25T01:30:00+01:00 <- wall arithmetic
+ one hour of real time 2026-10-25T01:30:00+01:00 <- elapsed time
Here they agree by luck: both land on the first 01:30. Cross the
transition and they part company:
+ timedelta(hours=2) 2026-10-25T02:30:00+00:00
+ two hours of real time 2026-10-25T01:30:00+00:00
a whole hour apart: 1:00:00
timedelta arithmetic on an aware datetime adds to the WALL clock
fields and then re-derives the offset from the result, so two
hours of wall clock can be one, two or three hours of elapsed
time. To add elapsed time, convert to UTC, add,
and convert back. Decide which one you meant; both are legitimate.
'The meeting is at 09:00 next Tuesday' is wall arithmetic. 'The
token expires in one hour' is elapsed time.
Every instant above is pinned in the source. Nothing read a clock.
odd-days.txt
How long is a day? Measured in elapsed time, not in wall clock.
zone local date real wall what happened
----------------------------------------------------------------------
Europe/London 2026-03-29 23.0h 24.0h spring forward
Europe/London 2026-10-25 25.0h 24.0h autumn back
Europe/London 2026-06-15 24.0h 24.0h an ordinary day
America/New_York 2026-03-08 23.0h 24.0h spring forward
America/New_York 2026-11-01 25.0h 24.0h autumn back
Australia/Lord_Howe 2026-10-04 23.5h 24.0h forward by half an hour
Australia/Lord_Howe 2026-04-05 24.5h 24.0h back by half an hour
The wall column is 24.0 on every row, including the two that are not.
That column is what you get if you subtract two local datetimes, and
it is why a report that measures a day by subtracting midnights is
wrong twice a year and right the rest of the time, which is the worst
possible failure schedule.
London 2026-03-29 lasted 23:00:00 — an hour was skipped.
London 2026-10-25 lasted 1 day, 1:00:00 — an hour was repeated.
The two together: 2 days, 0:00:00, which is exactly two days.
Daylight saving borrows an hour in March and returns it in October.
And an hour is not the only step size. Lord Howe Island moves by
thirty minutes, so its short day measures 23.5 hours and its long
day 24.5. Any code that special-cases 'plus or minus exactly one
hour' is already wrong there, and it is in the table above.
The hour that is missing, and the hour that is doubled:
2026-03-29 skipped first six real hours read: 00:00 02:00 03:00 04:00 05:00 06:00
2026-10-25 repeated first six real hours read: 00:00 01:00 01:00 02:00 03:00 04:00
On 29 March the wall clock never shows 01:xx. On 25 October it
shows 01:xx twice, and those two 01:30s are an hour apart.
resolver.txt
A hand-written resolver over a three-line rule table, checked
against the real IANA database on every case and both folds.
rule table: base +00:00 GMT
at 2026-03-29T01:00:00+00:00 -> +01:00 BST
at 2026-10-25T01:00:00+00:00 -> +00:00 GMT
wall clock fold mine zoneinfo kind agree
----------------------------------------------------------------
2026-01-15T12:00:00 0 +00:00 +00:00 normal yes
2026-01-15T12:00:00 1 +00:00 +00:00 normal yes
2026-03-29T00:59:00 0 +00:00 +00:00 normal yes
2026-03-29T00:59:00 1 +00:00 +00:00 normal yes
2026-03-29T01:00:00 0 +00:00 +00:00 nonexistent yes
2026-03-29T01:00:00 1 +01:00 +01:00 nonexistent yes
2026-03-29T01:30:00 0 +00:00 +00:00 nonexistent yes
2026-03-29T01:30:00 1 +01:00 +01:00 nonexistent yes
2026-03-29T01:59:00 0 +00:00 +00:00 nonexistent yes
2026-03-29T01:59:00 1 +01:00 +01:00 nonexistent yes
2026-03-29T02:00:00 0 +01:00 +01:00 normal yes
2026-03-29T02:00:00 1 +01:00 +01:00 normal yes
2026-07-01T12:00:00 0 +01:00 +01:00 normal yes
2026-07-01T12:00:00 1 +01:00 +01:00 normal yes
2026-10-25T00:59:00 0 +01:00 +01:00 normal yes
2026-10-25T00:59:00 1 +01:00 +01:00 normal yes
2026-10-25T01:00:00 0 +01:00 +01:00 ambiguous yes
2026-10-25T01:00:00 1 +00:00 +00:00 ambiguous yes
2026-10-25T01:30:00 0 +01:00 +01:00 ambiguous yes
2026-10-25T01:30:00 1 +00:00 +00:00 ambiguous yes
2026-10-25T01:59:00 0 +01:00 +01:00 ambiguous yes
2026-10-25T01:59:00 1 +00:00 +00:00 ambiguous yes
2026-10-25T02:00:00 0 +00:00 +00:00 normal yes
2026-10-25T02:00:00 1 +00:00 +00:00 normal yes
2026-12-25T09:00:00 0 +00:00 +00:00 normal yes
2026-12-25T09:00:00 1 +00:00 +00:00 normal yes
cases: 13 wall readings x 2 folds = 26 comparisons
disagreements with zoneinfo: 0
cases classified wrongly: 0
And the other direction, which has no ambiguity to resolve:
2026-03-29T00:59:59+00:00 mine +00:00 GMT zoneinfo +00:00 agree
2026-03-29T01:00:00+00:00 mine +01:00 BST zoneinfo +01:00 agree
2026-10-25T00:59:59+00:00 mine +01:00 BST zoneinfo +01:00 agree
2026-10-25T01:00:00+00:00 mine +00:00 GMT zoneinfo +00:00 agree
What the real thing adds, and it is worth being honest about it:
* every zone in the database instead of one, and every recorded
change each of them has ever made, not just the two in 2026;
* a compiled binary file and a cache of loaded zones, instead of a
table typed out by hand and scanned from the top;
* the rule string at the end of each file, which extrapolates the
rules past the last stored transition into the future;
* and correct handling of the historical oddities — offsets that
were not whole minutes, zones that changed name without changing
offset, and days that were skipped entirely.
The ALGORITHM, though, is the one above. That is the whole trick.
sorting.txt
====================================================================
UTC ISO 8601 text: lexicographic order IS chronological order
====================================================================
2026-08-16T11:30:00Z ordered
2026-08-16T15:00:00Z packed
2026-08-16T16:00:00Z dispatch
2026-08-16T18:00:00Z checkout
sorted as text == sorted as instants : True
It works because the format was designed so it would: the fields
run most-significant first, every one is zero-padded to a fixed
width, and the offset is always the same. Character 1 outranks
character 2 in exactly the way a year outranks a month.
That is what lets a database with no date type — SQLite, a CSV, a
key in a key-value store — do ORDER BY, MIN, MAX, BETWEEN and a
range scan on a plain text column and be right.
====================================================================
Local text: lexicographic order is NOT chronological order
====================================================================
Same four events, each stored as the local clock in its own office:
2026-08-16T14:00:00 checkout
2026-08-16T17:00:00 dispatch
2026-08-16T17:00:00 ordered
2026-08-16T20:30:00 packed
sorted as text : ['checkout', 'dispatch', 'ordered', 'packed']
sorted as instants : ['ordered', 'packed', 'dispatch', 'checkout']
same order? : False
Reversed, in this case. The strings are all well-formed ISO 8601
and every one of them is true. They are simply not comparable to
each other, because they are measured against different rulers.
====================================================================
Local text WITH the offset: still not sortable as text
====================================================================
2026-08-16T14:00:00-04:00 checkout
2026-08-16T17:00:00+01:00 dispatch
2026-08-16T17:00:00+05:30 ordered
2026-08-16T20:30:00+05:30 packed
sorted as text : ['checkout', 'dispatch', 'ordered', 'packed']
sorted as instants : ['ordered', 'packed', 'dispatch', 'checkout']
same order? : False
This is the subtle one. These strings carry their offsets, so
nothing is lost — a parser can recover every instant exactly. But
a text sort compares the digits left to right and never reaches
the offset on the end, so an index, a sorted file or an ORDER BY
over the raw column is still wrong. Losslessness and sortability
are different properties. UTC text has both.
====================================================================
The third failure: two instants, one local string
====================================================================
2026-10-25T00:30:00Z -> London local 2026-10-25T01:30:00
2026-10-25T01:30:00Z -> London local 2026-10-25T01:30:00
identical local strings? True
Two instants an hour apart collapse to one string, so no sort of
any kind can order them and no query can tell them apart. This is
not a sorting bug you can fix with a better comparator; the
information is gone at the moment of writing.
====================================================================
ISO 8601 and RFC 3339 are not the same thing
====================================================================
Renderings of one instant:
isoformat() 2026-10-25T01:30:00+00:00
strftime Z form 2026-10-25T01:30:00Z
basic ISO 8601 form 20261025T013000Z
ordinal date (ISO 8601) 2026-298T01:30:00Z
ISO week date datetime.IsoCalendarDate(year=2026, week=43, weekday=7)
ISO 8601 is a large standard: it allows the basic form with no
separators, week dates, ordinal dates, durations, intervals and
reduced precision. RFC 3339 is a small profile of it for the
internet: date, T or a space, time, and a mandatory offset. Every
RFC 3339 timestamp is valid ISO 8601; the reverse is not true —
'2026-W43-7' is ISO 8601 and is not a timestamp at all.
Write RFC 3339 with Z. It is the intersection everything reads.
What Python actually parses on this machine:
fromisoformat('2026-10-25T01:30:00+00:00' ) -> 2026-10-25T01:30:00+00:00
fromisoformat('2026-10-25T01:30:00Z' ) -> 2026-10-25T01:30:00+00:00
fromisoformat('2026-10-25 01:30:00Z' ) -> 2026-10-25T01:30:00+00:00
fromisoformat('20261025T013000Z' ) -> 2026-10-25T01:30:00+00:00
fromisoformat('2026-W43-7' ) -> 2026-10-25T00:00:00
fromisoformat('2026-10-25T01:30:00+0100' ) -> 2026-10-25T01:30:00+01:00
fromisoformat('Sun, 25 Oct 2026 01:30:00 GMT' ) -> ValueError: Invalid isoformat string: 'Sun, 25 Oct 2026 01:30:00 GMT'
`fromisoformat` was strict before Python 3.11 and accepts most of
ISO 8601 from 3.11 onward, including the trailing Z. Note the
week-date line: it parses, and it silently becomes a midnight.
The last line is RFC 2822, the email date format, which
`fromisoformat` refuses — `email.utils.parsedate_to_datetime`
is the standard-library function for that one.
====================================================================
strftime and strptime: the two traps
====================================================================
Trap 1 — %Z parses almost nothing, and throws the zone away:
UTC -> datetime.datetime(2026, 10, 25, 1, 30)
tzinfo is None — the zone name is gone
GMT -> datetime.datetime(2026, 10, 25, 1, 30)
tzinfo is None — the zone name is gone
BST -> ValueError: does not match the format
EST -> ValueError: does not match the format
%z with a numeric offset is the one that works:
datetime.datetime(2026, 10, 25, 1, 30, tzinfo=datetime.timezone(datetime.timedelta(seconds=3600)))
And an abbreviation could not identify a zone even if it parsed:
IST is India, Ireland and Israel; CST is at least three places.
Trap 2 — the order of the numbers is a cultural convention:
05/03/2026 parsed as %d/%m/%Y -> 2026-03-05
05/03/2026 parsed as %m/%d/%Y -> 2026-05-03
Both parse. Both succeed. They are two months apart, and no
error is raised in either direction. This is exactly the failure
ISO 8601 was written to end.
%c and %x follow the C locale, so their output changes with the
environment: %c here gives Sun Oct 25 01:30:00 2026
and %x gives 10/25/26
Never write either into a file another program will read.
====================================================================
'Add one month' is not a timedelta
====================================================================
start 2026-01-31
+ timedelta(days=30) 2026-03-02
+ timedelta(days=31) 2026-03-03
Neither is 'one month later', because a month is not a fixed
number of days — it is 28, 29, 30 or 31 depending on which one and
which year. timedelta carries days, seconds and microseconds and
deliberately has no months or years field, because it could not
give them a length.
So 'the 31st of the next month' has to be a policy decision:
31 January + 1 month = 28 February (clamp to the month end)?
or 3 March (overflow the extra days)?
or an error, because the caller has not said which they meant?
Pick one, write it down, and put it in a named function. The
standard library has calendar.monthrange to tell you the length;
it deliberately does not choose the policy for you.
starter-progress.txt
$ bash starter/02_check.sh
Marking starter/01_timezones.py
1. not started zone_count
2. not started to_utc_text
3. not started day_length_hours
4. not started ambiguous_offsets
5. not started is_nonexistent
6. not started is_ambiguous
7. not started sorted_utc_texts
8. not started measure_elapsed
9. not started offset_at_instant
10. not started resolve_wall
0 of 10 exercises complete.
exit code: 1
$ bash starter/02_check.sh examples/07_solution.py
Marking examples/07_solution.py
1. complete zone_count
2. complete to_utc_text
3. complete day_length_hours
4. complete ambiguous_offsets
5. complete is_nonexistent
6. complete is_ambiguous
7. complete sorted_utc_texts
8. complete measure_elapsed
9. complete offset_at_instant
10. complete resolve_wall
10 of 10 exercises complete.
exit code: 0
test-run.txt
Day 095 — Dates, Times, and Time Zones
python3: 3.14.0
zones: 598
tzpath: /usr/share/zoneinfo
work: a temporary directory, removed when this script exits
1. The zone database is present and usable
ok: zoneinfo imports
ok: Europe/London loads from the system database
ok: America/New_York loads
ok: the database holds more than 100 zones (found 598)
ok: a name that is not a zone raises ZoneInfoNotFoundError
2. The 23-hour day and the 25-hour day
ok: Europe/London 2026-03-29 is 23 hours
ok: Europe/London 2026-10-25 is 25 hours
ok: Europe/London 2026-06-15 is an ordinary 24 hours
ok: America/New_York 2026-03-08 is 23 hours
ok: America/New_York 2026-11-01 is 25 hours
ok: the short and long days sum to exactly 48 hours
ok: Australia/Lord_Howe moves by half an hour, not one
ok: subtracting two LOCAL midnights always says 24, even on 25 Oct
3. The hour that happened twice
ok: 2026-10-25 01:30 London: offsets are +01:00 then +00:00
ok: the two folds name two instants an hour apart
ok: the two epoch seconds differ by 3600
ok: and yet == says they are equal: compare in UTC, always
ok: the tz names are BST then GMT
ok: a job firing when the local clock reads 01:30 fires twice
4. The hour that never happened
ok: 2026-03-29 01:30 London does not survive a round trip
ok: fold=0 uses the offset before the gap, fold=1 the offset after
ok: a job firing at local 01:30 fires zero times on 2026-03-29
ok: the fold order separates the two cases: ambiguous <, nonexistent >
5. Sorting: UTC text works, local text does not
ok: UTC ISO text sorted as TEXT equals sorted by instant
ok: the UTC text order is the true chronological order
ok: local text sorts into a DIFFERENT order — here, the reverse
ok: local text WITH its offset also sorts wrongly
ok: two instants an hour apart collapse to one local string
6. Parsing, formatting and durations
ok: fromisoformat accepts a trailing Z
ok: %Z does not parse BST at all
ok: %Z parses UTC and then throws the zone away
ok: %z with a numeric offset keeps it
ok: one ambiguous date string, two valid parses, two months apart
ok: 31 January plus timedelta(days=30) is 2 March, not one month
ok: a leap second is not representable
ok: the signed 32-bit epoch runs out in January 2038
7. Wall clock against monotonic clock
ok: time.monotonic() is monotonic and not adjustable
ok: time.time() is neither
ok: monotonic never goes backwards across 20000 samples
ok: a monotonic measurement of real work is positive
ok: a wall-clock stopwatch across the repeated hour under-reports by 3600s
ok: and it can report a NEGATIVE duration for real work
8. The from-scratch resolver agrees with the real database
ok: examples/06_resolver.py exits 0
ok: 26 comparisons were made
ok: zero disagreements with zoneinfo
ok: zero cases classified wrongly
ok: no line in the comparison table says NO
ok: the resolver classifies the gap as nonexistent
ok: and the repeat as ambiguous
ok: a resolver with a wrong transition date FAILS (proving the check is real)
ok: the broken run reports disagreements with zoneinfo
9. Every example script runs and prints what the lesson quotes
ok: examples/01_zone_database.py exits 0
ok: examples/02_odd_days.py exits 0
ok: examples/03_fold.py exits 0
ok: examples/04_sorting.py exits 0
ok: examples/05_clocks.py exits 0
ok: 01 reports the search path and a zone count
ok: 02 shows the 23-hour and 25-hour London days
ok: 03 shows the local clock reading 01:30 twice
ok: 03 shows zero firings on the spring-forward day
ok: 04 proves the UTC text sort matches the instant sort
ok: 05 reports monotonic as not adjustable
10. The starter and its reference answers
ok: an untouched starter reports 0 of 10
ok: an untouched starter exits non-zero
ok: every exercise is reported as not started, not as an error
ok: the reference answers report 10 of 10
ok: the reference answers exit 0
ok: a solution that confuses ambiguous with nonexistent is caught
ok: and it is reported as WRONG rather than as complete
11. The lab is offline, unprivileged and leaves nothing behind
ok: no example or starter file imports a network module
ok: nothing in the lab invokes sudo
ok: no URL appears in any script in this lab
ok: no script this suite asserts on ever reads the clock for an instant
ok: no __pycache__ directory was left in the lab
ok: no stray files were created in the lab directory
75 checks, 0 failure(s).
zone-database.txt
Day 095 — is the zone database actually here?
python: 3.14.0
zoneinfo.TZPATH — searched in this order, first match wins:
present /usr/share/zoneinfo
absent /usr/lib/zoneinfo
absent /usr/share/lib/zoneinfo
absent /etc/zoneinfo
IANA database version: 2026c (from /usr/share/zoneinfo/+VERSION)
zones available here: 598
One instant — 2026-08-16T12:00:00+00:00 — read in five places:
zone local time offset name
UTC 2026-08-16 12:00:00 +00:00 UTC
Europe/London 2026-08-16 13:00:00 +01:00 BST
America/New_York 2026-08-16 08:00:00 -04:00 EDT
Asia/Kolkata 2026-08-16 17:30:00 +05:30 IST
Asia/Kathmandu 2026-08-16 17:45:00 +05:45 +0545
Kolkata is +05:30 and Kathmandu +05:45. Offsets are not whole hours,
which is why an offset is a timedelta and not an integer of hours.
A zone is not a constant. Europe/London at noon on 1 January:
1967: offset +00:00 name GMT daylight saving 0:00:00
1969: offset +01:00 name BST daylight saving 0:00:00
1970: offset +01:00 name BST daylight saving 0:00:00
1971: offset +01:00 name BST daylight saving 0:00:00
1972: offset +00:00 name GMT daylight saving 0:00:00
2026: offset +00:00 name GMT daylight saving 0:00:00
For three winters London sat at +01:00 with no daylight saving in
force: Britain ran an experiment with year-round summer time.
Bisecting the database for the two boundary instants:
clocks went forward 1968-02-18T02:00:00+00:00
and did not go back until 1971-10-31T02:00:00+00:00
Three years and eight months between one spring forward and the
next autumn back. No code models that; a file records it.
Those two instants are facts about a file, and a future one would
be a prediction: a government can move a transition, and then the
database is updated and your 'fixed' timestamp moves with it.
Everything above came from files on disk. None of it is in Python.
Source files
examples/01_zone_database.py (6595 bytes)
"""Prove the zone database exists on this machine, then count what is in it.
Nothing in this file is a claim about time. It is a claim about a FILE, which
is the point: `zoneinfo` does not know when the clocks change in London. It
looks the answer up in a database of compiled rules shipped by your operating
system, and if that database is missing or out of date, every answer below
changes.
Run: python3 examples/01_zone_database.py
"""
from __future__ import annotations
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
try:
import zoneinfo
except ImportError: # pragma: no cover - zoneinfo is standard from 3.9
sys.exit("zoneinfo is missing: this lab needs Python 3.9 or newer.")
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
UTC = timezone.utc
def fmt_offset(delta: timedelta | None) -> str:
"""Format a UTC offset as +HH:MM.
Worth its own function: str(timedelta(hours=-4)) is '-1 day, 20:00:00',
which is arithmetically correct and useless in a table.
"""
if delta is None:
return " none"
total = int(delta.total_seconds())
sign = "-" if total < 0 else "+"
total = abs(total)
return f"{sign}{total // 3600:02d}:{total % 3600 // 60:02d}"
def report_search_path() -> list[str]:
"""Print every directory zoneinfo will search, and say which ones exist."""
print("zoneinfo.TZPATH — searched in this order, first match wins:")
present = []
for entry in zoneinfo.TZPATH:
exists = Path(entry).is_dir()
print(f" {'present' if exists else 'absent '} {entry}")
if exists:
present.append(entry)
if not present:
print(" none of those exist — zoneinfo would fall back to the tzdata package")
return present
def report_database_version(present: list[str]) -> str | None:
"""The IANA database is versioned. Read the version file if it is there."""
for entry in present:
version_file = Path(entry) / "+VERSION"
if version_file.is_file():
version = version_file.read_text(encoding="utf-8").strip()
print(f"\nIANA database version: {version} (from {version_file})")
return version
print("\nIANA database version: no +VERSION file found in the search path")
return None
def count_zones() -> int:
"""Every zone name the database offers, including links and aliases."""
names = zoneinfo.available_timezones()
print(f"\nzones available here: {len(names)}")
return len(names)
def show_a_few() -> None:
"""One instant, five places, five different local readings of it."""
instant = datetime(2026, 8, 16, 12, 0, tzinfo=UTC)
print(f"\nOne instant — {instant.isoformat()} — read in five places:")
print(f" {'zone':<20} {'local time':<21} {'offset':>7} name")
for name in [
"UTC",
"Europe/London",
"America/New_York",
"Asia/Kolkata",
"Asia/Kathmandu",
]:
local = instant.astimezone(ZoneInfo(name))
print(
f" {name:<20} {local.strftime('%Y-%m-%d %H:%M:%S'):<21} "
f"{fmt_offset(local.utcoffset()):>7} {local.tzname()}"
)
print(" Kolkata is +05:30 and Kathmandu +05:45. Offsets are not whole hours,")
print(" which is why an offset is a timedelta and not an integer of hours.")
def find_transition(zone: ZoneInfo, low: datetime, high: datetime) -> datetime | None:
"""Bisect for the first instant in (low, high] where the offset changes.
This is how you discover a zone's rules without parsing the binary file:
ask the database for the offset at two instants, and if they differ, halve
the interval until you have the second the change lands on. It assumes at
most one change in the window, which is why the callers pass narrow ones.
"""
if low.utcoffset() is None or low.tzinfo is not UTC:
raise ValueError("bisect over UTC instants, not local ones")
before = low.astimezone(zone).utcoffset()
after = high.astimezone(zone).utcoffset()
if before == after:
return None
while high - low > timedelta(seconds=1):
middle = low + (high - low) / 2
if middle.astimezone(zone).utcoffset() == before:
low = middle
else:
high = middle
return high.replace(microsecond=0)
def show_rules_are_data() -> None:
"""The same zone, different years, different answers — read from the file."""
london = ZoneInfo("Europe/London")
print("\nA zone is not a constant. Europe/London at noon on 1 January:")
for year in (1967, 1969, 1970, 1971, 1972, 2026):
moment = datetime(year, 1, 1, 12, 0, tzinfo=london)
print(
f" {year}: offset {fmt_offset(moment.utcoffset())} "
f"name {moment.tzname():<4} daylight saving {moment.dst()}"
)
print(" For three winters London sat at +01:00 with no daylight saving in")
print(" force: Britain ran an experiment with year-round summer time.")
start = find_transition(
london,
datetime(1968, 1, 1, tzinfo=UTC),
datetime(1969, 1, 1, tzinfo=UTC),
)
end = find_transition(
london,
datetime(1971, 6, 1, tzinfo=UTC),
datetime(1972, 1, 1, tzinfo=UTC),
)
print(" Bisecting the database for the two boundary instants:")
print(f" clocks went forward {start.isoformat() if start else 'not found'}")
print(f" and did not go back until {end.isoformat() if end else 'not found'}")
print(" Three years and eight months between one spring forward and the")
print(" next autumn back. No code models that; a file records it.")
print(" Those two instants are facts about a file, and a future one would")
print(" be a prediction: a government can move a transition, and then the")
print(" database is updated and your 'fixed' timestamp moves with it.")
def main() -> int:
print("Day 095 — is the zone database actually here?\n")
print(f"python: {sys.version.split()[0]}")
present = report_search_path()
report_database_version(present)
try:
ZoneInfo("Europe/London")
except ZoneInfoNotFoundError as exc:
print(f"\nEurope/London could not be loaded: {exc}")
print("Install your system's tzdata package, or add the tzdata module.")
return 1
count_zones()
show_a_few()
show_rules_are_data()
print("\nEverything above came from files on disk. None of it is in Python.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/02_odd_days.py (4371 bytes)
"""The 23-hour day and the 25-hour day, measured rather than asserted.
"A day is 24 hours" is false twice a year in most of the world, and the two
exceptions are not rare edge cases — they are scheduled, published years in
advance, and they arrive on the same weekend as everybody's monthly billing
run.
The measurement below is the only honest one: convert both midnights to UTC
and subtract. Subtracting two local datetimes gives you the WALL-CLOCK
difference, which is 24 hours on every day of the year by construction and
therefore tells you nothing.
Run: python3 examples/02_odd_days.py
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
UTC = timezone.utc
def midnight(day: date, zone: ZoneInfo) -> datetime:
"""The first instant of a local calendar day, as an aware datetime."""
return datetime.combine(day, time(0, 0), tzinfo=zone)
def real_length(day: date, zone: ZoneInfo) -> timedelta:
"""How much time actually elapsed during a local calendar day."""
start = midnight(day, zone).astimezone(UTC)
end = midnight(day + timedelta(days=1), zone).astimezone(UTC)
return end - start
def wall_length(day: date, zone: ZoneInfo) -> timedelta:
"""What naive subtraction says. Always 24 hours. Always."""
return midnight(day + timedelta(days=1), zone) - midnight(day, zone)
DAYS = [
("Europe/London", date(2026, 3, 29), "spring forward"),
("Europe/London", date(2026, 10, 25), "autumn back"),
("Europe/London", date(2026, 6, 15), "an ordinary day"),
("America/New_York", date(2026, 3, 8), "spring forward"),
("America/New_York", date(2026, 11, 1), "autumn back"),
("Australia/Lord_Howe", date(2026, 10, 4), "forward by half an hour"),
("Australia/Lord_Howe", date(2026, 4, 5), "back by half an hour"),
]
def main() -> int:
print("How long is a day? Measured in elapsed time, not in wall clock.\n")
header = f"{'zone':<20} {'local date':<12} {'real':>10} {'wall':>10} what happened"
print(header)
print("-" * len(header))
for zone_name, day, label in DAYS:
zone = ZoneInfo(zone_name)
real = real_length(day, zone)
wall = wall_length(day, zone)
hours = real.total_seconds() / 3600
print(
f"{zone_name:<20} {day.isoformat():<12} "
f"{hours:>9.1f}h {wall.total_seconds() / 3600:>9.1f}h {label}"
)
print("\nThe wall column is 24.0 on every row, including the two that are not.")
print("That column is what you get if you subtract two local datetimes, and")
print("it is why a report that measures a day by subtracting midnights is")
print("wrong twice a year and right the rest of the time, which is the worst")
print("possible failure schedule.\n")
london = ZoneInfo("Europe/London")
short = real_length(date(2026, 3, 29), london)
long = real_length(date(2026, 10, 25), london)
print(f"London 2026-03-29 lasted {short} — an hour was skipped.")
print(f"London 2026-10-25 lasted {long} — an hour was repeated.")
print(f"The two together: {short + long}, which is exactly two days.")
print("Daylight saving borrows an hour in March and returns it in October.")
print("\nAnd an hour is not the only step size. Lord Howe Island moves by")
print("thirty minutes, so its short day measures 23.5 hours and its long")
print("day 24.5. Any code that special-cases 'plus or minus exactly one")
print("hour' is already wrong there, and it is in the table above.")
print("\nThe hour that is missing, and the hour that is doubled:")
for label, day, zone_name in [
("skipped", date(2026, 3, 29), "Europe/London"),
("repeated", date(2026, 10, 25), "Europe/London"),
]:
zone = ZoneInfo(zone_name)
start = midnight(day, zone).astimezone(UTC)
seen: list[str] = []
for step in range(6):
local = (start + timedelta(hours=step)).astimezone(zone)
seen.append(local.strftime("%H:%M"))
print(f" {day} {label:<8} first six real hours read: {' '.join(seen)}")
print(" On 29 March the wall clock never shows 01:xx. On 25 October it")
print(" shows 01:xx twice, and those two 01:30s are an hour apart.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/03_fold.py (7554 bytes)
"""The hour that happened twice, and the hour that never happened at all.
A wall-clock reading plus a zone name is not enough to identify an instant.
Twice a year it identifies two instants, or none. `fold` is the single bit
that chooses between the two, and it is the whole reason this lesson exists.
Everything here is pinned to explicit dates. Nothing reads a clock, because a
test that uses "now" cannot be trusted on the one day of the year it matters.
Run: python3 examples/03_fold.py
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
UTC = timezone.utc
LONDON = ZoneInfo("Europe/London")
# 25 October 2026, 02:00 local, clocks go back to 01:00. Wall times from
# 01:00 up to but not including 02:00 happen twice.
AMBIGUOUS = datetime(2026, 10, 25, 1, 30)
# 29 March 2026, 01:00 local, clocks jump to 02:00. Wall times from 01:00 up
# to but not including 02:00 do not happen at all.
NONEXISTENT = datetime(2026, 3, 29, 1, 30)
def fmt_offset(delta: timedelta | None) -> str:
"""Format a UTC offset as +HH:MM. See 01_zone_database.py for why."""
if delta is None:
return "none"
total = int(delta.total_seconds())
sign = "-" if total < 0 else "+"
total = abs(total)
return f"{sign}{total // 3600:02d}:{total % 3600 // 60:02d}"
def show_ambiguous() -> None:
print("=" * 68)
print("AMBIGUOUS — the same wall clock, two different instants")
print("=" * 68)
print(f"wall reading: {AMBIGUOUS.isoformat()} zone: Europe/London\n")
first = AMBIGUOUS.replace(tzinfo=LONDON, fold=0)
second = AMBIGUOUS.replace(tzinfo=LONDON, fold=1)
for label, moment in [("fold=0", first), ("fold=1", second)]:
print(f" {label}")
print(f" local {moment.isoformat()}")
print(f" offset {fmt_offset(moment.utcoffset())} ({moment.tzname()})")
print(f" the UTC instant it means {moment.astimezone(UTC).isoformat()}")
print(f" epoch seconds {moment.timestamp():.0f}")
gap = second.astimezone(UTC) - first.astimezone(UTC)
print(f"\n They are {gap} apart. Same string, same zone, different moments.")
print("\n The trap, and it is a real one:")
print(f" first == second -> {first == second}")
print(f" same instant? -> {first.astimezone(UTC) == second.astimezone(UTC)}")
print(f" first.timestamp() equal? -> {first.timestamp() == second.timestamp()}")
print(" Comparing two aware datetimes that carry the SAME tzinfo object")
print(" compares their wall readings and ignores fold, so two moments an")
print(" hour apart test equal. Convert to UTC before you compare, sort or")
print(" deduplicate anything.")
def show_nonexistent() -> None:
print()
print("=" * 68)
print("NONEXISTENT — a wall clock reading nobody ever saw")
print("=" * 68)
print(f"wall reading: {NONEXISTENT.isoformat()} zone: Europe/London\n")
for fold in (0, 1):
moment = NONEXISTENT.replace(tzinfo=LONDON, fold=fold)
as_utc = moment.astimezone(UTC)
back = as_utc.astimezone(LONDON)
print(f" fold={fold}")
print(f" local {moment.isoformat()} offset {fmt_offset(moment.utcoffset())}")
print(f" as UTC {as_utc.isoformat()}")
print(f" back to London {back.isoformat()} <- NOT what you started with")
print("\n Python constructs the object without complaining, because a naive")
print(" wall reading plus a zone is a request, not a fact. The round trip")
print(" is where it shows: local -> UTC -> local does not come home.")
print(" fold=0 uses the offset in force BEFORE the gap, fold=1 the offset")
print(" after it, and neither answer is the time you asked for, because")
print(" the time you asked for did not occur.")
def show_job_firing_twice() -> None:
print()
print("=" * 68)
print("THE CONSEQUENCE — a job scheduled at 01:30 local")
print("=" * 68)
print("A scheduler that wakes every minute and fires when the local clock")
print("reads 01:30 will fire once on an ordinary day. Walk 25 October 2026")
print("minute by minute in real elapsed time and count:\n")
start = datetime(2026, 10, 25, 0, 0, tzinfo=LONDON).astimezone(UTC)
fires = []
for minute in range(5 * 60):
instant = start + timedelta(minutes=minute)
local = instant.astimezone(LONDON)
if (local.hour, local.minute) == (1, 30):
fires.append(instant)
print(f" the local clock read 01:30 {len(fires)} times:")
for instant in fires:
local = instant.astimezone(LONDON)
print(
f" {instant.isoformat()} UTC = {local.strftime('%H:%M')} "
f"{local.tzname()} (fold={local.fold})"
)
print("\n Two firings, one hour apart, from one schedule entry. If that job")
print(" charges a card, sends a statement or writes a daily partition, it")
print(" has now done it twice. On 29 March 2026 the same schedule fires")
print(" zero times, because 01:30 never arrives:")
spring_start = datetime(2026, 3, 29, 0, 0, tzinfo=LONDON).astimezone(UTC)
spring_fires = sum(
1
for minute in range(5 * 60)
if (lambda local: (local.hour, local.minute) == (1, 30))(
(spring_start + timedelta(minutes=minute)).astimezone(LONDON)
)
)
print(f" firings on 2026-03-29: {spring_fires}")
print("\n Schedule in UTC and you get exactly one firing on both days. That")
print(" is not a workaround; it is what 'daily at 01:30' actually meant.")
def show_arithmetic() -> None:
print()
print("=" * 68)
print("ARITHMETIC — 'one hour later' has two different meanings")
print("=" * 68)
start = datetime(2026, 10, 25, 0, 30, tzinfo=LONDON)
wall = start + timedelta(hours=1)
absolute = (start.astimezone(UTC) + timedelta(hours=1)).astimezone(LONDON)
print(f" start {start.isoformat()}")
print(f" + timedelta(hours=1) {wall.isoformat()} <- wall arithmetic")
print(f" + one hour of real time {absolute.isoformat()} <- elapsed time")
print(" Here they agree by luck: both land on the first 01:30. Cross the")
print(" transition and they part company:")
start2 = datetime(2026, 10, 25, 0, 30, tzinfo=LONDON)
wall2 = start2 + timedelta(hours=2)
abs2 = (start2.astimezone(UTC) + timedelta(hours=2)).astimezone(LONDON)
print(f" + timedelta(hours=2) {wall2.isoformat()}")
print(f" + two hours of real time {abs2.isoformat()}")
print(f" a whole hour apart: {wall2.astimezone(UTC) - abs2.astimezone(UTC)}")
print("\n timedelta arithmetic on an aware datetime adds to the WALL clock")
print(" fields and then re-derives the offset from the result, so two")
print(" hours of wall clock can be one, two or three hours of elapsed")
print(" time. To add elapsed time, convert to UTC, add,")
print(" and convert back. Decide which one you meant; both are legitimate.")
print(" 'The meeting is at 09:00 next Tuesday' is wall arithmetic. 'The")
print(" token expires in one hour' is elapsed time.")
def main() -> int:
show_ambiguous()
show_nonexistent()
show_job_firing_twice()
show_arithmetic()
print("\nEvery instant above is pinned in the source. Nothing read a clock.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/04_sorting.py (11173 bytes)
"""Why UTC ISO 8601 text sorts correctly, and what breaks when it is local.
Day 91 stored every timestamp in SQLite as ISO 8601 text in UTC and leaned on
one property: for that exact format, comparing the strings character by
character gives the same order as comparing the instants. This file proves it
and then breaks it in the two ways it can be broken.
Run: python3 examples/04_sorting.py
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
UTC = timezone.utc
LONDON = ZoneInfo("Europe/London")
NEW_YORK = ZoneInfo("America/New_York")
KOLKATA = ZoneInfo("Asia/Kolkata")
# Four events, each a real instant, each recorded by a different office.
EVENTS = [
("checkout", datetime(2026, 8, 16, 18, 0, tzinfo=UTC), NEW_YORK),
("dispatch", datetime(2026, 8, 16, 16, 0, tzinfo=UTC), LONDON),
("packed", datetime(2026, 8, 16, 15, 0, tzinfo=UTC), KOLKATA),
("ordered", datetime(2026, 8, 16, 11, 30, tzinfo=UTC), KOLKATA),
]
def utc_text(instant: datetime) -> str:
"""The storage format: UTC, fixed width, Z suffix, no offset to parse."""
return instant.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def local_text(instant: datetime, zone: ZoneInfo) -> str:
"""What you get if you store what the local clock said. Do not do this."""
return instant.astimezone(zone).strftime("%Y-%m-%dT%H:%M:%S")
def local_text_with_offset(instant: datetime, zone: ZoneInfo) -> str:
"""Local time with its offset. Correct, complete — and still sorts wrong."""
return instant.astimezone(zone).isoformat()
def show_utc_sorts() -> None:
print("=" * 68)
print("UTC ISO 8601 text: lexicographic order IS chronological order")
print("=" * 68)
by_text = sorted(utc_text(i) for _, i, _ in EVENTS)
by_instant = [utc_text(i) for _, i, _ in sorted(EVENTS, key=lambda e: e[1])]
for name, instant, _ in sorted(EVENTS, key=lambda e: e[1]):
print(f" {utc_text(instant)} {name}")
print(f"\n sorted as text == sorted as instants : {by_text == by_instant}")
print("\n It works because the format was designed so it would: the fields")
print(" run most-significant first, every one is zero-padded to a fixed")
print(" width, and the offset is always the same. Character 1 outranks")
print(" character 2 in exactly the way a year outranks a month.")
print(" That is what lets a database with no date type — SQLite, a CSV, a")
print(" key in a key-value store — do ORDER BY, MIN, MAX, BETWEEN and a")
print(" range scan on a plain text column and be right.")
def show_local_breaks() -> None:
print()
print("=" * 68)
print("Local text: lexicographic order is NOT chronological order")
print("=" * 68)
print(" Same four events, each stored as the local clock in its own office:\n")
rows = [(name, local_text(i, z), i) for name, i, z in EVENTS]
text_order = [name for name, _, _ in sorted(rows, key=lambda r: r[1])]
true_order = [name for name, _, _ in sorted(rows, key=lambda r: r[2])]
for name, text, instant in sorted(rows, key=lambda r: r[1]):
print(f" {text} {name}")
print(f"\n sorted as text : {text_order}")
print(f" sorted as instants : {true_order}")
print(f" same order? : {text_order == true_order}")
print("\n Reversed, in this case. The strings are all well-formed ISO 8601")
print(" and every one of them is true. They are simply not comparable to")
print(" each other, because they are measured against different rulers.")
def show_offset_text_also_breaks() -> None:
print()
print("=" * 68)
print("Local text WITH the offset: still not sortable as text")
print("=" * 68)
rows = [(name, local_text_with_offset(i, z), i) for name, i, z in EVENTS]
text_order = [name for name, _, _ in sorted(rows, key=lambda r: r[1])]
true_order = [name for name, _, _ in sorted(rows, key=lambda r: r[2])]
for name, text, _ in sorted(rows, key=lambda r: r[1]):
print(f" {text} {name}")
print(f"\n sorted as text : {text_order}")
print(f" sorted as instants : {true_order}")
print(f" same order? : {text_order == true_order}")
print("\n This is the subtle one. These strings carry their offsets, so")
print(" nothing is lost — a parser can recover every instant exactly. But")
print(" a text sort compares the digits left to right and never reaches")
print(" the offset on the end, so an index, a sorted file or an ORDER BY")
print(" over the raw column is still wrong. Losslessness and sortability")
print(" are different properties. UTC text has both.")
def show_ambiguous_collision() -> None:
print()
print("=" * 68)
print("The third failure: two instants, one local string")
print("=" * 68)
first = datetime(2026, 10, 25, 0, 30, tzinfo=UTC)
second = datetime(2026, 10, 25, 1, 30, tzinfo=UTC)
print(f" {utc_text(first)} -> London local {local_text(first, LONDON)}")
print(f" {utc_text(second)} -> London local {local_text(second, LONDON)}")
same = local_text(first, LONDON) == local_text(second, LONDON)
print(f"\n identical local strings? {same}")
print(" Two instants an hour apart collapse to one string, so no sort of")
print(" any kind can order them and no query can tell them apart. This is")
print(" not a sorting bug you can fix with a better comparator; the")
print(" information is gone at the moment of writing.")
def show_formats() -> None:
print()
print("=" * 68)
print("ISO 8601 and RFC 3339 are not the same thing")
print("=" * 68)
instant = datetime(2026, 10, 25, 1, 30, tzinfo=UTC)
print(" Renderings of one instant:")
print(f" isoformat() {instant.isoformat()}")
print(f" strftime Z form {utc_text(instant)}")
print(f" basic ISO 8601 form {instant.strftime('%Y%m%dT%H%M%SZ')}")
print(f" ordinal date (ISO 8601) {instant.strftime('%Y-%jT%H:%M:%SZ')}")
print(f" ISO week date {date(2026, 10, 25).isocalendar()}")
print()
print(" ISO 8601 is a large standard: it allows the basic form with no")
print(" separators, week dates, ordinal dates, durations, intervals and")
print(" reduced precision. RFC 3339 is a small profile of it for the")
print(" internet: date, T or a space, time, and a mandatory offset. Every")
print(" RFC 3339 timestamp is valid ISO 8601; the reverse is not true —")
print(" '2026-W43-7' is ISO 8601 and is not a timestamp at all.")
print(" Write RFC 3339 with Z. It is the intersection everything reads.")
print()
print(" What Python actually parses on this machine:")
samples = [
"2026-10-25T01:30:00+00:00",
"2026-10-25T01:30:00Z",
"2026-10-25 01:30:00Z",
"20261025T013000Z",
"2026-W43-7",
"2026-10-25T01:30:00+0100",
"Sun, 25 Oct 2026 01:30:00 GMT",
]
for text in samples:
try:
parsed = datetime.fromisoformat(text)
print(f" fromisoformat({text!r:<32}) -> {parsed.isoformat()}")
except ValueError as exc:
print(f" fromisoformat({text!r:<32}) -> ValueError: {exc}")
print()
print(" `fromisoformat` was strict before Python 3.11 and accepts most of")
print(" ISO 8601 from 3.11 onward, including the trailing Z. Note the")
print(" week-date line: it parses, and it silently becomes a midnight.")
print(" The last line is RFC 2822, the email date format, which")
print(" `fromisoformat` refuses — `email.utils.parsedate_to_datetime`")
print(" is the standard-library function for that one.")
def show_strptime_traps() -> None:
print()
print("=" * 68)
print("strftime and strptime: the two traps")
print("=" * 68)
print(" Trap 1 — %Z parses almost nothing, and throws the zone away:")
for name in ["UTC", "GMT", "BST", "EST"]:
text = f"2026-10-25 01:30:00 {name}"
try:
parsed = datetime.strptime(text, "%Y-%m-%d %H:%M:%S %Z")
print(f" {name:<4} -> {parsed!r}")
print(f" tzinfo is {parsed.tzinfo} — the zone name is gone")
except ValueError:
print(f" {name:<4} -> ValueError: does not match the format")
print(" %z with a numeric offset is the one that works:")
numeric = datetime.strptime("2026-10-25 01:30:00 +0100", "%Y-%m-%d %H:%M:%S %z")
print(f" {numeric!r}")
print(" And an abbreviation could not identify a zone even if it parsed:")
print(" IST is India, Ireland and Israel; CST is at least three places.")
print()
print(" Trap 2 — the order of the numbers is a cultural convention:")
text = "05/03/2026"
day_first = datetime.strptime(text, "%d/%m/%Y").date()
month_first = datetime.strptime(text, "%m/%d/%Y").date()
print(f" {text} parsed as %d/%m/%Y -> {day_first}")
print(f" {text} parsed as %m/%d/%Y -> {month_first}")
print(" Both parse. Both succeed. They are two months apart, and no")
print(" error is raised in either direction. This is exactly the failure")
print(" ISO 8601 was written to end.")
print()
print(" %c and %x follow the C locale, so their output changes with the")
print(f" environment: %c here gives {datetime(2026, 10, 25, 1, 30):%c}")
print(f" and %x gives {datetime(2026, 10, 25, 1, 30):%x}")
print(" Never write either into a file another program will read.")
def show_month_arithmetic() -> None:
print()
print("=" * 68)
print("'Add one month' is not a timedelta")
print("=" * 68)
start = datetime(2026, 1, 31, 12, 0, tzinfo=UTC)
print(f" start {start.date()}")
print(f" + timedelta(days=30) {(start + timedelta(days=30)).date()}")
print(f" + timedelta(days=31) {(start + timedelta(days=31)).date()}")
print("\n Neither is 'one month later', because a month is not a fixed")
print(" number of days — it is 28, 29, 30 or 31 depending on which one and")
print(" which year. timedelta carries days, seconds and microseconds and")
print(" deliberately has no months or years field, because it could not")
print(" give them a length.")
print("\n So 'the 31st of the next month' has to be a policy decision:")
print(" 31 January + 1 month = 28 February (clamp to the month end)?")
print(" or 3 March (overflow the extra days)?")
print(" or an error, because the caller has not said which they meant?")
print(" Pick one, write it down, and put it in a named function. The")
print(" standard library has calendar.monthrange to tell you the length;")
print(" it deliberately does not choose the policy for you.")
def main() -> int:
show_utc_sorts()
show_local_breaks()
show_offset_text_also_breaks()
show_ambiguous_collision()
show_formats()
show_strptime_traps()
show_month_arithmetic()
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/05_clocks.py (10001 bytes)
"""Two clocks, two jobs: wall-clock time and monotonic time.
Day 81 measured the drift in a scheduled job by subtracting wall-clock
readings. That works right up until the wall clock is adjusted underneath you
— by NTP correcting a few milliseconds, by a daylight-saving transition, by an
administrator typing `date`, by a laptop waking from sleep — at which point
the measurement is not merely imprecise, it can be negative.
`time.monotonic()` never goes backwards and is never adjusted. It has no
relationship to any calendar and cannot tell you the date. That is not a
limitation; it is the entire design.
Run: python3 examples/05_clocks.py
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
UTC = timezone.utc
LONDON = ZoneInfo("Europe/London")
def show_clock_properties() -> None:
print("=" * 68)
print("What the standard library says about each clock")
print("=" * 68)
print(f" {'clock':<16} {'monotonic':<10} {'adjustable':<11} implementation")
for name in ("time", "monotonic", "perf_counter", "process_time"):
info = time.get_clock_info(name)
print(
f" {name:<16} {str(info.monotonic):<10} {str(info.adjustable):<11} "
f"{info.implementation}"
)
print()
print(" Read the `adjustable` column. `time.time()` is adjustable, which")
print(" is a polite way of saying something else may move it while your")
print(" code is between two readings of it. `time.monotonic()` is not.")
print()
for name in ("time", "monotonic", "perf_counter"):
info = time.get_clock_info(name)
print(f" {name:<13} resolution {info.resolution:.9f} s")
def measure_with_both() -> tuple[float, float]:
print()
print("=" * 68)
print("Measuring the same piece of work with both")
print("=" * 68)
wall_start = time.time()
mono_start = time.monotonic()
total = 0
for value in range(2_000_000):
total += value
wall_elapsed = time.time() - wall_start
mono_elapsed = time.monotonic() - mono_start
print(f" the work summed to {total}")
print(f" time.time() measured {wall_elapsed:.6f} s")
print(f" time.monotonic() measured {mono_elapsed:.6f} s")
print(f" both positive: {wall_elapsed > 0 and mono_elapsed > 0}")
print("\n On an undisturbed machine they agree to within their resolution,")
print(" and that agreement is exactly what makes the bug invisible in")
print(" testing. The difference only appears on the day the clock moves.")
return wall_elapsed, mono_elapsed
def show_wall_clock_hazard() -> None:
print()
print("=" * 68)
print("What a wall-clock measurement does when the clock moves")
print("=" * 68)
print(" Nothing below changes your system clock — that would need root and")
print(" would be a rude thing for a lab to do. Instead it computes what a")
print(" wall-clock stopwatch WOULD have reported, using real transitions.\n")
scenarios = [
(
"autumn back in London: a task starting at 01:30 BST and",
" ending 20 real minutes later",
datetime(2026, 10, 25, 0, 30, tzinfo=UTC),
timedelta(minutes=20),
LONDON,
),
(
"a task spanning the whole repeated hour",
" (starts 01:00 BST, ends 01:15 GMT)",
datetime(2026, 10, 25, 0, 0, tzinfo=UTC),
timedelta(minutes=75),
LONDON,
),
(
"a task ending after the clocks go back",
" (starts 01:50 BST, ends 20 real minutes later at 01:10 GMT)",
datetime(2026, 10, 25, 0, 50, tzinfo=UTC),
timedelta(minutes=20),
LONDON,
),
(
"spring forward in London: a task over the gap",
" (starts 00:45 GMT, ends 25 real minutes later)",
datetime(2026, 3, 29, 0, 45, tzinfo=UTC),
timedelta(minutes=25),
LONDON,
),
]
for title, detail, start_utc, duration, zone in scenarios:
end_utc = start_utc + duration
local_start = start_utc.astimezone(zone).replace(tzinfo=None)
local_end = end_utc.astimezone(zone).replace(tzinfo=None)
naive_measure = local_end - local_start
print(f" {title}\n {detail}")
print(f" real elapsed time {duration}")
print(f" local clock at start / end {local_start.time()} / {local_end.time()}")
print(f" a naive local stopwatch reports {naive_measure}")
wrong = naive_measure != duration
print(f" wrong? {wrong}")
print()
print(" Three failures, three different shapes. The second under-reports")
print(" by a full hour: a job that ran for 75 minutes is logged as 15. The")
print(" third reports a NEGATIVE duration — minus forty minutes for work")
print(" that took twenty — and a retry loop written as `while elapsed <")
print(" timeout` never terminates on a negative elapsed. The fourth")
print(" over-reports by an hour, which is how a healthy job ends up paged")
print(" as a timeout. All three are one line of monotonic away from")
print(" correct, and all three pass every test you will ever run in June.")
def show_epoch() -> None:
print()
print("=" * 68)
print("Epoch seconds: what they are, and where they stop")
print("=" * 68)
instant = datetime(2026, 10, 25, 1, 30, tzinfo=UTC)
stamp = instant.timestamp()
print(f" {instant.isoformat()}")
print(f" epoch seconds {stamp:.0f}")
print(f" back again {datetime.fromtimestamp(stamp, UTC).isoformat()}")
print(f" the epoch itself {datetime.fromtimestamp(0, UTC).isoformat()}")
print(f" signed 32-bit limit {datetime.fromtimestamp(2**31 - 1, UTC).isoformat()}")
print("\n An epoch count is unambiguous by construction — it names an")
print(" instant with no zone, no offset and no wall clock anywhere in it.")
print(" It is also unreadable, which is a real cost: nobody spots that")
print(" 1792891800 is wrong by a month while reading a log.")
print()
print(" Two limits worth knowing. The first is that famous one above: a")
print(" signed 32-bit count of seconds runs out in January 2038, and code")
print(" storing seconds in an int32 anywhere still exists. The second is")
print(" quieter — Python's timestamp() returns a float, and a float has")
print(" 53 bits of mantissa:")
micro = datetime(2026, 8, 16, 9, 0, 0, 123456, tzinfo=UTC)
print(f" {micro.isoformat()}")
print(f" -> {micro.timestamp()!r}")
print(f" -> {datetime.fromtimestamp(micro.timestamp(), UTC).isoformat()}")
print(f" smallest representable step near now: {2**-52 * 1.79e9:.3e} s")
print(" Microseconds survive today. Nanoseconds do not, which is why")
print(" time.time_ns() exists and returns an integer.")
print(f" time.time_ns() returns an integer: {type(time.time_ns()).__name__}")
def show_leap_seconds() -> None:
print()
print("=" * 68)
print("Leap seconds: why Python does not model them")
print("=" * 68)
print(" A leap second is an extra second inserted into UTC to keep it in")
print(" step with the Earth's rotation, which is neither constant nor")
print(" predictable. When one is inserted, that UTC minute really does")
print(" contain 61 seconds, labelled 23:59:60.")
print()
print(" Ask Python for one:")
try:
datetime(2016, 12, 31, 23, 59, 60, tzinfo=UTC)
except ValueError as exc:
print(f" datetime(2016, 12, 31, 23, 59, 60) -> ValueError: {exc}")
print()
print(" Python's datetime implements POSIX time, in which every day has")
print(" exactly 86400 seconds by definition. A leap second is therefore")
print(" not representable, and epoch counts silently repeat or stretch a")
print(" second when one occurs. Most large operators now smear the extra")
print(" second across a whole day instead, so no clock ever shows :60.")
print()
print(" The practical position: if you are timing rocket launches or")
print(" reconciling financial trades to the microsecond you need TAI and a")
print(" specialist library. For everything else, treat 'a day has 86400")
print(" seconds' as true, know that it is an approximation, and use a")
print(" monotonic clock for any duration you actually care about.")
def show_recommendation() -> None:
print()
print("=" * 68)
print("Which clock, for which question")
print("=" * 68)
rows = [
("How long did this take?", "time.monotonic()", "never adjusted"),
("Has the timeout expired?", "time.monotonic()", "never goes backwards"),
("How fast is this function?", "time.perf_counter()", "highest resolution"),
("How much CPU did it use?", "time.process_time()", "excludes sleep"),
("When did this happen?", "datetime.now(timezone.utc)", "a calendar instant"),
("What should the log say?", "datetime.now(timezone.utc)", "comparable across hosts"),
]
print(f" {'question':<28} {'use':<28} why")
for question, tool, why in rows:
print(f" {question:<28} {tool:<28} {why}")
print()
print(" And the one to stop using: datetime.utcnow() returns a NAIVE")
print(" datetime holding UTC fields, which is the worst of both worlds —")
print(" it looks like a local time and is not one. It is deprecated.")
print(" Write datetime.now(timezone.utc) and get an aware one.")
def main() -> int:
show_clock_properties()
measure_with_both()
show_wall_clock_hazard()
show_epoch()
show_leap_seconds()
show_recommendation()
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/06_resolver.py (10606 bytes)
"""A zone-offset resolver from scratch, then the same questions to zoneinfo.
`zoneinfo` looks like magic until you write the thirty lines it is doing. A
compiled zone file is, in essence, a sorted list of UTC instants at which the
offset changes, plus the offset in force after each one. Given that list, both
directions are ordinary code:
* instant -> offset is a search: find the last transition at or before it.
* wall clock -> instant is a search with a twist: try each segment, and see
how many of them can produce that wall reading. One is the ordinary case.
Two is an ambiguous time. Zero is a nonexistent one.
The resolver below has no access to the database beyond a table you can read.
It is then checked against `zoneinfo` on every case, both values of `fold`.
Run: python3 examples/06_resolver.py
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
UTC = timezone.utc
# ---------------------------------------------------------------------------
# The rule table. Three columns, and that is genuinely all a zone is.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Transition:
"""At `instant` (UTC) the offset becomes `offset` and the name `name`."""
instant: datetime
offset: timedelta
name: str
@dataclass(frozen=True)
class ZoneRules:
"""A base offset, plus every instant at which it changes. Sorted."""
base_offset: timedelta
base_name: str
transitions: tuple[Transition, ...]
HOUR = timedelta(hours=1)
# Europe/London across 2026, written out by hand from the two transitions.
# The transition instants are in UTC because that is the only ruler that does
# not move: London's clocks change at 01:00 UTC in both directions.
LONDON_2026 = ZoneRules(
base_offset=timedelta(0),
base_name="GMT",
transitions=(
Transition(datetime(2026, 3, 29, 1, 0, tzinfo=UTC), HOUR, "BST"),
Transition(datetime(2026, 10, 25, 1, 0, tzinfo=UTC), timedelta(0), "GMT"),
),
)
# ---------------------------------------------------------------------------
# Direction 1: an instant is easy. There is exactly one answer, always.
# ---------------------------------------------------------------------------
def offset_at_instant(instant: datetime, rules: ZoneRules) -> tuple[timedelta, str]:
"""The offset in force at a UTC instant: the last transition at or before it."""
if instant.tzinfo is not UTC:
raise ValueError("pass a UTC instant")
offset, name = rules.base_offset, rules.base_name
for transition in rules.transitions:
if instant >= transition.instant:
offset, name = transition.offset, transition.name
else:
break
return offset, name
# ---------------------------------------------------------------------------
# Direction 2: a wall clock is hard, because it may name 0, 1 or 2 instants.
# ---------------------------------------------------------------------------
def segments(rules: ZoneRules) -> list[tuple[datetime | None, datetime | None, timedelta, str]]:
"""The timeline chopped into (start, end, offset, name) pieces.
`None` at either end means unbounded. Each piece is a stretch of UTC
during which the offset does not change.
"""
edges = [None, *[t.instant for t in rules.transitions], None]
offsets = [(rules.base_offset, rules.base_name)]
offsets += [(t.offset, t.name) for t in rules.transitions]
return [
(edges[i], edges[i + 1], offsets[i][0], offsets[i][1])
for i in range(len(offsets))
]
def candidates(wall: datetime, rules: ZoneRules) -> list[tuple[datetime, timedelta, str]]:
"""Every UTC instant whose local reading in this zone is `wall`.
The whole algorithm: for each segment, assume its offset applies, compute
the UTC instant that would produce this wall reading, and keep it only if
that instant really falls inside the segment. A candidate that lands
outside its own segment is a self-contradiction — it says "the offset was
+1 at a moment when the offset was not +1" — and is discarded. What
survives is 1, 2, or 0 answers.
"""
if wall.tzinfo is not None:
raise ValueError("pass a naive wall-clock reading")
found = []
for start, end, offset, name in segments(rules):
instant = wall.replace(tzinfo=UTC) - offset
if (start is None or instant >= start) and (end is None or instant < end):
found.append((instant, offset, name))
return found
def resolve(wall: datetime, rules: ZoneRules, fold: int = 0) -> tuple[timedelta, str, str]:
"""Resolve a wall reading to (offset, name, kind).
kind is one of "normal", "ambiguous", "nonexistent". The `fold` rule below
is PEP 495's, and it is worth stating precisely because it is the whole
specification in two sentences:
* ambiguous — fold=0 picks the FIRST of the two instants, fold=1 the
second.
* nonexistent — fold=0 uses the offset in force BEFORE the gap, fold=1
the offset after it. Neither produces the wall time you asked for,
because no instant does.
"""
found = candidates(wall, rules)
if len(found) == 1:
_, offset, name = found[0]
return offset, name, "normal"
if len(found) == 2:
_, offset, name = found[fold]
return offset, name, "ambiguous"
if not found:
pieces = segments(rules)
for index in range(len(pieces) - 1):
before, after = pieces[index], pieces[index + 1]
boundary = before[1]
if boundary is None:
continue
gap_start = boundary + before[2]
gap_end = boundary + after[2]
if gap_start <= wall.replace(tzinfo=UTC) < gap_end:
chosen = before if fold == 0 else after
return chosen[2], chosen[3], "nonexistent"
raise ValueError(f"no rule covers {wall}")
# ---------------------------------------------------------------------------
# The check: does the hand-written resolver agree with the real database?
# ---------------------------------------------------------------------------
CASES = [
(datetime(2026, 1, 15, 12, 0), "midwinter", "normal"),
(datetime(2026, 3, 29, 0, 59), "one minute before the gap", "normal"),
(datetime(2026, 3, 29, 1, 0), "the first instant of the gap", "nonexistent"),
(datetime(2026, 3, 29, 1, 30), "the middle of the gap", "nonexistent"),
(datetime(2026, 3, 29, 1, 59), "the last minute of the gap", "nonexistent"),
(datetime(2026, 3, 29, 2, 0), "the first instant after it", "normal"),
(datetime(2026, 7, 1, 12, 0), "midsummer", "normal"),
(datetime(2026, 10, 25, 0, 59), "one minute before the repeat", "normal"),
(datetime(2026, 10, 25, 1, 0), "the first repeated minute", "ambiguous"),
(datetime(2026, 10, 25, 1, 30), "the middle of the repeat", "ambiguous"),
(datetime(2026, 10, 25, 1, 59), "the last repeated minute", "ambiguous"),
(datetime(2026, 10, 25, 2, 0), "the first instant after it", "normal"),
(datetime(2026, 12, 25, 9, 0), "midwinter again", "normal"),
]
def fmt(delta: timedelta) -> str:
total = int(delta.total_seconds())
sign = "-" if total < 0 else "+"
total = abs(total)
return f"{sign}{total // 3600:02d}:{total % 3600 // 60:02d}"
def main() -> int:
london = ZoneInfo("Europe/London")
print("A hand-written resolver over a three-line rule table, checked")
print("against the real IANA database on every case and both folds.\n")
print(f"rule table: base {fmt(LONDON_2026.base_offset)} {LONDON_2026.base_name}")
for transition in LONDON_2026.transitions:
print(
f" at {transition.instant.isoformat()} -> "
f"{fmt(transition.offset)} {transition.name}"
)
print()
header = (
f"{'wall clock':<20} {'fold':<5} {'mine':<8} {'zoneinfo':<9} "
f"{'kind':<12} agree"
)
print(header)
print("-" * len(header))
disagreements = 0
kind_errors = 0
for wall, label, expected_kind in CASES:
for fold in (0, 1):
mine, name, kind = resolve(wall, LONDON_2026, fold=fold)
theirs = wall.replace(tzinfo=london, fold=fold).utcoffset()
agree = mine == theirs
if not agree:
disagreements += 1
if kind != expected_kind:
kind_errors += 1
print(
f"{wall.isoformat():<20} {fold:<5} {fmt(mine):<8} {fmt(theirs):<9} "
f"{kind:<12} {'yes' if agree else 'NO'}"
)
print()
print(f"cases: {len(CASES)} wall readings x 2 folds = {len(CASES) * 2} comparisons")
print(f"disagreements with zoneinfo: {disagreements}")
print(f"cases classified wrongly: {kind_errors}")
print("\nAnd the other direction, which has no ambiguity to resolve:")
for probe in [
datetime(2026, 3, 29, 0, 59, 59, tzinfo=UTC),
datetime(2026, 3, 29, 1, 0, 0, tzinfo=UTC),
datetime(2026, 10, 25, 0, 59, 59, tzinfo=UTC),
datetime(2026, 10, 25, 1, 0, 0, tzinfo=UTC),
]:
mine, name = offset_at_instant(probe, LONDON_2026)
theirs = probe.astimezone(london).utcoffset()
status = "agree" if mine == theirs else "DISAGREE"
print(
f" {probe.isoformat()} mine {fmt(mine)} {name:<4} "
f"zoneinfo {fmt(theirs)} {status}"
)
if mine != theirs:
disagreements += 1
print("\nWhat the real thing adds, and it is worth being honest about it:")
print(" * every zone in the database instead of one, and every recorded")
print(" change each of them has ever made, not just the two in 2026;")
print(" * a compiled binary file and a cache of loaded zones, instead of a")
print(" table typed out by hand and scanned from the top;")
print(" * the rule string at the end of each file, which extrapolates the")
print(" rules past the last stored transition into the future;")
print(" * and correct handling of the historical oddities — offsets that")
print(" were not whole minutes, zones that changed name without changing")
print(" offset, and days that were skipped entirely.")
print(" The ALGORITHM, though, is the one above. That is the whole trick.")
return 1 if (disagreements or kind_errors) else 0
if __name__ == "__main__":
raise SystemExit(main())
examples/07_solution.py (7885 bytes)
"""The reference answers to the ten starter exercises.
Read this AFTER you have tried them. It is marked by the same checker:
bash starter/02_check.sh examples/07_solution.py
which prints "10 of 10 exercises complete." and exits 0.
The comments explain the choice made, not the syntax used.
"""
from __future__ import annotations
import time
import zoneinfo
from dataclasses import dataclass
from datetime import date, datetime, time as time_of_day, timedelta, timezone
from typing import Callable
from zoneinfo import ZoneInfo
UTC = timezone.utc
# ===========================================================================
# GIVEN — identical to the starter.
# ===========================================================================
@dataclass(frozen=True)
class Transition:
"""At `instant` (a UTC instant) the offset becomes `offset`, named `name`."""
instant: datetime
offset: timedelta
name: str
@dataclass(frozen=True)
class ZoneRules:
"""The offset in force before the first transition, plus the transitions."""
base_offset: timedelta
base_name: str
transitions: tuple[Transition, ...]
HOUR = timedelta(hours=1)
LONDON_2026 = ZoneRules(
base_offset=timedelta(0),
base_name="GMT",
transitions=(
Transition(datetime(2026, 3, 29, 1, 0, tzinfo=UTC), HOUR, "BST"),
Transition(datetime(2026, 10, 25, 1, 0, tzinfo=UTC), timedelta(0), "GMT"),
),
)
def zone_count() -> int:
"""Exercise 1. The set includes links and aliases, which is correct: they
are all names you may legitimately be handed."""
return len(zoneinfo.available_timezones())
def to_utc_text(instant: datetime) -> str:
"""Exercise 2. Refusing the naive input is the important half.
A naive datetime has no offset, so converting it to UTC would mean
guessing which zone it came from — and the only guess available is the
machine's local zone, which changes when the code moves to a server. The
error is better than the guess.
"""
if instant.tzinfo is None or instant.utcoffset() is None:
raise ValueError("a naive datetime has no offset and cannot be written as UTC")
return instant.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
def day_length_hours(day: date, zone_name: str) -> float:
"""Exercise 3. Both midnights go to UTC before the subtraction.
Without those two .astimezone(UTC) calls this returns 24.0 for every day
ever, including the two it exists to detect.
"""
zone = ZoneInfo(zone_name)
start = datetime.combine(day, time_of_day(0, 0), tzinfo=zone)
end = datetime.combine(day + timedelta(days=1), time_of_day(0, 0), tzinfo=zone)
return (end.astimezone(UTC) - start.astimezone(UTC)).total_seconds() / 3600
def ambiguous_offsets(wall: datetime, zone_name: str) -> tuple[timedelta, timedelta]:
"""Exercise 4. Two folds, two offsets — equal on any ordinary reading."""
zone = ZoneInfo(zone_name)
return (
wall.replace(tzinfo=zone, fold=0).utcoffset(),
wall.replace(tzinfo=zone, fold=1).utcoffset(),
)
def is_nonexistent(wall: datetime, zone_name: str) -> bool:
"""Exercise 5. The round trip, which is the standard idiom.
Out to UTC and back. If the wall reading exists, it comes home. If it was
skipped, the return trip lands somewhere else, because the instant Python
picked for it renders as a different local time.
"""
zone = ZoneInfo(zone_name)
aware = wall.replace(tzinfo=zone)
round_tripped = aware.astimezone(UTC).astimezone(zone)
return round_tripped.replace(tzinfo=None) != wall
def is_ambiguous(wall: datetime, zone_name: str) -> bool:
"""Exercise 6. Compare the INSTANTS, and compare them with `<`.
Two things to get right, and the second one bites everybody once.
First: `wall.replace(tzinfo=zone, fold=0) == wall.replace(tzinfo=zone,
fold=1)` is True even in the repeated hour, because two datetimes sharing
a tzinfo object are compared by their wall fields. Convert to UTC before
comparing.
Second: `!=` is not enough, because the two folds also differ on a
NONEXISTENT reading. The direction is what separates them:
ambiguous fold=0 -> 00:30Z, fold=1 -> 01:30Z first < second
nonexistent fold=0 -> 01:30Z, fold=1 -> 00:30Z first > second
An ambiguous reading picks the earlier instant first, and a nonexistent
one picks the later, so a single `<` classifies both cases.
"""
zone = ZoneInfo(zone_name)
first = wall.replace(tzinfo=zone, fold=0).astimezone(UTC)
second = wall.replace(tzinfo=zone, fold=1).astimezone(UTC)
return first < second
def sorted_utc_texts(instants: list[datetime]) -> list[str]:
"""Exercise 7. A plain text sort. No key, no parsing, and still correct."""
return sorted(to_utc_text(instant) for instant in instants)
def measure_elapsed(work: Callable[[], object]) -> float:
"""Exercise 8. time.monotonic, because a duration is not a calendar fact.
time.time() would give the same answer nearly always and a wrong one — a
negative one — on the day something adjusts the clock underneath you.
"""
started = time.monotonic()
work()
return time.monotonic() - started
def offset_at_instant(instant: datetime, rules: ZoneRules) -> timedelta:
"""Exercise 9. One instant, one answer, no fold required."""
if instant.tzinfo is None:
raise ValueError("pass an aware instant")
instant = instant.astimezone(UTC)
offset = rules.base_offset
for transition in rules.transitions:
if instant >= transition.instant:
offset = transition.offset
else:
break
return offset
def _segments(rules: ZoneRules) -> list[tuple[datetime | None, datetime | None, timedelta]]:
"""The timeline as (start, end, offset) pieces; None means unbounded."""
edges: list[datetime | None] = [None]
edges += [transition.instant for transition in rules.transitions]
edges.append(None)
offsets = [rules.base_offset] + [t.offset for t in rules.transitions]
return [(edges[i], edges[i + 1], offsets[i]) for i in range(len(offsets))]
def resolve_wall(wall: datetime, rules: ZoneRules, fold: int = 0) -> tuple[timedelta, str]:
"""Exercise 10. Zero, one or two candidates — and that is the whole idea.
For each segment: assume its offset, compute the instant that would give
this wall reading, and keep it only if that instant lies inside the
segment. The count of survivors classifies the reading.
"""
if wall.tzinfo is not None:
raise ValueError("pass a naive wall-clock reading")
as_utc = wall.replace(tzinfo=UTC)
pieces = _segments(rules)
found = []
for start, end, offset in pieces:
instant = as_utc - offset
if (start is None or instant >= start) and (end is None or instant < end):
found.append(offset)
if len(found) == 1:
return found[0], "normal"
if len(found) == 2:
# Ordered by segment, so index 0 is the earlier instant. PEP 495 says
# fold=0 selects it.
return found[fold], "ambiguous"
# No candidate: the reading falls in a gap. PEP 495 says fold=0 uses the
# offset before the gap and fold=1 the offset after it.
for index in range(len(pieces) - 1):
boundary = pieces[index][1]
if boundary is None:
continue
before_offset = pieces[index][2]
after_offset = pieces[index + 1][2]
if boundary + before_offset <= as_utc < boundary + after_offset:
return (before_offset if fold == 0 else after_offset), "nonexistent"
raise ValueError(f"no rule in this table covers {wall}")
if __name__ == "__main__":
print("The reference answers. Mark them with:")
print(" bash starter/02_check.sh examples/07_solution.py")
metadata.yml (1184 bytes)
lesson_id: D095
day: 95
kind: guided-build
languages: [python, bash]
setup_commands:
- cd labs/sections/programming-with-python/day-095-dates-times-and-time-zones
- python3 --version
- 'python3 -c "import zoneinfo; print(len(zoneinfo.available_timezones()))"'
- 'python3 -c "from zoneinfo import ZoneInfo; print(ZoneInfo(''Europe/London''))"'
run_commands:
- bash tests/run_tests.sh
- bash starter/02_check.sh
- python3 examples/01_zone_database.py
- python3 examples/02_odd_days.py
- python3 examples/03_fold.py
- python3 examples/04_sorting.py
- python3 examples/05_clocks.py
- python3 examples/06_resolver.py
- bash starter/02_check.sh examples/07_solution.py
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- find . -type d -name __pycache__ -prune -exec rm -rf -- {} +
- 'git checkout -- starter/ # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-08-16'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, bash 3.2.57, IANA time zone database 2026c with 598 zones at /usr/share/zoneinfo — bash tests/run_tests.sh -> 75 checks, 0 failure(s), exit 0'
requirements/README.md (4643 bytes)
# Dependencies
**None.** `requirements.txt` lists no packages, and nothing is added to your
Python environment. The day's whole argument is that the standard library
already does this correctly, and that the hard part is knowing which question
you are asking.
| Tool | Version used here | Where it comes from | Licence |
| --- | --- | --- | --- |
| `python3` | 3.14.0 | Whatever Python you installed on Day 43. Standard library only: `datetime`, `zoneinfo`, `time`, `calendar`, `dataclasses`, `pathlib` | PSF licence |
| `bash` | 3.2.57 | Preinstalled on macOS; every Linux has it | GPL |
| IANA time zone database | 2026c | `/usr/share/zoneinfo`, shipped with the operating system | public domain |
Check all three:
```bash
python3 --version
bash --version | head -1
python3 -c "import zoneinfo; print(len(zoneinfo.available_timezones()))"
```
The third line printed `598` on the authoring machine. Yours may print a
different number, and that is not a problem — it is the day's subject.
## The dependency that is not a package
`zoneinfo` contains no time zone data. It is a reader for a database your
operating system installs, and `zoneinfo.TZPATH` lists where it looks:
```bash
python3 -c "import zoneinfo; print(zoneinfo.TZPATH)"
cat /usr/share/zoneinfo/+VERSION # on macOS and most Linux distributions
```
That database is updated several times a year, because governments change
their minds about daylight saving with about six weeks' notice and sometimes
less. Your operating system's update mechanism is what keeps it current: on
Debian and Ubuntu the package is `tzdata`, on Fedora and RHEL it is also
`tzdata`, and on macOS it arrives with system updates.
**This is the single most important operational fact in the lesson.** A
container image built two years ago and never rebuilt has a two-year-old
opinion about when the clocks change, and it will be confidently wrong on the
first Sunday a government has since moved.
## Minimum versions, and why
**Python 3.9 or newer** for `zoneinfo` itself, which arrived in 3.9 through
PEP 615. Before that the answer was the third-party `pytz` or `dateutil`.
**Python 3.11 or newer** for two things this lab uses:
- `datetime.fromisoformat` accepting the trailing `Z` and most of ISO 8601.
Before 3.11 it parsed only what `isoformat()` produced, and `"...Z"` raised
`ValueError` — a genuine and much-complained-about sharp edge.
- The `str | Path` and `timedelta | None` type-hint syntax in the example
files.
**`fold`, which the whole lab turns on, arrived in Python 3.6** through
PEP 495. Every supported Python has it.
Confirm the two that matter in one line:
```bash
python3 -c "from datetime import datetime; from zoneinfo import ZoneInfo; \
print(datetime.fromisoformat('2026-10-25T01:30:00Z'), \
datetime(2026,10,25,1,30,fold=1,tzinfo=ZoneInfo('Europe/London')).utcoffset())"
```
## Windows
Windows ships no IANA database. `zoneinfo` finds nothing on `TZPATH` and falls
back to a first-party PyPI package called `tzdata`, which contains the same
data as a Python package:
```bash
pip install tzdata
```
That package was created precisely so `zoneinfo` could work identically
everywhere. It is maintained by the Python core developers and updated when
IANA publishes a release. This lab was **not** run on native Windows and no
output is claimed for it; use WSL and follow the Linux path.
## If python3 is somewhere unusual
Both shell scripts take an override rather than guessing:
```bash
PYTHON=/path/to/python3 bash tests/run_tests.sh
PYTHON=/path/to/python3 bash starter/02_check.sh
```
They fail with that instruction rather than silently skipping checks.
## What is deliberately absent
**No `pytz`.** It was the right answer for a decade and it is now the wrong
one for new code. Its unusual `localize()` API exists because it predates
`fold` and had to solve the ambiguous hour without any help from the language.
The lesson covers what it did and why it worked that way; you do not need it
installed to understand the argument.
**No `dateutil`, `arrow` or `pendulum`.** All three are good libraries, all
three are covered in the lesson's Alternatives section from their
documentation, and none of them is installed here — so no output is reproduced
for any of them. What each one buys is stated; what none of them can do is
change the fact that 01:30 happened twice.
**No database and no scheduler.** The failures in the brief are a scheduler's
failures, and reproducing them with a real scheduler would take an afternoon
and teach you less than walking the minutes yourself, which is what
`examples/03_fold.py` does in eight lines.
requirements/requirements.txt (432 bytes)
# Day 095 — Dates, Times, and Time Zones
#
# This lab has no third-party dependencies. It uses python3 and its standard
# library only: datetime, zoneinfo, time and calendar.
#
# There is nothing to install on macOS or Linux. On Windows there is one
# exception, because Windows ships no IANA time zone database at all — see
# requirements/README.md for what to do about it and why the standard library
# was designed that way.
starter/00_brief.md (4249 bytes)
# The brief — "The Hour That Happened Twice"
Read this before you write any code. It is short, and it is the whole job.
## What happened
A small subscription business runs one job every night. It reconciles the
day's payments, writes a partition of rows into the warehouse, and emails a
summary. It has run at 01:30 every night for two years, and the schedule entry
says exactly that: **01:30, Europe/London**.
Three things went wrong on three different nights, and nobody connected them
for a month.
**On the night of 25 October, every customer was charged twice.** The job ran
at 01:30, finished, and ran again at 01:30. Both runs were legitimate: the
local clock genuinely read 01:30 twice that night, an hour apart, because the
clocks went back at 02:00.
**On the night of 29 March, the job did not run at all.** The clocks went
forward at 01:00, so the local clock went straight from 00:59 to 02:00 and
never showed 01:30. Nothing errored. Nothing alerted. The daily partition for
that date is simply missing, and the gap was found in June.
**And the monitoring dashboard, which measures how long the job takes by
subtracting two clock readings, reported a run of minus forty minutes** on the
October night and a run of eighty-five minutes on the March one. Both figures
went into the average.
Underneath all three is one sentence, and it is the sentence this lab exists
to make true for you:
> A timestamp without a time zone is not a time. It is a rumour.
## What you are going to do
Ten exercises in `starter/01_timezones.py`, marked by `bash starter/02_check.sh`.
They build, in order, everything the three failures needed:
| # | Function | What it proves |
| --- | --- | --- |
| 1 | `zone_count` | the rules live in a database on your disk, not in Python |
| 2 | `to_utc_text` | the storage format, and why a naive datetime cannot use it |
| 3 | `day_length_hours` | the 23-hour day and the 25-hour day, measured |
| 4 | `ambiguous_offsets` | one wall reading, two offsets |
| 5 | `is_nonexistent` | the hour of 29 March that nobody ever saw |
| 6 | `is_ambiguous` | the hour of 25 October that everybody saw twice |
| 7 | `sorted_utc_texts` | why UTC ISO text sorts chronologically as plain text |
| 8 | `measure_elapsed` | which clock a duration is measured with |
| 9 | `offset_at_instant` | the resolver, the easy direction |
| 10 | `resolve_wall` | the resolver, the direction with 0, 1 or 2 answers |
Exercises 9 and 10 are the from-scratch build. You are given a three-line rule
table — a base offset and two transitions — and you write the lookup that
`zoneinfo` performs against the real database. When you have finished, the
checker runs your resolver against `zoneinfo` on thirteen wall-clock readings
and both values of `fold`, twenty-six comparisons, and every one must agree.
That is the point of the day. `zoneinfo` stops being magic the moment you have
written the thirty lines it is doing.
## The rules of engagement
**Nothing reads the clock.** Every instant in your code arrives as an
argument. A test that calls `datetime.now()` passes on 363 days a year and
fails on the two this material is about, which is worse than no test.
**Every date here is real.** Europe/London moved its clocks forward at 01:00
UTC on 29 March 2026 and back at 01:00 UTC on 25 October 2026. Those two
instants come from the IANA database on your machine, and the exercises are
built around them. If your database is unusually old the dates still hold —
they were published years in advance — but run `python3
examples/01_zone_database.py` first and see what you actually have.
**The standard library only.** `datetime`, `zoneinfo`, `time`, `calendar`.
Nothing to install.
## Working order
```bash
bash tests/run_tests.sh # green before you start
bash starter/02_check.sh # says 0 of 10, and why
# work down starter/01_timezones.py, re-running the checker as you go
bash starter/02_check.sh examples/07_solution.py # the answers, afterwards
```
Try each exercise before reading the matching example. Reading a correct
answer to a question you have not yet asked yourself teaches almost nothing,
and this is a subject where almost everyone has to be wrong once before the
distinction lands.
starter/01_timezones.py (12084 bytes)
"""Day 095 starter — ten exercises. Your work goes in this file.
Read `starter/00_brief.md` first. Then work down this file, replacing each
`raise NotImplementedError(...)` with a real implementation, and re-running:
bash starter/02_check.sh
after each one. It will tell you how many of the ten are done, and for each
failure it prints what it wanted and what it got.
Rules for this file:
* Nothing here may read the clock. Every instant is passed in. A test that
calls `datetime.now()` cannot be trusted on the one day of the year this
material is about, which is the day the clocks change.
* The standard library only: `datetime`, `zoneinfo`, `time`, `calendar`.
* Do not edit the given code above the exercises, or the checker.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from typing import Callable
import zoneinfo
from zoneinfo import ZoneInfo
UTC = timezone.utc
# ===========================================================================
# GIVEN — the rule table types for exercises 9 and 10. Do not change these.
# ===========================================================================
@dataclass(frozen=True)
class Transition:
"""At `instant` (a UTC instant) the offset becomes `offset`, named `name`."""
instant: datetime
offset: timedelta
name: str
@dataclass(frozen=True)
class ZoneRules:
"""The offset in force before the first transition, plus the transitions."""
base_offset: timedelta
base_name: str
transitions: tuple[Transition, ...]
HOUR = timedelta(hours=1)
# Europe/London through 2026. London's clocks change at 01:00 UTC in both
# directions, which is why the instants below are round numbers in UTC and
# not in local time.
LONDON_2026 = ZoneRules(
base_offset=timedelta(0),
base_name="GMT",
transitions=(
Transition(datetime(2026, 3, 29, 1, 0, tzinfo=UTC), HOUR, "BST"),
Transition(datetime(2026, 10, 25, 1, 0, tzinfo=UTC), timedelta(0), "GMT"),
),
)
# ===========================================================================
# EXERCISE 1 — is the zone database actually here, and how big is it?
# ===========================================================================
def zone_count() -> int:
"""Return the number of time zones this machine's database offers.
One call does it: `zoneinfo.available_timezones()` returns a set of every
zone name available, and you want its length. On the authoring machine
this was 598; yours may differ, and the checker only requires it to be
over 100, because a database with fewer than that is not a real one.
Command to see it by hand:
python3 -c "import zoneinfo; print(len(zoneinfo.available_timezones()))"
"""
raise NotImplementedError("Exercise 1: count the zones in the database")
# ===========================================================================
# EXERCISE 2 — the storage format
# ===========================================================================
def to_utc_text(instant: datetime) -> str:
"""Render an aware datetime as RFC 3339 UTC text: 2026-10-25T01:30:00Z.
Two steps, and the first is the one people forget:
1. convert to UTC with `.astimezone(timezone.utc)`;
2. format with `strftime("%Y-%m-%dT%H:%M:%SZ")`.
Raise ValueError if `instant` is naive — a naive datetime has no offset,
so there is no honest way to write a Z on the end of it. Check it with
`instant.tzinfo is None or instant.utcoffset() is None`.
"""
raise NotImplementedError("Exercise 2: render an instant as RFC 3339 UTC text")
# ===========================================================================
# EXERCISE 3 — how long is a day, really?
# ===========================================================================
def day_length_hours(day: date, zone_name: str) -> float:
"""Return the real elapsed hours in one local calendar day, as a float.
The trap is that subtracting two local midnights gives 24.0 every time.
Convert both to UTC first, and the difference becomes true:
start = datetime.combine(day, time(0, 0), tzinfo=ZoneInfo(zone_name))
end = datetime.combine(day + timedelta(days=1), time(0, 0), ...)
elapsed = end.astimezone(UTC) - start.astimezone(UTC)
Expected: 23.0 for Europe/London on 2026-03-29, 25.0 on 2026-10-25,
and 24.0 on any ordinary day.
"""
raise NotImplementedError("Exercise 3: measure the real length of a local day")
# ===========================================================================
# EXERCISE 4 — the two offsets of an ambiguous wall clock
# ===========================================================================
def ambiguous_offsets(wall: datetime, zone_name: str) -> tuple[timedelta, timedelta]:
"""Return the offsets the same wall reading has at fold=0 and fold=1.
`wall` arrives naive. Attach the zone with `.replace(tzinfo=..., fold=...)`
and read `.utcoffset()` from each.
For 2026-10-25 01:30 in Europe/London this is
(timedelta(hours=1), timedelta(0)) — BST first, then GMT.
On an ordinary wall reading both entries are the same, which is the point:
fold only ever matters on the transition.
"""
raise NotImplementedError("Exercise 4: read both offsets of a wall reading")
# ===========================================================================
# EXERCISE 5 — did this wall clock ever happen?
# ===========================================================================
def is_nonexistent(wall: datetime, zone_name: str) -> bool:
"""True if this local wall reading never occurred in this zone.
The test is a round trip, and it is the standard one:
aware = wall.replace(tzinfo=zone)
back = aware.astimezone(UTC).astimezone(zone)
nonexistent = back.replace(tzinfo=None) != wall
If the time exists, going out to UTC and back brings you home. If it was
skipped, it does not.
True for 2026-03-29 01:30 in Europe/London. False for 01:30 on 28 March,
and false for 2026-10-25 01:30, which happened twice rather than never.
"""
raise NotImplementedError("Exercise 5: detect a wall time that never happened")
# ===========================================================================
# EXERCISE 6 — did this wall clock happen twice?
# ===========================================================================
def is_ambiguous(wall: datetime, zone_name: str) -> bool:
"""True if this local wall reading occurred twice in this zone.
Compare the two folds. If attaching the zone with fold=0 and with fold=1
gives two different UTC instants, the reading is ambiguous:
a = wall.replace(tzinfo=zone, fold=0).astimezone(UTC)
b = wall.replace(tzinfo=zone, fold=1).astimezone(UTC)
Two traps here, and the second is the interesting one.
Comparing the two AWARE datetimes directly returns True even when they
are an hour apart, because two datetimes with the same tzinfo object are
compared by wall clock. Compare the UTC conversions.
And `a != b` is not the answer, because the two folds differ on a
NONEXISTENT reading too. Compare their direction instead: an ambiguous
reading gives the earlier instant at fold=0, while a nonexistent one
gives the later. So `a < b` is the whole test.
True for 2026-10-25 01:30 in Europe/London, false for 2026-03-29 01:30.
"""
raise NotImplementedError("Exercise 6: detect a wall time that happened twice")
# ===========================================================================
# EXERCISE 7 — sorting text and getting chronology for free
# ===========================================================================
def sorted_utc_texts(instants: list[datetime]) -> list[str]:
"""Render each instant with `to_utc_text` and return the list sorted AS TEXT.
Use `sorted(...)` on the strings — a plain lexicographic sort, no key
function, no parsing back into datetimes. The exercise is to demonstrate
that you do not need to: for this format the text order and the
chronological order are the same order, which is why Day 91 could store
timestamps in a database with no date type and still write ORDER BY.
The checker compares your result against the instants sorted properly.
"""
raise NotImplementedError("Exercise 7: sort UTC ISO text lexicographically")
# ===========================================================================
# EXERCISE 8 — which clock measures a duration
# ===========================================================================
def measure_elapsed(work: Callable[[], object]) -> float:
"""Call `work()` once and return how long it took, in seconds.
Use `time.monotonic()` — read it before and after and subtract. Not
`time.time()`, which is adjustable and can move or go backwards while
your code is between the two readings.
The checker asserts three things: that the result is a float, that it is
not negative, and that this file mentions `time.monotonic`. You will need
to import `time` yourself — it is deliberately not imported above.
"""
raise NotImplementedError("Exercise 8: time a callable with a monotonic clock")
# ===========================================================================
# EXERCISE 9 — the resolver, direction 1: an instant has exactly one offset
# ===========================================================================
def offset_at_instant(instant: datetime, rules: ZoneRules) -> timedelta:
"""Return the offset in force at a UTC instant, using the rule table only.
No `zoneinfo` in this function. Start from `rules.base_offset`, walk
`rules.transitions` in order, and every time the transition's instant is
at or before `instant`, adopt that transition's offset. The transitions
are already sorted.
This direction is easy and always has exactly one answer, which is the
argument for storing instants rather than wall clocks.
"""
raise NotImplementedError("Exercise 9: resolve an instant to its offset")
# ===========================================================================
# EXERCISE 10 — the resolver, direction 2: a wall clock may have 0, 1 or 2
# ===========================================================================
def resolve_wall(wall: datetime, rules: ZoneRules, fold: int = 0) -> tuple[timedelta, str]:
"""Resolve a naive wall reading to (offset, kind) using the rule table only.
`kind` is "normal", "ambiguous" or "nonexistent".
The algorithm, which is the one `zoneinfo` runs:
1. Chop the timeline into segments — one per offset, bounded by the
transitions. For LONDON_2026 there are three: before 29 March (GMT),
between the two transitions (BST), and after 25 October (GMT).
2. For each segment, assume its offset applies and compute the instant
that would produce this wall reading: `wall_as_utc - offset`.
3. Keep the candidate only if that instant actually falls inside that
segment. A candidate outside its own segment contradicts itself.
4. One survivor: "normal". Two: "ambiguous" — fold picks which, 0 for
the earlier instant and 1 for the later. None: "nonexistent" — the
reading falls in the gap, and fold=0 means the offset before the gap
while fold=1 means the offset after it.
The checker runs this against `zoneinfo` for Europe/London on thirteen
wall readings and both folds, twenty-six comparisons, and every one must
agree. `examples/06_resolver.py` is a worked version; try it yourself
first, because reading it teaches you much less than writing it.
"""
raise NotImplementedError("Exercise 10: resolve a wall reading to an offset")
if __name__ == "__main__":
print("This file is a workbook, not a program. Check your work with:")
print(" bash starter/02_check.sh")
starter/02_check.sh (795 bytes)
#!/usr/bin/env bash
# Mark the ten exercises in starter/01_timezones.py.
#
# bash starter/02_check.sh # marks your starter file
# bash starter/02_check.sh some/other/file.py # marks a file of your choosing
#
# Prints "N of 10 exercises complete." and exits 0 only when N is 10.
# Set PYTHON=/path/to/python3 if python3 is somewhere unusual.
set -u
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
target="${1:-${lab_dir}/starter/01_timezones.py}"
python_bin="${PYTHON:-$(command -v python3 || true)}"
if [ -z "${python_bin}" ] || [ ! -x "${python_bin}" ]; then
echo "python3 was not found. Install Python 3.11+ or set PYTHON=/path/to/python3."
exit 1
fi
export PYTHONDONTWRITEBYTECODE=1
"${python_bin}" "${lab_dir}/starter/check_exercises.py" "${target}"
starter/check_exercises.py (9192 bytes)
"""Marks the ten exercises in a starter file. Do not edit this file.
Usage (through the wrapper, which is what you should run):
bash starter/02_check.sh
bash starter/02_check.sh path/to/some/other/file.py
It loads the file by path, calls each exercise function with pinned inputs,
and compares against values that do not depend on when you run it. An
unfinished exercise raises NotImplementedError and is reported as not done,
not as a crash.
"""
from __future__ import annotations
import importlib.util
import os
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
UTC = timezone.utc
LONDON = "Europe/London"
HOUR = timedelta(hours=1)
def load(path: Path):
spec = importlib.util.spec_from_file_location("starter_under_test", path)
if spec is None or spec.loader is None:
raise SystemExit(f"cannot load {path}")
module = importlib.util.module_from_spec(spec)
# Register it before executing: @dataclass looks the defining module up in
# sys.modules, and a module loaded by path is not there unless we put it
# there. Leaving this out fails with an AttributeError from dataclasses.
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class Marker:
def __init__(self) -> None:
self.done = 0
self.total = 0
self.notes: list[str] = []
def exercise(self, number: int, title: str, body) -> None:
self.total += 1
try:
body()
except NotImplementedError:
print(f" {number:>2}. not started {title}")
return
except AssertionError as exc:
print(f" {number:>2}. WRONG {title}")
print(f" {exc}")
return
except Exception as exc: # noqa: BLE001 - report anything, mark nothing
print(f" {number:>2}. ERROR {title}")
print(f" {type(exc).__name__}: {exc}")
return
self.done += 1
print(f" {number:>2}. complete {title}")
def equal(label: str, expected, actual) -> None:
assert expected == actual, f"{label}: expected {expected!r}, got {actual!r}"
def main() -> int:
target = Path(sys.argv[1] if len(sys.argv) > 1 else "starter/01_timezones.py")
if not target.is_file():
raise SystemExit(f"no such file: {target}")
source = target.read_text(encoding="utf-8")
module = load(target)
marker = Marker()
# Print a path relative to where the reader is standing, never an absolute
# one: absolute paths leak somebody's home directory into captured output.
relative = os.path.relpath(target, Path.cwd())
print(f"Marking {relative if not relative.startswith('..') else target.name}\n")
def ex1() -> None:
count = module.zone_count()
assert isinstance(count, int), f"expected an int, got {type(count).__name__}"
assert count > 100, f"expected more than 100 zones, got {count}"
import zoneinfo
equal("zone count", len(zoneinfo.available_timezones()), count)
def ex2() -> None:
equal(
"UTC instant",
"2026-10-25T01:30:00Z",
module.to_utc_text(datetime(2026, 10, 25, 1, 30, tzinfo=UTC)),
)
equal(
"converted from London BST",
"2026-08-16T11:00:00Z",
module.to_utc_text(datetime(2026, 8, 16, 12, 0, tzinfo=ZoneInfo(LONDON))),
)
try:
module.to_utc_text(datetime(2026, 8, 16, 12, 0))
except ValueError:
return
raise AssertionError("a naive datetime should raise ValueError")
def ex3() -> None:
equal("2026-03-29 London", 23.0, module.day_length_hours(date(2026, 3, 29), LONDON))
equal("2026-10-25 London", 25.0, module.day_length_hours(date(2026, 10, 25), LONDON))
equal("2026-06-15 London", 24.0, module.day_length_hours(date(2026, 6, 15), LONDON))
equal(
"2026-03-08 New York",
23.0,
module.day_length_hours(date(2026, 3, 8), "America/New_York"),
)
def ex4() -> None:
equal(
"the repeated hour",
(HOUR, timedelta(0)),
tuple(module.ambiguous_offsets(datetime(2026, 10, 25, 1, 30), LONDON)),
)
equal(
"an ordinary hour",
(HOUR, HOUR),
tuple(module.ambiguous_offsets(datetime(2026, 7, 1, 12, 0), LONDON)),
)
def ex5() -> None:
equal("2026-03-29 01:30", True, module.is_nonexistent(datetime(2026, 3, 29, 1, 30), LONDON))
equal("2026-03-29 02:30", False, module.is_nonexistent(datetime(2026, 3, 29, 2, 30), LONDON))
equal("2026-10-25 01:30", False, module.is_nonexistent(datetime(2026, 10, 25, 1, 30), LONDON))
equal("2026-06-15 01:30", False, module.is_nonexistent(datetime(2026, 6, 15, 1, 30), LONDON))
def ex6() -> None:
equal("2026-10-25 01:30", True, module.is_ambiguous(datetime(2026, 10, 25, 1, 30), LONDON))
equal("2026-10-25 02:30", False, module.is_ambiguous(datetime(2026, 10, 25, 2, 30), LONDON))
equal("2026-03-29 01:30", False, module.is_ambiguous(datetime(2026, 3, 29, 1, 30), LONDON))
equal("2026-06-15 01:30", False, module.is_ambiguous(datetime(2026, 6, 15, 1, 30), LONDON))
def ex7() -> None:
instants = [
datetime(2026, 8, 16, 18, 0, tzinfo=UTC),
datetime(2026, 8, 16, 16, 0, tzinfo=UTC),
datetime(2026, 8, 16, 15, 0, tzinfo=UTC),
datetime(2026, 8, 16, 11, 30, tzinfo=UTC),
]
expected = [
"2026-08-16T11:30:00Z",
"2026-08-16T15:00:00Z",
"2026-08-16T16:00:00Z",
"2026-08-16T18:00:00Z",
]
equal("sorted text", expected, module.sorted_utc_texts(instants))
def ex8() -> None:
marks: list[int] = []
elapsed = module.measure_elapsed(lambda: marks.append(1))
assert marks == [1], "the callable was not called exactly once"
assert isinstance(elapsed, float), f"expected a float, got {type(elapsed).__name__}"
assert elapsed >= 0.0, f"a duration cannot be negative: {elapsed}"
assert "time.monotonic" in source, "use time.monotonic(), not time.time()"
def ex9() -> None:
rules = module.LONDON_2026
cases = [
(datetime(2026, 1, 1, 12, 0, tzinfo=UTC), timedelta(0)),
(datetime(2026, 3, 29, 0, 59, 59, tzinfo=UTC), timedelta(0)),
(datetime(2026, 3, 29, 1, 0, 0, tzinfo=UTC), HOUR),
(datetime(2026, 7, 1, 12, 0, tzinfo=UTC), HOUR),
(datetime(2026, 10, 25, 0, 59, 59, tzinfo=UTC), HOUR),
(datetime(2026, 10, 25, 1, 0, 0, tzinfo=UTC), timedelta(0)),
(datetime(2026, 12, 25, 9, 0, tzinfo=UTC), timedelta(0)),
]
london = ZoneInfo(LONDON)
for instant, expected_offset in cases:
got = module.offset_at_instant(instant, rules)
equal(f"offset at {instant.isoformat()}", expected_offset, got)
equal(
f"zoneinfo agrees at {instant.isoformat()}",
instant.astimezone(london).utcoffset(),
got,
)
def ex10() -> None:
rules = module.LONDON_2026
london = ZoneInfo(LONDON)
walls = [
(datetime(2026, 1, 15, 12, 0), "normal"),
(datetime(2026, 3, 29, 0, 59), "normal"),
(datetime(2026, 3, 29, 1, 0), "nonexistent"),
(datetime(2026, 3, 29, 1, 30), "nonexistent"),
(datetime(2026, 3, 29, 1, 59), "nonexistent"),
(datetime(2026, 3, 29, 2, 0), "normal"),
(datetime(2026, 7, 1, 12, 0), "normal"),
(datetime(2026, 10, 25, 0, 59), "normal"),
(datetime(2026, 10, 25, 1, 0), "ambiguous"),
(datetime(2026, 10, 25, 1, 30), "ambiguous"),
(datetime(2026, 10, 25, 1, 59), "ambiguous"),
(datetime(2026, 10, 25, 2, 0), "normal"),
(datetime(2026, 12, 25, 9, 0), "normal"),
]
for wall, expected_kind in walls:
for fold in (0, 1):
offset, kind = module.resolve_wall(wall, rules, fold)
equal(f"kind of {wall.isoformat()}", expected_kind, kind)
equal(
f"offset of {wall.isoformat()} fold={fold}",
wall.replace(tzinfo=london, fold=fold).utcoffset(),
offset,
)
marker.exercise(1, "zone_count", ex1)
marker.exercise(2, "to_utc_text", ex2)
marker.exercise(3, "day_length_hours", ex3)
marker.exercise(4, "ambiguous_offsets", ex4)
marker.exercise(5, "is_nonexistent", ex5)
marker.exercise(6, "is_ambiguous", ex6)
marker.exercise(7, "sorted_utc_texts", ex7)
marker.exercise(8, "measure_elapsed", ex8)
marker.exercise(9, "offset_at_instant", ex9)
marker.exercise(10, "resolve_wall", ex10)
print(f"\n{marker.done} of {marker.total} exercises complete.")
return 0 if marker.done == marker.total else 1
if __name__ == "__main__":
raise SystemExit(main())
tests/run_tests.sh (20936 bytes)
#!/usr/bin/env bash
# Tests for the Day 095 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# Every check below asserts a REAL VALUE, and every instant it asserts about
# is pinned in this file. Nothing here reads the clock, because a suite that
# used "now" would pass 363 days a year and fail on the two that matter.
#
# What it asks:
#
# * is the IANA zone database actually present, and how many zones?
# * are the 23-hour and the 25-hour day really 23 and 25 hours?
# * does the ambiguous wall reading give two offsets and two instants?
# * does the nonexistent one fail to survive a round trip?
# * does UTC ISO text sort chronologically, and does local text not?
# * does the hand-written resolver agree with zoneinfo on all 26 cases?
# * does the starter report 0 of 10, and the reference solution 10 of 10?
#
# Nothing touches the network. Nothing needs sudo. Temporary work happens in
# mktemp -d and is removed in a trap, so a finished run leaves this directory
# exactly as it found it.
set -u
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
work=""
checks=0
failures=0
cleanup() { [ -n "${work}" ] && [ -d "${work}" ] && rm -rf "${work}"; }
trap cleanup EXIT INT TERM
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
# check_eq LABEL EXPECTED ACTUAL — prints what it wanted when it does not match.
check_eq() {
local label="$1" expected="$2" actual="$3"
checks=$((checks + 1))
if [ "${expected}" = "${actual}" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
echo " expected: ${expected}"
echo " actual: ${actual}"
failures=$((failures + 1))
fi
}
python_bin="${PYTHON:-$(command -v python3 || true)}"
if [ -z "${python_bin}" ] || [ ! -x "${python_bin}" ]; then
echo "python3 was not found. Install Python 3.11+ or set PYTHON=/path/to/python3."
exit 1
fi
export PYTHONDONTWRITEBYTECODE=1
work="$(mktemp -d)"
# py 'SOURCE' — run a snippet and echo its single line of output.
py() { "${python_bin}" -c "$1"; }
PRELUDE='
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
UTC = timezone.utc
L = ZoneInfo("Europe/London")
NY = ZoneInfo("America/New_York")
def daylen(y, m, d, z):
a = datetime.combine(date(y, m, d), time(0), tzinfo=z)
b = datetime.combine(date(y, m, d) + timedelta(days=1), time(0), tzinfo=z)
return (b.astimezone(UTC) - a.astimezone(UTC)).total_seconds() / 3600
'
echo "Day 095 — Dates, Times, and Time Zones"
echo "python3: $("${python_bin}" -c 'import sys; print(sys.version.split()[0])')"
echo "zones: $("${python_bin}" -c 'import zoneinfo; print(len(zoneinfo.available_timezones()))')"
echo "tzpath: $("${python_bin}" -c 'import zoneinfo; print(zoneinfo.TZPATH[0] if zoneinfo.TZPATH else "empty")')"
echo "work: a temporary directory, removed when this script exits"
echo
# ---------------------------------------------------------------------------
echo "1. The zone database is present and usable"
# ---------------------------------------------------------------------------
check "zoneinfo imports" \
"$(py 'import zoneinfo' >/dev/null 2>&1 && echo yes || echo no)"
check "Europe/London loads from the system database" \
"$(py 'from zoneinfo import ZoneInfo; ZoneInfo("Europe/London")' >/dev/null 2>&1 && echo yes || echo no)"
check "America/New_York loads" \
"$(py 'from zoneinfo import ZoneInfo; ZoneInfo("America/New_York")' >/dev/null 2>&1 && echo yes || echo no)"
zone_total="$(py 'import zoneinfo; print(len(zoneinfo.available_timezones()))')"
check "the database holds more than 100 zones (found ${zone_total})" \
"$([ "${zone_total}" -gt 100 ] && echo yes || echo no)"
check "a name that is not a zone raises ZoneInfoNotFoundError" \
"$(py 'from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try:
ZoneInfo("Europe/Atlantis")
except ZoneInfoNotFoundError:
raise SystemExit(0)
raise SystemExit(1)' >/dev/null 2>&1 && echo yes || echo no)"
# ---------------------------------------------------------------------------
echo
echo "2. The 23-hour day and the 25-hour day"
# ---------------------------------------------------------------------------
check_eq "Europe/London 2026-03-29 is 23 hours" "23.0" \
"$(py "${PRELUDE}"'print(daylen(2026, 3, 29, L))')"
check_eq "Europe/London 2026-10-25 is 25 hours" "25.0" \
"$(py "${PRELUDE}"'print(daylen(2026, 10, 25, L))')"
check_eq "Europe/London 2026-06-15 is an ordinary 24 hours" "24.0" \
"$(py "${PRELUDE}"'print(daylen(2026, 6, 15, L))')"
check_eq "America/New_York 2026-03-08 is 23 hours" "23.0" \
"$(py "${PRELUDE}"'print(daylen(2026, 3, 8, NY))')"
check_eq "America/New_York 2026-11-01 is 25 hours" "25.0" \
"$(py "${PRELUDE}"'print(daylen(2026, 11, 1, NY))')"
check_eq "the short and long days sum to exactly 48 hours" "48.0" \
"$(py "${PRELUDE}"'print(daylen(2026, 3, 29, L) + daylen(2026, 10, 25, L))')"
check_eq "Australia/Lord_Howe moves by half an hour, not one" "23.5" \
"$(py "${PRELUDE}"'print(daylen(2026, 10, 4, ZoneInfo("Australia/Lord_Howe")))')"
check_eq "subtracting two LOCAL midnights always says 24, even on 25 Oct" "24.0" \
"$(py "${PRELUDE}"'
a = datetime.combine(date(2026, 10, 25), time(0), tzinfo=L)
b = datetime.combine(date(2026, 10, 26), time(0), tzinfo=L)
print((b - a).total_seconds() / 3600)')"
# ---------------------------------------------------------------------------
echo
echo "3. The hour that happened twice"
# ---------------------------------------------------------------------------
check_eq "2026-10-25 01:30 London: offsets are +01:00 then +00:00" "1:00:00|0:00:00" \
"$(py "${PRELUDE}"'
w = datetime(2026, 10, 25, 1, 30)
print(w.replace(tzinfo=L, fold=0).utcoffset(), w.replace(tzinfo=L, fold=1).utcoffset(), sep="|")')"
check_eq "the two folds name two instants an hour apart" \
"2026-10-25T00:30:00+00:00|2026-10-25T01:30:00+00:00|1:00:00" \
"$(py "${PRELUDE}"'
w = datetime(2026, 10, 25, 1, 30)
a = w.replace(tzinfo=L, fold=0).astimezone(UTC)
b = w.replace(tzinfo=L, fold=1).astimezone(UTC)
print(f"{a.isoformat()}|{b.isoformat()}|{b - a}")')"
check_eq "the two epoch seconds differ by 3600" "3600.0" \
"$(py "${PRELUDE}"'
w = datetime(2026, 10, 25, 1, 30)
print(w.replace(tzinfo=L, fold=1).timestamp() - w.replace(tzinfo=L, fold=0).timestamp())')"
check_eq "and yet == says they are equal: compare in UTC, always" "True" \
"$(py "${PRELUDE}"'
w = datetime(2026, 10, 25, 1, 30)
print(w.replace(tzinfo=L, fold=0) == w.replace(tzinfo=L, fold=1))')"
check_eq "the tz names are BST then GMT" "BST|GMT" \
"$(py "${PRELUDE}"'
w = datetime(2026, 10, 25, 1, 30)
print(w.replace(tzinfo=L, fold=0).tzname(), w.replace(tzinfo=L, fold=1).tzname(), sep="|")')"
check_eq "a job firing when the local clock reads 01:30 fires twice" "2" \
"$(py "${PRELUDE}"'
start = datetime(2026, 10, 25, 0, 0, tzinfo=L).astimezone(UTC)
fires = sum(1 for m in range(300)
if (lambda x: (x.hour, x.minute) == (1, 30))((start + timedelta(minutes=m)).astimezone(L)))
print(fires)')"
# ---------------------------------------------------------------------------
echo
echo "4. The hour that never happened"
# ---------------------------------------------------------------------------
check_eq "2026-03-29 01:30 London does not survive a round trip" \
"2026-03-29T02:30:00+01:00" \
"$(py "${PRELUDE}"'
w = datetime(2026, 3, 29, 1, 30).replace(tzinfo=L)
print(w.astimezone(UTC).astimezone(L).isoformat())')"
check_eq "fold=0 uses the offset before the gap, fold=1 the offset after" \
"0:00:00|1:00:00" \
"$(py "${PRELUDE}"'
w = datetime(2026, 3, 29, 1, 30)
print(w.replace(tzinfo=L, fold=0).utcoffset(), w.replace(tzinfo=L, fold=1).utcoffset(), sep="|")')"
check_eq "a job firing at local 01:30 fires zero times on 2026-03-29" "0" \
"$(py "${PRELUDE}"'
start = datetime(2026, 3, 29, 0, 0, tzinfo=L).astimezone(UTC)
fires = sum(1 for m in range(300)
if (lambda x: (x.hour, x.minute) == (1, 30))((start + timedelta(minutes=m)).astimezone(L)))
print(fires)')"
check_eq "the fold order separates the two cases: ambiguous <, nonexistent >" \
"True|True" \
"$(py "${PRELUDE}"'
def pair(w):
return (w.replace(tzinfo=L, fold=0).astimezone(UTC), w.replace(tzinfo=L, fold=1).astimezone(UTC))
amb = pair(datetime(2026, 10, 25, 1, 30))
non = pair(datetime(2026, 3, 29, 1, 30))
print(f"{amb[0] < amb[1]}|{non[0] > non[1]}")')"
# ---------------------------------------------------------------------------
echo
echo "5. Sorting: UTC text works, local text does not"
# ---------------------------------------------------------------------------
SORT_PRELUDE="${PRELUDE}"'
KOL = ZoneInfo("Asia/Kolkata")
EVENTS = [
("checkout", datetime(2026, 8, 16, 18, 0, tzinfo=UTC), NY),
("dispatch", datetime(2026, 8, 16, 16, 0, tzinfo=UTC), L),
("packed", datetime(2026, 8, 16, 15, 0, tzinfo=UTC), KOL),
("ordered", datetime(2026, 8, 16, 11, 30, tzinfo=UTC), KOL),
]
def utc_text(i):
return i.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
'
check_eq "UTC ISO text sorted as TEXT equals sorted by instant" "True" \
"$(py "${SORT_PRELUDE}"'
by_text = sorted(utc_text(i) for _, i, _ in EVENTS)
by_instant = [utc_text(i) for _, i, _ in sorted(EVENTS, key=lambda e: e[1])]
print(by_text == by_instant)')"
check_eq "the UTC text order is the true chronological order" \
"ordered,packed,dispatch,checkout" \
"$(py "${SORT_PRELUDE}"'
rows = sorted(((utc_text(i), n) for n, i, _ in EVENTS))
print(",".join(n for _, n in rows))')"
check_eq "local text sorts into a DIFFERENT order — here, the reverse" \
"checkout,dispatch,ordered,packed" \
"$(py "${SORT_PRELUDE}"'
rows = sorted((i.astimezone(z).strftime("%Y-%m-%dT%H:%M:%S"), n) for n, i, z in EVENTS)
print(",".join(n for _, n in rows))')"
check_eq "local text WITH its offset also sorts wrongly" "False" \
"$(py "${SORT_PRELUDE}"'
by_text = [n for _, n in sorted((i.astimezone(z).isoformat(), n) for n, i, z in EVENTS)]
by_instant = [n for n, _, _ in sorted(EVENTS, key=lambda e: e[1])]
print(by_text == by_instant)')"
check_eq "two instants an hour apart collapse to one local string" "True" \
"$(py "${SORT_PRELUDE}"'
a = datetime(2026, 10, 25, 0, 30, tzinfo=UTC).astimezone(L).strftime("%Y-%m-%dT%H:%M:%S")
b = datetime(2026, 10, 25, 1, 30, tzinfo=UTC).astimezone(L).strftime("%Y-%m-%dT%H:%M:%S")
print(a == b)')"
# ---------------------------------------------------------------------------
echo
echo "6. Parsing, formatting and durations"
# ---------------------------------------------------------------------------
check_eq "fromisoformat accepts a trailing Z" "2026-10-25T01:30:00+00:00" \
"$(py 'from datetime import datetime; print(datetime.fromisoformat("2026-10-25T01:30:00Z").isoformat())')"
check_eq "%Z does not parse BST at all" "ValueError" \
"$(py 'from datetime import datetime
try:
datetime.strptime("2026-10-25 01:30:00 BST", "%Y-%m-%d %H:%M:%S %Z")
print("parsed")
except ValueError:
print("ValueError")')"
check_eq "%Z parses UTC and then throws the zone away" "None" \
"$(py 'from datetime import datetime
print(datetime.strptime("2026-10-25 01:30:00 UTC", "%Y-%m-%d %H:%M:%S %Z").tzinfo)')"
check_eq "%z with a numeric offset keeps it" "1:00:00" \
"$(py 'from datetime import datetime
print(datetime.strptime("2026-10-25 01:30:00 +0100", "%Y-%m-%d %H:%M:%S %z").utcoffset())')"
check_eq "one ambiguous date string, two valid parses, two months apart" \
"2026-03-05|2026-05-03" \
"$(py 'from datetime import datetime
a = datetime.strptime("05/03/2026", "%d/%m/%Y").date()
b = datetime.strptime("05/03/2026", "%m/%d/%Y").date()
print(f"{a}|{b}")')"
check_eq "31 January plus timedelta(days=30) is 2 March, not one month" "2026-03-02" \
"$(py 'from datetime import datetime, timedelta
print((datetime(2026, 1, 31) + timedelta(days=30)).date())')"
check_eq "a leap second is not representable" "ValueError" \
"$(py 'from datetime import datetime
try:
datetime(2016, 12, 31, 23, 59, 60)
print("accepted")
except ValueError:
print("ValueError")')"
check_eq "the signed 32-bit epoch runs out in January 2038" \
"2038-01-19T03:14:07+00:00" \
"$(py 'from datetime import datetime, timezone
print(datetime.fromtimestamp(2**31 - 1, timezone.utc).isoformat())')"
# ---------------------------------------------------------------------------
echo
echo "7. Wall clock against monotonic clock"
# ---------------------------------------------------------------------------
check_eq "time.monotonic() is monotonic and not adjustable" "True|False" \
"$(py 'import time
i = time.get_clock_info("monotonic")
print(f"{i.monotonic}|{i.adjustable}")')"
check_eq "time.time() is neither" "False|True" \
"$(py 'import time
i = time.get_clock_info("time")
print(f"{i.monotonic}|{i.adjustable}")')"
check "monotonic never goes backwards across 20000 samples" \
"$(py 'import time
last = time.monotonic()
for _ in range(20000):
now = time.monotonic()
if now < last:
raise SystemExit(1)
last = now
raise SystemExit(0)' >/dev/null 2>&1 && echo yes || echo no)"
check "a monotonic measurement of real work is positive" \
"$(py 'import time
s = time.monotonic()
t = sum(range(200000))
raise SystemExit(0 if time.monotonic() - s > 0 else 1)' >/dev/null 2>&1 && echo yes || echo no)"
check_eq "a wall-clock stopwatch across the repeated hour under-reports by 3600s" \
"-3600.0" \
"$(py "${PRELUDE}"'
start = datetime(2026, 10, 25, 0, 0, tzinfo=UTC)
end = start + timedelta(minutes=75)
naive = end.astimezone(L).replace(tzinfo=None) - start.astimezone(L).replace(tzinfo=None)
print((naive - timedelta(minutes=75)).total_seconds())')"
check_eq "and it can report a NEGATIVE duration for real work" "-2400.0" \
"$(py "${PRELUDE}"'
start = datetime(2026, 10, 25, 0, 50, tzinfo=UTC)
end = start + timedelta(minutes=20)
naive = end.astimezone(L).replace(tzinfo=None) - start.astimezone(L).replace(tzinfo=None)
print(naive.total_seconds())')"
# ---------------------------------------------------------------------------
echo
echo "8. The from-scratch resolver agrees with the real database"
# ---------------------------------------------------------------------------
"${python_bin}" "${lab_dir}/examples/06_resolver.py" >"${work}/resolver.txt" 2>&1
resolver_status=$?
check "examples/06_resolver.py exits 0" \
"$([ ${resolver_status} -eq 0 ] && echo yes || echo no)"
check_eq "26 comparisons were made" "yes" \
"$(grep -q "13 wall readings x 2 folds = 26 comparisons" "${work}/resolver.txt" && echo yes || echo no)"
check_eq "zero disagreements with zoneinfo" "disagreements with zoneinfo: 0" \
"$(grep "disagreements with zoneinfo" "${work}/resolver.txt")"
check_eq "zero cases classified wrongly" "cases classified wrongly: 0" \
"$(grep "cases classified wrongly" "${work}/resolver.txt")"
check_eq "no line in the comparison table says NO" "0" \
"$(grep -c " NO$" "${work}/resolver.txt" || true)"
check_eq "the resolver classifies the gap as nonexistent" "6" \
"$(grep -c "nonexistent" "${work}/resolver.txt")"
check_eq "and the repeat as ambiguous" "6" \
"$(grep -c "ambiguous " "${work}/resolver.txt")"
# The resolver is not vacuous: corrupt the rule table and it must disagree.
sed 's/datetime(2026, 10, 25, 1, 0, tzinfo=UTC)/datetime(2026, 10, 18, 1, 0, tzinfo=UTC)/' \
"${lab_dir}/examples/06_resolver.py" >"${work}/broken_resolver.py"
"${python_bin}" "${work}/broken_resolver.py" >"${work}/broken.txt" 2>&1
broken_status=$?
check "a resolver with a wrong transition date FAILS (proving the check is real)" \
"$([ ${broken_status} -ne 0 ] && echo yes || echo no)"
check "the broken run reports disagreements with zoneinfo" \
"$(grep -q "disagreements with zoneinfo: 0" "${work}/broken.txt" && echo no || echo yes)"
# ---------------------------------------------------------------------------
echo
echo "9. Every example script runs and prints what the lesson quotes"
# ---------------------------------------------------------------------------
for script in 01_zone_database 02_odd_days 03_fold 04_sorting 05_clocks; do
"${python_bin}" "${lab_dir}/examples/${script}.py" >"${work}/${script}.txt" 2>&1
status=$?
check "examples/${script}.py exits 0" \
"$([ ${status} -eq 0 ] && echo yes || echo no)"
done
check "01 reports the search path and a zone count" \
"$(grep -q "zones available here:" "${work}/01_zone_database.txt" && echo yes || echo no)"
check "02 shows the 23-hour and 25-hour London days" \
"$(grep -q "23.0h" "${work}/02_odd_days.txt" && grep -q "25.0h" "${work}/02_odd_days.txt" && echo yes || echo no)"
check "03 shows the local clock reading 01:30 twice" \
"$(grep -q "the local clock read 01:30 2 times" "${work}/03_fold.txt" && echo yes || echo no)"
check "03 shows zero firings on the spring-forward day" \
"$(grep -q "firings on 2026-03-29: 0" "${work}/03_fold.txt" && echo yes || echo no)"
check "04 proves the UTC text sort matches the instant sort" \
"$(grep -q "sorted as text == sorted as instants : True" "${work}/04_sorting.txt" && echo yes || echo no)"
check "05 reports monotonic as not adjustable" \
"$(grep -q "monotonic True False" "${work}/05_clocks.txt" && echo yes || echo no)"
# ---------------------------------------------------------------------------
echo
echo "10. The starter and its reference answers"
# ---------------------------------------------------------------------------
bash "${lab_dir}/starter/02_check.sh" "${lab_dir}/starter/01_timezones.py" \
>"${work}/starter.txt" 2>&1
starter_status=$?
check_eq "an untouched starter reports 0 of 10" "0 of 10 exercises complete." \
"$(grep "exercises complete" "${work}/starter.txt")"
check "an untouched starter exits non-zero" \
"$([ ${starter_status} -ne 0 ] && echo yes || echo no)"
check_eq "every exercise is reported as not started, not as an error" "10" \
"$(grep -c "not started" "${work}/starter.txt")"
bash "${lab_dir}/starter/02_check.sh" "${lab_dir}/examples/07_solution.py" \
>"${work}/solution.txt" 2>&1
solution_status=$?
check_eq "the reference answers report 10 of 10" "10 of 10 exercises complete." \
"$(grep "exercises complete" "${work}/solution.txt")"
check "the reference answers exit 0" \
"$([ ${solution_status} -eq 0 ] && echo yes || echo no)"
# And the marker is not vacuous either: break one answer, expect it caught.
sed 's/return first < second/return first != second/' \
"${lab_dir}/examples/07_solution.py" >"${work}/broken_solution.py"
bash "${lab_dir}/starter/02_check.sh" "${work}/broken_solution.py" \
>"${work}/broken_solution.txt" 2>&1
broken_solution_status=$?
check "a solution that confuses ambiguous with nonexistent is caught" \
"$([ ${broken_solution_status} -ne 0 ] && echo yes || echo no)"
check_eq "and it is reported as WRONG rather than as complete" "9 of 10 exercises complete." \
"$(grep "exercises complete" "${work}/broken_solution.txt")"
# ---------------------------------------------------------------------------
echo
echo "11. The lab is offline, unprivileged and leaves nothing behind"
# ---------------------------------------------------------------------------
check "no example or starter file imports a network module" \
"$(grep -rlE "^[[:space:]]*(import|from)[[:space:]]+(socket|urllib|http|requests|ftplib|smtplib)" \
"${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null | grep -q . && echo no || echo yes)"
check "nothing in the lab invokes sudo" \
"$(grep -rn "^[^#]*sudo " "${lab_dir}/examples" "${lab_dir}/starter" "${lab_dir}/tests" 2>/dev/null | grep -q . && echo no || echo yes)"
check "no URL appears in any script in this lab" \
"$(grep -rnE "https?://" "${lab_dir}/examples" "${lab_dir}/starter" "${lab_dir}/tests" 2>/dev/null | grep -q . && echo no || echo yes)"
check "no script this suite asserts on ever reads the clock for an instant" \
"$(grep -rn "datetime.now" "${lab_dir}/examples/01_zone_database.py" \
"${lab_dir}/examples/02_odd_days.py" "${lab_dir}/examples/03_fold.py" \
"${lab_dir}/examples/04_sorting.py" "${lab_dir}/examples/06_resolver.py" \
"${lab_dir}/examples/07_solution.py" \
2>/dev/null | grep -q . && echo no || echo yes)"
check "no __pycache__ directory was left in the lab" \
"$([ -z "$(find "${lab_dir}" -type d -name __pycache__ 2>/dev/null)" ] && echo yes || echo no)"
check "no stray files were created in the lab directory" \
"$([ -z "$(find "${lab_dir}" -maxdepth 1 -type f ! -name 'README.md' ! -name 'metadata.yml' ! -name 'security.md' ! -name 'troubleshooting.md' 2>/dev/null)" ] && echo yes || echo no)"
# ---------------------------------------------------------------------------
echo
if [ "${failures}" -eq 0 ]; then
echo "${checks} checks, ${failures} failure(s)."
exit 0
fi
echo "${checks} checks, ${failures} failure(s)."
exit 1
Troubleshooting
Troubleshooting — Day 095
Grouped by the message you actually see, or by the wrong answer you actually get. The second group is longer, because this subject fails quietly far more often than it raises anything.
Errors you can see
ZoneInfoNotFoundError: 'No time zone found with key Europe/London'
Your machine has no IANA time zone database where zoneinfo looks. Check:
python3 -c "import zoneinfo; print(zoneinfo.TZPATH)"
ls /usr/share/zoneinfo | head
- Linux — install your distribution's
tzdatapackage. - A slim container image — this is the usual cause.
python:3.x-slimand Alpine images often ship withouttzdata. Add it in the Dockerfile, or add thetzdataPyPI package as a dependency. - Windows — there is no system database, by design.
pip install tzdata.
ModuleNotFoundError: No module named 'zoneinfo'
You are on Python 3.8 or older. zoneinfo arrived in 3.9. Upgrade, or use the
backports.zoneinfo package on the older interpreter.
ValueError: Invalid isoformat string: '2026-10-25T01:30:00Z'
You are on Python 3.10 or older, where fromisoformat did not accept the
trailing Z. Two options: upgrade to 3.11+, or replace the suffix first —
text.replace("Z", "+00:00") — which is the workaround that was everywhere
for years.
ValueError: time data '...' does not match format '%Y-%m-%d %H:%M:%S %Z'
%Z parses far less than people expect: on this machine it accepted only
UTC and GMT, and rejected BST and EST. Even when it succeeds it
produces a naive datetime — the zone name is parsed and then discarded.
Use %z with a numeric offset (+0100), or better, use ISO 8601 text and
fromisoformat.
ValueError: second must be in 0..59, not 60
You have been handed a leap second — 23:59:60. Python's datetime cannot
represent one, because it implements POSIX time in which every day has exactly
86400 seconds. If the input is a real UTC timestamp containing :60, the
usual handling is to clamp it to :59 and record that you did.
TypeError: can't subtract offset-naive and offset-aware datetimes
You mixed a naive datetime with an aware one. This is the one time-zone mistake Python does catch for you, and it is a gift. Make the naive one aware by attaching the zone it actually came from — not the machine's local zone just because that is easiest.
AttributeError: 'NoneType' object has no attribute '__dict__' from dataclasses
You are loading a module by file path without registering it in sys.modules
first. @dataclass looks its defining module up there. starter/check_exercises.py
has the two-line fix, with a comment.
Wrong answers with no error at all
This is where the day lives.
Every day measures 24 hours, including 29 March
You subtracted two local datetimes. That gives the wall-clock difference, which is 24 hours by construction on every day of the year. Convert both to UTC before subtracting:
(end.astimezone(UTC) - start.astimezone(UTC))
Two datetimes an hour apart compare equal
Expected, documented, and the cause of more than one production bug. Two aware
datetimes with the same tzinfo object are compared by their wall-clock
fields, and fold is ignored in that comparison. Convert both to UTC before
comparing, sorting, or using them as dictionary keys.
is_ambiguous also returns True for the nonexistent hour
fold=0 and fold=1 give different instants in both cases, so != cannot
tell them apart. The direction can: an ambiguous reading gives the earlier
instant at fold=0, a nonexistent one gives the later. Test first < second.
A scheduled job runs twice, or not at all
The schedule is expressed in local time and the local clock repeated or skipped the hour. Schedule in UTC. If the requirement genuinely is "09:00 local whatever happens", store the zone name alongside the local time and recompute the next firing instant after every run — never store a pre-computed list of future instants, because the database can change underneath it.
A duration comes out negative, or an hour too long
You measured with time.time() or by subtracting two local datetimes. Use
time.monotonic() for any elapsed time. The rule is short: wall clock for
when, monotonic for how long.
A sort by timestamp gives the wrong order
Your column holds local time. Text order equals chronological order only for UTC ISO 8601 at a fixed width. Local text sorts wrongly even when the offset is included, because the comparison never reaches the offset on the end. Store UTC; convert at display.
Timestamps drift by an hour after a deployment
Two likely causes, and it is worth checking both. Either the process's TZ
environment variable differs between machines and something is using local
time implicitly, or the container image's tzdata is older than the change a
government has since made. python3 examples/01_zone_database.py prints the
database version; compare it across your environments.
The whole test suite passes today and fails in October
It reads the clock somewhere. Every instant in a test about time zones must be
written out in the source. Grep your suite for now(), today() and
utcnow(); every hit is a test that will fail on a date you cannot control.
Checking the environment quickly
python3 --version
python3 -c "import zoneinfo; print(zoneinfo.TZPATH, len(zoneinfo.available_timezones()))"
cat /usr/share/zoneinfo/+VERSION 2>/dev/null || echo "no +VERSION file"
python3 examples/01_zone_database.py
If tests/run_tests.sh fails on a value rather than on an error, read
expected-output/FIELDS.md first: it separates the values that must match on
any machine from the ones that are legitimately allowed to differ on yours.
Security notes
Security notes — Day 095
What this lab does to your machine
Almost nothing, and the test suite checks each claim rather than promising it.
- No network. Nothing here opens a socket. The suite asserts that no file
in
examples/orstarter/importssocket,urllib,http,requests,ftpliborsmtplib, and that no URL appears anywhere in the lab's.pyor.shfiles at all. - No privilege. Nothing runs
sudo, and the suite greps for a line that would actually invoke it rather than a comment saying it does not. In particular, nothing in this lab changes your system clock or your system time zone — both would need root, and a lab that moved your clock would be a rude thing to run. Every "what if the clock jumped" demonstration computes the answer from real transition data instead. - No credentials. There is no account, no key, no token and no service.
- No installation.
requirements.txtlists no packages. The one exception is Windows, wherezoneinfoneeds thetzdatapackage because Windows ships no IANA database; that is documented inrequirements/README.mdand is not required for the macOS or Linux path. - No mess. The suite works inside
mktemp -dand removes it in atrap,PYTHONDONTWRITEBYTECODE=1is exported so no__pycache__appears, and the suite asserts afterwards that no__pycache__and no stray file was left in the lab directory.
The data in this lab
There is none. No personal data of any kind appears here: no names, no email addresses, no records. The only data are calendar instants — public facts about when governments moved their clocks — and four invented order events labelled "ordered", "packed", "dispatch" and "checkout".
Timestamps are personal data more often than people expect
This is the day's own security point, and it is worth stating plainly because timestamps do not feel like personal data.
A precise timestamp plus a local time zone is a location signal. Storing
Europe/London next to a user's activity says roughly where that person is,
and a change in that field says they moved. Storing UTC instants and rendering
in the viewer's zone at display time gives you the same product with none of
that inference stored.
A sequence of timestamps is a behavioural profile. When somebody logs in, how long they stay, whether they work at 02:00 — none of that is anybody's name and all of it identifies a person quite well. Timestamps at microsecond precision are also a strong fingerprint for correlating one person's records across two systems that share nothing else.
Precision is a decision. If your feature needs "which day did this happen", storing the second and the microsecond is data you have chosen to hold and must now protect, retain and delete correctly. Round at the point of collection; you cannot un-collect later.
The security-relevant failures that time causes
Three of these are real classes of vulnerability rather than merely bugs, and all three are the direct consequence of what this lab teaches.
Expiry checks that use the wall clock. A token that expires "one hour from
now" evaluated against time.time() can be handed an extra hour of validity
by a clock adjustment, and a session that should have ended can be extended by
moving the clock. Durations belong on a monotonic clock; absolute expiry
belongs on a UTC instant that no local rule can move.
Signature and certificate windows. "Valid from" and "valid until" are absolute instants. Comparing them against a naive local datetime means the comparison is off by the offset — an hour or several — and in the wrong direction it accepts something that has expired. Compare aware instants, in UTC, always.
Retries and lockouts. "Three failed attempts in five minutes" measured with a wall clock can be reset by a clock jump, and on the 25 October night the five-minute window genuinely covers sixty-five minutes of real time.
Two smaller sharp edges
datetime.utcnow() is deprecated, and it was always a trap. It returns a
naive datetime holding UTC field values, so it looks exactly like a local
time and compares wrongly against one with no error raised. Write
datetime.now(timezone.utc) and get an aware value that cannot be
accidentally compared to a local one.
Parsing untrusted timestamps. datetime.fromisoformat and
strptime raise ValueError on rubbish, which is the correct behaviour and
also means an unhandled one is a denial of service on a request handler. Catch
it. And prefer fromisoformat over hand-rolled parsing: a regular expression
over date text is one of the more reliable ways to write a
catastrophically backtracking pattern.
Cleanup
find . -type d -name __pycache__ -prune -exec rm -rf -- {} +
Nothing else was created, nothing was installed, and nothing outside this
directory was touched. If you only ran the two shell scripts, there is nothing
to clean up at all — they set PYTHONDONTWRITEBYTECODE=1, work in a temporary
directory, and the suite asserts that the lab directory is unchanged
afterwards.