From ab640259d876fa712d19d9da5a27e796657cbc21 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:18:38 +0530 Subject: [PATCH 01/20] Add external busy period policy support --- src/slotify/policy.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/slotify/policy.py b/src/slotify/policy.py index 607d1a2..b5e9a6a 100644 --- a/src/slotify/policy.py +++ b/src/slotify/policy.py @@ -32,6 +32,31 @@ def overlaps(self, slot: Slot) -> bool: return slot.start_utc < end and start < slot.end_utc +@dataclass(frozen=True, slots=True) +class BusyPeriod: + """External-calendar or application-provided busy interval.""" + + start: datetime + end: datetime + reason: str = "Busy" + + def __post_init__(self) -> None: + if ( + self.start.tzinfo is None + or self.start.utcoffset() is None + or self.end.tzinfo is None + or self.end.utcoffset() is None + ): + raise ValueError("BusyPeriod datetimes must be timezone-aware.") + if self.end.astimezone(timezone.utc) <= self.start.astimezone(timezone.utc): + raise ValueError("BusyPeriod end must be after start.") + + def overlaps(self, slot: Slot) -> bool: + start = self.start.astimezone(timezone.utc) + end = self.end.astimezone(timezone.utc) + return slot.start_utc < end and start < slot.end_utc + + @dataclass(frozen=True, slots=True) class BookingPolicy: """ @@ -60,6 +85,7 @@ class BookingPolicy: minimum_notice: timedelta = timedelta(0) maximum_horizon: timedelta | None = None blocked_periods: tuple[BlockedPeriod, ...] = () + busy_periods: tuple[BusyPeriod, ...] = () cancellation_window: timedelta | None = None reschedule_window: timedelta | None = None max_reschedules: int | None = None @@ -115,6 +141,7 @@ def __post_init__(self) -> None: ) object.__setattr__(self, "blocked_periods", tuple(self.blocked_periods)) + object.__setattr__(self, "busy_periods", tuple(self.busy_periods)) def validate(self, slot: Slot, *, now: datetime) -> None: if now.tzinfo is None or now.utcoffset() is None: @@ -141,6 +168,12 @@ def validate(self, slot: Slot, *, now: datetime) -> None: blocked.reason or "Slot falls inside a blocked period." ) + for busy in self.busy_periods: + if busy.overlaps(slot): + raise SlotUnavailableError( + busy.reason or "Slot overlaps an external busy period." + ) + def validate_cancellation( self, slot: Slot, From 01e1ada599e783ba4d4291f5e63a1c62436c7c46 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:18:41 +0530 Subject: [PATCH 02/20] Add idempotent booking keys --- src/slotify/booking.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/slotify/booking.py b/src/slotify/booking.py index 9a7ea4e..fd6ab9d 100644 --- a/src/slotify/booking.py +++ b/src/slotify/booking.py @@ -41,6 +41,7 @@ class Booking: resource_ids: tuple[str, ...] = () series_id: str | None = None rescheduled_count: int = 0 + idempotency_key: str | None = None def __post_init__(self) -> None: if not self.booking_id: @@ -63,6 +64,11 @@ def __post_init__(self) -> None: ): raise ValueError("created_at must be timezone-aware.") + if self.idempotency_key is not None and ( + not isinstance(self.idempotency_key, str) or not self.idempotency_key.strip() + ): + raise ValueError("idempotency_key must be a non-empty string or None.") + if ( isinstance(self.rescheduled_count, bool) or not isinstance(self.rescheduled_count, int) @@ -149,6 +155,7 @@ def cancel(self) -> "Booking": resource_ids=self.resource_ids, series_id=self.series_id, rescheduled_count=self.rescheduled_count, + idempotency_key=self.idempotency_key, ) def reschedule(self, new_slot: Slot) -> "Booking": @@ -177,6 +184,7 @@ def to_dict(self) -> dict[str, Any]: "status": self.status, "series_id": self.series_id, "rescheduled_count": self.rescheduled_count, + "idempotency_key": self.idempotency_key, "slot": self.slot.to_dict(), "protected_start": self.protected_start.isoformat(), "protected_end": self.protected_end.isoformat(), @@ -225,6 +233,13 @@ def reschedule( def get(self, booking_id: str) -> Booking: ... + def find_by_idempotency_key(self, idempotency_key: str) -> Booking | None: + with self._lock: + for booking in self._bookings.values(): + if booking.idempotency_key == idempotency_key and booking.status == "confirmed": + return booking + return None + def list( self, *, @@ -234,6 +249,9 @@ def list( ) -> list[Booking]: ... + def find_by_idempotency_key(self, idempotency_key: str) -> Booking | None: + ... + def available_capacity( self, booking: Booking, From 8acfd8b39955133ebabb9426fe6e6739888a873c Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:18:44 +0530 Subject: [PATCH 03/20] Allow holds to reserve multiple resources --- src/slotify/holds.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/slotify/holds.py b/src/slotify/holds.py index 9566f4d..81fa8af 100644 --- a/src/slotify/holds.py +++ b/src/slotify/holds.py @@ -18,6 +18,7 @@ class BookingHold: slot: Slot expires_at: datetime resource_id: str | None = None + resource_ids: tuple[str, ...] = () hold_id: str = field(default_factory=lambda: str(uuid4())) metadata: Mapping[str, Any] = field(default_factory=dict) @@ -26,16 +27,18 @@ def __post_init__(self) -> None: raise ValueError("hold_id cannot be empty.") if self.expires_at.tzinfo is None or self.expires_at.utcoffset() is None: raise ValueError("expires_at must be timezone-aware.") + normalized = list(self.resource_ids) + if any(not isinstance(value, str) or not value.strip() for value in normalized): + raise ValueError("resource_ids must contain non-empty strings.") if self.resource_id is not None and ( not isinstance(self.resource_id, str) or not self.resource_id.strip() ): raise ValueError("resource_id must be a non-empty string or None.") + if self.resource_id is not None and self.resource_id not in normalized: + normalized.insert(0, self.resource_id) + object.__setattr__(self, "resource_ids", tuple(dict.fromkeys(normalized))) object.__setattr__(self, "metadata", dict(self.metadata)) - @property - def resource_ids(self) -> tuple[str | None, ...]: - return (self.resource_id,) - def is_active(self, now: datetime | None = None) -> bool: current = now or datetime.now(timezone.utc) if current.tzinfo is None or current.utcoffset() is None: From bc78f2a591727c2c92557c8c31ffe787d5afdc0e Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:19:13 +0530 Subject: [PATCH 04/20] Add availability diagnostics, idempotency and hold enforcement --- src/slotify/availability.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/slotify/availability.py b/src/slotify/availability.py index 35447d9..beaa164 100644 --- a/src/slotify/availability.py +++ b/src/slotify/availability.py @@ -23,6 +23,7 @@ class AvailabilityResult: available: bool reason: str | None = None resource_id: str | None = None + code: str | None = None class AvailabilityEngine: @@ -389,6 +390,7 @@ def _check_availability( slot=slot, available=False, reason=str(exc), + code="policy", ) if self.resource_pool: @@ -407,11 +409,13 @@ def _check_availability( slot=slot, available=True, resource_id=resource_id, + code="available", ) return AvailabilityResult( slot=slot, available=False, reason="No resource in the resource pool is available.", + code="resource_unavailable", ) candidate = self._candidate(slot) @@ -420,6 +424,7 @@ def _check_availability( slot=slot, available=False, reason="The requested slot is temporarily held.", + code="hold_conflict", ) if ( self.store.available_capacity( @@ -436,12 +441,14 @@ def _check_availability( if len(self.resource_ids) == 1 else self.resource_id ), + code="available", ) return AvailabilityResult( slot=slot, available=False, reason="The requested slot is at capacity or conflicts with an existing booking.", + code="capacity_conflict", ) def check_availability( @@ -712,6 +719,7 @@ def reserve( metadata=None, now: datetime | None = None, series_id: str | None = None, + idempotency_key: str | None = None, schedule_name: str | None = None, ) -> Booking: generator = self._generator_for(schedule_name) @@ -743,11 +751,36 @@ def reserve( "No resource in the resource pool is available." ) from last_error + if idempotency_key is not None: + if not isinstance(idempotency_key, str) or not idempotency_key.strip(): + raise ValueError("idempotency_key must be a non-empty string or None.") + finder = getattr(self.store, "find_by_idempotency_key", None) + if finder is not None: + existing = finder(idempotency_key) + if existing is not None: + return existing booking = self._booking_for( slot, metadata=metadata, series_id=series_id, ) + if idempotency_key is not None: + booking = Booking( + slot=booking.slot, + resource_id=booking.resource_id, + booking_id=booking.booking_id, + status=booking.status, + buffer_before=booking.buffer_before, + buffer_after=booking.buffer_after, + created_at=booking.created_at, + metadata=booking.metadata, + resource_ids=booking.resource_ids, + series_id=booking.series_id, + rescheduled_count=booking.rescheduled_count, + idempotency_key=idempotency_key, + ) + if self.hold_store.conflicts(booking, now=current): + raise BookingConflictError("The requested slot is temporarily held.") return self.store.reserve(booking) def reserve_first_available( From 246e8a0535f16e292f69693b46987d64dae2dbff Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:19:20 +0530 Subject: [PATCH 05/20] Apply idempotency keys to resource pool reservations --- src/slotify/availability.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/slotify/availability.py b/src/slotify/availability.py index beaa164..a7c797f 100644 --- a/src/slotify/availability.py +++ b/src/slotify/availability.py @@ -728,6 +728,15 @@ def reserve( self.policy.validate(slot, now=current) self._validate_limits(slot, now=current) + if idempotency_key is not None: + if not isinstance(idempotency_key, str) or not idempotency_key.strip(): + raise ValueError("idempotency_key must be a non-empty string or None.") + finder = getattr(self.store, "find_by_idempotency_key", None) + if finder is not None: + existing = finder(idempotency_key) + if existing is not None: + return existing + if self.resource_pool: last_error: BookingConflictError | None = None for resource_id in self._ordered_pool_resources( @@ -740,6 +749,21 @@ def reserve( metadata=metadata, series_id=series_id, ) + if idempotency_key is not None: + booking = Booking( + slot=booking.slot, + resource_id=booking.resource_id, + booking_id=booking.booking_id, + status=booking.status, + buffer_before=booking.buffer_before, + buffer_after=booking.buffer_after, + created_at=booking.created_at, + metadata=booking.metadata, + resource_ids=booking.resource_ids, + series_id=booking.series_id, + rescheduled_count=booking.rescheduled_count, + idempotency_key=idempotency_key, + ) try: if self.hold_store.conflicts(booking, now=current): last_error = BookingConflictError("The requested slot is temporarily held.") From 82c6319c9529e8cbcfd9c8ac85ffbd96f92d0b63 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:19:25 +0530 Subject: [PATCH 06/20] Export v0.5 APIs and bump package version --- src/slotify/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/slotify/__init__.py b/src/slotify/__init__.py index f7b332b..de5e335 100644 --- a/src/slotify/__init__.py +++ b/src/slotify/__init__.py @@ -23,6 +23,7 @@ ) from .policy import ( BlockedPeriod, + BusyPeriod, BookingPolicy, ) from .recurrence import RecurrenceRule @@ -35,7 +36,7 @@ to_utc, ) -__version__ = "0.3.0" +__version__ = "0.5.0" __all__ = [ "SlotGenerator", "Slot", @@ -47,6 +48,7 @@ "InMemoryBookingStore", "BookingPolicy", "BlockedPeriod", + "BusyPeriod", "RecurrenceRule", "SlotifyError", "ConfigurationError", From 310c015e4d38eabb609a8c4fd46c09166e431b49 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:19:38 +0530 Subject: [PATCH 07/20] Test v0.5 production hardening features --- tests/test_v050_features.py | 94 +++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/test_v050_features.py diff --git a/tests/test_v050_features.py b/tests/test_v050_features.py new file mode 100644 index 0000000..f872416 --- /dev/null +++ b/tests/test_v050_features.py @@ -0,0 +1,94 @@ +from datetime import datetime, timedelta + +import pytest + +from slotify import ( + AvailabilityEngine, + BookingPolicy, + BusyPeriod, + BookingConflictError, + SlotGenerator, +) + + +def make_generator(): + return SlotGenerator( + start="09:00", + end="12:00", + duration=60, + timezone="Asia/Kolkata", + ) + + +def test_external_busy_period_blocks_availability(): + policy = BookingPolicy( + busy_periods=( + BusyPeriod( + start=datetime.fromisoformat("2026-10-05T10:00:00+05:30"), + end=datetime.fromisoformat("2026-10-05T11:00:00+05:30"), + reason="Google Calendar event", + ), + ) + ) + engine = AvailabilityEngine(make_generator(), policy=policy) + slots = engine.available_slots( + "2026-10-05", + now=datetime.fromisoformat("2026-10-04T08:00:00+05:30"), + ) + assert [slot.start.hour for slot in slots] == [9, 11] + + +def test_availability_result_exposes_machine_readable_code(): + engine = AvailabilityEngine(make_generator()) + slot = make_generator().generate_for_date("2026-10-05")[0] + hold = engine.hold( + slot, + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=datetime.fromisoformat("2026-10-04T08:00:00+05:30"), + ) + result = engine.check_availability( + slot, + now=datetime.fromisoformat("2026-10-04T08:30:00+05:30"), + ) + assert result.available is False + assert result.code == "hold_conflict" + engine.release_hold(hold.hold_id) + + +def test_direct_reserve_cannot_bypass_non_pool_hold(): + engine = AvailabilityEngine(make_generator()) + slot = make_generator().generate_for_date("2026-10-05")[0] + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + engine.hold( + slot, + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=now, + ) + with pytest.raises(BookingConflictError): + engine.reserve(slot, now=now) + + +def test_idempotency_key_returns_same_booking(): + engine = AvailabilityEngine(make_generator()) + slot = make_generator().generate_for_date("2026-10-05")[0] + first = engine.reserve( + slot, + idempotency_key="checkout-123", + now=datetime.fromisoformat("2026-10-04T08:00:00+05:30"), + ) + second = engine.reserve( + slot, + idempotency_key="checkout-123", + now=datetime.fromisoformat("2026-10-04T08:00:00+05:30"), + ) + assert second.booking_id == first.booking_id + assert len(engine.store.list()) == 1 + + +def test_different_idempotency_keys_still_conflict(): + engine = AvailabilityEngine(make_generator()) + slot = make_generator().generate_for_date("2026-10-05")[0] + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + engine.reserve(slot, idempotency_key="one", now=now) + with pytest.raises(BookingConflictError): + engine.reserve(slot, idempotency_key="two", now=now) From 415f5dd504e92cfd1d1fcfd366e2c08e2ee0cb17 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:19:45 +0530 Subject: [PATCH 08/20] Set package version to 0.5.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3dafd95..09850e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "slotify-scheduling" -version = "0.4.0" +version = "0.5.0" description = "Timezone-aware Python scheduling and appointment slot engine for availability and booking systems." readme = "README.md" requires-python = ">=3.10" From 5002e387d41492fa6cce5dc6edf86595add0427b Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:19:58 +0530 Subject: [PATCH 09/20] Add database-stores.md guide --- docs/database-stores.md | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/database-stores.md diff --git a/docs/database-stores.md b/docs/database-stores.md new file mode 100644 index 0000000..98ef5c9 --- /dev/null +++ b/docs/database-stores.md @@ -0,0 +1,60 @@ +# Database-backed Stores + +Slotify deliberately does not require Django, SQLAlchemy, or a database. The core package defines the BookingStore protocol so your application can choose its persistence layer. + +## Production rule + +The final reservation must be atomic in your database. + +For PostgreSQL, a production store normally uses a transaction plus database-level protection for the booking/resource/time interval. The exact implementation depends on your schema and whether you use Django ORM, SQLAlchemy, or another database layer. + +Your store should implement: + +~~~python +class MyBookingStore: + def reserve(self, booking): + # transaction + database conflict protection + ... +~~~ + +## Django + +Keep Slotify objects at the service boundary: + +~~~python +def reserve_slot(provider, slot): + engine = get_provider_engine(provider) + return engine.reserve(slot, idempotency_key="checkout-123") +~~~ + +Persist the returned Booking in your Django model inside the same application-level transaction strategy used by your store. + +## SQLAlchemy + +The same design works with SQLAlchemy: + +~~~python +class SQLAlchemyBookingStore: + def reserve(self, booking): + # use Session transaction and database constraints + ... +~~~ + +Slotify does not import SQLAlchemy, so applications that do not use it pay no dependency cost. + +## Idempotency + +Use an idempotency key for retried API requests: + +~~~python +booking = engine.reserve( + slot, + idempotency_key=request_id, +) +~~~ + +The built-in in-memory store remembers the key. A database-backed store should also enforce uniqueness atomically so concurrent requests with the same key cannot create duplicates. + +## Holds + +The same production rule applies to HoldStore. A checkout hold and its final confirmation need shared durable storage and concurrency protection when multiple application workers can handle the same customer. From 5248a3f8db568dcc30924609834b76a52277b6f1 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:20:00 +0530 Subject: [PATCH 10/20] Add framework-examples.md guide --- docs/framework-examples.md | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/framework-examples.md diff --git a/docs/framework-examples.md b/docs/framework-examples.md new file mode 100644 index 0000000..1d3c7c6 --- /dev/null +++ b/docs/framework-examples.md @@ -0,0 +1,45 @@ +# Framework Examples + +Slotify is framework-independent. These patterns show where it fits. + +## Django / DRF + +Use Slotify in a service layer and return serialized Slot or Booking values from your API. + +~~~python +def available_slots(provider, date): + engine = get_provider_engine(provider) + return engine.available_slots(date) +~~~ + +See the Django integration guide for a complete example. + +## FastAPI + +The same service can be called from a FastAPI route: + +~~~python +from fastapi import FastAPI + +app = FastAPI() + +@app.get("/providers/{provider_id}/slots") +def slots(provider_id: str, date: str): + engine = get_provider_engine(provider_id) + return [slot.to_dict() for slot in engine.available_slots(date)] +~~~ + +FastAPI owns HTTP, authentication, dependency injection, and persistence. Slotify owns scheduling rules. + +## Flask + +A Flask route can use the same service: + +~~~python +@app.get("/providers//slots") +def slots(provider_id): + engine = get_provider_engine(provider_id) + return {"slots": [slot.to_dict() for slot in engine.available_slots(request.args["date"])]} +~~~ + +No framework integration package is required. From c01aa185a5acd7e9a86054124cd1774be8ab6042 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:20:03 +0530 Subject: [PATCH 11/20] Add benchmarks.md guide --- docs/benchmarks.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/benchmarks.md diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..c324283 --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,38 @@ +# Performance Benchmarks + +Slotify should be measured with the scheduling configuration your application actually uses. + +A useful benchmark should vary: + +- number of days scanned +- slots per day +- number of resources +- number of existing bookings +- number of holds +- timezone/DST complexity + +## Simple local benchmark + +The following measures a repeated availability query without adding a benchmark dependency: + +~~~python +from time import perf_counter +from slotify import AvailabilityEngine, SlotGenerator + +generator = SlotGenerator( + start="09:00", + end="17:00", + duration=30, + timezone="Asia/Kolkata", +) +engine = AvailabilityEngine(generator) + +start = perf_counter() +for _ in range(100): + engine.available_slots("2026-10-05", "2026-10-30") +elapsed = perf_counter() - start + +print(f"{elapsed:.4f}s for 100 queries") +~~~ + +Treat this as a smoke benchmark, not a universal performance claim. Production performance depends heavily on the application store, resource count, database, and workload. From a8b6e6e5015f45c5fdaea802ee67b73629ab9841 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:20:11 +0530 Subject: [PATCH 12/20] Update documentation navigation --- mkdocs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 6e579bd..40bbefe 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -39,11 +39,14 @@ nav: - Examples: examples.md - Integrations: - Django / DRF: django.md + - Framework Examples: framework-examples.md - Reference: - API Guide: api.md - Timezones & DST: timezones.md - Production: - Testing & Production: testing.md + - Database-backed Stores: database-stores.md + - Performance Benchmarks: benchmarks.md - FAQ: faq.md - Changelog: changelog.md - Migrating from v0.2/v0.3: migration.md From 806dcf5d6624be4a53c4b3556cab62d71599f6a0 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:20:15 +0530 Subject: [PATCH 13/20] Document v0.5 APIs --- docs/api.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/api.md b/docs/api.md index 4673684..353313a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -281,6 +281,53 @@ RecurrenceRule( weekdays is valid for weekly recurrence. day_of_month is used for monthly/yearly recurrence. When a monthly day does not exist in a month, the last valid day of that month is used. count counts emitted occurrences, so excluded dates do not consume the count. +## v0.5.0 external busy periods + +Use BusyPeriod for conflicts supplied by another calendar or application: + +~~~python +from slotify import BookingPolicy, BusyPeriod + +policy = BookingPolicy( + busy_periods=( + BusyPeriod( + start=calendar_start, + end=calendar_end, + reason="External calendar event", + ), + ), +) +~~~ + +Busy periods are evaluated alongside Slotify's own booking rules. + +## v0.5.0 availability diagnostics + +AvailabilityResult includes a machine-readable code in addition to the human-readable reason. + +Common codes include: + +~~~text +available +policy +hold_conflict +capacity_conflict +resource_unavailable +~~~ + +## v0.5.0 idempotent reservations + +Pass an idempotency key when an API request may be retried: + +~~~python +booking = engine.reserve( + slot, + idempotency_key="payment-attempt-123", +) +~~~ + +A store should enforce idempotency atomically in production. + ## v0.4.0 holds Temporary checkout holds are represented by BookingHold. From 1b006e7bff7b7a0c57675281de227876ad58d655 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:20:18 +0530 Subject: [PATCH 14/20] Update production testing guidance --- docs/testing.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 271a494..a3df99e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,7 +16,7 @@ python -c "import slotify; print(slotify.__version__)" Expected for this documentation release: ~~~text -0.2.0 +0.5.0 ~~~ ## Run your tests @@ -181,7 +181,7 @@ Slotify is currently in alpha. For production, pin the version that you have tested: ~~~text -slotify-scheduling==0.2.0 +slotify-scheduling==0.5.0 ~~~ Review the changelog before upgrading. @@ -208,3 +208,16 @@ Before deploying a system built with Slotify, verify: [ ] payment and notification logic is outside Slotify [ ] package version is pinned ~~~ + + +## External calendar conflicts + +Test BusyPeriod values from Google Calendar, Outlook, your own calendar service, or another source as ordinary scheduling conflicts. Keep the external adapter in your application and pass normalized BusyPeriod objects into Slotify. + +## Idempotency + +For retried HTTP/payment requests, test that the same idempotency key returns the same Booking and that different keys still obey normal conflict rules. + +## Availability diagnostics + +Use AvailabilityResult.code for API-level error handling and AvailabilityResult.reason for logs or user-facing explanations. From a4edca3b1a979c54cde54766215a897dc34c13c9 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:20:21 +0530 Subject: [PATCH 15/20] Document multi-resource availability intersection --- docs/resources.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/resources.md b/docs/resources.md index 6efb3c6..3404c5a 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -61,3 +61,17 @@ These strategies are allocation helpers. Your application should still filter re Resource selection does not replace concurrency control. Two requests can observe the same resource as available. The final reservation in your BookingStore must be atomic. + + +## Required resources + +For appointments that require multiple resources together, use resource_ids: + +~~~python +AvailabilityEngine( + generator, + resource_ids=("doctor-1", "room-4"), +) +~~~ + +A booking protects all listed resources. Another booking conflicts when its protected interval overlaps and it shares at least one required resource. This provides the core availability intersection needed for provider + room + equipment style appointments. From 9f38c72280ba32dfe5f5028e8da87aa4af8f4e0f Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:20:34 +0530 Subject: [PATCH 16/20] Refresh README for v0.5 --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index acefaba..e1e65be 100644 --- a/README.md +++ b/README.md @@ -434,7 +434,7 @@ RecurrenceRule( ## Project status -Current version: **0.3.0** +Current version: **0.5.0** Slotify is currently in alpha. The API may evolve before 1.0.0, so pin the version in production applications and review the changelog when upgrading. @@ -445,3 +445,28 @@ MIT License ## Author Kalash Gulati + + +### External calendar conflicts + +Normalize conflicts from an external calendar in your application and pass them to BookingPolicy: + +~~~python +from slotify import BookingPolicy, BusyPeriod + +policy = BookingPolicy( + busy_periods=( + BusyPeriod(start=calendar_start, end=calendar_end, reason="External event"), + ), +) +~~~ + +### Idempotent API retries + +Use an idempotency key for requests that may be retried: + +~~~python +booking = engine.reserve(slot, idempotency_key=request_id) +~~~ + +A production BookingStore should enforce the same key atomically in its database. From 88619d742e1941447c87d6677b277208699d1101 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:20:37 +0530 Subject: [PATCH 17/20] Refresh roadmap for v0.5 --- ROADMAP.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e558e18..bf6f68f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,21 +21,30 @@ Slotify is currently in alpha. This roadmap is a guide rather than a promise; pr - Recurrence exclusions - Expanded tests and documentation +## v0.5.0 completed + +- External busy-period conflicts +- Machine-readable availability diagnostics +- Idempotent reservation keys +- Stronger hold enforcement on direct reservations +- Database-backed store guidance +- Django, FastAPI, and Flask integration patterns +- Availability performance benchmark guidance +- Required multi-resource availability documentation + ## Current focus - Keep scheduling behavior predictable and timezone-safe. -- Expand DST and recurrence edge-case coverage. -- Improve database-backed BookingStore examples. -- Add performance benchmarks for large availability ranges. +- Expand DST, recurrence, concurrency, and store-contract coverage. - Collect real-world scheduling use cases and bug reports. +- Measure performance with realistic workloads before optimizing. ## Exploring -- PostgreSQL/SQLAlchemy BookingStore examples -- Calendar conflict adapters -- Additional framework examples +- Reference PostgreSQL store implementations +- Calendar provider adapters maintained outside the core package +- Additional resource routing strategies only when real use cases require them - Performance improvements for large resource pools -- More advanced routing and resource-selection strategies ## Scope From 73dc2922dd00edc30a0ffd4f9f9967257efa3ddf Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:20:43 +0530 Subject: [PATCH 18/20] Add v0.5 changelog --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c0811..a611a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# Changelog + +## 0.5.0 + +- Added external BusyPeriod conflicts for calendar/application-provided busy intervals. +- Added machine-readable AvailabilityResult diagnostic codes. +- Added idempotency keys for retried reservations. +- Prevented direct non-pool reservations from bypassing active holds. +- Added database-backed BookingStore guidance and framework integration patterns. +- Added performance benchmark guidance. +- Expanded documentation for required multi-resource availability. + +## 0.4.0 + +- Added temporary booking holds and hold storage abstractions. +- Added first-available, round-robin, and least-loaded resource allocation. +- Added hold and resource-pool regression coverage. +- Added runnable examples for holds and resource pools. + ## 0.2.0 - Added recurring weekly appointment series with rollback on failed occurrence. From e9e3b2f6763068f3229e0ab42ee8a52444009c66 Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:21:27 +0530 Subject: [PATCH 19/20] Fix idempotency lookup placement --- src/slotify/booking.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/slotify/booking.py b/src/slotify/booking.py index fd6ab9d..188a093 100644 --- a/src/slotify/booking.py +++ b/src/slotify/booking.py @@ -233,13 +233,6 @@ def reschedule( def get(self, booking_id: str) -> Booking: ... - def find_by_idempotency_key(self, idempotency_key: str) -> Booking | None: - with self._lock: - for booking in self._bookings.values(): - if booking.idempotency_key == idempotency_key and booking.status == "confirmed": - return booking - return None - def list( self, *, @@ -426,6 +419,13 @@ def get(self, booking_id: str) -> Booking: return booking + def find_by_idempotency_key(self, idempotency_key: str) -> Booking | None: + with self._lock: + for booking in self._bookings.values(): + if booking.idempotency_key == idempotency_key and booking.status == "confirmed": + return booking + return None + def list( self, *, From 883894a7fe648af57d513c426bf0737afa0801ec Mon Sep 17 00:00:00 2001 From: Kalash Gulati Date: Tue, 29 Sep 2026 14:21:32 +0530 Subject: [PATCH 20/20] Fix unscoped hold conflict detection --- src/slotify/holds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slotify/holds.py b/src/slotify/holds.py index 81fa8af..9122978 100644 --- a/src/slotify/holds.py +++ b/src/slotify/holds.py @@ -81,7 +81,7 @@ def __init__(self) -> None: @staticmethod def _overlaps(hold: BookingHold, booking: Booking) -> bool: - hold_resources = set(hold.resource_ids) + hold_resources = set(hold.resource_ids or (hold.resource_id,)) booking_resources = set(booking.effective_resource_ids) if not hold_resources.intersection(booking_resources): return False