Skip to main content

xtr-clock

A clock an application can be handed, instead of the one it is standing on.

python 3.11+ core dependencies: 0 coverage 100% typed license MIT

Why?

Reading the time is an input like any other. Code that reaches for the operating system to get it is code you cannot tell what time it is — so a test about a token expiring in an hour either waits an hour, or reaches for a library that rewrites the interpreter underneath it.

Take a clock as a constructor argument and the choice becomes configuration: the system clock in production, a frozen one in a test. What comes back is a DatePoint — a datetime, so nothing downstream has to change.

  • 🪶 No dependencies. Not one. zoneinfo, contextvars and datetime are already there.
  • 🕐 A DatePoint is a datetime. It drops into an ORM column, a serializer, a comparison, an existing signature. Adopting this library changes no type you already have.
  • 🧭 Always timezone-aware. There is no such thing here as an instant whose offset is unknown, which is what makes comparing two of them always mean something.
  • ⏱️ Frozen time costs nothing. clock.sleep(86400) returns immediately and the clock is a day later.
  • 🔀 Blocking or awaiting. sleep() and sleep_async() on the same clock, so one contract serves a worker and a web handler.
  • 🧩 Protocol-based. Every collaborator is a constructor argument, so a DI container can own the graph — and a clock from another library satisfies SupportsNow as it is.
expires_at = self._clock.now().modify("+1 hour")

Whether that is the real hour or an instant one in a test is a constructor argument.

Install

uv add xtr-clock                 # everything
uv add "xtr-clock[tzdata]"       # + a timezone database, where the system has none
Extra Brings For
(none) — The whole library
tzdata tzdata Windows, and slim containers that ship no zone database

Requires Python 3.11+.

Quick start

A time-sensitive class asks for a clock and nothing else:

from dataclasses import dataclass

from xtr_clock import ClockInterface, DatePoint


@dataclass(frozen=True, slots=True)
class TokenIssuer:
    clock: ClockInterface

    def issue(self) -> DatePoint:
        return self.clock.now().modify("+1 hour")

In production it gets the real one:

from xtr_clock import SystemClock

issuer = TokenIssuer(SystemClock())

In a test it gets one that stands still, and the test is about the hour rather than spent waiting for it:

from xtr_clock import MockClock


def test_a_token_expires_in_an_hour() -> None:
    issuer = TokenIssuer(MockClock("2024-04-09 12:00:00"))

    assert issuer.issue().isoformat() == "2024-04-09T13:00:00+00:00"

Nothing about TokenIssuer changed, and nothing patched the interpreter to get there.

Clocks

Four, and the differences between them are the point.

Clock Reads Use for
SystemClock The operating system's wall clock Production. Recording when something happened
MonotonicClock A counter that only moves forward Measuring how long something took
MockClock Whatever it was told, until told otherwise Tests
Clock Whatever is in force, or a clock from elsewhere Adapting, and code nothing can be handed to

Each answers the same four questions:

clock.now()  # a DatePoint, always aware
clock.sleep(2.5)  # block; a MockClock returns at once, 2.5s later
await clock.sleep_async(2.5)  # the same, without holding the event loop
clock.with_timezone("Europe/Paris")  # a copy that reports in another zone

with_timezone returns a copy, so pinning a zone for one class does not change the time anybody else reads.

Wall clock or monotonic

A wall clock jumps. A time daemon corrects a drift, an administrator fixes the date, a zone changes its mind about daylight saving. Subtract two readings taken either side of one of those and the duration is wrong — occasionally negative.

MonotonicClock anchors to the wall clock once, when it is built, and then reports that anchor plus however far the machine's monotonic counter has moved. It is the right clock for a timeout and the wrong one for a timestamp, because it drifts from the wall clock by exactly the correction it refused to follow.

A clock from somewhere else

Anything with a now() satisfies SupportsNow, which is the whole contract for adopting one:

from xtr_clock import Clock

clock = Clock(some_other_libraries_clock)  # now a full ClockInterface
clock.now()  # a DatePoint, whatever it answered with

Clock fills in what the wrapped object cannot do. Asked to sleep, it hands the request on if the wrapped clock knows how, and lets real time pass if it does not.

Instants

DatePoint is a datetime subclass, which is the most important thing about it. Every method you already use works, and every result is still a DatePoint:

point = clock.now()

point.replace(hour=9)  # DatePoint
point + timedelta(days=1)  # DatePoint
point.astimezone(UTC)  # DatePoint
point.isoformat()  # a string, as always
point < other_datetime  # compares fine

It adds two guarantees and three methods.

It is always timezone-aware. A naive reading is taken as local time — the reading the standard library itself picks whenever it has to convert one. So DatePoint(2024, 4, 9) carries a zone, and so does anything fromisoformat, strptime or fromtimestamp produces.

now() reads the clock in force, not the operating system. That one departure from datetime is deliberate: it means freezing the clock in a test reaches a helper you never got round to injecting a clock into.

DatePoint.parse("+1 day Europe/Paris")  # the grammar below, against the current clock
point.modify("+1 hour")  # the same, against this instant
point.with_timezone("Asia/Tokyo")  # the same instant, another wall clock

DatePoint.from_datetime(value) adopts any datetime you already have.

A DatePoint is built the way a datetime is — DatePoint(2024, 4, 9). The string grammar lives on parse() and now() rather than on the constructor, because replace, arithmetic and every inherited from* method route back through it, and a first argument that is sometimes a year and sometimes a sentence makes all of them untypeable.

The modifier grammar

'+1 day' is convenient enough to be worth parsing, and small enough to be worth parsing ourselves. Five shapes, and anything else is an error:

Written Means
now The reference, untouched
+1 day, -2 hours 30 minutes, 2 days ago An offset. Units chain, and may be negative
today, tomorrow, yesterday, midnight, noon A boundary of the day
2024-04-09, 2024-04-09 15:00, 2024-04 An absolute datetime, in ISO-8601
Europe/Paris, UTC, +02:00 The same instant, read in another zone

Units are year, month, week, day, hour, minute, second, millisecond and microsecond, singular or plural, with the obvious short forms (hr, mins, secs). Single letters are refused: m reads as both minute and month, and a modifier should never be a guess.

A zone may trail any of them, and is applied first:

now("+1 day Europe/Paris")  # move to Paris, then add a day there

Which is not the same instant as adding a day and then moving — so the order is worth being explicit about.

Two rules about arithmetic

Calendar units keep the wall clock. Durations keep the elapsed time.

eve = MockClock("2025-03-30 01:00:00", "Europe/Amsterdam").now()  # a spring forward is coming

eve.modify("+1 day")  # 2025-03-31T01:00:00+02:00 — same time tomorrow
eve.modify("+24 hours")  # 2025-03-31T02:00:00+02:00 — 24 real hours later

Both are correct; they are answers to different questions. Keeping the wall clock can land on one that never existed, on the morning an hour went missing, so every result is resolved back through the instant it names — what comes out is a time that was really on the wall.

Month arithmetic clamps. January 31 plus a month is February 28, not March 3, so adding a month never skips one. Clamping makes month arithmetic non-associative at month ends — two '+1 month' steps from January 31 reach March 28, one '+2 months' step reaches March 31 — which is a property of calendars rather than a defect here.

Reaching code you cannot hand a clock

Injection is the honest answer and covers most code. It does not cover a module-level helper, a validator a framework calls, or a function three libraries deep. That code calls now():

from xtr_clock import now

now()  # the current instant, from the clock in force
now("+1 hour")
now("tomorrow")

and a test answers by installing a different clock:

from xtr_clock.testing import mock_time

with mock_time("2024-04-09 12:00:00"):
    assert now().hour == 12

The clock in force lives in a ContextVar, so a scope that installs one does not leak into a concurrent task that did not, and two tests running side by side cannot see each other's. Installing also updates a process-wide fallback, so a thread started later — which begins with a fresh context — still sees the clock the application chose.

Clock.set(clock) installs one for good, which is what an application does at startup. Clock.using(clock) installs one for a block and puts the old one back on the way out, including when the block raises. Prefer using everywhere else: set has no end, and a test that forgets to undo it hands the next one a clock it never asked for.

A class that cannot take a constructor argument

ClockAwareMixin gives a class a clock it can be handed, and a default until it is. It defines no __init__, so it composes with anything — including a dataclass:

from xtr_clock import ClockAwareMixin


class AuditLog(ClockAwareMixin):
    def record(self, event: str) -> None:
        self._rows.append((self.now(), event))


log = AuditLog()
log.set_clock(MockClock("2024-04-09 12:00:00"))

Until set_clock is called it reads whatever is in force, so it works untouched in production and freezes with everything else in a test.

Testing your application

Three ways in, depending on how much of the application knows about clocks.

A class that takes one needs nothing from this section — hand it a MockClock.

Code that calls now() gets mock_time, which installs a frozen clock for a block:

from xtr_clock.testing import mock_time

with mock_time("2024-04-09 12:00:00") as clock:
    assert issue_token().expires_at.hour == 12
    clock.sleep(3600)  # an hour passes; the test does not wait
    assert token_has_expired()

when may be a string the grammar reads, a datetime, or nothing at all to freeze where you already are. A relative one is read against the clock already in force, so mock_time("+1 day") nested inside the block above lands on the 10th.

A whole test can have the fixture. Opt in once, in a conftest.py:

pytest_plugins = ["xtr_clock.pytest_plugin"]
def test_a_token_expires_in_an_hour(clock: MockClock) -> None:
    token = issue_token()

    clock.sleep(3599)
    assert token.is_valid()
    clock.sleep(2)
    assert not token.is_valid()

It is not registered automatically on purpose: a clock fixture appearing in every suite that merely installs this library would collide with the one plenty of suites already have.

A MockClock freezes in UTC by default, where a real clock is built in the machine's own zone — a frozen test should not change its answer because of where the laptop running it is.

In async code prefer mock_time inside the coroutine over a fixture that finishes before the coroutine starts, so the block and the code it covers share one context. MockClock.sleep_async advances instantly and yields control once, so the tasks waiting on the clock get their turn — which is usually the behaviour the test is there to observe.

Errors

Everything this library raises derives from ClockError, and carries what went wrong as typed attributes rather than only a message. Both are also ValueErrors, so code already guarding a conversion with except ValueError keeps working.

Error Raised when
InvalidModifierError The grammar cannot read a modifier. Carries .modifier and .reason
InvalidTimezoneError A timezone is named that this system cannot resolve. Carries .timezone

A typo is refused where it is written rather than resolving to something plausible:

now("+1 dya")  # InvalidModifierError, not silently "now"

Relation to other datetime libraries

This library is about who tells the time, not about replacing datetime. It deliberately introduces no datetime type of its own, and the arithmetic it does own is only what the modifier grammar needs.

If you want a genuinely better datetime — nanosecond precision, naive and aware as separate types, an explicit choice of what to do with an ambiguous hour — whenever is the one to reach for. It is not so much an alternative to this library as an orthogonal one, and because a DatePoint is an aware datetime, the two need no adapter:

from whenever import Instant

moment = Instant(clock.now())  # straight in
later = moment.to_tz("Europe/Paris").add(months=5)  # its arithmetic, its disambiguation rules
back = DatePoint.from_datetime(later.to_stdlib())  # and straight back out

Instant takes any aware datetime. Its ZonedDateTime constructor is stricter — it wants a zoneinfo.ZoneInfo exactly — so reach it through to_tz as above, or hand it a DatePoint built with a named zone rather than the UTC default.

Prefer whenever outright if its types are the ones you want in your domain model; it has its own way to patch the current time. Prefer this library when you want the clock to be an injected dependency and datetime to stay the type on the boundary. The two compose.

Layout

xtr_clock/
├── clock_interface.py     what a clock answers to — and SupportsNow, the smaller contract
├── date_point.py          an instant: a datetime that stays aware, and stays itself
├── system_clock.py        reads the operating system's wall clock
├── monotonic_clock.py     counts forward, whatever the wall clock does
├── mock_clock.py          stands still until a test moves it
├── clock.py               the clock in force, and the adapter that fits a foreign one
├── clock_aware_mixin.py   for a class that cannot take a constructor argument
├── now.py                 now() — the one-call front door
├── modifier.py            the little grammar '+1 day' is written in
├── timezone.py            turning a zone's name into a zone, in one place
├── testing.py             freeze the clock for a block, restore it after
├── pytest_plugin.py       the same as a fixture, opt-in
└── exception/             one error per module, all a ClockError

Development

Developed in the python-xtr monorepo, under packages/xtr-clock; run the commands below from there. The python-xtr-clock repository is a read-only copy, so send issues and pull requests to the monorepo.

uv sync --all-extras
uv run ruff check src tests
uv run ruff format --check src tests
uv run ty check
uv run basedpyright
uv run coverage run -m pytest && uv run coverage report

Two type checkers on purpose — they disagree often enough to be worth both. Both run strict on the tests too, with nothing suppressed.

The suite mirrors the source tree: tests/unit/ holds a test_<module>.py for each module, testing it alone; tests/integration/ holds what needs a fresh interpreter — that the fixture is invisible until a suite opts in, and that importing the library pulls in nothing but the standard library. Statement and branch coverage are both 100%, measured with coverage run rather than pytest --cov, which starts too late to see a module's import.

License

MIT © xterr

Release files for xtr-clock 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for xtr-clock 1.0.0
File Size Uploaded
xtr_clock-1.0.0.tar.gz 25.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for xtr-clock 1.0.0
File Interpreter ABI Platform
xtr_clock-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 59.4 kB

Release files / xtr_clock-1.0.0.tar.gz

Download URL xtr_clock-1.0.0.tar.gz
Size 25.9 kB
Tags Source
SHA-256 checksum
How to use checksums
d4de0e03e685281502c563337fba182bbb3ca0f8e838b5311a1d683d2a2b2822
BLAKE2b-256 checksum
How to use checksums
d1eb4356a4c9534075ff142f0b46342d05206967d9c19fbb972245f8748a8095
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / xtr_clock-1.0.0-py3-none-any.whl

Download URL xtr_clock-1.0.0-py3-none-any.whl
Size 33.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7a678d5fd764706b94df96e2b5cb4d6c9984a2f05c32117d2795b42f144a9ecb
BLAKE2b-256 checksum
How to use checksums
f27a2cb8f02db4cd20f9bf07c98604b5310627955c0cc522a20c2fc041e2bc0d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

1.0.1

2 release files

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page