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: 8 additions & 1 deletion docs/reference/expression-coverage-scoped.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

Scoped deviations — dialect, parameter, option, value-class; function-level coverage and matrices live in [`expression-coverage.md`](expression-coverage.md).

Declarations: 42 · Facts: 1558 · Registered operations: 324 · Implementation records: 972
Declarations: 42 · Facts: 1560 · Registered operations: 324 · Implementation records: 972

Legend — scoped deviations:

Expand Down Expand Up @@ -165,6 +165,13 @@ Legend — scoped deviations:
| ibis-duckdb | unit | 1q | — | unsupported | gate | build | — | ibis TimestampTruncate rejects the quarter unit '1q' (and its friendly alias 'quarter') | — | — | 2026-07-24 | — | — |
| ibis-duckdb | unit | quarter | — | unsupported | gate | build | — | ibis TimestampTruncate rejects the quarter unit '1q' (and its friendly alias 'quarter') | — | — | 2026-07-24 | — | — |

### `IS_DST` × ibis (FKEY_MOUNTAINASH_SCALAR_DATETIME)

| Dialect | Param | Option values | Value class | Level | Enforcement | Boundary | Condition | Message | Workaround | Upstream | Since | Native errors | Probe-exempt |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| * | timezone | — | iana_timezone | unsupported | gate | build | — | is_dst is not supported on ibis -- ibis has no DST/timezone-offset primitive to build on (verified 2026-08-16, ibis 12.0.0/duckdb) | — | — | 2026-08-16 | — | — |
| ibis-duckdb | timezone | — | iana_timezone | unsupported | gate | build | — | is_dst is not supported on ibis -- ibis has no DST/timezone-offset primitive to build on (verified 2026-08-16, ibis 12.0.0/duckdb) | — | — | 2026-08-16 | — | — |

### `ROUND` × narwhals (FKEY_MOUNTAINASH_SCALAR_DATETIME)

| Dialect | Param | Option values | Value class | Level | Enforcement | Boundary | Condition | Message | Workaround | Upstream | Since | Native errors | Probe-exempt |
Expand Down
89 changes: 77 additions & 12 deletions docs/reference/expression-coverage.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"stamp": {
"declarations": 42,
"facts": 1558,
"facts": 1560,
"operations": 324,
"implementation_records": 972
},
Expand Down Expand Up @@ -39,8 +39,8 @@
"unknown": 0
},
"default_capable": 138,
"audited_clean": 112,
"constrained": 74,
"audited_clean": 111,
"constrained": 75,
"audited_unknown": 0
}
},
Expand All @@ -50,19 +50,19 @@
"expr_capable": 149,
"literal_only": 65,
"polymorphic": 9,
"unsupported": 1335
"unsupported": 1337
},
"facts_by_enforcement": {
"gate": 1551,
"gate": 1553,
"router_metadata": 3,
"materialize_residue": 4
},
"facts_by_backend": {
"polars": 265,
"narwhals": 662,
"ibis": 631
"ibis": 633
},
"facts_total": 1558
"facts_total": 1560
},
"families": [
{
Expand Down Expand Up @@ -4997,15 +4997,48 @@
"impl_protocol": "MountainAshIbisScalarDatetimeExpressionSystem",
"audited": true,
"whole_op": null,
"constrained": false,
"constrained": true,
"contradiction": false,
"selector_counts": {
"params": 0,
"params": 1,
"option_selectors": 0,
"value_classes": 0,
"dialects": 0
"value_classes": 1,
"dialects": 1
},
"constraints": [],
"constraints": [
{
"dialect": null,
"param": "timezone",
"option_value": null,
"value_class": "iana_timezone",
"level": "unsupported",
"enforcement": "gate",
"boundary": "build",
"condition": null,
"message": "is_dst is not supported on ibis -- ibis has no DST/timezone-offset primitive to build on (verified 2026-08-16, ibis 12.0.0/duckdb)",
"workaround": null,
"upstream_ref": null,
"since": "2026-08-16",
"native_errors": [],
"probe_exempt": null
},
{
"dialect": "ibis-duckdb",
"param": "timezone",
"option_value": null,
"value_class": "iana_timezone",
"level": "unsupported",
"enforcement": "gate",
"boundary": "build",
"condition": null,
"message": "is_dst is not supported on ibis -- ibis has no DST/timezone-offset primitive to build on (verified 2026-08-16, ibis 12.0.0/duckdb)",
"workaround": null,
"upstream_ref": null,
"since": "2026-08-16",
"native_errors": [],
"probe_exempt": null
}
],
"residue": [],
"routed": [],
"refinements": []
Expand Down Expand Up @@ -48140,6 +48173,22 @@
"native_errors": [],
"probe_exempt": null
},
{
"dialect": null,
"param": "timezone",
"option_value": null,
"value_class": "iana_timezone",
"level": "unsupported",
"enforcement": "gate",
"boundary": "build",
"condition": null,
"message": "is_dst is not supported on ibis -- ibis has no DST/timezone-offset primitive to build on (verified 2026-08-16, ibis 12.0.0/duckdb)",
"workaround": null,
"upstream_ref": null,
"since": "2026-08-16",
"native_errors": [],
"probe_exempt": null
},
{
"dialect": null,
"param": "unit",
Expand Down Expand Up @@ -48220,6 +48269,22 @@
"native_errors": [],
"probe_exempt": null
},
{
"dialect": "ibis-duckdb",
"param": "timezone",
"option_value": null,
"value_class": "iana_timezone",
"level": "unsupported",
"enforcement": "gate",
"boundary": "build",
"condition": null,
"message": "is_dst is not supported on ibis -- ibis has no DST/timezone-offset primitive to build on (verified 2026-08-16, ibis 12.0.0/duckdb)",
"workaround": null,
"upstream_ref": null,
"since": "2026-08-16",
"native_errors": [],
"probe_exempt": null
},
{
"dialect": "ibis-duckdb",
"param": "unit",
Expand Down
12 changes: 6 additions & 6 deletions docs/reference/expression-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<!-- GENERATED FILE — do not edit by hand. -->
<!-- Regenerate: hatch -e test run python -m mountainash.core.capabilities.render_markdown -->

Declarations: 42 · Facts: 1558 · Registered operations: 324 · Implementation records: 972
Declarations: 42 · Facts: 1560 · Registered operations: 324 · Implementation records: 972

Scoped deviations (dialect/param/option/value-class) live in [`expression-coverage-scoped.md`](expression-coverage-scoped.md).

Expand Down Expand Up @@ -52,7 +52,7 @@ Legend — cell states (by exception):
| --- | --- | --- | --- | --- | --- | --- |
| polars | 191 | 81 | 52 | 0 | 0 | 324 |
| narwhals | 97 | 149 | 78 | 0 | 0 | 324 |
| ibis | 138 | 112 | 74 | 0 | 0 | 324 |
| ibis | 138 | 111 | 75 | 0 | 0 | 324 |

contradictions: 0
audited_unknown: 0
Expand All @@ -61,9 +61,9 @@ audited_unknown: 0

| Axis | Breakdown |
| --- | --- |
| Level | expr_capable 149, literal_only 65, polymorphic 9, unsupported 1335 |
| Enforcement | gate 1551, router_metadata 3, materialize_residue 4 |
| Backend | polars 265, narwhals 662, ibis 631 |
| Level | expr_capable 149, literal_only 65, polymorphic 9, unsupported 1337 |
| Enforcement | gate 1553, router_metadata 3, materialize_residue 4 |
| Backend | polars 265, narwhals 662, ibis 633 |

`pandas` / `pyarrow` are routed input types (they execute via the narwhals path) and are not independent coverage columns.

Expand Down Expand Up @@ -161,7 +161,7 @@ audited_unknown: 0
| `EXTRACT_WEEKDAY` | ✓ | ✓ audited | ✓ audited |
| `EXTRACT_YEAR` | ✓ | ✓ audited | ✓ audited |
| `FLOOR` | ✓ | ◐ partial (1 params, 2 option-selectors, 0 value-classes, 2 dialects) | ◐ partial (1 params, 2 option-selectors, 1 value-classes, 1 dialects) |
| `IS_DST` | ✓ | ✓ audited | ✓ audited |
| `IS_DST` | ✓ | ✓ audited | ◐ partial (1 params, 0 option-selectors, 1 value-classes, 1 dialects) |
| `IS_LEAP_YEAR` | ✓ | ✓ audited | ✓ audited |
| `MONTH_END` | ✓ | ✓ audited | ✓ audited |
| `MONTH_START` | ✓ | ✓ audited | ✓ audited |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@
"bare CAST AS TIMESTAMPTZ), so any expression composed on the result "
"raises UnsupportedOperationError (verified 2026-07-29, ibis 12.0.0/duckdb)"
)
_IS_DST_MSG = (
"is_dst is not supported on ibis -- ibis has no DST/timezone-offset "
"primitive to build on (verified 2026-08-16, ibis 12.0.0/duckdb)"
)


def _mult_fact(op: str, backend, dialect: str | None, message: str) -> CapabilityFact:
Expand Down Expand Up @@ -108,13 +112,29 @@ def _tz_fact(backend, dialect: str | None, message: str) -> CapabilityFact:
)


def _is_dst_fact(backend, dialect: str | None, message: str) -> CapabilityFact:
return CapabilityFact(
operation_key=FK_DT.IS_DST,
param="timezone",
value_class=ValueClass.IANA_TIMEZONE,
level=CapabilityLevel.UNSUPPORTED,
backend=backend,
dialect=dialect,
message=message,
since="2026-08-16",
)


_IBIS_FACTS = tuple(
_mult_fact(op, CONST_BACKEND.IBIS, dialect, _IBIS_MSG)
for op in _IBIS_MULTIPLIER_OPS
for dialect in (None, "ibis-duckdb") # family default + duckdb
) + tuple(
_tz_fact(CONST_BACKEND.IBIS, dialect, _TO_TIMEZONE_MSG)
for dialect in (None, "ibis-duckdb")
) + tuple(
_is_dst_fact(CONST_BACKEND.IBIS, dialect, _IS_DST_MSG)
for dialect in (None, "ibis-duckdb")
)
_NARWHALS_FACTS = tuple(
_mult_fact(op, CONST_BACKEND.NARWHALS, dialect, _NARWHALS_ROUND_CEIL_MSG)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from __future__ import annotations

from datetime import date
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING

import ibis

Expand All @@ -24,6 +24,10 @@
"to_timezone is not supported on ibis — the target zone never reaches the "
"engine; see capabilities/datetime/value_classes_ma.py"
)
_IS_DST_UNSUPPORTED = (
"is_dst is not supported on ibis — ibis has no DST/timezone-offset "
"primitive; see capabilities/datetime/value_classes_ma.py"
)


# Ibis .truncate() expects a bare unit letter, not the Polars-style "<n><unit>" duration.
Expand Down Expand Up @@ -143,14 +147,19 @@ def is_dst(
self,
x: IbisTemporalExpr,
/,
timezone: Optional[str] = None,
timezone: str,
) -> IbisValueExpr:
"""Check if DST is observed at this time.
"""Ibis has no timezone/DST detection primitive; declared UNSUPPORTED.

Note: Ibis doesn't have direct DST detection.
Returns False as a placeholder.
Declared UNSUPPORTED on ibis (see capabilities/datetime/value_classes_ma.py)
— the capability gate raises before this method is reached. The raise
here is defence in depth (mirrors to_timezone's established pattern).
"""
return ibis.literal(False)
raise BackendCapabilityError(
_IS_DST_UNSUPPORTED,
backend="ibis",
function_key=FKEY_MOUNTAINASH_SCALAR_DATETIME.IS_DST,
)

# =========================================================================
# Date Arithmetic Methods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

from __future__ import annotations

import datetime as dt
from datetime import date, datetime
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING

import narwhals as nw

Expand Down Expand Up @@ -121,14 +122,43 @@ def is_dst(
self,
x: NarwhalsExpr,
/,
timezone: Optional[str] = None,
timezone: str,
) -> NarwhalsExpr:
"""Check if DST is observed at this time.

Note: Narwhals doesn't have direct DST detection.
Returns False as a placeholder.
"""Return True if DST is observed for x in the given timezone.

Narwhals has no direct DST/offset primitive. Hand-rolled via two
reference-offset comparisons: compute the zone's UTC offset at `x`,
and compare against the smaller (standard-time) of two fixed
reference-instant offsets six months apart. DST always moves the
offset in the direction that makes local time further from UTC, so
`current_offset > standard_offset` <=> DST is in effect. Zones with
no DST have equal reference offsets, so this is always False.

Verified against `zoneinfo` for UTC, America/New_York,
America/Los_Angeles, Europe/Paris, Australia/Sydney,
Pacific/Auckland, Asia/Kolkata (both hemispheres).

Warning: `utc_naive` must always be derived directly from `x`
(never by converting back from `local_naive`), and every reference
offset must be derived directly from a fresh UTC-anchored literal --
narwhals 2.24.0 + pandas 3.0.5 raises AttributeError when a second
`convert_time_zone` call chains directly onto an expression whose
first hop already targeted America/New_York or America/Los_Angeles.
"""
return nw.lit(False)
utc_naive = x.dt.convert_time_zone("UTC").dt.replace_time_zone(None)
local_naive = x.dt.convert_time_zone(timezone).dt.replace_time_zone(None)
current_offset = local_naive - utc_naive

def _offset_at(utc_dt: datetime) -> NarwhalsExpr:
ref_utc = nw.lit(utc_dt)
ref_utc_naive = ref_utc.dt.replace_time_zone(None)
ref_local_naive = ref_utc.dt.convert_time_zone(timezone).dt.replace_time_zone(None)
return ref_local_naive - ref_utc_naive

ref1 = _offset_at(datetime(2020, 1, 1, 12, 0, tzinfo=dt.timezone.utc))
ref2 = _offset_at(datetime(2020, 7, 1, 12, 0, tzinfo=dt.timezone.utc))
standard_offset = nw.when(ref1 < ref2).then(ref1).otherwise(ref2)
return current_offset > standard_offset

# =========================================================================
# Date Arithmetic Methods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from __future__ import annotations

from datetime import date, datetime
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING

import polars as pl

Expand Down Expand Up @@ -116,14 +116,25 @@ def is_dst(
self,
x: PolarsExpr,
/,
timezone: Optional[str] = None,
timezone: str,
) -> PolarsExpr:
"""Check if DST is observed at this time.
"""Return True if DST is observed for x in the given timezone.

Note: Polars doesn't have direct DST detection.
Returns False as a placeholder.
Args:
x: Datetime expression.
timezone: IANA timezone name. `x` need not be timezone-aware:
`convert_time_zone` converts to the target zone.

Returns:
Boolean expression; True where DST is in effect. Matches the
existing extract/extract_boolean precedent: a naive `x` is
silently treated as UTC (no guard applied here either).
"""
return pl.lit(False)
return (
x.dt.convert_time_zone(timezone)
.dt.dst_offset()
.ne(pl.duration(seconds=0))
)

# =========================================================================
# Date Arithmetic Methods
Expand Down
Loading
Loading