pydura
Parse, format, and apply human-readable durations with nanosecond precision.
from pydura import Duration, format_duration, parse
delay = parse('1 day, 3h & 15m')
str(delay) # '1 day 3 hours 15 minutes'
format_duration(delay, max_units=2) # '1 day 3 hours'
delay.total_seconds() # 98100.0
Getting started
With a Python 3.14+ virtual environment active:
python -m pip install pydura
There are three things to get familiar with:
parse(text)reads a duration from text.Duration(...)builds one from integer amounts, likeDuration(minutes=5).format_duration(value)writes a duration ortimedeltaas words.
Reading durations
Compact strings, full words, and a bit of extra text all work:
parse('1h30m')
parse('1.5 hours')
parse('wait 1 hour and another 30 minutes')
# All three give you the same duration.
Units are case-insensitive, and whitespace between a number and its unit is fine. Here are the supported spellings:
| Unit | Spellings |
|---|---|
| Year | y, yr, yrs, year, years |
| Month | mo, mon, mons, month, months |
| Week | w, wk, wks, week, weeks |
| Day | d, day, days |
| Hour | h, hr, hrs, hour, hours |
| Minute | m, min, mins, minute, minutes |
| Second | s, sec, secs, second, seconds |
| Millisecond | ms, msec, msecs, millisecond, milliseconds |
| Microsecond | us, µs, μs, usec, microsecond, microseconds |
| Nanosecond | ns, nsec, nanosecond, nanoseconds |
A year is always 365 days, a month 30 days, a week 7 days, and a day 24 hours. These are elapsed durations, so adding a month won't adjust for which calendar month you're in.
Extra text and errors
The parser picks out the number-and-unit pairs it knows and skips everything
else. 1h 999 elephants gives you one hour. If nothing matches, it raises
ValueError. Non-string input raises TypeError.
That makes it forgiving, but also means it won't catch every typo. A bare 0
needs a unit (0s works), and scientific notation, HH:MM times, and ISO 8601
durations aren't supported. For example, 1e3s becomes three seconds because
1e is skipped. Keep that in mind if you're validating user input.
Negative durations
A sign on the first recognized component becomes the default for later unsigned components. A later explicit sign affects just that component:
str(parse('-1h30m')) # '-1 hour 30 minutes'
str(parse('-1h+30m15m')) # '-45 minutes': -60 + 30 - 15
str(parse('1h-30m15m')) # '45 minutes': 60 - 30 + 15
Working with a duration
Build a duration with any mix of integer weeks, days, hours, minutes,
seconds, milliseconds, microseconds, and nanoseconds:
timeout = Duration(seconds=30, milliseconds=500)
timeout * 2 # Duration(nanoseconds=61000000000)
timeout + Duration(seconds=5) # Duration(nanoseconds=35500000000)
timeout - Duration(milliseconds=500) # Duration(nanoseconds=30000000000)
Values are immutable, so arithmetic gives you a new duration. You can add and
subtract durations or timedelta objects, multiply by an integer in either
order, negate a duration, or take its abs().
Durations can also be sorted, compared with each other, and used as dictionary
keys. Only a zero duration is false in a boolean check. To compare with a
timedelta, convert it with Duration.from_timedelta() first.
The constructor accepts integers, including negative ones. Use parse('1.5h')
for fractions; passing a float or boolean to the constructor raises TypeError.
Precision
Every duration stores an exact integer .nanoseconds value:
parse('1.000000001s').nanoseconds # 1000000001
Parsing uses exact decimal arithmetic. Each component drops any fraction of a
nanosecond toward zero before the components are added, so 0.5ns 0.5ns is zero.
Use .total_seconds() when you want a float and don't need every nanosecond.
Values can grow beyond a signed 64-bit integer. Python's integer-string digit limit still applies to exceptionally long whole-number inputs and formatted output.
Dates and timedeltas
Add a duration to a datetime, or convert it to and from a timedelta:
from datetime import UTC, datetime, timedelta
delay = parse('1h30m')
start = datetime(2026, 1, 1, 12, tzinfo=UTC)
start + delay # 2026-01-01 13:30:00+00:00
start - delay # 2026-01-01 10:30:00+00:00
delay.to_timedelta() # timedelta(seconds=5400)
Duration.from_timedelta(timedelta(minutes=90)) == delay # True
Timezone-aware datetimes keep their timezone. Adding 1d advances exactly 24
elapsed hours, including across daylight saving changes, so the local clock
hour may change. Naive datetimes stay naive.
Python's timedelta stores microseconds. If conversion would lose nanoseconds,
pydura raises ValueError unless you explicitly allow truncation:
parse('1501ns').to_timedelta(truncate=True) # timedelta(microseconds=1)
parse('-1501ns').to_timedelta(truncate=True) # timedelta(microseconds=-1)
parse('1501ns').add_to(start, truncate=True)
Datetime arithmetic uses exact conversion by default. Use add_to() with
truncate=True when dropping the remainder is okay.
Conversions and datetime arithmetic can raise OverflowError outside the
standard library's supported range. Timezones with changing offsets also need
the resulting UTC datetime to fit that range.
Turning durations into words
str(duration) includes every nonzero component, down to nanoseconds.
format_duration() does the same and also accepts a timedelta:
format_duration(timedelta(hours=2, minutes=15)) # '2 hours 15 minutes'
format_duration(parse('2h15m3s'), max_units=2) # '2 hours 15 minutes'
str(Duration()) # '0 milliseconds'
max_units limits the number of nonzero components. Smaller units are left off
without rounding. Use a positive integer, or None for everything. Full output
can be passed back to parse() without losing precision.
Development
With the project's virtual environment active:
python -m pip install -e ".[dev]"
python -m pytest --cov=pydura --cov-branch
python -m ruff check .
python -m ruff format --check .
python -m mypy
python -m build
python -m twine check --strict dist/*
python benchmarks/benchmark.py
The benchmark runner covers parsing, formatting, arithmetic, and large inputs.
Pass --baseline /path/to/saved_duration.py to compare with a saved copy of
pydura/_duration.py. Add --json /path/to/results.json to keep the measurements.
Release files for pydura 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pydura-0.1.0.tar.gz | 18.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pydura-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 27.1 kB
Release files / pydura-0.1.0.tar.gz
| Download URL | pydura-0.1.0.tar.gz |
|---|---|
| Size | 18.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
75ded9d1fb117113d8bb67f722d24501f4a427fc146f44c77af5ed60405c1c4b
|
|
BLAKE2b-256 checksum How to use checksums |
a3be52e0da02b6b53348675fe9a74204868edfdbb29f73faca56f5b6074c7898
|
| 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 15, 2026.
Transparency logRelease files / pydura-0.1.0-py3-none-any.whl
| Download URL | pydura-0.1.0-py3-none-any.whl |
|---|---|
| Size | 9.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1cf0fae674f743c7a86400ec0b2de1beaaf018533ab91992ca0b46bea8bfa148
|
|
BLAKE2b-256 checksum How to use checksums |
03ac368ff69e363d1b6579f86184ee1026ff4e88dc9b543a73a249db97d2e05a
|
| 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 15, 2026.
Transparency log