Skip to content

KTDateRange

Example usages of KTDateRange, a psycopg DateRange subclass. It requires the psycopg extra:

pip install ktcalendars[psycopg]
import datetime

from psycopg.types.range import DateRange

from ktcalendars.days import KTDay
from ktcalendars.ranges import KTDateRange

Creation

Bounds accept anything a KTDay accepts — a KTDay, a datetime.date or a date string — plus None for an unbounded side. A range can also be built as a copy of another DateRange:

KTDateRange("2025-06-01", "2025-06-04")        # 2025-06-01 to 2025-06-03
KTDateRange(datetime.date(2025, 6, 1), KTDay("2025-06-04"))
KTDateRange(DateRange(datetime.date(2025, 6, 1), datetime.date(2025, 6, 4)))
KTDateRange("2025-06-01", None)                # unbounded above
KTDateRange()                                  # unbounded on both sides
KTDateRange(empty=True)                        # the empty range

from_start_end builds a range where both dates are included:

KTDateRange.from_start_end("2025-06-01", "2025-06-03")
# [2025-06-01:2025-06-04) — covers the 1st, 2nd and 3rd

The lower bound may not be after the upper bound:

KTDateRange("2025-06-04", "2025-06-01")  # ValueError

Bounds accept exactly what a KTDay accepts and raise the same ValueError for invalid values, with one deviation: None means an unbounded bound, not "today" as in KTDay(None).

Calendar awareness

Like KTDay, a range is bound to a KTCalendar: pass one with ktcalendar, or let the range create its own — cal_-prefixed keyword arguments are forwarded to the KTCalendar constructor. Every KTDay the range produces keeps that calendar:

r = KTDateRange("2025-06-01", "2025-06-04", cal_country_code="IT")
[day.is_holiday for day in r]           # [False, True, False] — 2025-06-02 is a holiday in IT

KTDateRange(r).ktcalendar is r.ktcalendar          # True — copies inherit the calendar
r.intersection("2025-06-02").ktcalendar is r.ktcalendar  # True — derived ranges too

The calendar does not affect equality: two ranges with the same bounds are equal regardless of their calendars.

Bound inclusivity

Bounds are stored exactly as given — [) by default (lower included, upper excluded) — like psycopg's Range. Iteration, str and the comparison helpers normalise inclusivity internally, so a [] range behaves like the equivalent [) range ending one day later:

r = KTDateRange("2025-06-01", "2025-06-10", bounds="[]")
r.boundaries          # (datetime.date(2025, 6, 1), datetime.date(2025, 6, 10)) — as given
r.upper_inc           # True
"2025-06-10" in r     # True
str(r)                # '[2025-06-01:2025-06-11)' — displayed in normalised [) form

list(KTDateRange("2025-06-01", "2025-06-01"))    # [] — equal bounds with [) cover no days
KTDateRange("2025-06-01", "2025-06-01").isempty  # False — only KTDateRange(empty=True) is empty

Membership and iteration

in accepts anything a KTDay accepts; iteration yields KTDays (the excluded upper bound is not yielded):

r = KTDateRange("2025-06-01", "2025-06-04")

"2025-06-03" in r                  # True
datetime.date(2025, 6, 4) in r     # False — upper bound excluded
list(r)                            # [K2025-06-01, K2025-06-02, K2025-06-03]

r.boundaries                       # (datetime.date(2025, 6, 1), datetime.date(2025, 6, 4))
str(r)                             # '[2025-06-01:2025-06-04)'
str(KTDateRange("2025-06-01", None))  # '[2025-06-01:...)'

list(KTDateRange("2025-06-01", None))  # TypeError — cannot iterate an unbounded range
list(KTDateRange(empty=True))          # []

Comparisons

Every helper accepts another range (a KTDateRange, any DateRange or a (start, end) tuple) or a single day, treated as the one-day range covering it:

a = KTDateRange("2025-06-01", "2025-06-10")
b = KTDateRange("2025-06-05", "2025-06-20")

a.fully_lt("2025-06-10")   # True — the whole range is before that day
a.fully_lt(b)              # False — they overlap
a.fully_gt("2025-05-31")   # True — the whole range is after that day

a.startsbefore(b)          # True
b.startsafter(a)           # True
a.endsbefore(b)            # True
b.endsafter(a)             # True

precedes, follows and adjacent_to check that two ranges touch at a boundary without overlapping:

c = KTDateRange("2025-06-10", "2025-06-20")

a.precedes(c)       # True — a ends exactly where c starts
c.follows(a)        # True
a.adjacent_to(c)    # True
a.precedes("2025-06-10")  # True — adjacent to that day

Containment, overlap and intersection

a = KTDateRange("2025-06-01", "2025-06-10")
b = KTDateRange("2025-06-05", "2025-06-20")

a.contains("2025-06-05")                          # True
a.contains(KTDateRange("2025-06-02", "2025-06-05"))  # True
KTDateRange("2025-06-02", "2025-06-05").contained_by(a)  # True

a.overlap(b)               # True
a.overlap(KTDateRange("2025-06-10", "2025-06-20"))  # False — adjacent, not overlapping

a.intersection(b)          # [2025-06-05:2025-06-10)
a.intersection("2025-06-05")  # [2025-06-05:2025-06-06)
a.intersection(KTDateRange("2025-06-25", None))  # None — no overlap

as_dates returns the first and last day covered as a (date, date) tuple, with infinite boundaries replaced by datetime.date.min / datetime.date.max:

KTDateRange("2025-06-01", "2025-06-04").as_dates()
# (datetime.date(2025, 6, 1), datetime.date(2025, 6, 3)) — last covered day

KTDateRange("2025-06-01", None).as_dates()[1]  # datetime.date.max

The empty range

Following PostgreSQL semantics, the empty range overlaps, precedes and follows nothing, contains only itself, and is contained by every range:

empty = KTDateRange(empty=True)
a = KTDateRange("2025-06-01", "2025-06-10")

str(empty)             # 'empty'
a.overlap(empty)       # False
a.contains(empty)      # True
empty.contains(a)      # False
empty.contained_by(a)  # True
a.adjacent_to(empty)   # False
a.intersection(empty)  # None