Programming with PythonData Formats and Pipelines › Day 95

Hands-on lab — Day 95: 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

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:

  1. 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.
  2. Build the 23-hour day and the 25-hour day and assert their lengths.
  3. Find the wall-clock time that never happened and watch a round trip fail to come home.
  4. Produce the two distinct instants that both render as 01:30 and separate them with fold.
  5. 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.
  6. Measure elapsed time with time.time() and time.monotonic() and say which one you would trust, with the reason.
  7. Implement the offset resolver from scratch and assert it agrees with zoneinfo on twenty-six comparisons.

Learning objectives

By the end of this lab you will be able to:

  • Show where zoneinfo gets its data, name the database, read its version, and say what happens when it is out of date.
  • Explain what a naive datetime is, 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 fold selects 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: %Z parsing 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 python3 on your PATH.
  • 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. pytz is 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

  1. bash tests/run_tests.sh ends with 75 checks, 0 failure(s). and exits 0.
  2. python3 -c "import zoneinfo; print(len(zoneinfo.available_timezones()))" prints a number over 100. It printed 598 here.
  3. 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.
  4. 2026-10-25 01:30 in Europe/London gives offset +01:00 at fold=0 and +00:00 at fold=1, naming two instants exactly one hour apart.
  5. 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.
  6. 2026-03-29 01:30 in Europe/London does not survive a round trip through UTC: it comes back as 02:30+01:00.
  7. A schedule matching local 01:30 fires twice on 25 October 2026 and zero times on 29 March 2026.
  8. 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.
  9. %Z raises ValueError on BST, and on UTC it succeeds and leaves tzinfo as None.
  10. datetime(2016, 12, 31, 23, 59, 60) raises ValueError.
  11. examples/06_resolver.py exits 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.
  12. 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 with zoneinfo no matter what its rule table said would be checking nothing.
  • The suite copies examples/07_solution.py, replaces first < second with first != second in is_ambiguous — the single most likely mistake in the whole lab — and requires the marker to catch it and report 9 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. Install tzdata.
  • ValueError: Invalid isoformat string: '...Z' — Python 3.10 or older; fromisoformat did not accept the trailing Z until 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 tzinfo means comparison by wall clock, and fold is ignored. Compare in UTC.
  • is_ambiguous is also true for the skipped hour!= cannot separate them; the fold order can. Test first < 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

  1. Extend the rule table backwards and find something strange. Add Europe/London's transitions for 1968 to 1972 to LONDON_2026 and re-run your resolver against zoneinfo for wall readings in those years. The three winters at +01:00 are in examples/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 your resolve_wall still terminate correctly on the first one?
  2. 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 — using calendar.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.
  3. 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.
  4. 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 where utcoffset().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.
  5. 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.
  • 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 tzdata package.
  • A slim container image — this is the usual cause. python:3.x-slim and Alpine images often ship without tzdata. Add it in the Dockerfile, or add the tzdata PyPI 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/ or starter/ imports socket, urllib, http, requests, ftplib or smtplib, and that no URL appears anywhere in the lab's .py or .sh files 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.txt lists no packages. The one exception is Windows, where zoneinfo needs the tzdata package because Windows ships no IANA database; that is documented in requirements/README.md and is not required for the macOS or Linux path.
  • No mess. The suite works inside mktemp -d and removes it in a trap, PYTHONDONTWRITEBYTECODE=1 is 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.