From c8a25aa1e2e0d29bc8d66fb70d953f43c258fbf0 Mon Sep 17 00:00:00 2001 From: Brian Thorne Date: Tue, 15 Sep 2026 20:01:39 +1200 Subject: [PATCH] Document that naive datetimes are read in the host time zone; use aware datetimes in examples Refs #50. Option 1 from the issue: no behaviour change, the API reference warns about the host-TZ dependence, and every example that hands a datetime to CEL now uses datetime.now(timezone.utc). --- CHANGELOG.md | 9 +++++++++ docs/getting-started/quick-start.md | 8 +++++--- docs/reference/python-api.md | 14 +++++++++++++- docs/tutorials/extending-cel.md | 17 ++++++++--------- src/context.rs | 4 ++-- 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f1696..c6bd82d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 this package once wrapped; `mkdocs.yml` points at the Read the Docs URL that actually serves the documentation. +### Documented + +- Naive `datetime` values are interpreted in the host's local time zone when converted + to a CEL `timestamp`, so the same value can mean a different instant on different + machines ([#50](https://github.com/hardbyte/python-common-expression-language/issues/50)). + The Python API reference now carries a warning, and the quick start, tutorials and + `Context.add_variable` docstring use timezone-aware `datetime.now(timezone.utc)` + instead of naive `datetime.now()`. + ## [0.9.0] - 2026-09-09 Upgrades to cel-rust 0.14.5, which brings native `type()`, range-checked diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 7aa79dd..282c472 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -262,7 +262,7 @@ CEL has a rich type system that maps naturally to Python: ```python from cel import evaluate -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone # Numbers with operations result = evaluate("42") @@ -325,8 +325,10 @@ result = evaluate('duration("1h30m")') assert isinstance(result, timedelta) # → timedelta object (duration string parsing) assert result.total_seconds() == 5400.0 # → 5400.0 (1.5 hours in seconds) -# Timestamp arithmetic -context = {"now": datetime.now()} +# Timestamp arithmetic. Pass timezone-aware datetimes: a naive datetime is read +# in the host's local time zone, so the same value means a different instant on +# different machines. +context = {"now": datetime.now(timezone.utc)} result = evaluate('now + duration("2h")', context) assert isinstance(result, datetime) # → datetime object (time arithmetic works naturally) diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index bb9892b..f200a0b 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -444,9 +444,21 @@ When passing Python objects as context: | `list` | `list(T)` | Element types preserved | | `dict` | `map(K, V)` | Key/value types preserved | | `bytes` | `bytes` | Direct mapping | -| `datetime.datetime` | `timestamp` | Timezone info preserved | +| `datetime.datetime` | `timestamp` | Aware datetimes keep their offset; **naive datetimes are read in the host's local time zone** (see below) | | `datetime.timedelta` | `duration` | Direct mapping | +!!! warning "Use timezone-aware datetimes" + A CEL `timestamp` is an absolute instant, and every `timestamp("...")` literal + an expression builds is in UTC. A naive `datetime` has no offset, so the + binding interprets it in the **host's local time zone**: `datetime(2026, 1, 1, 12)` + is a different instant on a UTC server and on a laptop set to + `Pacific/Auckland`, and a comparison such as + `created > timestamp("2026-01-01T00:00:00Z")` silently depends on `TZ`. Always + attach a `tzinfo`, e.g. `datetime.now(timezone.utc)` rather than `datetime.now()`. + This matches Python's own convention for naive datetimes; a future major release + may reject naive values instead + ([#50](https://github.com/hardbyte/python-common-expression-language/issues/50)). + --- ## Error Handling diff --git a/docs/tutorials/extending-cel.md b/docs/tutorials/extending-cel.md index d19c25a..b270ee4 100644 --- a/docs/tutorials/extending-cel.md +++ b/docs/tutorials/extending-cel.md @@ -237,7 +237,7 @@ Now let's see how to combine custom functions for a real-world application - a b ```python from cel import Context, evaluate import re -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone def validate_password(password): """Validate password strength.""" @@ -253,10 +253,8 @@ def days_until_expiry(expiry_date_str): """Calculate days until expiry.""" try: expiry = datetime.fromisoformat(expiry_date_str.replace('Z', '+00:00')) - now = datetime.now() - # Remove timezone info for comparison - expiry_naive = expiry.replace(tzinfo=None) - delta = expiry_naive - now + now = datetime.now(timezone.utc) + delta = expiry - now return max(0, delta.days) except: return 0 @@ -432,7 +430,7 @@ These patterns provide the foundation for production-ready systems: **Complete PolicyContext Implementation** ```python from cel import Context, evaluate -from datetime import datetime +from datetime import datetime, timezone class PolicyContext: """Reusable context builder for policy evaluation.""" @@ -444,11 +442,12 @@ class PolicyContext: def _setup_common_functions(self): """Set up commonly used functions.""" def current_time(): - return datetime.now() + # Timezone-aware, so the CEL timestamp is the same instant on every host. + return datetime.now(timezone.utc) def is_business_hours(): # For testing purposes, always return True - # In production, use: datetime.now().hour to check 9 <= hour <= 17 + # In production, use: datetime.now(timezone.utc).hour to check 9 <= hour <= 17 return True def contains_any(text, keywords): @@ -488,7 +487,7 @@ class PolicyContext: "method": method, "path": path, "ip": ip_address, - "time": datetime.now().isoformat() + "time": datetime.now(timezone.utc).isoformat() }) return self diff --git a/src/context.rs b/src/context.rs index 0e3f26a..4d39829 100644 --- a/src/context.rs +++ b/src/context.rs @@ -333,8 +333,8 @@ impl Context { /// /// Adding datetime objects: /// - /// >>> from datetime import datetime, timedelta - /// >>> context.add_variable("now", datetime.now()) + /// >>> from datetime import datetime, timedelta, timezone + /// >>> context.add_variable("now", datetime.now(timezone.utc)) /// >>> context.add_variable("one_hour", timedelta(hours=1)) /// /// Overwriting existing variables: