Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand Down
14 changes: 13 additions & 1 deletion docs/reference/python-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 8 additions & 9 deletions docs/tutorials/extending-cel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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
Comment on lines +256 to +257

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve support for offset-less expiry strings

When expiry_date_str is a valid ISO-8601 value without an offset, datetime.fromisoformat() returns a naive datetime, so subtracting the newly aware now raises TypeError; the broad handler then silently returns 0 and treats even a future subscription as expired. Either require and validate an offset explicitly or attach the intended timezone before performing the subtraction.

Useful? React with 👍 / 👎.

return max(0, delta.days)
except:
return 0
Expand Down Expand Up @@ -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."""
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check business hours in the business timezone

For organizations whose business hours are not defined in UTC, this production recommendation checks the wrong clock—for example, 09:00 in Auckland is 20:00 or 21:00 UTC on the preceding day. Since only the integer hour is used and no datetime crosses into CEL here, switching to UTC does not address the naive-datetime conversion issue; the example should use the organization's explicit ZoneInfo timezone instead.

Useful? React with 👍 / 👎.

return True

def contains_any(text, keywords):
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down