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