Migrating
Run businesstimedelta 1.0.1 code on workclock by changing one import, see what changes, and move to the typed workclock.Schedule API.
Your Python code computes due dates and elapsed working time with businesstimedelta 1.0.1. You want it to run on workclock with as few changes as possible, then move to the typed API when it suits you. This guide is for Python only: businesstimedelta and workclock.compat are Python packages, and the Rust and JavaScript bindings have no compatibility layer.
Change one import
workclock ships a compatibility layer for businesstimedelta 1.0.1. Most code migrates with one line:
import workclock.compat as businesstimedeltaWorkDayRule, LunchTimeRule, HolidayRule, Rules and BusinessTimeDelta
take the same arguments and defaults, support the same operators and
hours/seconds properties, and treat naive datetimes as UTC. The
businesstimedelta test suite runs against workclock.compat in CI.
For new code, the typed API (workclock.Schedule) is smaller and stricter: it
rejects naive datetimes, returns a signed between
(#12), and has
batch methods for lists and NumPy arrays. Rules.difference keeps
businesstimedelta's absolute value, so existing code sees no change there.
Fixed
| Upstream behaviour | workclock.compat |
|---|---|
Overnight shifts: difference(Mon 22:00, Tue 06:00) is 0h, Mon 22:00 + 4h is Wed 02:00, Tue 06:00 - 4h raises OverflowError | 8h, Tue 02:00, Tue 02:00 |
A holiday list that ends before the queried range raises TypeError (#8, #10) | Dates after the last holiday are ordinary days |
| Holidays in a different zone from the working hours can loop forever (#9) | Terminates with the correct result |
| A time-off block that starts inside a period of working time does not always shorten it | Working time is always the union of working hours minus the union of time off |
A rule without working time (such as start_time == end_time) hangs on addition | Raises workclock.NoWorkingTimeError; difference returns zero |
| Any search can run forever | Searches give up after 366 days, plus seven days per holiday date, without working time |
Intentional differences
- Result time zone. Results are in the
tzinfoof the datetime you pass in, or UTC if it is naive. businesstimedelta sometimes returns the rule's zone instead. The instants are the same, so comparisons are unaffected. - Negative deltas. Adding a negative
BusinessTimeDeltasubtracts, and subtracting one adds. businesstimedelta returned the start of the next block minus the amount. - Repeated times at the end of daylight saving time. A rule boundary that
falls in the repeated hour resolves to its first occurrence, as jiff, RFC
5545 and JavaScript Temporal do. businesstimedelta's pytz
localize()picks the second. - Holidays on DST transition days last from midnight to midnight, so they are shorter or longer than 24 hours by the size of the change (usually one hour). businesstimedelta always removes 24 hours.
- Holiday dates are read once, when the
HolidayRuleis created. Dates added to the list or object later, or a newholidaysattribute, are not seen; businesstimedelta readsholidayson every call. A python-holidays object is filled in for every year from 1970 to 2100 first, and dates outside that window are not holidays. Lists ofdatetimeobjects raiseTypeError; passdateobjects. - Working days must be integers from 0 (Monday) to 6. Other entries raise
ValueErrorwhen the rule is used; businesstimedelta ignored them, or matched1.0as Tuesday. next()andprevious()onRulesreturn the whole period of working time, so overlapping working-hour rules form one period. businesstimedelta returned the block of one rule.- Empty
WorkDayRule. Withstart_time == end_time,next()andprevious()raiseworkclock.NoWorkingTimeError. businesstimedelta returned a zero-length block. HolidayRule.previous(dt)at the start of a holiday returns the holiday before it, which ends at or beforedt. businesstimedelta returned the holiday that starts atdt.- Supported rules.
RulesacceptsWorkDayRuleandLunchTimeRuleas working time, andWorkDayRule,LunchTimeRuleandHolidayRuleas time off. Subclasses ofRulethat compute their own blocks, aHolidayRuleused as working time, and nestedRulesraiseTypeError, because rules run on the engine rather than as Python code. - Default zone.
tzdefaults todatetime.timezone.utcinstead ofpytz.utc; workclock does not depend on pytz. Zones may be IANA names,zoneinfo.ZoneInfo, pytz zones ordatetime.timezone.utc. Fixed-offsettzinfoobjects raiseTypeError, because they have no IANA name. - Operators with other types.
BusinessTimeDelta == xandBusinessTimeDelta - xfor unsupportedxreturnNotImplemented, so Python raisesTypeError, instead of raisingAttributeErroror returningNone. - Errors are
workclock.WorkclockErrorsubclasses that also inherit fromValueErrororOverflowError, so existingexceptclauses keep working.
Move to the typed API
The typed API is workclock.Schedule with the rules workclock.WorkDay, workclock.Break and workclock.Holidays. It runs on the same engine as workclock.compat. Both samples in this section use the London support desk: working hours 09:00–17:00 Monday to Friday in Europe/London, a 12:00–13:00 break, holidays on 25 and 28 December 2026, and a ticket opened at 16:30 on Thursday 24 December 2026 with an 8-hour SLA.
In Python, with workclock.compat. It takes a zone name such as "Europe/London" where businesstimedelta needed a pytz zone, and reads naive datetimes as UTC, which on 24 December is also London time:
from datetime import date, datetime, time
import workclock.compat as businesstimedelta
london = "Europe/London"
workday = businesstimedelta.WorkDayRule(time(9), time(17), tz=london)
lunch = businesstimedelta.LunchTimeRule(tz=london) # 12:00–13:00
holidays = businesstimedelta.HolidayRule(
[date(2026, 12, 25), date(2026, 12, 28)], tz=london
)
support = businesstimedelta.Rules([workday, lunch, holidays])
opened = datetime(2026, 12, 24, 16, 30) # naive: read as UTC
eight_hours = businesstimedelta.BusinessTimeDelta(support, hours=8)
opened + eight_hours # 2026-12-30 09:30, UTC
support.difference(opened, datetime(2026, 12, 29, 11))
# <BusinessTimeDelta 2 hours 1800 seconds>In Python, with workclock.Schedule. Every datetime carries its zone, and the results come back in that zone:
from datetime import date, datetime, time
from zoneinfo import ZoneInfo
from workclock import Break, Holidays, Schedule, WorkDay
london = ZoneInfo("Europe/London")
support = Schedule(
[
WorkDay(time(9), time(17)), # Monday to Friday by default
Break(time(12), time(13)),
Holidays([date(2026, 12, 25), date(2026, 12, 28)]),
],
time_zone=london,
)
opened = datetime(2026, 12, 24, 16, 30, tzinfo=london)
support.add(opened, hours=8) # 2026-12-30 09:30, London time
support.between(opened, datetime(2026, 12, 29, 11, tzinfo=london)) # 2:30:00Both reach 09:30 on Wednesday 30 December 2026. The ticket uses 30 minutes on 24 December, none over the holidays and the weekend, 7 hours on Tuesday 29 December and 30 minutes on 30 December:
- Thu 24
- Fri 25
- Sat–Sun
- Mon 28
- Tue 29
- Wed 30
Replace each name
workclock.compat | workclock typed API |
|---|---|
WorkDayRule(start_time, end_time, working_days, tz) | WorkDay(start, end, days, time_zone=...) |
LunchTimeRule(start_time, end_time, working_days, tz) | Break(start, end, days, time_zone=...) |
HolidayRule(holidays, tz) | Holidays(dates, time_zone=...) |
Rules([...]) | Schedule([...], time_zone=...) |
dt + BusinessTimeDelta(rule, hours=8) | schedule.add(dt, hours=8) |
dt - BusinessTimeDelta(rule, hours=8) | schedule.sub(dt, hours=8) |
rule.difference(dt1, dt2) | schedule.between(dt1, dt2) |
rule.next(dt), rule.previous(dt) | schedule.next_period(dt), schedule.previous_period(dt) |
What else changes:
- Aware datetimes only.
workclock.ScheduleraisesValueErrorfor a naive datetime instead of reading it as UTC. - No default times.
WorkDayandBreakneedstartandend;WorkDayRuledefaulted to 09:00–18:00 andLunchTimeRuleto 12:00–13:00.daysstill defaults to Monday to Friday. - No
time_offflag. The rule type says it:WorkDayis working time,BreakandHolidaysare time off. betweenreturns adatetime.timedelta, not aBusinessTimeDeltawithhoursandseconds, and it is negative when the second datetime is before the first (#12).- Errors when the schedule is built.
Schedule(...)raisesworkclock.NoWorkingTimeErrorif the working hours minus the breaks are empty. Inworkclock.compat,Rules.differencereturns zero for such rules (C4). - Batch methods.
between_many,add_manyandsub_manytake lists or NumPy arrays and process them in one call.
Edge cases
Where workclock.compat keeps businesstimedelta's behaviour and the typed API does not. The IDs are the examples in DESIGN.md, each checked by a test.
| Case | workclock.compat | workclock.Schedule |
|---|---|---|
| Second datetime before the first | difference is positive (C1) | between is negative (B4) |
| Adding zero at Saturday 10:00, 9–17 Mon–Fri | Monday 09:00 (C2) | Saturday 10:00 (A5) |
| Subtracting zero at Saturday 10:00 | Friday 17:00 (C3) | Saturday 10:00 (A8) |
start == end, such as 09:00–09:00 | An empty rule (C4) | A 24-hour shift (O9) |
| A naive datetime | Read as UTC (N3) | ValueError (N1) |
| A 22:00–06:00 shift, Monday 22:00 to Tuesday 06:00 | 8 hours (C7) | 8 hours (O1) |
- Overnight and 24-hour shifts, breaks and holidays: Rules.
- Adding zero, and adding past a holiday: Adding time.
- Signed results: Measuring time.
- Repeated and skipped times when clocks change for daylight saving time (DST): Time zones and DST.
- Naive datetimes and the supported range: Inputs and precision.
- Every error class and when it is raised: Errors.
See also
- Quickstart: Python
- Schedules
- MIGRATING.md, the source of the lists on this page
python/workclock/compat.py, the compatibility layer's docstrings
Report a problem with this pageAsk a question in Discussions