Skip to content

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 businesstimedelta

WorkDayRule, 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 behaviourworkclock.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 OverflowError8h, 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 itWorking 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 additionRaises workclock.NoWorkingTimeError; difference returns zero
Any search can run foreverSearches give up after 366 days, plus seven days per holiday date, without working time

Intentional differences

  • Result time zone. Results are in the tzinfo of 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 BusinessTimeDelta subtracts, 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 HolidayRule is created. Dates added to the list or object later, or a new holidays attribute, are not seen; businesstimedelta reads holidays on 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 of datetime objects raise TypeError; pass date objects.
  • Working days must be integers from 0 (Monday) to 6. Other entries raise ValueError when the rule is used; businesstimedelta ignored them, or matched 1.0 as Tuesday.
  • next() and previous() on Rules return the whole period of working time, so overlapping working-hour rules form one period. businesstimedelta returned the block of one rule.
  • Empty WorkDayRule. With start_time == end_time, next() and previous() raise workclock.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 before dt. businesstimedelta returned the holiday that starts at dt.
  • Supported rules. Rules accepts WorkDayRule and LunchTimeRule as working time, and WorkDayRule, LunchTimeRule and HolidayRule as time off. Subclasses of Rule that compute their own blocks, a HolidayRule used as working time, and nested Rules raise TypeError, because rules run on the engine rather than as Python code.
  • Default zone. tz defaults to datetime.timezone.utc instead of pytz.utc; workclock does not depend on pytz. Zones may be IANA names, zoneinfo.ZoneInfo, pytz zones or datetime.timezone.utc. Fixed-offset tzinfo objects raise TypeError, because they have no IANA name.
  • Operators with other types. BusinessTimeDelta == x and BusinessTimeDelta - x for unsupported x return NotImplemented, so Python raises TypeError, instead of raising AttributeError or returning None.
  • Errors are workclock.WorkclockError subclasses that also inherit from ValueError or OverflowError, so existing except clauses 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:

Before: workclock.compat
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:

After: workclock.Schedule
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:00

Both 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
Working hours 09:00–17:00, break 12:00–13:00. Times in Europe/London. Due 09:30 on Wednesday 30 December 2026 (Europe/London).

Replace each name

workclock.compatworkclock 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.Schedule raises ValueError for a naive datetime instead of reading it as UTC.
  • No default times. WorkDay and Break need start and end; WorkDayRule defaulted to 09:00–18:00 and LunchTimeRule to 12:00–13:00. days still defaults to Monday to Friday.
  • No time_off flag. The rule type says it: WorkDay is working time, Break and Holidays are time off.
  • between returns a datetime.timedelta, not a BusinessTimeDelta with hours and seconds, and it is negative when the second datetime is before the first (#12).
  • Errors when the schedule is built. Schedule(...) raises workclock.NoWorkingTimeError if the working hours minus the breaks are empty. In workclock.compat, Rules.difference returns zero for such rules (C4).
  • Batch methods. between_many, add_many and sub_many take 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.

Caseworkclock.compatworkclock.Schedule
Second datetime before the firstdifference is positive (C1)between is negative (B4)
Adding zero at Saturday 10:00, 9–17 Mon–FriMonday 09:00 (C2)Saturday 10:00 (A5)
Subtracting zero at Saturday 10:00Friday 17:00 (C3)Saturday 10:00 (A8)
start == end, such as 09:00–09:00An empty rule (C4)A 24-hour shift (O9)
A naive datetimeRead as UTC (N3)ValueError (N1)
A 22:00–06:00 shift, Monday 22:00 to Tuesday 06:008 hours (C7)8 hours (O1)

See also

Report a problem with this pageAsk a question in Discussions

On this page